- Status: done
- Date: 2026-05-24
- Specs touched:
docs/specs/CONTROL_PLANE.md(catch-all 404 invariant paragraph)
Verification harness for the Caddy Host-header subdomain routing plus the catch-all 404 invariant that was missing from the initial slice.
Before this change, Caddy returned HTTP 200 OK with an empty body for any
unmatched hostname. That is a UX failure (a user visiting a stale or mistyped
URL sees a blank page with no explanation) and a test correctness failure (tests
that expected "non-200 for unmatched" could not distinguish "catch-all hit" from
"Caddy silently accepted the request"). Both problems are now fixed.
"routes": [] replaced with a single malmo-catchall route — no matcher
(matches everything), static_response handler, HTTP 404, HTML body linking
back to the dashboard. Caddy starts with this in place so Test 0 works without
waiting for brain startup.
Three changes:
-
Insert at index 0.
upsertRoutenow POSTs to/config/apps/http/servers/malmo/routes/0instead of appending, so each new per-app route is prepended and the catch-all stays at the tail. -
EnsureCatchAll(ctx)— idempotent startup call. GETs/id/malmo-catchall; if 200, logs "already present" and returns nil. If absent (any non-200), appends the catch-all viaPOST /config/apps/http/servers/malmo/routes. Returns a wrapped error on transport failure (caller logs and continues — best-effort posture). -
get(ctx, path) (int, error)helper — issues a GET to the admin API, returns the HTTP status code without erroring on non-2xx. Mirrors the existingsendmethod; used only byEnsureCatchAll.
Calls cd.EnsureCatchAll(ctx) after Reconcile completes. Failure is
slog.Warn + continue — matches the "best-effort Caddy" posture in
lifecycle.go:reassertRouting.
Four changes:
-
Defensive cleanup (after preflight, before setup): removes stale
malmo-app-*routes from prior failed runs via jq + DELETE on each@id. Does not touchmalmo-catchall. -
[TEST 0] (new, before install): unmatched hostname → expect exactly HTTP 404 AND body contains "No app at this hostname". Proves the catch-all is wired with zero installed apps.
-
Tightened negative tests (Tests 2, 3, post-uninstall Test 4): changed "not 200" to "exactly 404" and added
grep -q 'No app at this hostname'on the body. Proves the catch-all is responding — not an accidental HTTP error from another layer. -
Updated header comment to list Test 0 and reflect the new exact-404 expectations.
Added a "Catch-all 404 invariant" bullet to the Caddy section documenting the
malmo-catchall route contract, the index-0 insert pattern, and
EnsureCatchAll.
bash -n dev/test-caddy-routing.sh— syntax cleanCGO_CFLAGS=-D_GNU_SOURCE go vet ./...— no issuesmake test— Go tests greenmake build— binary buildsjq . dev/caddy.json— valid JSON
make test-caddy is the manual gate run against make dev.
In dev, dev/docker-compose.yml maps container :80 → host :8088 because
host port 80 is taken on most laptops. In production Caddy listens on :80.
The :8088 indirection means:
curl -H "Host: slug.malmo.local" localhost:8088works (correct Host header, port is just the TCP target).curl --resolve slug.malmo.local:8088:127.0.0.1 http://slug.malmo.local:8088/works (DNS override to loopback on the custom port).- Browser via LAN IP on another device does not work against the dev stack:
the browser would try port 80, not 8088. The secure-URL path
(
MALMO_NETWORK.md) is the real-device compatibility path.
- HTTPS / Let's Encrypt for
.networkURLs not covered. TLS termination, certificate provisioning, and the<box-id>.malmo.networkURL scheme are all deferred toMALMO_NETWORK.md. This slice is HTTP-only. - Trust-proxy / X-Forwarded-For not validated. Apps that depend on the real client IP see Caddy's container IP today. Not yet tested end-to-end.
- No nspawn-lane automated coverage. This script is a manual gate run
against the dev inner loop. The nspawn and QEMU CI lanes in
TESTING.mdare not yet wired; subdomain routing isn't covered there. - Test user left behind after the run. The script leaves the
caddytestadmin user in the database.make cleanwipes state. Intentional and documented in the script header.
- nspawn-lane: add a routing verification step once the lane is wired.
- HTTPS routing: validate Let's Encrypt +
<box-id>.malmo.networkroutes whenMALMO_NETWORK.mdlands. - X-Forwarded-For: add an assertion in
test-caddy-routing.shthat theX-Forwarded-Forheader in the whoami echo contains127.0.0.1.