Files
felhom-agent/internal/config/config.go
T
admin 4618169036
gates / gates (push) Failing after 7s
R-86: restore-test follows the backup, not the clock (v0.121.0)
The ticker survives as the EVALUATION interval only. A tier is DUE when its
newest archive that has settled for `settle` (default 24h) has not been proven:
daily tier -> proved daily on yesterday's archive, weekly tier -> weekly on its
own, newborn -> UNKNOWN.

The trap avoided: the literal reading ("newest archive is >= 24h old") is NEVER
true on a daily tier, so it silently switches restore-testing off where it
matters most. Red-proved at 0 runs over 5 simulated days.

- state records WHICH archive was proven; legacy files keep their time and yield
  no proven archive (each tier due once after the upgrade, deliberately)
- two knobs replace one: restore_test_eval_interval_seconds (6h, measured) and
  restore_test_settle_seconds (24h). The old cadence key keeps its DISABLE
  meaning verbatim and now seeds the settle lag, with a start-up WARN.
- due-check runs BEFORE the heavy-op gate (a frequent poll must not make a
  starting backup record a failure, F-A1)
- candidate picker skips implausible archives (a phantom would be due forever)
- new read-only --selftest=restore-test-due prints the verdict + its cost
2026-08-03 14:54:57 +02:00

1002 lines
46 KiB
Go
Raw Permalink 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"`
GuestNet GuestNetConfig `json:"guest_net"`
OOB OOBConfig `json:"oob"`
SelfUpdate SelfUpdateConfig `json:"selfupdate"`
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
// DeploymentMode gates host-service self-heal (CAMPAIGN-3 Part 6). "appliance" = a Felhom-managed
// node the agent may remediate (e.g. start networking at boot — F12-class defense in depth). Any
// other value, including absent/unknown, is treated as "byo" (a customer's own host): the self-heal
// CHECK still runs and WARNs, but the REMEDY is structurally unreachable. Fail-safe to byo — never
// touch a host we do not own. Distinct from Privileged.Mode (sudo vs direct exec) — do NOT overload.
DeploymentMode string `json:"deployment_mode,omitempty"`
// 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:"-"`
}
// DeploymentModeAppliance is the ONLY value that unlocks host-service self-heal. Everything else,
// including "" and any typo, is byo (fail-safe — a host we do not own is never remediated).
const DeploymentModeAppliance = "appliance"
// IsAppliance reports whether this node is a Felhom-managed appliance (self-heal remedies allowed).
// Fail-safe: absent/unknown → false (byo).
func (c *Config) IsAppliance() bool { return c.DeploymentMode == DeploymentModeAppliance }
// 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
}
// GuestNetConfig configures the R-54 guest-network watchdog (internal/guestnet).
//
// **This is the repo's first DEFAULT-ON feature gate, and the inversion is deliberate.** Every other
// gate here is `Enabled bool` defaulting to false, because those features reach outward (an offsite
// endpoint, an OOB tunnel) and enrolling a box into one by an update would be wrong. This one only
// looks INWARD at guests the agent already owns, and the failure it prevents — an unsupervised DHCP
// client dying and taking the box off the internet 1-2 hours later, invisibly
// (INCIDENT-guest-dhclient-killed-2026-07-20) — is one every box has today. A watchdog that must be
// remembered per box is a watchdog that is missing on the box that needed it. Opting out is
// therefore the explicit act: `"guest_net": {"disable": true}`.
type GuestNetConfig struct {
Disable bool `json:"disable"` // explicit opt-OUT; default is enabled
IntervalSeconds int `json:"interval_seconds"` // probe cadence; default 60
MinHealIntervalSeconds int `json:"min_heal_interval_seconds"` // per-guest cool-off; default 600
MaxHealsPerHour int `json:"max_heals_per_hour"` // per-guest hourly cap; default 3
SettleSeconds int `json:"settle_seconds"` // boot-race guard (guest AND agent uptime); default 180
}
// Enabled reports whether the guest-network watchdog should run.
func (g GuestNetConfig) Enabled() bool { return !g.Disable }
// WithDefaults fills the cadence and the three dampers. A NEGATIVE value is honoured as-is by the
// watchdog constructor's own guards, so an operator can set 0 to mean "package default" without
// having to know the number.
func (g GuestNetConfig) WithDefaults() GuestNetConfig {
if g.IntervalSeconds == 0 {
g.IntervalSeconds = 60
}
if g.MinHealIntervalSeconds == 0 {
g.MinHealIntervalSeconds = 600
}
if g.MaxHealsPerHour == 0 {
g.MaxHealsPerHour = 3
}
if g.SettleSeconds == 0 {
g.SettleSeconds = 180
}
return g
}
// 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
// IslandBridge + IslandGuestAddr configure the R-50 host-internal control-plane bridge. When
// BOTH are set, the provisioner attaches each guest a static net1 on IslandBridge with
// IslandGuestAddr, so the controller reaches the agent over a fixed private address that no
// LAN/DHCP/site move can invalidate (the F1 fix — AUDIT-vacation-remote-ops-2026-07-20). Empty
// (the default) = LAN-only, byte-for-byte the pre-R-50 behaviour. On an island install ListenAddr
// is the host side (169.254.253.1:8443); IslandGuestAddr is the guest side (169.254.253.2/30 — a
// /30 is exactly host + one guest). Additive-only: it never removes a NIC, so a guest restored on
// a non-island host (both empty) is unaffected.
IslandBridge string `json:"island_bridge"` // e.g. "vmbr9" (portless host-internal bridge)
IslandGuestAddr string `json:"island_guest_addr"` // guest net1 CIDR, e.g. "169.254.253.2/30"
}
// 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) != ""
}
// IslandEnabled reports whether the provisioner should attach a guest island NIC (net1). True only
// when BOTH the bridge and the guest CIDR are set (R-50); empty = pre-R-50 LAN-only behaviour.
func (l LocalAPIConfig) IslandEnabled() bool {
return strings.TrimSpace(l.IslandBridge) != "" && strings.TrimSpace(l.IslandGuestAddr) != ""
}
// 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)
}
// R-50: island fields are all-or-nothing, and the guest addr must be a CIDR (the net1 ip= value).
// A half-set island (bridge without guest addr, or vice versa) is a provisioning mistake, not a
// silent LAN fallback — fail loudly so a botched install config is caught at load, not at day-0.
if (strings.TrimSpace(l.IslandBridge) != "") != (strings.TrimSpace(l.IslandGuestAddr) != "") {
return fmt.Errorf("config: local_api.island_bridge and local_api.island_guest_addr must be set together (got bridge=%q guest_addr=%q)", l.IslandBridge, l.IslandGuestAddr)
}
if l.IslandEnabled() {
if _, _, err := net.ParseCIDR(strings.TrimSpace(l.IslandGuestAddr)); err != nil {
return fmt.Errorf("config: local_api.island_guest_addr %q is not a CIDR (want e.g. 169.254.253.2/30): %w", l.IslandGuestAddr, 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 LEGACY restore-test knob, retained for one meaning only:
// NEGATIVE still DISABLES the automatic restore-test entirely (on-demand selftest still works),
// and 0 still means "use the default". It no longer sets how often a test runs — R-86 replaced
// the interval trigger with a per-archive due-check — so a positive value now seeds
// RestoreTestSettleSeconds instead (see RestoreTestSettle). Prefer the two explicit keys below.
RestoreTestCadenceSeconds int `json:"restore_test_cadence_seconds"`
// RestoreTestEvalIntervalSeconds is how often the scheduler ASKS whether any tier is due
// (R-86); 0 → default. It is not how often a test runs: a tier is tested once per archive
// generation no matter how often it is asked. This interval sets two things — the latency
// between an archive settling and its proof, and the retry rate of a tier whose restore-test
// keeps failing. See defaultRestoreTestEvalInterval for the measurement it was chosen from.
RestoreTestEvalIntervalSeconds int `json:"restore_test_eval_interval_seconds"`
// RestoreTestSettleSeconds is how long an archive must have sat on its tier before it is a
// restore-test candidate (R-86); 0 → default (24h), negative → 0 (no settle requirement).
// Restore-testing an archive a backup is still writing proves nothing about the backup that
// finished — this is the same settle discipline R-71a's gate applies to the offsite consume.
RestoreTestSettleSeconds int `json:"restore_test_settle_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"`
// ExtraTargets (R-82) are ADDITIONAL backup tiers beyond the primary one above — the shape that
// makes "local daily + PBS weekly" expressible at all. Each carries its OWN cadence and its OWN
// retention, because those are semantically different per tier: keep-last=3 on a daily tier is
// three DAYS of restore points; on a weekly tier it is three WEEKS. Sharing one knob between
// tiers silently means one of them is wrong.
//
// ADDITIVE BY CONSTRUCTION: an existing config with no `backup_targets` key resolves to exactly
// one tier — the primary — and behaves byte-identically to pre-R-82. Nothing here changes the
// local tier.
ExtraTargets []BackupTargetConfig `json:"backup_targets"`
}
// BackupTargetConfig is ONE additional backup tier: a vzdump storage plus its own cadence and
// retention. A tier with no cadence is not a tier — see BackupTiers for why that is rejected loudly
// rather than defaulted.
type BackupTargetConfig struct {
// TargetID is the Proxmox storage id (content=backup), e.g. "felhom-pbs".
TargetID string `json:"target_id"`
// CadenceSeconds is THIS tier's /backup/due window. REQUIRED (>0) — see BackupTiers.
CadenceSeconds int `json:"cadence_seconds"`
// KeepLast is THIS tier's per-run `--prune-backups` keep-last. 0/unset → NEVER prune this tier
// (the fail-safe default, and the current behaviour for every PBS target). A PBS tier is never
// pruned by the per-run flag regardless — see BackupRunner.localPruneSpec.
KeepLast int `json:"keep_last"`
// WaitTimeoutSeconds bounds how long the agent WAITS for this tier's vzdump task. 0/unset →
// defaultExtraTierWaitTimeout.
//
// THIS FIELD EXISTS BECAUSE OF A LIVE FAILURE (2026-07-26, R-82 Slice A validation). The runner
// hard-coded a 30-minute wait, which is right for a local vzdump (minutes) and badly wrong for
// an offsite PBS backup over a home uplink: the first full ~10 GB snapshot ran past 30 min, the
// agent gave up waiting and recorded success=false — WHILE THE BACKUP WAS STILL RUNNING. That
// false failure is worse than a slow pass: the tier stays "due", a retry collides with the
// guest lock vzdump still holds, and the hub sees a DR tier that never succeeds.
//
// Same reasoning as RestoreTestPBSRestoreTimeoutSeconds on the restore side, and the same
// direction: when in doubt wait LONGER. A slow backup is a slow backup; a false timeout is a
// corrupt status plus lock contention.
WaitTimeoutSeconds int `json:"wait_timeout_seconds"`
}
// Per-tier vzdump wait bounds.
//
// The PRIMARY keeps the historical 30 minutes: it is the local tier, a local vzdump takes minutes,
// and one hanging 30 minutes is a genuine fault worth surfacing. Unchanged behaviour.
//
// An ADDITIONAL tier is by construction the offsite/WAN one in this design, where the binding
// constraint is uplink speed, not health. Measured on demo-felhom: ~33 MB/min over the wg link to
// Hetzner, so a first FULL ~10 GB snapshot projects to ~5h. Operator ruling 2026-07-26: "let the
// first backup run as long as needed" — 12h gives that real margin on a slower link while still
// being BOUNDED, so a genuinely hung task eventually surfaces instead of hanging forever.
const (
defaultPrimaryTierWaitTimeout = 30 * time.Minute
defaultExtraTierWaitTimeout = 12 * time.Hour
)
// BackupTier is a RESOLVED backup tier: one target, its own cadence, its own retention. The agent
// builds one runner per tier from these.
type BackupTier struct {
TargetID string
Cadence time.Duration
// WaitTimeout bounds the wait on this tier's vzdump task (see WaitTimeoutSeconds).
WaitTimeout time.Duration
// KeepLast is the per-run prune keep-last; 0 means DO NOT PRUNE this tier.
KeepLast int
// Primary marks the tier that the UNTARGETED local-API endpoints act on — the pre-R-82 tier.
// Exactly one tier is primary, and it is always first.
Primary bool
}
// BackupTiers resolves the effective tier list, primary first, plus any warnings the caller MUST
// log (they describe tiers that were REJECTED, and a silently-dropped backup tier is precisely the
// "applied and empty" fault R-82 exists to fix).
//
// Rules:
// - Tier 0 is always the primary, built from BackupTarget()/BackupCadence()/KeepLast() — so a
// config with no `backup_targets` is byte-identical to pre-R-82.
// - An extra with an empty target_id is rejected.
// - An extra with cadence_seconds <= 0 is REJECTED, not defaulted. Defaulting a PBS tier to the
// 24h local default would quietly turn a weekly tier into a daily one and fill the DR datastore;
// a tier whose cadence you did not state is not a tier.
// - An extra repeating the primary's target is rejected (one policy per target, or the two
// cadences race and neither is the truth).
// - Duplicate extras are rejected after the first.
func (b BackupConfig) BackupTiers() ([]BackupTier, []string) {
primary := BackupTier{
TargetID: b.BackupTarget(),
Cadence: b.BackupCadence(),
KeepLast: b.KeepLast(),
WaitTimeout: defaultPrimaryTierWaitTimeout,
Primary: true,
}
tiers := []BackupTier{primary}
var warnings []string
seen := map[string]bool{primary.TargetID: true}
for i, t := range b.ExtraTargets {
id := strings.TrimSpace(t.TargetID)
switch {
case id == "":
warnings = append(warnings, fmt.Sprintf("backup_targets[%d]: empty target_id — tier ignored", i))
continue
case seen[id]:
warnings = append(warnings, fmt.Sprintf("backup_targets[%d]: target %q already configured — duplicate tier ignored", i, id))
continue
case t.CadenceSeconds <= 0:
warnings = append(warnings, fmt.Sprintf("backup_targets[%d] (%s): cadence_seconds must be > 0 — tier ignored (a cadence is NOT defaulted: a weekly tier silently running daily would fill the DR datastore)", i, id))
continue
}
seen[id] = true
keep := t.KeepLast
if keep < 0 {
keep = 0
}
wait := defaultExtraTierWaitTimeout
if t.WaitTimeoutSeconds > 0 {
wait = time.Duration(t.WaitTimeoutSeconds) * time.Second
}
tiers = append(tiers, BackupTier{
TargetID: id,
Cadence: time.Duration(t.CadenceSeconds) * time.Second,
KeepLast: keep,
WaitTimeout: wait,
})
}
return tiers, warnings
}
// 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 + the two R-86 restore-test knobs.
const (
defaultScratchVMIDMin = 990000
defaultScratchVMIDMax = 990009
// defaultRestoreTestEvalInterval is how often due-ness is ASKED. It is bounded from BOTH sides,
// and neither bound alone would have picked it:
//
// FLOOR — what one evaluation costs. MEASURED on demo-felhom, 2026-08-03 (R-86 Part 1.4), via
// --selftest=restore-test-due and by timing the underlying API call directly. One evaluation
// is one storage-content listing per tier:
//
// local dir storage (3 archives) ....... 18 ms (18.7 / 18.3 / 18.5)
// PBS tier, WAN to ep0 (2 snapshots) ... 392 ms (375 / 378 / 424)
// both tiers together .................. 430 ms
//
// So cost does NOT set this: even at one evaluation a minute the offsite leg would be ~0.7 %
// of a WAN link's time and ~9 minutes of ep0's day. Worth writing down anyway, because the
// number that would have forbidden a frequent poll is the one nobody measures.
//
// CEILING — the retry rate of a FAILING tier. Under a per-archive due-check a tier whose
// restore-test keeps failing stays due, so the evaluation interval IS its retry interval, and
// a retry is a multi-GB restore. Every few minutes would be an incident of its own; the old
// timer retried a broken tier once a day.
//
// 6h sits between them: four heavy retries a day at the very worst, latency from settle to
// proof of at most 6h against a 24h settle lag (so a daily tier is still proved daily), and no
// second rate limiter anywhere — the pacing remains one test per archive generation.
defaultRestoreTestEvalInterval = 6 * time.Hour
// defaultRestoreTestSettle is how long an archive must sit before it may be restore-tested.
// 24h is R-86's own figure ("~24 h after its own newest archive") and it is what makes the
// candidate on a daily tier YESTERDAY's archive rather than the one still being written.
defaultRestoreTestSettle = 24 * time.Hour
)
// RestoreTestEvalInterval returns how often the scheduler evaluates due-ness (R-86): a positive
// value as-is, 0 → the measured default, negative → 0 (disabled).
//
// The LEGACY `restore_test_cadence_seconds` keeps exactly one power here, the one a box may be
// relying on: a NEGATIVE value still disables the automatic restore-test outright. It no longer
// sets the interval, because the interval no longer decides that a test happens.
func (b BackupConfig) RestoreTestEvalInterval() time.Duration {
if b.RestoreTestCadenceSeconds < 0 {
return 0 // legacy DISABLE — preserved verbatim
}
switch {
case b.RestoreTestEvalIntervalSeconds > 0:
return time.Duration(b.RestoreTestEvalIntervalSeconds) * time.Second
case b.RestoreTestEvalIntervalSeconds < 0:
return 0 // disabled
default:
return defaultRestoreTestEvalInterval
}
}
// RestoreTestSettle returns how long an archive must have sat before it is a restore-test
// candidate (R-86): a positive value as-is, negative → 0 (no settle requirement), 0 → the default.
//
// WHAT HAPPENED TO THE OLD KEY. A box that set `restore_test_cadence_seconds` to a positive value
// was expressing "how long may pass between a backup and the confidence that it restores". That
// quantity survives R-86 as the SETTLE LAG, so a positive legacy value seeds this rather than being
// dropped or silently repurposed as the evaluation interval — and the daemon says so at start-up
// (see RestoreTestLegacyCadenceInUse). It is deliberately not carried into the evaluation interval:
// a box that set 72h to spare a weak endpoint would otherwise get a 72h-latency due-check, whereas
// what it actually wanted — fewer heavy restores — is what per-archive due-ness already gives it.
func (b BackupConfig) RestoreTestSettle() time.Duration {
switch {
case b.RestoreTestSettleSeconds > 0:
return time.Duration(b.RestoreTestSettleSeconds) * time.Second
case b.RestoreTestSettleSeconds < 0:
return 0 // explicitly no settle requirement
case b.RestoreTestCadenceSeconds > 0:
return time.Duration(b.RestoreTestCadenceSeconds) * time.Second // legacy seeding
default:
return defaultRestoreTestSettle
}
}
// RestoreTestLegacyCadenceInUse reports whether the deprecated key is what is deciding the settle
// lag, so the daemon can name both replacements ONCE at start-up. A config key that changed meaning
// without saying so is exactly the silent repurposing §8.3 forbids.
func (b BackupConfig) RestoreTestLegacyCadenceInUse() bool {
return b.RestoreTestCadenceSeconds > 0 && b.RestoreTestSettleSeconds == 0
}
// 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)
}
}
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
}
if v := os.Getenv("FELHOM_AGENT_DEPLOYMENT_MODE"); v != "" {
cfg.DeploymentMode = 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)
cfg.Backup.RestoreTestEvalIntervalSeconds = envInt("FELHOM_AGENT_BACKUP_RESTORE_TEST_EVAL_INTERVAL_SECONDS", cfg.Backup.RestoreTestEvalIntervalSeconds)
cfg.Backup.RestoreTestSettleSeconds = envInt("FELHOM_AGENT_BACKUP_RESTORE_TEST_SETTLE_SECONDS", cfg.Backup.RestoreTestSettleSeconds)
}
// 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 "********"
}