The hosted box gets its *.<box-id>.malmo.network wildcard HTTPS with no toggle: it happens on every boot, driven by the brain. This page is the "where we are now" view for that path and its air-gapped test lane — read it before chasing a red cloud boot-proof or a ":443 doesn't bind" report. As of the entry that added this doc, the path works and the lane is green; the notes below are the map for when it isn't.
The design source of truth is ../specs/ENVIRONMENT.md # Networking & discovery and # Provisioning & first-boot; the lane's shape is ../specs/TESTING.md # Hosted cloud variant. This doc is the operational companion — the flow, the log lines, and the failure-mode → where-to-look table.
- Seed lands.
malmo-seed.servicematerializes/var/lib/malmo/seed.json(delivered by the provider's cloud-init on a real box, or by an SMBIOS credential in the QEMU lane) before host-agent launches the brain. The seed is{box_id, admin_bootstrap_secret, enrollment};enrollmentis the per-box acme-dns account{subdomain, username, password}. - Brain ingests it. On the first hosted boot
cmd/brain'sloadHostedEnvironmentreads the seed, persists the assertion key + enrollment + box-id (box-id last, as the commit marker), and logshosted: provisioning seed ingested. On every later boot it loads the persisted identity and ignores any re-delivered seed (the identity is frozen in SQLite). An absent/unreadable seed logshosted box has no provisioning seed …and the box stays pre-provisioned (no box-id, no wildcard pass) rather than crashing. - Wildcard TLS is applied. If
profile == hostedandenrollment.Complete(), the brain callscaddy.EnsureWildcardTLS, which:- PUTs Caddy's
tlsapp: the wildcard*.<box-id>.malmo.networkincertificates.automate(the "what to obtain") plus an automation policy pinning it to the acme-dns DNS-01 issuer (the "how"). Both are required — a policy without an automate entry never places the order (this was the #278/#301 bug; see../progress/hosted-wildcard-cert-automate.md). - PATCHes the server's
listento[":80", ":443"], binding:443. - Logs
caddy: wildcard TLS configured. This is synchronous config only — Caddy obtains the cert in the background on its own schedule, so a slow/unreachable ACME never blocks startup and:443binds regardless of whether a cert exists yet.
- PUTs Caddy's
- Two-path cert model. The wildcard
*.<box-id>is obtained via acme-dns DNS-01 (challenge at the delegated_acme-challenge.<box-id>). The apex<box-id>.malmo.network(the dashboard host) is not routed through acme-dns — Caddy's default issuer gets it over tls-alpn-01/http-01 once the dashboard route names it. So exactly one name touches acme-dns; there is no order-vs-order race to manage.
Net: a provisioned box logs both milestones, binds :443, and serves every <slug>.<box-id>.malmo.network from the one wildcard cert.
dev/cloud/run-cloud-tests.sh (make test-cloud-qemu) boots the real production image under QEMU, air-gapped (restrict=on — the seed arrives over SMBIOS, never the network), and greps a serial-console verdict written by dev/cloud/cloud-assertions.sh. Three UEFI scenarios over one persisted overlay, plus three boots that each get their own fresh overlay — the legacy-BIOS smoke boot, and the two that change the box (access, update):
| Scenario | What it asserts (box-facing) |
|---|---|
unseeded |
No seed → GET /_malmo/sso ⇒ 503 (gate armed, closed); POST /setup ⇒ 403 (disabled on hosted — the owner bootstraps via SSO, not a secret). |
seeded |
Seed with a complete enrollment → brain logs provisioning seed ingested; a bad token on /_malmo/sso ⇒ 401 (verifier armed); :443 binds and the brain logs caddy: wildcard TLS configured. |
frozen |
A different seed re-delivered on a later boot is ignored; the box still serves under the original box-id and does not re-ingest. |
access |
A valid owner assertion (minted by the harness, which holds the test-portal private key) → owner session → an app installed air-gapped, then the per-app forward-auth access modes end-to-end through real Caddy: restricted gates an anonymous request and proxies the owner through, public is reachable with no session, and malmo_forward_auth never reaches the app upstream in either mode (#308/#335). The same app declares access.public_paths, so the boot also proves path-scoped exposure (#415): the declared paths answer anonymously, a forged X-Malmo-User never reaches the app on either branch, and a bypass table (prefix footgun, traversal, encoded traversal, double slash) stays gated. Own fresh overlay + box-id. |
update |
A real control-plane update and a real failed-update-then-revert (#382) — see the section below. Own fresh overlay + box-id; the only scenario that replaces the box's own images. |
bios |
The same image booted under legacy BIOS (SeaBIOS — QEMU's default firmware, no OVMF pflash) instead of UEFI → the control plane comes up and the SSO gate is armed. Proves the dual-firmware image (systemd-boot UEFI + GRUB BIOS, ENVIRONMENT.md # Boot) boots where a UEFI-only image hung on Hetzner CX/Intel (#277). Runs on its own fresh overlay, so it's independent of the provisioning sequence above. |
Air-gapped means config-apply, not a real cert. The lane cannot reach acme-dns or Let's Encrypt, so the seeded scenario proves the brain applies the issuer + binds :443, not that a cert was obtained. Real DNS-01 issuance + a live *.<box-id> cert are verified on a real provider box on a real network — deliberately outside this lane (the whole "config that looks right but never issues" class is invisible air-gapped; the certificates.automate unit test in internal/caddy/caddy_test.go is the closest static guard).
The update scenario is the only place the updater meets a real Docker daemon, a real registry, a real brain restart and a real revert. Everything under POST /api/v1/system/update — the ledger, the transaction, the trigger — is otherwise proven against a fake Docker. Read ../progress/cloud-update-boot-proof.md for why it is shaped this way.
What the boot does, in order:
- Owner session. The boot is seeded with a test-portal key and given a signed owner assertion over SMBIOS, like the
accessboot. The update trigger is admin-only, so without this the box cannot be asked to update itself. - A registry inside the guest.
docker loadof a test-only tarball (/var/lib/malmo/test-images/registry.tar), then a container on127.0.0.1:5000. Docker treats a localhost registry as insecure by default, so no daemon config is involved. - A new pair, published by digest.
docker commitderives a new brain and a new UI from the images the box is running, adding one marker label. Both are pushed and then removed from the local image store, and their absence is asserted — that is what makes the updater's pull a real fetch instead of a no-op. - The happy path.
POST /api/v1/system/updatewith both refs, then poll the job. Asserted: the job completes with both*_changedtrue andrevertedfalse; the brain container id changed; the running brain is on the target ref and carries the marker label;images.jsonnames the new pair and records the previous one;compose.ymlpins the new UI ref;/healthzanswers on the new container's own address;GET /api/v1/system/versionreports the new UI image. - The revert. A second update points at a brain that starts, truncates
/var/lib/malmo/state/malmo.db, drops a marker file and never serves. Asserted: the job isfailedwithfailure_mode: healthandreverted: trueand norevert_error; the marker file exists (so the bad brain really ran); the brain is back on the previous good ref and the ledger no longer names the failed one;malmo.dbis a valid SQLite file again; and the box answers an authenticated/api/v1/mewith the same session cookie.
Reading a red one:
| Symptom | Most likely cause | Where to look |
|---|---|---|
in-guest registry never answered on 127.0.0.1:5000 |
The registry tarball is missing from the image, or the container could not start. | Was the image rebuilt after dev/cloud/test/bootstrap.sh changed? The canary (.dev/cloud-boot/.cloud-boot-ready) gates the rebuild — bump CANARY_VERSION when staging changes. |
… is still in the local image store before the update |
The post-push docker rmi did not remove the image. The scenario refuses to continue, because the pull it is about to prove would not be a real fetch. |
Something else references the image — check docker ps -a in the serial diag. |
the brain container was NEVER recreated (same id …) |
The update reported success without replacing the brain. This is the single most important assertion in the boot. | host-agent's journal lines in the diag block (system-update, control plane), then images.json. |
| job never reaches a terminal state | The box did not come back after the brain was recreated — most likely Caddy cannot reach the new brain, or the brain did not re-install its routes. | The diag block's docker ps -a and brain log tail; the job record lives in host-agent, so journalctl -u host-agent is the ground truth. |
job fails with resolve ui address: docker inspect malmo-ui: exit status 1 |
Two docker compose up running on the malmo-control-plane project at once, which can leave the box with no malmo-ui container. This is what the first run of this boot found (see ../progress/cloud-update-boot-proof.md). The fix was to recreate the UI before starting the brain, so host-agent's compose runs while no brain exists. |
The brain's control-plane stack up failed; continuing line in the serial log — a Conflict. The container name "…_malmo-ui" is already in use there is the signature. Then check the recreate order in internal/hostagent/cpupdate/update.go. |
revert asserted but malmo.db still broken |
restoreBrainDB did not run or restored nothing. |
The brain snapshots listing in the diag block: an empty snapshot dir means the snapshot step, not the restore, is the failure. |
The diag block dumped on failure carries images.json, the compose image lines, the snapshot dir listing and host-agent's update log lines, so a red update boot should be diagnosable from the serial log alone.
The serial log is the primary artifact: .dev/cloud-boot/last-serial.log locally, or the "Seeded-boot test" step log in CI. On failure cloud-assertions.sh dumps a === MALMO_CLOUD_DIAG === block (docker ps, networks, iptables, and malmo-brain log tail). Grep it for the milestone lines above.
| Symptom | Most likely cause | Where to look |
|---|---|---|
:443 never binds / connection-refused |
EnsureWildcardTLS was skipped — this is nearly always boxID == "" (seed not ingested) or an incomplete enrollment, not a bug in the bind/apply path. |
Is provisioning seed ingested in the brain log? Is /var/lib/malmo/seed.json present and does it carry an enrollment block? If both are present and :443 still doesn't bind, then it's a genuine apply-path regression — check caddy: wildcard TLS configured is absent and read the Caddy admin PUT/PATCH errors. |
:443 binds but no cert (real box) |
DNS/ACME reachability — acme-dns delegation, the box's resolver, or Let's Encrypt reach. Not reproducible air-gapped. | A real provider box only; read the box's Caddy log (via provider rescue mode — hosted ships no SSH). Not a boot-proof concern. |
hosted /setup not disabled: … 503 (unseeded) |
Startup race, not a bug. A 503 there is Caddy answering "no ready upstream for /api" in the first second after the stack comes up. |
The assertion now rides through transient 502/503 and only fails on a stuck window (cloud-assertions.sh, the /setup poll). If it recurs, confirm the brain reached caddy: dashboard route installed and malmo-brain listening. |
| Brain baked old but image labeled new | Stale .dev/ control-plane cache reused across a build. |
Build on a fresh checkout (the CI job does — no .dev/ cache), which rebuilds the control-plane images from source. |
A broken image build presents as several of these at once (e.g. :443 refused and seed-ingest missing) — check the build step succeeded and the control-plane images were rebuilt before diagnosing the box logic.
- It can also start on its own. A PR into
devormainthat touches the updater —internal/hostagent/**,cmd/host-agent-real/**,internal/lifecycle/controlplane*.goorlifecycle.go,internal/api/system*.go,dev/cloud/**, or the workflow itself — runsCI / Cloud imageautomatically and boots only theupdatescenario (#389). It publishes nothing (the run asserts that before it builds). So a PR in those paths can turn this lane red without anyone asking for it; read the run the same way as a manual one. Expect ~12–15 min, most of it the image build. Draft PRs are skipped — marking the PR ready starts the run. A PR that needs the other boots can still run them by hand with the command below. - CI (preferred — no local root/KVM, no image push):
gh workflow run "CI / Cloud image" --ref <branch> -f publish=false. Builds the image, then runs theunseeded seeded bios access updateboots under QEMU.publish=true(the default) additionally uploads the built image to the provider — only do that deliberately. Runtime ~10 min for the build, plus the boots (theupdateboot is the longest — it runs two full update transactions, one of which spends a 60s health wait failing on purpose). - Local:
sudo make test-cloud-qemu(needs root +/dev/kvm). Scope boots withMALMO_CLOUD_BOOTS="seeded"to reproduce the wildcard path alone,"bios"for the legacy-BIOS boot alone,"update"for the updater proof alone, or the default"unseeded seeded frozen bios access update"for the full run.
../progress/hosted-wildcard-cert-automate.md— thecertificates.automatefix (the real #278/#301 root cause).../progress/cloud-wildcard-tls-assertion.md— how the seeded lane came to assert the:443bind + apply.../progress/cloud-image-live-onramp-fixes.md— the two real-box fixes (seed-fetch keep-alive, static resolver) that a green real box depends on.