package storage import ( "context" "encoding/json" "fmt" "log/slog" "os" "path/filepath" "strings" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // HostOps is the narrow privileged host surface (slice 5 Phase B) — the ONE place the // agent steps outside its Proxmox API token into OS-root. Production is *SudoHostOps // (shells out via a sudoers allowlist, arg vectors, no shell); tests use a fake, so NO // real root runs in the test suite. // // Every method validates its arguments (validate.go) before constructing a command. The // surface is deliberately tiny: persistent mounts (systemd .mount units keyed by fs-UUID), // detach (stop+disable the unit), SMART, and thin-pool metadata. type HostOps interface { // EnsureMount writes + enables a systemd .mount unit for spec (idempotent: re-applying // an existing target is a no-op). Benign — additive, no signature. EnsureMount(ctx context.Context, spec MountSpec) error // Unmount stops + disables the .mount unit for a mountpoint (detach from service). This // is DESTRUCTIVE (deliberately removing a target) — the caller MUST have routed it // through the gate first; HostOps only performs an already-authorized op. Unmount(ctx context.Context, where string) error // SMART returns a parsed health summary for a raw block device, degrading to // {Health: UNKNOWN} when the device exposes no SMART (e.g. a USB-SATA bridge). SMART(ctx context.Context, device string) (hub.SmartSummary, error) // ThinPoolMetadata returns the lvmthin pool's metadata-used fraction (0..1) via lvs. // ok=false when it cannot be read (the field stays null in the report). ThinPoolMetadata(ctx context.Context, vg, pool string) (fraction float64, ok bool) // InspectDevice probes a block device for data-bearing evidence (filesystem signature, // partition table, partitions, mounted) — the AGENT-INTERNAL evidence the 8C classifier // uses, NEVER the caller's claim. Conservative: a failed/ambiguous probe → DataBearing() // true (fail-safe). This is the read that decides whether a format is benign or destructive. InspectDevice(ctx context.Context, device string) (DeviceProbe, error) // Format runs mkfs. on a (validated) device. DESTRUCTIVE to whatever is on the // device — the caller MUST have classified it non-data-bearing AND/OR routed it through the // gate first; HostOps only performs an already-authorized format. Format(ctx context.Context, device, fstype string) error } // DeviceProbe is the result of inspecting a block device for data-bearing evidence (8C). The // agent decides data-bearing-ness from THIS (its own device read), never from the caller's claim. type DeviceProbe struct { Device string `json:"device"` Probed bool `json:"probed"` // false = the probe failed/was ambiguous → treat as data-bearing HasFilesystem bool `json:"has_filesystem"` // a filesystem signature (blkid TYPE / USAGE) HasPartitionTable bool `json:"has_partition_table"` // a partition table (blkid PTTYPE) HasPartitions bool `json:"has_partitions"` // child partitions present (lsblk) Mounted bool `json:"mounted"` // currently mounted somewhere FSType string `json:"fstype,omitempty"` } // DataBearing is the conservative verdict: any signature / partition table / partition / mount — // OR a probe that did not complete cleanly — makes the device data-bearing. Only a device that // probed cleanly AND shows none of those is considered blank (benign to format). func (p DeviceProbe) DataBearing() bool { if !p.Probed { return true // fail-safe: never call an unprobed device blank } return p.HasFilesystem || p.HasPartitionTable || p.HasPartitions || p.Mounted } // Reason returns a short human string for why the device is data-bearing (for the UI/audit). func (p DeviceProbe) Reason() string { switch { case !p.Probed: return "device could not be reliably inspected" case p.Mounted: return "device is mounted" case p.HasFilesystem: return "device has a " + p.FSType + " filesystem" case p.HasPartitionTable: return "device has a partition table" case p.HasPartitions: return "device has partitions" default: return "device is blank" } } // MountSpec describes a persistent by-UUID mount. type MountSpec struct { Name string // storage name (unit Description only) UUID string // filesystem UUID — validated; becomes What=/dev/disk/by-uuid/ Where string // mountpoint — validated; the unit name is derived from it FSType string // optional Type= Options string // optional Options= } // Binaries holds the absolute paths of the allow-listed binaries (overridable from config // so the sudoers entries and the agent agree on exact paths). type Binaries struct { Systemctl string Install string Smartctl string Lvs string Blkid string // device signature probe (8C data-bearing detection) Lsblk string // partition/mount topology (8C) MkfsExt4 string // 8C format executor (ext4) MkfsXfs string // 8C format executor (xfs) } func (b Binaries) withDefaults() Binaries { if b.Systemctl == "" { b.Systemctl = "/usr/bin/systemctl" } if b.Install == "" { b.Install = "/usr/bin/install" } if b.Smartctl == "" { b.Smartctl = "/usr/sbin/smartctl" } if b.Lvs == "" { b.Lvs = "/usr/sbin/lvs" } if b.Blkid == "" { b.Blkid = "/usr/sbin/blkid" } if b.Lsblk == "" { b.Lsblk = "/usr/bin/lsblk" } if b.MkfsExt4 == "" { b.MkfsExt4 = "/usr/sbin/mkfs.ext4" } if b.MkfsXfs == "" { b.MkfsXfs = "/usr/sbin/mkfs.xfs" } return b } // SudoHostOps is the production HostOps: it stages a unit file the agent owns, then uses // the sudoers allowlist (`install` it into the unit dir, `systemctl` to manage it, // `smartctl`/`lvs` to read). The Runner execs with an arg vector (no shell) — see // proxmox.ExecRunner in RunnerSudo mode. type SudoHostOps struct { runner proxmox.Runner bins Binaries unitDir string // where enabled units live (e.g. /etc/systemd/system) stageDir string // agent-owned staging dir for unit files before install logger *slog.Logger } // SudoHostOpsConfig configures a SudoHostOps. type SudoHostOpsConfig struct { Runner proxmox.Runner Bins Binaries UnitDir string // default /etc/systemd/system StageDir string // default /units; must be agent-writable Logger *slog.Logger } // NewSudoHostOps builds the production privileged surface. func NewSudoHostOps(cfg SudoHostOpsConfig) *SudoHostOps { unitDir := cfg.UnitDir if unitDir == "" { unitDir = "/etc/systemd/system" } stageDir := cfg.StageDir if stageDir == "" { stageDir = "/var/lib/felhom-agent/units" } logger := cfg.Logger if logger == nil { logger = slog.Default() } return &SudoHostOps{ runner: cfg.Runner, bins: cfg.Bins.withDefaults(), unitDir: unitDir, stageDir: stageDir, logger: logger, } } // EnsureMount validates, renders, stages, installs and enables the .mount unit. func (h *SudoHostOps) EnsureMount(ctx context.Context, spec MountSpec) error { // VALIDATE FIRST — refuse before constructing any command. if err := ValidateUUID(spec.UUID); err != nil { return err } if err := ValidateMountPath(spec.Where); err != nil { return err } if err := validateUnitOpt(spec.FSType); err != nil { return fmt.Errorf("storage: fstype: %w", err) } if err := validateUnitOpt(spec.Options); err != nil { return fmt.Errorf("storage: mount options: %w", err) } unitName, err := UnitNameForMount(spec.Where) if err != nil { return err } content := renderMountUnit(spec) // uses the validated fields only // Stage the unit file (agent-owned dir; no root needed for this write). if err := os.MkdirAll(h.stageDir, 0o700); err != nil { return fmt.Errorf("storage: staging dir: %w", err) } stagePath := filepath.Join(h.stageDir, unitName) if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil { return fmt.Errorf("storage: staging unit: %w", err) } dest := filepath.Join(h.unitDir, unitName) // install as root (atomic copy with fixed mode/owner) — fixed arg vector. if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil { return fmt.Errorf("storage: installing unit %s: %w", unitName, err) } if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil { return fmt.Errorf("storage: daemon-reload: %w", err) } // enable --now both mounts now and persists across reboot. Idempotent. if err := h.run(ctx, h.bins.Systemctl, "enable", "--now", "--", unitName); err != nil { return fmt.Errorf("storage: enabling mount %s: %w", unitName, err) } h.logger.Info("storage: ensured mount", "name", spec.Name, "where", spec.Where, "unit", unitName) return nil } // Unmount stops + disables the unit (detach). The caller is responsible for authorization. func (h *SudoHostOps) Unmount(ctx context.Context, where string) error { unitName, err := UnitNameForMount(where) if err != nil { return err } if err := h.run(ctx, h.bins.Systemctl, "stop", "--", unitName); err != nil { return fmt.Errorf("storage: stopping mount %s: %w", unitName, err) } if err := h.run(ctx, h.bins.Systemctl, "disable", "--", unitName); err != nil { return fmt.Errorf("storage: disabling mount %s: %w", unitName, err) } h.logger.Info("storage: unmounted (detached)", "where", where, "unit", unitName) return nil } // SMART runs `smartctl -a -j ` and parses the JSON. func (h *SudoHostOps) SMART(ctx context.Context, device string) (hub.SmartSummary, error) { if err := ValidateSMARTDevice(device); err != nil { return hub.SmartSummary{Health: hub.SmartUnknown}, err } out, stderr, err := h.runner.Run(ctx, h.bins.Smartctl, "-a", "-j", device) if err != nil && len(out) == 0 { // smartctl uses a nonzero exit bitmask even on success; only treat empty output // as a hard failure. A device with no SMART → degrade to UNKNOWN, not an error. return hub.SmartSummary{Health: hub.SmartUnknown}, fmt.Errorf("storage: smartctl %s: %w: %s", device, err, trim(stderr)) } return parseSMART(out), nil } // ThinPoolMetadata runs `lvs` for the pool and returns its metadata-used fraction. func (h *SudoHostOps) ThinPoolMetadata(ctx context.Context, vg, pool string) (float64, bool) { if err := ValidateLVMName(vg); err != nil { h.logger.Warn("storage: refusing lvs on invalid vg", "vg", vg, "err", err) return 0, false } if err := ValidateLVMName(pool); err != nil { h.logger.Warn("storage: refusing lvs on invalid pool", "pool", pool, "err", err) return 0, false } // --reportformat json, metadata_percent for the specific LV. lv path "vg/pool". out, stderr, err := h.runner.Run(ctx, h.bins.Lvs, "--reportformat", "json", "--units", "b", "-o", "lv_name,data_percent,metadata_percent", "--", vg+"/"+pool) if err != nil && len(out) == 0 { h.logger.Warn("storage: lvs failed", "vg", vg, "pool", pool, "err", err, "stderr", trim(stderr)) return 0, false } return parseThinPoolMetadata(out) } // InspectDevice probes a device for data-bearing evidence (8C). It runs `blkid -p -o export` // (the reliable signature probe) for filesystem/partition-table signatures and `lsblk -J` for // child partitions + mount state. The verdict defaults to data-bearing on ANY read failure // (Probed=false), so a compromised caller cannot get a data-bearing device declared blank. func (h *SudoHostOps) InspectDevice(ctx context.Context, device string) (DeviceProbe, error) { if err := ValidateBlockDevice(device); err != nil { return DeviceProbe{Device: device}, err // Probed=false → DataBearing()=true } probe := DeviceProbe{Device: device} // blkid -p -o export is the authoritative on-disk SIGNATURE probe. Its OUTPUT is the signal: // any TYPE/PTTYPE/USAGE line is positive data-bearing evidence. Its exit code is NOT relied // on (blkid exits 2 on a blank device) — output presence is what matters. A broken/empty // blkid simply adds no positive evidence; lsblk (below) is the read-success authority. bout, _, _ := h.runner.Run(ctx, h.bins.Blkid, "-p", "-o", "export", device) for k, v := range parseBlkidExport(bout) { switch k { case "TYPE": probe.HasFilesystem = true probe.FSType = v case "PTTYPE": probe.HasPartitionTable = true case "USAGE": if v != "" { probe.HasFilesystem = true // filesystem/raid/crypto member = data-bearing } } } // lsblk -J is the READ-SUCCESS authority + the partition/mount view. It exits 0 on any valid // device (blank or not), so a clean parse means the agent reliably read the device. If lsblk // fails, Probed stays false → DataBearing()=true (fail-safe — never call a device blank on a // failed read). lout, _, lerr := h.runner.Run(ctx, h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", device) if lerr == nil { probe.Probed = true hasChildren, mounted, fstype, pttype := parseLsblkDevice(lout) if hasChildren { probe.HasPartitions = true } if mounted { probe.Mounted = true } if fstype != "" { probe.HasFilesystem = true if probe.FSType == "" { probe.FSType = fstype } } if pttype != "" { probe.HasPartitionTable = true } } return probe, nil } // Format runs mkfs. on a validated device. The caller is responsible for authorization // (8C: only after classifying the device non-data-bearing, or via a slice-10 operator signature). func (h *SudoHostOps) Format(ctx context.Context, device, fstype string) error { if err := ValidateBlockDevice(device); err != nil { return err } if err := ValidateFSType(fstype); err != nil { return err } switch fstype { case "ext4": if err := h.run(ctx, h.bins.MkfsExt4, "-F", device); err != nil { return fmt.Errorf("storage: mkfs.ext4 %s: %w", device, err) } case "xfs": if err := h.run(ctx, h.bins.MkfsXfs, "-f", device); err != nil { return fmt.Errorf("storage: mkfs.xfs %s: %w", device, err) } default: return fmt.Errorf("storage: unsupported fstype %q", fstype) // unreachable after Validate } h.logger.Info("storage: formatted device", "device", device, "fstype", fstype) return nil } // run execs an allow-listed command with a fixed arg vector and wraps a nonzero exit. func (h *SudoHostOps) run(ctx context.Context, name string, args ...string) error { _, stderr, err := h.runner.Run(ctx, name, args...) if err != nil { return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, trim(stderr)) } return nil } // validateUnitOpt rejects metacharacters / newlines in an optional unit value (FSType / // Options) so a crafted value can't inject extra directives into the unit file. Empty is OK. func validateUnitOpt(v string) error { if v == "" { return nil } if strings.ContainsAny(v, "\n\r\x00[]=") { return fmt.Errorf("storage: value %q contains forbidden characters", v) } return nil } func trim(b []byte) string { s := strings.TrimSpace(string(b)) if len(s) > 300 { return s[:300] + "…" } return s } // parseBlkidExport parses `blkid -p -o export` output (KEY=value lines) into a map. func parseBlkidExport(out []byte) map[string]string { m := map[string]string{} for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if i := strings.IndexByte(line, '='); i > 0 { m[line[:i]] = line[i+1:] } } return m } // lsblkDevice mirrors the `lsblk -J` device shape (only the fields we read). type lsblkDevice struct { Name string `json:"name"` FSType string `json:"fstype"` PTType string `json:"pttype"` MountPoint string `json:"mountpoint"` Children []lsblkDevice `json:"children"` } // parseLsblkDevice parses `lsblk -J -o NAME,FSTYPE,PTTYPE,MOUNTPOINT ` for the top device: // whether it has child partitions, is mounted (itself or any child), and its fstype/pttype. func parseLsblkDevice(out []byte) (hasChildren, mounted bool, fstype, pttype string) { var doc struct { BlockDevices []lsblkDevice `json:"blockdevices"` } if json.Unmarshal(out, &doc) != nil || len(doc.BlockDevices) == 0 { return false, false, "", "" } d := doc.BlockDevices[0] fstype, pttype = d.FSType, d.PTType hasChildren = len(d.Children) > 0 mounted = d.MountPoint != "" for _, c := range d.Children { if c.MountPoint != "" { mounted = true } } return hasChildren, mounted, fstype, pttype } // NoopHostOps is the safe fallback when the privileged surface is unavailable or declined // (a missing sudoers entry must degrade with a clear warning, not crash — slice notes). It // reports SMART as UNKNOWN, no thin-pool metadata, and errors on any write (so a benign // re-mount logs a clear failure rather than silently "succeeding"). type NoopHostOps struct{ Logger *slog.Logger } func (n NoopHostOps) EnsureMount(context.Context, MountSpec) error { return fmt.Errorf("storage: privileged HostOps not configured; cannot mount") } func (n NoopHostOps) Unmount(context.Context, string) error { return fmt.Errorf("storage: privileged HostOps not configured; cannot unmount") } func (n NoopHostOps) SMART(context.Context, string) (hub.SmartSummary, error) { return hub.SmartSummary{Health: hub.SmartUnknown}, nil } func (n NoopHostOps) ThinPoolMetadata(context.Context, string, string) (float64, bool) { return 0, false } func (n NoopHostOps) InspectDevice(_ context.Context, device string) (DeviceProbe, error) { // Probed=false → DataBearing()=true: with no privileged surface we MUST NOT call any device // blank (fail-safe — a format would then be refused as destructive). return DeviceProbe{Device: device}, nil } func (n NoopHostOps) Format(context.Context, string, string) error { return fmt.Errorf("storage: privileged HostOps not configured; cannot format") }