Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
SPIKE — Day-0 first-boot trust handshake (operator-deploy)
Update 2026-06-28 (BUNDLE slice): Day-0 now self-installs the agent + fetches the golden from Gitea. The host-bootstrap script (
scripts/felhom-host-install.shv1.1.0) fetches the agent binary + golden from Gitea generic packages and verifies each sha256 against a hub-vouched artifact manifest (GET /api/v1/artifacts/{id}, hub v0.16.0) before installing/using them — the fetch credential is the existing config-retrieve git token (no new credential); the checksum trust root is the hub, not Gitea. The "works on a box that isn't felhom-pve" gap is closed: prerequisites are now just install PVE + create the customer in the hub. The agent runs non-root (felhom-agent+ sudoers). Remaining follow-ups: per-customer artifact pinning, amake goldenthat resolves-latest, non-root PBS-key access, the DR mode, and the local-DNS slice.
Date: 2026-06-26 Class: Spike (empirical validation; no production code shipped). Output is this doc only. Question: Does the composed first-boot chain — customer-in-hub → host mint → agent host-auth → controller config-pull — work end-to-end today with existing mechanisms (option A), and what exact contracts/gaps must the host-bootstrap script own? Secondary: is option C worth a hub change?
Verdict (short): Option A works end-to-end today with ZERO new code. All three trust links plus every wrong-case refusal behaved exactly as the source predicts, exercised against the LIVE hub with the real agent hub-client. Option C (collapse to one secret) is feasible and recommended for the production cutover but is a deliberate, small hub change — out of scope for this spike. No blocking gap. The host-bootstrap script can be specified on the contracts below.
1. Confirmed baselines (live Gitea = local HEAD, verified by git fetch)
| Repo | main @ commit |
Version | Role |
|---|---|---|---|
| felhom.eu (hub) | 347546e |
v0.13.1 | mints host (POST /admin/hosts), serves config-pull |
| felhom-agent | 4725396 (code = 166a1c8, v0.41.0) |
v0.41.0 | consumes host credential; authenticates to hub |
| felhom-controller | e0cf78b |
v0.84.0 | in-guest config-pull (the already-proven half) |
The controller advanced to v0.84.0 (prompt said v0.82.0); irrelevant — the controller half was re-confirmed at the hub contract, which is unchanged.
Exact config-retrieve route string (the spike needed this):
GET /api/v1/config/{customer_id}
Header: X-Retrieval-Password: <5-word passphrase>
→ 200, Content-Type: text/yaml; charset=utf-8, body = the generated controller.yaml (RAW yaml, not JSON)
Confirmed at hub/internal/api/handler.go:187-189 (router) → handleConfigRetrieve (:1341), and it is
the literal command the operator UI prints (internal/web/templates/config_detail.html:115):
curl -fsSL https://hub.felhom.eu/api/v1/config/{id} -H "X-Retrieval-Password: {pw}" -o controller.yaml.
Correction to the prompt's grounding: config-retrieve is GET with the customer_id in the PATH and the response is raw YAML — not a POST with a
{customer_id}body returning a JSON{ConfigYAML}. The host-bootstrap script spec must use the GET form.
Correction #2: the agent hub env vars are
FELHOM_AGENT_HUB_URL/FELHOM_AGENT_HUB_HOST_ID/FELHOM_AGENT_HUB_API_KEY(prefixFELHOM_AGENT_HUB_), confirmed atconfig.go:446-453— notFELHOM_HUB_*as the prompt stated.
2. Environment & method
- Live hub:
hub.felhom.euon k3s (felhom-system/hub-844754f7b4-...), SQLite at/data/hub.db, config at/etc/felhom-hub/hub.yaml. Global key =api.report_api_key(read out-of-band from the pod config into a shell var on the build box; never printed, never written to disk or this doc). - Throwaway customer:
spike-day0-2026-06-26(domainspike.demo-felhom.eu, a throwaway 64-hex customer api_key, a throwaway passphrase, config_json carrying a fake tunnel token). Inserted via the hub's owncustomer_configsschema (the web-form create path is operator-auth-only; the row is byte-identical to whatSaveCustomerConfigwrites). The realdemo-felhomcustomer was not touched. - Probes ran from the build box (
192.168.0.180, reacheshub.felhom.eu). Link 3 (agent auth) was exercised with the realinternal/hubclient via a throwawaycmd/spikeprobebuilt from the agent checkout — not a hand-rolled curl. No Proxmox, no provision, no real customer data. - All secrets (global key, customer key, passphrase, minted host api_key) stayed resident on the build
box in
/tmp/spike-day0/; only status codes, the non-secrethost_id, lengths, and boolean content checks were surfaced.
3. Per-probe results
Probe 1 — Host mint (link 2, option A) — WORKS
POST /api/v1/admin/hosts, handler handleAdminCreateHost (handler.go:599), global-key gated.
| Case | Request | Expected | Observed |
|---|---|---|---|
| 1a correct | global key, {customer_id: spike-day0-…} |
201 + {host_id, api_key} |
201, host_id=spike-day0-2026-06-26-6e5dc8, api_key 64-hex |
| host_id default | omitted host_id |
<customer>-<6hex> |
matched ^<customer>-[0-9a-f]{6}$ |
| row landed | — | hosts row resolvable |
present, api_key length 64 (GetHostByAPIKey works — proven in Probe 2) |
| 1c wrong | customer key (non-global) | 403 | 403 |
| 1d wrong | minted per-host key (non-global) | 403 | 403 |
| 1e wrong | global key, unknown customer | 400 Unknown customer_id |
400 "Unknown customer_id" |
| 1f wrong | no Authorization header | 403 | 403 |
The !isGlobal gate (handler.go:600-604) and the cc == nil → 400 customer-existence check
(:624-627) are correct, not bugs.
Probe 2 — Agent host-auth (link 3) with the minted credential — WORKS
Exercised the real internal/hub.Client (client.go) against the live hub with the Probe-1 credential.
| Case | Call | Expected | Observed |
|---|---|---|---|
| 2a correct | Report() → POST /api/v1/host-report |
2xx + control envelope | OK, status="ok", desired_generation=0 |
| 2a effect | — | host-report row lands | landed: host_reports row, agent_version=spike-probe |
| 2b correct | FetchDesiredState() → GET /api/v1/hosts/{id}/desired-state |
2xx, well-formed (empty ok) | OK, generation=0, 0 guests |
| 2c wrong | Report() with one flipped key char |
401/403 | HTTP 401 Unauthorized |
| 2d wrong | FetchDesiredState() with empty host_id |
client refuses pre-flight | refused: "FetchDesiredState requires a configured host_id" (client.go:119-120) |
| 2e wrong | HubConfig.Validate() empty host_id |
error before any call | "hub.host_id is required" (config.go:532-534) |
checkAuthHost (handler.go:99-116) resolves the per-host Bearer via GetHostByAPIKey; host-report
with isGlobal=false requires body.host_id == authed host_id (handler.go:453-455) — satisfied by
the client always sending its configured host_id.
Probe 3 — Controller config-pull (link 1) re-confirm — WORKS (unchanged)
| Case | Request | Expected | Observed |
|---|---|---|---|
| 3a correct | GET /config/{id} + correct X-Retrieval-Password |
200 text/yaml | 200, text/yaml; charset=utf-8 |
| 3a content | — | domain, hub.url, hub api_key, tunnel token present | all YES (domain spike.demo-felhom.eu; hub.api_key = the customer-scoped key; injected tunnel token; hub.url) |
| 3b wrong | wrong passphrase | 401 | 401 |
| 3c wrong | missing X-Retrieval-Password header |
401 | 401 |
| 3d wrong | unknown customer (correct-shaped pw) | 404 | 404 |
Constant-time passphrase compare (handler.go:1365); cfg == nil → 404 (:1359-1362); missing header
→ 401 (:1347-1351). The controller half is intact and needs no re-architecting. The generated yaml
carries the customer-scoped hub api_key (configgen.go:47, = cfg.APIKey) — this is the
controller's key, distinct from the agent's per-host key minted in Probe 1.
4. End-to-end verdict on Option A
Option A composes today with ZERO new code. The full Day-0 chain is exercisable as:
- HQ: operator creates the customer in the hub UI (mints customer api_key + 5-word passphrase).
- HQ: operator mints the host with the global key (
POST /admin/hosts) and obtains{host_id, host_api_key}. (The global key never leaves HQ.) - On the Proxmox box: the host-bootstrap script writes the agent config (
/etc/felhom-agent/agent.jsonorFELHOM_AGENT_HUB_*env) with{hub.url, host_id, host_api_key}; the agent authenticates (host-report + desired-state) — proven in Probe 2. - Provision: the agent's provision back-half renders
bootstrap.jsoninto the guest from--customer-id+--hub-password(the passphrase) — (not run in this spike; existing, separately validated). - In-guest: the controller pulls
controller.yamlviaGET /config/{id}+X-Retrieval-Password— proven in Probe 3 — which yields the customer-scoped hub key, domain, and tunnel token; the tunnel comes up.
No link 401/403/404'd unexpectedly. The only thing not physically run start-to-finish is the provision step (4), which is deliberately out of a no-destructive-provision spike and is already validated elsewhere; both its inputs (host credential present; passphrase → config-pull) are proven.
Blocking gaps: none.
5. Option A vs C — recommendation
Two secrets cross to the customer box under A; one under C.
- Option A (proven): the operator carries two secrets to the deployment: the per-host api_key (for the agent, step 3) and the retrieval passphrase (for the in-guest controller, steps 4–5). The global key stays at HQ. Works today, no code change.
- Option C (proposed): the passphrase-authed pull also mints-or-returns the host credential, so the operator carries only the passphrase — and no global key participates in the deploy path at all (it stays a pure HQ-admin key). Fewer secrets in flight; the deploy box never holds an operator-tier global key even transiently.
Feasibility of C (confirmed at source): handleConfigRetrieve already has both prerequisites in
scope — the customerID (path param) and the h.store handle (which exposes UpsertHost,
GetHostByAPIKey, GetHost, ListHosts). The required hub delta:
- Idempotency lookup (new): there is no
GetHostByCustomertoday.UpsertHostconflicts onhost_id(store.go:1172-1183), so naive re-pull would mint a new random host each call and orphan the prior. C should mint-once-and-reuse: add aGetHostByCustomer(customerID)(or filterListHosts) so a re-pull returns the existing credential. (Mint-per-pull = rotation; rejected — it would silently invalidate a running agent's key on any controller re-pull.) - Response-shape change — keep it backward-compatible: config-retrieve currently returns raw
YAML consumed by the in-guest controller. Do not switch the body to JSON (breaks the controller
curl). Two safe options, in order of preference:
- (c-pref) Separate passphrase-authed endpoint, e.g.
POST /api/v1/hosts/enrollwithX-Retrieval-Password, body{customer_id}, returns{host_id, api_key}(idempotent). Leaves the proven config-retrieve path untouched; the host-bootstrap script calls enroll for the agent and config-retrieve for the guest seed. Cleanest separation of the host-tier and guest-tier pulls. - (c-alt) Side-channel headers on the existing GET: emit
X-Host-Id/X-Host-Api-Keyresponse headers alongside the YAML body. The in-guest controller ignores them; the script reads them. Fewer endpoints but couples two tiers' secrets into one response.
- (c-pref) Separate passphrase-authed endpoint, e.g.
UPDATE 2026-06-26 — c-pref SHIPPED in hub v0.14.0. The dedicated passphrase-authed endpoint landed as
POST /api/v1/host-enroll(mint-once-reuse via the newStore.GetHostByCustomer); the provenGET /config/{id}and the global-keyPOST /admin/hostsescape hatch are unchanged. See the hubCHANGELOG.mdv0.14.0 +README.md"Host Enrollment (Day-0, option C)". The route is the exactpath == "/host-enroll"form (not/hosts/enroll, which would collide with the/hosts/per-host prefix routes).UPDATE 2026-06-26 — Day-0 host-bootstrap script SHIPPED. This gap inventory is now implemented end-to-end by
scripts/felhom-host-install.shv1.0.0 (provision mode): pveum token →host-enroll→ agent config →--selftest=provision→ verify. Live-validated by an authorized wipe + re-provision of demo guest 9201 (controller config-pull + public tunnelHTTP 200+ host-report confirmed). DR mode is a documented stub (10D). Seescripts/README.md+REPORT.md.
Recommendation: ship Option A now (it is proven and needs nothing). Adopt Option C, variant
(c-pref) — a dedicated passphrase-authed host-enroll endpoint, mint-once-reuse — at the production
cutover, on the secret-hygiene argument: it removes the operator-tier global key from the field
deployment entirely and reduces the operator-carried secret set to one (the passphrase the customer
already has). It is a small, isolated change that does not touch the proven controller config-pull.
This matches the existing handleAdminCreateHost PROVISIONAL note (handler.go:595-598), which already
flags that endpoint for lock-down at the enrollment cutover.
6. Gap inventory — the checklist the host-bootstrap script spec consumes
Exact config-retrieve contract: GET https://hub.felhom.eu/api/v1/config/{customer_id}, header
X-Retrieval-Password: <passphrase>; 200 → raw controller.yaml (text/yaml), 401 wrong/missing pw,
404 unknown customer. Capture HTTP status and only treat 2xx as success (do not pipe a non-2xx body
onward — the spike's recurring lesson; curl -f/-w '%{http_code}', check before use).
Secrets/values the script must obtain, and their source (A vs C):
| Value | Source under A | Source under C |
|---|---|---|
hub.url |
static (https://hub.felhom.eu) |
static |
customer_id |
operator-supplied (created in hub UI) | operator-supplied |
retrieval passphrase |
operator-supplied (hub UI) — SECRET | operator-supplied — SECRET (the only carried secret) |
host_id |
returned by HQ POST /admin/hosts |
returned by passphrase-authed enroll |
host api_key |
returned by HQ POST /admin/hosts — SECRET |
returned by passphrase-authed enroll — SECRET |
| global key | HQ-only; never on the box | not used in deploy path at all |
| customer-scoped hub key, domain, tunnel token | inside the pulled controller.yaml (not handled directly by the script) |
same |
Agent config write path on a real host (confirmed): /etc/felhom-agent/agent.json (JSON; flag
--config, env FELHOM_AGENT_CONFIG), or override the hub fields via env
FELHOM_AGENT_HUB_URL / FELHOM_AGENT_HUB_HOST_ID / FELHOM_AGENT_HUB_API_KEY (config.go:446-453).
Host-specific fields: hub.host_id, hub.api_key (+ proxmox endpoint/node/token, which the agent
needs anyway). Static fields: hub.url. (Per the live-agent notes, the demo box keeps its config
under /root/.config/felhom-agent/ via FELHOM_AGENT_CONFIG; the script must write to whatever path
the agent's systemd unit points --config/FELHOM_AGENT_CONFIG at — confirm the unit on the target.)
How the controller gets bootstrap.json (confirmed at source — the script does NOT write it): the
agent's provision back-half renders it (felhom-agent/internal/provision/backhalf.go:121,
doc.go:23-47), writes it 0600, chowns to the mapped guest-root, and attaches it on the guest config
mount. The script's job is only to invoke the agent's provision with --customer-id <id> and
--hub-password <passphrase> (cmd/felhom-agent/main.go:131-132); the agent bakes those into
bootstrap.json. The bootstrap.json contract (cross-repo, must match the controller's
internal/bootstrap.Bootstrap):
{ "schema": "...",
"customer": { "id": "<customer_id>" },
"hub": { "url": "<hub_url>", "retrieval_password": "<passphrase, SECRET>" },
"local_api": { "endpoint": "<host-bridge ip:port>", "fingerprint": "<agent leaf SHA-256>", "token": "<per-guest bearer, SECRET>" } }
Note it carries no host key, no customer-scoped hub key, no CF tokens — those all come from the
controller's pull (doc.go:24-26).
Unexpected 401/403/404 / values with no source: none observed. Every refusal was the correct gate.
7. Cleanup performed
All throwaway state removed and verified (counts → 0); the real demo-felhom customer confirmed still
present and untouched:
DELETE FROM host_reports / hosts / customer_configs WHERE customer_id='spike-day0-2026-06-26'(before: 1/1/1 → after: 0/0/0).- Removed scratch
cmd/spikeprobefrom the build-box agent checkout (git statusclean) and/tmp/spike{probe,-day0},/tmp/*.sh. - Uninstalled the temporary
sqlitepackage from the hub pod (apk del sqlite). - No production code, no version bump, no push beyond this doc; no image build; no deploy; no provision.
8. Open questions for the host-bootstrap script spec
- Agent config target path on a production host — the spec must pin where the script writes
agent.jsonvs. relying onFELHOM_AGENT_HUB_*env, and align with the agent's systemd unit--config/FELHOM_AGENT_CONFIG. (On the demo box this is/root/.config/felhom-agent/; production may differ.) Also: the agent needs a Proxmox token in that same config — where does the script source it on a freshly-imaged host? (Likely apveumtoken-create step earlier in the runbook; confirm it is in the chain.) - Option C decision — if the team wants single-secret Day-0, the
hosts/enrollendpoint (variant c-pref, idempotent mint-once-reuse) is the prerequisite and should be specced/shipped before the script depends on it. Until then the script uses Option A (HQ pre-mint). local_api.endpoint/fingerprintprovenance — these are filled by the agent at provision time, not by the script; the spec should state the script does not supply them (avoids a false "missing value" in the script's input list).- Re-run / idempotency of the whole script — under A, re-running the mint step creates a second host for the same customer (admin mint is not idempotent). The script should either reuse a prior credential or the runbook should make the mint a one-time HQ step. (C with mint-once-reuse fixes this structurally.)
No secrets appear anywhere in this document.