26c6d1e4d1
A guest's parent bind is non-recursive, so a guest reboot leaves enrolled drives bound on the HOST but invisible in the fresh guest ns (propagation only delivers new events). AttachDrive(vmid) now checks GuestSeesMount (/proc/<pid>/mountinfo) and force re-binds (umount+mount) to re-propagate; a 20s periodic reconcile self-heals guest reboots without an agent restart; BoundUnderParent reflects guest visibility (the controller gate's signal). Caught + fixed in the live migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
637 lines
25 KiB
Go
637 lines
25 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
// ---- fakes ------------------------------------------------------------------------------
|
|
|
|
type fakeDiskOps struct {
|
|
mu sync.Mutex
|
|
probe storage.DeviceProbe // returned by InspectDevice (Device filled per call)
|
|
inspectErr error
|
|
formatCalls []string
|
|
mountCalls []storage.MountSpec
|
|
unmountCalls []string
|
|
}
|
|
|
|
func (f *fakeDiskOps) InspectDevice(_ context.Context, device string) (storage.DeviceProbe, error) {
|
|
p := f.probe
|
|
p.Device = device
|
|
return p, f.inspectErr
|
|
}
|
|
func (f *fakeDiskOps) Format(_ context.Context, device, _ string) error {
|
|
f.mu.Lock()
|
|
f.formatCalls = append(f.formatCalls, device)
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (f *fakeDiskOps) EnsureMount(_ context.Context, spec storage.MountSpec) error {
|
|
f.mu.Lock()
|
|
f.mountCalls = append(f.mountCalls, spec)
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (f *fakeDiskOps) Unmount(_ context.Context, where string) error {
|
|
f.mu.Lock()
|
|
f.unmountCalls = append(f.unmountCalls, where)
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) }
|
|
|
|
type fakeGate struct {
|
|
mu sync.Mutex
|
|
decision WipeDecision
|
|
reqs []WipeRequest
|
|
}
|
|
|
|
func (g *fakeGate) AuthorizeWipe(req WipeRequest) WipeDecision {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.reqs = append(g.reqs, req)
|
|
return g.decision
|
|
}
|
|
func (g *fakeGate) requests() []WipeRequest {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return append([]WipeRequest(nil), g.reqs...)
|
|
}
|
|
|
|
type fakeGuestList struct{ guests []proxmox.Guest }
|
|
|
|
func (f fakeGuestList) ListLXC(context.Context) ([]proxmox.Guest, error) { return f.guests, nil }
|
|
|
|
// fakeHostReader is a deterministic HostReader fixture for the role classifier: "/" lives on the
|
|
// whole disk /dev/sda, so SystemDisks resolves {/dev/sda} (sysKnown=true) and any storage backed by
|
|
// a different whole-disk (e.g. /dev/sdb1) classifies as user-data without touching the real host.
|
|
type fakeHostReader struct{ mounts []storage.Mount }
|
|
|
|
func (f fakeHostReader) Mounts() ([]storage.Mount, error) { return f.mounts, nil }
|
|
func (f fakeHostReader) ResolveUUID(string) (string, bool) { return "", false }
|
|
func (f fakeHostReader) DeviceExists(string) bool { return true }
|
|
func (f fakeHostReader) Rotational(string) (bool, bool) { return false, false }
|
|
func (f fakeHostReader) Removable(string) (bool, bool) { return false, false }
|
|
|
|
// sysOnSDA is the default system-disk fixture (root on /dev/sda) used by the disk-server test helpers.
|
|
func sysOnSDA() fakeHostReader {
|
|
return fakeHostReader{mounts: []storage.Mount{{Device: "/dev/sda1", MountPoint: "/"}}}
|
|
}
|
|
|
|
// newDiskServer builds a server wired with the 8C disk deps (token A → guest 8200).
|
|
func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl GuestLister) http.Handler {
|
|
t.Helper()
|
|
if sv == nil {
|
|
sv = fakeStorage{}
|
|
}
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: sv,
|
|
Tokens: staticTokens{"A": 8200, "B": 9300},
|
|
Disks: d,
|
|
DiskGate: g,
|
|
Guests2: gl,
|
|
HostReader: sysOnSDA(),
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
// [AGENT-001] The real anti-retarget re-resolution touches /dev/disk/by-*,
|
|
// which doesn't exist in unit tests. Stub it to a successful re-resolve of the
|
|
// 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()
|
|
}
|
|
|
|
// ---- the security centerpiece -----------------------------------------------------------
|
|
|
|
// A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted.
|
|
func TestFormat_BlankDevice_Formats(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set
|
|
g := &fakeGate{}
|
|
h := newDiskServer(t, d, g, nil, nil)
|
|
|
|
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("blank format: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
|
|
t.Fatalf("mkfs not called for blank device: %v", got)
|
|
}
|
|
if len(g.requests()) != 0 {
|
|
t.Fatal("gate was consulted for a blank-device format (should be benign)")
|
|
}
|
|
}
|
|
|
|
// THE HEADLINE SECURITY TEST: a caller asks to format a DATA-BEARING device the gate tiers as
|
|
// SYSTEM/BACKUP (destructive). The gate refuses pending_signature, **mkfs is NEVER called**, and the
|
|
// response surfaces the operator-signature pending op (NOT a customer-confirmation prompt).
|
|
func TestFormat_DataBearing_ProtectedRole_RefusedNoMkfs(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
|
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
|
|
h := newDiskServer(t, d, g, nil, nil)
|
|
|
|
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("data-bearing protected format: got %d want 403", w.Code)
|
|
}
|
|
if got := d.formatted(); len(got) != 0 {
|
|
t.Fatalf("mkfs WAS called on a protected data-bearing device — security invariant violated: %v", got)
|
|
}
|
|
if len(g.requests()) != 1 {
|
|
t.Fatalf("gate not consulted for the destructive format: %v", g.requests())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "operator signature") {
|
|
t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// A hand-issued confirmed:true on a data-bearing device the gate tiers as SYSTEM/BACKUP is STILL
|
|
// refused (operator-signature) and mkfs is NOT called — role beats confirmation. (The gate's
|
|
// role-tiering is itself asserted in reconcile/gate_test; here we assert the handler honors a
|
|
// destructive verdict even when the caller claimed confirmed:true.)
|
|
func TestFormat_DataBearing_ConfirmedTrueButProtected_StillRefused(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
|
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
|
|
h := newDiskServer(t, d, g, nil, nil)
|
|
|
|
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byid:wwn-xyz"}`)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("confirmed-but-protected format: got %d want 403", w.Code)
|
|
}
|
|
if got := d.formatted(); len(got) != 0 {
|
|
t.Fatalf("mkfs called despite protected role — confirmation must not beat role: %v", got)
|
|
}
|
|
// The handler must have forwarded the caller's confirmation claim to the gate (the gate, not the
|
|
// handler, ignores it for protected roles).
|
|
if reqs := g.requests(); len(reqs) != 1 || !reqs[0].Confirmed || reqs[0].ConfirmDurableID != "byid:wwn-xyz" {
|
|
t.Fatalf("handler did not forward the confirmation claim to the gate: %+v", reqs)
|
|
}
|
|
}
|
|
|
|
// USER-DATA + customer-confirmed (gate Allowed): the wipe proceeds — mkfs IS called, 200.
|
|
func TestFormat_DataBearing_UserDataConfirmed_Formats(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
|
g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}}
|
|
h := newDiskServer(t, d, g, nil, nil)
|
|
|
|
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byuuid:abc"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("user-data confirmed format: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
|
|
t.Fatalf("mkfs not called for a customer-confirmed user-data wipe: %v", got)
|
|
}
|
|
}
|
|
|
|
// USER-DATA + NOT yet confirmed (gate refuses pending_confirmation): 403 with needs_confirmation,
|
|
// mkfs NOT called, and NO operator-signature pending op (it's the customer's to confirm).
|
|
func TestFormat_DataBearing_UserDataNeedsConfirmation(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
|
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "customer_confirmable", Reason: "pending_confirmation", NeedsConfirmation: true}}
|
|
h := newDiskServer(t, d, g, nil, nil)
|
|
|
|
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("user-data unconfirmed: got %d want 403", w.Code)
|
|
}
|
|
if len(d.formatted()) != 0 {
|
|
t.Fatal("mkfs called for an unconfirmed user-data wipe")
|
|
}
|
|
var resp struct {
|
|
Data FormatResponse `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if !resp.Data.NeedsConfirmation {
|
|
t.Fatalf("response missing needs_confirmation: %s", w.Body.String())
|
|
}
|
|
if resp.Data.PendingOp != nil {
|
|
t.Fatal("user-data refusal must NOT surface an operator-signature pending op")
|
|
}
|
|
if strings.Contains(w.Body.String(), "operator signature") {
|
|
t.Fatalf("user-data refusal must not ask for an operator signature: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as
|
|
// data-bearing → routed through the gate, mkfs not called.
|
|
func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true
|
|
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
|
|
h := newDiskServer(t, d, g, nil, nil)
|
|
|
|
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("ambiguous device: got %d want 403", w.Code)
|
|
}
|
|
if got := d.formatted(); len(got) != 0 {
|
|
t.Fatalf("mkfs called on an unprobed device: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
|
|
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
|
|
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/../etc","fstype":"ext4"}`); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad device: got %d want 400", w.Code)
|
|
}
|
|
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ntfs"}`); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad fstype: got %d want 400", w.Code)
|
|
}
|
|
if len(d.formatted()) != 0 {
|
|
t.Fatal("formatted despite invalid input")
|
|
}
|
|
}
|
|
|
|
// ---- assign / eject ---------------------------------------------------------------------
|
|
|
|
func TestAssign_EnsureMount(t *testing.T) {
|
|
d := &fakeDiskOps{}
|
|
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
|
|
w := do(t, h, "POST", "/disks/assign", "A", `{"uuid":"1234-ABCD","where":"/mnt/data","fstype":"ext4"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("assign: got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if len(d.mountCalls) != 1 || d.mountCalls[0].Where != "/mnt/data" || d.mountCalls[0].UUID != "1234-ABCD" {
|
|
t.Fatalf("EnsureMount not called correctly: %+v", d.mountCalls)
|
|
}
|
|
}
|
|
|
|
func TestEject_UnmountAndDependents(t *testing.T) {
|
|
d := &fakeDiskOps{}
|
|
// storage view: USER-DATA target "bulk" (usb on a non-system disk) mounted at /mnt/bulk — the
|
|
// role-gate permits ejecting it.
|
|
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk"}}}
|
|
// guest 8200 mounts storage "bulk"; guest 9300 does not
|
|
gl := fakeGuestList{guests: []proxmox.Guest{{VMID: 8200}, {VMID: 9300}}}
|
|
h := newDiskServerWithGuestConfigs(t, d, sv, gl, map[int]map[string]string{
|
|
8200: {"mp0": "bulk:200,mp=/mnt/media,backup=0"},
|
|
9300: {"mp0": "local-lvm:8,mp=/var,backup=1"},
|
|
})
|
|
|
|
w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/bulk"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("eject: got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
d.mu.Lock()
|
|
unmounts := append([]string(nil), d.unmountCalls...)
|
|
d.mu.Unlock()
|
|
if len(unmounts) != 1 || unmounts[0] != "/mnt/bulk" {
|
|
t.Fatalf("Unmount not called: %v", unmounts)
|
|
}
|
|
var resp struct {
|
|
Data struct {
|
|
DependentGuests []int `json:"dependent_guests"`
|
|
} `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
|
found := false
|
|
for _, v := range resp.Data.DependentGuests {
|
|
if v == 8200 {
|
|
found = true
|
|
}
|
|
if v == 9300 {
|
|
t.Fatal("9300 listed as dependent but it does not mount bulk")
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("dependent guest 8200 not reported: %v", resp.Data.DependentGuests)
|
|
}
|
|
}
|
|
|
|
// A2 (security): the eject path is ROLE-GATED at the agent. A system mount (local dir on the OS disk)
|
|
// and a backup mount (PBS) are REFUSED 403 with NO Unmount; only a user-data mount ejects. The UI
|
|
// hiding the button is not the control — a direct API call to unmount a protected storage is refused.
|
|
func TestEject_RoleGated(t *testing.T) {
|
|
sv := fakeStorage{targets: []hub.StorageTarget{
|
|
{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk"}, // user-data
|
|
{Name: "local", Type: "local", MountPath: "/var/lib/vz"}, // system (builtin dir)
|
|
{Name: "felhom-pbs", Type: hub.StorageTypePBS, MountPath: "/mnt/pbs"}, // backup (PBS)
|
|
}}
|
|
|
|
// system mount → refused, no Unmount.
|
|
d := &fakeDiskOps{}
|
|
h := newDiskServer(t, d, &fakeGate{}, sv, nil)
|
|
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/var/lib/vz"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("eject system mount: got %d want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
d.mu.Lock()
|
|
if len(d.unmountCalls) != 0 {
|
|
t.Fatalf("Unmount called on a system mount — role-gate bypassed: %v", d.unmountCalls)
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
// backup mount (PBS) → refused, no Unmount.
|
|
d2 := &fakeDiskOps{}
|
|
h2 := newDiskServer(t, d2, &fakeGate{}, sv, nil)
|
|
if w := do(t, h2, "POST", "/disks/eject", "A", `{"where":"/mnt/pbs"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("eject backup mount: got %d want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
d2.mu.Lock()
|
|
if len(d2.unmountCalls) != 0 {
|
|
t.Fatalf("Unmount called on a backup mount — role-gate bypassed: %v", d2.unmountCalls)
|
|
}
|
|
d2.mu.Unlock()
|
|
|
|
// user-data mount → ejects (Unmount called once).
|
|
d3 := &fakeDiskOps{}
|
|
h3 := newDiskServer(t, d3, &fakeGate{}, sv, nil)
|
|
if w := do(t, h3, "POST", "/disks/eject", "A", `{"where":"/mnt/bulk"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("eject user-data mount: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
d3.mu.Lock()
|
|
if len(d3.unmountCalls) != 1 || d3.unmountCalls[0] != "/mnt/bulk" {
|
|
t.Fatalf("user-data eject did not Unmount /mnt/bulk: %v", d3.unmountCalls)
|
|
}
|
|
d3.mu.Unlock()
|
|
|
|
// fail-safe: an unknown mount (no storage target) → refused, no Unmount.
|
|
d4 := &fakeDiskOps{}
|
|
h4 := newDiskServer(t, d4, &fakeGate{}, sv, nil)
|
|
if w := do(t, h4, "POST", "/disks/eject", "A", `{"where":"/mnt/unknown"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("eject unresolvable mount: got %d want 403 (fail-safe) (%s)", w.Code, w.Body.String())
|
|
}
|
|
d4.mu.Lock()
|
|
if len(d4.unmountCalls) != 0 {
|
|
t.Fatalf("Unmount called on an unresolvable mount — fail-safe violated: %v", d4.unmountCalls)
|
|
}
|
|
d4.mu.Unlock()
|
|
}
|
|
|
|
// ---- guest data-drive passthrough (slice 10 P2) -----------------------------------------
|
|
|
|
type fakeGuestAttacher struct {
|
|
mu sync.Mutex
|
|
calls []struct {
|
|
vmid int
|
|
slot, where string
|
|
}
|
|
reboots []int
|
|
detaches []struct {
|
|
vmid int
|
|
slot string
|
|
}
|
|
attachDrives []string // where passed to AttachDrive (intermediary model)
|
|
detachDrives []string // where passed to DetachDrive
|
|
ensureParentN int // EnsureSharedParent call count
|
|
attachDriveFail bool // when set, AttachDrive returns an error
|
|
}
|
|
|
|
func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, struct {
|
|
vmid int
|
|
slot, where string
|
|
}{vmid, mountKey, where})
|
|
return nil
|
|
}
|
|
func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) }
|
|
|
|
func (f *fakeGuestAttacher) AttachDrive(_ context.Context, _ int, where string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.attachDriveFail {
|
|
return "", fmt.Errorf("fake attach-drive failure")
|
|
}
|
|
f.attachDrives = append(f.attachDrives, where)
|
|
return StablePathForRaw(where), nil
|
|
}
|
|
func (f *fakeGuestAttacher) GuestSeesMount(_ context.Context, _ int, _ string) bool { return true }
|
|
func (f *fakeGuestAttacher) attachDriveCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.attachDrives)
|
|
}
|
|
|
|
func (f *fakeGuestAttacher) DetachDrive(_ context.Context, where string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.detachDrives = append(f.detachDrives, where)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeGuestAttacher) EnsureSharedParent(_ context.Context) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.ensureParentN++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeGuestAttacher) DetachBind(_ context.Context, vmid int, mountKey string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.detaches = append(f.detaches, struct {
|
|
vmid int
|
|
slot string
|
|
}{vmid, mountKey})
|
|
return nil
|
|
}
|
|
func (f *fakeGuestAttacher) detachCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.detaches)
|
|
}
|
|
|
|
func (f *fakeGuestAttacher) RebootGuest(_ context.Context, vmid int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.reboots = append(f.reboots, vmid)
|
|
return nil
|
|
}
|
|
func (f *fakeGuestAttacher) rebootCount() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.reboots) }
|
|
|
|
func newAttachServer(t *testing.T, ga GuestAttacher, mounts map[int]map[string]string) http.Handler {
|
|
t.Helper()
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0", Guests: &fakeGuestsCfg{mounts: mounts}, Backups: &fakeBackups{},
|
|
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200, "B": 9300},
|
|
GuestAttach: ga, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
return srv.Handler()
|
|
}
|
|
|
|
// Intermediary model: a guest-attach binds the drive's felhom-data under the shared parent (host-side)
|
|
// and returns its STABLE guest path — no pct slot. EnsureSharedParent runs first.
|
|
//
|
|
// COMPANION GUARD: the legacy `pct set -mpN` AttachBind must NOT be used in the intermediary model — a
|
|
// pre-fix impl that still called AttachBind would trip ga.count()!=0 here.
|
|
func TestGuestAttach_BindsUnderParent(t *testing.T) {
|
|
ga := &fakeGuestAttacher{}
|
|
h := newAttachServer(t, ga, map[int]map[string]string{
|
|
8200: {"mp9": "/var/lib/.../bootstrap,mp=/etc/felhom-bootstrap,ro=1"},
|
|
})
|
|
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("attach: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if ga.attachDriveCount() != 1 || ga.attachDrives[0] != "/mnt/felhom-usb" {
|
|
t.Fatalf("AttachDrive not called with /mnt/felhom-usb: %+v", ga.attachDrives)
|
|
}
|
|
if ga.ensureParentN < 1 {
|
|
t.Fatalf("EnsureSharedParent must run before binding the drive")
|
|
}
|
|
if !strings.Contains(w.Body.String(), `"guest_path":"/mnt/felhom-drives/felhom-usb"`) {
|
|
t.Fatalf("response missing the stable guest_path: %s", w.Body.String())
|
|
}
|
|
if ga.count() != 0 {
|
|
t.Fatalf("legacy pct AttachBind called (%d) — intermediary model must use host-side AttachDrive", ga.count())
|
|
}
|
|
}
|
|
|
|
// An AttachDrive failure surfaces as 502 (no silent success).
|
|
func TestGuestAttach_AttachDriveFailure(t *testing.T) {
|
|
ga := &fakeGuestAttacher{attachDriveFail: true}
|
|
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
|
|
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("attach failure: got %d want 502 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// A hostile/invalid where is refused with no binder call.
|
|
func TestGuestAttach_RejectsBadPath(t *testing.T) {
|
|
ga := &fakeGuestAttacher{}
|
|
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
|
|
for _, bad := range []string{`{"where":"/mnt/../etc"}`, `{"where":"/etc/passwd"}`, `{"where":"/mnt/a/b"}`, `{"where":""}`} {
|
|
if w := do(t, h, "POST", "/disks/guest-attach", "A", bad); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad where %s: got %d want 400", bad, w.Code)
|
|
}
|
|
}
|
|
if ga.count() != 0 {
|
|
t.Fatal("binder called for an invalid path")
|
|
}
|
|
}
|
|
|
|
// Not configured (no GuestAttach dep) → 503.
|
|
func TestGuestAttach_NotConfigured(t *testing.T) {
|
|
h := newAttachServer(t, nil, map[int]map[string]string{8200: {}})
|
|
if w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`); w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("unconfigured: got %d want 503", w.Code)
|
|
}
|
|
}
|
|
|
|
// Guest reboot (activation) returns 202 and triggers RebootGuest for the token's vmid (detached).
|
|
func TestGuestReboot_Accepted(t *testing.T) {
|
|
ga := &fakeGuestAttacher{}
|
|
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
|
|
w := do(t, h, "POST", "/guest/reboot", "A", "")
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("reboot: got %d want 202 (%s)", w.Code, w.Body.String())
|
|
}
|
|
// The reboot runs in a goroutine; give it a moment to record the call.
|
|
for i := 0; i < 100 && ga.rebootCount() == 0; i++ {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if ga.rebootCount() != 1 || ga.reboots[0] != 8200 {
|
|
t.Fatalf("RebootGuest not invoked for vmid 8200: %+v", ga.reboots)
|
|
}
|
|
}
|
|
|
|
// A cross-guest reboot (body vmid != token's) is refused 403, no reboot.
|
|
func TestGuestReboot_CrossGuest403(t *testing.T) {
|
|
ga := &fakeGuestAttacher{}
|
|
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
|
|
if w := do(t, h, "POST", "/guest/reboot", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("cross-guest reboot: got %d want 403", w.Code)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
if ga.rebootCount() != 0 {
|
|
t.Fatal("reboot triggered for a cross-guest request")
|
|
}
|
|
}
|
|
|
|
// ---- auth / config ----------------------------------------------------------------------
|
|
|
|
func TestDisks_CrossGuest403(t *testing.T) {
|
|
h := newDiskServer(t, &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}, &fakeGate{}, nil, nil)
|
|
if w := do(t, h, "GET", "/disks?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
|
|
t.Fatalf("cross-guest /disks: got %d want 403", w.Code)
|
|
}
|
|
if w := do(t, h, "POST", "/disks/format", "A", `{"vmid":9300,"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusForbidden {
|
|
t.Fatalf("cross-guest format: got %d want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDisks_NotConfigured(t *testing.T) {
|
|
// a server with no disk deps → 503 on disk endpoints
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{},
|
|
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200},
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
h := srv.Handler()
|
|
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("unconfigured format: got %d want 503", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---- helpers ----------------------------------------------------------------------------
|
|
|
|
// newDiskServerWithGuestConfigs is like newDiskServer but with a fakeGuests that returns the given
|
|
// per-vmid mount maps (so eject's dependent-scan can resolve).
|
|
func newDiskServerWithGuestConfigs(t *testing.T, d *fakeDiskOps, sv StorageView, gl GuestLister, mounts map[int]map[string]string) http.Handler {
|
|
t.Helper()
|
|
fg := &fakeGuestsCfg{mounts: mounts}
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
|
|
Storage: sv, Tokens: staticTokens{"A": 8200, "B": 9300},
|
|
Disks: d, DiskGate: &fakeGate{}, Guests2: gl, HostReader: sysOnSDA(),
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
return srv.Handler()
|
|
}
|
|
|
|
// fakeGuestsCfg returns per-vmid mountpoints from GuestConfig.
|
|
type fakeGuestsCfg struct{ mounts map[int]map[string]string }
|
|
|
|
func (f *fakeGuestsCfg) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) {
|
|
extra := map[string]json.RawMessage{}
|
|
for k, v := range f.mounts[vmid] {
|
|
b, _ := json.Marshal(v)
|
|
extra[k] = b
|
|
}
|
|
return proxmox.GuestConfig{Extra: extra}, nil
|
|
}
|
|
func (f *fakeGuestsCfg) Snapshot(context.Context, int, string, string) (string, error) { return "", nil }
|
|
func (f *fakeGuestsCfg) Rollback(context.Context, int, string) (string, error) { return "", nil }
|
|
func (f *fakeGuestsCfg) WaitTask(context.Context, string, proxmox.WaitOptions) (proxmox.TaskStatus, error) {
|
|
return proxmox.TaskStatus{ExitStatus: "OK"}, nil
|
|
}
|