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 a fourth legacy-BIOS boot on its own overlay:
| 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. |
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 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.
- 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 biosboots under QEMU (thebiosboot re-boots under legacy BIOS, #277).publish=true(the default) additionally uploads the built image to the provider — only do that deliberately. Runtime ~10 min. - Local:
sudo make test-cloud-qemu(needs root +/dev/kvm). Scope boots withMALMO_CLOUD_BOOTS="seeded"to reproduce the wildcard path alone,"bios"to reproduce the legacy-BIOS boot alone, or the default"unseeded seeded frozen bios"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.