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>
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`. |