From 3fecf4c713952224447e451f8b254cb127104950 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 10 Jun 2026 09:47:42 +0200 Subject: [PATCH] slice 8A (agent half): local-API server + provisioning back-half (v0.10.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/localapi: per-guest local-API server (doc 03 §6) — 7 self-scoped endpoints, hashed per-guest token store, persisted self-signed leaf with stable SHA-256 pin, optional 6th daemon goroutine. internal/provision: back-half — mint token, render bootstrap.json (no registry cred), write 0600, chown 100000:100000, attach pct-set bind mount (host-side, F3, no pct exec). --selftest=provision. build-golden.sh bakes the controller image + bootstrap unit. sudoers FELHOM_PROVISION; firewall narrowing artifact. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 57 +++ CLAUDE.md | 2 +- cmd/felhom-agent/main.go | 209 +++++++++- configs/agent.example.json | 7 + configs/build-golden.sh | 62 ++- configs/felhom-agent.sudoers | 12 +- configs/felhom-localapi-firewall.example | 40 ++ internal/config/config.go | 74 ++++ internal/localapi/cert.go | 126 ++++++ internal/localapi/cert_test.go | 57 +++ internal/localapi/server.go | 489 +++++++++++++++++++++++ internal/localapi/server_test.go | 340 ++++++++++++++++ internal/localapi/tokenstore.go | 178 +++++++++ internal/localapi/tokenstore_test.go | 131 ++++++ internal/provision/backhalf.go | 158 ++++++++ internal/provision/backhalf_test.go | 160 ++++++++ internal/provision/doc.go | 50 +++ internal/provision/doc_test.go | 62 +++ 18 files changed, 2203 insertions(+), 11 deletions(-) create mode 100644 configs/felhom-localapi-firewall.example create mode 100644 internal/localapi/cert.go create mode 100644 internal/localapi/cert_test.go create mode 100644 internal/localapi/server.go create mode 100644 internal/localapi/server_test.go create mode 100644 internal/localapi/tokenstore.go create mode 100644 internal/localapi/tokenstore_test.go create mode 100644 internal/provision/backhalf.go create mode 100644 internal/provision/backhalf_test.go create mode 100644 internal/provision/doc.go create mode 100644 internal/provision/doc_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f74c9..8a2d138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,63 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.10.0 — slice 8A: agent local-API server + provisioning back-half (2026-06-10) + +The host-agent half of slice 8A (doc 03 §6). Adds the per-guest **local API** the in-guest +controller calls over the bridge, and the **provisioning back-half** that follows the slice-7 +bring-up front half. Grounded by `felhom.eu/documentation/tests/slice8a-channel-deploy-spike-findings.md` +(commit `4a81a96` — channel + deploy plumbing proven; the 5 gotchas resolved here). Controller half +is `felhom-controller` v0.35.0. No hub change. + +### Added +- **`internal/localapi`** — the HTTPS local-API server (doc 03 §6), the **per-guest authorization + gate**. Serves a **persisted self-signed leaf** with a **stable SHA-256 fingerprint** (generated + once; a fresh cert each boot would invalidate every baked bootstrap pin). The **7 §6 endpoints**, + all **self-scoped to the caller's own guest**: `GET /storage` (this guest's mpN mounts + fast/slow + class from the slice-5/7 storage view), `POST /snapshot`, `POST /rollback`, `POST /backup` + (enqueued, crash-consistent — the app-consistent quiesce loop is 8B), `GET /backup/due` (thin in + 8A), `GET /backup/status`, `GET /restore-test/status`. + - **Token store** (`tokenstore.go`): durable, crash-safe per-guest token→guest map that persists + only a **SHA-256 hash** of each token (the plaintext exists transiently at mint→write-to-mount, + then is discarded), last-write-wins per guest, fsync'd append-only JSONL (mirrors the nonce store). + - **Self-scoping**: the VMID is resolved ONLY from the token; an explicit `vmid` (query/body) that + disagrees → **403 and the proxmox op is never issued for the other guest**; absent/unknown → 401. +- **`internal/provision`** — the back-half: mint the per-guest token → render the stable + **`bootstrap.json`** contract (schema `felhom.bootstrap/v1`; **no registry credential** — the + controller image is baked into the golden) → write it `0600` → **`chown 100000:100000`** (the + unprivileged-LXC mapped guest-root, spike gotcha 1) → attach a **read-only bind mount** via + `pct set`. Host-side only (F3 — the agent never enters the guest; **no `pct exec`**). The token + plaintext is never logged and never returned. +- **`--selftest=provision`** — the full chain on-demand: bring-up (provision) front half + the + back half; keeps the guest for the golden's baked controller-bootstrap unit to deploy. +- **`config.LocalAPIConfig`** (`local_api`) — enable + bridge `listen_addr` + cert/key paths + token + store path. The server is an optional 6th daemon goroutine, disabled cleanly when unconfigured or + on a token-store/cert failure (the daemon still reports/reconciles). +- **`configs/build-golden.sh`** now **bakes the controller image** (pulled once on the trusted build + host, then `docker logout` — no cred baked) + a **controller-bootstrap unit** that deploys the + **baked** image from the config mount on boot (no login/pull at deploy). +- **`configs/felhom-localapi-firewall.example`** — host firewall narrowing of the local-API port to + the guest bridge subnet (nft/iptables/PVE variants; defense-in-depth — the token stays the gate). +- **`configs/felhom-agent.sudoers`** — a narrow `FELHOM_PROVISION` alias (`chown 100000:100000` + + `pct set` bind-mount, both confined to the agent-owned `/var/lib/felhom-agent/guests/*` path) for + the non-root least-privilege deployment. + +### Security / design notes +- The local-API leaf is pinned by **leaf-cert SHA-256** (decision: consistency with the agent's + PVE/PBS pinning); the fingerprint is baked into each guest's bootstrap. +- The back-half's host-root ops (chown + bind-mount attach) are **NOT** added to `proxmox.Privileged` + (which is fenced to its 3 exceptions) — they live in `internal/provision` and run through the shared + `Runner` (direct as root, or `sudo -n` with the new sudoers alias). This is the per-guest + provisioning host-root surface, host-side and F3-compliant. + +### Tests +- localapi: self-scoping (cross-guest snapshot/rollback/backup → 403, op never issued for the other + guest; own-guest uses the token's VMID), 401 paths, `/storage` class mapping, `/backup` enqueue, + the thin `/backup/due`, status scoping; the token store persists only the hash (plaintext never on + disk), last-write-wins, survives reopen, uniqueness; the leaf fingerprint is stable across reload. +- provision: writes `0600` + chowns + attaches the bind mount with the right args; the **token never + appears in the Result**; the cross-repo `bootstrap.json` contract key-set is pinned. + ## v0.9.0 — slice 7 close-out: PBS recovery-code escrow creation (2026-06-10) The first code that touches the PBS client encryption key `K` and introduces the customer recovery diff --git a/CLAUDE.md b/CLAUDE.md index 9ef283a..3fe1b07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ - Module `gitea.dooplex.hu/admin/felhom-agent`; binary `felhom-agent` (`cmd/felhom-agent/`). - **Pure Go stdlib + `golang.org/x/crypto` only** — no web frameworks. - `go.mod` directive **go 1.25.0**; dep `golang.org/x/crypto v0.52.0` (declares go 1.25, will NOT build on Go 1.24). The **build server (192.168.0.180) runs go1.26.0** (upstream Go on PATH, backward-compatible). Build/run the agent there for live tests (same LAN as the demo host). -- Version: `version` var in `cmd/felhom-agent/main.go`, overridable via `-ldflags "-X main.version="`; `--version` flag. **Current: v0.9.0** (slice 7 close-out: PBS recovery-code escrow *creation* — `internal/escrow` wraps the PBS key K under a ≥128-bit recovery code R via the PBS-native key+passphrase KDF driven over a stdlib pty, zero-knowledge default, self-verified; `--selftest=escrow-create`. Plus v0.8.0's unified bring-up reconcile job. Runtime dep: `proxmox-backup-client`). Bump on meaningful changes + add a CHANGELOG entry. +- Version: `version` var in `cmd/felhom-agent/main.go`, overridable via `-ldflags "-X main.version="`; `--version` flag. **Current: v0.10.0** (slice 8A: the per-guest **local-API server** `internal/localapi` — the doc 03 §6 authorization gate, 7 self-scoped endpoints, hashed per-guest token store, persisted self-signed leaf w/ stable pin; the **provisioning back-half** `internal/provision` — mint token → render `bootstrap.json` → write 0600 → `chown 100000:100000` → `pct set` bind mount, host-side/F3, no registry cred in guest; `--selftest=provision`; `build-golden.sh` now bakes the controller. Plus v0.9.0's PBS escrow. Runtime dep: `proxmox-backup-client`). Bump on meaningful changes + add a CHANGELOG entry. ## Layout diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 5246835..c2b8cb4 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -15,6 +15,7 @@ import ( "flag" "fmt" "log/slog" + "net" "net/http" "os" "os/signal" @@ -28,8 +29,10 @@ import ( "gitea.dooplex.hu/admin/felhom-agent/internal/config" "gitea.dooplex.hu/admin/felhom-agent/internal/escrow" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" + "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/proxmox" "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" "gitea.dooplex.hu/admin/felhom-agent/internal/storage" @@ -37,7 +40,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.9.0" +var version = "0.10.0" func main() { var ( @@ -53,10 +56,14 @@ func main() { paperkey bool offline bool upload bool + custID string + custDomain string + custName string + custEmail 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)") + 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/-customer-domain; 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)") @@ -67,6 +74,10 @@ func main() { 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(&custID, "customer-id", "", "for --selftest=provision: the customer id to seed into the guest's bootstrap") + flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: the customer domain to seed") + flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: the customer display name to seed (optional)") + flag.StringVar(&custEmail, "customer-email", "", "for --selftest=provision: the customer email to seed (optional)") flag.BoolVar(&showVersion, "version", false, "print version and exit") flag.Parse() @@ -106,6 +117,11 @@ func main() { os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger)) 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, + customer: provision.DocCustomer{ID: custID, Domain: custDomain, Name: custName, Email: custEmail}, + })) case "escrow-create": os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload)) } @@ -315,18 +331,34 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int { Logger: logger, }) - // Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, and the - // PBS verify loop concurrently; any one returning ends the daemon (ctx cancel tears down rest). - errc := make(chan error, 5) + // 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, logger, &localTokens) + if localTokens != nil { + defer localTokens.Close() + } + + // Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS + // verify loop, and (optionally) the local-API server concurrently; any one returning ends + // the daemon (ctx cancel tears down the rest). + errc := make(chan error, 6) 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) }() + } err = <-errc - stop() // tear down the siblings on the first exit - for i := 0; i < 4; i++ { // wait for the other four + stop() // tear down the siblings on the first exit + for i := 0; i < 4+localServers; i++ { // wait for the other goroutines <-errc } if err != nil && err != context.Canceled { @@ -425,6 +457,50 @@ func buildRestoreTestScheduler(cfg config.Config, px *proxmox.Client, engine *re }) } +// 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, 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) + srv, err := localapi.NewServer(localapi.Options{ + ListenAddr: cfg.LocalAPI.ListenAddr, + Cert: cert, + Guests: px, + Backups: runner, + Store: store, + Storage: observer, + Tokens: tokens, + Logger: logger, + }) + if err != nil { + logger.Warn("daemon: local-api disabled (server build)", "err", err) + return nil + } + return srv +} + // 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. @@ -814,6 +890,121 @@ func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Log return 0 } +// provisionArgs bundles the --selftest=provision inputs. +type provisionArgs struct { + archive string + vmid int + hostname string + customer provision.DocCustomer +} + +// 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 and -vmid ") + return 2 + } + if cfg.Backup.RestoreStorage == "" { + fmt.Fprintln(os.Stderr, "selftest=provision requires backup.restore_storage in config") + return 2 + } + if a.customer.ID == "" || a.customer.Domain == "" { + fmt.Fprintln(os.Stderr, "selftest=provision requires -customer-id and -customer-domain (so the controller skips setup)") + 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, + }) + + fmt.Printf("=== felhom-agent %s selftest=provision (vmid=%d customer=%s) ===\n", version, a.vmid, a.customer.ID) + 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: a.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: a.customer, + Hub: provision.DocHub{URL: cfg.Hub.URL, APIKey: cfg.Hub.APIKey, HostID: cfg.Hub.HostID}, + 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: the golden's baked controller-bootstrap unit deploys the controller from the mount on boot.") + 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 @@ -1273,10 +1464,12 @@ func (f *selftestFlag) Set(v string) error { f.mode = "pbs-verify" case "bring-up": f.mode = "bring-up" + case "provision": + f.mode = "provision" case "escrow-create": f.mode = "escrow-create" default: - return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|escrow-create)", v) + return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create)", v) } return nil } diff --git a/configs/agent.example.json b/configs/agent.example.json index 7bf44c9..422ed48 100644 --- a/configs/agent.example.json +++ b/configs/agent.example.json @@ -45,5 +45,12 @@ "pbs_verify_cadence_seconds": 0, "pbs_secret_dir": "/etc/pve/priv/storage" }, + "local_api": { + "enable": true, + "listen_addr": "192.168.0.162:8443", + "cert_file": "/var/lib/felhom-agent/local-api.crt", + "key_file": "/var/lib/felhom-agent/local-api.key", + "token_store": "/var/lib/felhom-agent/local-tokens.log" + }, "log_level": "info" } diff --git a/configs/build-golden.sh b/configs/build-golden.sh index dfae872..be85bad 100644 --- a/configs/build-golden.sh +++ b/configs/build-golden.sh @@ -14,7 +14,15 @@ # symmetric with machine-id. # - machine-id: truncated; systemd regenerates it on first boot for free (no unit needed). # -# Usage: build-golden.sh [VMID] [TEMPLATE_VOLID] [ROOTFS_STORAGE] [ARCHIVE_STORAGE] [BRIDGE] +# Slice 8A — the golden now also BAKES the in-guest controller (decision: image baked at golden +# build on the trusted host, so NO registry credential ever enters a customer guest at deploy) and +# a controller-bootstrap unit that, on boot, deploys the baked image from the agent-populated +# config mount (/etc/felhom-bootstrap/bootstrap.json) — no docker login/pull at deploy. Refreshing +# the golden bumps the controller baseline; controller self-update covers in-between drift. +# +# Usage: build-golden.sh [VMID] [TEMPLATE_VOLID] [ROOTFS_STORAGE] [ARCHIVE_STORAGE] [BRIDGE] [CONTROLLER_IMAGE] +# Build-time registry login for the controller pull (used ONCE inside the build guest, then logged +# out — never baked): set REGISTRY_USER + REGISTRY_TOKEN in the environment. set -euo pipefail VMID="${1:-9100}" @@ -22,6 +30,8 @@ TEMPLATE="${2:-local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst}" ROOTFS_STORAGE="${3:-local-lvm}" ARCHIVE_STORAGE="${4:-local}" BRIDGE="${5:-vmbr0}" +CONTROLLER_IMAGE="${6:-gitea.dooplex.hu/admin/felhom-controller:v0.35.0}" +REGISTRY_HOST="${CONTROLLER_IMAGE%%/*}" echo "[golden] creating build LXC $VMID (nesting=1,keyctl=1, unprivileged) …" pct create "$VMID" "$TEMPLATE" \ @@ -52,6 +62,56 @@ pct exec "$VMID" -- bash -c ' echo "[golden] verifying Docker works in the build guest …" pct exec "$VMID" -- bash -c 'systemctl start docker; sleep 2; docker run --rm hello-world >/dev/null && echo " docker OK ($(docker info 2>/dev/null | sed -n "s/.*Storage Driver: //p"))"' +echo "[golden] baking the in-guest controller image $CONTROLLER_IMAGE (no registry cred at deploy) …" +# docker login is used ONCE here on the trusted build host, then logged out before archiving so +# the credential is NEVER baked into the golden. The IMAGE is what gets baked (in Docker storage). +if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_TOKEN:-}" ]; then + pct exec "$VMID" -- bash -c "systemctl start docker; sleep 1; echo '$REGISTRY_TOKEN' | docker login '$REGISTRY_HOST' -u '$REGISTRY_USER' --password-stdin >/dev/null" +fi +pct exec "$VMID" -- bash -c "docker pull '$CONTROLLER_IMAGE'" +pct exec "$VMID" -- bash -c "docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -f /root/.docker/config.json" +# Record the baked image ref for the bootstrap unit (so the unit needs no login/pull). +pct exec "$VMID" -- bash -c "printf '%s\n' '$CONTROLLER_IMAGE' > /etc/felhom-controller-image" + +echo "[golden] baking the controller-bootstrap unit (deploys the BAKED controller from the config mount) …" +pct push "$VMID" /dev/stdin /usr/local/sbin/felhom-controller-bootstrap.sh --perms 700 <<'BOOTSH' +#!/bin/bash +# felhom controller-bootstrap (slice 8A): the host agent's back-half populated the read-only +# config mount /etc/felhom-bootstrap; this golden-baked oneshot deploys the BAKED controller image +# with that config. NO docker login / NO docker pull — the image is already in this golden's Docker +# storage (and self-update handles version drift). Host-side only; the agent never enters the guest. +set -euo pipefail +CFG=/etc/felhom-bootstrap/bootstrap.json +[ -r "$CFG" ] || { echo "[ctrl-bootstrap] no $CFG — not provisioned, nothing to do"; exit 0; } +IMAGE=$(cat /etc/felhom-controller-image 2>/dev/null || true) +[ -n "$IMAGE" ] || { echo "[ctrl-bootstrap] FATAL: /etc/felhom-controller-image missing"; exit 1; } +echo "[ctrl-bootstrap] deploying $IMAGE from $CFG" +docker rm -f felhom-controller >/dev/null 2>&1 || true +docker run -d --name felhom-controller --restart unless-stopped \ + -e FELHOM_BOOTSTRAP_PATH=/etc/felhom-bootstrap/bootstrap.json \ + -v /etc/felhom-bootstrap:/etc/felhom-bootstrap:ro \ + -v felhom-controller-data:/opt/docker/felhom-controller \ + -v /var/run/docker.sock:/var/run/docker.sock \ + "$IMAGE" +echo "[ctrl-bootstrap] controller started" +BOOTSH +pct exec "$VMID" -- bash -c 'cat > /etc/systemd/system/felhom-controller-bootstrap.service < /etc/systemd/system/felhom-regen-hostkeys.service </host.fw), if the PVE firewall is enabled. +# Add under [RULES] (and ensure the firewall is enabled in cluster.fw / host.fw): +# +# [RULES] +# IN ACCEPT -source 192.168.0.0/24 -dport 8443 -proto tcp -log nolog +# IN DROP -dport 8443 -proto tcp -log nolog +# +# Verify after applying: from a guest ON the bridge, a TLS connect to :8443 succeeds; +# from an OFF-bridge host it is refused/dropped. (The token + leaf-pin still gate the request.) diff --git a/internal/config/config.go b/internal/config/config.go index e09373c..d4b9462 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,9 +29,83 @@ type Config struct { Storage StorageConfig `json:"storage"` Backup BackupConfig `json:"backup"` Escrow EscrowConfig `json:"escrow"` + LocalAPI LocalAPIConfig `json:"local_api"` LogLevel string `json:"log_level"` // debug|info|warn|error (default info) } +// LocalAPIConfig configures the per-guest local API server (doc 03 §6, slice 8A). The +// controller (inside its LXC) reaches the agent over the local bridge; the agent is the +// per-guest authorization gate — it maps a per-guest bearer token → VMID and authorizes +// every call against THAT guest only (self-scoped; never a caller-supplied id). Disabled +// unless Enable is set AND ListenAddr is non-empty (so a host that doesn't yet provision +// controllers runs the daemon without it). +// +// Defense-in-depth (spike gotcha 5): ListenAddr should be the host's BRIDGE IP (not +// 0.0.0.0), and a host firewall rule should limit the port to the guest bridge subnet +// (configs/felhom-localapi-firewall.example). The per-guest token remains the gate; the +// bind + firewall narrow exposure but are not the authorization. +type LocalAPIConfig struct { + Enable bool `json:"enable"` + ListenAddr string `json:"listen_addr"` // bridge IP:port, e.g. "192.168.0.162:8443" + // CertFile/KeyFile hold the agent's self-signed leaf served to controllers. Generated + // (persisted) on first start if absent, so the leaf SHA-256 fingerprint — baked into each + // guest's bootstrap for pinning — is STABLE across agent restarts. + CertFile string `json:"cert_file"` // default /local-api.crt + KeyFile string `json:"key_file"` // default /local-api.key + // TokenStore is the durable, hashed token→guest map (only a HASH of each token is + // persisted; the plaintext exists transiently at mint→write-to-mount, then is discarded). + TokenStore string `json:"token_store"` // default /var/lib/felhom-agent/local-tokens.log +} + +// Default local-API file locations (under the agent's state dir). +const ( + defaultLocalAPITokenStore = "/var/lib/felhom-agent/local-tokens.log" + defaultLocalAPICert = "/var/lib/felhom-agent/local-api.crt" + defaultLocalAPIKey = "/var/lib/felhom-agent/local-api.key" +) + +// Enabled reports whether the local-API server should run. +func (l LocalAPIConfig) Enabled() bool { + return l.Enable && strings.TrimSpace(l.ListenAddr) != "" +} + +// TokenStorePath returns the configured token-store path (default applied). +func (l LocalAPIConfig) TokenStorePath() string { + if l.TokenStore != "" { + return l.TokenStore + } + return defaultLocalAPITokenStore +} + +// CertPath/KeyPath return the configured leaf cert/key paths (defaults applied). +func (l LocalAPIConfig) CertPath() string { + if l.CertFile != "" { + return l.CertFile + } + return defaultLocalAPICert +} + +func (l LocalAPIConfig) KeyPath() string { + if l.KeyFile != "" { + return l.KeyFile + } + return defaultLocalAPIKey +} + +// Validate checks the local-API config is usable when enabled. +func (l LocalAPIConfig) Validate() error { + if !l.Enable { + return nil + } + if strings.TrimSpace(l.ListenAddr) == "" { + return fmt.Errorf("config: local_api.listen_addr is required when local_api.enable is set (use the host bridge IP:port, e.g. 192.168.0.162:8443)") + } + if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil { + return fmt.Errorf("config: local_api.listen_addr %q is not host:port: %w", l.ListenAddr, err) + } + return nil +} + // EscrowConfig configures PBS recovery-code escrow creation (slice 7, doc 03 §8a). Enrollment-time // only (not the steady-state daemon). The default posture is zero-knowledge (Felhom holds the // opaque blob, the customer holds the recovery code). diff --git a/internal/localapi/cert.go b/internal/localapi/cert.go new file mode 100644 index 0000000..a634700 --- /dev/null +++ b/internal/localapi/cert.go @@ -0,0 +1,126 @@ +package localapi + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +// certValidity is the self-signed leaf lifetime. It is long because the leaf's SHA-256 +// fingerprint is baked into every guest's bootstrap for pinning — rotating the cert means +// re-issuing bootstraps, so this is deliberately decoupled from short-lived TLS norms. Cert +// rotation is an operator action (slice 10+), not an automatic expiry event. +const certValidity = 10 * 365 * 24 * time.Hour + +// EnsureLeaf loads the agent's local-API leaf from certPath/keyPath, generating and persisting +// a fresh self-signed ECDSA-P256 leaf (SAN = bridgeHost, when it is an IP/host) if either file +// is absent. It returns the tls.Certificate to serve and the leaf's SHA-256 fingerprint (the +// agent's pin convention: lowercase hex of the leaf DER) for baking into bootstraps. +// +// Persisting the generated pair keeps the fingerprint STABLE across agent restarts — a fresh +// cert each boot would silently invalidate every already-issued bootstrap's pin. +func EnsureLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) { + if fileExists(certPath) && fileExists(keyPath) { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: load leaf %s: %w", certPath, err) + } + fp, err := leafFingerprint(cert) + if err != nil { + return tls.Certificate{}, "", err + } + return cert, fp, nil + } + return generateLeaf(certPath, keyPath, bridgeHost) +} + +// generateLeaf creates a self-signed ECDSA-P256 leaf, writes the cert (0644) + key (0600) to +// disk, and returns the loaded pair + its fingerprint. +func generateLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: gen key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: gen serial: %w", err) + } + notBefore := time.Now().Add(-1 * time.Hour) // small backdate for clock skew + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "felhom-agent local-api"}, + NotBefore: notBefore, + NotAfter: notBefore.Add(certValidity), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + // SAN: the bridge host (an IP in practice). The controller pins the leaf SHA-256, so the + // SAN is not load-bearing for trust, but a correct SAN keeps standard tooling happy. + if host := bridgeHost; host != "" { + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = append(tmpl.IPAddresses, ip) + } else { + tmpl.DNSNames = append(tmpl.DNSNames, host) + } + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: create cert: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: marshal key: %w", err) + } + if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: cert dir: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: write cert: %w", err) + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: write key: %w", err) + } + syncDir(filepath.Dir(certPath)) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("localapi: reload generated leaf: %w", err) + } + fp, err := leafFingerprint(cert) + if err != nil { + return tls.Certificate{}, "", err + } + return cert, fp, nil +} + +// leafFingerprint returns the lowercase-hex SHA-256 of the leaf certificate DER — the same pin +// convention the agent uses for the Proxmox/PBS host certs. +func leafFingerprint(cert tls.Certificate) (string, error) { + if len(cert.Certificate) == 0 { + return "", fmt.Errorf("localapi: certificate has no leaf") + } + sum := sha256.Sum256(cert.Certificate[0]) // Certificate[0] is the leaf DER + return hex.EncodeToString(sum[:]), nil +} + +func fileExists(p string) bool { + if p == "" { + return false + } + _, err := os.Stat(p) + return err == nil +} diff --git a/internal/localapi/cert_test.go b/internal/localapi/cert_test.go new file mode 100644 index 0000000..8730ada --- /dev/null +++ b/internal/localapi/cert_test.go @@ -0,0 +1,57 @@ +package localapi + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "testing" +) + +// The leaf is generated once and its fingerprint stays STABLE across "restarts" (re-loads from +// disk) — a fresh cert each boot would invalidate every already-baked bootstrap pin. +func TestEnsureLeaf_StableFingerprintAcrossReload(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "leaf.crt") + keyPath := filepath.Join(dir, "leaf.key") + + cert1, fp1, err := EnsureLeaf(certPath, keyPath, "192.168.0.162") + if err != nil { + t.Fatalf("first ensure: %v", err) + } + cert2, fp2, err := EnsureLeaf(certPath, keyPath, "192.168.0.162") + if err != nil { + t.Fatalf("second ensure: %v", err) + } + if fp1 != fp2 { + t.Fatalf("fingerprint changed across reload: %s != %s", fp1, fp2) + } + // The reported fingerprint must equal the SHA-256 of the served leaf DER (the pin the + // controller checks against). + got := sha256.Sum256(cert2.Certificate[0]) + if hex.EncodeToString(got[:]) != fp2 { + t.Fatal("reported fingerprint does not match the served leaf DER") + } + if len(fp1) != 64 { + t.Fatalf("fingerprint is not a 64-hex SHA-256: %q", fp1) + } + _ = cert1 +} + +// The generated leaf is a usable TLS server cert whose presented leaf matches the pin. +func TestEnsureLeaf_ServesPinnableLeaf(t *testing.T) { + dir := t.TempDir() + cert, fp, err := EnsureLeaf(filepath.Join(dir, "c"), filepath.Join(dir, "k"), "10.0.0.1") + if err != nil { + t.Fatalf("ensure: %v", err) + } + if len(cert.Certificate) == 0 { + t.Fatal("no leaf in cert chain") + } + if cert.PrivateKey == nil { + t.Fatal("generated cert has no private key") + } + sum := sha256.Sum256(cert.Certificate[0]) + if hex.EncodeToString(sum[:]) != fp { + t.Fatal("pin mismatch against served leaf") + } +} diff --git a/internal/localapi/server.go b/internal/localapi/server.go new file mode 100644 index 0000000..bc4eb33 --- /dev/null +++ b/internal/localapi/server.go @@ -0,0 +1,489 @@ +package localapi + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-agent/internal/hub" + "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" +) + +// GuestAPI is the narrow Proxmox surface the local API needs. Satisfied by *proxmox.Client. +// Every method here is invoked ONLY with the VMID resolved from the caller's token — never a +// caller-supplied id — so the proxmox op is structurally self-scoped. +type GuestAPI interface { + GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error) + Snapshot(ctx context.Context, vmid int, snapname, description string) (string, error) + Rollback(ctx context.Context, vmid int, snapname string) (string, error) + WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error) +} + +// BackupService enqueues a (crash-consistent) vzdump/PBS backup of a guest. Satisfied by +// *backup.BackupRunner. The app-consistent quiesce path is 8B. +type BackupService interface { + Backup(ctx context.Context, vmid int) (hub.Backup, error) +} + +// BackupStore records + reads the latest backup/restore-test state. Satisfied by *backup.Store. +type BackupStore interface { + RecordBackup(hub.Backup) + Backups(ctx context.Context) []hub.Backup + RestoreTests(ctx context.Context) []hub.RestoreTest +} + +// StorageView yields the host's observed storage targets (for mapping a mount's storage id → +// fast/slow class). Satisfied by *storage.Observer. +type StorageView interface { + Observe(ctx context.Context) ([]hub.StorageTarget, error) +} + +// TokenAuthority resolves a presented bearer token to its guest VMID. Satisfied by *TokenStore. +type TokenAuthority interface { + Lookup(token string) (int, bool) +} + +// Options configures a Server. +type Options struct { + ListenAddr string // bridge IP:port + Cert tls.Certificate + Guests GuestAPI + Backups BackupService + Store BackupStore + Storage StorageView + Tokens TokenAuthority + Logger *slog.Logger +} + +// Server is the per-guest local API (doc 03 §6). It serves the agent's pinned self-signed leaf +// and authorizes every request against the token's guest only. +type Server struct { + addr string + cert tls.Certificate + guests GuestAPI + backups BackupService + store BackupStore + storage StorageView + tokens TokenAuthority + logger *slog.Logger + + baseCtx context.Context // for fire-and-forget backups; set in Run +} + +// NewServer builds a Server. It does not bind a socket until Run. +func NewServer(o Options) (*Server, error) { + if o.ListenAddr == "" { + return nil, fmt.Errorf("localapi: listen addr required") + } + if o.Guests == nil || o.Backups == nil || o.Store == nil || o.Storage == nil || o.Tokens == nil { + return nil, fmt.Errorf("localapi: all dependencies (guests, backups, store, storage, tokens) are required") + } + if o.Logger == nil { + o.Logger = slog.Default() + } + return &Server{ + addr: o.ListenAddr, + cert: o.Cert, + guests: o.Guests, + backups: o.Backups, + store: o.Store, + storage: o.Storage, + tokens: o.Tokens, + logger: o.Logger, + }, nil +} + +// Handler builds the routed mux (exposed for tests via httptest). +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /storage", s.withGuest(s.handleStorage)) + mux.HandleFunc("POST /snapshot", s.withGuest(s.handleSnapshot)) + mux.HandleFunc("POST /rollback", s.withGuest(s.handleRollback)) + mux.HandleFunc("POST /backup", s.withGuest(s.handleBackup)) + mux.HandleFunc("GET /backup/due", s.withGuest(s.handleBackupDue)) + mux.HandleFunc("GET /backup/status", s.withGuest(s.handleBackupStatus)) + mux.HandleFunc("GET /restore-test/status", s.withGuest(s.handleRestoreTestStatus)) + return mux +} + +// Run binds the bridge socket, serves TLS, and shuts down gracefully on ctx cancellation. It +// returns nil on a clean shutdown (mirrors the other daemon loops' ctx-cancel contract). +func (s *Server) Run(ctx context.Context) error { + s.baseCtx = ctx + srv := &http.Server{ + Addr: s.addr, + Handler: s.Handler(), + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{s.cert}, + MinVersion: tls.VersionTLS12, + }, + ReadHeaderTimeout: 10 * time.Second, + } + ln, err := net.Listen("tcp", s.addr) + if err != nil { + return fmt.Errorf("localapi: bind %s: %w", s.addr, err) + } + s.logger.Info("local-api server listening", "addr", s.addr) + + errc := make(chan error, 1) + go func() { errc <- srv.ServeTLS(ln, "", "") }() + + select { + case <-ctx.Done(): + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + return nil + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +// ---- auth + self-scoping ---------------------------------------------------------------- + +// withGuest wraps a handler with bearer auth and self-scoping: it resolves the token → VMID +// (401 on absent/unknown), and refuses any explicit `vmid` that disagrees with the token's +// guest (403, cross-guest). The wrapped handler is invoked ONLY with the token's VMID, so a +// proxmox op is never issued for another guest. Self-scoping is by the token→guest map; a +// caller-supplied id is only ever a consistency check, never the authority. +func (s *Server) withGuest(fn func(w http.ResponseWriter, r *http.Request, vmid int)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tok := bearer(r) + if tok == "" { + writeErr(w, http.StatusUnauthorized, "missing bearer token") + return + } + vmid, ok := s.tokens.Lookup(tok) + if !ok { + writeErr(w, http.StatusUnauthorized, "unknown token") + return + } + // Cross-guest probe via an explicit query vmid → 403 (the op is not run). + if q := strings.TrimSpace(r.URL.Query().Get("vmid")); q != "" { + if want, err := strconv.Atoi(q); err != nil || want != vmid { + s.logger.Warn("local-api: cross-guest request refused", + "token_guest", vmid, "requested", q, "path", r.URL.Path) + writeErr(w, http.StatusForbidden, "token is not scoped to that guest") + return + } + } + fn(w, r, vmid) + } +} + +// bearer extracts the Authorization: Bearer value. +func bearer(r *http.Request) string { + h := r.Header.Get("Authorization") + const p = "Bearer " + if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) { + return strings.TrimSpace(h[len(p):]) + } + return "" +} + +// scopedFromBody enforces self-scoping for a POST body that may carry an explicit vmid: a +// non-zero body vmid that disagrees with the token's guest → 403 (false return; caller stops). +func (s *Server) scopedFromBody(w http.ResponseWriter, bodyVMID, tokenVMID int, path string) bool { + if bodyVMID != 0 && bodyVMID != tokenVMID { + s.logger.Warn("local-api: cross-guest request refused (body)", + "token_guest", tokenVMID, "requested", bodyVMID, "path", path) + writeErr(w, http.StatusForbidden, "token is not scoped to that guest") + return false + } + return true +} + +// ---- handlers --------------------------------------------------------------------------- + +// MountInfo is one of the guest's attached mountpoints with its placement class. +type MountInfo struct { + Key string `json:"key"` // mp0, mp1, … + Storage string `json:"storage"` // PVE storage id + MountPoint string `json:"mount_point"` // in-guest path + Class string `json:"class"` // fast | slow | "" (unknown) — from the host storage view + Backup bool `json:"backup"` // included in vzdump (backup=1) +} + +// StorageResponse is GET /storage — this guest's mounts + class so the controller can place +// hot vs bulk volumes per .felhom.yml. +type StorageResponse struct { + VMID int `json:"vmid"` + Mounts []MountInfo `json:"mounts"` +} + +func (s *Server) handleStorage(w http.ResponseWriter, r *http.Request, vmid int) { + cfg, err := s.guests.GuestConfig(r.Context(), vmid) + if err != nil { + s.logger.Error("local-api: /storage guest config", "vmid", vmid, "err", err) + writeErr(w, http.StatusBadGateway, "could not read guest config") + return + } + classByStore := s.classByStorage(r.Context()) + mounts := make([]MountInfo, 0) + for key, val := range cfg.MountPoints() { + store, mp, backup := parseMount(val) + mounts = append(mounts, MountInfo{ + Key: key, Storage: store, MountPoint: mp, + Class: classByStore[store], Backup: backup, + }) + } + writeOK(w, StorageResponse{VMID: vmid, Mounts: mounts}) +} + +type snapshotRequest struct { + VMID int `json:"vmid"` // optional; must match the token's guest if set + Snapname string `json:"snapname"` + Description string `json:"description"` +} + +func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request, vmid int) { + var req snapshotRequest + if !decodeBody(w, r, &req) { + return + } + if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) { + return + } + snap := strings.TrimSpace(req.Snapname) + if snap == "" { + snap = "felhom-" + strconv.FormatInt(time.Now().UTC().Unix(), 10) + } + if !validSnapname(snap) { + writeErr(w, http.StatusBadRequest, "invalid snapname (allowed: letters, digits, '_', '-')") + return + } + upid, err := s.guests.Snapshot(r.Context(), vmid, snap, req.Description) + if err != nil { + s.logger.Error("local-api: snapshot", "vmid", vmid, "err", err) + writeErr(w, http.StatusBadGateway, "snapshot failed") + return + } + if _, err := s.guests.WaitTask(r.Context(), upid, proxmox.WaitOptions{}); err != nil { + s.logger.Error("local-api: snapshot task", "vmid", vmid, "err", err) + writeErr(w, http.StatusBadGateway, "snapshot task failed") + return + } + writeOK(w, map[string]any{"vmid": vmid, "snapname": snap}) +} + +type rollbackRequest struct { + VMID int `json:"vmid"` + Snapname string `json:"snapname"` +} + +func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request, vmid int) { + var req rollbackRequest + if !decodeBody(w, r, &req) { + return + } + if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) { + return + } + snap := strings.TrimSpace(req.Snapname) + if snap == "" || !validSnapname(snap) { + writeErr(w, http.StatusBadRequest, "snapname is required (letters, digits, '_', '-')") + return + } + upid, err := s.guests.Rollback(r.Context(), vmid, snap) + if err != nil { + s.logger.Error("local-api: rollback", "vmid", vmid, "snap", snap, "err", err) + writeErr(w, http.StatusBadGateway, "rollback failed") + return + } + if _, err := s.guests.WaitTask(r.Context(), upid, proxmox.WaitOptions{}); err != nil { + s.logger.Error("local-api: rollback task", "vmid", vmid, "err", err) + writeErr(w, http.StatusBadGateway, "rollback task failed") + return + } + writeOK(w, map[string]any{"vmid": vmid, "rolled_back_to": snap}) +} + +type backupRequest struct { + VMID int `json:"vmid"` +} + +func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int) { + // Body is optional; if present its vmid must match the token's guest. + if r.ContentLength != 0 { + var req backupRequest + if !decodeBody(w, r, &req) { + return + } + if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) { + return + } + } + // Enqueue: a vzdump runs for minutes, so we do not block the request. The backup runs on + // the server's base context (cancelled on daemon shutdown) and records into the store; the + // controller polls GET /backup/status. This is the crash-consistent path (8A); the + // app-consistent quiesce-then-backup loop is 8B. + base := s.baseCtx + if base == nil { + base = context.Background() + } + go func() { + bctx, cancel := context.WithTimeout(base, 2*time.Hour) + defer cancel() + b, err := s.backups.Backup(bctx, vmid) + if err != nil { + b.VMID = vmid + b.Success = false + if b.Error == "" { + b.Error = err.Error() + } + s.logger.Error("local-api: enqueued backup failed", "vmid", vmid, "err", err) + } else { + s.logger.Info("local-api: enqueued backup complete", "vmid", vmid, "archive", b.Archive) + } + s.store.RecordBackup(b) + }() + writeStatus(w, http.StatusAccepted, true, map[string]any{"vmid": vmid, "enqueued": true}, "") +} + +// BackupDueResponse is GET /backup/due. Thin in 8A: a guest with no successful backup recorded +// is "due"; otherwise not. Policy-scheduled cadence (hub manifest) lands in slice 10, and the +// quiesce-on-due consumer is 8B — both noted in the response. +type BackupDueResponse struct { + VMID int `json:"vmid"` + Due bool `json:"due"` + Reason string `json:"reason"` +} + +func (s *Server) handleBackupDue(w http.ResponseWriter, r *http.Request, vmid int) { + latest := s.latestBackupFor(r.Context(), vmid) + if latest == nil || !latest.Success { + writeOK(w, BackupDueResponse{VMID: vmid, Due: true, Reason: "no successful backup recorded yet"}) + return + } + writeOK(w, BackupDueResponse{VMID: vmid, Due: false, Reason: "policy-scheduled cadence lands in slice 10"}) +} + +func (s *Server) handleBackupStatus(w http.ResponseWriter, r *http.Request, vmid int) { + latest := s.latestBackupFor(r.Context(), vmid) + writeOK(w, map[string]any{"vmid": vmid, "backup": latest}) +} + +func (s *Server) handleRestoreTestStatus(w http.ResponseWriter, r *http.Request, vmid int) { + // The self-restore-test is host-level (it tests the newest backup in a throwaway scratch), + // not per-guest — the controller surfaces it in its UI. Return the latest (0 or 1). + tests := s.store.RestoreTests(r.Context()) + var latest *hub.RestoreTest + if len(tests) > 0 { + latest = &tests[0] + } + writeOK(w, map[string]any{"restore_test": latest}) +} + +// ---- helpers ---------------------------------------------------------------------------- + +// latestBackupFor returns this guest's most recent backup from the store (nil if none). +func (s *Server) latestBackupFor(ctx context.Context, vmid int) *hub.Backup { + var latest *hub.Backup + for _, b := range s.store.Backups(ctx) { + if b.VMID != vmid { + continue + } + bb := b + if latest == nil || bb.StartedAt > latest.StartedAt { // RFC3339 sorts lexically + latest = &bb + } + } + return latest +} + +// classByStorage builds a storage-id → class(fast|slow|"") map from the host storage view. A +// view error is logged and yields an empty map (class falls back to "" — a hint, not load-bearing). +func (s *Server) classByStorage(ctx context.Context) map[string]string { + out := map[string]string{} + targets, err := s.storage.Observe(ctx) + if err != nil { + s.logger.Warn("local-api: storage view unavailable for class hints", "err", err) + return out + } + for _, t := range targets { + out[t.Name] = t.ClassHint + } + return out +} + +// parseMount splits a PVE mpN value like "local-lvm:1,mp=/mnt/bulk,backup=0" into its storage +// id, in-guest mount path, and backup flag. +func parseMount(val string) (storage, mountPoint string, backup bool) { + parts := strings.Split(val, ",") + if len(parts) > 0 { + if i := strings.IndexByte(parts[0], ':'); i >= 0 { + storage = parts[0][:i] + } else { + storage = parts[0] + } + } + for _, p := range parts[1:] { + switch { + case strings.HasPrefix(p, "mp="): + mountPoint = strings.TrimPrefix(p, "mp=") + case strings.HasPrefix(p, "backup="): + backup = strings.TrimPrefix(p, "backup=") == "1" + } + } + return storage, mountPoint, backup +} + +// validSnapname allows PVE-safe snapshot names (letters, digits, '_' and '-'). +func validSnapname(s string) bool { + if s == "" || len(s) > 64 { + return false + } + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' { + continue + } + return false + } + return true +} + +func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Body == nil || r.ContentLength == 0 { + return true // empty body is allowed; fields stay zero + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return false + } + return true +} + +// ---- response envelope (matches the controller's {ok,data,error} style) ----------------- + +type apiResponse struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +func writeOK(w http.ResponseWriter, data any) { + writeStatus(w, http.StatusOK, true, data, "") +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeStatus(w, code, false, nil, msg) +} + +func writeStatus(w http.ResponseWriter, code int, ok bool, data any, errMsg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(apiResponse{OK: ok, Data: data, Error: errMsg}) +} diff --git a/internal/localapi/server_test.go b/internal/localapi/server_test.go new file mode 100644 index 0000000..a93eb6a --- /dev/null +++ b/internal/localapi/server_test.go @@ -0,0 +1,340 @@ +package localapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-agent/internal/hub" + "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" +) + +// ---- fakes ------------------------------------------------------------------------------ + +// fakeGuests records every VMID it was called with, so a test can assert an op was NOT issued +// for a guest the caller is not scoped to. +type fakeGuests struct { + mu sync.Mutex + snapVMIDs []int + rbVMIDs []int + cfgVMIDs []int + mounts map[string]string // mpN -> value, returned by GuestConfig + failSnap bool +} + +func (f *fakeGuests) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) { + f.mu.Lock() + f.cfgVMIDs = append(f.cfgVMIDs, vmid) + f.mu.Unlock() + extra := map[string]json.RawMessage{} + for k, v := range f.mounts { + b, _ := json.Marshal(v) + extra[k] = b + } + return proxmox.GuestConfig{Extra: extra}, nil +} + +func (f *fakeGuests) Snapshot(_ context.Context, vmid int, _, _ string) (string, error) { + f.mu.Lock() + f.snapVMIDs = append(f.snapVMIDs, vmid) + f.mu.Unlock() + if f.failSnap { + return "", fmt.Errorf("boom") + } + return "UPID:snap", nil +} + +func (f *fakeGuests) Rollback(_ context.Context, vmid int, _ string) (string, error) { + f.mu.Lock() + f.rbVMIDs = append(f.rbVMIDs, vmid) + f.mu.Unlock() + return "UPID:rb", nil +} + +func (f *fakeGuests) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) { + return proxmox.TaskStatus{ExitStatus: "OK"}, nil +} + +type fakeBackups struct { + mu sync.Mutex + vmids []int +} + +func (f *fakeBackups) Backup(_ context.Context, vmid int) (hub.Backup, error) { + f.mu.Lock() + f.vmids = append(f.vmids, vmid) + f.mu.Unlock() + return hub.Backup{VMID: vmid, Success: true, Archive: "local:backup/vzdump-x", StartedAt: "2026-06-10T00:00:00Z"}, nil +} +func (f *fakeBackups) called() []int { f.mu.Lock(); defer f.mu.Unlock(); return append([]int(nil), f.vmids...) } + +type fakeStore struct { + mu sync.Mutex + backups []hub.Backup + tests []hub.RestoreTest +} + +func (s *fakeStore) RecordBackup(b hub.Backup) { s.mu.Lock(); s.backups = append(s.backups, b); s.mu.Unlock() } +func (s *fakeStore) Backups(context.Context) []hub.Backup { + s.mu.Lock() + defer s.mu.Unlock() + return append([]hub.Backup(nil), s.backups...) +} +func (s *fakeStore) RestoreTests(context.Context) []hub.RestoreTest { + s.mu.Lock() + defer s.mu.Unlock() + return append([]hub.RestoreTest(nil), s.tests...) +} + +type fakeStorage struct{ targets []hub.StorageTarget } + +func (f fakeStorage) Observe(context.Context) ([]hub.StorageTarget, error) { return f.targets, nil } + +// staticTokens is a fixed token→guest map for server tests (the durable store is tested +// separately). Token "A" → guest 8200, token "B" → guest 9300. +type staticTokens map[string]int + +func (m staticTokens) Lookup(tok string) (int, bool) { v, ok := m[tok]; return v, ok } + +// ---- harness ---------------------------------------------------------------------------- + +func newTestServer(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) http.Handler { + t.Helper() + if sv == nil { + sv = fakeStorage{} + } + srv, err := NewServer(Options{ + ListenAddr: "127.0.0.1:0", + Guests: g, + Backups: b, + Store: st, + Storage: sv, + Tokens: staticTokens{"A": 8200, "B": 9300}, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatalf("new server: %v", err) + } + srv.baseCtx = context.Background() + return srv.Handler() +} + +func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + t.Helper() + var r *http.Request + if body != "" { + r = httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequest(method, path, nil) + } + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// ---- auth ------------------------------------------------------------------------------- + +func TestAuth_AbsentAndWrongToken(t *testing.T) { + g := &fakeGuests{} + h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) + + if w := do(t, h, "GET", "/storage", "", ""); w.Code != http.StatusUnauthorized { + t.Fatalf("absent token: got %d, want 401", w.Code) + } + if w := do(t, h, "GET", "/storage", "nope", ""); w.Code != http.StatusUnauthorized { + t.Fatalf("wrong token: got %d, want 401", w.Code) + } + if len(g.cfgVMIDs) != 0 { + t.Fatal("guest config was read despite failed auth") + } +} + +// ---- self-scoping: the headline security test ------------------------------------------- + +// A token for guest A (8200) cannot snapshot/rollback/backup guest B (9300): the agent refuses +// with 403 and the proxmox op is NEVER issued for B. +func TestSelfScoping_CrossGuestRefused(t *testing.T) { + g := &fakeGuests{} + b := &fakeBackups{} + h := newTestServer(t, g, b, &fakeStore{}, nil) + + // token A targets guest B via explicit body vmid → 403, op not called. + if w := do(t, h, "POST", "/snapshot", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden { + t.Fatalf("cross-guest snapshot: got %d, want 403", w.Code) + } + if w := do(t, h, "POST", "/rollback", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden { + t.Fatalf("cross-guest rollback: got %d, want 403", w.Code) + } + if w := do(t, h, "POST", "/backup", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden { + t.Fatalf("cross-guest backup: got %d, want 403", w.Code) + } + // token A targets guest B via explicit query vmid → 403. + if w := do(t, h, "GET", "/storage?vmid=9300", "A", ""); w.Code != http.StatusForbidden { + t.Fatalf("cross-guest storage query: got %d, want 403", w.Code) + } + + if len(g.snapVMIDs) != 0 || len(g.rbVMIDs) != 0 { + t.Fatalf("a guest op was issued on a cross-guest request: snaps=%v rb=%v", g.snapVMIDs, g.rbVMIDs) + } + // the backup goroutine must never have started for B + time.Sleep(20 * time.Millisecond) + if called := b.called(); len(called) != 0 { + t.Fatalf("backup issued on a cross-guest request: %v", called) + } +} + +// Own-guest ops call the proxmox op for the CORRECT VMID (the token's guest), even when the +// caller names its own guest explicitly. +func TestSelfScoping_OwnGuestUsesTokenVMID(t *testing.T) { + g := &fakeGuests{} + h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) + + if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK { + t.Fatalf("own snapshot: got %d (%s)", w.Code, w.Body.String()) + } + if w := do(t, h, "POST", "/snapshot", "B", `{"vmid":9300,"snapname":"ok"}`); w.Code != http.StatusOK { + t.Fatalf("own snapshot (explicit matching vmid): got %d", w.Code) + } + if len(g.snapVMIDs) != 2 || g.snapVMIDs[0] != 8200 || g.snapVMIDs[1] != 9300 { + t.Fatalf("snapshot used wrong VMIDs: %v", g.snapVMIDs) + } +} + +// ---- endpoints -------------------------------------------------------------------------- + +func TestStorage_ReturnsOnlyThisGuestMountsWithClass(t *testing.T) { + g := &fakeGuests{mounts: map[string]string{ + "mp0": "fastpool:8,mp=/var/lib/docker,backup=1", + "mp1": "bulk:200,mp=/mnt/media,backup=0", + }} + sv := fakeStorage{targets: []hub.StorageTarget{ + {Name: "fastpool", ClassHint: "fast"}, + {Name: "bulk", ClassHint: "slow"}, + }} + h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, sv) + + w := do(t, h, "GET", "/storage", "A", "") + if w.Code != http.StatusOK { + t.Fatalf("storage: got %d (%s)", w.Code, w.Body.String()) + } + var resp struct { + Data StorageResponse `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Data.VMID != 8200 { + t.Fatalf("vmid: got %d want 8200", resp.Data.VMID) + } + byKey := map[string]MountInfo{} + for _, m := range resp.Data.Mounts { + byKey[m.Key] = m + } + if byKey["mp0"].Class != "fast" || byKey["mp0"].Storage != "fastpool" || byKey["mp0"].MountPoint != "/var/lib/docker" || !byKey["mp0"].Backup { + t.Fatalf("mp0 wrong: %+v", byKey["mp0"]) + } + if byKey["mp1"].Class != "slow" || byKey["mp1"].Backup { + t.Fatalf("mp1 wrong: %+v", byKey["mp1"]) + } + if g.cfgVMIDs[0] != 8200 { + t.Fatalf("guest config read for wrong vmid: %v", g.cfgVMIDs) + } +} + +func TestRollback_RequiresSnapname(t *testing.T) { + g := &fakeGuests{} + h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) + if w := do(t, h, "POST", "/rollback", "A", `{}`); w.Code != http.StatusBadRequest { + t.Fatalf("missing snapname: got %d, want 400", w.Code) + } + if len(g.rbVMIDs) != 0 { + t.Fatal("rollback issued without a snapname") + } + if w := do(t, h, "POST", "/rollback", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK { + t.Fatalf("valid rollback: got %d", w.Code) + } + if len(g.rbVMIDs) != 1 || g.rbVMIDs[0] != 8200 { + t.Fatalf("rollback VMIDs: %v", g.rbVMIDs) + } +} + +func TestSnapshot_RejectsBadName(t *testing.T) { + g := &fakeGuests{} + h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) + if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"bad name/slash"}`); w.Code != http.StatusBadRequest { + t.Fatalf("bad snapname: got %d, want 400", w.Code) + } + if len(g.snapVMIDs) != 0 { + t.Fatal("snapshot issued with an invalid name") + } +} + +func TestBackup_EnqueuesForTokenGuest(t *testing.T) { + g := &fakeGuests{} + b := &fakeBackups{} + st := &fakeStore{} + h := newTestServer(t, g, b, st, nil) + + w := do(t, h, "POST", "/backup", "A", "") + if w.Code != http.StatusAccepted { + t.Fatalf("backup enqueue: got %d, want 202", w.Code) + } + // the goroutine should run and record for guest 8200 + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if c := b.called(); len(c) == 1 && c[0] == 8200 { + break + } + time.Sleep(5 * time.Millisecond) + } + if c := b.called(); len(c) != 1 || c[0] != 8200 { + t.Fatalf("backup not enqueued for token guest: %v", c) + } +} + +func TestBackupDue_ThinHeuristic(t *testing.T) { + st := &fakeStore{} + h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil) + + // no backup recorded → due + w := do(t, h, "GET", "/backup/due", "A", "") + var resp struct { + Data BackupDueResponse `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if !resp.Data.Due { + t.Fatal("expected due=true with no backup recorded") + } + // a successful backup for this guest → not due + st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-10T00:00:00Z"}} + w = do(t, h, "GET", "/backup/due", "A", "") + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.Data.Due { + t.Fatal("expected due=false after a successful backup") + } +} + +func TestBackupStatus_FiltersToThisGuest(t *testing.T) { + st := &fakeStore{backups: []hub.Backup{ + {VMID: 9300, Success: true, StartedAt: "2026-06-10T10:00:00Z", Archive: "other"}, + {VMID: 8200, Success: true, StartedAt: "2026-06-10T09:00:00Z", Archive: "mine-old"}, + {VMID: 8200, Success: true, StartedAt: "2026-06-10T11:00:00Z", Archive: "mine-new"}, + }} + h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil) + w := do(t, h, "GET", "/backup/status", "A", "") + if !strings.Contains(w.Body.String(), "mine-new") || strings.Contains(w.Body.String(), "other") { + t.Fatalf("status not scoped to this guest / not latest: %s", w.Body.String()) + } +} diff --git a/internal/localapi/tokenstore.go b/internal/localapi/tokenstore.go new file mode 100644 index 0000000..3e2d87c --- /dev/null +++ b/internal/localapi/tokenstore.go @@ -0,0 +1,178 @@ +// Package localapi implements the agent's per-guest local API (doc 03 §6, slice 8A): the +// HTTPS server the in-guest controller calls over the bridge, the durable per-guest token +// store, and the self-signed leaf the agent serves. The agent is the per-guest authorization +// gate — every call is authorized against the token's guest only (self-scoped), never a +// caller-supplied id. +package localapi + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" +) + +// TokenStore is the durable, crash-safe per-guest token→guest map. It mirrors the +// authz.FileNonceStore mechanism (fsync'd append-only JSONL + in-memory index) with two +// differences: it stores a SHA-256 HASH of each token (never the plaintext — the plaintext +// exists only transiently between Mint and write-to-mount, then is discarded), and it is a +// last-write-wins map keyed by VMID (re-provisioning a guest rotates its token). +// +// Security: a leaked token store reveals only hashes, not usable tokens. Lookup is +// constant-time per candidate (subtle.ConstantTimeCompare) to avoid leaking the hash via +// timing. Single-process; one mutex guards the file handle and both indexes (03 §10). +type TokenStore struct { + mu sync.Mutex + path string + f *os.File + byHash map[string]int // tokenHash(hex) -> vmid + byVMID map[int]string // vmid -> current tokenHash(hex) +} + +// tokenRecord is one durable line: VMID v gets token-hash h (last write per v wins). +type tokenRecord struct { + VMID int `json:"v"` + Hash string `json:"h"` +} + +// tokenBytes is the per-guest token length (256-bit; well above the ≥128-bit requirement). +const tokenBytes = 32 + +// OpenTokenStore opens (or creates) the durable store at path, replaying the log into the +// in-memory indexes (last record per VMID wins; a superseded hash is dropped from byHash). +func OpenTokenStore(path string) (*TokenStore, error) { + s := &TokenStore{ + path: path, + byHash: make(map[string]int), + byVMID: make(map[int]string), + } + if err := s.load(); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("localapi: token store dir: %w", err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + s.f = f + syncDir(filepath.Dir(path)) + return s, nil +} + +func (s *TokenStore) load() error { + b, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + for _, line := range bytes.Split(b, []byte("\n")) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + var r tokenRecord + if json.Unmarshal(line, &r) != nil { + continue // skip a torn trailing line from a crash mid-append + } + s.apply(r) + } + return nil +} + +// apply folds one record into the indexes (last-write-wins per VMID). Caller holds the mutex +// (or is in single-threaded load). +func (s *TokenStore) apply(r tokenRecord) { + if old, ok := s.byVMID[r.VMID]; ok && old != r.Hash { + delete(s.byHash, old) // the previous token for this guest is no longer valid + } + s.byVMID[r.VMID] = r.Hash + s.byHash[r.Hash] = r.VMID +} + +// Mint generates a fresh high-entropy token for vmid, durably records its HASH (last-write +// wins — any previous token for this guest is revoked), and returns the PLAINTEXT exactly +// once. The caller must write the plaintext into the guest's bootstrap mount and then discard +// it; only the hash is persisted. On any I/O failure the token is not recorded and an error is +// returned (the caller must not hand out an unrecorded token). +func (s *TokenStore) Mint(vmid int) (string, error) { + if vmid <= 0 { + return "", fmt.Errorf("localapi: Mint needs a positive vmid") + } + raw := make([]byte, tokenBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("localapi: token entropy: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + hash := hashToken(token) + + s.mu.Lock() + defer s.mu.Unlock() + rec, _ := json.Marshal(tokenRecord{VMID: vmid, Hash: hash}) + rec = append(rec, '\n') + if _, err := s.f.Write(rec); err != nil { + return "", fmt.Errorf("localapi: token store write: %w", err) + } + if err := s.f.Sync(); err != nil { + return "", fmt.Errorf("localapi: token store sync: %w", err) + } + s.apply(tokenRecord{VMID: vmid, Hash: hash}) + return token, nil +} + +// Lookup resolves a presented bearer token to its guest VMID. It hashes the candidate and +// looks it up; the per-candidate comparison is constant-time to avoid a timing oracle on the +// stored hash. ok is false for an unknown/empty token. +func (s *TokenStore) Lookup(token string) (int, bool) { + if token == "" { + return 0, false + } + want := hashToken(token) + s.mu.Lock() + defer s.mu.Unlock() + // Direct map hit is the common path; the constant-time compare guards against a timing + // side-channel by re-checking the matched key (map lookup itself is not the secret-bearing + // comparison — the hash of a random 256-bit token is not feasibly guessable regardless). + if vmid, ok := s.byHash[want]; ok { + if subtle.ConstantTimeCompare([]byte(want), []byte(s.byVMID[vmid])) == 1 { + return vmid, true + } + } + return 0, false +} + +// Close releases the file handle. +func (s *TokenStore) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.f != nil { + return s.f.Close() + } + return nil +} + +// hashToken is the persisted representation of a token: lowercase hex SHA-256. +func hashToken(tok string) string { + sum := sha256.Sum256([]byte(tok)) + return hex.EncodeToString(sum[:]) +} + +// syncDir best-effort fsyncs a directory so a create/rename is durable. +func syncDir(dir string) { + if d, err := os.Open(dir); err == nil { + _ = d.Sync() + _ = d.Close() + } +} diff --git a/internal/localapi/tokenstore_test.go b/internal/localapi/tokenstore_test.go new file mode 100644 index 0000000..e746427 --- /dev/null +++ b/internal/localapi/tokenstore_test.go @@ -0,0 +1,131 @@ +package localapi + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTokenStore_MintLookup(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.log") + s, err := OpenTokenStore(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + + tok, err := s.Mint(8200) + if err != nil { + t.Fatalf("mint: %v", err) + } + if tok == "" { + t.Fatal("mint returned empty token") + } + if vmid, ok := s.Lookup(tok); !ok || vmid != 8200 { + t.Fatalf("lookup: got (%d,%v), want (8200,true)", vmid, ok) + } + if _, ok := s.Lookup("not-a-real-token"); ok { + t.Fatal("lookup of unknown token succeeded") + } + if _, ok := s.Lookup(""); ok { + t.Fatal("lookup of empty token succeeded") + } +} + +// The persisted file must contain only the HASH, never the plaintext token. +func TestTokenStore_PlaintextNeverPersisted(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.log") + s, err := OpenTokenStore(path) + if err != nil { + t.Fatalf("open: %v", err) + } + tok, err := s.Mint(101) + if err != nil { + t.Fatalf("mint: %v", err) + } + s.Close() + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read store: %v", err) + } + if strings.Contains(string(b), tok) { + t.Fatal("plaintext token found in the persisted store — must store only the hash") + } + if !strings.Contains(string(b), hashToken(tok)) { + t.Fatal("token hash not found in the persisted store") + } +} + +// A re-mint for the same guest revokes the previous token (last-write-wins). +func TestTokenStore_RemintRevokesOld(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.log") + s, err := OpenTokenStore(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + + old, _ := s.Mint(7) + fresh, _ := s.Mint(7) + if old == fresh { + t.Fatal("re-mint produced the same token") + } + if _, ok := s.Lookup(old); ok { + t.Fatal("old token still valid after re-mint") + } + if vmid, ok := s.Lookup(fresh); !ok || vmid != 7 { + t.Fatalf("fresh token lookup: got (%d,%v), want (7,true)", vmid, ok) + } +} + +// Durability: tokens survive a store reopen (replay). +func TestTokenStore_SurvivesReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.log") + s1, err := OpenTokenStore(path) + if err != nil { + t.Fatalf("open: %v", err) + } + a, _ := s1.Mint(1) + b, _ := s1.Mint(2) + // rotate guest 1 + a2, _ := s1.Mint(1) + s1.Close() + + s2, err := OpenTokenStore(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer s2.Close() + + if _, ok := s2.Lookup(a); ok { + t.Fatal("rotated-out token survived reopen") + } + if vmid, ok := s2.Lookup(a2); !ok || vmid != 1 { + t.Fatalf("guest1 current token lost across reopen: (%d,%v)", vmid, ok) + } + if vmid, ok := s2.Lookup(b); !ok || vmid != 2 { + t.Fatalf("guest2 token lost across reopen: (%d,%v)", vmid, ok) + } +} + +func TestTokenStore_Uniqueness(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.log") + s, err := OpenTokenStore(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + seen := map[string]bool{} + for i := 1; i <= 200; i++ { + tok, err := s.Mint(i) + if err != nil { + t.Fatalf("mint %d: %v", i, err) + } + if seen[tok] { + t.Fatalf("duplicate token at %d", i) + } + seen[tok] = true + } +} diff --git a/internal/provision/backhalf.go b/internal/provision/backhalf.go new file mode 100644 index 0000000..b176dd9 --- /dev/null +++ b/internal/provision/backhalf.go @@ -0,0 +1,158 @@ +package provision + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + + "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" +) + +// TokenMinter mints a per-guest local-API token, persisting only its hash. Satisfied by +// *localapi.TokenStore. +type TokenMinter interface { + Mint(vmid int) (string, error) +} + +// defaults for the config-mount layout. +const ( + // DefaultGuestPath is where the config mount appears INSIDE the guest (matches the + // controller's bootstrap.DefaultMountPath dir + the golden bootstrap unit). + DefaultGuestPath = "/etc/felhom-bootstrap" + // DefaultMountIndex is the mpN slot used for the config mount. It is intentionally high so + // it never collides with a bring-up data mount (mp0, mp1, …). + DefaultMountIndex = 9 + // bootstrapFile is the file name inside the config mount. + bootstrapFile = "bootstrap.json" + // mappedRoot is the unprivileged-LXC host uid/gid that maps to the guest's root (spike + // gotcha 1): files chowned to this appear as root:root 0600 inside the guest. + mappedRoot = "100000:100000" +) + +// BackHalf populates a guest's bootstrap config mount host-side (F3). It mints the per-guest +// token, renders bootstrap.json, writes it 0600, chowns it to the mapped guest-root, and attaches +// it as a read-only bind mount via `pct set`. The bind-mount attach + chown are host-root ops +// (NOT API ops and NOT one of proxmox.Privileged's 3 exceptions) — they run through the shared +// Runner (direct as root, or `sudo -n` with the configs/felhom-agent.sudoers PROVISION entries). +type BackHalf struct { + tokens TokenMinter + runner proxmox.Runner + stateDir string // agent state dir; the per-guest config dir lives under /guests//bootstrap + logger *slog.Logger +} + +// NewBackHalf builds the back-half. stateDir defaults to /var/lib/felhom-agent when empty. +func NewBackHalf(tokens TokenMinter, runner proxmox.Runner, stateDir string, logger *slog.Logger) *BackHalf { + if stateDir == "" { + stateDir = "/var/lib/felhom-agent" + } + if logger == nil { + logger = slog.Default() + } + return &BackHalf{tokens: tokens, runner: runner, stateDir: stateDir, logger: logger} +} + +// Input is everything the back-half needs that is NOT secret. The per-guest token is minted here, +// never supplied by the caller. +type Input struct { + VMID int + Customer DocCustomer + Hub DocHub + Endpoint string // local-api bridge IP:port + Fingerprint string // agent leaf-cert SHA-256 (hex) + GuestPath string // in-guest mount path; "" → DefaultGuestPath + MountIndex int // mpN slot; 0 → DefaultMountIndex (note: mp0 is a valid slot but reserved for data) +} + +// Result reports the placement of the config mount. It deliberately contains NO token (the secret +// lives only in the 0600 file + the token store's hash). +type Result struct { + VMID int + HostDir string // agent-owned host dir backing the bind mount + GuestPath string // in-guest mount path + MountKey string // mpN key used +} + +// Provision runs the back-half for one already-brought-up guest. Order: mint → render → write → +// chown → attach. On any failure the partial host dir is left for inspection (it holds the 0600 +// token file; it is not world-readable) and the error is returned. The token plaintext is NEVER +// logged and NEVER returned. +func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) { + if in.VMID <= 0 { + return Result{}, fmt.Errorf("provision: needs a positive vmid") + } + if in.Endpoint == "" || in.Fingerprint == "" { + return Result{}, fmt.Errorf("provision: needs the local-api endpoint and leaf fingerprint") + } + if in.Customer.ID == "" || in.Customer.Domain == "" { + return Result{}, fmt.Errorf("provision: needs customer id and domain (so the controller skips setup)") + } + guestPath := in.GuestPath + if guestPath == "" { + guestPath = DefaultGuestPath + } + idx := in.MountIndex + if idx == 0 { + idx = DefaultMountIndex + } + mountKey := "mp" + strconv.Itoa(idx) + + // 1. Mint the per-guest token (only its hash is persisted). The plaintext exists in `tok` + // until it is written into the mount below; it is never logged or returned. + tok, err := b.tokens.Mint(in.VMID) + if err != nil { + return Result{}, fmt.Errorf("provision: mint token: %w", err) + } + + // 2. Render the stable bootstrap.json contract (with the token injected). + doc := Doc{ + Schema: SchemaV1, + Customer: in.Customer, + Hub: in.Hub, + LocalAPI: DocLocalAPI{Endpoint: in.Endpoint, Fingerprint: in.Fingerprint, Token: tok}, + } + rendered, err := doc.render() + if err != nil { + return Result{}, fmt.Errorf("provision: render bootstrap: %w", err) + } + + // 3. Write it 0600 into the agent-owned per-guest config dir. + hostDir := filepath.Join(b.stateDir, "guests", strconv.Itoa(in.VMID), "bootstrap") + if err := os.MkdirAll(hostDir, 0o700); err != nil { + return Result{}, fmt.Errorf("provision: config dir: %w", err) + } + bootPath := filepath.Join(hostDir, bootstrapFile) + if err := os.WriteFile(bootPath, rendered, 0o600); err != nil { + return Result{}, fmt.Errorf("provision: write bootstrap: %w", err) + } + + // 4. chown to the unprivileged-LXC mapped root so the guest reads it as root:root 0600 + // (spike gotcha 1). Host-root op via the Runner. + if err := b.run(ctx, "chown", "-R", mappedRoot, hostDir); err != nil { + return Result{}, fmt.Errorf("provision: chown config mount: %w", err) + } + + // 5. Attach the read-only bind mount via `pct set` (host-root op; bind mounts are root@pam + // only, so this cannot be the API token). The golden's baked unit consumes it on boot. + mpSpec := fmt.Sprintf("%s,mp=%s,ro=1", hostDir, guestPath) + if err := b.run(ctx, "pct", "set", strconv.Itoa(in.VMID), "-"+mountKey, mpSpec); err != nil { + return Result{}, fmt.Errorf("provision: attach config mount: %w", err) + } + + b.logger.Info("provision: back-half complete", + "vmid", in.VMID, "mount", mountKey, "guest_path", guestPath, "endpoint", in.Endpoint) + // tok intentionally goes out of scope here — never logged, never returned. + return Result{VMID: in.VMID, HostDir: hostDir, GuestPath: guestPath, MountKey: mountKey}, nil +} + +// run executes a host-root command through the Runner, wrapping a nonzero exit with stderr. +func (b *BackHalf) run(ctx context.Context, name string, args ...string) error { + _, stderr, err := b.runner.Run(ctx, name, args...) + if err != nil { + return fmt.Errorf("%s: %w: %s", name, err, string(stderr)) + } + return nil +} diff --git a/internal/provision/backhalf_test.go b/internal/provision/backhalf_test.go new file mode 100644 index 0000000..a1776bd --- /dev/null +++ b/internal/provision/backhalf_test.go @@ -0,0 +1,160 @@ +package provision + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// recRunner records every command issued (to assert chown + pct set ran with correct args). +type recRunner struct { + mu sync.Mutex + cmds [][]string + fail string // if a command's name == fail, return an error +} + +func (r *recRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { + r.mu.Lock() + r.cmds = append(r.cmds, append([]string{name}, args...)) + r.mu.Unlock() + if name == r.fail { + return nil, []byte("boom"), io.ErrUnexpectedEOF + } + return nil, nil, nil +} + +func (r *recRunner) find(name string) []string { + r.mu.Lock() + defer r.mu.Unlock() + for _, c := range r.cmds { + if c[0] == name { + return c + } + } + return nil +} + +// mintMinter returns a fixed token and records the vmid it was minted for. +type mintMinter struct { + token string + vmids []int +} + +func (m *mintMinter) Mint(vmid int) (string, error) { m.vmids = append(m.vmids, vmid); return m.token, nil } + +func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func newInput() Input { + return Input{ + VMID: 8200, + Customer: DocCustomer{ID: "cust-8200", Domain: "cust8200.felhom.eu", Name: "Teszt"}, + Hub: DocHub{URL: "https://hub.felhom.eu", APIKey: "HUBKEY", HostID: "demo-felhom-01"}, + Endpoint: "192.168.0.162:8443", + Fingerprint: "ab12cd", + } +} + +func TestProvision_WritesChownsAndAttaches(t *testing.T) { + dir := t.TempDir() + runner := &recRunner{} + minter := &mintMinter{token: "SECRET-TOKEN-XYZ"} + bh := NewBackHalf(minter, runner, dir, testLogger()) + + res, err := bh.Provision(context.Background(), newInput()) + if err != nil { + t.Fatalf("provision: %v", err) + } + + // token minted for the right guest + if len(minter.vmids) != 1 || minter.vmids[0] != 8200 { + t.Fatalf("mint vmids: %v", minter.vmids) + } + + // bootstrap.json written 0600, contains the token + customer, valid contract + bootPath := filepath.Join(res.HostDir, "bootstrap.json") + info, err := os.Stat(bootPath) + if err != nil { + t.Fatalf("stat bootstrap: %v", err) + } + // Unix perms are not modeled on Windows; the 0600 is enforced on the Linux target (where the + // agent runs). Assert only where the OS honors it. + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("bootstrap perms: got %o want 600", perm) + } + } + raw, _ := os.ReadFile(bootPath) + var doc Doc + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("bootstrap not valid JSON: %v", err) + } + if doc.Schema != SchemaV1 || doc.Customer.ID != "cust-8200" || doc.LocalAPI.Token != "SECRET-TOKEN-XYZ" { + t.Fatalf("bootstrap content wrong: %+v", doc) + } + if doc.LocalAPI.Endpoint != "192.168.0.162:8443" || doc.LocalAPI.Fingerprint != "ab12cd" { + t.Fatalf("local_api wrong: %+v", doc.LocalAPI) + } + + // chown to the mapped guest-root ran on the host dir + chown := runner.find("chown") + if chown == nil || chown[1] != "-R" || chown[2] != "100000:100000" || chown[3] != res.HostDir { + t.Fatalf("chown command wrong: %v", chown) + } + + // pct set attached the read-only bind mount at the default high slot + pct := runner.find("pct") + if pct == nil { + t.Fatal("pct set not called") + } + joined := strings.Join(pct, " ") + if !strings.Contains(joined, "set 8200 -mp9") || !strings.Contains(joined, res.HostDir+",mp=/etc/felhom-bootstrap,ro=1") { + t.Fatalf("pct set command wrong: %v", pct) + } + if res.MountKey != "mp9" || res.GuestPath != "/etc/felhom-bootstrap" { + t.Fatalf("result placement wrong: %+v", res) + } +} + +// The Result must never carry the token, and the token must not appear in any field returned to +// the caller (secret discipline — only the 0600 file + the store hash hold it). +func TestProvision_ResultHasNoToken(t *testing.T) { + dir := t.TempDir() + bh := NewBackHalf(&mintMinter{token: "SECRET-TOKEN-XYZ"}, &recRunner{}, dir, testLogger()) + res, err := bh.Provision(context.Background(), newInput()) + if err != nil { + t.Fatal(err) + } + blob, _ := json.Marshal(res) + if strings.Contains(string(blob), "SECRET-TOKEN-XYZ") { + t.Fatalf("token leaked into the Result: %s", blob) + } +} + +func TestProvision_RejectsIncompleteInput(t *testing.T) { + dir := t.TempDir() + bh := NewBackHalf(&mintMinter{token: "t"}, &recRunner{}, dir, testLogger()) + bad := Input{VMID: 8200} // no endpoint/fingerprint/customer + if _, err := bh.Provision(context.Background(), bad); err == nil { + t.Fatal("expected an error for incomplete input") + } +} + +// A failed chown surfaces an error (and does not proceed to attach). +func TestProvision_ChownFailureStops(t *testing.T) { + dir := t.TempDir() + runner := &recRunner{fail: "chown"} + bh := NewBackHalf(&mintMinter{token: "t"}, runner, dir, testLogger()) + if _, err := bh.Provision(context.Background(), newInput()); err == nil { + t.Fatal("expected chown failure to surface") + } + if runner.find("pct") != nil { + t.Fatal("pct set ran despite a chown failure") + } +} diff --git a/internal/provision/doc.go b/internal/provision/doc.go new file mode 100644 index 0000000..f004113 --- /dev/null +++ b/internal/provision/doc.go @@ -0,0 +1,50 @@ +// Package provision implements the slice-8A provisioning BACK HALF: after the slice-7 bring-up +// front half (restore → identity → size → start), the agent mints a per-guest local-API token, +// renders the stable bootstrap.json contract, and populates a read-only config mount the golden's +// baked controller-bootstrap unit consumes (F3: host-side only, no pct exec). +// +// The agent NEVER enters the guest and NEVER puts a registry credential in the guest (the +// controller image is baked into the golden — configs/build-golden.sh). The only secret written +// into the guest is the per-guest local-API token, in the 0600 bootstrap.json on the config mount. +package provision + +import "encoding/json" + +// SchemaV1 is the stable agent→controller contract version. It MUST stay byte-compatible with the +// controller's internal/bootstrap.SchemaV1 / Bootstrap shape (cross-repo contract; doc_test.go +// pins the key set, mirroring the controller's bootstrap_test.go). +const SchemaV1 = "felhom.bootstrap/v1" + +// Doc is the bootstrap.json the agent emits. Field names + json tags MUST match the controller's +// internal/bootstrap.Bootstrap exactly. It carries ONLY what the controller needs to come up +// configured and reach the agent's local API — no registry credential (image is baked). +type Doc struct { + Schema string `json:"schema"` + Customer DocCustomer `json:"customer"` + Hub DocHub `json:"hub"` + LocalAPI DocLocalAPI `json:"local_api"` +} + +type DocCustomer struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + Email string `json:"email"` +} + +type DocHub struct { + URL string `json:"url"` + APIKey string `json:"api_key"` + HostID string `json:"host_id"` +} + +type DocLocalAPI struct { + Endpoint string `json:"endpoint"` // host bridge IP:port + Fingerprint string `json:"fingerprint"` // agent leaf-cert SHA-256 (hex) to pin + Token string `json:"token"` // per-guest bearer; SECRET — written 0600 only +} + +// render marshals the doc as indented JSON (the bytes written into the config mount). +func (d Doc) render() ([]byte, error) { + return json.MarshalIndent(d, "", " ") +} diff --git a/internal/provision/doc_test.go b/internal/provision/doc_test.go new file mode 100644 index 0000000..abce39e --- /dev/null +++ b/internal/provision/doc_test.go @@ -0,0 +1,62 @@ +package provision + +import ( + "encoding/json" + "sort" + "testing" +) + +// The bootstrap.json key set is a CROSS-REPO contract: it must match felhom-controller's +// internal/bootstrap.Bootstrap exactly. This test pins the emitted key set; the controller's +// bootstrap_test.go ingests the same shape. A drift here (or there) breaks provisioning. +func TestDoc_ContractKeySet(t *testing.T) { + d := Doc{ + Schema: SchemaV1, + Customer: DocCustomer{ID: "c", Name: "n", Domain: "d", Email: "e"}, + Hub: DocHub{URL: "u", APIKey: "k", HostID: "h"}, + LocalAPI: DocLocalAPI{Endpoint: "ep", Fingerprint: "fp", Token: "tok"}, + } + b, err := d.render() + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + assertKeys(t, "top", m, []string{"schema", "customer", "hub", "local_api"}) + + var full struct { + Customer map[string]json.RawMessage `json:"customer"` + Hub map[string]json.RawMessage `json:"hub"` + LocalAPI map[string]json.RawMessage `json:"local_api"` + } + if err := json.Unmarshal(b, &full); err != nil { + t.Fatal(err) + } + assertKeys(t, "customer", full.Customer, []string{"id", "name", "domain", "email"}) + assertKeys(t, "hub", full.Hub, []string{"url", "api_key", "host_id"}) + assertKeys(t, "local_api", full.LocalAPI, []string{"endpoint", "fingerprint", "token"}) + + if SchemaV1 != "felhom.bootstrap/v1" { + t.Fatalf("schema drift: %q", SchemaV1) + } +} + +func assertKeys(t *testing.T, label string, m map[string]json.RawMessage, want []string) { + t.Helper() + got := make([]string, 0, len(m)) + for k := range m { + got = append(got, k) + } + sort.Strings(got) + sort.Strings(want) + if len(got) != len(want) { + t.Fatalf("%s: key set %v, want %v", label, got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("%s: key set %v, want %v", label, got, want) + } + } +}