- Status: done
- Date: 2026-06-19
- Specs touched:
ENVIRONMENT.md(# Provisioning & first-boot — as-built mechanism added),FIRST_RUN.md(hosted per-step markers)
Part of the cloud-VM track in #196. The C1 profile foundation is in place — the profile marker + brain reader (C1a #202), the lean hosted mkosi image's sibling C1b #203, and the slim cloud host-agent's sibling C1c #204 (all three OPEN, not yet merged). This is C3a (#206): the brain-side first-boot provisioning seed and the /setup gate that consumes it. On a hosted box there is no "whoever is physically present during first boot" trust to lean on (ENVIRONMENT.md # Provisioning, the "Admin bootstrap" bullet), so the first admin's creation is gated on a one-time secret delivered in the provisioning seed — closing the public-open-/setup window a hosted box would otherwise have.
This slice is brain-side core only. The cloud-lane wiring that actually delivers the seed onto a booted VM (a dev/cloud/ SMBIOS / systemd-credential injection, exercised end-to-end in QEMU) is deferred — dev/cloud/ exists only on the unmerged C1b branch, the boot harness is C2 (#205, which does not exist yet), and the QEMU outer loop is blocked on this box by #189. So this PR is "Part of #206", not "Closes"; the cloud-lane exercise is tracked as a follow-up.
internal/profile/seed.go(new) — the first-boot provisioning seed contract.Seed{BoxID, AdminBootstrapSecret, Enrollment json.RawMessage}read from JSON atDefaultSeedPath(/var/lib/malmo/seed.json).ReadSeed(path)returns a typedErrSeedAbsentwhen the file is missing (the not-yet-provisioned case, which keeps/setupclosed rather than crashing) and a hard error on a malformed or field-incomplete seed (a present-but-broken seed is an operator error, not "no seed").Enrollmentis carried as raw JSON and deliberately unconsumed — it reserves the*.<box-id>.malmo.networkDNS-01 enrollment payload for C3b without forcing a schema now.internal/store/(box_meta) — a smallbox_meta(key TEXT PRIMARY KEY, value TEXT)KV table inmigrate(), withGetBoxMeta/SetBoxMeta(upsert) and the key constantsBoxMetaBoxID/BoxMetaBootstrapSecretHash. The brain persists the box-id and the SHA-256 hex of the bootstrap secret — never the plaintext.cmd/brain/main.go(ingestion) —loadHostedEnvironment(prof, store, seedPath), called once at startup, resolves the hosted box's provisioning identity for the gate. Appliance is a no-op (empty box-id + hash ⇒ no gate). On hosted: a box-id already persisted is the install's frozen identity — load it (and the stored hash) and ignore the seed on every later boot; otherwise this is the first hosted boot — read the seed, persist the hash then the box-id (box-id last as the commit marker, so a crash mid-write re-ingests next boot rather than stranding a box-id with no secret), and hand both to the API. An absent or unreadable seed logs and returns empty — the brain stays pre-setup, never falling back to the appliance's open behavior. NewseedPathconfig (envMALMO_SEED_PATH).internal/api/(the gate) —Servergainedprofile/boxID/bootstrapSecretHash, set via aSetEnvironmentsetter (not aNewServerparameter — it avoids touching ~6 positional call sites).gateBootstrapruns inside/setupbefore the empty-box check, only whenprofile == hosted:- no seed yet (
bootstrapSecretHash == "") → 503 (box is not provisioned for setup) — a hosted box must never fall through to appliance open-on-empty-box; - missing or wrong secret → 401, audited
audit.ActionSetupFailure(success=false), mirroringlogin.failure; - correct secret → proceeds to the normal first-admin creation.
The secret arrives in the request body as
bootstrap_secret; the compare is constant-time (crypto/subtle) over the SHA-256 hex. Because the gate sits ahead of the existing empty-box guard, it is naturally one-time: once the first admin exists, even the correct secret gets the usual 409.UserDTOgainedbox_id,omitempty, surfaced on hosted only. Appliance/setupis byte-unchanged — the gate is a no-op andbox_idis omitted.
- no seed yet (
- Operator hand-off. The brain never serves the secret over any endpoint. The operator receives it out-of-band (the cloud console, the way a VPS hands over an initial root password) and types it into the setup wizard, which sends it as the
bootstrap_secretbody field. The gate trims surrounding whitespace on the submitted secret to matchReadSeed, so a console copy-paste with a trailing newline still validates. This contract — brain consumes the secret, never serves it; C4's wizard consumes the samebootstrap_secretfield rather than inventing its own one-time URL; the cloud control plane (CL5) owns the one-time display + expiry — is locked inDECISIONS.md(2026-06-20). - Codegen —
make openapiandnpm run gen:apiregenerated; the only deltas arebootstrap_secreton the setup request andbox_idonUserDTO. - Tests —
internal/profile/seed_test.go(valid / whitespace-trim / absent→ErrSeedAbsent/ malformed / missing-or-blank box-id / missing-or-blank secret / unreadable-is-hard-error / enrollment-preserved);internal/store/box_meta_test.go(unset→ErrNotFound, roundtrip, upsert);internal/api/auth_hosted_test.go(the gate matrix: correct→200 + box-id surfaced, whitespace-padded-correct→200, wrong→401 audited, missing→401 audited, no-seed→503, one-time→409, appliance ignores the secret and omits box-id);cmd/brain/main_hosted_test.go(appliance no-op, first-boot ingest with hash-before-box-id ordering + no plaintext, frozen-identity ignores the seed, frozen-identity-with-missing-hash stays closed, absent/malformed seed stays closed, hash-persist failure stays closed).make checkgreen.
- Realizes
ENVIRONMENT.md# Provisioning & first-boot — the "first-boot configuration" seed (box-id + one-time admin-bootstrap secret + reserved enrollment) and the "Admin bootstrap" bullet ("the bootstrap secret gates who gets to create that first account, replacing the appliance's 'whoever is physically at the box during first boot' trust"). The as-built mechanism (seed path,box_meta, hash-not-plaintext, the 503/401 gate, operator out-of-band hand-off) was added to that section in this change. - Stays inside the Layer-1 / Layer-2 split (
ENVIRONMENT.md# Two layers): the brain is profile-aware only at one narrow new seam (gateBootstrap); the appliance path is untouched. PAM stays the identity source — the gate decides who may create the first admin, not how it is created. - The frozen box-id mirrors the appliance's "name is frozen at enrollment" rule (
MALMO_NETWORK.md): the persisted box-id is the install's permanent identity.
- No seed-delivery path is exercised. Nothing in this PR puts a
seed.jsononto a real booted VM. The SMBIOS / systemd-credential injection (dev/cloud/) and the QEMU end-to-end proof are deferred — see the opening paragraph. Onmake devand every appliance box the seed path simply doesn't exist and the gate is inert. enrollmentis reserved, not used. The seed carries it as raw JSON; C3b consumes it for the*.<box-id>.malmo.networkDNS-01 cert.- Key custody for the secret at rest is the store's, not the seed's. The seed file holds the plaintext secret until first boot ingests it; hardening the on-disk seed lifetime (e.g. shredding it post-ingest) is a cloud-lane concern, deferred with the delivery path.
- Cloud-lane seed delivery (follow-up issue, depends on C2 #205). Inject
seed.jsoninto a booted hosted VM (SMBIOS / systemd credential) and assert the gate end-to-end in QEMU. Blocked here by #189. - C3b — seeded enrollment + DNS-01 cert. Consume the reserved
enrollmentpayload: enroll*.<box-id>.malmo.networkat first boot and drive Caddy's ACME DNS-01 from it (ENVIRONMENT.md# Networking & discovery, "Certs"). - C4 — the trimmed hosted setup wizard. The web-ui side: the wizard drops the network / storage / enrollment steps and adds the bootstrap-secret field to the first-admin step (
ENVIRONMENT.md# Provisioning, "Setup wizard, trimmed").