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:
2026-07-03 07:25:50 +02:00
parent cc93dae792
commit 3f382bf762
20 changed files with 767 additions and 44 deletions
+3 -3
View File
@@ -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},
+14 -3
View File
@@ -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))
}
+83
View File
@@ -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)
}
}
}
+147
View File
@@ -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)
}
}
+30 -5
View File
@@ -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
+11 -1
View File
@@ -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 -----------------------------------------------------------
+20 -10
View File
@@ -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.
+28 -6
View File
@@ -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))
}
}
}
+6
View File
@@ -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)
+46 -1
View File
@@ -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) },
)
}
+18
View File
@@ -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"
}
+30
View File
@@ -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)