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:
@@ -0,0 +1,259 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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/<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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
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 <dataDir>/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 <device>` 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user