dc70e15d28
A WAN (pbs-tier) restore of a large guest exceeds the restore-task wait's 10m default → the wait expired mid-restore, teardown fired against a still-restoring (not-yet-pool-associated) scratch guest → leak + a phantom VM.Allocate 403. - RestoreTestSpec.RestoreTaskTimeout (0→10m default); the restore WaitTask passes it. Local tier unchanged (10m). - config RestoreTestPBSRestoreTimeoutSeconds + accessor (default 120m). - main restoreTaskTimeout(cfg,tier): configured PBS timeout only when tier==pbs, else 0. Both scheduler + selftest spec builds. - Tests + WaitOptions red-proof + accessor contract. The "grant scratch-band VM.Allocate" follow-up is diagnosed not blind-applied: the scratch is restored INTO /pool/felhom (ACL already grants VM.Allocate), so the earlier 403 was a consequence of the timeout. No ACL/host-install change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
644 lines
27 KiB
Go
644 lines
27 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"`
|
||
Backup BackupConfig `json:"backup"`
|
||
Escrow EscrowConfig `json:"escrow"`
|
||
LocalAPI LocalAPIConfig `json:"local_api"`
|
||
LANResolver LANResolverConfig `json:"lan_resolver"`
|
||
WGTunnel WGTunnelConfig `json:"wg_tunnel"`
|
||
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
|
||
}
|
||
|
||
// 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 <StateDir>/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 <StateDir>/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 <token_store_dir>/local-api.crt
|
||
KeyFile string `json:"key_file"` // default <token_store_dir>/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 (<id>.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 <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
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
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 "********"
|
||
}
|