9d6e49236c
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>
210 lines
7.1 KiB
Go
210 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|