carlos / platform Public

spec: wildcard DNS-01 certs for instant app hostnames

Per-account *.<sqid>.<apps-domain> certs (TLS wildcards match one label;
app hosts are two under the apps domain), static *.go.tito.io for tito,
ported from Eleven's wildcard.go with its dnsProvider seam (DNSimple +
Route53). Fallback contract: any failure degrades to today's per-host
HTTP-01.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 128543c64c89f0d134a475d139d387324dcd9f61 parent 09b084d
1 file changed, +172 −0
  • docs/superpowers/specs/2026-08-05-wildcard-certs-design.md +172 −0
diff --git a/docs/superpowers/specs/2026-08-05-wildcard-certs-design.md b/docs/superpowers/specs/2026-08-05-wildcard-certs-design.md
new file mode 100644
index 000000000..16e20c57e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-05-wildcard-certs-design.md
@@ -0,0 +1,172 @@
+# Wildcard certs for instant app hostnames
+
+**Date:** 2026-08-05
+**Status:** Approved
+
+## Problem
+
+Today the edge issues one certificate per hostname (autocert HTTP-01, route
+table as HostPolicy). A freshly created app's hostname fails TLS until its
+cert is issued, so provisioning is not instant — and every essentially
+disposable `<app>.<acct>.<apps-domain>` hostname spends against Let's
+Encrypt's ~50 new certs/week per registered domain and is published to
+Certificate Transparency logs by name.
+
+A single `*.oncarlos.com` cert cannot fix this: TLS wildcards match exactly
+one label, and app hosts are two labels under the apps domain
+(`<app>.<acctsqid>.oncarlos.com`; canary hosts are three:
+`<name>.<app>.<acct>.…`). The DNS wildcard covers all depths (RFC 4592); the
+cert does not.
+
+## Decision summary (approved 2026-08-05)
+
+- **Per-account wildcards**: one `*.<acctsqid>.<apps-domain>` cert per
+ account, issued eagerly when the account's first route appears. Every later
+ app under that account gets working TLS instantly. Canary hosts and custom
+ domains stay per-host HTTP-01 (no behavior change).
+- **Both deployments, one seam**: flagship gets dynamic per-account parents
+ under `PLATFORM_APPS_DOMAIN`; tito gets a static `*.go.tito.io` via
+ `CARLOS_WILDCARD_DOMAINS` (its instance hosts are one label), matching the
+ 5-region spec's DNS-01 commitment.
+- **DNSimple token via SSM → unit env** on flagship (`oncarlos.com` is
+ DNSimple-hosted); **Route53 via the box IAM role** on tito (`tito.io` is
+ Route53-hosted).
+- **Build by porting Eleven's `wildcard.go` + `route53dns.go`**
+ (elevenmessenger/messenger) into `internal/edge` — the router itself is
+ already a port from that lineage; the reference has the pluggable
+ `dnsProvider` seam, cert-cache integration, and fallback contract we want.
+
+## 1. Cert shape and parent derivation
+
+The wildcard manager maintains a set of **parent domains**, each backed by
+one `*.<parent>` certificate:
+
+- **Dynamic parents** (flagship): derived from the route table. For every
+ route host `<app>.<acct>.<AppsDomain>`, the parent is
+ `<acct>.<AppsDomain>`. When a new account's first route appears (reconcile
+ mints routes at account/app creation), issuance for
+ `*.<acct>.<AppsDomain>` starts immediately — normally done before the
+ first app's first deploy completes. The parent set is recomputed on every
+ route-table swap (existing Watch/Reload/SIGHUP path); a swap that adds a
+ parent wakes the issuance loop via a channel rather than waiting for the
+ 12h tick.
+- **Static parents** (tito): `CARLOS_WILDCARD_DOMAINS` comma list of parent
+ domains. Tito sets `go.tito.io`; one cert covers every `<slug>.go.tito.io`.
+
+Out of scope for wildcards, unchanged behavior: canary hosts
+(`<name>.<app>.<acct>.…` — one label too deep), custom domains (their own
+registered domains, own LE budget), the apps-domain apex.
+
+## 2. TLS wiring and issuance manager
+
+Port Eleven's `wildcardManager` into `internal/edge/wildcard.go`, wired at
+the documented drop-in point (edge.go, "wildcard.go (ACME DNS-01) drops in
+here"): wrap `tlsCfg.GetCertificate` so a handshake whose SNI is exactly one
+label under a live parent is served the wildcard cert; everything else — no
+parent match, cert not yet obtained, cert expired — falls through to the
+existing autocert per-host path untouched. Routing, `HostPolicy`, and the
+:80 handler do not change.
+
+Issuance is Eleven's flow: a dedicated ACME account key (cache key
+`wildcard-account.key`, minted only on a genuine cache miss),
+`AuthorizeOrder` for `*.<parent>`, DNS-01 challenge TXT published through
+the provider seam with propagation wait and always-runs cleanup (fresh
+context, so a dead issuance context still removes the TXT), ECDSA P-256 key
++ CSR, finalize, atomic pointer swap, persist through the **existing
+cert-cache seam** (`newCertCache`) under key `wildcard-<parent>.pem` — so
+the reserved S3 pooled-edge backend inherits wildcards with no further work.
+
+Renewal: re-issue 30 days before expiry (90-day LE certs tolerate a month of
+provider outage). Loop cadence 12h, dropping to 1h after a failure. An
+expired wildcard is never served — `certFor` returns nil and the handshake
+falls back to per-host issuance.
+
+## 3. DNS provider seam and config
+
+`dnsProvider` interface ported as-is (present a TXT, return a cleanup
+closure), with both backends:
+
+- **DNSimple** (default; flagship — `oncarlos.com`'s zone): needs
+ `CARLOS_DNSIMPLE_TOKEN` (account API token). The token lives in SSM
+ Parameter Store (SecureString, keymail account) and is delivered into the
+ agent unit's environment at adopt — the same pattern as the ship-script
+ signing key: from SSM, never a terminal.
+- **Route53** (`CARLOS_DNS_PROVIDER=route53`; tito — `tito.io`'s zone):
+ standard AWS credential chain, no token on disk. The box IAM role gains
+ `route53:ListHostedZonesByName`, `route53:ChangeResourceRecordSets`,
+ `route53:GetChange` scoped to the hosted zone, via a new optional variable
+ on `infra/modules/region-host` (default empty → no statement; flagship's
+ role is untouched).
+
+Env vocabulary (CARLOS names for Eleven's `LCHAT_*`):
+`CARLOS_WILDCARD_DOMAINS`, `CARLOS_DNS_PROVIDER`, `CARLOS_DNSIMPLE_TOKEN`,
+`CARLOS_DNSIMPLE_BASE` (test override), `CARLOS_ACME_DIRECTORY` (test
+override). Dynamic per-account parents additionally require
+`PLATFORM_APPS_DOMAIN` (already the agent's apps-domain source).
+
+Nothing configured → the wildcard machinery is off and per-host issuance
+continues. Today's behavior is the zero-config state.
+
+## 4. Failure modes
+
+- DNS-provider outage, ACME outage, unreadable cached cert: degrade to
+ per-host HTTP-01 — exactly today's behavior, never a dead edge.
+- Cert-cache backend fault while loading the account key: fail the attempt
+ and retry hourly; only a genuine `ErrCacheMiss` mints a new ACME account
+ (a fault treated as a miss would register a fresh account per hiccup).
+- Challenge TXT cleanup runs on its own context so stale records can't
+ poison the next issuance; propagation wait is best-effort (the CA retries
+ on its own).
+- LE budget: renewals cost ~1 order/90 days per parent; new orders are spent
+ at account-creation cadence, not app-creation cadence — the point of the
+ feature. Account creation is already console-gated.
+
+## 5. Consumers of the acme cache shape
+
+`domains.Reconcile`'s `certExpiry` parses the acme cache dir
+(`<AcmeDir>/<host>`) to publish per-route cert expiry into the registry.
+Wildcard-covered hosts never get a per-host cache file, so without a fix
+status would report them certless forever. Same change therefore teaches
+`certExpiry` to fall back to the covering `wildcard-<parent>.pem` when the
+per-host file is absent.
+
+Wire-shape check: the new cache keys (`wildcard-<parent>.pem`,
+`wildcard-account.key`) are additive files beside autocert's per-host
+entries; nothing existing is rewritten, old binaries ignore them, and the
+key names match Eleven's pre-seam file names so a dir cache needs no
+migration.
+
+## 6. Testing
+
+All against fakes, no network, ported from `wildcard_test.go` plus the
+CARLOS-specific parts:
+
+- `certFor` matching: apex, exactly-one-label, two-labels-deep, unknown
+ parent, expired cert (returns nil → fallback), case/trailing-dot
+ normalization.
+- Parent derivation: accounts appearing/disappearing across route swaps,
+ `PLATFORM_APPS_DOMAIN` unset (no dynamic parents), static-list merge,
+ wake-on-new-parent.
+- PEM encode/parse roundtrip; `needsObtain` renewal-window edges.
+- Fake `dnsProvider` capturing present/cleanup ordering and
+ cleanup-despite-dead-context.
+- `certExpiry` wildcard fallback in `internal/domains`.
+
+Gate: `go build ./... && go vet ./... && go test ./... -count=1` all clean,
+`gofmt -l` clean for touched files.
+
+## 7. Rollout
+
+Ship binary, then per deployment (order-safe — any gap degrades to today's
+behavior):
+
+1. **Flagship**: DNSimple token into SSM → agent unit env
+ (`CARLOS_DNSIMPLE_TOKEN`; `PLATFORM_APPS_DOMAIN` already set) → promote →
+ adopt → **restart** the agent (long-running unit; adoption swaps a
+ symlink, it does not restart processes). Verify `wildcard *.<acct>
+ obtained` in logs and `openssl s_client` against a never-served
+ hostname under an existing account.
+2. **Tito**: apply the region-host IAM statement (operator tofu queue) → set
+ `CARLOS_WILDCARD_DOMAINS=go.tito.io`, `CARLOS_DNS_PROVIDER=route53` on
+ the agent unit → restart → same verification against a fresh
+ `<slug>.go.tito.io`.