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,178 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file is the security boundary for the privileged host surface (slice 5 Phase B).
|
||||
// EVERY argument that will reach a root shell-out is validated HERE, before any command
|
||||
// is constructed — the SudoHostOps methods refuse on a validation error and never build an
|
||||
// arg vector, let alone exec. The adversarial matrix in validate_test.go is the proof that
|
||||
// the "aggressive write side" is not a loose one: shell metacharacters, path traversal, and
|
||||
// malformed inputs are rejected up front. Combined with arg-vector exec (never a shell
|
||||
// string), a validated input cannot inject.
|
||||
|
||||
var (
|
||||
// fs-UUIDs: ext/xfs are 8-4-4-4-12 lowercase hex; FAT/vFAT are "XXXX-XXXX" (upper
|
||||
// hex); others vary. Accept hex groups joined by single hyphens, length-bounded.
|
||||
// This rejects '/', '.', whitespace, and every shell metacharacter by construction.
|
||||
reUUID = regexp.MustCompile(`^[A-Fa-f0-9]{4,}(-[A-Fa-f0-9]+){0,4}$`)
|
||||
|
||||
// SMART device: a strict whitelist of real block-disk patterns under /dev. No
|
||||
// /dev/disk/by-* symlinks, no device-mapper, no traversal — just the raw disks
|
||||
// smartctl is run against. Anything else is refused.
|
||||
reSMARTDevice = regexp.MustCompile(`^/dev/(sd[a-z]+|nvme[0-9]+n[0-9]+|hd[a-z]+|vd[a-z]+)$`)
|
||||
|
||||
// LVM VG / pool names: LVM permits [A-Za-z0-9._+-]; we forbid leading '-' (would look
|
||||
// like a flag) and cap the length.
|
||||
reLVMName = regexp.MustCompile(`^[A-Za-z0-9_+.][A-Za-z0-9_+.-]*$`)
|
||||
|
||||
// A single safe path segment (for mountpoint validation). No metacharacters; "." and
|
||||
// ".." are rejected separately as traversal.
|
||||
rePathSegment = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
)
|
||||
|
||||
const (
|
||||
maxUUIDLen = 40
|
||||
maxPathLen = 255
|
||||
maxLVMLen = 128
|
||||
byUUIDDir = "/dev/disk/by-uuid"
|
||||
maxMountSeg = 32 // a sane cap on mountpoint depth
|
||||
)
|
||||
|
||||
// ValidateUUID accepts a filesystem UUID for use in a by-uuid device path. It is the
|
||||
// load-bearing check (the UUID is the DR re-attach key AND a shell-out argument).
|
||||
func ValidateUUID(uuid string) error {
|
||||
if uuid == "" {
|
||||
return fmt.Errorf("storage: empty UUID")
|
||||
}
|
||||
if len(uuid) > maxUUIDLen {
|
||||
return fmt.Errorf("storage: UUID too long (%d > %d)", len(uuid), maxUUIDLen)
|
||||
}
|
||||
if !reUUID.MatchString(uuid) {
|
||||
return fmt.Errorf("storage: invalid UUID %q (want hex groups, no metacharacters)", uuid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByUUIDDevicePath returns the validated /dev/disk/by-uuid/<uuid> path for a mount unit's
|
||||
// What=. Device paths for mounting are ALWAYS confined to this directory — we never accept
|
||||
// an arbitrary device path from any source.
|
||||
func ByUUIDDevicePath(uuid string) (string, error) {
|
||||
if err := ValidateUUID(uuid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return byUUIDDir + "/" + uuid, nil
|
||||
}
|
||||
|
||||
// ValidateMountPath accepts an absolute mountpoint with no traversal and no metacharacters.
|
||||
// Each segment must be a safe token; "." / ".." segments are rejected; the bare root "/"
|
||||
// is rejected (we never manage a mount at root).
|
||||
func ValidateMountPath(path string) error {
|
||||
if path == "" || path[0] != '/' {
|
||||
return fmt.Errorf("storage: mount path must be absolute, got %q", path)
|
||||
}
|
||||
if len(path) > maxPathLen {
|
||||
return fmt.Errorf("storage: mount path too long (%d > %d)", len(path), maxPathLen)
|
||||
}
|
||||
if strings.ContainsAny(path, "\x00\n\r\t") {
|
||||
return fmt.Errorf("storage: mount path contains control characters")
|
||||
}
|
||||
segs := nonEmptySegments(path)
|
||||
if len(segs) == 0 {
|
||||
return fmt.Errorf("storage: refusing to manage a mount at %q", path)
|
||||
}
|
||||
if len(segs) > maxMountSeg {
|
||||
return fmt.Errorf("storage: mount path too deep")
|
||||
}
|
||||
for _, s := range segs {
|
||||
if s == "." || s == ".." {
|
||||
return fmt.Errorf("storage: mount path traversal segment %q in %q", s, path)
|
||||
}
|
||||
if !rePathSegment.MatchString(s) {
|
||||
return fmt.Errorf("storage: invalid mount path segment %q in %q", s, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSMARTDevice accepts only a raw block-disk path (sdX/nvmeXnY/hdX/vdX) under /dev.
|
||||
func ValidateSMARTDevice(device string) error {
|
||||
if !reSMARTDevice.MatchString(device) {
|
||||
return fmt.Errorf("storage: refusing smartctl on non-whitelisted device %q", device)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateLVMName accepts an LVM VG or LV (pool) name.
|
||||
func ValidateLVMName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("storage: empty LVM name")
|
||||
}
|
||||
if len(name) > maxLVMLen {
|
||||
return fmt.Errorf("storage: LVM name too long")
|
||||
}
|
||||
if !reLVMName.MatchString(name) {
|
||||
return fmt.Errorf("storage: invalid LVM name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnitNameForMount returns the systemd .mount unit name for a (validated) mountpoint. A
|
||||
// .mount unit's name MUST be the systemd-escaped mountpoint — this is computed
|
||||
// deterministically from the already-validated path, so the result is inherently safe to
|
||||
// pass in an arg vector (no shell).
|
||||
func UnitNameForMount(where string) (string, error) {
|
||||
if err := ValidateMountPath(where); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return systemdEscapePath(where) + ".mount", nil
|
||||
}
|
||||
|
||||
// nonEmptySegments splits a path on '/', dropping empties (so "//a///b/" → [a b]).
|
||||
func nonEmptySegments(path string) []string {
|
||||
parts := strings.Split(path, "/")
|
||||
out := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// systemdEscapePath replicates `systemd-escape --path`: strip leading/trailing slashes and
|
||||
// collapse internal repeats, then escape each char — '/' → '-', alnum/'_' kept, '.' kept
|
||||
// (except a leading '.'), everything else (including a literal '-') → '\xNN'. The empty
|
||||
// path / "/" escapes to "-". Computed in-process so no `systemd-escape` shell-out / sudoers
|
||||
// entry is needed.
|
||||
func systemdEscapePath(path string) string {
|
||||
segs := nonEmptySegments(path)
|
||||
if len(segs) == 0 {
|
||||
return "-"
|
||||
}
|
||||
joined := strings.Join(segs, "/")
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(joined); i++ {
|
||||
c := joined[i]
|
||||
switch {
|
||||
case c == '/':
|
||||
b.WriteByte('-')
|
||||
case i == 0 && c == '.':
|
||||
b.WriteString(`\x2e`)
|
||||
case isAlnum(c) || c == '_':
|
||||
b.WriteByte(c)
|
||||
case c == '.':
|
||||
b.WriteByte('.')
|
||||
default:
|
||||
fmt.Fprintf(&b, `\x%02x`, c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isAlnum(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
}
|
||||
Reference in New Issue
Block a user