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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+200
-7
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "", " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user