slice 8A (agent half): local-API server + provisioning back-half (v0.10.0)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 09:47:42 +02:00
parent fae11020a5
commit 3fecf4c713
18 changed files with 2203 additions and 11 deletions
+57
View File
@@ -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
+1 -1
View File
@@ -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=<v>"`; `--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=<v>"`; `--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
+201 -8
View File
@@ -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=<v>"; 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 <golden volid> and -vmid <N>")
return 2
}
if cfg.Backup.RestoreStorage == "" {
fmt.Fprintln(os.Stderr, "selftest=provision requires backup.restore_storage in config")
return 2
}
if a.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
}
+7
View File
@@ -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"
}
+61 -1
View File
@@ -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 <<UNIT
[Unit]
Description=Felhom controller bootstrap (deploy the baked controller from the agent-populated config mount)
After=docker.service network-online.target
Wants=docker.service network-online.target
ConditionPathExists=/etc/felhom-bootstrap/bootstrap.json
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/felhom-controller-bootstrap.sh
[Install]
WantedBy=multi-user.target
UNIT
systemctl enable felhom-controller-bootstrap.service'
echo "[golden] baking the first-boot SSH host-key regeneration unit (F3) …"
pct exec "$VMID" -- bash -c 'cat > /etc/systemd/system/felhom-regen-hostkeys.service <<UNIT
[Unit]
+11 -1
View File
@@ -26,4 +26,14 @@ Cmnd_Alias FELHOM_DISK = \
/usr/sbin/smartctl -a -j /dev/hd[a-z]*, \
/usr/sbin/lvs --reportformat json --units b -o lv_name,data_percent,metadata_percent -- *
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK
# Provisioning back-half (slice 8A, doc 03 §6): populate a guest's bootstrap config mount
# host-side (internal/provision). These are host-root ops the API token cannot do — a bind mount
# is root@pam-only, and the chown maps the 0600 bootstrap.json to the unprivileged-LXC guest-root
# (uid/gid 100000, spike gotcha 1). The host dir is AGENT-OWNED state under /var/lib/felhom-agent/
# (the wildcard only ever names a path the agent itself created), and the bootstrap file the agent
# writes there is the only thing these touch. ':' is escaped per sudoers grammar.
Cmnd_Alias FELHOM_PROVISION = \
/usr/bin/chown -R 100000\:100000 /var/lib/felhom-agent/guests/*, \
/usr/sbin/pct set [0-9]* -mp[0-9]* /var/lib/felhom-agent/guests/*
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION
+40
View File
@@ -0,0 +1,40 @@
# felhom-agent local API — host firewall narrowing (doc 03 §6, slice 8A)
#
# Defense-in-depth for the per-guest local API (the controller→agent channel on the host
# bridge). The PER-GUEST BEARER TOKEN is the authorization gate; this firewall rule is an
# ADDITIONAL layer that limits who can even reach the port. The slice-8A spike found no rule
# was needed for reachability on the demo (PVE firewall off) — this narrows exposure so that
# only guests on the bridge subnet (not arbitrary LAN hosts) can open a connection.
#
# The agent already binds the listener to the host BRIDGE IP (local_api.listen_addr), not
# 0.0.0.0. This file adds the subnet restriction. Apply it at HOST SETUP (it is a host-level
# packet-filter change, intentionally OUTSIDE the agent's 3-exception privileged fence — the
# agent never mutates the host firewall at runtime).
#
# Replace the bridge IP (192.168.0.162), port (8443), and the guest bridge subnet
# (192.168.0.0/24) with this host's values.
#
# ---------------------------------------------------------------------------------------------
# Option A — nftables (recommended on PVE 8/9; inet filter table). Insert ABOVE any accept:
#
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
# ip saddr != 192.168.0.0/24 drop
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
# ip saddr 192.168.0.0/24 accept
#
# ---------------------------------------------------------------------------------------------
# Option B — iptables:
#
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -s 192.168.0.0/24 -j ACCEPT
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -j DROP
#
# ---------------------------------------------------------------------------------------------
# Option C — PVE host firewall (/etc/pve/nodes/<node>/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 <bridge-ip>:8443 succeeds;
# from an OFF-bridge host it is refused/dropped. (The token + leaf-pin still gate the request.)
+74
View File
@@ -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 <token_store_dir>/local-api.crt
KeyFile string `json:"key_file"` // default <token_store_dir>/local-api.key
// TokenStore is the durable, hashed token→guest map (only a HASH of each token is
// persisted; the plaintext exists transiently at mint→write-to-mount, then is discarded).
TokenStore string `json:"token_store"` // default /var/lib/felhom-agent/local-tokens.log
}
// Default local-API file locations (under the agent's state dir).
const (
defaultLocalAPITokenStore = "/var/lib/felhom-agent/local-tokens.log"
defaultLocalAPICert = "/var/lib/felhom-agent/local-api.crt"
defaultLocalAPIKey = "/var/lib/felhom-agent/local-api.key"
)
// Enabled reports whether the local-API server should run.
func (l LocalAPIConfig) Enabled() bool {
return l.Enable && strings.TrimSpace(l.ListenAddr) != ""
}
// TokenStorePath returns the configured token-store path (default applied).
func (l LocalAPIConfig) TokenStorePath() string {
if l.TokenStore != "" {
return l.TokenStore
}
return defaultLocalAPITokenStore
}
// CertPath/KeyPath return the configured leaf cert/key paths (defaults applied).
func (l LocalAPIConfig) CertPath() string {
if l.CertFile != "" {
return l.CertFile
}
return defaultLocalAPICert
}
func (l LocalAPIConfig) KeyPath() string {
if l.KeyFile != "" {
return l.KeyFile
}
return defaultLocalAPIKey
}
// Validate checks the local-API config is usable when enabled.
func (l LocalAPIConfig) Validate() error {
if !l.Enable {
return nil
}
if strings.TrimSpace(l.ListenAddr) == "" {
return fmt.Errorf("config: local_api.listen_addr is required when local_api.enable is set (use the host bridge IP:port, e.g. 192.168.0.162:8443)")
}
if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil {
return fmt.Errorf("config: local_api.listen_addr %q is not host:port: %w", l.ListenAddr, err)
}
return nil
}
// EscrowConfig configures PBS recovery-code escrow creation (slice 7, doc 03 §8a). Enrollment-time
// only (not the steady-state daemon). The default posture is zero-knowledge (Felhom holds the
// opaque blob, the customer holds the recovery code).
+126
View File
@@ -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
}
+57
View File
@@ -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")
}
}
+489
View File
@@ -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 <token> 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})
}
+340
View File
@@ -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())
}
}
+178
View File
@@ -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()
}
}
+131
View File
@@ -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
}
}
+158
View File
@@ -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 <stateDir>/guests/<vmid>/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
}
+160
View File
@@ -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")
}
}
+50
View File
@@ -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, "", " ")
}
+62
View File
@@ -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)
}
}
}