v0.61.0: audit fixes B1 (random temp staging) + D1 (mkfs wrapper member/RO re-checks) + D2 (empty-lsblk fail-safe) + D3 (blank-format anti-retarget)
From AUDIT-blast-radius-hostroot-localapi-2026-07-02.md. Each fix ships with a non-hollow test + a companion red-proof (shown failing on the pre-fix impl). Sudoers install-source grants became globs — deploy the sudoers drop-in with the binary. A1 (stale-lock pool-membership) deliberately excluded (spike). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -49,8 +49,8 @@ var manifest = []Capability{
|
||||
{"drives-mkdir-sub", "per-drive stable dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives/felhom-usb"}, false},
|
||||
{"drives-mkdir-data", "felhom-data namespace create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-usb/felhom-data"}, false},
|
||||
{"drives-chown-data", "felhom-data guest-root chown", "/usr/bin/chown", []string{"100000:100000", "/mnt/felhom-usb/felhom-data"}, false},
|
||||
{"parent-script-install", "shared-parent boot script install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-shared-parent.sh", "/usr/local/sbin/felhom-shared-parent.sh"}, false},
|
||||
{"parent-unit-install", "shared-parent boot unit install", "/usr/bin/install", []string{"-m", "0644", "--", "/tmp/felhom-shared-parent.service", "/etc/systemd/system/felhom-shared-parent.service"}, false},
|
||||
{"parent-script-install", "shared-parent boot script install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-shared-parent-123456789.sh", "/usr/local/sbin/felhom-shared-parent.sh"}, false},
|
||||
{"parent-unit-install", "shared-parent boot unit install", "/usr/bin/install", []string{"-m", "0644", "--", "/tmp/felhom-shared-parent-123456789.service", "/etc/systemd/system/felhom-shared-parent.service"}, false},
|
||||
{"parent-unit-enable", "shared-parent boot-persistence enable", "/usr/bin/systemctl", []string{"enable", "felhom-shared-parent.service"}, false},
|
||||
{"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives"}, false},
|
||||
|
||||
@@ -75,7 +75,7 @@ var manifest = []Capability{
|
||||
{"provision-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, false},
|
||||
|
||||
// ---- Pre-start self-heal hook + guest lifecycle ----
|
||||
{"guesthook-install", "pre-start hook snippet install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-guest-hook.sh", "/var/lib/vz/snippets/felhom-guest-hook.sh"}, false},
|
||||
{"guesthook-install", "pre-start hook snippet install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-guest-hook-123456789.sh", "/var/lib/vz/snippets/felhom-guest-hook.sh"}, false},
|
||||
{"guesthook-register", "pre-start hook register", "/usr/sbin/pct", []string{"set", "9201", "--hookscript", "local:snippets/felhom-guest-hook.sh"}, false},
|
||||
{"guesthook-delete-mp", "dead mountpoint slot delete (C1 net)", "/usr/sbin/pct", []string{"set", "9201", "--delete", "mp0"}, false},
|
||||
{"guest-reboot", "enroll activate-binds reboot", "/usr/sbin/pct", []string{"reboot", "9201"}, false},
|
||||
|
||||
@@ -36,12 +36,23 @@ exec ` + AgentBin + ` guest-hook "$1" "$2"
|
||||
// InstallSnippet writes the pre-start hook wrapper into the PVE snippets dir (idempotent, root-owned,
|
||||
// executable). The agent runs as a non-root service user, so it writes an agent-writable temp file then
|
||||
// `install`s it host-root (same pattern as the bootstrap mount + dnsmasq drop-ins). Safe to call repeatedly.
|
||||
// The temp file is a RANDOM-named os.CreateTemp (audit B1): a fixed, predictable /tmp name could be
|
||||
// pre-created by another local user and rewritten between our write and root's install (TOCTOU into a
|
||||
// root-executed hookscript). The final mode comes from `install -m`, so the 0600 temp is fine.
|
||||
func InstallSnippet(ctx context.Context, runner proxmox.Runner) error {
|
||||
tmp := filepath.Join(os.TempDir(), "felhom-guest-hook.sh")
|
||||
if err := os.WriteFile(tmp, []byte(snippetBody), 0o755); err != nil {
|
||||
f, err := os.CreateTemp("", "felhom-guest-hook-*.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("guesthook: create temp snippet: %w", err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp)
|
||||
if _, err := f.WriteString(snippetBody); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("guesthook: write temp snippet: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("guesthook: close temp snippet: %w", err)
|
||||
}
|
||||
if _, stderr, err := runner.Run(ctx, "install", "-m", "0755", "--", tmp, SnippetPath); err != nil {
|
||||
return fmt.Errorf("guesthook: install snippet to %s: %w: %s", SnippetPath, err, string(stderr))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package guesthook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recordingRunner is a fake proxmox.Runner that records every call and snapshots the content of the
|
||||
// install SOURCE file at call time (the deferred os.Remove would erase it before the test can look).
|
||||
type recordingRunner struct {
|
||||
calls [][]string
|
||||
srcContent []string
|
||||
}
|
||||
|
||||
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.calls = append(r.calls, append([]string{name}, args...))
|
||||
if name == "install" && len(args) > 0 {
|
||||
src := args[len(args)-2]
|
||||
b, _ := os.ReadFile(src)
|
||||
r.srcContent = append(r.srcContent, string(b))
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// TestInstallSnippet_RandomTempName is the audit-B1 negative test: the staged install SOURCE must be a
|
||||
// RANDOM os.CreateTemp name (felhom-guest-hook-<random>.sh), never the fixed, pre-creatable
|
||||
// /tmp/felhom-guest-hook.sh (a local TOCTOU into a root-executed hookscript), and two consecutive
|
||||
// installs must stage through DIFFERENT paths.
|
||||
func TestInstallSnippet_RandomTempName(t *testing.T) {
|
||||
r := &recordingRunner{}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #1: %v", err)
|
||||
}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #2: %v", err)
|
||||
}
|
||||
if len(r.calls) != 2 {
|
||||
t.Fatalf("expected 2 install calls, got %d: %v", len(r.calls), r.calls)
|
||||
}
|
||||
|
||||
randomName := regexp.MustCompile(`felhom-guest-hook-[^/\\]+\.sh$`)
|
||||
fixedName := regexp.MustCompile(`felhom-guest-hook\.sh$`)
|
||||
var srcs []string
|
||||
for i, call := range r.calls {
|
||||
// install -m 0755 -- <src> <dest>
|
||||
if call[0] != "install" || len(call) != 6 {
|
||||
t.Fatalf("call %d: unexpected vector %v", i, call)
|
||||
}
|
||||
src, dest := call[4], call[5]
|
||||
if dest != SnippetPath {
|
||||
t.Errorf("call %d: dest = %q, want %q", i, dest, SnippetPath)
|
||||
}
|
||||
if !randomName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q does not match the random felhom-guest-hook-*.sh pattern", i, src)
|
||||
}
|
||||
if fixedName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q is the FIXED predictable temp name (B1 TOCTOU)", i, src)
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
if srcs[0] == srcs[1] {
|
||||
t.Errorf("two consecutive installs staged through the SAME source path %q — must be random per call", srcs[0])
|
||||
}
|
||||
// Non-hollow: the staged file must actually carry the snippet body at install time.
|
||||
for i, c := range r.srcContent {
|
||||
if c != snippetBody {
|
||||
t.Errorf("call %d: staged content is not the snippet body (got %d bytes)", i, len(c))
|
||||
}
|
||||
}
|
||||
// And the temp is cleaned up after.
|
||||
for _, src := range srcs {
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Errorf("staged temp %q left behind (defer os.Remove missing)", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// ---- Audit D3: the blank-format branch's anti-retarget guard ------------------------------
|
||||
//
|
||||
// AGENT-001 closed the classify→mkfs TOCTOU on the data-bearing branch; these tests pin the same
|
||||
// guard onto the BLANK branch: the format must bind to a durable id, re-resolve it to the CURRENT
|
||||
// device, and format THAT — never the mutable caller-supplied /dev path.
|
||||
|
||||
// Pure-function coverage of antiRetargetResolveBlank (the injected-deps sibling; no real /dev).
|
||||
func TestFormatBlankPath_AntiRetarget_ReassignedDataBearingRefused(t *testing.T) {
|
||||
const durable = "byid:wwn-0xBLANK"
|
||||
dataBearing := storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}
|
||||
got, err := antiRetargetResolveBlank(durable,
|
||||
func(string) (string, error) { return "/dev/sdb", nil },
|
||||
func(string) (string, error) { return durable, nil },
|
||||
func(string) (storage.DeviceProbe, error) { return dataBearing, nil },
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("re-inspect found a DATA-BEARING disk on the blank path — must refuse, got device %q", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "now data-bearing") {
|
||||
t.Errorf("error %q missing the now-data-bearing explanation", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBlankPath_AntiRetarget_ReassignedDifferentDiskRefused(t *testing.T) {
|
||||
const durable = "byid:wwn-0xBLANK"
|
||||
blank := storage.DeviceProbe{Probed: true}
|
||||
got, err := antiRetargetResolveBlank(durable,
|
||||
func(string) (string, error) { return "/dev/sdb", nil },
|
||||
func(string) (string, error) { return "byid:wwn-0xDIFFERENT", nil }, // node reassigned → different id
|
||||
func(string) (storage.DeviceProbe, error) { return blank, nil },
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "durable-id mismatch") {
|
||||
t.Fatalf("want durable-id mismatch refusal, got device %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBlankPath_AntiRetarget_UnresolvableRefused(t *testing.T) {
|
||||
blank := storage.DeviceProbe{Probed: true}
|
||||
// Unresolvable durable id → refuse.
|
||||
if got, err := antiRetargetResolveBlank("byid:wwn-0xGONE",
|
||||
func(string) (string, error) { return "", errors.New("gone") },
|
||||
func(string) (string, error) { return "byid:wwn-0xGONE", nil },
|
||||
func(string) (storage.DeviceProbe, error) { return blank, nil },
|
||||
); err == nil || !strings.Contains(err.Error(), "no longer resolves") {
|
||||
t.Fatalf("want no-longer-resolves refusal, got device %q err %v", got, err)
|
||||
}
|
||||
// Empty durable id (nothing to bind) → refuse: a path-only blank format is what the guard prevents.
|
||||
if got, err := antiRetargetResolveBlank("",
|
||||
func(string) (string, error) { return "/dev/sdb", nil },
|
||||
func(string) (string, error) { return "", nil },
|
||||
func(string) (storage.DeviceProbe, error) { return blank, nil },
|
||||
); err == nil || !strings.Contains(err.Error(), "path-only") {
|
||||
t.Fatalf("want path-only refusal on empty durable id, got device %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBlankPath_AntiRetarget_SameBlankProceeds(t *testing.T) {
|
||||
const durable = "byid:wwn-0xBLANK"
|
||||
blank := storage.DeviceProbe{Probed: true}
|
||||
got, err := antiRetargetResolveBlank(durable,
|
||||
func(string) (string, error) { return "/dev/sdb", nil },
|
||||
func(string) (string, error) { return durable, nil },
|
||||
func(string) (storage.DeviceProbe, error) { return blank, nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("same still-blank disk must proceed, got refusal: %v", err)
|
||||
}
|
||||
if got != "/dev/sdb" {
|
||||
t.Fatalf("device = %q, want the re-resolved /dev/sdb", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- handler wiring: the blank branch formats the RE-RESOLVED device, never req.Device ----
|
||||
|
||||
// The red-proof companion vs the pre-fix handler: pre-fix, the blank branch called
|
||||
// startFormatDetached(req.Device, ...) directly, so the format hit the caller path even when the
|
||||
// re-resolve seam says the durable id now lives at a different node. Post-fix, mkfs must run on the
|
||||
// re-resolved device.
|
||||
func TestFormat_Blank_FormatsReresolvedDeviceNotCallerPath(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank
|
||||
srv := newDiskServerRaw(t, d, &fakeGate{}, nil, nil)
|
||||
var boundID string
|
||||
srv.reresolveBlank = func(_ context.Context, durableID string) (string, error) {
|
||||
boundID = durableID
|
||||
return "/dev/sdz", nil // the durable id's CURRENT node differs from the caller path
|
||||
}
|
||||
h := srv.Handler()
|
||||
|
||||
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 boundID != "byid:wwn-sdb" {
|
||||
t.Errorf("blank format bound durable id %q, want the one derived from req.Device", boundID)
|
||||
}
|
||||
got := d.formatted()
|
||||
if len(got) != 1 || got[0] != "/dev/sdz" {
|
||||
t.Fatalf("mkfs ran on %v — must run on the RE-RESOLVED device /dev/sdz, never the caller path /dev/sdb", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A blank-branch anti-retarget refusal (device changed in the window) must refuse 409 with ZERO mkfs.
|
||||
func TestFormat_Blank_ReresolveRefusalNoMkfs(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank
|
||||
srv := newDiskServerRaw(t, d, &fakeGate{}, nil, nil)
|
||||
srv.reresolveBlank = func(_ context.Context, durableID string) (string, error) {
|
||||
return "", fmt.Errorf("%s is now data-bearing (target changed since blank inspection) — refusing", "/dev/sdb")
|
||||
}
|
||||
h := srv.Handler()
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("refused blank format: got %d want 409 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := d.formatted(); len(got) != 0 {
|
||||
t.Fatalf("mkfs WAS called after an anti-retarget refusal: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A device with no durable id cannot be bound → the blank format is refused (no path-only formats).
|
||||
func TestFormat_Blank_NoDurableIDRefused(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank
|
||||
srv := newDiskServerRaw(t, d, &fakeGate{}, nil, nil)
|
||||
srv.deviceDurableID = func(string) (string, error) { return "", errors.New("no by-id/by-uuid entry") }
|
||||
h := srv.Handler()
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("unbindable blank format: got %d want 409 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := d.formatted(); len(got) != 0 {
|
||||
t.Fatalf("mkfs WAS called with no durable-id binding: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -678,16 +678,41 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
|
||||
// Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy). F20-BUG3: run
|
||||
// it DETACHED off s.baseCtx so a request/client deadline can't SIGKILL mkfs mid-write; we still
|
||||
// wait here to return the synchronous result (backward-compatible with the controller's client).
|
||||
done := s.startFormatDetached(req.Device, "", req.FSType)
|
||||
if err := s.awaitFormat(r.Context(), done, vmid, req.Device); err != nil {
|
||||
//
|
||||
// [audit D3, AGENT-001's benign-branch twin] anti-retarget: bind the format to the device's
|
||||
// durable id and re-resolve it to the CURRENT device (re-derive + exact match + re-inspect
|
||||
// STILL blank) — then format THAT device, never the mutable req.Device path. A /dev
|
||||
// re-enumeration in the window could otherwise mkfs a data-bearing disk that inherited the
|
||||
// node, with neither the DataBearing customer-confirm nor any durable-id binding. A device with
|
||||
// no durable id cannot be bound → refused (a path-only format is what the guard prevents).
|
||||
blankDurable, derr := s.deviceDurableID(req.Device)
|
||||
if derr != nil || blankDurable == "" {
|
||||
s.logger.Warn("local-api: blank format REFUSED — device has no durable id to bind (anti-retarget)",
|
||||
"vmid", vmid, "device", req.Device, "err", derr)
|
||||
writeStatus(w, http.StatusConflict, false,
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: false},
|
||||
"format refused: device has no durable id to bind the format to (path-only formats are not permitted)")
|
||||
return
|
||||
}
|
||||
device, rerr := s.reresolveBlank(r.Context(), blankDurable)
|
||||
if rerr != nil {
|
||||
s.logger.Warn("local-api: blank format REFUSED at anti-retarget re-resolve",
|
||||
"vmid", vmid, "req_device", req.Device, "durable_id", blankDurable, "err", rerr)
|
||||
writeStatus(w, http.StatusConflict, false,
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: false, DurableID: blankDurable},
|
||||
"format refused (device may have changed since inspection): "+rerr.Error())
|
||||
return
|
||||
}
|
||||
done := s.startFormatDetached(device, blankDurable, req.FSType, true)
|
||||
if err := s.awaitFormat(r.Context(), done, vmid, device); err != nil {
|
||||
if err == errFormatClientGone {
|
||||
return // client gone; mkfs continues detached + the job record records the outcome
|
||||
}
|
||||
s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
|
||||
s.logger.Error("local-api: format", "vmid", vmid, "device", device, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
|
||||
writeOK(w, FormatResponse{VMID: vmid, Device: device, Formatted: true, DataBearing: false, DurableID: blankDurable, Reason: "blank device formatted " + req.FSType})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -724,7 +749,7 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
|
||||
// F20-BUG3: run the destructive mkfs DETACHED off s.baseCtx (bound durable id recorded for
|
||||
// restart-recovery), so a request/client deadline can never SIGKILL it mid-write and corrupt the
|
||||
// disk. We still wait to return the synchronous result (backward-compatible with the controller).
|
||||
done := s.startFormatDetached(device, deviceDurable, req.FSType)
|
||||
done := s.startFormatDetached(device, deviceDurable, req.FSType, false)
|
||||
if err := s.awaitFormat(r.Context(), done, vmid, device); err != nil {
|
||||
if err == errFormatClientGone {
|
||||
return // client gone; the wipe continues detached + survives a restart via the job record
|
||||
|
||||
@@ -103,6 +103,13 @@ func sysOnSDA() fakeHostReader {
|
||||
|
||||
// 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()
|
||||
return newDiskServerRaw(t, d, g, sv, gl).Handler()
|
||||
}
|
||||
|
||||
// newDiskServerRaw is newDiskServer returning the *Server, so tests can override seams
|
||||
// (reresolveWipe/reresolveBlank/deviceDurableID) before taking the handler.
|
||||
func newDiskServerRaw(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl GuestLister) *Server {
|
||||
t.Helper()
|
||||
if sv == nil {
|
||||
sv = fakeStorage{}
|
||||
@@ -129,10 +136,13 @@ 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 }
|
||||
// [audit D3] the blank-path anti-retarget sibling touches /dev/disk/by-* too; stub it to a
|
||||
// successful still-blank re-resolve of the same device. Covered directly in wipe_reresolve_test.go.
|
||||
srv.reresolveBlank = 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()
|
||||
return srv
|
||||
}
|
||||
|
||||
// ---- the security centerpiece -----------------------------------------------------------
|
||||
|
||||
@@ -17,9 +17,10 @@ import (
|
||||
type formatJob struct {
|
||||
JobID string `json:"job_id"`
|
||||
Device string `json:"device"`
|
||||
DurableID string `json:"durable_id"` // "" for a blank (benign) format — never auto-recovered
|
||||
DurableID string `json:"durable_id"` // durable-id binding; "" only in legacy records (never auto-recovered)
|
||||
FSType string `json:"fstype"`
|
||||
Phase string `json:"phase"` // running | done | failed
|
||||
Blank bool `json:"blank,omitempty"` // audit D3: blank (benign) format — recovery re-checks STILL-blank, not data-bearing
|
||||
Phase string `json:"phase"` // running | done | failed
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
@@ -93,15 +94,16 @@ func (s *FormatJobStore) save(j *formatJob) error {
|
||||
// request context), with a long bound. It returns a channel that yields the format error (nil on
|
||||
// success). The caller may stop waiting (client disconnect) without killing the mkfs — the goroutine
|
||||
// runs to completion and records the outcome. device is the ALREADY anti-retarget-resolved device; the
|
||||
// record carries durableID so a restart can re-resolve + re-run.
|
||||
func (s *Server) startFormatDetached(device, durableID, fstype string) <-chan error {
|
||||
// record carries durableID so a restart can re-resolve + re-run. blank marks a benign (blank-device)
|
||||
// format, so restart recovery re-checks STILL-blank rather than data-bearing (audit D3).
|
||||
func (s *Server) startFormatDetached(device, durableID, fstype string, blank bool) <-chan error {
|
||||
base := s.baseCtx
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
job := &formatJob{
|
||||
JobID: s.nowFn().UTC().Format("20060102T150405Z"), Device: device, DurableID: durableID,
|
||||
FSType: fstype, Phase: formatPhaseRunning,
|
||||
FSType: fstype, Blank: blank, Phase: formatPhaseRunning,
|
||||
StartedAt: s.nowFn().UTC().Format(time.RFC3339), UpdatedAt: s.nowFn().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if s.formatJobs != nil {
|
||||
@@ -151,18 +153,26 @@ func (s *Server) RecoverFormatJob(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
if job.DurableID == "" {
|
||||
s.logger.Warn("format-job recover: interrupted blank format — marking failed (retry needed; not auto-re-running a path-bound format)", "device", job.Device)
|
||||
s.logger.Warn("format-job recover: interrupted format has no durable id (legacy record) — marking failed (retry needed; not auto-re-running a path-bound format)", "device", job.Device)
|
||||
s.finishFormatJob(job, fmt.Errorf("interrupted by agent restart; retry the format"))
|
||||
return
|
||||
}
|
||||
device, err := s.reresolveWipe(ctx, job.DurableID)
|
||||
// Audit D3: a blank format authorized "nothing to destroy" — its recovery re-check must assert
|
||||
// STILL-blank (an interrupted mkfs may leave partial signatures; if the re-resolved device probes
|
||||
// data-bearing the blank re-check refuses fail-safe and the caller retries). The customer-confirmed
|
||||
// wipe path keeps the data-bearing re-check as before.
|
||||
reresolve := s.reresolveWipe
|
||||
if job.Blank {
|
||||
reresolve = s.reresolveBlank
|
||||
}
|
||||
device, err := reresolve(ctx, job.DurableID)
|
||||
if err != nil {
|
||||
s.logger.Warn("format-job recover: durable-id no longer resolves — NOT re-formatting (anti-retarget)", "durable_id", job.DurableID, "err", err)
|
||||
s.logger.Warn("format-job recover: durable-id did not re-resolve cleanly — NOT re-formatting (anti-retarget)", "durable_id", job.DurableID, "blank", job.Blank, "err", err)
|
||||
s.finishFormatJob(job, fmt.Errorf("durable-id %s did not re-resolve after restart: %w", job.DurableID, err))
|
||||
return
|
||||
}
|
||||
s.logger.Warn("format-job recover: re-running interrupted format detached", "durable_id", job.DurableID, "device", device, "fstype", job.FSType)
|
||||
_ = s.startFormatDetached(device, job.DurableID, job.FSType) // detached; updates the record on completion
|
||||
s.logger.Warn("format-job recover: re-running interrupted format detached", "durable_id", job.DurableID, "device", device, "fstype", job.FSType, "blank", job.Blank)
|
||||
_ = s.startFormatDetached(device, job.DurableID, job.FSType, job.Blank) // detached; updates the record on completion
|
||||
}
|
||||
|
||||
// nowFn returns the server clock (testable), defaulting to time.Now.
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -130,6 +129,26 @@ func (b *GuestBinder) EnsureSharedParent(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// stageTemp writes content to a fresh random-named temp file (os.CreateTemp pattern — `*` is replaced
|
||||
// by a random string) and returns its path. Caller removes it after the privileged `install`.
|
||||
func stageTemp(pattern, content string) (string, error) {
|
||||
f, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := f.Name()
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// sharedParentInstallStale reports whether the on-disk boot script OR unit is missing or differs from
|
||||
// what this build ships — the trigger to (re)install both. Comparing BOTH (not the unit alone) is the
|
||||
// F2-a fix: a script-only change must still redeploy. Pure (path args) so it is unit-testable.
|
||||
@@ -144,18 +163,21 @@ func sharedParentInstallStale(unitPath, scriptPath string) bool {
|
||||
}
|
||||
|
||||
// installSharedParentUnit writes the script + unit (from agent-written temps) and enables the unit so the
|
||||
// shared parent is re-established on every host boot before pve-guests. Idempotent.
|
||||
// shared parent is re-established on every host boot before pve-guests. Idempotent. The temps are
|
||||
// RANDOM-named os.CreateTemp files (audit B1): a fixed, predictable /tmp name could be pre-created by
|
||||
// another local user and rewritten between our write and root's install (TOCTOU into a root-executed
|
||||
// boot script). The final modes come from `install -m`, so the 0600 temps are fine.
|
||||
func (b *GuestBinder) installSharedParentUnit(ctx context.Context) error {
|
||||
tmpScript := filepath.Join(os.TempDir(), "felhom-shared-parent.sh")
|
||||
if err := os.WriteFile(tmpScript, []byte(sharedParentScript), 0o755); err != nil {
|
||||
tmpScript, err := stageTemp("felhom-shared-parent-*.sh", sharedParentScript)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write temp script: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpScript)
|
||||
if err := b.run(ctx, "install", "-m", "0755", "--", tmpScript, sharedParentScriptPath); err != nil {
|
||||
return fmt.Errorf("install script: %w", err)
|
||||
}
|
||||
tmpUnit := filepath.Join(os.TempDir(), "felhom-shared-parent.service")
|
||||
if err := os.WriteFile(tmpUnit, []byte(sharedParentUnit), 0o644); err != nil {
|
||||
tmpUnit, err := stageTemp("felhom-shared-parent-*.service", sharedParentUnit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write temp unit: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpUnit)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stagingRecorderRunner is a fake proxmox.Runner recording every call vector and snapshotting each
|
||||
// install SOURCE file's content at call time (the deferred os.Remove erases it afterwards).
|
||||
type stagingRecorderRunner struct {
|
||||
calls [][]string
|
||||
srcContent map[string]string // install dest → staged source content
|
||||
}
|
||||
|
||||
func (r *stagingRecorderRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.calls = append(r.calls, append([]string{name}, args...))
|
||||
if name == "install" && len(args) >= 2 {
|
||||
src, dest := args[len(args)-2], args[len(args)-1]
|
||||
if r.srcContent == nil {
|
||||
r.srcContent = map[string]string{}
|
||||
}
|
||||
b, _ := os.ReadFile(src)
|
||||
r.srcContent[dest] = string(b)
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *stagingRecorderRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// installSources returns the install-call source paths keyed by destination.
|
||||
func (r *stagingRecorderRunner) installSources() map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, c := range r.calls {
|
||||
if c[0] == "install" && len(c) >= 3 {
|
||||
src, dest := c[len(c)-2], c[len(c)-1]
|
||||
out[dest] = append(out[dest], src)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestInstallSharedParent_RandomTempName is the audit-B1 negative test for the shared-parent boot
|
||||
// persistence install: both staged install SOURCES (script + unit) must be RANDOM os.CreateTemp names
|
||||
// (felhom-shared-parent-<random>.sh / .service), never the fixed, pre-creatable /tmp names (a local
|
||||
// TOCTOU into a root-executed boot script), and two consecutive installs must use DIFFERENT paths.
|
||||
func TestInstallSharedParent_RandomTempName(t *testing.T) {
|
||||
r := &stagingRecorderRunner{}
|
||||
b := NewGuestBinder(r, nil)
|
||||
if err := b.installSharedParentUnit(context.Background()); err != nil {
|
||||
t.Fatalf("installSharedParentUnit #1: %v", err)
|
||||
}
|
||||
if err := b.installSharedParentUnit(context.Background()); err != nil {
|
||||
t.Fatalf("installSharedParentUnit #2: %v", err)
|
||||
}
|
||||
|
||||
srcs := r.installSources()
|
||||
cases := []struct {
|
||||
dest string
|
||||
random *regexp.Regexp
|
||||
fixed *regexp.Regexp
|
||||
content string
|
||||
}{
|
||||
{sharedParentScriptPath, regexp.MustCompile(`felhom-shared-parent-[^/\\]+\.sh$`),
|
||||
regexp.MustCompile(`felhom-shared-parent\.sh$`), sharedParentScript},
|
||||
{sharedParentUnitPath, regexp.MustCompile(`felhom-shared-parent-[^/\\]+\.service$`),
|
||||
regexp.MustCompile(`felhom-shared-parent\.service$`), sharedParentUnit},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := srcs[tc.dest]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("dest %s: expected 2 install calls, got %d (%v)", tc.dest, len(got), got)
|
||||
}
|
||||
for i, src := range got {
|
||||
if !tc.random.MatchString(src) {
|
||||
t.Errorf("dest %s call %d: source %q does not match the random temp pattern", tc.dest, i, src)
|
||||
}
|
||||
if tc.fixed.MatchString(src) {
|
||||
t.Errorf("dest %s call %d: source %q is the FIXED predictable temp name (B1 TOCTOU)", tc.dest, i, src)
|
||||
}
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Errorf("dest %s: staged temp %q left behind (defer os.Remove missing)", tc.dest, src)
|
||||
}
|
||||
}
|
||||
if got[0] == got[1] {
|
||||
t.Errorf("dest %s: two consecutive installs staged through the SAME source %q — must be random per call", tc.dest, got[0])
|
||||
}
|
||||
// Non-hollow: the staged file carried the real content at install time.
|
||||
if r.srcContent[tc.dest] != tc.content {
|
||||
t.Errorf("dest %s: staged content mismatch (got %d bytes, want %d)", tc.dest, len(r.srcContent[tc.dest]), len(tc.content))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,11 @@ type Server struct {
|
||||
// override it to avoid touching real /dev.
|
||||
reresolveWipe func(ctx context.Context, durableID string) (string, error)
|
||||
|
||||
// reresolveBlank is the BLANK-format sibling (audit D3): same anti-retarget
|
||||
// sequence, but requires the re-inspected device to STILL be blank. Defaults
|
||||
// to s.reresolveDurableForBlankFormat; tests override it.
|
||||
reresolveBlank 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
|
||||
@@ -253,6 +258,7 @@ func NewServer(o Options) (*Server, error) {
|
||||
swapInFlight: map[int]bool{},
|
||||
}
|
||||
s.reresolveWipe = s.reresolveDurableForWipe
|
||||
s.reresolveBlank = s.reresolveDurableForBlankFormat
|
||||
s.deviceDurableID = storage.DeviceDurableID
|
||||
if o.ControllerSwap != nil {
|
||||
s.swap = NewControllerSwapper(o.ControllerSwap, o.ControllerSwapStateDir, o.Logger)
|
||||
|
||||
@@ -27,6 +27,35 @@ func antiRetargetResolve(
|
||||
resolve func(string) (string, error),
|
||||
derive func(string) (string, error),
|
||||
inspect func(string) (storage.DeviceProbe, error),
|
||||
) (string, error) {
|
||||
return antiRetargetResolveExpect(durableID, true, resolve, derive, inspect)
|
||||
}
|
||||
|
||||
// antiRetargetResolveBlank is the BLANK-format sibling [audit D3, AGENT-001's benign-branch twin]: the
|
||||
// same resolve → re-derive-match → re-inspect sequence, but it requires the re-inspected device to
|
||||
// STILL be blank (!DataBearing). The blank branch authorized mkfs precisely because the agent read the
|
||||
// device as having nothing to destroy; if the /dev node was reassigned in the window and now holds
|
||||
// data, the target changed — refuse rather than format it with neither a customer confirm nor a bound
|
||||
// durable id.
|
||||
func antiRetargetResolveBlank(
|
||||
durableID string,
|
||||
resolve func(string) (string, error),
|
||||
derive func(string) (string, error),
|
||||
inspect func(string) (storage.DeviceProbe, error),
|
||||
) (string, error) {
|
||||
return antiRetargetResolveExpect(durableID, false, resolve, derive, inspect)
|
||||
}
|
||||
|
||||
// antiRetargetResolveExpect is the shared core: resolve the bound durable id to the CURRENT device,
|
||||
// re-derive the device's durable id and require an exact match, then re-inspect and require the
|
||||
// data-bearing state to still be what the caller authorized (expectDataBearing). Returns the
|
||||
// re-resolved device to format — never a caller-supplied path.
|
||||
func antiRetargetResolveExpect(
|
||||
durableID string,
|
||||
expectDataBearing bool,
|
||||
resolve func(string) (string, error),
|
||||
derive func(string) (string, error),
|
||||
inspect func(string) (storage.DeviceProbe, error),
|
||||
) (string, error) {
|
||||
if durableID == "" {
|
||||
// A path-only binding is exactly what the durable id exists to prevent.
|
||||
@@ -50,11 +79,16 @@ func antiRetargetResolve(
|
||||
if !probe.Probed {
|
||||
return "", fmt.Errorf("%s did not probe cleanly at execution — refusing", device)
|
||||
}
|
||||
if !probe.DataBearing() {
|
||||
if expectDataBearing && !probe.DataBearing() {
|
||||
// The customer confirmed wiping a DATA-BEARING device; if it is now blank,
|
||||
// the target changed since confirmation — refuse rather than wipe blindly.
|
||||
return "", fmt.Errorf("%s is no longer data-bearing (target changed since confirmation) — refusing", device)
|
||||
}
|
||||
if !expectDataBearing && probe.DataBearing() {
|
||||
// The agent authorized a BLANK format; the device is now data-bearing —
|
||||
// the target changed since inspection (D3 re-enumeration race) — refuse.
|
||||
return "", fmt.Errorf("%s is now data-bearing (target changed since blank inspection) — refusing", device)
|
||||
}
|
||||
return device, nil
|
||||
}
|
||||
|
||||
@@ -69,3 +103,14 @@ func (s *Server) reresolveDurableForWipe(ctx context.Context, durableID string)
|
||||
func(dev string) (storage.DeviceProbe, error) { return s.disks.InspectDevice(ctx, dev) },
|
||||
)
|
||||
}
|
||||
|
||||
// reresolveDurableForBlankFormat wires antiRetargetResolveBlank with the real storage funcs and the
|
||||
// server's disk inspector (audit D3 — the blank-format branch's pre-mkfs anti-retarget re-check).
|
||||
func (s *Server) reresolveDurableForBlankFormat(ctx context.Context, durableID string) (string, error) {
|
||||
return antiRetargetResolveBlank(
|
||||
durableID,
|
||||
storage.ResolveDurableDevice,
|
||||
storage.DeviceDurableID,
|
||||
func(dev string) (storage.DeviceProbe, error) { return s.disks.InspectDevice(ctx, dev) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,24 @@ func classifyClaim(f claimFacts) (unclaimed bool, reason string) {
|
||||
return false, "device is mounted at " + n.mountpoint + " (" + n.name + ")"
|
||||
}
|
||||
}
|
||||
// Fail-safe backstop (audit D2): a successful-but-EMPTY lsblk (or a tree that does not even contain
|
||||
// the target whole-disk) means the member/mount loop above inspected nothing — that is undeterminable
|
||||
// topology, not proof of freedom. Without this, "unclaimed" rested on the untested assumption that
|
||||
// lsblk always ERRORS (non-zero exit) on a bad device rather than emitting empty success.
|
||||
if len(f.nodes) == 0 {
|
||||
return false, "empty block topology (undeterminable) — refusing"
|
||||
}
|
||||
base := path.Base(f.wholeDisk)
|
||||
found := false
|
||||
for _, n := range f.nodes {
|
||||
if n.name == base {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false, "target disk " + base + " absent from block topology (undeterminable) — refusing"
|
||||
}
|
||||
return true, "unclaimed"
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,36 @@ func TestClassifyClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyClaim_EmptyNodesRefused is the audit-D2 negative test: a successful-but-EMPTY lsblk
|
||||
// (`{"blockdevices":[]}` → zero nodes) previously skipped the member/mount loop entirely and returned
|
||||
// (true,"unclaimed") — the one hole in the "undeterminable ⇒ claimed" fail-safe. It must refuse.
|
||||
func TestClassifyClaim_EmptyNodesRefused(t *testing.T) {
|
||||
for _, nodes := range [][]claimNode{nil, {}} {
|
||||
f := claimFacts{device: "/dev/sdd", wholeDisk: "/dev/sdd", wholeDiskOK: true, nodes: nodes}
|
||||
unclaimed, reason := classifyClaim(f)
|
||||
if unclaimed {
|
||||
t.Fatalf("nodes=%v: empty topology classified UNCLAIMED (reason %q) — fail-safe hole", nodes, reason)
|
||||
}
|
||||
if !strings.Contains(reason, "empty block topology") {
|
||||
t.Errorf("nodes=%v: reason %q missing the empty-topology explanation", nodes, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyClaim_TargetAbsentFromTree (audit D2): lsblk returned SOME tree, but the target
|
||||
// whole-disk is not in it — the loop inspected the wrong device's signals. Undeterminable → refuse.
|
||||
func TestClassifyClaim_TargetAbsentFromTree(t *testing.T) {
|
||||
f := claimFacts{device: "/dev/sdd", wholeDisk: "/dev/sdd", wholeDiskOK: true,
|
||||
nodes: []claimNode{{name: "sdc"}, {name: "sdc1", fstype: "ntfs"}}} // benign signals, wrong disk
|
||||
unclaimed, reason := classifyClaim(f)
|
||||
if unclaimed {
|
||||
t.Fatalf("target-absent tree classified UNCLAIMED (reason %q)", reason)
|
||||
}
|
||||
if !strings.Contains(reason, "absent from block topology") {
|
||||
t.Errorf("reason %q missing the target-absent explanation", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLsblkNodes(t *testing.T) {
|
||||
out := []byte(`{"blockdevices":[{"name":"sdd","fstype":null,"mountpoint":null,"children":[{"name":"sdd1","fstype":"ntfs","mountpoint":null}]}]}`)
|
||||
nodes, err := parseLsblkNodes(out)
|
||||
|
||||
Reference in New Issue
Block a user