// 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" "os" "strconv" "strings" ) // Config is the agent configuration. type Config struct { Proxmox ProxmoxConfig `json:"proxmox"` Privileged PrivilegedConfig `json:"privileged"` Authz AuthzConfig `json:"authz"` LogLevel string `json:"log_level"` // debug|info|warn|error (default info) } // 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. 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"` } // 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"}, 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 } } // 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 token secret is masked. func (c Config) Redacted() Config { if c.Proxmox.Token != "" { c.Proxmox.Token = redactToken(c.Proxmox.Token) } return c } // 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 "********" }