// 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"` LocalAPI LocalAPIConfig `json:"local_api"` LANResolver LANResolverConfig `json:"lan_resolver"` WGTunnel WGTunnelConfig `json:"wg_tunnel"` OOB OOBConfig `json:"oob"` SelfUpdate SelfUpdateConfig `json:"selfupdate"` LogLevel string `json:"log_level"` // debug|info|warn|error (default info) // SourcePath is the file this config was loaded from ("" = all-env). Set by Load, never // serialized — the pbsdr bridge's escrow.pbs_storage_id seed writes back to it. SourcePath string `json:"-"` } // OOBConfig configures the dedicated felhom-sshd OOB access instance + belt (TASK H1). **Enabled // DEFAULTS TO FALSE** — a rollout to a box without explicit oob.enabled=true is a no-op (no port // claim, no config render, no belt mutation, no oob report stanza) until the operator endpoint + // static belt table exist. type OOBConfig struct { Enabled bool `json:"enabled"` IntervalSeconds int `json:"interval_seconds"` // reconcile cadence; default 60 StateDir string `json:"state_dir"` // staged config/authkeys under /felhom-sshd/; default /var/lib/felhom-agent } // WithDefaults fills the OOB reconcile cadence + state dir. func (o OOBConfig) WithDefaults() OOBConfig { if o.IntervalSeconds == 0 { o.IntervalSeconds = 60 } if o.StateDir == "" { o.StateDir = "/var/lib/felhom-agent" } return o } // SelfUpdateConfig configures the operator-signed agent self-update (TASK D1). The artifact HOST // is operator-controlled config; the artifact INTEGRITY comes only from the sha256 pinned inside // the operator-signed op — the hub's Day-0 manifest plays no role here, and a compromised Gitea // can serve garbage but never a binary that passes the signed sha. type SelfUpdateConfig struct { // URLTemplate is the download URL with a literal "{version}" placeholder. Default mirrors the // day-0 host-install scheme (Gitea generic package). URLTemplate string `json:"url_template"` // Username/Token are optional HTTP basic-auth credentials for the artifact host (the same git // read token day-0 uses). Token is a secret — redacted in Config.Redacted. Username string `json:"username,omitempty"` Token string `json:"token,omitempty"` // StateDir holds the staging subdir (/selfupdate/); default /var/lib/felhom-agent. StateDir string `json:"state_dir,omitempty"` // DwellSeconds is how long the NEW binary must run cleanly (after core init) before it commits // the update; default 60. DwellSeconds int `json:"dwell_seconds,omitempty"` } // WithDefaults fills the artifact URL template, state dir and dwell. func (s SelfUpdateConfig) WithDefaults() SelfUpdateConfig { if s.URLTemplate == "" { s.URLTemplate = "https://gitea.dooplex.hu/api/packages/admin/generic/felhom-agent/{version}/felhom-agent" } if s.StateDir == "" { s.StateDir = "/var/lib/felhom-agent" } if s.DwellSeconds == 0 { s.DwellSeconds = 60 } return s } // WGTunnelConfig configures the offsite WireGuard tunnel (S3, doc 06). **Enabled DEFAULTS TO // FALSE — the safety gate:** agent releases roll to near-production boxes, and auto-registering // one into the DEV endpoint on update would be wrong. Enable explicitly per box; the default // flips only when the production endpoint exists (a later, deliberate decision). type WGTunnelConfig struct { Enabled bool `json:"enabled"` IntervalSeconds int `json:"interval_seconds"` // reconcile cadence; default 60 StateDir string `json:"state_dir"` // key/marker/staged-conf under /wg/; default /var/lib/felhom-agent (the FELHOM_WG sudoers install entry hard-codes this default) // StaleAfterSeconds is the handshake-age threshold (S4, doc 06 §4.2) beyond which the manager // re-resolves the endpoint's A record and re-applies on an IP change (endpoint re-IP recovery). // Default 180 (≈ 3× the 25s keepalive → a healthy tunnel never trips it). StaleAfterSeconds int `json:"stale_after_seconds"` } // WithDefaults fills interval + state dir + staleness threshold. func (w WGTunnelConfig) WithDefaults() WGTunnelConfig { if w.IntervalSeconds == 0 { w.IntervalSeconds = 60 } if w.StateDir == "" { w.StateDir = "/var/lib/felhom-agent" } if w.StaleAfterSeconds == 0 { w.StaleAfterSeconds = 180 } return w } // LANResolverConfig configures the host-level split-horizon DNS resolver (internal/lanresolver): a // dnsmasq the agent manages so LAN clients reach their guest DIRECTLY at the same hostname + real cert. // Disabled unless Enable is set. HostIP defaults to the local-API bridge IP (the host LAN anchor); // Upstreams default to public resolvers; the loop re-checks the guest's live IP every interval. type LANResolverConfig struct { Enable bool `json:"enable"` HostIP string `json:"host_ip"` // dnsmasq listen-address; default = LocalAPI bridge IP Upstreams []string `json:"upstreams"` // forward targets for non-customer names IntervalSeconds int `json:"interval_seconds"` // IP-freshness re-check cadence; default 300 StateDir string `json:"state_dir"` // provisioned guests under /guests/; default /var/lib/felhom-agent } // Enabled reports whether the split-horizon resolver should run. func (l LANResolverConfig) Enabled() bool { return l.Enable } // WithDefaults fills upstreams/interval/state-dir and derives HostIP from the local-API bind addr. func (l LANResolverConfig) WithDefaults(localAPIListen string) LANResolverConfig { if len(l.Upstreams) == 0 { l.Upstreams = []string{"1.1.1.1", "8.8.8.8"} } if l.IntervalSeconds == 0 { l.IntervalSeconds = 300 } if l.StateDir == "" { l.StateDir = "/var/lib/felhom-agent" } if strings.TrimSpace(l.HostIP) == "" && localAPIListen != "" { if h, _, err := net.SplitHostPort(localAPIListen); err == nil && h != "" && h != "0.0.0.0" { l.HostIP = h } } return l } // LocalAPIConfig configures the per-guest local API server (doc 03 §6, slice 8A). The // controller (inside its LXC) reaches the agent over the local bridge; the agent is the // per-guest authorization gate — it maps a per-guest bearer token → VMID and authorizes // every call against THAT guest only (self-scoped; never a caller-supplied id). Disabled // unless Enable is set AND ListenAddr is non-empty (so a host that doesn't yet provision // controllers runs the daemon without it). // // Defense-in-depth (spike gotcha 5): ListenAddr should be the host's BRIDGE IP (not // 0.0.0.0), and a host firewall rule should limit the port to the guest bridge subnet // (configs/felhom-localapi-firewall.example). The per-guest token remains the gate; the // bind + firewall narrow exposure but are not the authorization. type LocalAPIConfig struct { Enable bool `json:"enable"` ListenAddr string `json:"listen_addr"` // bridge IP:port, e.g. "192.168.0.162:8443" // CertFile/KeyFile hold the agent's self-signed leaf served to controllers. Generated // (persisted) on first start if absent, so the leaf SHA-256 fingerprint — baked into each // guest's bootstrap for pinning — is STABLE across agent restarts. CertFile string `json:"cert_file"` // default /local-api.crt KeyFile string `json:"key_file"` // default /local-api.key // TokenStore is the durable, hashed token→guest map (only a HASH of each token is // persisted; the plaintext exists transiently at mint→write-to-mount, then is discarded). TokenStore string `json:"token_store"` // default /var/lib/felhom-agent/local-tokens.log } // Default local-API file locations (under the agent's state dir). const ( defaultLocalAPITokenStore = "/var/lib/felhom-agent/local-tokens.log" defaultLocalAPICert = "/var/lib/felhom-agent/local-api.crt" defaultLocalAPIKey = "/var/lib/felhom-agent/local-api.key" ) // Enabled reports whether the local-API server should run. func (l LocalAPIConfig) Enabled() bool { return l.Enable && strings.TrimSpace(l.ListenAddr) != "" } // TokenStorePath returns the configured token-store path (default applied). func (l LocalAPIConfig) TokenStorePath() string { if l.TokenStore != "" { return l.TokenStore } return defaultLocalAPITokenStore } // CertPath/KeyPath return the configured leaf cert/key paths (defaults applied). func (l LocalAPIConfig) CertPath() string { if l.CertFile != "" { return l.CertFile } return defaultLocalAPICert } func (l LocalAPIConfig) KeyPath() string { if l.KeyFile != "" { return l.KeyFile } return defaultLocalAPIKey } // Validate checks the local-API config is usable when enabled. func (l LocalAPIConfig) Validate() error { if !l.Enable { return nil } if strings.TrimSpace(l.ListenAddr) == "" { return fmt.Errorf("config: local_api.listen_addr is required when local_api.enable is set (use the host bridge IP:port, e.g. 192.168.0.162:8443)") } if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil { return fmt.Errorf("config: local_api.listen_addr %q is not host:port: %w", l.ListenAddr, err) } return nil } // 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. Empty → the // offsite PBS default (see BackupTarget); set e.g. "local" or "felhom-pbs" to override. // (Name kept for config back-compat; the default is no longer "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 990000–990009. ScratchVMIDMin int `json:"scratch_vmid_min"` ScratchVMIDMax int `json:"scratch_vmid_max"` // RestoreTestPBSRestoreTimeoutSeconds bounds the wait on a PBS-tier (offsite/WAN) restore-test // restore task; 0 → default 120m. A large guest restored over a slow home uplink runs long, and // for an UNATTENDED nightly test a false timeout (→ mid-restore teardown → leaked scratch) is // worse than a slow pass. Very large guests may need a higher value. LOCAL-tier restores keep // the 10m WaitOptions default (a local restore hanging 10m is a genuine fault). RestoreTestPBSRestoreTimeoutSeconds int `json:"restore_test_pbs_restore_timeout_seconds"` // 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 (.pw). Default // /etc/pve/priv/storage (PVE-managed, 0600). The agent reads it at runtime; never logged. PBSSecretDir string `json:"pbs_secret_dir"` // BackupCadenceSeconds drives the local-API GET /backup/due (slice 8B): a guest is "due" when // its newest successful backup is older than this (or none exists). 0 → default (24h). The // hub-served per-guest policy is slice 10; this is the agent-local cadence. BackupCadenceSeconds int `json:"backup_cadence_seconds"` // LocalBackupRetention is keep-last=N for the per-run `--prune-backups` on a LOCAL vzdump target — // so the agent's own local whole-guest backups can't pile up and refill root (the felhom-pve incident; // the host_disk + storage_fill checkers are the detectors, this is the preventive default). 0/unset → // default 3; ALWAYS clamped to ≥1 by KeepLast() so a mis-config can never prune the fresh backup. // NEVER applied to a PBS target (offsite retention is a separate lifecycle). LocalBackupRetention int `json:"local_backup_retention"` } // defaultLocalBackupKeepLast is the local vzdump retention default (newest N restore points kept). const defaultLocalBackupKeepLast = 3 // KeepLast returns the effective local-backup keep-last, clamped to ≥1 (0/unset → default 3, negative → // default). The clamp is load-bearing: keep-last=0 would tell PVE to prune EVERY archive, including the // one just made — a mis-config must never self-destruct the fresh backup. func (b BackupConfig) KeepLast() int { if b.LocalBackupRetention < 1 { return defaultLocalBackupKeepLast } return b.LocalBackupRetention } // PruneBackupsSpec returns the PVE `--prune-backups` value for the local vzdump (e.g. "keep-last=3"). func (b BackupConfig) PruneBackupsSpec() string { return fmt.Sprintf("keep-last=%d", b.KeepLast()) } // BackupCadence returns the per-guest /backup/due window: positive as-is, else 24h default. func (b BackupConfig) BackupCadence() time.Duration { if b.BackupCadenceSeconds > 0 { return time.Duration(b.BackupCadenceSeconds) * time.Second } return 24 * time.Hour } // RestoreTestPBSRestoreTimeout returns the PBS-tier restore-task wait: positive as-is, else 120m. func (b BackupConfig) RestoreTestPBSRestoreTimeout() time.Duration { if b.RestoreTestPBSRestoreTimeoutSeconds > 0 { return time.Duration(b.RestoreTestPBSRestoreTimeoutSeconds) * time.Second } return 120 * time.Minute } // defaultBackupTarget is the offsite PBS storage whole-guest backups land on by default. It is // SEPARATE HARDWARE from the guest's own disk (a PBS datastore on the DooPlex box), so a host // disk/hardware failure doesn't take the backups with it — that's what makes it real DR. Proven // live: snapshot-mode vzdump to PBS still fires the `create storage snapshot` marker (early-resume // intact) and pct-restore-from-PBS round-trips cleanly via the storage.cfg encryption key. const defaultBackupTarget = "felhom-pbs" // BackupTarget is the vzdump storage (content=backup) whole-guest backups go to. Defaults to the // offsite PBS storage (see defaultBackupTarget); override via backup.local_backup_target for a // local or other target. Kept configurable on purpose — the field is never hardcoded at a call site. func (b BackupConfig) BackupTarget() string { if b.LocalBackupTarget != "" { return b.LocalBackupTarget } return defaultBackupTarget } // 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 .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 // SmbCredsDir is where the agent writes 0600 SMB credentials files for network storage (Part A1). // Out-of-band: never committed, never logged. Default /var/lib/felhom-agent/smb-creds (agent-owned). SmbCredsDir string `json:"smb_creds_dir"` } // 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) } } cfg.SourcePath = path // where this config came from (pbsdr's escrow seed writes back here) 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 = "********" } if c.SelfUpdate.Token != "" { c.SelfUpdate.Token = "********" } 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 "********" }