Files
felhom-agent/internal/config/config.go
T
admin 47dd0bd244 slice 7: PBS recovery-code escrow creation (agent, Phase B) (v0.9.0 wip)
internal/escrow: zero-knowledge escrow creation. R = 10 EFF-wordlist words
(crypto/rand, ~129 bits); wrap K under R via PBS-native key change-passphrase
driven over a stdlib pty (x/sys/unix; output discarded so R can't leak, F-A2);
self-verify the blob recovers K (fingerprint match) before shipping. Opt-in (b)
R-wrapped offline copy + (a) raw paperkey. Live K is byte-unchanged (operates on
a copy). --selftest=escrow-create (-storage/-paperkey/-offline/-upload). Posture
config field (zero_knowledge default). PBSEncKeyPath helper. Grounded by the
escrow spike findings.

Tests: R entropy>=128/format/uniqueness; integration round-trip (wrap->unwrap
fingerprint match, wrong-R fails, K byte-unchanged) guarded to linux+pbc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:37:03 +02:00

431 lines
16 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"`
Escrow EscrowConfig `json:"escrow"`
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
}
// EscrowConfig configures PBS recovery-code escrow creation (slice 7, doc 03 §8a). Enrollment-time
// only (not the steady-state daemon). The default posture is zero-knowledge (Felhom holds the
// opaque blob, the customer holds the recovery code).
type EscrowConfig struct {
// Posture is the key-custody posture; "" → zero_knowledge (the only one implemented this slice).
Posture string `json:"posture"`
// PBSStorageID is the pbs storage whose client encryption key is escrowed (e.g. "felhom-pbs").
PBSStorageID string `json:"pbs_storage_id"`
}
// 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"`
// PBS (slice 6 Phase B). The verify maintenance loop runs on its own cadence (cheaper +
// more frequent than the full restore-test); 0 → default (6h), negative → disabled.
PBSVerifyCadenceSeconds int `json:"pbs_verify_cadence_seconds"`
// PBSSecretDir holds the per-storage PBS token secret files (<id>.pw). Default
// /etc/pve/priv/storage (PVE-managed, 0600). The agent reads it at runtime; never logged.
PBSSecretDir string `json:"pbs_secret_dir"`
}
// 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
}
}
// PBSVerifyCadence returns the verify-loop interval: positive as-is, 0 → 6h default,
// negative → 0 (disabled).
func (b BackupConfig) PBSVerifyCadence() time.Duration {
switch {
case b.PBSVerifyCadenceSeconds > 0:
return time.Duration(b.PBSVerifyCadenceSeconds) * time.Second
case b.PBSVerifyCadenceSeconds < 0:
return -1 // disabled (pbs.VerifyLoop treats <0 as disabled)
default:
return 6 * time.Hour
}
}
// PBSSecretPath returns the path to a pbs storage's token-secret file.
func (b BackupConfig) PBSSecretPath(storageID string) string {
return b.pbsSecretDir() + "/" + storageID + ".pw"
}
// PBSEncKeyPath returns the path to a pbs storage's CLIENT ENCRYPTION KEY (K) file — the key the
// escrow wraps (slice 7). PVE stores it alongside the token secret as <id>.enc, 0600 root.
func (b BackupConfig) PBSEncKeyPath(storageID string) string {
return b.pbsSecretDir() + "/" + storageID + ".enc"
}
func (b BackupConfig) pbsSecretDir() string {
if b.PBSSecretDir == "" {
return "/etc/pve/priv/storage"
}
return b.PBSSecretDir
}
// 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 "********"
}