- Status: done — box-side machinery; real Let's Encrypt issuance is verified jointly in the cloud on-ramp (cloud #6 / CL6), not the inner loop (no real ACME/DNS locally)
- Date: 2026-06-21
- Specs touched:
ENVIRONMENT.md(# Networking & discovery — added an "as built" subsection; # Admin bootstrap — as built:enrollmentflipped from reserved to consumed),DECISIONS.md(hosted custom Caddy image; acme-dns endpoint a box-side constant)
Realizes C3b (#207) — the hosted box's always-on <slug>.<box-id>.malmo.network HTTPS scheme and its *.<box-id> wildcard cert. Closes the seam C3a (#206, hosted-setup-gate.md) left open: C3a ingested the seed but carried enrollment as opaque, unconsumed JSON. The blocker was the cloud-side DNS/ACME service, now shipped (cloud CL4 Route53 + acme-dns substrate "unblocks os#207", CL5 seed assembly) — the cloud registers a per-box acme-dns account and ships {subdomain, username, password} in the seed; this is what C3b consumes. The two repos meet at the seed's JSON wire format, not a shared Go type.
internal/profile/seed.go:Seed.Enrollmentchanged fromjson.RawMessageto a typedEnrollmentCredentials{Subdomain, Username, Password}whose JSON tags mirror the cloud producer'sinternal/seed.EnrollmentCredentialsbyte-for-byte. Optional at parse (Complete()reports all three present); a hosted box seeded without it still gates/setup, it just gets no cert.cmd/brain/main.goloadHostedEnvironmentnow also returns the enrollment and persists it (store.BoxMetaEnrollment, JSON) in hash → enrollment → box-id order — box-id stays the crash-safe commit marker. A complete enrollment that fails to persist aborts the ingest before the box-id commit marker (mirroring the hash-persist abort), so the seed is re-ingested next boot rather than freezing an identity whose enrollment was never recorded — a frozen box with no enrollment row would skip the cert pass on every subsequent boot. A seed with no enrollment is a legitimate "no HTTPS" box and proceeds. Frozen-identity boots reload the persisted enrollment (the seed is ignored), so Caddy can be reconfigured without it.
caddy.EnsureWildcardTLSconfigures thetlsapp: one automation policy over the cert subjects, an ACME issuer whose DNS-01 challenge uses theacmednsprovider with the seeded creds + a box-side endpoint constant, then PATCHes the malmo server's listen to add:443. Remove-then-put for idempotency (the file'supsertRouteidiom).cmd/braincalls it once at startup, gated onprofile == hosted && enrollment.Complete()— appliance and un-enrolled boxes skip it and stay on:80.- Cert subjects are
<box-id>.malmo.network+*.<box-id>.malmo.network(profile.CertSubjects): the apex (dashboard host) is listed separately because a*.<box-id>wildcard does not cover the bare parent. Both names' DNS-01 challenges land on the one_acme-challenge.<box-id>record the cloud CNAMEs to acme-dns. (This entry originally read "a single challenge issues the combined cert." That was wrong: Caddy issues one certificate per SAN, so these are two independent ACME orders, and running them concurrently races on that shared acme-dns record. Corrected and serialized in wildcard-cert-serialize-acme-orders.md,DECISIONS.md2026-07-03.) - The acme-dns API endpoint is a box-side constant —
MALMO_ACMEDNS_ENDPOINT, defaulthttps://auth.malmo.network— not seeded (the same for every box; cloudspecs/ARCHITECTURE.mdContract 2).
- New
internal/profile/appurl.go:HostedAppHost/HostedAppURL/HostedDashboardHost/CertSubjects+NetworkApex— the single place the<slug>.<box-id>.malmo.networkshape is named, importable by bothlifecycleandapi(leaf package). internal/lifecycle:Managergainsprofile+boxID(set viaSetEnvironment) and anm.hosted()guard. On hosted, install,routeHost,publishHost, andMALMO_APP_URLall use the public host and skip the mDNS publish (no LAN, slim host-agent has no Avahi). Appliance's.local/Avahi path is byte-for-byte unchanged.internal/api:toDTOis now a*Servermethod; on hosted it surfaceshttps://<slug>.<box-id>.malmo.networkas the app's sole URL.cmd/brain: the dashboard host is<box-id>.malmo.networkon hosted (the wildcard apex) instead ofmalmo.local.
- Stock
caddy:2-alpineships no DNS-provider module, so DNS-01 needs a build withcaddy-dns/acmednscompiled in.dev/control-plane/caddy-acmedns/Dockerfileis the xcaddy recipe;compose.yml's Caddy image is now${MALMO_CADDY_IMAGE:-caddy:2-alpine}. The hosted profile setsMALMO_CADDY_IMAGE(the brain's env, whichdocker composeinherits) to the custom build; appliance keeps stock Caddy (it does no ACME yet). One env var selects the image so the future appliance toggle reuses the same build.
- Realizes
ENVIRONMENT.md# Networking & discovery:<slug>.<box-id>.malmo.networkis the sole, always-on scheme; the wildcard cert is obtained via ACME DNS-01 with the seeded acme-dns credential; no toggle, no.local. - Consumes the seed's
enrollmentfield (ENVIRONMENT.md# Admin bootstrap — as built; the field C3a reserved). - Honors the cloud contract: renewal is box→acme-dns directly (cloud
specs/ARCHITECTURE.mdContract 2); the brain never calls the control plane on the cert path.
- Not verified against real ACME. The DNS-provider JSON shape, the acme-dns endpoint, and actual issuance are pinned but exercised only in the cloud on-ramp (cloud #6 / CL6) — there is no real Let's Encrypt/DNS in the inner loop or the QEMU cloud lane. Box-side tests assert config generation + URL surfacing, not a real cert.
- Public acme-dns API face not deployed cloud-side. The cloud deploy binds acme-dns's HTTP API to
127.0.0.1:4443(internal/registeronly); no public face exists yet for boxes to push TXT updates to, andhttps://auth.malmo.networkis a chosen default, not a confirmed deployed endpoint. Filed asmalmoos/cloud#14; the box value is overridable viaMALMO_ACMEDNS_ENDPOINT. - Enrollment creds stored plaintext in
box_meta, matching the cloud producer's MVP posture — a leaked pair only lets an attacker renew certs for that one box. At-rest encryption is a deferred hardening item (NEXT.md). - Appliance secure-URLs toggle not built. This is hosted-only and always-on. The profile-aware seam is structured so the appliance toggle (
MALMO_NETWORK.md# The toggle) can reuse it, but interactive enrollment + the Settings toggle are a separate later issue. - Custom Caddy image wiring into the hosted image / offline bundle is not landed here — the compose is parameterized and the recipe exists, but baking the built image into the hosted cloud image and setting
MALMO_CADDY_IMAGEthere is part of the hosted-image / CL6 work. - Caddy has no persistent
/datavolume in the control-plane compose, so the wildcard cert and the admin-API-set (ephemeral):443TLS policy live only in the running Caddy. A Caddy-only restart drops both, and the brain re-assertsEnsureWildcardTLSonly on its next restart — so a Caddy bounce without a brain bounce leaves the box serving no HTTPS until the brain restarts. Same class as the deferred Caddy-reconnect handling (#187); recorded here, not fixed in this PR. Acaddy_datavolume (persisting the issued cert + Caddy's autosave config across restarts) is the likely fix when #187 is taken.
- CL6 (cloud #6) joint verification — boot a real seeded Hetzner VM, confirm Caddy obtains the wildcard cert and serves the dashboard + an app over real HTTPS at
<box-id>.malmo.network. - Bake the custom Caddy image into the hosted cloud image build + offline bundle and set
MALMO_CADDY_IMAGEin the hosted run-spec. - Pin the public acme-dns endpoint once the cloud deploys its public acme-dns API face (cross-repo).
- Appliance secure-URLs toggle — interactive enrollment + Settings → Network toggle, reusing this PR's profile-aware URL seam.