Files
felhom-agent/internal/config/config.go
T
admin b527430ec7 v0.6.0-rc1: slice 6 Phase A — backup + the self-restore-test (local target)
The guest-level backup layer + the journaled self-restore-test (restore→boot→verify→
teardown) that closes "a backup you haven't restored isn't a backup". All benign
(reuses the slice-4 classifier/gate/journal; no new destructive class/crypto). Local
target only; PBS = Phase B. Restore to a NEW guest only. Backups crash-consistent.

- proxmox: DestroyLXC, VzdumpOptions.Notes (notes-template), LatestBackupVolID.
- reconcile: Engine.RunRestoreTest (journal Scratch entry BEFORE mutation; net link-down
  pre-boot; defer teardown always; benign gated destroy) + Recover extended to reap a
  leaked scratch guest (Scratch flag, special-cased before the UPID path; idempotent).
- internal/backup: runner (vzdump + archive resolve + bulk-gap = backup!=1) + cadence
  scheduler (4th daemon goroutine, default 24h) + in-memory report store.
- hub: Backup/RestoreTest filled; collector seams; cross-repo golden byte-identical +
  bidirectional key-set tests; hub handler logs a FAILED restore-test prominently.
- config BackupConfig (band 990000-990009 default); --selftest=backup / restore-test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:49:39 +02:00

382 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"`
Backup BackupConfig `json:"backup"`
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
}
// BackupConfig tunes the slice-6 backup + self-restore-test layer. The restore-test runs on
// an agent-internal cadence (no hub policy needed — it's self-validation); the backup
// schedule/retention/target-selection policy is hub-manifest-owned and unfed until slice 10.
type BackupConfig struct {
// LocalBackupTarget is the vzdump storage (content=backup) backups go to, e.g. "local".
LocalBackupTarget string `json:"local_backup_target"`
// RestoreStorage is where a restore-test's restored rootfs lands, e.g. "local-lvm".
RestoreStorage string `json:"restore_storage"`
// RestoreTestCadenceSeconds is the self-restore-test interval; 0 → default (24h).
// Set negative to DISABLE the automatic cadence (on-demand selftest still works).
RestoreTestCadenceSeconds int `json:"restore_test_cadence_seconds"`
// ScratchVMIDMin/Max bound the throwaway restore-test scratch-guest VMID band. The
// restore-test refuses to run unless this is a valid band (min>0, max>=min); 9999 is
// always excluded. Defaults to 990000990009.
ScratchVMIDMin int `json:"scratch_vmid_min"`
ScratchVMIDMax int `json:"scratch_vmid_max"`
}
// Default scratch VMID band + restore-test cadence.
const (
defaultScratchVMIDMin = 990000
defaultScratchVMIDMax = 990009
defaultRestoreTestCadence = 24 * time.Hour
)
// RestoreTestCadence returns the configured restore-test interval: a positive value as-is,
// 0 → 24h default, negative → 0 (disabled).
func (b BackupConfig) RestoreTestCadence() time.Duration {
switch {
case b.RestoreTestCadenceSeconds > 0:
return time.Duration(b.RestoreTestCadenceSeconds) * time.Second
case b.RestoreTestCadenceSeconds < 0:
return 0 // disabled
default:
return defaultRestoreTestCadence
}
}
// ScratchBand returns the effective [min,max] scratch VMID band (defaults applied).
func (b BackupConfig) ScratchBand() (min, max int) {
min, max = b.ScratchVMIDMin, b.ScratchVMIDMax
if min == 0 && max == 0 {
return defaultScratchVMIDMin, defaultScratchVMIDMax
}
return min, max
}
// ValidateForRestoreTest checks the scratch band is usable. Called only when the restore-test
// cadence is enabled (so a host that never restore-tests needn't configure a band).
func (b BackupConfig) ValidateForRestoreTest() error {
min, max := b.ScratchBand()
if min <= 0 || max < min {
return fmt.Errorf("config: backup.scratch_vmid_[min,max] is an invalid band [%d,%d]", min, max)
}
if 9999 >= min && 9999 <= max {
return fmt.Errorf("config: backup scratch band [%d,%d] must not include the standing scratch 9999", min, max)
}
if b.RestoreStorage == "" {
return fmt.Errorf("config: backup.restore_storage is required when the restore-test cadence is enabled")
}
return nil
}
// 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)
// backup (slice 6)
if v := os.Getenv("FELHOM_AGENT_BACKUP_LOCAL_TARGET"); v != "" {
cfg.Backup.LocalBackupTarget = v
}
if v := os.Getenv("FELHOM_AGENT_BACKUP_RESTORE_STORAGE"); v != "" {
cfg.Backup.RestoreStorage = v
}
cfg.Backup.RestoreTestCadenceSeconds = envInt("FELHOM_AGENT_BACKUP_RESTORE_TEST_CADENCE_SECONDS", cfg.Backup.RestoreTestCadenceSeconds)
}
// 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 "********"
}