diff --git a/internal/localapi/disks.go b/internal/localapi/disks.go index 0a63842..7d48ef9 100644 --- a/internal/localapi/disks.go +++ b/internal/localapi/disks.go @@ -103,6 +103,17 @@ type DiskInfo struct { // controller strips the "uuid:" prefix to get the fs UUID it passes to POST /disks/assign — // the only way the (de-privileged) controller can learn the mount key it cannot read itself. DurableID string `json:"durable_id,omitempty"` + // WipeDurableID is the device's WIPE-binding durable id in the SAME scheme the format gate resolves + // against (byid:/byuuid:). F20-BUG2: the customer must confirm a data-bearing wipe with + // THIS id, not DurableID (uuid:) — passing the uuid: id was rejected as a binding_mismatch. "" when + // the device has no durable identity (a wipe of it can't be bound anyway). Distinct from DurableID + // (uuid:, used for /disks/assign) on purpose; both are derived from the agent's own device read. + WipeDurableID string `json:"wipe_durable_id,omitempty"` + // GuestAttached reports whether THIS guest (the token's vmid) actually has the drive's felhom-data + // namespace bound into its config — i.e. the drive is usable IN the guest, not merely present on the + // host. F9: host presence (State=attached) != guest-usable; this is the missing signal that made the + // HDD look available when it wasn't bound. Only meaningful for user-data drives. + GuestAttached bool `json:"guest_attached"` } // handleDisks lists the host's drives + data-bearing flags (read-only/benign). @@ -119,16 +130,20 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) { // Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative // (the agent's own mount/topology read, never the caller's claim). sysDisks, sysKnown := storage.SystemDisks(s.hostReader()) + // F9: which host mount paths are actually BOUND into THIS guest's config (guest-usable, not just + // host-present). A bind's mp= equals the guest path, which is the drive's host mount path (`where`). + boundPaths := s.guestBoundPaths(r.Context(), vmid) out := make([]DiskInfo, 0, len(targets)) for _, t := range targets { di := DiskInfo{ Name: t.Name, Type: t.Type, State: t.State, BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint, - DurableID: t.DurableID, - Role: string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)), - TotalBytes: t.TotalBytes, - UsedBytes: t.UsedBytes, - UsedFraction: t.UsedFraction, + DurableID: t.DurableID, + Role: string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)), + TotalBytes: t.TotalBytes, + UsedBytes: t.UsedBytes, + UsedFraction: t.UsedFraction, + GuestAttached: t.MountPath != "" && boundPaths[t.MountPath], } // Inspect the backing device for the UI's data-bearing hint (the authoritative check // is re-run at format time on the actual device). @@ -140,6 +155,11 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) { di.DataBearing = true // fail-safe di.DataReason = "could not inspect device" } + // F20-BUG2: surface the gate-scheme wipe id (byid:/byuuid:) so a customer-confirmed wipe + // binds with the id the gate accepts. Same seam the gate uses → guaranteed to match. + if wid, werr := s.deviceDurableID(t.BackingDevice); werr == nil { + di.WipeDurableID = wid + } } out = append(out, di) } @@ -447,7 +467,7 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i // never the caller's claim). The agent also re-resolves the device's durable id; the customer's // confirmation must bind to it. role := s.deviceRole(r.Context(), req.Device) - deviceDurable, derr := storage.DeviceDurableID(req.Device) + deviceDurable, derr := s.deviceDurableID(req.Device) if derr != nil { deviceDurable = "" // refusal still stands; binding/pending-op just lack the id } @@ -517,6 +537,30 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i "device is system/backup-protected — format requires an operator signature ("+dec.Reason+")") } +// guestBoundPaths returns the set of guest mountpoint paths (the `mp=` of each entry in the guest's +// config) — i.e. the host drives actually BOUND into the guest. F9: this is the guest-attached signal +// (`GuestAttached`) that distinguishes a guest-usable drive from one merely present on the host. A bind +// created by AttachBind has `mp=` where `where` is the drive's host mount path, so a storage +// target is guest-attached iff its MountPath is in this set. Best-effort: a config-read error yields an +// empty set (reported as not-attached — the safe direction). +func (s *Server) guestBoundPaths(ctx context.Context, vmid int) map[string]bool { + out := map[string]bool{} + if s.guests == nil { + return out + } + cfg, err := s.guests.GuestConfig(ctx, vmid) + if err != nil { + s.logger.Warn("local-api: guest-attached check — could not read guest config", "vmid", vmid, "err", err) + return out + } + for _, spec := range cfg.MountPoints() { + if _, mp, _ := parseMount(spec); mp != "" { + out[mp] = true + } + } + return out +} + // durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own // storage view) — the key the intent store records enroll/eject against. "" if not resolvable. func (s *Server) durableIDForMount(ctx context.Context, where string) string { diff --git a/internal/localapi/disks_bug2_f9_test.go b/internal/localapi/disks_bug2_f9_test.go new file mode 100644 index 0000000..22c08c4 --- /dev/null +++ b/internal/localapi/disks_bug2_f9_test.go @@ -0,0 +1,116 @@ +package localapi + +import ( + "encoding/json" + "net/http" + "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/hub" + "gitea.dooplex.hu/admin/felhom-agent/internal/storage" +) + +// decodeDisks pulls the disks array out of a GET /disks response. +func decodeDisks(t *testing.T, body []byte) []DiskInfo { + t.Helper() + var resp struct { + Data struct { + Disks []DiskInfo `json:"disks"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode /disks: %v (body=%s)", err, string(body)) + } + return resp.Data.Disks +} + +// TestDisks_WipeDurableID_GateScheme asserts F20-BUG2: /disks surfaces a wipe_durable_id in the gate's +// scheme (byid:/byuuid:, via the SAME s.deviceDurableID seam the format gate uses), DISTINCT from the +// uuid: durable_id (which feeds /disks/assign). Pre-fix the only id was uuid:, which the gate rejected +// as a binding_mismatch. +func TestDisks_WipeDurableID_GateScheme(t *testing.T) { + d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} + sv := fakeStorage{targets: []hub.StorageTarget{ + {Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk", DurableID: "uuid:abc-123"}, + }} + h := newDiskServer(t, d, &fakeGate{}, sv, nil) + + w := do(t, h, "GET", "/disks", "A", "") + if w.Code != http.StatusOK { + t.Fatalf("GET /disks: %d (%s)", w.Code, w.Body.String()) + } + disks := decodeDisks(t, w.Body.Bytes()) + if len(disks) != 1 { + t.Fatalf("want 1 disk, got %d", len(disks)) + } + got := disks[0] + // The stub in newDiskServer maps /dev/sdb1 → byid:wwn-sdb1 (the gate scheme). + if got.WipeDurableID != "byid:wwn-sdb1" { + t.Fatalf("WipeDurableID = %q, want byid:wwn-sdb1 (gate scheme)", got.WipeDurableID) + } + if got.DurableID != "uuid:abc-123" { + t.Fatalf("DurableID = %q, want uuid:abc-123 (assign scheme, unchanged)", got.DurableID) + } + if got.WipeDurableID == got.DurableID { + t.Fatal("wipe id must differ from the assign (uuid:) id") + } +} + +// TestDisks_WipeID_MatchesGateBinding asserts the BUG2 end-to-end property: when the customer confirms a +// wipe with the wipe_durable_id surfaced by /disks, the handler derives the device's gate id through the +// SAME seam — so the value the gate compares as DeviceDurableID equals the customer's ConfirmDurableID +// (no binding_mismatch). Asserted via the request the handler forwards to the gate. +func TestDisks_WipeID_MatchesGateBinding(t *testing.T) { + d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} + sv := fakeStorage{targets: []hub.StorageTarget{ + {Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk", DurableID: "uuid:abc-123"}, + }} + g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}} + h := newDiskServer(t, d, g, sv, nil) + + wipeID := decodeDisks(t, do(t, h, "GET", "/disks", "A", "").Body.Bytes())[0].WipeDurableID + + body := `{"device":"/dev/sdb1","fstype":"ext4","confirmed":true,"durable_id":"` + wipeID + `"}` + w := do(t, h, "POST", "/disks/format", "A", body) + if w.Code != http.StatusOK { + t.Fatalf("confirmed wipe with the list's wipe id: %d (%s)", w.Code, w.Body.String()) + } + reqs := g.requests() + if len(reqs) != 1 { + t.Fatalf("gate consulted %d times, want 1", len(reqs)) + } + // The crux: the customer's confirm id (from the list) equals the device id the gate resolves. + if reqs[0].ConfirmDurableID != wipeID { + t.Fatalf("ConfirmDurableID forwarded = %q, want the list's wipe id %q", reqs[0].ConfirmDurableID, wipeID) + } + if reqs[0].DeviceDurableID != reqs[0].ConfirmDurableID { + t.Fatalf("gate binding mismatch: device id %q != confirm id %q (BUG2 not fixed)", reqs[0].DeviceDurableID, reqs[0].ConfirmDurableID) + } +} + +// TestDisks_GuestAttached asserts F9 reporting: a drive bound into THIS guest's config (mp=) +// reports guest_attached=true; a host-present-but-unbound drive reports false — the signal that was +// missing when the HDD looked available but wasn't attached. +func TestDisks_GuestAttached(t *testing.T) { + d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} + sv := fakeStorage{targets: []hub.StorageTarget{ + {Name: "usb", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/felhom-usb"}, + {Name: "extra", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdc1", MountPath: "/mnt/extra"}, + }} + // Guest 8200 (token "A") has /mnt/felhom-usb bound (Model-A bind: source .../felhom-data, mp=where), + // but NOT /mnt/extra. + h := newDiskServerWithGuestConfigs(t, d, sv, nil, map[int]map[string]string{ + 8200: {"mp3": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"}, + }) + + disks := decodeDisks(t, do(t, h, "GET", "/disks", "A", "").Body.Bytes()) + got := map[string]bool{} + for _, di := range disks { + got[di.MountPath] = di.GuestAttached + } + if !got["/mnt/felhom-usb"] { + t.Errorf("/mnt/felhom-usb should be guest_attached=true (bound into the guest)") + } + if got["/mnt/extra"] { + t.Errorf("/mnt/extra should be guest_attached=false (host-present but not bound)") + } +} diff --git a/internal/localapi/disks_test.go b/internal/localapi/disks_test.go index 9e942bb..cc7f5e5 100644 --- a/internal/localapi/disks_test.go +++ b/internal/localapi/disks_test.go @@ -118,6 +118,9 @@ func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl // device the format tests use; antiRetargetResolve itself is covered directly // in wipe_reresolve_test.go. srv.reresolveWipe = func(_ context.Context, _ string) (string, error) { return "/dev/sdb", nil } + // F20-BUG2: the wipe id derivation hits /dev/disk/by-* in production; stub it deterministically so + // both the /disks list and the gate (which share this seam) resolve the same id in tests. + srv.deviceDurableID = func(device string) (string, error) { return "byid:wwn-" + strings.TrimPrefix(device, "/dev/"), nil } return srv.Handler() } diff --git a/internal/localapi/server.go b/internal/localapi/server.go index dea0ba5..6fbd147 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -154,6 +154,13 @@ type Server struct { // override it to avoid touching real /dev. reresolveWipe func(ctx context.Context, durableID string) (string, error) + // deviceDurableID derives the WIPE-binding durable id of a block device (the byid:/byuuid: scheme + // the wipe gate resolves against). F20-BUG2: BOTH the /disks list (DiskInfo.WipeDurableID) and the + // format gate use this single seam, so the id the customer copies from the list is exactly the id + // the gate accepts (no more uuid: vs byid: binding_mismatch). Defaults to storage.DeviceDurableID; + // tests override it. (DiskInfo.DurableID stays the uuid: storage id — that one feeds /disks/assign.) + deviceDurableID func(device string) (string, error) + jobsMu sync.Mutex jobs map[int]*backupJob // per-guest backup job state (slice 8B) @@ -197,6 +204,7 @@ func NewServer(o Options) (*Server, error) { jobs: map[int]*backupJob{}, } s.reresolveWipe = s.reresolveDurableForWipe + s.deviceDurableID = storage.DeviceDurableID return s, nil }