Files
felhom-agent/internal/config/config.go
T
admin c983a25609 feat(felhomsshd): dedicated OOB sshd instance + port-adaptive belt (H1 Parts 2-4 agent)
internal/felhomsshd: agent-managed felhom-sshd (claim port [8822,2222,8022,62222]
loud-fail-on-exhaustion; render config→sshd -t→reload never-restart-on-change
[SF-2]; operator authorized_keys from the hub block outside ~/.ssh [SF-3]); the
static-table nft belt mutating ONLY @operator_ips + @ssh_port [trap 4]; health/heal
(reset-failed-then-restart with 10min cooldown, NEVER restart onto an invalid
config) + the oob heartbeat stanza. configs/felhom-sshd.service (SAFE, no
RuntimeDirectory [SF-1]). FELHOM_SSHD + FELHOM_OOB sudoers (set-elements only).
oob.enabled config DEFAULT FALSE. Wired into main like wgtunnel.

Non-hollow tests: claim clean/contention/idempotent/exhaustion; config
safe+byte-stable+refuses-:22; belt mutate-then-idempotent + never-touches-rules;
heal no-restart-on-invalid-config + cooldown; status reflects block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 22:27:02 +02:00

703 lines
29 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"`
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)
}
// 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 <StateDir>/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 (<StateDir>/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 <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 990000990009.
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 = "********"
}
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 "********"
}