Files
felhom-agent/cmd/felhom-agent/main.go
T
admin bb1692cbfb agent v0.26.0: slice 10 P2 activation — POST /guest/reboot (user-triggered)
Self-scoped guest reboot (pct reboot, detached, 202) so an enrolled-into-running-
guest drive's persisted bind activates at next boot. Tests: accepted + cross-guest
403. Pairs with controller v0.49.0 pending-drive detection + "Újraindítás most".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:19:12 +02:00

1779 lines
75 KiB
Go

// Command felhom-agent is the host agent.
//
// With no --selftest flag it runs as the daemon: the host-report poll loop
// (slice 3) that periodically POSTs a read-only host-report to the hub (the
// heartbeat). --selftest=read|task exercise the proxmox layer; --selftest=hub does
// one collect+report against the hub and prints what it would send.
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/lanresolver"
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
"gitea.dooplex.hu/admin/felhom-agent/internal/provision"
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.26.0"
func main() {
var (
cfgPath string
selftest selftestFlag
vmid int
watch time.Duration
archive string
mode string
hostname string
keep bool
pbsStorage string
paperkey bool
offline bool
upload bool
custID string
custDomain string
custName string
custEmail string
hubPassword string
blobPath string
expectedFP string
keyDest string
idBundlePath string
directivePath string
showVersion bool
)
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-hub-password; keeps the guest)")
flag.IntVar(&vmid, "vmid", 0, "guest VMID for --selftest=task|backup|bring-up")
flag.DurationVar(&watch, "watch", 0, "for --selftest=storage: run the watchdog verbose for this duration (e.g. 3m) with the re-mount response live; 0 = observe pass only")
flag.StringVar(&archive, "archive", "", "for --selftest=restore-test|bring-up: the backup volid to restore (restore-test: default newest on the local target)")
flag.StringVar(&mode, "mode", "provision", "for --selftest=bring-up: `provision` (golden, fresh identity) | `dr` (customer backup, preserve continuity)")
flag.StringVar(&hostname, "hostname", "", "for --selftest=bring-up provision: the hostname to set on the new guest")
flag.BoolVar(&keep, "keep", false, "for --selftest=bring-up: KEEP the guest instead of tearing it down at the end")
flag.StringVar(&pbsStorage, "storage", "", "for --selftest=escrow-create: the pbs storage whose key to escrow (default: escrow.pbs_storage_id)")
flag.BoolVar(&paperkey, "paperkey", false, "for --selftest=escrow-create: ALSO emit the raw-key paperkey (opt-in (a); single-factor, unrevocable)")
flag.BoolVar(&offline, "offline", false, "for --selftest=escrow-create: ALSO emit the R-wrapped offline copy to print (opt-in (b))")
flag.BoolVar(&upload, "upload", false, "for --selftest=escrow-create: upload the opaque blob to the hub")
flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file")
flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)")
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)")
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id — the hub config-pull target, baked into the guest's bootstrap")
flag.StringVar(&hubPassword, "hub-password", "", "for --selftest=provision: the customer's hub RETRIEVAL PASSPHRASE (SECRET) — baked into bootstrap.json so the controller pulls its config (and the customer-scoped hub key) from the hub. The customer must already exist in the hub.")
flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: customer domain (accepted; used by bring-up only — NOT baked into v2 bootstrap, the hub provides it)")
flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: customer display name (accepted; NOT baked into v2 bootstrap)")
flag.StringVar(&custEmail, "customer-email", "", "for --selftest=provision: customer email (accepted; NOT baked into v2 bootstrap)")
flag.BoolVar(&showVersion, "version", false, "print version and exit")
flag.Parse()
if showVersion {
fmt.Println("felhom-agent", version)
return
}
cfg, err := config.Load(cfgPath)
if err != nil {
// A missing default config file is fine if env provides the values; only a
// present-but-unreadable/invalid file is fatal here.
if !(os.IsNotExist(errors.Unwrap(err)) && cfgPath == flag.Lookup("config").DefValue) {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(2)
}
cfg = config.Default()
}
logger := applog.New(cfg.LogLevel)
switch selftest.mode {
case "":
os.Exit(runDaemon(cfg, logger))
case "read":
os.Exit(runSelftestRead(context.Background(), cfg, logger))
case "task":
os.Exit(runSelftestTask(context.Background(), cfg, logger, vmid))
case "hub":
os.Exit(runSelftestHub(context.Background(), cfg, logger))
case "storage":
os.Exit(runSelftestStorage(context.Background(), cfg, logger, watch))
case "backup":
os.Exit(runSelftestBackup(context.Background(), cfg, logger, vmid))
case "restore-test":
os.Exit(runSelftestRestoreTest(context.Background(), cfg, logger, archive))
case "pbs-verify":
os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger))
case "lanresolver":
os.Exit(runSelftestLANResolver(context.Background(), cfg, logger, vmid))
case "bring-up":
os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep))
case "provision":
os.Exit(runSelftestProvision(context.Background(), cfg, logger, provisionArgs{
archive: archive, vmid: vmid, hostname: hostname,
customerID: custID, hubPassword: hubPassword,
}))
case "escrow-create":
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath))
case "escrow-consume":
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
case "identity-consume":
os.Exit(runSelftestIdentityConsume(context.Background(), logger, blobPath, keyDest))
}
}
// newProxmoxClient builds the read-path proxmox client from config.
func newProxmoxClient(cfg config.Config) (*proxmox.Client, error) {
return proxmox.NewClient(proxmox.Config{
Endpoint: cfg.Proxmox.Endpoint,
Node: cfg.Proxmox.Node,
Token: cfg.Proxmox.Token,
TLS: proxmox.TLSConfig{
CAFile: cfg.Proxmox.TLS.CAFile,
Fingerprint: cfg.Proxmox.TLS.Fingerprint,
InsecureSkipVerify: cfg.Proxmox.TLS.InsecureSkipVerify,
},
})
}
// newHostOps builds the privileged storage surface (slice 5 Phase B) from config. It shells
// out via the same fenced Runner the proxmox layer uses (sudo -n, arg vectors, no shell);
// every argument is validated in internal/storage before any command is built. A
// missing/declined sudoers entry degrades per-op (SMART→UNKNOWN, mount→logged error), not a
// crash.
func newHostOps(cfg config.Config, logger *slog.Logger) storage.HostOps {
mode := proxmox.RunnerMode(cfg.Privileged.Mode)
if mode == "" {
mode = proxmox.RunnerSudo
}
runner := &proxmox.ExecRunner{Mode: mode, SudoPath: cfg.Privileged.SudoPath}
return storage.NewSudoHostOps(storage.SudoHostOpsConfig{
Runner: runner,
Bins: storage.Binaries{
Systemctl: cfg.Privileged.Systemctl,
Install: cfg.Privileged.Install,
Smartctl: cfg.Privileged.Smartctl,
Lvs: cfg.Privileged.Lvs,
},
UnitDir: cfg.Privileged.UnitDir,
StageDir: cfg.Privileged.StageDir,
Logger: logger,
})
}
// gateRemounter is the watchdog's benign re-mount response: it routes the re-mount through
// the reversibility gate (classified benign) and, if allowed, calls HostOps.EnsureMount. It
// lives here (not in internal/storage) so storage stays decoupled from reconcile — main is
// the one place that holds both.
type gateRemounter struct {
gate *reconcile.Gate
ops storage.HostOps
hostID string
logger *slog.Logger
}
// Remount authorizes (benign) and performs a by-UUID re-mount of a returned target.
func (r *gateRemounter) Remount(ctx context.Context, t storage.KnownTarget) {
dec := r.gate.Authorize(reconcile.IntentForStorageMount(r.hostID, t.Name), nil)
if !dec.Allowed {
r.logger.Warn("storage: re-mount refused by gate (unexpected for a benign mount)",
"target", t.Name, "reason", dec.Reason)
return
}
uuid := t.UUID
if uuid == "" {
uuid = strings.TrimPrefix(t.DurableID, "uuid:") // durable_id carries it for usb/local-dir
}
spec := storage.MountSpec{Name: t.Name, UUID: uuid, Where: t.MountPath}
if err := r.ops.EnsureMount(ctx, spec); err != nil {
r.logger.Error("storage: re-mount failed", "target", t.Name, "where", t.MountPath, "err", err)
return
}
r.logger.Info("storage: re-mounted returned target", "target", t.Name, "where", t.MountPath)
}
// runDaemon is the default mode: collect a host-report and POST it to the hub on a
// loop. Requires both proxmox (to collect) and hub config.
func runDaemon(cfg config.Config, logger *slog.Logger) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "daemon: proxmox not configured:", err)
return 2
}
if err := cfg.Hub.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "daemon: hub not configured:", err)
return 2
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "daemon: proxmox client:", err)
return 1
}
client, err := hub.NewClient(cfg.Hub, logger)
if err != nil {
fmt.Fprintln(os.Stderr, "daemon: hub client:", err)
return 1
}
hcfg := cfg.Hub.WithDefaults()
// Storage observer (slice 5): builds the report's storage_targets from Proxmox +
// non-privileged host reads, enriched with the privileged SMART/lvs reads via HostOps
// (Phase B). Wired into the collector via the StorageObserver seam (so hub does not
// import storage).
hostReader := storage.NewProcHostReader()
hostOps := newHostOps(cfg, logger)
observer := storage.NewObserver(px, hostReader, hostOps, logger)
// Backup + restore-test state store (slice 6): holds the latest backup-per-target +
// latest restore-test result; the collector reads it via the BackupReporter /
// RestoreTestReporter seams; the cadence scheduler writes it.
backupStore := backup.NewStore()
// PBS snapshot inventory + verify-state (slice 6 Phase B): the verify loop writes it; the
// collector reads it via the PBSReporter seam.
pbsStore := pbs.NewSnapshotStore()
collector := hub.NewCollector(px, hub.SystemctlProber{}, observer, backupStore, backupStore, pbsStore, cfg.Hub.HostID, version, logger)
loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
interval := time.Duration(hcfg.PollSeconds) * time.Second
// Desired-state provider (slice 10A): the hub-served target the reconcile engine converges
// toward. Starts EMPTY (generation 0) — reconcile is a live no-op until the hub serves intent,
// exactly like the slice-4 EmptyProvider. The desired.Syncer (wired below) fills it when the
// control envelope's generation advances. Replaces EmptyProvider in the engine.
desiredProvider := reconcile.NewCachingProvider()
// The "Down" channel sync hook: on each heartbeat, fetch desired-state when the generation
// advances. The loop calls it via the EnvelopeObserver seam (hub does not import desired).
desiredSyncer := desired.NewSyncer(client, desiredProvider, logger)
// The signed-jobs runner (slice 10B) is wired as a SECOND envelope observer below (after the
// gate is built) — when the heartbeat flags pending signed ops, it fetches + verifies + executes.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Info("felhom-agent daemon starting",
"version", version, "host_id", cfg.Hub.HostID, "hub_url", cfg.Hub.URL,
"interval_s", hcfg.PollSeconds) // hub key intentionally not logged
// Reconcile (slice 4) runs alongside the hub loop, sharing the per-guest queue
// (doc 03 §10). At slice 4 the desired-state provider is empty (no hub serving
// until slice 10), so reconcile is a live no-op: it reads state and computes an
// empty action set each tick, mutating nothing. The daemon must run cleanly with
// no desired state and no signers configured — so a journal-open failure is logged
// and reconcile proceeds journal-less (it has nothing destructive to journal yet).
queue := reconcile.NewQueue()
defer queue.Close()
var journal *reconcile.Journal
if jp := reconcileJournalPath(cfg); jp != "" {
if err := os.MkdirAll(filepath.Dir(jp), 0o700); err != nil {
logger.Warn("daemon: cannot ensure journal dir; reconcile runs without a journal", "path", jp, "err", err)
} else if j, err := reconcile.OpenJournal(jp); err != nil {
logger.Warn("daemon: cannot open op journal; reconcile runs without a journal", "path", jp, "err", err)
} else {
journal = j
defer journal.Close()
}
}
// The reversibility gate (slice 4 Phase B) sits in front of every mutation. With
// no signers pinned (the common slice-4 state) the verifier is nil: benign actions
// pass, destructive intents are refused pending_signature — and reconcile only
// produces benign actions, so nothing is gated away. A misconfigured signer key is
// a security misconfig and is fatal.
verifier, nonceStore, err := buildVerifier(cfg, logger)
if err != nil {
fmt.Fprintln(os.Stderr, "daemon: authz verifier:", err)
return 2
}
if nonceStore != nil {
defer nonceStore.Close()
}
gate := reconcile.NewGate(verifier, cfg.Hub.HostID, reconcile.SlogAudit{Logger: logger}, logger)
// Signed-jobs runner (slice 10B): the consumer of the hub's signed-jobs queue. On a heartbeat
// that flags pending signed ops, it fetches each opaque blob, runs it through the gate (the
// LOCKED authz pipeline: pinned-key SSHSIG → namespace → allow-list → crypto → host → time →
// durable nonce-burn) and, only on all-pass, hands the verified op to the storage-WIPE executor
// — which re-resolves the DURABLE device id + re-inspects (8C) before mkfs. This closes the 8C
// data-bearing `pending_signature` gap. With no signers pinned the gate refuses every job
// (pending_signature) and nothing executes — correct. Wired as a second envelope observer
// alongside the desired-state syncer.
wipeExec := signedjobs.NewWipeExecutor(hostOps, logger)
jobsRunner := signedjobs.NewRunner(client, gate, wipeExec, cfg.Hub.HostID, logger)
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
// Storage watchdog (slice 5): the third daemon goroutine. Fast-polls the known target
// set for attached↔disconnected transitions → debounced out-of-band report; and, on a
// known mount-backed target's device returning unmounted, dispatches a benign re-mount
// (routed through the gate as benign, then HostOps). With no removable/network storage
// it finds nothing to flag. The re-mount dispatch is off the poll path (a goroutine).
storageTrigger := make(chan struct{}, 1)
loop.SetTrigger(storageTrigger)
remounter := &gateRemounter{gate: gate, ops: hostOps, hostID: cfg.Hub.HostID, logger: logger}
watchdog := storage.NewWatchdog(storage.WatchdogOptions{
Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()),
Liveness: storage.NewHostLiveness(hostReader, 0),
Remounter: remounter,
Trigger: func() {
select {
case storageTrigger <- struct{}{}:
default:
}
},
Interval: cfg.Storage.WatchdogInterval(),
Debounce: cfg.Storage.WatchdogDebounce(),
Logger: logger,
})
engine := reconcile.NewEngine(reconcile.EngineOptions{
API: px,
Queue: queue,
Journal: journal,
Provider: desiredProvider, // slice 10A: hub-served desired-state (empty until the hub serves intent)
Gate: gate,
HostID: cfg.Hub.HostID,
Logger: logger,
})
// Crash recovery (doc 03 §10): resolve any op that was in flight when the agent
// last died BEFORE issuing new mutations. For slice 6 this is load-bearing — a
// restore-test scratch guest leaked by a mid-test crash is torn down here.
engine.Recover(ctx)
// Self-restore-test scheduler (slice 6): the fourth daemon goroutine. Runs the restore-
// test on the configured cadence (default 24h). Disabled cleanly when the cadence is off
// OR the scratch band / restore storage is misconfigured — the daemon still runs.
scheduler := buildRestoreTestScheduler(cfg, px, engine, backupStore, logger)
// PBS verify loop (slice 6 Phase B): the fifth daemon goroutine. Cheap, key-free,
// ciphertext-level integrity check on its own cadence (default 6h), reporting per-snapshot
// verify-state. It is maintenance/reporting (NOT gated/journaled). Auto-discovers pbs
// storages from the PVE config each cycle; disabled cleanly (cadence<0) without crashing.
pbsLoop := pbs.NewVerifyLoop(pbs.VerifyLoopOptions{
Targets: pbsTargetsFromPVE(cfg, px, logger),
Store: pbsStore,
Cadence: cfg.Backup.PBSVerifyCadence(),
Logger: logger,
})
// Local API server (slice 8A, doc 03 §6): the per-guest authorization gate the in-guest
// controller calls over the bridge. Optional — runs only when local_api.enable + listen_addr
// are configured; a token-store or cert failure disables it WITHOUT killing the daemon (the
// host still reports/reconciles). The leaf is generated+persisted once so its pin is stable.
localServers := 0
var localTokens *localapi.TokenStore
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, logger, &localTokens)
if localTokens != nil {
defer localTokens.Close()
}
// LAN split-horizon resolver: the agent manages a host-side dnsmasq answering *.<customer-domain>
// with each guest's live LAN IP (so LAN clients reach the box directly — same hostname, same real
// wildcard cert — instead of hairpinning through Cloudflare). Optional; runs only when
// lan_resolver.enable is set and a host LAN IP is known (explicit or derived from local_api).
lanServers := 0
var lanLoop *lanresolver.Loop
{
lr := cfg.LANResolver.WithDefaults(cfg.LocalAPI.ListenAddr)
if lr.Enabled() && lr.HostIP != "" {
lrMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if lrMode == "" {
lrMode = proxmox.RunnerSudo
}
lrRunner := &proxmox.ExecRunner{Mode: lrMode, SudoPath: cfg.Privileged.SudoPath}
mgr := lanresolver.NewManager(lrRunner, lr.HostIP, lr.Upstreams, logger)
lanLoop = lanresolver.NewLoop(mgr, lr.StateDir, time.Duration(lr.IntervalSeconds)*time.Second, logger)
logger.Info("lanresolver: enabled", "host_ip", lr.HostIP, "upstreams", lr.Upstreams, "interval_s", lr.IntervalSeconds)
} else if lr.Enabled() {
logger.Warn("lanresolver: enabled but no host IP (set lan_resolver.host_ip or local_api.listen_addr) — disabled")
}
}
// Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS
// verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop
// concurrently; any one returning ends the daemon (ctx cancel tears down the rest).
errc := make(chan error, 7)
go func() { errc <- engine.Run(ctx, interval) }()
go func() { errc <- loop.Run(ctx) }()
go func() { errc <- watchdog.Run(ctx) }()
go func() { errc <- scheduler.Run(ctx) }()
go func() { errc <- pbsLoop.Run(ctx) }()
if localSrv != nil {
localServers = 1
go func() { errc <- localSrv.Run(ctx) }()
}
if lanLoop != nil {
lanServers = 1
go func() { errc <- lanLoop.Run(ctx) }()
}
err = <-errc
stop() // tear down the siblings on the first exit
for i := 0; i < 4+localServers+lanServers; i++ { // wait for the other goroutines
<-errc
}
if err != nil && err != context.Canceled {
logger.Error("daemon: exited with error", "err", err)
return 1
}
return 0
}
// pbsTargetsFromPVE returns a pbs.Targets closure that, each cycle, discovers the pbs
// storages from the PVE config and builds a fingerprint-pinned, token-authed client for each
// (token id from the storage `username`, secret read from <PBSSecretDir>/<id>.pw). A storage
// whose secret/client can't be built is skipped with a warning (the loop still verifies the
// rest). The secret is read at runtime, never logged.
func pbsTargetsFromPVE(cfg config.Config, px *proxmox.Client, logger *slog.Logger) pbs.Targets {
return func(ctx context.Context) ([]pbs.Target, error) {
stores, err := px.ListStorage(ctx)
if err != nil {
return nil, err
}
var targets []pbs.Target
for _, s := range stores {
if s.Type != "pbs" {
continue
}
secret, err := readTrimmed(cfg.Backup.PBSSecretPath(s.Storage))
if err != nil {
logger.Warn("pbs: cannot read token secret; skipping datastore", "storage", s.Storage, "err", err)
continue
}
c, err := pbs.NewClient(pbs.Config{Server: s.Server, Fingerprint: s.Fingerprint, TokenID: s.Username, Secret: secret})
if err != nil {
logger.Warn("pbs: cannot build client; skipping datastore", "storage", s.Storage, "err", err)
continue
}
targets = append(targets, pbs.Target{Datastore: s.Datastore, Client: c})
}
return targets, nil
}
}
// storageTier returns the restore-test source tier for a backup storage id: "pbs" when that
// storage is a PBS datastore, else "local". Best-effort (a lookup failure → "local").
func storageTier(ctx context.Context, px *proxmox.Client, storageID string) string {
stores, err := px.ListStorage(ctx)
if err != nil {
return "local"
}
for _, s := range stores {
if s.Storage == storageID && s.Type == "pbs" {
return "pbs"
}
}
return "local"
}
// readTrimmed reads a file and trims surrounding whitespace/newline (for the .pw secret).
func readTrimmed(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(b))
if s == "" {
return "", fmt.Errorf("empty file %s", path)
}
return s, nil
}
// buildRestoreTestScheduler constructs the restore-test cadence scheduler from config. It
// disables the cadence (returns a scheduler that just waits) when the cadence is off or the
// scratch band / restore storage is invalid — a misconfig must not crash the daemon, and the
// machinery still works on-demand via --selftest=restore-test.
func buildRestoreTestScheduler(cfg config.Config, px *proxmox.Client, engine *reconcile.Engine, store *backup.Store, logger *slog.Logger) *backup.Scheduler {
cadence := cfg.Backup.RestoreTestCadence()
if cadence > 0 {
if err := cfg.Backup.ValidateForRestoreTest(); err != nil {
logger.Warn("daemon: restore-test cadence disabled (config invalid)", "err", err)
cadence = 0
}
}
min, max := cfg.Backup.ScratchBand()
runner := backup.NewBackupRunner(px, cfg.Backup.LocalBackupTarget, "", "felhom restore-test", logger)
return backup.NewScheduler(backup.SchedulerOptions{
Runner: engine,
Pick: runner.PickRestoreCandidate,
Store: store,
Spec: reconcile.RestoreTestSpec{
RestoreStorage: cfg.Backup.RestoreStorage,
ScratchMin: min,
ScratchMax: max,
SourceTier: storageTier(context.Background(), px, cfg.Backup.LocalBackupTarget),
},
Cadence: cadence,
Logger: logger,
})
}
// buildLocalAPIServer constructs the per-guest local-API server (slice 8A, doc 03 §6) when
// configured. It opens the durable hashed token store and ensures the persisted self-signed
// leaf (stable fingerprint). Any failure DISABLES the server (returns nil) WITHOUT crashing the
// daemon — the host still reports/reconciles; only the controller channel is unavailable until
// fixed. The opened token store is returned via outTokens so the caller can Close it.
func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.Store, observer *storage.Observer, hostOps storage.HostOps, gate *reconcile.Gate, collector *hub.Collector, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server {
if !cfg.LocalAPI.Enabled() {
return nil
}
if err := cfg.LocalAPI.Validate(); err != nil {
logger.Warn("daemon: local-api disabled (config invalid)", "err", err)
return nil
}
tokens, err := localapi.OpenTokenStore(cfg.LocalAPI.TokenStorePath())
if err != nil {
logger.Warn("daemon: local-api disabled (token store)", "err", err)
return nil
}
*outTokens = tokens
host, _, _ := net.SplitHostPort(cfg.LocalAPI.ListenAddr)
cert, fp, err := localapi.EnsureLeaf(cfg.LocalAPI.CertPath(), cfg.LocalAPI.KeyPath(), host)
if err != nil {
logger.Warn("daemon: local-api disabled (leaf cert)", "err", err)
return nil
}
logger.Info("local-api leaf ready", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath())
runner := backup.NewBackupRunner(px, cfg.Backup.LocalBackupTarget, "", "felhom local-api", logger)
// Guest data-drive passthrough (slice 10 P2): a root-CLI runner for the `pct set` bind + chown
// (same fenced ExecRunner the host-storage + provision back-half use).
gaMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if gaMode == "" {
gaMode = proxmox.RunnerSudo
}
guestBinder := localapi.NewGuestBinder(&proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, logger)
srv, err := localapi.NewServer(localapi.Options{
ListenAddr: cfg.LocalAPI.ListenAddr,
Cert: cert,
Guests: px,
Backups: runner,
Store: store,
Storage: observer,
Tokens: tokens,
BackupCadence: cfg.Backup.BackupCadence(),
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
Disks: hostOps,
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
Guests2: px,
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).
HostMetrics: collector,
HostID: cfg.Hub.HostID, // slice 10B: anti-retarget host in the data-bearing-format pending-op
Logger: logger,
})
if err != nil {
logger.Warn("daemon: local-api disabled (server build)", "err", err)
return nil
}
return srv
}
// storageGateAdapter bridges the local-API disk-format path to the reversibility gate, TIERED by the
// agent's authoritative device-role verdict: a USER-DATA data-bearing wipe is customer-confirmable
// (durable-id-bound, no signature); a SYSTEM/BACKUP wipe stays operator-signature (unsigned →
// pending_signature; the signed completion runs via the signed-jobs runner).
type storageGateAdapter struct {
gate *reconcile.Gate
hostID string
}
func (a storageGateAdapter) AuthorizeWipe(req localapi.WipeRequest) localapi.WipeDecision {
dec := a.gate.AuthorizeStorageWipe(reconcile.StorageWipeAuthz{
HostID: a.hostID,
Role: req.Role,
DeviceDurableID: req.DeviceDurableID,
Confirmed: req.Confirmed,
ConfirmDurableID: req.ConfirmDurableID,
}, nil) // no operator signature on the inline path → system/backup is pending_signature
tier := "destructive"
if dec.Disposition == reconcile.CustomerConfirmable {
tier = "customer_confirmable"
}
return localapi.WipeDecision{
Allowed: dec.Allowed,
Tier: tier,
Reason: string(dec.Reason),
NeedsConfirmation: dec.Reason == reconcile.ReasonPendingConfirmation,
}
}
// reconcileJournalPath chooses the op-journal path: a `journal.log` sibling of the
// configured nonce store (both are durable agent state), falling back to the standard
// host state dir when the nonce store is unset.
func reconcileJournalPath(cfg config.Config) string {
if p := cfg.Authz.NonceStorePath; p != "" {
return filepath.Join(filepath.Dir(p), "journal.log")
}
return "/var/lib/felhom-agent/journal.log"
}
// buildVerifier constructs the operator-signed-op verifier from config. With no signers
// pinned it returns (nil, nil, nil) — the gate then refuses any destructive intent as
// pending_signature (and reconcile serves none). With signers it requires a durable
// nonce-store path (anti-replay must survive restarts) and parses every pinned key —
// a bad key or missing path is a fatal security misconfig.
func buildVerifier(cfg config.Config, logger *slog.Logger) (reconcile.OpVerifier, *authz.FileNonceStore, error) {
if len(cfg.Authz.Signers) == 0 {
logger.Info("daemon: no operator signers pinned; destructive ops will be refused pending_signature")
return nil, nil, nil
}
if cfg.Authz.NonceStorePath == "" {
return nil, nil, fmt.Errorf("authz.nonce_store_path is required when signers are configured")
}
signers := make([]authz.AllowedSigner, 0, len(cfg.Authz.Signers))
for _, s := range cfg.Authz.Signers {
as, err := authz.NewAllowedSigner(s.KeyID, authz.KeyRole(s.Role), s.PublicKey)
if err != nil {
return nil, nil, fmt.Errorf("pinned signer %q: %w", s.KeyID, err)
}
signers = append(signers, as)
}
store, err := authz.OpenFileNonceStore(cfg.Authz.NonceStorePath)
if err != nil {
return nil, nil, fmt.Errorf("nonce store: %w", err)
}
logger.Info("daemon: operator signers pinned", "count", len(signers))
return authz.New(signers, store, cfg.Hub.HostID), store, nil
}
// runSelftestHub validates hub config, does ONE collect + report, and prints the
// report it would send plus the envelope it got back.
func runSelftestHub(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
if err := cfg.Hub.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: hub not configured:", err)
return 1
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
client, err := hub.NewClient(cfg.Hub, logger)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: hub client:", err)
return 1
}
observer := storage.NewObserver(px, storage.NewProcHostReader(), newHostOps(cfg, logger), logger)
collector := hub.NewCollector(px, hub.SystemctlProber{}, observer, nil, nil, nil, cfg.Hub.HostID, version, logger)
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
fmt.Printf("=== felhom-agent %s selftest=hub (host_id=%s url=%s) ===\n", version, cfg.Hub.HostID, cfg.Hub.URL)
report, err := collector.Collect(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] collect:", err)
return 1
}
if b, e := json.MarshalIndent(report, " ", " "); e == nil {
fmt.Println(" --- report it would send ---")
fmt.Println(" " + string(b))
}
env, err := client.Report(ctx, report)
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] report:", err)
return 1
}
if b, e := json.MarshalIndent(env, " ", " "); e == nil {
fmt.Println(" --- control envelope received ---")
fmt.Println(" " + string(b))
}
fmt.Println("=== selftest=hub OK ===")
return 0
}
// runSelftestStorage is the live storage harness (slice 5 Phase B, for the USB runbook).
// It needs Proxmox config only (NO hub) so it runs standalone on the Proxmox host:
// - observe pass: print the full StorageTarget table incl. the privileged SMART summary
// and thin-pool data+metadata fill.
// - -watch D: run the watchdog verbose for D with the re-mount response LIVE, so the
// operator can physically cycle a drive and watch detect → report → re-mount.
func runSelftestStorage(ctx context.Context, cfg config.Config, logger *slog.Logger, watch time.Duration) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
hostReader := storage.NewProcHostReader()
hostOps := newHostOps(cfg, logger)
observer := storage.NewObserver(px, hostReader, hostOps, logger)
octx, cancel := context.WithTimeout(ctx, 60*time.Second)
fmt.Printf("=== felhom-agent %s selftest=storage ===\n", version)
targets, err := observer.Observe(octx)
cancel()
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] observe:", err)
return 1
}
fmt.Printf(" observed %d storage target(s):\n", len(targets))
for _, t := range targets {
fmt.Printf(" - %-12s type=%-9s state=%-12s reach=%-5v class=%-4s durable=%s\n",
t.Name, t.Type, t.State, t.Reachable, t.ClassHint, t.DurableID)
fmt.Printf(" usage %s/%s (%.0f%%) mount=%q dev=%q\n",
gib(t.UsedBytes), gib(t.TotalBytes), t.UsedFraction*100, t.MountPath, t.BackingDevice)
fmt.Printf(" smart: health=%s%s\n", t.Smart.Health, smartCounters(t.Smart))
if t.ThinPool != nil {
meta := "n/a"
if t.ThinPool.MetadataUsedFraction != nil {
meta = fmt.Sprintf("%.1f%%", *t.ThinPool.MetadataUsedFraction*100)
}
fmt.Printf(" thin-pool: data=%.1f%% metadata=%s\n", t.ThinPool.DataUsedFraction*100, meta)
}
}
if watch <= 0 {
fmt.Println("=== selftest=storage OK (observe pass; pass -watch D for the live watchdog) ===")
return 0
}
// Live watchdog window: re-mount response active. Cycle a drive and watch the logs.
fmt.Printf(" --- watching for %s (cycle a drive now; detect → report → re-mount) ---\n", watch)
wctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
wctx, cancel2 := context.WithTimeout(wctx, watch)
defer cancel2()
gate := reconcile.NewGate(nil, cfg.Hub.HostID, reconcile.SlogAudit{Logger: logger}, logger)
remounter := &gateRemounter{gate: gate, ops: hostOps, hostID: cfg.Hub.HostID, logger: logger}
wd := storage.NewWatchdog(storage.WatchdogOptions{
Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()),
Liveness: storage.NewHostLiveness(hostReader, 0),
Remounter: remounter,
Trigger: func() { logger.Info("storage: (selftest) would send out-of-band host-report now") },
Interval: cfg.Storage.WatchdogInterval(),
Debounce: cfg.Storage.WatchdogDebounce(),
Logger: logger,
})
_ = wd.Run(wctx)
fmt.Println("=== selftest=storage watch window ended ===")
return 0
}
// smartCounters renders the non-nil SMART counters compactly for the selftest table.
func smartCounters(s hub.SmartSummary) string {
var parts []string
add := func(name string, v *int) {
if v != nil {
parts = append(parts, fmt.Sprintf("%s=%d", name, *v))
}
}
add("temp", s.TemperatureC)
add("poh", s.PowerOnHours)
add("realloc", s.ReallocatedSectors)
add("pending", s.PendingSectors)
add("offline_unc", s.OfflineUncorrectable)
add("crit_warn", s.CriticalWarning)
add("media_err", s.MediaErrors)
add("pct_used", s.PercentageUsed)
if len(parts) == 0 {
return ""
}
return " " + strings.Join(parts, " ")
}
// runSelftestBackup runs one vzdump of -vmid to the configured local backup target and
// prints the resulting Backup record. Standalone (no hub). Live + benign.
func runSelftestBackup(ctx context.Context, cfg config.Config, logger *slog.Logger, vmid int) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
if vmid == 0 {
fmt.Fprintln(os.Stderr, "selftest=backup requires -vmid N")
return 2
}
if cfg.Backup.LocalBackupTarget == "" {
fmt.Fprintln(os.Stderr, "selftest=backup requires backup.local_backup_target in config (a content=backup storage)")
return 2
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
fmt.Printf("=== felhom-agent %s selftest=backup (vmid %d → %s) ===\n", version, vmid, cfg.Backup.LocalBackupTarget)
runner := backup.NewBackupRunner(px, cfg.Backup.LocalBackupTarget, "", "felhom selftest", logger)
rec, err := runner.Backup(ctx, vmid)
printJSON("backup record", rec)
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] backup:", err)
return 1
}
fmt.Printf("=== selftest=backup OK (crash-consistent=%v, archive=%s) ===\n", rec.CrashConsistent, rec.Archive)
return 0
}
// runSelftestRestoreTest runs one self-restore-test (restore → net-link-down → boot → verify
// running → teardown) of -archive (or the newest backup on the local target) into a scratch
// guest. Standalone (no hub). Runs engine.Recover first so a leaked scratch from a prior
// crashed test is reaped before this run.
func runSelftestRestoreTest(ctx context.Context, cfg config.Config, logger *slog.Logger, archive string) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
if err := cfg.Backup.ValidateForRestoreTest(); err != nil {
fmt.Fprintln(os.Stderr, "selftest=restore-test:", err)
return 2
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
// Build a journaled engine so a leaked scratch from a prior crash is recoverable.
queue := reconcile.NewQueue()
defer queue.Close()
var journal *reconcile.Journal
if jp := reconcileJournalPath(cfg); jp != "" {
if err := os.MkdirAll(filepath.Dir(jp), 0o700); err == nil {
if j, err := reconcile.OpenJournal(jp); err == nil {
journal = j
defer journal.Close()
}
}
}
gate := reconcile.NewGate(nil, cfg.Hub.HostID, reconcile.SlogAudit{Logger: logger}, logger)
engine := reconcile.NewEngine(reconcile.EngineOptions{
API: px, Queue: queue, Journal: journal, Gate: gate, HostID: cfg.Hub.HostID, Logger: logger,
})
fmt.Printf("=== felhom-agent %s selftest=restore-test ===\n", version)
fmt.Println(" --- recover: reaping any leaked scratch from a prior crashed test ---")
rec := engine.Recover(ctx)
fmt.Printf(" recover: examined=%d scratch_destroyed=%d scratch_clean=%d\n", rec.Examined, rec.ScratchDestroyed, rec.ScratchClean)
if archive == "" {
runner := backup.NewBackupRunner(px, cfg.Backup.LocalBackupTarget, "", "", logger)
archive, err = runner.PickRestoreCandidate(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] pick backup:", err)
return 1
}
if archive == "" {
fmt.Fprintln(os.Stderr, " [FAIL] no backup available on", cfg.Backup.LocalBackupTarget, "(run --selftest=backup first)")
return 1
}
}
min, max := cfg.Backup.ScratchBand()
fmt.Printf(" restoring %s into scratch band [%d,%d] on %s …\n", archive, min, max, cfg.Backup.RestoreStorage)
res := engine.RunRestoreTest(ctx, reconcile.RestoreTestSpec{
Archive: archive, RestoreStorage: cfg.Backup.RestoreStorage,
ScratchMin: min, ScratchMax: max, SourceTier: storageTier(ctx, px, cfg.Backup.LocalBackupTarget),
})
printJSON("restore-test record", backup.ToHubRestoreTest(res, time.Now().UTC()))
if res.Skipped {
fmt.Println("=== selftest=restore-test SKIPPED (no free scratch VMID in band) ===")
return 0
}
if res.Err != nil || !res.Pass {
fmt.Fprintf(os.Stderr, " [FAIL] restore-test (scratch %d): %v\n", res.ScratchVMID, res.Err)
return 1
}
fmt.Printf("=== selftest=restore-test OK (scratch %d restored+booted+verified+torn-down in %s) ===\n", res.ScratchVMID, res.Duration.Round(time.Second))
return 0
}
// runSelftestBringUp runs the REAL journaled bring-up job (slice 7) on-demand: restore →
// reset identity → size → attach mounts → start link-up, then tears the guest down by default
// (a selftest must not leave a guest running) unless -keep. -mode picks provision (golden, fresh
// identity) or dr (customer backup, preserve continuity). It first Recovers, so a leaked guest
// from a prior crashed bring-up is reaped before this run.
func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Logger, mode, archive string, vmid int, hostname string, keep bool) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
var bmode reconcile.BringUpMode
switch mode {
case "provision":
bmode = reconcile.ModeProvision
case "dr":
bmode = reconcile.ModeDRGuestLoss
default:
fmt.Fprintf(os.Stderr, "selftest=bring-up: -mode must be provision|dr (got %q)\n", mode)
return 2
}
if archive == "" || vmid <= 0 {
fmt.Fprintln(os.Stderr, "selftest=bring-up requires -archive <volid> and -vmid <N>")
return 2
}
if cfg.Backup.RestoreStorage == "" {
fmt.Fprintln(os.Stderr, "selftest=bring-up requires backup.restore_storage in config")
return 2
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
queue := reconcile.NewQueue()
defer queue.Close()
var journal *reconcile.Journal
if jp := reconcileJournalPath(cfg); jp != "" {
if err := os.MkdirAll(filepath.Dir(jp), 0o700); err == nil {
if j, err := reconcile.OpenJournal(jp); err == nil {
journal = j
defer journal.Close()
}
}
}
gate := reconcile.NewGate(nil, cfg.Hub.HostID, reconcile.SlogAudit{Logger: logger}, logger)
engine := reconcile.NewEngine(reconcile.EngineOptions{
API: px, Queue: queue, Journal: journal, Gate: gate, HostID: cfg.Hub.HostID, Logger: logger,
})
fmt.Printf("=== felhom-agent %s selftest=bring-up (mode=%s vmid=%d) ===\n", version, mode, vmid)
fmt.Println(" --- recover: reaping any half-built guest from a prior crashed bring-up ---")
rec := engine.Recover(ctx)
fmt.Printf(" recover: examined=%d bring_up_rolled_back=%d bring_up_clean=%d scratch_destroyed=%d\n",
rec.Examined, rec.BringUpRolledBack, rec.BringUpClean, rec.ScratchDestroyed)
spec := reconcile.BringUpSpec{
Mode: bmode, Archive: archive, VMID: vmid, RestoreStorage: cfg.Backup.RestoreStorage,
Hostname: hostname, KeepMAC: bmode == reconcile.ModeDRGuestLoss,
}
fmt.Printf(" bringing up %s → vmid %d on %s …\n", archive, vmid, cfg.Backup.RestoreStorage)
res := engine.RunBringUp(ctx, spec)
printJSON("bring-up record", res)
if res.Err != nil || !res.Pass {
fmt.Fprintf(os.Stderr, " [FAIL] bring-up (vmid %d): %v\n", vmid, res.Err)
// On failure the job already compensating-rolled-back; nothing to tear down.
return 1
}
fmt.Printf(" [OK] vmid %d up (boot+running) in %s; MAC=%s\n", res.VMID, res.Duration.Round(time.Second), res.AssignedMAC)
if len(res.StartWarnings) > 0 {
fmt.Printf(" start warnings (recognized=%v): %v\n", res.WarningsRecognized, res.StartWarnings)
}
if keep {
fmt.Printf("=== selftest=bring-up OK — guest %d KEPT (-keep) ===\n", vmid)
return 0
}
// A selftest must not leave a guest running. Tear it down (out-of-band, like the spike).
fmt.Printf(" --- teardown: destroying selftest guest %d ---\n", vmid)
upid, err := px.DestroyLXC(ctx, vmid)
if err != nil {
fmt.Fprintf(os.Stderr, " [WARN] teardown destroy: %v (destroy vmid %d manually)\n", err, vmid)
return 1
}
if upid != "" {
if _, err := px.WaitTask(ctx, upid, proxmox.WaitOptions{}); err != nil {
fmt.Fprintf(os.Stderr, " [WARN] teardown destroy task: %v (check vmid %d)\n", err, vmid)
return 1
}
}
fmt.Printf("=== selftest=bring-up OK (vmid %d brought up, verified, torn down) ===\n", vmid)
return 0
}
// provisionArgs bundles the --selftest=provision inputs.
type provisionArgs struct {
archive string
vmid int
hostname string
customerID string // baked into bootstrap (the hub config-pull target)
hubPassword string // the customer's hub retrieval passphrase (SECRET) — baked into bootstrap
}
// sanitizeHostname makes s a DNS-safe LXC hostname (RFC 1123 label-ish): lowercase, any run of
// invalid characters collapses to a single '-', leading/trailing '-' stripped, capped at 63 chars.
// Returns "" if nothing usable remains (caller then sets no hostname). PVE itself validates, but a
// customer id can legitimately contain characters (e.g. '_' or spaces) that a hostname cannot.
func sanitizeHostname(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
prevDash := false
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
prevDash = false
} else if !prevDash && b.Len() > 0 {
b.WriteByte('-')
prevDash = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// runSelftestProvision runs the FULL slice-8A provisioning chain on-demand: the slice-7 bring-up
// FRONT half (provision mode, golden) + the slice-8A BACK half (mint per-guest token → render the
// stable bootstrap.json → write 0600 → chown to the mapped guest-root → attach the read-only bind
// mount). The guest is KEPT (the golden's baked controller-bootstrap unit then deploys the baked
// controller from the mount). The per-guest token is NEVER printed (only its hash is persisted +
// the 0600 file holds the plaintext).
func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.Logger, a provisionArgs) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
if a.archive == "" || a.vmid <= 0 {
fmt.Fprintln(os.Stderr, "selftest=provision requires -archive <golden volid> and -vmid <N>")
return 2
}
if cfg.Backup.RestoreStorage == "" {
fmt.Fprintln(os.Stderr, "selftest=provision requires backup.restore_storage in config")
return 2
}
if a.customerID == "" || a.hubPassword == "" {
fmt.Fprintln(os.Stderr, "selftest=provision requires -customer-id and -hub-password (the customer's hub retrieval passphrase, so the controller pulls its config)")
return 2
}
if err := cfg.LocalAPI.Validate(); err != nil || !cfg.LocalAPI.Enabled() {
fmt.Fprintln(os.Stderr, "selftest=provision requires local_api.enable + local_api.listen_addr in config:", err)
return 2
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
// The leaf fingerprint baked into the bootstrap must be the SAME leaf the daemon's local-API
// server serves — so use the configured (persisted) cert path. EnsureLeaf generates it once.
host, _, _ := net.SplitHostPort(cfg.LocalAPI.ListenAddr)
_, fingerprint, err := localapi.EnsureLeaf(cfg.LocalAPI.CertPath(), cfg.LocalAPI.KeyPath(), host)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest=provision: local-api leaf:", err)
return 1
}
tokens, err := localapi.OpenTokenStore(cfg.LocalAPI.TokenStorePath())
if err != nil {
fmt.Fprintln(os.Stderr, "selftest=provision: token store:", err)
return 1
}
defer tokens.Close()
// --- FRONT HALF: bring up the guest (provision mode) ---
queue := reconcile.NewQueue()
defer queue.Close()
var journal *reconcile.Journal
if jp := reconcileJournalPath(cfg); jp != "" {
if err := os.MkdirAll(filepath.Dir(jp), 0o700); err == nil {
if j, err := reconcile.OpenJournal(jp); err == nil {
journal = j
defer journal.Close()
}
}
}
gate := reconcile.NewGate(nil, cfg.Hub.HostID, reconcile.SlogAudit{Logger: logger}, logger)
engine := reconcile.NewEngine(reconcile.EngineOptions{
API: px, Queue: queue, Journal: journal, Gate: gate, HostID: cfg.Hub.HostID, Logger: logger,
})
// Default the guest hostname to the customer id (DNS-safe-sanitized) when not explicitly given,
// so the CT/LXC is named meaningfully (e.g. "demo-felhom") instead of inheriting the golden's
// baked "felhom-golden". An explicit -hostname always wins.
hostname := a.hostname
if hostname == "" {
hostname = sanitizeHostname(a.customerID)
}
fmt.Printf("=== felhom-agent %s selftest=provision (vmid=%d customer=%s hostname=%s) ===\n", version, a.vmid, a.customerID, hostname)
engine.Recover(ctx)
fmt.Printf(" --- front half: bring-up (provision) %s → vmid %d ---\n", a.archive, a.vmid)
res := engine.RunBringUp(ctx, reconcile.BringUpSpec{
Mode: reconcile.ModeProvision, Archive: a.archive, VMID: a.vmid,
RestoreStorage: cfg.Backup.RestoreStorage, Hostname: hostname,
})
if res.Err != nil || !res.Pass {
fmt.Fprintf(os.Stderr, " [FAIL] front-half bring-up (vmid %d): %v\n", a.vmid, res.Err)
return 1
}
fmt.Printf(" [OK] front half: vmid %d up (boot+running) in %s; MAC=%s\n", res.VMID, res.Duration.Round(time.Second), res.AssignedMAC)
// --- BACK HALF: mint token + populate the bootstrap config mount (host-side, no pct exec) ---
mode := proxmox.RunnerMode(cfg.Privileged.Mode)
if mode == "" {
mode = proxmox.RunnerSudo
}
runner := &proxmox.ExecRunner{Mode: mode, SudoPath: cfg.Privileged.SudoPath}
bh := provision.NewBackHalf(tokens, runner, "", logger)
fmt.Println(" --- back half: mint per-guest token + populate bootstrap config mount ---")
pres, err := bh.Provision(ctx, provision.Input{
VMID: a.vmid,
Customer: provision.DocCustomer{ID: a.customerID},
Hub: provision.DocHub{URL: cfg.Hub.URL, RetrievalPassword: a.hubPassword},
Endpoint: cfg.LocalAPI.ListenAddr,
Fingerprint: fingerprint,
})
if err != nil {
fmt.Fprintf(os.Stderr, " [FAIL] back-half provision (vmid %d): %v\n", a.vmid, err)
return 1
}
fmt.Printf(" [OK] back half: bootstrap mount %s → %s on vmid %d (host dir %s)\n",
pres.MountKey, pres.GuestPath, pres.VMID, pres.HostDir)
fmt.Printf(" local-api endpoint %s · leaf fp %s · token: minted (not printed)\n", cfg.LocalAPI.ListenAddr, fingerprint)
fmt.Printf("=== selftest=provision OK — guest %d provisioned + bootstrap-mounted (KEPT) ===\n", a.vmid)
fmt.Println(" next: reboot the guest → the golden's baked controller-bootstrap unit deploys the controller,")
fmt.Println(" which PULLS its controller.yaml from the hub (retrieval passphrase) and merges in this local_api.")
return 0
}
// runSelftestEscrowCreate creates the PBS recovery-code escrow (slice 7, doc 03 §8a): generate R,
// wrap the live PBS key under R (zero-knowledge), self-verify recoverability, and emit the opaque
// blob. R is surfaced to stdout EXACTLY ONCE (never to the logger/journald). With -upload it PUTs
// the opaque blob to the hub. Enrollment-time, root-capable (reads the 0600 key).
func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool, identityBundlePath, directivePath string) int {
if storage == "" {
storage = cfg.Escrow.PBSStorageID
}
if storage == "" {
fmt.Fprintln(os.Stderr, "selftest=escrow-create requires -storage <pbs-storage-id> (or escrow.pbs_storage_id)")
return 2
}
keyPath := cfg.Backup.PBSEncKeyPath(storage)
if _, err := os.Stat(keyPath); err != nil {
fmt.Fprintf(os.Stderr, "selftest=escrow-create: PBS key for %q not found (%s): %v\n", storage, keyPath, err)
return 1
}
// Slice 10D.1: optionally ALSO wrap the identity bundle under the same R, and carry the non-secret
// directive for the hub. The bundle file is a 0600 secret (tunnel/pbs tokens); the directive is
// non-secret (pbs repo/ns, expected fingerprint, tunnel id).
var identity *escrow.IdentityBundle
var directive json.RawMessage
if identityBundlePath != "" {
raw, err := os.ReadFile(identityBundlePath)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest=escrow-create: reading identity bundle %s: %v\n", identityBundlePath, err)
return 1
}
var b escrow.IdentityBundle
if err := json.Unmarshal(raw, &b); err != nil {
fmt.Fprintf(os.Stderr, "selftest=escrow-create: identity bundle is not valid JSON {tunnel_token,pbs_token}: %v\n", err)
return 1
}
identity = &b
if directivePath != "" {
if d, err := os.ReadFile(directivePath); err == nil && json.Valid(d) {
directive = d
}
}
}
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil)
// NB: nothing about R is logged. The logger never sees R; only stdout does, once.
logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath, "with_identity", identity != nil)
R, res, err := escrow.Create(ctx, escrow.CreateOptions{
KeyPath: keyPath,
Posture: escrow.Posture(cfg.Escrow.Posture),
WantOfflineCopy: offline,
WantPaperkey: paperkey,
IdentityBundle: identity,
})
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err)
return 1
}
// Surface R EXACTLY ONCE — to stdout, with a write-it-down banner. Never logged/persisted.
fmt.Println()
fmt.Println(" ┌──────────────────────────────────────────────────────────────────────┐")
fmt.Println(" │ RECOVERY CODE — write it down now. It is shown ONCE and never stored. │")
fmt.Println(" │ Without it your offsite backups are unrecoverable, by anyone. │")
fmt.Println(" └──────────────────────────────────────────────────────────────────────┘")
fmt.Println(" " + R)
fmt.Println()
R = "" // drop our reference promptly
fmt.Printf(" blob: %d bytes (opaque, R-wrapped) · key fingerprint %s · posture %s · ~%.0f bits R\n",
len(res.Blob), res.KeyFingerprint, res.Posture, res.EntropyBits)
fmt.Println(" self-verify: the blob unwraps back to the key with R (recoverability confirmed)")
if offline && len(res.OfflineCopy) > 0 {
fmt.Println(" --- (b) R-wrapped OFFLINE COPY (print + store; still needs R) ---")
fmt.Println(base64.StdEncoding.EncodeToString(res.OfflineCopy))
}
if paperkey && res.Paperkey != "" {
fmt.Println(" --- (a) RAW PAPERKEY — single-factor, UNREVOCABLE. Store in a safe only. ---")
fmt.Println(res.Paperkey)
}
if len(res.IdentityBlob) > 0 {
fmt.Printf(" identity escrow: %d bytes (age-wrapped {tunnel,pbs} under the same R) · self-verify OK\n", len(res.IdentityBlob))
}
if upload {
if err := uploadEscrowBlob(ctx, cfg, res, directive); err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err)
return 1
}
fmt.Println(" uploaded the opaque blob(s) to the hub (host record); the hub cannot open them")
}
fmt.Println("=== selftest=escrow-create OK ===")
return 0
}
// runSelftestEscrowConsume exercises the slice-10C production Consume path live: recover K from an
// R-wrapped blob, gate it on the expected fingerprint, install it at -keydest. R is taken BY HAND
// from the env var FELHOM_RECOVERY_CODE (kept off the command line / ps) — never a flag, never
// logged. The other three inputs (blob/fingerprint/keydest) are flags (10D sources blob+fingerprint
// from the hub directive). This is the real Consume code, not a throwaway harness.
func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath, expectedFP, keyDest string) int {
if blobPath == "" || expectedFP == "" || keyDest == "" {
fmt.Fprintln(os.Stderr, "selftest=escrow-consume requires -blob, -fingerprint and -keydest (R via env FELHOM_RECOVERY_CODE)")
return 2
}
R := os.Getenv("FELHOM_RECOVERY_CODE")
if R == "" {
fmt.Fprintln(os.Stderr, "selftest=escrow-consume: set the recovery code in env FELHOM_RECOVERY_CODE (by-hand input; never a flag/arg)")
return 2
}
blob, err := os.ReadFile(blobPath)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest=escrow-consume: reading blob %s: %v\n", blobPath, err)
return 1
}
fmt.Printf("=== felhom-agent %s selftest=escrow-consume (blob=%s → %s) ===\n", version, blobPath, keyDest)
logger.Info("escrow: consuming R-wrapped escrow (Unwrap → fingerprint-gate → install)",
"blob_bytes", len(blob), "key_dest", keyDest) // R is NOT logged
if err := escrow.Consume(ctx, blob, R, expectedFP, keyDest); err != nil {
R = "" // drop the reference
fmt.Fprintln(os.Stderr, " [FAIL] consume:", err) // the error never contains R or key bytes
return 1
}
R = "" // drop the reference promptly
fmt.Printf(" [OK] recovered key installed at %s (fingerprint-gated, 0600) — ready for the PBS restore\n", keyDest)
fmt.Println("=== selftest=escrow-consume OK ===")
return 0
}
// runSelftestIdentityConsume recovers the IDENTITY bundle from its age blob with R (slice 10D.1/10D.3)
// and writes the recovered {tunnel_token, pbs_token} JSON to -keydest (0600). R is taken BY HAND from
// FELHOM_RECOVERY_CODE (off the command line); the recovered tokens are never logged. The drill then
// uses the tunnel token to re-establish the tunnel + the pbs token for steady-state.
func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPath, keyDest string) int {
if blobPath == "" || keyDest == "" {
fmt.Fprintln(os.Stderr, "selftest=identity-consume requires -blob and -keydest (R via env FELHOM_RECOVERY_CODE)")
return 2
}
R := os.Getenv("FELHOM_RECOVERY_CODE")
if R == "" {
fmt.Fprintln(os.Stderr, "selftest=identity-consume: set the recovery code in env FELHOM_RECOVERY_CODE (by-hand input)")
return 2
}
blob, err := os.ReadFile(blobPath)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest=identity-consume: reading blob %s: %v\n", blobPath, err)
return 1
}
fmt.Printf("=== felhom-agent %s selftest=identity-consume (blob=%s → %s) ===\n", version, blobPath, keyDest)
logger.Info("escrow: recovering identity bundle from R-wrapped age blob", "blob_bytes", len(blob)) // R + tokens NOT logged
bundle, err := escrow.UnwrapIdentityBundle(ctx, blob, R)
if err != nil {
R = ""
fmt.Fprintln(os.Stderr, " [FAIL] identity consume:", err) // never contains R or token bytes
return 1
}
R = ""
raw, _ := json.Marshal(bundle)
if err := os.WriteFile(keyDest, raw, 0o600); err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] writing recovered bundle:", err)
return 1
}
fmt.Printf(" [OK] identity recovered (tunnel_token + pbs_token) → %s (0600) — never printed\n", keyDest)
fmt.Println("=== selftest=identity-consume OK ===")
return 0
}
// escrowUploadRequest is the agent→hub wire shape for the opaque escrow blob. MUST stay in lockstep
// with the hub's ingest struct (felhom-hub api.escrowUploadRequest). The hub stores the bytes and
// never decrypts them.
type escrowUploadRequest struct {
BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext)
KeyFingerprint string `json:"key_fingerprint"` // for operator display only
Posture string `json:"posture"` // e.g. "zero_knowledge"
// Slice 10D.1 — optional DR bundle (identity escrow + non-secret directive). Omitted in slice-7.
IdentityBlobB64 string `json:"identity_blob_b64,omitempty"`
DirectiveJSON json.RawMessage `json:"directive,omitempty"`
CreatedAt string `json:"created_at"` // RFC3339
}
// uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob + non-secret directive) to
// the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable
// secret leaves the agent.
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage) error {
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
return fmt.Errorf("hub not configured (url/host_id/api_key)")
}
upReq := escrowUploadRequest{
BlobB64: base64.StdEncoding.EncodeToString(res.Blob),
KeyFingerprint: res.KeyFingerprint,
Posture: string(res.Posture),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if len(res.IdentityBlob) > 0 {
upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob)
upReq.DirectiveJSON = directive
}
body, _ := json.Marshal(upReq)
url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow"
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.Hub.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("hub returned HTTP %d", resp.StatusCode)
}
return nil
}
// runSelftestPBSVerify discovers the pbs storages, triggers a verify on each (the new §2
// path), then lists + prints the resulting PBSSnapshot records (verify-state included).
// Standalone on the host. Covers the runbook's (c) verify and (d) list.
func runSelftestPBSVerify(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
fmt.Printf("=== felhom-agent %s selftest=pbs-verify ===\n", version)
targets, err := pbsTargetsFromPVE(cfg, px, logger)(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] discover pbs storages:", err)
return 1
}
if len(targets) == 0 {
fmt.Println(" no pbs storages configured on this host")
return 0
}
store := pbs.NewSnapshotStore()
loop := pbs.NewVerifyLoop(pbs.VerifyLoopOptions{
Targets: func(context.Context) ([]pbs.Target, error) { return targets, nil },
Store: store,
Logger: logger,
})
// One synchronous verify+list pass over all datastores.
loop.RunOnce(ctx)
snaps := store.PBSSnapshots(ctx)
printJSON(fmt.Sprintf("%d pbs snapshot record(s)", len(snaps)), snaps)
failed := 0
for _, s := range snaps {
if s.VerifyState == pbs.VerifyFailed {
failed++
}
}
if failed > 0 {
fmt.Fprintf(os.Stderr, "=== selftest=pbs-verify: %d FAILED-verify snapshot(s) ===\n", failed)
return 1
}
fmt.Printf("=== selftest=pbs-verify OK (%d snapshot(s) across %d datastore(s)) ===\n", len(snaps), len(targets))
return 0
}
// printJSON prints a labelled, indented JSON dump (best-effort) to stdout.
func printJSON(label string, v any) {
if b, err := json.MarshalIndent(v, " ", " "); err == nil {
fmt.Printf(" --- %s ---\n %s\n", label, string(b))
}
}
// runSelftestRead loads config, builds the API client, and runs the read-only
// queries against the live host, printing a short health report. It mutates
// nothing. Missing/invalid config is reported cleanly (no panic).
func runSelftestRead(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: not configured:", err)
return 1
}
logger.Info("selftest (read-only) starting", "config", fmt.Sprintf("%+v", cfg.Redacted().Proxmox))
client, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: client init:", err)
return 1
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
fmt.Printf("=== felhom-agent %s selftest (read-only) ===\n", version)
fmt.Printf("endpoint : %s node=%s\n", cfg.Proxmox.Endpoint, cfg.Proxmox.Node)
fail := 0
report := func(label string, err error) bool {
if err != nil {
fmt.Printf(" [FAIL] %-14s %v\n", label, err)
fail++
return false
}
return true
}
if v, err := client.Version(ctx); report("version", err) {
fmt.Printf(" [ ok ] %-14s PVE %s (release %s)\n", "version", v.Version, v.Release)
}
if nodes, err := client.Nodes(ctx); report("nodes", err) {
fmt.Printf(" [ ok ] %-14s %d node(s)\n", "nodes", len(nodes))
for _, n := range nodes {
marker := " "
if n.Node == cfg.Proxmox.Node {
marker = "* "
}
fmt.Printf(" %s%s status=%s fp=%s…\n", marker, n.Node, n.Status, head(n.SSLFingerprint, 17))
}
}
if s, err := client.NodeStatus(ctx); report("node status", err) {
fmt.Printf(" [ ok ] %-14s up %s, load %v, mem %s/%s, root %s/%s\n", "node status",
dur(s.Uptime), s.LoadAvg,
gib(s.Memory.Used), gib(s.Memory.Total), gib(s.RootFS.Used), gib(s.RootFS.Total))
}
if gs, err := client.ListLXC(ctx); report("list lxc", err) {
fmt.Printf(" [ ok ] %-14s %d guest(s)\n", "list lxc", len(gs))
for _, g := range gs {
fmt.Printf(" - %d %q status=%s\n", g.VMID, g.Name, g.Status)
}
}
if ss, err := client.NodeStorage(ctx); report("storage", err) {
fmt.Printf(" [ ok ] %-14s %d store(s)\n", "storage", len(ss))
for _, s := range ss {
fmt.Printf(" - %-10s type=%-8s content=%s used=%s/%s\n",
s.Storage, s.Type, s.Content, gib(s.Used), gib(s.Total))
}
}
if fail > 0 {
fmt.Printf("=== selftest FAILED (%d check(s)) ===\n", fail)
return 1
}
fmt.Println("=== selftest OK ===")
return 0
}
// runSelftestTask exercises WaitTask on a reversible op against -vmid: snapshot ->
// rollback -> delete-snapshot. Explicitly gated; never runs under bare --selftest.
func runSelftestTask(ctx context.Context, cfg config.Config, logger *slog.Logger, vmid int) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: not configured:", err)
return 1
}
if vmid == 0 {
fmt.Fprintln(os.Stderr, "selftest=task requires -vmid N (a guest safe to snapshot/rollback)")
return 2
}
client, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: client init:", err)
return 1
}
// Ctrl-C aborts the wait cleanly.
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
const snap = "felhom-selftest"
steps := []struct {
name string
do func() (string, error)
}{
{"snapshot", func() (string, error) { return client.Snapshot(ctx, vmid, snap, "felhom-agent selftest") }},
{"rollback", func() (string, error) { return client.Rollback(ctx, vmid, snap) }},
{"delete-snapshot", func() (string, error) { return client.DeleteSnapshot(ctx, vmid, snap) }},
}
fmt.Printf("=== felhom-agent selftest=task (vmid %d, snapshot %q) ===\n", vmid, snap)
for _, st := range steps {
upid, err := st.do()
if err != nil {
fmt.Printf(" [FAIL] %-16s %v\n", st.name, err)
return 1
}
fmt.Printf(" .... %-16s upid=%s\n", st.name, upid)
status, err := client.WaitTask(ctx, upid, proxmox.WaitOptions{})
if err != nil {
fmt.Printf(" [FAIL] %-16s %v\n", st.name, err)
return 1
}
fmt.Printf(" [ ok ] %-16s exitstatus=%s\n", st.name, status.ExitStatus)
}
// Reversible SetConfig exercise: this is the first live use of the VM.Config.*
// privilege cluster. It round-trips the cosmetic `description` field (no runtime
// effect, fully reversible) to prove SetConfig works under the scoped token
// before slice 4's reconcile is built on top of it.
if rc := selftestSetConfig(ctx, client, vmid); rc != 0 {
return rc
}
fmt.Println("=== selftest=task OK ===")
return 0
}
// selftestSetConfig performs a reversible write+revert of the LXC `description`
// field on vmid and asserts both land. Returns 0 on success, non-zero (with a
// printed [FAIL] line) on any failure — the caller stops on a non-zero return.
func selftestSetConfig(ctx context.Context, client *proxmox.Client, vmid int) int {
// 1. Read current state; capture the original description (may be absent).
cfg, err := client.GuestConfig(ctx, vmid)
if err != nil {
fmt.Printf(" [FAIL] %-16s GuestConfig: %v\n", "setconfig", err)
return 1
}
origDesc, origPresent, err := extraString(cfg, "description")
if err != nil {
fmt.Printf(" [FAIL] %-16s decode description: %v\n", "setconfig", err)
return 1
}
// PVE normalizes `description` by appending a trailing newline on read, so all
// comparisons here use the shared reconcile.NormDescription (strip trailing
// newlines) and restores write the normalized original — otherwise an exact-match
// check sees false drift. Same helper the slice-4 reconciler uses to normalize
// description, so the quirk has one source of truth.
origDesc = reconcile.NormDescription(origDesc)
// 2. Write the marker.
marker := "felhom-selftest " + time.Now().UTC().Format(time.RFC3339)
if rc := applySetConfig(ctx, client, vmid, "setconfig", map[string]string{"description": marker}); rc != 0 {
return rc
}
// 3. Verify the marker landed.
cfg, err = client.GuestConfig(ctx, vmid)
if err != nil {
fmt.Printf(" [FAIL] %-16s GuestConfig (verify): %v\n", "setconfig", err)
return 1
}
got, present, err := extraString(cfg, "description")
if err != nil {
fmt.Printf(" [FAIL] %-16s decode description (verify): %v\n", "setconfig", err)
return 1
}
if !present || reconcile.NormDescription(got) != marker {
fmt.Printf(" [FAIL] %-16s write did not land: present=%v got=%q want=%q\n", "setconfig", present, got, marker)
return 1
}
fmt.Printf(" [ ok ] %-16s description verified == marker\n", "verify-write")
// 4. Restore the original value (or clear it if it was absent originally).
var revert map[string]string
if origPresent {
revert = map[string]string{"description": origDesc}
} else {
revert = map[string]string{"delete": "description"}
}
if rc := applySetConfig(ctx, client, vmid, "setconfig-revert", revert); rc != 0 {
return rc
}
// 5. Confirm the restore.
cfg, err = client.GuestConfig(ctx, vmid)
if err != nil {
fmt.Printf(" [FAIL] %-16s GuestConfig (revert verify): %v\n", "setconfig-revert", err)
return 1
}
got, present, err = extraString(cfg, "description")
if err != nil {
fmt.Printf(" [FAIL] %-16s decode description (revert verify): %v\n", "setconfig-revert", err)
return 1
}
if origPresent {
if !present || reconcile.NormDescription(got) != origDesc {
fmt.Printf(" [FAIL] %-16s revert did not restore: present=%v got=%q want=%q\n", "setconfig-revert", present, reconcile.NormDescription(got), origDesc)
return 1
}
} else if present {
fmt.Printf(" [FAIL] %-16s revert did not clear: still present got=%q\n", "setconfig-revert", got)
return 1
}
fmt.Printf(" [ ok ] %-16s description restored to original\n", "verify-revert")
return 0
}
// applySetConfig runs one SetConfig and asserts success, handling PVE's dual-mode
// return: a UPID means async (WaitTask + assert exitstatus OK); an empty string
// means PVE applied it synchronously (not an error — phase1-2/mutate.go contract).
func applySetConfig(ctx context.Context, client *proxmox.Client, vmid int, step string, params map[string]string) int {
upid, err := client.SetConfig(ctx, vmid, params)
if err != nil {
fmt.Printf(" [FAIL] %-16s %v\n", step, err)
return 1
}
if upid == "" {
// Synchronous path: empty UPID is a clean success.
fmt.Printf(" [ ok ] %-16s synchronous exitstatus=OK\n", step)
return 0
}
status, err := client.WaitTask(ctx, upid, proxmox.WaitOptions{})
if err != nil {
fmt.Printf(" [FAIL] %-16s %s %v\n", step, upid, err)
return 1
}
if status.ExitStatus != "OK" {
fmt.Printf(" [FAIL] %-16s %s exitstatus=%s\n", step, upid, status.ExitStatus)
return 1
}
fmt.Printf(" [ ok ] %-16s %s exitstatus=%s\n", step, upid, status.ExitStatus)
return 0
}
// extraString reads a string-valued key from GuestConfig.Extra (raw JSON). It
// returns ("", false, nil) when the key is absent, and decodes the JSON string
// otherwise.
func extraString(cfg proxmox.GuestConfig, key string) (string, bool, error) {
raw, ok := cfg.Extra[key]
if !ok || len(raw) == 0 {
return "", false, nil
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false, err
}
return s, true, nil
}
// --- small helpers / flag type ---
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func dur(seconds int64) string { return (time.Duration(seconds) * time.Second).String() }
func gib(bytes int64) string { return fmt.Sprintf("%.1fGiB", float64(bytes)/(1<<30)) }
// runSelftestLANResolver ensures dnsmasq + the host base config, then applies the split-horizon record
// for ONE guest (its live LAN IP + domain discovered from the running guest). Prints what it wrote;
// verify the actual resolution out-of-band (dig @<host-ip> <app>.<domain>). Requires a host LAN IP
// (lan_resolver.host_ip or derivable from local_api.listen_addr).
func runSelftestLANResolver(ctx context.Context, cfg config.Config, logger *slog.Logger, vmid int) int {
if vmid <= 0 {
fmt.Fprintln(os.Stderr, "selftest=lanresolver: -vmid is required")
return 1
}
lr := cfg.LANResolver.WithDefaults(cfg.LocalAPI.ListenAddr)
if lr.HostIP == "" {
fmt.Fprintln(os.Stderr, "selftest=lanresolver: no host IP (set lan_resolver.host_ip or local_api.listen_addr)")
return 1
}
mode := proxmox.RunnerMode(cfg.Privileged.Mode)
if mode == "" {
mode = proxmox.RunnerSudo
}
runner := &proxmox.ExecRunner{Mode: mode, SudoPath: cfg.Privileged.SudoPath}
mgr := lanresolver.NewManager(runner, lr.HostIP, lr.Upstreams, logger)
fmt.Printf("=== felhom-agent %s selftest=lanresolver (vmid=%d host_ip=%s) ===\n", version, vmid, lr.HostIP)
if err := mgr.EnsureDnsmasq(ctx); err != nil {
fmt.Fprintf(os.Stderr, " [FAIL] EnsureDnsmasq: %v\n", err)
return 1
}
fmt.Printf(" [OK] dnsmasq present + base config (listen %s, upstreams %v)\n", lr.HostIP, lr.Upstreams)
cid := lanresolver.CustomerID(lr.StateDir, vmid)
if err := mgr.ReconcileGuest(ctx, vmid, cid); err != nil {
fmt.Fprintf(os.Stderr, " [FAIL] ReconcileGuest: %v\n", err)
return 1
}
fmt.Printf(" [OK] split-horizon applied for guest %d (customer=%q). Verify: dig @%s felhom.<domain>\n", vmid, cid, lr.HostIP)
return 0
}
// selftestFlag is a flag.Value that also satisfies IsBoolFlag, so `--selftest`
// works bare (read-only) and `--selftest=task` / `--selftest=read` set the mode.
type selftestFlag struct{ mode string }
func (f *selftestFlag) String() string { return f.mode }
func (f *selftestFlag) IsBoolFlag() bool { return true }
func (f *selftestFlag) Set(v string) error {
switch v {
case "true", "", "read":
f.mode = "read"
case "task":
f.mode = "task"
case "hub":
f.mode = "hub"
case "storage":
f.mode = "storage"
case "backup":
f.mode = "backup"
case "restore-test":
f.mode = "restore-test"
case "pbs-verify":
f.mode = "pbs-verify"
case "lanresolver":
f.mode = "lanresolver"
case "bring-up":
f.mode = "bring-up"
case "provision":
f.mode = "provision"
case "escrow-create":
f.mode = "escrow-create"
case "escrow-consume":
f.mode = "escrow-consume"
case "identity-consume":
f.mode = "identity-consume"
default:
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create|escrow-consume|identity-consume)", v)
}
return nil
}