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>
310 lines
11 KiB
Go
310 lines
11 KiB
Go
// Package config loads the felhom-agent configuration the proxmox layer needs.
|
|
//
|
|
// Format: a JSON file (stdlib-only — no YAML dep, consistent with the agent's
|
|
// "pure stdlib" constraint), with per-field environment overrides. Secrets (the
|
|
// API token) are never logged; see Config.Redacted.
|
|
//
|
|
// OPEN item (noted in the slice reply): the controller/hub use YAML; if matching
|
|
// that house style is preferred over the zero-dependency constraint, the loader
|
|
// can swap to yaml.v3 without touching call sites.
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config is the agent configuration.
|
|
type Config struct {
|
|
Proxmox ProxmoxConfig `json:"proxmox"`
|
|
Privileged PrivilegedConfig `json:"privileged"`
|
|
Authz AuthzConfig `json:"authz"`
|
|
Hub HubConfig `json:"hub"`
|
|
Storage StorageConfig `json:"storage"`
|
|
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
|
|
}
|
|
|
|
// StorageConfig tunes the storage watchdog (slice 5). All optional — zero values fall back
|
|
// to the storage package defaults via the accessor methods. The watchdog poll is FAST
|
|
// (seconds) to catch a USB drop quickly; the debounce keeps a flapping drive from storming
|
|
// the hub; the known-set refresh bounds how often the watchdog re-derives the target set
|
|
// from the Proxmox API (liveness is probed every poll regardless).
|
|
type StorageConfig struct {
|
|
WatchdogIntervalSeconds int `json:"watchdog_interval_seconds"`
|
|
WatchdogDebounceSeconds int `json:"watchdog_debounce_seconds"`
|
|
KnownRefreshSeconds int `json:"known_refresh_seconds"`
|
|
}
|
|
|
|
// WatchdogInterval returns the configured poll interval (0 = package default).
|
|
func (s StorageConfig) WatchdogInterval() time.Duration {
|
|
return time.Duration(s.WatchdogIntervalSeconds) * time.Second
|
|
}
|
|
|
|
// WatchdogDebounce returns the configured debounce window (0 = package default).
|
|
func (s StorageConfig) WatchdogDebounce() time.Duration {
|
|
return time.Duration(s.WatchdogDebounceSeconds) * time.Second
|
|
}
|
|
|
|
// KnownRefresh returns the configured known-set refresh TTL (0 = package default).
|
|
func (s StorageConfig) KnownRefresh() time.Duration {
|
|
return time.Duration(s.KnownRefreshSeconds) * time.Second
|
|
}
|
|
|
|
// HubConfig configures the outbound hub client + daemon poll loop (internal/hub).
|
|
// The hub serves a real cert (hub.felhom.eu, cert-manager) — this is standard TLS
|
|
// (system roots), NOT the Proxmox fingerprint-pinning path.
|
|
type HubConfig struct {
|
|
URL string `json:"url"` // e.g. "https://hub.felhom.eu"
|
|
HostID string `json:"host_id"` // the hub's PK for this host
|
|
APIKey string `json:"api_key"` // per-host hub key; SECRET — redacted
|
|
PollSeconds int `json:"poll_seconds"` // default 900; hub may override per-cycle
|
|
TimeoutSeconds int `json:"timeout_seconds"` // per-request HTTP timeout; default 30
|
|
CAFile string `json:"ca_file"` // optional; "" = system roots
|
|
}
|
|
|
|
// AuthzConfig configures operator-signed-op verification (internal/authz). The
|
|
// pinned operator public keys are kept here as raw authorized_keys-style lines
|
|
// (this package stays dependency-free); the authz package parses them into its
|
|
// AllowedSigner set. Role-scoping (recovery keys authorize only key-rotation) is
|
|
// enforced by the consuming layer, not loaded here.
|
|
type AuthzConfig struct {
|
|
// NonceStorePath is the durable, crash-safe nonce log (anti-replay). Must be on
|
|
// persistent host storage so replay protection survives agent restarts.
|
|
NonceStorePath string `json:"nonce_store_path"`
|
|
// Signers are the pinned operator public keys (doc 04 §3 two-key model).
|
|
Signers []SignerKey `json:"signers"`
|
|
}
|
|
|
|
// SignerKey is one pinned operator public key.
|
|
type SignerKey struct {
|
|
KeyID string `json:"key_id"`
|
|
// Role is "operational" (signs destructive ops) or "recovery" (cold key;
|
|
// authorizes only key-rotation/break-glass).
|
|
Role string `json:"role"`
|
|
// PublicKey is a standard authorized_keys line, e.g.
|
|
// "ssh-ed25519 AAAA… felhom-op-1" or "sk-ssh-ed25519@openssh.com AAAA… …".
|
|
PublicKey string `json:"public_key"`
|
|
}
|
|
|
|
// ProxmoxConfig configures the API client.
|
|
type ProxmoxConfig struct {
|
|
// Endpoint defaults to https://127.0.0.1:8006 (agent runs on the host).
|
|
Endpoint string `json:"endpoint"`
|
|
// Node is the Proxmox node name; confirm on the box (GET /nodes).
|
|
Node string `json:"node"`
|
|
// Token is the full API token "USER@REALM!TOKENID=SECRET".
|
|
//
|
|
// Provisioning note: this is a privilege-SEPARATED token. Its role
|
|
// (FelhomAgent, 16 privileges) must be granted on BOTH the user AND the token
|
|
// for the same path, or the intersection is empty and every call 403s
|
|
// (phase1-2 §1.2). Role setup is out-of-band; the agent only consumes the token.
|
|
Token string `json:"token"`
|
|
// TLS trust to the host's (self-signed) cert.
|
|
TLS TLSTrust `json:"tls"`
|
|
}
|
|
|
|
// TLSTrust mirrors proxmox.TLSConfig (kept dependency-free here).
|
|
type TLSTrust struct {
|
|
CAFile string `json:"ca_file"`
|
|
Fingerprint string `json:"fingerprint"` // SHA-256 of the host leaf cert
|
|
InsecureSkipVerify bool `json:"insecure_skip_verify"` // off by default; selftest-only
|
|
}
|
|
|
|
// PrivilegedConfig configures the fenced root-CLI runner and the slice-5 HostOps surface
|
|
// (systemd mount units + smartctl + lvs). The binary paths must match the sudoers allowlist
|
|
// exactly (see configs/felhom-agent.sudoers).
|
|
type PrivilegedConfig struct {
|
|
// Mode: "sudo" (default — non-root agent + narrow sudoers) or "direct".
|
|
Mode string `json:"mode"`
|
|
// SudoPath overrides the sudo binary (default "sudo").
|
|
SudoPath string `json:"sudo_path"`
|
|
|
|
// HostOps (slice 5 Phase B) — the privileged storage write/read surface.
|
|
UnitDir string `json:"unit_dir"` // where enabled .mount units live (default /etc/systemd/system)
|
|
StageDir string `json:"stage_dir"` // agent-owned staging dir for unit files (default /var/lib/felhom-agent/units)
|
|
Systemctl string `json:"systemctl"` // default /usr/bin/systemctl
|
|
Install string `json:"install"` // default /usr/bin/install
|
|
Smartctl string `json:"smartctl"` // default /usr/sbin/smartctl
|
|
Lvs string `json:"lvs"` // default /usr/sbin/lvs
|
|
}
|
|
|
|
// Default returns a Config pre-populated with sane defaults.
|
|
func Default() Config {
|
|
return Config{
|
|
Proxmox: ProxmoxConfig{Endpoint: "https://127.0.0.1:8006"},
|
|
Privileged: PrivilegedConfig{Mode: "sudo"},
|
|
Authz: AuthzConfig{NonceStorePath: "/var/lib/felhom-agent/nonces.log"},
|
|
Hub: HubConfig{PollSeconds: 900, TimeoutSeconds: 30},
|
|
LogLevel: "info",
|
|
}
|
|
}
|
|
|
|
// Load reads the config file at path (if non-empty) over the defaults, then
|
|
// applies environment overrides. A missing path with all-env config is allowed.
|
|
func Load(path string) (Config, error) {
|
|
cfg := Default()
|
|
if path != "" {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return cfg, fmt.Errorf("config: reading %s: %w", path, err)
|
|
}
|
|
if err := json.Unmarshal(b, &cfg); err != nil {
|
|
return cfg, fmt.Errorf("config: parsing %s: %w", path, err)
|
|
}
|
|
}
|
|
applyEnv(&cfg)
|
|
return cfg, nil
|
|
}
|
|
|
|
// applyEnv overlays FELHOM_AGENT_* environment variables. Useful for the token in
|
|
// particular (keep the secret out of the file on disk if desired).
|
|
func applyEnv(cfg *Config) {
|
|
if v := os.Getenv("FELHOM_AGENT_PROXMOX_ENDPOINT"); v != "" {
|
|
cfg.Proxmox.Endpoint = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_PROXMOX_NODE"); v != "" {
|
|
cfg.Proxmox.Node = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TOKEN"); v != "" {
|
|
cfg.Proxmox.Token = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TLS_CA_FILE"); v != "" {
|
|
cfg.Proxmox.TLS.CAFile = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TLS_FINGERPRINT"); v != "" {
|
|
cfg.Proxmox.TLS.Fingerprint = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TLS_INSECURE"); v != "" {
|
|
if b, err := strconv.ParseBool(v); err == nil {
|
|
cfg.Proxmox.TLS.InsecureSkipVerify = b
|
|
}
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_LOG_LEVEL"); v != "" {
|
|
cfg.LogLevel = v
|
|
}
|
|
// hub
|
|
if v := os.Getenv("FELHOM_AGENT_HUB_URL"); v != "" {
|
|
cfg.Hub.URL = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_HUB_HOST_ID"); v != "" {
|
|
cfg.Hub.HostID = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_HUB_API_KEY"); v != "" {
|
|
cfg.Hub.APIKey = v
|
|
}
|
|
if v := os.Getenv("FELHOM_AGENT_HUB_CA_FILE"); v != "" {
|
|
cfg.Hub.CAFile = v
|
|
}
|
|
cfg.Hub.PollSeconds = envInt("FELHOM_AGENT_HUB_POLL_SECONDS", cfg.Hub.PollSeconds)
|
|
cfg.Hub.TimeoutSeconds = envInt("FELHOM_AGENT_HUB_TIMEOUT_SECONDS", cfg.Hub.TimeoutSeconds)
|
|
}
|
|
|
|
// envInt overlays an int env var, keeping cur (with a stderr warning) on parse
|
|
// error rather than crashing. (Load runs before the slog logger exists.)
|
|
func envInt(key string, cur int) int {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return cur
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "config: %s=%q is not an integer, keeping %d\n", key, v, cur)
|
|
return cur
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Validate checks the config is usable for talking to the API.
|
|
func (c Config) Validate() error {
|
|
if c.Proxmox.Endpoint == "" {
|
|
return fmt.Errorf("config: proxmox.endpoint is required")
|
|
}
|
|
if c.Proxmox.Node == "" {
|
|
return fmt.Errorf("config: proxmox.node is required (confirm with `pvesh get /nodes`)")
|
|
}
|
|
if c.Proxmox.Token == "" {
|
|
return fmt.Errorf("config: proxmox.token is required (set proxmox.token or FELHOM_AGENT_PROXMOX_TOKEN)")
|
|
}
|
|
if !strings.Contains(c.Proxmox.Token, "!") || !strings.Contains(c.Proxmox.Token, "=") {
|
|
return fmt.Errorf("config: proxmox.token must be USER@REALM!TOKENID=SECRET")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Redacted returns a copy safe to log: the proxmox token and hub key are masked.
|
|
func (c Config) Redacted() Config {
|
|
if c.Proxmox.Token != "" {
|
|
c.Proxmox.Token = redactToken(c.Proxmox.Token)
|
|
}
|
|
if c.Hub.APIKey != "" {
|
|
c.Hub.APIKey = "********"
|
|
}
|
|
return c
|
|
}
|
|
|
|
// WithDefaults fills zero-valued hub timing fields. Applied at client/loop
|
|
// construction so programmatic configs (not from Default()) still get sane values.
|
|
func (h HubConfig) WithDefaults() HubConfig {
|
|
if h.PollSeconds == 0 {
|
|
h.PollSeconds = 900
|
|
}
|
|
if h.TimeoutSeconds == 0 {
|
|
h.TimeoutSeconds = 30
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Validate checks the hub config is usable for the daemon / --selftest=hub. It is
|
|
// separate from Config.Validate (proxmox-only) so --selftest=read|task still runs
|
|
// without hub config.
|
|
func (h HubConfig) Validate() error {
|
|
if h.URL == "" {
|
|
return fmt.Errorf("config: hub.url is required (set hub.url or FELHOM_AGENT_HUB_URL)")
|
|
}
|
|
if h.HostID == "" {
|
|
return fmt.Errorf("config: hub.host_id is required")
|
|
}
|
|
if h.APIKey == "" {
|
|
return fmt.Errorf("config: hub.api_key is required (set hub.api_key or FELHOM_AGENT_HUB_API_KEY)")
|
|
}
|
|
u, err := url.Parse(h.URL)
|
|
if err != nil {
|
|
return fmt.Errorf("config: hub.url is not a valid URL: %w", err)
|
|
}
|
|
switch u.Scheme {
|
|
case "https":
|
|
// always fine
|
|
case "http":
|
|
if !isLoopbackHost(u.Hostname()) {
|
|
return fmt.Errorf("config: hub.url must be https:// (http:// only allowed for loopback in tests)")
|
|
}
|
|
default:
|
|
return fmt.Errorf("config: hub.url must be https:// (got scheme %q)", u.Scheme)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
if host == "localhost" {
|
|
return true
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return ip.IsLoopback()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// redactToken keeps the public "USER@REALM!TOKENID=" prefix and masks the secret.
|
|
func redactToken(tok string) string {
|
|
if i := strings.LastIndex(tok, "="); i >= 0 {
|
|
return tok[:i+1] + "********"
|
|
}
|
|
return "********"
|
|
}
|