v0.5.0: slice 5 Phase B — the host-root surface (mounts + SMART + grow + destructive gate)

The privileged write surface, isolated behind a narrow, arg-validated, adversarially-
tested seam (HostOps), the same discipline as the slice-4 gate. Completes slice 5.

- internal/storage: HostOps seam + SudoHostOps (systemd .mount units by fs-UUID, detach,
  SMART, lvs) via sudoers allowlist + fixed arg vectors, no shell; NoopHostOps fallback.
- validate.go: strict UUID/mount-path/device/LVM validators + in-process systemd-escape.
  Headline test: adversarial matrix (metacharacters/traversal/malformed) refused with
  zero exec.
- smart.go: smartctl SATA + NVMe parse, UNKNOWN-degrade; lvs thin-pool metadata fill.
- observer enrichment (Observe only): fills smart + thin-pool metadata.
- watchdog: benign re-mount response off the poll path (DevicePresent probe, rate-limited).
- reconcile: ActionResize (benign, grow-only) + proxmox.ResizeLXC; destructive storage ops
  (ClassStorageWipe/Decommission) through the slice-4 gate, target-scoped; built+tested,
  inert live.
- --selftest=storage [-watch] live harness; configs/felhom-agent.sudoers; privileged.* knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:53:38 +02:00
parent 27b68f043b
commit 9d6e49236c
25 changed files with 2074 additions and 182 deletions
+209
View File
@@ -0,0 +1,209 @@
package storage
import (
"context"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// recordingRunner records every command it is asked to run (and never execs anything). The
// adversarial matrix asserts that a rejected argument means ZERO commands were constructed —
// the validator is the wall, not the exec.
type recordingRunner struct {
calls [][]string
err error
}
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
r.calls = append(r.calls, append([]string{name}, args...))
return nil, nil, r.err
}
// --- The headline: the arg-validator adversarial matrix. ---
func TestValidateUUID_AdversarialMatrix(t *testing.T) {
good := []string{
"0fc63daf-8483-4772-8e79-3d69d8477de4", // ext4
"1234-ABCD", // FAT
"deadbeefdeadbeef", // NTFS-ish 16 hex
}
for _, u := range good {
if err := ValidateUUID(u); err != nil {
t.Errorf("ValidateUUID(%q) rejected a valid UUID: %v", u, err)
}
}
bad := []string{
"", // empty
"../../etc/shadow", // traversal
"abcd; rm -rf /", // shell metacharacters
"abcd$(reboot)", // command substitution
"abcd`reboot`", // backticks
"abcd&whoami", // &
"abcd|cat", // pipe
"abcd\nreboot", // newline
"abcd /dev/sda", // space + extra arg
"g00dlooking-but-z-not-hex", // non-hex
"/dev/disk/by-uuid/abcd", // a path, not a uuid
strings.Repeat("a", maxUUIDLen+1), // too long
"abcd\x00", // NUL
}
for _, u := range bad {
if err := ValidateUUID(u); err == nil {
t.Errorf("ValidateUUID(%q) ACCEPTED a hostile UUID", u)
}
}
}
func TestValidateMountPath_AdversarialMatrix(t *testing.T) {
good := []string{"/mnt/usb-backup", "/srv/felhom/bulk", "/mnt/data_1"}
for _, p := range good {
if err := ValidateMountPath(p); err != nil {
t.Errorf("ValidateMountPath(%q) rejected a valid path: %v", p, err)
}
}
bad := []string{
"", // empty
"relative/path", // not absolute
"/", // bare root
"/mnt/../etc", // traversal
"/mnt/./x", // dot segment
"/mnt/usb backup", // space
"/mnt/usb;reboot", // metacharacter
"/mnt/$(reboot)", // command substitution
"/mnt/x\nWhat=/dev/sda", // newline → unit-file injection attempt
"/mnt/x\x00", // NUL
"/mnt/x`reboot`", // backticks
}
for _, p := range bad {
if err := ValidateMountPath(p); err == nil {
t.Errorf("ValidateMountPath(%q) ACCEPTED a hostile path", p)
}
}
}
func TestValidateSMARTDevice_AdversarialMatrix(t *testing.T) {
good := []string{"/dev/sda", "/dev/sdb", "/dev/nvme0n1", "/dev/vda"}
for _, d := range good {
if err := ValidateSMARTDevice(d); err != nil {
t.Errorf("ValidateSMARTDevice(%q) rejected a valid device: %v", d, err)
}
}
bad := []string{
"/dev/sda1", // a partition, not the whole disk (smartctl targets the disk)
"/dev/../etc/shadow", // traversal
"/dev/sda;reboot", // metacharacter
"/dev/sda /dev/sdb", // extra arg
"/etc/passwd", // not /dev
"sda", // no /dev prefix
"/dev/mapper/pve-root", // device-mapper not whitelisted
"", // empty
}
for _, d := range bad {
if err := ValidateSMARTDevice(d); err == nil {
t.Errorf("ValidateSMARTDevice(%q) ACCEPTED a hostile device", d)
}
}
}
func TestValidateLVMName_AdversarialMatrix(t *testing.T) {
for _, n := range []string{"pve", "data", "vg0", "vg.thin_pool"} {
if err := ValidateLVMName(n); err != nil {
t.Errorf("ValidateLVMName(%q) rejected a valid name: %v", n, err)
}
}
for _, n := range []string{"", "-rf", "vg;reboot", "vg/pool extra", "vg\nx", "vg$(x)"} {
if err := ValidateLVMName(n); err == nil {
t.Errorf("ValidateLVMName(%q) ACCEPTED a hostile name", n)
}
}
}
// TestHostOps_RejectsHostileArgsBeforeExec is the proof that validation happens BEFORE any
// command is constructed: a hostile UUID / mount path / device → error AND zero runner calls.
func TestHostOps_RejectsHostileArgsBeforeExec(t *testing.T) {
ctx := context.Background()
t.Run("EnsureMount hostile UUID", func(t *testing.T) {
rr := &recordingRunner{}
ops := newTestHostOps(rr)
err := ops.EnsureMount(ctx, MountSpec{Name: "x", UUID: "abcd; rm -rf /", Where: "/mnt/x"})
if err == nil {
t.Fatal("expected rejection")
}
if len(rr.calls) != 0 {
t.Fatalf("a hostile UUID must be refused before any exec; got calls %v", rr.calls)
}
})
t.Run("EnsureMount traversal mountpoint", func(t *testing.T) {
rr := &recordingRunner{}
ops := newTestHostOps(rr)
err := ops.EnsureMount(ctx, MountSpec{Name: "x", UUID: "1234-ABCD", Where: "/mnt/../etc"})
if err == nil || len(rr.calls) != 0 {
t.Fatalf("traversal mountpoint must be refused before exec; err=%v calls=%v", err, rr.calls)
}
})
t.Run("EnsureMount injection via mount options", func(t *testing.T) {
rr := &recordingRunner{}
ops := newTestHostOps(rr)
err := ops.EnsureMount(ctx, MountSpec{Name: "x", UUID: "1234-ABCD", Where: "/mnt/x", Options: "ro\nWhat=/dev/sda"})
if err == nil || len(rr.calls) != 0 {
t.Fatalf("newline-injecting options must be refused before exec; err=%v calls=%v", err, rr.calls)
}
})
t.Run("SMART hostile device", func(t *testing.T) {
rr := &recordingRunner{}
ops := newTestHostOps(rr)
_, err := ops.SMART(ctx, "/dev/sda;reboot")
if err == nil || len(rr.calls) != 0 {
t.Fatalf("hostile smart device must be refused before exec; err=%v calls=%v", err, rr.calls)
}
})
t.Run("ThinPoolMetadata hostile vg", func(t *testing.T) {
rr := &recordingRunner{}
ops := newTestHostOps(rr)
if _, ok := ops.ThinPoolMetadata(ctx, "vg;reboot", "data"); ok {
t.Fatal("hostile vg must return ok=false")
}
if len(rr.calls) != 0 {
t.Fatalf("hostile vg must be refused before exec; calls=%v", rr.calls)
}
})
}
// newTestHostOps builds a SudoHostOps over a recording runner with a temp stage dir (so the
// EnsureMount staging write — which happens AFTER validation — has somewhere to go in the
// rare valid-path test; hostile-path tests never reach it).
func newTestHostOps(rr proxmox.Runner) *SudoHostOps {
return NewSudoHostOps(SudoHostOpsConfig{
Runner: rr,
UnitDir: "/tmp/felhom-test-units",
StageDir: testStageDir(),
Logger: quietLogger(),
})
}
func TestSystemdEscapePath(t *testing.T) {
cases := map[string]string{
"/mnt/usb-backup": "mnt-usb\\x2dbackup",
"/var/lib/vz": "var-lib-vz",
"/srv/data": "srv-data",
"/": "-",
"/etc/foo.conf": "etc-foo.conf",
}
for in, want := range cases {
if got := systemdEscapePath(in); got != want {
t.Errorf("systemdEscapePath(%q) = %q, want %q", in, got, want)
}
}
// The unit name is derived deterministically and ends in .mount.
name, err := UnitNameForMount("/mnt/usb-backup")
if err != nil || !strings.HasSuffix(name, ".mount") {
t.Errorf("UnitNameForMount = %q, %v", name, err)
}
}