carlos / platform Public

merge: per-account wildcard DNS-01 certs — instant TLS for new app hostnames (#60)

edge: per-account wildcard DNS-01 certs — instant TLS for new app hostnames
Paul Campbell 7302bad8f1be37f97987c2cb183f0a4604659bd5 parent 7ea697fparent da385e7
16 files changed, +4259 −7
  • docs/superpowers/plans/2026-08-05-wildcard-certs.md +2073 −0
  • docs/superpowers/specs/2026-08-05-wildcard-certs-design.md +172 −0
  • go.mod +1 −0
  • go.sum +2 −0
  • infra/modules/region-host/main.tf +42 −0
  • infra/modules/region-host/variables.tf +10 −0
  • internal/domains/reconcile.go +45 −1
  • internal/domains/reconcile_internal_test.go +114 −0
  • internal/edge/dns.go +268 −0
  • internal/edge/dns_test.go +178 −0
  • internal/edge/edge.go +51 −6
  • internal/edge/route53dns.go +182 −0
  • internal/edge/route53dns_test.go +124 −0
  • internal/edge/wildcard.go +570 −0
  • internal/edge/wildcard_test.go +420 −0
  • internal/host/agent.go +7 −0
diff --git a/docs/superpowers/plans/2026-08-05-wildcard-certs.md b/docs/superpowers/plans/2026-08-05-wildcard-certs.md
new file mode 100644
index 000000000..c52ec4914
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-05-wildcard-certs.md
@@ -0,0 +1,2073 @@
+# Wildcard Certs Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Per-account wildcard DNS-01 certificates in the edge so freshly created app hostnames have working TLS instantly, with per-host HTTP-01 as the untouched fallback.
+
+**Architecture:** Port Eleven's `wildcard.go` + `route53dns.go` (elevenmessenger/messenger) into `internal/edge` behind the existing cert-cache seam, adapted for dynamic parents: one `*.<acct>.<apps-domain>` cert per account derived from the live route table, plus static parents from `CARLOS_WILDCARD_DOMAINS` (tito's `go.tito.io`). A wrapped `GetCertificate` serves the wildcard when SNI is exactly one label under a live parent; everything else falls through to autocert per-host HTTP-01. `domains.Reconcile`'s `certExpiry` learns to read the covering wildcard PEM (it parses the acme cache dir shape — the consumer rule).
+
+**Tech Stack:** Go, `golang.org/x/crypto/acme` (already vendored via autocert), `aws-sdk-go-v2/service/route53` v1.64.1 (new module, already in the local module cache; core SDK versions identical to what's in go.mod), DNSimple v2 REST API (plain net/http), OpenTofu for the region-host IAM variable.
+
+**Spec:** `docs/superpowers/specs/2026-08-05-wildcard-certs-design.md`
+
+## Global Constraints
+
+- Sandboxed go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off` (disable sandbox if the toolchain reports "read-only file system").
+- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean; `gofmt -l` clean for touched files.
+- Wire-shape changes are additive: new cert-cache keys (`wildcard-<parent>.pem`, `wildcard-account.key`) only — never rewrite existing autocert entries.
+- Env vocabulary (exact): `CARLOS_WILDCARD_DOMAINS`, `CARLOS_DNS_PROVIDER` (`""`/`dnsimple`/`route53`), `CARLOS_DNSIMPLE_TOKEN`, `CARLOS_DNSIMPLE_BASE` (test override), `CARLOS_ACME_DIRECTORY` (test override), plus existing `PLATFORM_APPS_DOMAIN`.
+- Zero-config state = today's behavior: nothing configured → wildcard machinery off, per-host issuance continues.
+- Log prefix is `edge:` (the package's existing convention), not Eleven's `router:`.
+
+---
+
+### Task 1: dnsProvider seam + DNSimple backend
+
+**Files:**
+- Modify: `go.mod` (add `github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1` — added here once so Task 2 compiles; harmless while unused)
+- Create: `internal/edge/dns.go`
+- Test: `internal/edge/dns_test.go`
+
+**Interfaces:**
+- Consumes: `envOr(key, fallback string) string` (exists, `edge.go`).
+- Produces:
+ - `type dnsProvider interface { present(ctx context.Context, fqdn, value string) (cleanup func(context.Context) error, err error) }` — publish a TXT at the full record FQDN (e.g. `_acme-challenge.abs.oncarlos.com`), backend discovers the owning zone itself. This is the CARLOS adaptation of Eleven's seam: Eleven passed (zone, name) because its parents were zones; per-account parents are NOT zones.
+ - `func dnsProviderFromEnv() (dnsProvider, string)` — `(nil, reason)` when unconfigured.
+ - `func newDNSimpleDNS(token string) *dnsimpleDNS`
+ - `func splitDomains(s string) []string`
+
+- [ ] **Step 1: Add the route53 dependency** (needed by Task 2; do it while go.mod is the only file touched)
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go get github.com/aws/aws-sdk-go-v2/service/route53@v1.64.1 && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go mod tidy`
+Expected: go.mod gains the module; resolution succeeds from the module cache (core versions match Eleven's exactly). `go mod tidy` may drop it again since nothing imports it yet — if so, re-run the `go get` at the start of Task 2 instead; either way build stays green.
+
+- [ ] **Step 2: Write the failing tests**
+
+`internal/edge/dns_test.go`:
+
+```go
+package edge
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+)
+
+func TestSplitDomains(t *testing.T) {
+ cases := []struct {
+ in string
+ want []string
+ }{
+ {"", nil},
+ {"go.tito.io", []string{"go.tito.io"}},
+ {" Go.Tito.IO , dev.oncarlos.com ,", []string{"go.tito.io", "dev.oncarlos.com"}},
+ }
+ for _, c := range cases {
+ if got := splitDomains(c.in); !reflect.DeepEqual(got, c.want) {
+ t.Errorf("splitDomains(%q) = %v, want %v", c.in, got, c.want)
+ }
+ }
+}
+
+func TestDNSProviderSelection(t *testing.T) {
+ t.Setenv("CARLOS_DNS_PROVIDER", "")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("unset env: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
+ if p, reason := dnsProviderFromEnv(); p == nil {
+ t.Fatalf("token set: want dnsimple provider, got nil (%s)", reason)
+ } else if _, ok := p.(*dnsimpleDNS); !ok {
+ t.Fatalf("token set: want *dnsimpleDNS, got %T", p)
+ }
+ t.Setenv("CARLOS_DNS_PROVIDER", "bogus")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("bogus provider: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ // route53 selection is exercised in route53dns_test.go — constructing it
+ // here would hit the AWS credential chain.
+}
+
+// fakeDNSimple is a minimal DNSimple v2 API: whoami, per-zone existence,
+// record create/delete. It records mutations for assertions.
+type fakeDNSimple struct {
+ zones map[string]bool // zone name → exists
+ created []string // "zone|name|content"
+ deleted []int64
+ nextID int64
+}
+
+func (f *fakeDNSimple) handler(t *testing.T) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer tok" {
+ t.Errorf("whoami auth header = %q", r.Header.Get("Authorization"))
+ }
+ fmt.Fprint(w, `{"data":{"account":{"id":42}}}`)
+ })
+ mux.HandleFunc("GET /v2/42/zones/{zone}", func(w http.ResponseWriter, r *http.Request) {
+ if !f.zones[r.PathValue("zone")] {
+ http.Error(w, `{"message":"not found"}`, 404)
+ return
+ }
+ fmt.Fprintf(w, `{"data":{"name":%q}}`, r.PathValue("zone"))
+ })
+ mux.HandleFunc("POST /v2/42/zones/{zone}/records", func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name, Type, Content string
+ TTL int
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("create record body: %v", err)
+ }
+ f.nextID++
+ f.created = append(f.created, fmt.Sprintf("%s|%s|%s", r.PathValue("zone"), body.Name, body.Content))
+ w.WriteHeader(201)
+ fmt.Fprintf(w, `{"data":{"id":%d}}`, f.nextID)
+ })
+ mux.HandleFunc("DELETE /v2/42/zones/{zone}/records/{id}", func(w http.ResponseWriter, r *http.Request) {
+ var id int64
+ fmt.Sscan(r.PathValue("id"), &id)
+ f.deleted = append(f.deleted, id)
+ w.WriteHeader(204)
+ })
+ return mux
+}
+
+// The per-account adaptation: the zone is discovered by suffix-walking fqdn
+// against the API, so a challenge under abs.oncarlos.com lands in the
+// oncarlos.com zone with the account label in the record NAME.
+func TestDNSimplePresentDiscoversZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{"oncarlos.com": true}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ cleanup, err := d.present(context.Background(), "_acme-challenge.abs.oncarlos.com", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ want := []string{"oncarlos.com|_acme-challenge.abs|txtvalue"}
+ if !reflect.DeepEqual(fake.created, want) {
+ t.Fatalf("created = %v, want %v", fake.created, want)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if !reflect.DeepEqual(fake.deleted, []int64{1}) {
+ t.Fatalf("deleted = %v, want [1]", fake.deleted)
+ }
+}
+
+func TestDNSimplePresentNoZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ if _, err := d.present(context.Background(), "_acme-challenge.abs.nowhere.example", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestSplitDomains|TestDNSProvider|TestDNSimple' -count=1`
+Expected: FAIL — `undefined: splitDomains`, `undefined: dnsProviderFromEnv`, etc.
+
+- [ ] **Step 4: Write `internal/edge/dns.go`**
+
+```go
+package edge
+
+// The DNS-01 provider seam for wildcard certs (wildcard.go) — ported from
+// Eleven's wildcard.go/dnsProvider with one deliberate change: present takes
+// the full record FQDN and the backend discovers the owning zone itself.
+// Eleven's parents were always zones (11ch.at); CARLOS's per-account parents
+// (<acct>.<apps-domain>) are not, so the split into (zone, record name) is
+// the backend's job — DNSimple suffix-walks the fqdn against the zones API,
+// Route53 suffix-matches ListHostedZonesByName (route53dns.go).
+//
+// Backends: dnsimpleDNS (default — oncarlos.com's registrar; needs
+// CARLOS_DNSIMPLE_TOKEN) and route53DNS (CARLOS_DNS_PROVIDER=route53 — the
+// tito.io path; standard AWS credential chain, no token on disk). Selection
+// lives in dnsProviderFromEnv; everything in wildcard.go is provider-blind.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+// dnsProvider publishes the ACME DNS-01 challenge TXT and hands back a
+// closure that removes it — a closure so each backend keeps its teardown
+// detail private (DNSimple deletes by the numeric record id it minted;
+// Route53 re-submits the record set). cleanup runs on its own context (see
+// solveDNS01's defer) so it still fires after the issuance context is dead.
+type dnsProvider interface {
+ present(ctx context.Context, fqdn, value string) (cleanup func(context.Context) error, err error)
+}
+
+// dnsProviderFromEnv selects the DNS-01 backend:
+//
+// - unset / CARLOS_DNS_PROVIDER=dnsimple → DNSimple; needs
+// CARLOS_DNSIMPLE_TOKEN (an account API token). A missing token leaves
+// the wildcard off (per-host issuance continues) — the zero-config state.
+// - CARLOS_DNS_PROVIDER=route53 → Route53, authed by the standard AWS
+// credential chain (env vars / instance IAM role) — no DNS token on disk.
+//
+// Returns (nil, reason) when nothing is configured, so the caller logs the
+// human reason and falls back to per-host issuance rather than failing boot.
+func dnsProviderFromEnv() (dnsProvider, string) {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_DNS_PROVIDER"))) {
+ case "route53":
+ r53, err := newRoute53DNS(context.Background())
+ if err != nil {
+ return nil, fmt.Sprintf("CARLOS_DNS_PROVIDER=route53 but AWS config load failed: %v", err)
+ }
+ return r53, ""
+ case "", "dnsimple":
+ token := os.Getenv("CARLOS_DNSIMPLE_TOKEN")
+ if token == "" {
+ return nil, "CARLOS_DNSIMPLE_TOKEN missing"
+ }
+ return newDNSimpleDNS(token), ""
+ default:
+ return nil, fmt.Sprintf("unknown CARLOS_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("CARLOS_DNS_PROVIDER"))
+ }
+}
+
+// splitDomains parses the CARLOS_WILDCARD_DOMAINS comma list.
+func splitDomains(s string) []string {
+ var out []string
+ for _, d := range strings.Split(s, ",") {
+ if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// ---- DNSimple zone records --------------------------------------------------
+
+type dnsimpleDNS struct {
+ base string
+ token string
+ mu sync.Mutex
+ account string // discovered via /v2/whoami on first use
+ zones map[string]bool // zone name → known-to-exist (positive cache only)
+}
+
+// newDNSimpleDNS builds the default DNS-01 backend from an account API token,
+// honouring the CARLOS_DNSIMPLE_BASE override (kept here so all env reads for
+// this backend live in one place).
+func newDNSimpleDNS(token string) *dnsimpleDNS {
+ return &dnsimpleDNS{
+ base: envOr("CARLOS_DNSIMPLE_BASE", "https://api.dnsimple.com"),
+ token: token,
+ zones: map[string]bool{},
+ }
+}
+
+// present satisfies dnsProvider: discover the zone owning fqdn, create the
+// challenge TXT inside it, and hand back a closure that deletes it by the
+// record id DNSimple returned.
+func (d *dnsimpleDNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
+ zone, name, err := d.zoneFor(ctx, fqdn)
+ if err != nil {
+ return nil, err
+ }
+ id, err := d.createTXT(ctx, zone, name, value)
+ if err != nil {
+ return nil, fmt.Errorf("dnsimple: create TXT: %v", err)
+ }
+ return func(cctx context.Context) error { return d.deleteRecord(cctx, zone, id) }, nil
+}
+
+// zoneFor walks fqdn's parent suffixes longest-first until the zones API
+// knows one, returning (zone, record name within it). Positive results are
+// cached; misses are not (a zone might be added later).
+func (d *dnsimpleDNS) zoneFor(ctx context.Context, fqdn string) (zone, name string, err error) {
+ fqdn = strings.ToLower(strings.TrimSuffix(fqdn, "."))
+ labels := strings.Split(fqdn, ".")
+ for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels
+ cand := strings.Join(labels[i:], ".")
+ ok, err := d.zoneExists(ctx, cand)
+ if err != nil {
+ return "", "", err
+ }
+ if ok {
+ return cand, strings.Join(labels[:i], "."), nil
+ }
+ }
+ return "", "", fmt.Errorf("dnsimple: no zone found for %s", fqdn)
+}
+
+func (d *dnsimpleDNS) zoneExists(ctx context.Context, zone string) (bool, error) {
+ d.mu.Lock()
+ known := d.zones[zone]
+ d.mu.Unlock()
+ if known {
+ return true, nil
+ }
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return false, err
+ }
+ err = d.do(ctx, "GET", "/v2/"+acct+"/zones/"+zone, nil, nil)
+ if err != nil {
+ if errors.Is(err, errDNSimpleNotFound) {
+ return false, nil
+ }
+ return false, err
+ }
+ d.mu.Lock()
+ d.zones[zone] = true
+ d.mu.Unlock()
+ return true, nil
+}
+
+// errDNSimpleNotFound distinguishes a 404 (zone genuinely absent — keep
+// walking suffixes) from transport or auth failures (stop and report).
+var errDNSimpleNotFound = errors.New("dnsimple: not found")
+
+func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error {
+ var rdr io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ rdr = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+d.token)
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf("%s %s: %w", method, path, errDNSimpleNotFound)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg)))
+ }
+ if out != nil {
+ return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
+ }
+ return nil
+}
+
+func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) {
+ d.mu.Lock()
+ acct := d.account
+ d.mu.Unlock()
+ if acct != "" {
+ return acct, nil
+ }
+ var who struct {
+ Data struct {
+ Account *struct {
+ ID int64 `json:"id"`
+ } `json:"account"`
+ } `json:"data"`
+ }
+ if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil {
+ return "", err
+ }
+ if who.Data.Account == nil {
+ return "", errors.New("whoami: token is not an account token")
+ }
+ d.mu.Lock()
+ d.account = fmt.Sprint(who.Data.Account.ID)
+ acct = d.account
+ d.mu.Unlock()
+ return acct, nil
+}
+
+func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) {
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return 0, err
+ }
+ var created struct {
+ Data struct {
+ ID int64 `json:"id"`
+ } `json:"data"`
+ }
+ err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{
+ "name": name, "type": "TXT", "content": content, "ttl": 60,
+ }, &created)
+ if err != nil {
+ return 0, err
+ }
+ return created.Data.ID, nil
+}
+
+func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error {
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return err
+ }
+ return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil)
+}
+```
+
+Note: `newRoute53DNS` doesn't exist until Task 2. To keep this task independently green, add a one-line stub at the bottom of `dns.go` that Task 2 deletes when it lands the real one:
+
+```go
+// newRoute53DNS is implemented in route53dns.go (Task 2). Stub so the
+// dnsimple-first commit compiles; selection tests skip route53 construction.
+func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
+ return nil, errors.New("route53 backend not built yet")
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestSplitDomains|TestDNSProvider|TestDNSimple' -count=1`
+Expected: PASS
+
+- [ ] **Step 6: Full gate + commit**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && gofmt -l internal/edge/`
+Expected: all clean, no gofmt output.
+
+```bash
+git add go.mod go.sum internal/edge/dns.go internal/edge/dns_test.go
+git commit -m "edge: dnsProvider seam + DNSimple DNS-01 backend
+
+Ported from Eleven's wildcard.go with one deliberate change: present()
+takes the full record FQDN and the backend discovers the owning zone
+(per-account wildcard parents are not zones)."
+```
+
+---
+
+### Task 2: Route53 backend
+
+**Files:**
+- Create: `internal/edge/route53dns.go`
+- Test: `internal/edge/route53dns_test.go`
+- Modify: `internal/edge/dns.go` (delete the `newRoute53DNS` stub)
+
+**Interfaces:**
+- Consumes: `dnsProvider`, `errDNSimpleNotFound` pattern not needed here; `dnsFQDN` is produced here and used only here.
+- Produces: `func newRoute53DNS(ctx context.Context) (dnsProvider, error)` (replaces the Task 1 stub — same signature, so `dnsProviderFromEnv` doesn't change).
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/edge/route53dns_test.go`:
+
+```go
+package edge
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+// fakeRoute53 satisfies route53API and records the record-set changes.
+type fakeRoute53 struct {
+ zones map[string]string // zone name (with trailing dot) → id
+ changes []string // "ACTION|name|value"
+}
+
+func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, in *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) {
+ out := &route53.ListHostedZonesByNameOutput{}
+ for name, id := range f.zones {
+ out.HostedZones = append(out.HostedZones, r53types.HostedZone{Name: aws.String(name), Id: aws.String("/hostedzone/" + id)})
+ }
+ return out, nil
+}
+
+func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
+ for _, c := range in.ChangeBatch.Changes {
+ f.changes = append(f.changes, string(c.Action)+"|"+aws.ToString(c.ResourceRecordSet.Name)+"|"+aws.ToString(c.ResourceRecordSet.ResourceRecords[0].Value))
+ }
+ return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("c1"), Status: r53types.ChangeStatusInsync}}, nil
+}
+
+func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) {
+ return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil
+}
+
+// The fqdn seam: the backend must find the most specific zone that is a
+// parent of the record fqdn — tito.io owns _acme-challenge.go.tito.io when
+// go.tito.io has no zone of its own, and the value must be quoted (Route53
+// stores TXT values as quoted character-strings).
+func TestRoute53PresentUpsertsAndCleansUp(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "io.": "Z0"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+
+ cleanup, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ if len(fake.changes) != 1 || fake.changes[0] != `UPSERT|_acme-challenge.go.tito.io.|"txtvalue"` {
+ t.Fatalf("changes after present = %v", fake.changes)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if len(fake.changes) != 2 || !strings.HasPrefix(fake.changes[1], "DELETE|_acme-challenge.go.tito.io.|") {
+ t.Fatalf("changes after cleanup = %v", fake.changes)
+ }
+ if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z1" {
+ t.Fatalf("cached zone id = %q, want Z1 (most specific parent zone)", got)
+ }
+}
+
+func TestRoute53NoZone(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"example.org.": "Z9"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+ if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestRoute53 -count=1`
+Expected: FAIL — `undefined: route53DNS` (the stub returns a plain error type, no struct exists).
+
+- [ ] **Step 3: Write `internal/edge/route53dns.go`** (port of Eleven's, minus `upsertA` — that's its relocation feature, YAGNI here; and `hostedZoneID` now takes the record fqdn directly)
+
+```go
+package edge
+
+// route53DNS — the Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53), the
+// tito path: obtain a *.go.tito.io wildcard with NO DNS token on disk; auth
+// rides the standard AWS credential chain (env vars or the box IAM role,
+// scoped to the hosted zone — infra/modules/region-host). Ported from
+// Eleven's route53dns.go minus its relocation-only upsertA.
+//
+// Same seam as DNSimple (dnsProvider): UPSERT the challenge TXT, wait for
+// the change to go INSYNC, and hand back a closure that removes it. The
+// zone owning the record fqdn is discovered via ListHostedZonesByName's
+// suffix match, so per-account parents (which have no zone of their own)
+// resolve to the registered domain's zone.
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+// route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to
+// reach INSYNC. Route53 usually syncs in well under a minute; on timeout we
+// proceed anyway — the CA runs its own retries and waitForTXT still gates
+// public visibility (best-effort, never fatal).
+const route53InSyncTimeout = 2 * time.Minute
+
+// route53API is the slice of the Route53 client route53DNS calls — an
+// interface so tests substitute a fake and never reach AWS.
+type route53API interface {
+ ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error)
+ ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
+ GetChange(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error)
+}
+
+type route53DNS struct {
+ client route53API
+ mu sync.Mutex
+ zoneIDs map[string]string // record fqdn → hosted-zone id
+}
+
+// newRoute53DNS builds the backend from the default AWS credential chain.
+// Route53 is a global service but the SDK still needs a signing region, so
+// default to us-east-1 when the environment names none — harmless, the
+// endpoint is global either way, and an explicit AWS_REGION still wins.
+func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
+ var opts []func(*awsconfig.LoadOptions) error
+ if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
+ opts = append(opts, awsconfig.WithRegion("us-east-1"))
+ }
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("route53: load aws config: %w", err)
+ }
+ return &route53DNS{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil
+}
+
+// present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC,
+// and return a closure that deletes the exact record set it created. UPSERT
+// (not CREATE) so a TXT stranded by a crashed prior attempt is overwritten
+// instead of colliding. Route53 stores a TXT value as a quoted
+// character-string; the ACME token is a bare base64url string, so wrap it.
+func (r *route53DNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
+ zoneID, err := r.hostedZoneID(ctx, fqdn)
+ if err != nil {
+ return nil, err
+ }
+ rrset := &r53types.ResourceRecordSet{
+ Name: aws.String(dnsFQDN(fqdn)),
+ Type: r53types.RRTypeTxt,
+ TTL: aws.Int64(60),
+ ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}},
+ }
+ out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{
+ Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}},
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("route53: upsert TXT: %w", err)
+ }
+ if out.ChangeInfo != nil {
+ r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id))
+ }
+ cleanup := func(cctx context.Context) error {
+ // DELETE must resubmit the same record set that was created.
+ _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{
+ Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}},
+ },
+ })
+ return err
+ }
+ return cleanup, nil
+}
+
+// hostedZoneID resolves the hosted zone authoritative for fqdn, caching the
+// lookup: the most specific returned zone whose name equals fqdn or is a
+// parent suffix of it. Ids come back as "/hostedzone/Z123"; we store the
+// bare id.
+func (r *route53DNS) hostedZoneID(ctx context.Context, fqdn string) (string, error) {
+ r.mu.Lock()
+ if id, ok := r.zoneIDs[fqdn]; ok {
+ r.mu.Unlock()
+ return id, nil
+ }
+ r.mu.Unlock()
+
+ want := dnsFQDN(fqdn)
+ out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(want)})
+ if err != nil {
+ return "", fmt.Errorf("route53: list hosted zones: %w", err)
+ }
+ var bestName, bestID string
+ for _, hz := range out.HostedZones {
+ zn := strings.ToLower(aws.ToString(hz.Name))
+ if zn == want || strings.HasSuffix(want, "."+zn) {
+ if len(zn) > len(bestName) {
+ bestName, bestID = zn, strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/")
+ }
+ }
+ }
+ if bestID == "" {
+ return "", fmt.Errorf("route53: no hosted zone for %s", fqdn)
+ }
+ r.mu.Lock()
+ r.zoneIDs[fqdn] = bestID
+ r.mu.Unlock()
+ return bestID, nil
+}
+
+// waitInSync polls GetChange until the change is INSYNC or the timeout
+// passes. Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA
+// retries).
+func (r *route53DNS) waitInSync(ctx context.Context, changeID string) {
+ if changeID == "" {
+ return
+ }
+ deadline := time.Now().Add(route53InSyncTimeout)
+ for time.Now().Before(deadline) && ctx.Err() == nil {
+ out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)})
+ if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync {
+ return
+ }
+ time.Sleep(3 * time.Second)
+ }
+}
+
+// dnsFQDN appends the root dot Route53 stores names with.
+func dnsFQDN(name string) string {
+ name = strings.ToLower(name)
+ if strings.HasSuffix(name, ".") {
+ return name
+ }
+ return name + "."
+}
+```
+
+Also delete the `newRoute53DNS` stub from `dns.go` (and its now-unused comment).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestRoute53|TestDNSProvider' -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+Run the full gate (Global Constraints), then:
+
+```bash
+git add internal/edge/route53dns.go internal/edge/route53dns_test.go internal/edge/dns.go go.mod go.sum
+git commit -m "edge: Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53)
+
+Port of Eleven's route53dns.go minus relocation-only upsertA; zone for a
+record fqdn is discovered by suffix match, so per-account parents with no
+zone of their own land in the registered domain's zone."
+```
+
+---
+
+### Task 3: wildcardManager state — parents, matching, PEM, cache
+
+**Files:**
+- Create: `internal/edge/wildcard.go`
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: `dnsProvider` (Task 1), `autocert.Cache` (certcache.go seam), `envOr`.
+- Produces (used by Tasks 4-5):
+ - `type wildcardManager struct` with fields `cache autocert.Cache`, `dns dnsProvider`, `directory string`, `staticDomains []string`, `appsDomain string`, `hosts func() []string`, `mu sync.Mutex`, `parents []string`, `certs map[string]*tls.Certificate`, `wake chan struct{}`
+ - `func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, cache autocert.Cache, dns dnsProvider) *wildcardManager`
+ - `func (w *wildcardManager) certFor(serverName string) *tls.Certificate`
+ - `func (w *wildcardManager) refresh()` — recompute parents, wake run loop if any parent needs obtaining
+ - `func parentsFor(static []string, appsDomain string, hosts []string) []string` — sorted, deduped
+ - `func (w *wildcardManager) needsObtain(parent string, now time.Time) bool`
+ - `func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool`
+ - `func wildcardPEMKey(domain string) string` → `"wildcard-" + domain + ".pem"`
+ - `func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error)`, `func parseWildcardPEM(raw []byte) (*tls.Certificate, error)`
+ - `const wildcardRenewBefore = 30 * 24 * time.Hour`, `const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"`, `const wildcardAccountKeyName = "wildcard-account.key"`
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/edge/wildcard_test.go`:
+
+```go
+package edge
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "math/big"
+ "reflect"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+)
+
+// selfSignedWildcard mints a throwaway *.parent cert valid for the given
+// window — enough for matching/renewal/PEM tests, no ACME involved.
+func selfSignedWildcard(t *testing.T, parent string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "*." + parent},
+ DNSNames: []string{"*." + parent},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: notAfter,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ leaf, err := x509.ParseCertificate(der)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key
+}
+
+func TestParentsFor(t *testing.T) {
+ hosts := []string{
+ "kass.abs.oncarlos.com", // app host → parent abs.oncarlos.com
+ "blog.abs.oncarlos.com", // same parent, deduped
+ "echo.bab.oncarlos.com", // second account
+ "canary.kass.abs.oncarlos.com", // canary depth → no wildcard parent
+ "oncarlos.com", // apex → skipped
+ "console.example.com", // foreign domain → skipped
+ "UPPER.CASE.ONCARLOS.COM", // case-normalized → case.oncarlos.com
+ }
+ got := parentsFor([]string{"go.tito.io"}, "oncarlos.com", hosts)
+ want := []string{"abs.oncarlos.com", "bab.oncarlos.com", "case.oncarlos.com", "go.tito.io"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("parentsFor = %v, want %v", got, want)
+ }
+ // No apps domain → static only.
+ if got := parentsFor([]string{"go.tito.io"}, "", hosts); !reflect.DeepEqual(got, []string{"go.tito.io"}) {
+ t.Fatalf("static only = %v", got)
+ }
+ // Nothing at all.
+ if got := parentsFor(nil, "", nil); got != nil {
+ t.Fatalf("empty = %v, want nil", got)
+ }
+}
+
+func TestCertForMatchesExactlyOneLabel(t *testing.T) {
+ w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} },
+ autocert.DirCache(t.TempDir()), nil)
+ w.refresh()
+ cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+
+ cases := []struct {
+ sni string
+ want bool
+ }{
+ {"kass.abs.oncarlos.com", true}, // exactly one label under parent
+ {"NEW.abs.oncarlos.com.", true}, // never-deployed name, case+dot normalized: instant
+ {"abs.oncarlos.com", false}, // the parent itself (wildcard doesn't cover apex)
+ {"x.y.abs.oncarlos.com", false}, // two labels deep
+ {"kass.bab.oncarlos.com", false}, // parent without a loaded cert (bab not in parents)
+ {"slug.go.tito.io", false}, // static parent present but no cert loaded yet
+ }
+ for _, c := range cases {
+ if got := w.certFor(c.sni) != nil; got != c.want {
+ t.Errorf("certFor(%q) served=%v, want %v", c.sni, got, c.want)
+ }
+ }
+
+ // An expired wildcard is never served — fall back to per-host issuance.
+ expired, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(-time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = expired
+ w.mu.Unlock()
+ if w.certFor("kass.abs.oncarlos.com") != nil {
+ t.Fatal("expired wildcard was served")
+ }
+}
+
+func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) {
+ cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ raw, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ back, err := parseWildcardPEM(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !back.Leaf.NotAfter.Equal(cert.Leaf.NotAfter) {
+ t.Fatalf("roundtrip NotAfter = %v, want %v", back.Leaf.NotAfter, cert.Leaf.NotAfter)
+ }
+ if _, err := parseWildcardPEM([]byte("junk")); err == nil {
+ t.Fatal("parseWildcardPEM(junk): want error")
+ }
+
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, autocert.DirCache(t.TempDir()), nil)
+ if !w.needsObtain("abs.oncarlos.com", time.Now()) {
+ t.Fatal("no cert loaded: needsObtain should be true")
+ }
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+ if w.needsObtain("abs.oncarlos.com", time.Now()) {
+ t.Fatal("60 days left: needsObtain should be false")
+ }
+ if !w.needsObtain("abs.oncarlos.com", cert.Leaf.NotAfter.Add(-15*24*time.Hour)) {
+ t.Fatal("15 days left (<30): needsObtain should be true")
+ }
+}
+
+func TestLoadCachedAndRefreshWake(t *testing.T) {
+ dir := t.TempDir()
+ cache := autocert.DirCache(dir)
+ cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ raw, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), raw); err != nil {
+ t.Fatal(err)
+ }
+
+ w := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, cache, nil)
+ if !w.loadCached(context.Background(), "abs.oncarlos.com") {
+ t.Fatal("loadCached: want true for cached parent")
+ }
+ if w.loadCached(context.Background(), "bab.oncarlos.com") {
+ t.Fatal("loadCached: want false for uncached parent")
+ }
+
+ // refresh derives the parent and, since its cert is already loaded and
+ // fresh, does NOT wake the run loop; a parent with no cert does.
+ w.refresh()
+ select {
+ case <-w.wake:
+ t.Fatal("refresh with fresh cert should not wake")
+ default:
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, autocert.DirCache(t.TempDir()), nil)
+ w2.refresh()
+ select {
+ case <-w2.wake:
+ default:
+ t.Fatal("refresh with missing cert should wake the run loop")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestParentsFor|TestCertFor|TestWildcardPEM|TestLoadCached' -count=1`
+Expected: FAIL — `undefined: parentsFor`, `undefined: newWildcardManager`, etc.
+
+- [ ] **Step 3: Write `internal/edge/wildcard.go`** (state half; Task 4 appends the ACME flow)
+
+```go
+package edge
+
+// Wildcard TLS (ACME DNS-01) for instant app hostnames — the port of
+// Eleven's wildcard.go this file's TLS wiring always reserved space for
+// (spec: docs/superpowers/specs/2026-08-05-wildcard-certs-design.md).
+//
+// A TLS wildcard matches exactly one label, and app hosts are two labels
+// under the apps domain (<app>.<acct>.<apps-domain>), so there is no single
+// *.<apps-domain> cert. Instead one wildcard per PARENT:
+//
+// - dynamic parents <acct>.<apps-domain>, derived from the live route
+// table under PLATFORM_APPS_DOMAIN — a new account's first route starts
+// issuance immediately, so every later app under it has working TLS the
+// moment its route exists (and CT logs see the account, not app names);
+// - static parents from CARLOS_WILDCARD_DOMAINS (tito: go.tito.io, whose
+// instance hosts are one label deep).
+//
+// Canary hosts (<name>.<app>.<acct>.…, one label too deep) and custom
+// domains stay per-host HTTP-01. The wildcard only changes how a handshake
+// GETS a certificate: routing, HostPolicy and the :80 handler are
+// untouched, and every failure (DNS provider outage, ACME outage,
+// unreadable cache) degrades to exactly the per-host behaviour — never a
+// dead edge.
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdsa"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "log"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+)
+
+const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"
+
+// wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90
+// days; renewing with 30 left tolerates a month of provider outage.
+const wildcardRenewBefore = 30 * 24 * time.Hour
+
+type wildcardManager struct {
+ // cache is the shared cert cache (certcache.go) — the same backend
+ // autocert uses for per-host certs, so a future pooled-edge S3 backend
+ // shares wildcards too. Keys: wildcardPEMKey / wildcardAccountKeyName.
+ cache autocert.Cache
+ dns dnsProvider
+ directory string
+
+ staticDomains []string
+ appsDomain string
+ hosts func() []string // live route hosts (Edge.currentHosts)
+
+ mu sync.Mutex
+ parents []string // last computed parent set (sorted)
+ certs map[string]*tls.Certificate
+
+ // wake nudges run() out of its 12h sleep when refresh() finds a parent
+ // with no usable cert (a new account's first route). Capacity 1: a wake
+ // is a level, not a count.
+ wake chan struct{}
+}
+
+func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
+ return &wildcardManager{
+ cache: cache,
+ dns: dns,
+ directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
+ staticDomains: staticDomains,
+ appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
+ hosts: hosts,
+ certs: map[string]*tls.Certificate{},
+ wake: make(chan struct{}, 1),
+ }
+}
+
+// parentsFor computes the wildcard parent set: the static domains plus
+// <acct>.<appsDomain> for every route host exactly two labels under
+// appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the
+// apex, and foreign domains contribute nothing. Sorted and deduped so the
+// set is deterministic for logs and tests.
+func parentsFor(static []string, appsDomain string, hosts []string) []string {
+ seen := map[string]bool{}
+ var out []string
+ add := func(p string) {
+ if !seen[p] {
+ seen[p] = true
+ out = append(out, p)
+ }
+ }
+ for _, d := range static {
+ add(strings.ToLower(d))
+ }
+ if appsDomain != "" {
+ suffix := "." + strings.ToLower(appsDomain)
+ for _, h := range hosts {
+ rest, ok := strings.CutSuffix(strings.ToLower(h), suffix)
+ if !ok {
+ continue
+ }
+ if labels := strings.Split(rest, "."); len(labels) == 2 {
+ add(labels[1] + suffix)
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// refresh recomputes the parent set from the live route table (called from
+// the reload hook on every route swap, and once at startup). If any parent
+// lacks a usable cert it wakes run() — that is what makes a NEW account's
+// wildcard start issuing the moment its first route lands, instead of on
+// the next 12h tick.
+func (w *wildcardManager) refresh() {
+ parents := parentsFor(w.staticDomains, w.appsDomain, w.hosts())
+ now := time.Now()
+ w.mu.Lock()
+ w.parents = parents
+ need := false
+ for _, p := range parents {
+ c := w.certs[p]
+ if c == nil || now.Add(wildcardRenewBefore).After(c.Leaf.NotAfter) {
+ need = true
+ break
+ }
+ }
+ w.mu.Unlock()
+ if need {
+ select {
+ case w.wake <- struct{}{}:
+ default:
+ }
+ }
+}
+
+// certFor returns the wildcard cert for serverName, or nil when the name
+// isn't exactly one label under a current parent (a wildcard doesn't cover
+// the apex or deeper levels) or no usable cert is loaded — nil sends the
+// handshake to the per-host autocert path.
+func (w *wildcardManager) certFor(serverName string) *tls.Certificate {
+ sn := strings.ToLower(strings.TrimSuffix(serverName, "."))
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for _, p := range w.parents {
+ sub, ok := strings.CutSuffix(sn, "."+p)
+ if !ok || sub == "" || strings.Contains(sub, ".") {
+ continue
+ }
+ cert := w.certs[p]
+ if cert == nil || time.Now().After(cert.Leaf.NotAfter) {
+ // Never serve a dead cert: a wildcard whose renewal has been
+ // broken past its actual expiry falls back to per-host issuance
+ // (the pre-wildcard behaviour) instead of hard-failing every
+ // handshake under the parent.
+ return nil
+ }
+ return cert
+ }
+ return nil
+}
+
+func (w *wildcardManager) needsObtain(parent string, now time.Time) bool {
+ w.mu.Lock()
+ cert := w.certs[parent]
+ w.mu.Unlock()
+ return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter)
+}
+
+// loadCached pulls parent's wildcard from the cert cache into memory,
+// reporting whether a parseable cert was loaded. Called by run() before any
+// obtain attempt, which covers both startup (certs an earlier process
+// obtained) and a pooled-edge future (certs a sibling box obtained).
+func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool {
+ raw, err := w.cache.Get(ctx, wildcardPEMKey(parent))
+ if errors.Is(err, autocert.ErrCacheMiss) {
+ return false
+ }
+ if err == nil {
+ var cert *tls.Certificate
+ if cert, err = parseWildcardPEM(raw); err == nil {
+ w.mu.Lock()
+ w.certs[parent] = cert
+ w.mu.Unlock()
+ log.Printf("edge: wildcard *.%s loaded from cache (valid until %s)", parent, cert.Leaf.NotAfter.Format(time.RFC3339))
+ return true
+ }
+ }
+ log.Printf("edge: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", parent, err)
+ return false
+}
+
+// wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM —
+// Eleven's file name, so a dir cache written by that lineage reads with no
+// migration, and the keys sit additively beside autocert's per-host entries.
+func wildcardPEMKey(domain string) string {
+ return "wildcard-" + domain + ".pem"
+}
+
+// ---- PEM encode/parse (key + chain in one blob; the cert cache owns
+// placement, permissions, and write atomicity) -------------------------------
+
+func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) {
+ var buf bytes.Buffer
+ der, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+ if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil {
+ return nil, err
+ }
+ for _, c := range cert.Certificate {
+ if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil {
+ return nil, err
+ }
+ }
+ return buf.Bytes(), nil
+}
+
+func parseWildcardPEM(raw []byte) (*tls.Certificate, error) {
+ cert := &tls.Certificate{}
+ for {
+ var block *pem.Block
+ block, raw = pem.Decode(raw)
+ if block == nil {
+ break
+ }
+ switch block.Type {
+ case "EC PRIVATE KEY":
+ key, err := x509.ParseECPrivateKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+ cert.PrivateKey = key
+ case "CERTIFICATE":
+ cert.Certificate = append(cert.Certificate, block.Bytes)
+ }
+ }
+ if cert.PrivateKey == nil || len(cert.Certificate) == 0 {
+ return nil, errors.New("missing key or certificate in PEM")
+ }
+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
+ if err != nil {
+ return nil, err
+ }
+ cert.Leaf = leaf
+ return cert, nil
+}
+```
+
+Note: this import block is exactly what this half uses. Task 4's appended code additionally needs `crypto/elliptic`, `crypto/rand`, `crypto/x509/pkix`, `fmt`, `net`, and `golang.org/x/crypto/acme` — add them in Task 4's step.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestParentsFor|TestCertFor|TestWildcardPEM|TestLoadCached' -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: wildcardManager state — dynamic parents, SNI matching, PEM cache
+
+Per-account parents derived from the route table (app.acct.domain →
+acct.domain; canary depth and apex excluded), static parents from
+CARLOS_WILDCARD_DOMAINS, refresh() wakes issuance when a parent has no
+usable cert. certFor serves exactly-one-label matches and never a dead
+cert."
+```
+
+---
+
+### Task 4: ACME DNS-01 issuance flow + run loop
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (append)
+- Test: `internal/edge/wildcard_test.go` (append)
+
+**Interfaces:**
+- Consumes: everything Task 3 produced; `w.dns.present(ctx, fqdn, value)` (Task 1 signature).
+- Produces (used by Task 5):
+ - `func (w *wildcardManager) run()` — blocking loop; caller starts it with `go w.run()`
+ - `func (w *wildcardManager) obtain(ctx context.Context, parent string) error`
+ - `func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) error`
+ - `func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error)`
+ - `const wildcardAccountKeyName = "wildcard-account.key"`
+ - `func waitForTXT(ctx context.Context, name, value string, timeout time.Duration)`
+
+- [ ] **Step 1: Write the failing tests** (append to `internal/edge/wildcard_test.go`)
+
+```go
+// fakeDNS records present/cleanup calls — the seam obtain talks through.
+type fakeDNS struct {
+ presented []string // fqdn|value
+ cleaned []string // fqdn
+ failWith error
+}
+
+func (f *fakeDNS) present(_ context.Context, fqdn, value string) (func(context.Context) error, error) {
+ if f.failWith != nil {
+ return nil, f.failWith
+ }
+ f.presented = append(f.presented, fqdn+"|"+value)
+ return func(context.Context) error {
+ f.cleaned = append(f.cleaned, fqdn)
+ return nil
+ }, nil
+}
+
+func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) {
+ dir := t.TempDir()
+ cache := autocert.DirCache(dir)
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k1, err := w1.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k2, err := w2.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !k1.Equal(k2) {
+ t.Fatal("account key not stable across managers sharing a cache")
+ }
+}
+
+// A cache backend fault must NOT mint a fresh ACME account — only a genuine
+// miss does (a fault treated as a miss would register a new account per
+// hiccup).
+type faultyCache struct{ autocert.Cache }
+
+func (f faultyCache) Get(context.Context, string) ([]byte, error) {
+ return nil, errors.New("backend down")
+}
+
+func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{})
+ if _, err := w.accountKey(context.Background()); err == nil {
+ t.Fatal("backend fault: want error, got a (freshly minted) key")
+ }
+}
+```
+
+(Requires adding `"errors"` to the test file imports.) Coverage line drawn deliberately, same place Eleven drew it: the full `obtain`/`solveDNS01`/`run` path needs a live ACME directory, which unit tests never touch — that path is exercised by the rollout verification (spec §7). The dead-context TXT cleanup is enforced structurally (the `defer` in `solveDNS01` uses a fresh 30s context, commented as such); the provider tests already prove present→cleanup ordering per backend.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestWildcardAccountKey|TestAccountKeyBackendFault' -count=1`
+Expected: FAIL — `undefined: (*wildcardManager).accountKey` (method not written), fakeDNS unused compile error is acceptable at this stage; fix by writing Step 3.
+
+- [ ] **Step 3: Append the issuance flow to `internal/edge/wildcard.go`**
+
+```go
+// run keeps the wildcard certs current: an immediate pass at startup (first
+// issuance / cache load), then every 12h, dropping to hourly retries after a
+// failure — and refresh() wakes it early when a route swap surfaces a parent
+// with no usable cert (a new account).
+func (w *wildcardManager) run() {
+ for {
+ delay := 12 * time.Hour
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ for _, p := range parents {
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ // Another process (or pool sibling) may have obtained it already.
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ w.loadCached(ctx, p)
+ cancel()
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 10*time.Minute)
+ err := w.obtain(ctx, p)
+ cancel()
+ if err != nil {
+ log.Printf("edge: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", p, err)
+ delay = time.Hour
+ continue
+ }
+ w.mu.Lock()
+ cert := w.certs[p]
+ w.mu.Unlock()
+ log.Printf("edge: wildcard *.%s obtained (valid until %s)", p, cert.Leaf.NotAfter.Format(time.RFC3339))
+ }
+ select {
+ case <-time.After(delay):
+ case <-w.wake:
+ }
+ }
+}
+
+// obtain runs one full ACME DNS-01 issuance for *.parent and swaps the
+// result in.
+func (w *wildcardManager) obtain(ctx context.Context, parent string) error {
+ acctKey, err := w.accountKey(ctx)
+ if err != nil {
+ return fmt.Errorf("account key: %v", err)
+ }
+ client := &acme.Client{Key: acctKey, DirectoryURL: w.directory}
+ if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists {
+ return fmt.Errorf("acme register: %v", err)
+ }
+
+ order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + parent}})
+ if err != nil {
+ return fmt.Errorf("authorize order: %v", err)
+ }
+ for _, zurl := range order.AuthzURLs {
+ if err := w.solveDNS01(ctx, client, parent, zurl); err != nil {
+ return err
+ }
+ }
+
+ certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+ csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
+ Subject: pkix.Name{CommonName: "*." + parent},
+ DNSNames: []string{"*." + parent},
+ }, certKey)
+ if err != nil {
+ return fmt.Errorf("csr: %v", err)
+ }
+ ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
+ if err != nil {
+ return fmt.Errorf("finalize: %v", err)
+ }
+ leaf, err := x509.ParseCertificate(ders[0])
+ if err != nil {
+ return fmt.Errorf("parse issued cert: %v", err)
+ }
+ cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf}
+ raw, err := encodeWildcardPEM(cert, certKey)
+ if err != nil {
+ return fmt.Errorf("encode: %v", err)
+ }
+ if err := w.cache.Put(ctx, wildcardPEMKey(parent), raw); err != nil {
+ return fmt.Errorf("persist: %v", err)
+ }
+ w.mu.Lock()
+ w.certs[parent] = cert
+ w.mu.Unlock()
+ return nil
+}
+
+// solveDNS01 answers one authorization: publish the challenge TXT record,
+// wait for the CA to see it, and always clean the record up afterwards.
+func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) error {
+ authz, err := client.GetAuthorization(ctx, authzURL)
+ if err != nil {
+ return fmt.Errorf("get authorization: %v", err)
+ }
+ if authz.Status == acme.StatusValid {
+ return nil // a recent order already proved this name
+ }
+ var chal *acme.Challenge
+ for _, c := range authz.Challenges {
+ if c.Type == "dns-01" {
+ chal = c
+ break
+ }
+ }
+ if chal == nil {
+ return errors.New("CA offered no dns-01 challenge")
+ }
+ txt, err := client.DNS01ChallengeRecord(chal.Token)
+ if err != nil {
+ return err
+ }
+
+ challengeFQDN := "_acme-challenge." + parent
+ cleanup, err := w.dns.present(ctx, challengeFQDN, txt)
+ if err != nil {
+ return fmt.Errorf("publish challenge TXT: %v", err)
+ }
+ // Clean up with a fresh context: the record must not outlive the attempt
+ // even when ctx is already dead (stale challenge TXTs break the NEXT run).
+ defer func() {
+ cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := cleanup(cctx); err != nil {
+ log.Printf("edge: wildcard *.%s: cleanup of challenge TXT failed: %v", parent, err)
+ }
+ }()
+
+ // Best-effort: give the record a moment to reach the authoritative NS
+ // before telling the CA to look. Proceed on timeout — the CA does its own
+ // retries, and a hard failure just comes back through the retry loop.
+ waitForTXT(ctx, challengeFQDN, txt, time.Minute)
+
+ if _, err := client.Accept(ctx, chal); err != nil {
+ return fmt.Errorf("accept challenge: %v", err)
+ }
+ if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil {
+ return fmt.Errorf("authorization: %v", err)
+ }
+ return nil
+}
+
+// wildcardAccountKeyName is the ACME account key's cache key — Eleven's file
+// name, for the same no-migration reason as wildcardPEMKey.
+const wildcardAccountKeyName = "wildcard-account.key"
+
+// accountKey loads (or mints) the ACME account key. Separate from autocert's
+// account on purpose: this flow manages its own registration lifecycle.
+func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error) {
+ raw, err := w.cache.Get(ctx, wildcardAccountKeyName)
+ if err == nil {
+ block, _ := pem.Decode(raw)
+ if block == nil {
+ return nil, fmt.Errorf("%s: not PEM", wildcardAccountKeyName)
+ }
+ return x509.ParseECPrivateKey(block.Bytes)
+ }
+ if !errors.Is(err, autocert.ErrCacheMiss) {
+ // Only a genuine miss mints a key: treating a backend fault as "no
+ // key yet" would register a fresh ACME account on every hiccup. Fail
+ // and let obtain()'s hourly retry come back around.
+ return nil, err
+ }
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+ der, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+ // Crash-safety (temp + rename, 0600) is the cache backend's contract —
+ // the dir backend inherits it from autocert.DirCache.
+ raw = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
+ if err := w.cache.Put(ctx, wildcardAccountKeyName, raw); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+// waitForTXT polls DNS until name resolves to a TXT containing value, or the
+// deadline passes (best-effort — see caller).
+func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) && ctx.Err() == nil {
+ if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil {
+ for _, t := range txts {
+ if t == value {
+ return
+ }
+ }
+ }
+ time.Sleep(3 * time.Second)
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -count=1`
+Expected: PASS (whole package)
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: wildcard ACME DNS-01 issuance — obtain, solve, run loop
+
+Eleven's flow: dedicated account key (minted only on a genuine cache
+miss), challenge TXT published through the provider seam with always-runs
+cleanup on a fresh context, 12h loop with 1h failure retries, refresh()
+wake for new accounts."
+```
+
+---
+
+### Task 5: Wire into the edge's TLS path
+
+**Files:**
+- Modify: `internal/edge/edge.go` (Edge struct: reload hook; `RunWithActivator`: wildcard setup replacing the "drops in here" comment; package header comment)
+- Test: `internal/edge/wildcard_test.go` (append)
+
+**Interfaces:**
+- Consumes: Tasks 1-4; `Edge.current()`, `Edge.Reload()`, `newCertCache`.
+- Produces:
+ - `func (e *Edge) OnReload(fn func())` — registers a hook run after every successful route swap (atomic pointer, so registration after Watch has started is race-free)
+ - `func (e *Edge) currentHosts() []string`
+ - `func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager` — nil when unconfigured (logged reason)
+
+- [ ] **Step 1: Write the failing tests** (append to `internal/edge/wildcard_test.go`)
+
+```go
+func TestWrappedGetCertificateFallsThrough(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} },
+ autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+
+ fallbackHit := ""
+ inner := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ fallbackHit = hello.ServerName
+ return nil, errors.New("inner path")
+ }
+ wrapped := wrapGetCertificate(w, inner)
+
+ got, err := wrapped(&tls.ClientHelloInfo{ServerName: "new.abs.oncarlos.com"})
+ if err != nil || got != cert {
+ t.Fatalf("covered name: got (%v, %v), want the wildcard", got, err)
+ }
+ if fallbackHit != "" {
+ t.Fatalf("covered name reached the inner path (%q)", fallbackHit)
+ }
+ if _, err := wrapped(&tls.ClientHelloInfo{ServerName: "custom.example.com"}); err == nil || fallbackHit != "custom.example.com" {
+ t.Fatalf("uncovered name must fall through to inner; fallbackHit=%q err=%v", fallbackHit, err)
+ }
+}
+
+func TestNewWildcardFromEnv(t *testing.T) {
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{})
+ cache := autocert.DirCache(t.TempDir())
+
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "")
+ t.Setenv("PLATFORM_APPS_DOMAIN", "")
+ t.Setenv("CARLOS_DNS_PROVIDER", "")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
+ if w := newWildcardFromEnv(e, cache); w != nil {
+ t.Fatal("nothing configured: want nil (per-host only, the historical default)")
+ }
+ // Domains but no provider → off (logged), never a broken half-config.
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ if w := newWildcardFromEnv(e, cache); w != nil {
+ t.Fatal("domains without provider: want nil")
+ }
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
+ w := newWildcardFromEnv(e, cache)
+ if w == nil {
+ t.Fatal("configured: want a manager")
+ }
+ if !reflect.DeepEqual(w.staticDomains, []string{"go.tito.io"}) {
+ t.Fatalf("staticDomains = %v", w.staticDomains)
+ }
+}
+
+func TestOnReloadHookFires(t *testing.T) {
+ reg := openReg(t) // edge_test.go's existing registry fixture
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fired := 0
+ e.OnReload(func() { fired++ })
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if fired != 1 {
+ t.Fatalf("hook fired %d times, want 1", fired)
+ }
+}
+```
+
+Check `edge_test.go` for the existing helper that builds a `*registry.Registry` for tests (there is one — `New` is exercised there) and use its actual name in `TestOnReloadHookFires`.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestWrappedGetCertificate|TestNewWildcardFromEnv|TestOnReloadHook' -count=1`
+Expected: FAIL — `undefined: wrapGetCertificate`, `undefined: newWildcardFromEnv`, `(*Edge).OnReload undefined`.
+
+- [ ] **Step 3: Implement the wiring**
+
+In `internal/edge/edge.go`, add to the `Edge` struct (after `purgeLogN`):
+
+```go
+ // reloadHook, when set (OnReload), runs after every successful route
+ // swap — the wildcard manager recomputes its parent set from it. Atomic
+ // because registration happens in RunWithActivator after Watch may
+ // already be reloading.
+ reloadHook atomic.Pointer[func()]
+```
+
+Add near `Reload`:
+
+```go
+// OnReload registers fn to run after every successful Reload — after the new
+// route set is swapped in, so fn sees the post-reload table via current().
+func (e *Edge) OnReload(fn func()) {
+ e.reloadHook.Store(&fn)
+}
+```
+
+At the end of `Reload()` (immediately before its final `return nil`, after the upstreams swap and any retire pass):
+
+```go
+ if fn := e.reloadHook.Load(); fn != nil {
+ (*fn)()
+ }
+```
+
+Add near `current()`:
+
+```go
+// currentHosts lists the hosts currently served — the wildcard manager's
+// view of the route table for parent derivation.
+func (e *Edge) currentHosts() []string {
+ m := e.current()
+ out := make([]string, 0, len(m))
+ for h := range m {
+ out = append(out, h)
+ }
+ return out
+}
+```
+
+In `internal/edge/wildcard.go`, add:
+
+```go
+// newWildcardFromEnv builds the manager from the environment, or returns nil
+// when wildcards are off: no CARLOS_WILDCARD_DOMAINS and no
+// PLATFORM_APPS_DOMAIN (nothing to cover — silent, the historical default),
+// or no DNS provider configured (logged: half a config deserves a reason in
+// the journal). nil means RunWithActivator wires nothing and the edge
+// behaves exactly as before this feature existed.
+func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
+ static := splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS"))
+ appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
+ if len(static) == 0 && appsDomain == "" {
+ return nil
+ }
+ dns, reason := dnsProviderFromEnv()
+ if dns == nil {
+ log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason)
+ return nil
+ }
+ return newWildcardManager(static, appsDomain, e.currentHosts, cache, dns)
+}
+
+// wrapGetCertificate serves the wildcard for covered names and hands
+// everything else to inner (autocert's per-host path) — split out so the
+// fall-through contract is testable without a TLS listener.
+func wrapGetCertificate(w *wildcardManager, inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+ return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ if c := w.certFor(hello.ServerName); c != nil {
+ return c, nil
+ }
+ return inner(hello)
+ }
+}
+```
+
+(`os` joins wildcard.go's imports.) In `RunWithActivator`, replace the drop-in comment block:
+
+```go
+ // Per-host HTTP-01 only for now. When per-tenant subdomains outgrow the
+ // Let's Encrypt per-domain rate limit (~50 new certs/week), Eleven's
+ // wildcard.go (ACME DNS-01) drops in here, wrapping tlsCfg.GetCertificate.
+ tlsCfg := m.TLSConfig()
+```
+
+with:
+
+```go
+ tlsCfg := m.TLSConfig()
+ // Wildcard certs (wildcard.go, ACME DNS-01): covered names — one label
+ // under a per-account or configured parent — are served the wildcard;
+ // everything else (custom domains, canary hosts, parents not yet
+ // obtained) stays on autocert's per-host HTTP-01 above. nil = not
+ // configured = exactly the pre-wildcard edge.
+ if w := newWildcardFromEnv(e, certCache); w != nil {
+ tlsCfg.GetCertificate = wrapGetCertificate(w, tlsCfg.GetCertificate)
+ e.OnReload(w.refresh)
+ w.refresh()
+ go w.run()
+ log.Printf("edge: wildcard certs on (static %v, apps domain %q)",
+ splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"))
+ }
+```
+
+Update the package header comment (edge.go line ~14): change "Dropped on port: … Eleven's mesh and wildcard DNS-01 certs." to note the wildcard came back: "Dropped on port: titogo's Stripe relay, auth/identity, root-domain platformIndex; Eleven's mesh (its wildcard DNS-01 certs were later ported back — wildcard.go)."
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/edge/edge.go internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: serve wildcard certs from the TLS path
+
+GetCertificate tries the wildcard first and falls through to autocert
+per-host HTTP-01; OnReload recomputes parents on every route swap so a
+new account's wildcard starts issuing the moment its first route lands.
+Unconfigured = the pre-wildcard edge, byte for byte."
+```
+
+---
+
+### Task 6: certExpiry learns the wildcard fallback (the consumer fix)
+
+**Files:**
+- Modify: `internal/domains/reconcile.go` (`certExpiry` call site ~line 314-317; add `wildcardParent`)
+- Test: `internal/domains/reconcile_test.go` (append)
+
+**Interfaces:**
+- Consumes: `certExpiry(path string) int64` (exists), `Config.AcmeDir`, `Config.AppsDomain`.
+- Produces: `func wildcardParent(host, appsDomain string) string` — `""` when no wildcard could cover host.
+
+- [ ] **Step 1: Write the failing test** (append to `internal/domains/reconcile_test.go`; match the file's existing test helpers for Config/store setup — read it first and reuse its fixture style)
+
+```go
+func TestWildcardParent(t *testing.T) {
+ cases := []struct {
+ host, appsDomain, want string
+ }{
+ {"kass.abs.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ {"canary.kass.abs.oncarlos.com", "oncarlos.com", ""}, // canary depth: not covered
+ {"abs.oncarlos.com", "oncarlos.com", ""}, // apex of a parent
+ {"custom.example.com", "oncarlos.com", ""}, // foreign domain
+ {"kass.abs.oncarlos.com", "", ""}, // no apps domain configured
+ {"Kass.ABS.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ }
+ for _, c := range cases {
+ if got := wildcardParent(c.host, c.appsDomain); got != c.want {
+ t.Errorf("wildcardParent(%q, %q) = %q, want %q", c.host, c.appsDomain, got, c.want)
+ }
+ }
+}
+
+// A wildcard-covered host has no per-host acme cache entry — its expiry must
+// come from the covering wildcard-<parent>.pem instead of reporting 0
+// (certless) forever.
+func TestCertExpiryWildcardFallback(t *testing.T) {
+ dir := t.TempDir()
+ // certExpiry parses "key PEM block(s) first, then CERTIFICATE" — build a
+ // minimal self-signed cert the same way the edge's wildcard PEM is laid
+ // out. Reuse/adapt the cert-building helper if reconcile_test.go already
+ // has one; otherwise inline ecdsa+x509 self-sign exactly as in
+ // internal/edge/wildcard_test.go's selfSignedWildcard.
+ notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, "wildcard-abs.oncarlos.com.pem"), "*.abs.oncarlos.com", notAfter)
+
+ host := "kass.abs.oncarlos.com"
+ if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() {
+ t.Fatalf("hostCertExpiry = %d, want %d (wildcard fallback)", got, notAfter.Unix())
+ }
+ // A per-host entry, when present, still wins.
+ perHost := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, host), host, perHost)
+ if got := hostCertExpiry(dir, host, "oncarlos.com"); got != perHost.Unix() {
+ t.Fatalf("hostCertExpiry with per-host file = %d, want %d", got, perHost.Unix())
+ }
+ // Nothing at all → 0, unknown, never a guess.
+ if got := hostCertExpiry(dir, "kass.zzz.oncarlos.com", "oncarlos.com"); got != 0 {
+ t.Fatalf("hostCertExpiry with no files = %d, want 0", got)
+ }
+}
+```
+
+`writeWildcardPEM` is a test helper in this file (the edge package's equivalents aren't importable):
+
+```go
+// writeWildcardPEM writes a self-signed cert for name valid until notAfter,
+// in the acme-cache bundle layout certExpiry parses: key block first, then
+// the certificate.
+func writeWildcardPEM(t *testing.T, path, name string, notAfter time.Time) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: name},
+ DNSNames: []string{name},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: notAfter,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf bytes.Buffer
+ if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
+ t.Fatal(err)
+ }
+ if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+(Adds `bytes`, `crypto/ecdsa`, `crypto/elliptic`, `crypto/rand`, `crypto/x509`, `crypto/x509/pkix`, `encoding/pem`, `math/big`, `os`, `path/filepath`, `time` to the test file's imports as needed.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/domains/ -run 'TestWildcardParent|TestCertExpiryWildcardFallback' -count=1`
+Expected: FAIL — `undefined: wildcardParent`, `undefined: hostCertExpiry`.
+
+- [ ] **Step 3: Implement in `internal/domains/reconcile.go`**
+
+Add beside `certExpiry`:
+
+```go
+// wildcardParent returns the parent domain whose per-account wildcard cert
+// would cover host — host exactly two labels under appsDomain
+// (<app>.<acct>.<appsDomain>) → <acct>.<appsDomain> — or "" when no wildcard
+// could cover it (canary depth, a parent apex, a foreign domain, no apps
+// domain configured).
+func wildcardParent(host, appsDomain string) string {
+ if appsDomain == "" {
+ return ""
+ }
+ suffix := "." + strings.ToLower(appsDomain)
+ rest, ok := strings.CutSuffix(strings.ToLower(host), suffix)
+ if !ok {
+ return ""
+ }
+ if labels := strings.Split(rest, "."); len(labels) == 2 {
+ return labels[1] + suffix
+ }
+ return ""
+}
+
+// hostCertExpiry resolves host's cert expiry from the acme cache dir: the
+// per-host autocert entry when it exists, else the covering per-account
+// wildcard's PEM (wildcard-<parent>.pem — a wildcard-covered host never gets
+// a per-host entry, and without this fallback status would call it certless
+// forever). 0 = unknown, never a guess.
+func hostCertExpiry(acmeDir, host, appsDomain string) int64 {
+ if at := certExpiry(filepath.Join(acmeDir, host)); at != 0 {
+ return at
+ }
+ if parent := wildcardParent(host, appsDomain); parent != "" {
+ return certExpiry(filepath.Join(acmeDir, "wildcard-"+parent+".pem"))
+ }
+ return 0
+}
+```
+
+At the call site (currently `at := certExpiry(filepath.Join(cfg.AcmeDir, rt.Host))` around line 316), replace with:
+
+```go
+ at := hostCertExpiry(cfg.AcmeDir, rt.Host, cfg.AppsDomain)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/domains/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/domains/reconcile.go internal/domains/reconcile_test.go
+git commit -m "domains: cert expiry falls back to the covering wildcard PEM
+
+certExpiry parses the acme cache dir shape, which makes it a consumer of
+the edge's new wildcard-<parent>.pem entries: a wildcard-covered host
+never gets a per-host cache file, and without the fallback status would
+report it certless forever."
+```
+
+---
+
+### Task 7: region-host IAM — optional Route53 statement
+
+**Files:**
+- Modify: `infra/modules/region-host/variables.tf`, `infra/modules/region-host/main.tf`, `infra/modules/region-host/README.md` (if the module has one; skip otherwise)
+
+**Interfaces:**
+- Produces: module variable `route53_zone_ids` (`list(string)`, default `[]`) — hosted-zone IDs the host's role may write DNS-01 challenge records into. Tito's stack (operator tofu queue) will set it to the `tito.io` zone id; flagship passes nothing and its role is untouched.
+
+- [ ] **Step 1: Add the variable** (`variables.tf`)
+
+```hcl
+variable "route53_zone_ids" {
+ description = "Hosted-zone IDs the host may write ACME DNS-01 challenge records into (wildcard certs, CARLOS_DNS_PROVIDER=route53). Empty = no Route53 access."
+ type = list(string)
+ default = []
+}
+```
+
+- [ ] **Step 2: Add the conditional policy** (`main.tf`, after `aws_iam_role_policy.host_buckets`)
+
+```hcl
+# Wildcard-cert DNS-01 (edge wildcard.go, CARLOS_DNS_PROVIDER=route53):
+# write challenge TXTs into the named zones only. ListHostedZonesByName has
+# no resource-level scoping in IAM, hence the split statement.
+resource "aws_iam_role_policy" "host_route53" {
+ count = length(var.route53_zone_ids) > 0 ? 1 : 0
+ name = "${var.iam_name}-route53"
+ role = aws_iam_role.host.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["route53:ChangeResourceRecordSets"]
+ Resource = [for z in var.route53_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
+ },
+ {
+ Effect = "Allow"
+ Action = ["route53:GetChange"]
+ Resource = "arn:aws:route53:::change/*"
+ },
+ {
+ Effect = "Allow"
+ Action = ["route53:ListHostedZonesByName"]
+ Resource = "*"
+ }
+ ]
+ })
+}
+```
+
+- [ ] **Step 3: Validate**
+
+Run: `tofu -chdir=infra/modules/region-host fmt -check && tofu -chdir=infra/modules/region-host validate` (validate needs an `init`; if the module has never been init'd standalone, `tofu -chdir=infra/modules/region-host init -backend=false` first. If tofu isn't runnable in the sandbox, `fmt -check` alone is acceptable — the module is exercised by the deployment stacks.)
+Expected: formatted, valid.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add infra/modules/region-host/
+git commit -m "infra/region-host: optional zone-scoped Route53 DNS-01 policy
+
+route53_zone_ids (default empty = no statement, flagship untouched);
+tito's stack names the tito.io zone so the box role can write
+_acme-challenge TXTs for *.go.tito.io — no DNS token on disk."
+```
+
+---
+
+### Task 8: Docs + full gate
+
+**Files:**
+- Modify: `internal/host/agent.go` (env documentation comment block, ~line 54)
+- Modify: `docs/superpowers/specs/2026-08-05-wildcard-certs-design.md` (status line only)
+
+**Interfaces:** none — documentation.
+
+- [ ] **Step 1: Document the new env vars in agent.go's env comment block**
+
+Find the comment block listing agent environment (starts around `agent.go:54` with `// - PLATFORM_APPS_DOMAIN — the deployment's platform apps domain`) and add, matching its comment style exactly:
+
+```go
+// - CARLOS_WILDCARD_DOMAINS — comma list of static wildcard-cert parent
+// domains for the edge (tito: go.tito.io); per-account parents under
+// PLATFORM_APPS_DOMAIN are derived automatically (edge wildcard.go)
+// - CARLOS_DNS_PROVIDER / CARLOS_DNSIMPLE_TOKEN — DNS-01 backend for
+// wildcard certs: unset/dnsimple needs the token (SSM → unit env),
+// route53 rides the box IAM role; nothing set = wildcards off,
+// per-host HTTP-01 only
+```
+
+- [ ] **Step 2: Mark the spec implemented**
+
+In `docs/superpowers/specs/2026-08-05-wildcard-certs-design.md`, change `**Status:** Approved` to `**Status:** Implemented (this branch)`.
+
+- [ ] **Step 3: Full gate across everything**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1`
+Expected: all 14+ packages pass.
+
+Run: `gofmt -l internal/edge internal/domains internal/host`
+Expected: no output (the three pre-existing unformatted files elsewhere are left alone).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/host/agent.go docs/superpowers/specs/2026-08-05-wildcard-certs-design.md
+git commit -m "docs: wildcard-cert env vocabulary in the agent env block"
+```
+
+---
+
+## Out of scope (deliberately)
+
+- **Deploy execution** (spec §7): SSM token placement, unit env changes, agent restarts, and the tito tofu apply are operator/deploy steps after merge — not code in this branch.
+- **S3 cert-cache backend**: still reserved (`CARLOS_CERT_CACHE=s3` refuses to start); wildcards go through the same seam and inherit it when it lands.
+- **Per-app wildcards for canary hosts**: rejected in brainstorming — canaries stay per-host HTTP-01.
+- **Eleven's `upsertA`/relocation**: not ported; relocation is not a CARLOS feature today.
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..5ce7500df
--- /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:** Implemented (this branch)
+
+## 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`.
diff --git a/go.mod b/go.mod
index b1bfa10f6..6d1969096 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.6
+ github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/smithy-go v1.27.3
github.com/benbjohnson/litestream v0.5.14
diff --git a/go.sum b/go.sum
index 0d5615ed3..5381a37b1 100644
--- a/go.sum
+++ b/go.sum
@@ -26,6 +26,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 h1:qL5ELChQ1o1ZbzHvsl8gzQfAZdbQtrDbUyzJkjkMKcg=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1/go.mod h1:0hIRXFez1bZsDFMGkLZvNJbByTSVZ4sFZWpxZ39NPuM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf
index cee383b1b..21afdcbca 100644
--- a/infra/modules/region-host/main.tf
+++ b/infra/modules/region-host/main.tf
@@ -99,6 +99,48 @@ resource "aws_iam_role_policy" "host_buckets" {
})
}
+# Wildcard-cert DNS-01 (edge wildcard.go, CARLOS_DNS_PROVIDER=route53):
+# write challenge TXTs into the named zones only. ListHostedZonesByName has
+# no resource-level scoping in IAM, hence the split statement.
+resource "aws_iam_role_policy" "host_route53" {
+ count = length(var.route53_zone_ids) > 0 ? 1 : 0
+ name = "${var.iam_name}-route53"
+ role = aws_iam_role.host.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["route53:ChangeResourceRecordSets"]
+ Resource = [for z in var.route53_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
+ # The box may touch ONLY _acme-challenge TXT records, and only via the
+ # two operations the edge issues (UPSERT in present, DELETE in cleanup).
+ # Without these conditions a zone grant is the whole zone — apex, MX,
+ # NS — and this policy is the only bound on the box's DNS authority.
+ Condition = {
+ "ForAllValues:StringEquals" = {
+ "route53:ChangeResourceRecordSetsRecordTypes" = ["TXT"]
+ "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"]
+ }
+ "ForAllValues:StringLike" = {
+ "route53:ChangeResourceRecordSetsNormalizedRecordNames" = ["_acme-challenge.*"]
+ }
+ }
+ },
+ {
+ Effect = "Allow"
+ Action = ["route53:GetChange"]
+ Resource = "arn:aws:route53:::change/*"
+ },
+ {
+ Effect = "Allow"
+ Action = ["route53:ListHostedZonesByName"]
+ Resource = "*"
+ }
+ ]
+ })
+}
+
resource "aws_iam_role_policy_attachment" "host_ssm" {
role = aws_iam_role.host.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf
index 2156aff47..5003e4d35 100644
--- a/infra/modules/region-host/variables.tf
+++ b/infra/modules/region-host/variables.tf
@@ -66,3 +66,13 @@ variable "ami_owner" {
type = string
default = "136693071363" # Debian
}
+
+variable "route53_zone_ids" {
+ description = "Hosted-zone IDs the host may write ACME DNS-01 challenge records into (wildcard certs, CARLOS_DNS_PROVIDER=route53). Empty = no Route53 access."
+ type = list(string)
+ default = []
+ validation {
+ condition = alltrue([for z in var.route53_zone_ids : can(regex("^Z[A-Z0-9]+$", z))])
+ error_message = "route53_zone_ids entries must be bare hosted-zone ids (Z...), not /hostedzone/-prefixed paths or wildcards."
+ }
+}
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index 039191c35..d96c9a1ff 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -433,7 +433,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// --- 5. TLS health --------------------------------------------------------
if cfg.AcmeDir != "" {
for _, rt := range routes {
- at := certExpiry(filepath.Join(cfg.AcmeDir, rt.Host))
+ at := hostCertExpiry(cfg.AcmeDir, rt.Host, cfg.AppsDomain)
if err := reg.SetCertExpiresAt(rt.Host, at); err != nil {
log.Printf("domains: stamp cert expiry on %s: %v", rt.Host, err)
}
@@ -673,6 +673,50 @@ func mintedRoute(host, app, account string, src registry.Route) registry.Route {
return rt
}
+// wildcardParent returns the parent domain whose per-account wildcard cert
+// would cover host — host exactly two labels under appsDomain
+// (<app>.<acct>.<appsDomain>) → <acct>.<appsDomain> — or "" when no wildcard
+// could cover it (canary depth, a parent apex, a foreign domain, no apps
+// domain configured).
+func wildcardParent(host, appsDomain string) string {
+ if appsDomain == "" {
+ return ""
+ }
+ suffix := "." + strings.ToLower(appsDomain)
+ rest, ok := strings.CutSuffix(strings.ToLower(host), suffix)
+ if !ok {
+ return ""
+ }
+ if labels := strings.Split(rest, "."); len(labels) == 2 {
+ return labels[1] + suffix
+ }
+ return ""
+}
+
+// hostCertExpiry resolves host's cert expiry from the acme cache dir as the
+// LATER of the per-host autocert entry and the covering per-account
+// wildcard's PEM (wildcard-<parent>.pem). Two reasons a bare per-host lookup
+// isn't enough: a wildcard-covered host never gets a per-host entry at all
+// (without the wildcard fallback status would call it certless forever), and
+// a host GRANDFATHERED onto a per-host cert from before its wildcard existed
+// keeps that file on disk forever — autocert stops renewing it once the
+// wildcard starts serving the host, so the stale file's expiry would
+// shadow the wildcard's and status would cry "expiring" over a cert the edge
+// no longer serves. The edge always serves whichever cert is actually valid,
+// so the max of the two is the only truthful answer. 0 = unknown, never a
+// guess.
+func hostCertExpiry(acmeDir, host, appsDomain string) int64 {
+ perHost := certExpiry(filepath.Join(acmeDir, host))
+ wildcard := int64(0)
+ if parent := wildcardParent(host, appsDomain); parent != "" {
+ wildcard = certExpiry(filepath.Join(acmeDir, "wildcard-"+parent+".pem"))
+ }
+ if wildcard > perHost {
+ return wildcard
+ }
+ return perHost
+}
+
// certExpiry reads the autocert DirCache entry at path (the PEM bundle
// cachePut writes: private key block first, then the cert chain) and returns
// the leaf's NotAfter as unix seconds. Anything short of a parseable
diff --git a/internal/domains/reconcile_internal_test.go b/internal/domains/reconcile_internal_test.go
new file mode 100644
index 000000000..3b1db6def
--- /dev/null
+++ b/internal/domains/reconcile_internal_test.go
@@ -0,0 +1,114 @@
+package domains
+
+// Internal-package tests for wildcardParent/hostCertExpiry: reconcile_test.go
+// is package domains_test and cannot reach these unexported helpers, so this
+// file follows slugname_test.go's existing convention of a same-package test
+// file living alongside it.
+
+import (
+ "bytes"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// writeWildcardPEM writes a self-signed cert for name valid until notAfter,
+// in the acme-cache bundle layout certExpiry parses: key block first, then
+// the certificate.
+func writeWildcardPEM(t *testing.T, path, name string, notAfter time.Time) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: name},
+ DNSNames: []string{name},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: notAfter,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf bytes.Buffer
+ if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
+ t.Fatal(err)
+ }
+ if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestWildcardParent(t *testing.T) {
+ cases := []struct {
+ host, appsDomain, want string
+ }{
+ {"kass.abs.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ {"canary.kass.abs.oncarlos.com", "oncarlos.com", ""}, // canary depth: not covered
+ {"abs.oncarlos.com", "oncarlos.com", ""}, // apex of a parent
+ {"custom.example.com", "oncarlos.com", ""}, // foreign domain
+ {"kass.abs.oncarlos.com", "", ""}, // no apps domain configured
+ {"Kass.ABS.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ }
+ for _, c := range cases {
+ if got := wildcardParent(c.host, c.appsDomain); got != c.want {
+ t.Errorf("wildcardParent(%q, %q) = %q, want %q", c.host, c.appsDomain, got, c.want)
+ }
+ }
+}
+
+// A wildcard-covered host has no per-host acme cache entry — its expiry must
+// come from the covering wildcard-<parent>.pem instead of reporting 0
+// (certless) forever.
+func TestCertExpiryWildcardFallback(t *testing.T) {
+ dir := t.TempDir()
+ // certExpiry parses "key PEM block(s) first, then CERTIFICATE" — build a
+ // minimal self-signed cert the same way the edge's wildcard PEM is laid
+ // out. Reuse/adapt the cert-building helper if reconcile_test.go already
+ // has one; otherwise inline ecdsa+x509 self-sign exactly as in
+ // internal/edge/wildcard_test.go's selfSignedWildcard.
+ notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, "wildcard-abs.oncarlos.com.pem"), "*.abs.oncarlos.com", notAfter)
+
+ host := "kass.abs.oncarlos.com"
+ if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() {
+ t.Fatalf("hostCertExpiry = %d, want %d (wildcard fallback)", got, notAfter.Unix())
+ }
+ // A grandfathered per-host file that expires SOONER than the wildcard
+ // (autocert stopped renewing it once the wildcard took over serving the
+ // host): the wildcard's later expiry must win, not the stale file — the
+ // edge serves the wildcard, so status must not shadow that with a cert
+ // nothing serves anymore.
+ stalePerHost := time.Now().Add(20 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, host), host, stalePerHost)
+ if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() {
+ t.Fatalf("hostCertExpiry with stale per-host file = %d, want %d (wildcard's later expiry wins)", got, notAfter.Unix())
+ }
+ // A per-host entry that is LATER than the wildcard still wins.
+ perHost := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, host), host, perHost)
+ if got := hostCertExpiry(dir, host, "oncarlos.com"); got != perHost.Unix() {
+ t.Fatalf("hostCertExpiry with per-host file = %d, want %d", got, perHost.Unix())
+ }
+ // Nothing at all → 0, unknown, never a guess.
+ if got := hostCertExpiry(dir, "kass.zzz.oncarlos.com", "oncarlos.com"); got != 0 {
+ t.Fatalf("hostCertExpiry with no files = %d, want 0", got)
+ }
+}
diff --git a/internal/edge/dns.go b/internal/edge/dns.go
new file mode 100644
index 000000000..89c152ea6
--- /dev/null
+++ b/internal/edge/dns.go
@@ -0,0 +1,268 @@
+package edge
+
+// The DNS-01 provider seam for wildcard certs (wildcard.go) — ported from
+// Eleven's wildcard.go/dnsProvider with one deliberate change: present takes
+// the full record FQDN and the backend discovers the owning zone itself.
+// Eleven's parents were always zones (11ch.at); CARLOS's per-account parents
+// (<acct>.<apps-domain>) are not, so the split into (zone, record name) is
+// the backend's job — DNSimple suffix-walks the fqdn against the zones API,
+// Route53 suffix-matches ListHostedZonesByName (route53dns.go).
+//
+// Backends: dnsimpleDNS (default — oncarlos.com's registrar; needs
+// CARLOS_DNSIMPLE_TOKEN) and route53DNS (CARLOS_DNS_PROVIDER=route53 — the
+// tito.io path; standard AWS credential chain, no token on disk). Selection
+// lives in dnsProviderFromEnv; everything in wildcard.go is provider-blind.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+// dnsProvider publishes the ACME DNS-01 challenge TXT and hands back a
+// closure that removes it — a closure so each backend keeps its teardown
+// detail private (DNSimple deletes by the numeric record id it minted;
+// Route53 re-submits the record set). cleanup runs on its own context (see
+// solveDNS01's defer) so it still fires after the issuance context is dead.
+type dnsProvider interface {
+ present(ctx context.Context, fqdn, value string) (cleanup func(context.Context) error, err error)
+}
+
+// dnsProviderFromEnv selects the DNS-01 backend:
+//
+// - unset / CARLOS_DNS_PROVIDER=dnsimple → DNSimple; needs
+// CARLOS_DNSIMPLE_TOKEN (an account API token). A missing token leaves
+// the wildcard off (per-host issuance continues) — the zero-config state.
+// - CARLOS_DNS_PROVIDER=route53 → Route53, authed by the standard AWS
+// credential chain (env vars / instance IAM role) — no DNS token on disk.
+//
+// Returns (nil, reason) when nothing is configured, so the caller logs the
+// human reason and falls back to per-host issuance rather than failing boot.
+func dnsProviderFromEnv() (dnsProvider, string) {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_DNS_PROVIDER"))) {
+ case "route53":
+ r53, err := newRoute53DNS(context.Background())
+ if err != nil {
+ return nil, fmt.Sprintf("CARLOS_DNS_PROVIDER=route53 but AWS config load failed: %v", err)
+ }
+ return r53, ""
+ case "", "dnsimple":
+ token := os.Getenv("CARLOS_DNSIMPLE_TOKEN")
+ if token == "" {
+ return nil, "CARLOS_DNSIMPLE_TOKEN missing"
+ }
+ return newDNSimpleDNS(token), ""
+ default:
+ return nil, fmt.Sprintf("unknown CARLOS_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("CARLOS_DNS_PROVIDER"))
+ }
+}
+
+// splitDomains parses the CARLOS_WILDCARD_DOMAINS comma list.
+func splitDomains(s string) []string {
+ var out []string
+ for _, d := range strings.Split(s, ",") {
+ if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// ---- DNSimple zone records --------------------------------------------------
+
+type dnsimpleDNS struct {
+ base string
+ token string
+ mu sync.Mutex
+ account string // discovered via /v2/whoami on first use
+ zones map[string]bool // zone name → known-to-exist (positive cache only)
+}
+
+// newDNSimpleDNS builds the default DNS-01 backend from an account API token,
+// honouring the CARLOS_DNSIMPLE_BASE override (kept here so all env reads for
+// this backend live in one place).
+func newDNSimpleDNS(token string) *dnsimpleDNS {
+ return &dnsimpleDNS{
+ base: envOr("CARLOS_DNSIMPLE_BASE", "https://api.dnsimple.com"),
+ token: token,
+ zones: map[string]bool{},
+ }
+}
+
+// present satisfies dnsProvider: discover the zone owning fqdn, create the
+// challenge TXT inside it, and hand back a closure that deletes it by the
+// record id DNSimple returned.
+func (d *dnsimpleDNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
+ zone, name, err := d.zoneFor(ctx, fqdn)
+ if err != nil {
+ return nil, err
+ }
+ id, err := d.createTXT(ctx, zone, name, value)
+ if err != nil {
+ return nil, fmt.Errorf("dnsimple: create TXT: %v", err)
+ }
+ return func(cctx context.Context) error { return d.deleteRecord(cctx, zone, id) }, nil
+}
+
+// zoneFor walks fqdn's parent suffixes longest-first until the zones API
+// knows one, returning (zone, record name within it). Positive results are
+// cached; misses are not (a zone might be added later).
+func (d *dnsimpleDNS) zoneFor(ctx context.Context, fqdn string) (zone, name string, err error) {
+ fqdn = strings.ToLower(strings.TrimSuffix(fqdn, "."))
+ labels := strings.Split(fqdn, ".")
+ for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels
+ cand := strings.Join(labels[i:], ".")
+ ok, err := d.zoneExists(ctx, cand)
+ if err != nil {
+ return "", "", err
+ }
+ if ok {
+ return cand, strings.Join(labels[:i], "."), nil
+ }
+ }
+ return "", "", fmt.Errorf("dnsimple: no zone found for %s", fqdn)
+}
+
+func (d *dnsimpleDNS) zoneExists(ctx context.Context, zone string) (bool, error) {
+ d.mu.Lock()
+ known := d.zones[zone]
+ d.mu.Unlock()
+ if known {
+ return true, nil
+ }
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return false, err
+ }
+ err = d.do(ctx, "GET", "/v2/"+acct+"/zones/"+zone, nil, nil)
+ if err != nil {
+ // 404 (zone genuinely absent) and 403 (token's account doesn't own
+ // this candidate zone — DNSimple returns Forbidden, not NotFound, for
+ // zones outside the authenticated account) both mean "not this zone,
+ // keep walking". A 401 (bad token) is neither and falls through to
+ // the generic error below, so it aborts loudly instead of reading as
+ // "no zone found".
+ if errors.Is(err, errDNSimpleNotFound) || errors.Is(err, errDNSimpleForbidden) {
+ return false, nil
+ }
+ return false, err
+ }
+ d.mu.Lock()
+ d.zones[zone] = true
+ d.mu.Unlock()
+ return true, nil
+}
+
+// errDNSimpleNotFound distinguishes a 404 (zone genuinely absent — keep
+// walking suffixes) from transport or auth failures (stop and report).
+var errDNSimpleNotFound = errors.New("dnsimple: not found")
+
+// errDNSimpleForbidden distinguishes a 403 (zone exists but isn't owned by
+// this account's token — DNSimple's zones API answers Forbidden rather than
+// NotFound for zones outside the account) from other failures. At the
+// zoneExists call site it is treated the same as errDNSimpleNotFound: keep
+// walking suffixes. A 401 (bad token) is deliberately NOT folded into this —
+// it must abort the walk with a clear diagnostic.
+var errDNSimpleForbidden = errors.New("dnsimple: forbidden")
+
+func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error {
+ var rdr io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ rdr = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+d.token)
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf("%s %s: %w", method, path, errDNSimpleNotFound)
+ }
+ if resp.StatusCode == http.StatusForbidden {
+ return fmt.Errorf("%s %s: %w", method, path, errDNSimpleForbidden)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg)))
+ }
+ if out != nil {
+ return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
+ }
+ return nil
+}
+
+func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) {
+ d.mu.Lock()
+ acct := d.account
+ d.mu.Unlock()
+ if acct != "" {
+ return acct, nil
+ }
+ var who struct {
+ Data struct {
+ Account *struct {
+ ID int64 `json:"id"`
+ } `json:"account"`
+ } `json:"data"`
+ }
+ if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil {
+ return "", err
+ }
+ if who.Data.Account == nil {
+ return "", errors.New("whoami: token is not an account token")
+ }
+ d.mu.Lock()
+ d.account = fmt.Sprint(who.Data.Account.ID)
+ acct = d.account
+ d.mu.Unlock()
+ return acct, nil
+}
+
+func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) {
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return 0, err
+ }
+ var created struct {
+ Data struct {
+ ID int64 `json:"id"`
+ } `json:"data"`
+ }
+ err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{
+ "name": name, "type": "TXT", "content": content, "ttl": 60,
+ }, &created)
+ if err != nil {
+ return 0, err
+ }
+ return created.Data.ID, nil
+}
+
+func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error {
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return err
+ }
+ return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil)
+}
diff --git a/internal/edge/dns_test.go b/internal/edge/dns_test.go
new file mode 100644
index 000000000..2cc0e3dc3
--- /dev/null
+++ b/internal/edge/dns_test.go
@@ -0,0 +1,178 @@
+package edge
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+)
+
+func TestSplitDomains(t *testing.T) {
+ cases := []struct {
+ in string
+ want []string
+ }{
+ {"", nil},
+ {"go.tito.io", []string{"go.tito.io"}},
+ {" Go.Tito.IO , dev.oncarlos.com ,", []string{"go.tito.io", "dev.oncarlos.com"}},
+ }
+ for _, c := range cases {
+ if got := splitDomains(c.in); !reflect.DeepEqual(got, c.want) {
+ t.Errorf("splitDomains(%q) = %v, want %v", c.in, got, c.want)
+ }
+ }
+}
+
+func TestDNSProviderSelection(t *testing.T) {
+ t.Setenv("CARLOS_DNS_PROVIDER", "")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("unset env: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
+ if p, reason := dnsProviderFromEnv(); p == nil {
+ t.Fatalf("token set: want dnsimple provider, got nil (%s)", reason)
+ } else if _, ok := p.(*dnsimpleDNS); !ok {
+ t.Fatalf("token set: want *dnsimpleDNS, got %T", p)
+ }
+ t.Setenv("CARLOS_DNS_PROVIDER", "bogus")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("bogus provider: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ // route53 selection is exercised in route53dns_test.go — constructing it
+ // here would hit the AWS credential chain.
+}
+
+// fakeDNSimple is a minimal DNSimple v2 API: whoami, per-zone existence,
+// record create/delete. It records mutations for assertions.
+type fakeDNSimple struct {
+ zones map[string]bool // zone name → exists (200)
+ forbidden map[string]bool // zone name → exists but not owned by this account (403)
+ created []string // "zone|name|content"
+ deleted []int64
+ nextID int64
+}
+
+// handler wires the mux and wraps it in an auth-checking middleware so the
+// Authorization header is asserted on every request — whoami, zone lookup,
+// record create, AND record delete — not just whoami.
+func (f *fakeDNSimple) handler(t *testing.T) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, `{"data":{"account":{"id":42}}}`)
+ })
+ mux.HandleFunc("GET /v2/42/zones/{zone}", func(w http.ResponseWriter, r *http.Request) {
+ zone := r.PathValue("zone")
+ switch {
+ case f.zones[zone]:
+ fmt.Fprintf(w, `{"data":{"name":%q}}`, zone)
+ case f.forbidden[zone]:
+ // A zone that exists but isn't owned by this account's token:
+ // DNSimple answers 403, not 404.
+ http.Error(w, `{"message":"forbidden"}`, 403)
+ default:
+ http.Error(w, `{"message":"not found"}`, 404)
+ }
+ })
+ mux.HandleFunc("POST /v2/42/zones/{zone}/records", func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name, Type, Content string
+ TTL int
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("create record body: %v", err)
+ }
+ if body.Type != "TXT" {
+ t.Errorf("create record type = %q, want TXT", body.Type)
+ }
+ if body.TTL != 60 {
+ t.Errorf("create record ttl = %d, want 60", body.TTL)
+ }
+ f.nextID++
+ f.created = append(f.created, fmt.Sprintf("%s|%s|%s", r.PathValue("zone"), body.Name, body.Content))
+ w.WriteHeader(201)
+ fmt.Fprintf(w, `{"data":{"id":%d}}`, f.nextID)
+ })
+ mux.HandleFunc("DELETE /v2/42/zones/{zone}/records/{id}", func(w http.ResponseWriter, r *http.Request) {
+ var id int64
+ fmt.Sscan(r.PathValue("id"), &id)
+ f.deleted = append(f.deleted, id)
+ w.WriteHeader(204)
+ })
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer tok" {
+ t.Errorf("%s %s auth header = %q", r.Method, r.URL.Path, r.Header.Get("Authorization"))
+ }
+ mux.ServeHTTP(w, r)
+ })
+}
+
+// The per-account adaptation: the zone is discovered by suffix-walking fqdn
+// against the API, so a challenge under abs.oncarlos.com lands in the
+// oncarlos.com zone with the account label in the record NAME.
+func TestDNSimplePresentDiscoversZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{"oncarlos.com": true}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ cleanup, err := d.present(context.Background(), "_acme-challenge.abs.oncarlos.com", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ want := []string{"oncarlos.com|_acme-challenge.abs|txtvalue"}
+ if !reflect.DeepEqual(fake.created, want) {
+ t.Fatalf("created = %v, want %v", fake.created, want)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if !reflect.DeepEqual(fake.deleted, []int64{1}) {
+ t.Fatalf("deleted = %v, want [1]", fake.deleted)
+ }
+}
+
+// A 403 means "this zone exists but your token doesn't own it" — DNSimple's
+// real API answers Forbidden, not NotFound, for zones outside the account.
+// The walk must treat that the same as a 404 (keep walking) rather than
+// aborting, or flagship would never discover oncarlos.com if an intermediate
+// suffix candidate happened to be a zone owned by a different account.
+func TestDNSimplePresentSkipsForbiddenAndNotFoundZones(t *testing.T) {
+ fake := &fakeDNSimple{
+ zones: map[string]bool{"oncarlos.com": true},
+ forbidden: map[string]bool{"x.abs.oncarlos.com": true},
+ // "abs.oncarlos.com" is in neither map, so it 404s (not found).
+ }
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ cleanup, err := d.present(context.Background(), "_acme-challenge.x.abs.oncarlos.com", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ want := []string{"oncarlos.com|_acme-challenge.x.abs|txtvalue"}
+ if !reflect.DeepEqual(fake.created, want) {
+ t.Fatalf("created = %v, want %v", fake.created, want)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+}
+
+func TestDNSimplePresentNoZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ if _, err := d.present(context.Background(), "_acme-challenge.abs.nowhere.example", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index d4835aabd..88308d2d9 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -10,9 +10,10 @@
// additions carried in: -dev (plain-HTTP mode, so multi-instance routing runs
// locally with no DNS/TLS) and the first-observed-fingerprint reload. Dropped
// on port: titogo's Stripe relay, auth/identity, root-domain platformIndex;
-// Eleven's mesh and wildcard DNS-01 certs. New here: TCP upstreams (Route.Addr)
-// so whole-app tenants sit behind the edge without unix-socket support, and
-// the static serving class stub (static.go).
+// Eleven's mesh (its wildcard DNS-01 certs were later ported back —
+// wildcard.go). New here: TCP upstreams (Route.Addr) so whole-app tenants sit
+// behind the edge without unix-socket support, and the static serving class
+// stub (static.go).
package edge
import (
@@ -117,6 +118,11 @@ type Edge struct {
// line per request. Lazily created under purgeLogMu.
purgeLogMu sync.Mutex
purgeLogN map[string]int
+ // reloadHook, when set (OnReload), runs after every successful route
+ // swap — the wildcard manager recomputes its parent set from it. Atomic
+ // because registration happens in RunWithActivator after Watch may
+ // already be reloading.
+ reloadHook atomic.Pointer[func()]
}
// upstream is one served host: the handler for its backend, plus — when the
@@ -318,9 +324,18 @@ func (e *Edge) Reload() error {
if e.act != nil {
e.act.RetireExcept(managed)
}
+ if fn := e.reloadHook.Load(); fn != nil {
+ (*fn)()
+ }
return nil
}
+// OnReload registers fn to run after every successful Reload — after the new
+// route set is swapped in, so fn sees the post-reload table via current().
+func (e *Edge) OnReload(fn func()) {
+ e.reloadHook.Store(&fn)
+}
+
func (e *Edge) current() map[string]upstream {
if p := e.upstreams.Load(); p != nil {
return *p
@@ -328,6 +343,26 @@ func (e *Edge) current() map[string]upstream {
return nil
}
+// currentHosts lists the hosts of PLATFORM-minted routes only
+// (upstream.route.Platform == true) — the wildcard manager's view of the
+// route table for parent derivation. Not every served host: parents are
+// derived from these hosts, and if a custom domain shaped like
+// x.y.<apps-domain> ever entered the route table, it would mint a wildcard
+// parent and burn a Let's Encrypt issuance on something the wildcard was
+// never meant to cover. Platform-minted app routes set Platform:true; static
+// parents (e.g. tito's go.tito.io) come from CARLOS_WILDCARD_DOMAINS via env,
+// so they are unaffected by this filter.
+func (e *Edge) currentHosts() []string {
+ m := e.current()
+ out := make([]string, 0, len(m))
+ for h, up := range m {
+ if up.route.Platform {
+ out = append(out, h)
+ }
+ }
+ return out
+}
+
// Allowed reports whether host is configured — used as the autocert
// HostPolicy, so a certificate is only ever requested for a host we serve.
func (e *Edge) Allowed(host string) bool {
@@ -1042,10 +1077,20 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
return fmt.Errorf("acme: host %q not in registry", host)
},
}
- // Per-host HTTP-01 only for now. When per-tenant subdomains outgrow the
- // Let's Encrypt per-domain rate limit (~50 new certs/week), Eleven's
- // wildcard.go (ACME DNS-01) drops in here, wrapping tlsCfg.GetCertificate.
tlsCfg := m.TLSConfig()
+ // Wildcard certs (wildcard.go, ACME DNS-01): covered names — one label
+ // under a per-account or configured parent — are served the wildcard;
+ // everything else (custom domains, canary hosts, parents not yet
+ // obtained) stays on autocert's per-host HTTP-01 above. nil = not
+ // configured = exactly the pre-wildcard edge.
+ if w := newWildcardFromEnv(e, certCache); w != nil {
+ tlsCfg.GetCertificate = wrapGetCertificate(w, tlsCfg.GetCertificate)
+ e.OnReload(w.refresh)
+ w.refresh()
+ go w.run()
+ log.Printf("edge: wildcard certs on (static %v, apps domain %q)",
+ splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"))
+ }
// The non-ACME :80 handler. Known hosts get the classic redirect to https
// (GET/HEAD only, 302); unknown hosts go to the fallback URL instead of a
diff --git a/internal/edge/route53dns.go b/internal/edge/route53dns.go
new file mode 100644
index 000000000..1a735c230
--- /dev/null
+++ b/internal/edge/route53dns.go
@@ -0,0 +1,182 @@
+package edge
+
+// route53DNS — the Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53), the
+// tito path: obtain a *.go.tito.io wildcard with NO DNS token on disk; auth
+// rides the standard AWS credential chain (env vars or the box IAM role,
+// scoped to the hosted zone — infra/modules/region-host). Ported from
+// Eleven's route53dns.go minus its relocation-only upsertA.
+//
+// Same seam as DNSimple (dnsProvider): UPSERT the challenge TXT, wait for
+// the change to go INSYNC, and hand back a closure that removes it. The
+// zone owning the record fqdn is discovered by walking fqdn's parent
+// suffixes and probing ListHostedZonesByName per candidate (see
+// hostedZoneID for why), so per-account parents (which have no zone of
+// their own) resolve to the registered domain's zone.
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+// route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to
+// reach INSYNC. Route53 usually syncs in well under a minute; on timeout we
+// proceed anyway — the CA runs its own retries and waitForTXT still gates
+// public visibility (best-effort, never fatal).
+const route53InSyncTimeout = 2 * time.Minute
+
+// route53API is the slice of the Route53 client route53DNS calls — an
+// interface so tests substitute a fake and never reach AWS.
+type route53API interface {
+ ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error)
+ ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
+ GetChange(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error)
+}
+
+type route53DNS struct {
+ client route53API
+ mu sync.Mutex
+ zoneIDs map[string]string // record fqdn → hosted-zone id
+}
+
+// newRoute53DNS builds the backend from the default AWS credential chain.
+// Route53 is a global service but the SDK still needs a signing region, so
+// default to us-east-1 when the environment names none — harmless, the
+// endpoint is global either way, and an explicit AWS_REGION still wins.
+func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
+ var opts []func(*awsconfig.LoadOptions) error
+ if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
+ opts = append(opts, awsconfig.WithRegion("us-east-1"))
+ }
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("route53: load aws config: %w", err)
+ }
+ return &route53DNS{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil
+}
+
+// present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC,
+// and return a closure that deletes the exact record set it created. UPSERT
+// (not CREATE) so a TXT stranded by a crashed prior attempt is overwritten
+// instead of colliding. Route53 stores a TXT value as a quoted
+// character-string; the ACME token is a bare base64url string, so wrap it.
+func (r *route53DNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
+ zoneID, err := r.hostedZoneID(ctx, fqdn)
+ if err != nil {
+ return nil, err
+ }
+ rrset := &r53types.ResourceRecordSet{
+ Name: aws.String(dnsFQDN(fqdn)),
+ Type: r53types.RRTypeTxt,
+ TTL: aws.Int64(60),
+ ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}},
+ }
+ out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{
+ Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}},
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("route53: upsert TXT: %w", err)
+ }
+ if out.ChangeInfo != nil {
+ r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id))
+ }
+ cleanup := func(cctx context.Context) error {
+ // DELETE must resubmit the same record set that was created.
+ _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{
+ Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}},
+ },
+ })
+ return err
+ }
+ return cleanup, nil
+}
+
+// hostedZoneID resolves the hosted zone authoritative for fqdn, caching the
+// lookup.
+//
+// ListHostedZonesByName's DNSName is NOT a filter — it is the start point of
+// a listing sorted by zone name with labels reversed (so "com.example" sorts
+// near other ".example.com" zones). A parent zone of fqdn, being shorter,
+// sorts BEFORE fqdn's own reversed key and would therefore be entirely
+// absent from a listing started at fqdn — silently, since the call still
+// succeeds with zero relevant results. Suffix-matching over that listing (as
+// an earlier version of this function did) never sees the parent and always
+// reports "no hosted zone".
+//
+// So instead we walk fqdn's parent suffixes longest-first (most specific
+// first) and issue one ListHostedZonesByName probe per candidate, using the
+// candidate itself as DNSName. A zone equal to the candidate has a reversed
+// key identical to the start point, so it sorts at the head of that
+// specific listing — meaning pagination is not load-bearing here, we only
+// ever need to look at the zones present in the first page. We accept only
+// a returned zone whose Name is an EXACT match for the candidate; the first
+// hit wins, which is automatically the most specific zone since candidates
+// are tried longest-first. Ids come back as "/hostedzone/Z123"; we store the
+// bare id.
+func (r *route53DNS) hostedZoneID(ctx context.Context, fqdn string) (string, error) {
+ r.mu.Lock()
+ if id, ok := r.zoneIDs[fqdn]; ok {
+ r.mu.Unlock()
+ return id, nil
+ }
+ r.mu.Unlock()
+
+ labels := strings.Split(strings.ToLower(strings.TrimSuffix(fqdn, ".")), ".")
+ for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels
+ cand := dnsFQDN(strings.Join(labels[i:], "."))
+ out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(cand)})
+ if err != nil {
+ return "", fmt.Errorf("route53: list hosted zones: %w", err)
+ }
+ for _, hz := range out.HostedZones {
+ if strings.ToLower(aws.ToString(hz.Name)) == cand {
+ id := strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/")
+ r.mu.Lock()
+ r.zoneIDs[fqdn] = id
+ r.mu.Unlock()
+ return id, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("route53: no hosted zone for %s", fqdn)
+}
+
+// waitInSync polls GetChange until the change is INSYNC or the timeout
+// passes. Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA
+// retries).
+func (r *route53DNS) waitInSync(ctx context.Context, changeID string) {
+ if changeID == "" {
+ return
+ }
+ deadline := time.Now().Add(route53InSyncTimeout)
+ for time.Now().Before(deadline) && ctx.Err() == nil {
+ out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)})
+ if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync {
+ return
+ }
+ time.Sleep(3 * time.Second)
+ }
+}
+
+// dnsFQDN appends the root dot Route53 stores names with.
+func dnsFQDN(name string) string {
+ name = strings.ToLower(name)
+ if strings.HasSuffix(name, ".") {
+ return name
+ }
+ return name + "."
+}
diff --git a/internal/edge/route53dns_test.go b/internal/edge/route53dns_test.go
new file mode 100644
index 000000000..5e42ff920
--- /dev/null
+++ b/internal/edge/route53dns_test.go
@@ -0,0 +1,124 @@
+package edge
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+// fakeRoute53 satisfies route53API and records the record-set changes.
+type fakeRoute53 struct {
+ zones map[string]string // zone name (with trailing dot) → id
+ changes []string // "ACTION|name|value"
+}
+
+// route53SortKey mirrors the real ListHostedZonesByName ordering: zone names
+// are compared with their dot-separated labels reversed (so "tito.io."
+// becomes "io.tito"), which groups zones under shared parents together.
+func route53SortKey(name string) string {
+ labels := strings.Split(strings.ToLower(strings.TrimSuffix(name, ".")), ".")
+ rev := make([]string, len(labels))
+ for i, l := range labels {
+ rev[len(labels)-1-i] = l
+ }
+ return strings.Join(rev, ".")
+}
+
+// ListHostedZonesByName models the real API's contract: DNSName is not a
+// filter, it is the start point of a listing sorted by route53SortKey. Only
+// zones whose sort key is >= the start key are returned — a real, and
+// initially unnoticed, source of the Critical bug this fake now exists to
+// catch: a parent zone of the query fqdn sorts BEFORE that fqdn's own key
+// and is silently absent from a listing started at the fqdn itself.
+func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, in *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) {
+ start := route53SortKey(aws.ToString(in.DNSName))
+ type entry struct{ name, id, key string }
+ var entries []entry
+ for name, id := range f.zones {
+ entries = append(entries, entry{name: name, id: id, key: route53SortKey(name)})
+ }
+ sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key })
+ out := &route53.ListHostedZonesByNameOutput{}
+ for _, e := range entries {
+ if e.key >= start {
+ out.HostedZones = append(out.HostedZones, r53types.HostedZone{Name: aws.String(e.name), Id: aws.String("/hostedzone/" + e.id)})
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
+ for _, c := range in.ChangeBatch.Changes {
+ f.changes = append(f.changes, string(c.Action)+"|"+aws.ToString(c.ResourceRecordSet.Name)+"|"+aws.ToString(c.ResourceRecordSet.ResourceRecords[0].Value))
+ }
+ return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("c1"), Status: r53types.ChangeStatusInsync}}, nil
+}
+
+func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) {
+ return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil
+}
+
+// The fqdn seam: the backend must find the zone that is a parent of the
+// record fqdn — tito.io owns _acme-challenge.go.tito.io when go.tito.io has
+// no zone of its own, and the value must be quoted (Route53 stores TXT
+// values as quoted character-strings). "example.com." is an unrelated
+// second zone in the account, present to prove the fake's DNSName
+// start-point filtering (and the real code's exact-match walk) don't get
+// tripped up by an unrelated zone sharing the account.
+//
+// This fixture is also the regression case for the Critical bug: tito.io.'s
+// route53SortKey ("io.tito") sorts BEFORE _acme-challenge.go.tito.io.'s own
+// key ("io.tito.go._acme-challenge"), so the old implementation — which
+// called ListHostedZonesByName with the full fqdn as DNSName and then
+// suffix-matched the results — never saw tito.io. in the listing and always
+// returned "no hosted zone" here.
+func TestRoute53PresentUpsertsAndCleansUp(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "example.com.": "Z9"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+
+ cleanup, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ if len(fake.changes) != 1 || fake.changes[0] != `UPSERT|_acme-challenge.go.tito.io.|"txtvalue"` {
+ t.Fatalf("changes after present = %v", fake.changes)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if len(fake.changes) != 2 || !strings.HasPrefix(fake.changes[1], "DELETE|_acme-challenge.go.tito.io.|") {
+ t.Fatalf("changes after cleanup = %v", fake.changes)
+ }
+ if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z1" {
+ t.Fatalf("cached zone id = %q, want Z1 (parent zone tito.io.)", got)
+ }
+}
+
+// TestRoute53PrefersMostSpecificZone covers the case where the challenge
+// fqdn's immediate parent (go.tito.io.) has its own hosted zone alongside
+// the grandparent (tito.io.) — the more specific zone must win, because the
+// candidate walk tries go.tito.io. before tito.io. and returns on first hit.
+func TestRoute53PrefersMostSpecificZone(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "go.tito.io.": "Z2"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+
+ if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue"); err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z2" {
+ t.Fatalf("cached zone id = %q, want Z2 (most specific parent zone go.tito.io.)", got)
+ }
+}
+
+func TestRoute53NoZone(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"example.org.": "Z9"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+ if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go
new file mode 100644
index 000000000..251c8e0fc
--- /dev/null
+++ b/internal/edge/wildcard.go
@@ -0,0 +1,570 @@
+package edge
+
+// Wildcard TLS (ACME DNS-01) for instant app hostnames — the port of
+// Eleven's wildcard.go this file's TLS wiring always reserved space for
+// (spec: docs/superpowers/specs/2026-08-05-wildcard-certs-design.md).
+//
+// A TLS wildcard matches exactly one label, and app hosts are two labels
+// under the apps domain (<app>.<acct>.<apps-domain>), so there is no single
+// *.<apps-domain> cert. Instead one wildcard per PARENT:
+//
+// - dynamic parents <acct>.<apps-domain>, derived from the live route
+// table under PLATFORM_APPS_DOMAIN — a new account's first route starts
+// issuance immediately, so every later app under it has working TLS the
+// moment its route exists (and CT logs see the account, not app names);
+// - static parents from CARLOS_WILDCARD_DOMAINS (tito: go.tito.io, whose
+// instance hosts are one label deep).
+//
+// Canary hosts (<name>.<app>.<acct>.…, one label too deep) and custom
+// domains stay per-host HTTP-01. The wildcard only changes how a handshake
+// GETS a certificate: routing, HostPolicy and the :80 handler are
+// untouched, and every failure (DNS provider outage, ACME outage,
+// unreadable cache) degrades to exactly the per-host behaviour — never a
+// dead edge.
+
+import (
+ "bytes"
+ "context"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/acme"
+ "golang.org/x/crypto/acme/autocert"
+)
+
+const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"
+
+// wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90
+// days; renewing with 30 left tolerates a month of provider outage.
+const wildcardRenewBefore = 30 * 24 * time.Hour
+
+type wildcardManager struct {
+ // cache is the shared cert cache (certcache.go) — the same backend
+ // autocert uses for per-host certs, so a future pooled-edge S3 backend
+ // shares wildcards too. Keys: wildcardPEMKey / wildcardAccountKeyName.
+ cache autocert.Cache
+ dns dnsProvider
+ directory string
+
+ staticDomains []string
+ appsDomain string
+ hosts func() []string // live route hosts (Edge.currentHosts)
+
+ mu sync.Mutex
+ parents []string // last computed parent set (sorted)
+ certs map[string]*tls.Certificate
+
+ // nextAttempt holds a per-parent backoff floor: run() skips a parent
+ // until this time has passed. Set on obtain failure, cleared on success —
+ // keeps a wake (which fires on ANY parent lacking a usable cert) from
+ // retrying a persistently failing parent more than hourly (see run()).
+ nextAttempt map[string]time.Time
+
+ // wake nudges run() out of its 12h sleep when refresh() finds a parent
+ // with no usable cert (a new account's first route). Capacity 1: a wake
+ // is a level, not a count.
+ wake chan struct{}
+}
+
+func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
+ return &wildcardManager{
+ cache: cache,
+ dns: dns,
+ directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
+ staticDomains: staticDomains,
+ appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
+ hosts: hosts,
+ certs: map[string]*tls.Certificate{},
+ nextAttempt: map[string]time.Time{},
+ wake: make(chan struct{}, 1),
+ }
+}
+
+// parentsFor computes the wildcard parent set: the static domains plus
+// <acct>.<appsDomain> for every route host exactly two labels under
+// appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the
+// apex, and foreign domains contribute nothing. Sorted and deduped so the
+// set is deterministic for logs and tests.
+func parentsFor(static []string, appsDomain string, hosts []string) []string {
+ seen := map[string]bool{}
+ var out []string
+ add := func(p string) {
+ if !seen[p] {
+ seen[p] = true
+ out = append(out, p)
+ }
+ }
+ for _, d := range static {
+ add(strings.ToLower(d))
+ }
+ if appsDomain != "" {
+ suffix := "." + strings.ToLower(appsDomain)
+ for _, h := range hosts {
+ rest, ok := strings.CutSuffix(strings.ToLower(h), suffix)
+ if !ok {
+ continue
+ }
+ if labels := strings.Split(rest, "."); len(labels) == 2 {
+ add(labels[1] + suffix)
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// refresh recomputes the parent set from the live route table (called from
+// the reload hook on every route swap, and once at startup). If any parent
+// lacks a usable cert it wakes run() — that is what makes a NEW account's
+// wildcard start issuing the moment its first route lands, instead of on
+// the next 12h tick. A wake cannot bypass a failing parent's backoff: run()
+// still skips any parent whose nextAttempt hasn't passed, so a wake only
+// advances parents that aren't already backing off.
+func (w *wildcardManager) refresh() {
+ parents := parentsFor(w.staticDomains, w.appsDomain, w.hosts())
+ now := time.Now()
+ w.mu.Lock()
+ w.parents = parents
+ need := false
+ for _, p := range parents {
+ c := w.certs[p]
+ if c == nil || now.Add(wildcardRenewBefore).After(c.Leaf.NotAfter) {
+ need = true
+ break
+ }
+ }
+ w.mu.Unlock()
+ if need {
+ select {
+ case w.wake <- struct{}{}:
+ default:
+ }
+ }
+}
+
+// certFor returns the wildcard cert for serverName, or nil when the name
+// isn't exactly one label under a current parent (a wildcard doesn't cover
+// the apex or deeper levels) or no usable cert is loaded — nil sends the
+// handshake to the per-host autocert path.
+func (w *wildcardManager) certFor(serverName string) *tls.Certificate {
+ sn := strings.ToLower(strings.TrimSuffix(serverName, "."))
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for _, p := range w.parents {
+ sub, ok := strings.CutSuffix(sn, "."+p)
+ if !ok || sub == "" || strings.Contains(sub, ".") {
+ continue
+ }
+ cert := w.certs[p]
+ if cert == nil || time.Now().After(cert.Leaf.NotAfter) {
+ // Never serve a dead cert: a wildcard whose renewal has been
+ // broken past its actual expiry falls back to per-host issuance
+ // (the pre-wildcard behaviour) instead of hard-failing every
+ // handshake under the parent.
+ return nil
+ }
+ return cert
+ }
+ return nil
+}
+
+func (w *wildcardManager) needsObtain(parent string, now time.Time) bool {
+ w.mu.Lock()
+ cert := w.certs[parent]
+ w.mu.Unlock()
+ return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter)
+}
+
+// backingOff reports whether parent is still inside its post-failure backoff
+// window — run() skips it until nextAttempt passes, so a wake (which fires
+// on ANY parent lacking a usable cert) can't retry a persistently failing
+// parent more than hourly.
+func (w *wildcardManager) backingOff(parent string, now time.Time) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ next, ok := w.nextAttempt[parent]
+ return ok && now.Before(next)
+}
+
+// loadCached pulls parent's wildcard from the cert cache into memory,
+// reporting whether a parseable cert was loaded. Called by run() before any
+// obtain attempt, which covers both startup (certs an earlier process
+// obtained) and a pooled-edge future (certs a sibling box obtained).
+func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool {
+ raw, err := w.cache.Get(ctx, wildcardPEMKey(parent))
+ if errors.Is(err, autocert.ErrCacheMiss) {
+ return false
+ }
+ if err == nil {
+ var cert *tls.Certificate
+ if cert, err = parseWildcardPEM(raw); err == nil {
+ if !coversParent(cert, parent) {
+ err = errors.New("cached cert does not cover parent or key mismatch")
+ } else {
+ w.mu.Lock()
+ w.certs[parent] = cert
+ w.mu.Unlock()
+ log.Printf("edge: wildcard *.%s loaded from cache (valid until %s)", parent, cert.Leaf.NotAfter.Format(time.RFC3339))
+ return true
+ }
+ }
+ }
+ log.Printf("edge: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", parent, err)
+ return false
+}
+
+// coversParent reports whether cert is usable as *.<parent>: the leaf must
+// name a one-label host under parent and the private key must match the
+// leaf. A cached entry failing this is treated as unreadable — served
+// certs must never be worse than no cert (per-host issuance covers).
+func coversParent(cert *tls.Certificate, parent string) bool {
+ if cert.Leaf == nil || cert.Leaf.VerifyHostname("acme-probe."+parent) != nil {
+ return false
+ }
+ priv, ok := cert.PrivateKey.(interface{ Public() crypto.PublicKey })
+ if !ok {
+ return false
+ }
+ pub, ok := priv.Public().(interface{ Equal(crypto.PublicKey) bool })
+ return ok && pub.Equal(cert.Leaf.PublicKey)
+}
+
+// wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM —
+// Eleven's file name, so a dir cache written by that lineage reads with no
+// migration, and the keys sit additively beside autocert's per-host entries.
+func wildcardPEMKey(domain string) string {
+ return "wildcard-" + domain + ".pem"
+}
+
+// ---- PEM encode/parse (key + chain in one blob; the cert cache owns
+// placement, permissions, and write atomicity) -------------------------------
+
+func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) {
+ var buf bytes.Buffer
+ der, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+ if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil {
+ return nil, err
+ }
+ for _, c := range cert.Certificate {
+ if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil {
+ return nil, err
+ }
+ }
+ return buf.Bytes(), nil
+}
+
+func parseWildcardPEM(raw []byte) (*tls.Certificate, error) {
+ cert := &tls.Certificate{}
+ for {
+ var block *pem.Block
+ block, raw = pem.Decode(raw)
+ if block == nil {
+ break
+ }
+ switch block.Type {
+ case "EC PRIVATE KEY":
+ key, err := x509.ParseECPrivateKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+ cert.PrivateKey = key
+ case "CERTIFICATE":
+ cert.Certificate = append(cert.Certificate, block.Bytes)
+ }
+ }
+ if cert.PrivateKey == nil || len(cert.Certificate) == 0 {
+ return nil, errors.New("missing key or certificate in PEM")
+ }
+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
+ if err != nil {
+ return nil, err
+ }
+ cert.Leaf = leaf
+ return cert, nil
+}
+
+// run keeps the wildcard certs current: an immediate pass at startup (first
+// issuance / cache load), then every 12h, dropping to hourly retries after a
+// failure — and refresh() wakes it early when a route swap surfaces a parent
+// with no usable cert (a new account).
+func (w *wildcardManager) run() {
+ for {
+ delay := 12 * time.Hour
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ for _, p := range parents {
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ if w.backingOff(p, time.Now()) {
+ // A wake (fired because some OTHER parent needs a cert) does
+ // not shorten this parent's own hourly retry.
+ continue
+ }
+ // Another process (or pool sibling) may have obtained it already.
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ w.loadCached(ctx, p)
+ cancel()
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 10*time.Minute)
+ err := w.obtain(ctx, p)
+ cancel()
+ if err != nil {
+ log.Printf("edge: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", p, err)
+ delay = time.Hour
+ w.mu.Lock()
+ w.nextAttempt[p] = time.Now().Add(time.Hour)
+ w.mu.Unlock()
+ continue
+ }
+ w.mu.Lock()
+ delete(w.nextAttempt, p)
+ cert := w.certs[p]
+ w.mu.Unlock()
+ log.Printf("edge: wildcard *.%s obtained (valid until %s)", p, cert.Leaf.NotAfter.Format(time.RFC3339))
+ }
+ select {
+ case <-time.After(delay):
+ case <-w.wake:
+ }
+ }
+}
+
+// obtain runs one full ACME DNS-01 issuance for *.parent and swaps the
+// result in.
+func (w *wildcardManager) obtain(ctx context.Context, parent string) error {
+ acctKey, err := w.accountKey(ctx)
+ if err != nil {
+ return fmt.Errorf("account key: %v", err)
+ }
+ client := &acme.Client{Key: acctKey, DirectoryURL: w.directory}
+ if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists {
+ return fmt.Errorf("acme register: %v", err)
+ }
+
+ order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + parent}})
+ if err != nil {
+ return fmt.Errorf("authorize order: %v", err)
+ }
+ for _, zurl := range order.AuthzURLs {
+ if err := w.solveDNS01(ctx, client, parent, zurl); err != nil {
+ return err
+ }
+ }
+
+ certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+ csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
+ Subject: pkix.Name{CommonName: "*." + parent},
+ DNSNames: []string{"*." + parent},
+ }, certKey)
+ if err != nil {
+ return fmt.Errorf("csr: %v", err)
+ }
+ ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
+ if err != nil {
+ return fmt.Errorf("finalize: %v", err)
+ }
+ leaf, err := x509.ParseCertificate(ders[0])
+ if err != nil {
+ return fmt.Errorf("parse issued cert: %v", err)
+ }
+ cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf}
+ // Paranoia: never store what certFor would refuse to serve.
+ if !coversParent(cert, parent) {
+ return fmt.Errorf("issued cert does not cover *.%s", parent)
+ }
+ raw, err := encodeWildcardPEM(cert, certKey)
+ if err != nil {
+ return fmt.Errorf("encode: %v", err)
+ }
+ if err := w.cache.Put(ctx, wildcardPEMKey(parent), raw); err != nil {
+ return fmt.Errorf("persist: %v", err)
+ }
+ w.mu.Lock()
+ w.certs[parent] = cert
+ w.mu.Unlock()
+ return nil
+}
+
+// solveDNS01 answers one authorization: publish the challenge TXT record,
+// wait for the CA to see it, and always clean the record up afterwards.
+func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) (retErr error) {
+ authz, err := client.GetAuthorization(ctx, authzURL)
+ if err != nil {
+ return fmt.Errorf("get authorization: %v", err)
+ }
+ if authz.Status == acme.StatusValid {
+ return nil // a recent order already proved this name
+ }
+ // On failure, deactivate the pending authorization rather than abandoning
+ // it — pending authzs count against LE's per-account cap (autocert does
+ // the same "to reduce rate limit quotas"). Best-effort on a fresh context.
+ defer func() {
+ if retErr == nil || authz.Status != acme.StatusPending {
+ return
+ }
+ dctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := client.RevokeAuthorization(dctx, authz.URI); err != nil {
+ log.Printf("edge: wildcard *.%s: deactivate abandoned authorization: %v", parent, err)
+ }
+ }()
+ var chal *acme.Challenge
+ for _, c := range authz.Challenges {
+ if c.Type == "dns-01" {
+ chal = c
+ break
+ }
+ }
+ if chal == nil {
+ return errors.New("CA offered no dns-01 challenge")
+ }
+ txt, err := client.DNS01ChallengeRecord(chal.Token)
+ if err != nil {
+ return err
+ }
+
+ challengeFQDN := "_acme-challenge." + parent
+ cleanup, err := w.dns.present(ctx, challengeFQDN, txt)
+ if err != nil {
+ return fmt.Errorf("publish challenge TXT: %v", err)
+ }
+ // Clean up with a fresh context: the record must not outlive the attempt
+ // even when ctx is already dead (stale challenge TXTs break the NEXT run).
+ defer func() {
+ cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := cleanup(cctx); err != nil {
+ log.Printf("edge: wildcard *.%s: cleanup of challenge TXT failed: %v", parent, err)
+ }
+ }()
+
+ // Best-effort: give the record a moment to reach the authoritative NS
+ // before telling the CA to look. Proceed on timeout — the CA does its own
+ // retries, and a hard failure just comes back through the retry loop.
+ waitForTXT(ctx, challengeFQDN, txt, time.Minute)
+
+ if _, err := client.Accept(ctx, chal); err != nil {
+ return fmt.Errorf("accept challenge: %v", err)
+ }
+ if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil {
+ return fmt.Errorf("authorization: %v", err)
+ }
+ return nil
+}
+
+// wildcardAccountKeyName is the ACME account key's cache key — Eleven's file
+// name, for the same no-migration reason as wildcardPEMKey.
+const wildcardAccountKeyName = "wildcard-account.key"
+
+// accountKey loads (or mints) the ACME account key. Separate from autocert's
+// account on purpose: this flow manages its own registration lifecycle.
+func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error) {
+ raw, err := w.cache.Get(ctx, wildcardAccountKeyName)
+ if err == nil {
+ block, _ := pem.Decode(raw)
+ if block == nil {
+ return nil, fmt.Errorf("%s: not PEM", wildcardAccountKeyName)
+ }
+ return x509.ParseECPrivateKey(block.Bytes)
+ }
+ if !errors.Is(err, autocert.ErrCacheMiss) {
+ // Only a genuine miss mints a key: treating a backend fault as "no
+ // key yet" would register a fresh ACME account on every hiccup. Fail
+ // and let obtain()'s hourly retry come back around.
+ return nil, err
+ }
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+ der, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+ // Crash-safety (temp + rename, 0600) is the cache backend's contract —
+ // the dir backend inherits it from autocert.DirCache.
+ raw = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
+ if err := w.cache.Put(ctx, wildcardAccountKeyName, raw); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+// newWildcardFromEnv builds the manager from the environment, or returns nil
+// when wildcards are off: no CARLOS_WILDCARD_DOMAINS and no
+// PLATFORM_APPS_DOMAIN (nothing to cover — silent, the historical default),
+// or no DNS provider configured (logged: half a config deserves a reason in
+// the journal). nil means RunWithActivator wires nothing and the edge
+// behaves exactly as before this feature existed.
+func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
+ static := splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS"))
+ appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
+ if len(static) == 0 && appsDomain == "" {
+ return nil
+ }
+ dns, reason := dnsProviderFromEnv()
+ if dns == nil {
+ log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason)
+ return nil
+ }
+ return newWildcardManager(static, appsDomain, e.currentHosts, cache, dns)
+}
+
+// wrapGetCertificate serves the wildcard for covered names and hands
+// everything else to inner (autocert's per-host path) — split out so the
+// fall-through contract is testable without a TLS listener.
+func wrapGetCertificate(w *wildcardManager, inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+ return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ // A tls-alpn-01 validation handshake must reach autocert's challenge
+ // cert, never the wildcard — the CA is probing the per-host order the
+ // inner path holds.
+ if len(hello.SupportedProtos) == 1 && hello.SupportedProtos[0] == acme.ALPNProto {
+ return inner(hello)
+ }
+ if c := w.certFor(hello.ServerName); c != nil {
+ return c, nil
+ }
+ return inner(hello)
+ }
+}
+
+// waitForTXT polls DNS until name resolves to a TXT containing value, or the
+// deadline passes (best-effort — see caller).
+func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) && ctx.Err() == nil {
+ if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil {
+ for _, t := range txts {
+ if t == value {
+ return
+ }
+ }
+ }
+ time.Sleep(3 * time.Second)
+ }
+}
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
new file mode 100644
index 000000000..4925d6918
--- /dev/null
+++ b/internal/edge/wildcard_test.go
@@ -0,0 +1,420 @@
+package edge
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "errors"
+ "math/big"
+ "reflect"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// selfSignedWildcard mints a throwaway *.parent cert valid for the given
+// window — enough for matching/renewal/PEM tests, no ACME involved.
+func selfSignedWildcard(t *testing.T, parent string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "*." + parent},
+ DNSNames: []string{"*." + parent},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: notAfter,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ leaf, err := x509.ParseCertificate(der)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key
+}
+
+func TestParentsFor(t *testing.T) {
+ hosts := []string{
+ "kass.abs.oncarlos.com", // app host → parent abs.oncarlos.com
+ "blog.abs.oncarlos.com", // same parent, deduped
+ "echo.bab.oncarlos.com", // second account
+ "canary.kass.abs.oncarlos.com", // canary depth → no wildcard parent
+ "oncarlos.com", // apex → skipped
+ "console.example.com", // foreign domain → skipped
+ "UPPER.CASE.ONCARLOS.COM", // case-normalized → case.oncarlos.com
+ }
+ got := parentsFor([]string{"go.tito.io"}, "oncarlos.com", hosts)
+ want := []string{"abs.oncarlos.com", "bab.oncarlos.com", "case.oncarlos.com", "go.tito.io"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("parentsFor = %v, want %v", got, want)
+ }
+ // No apps domain → static only.
+ if got := parentsFor([]string{"go.tito.io"}, "", hosts); !reflect.DeepEqual(got, []string{"go.tito.io"}) {
+ t.Fatalf("static only = %v", got)
+ }
+ // Nothing at all.
+ if got := parentsFor(nil, "", nil); got != nil {
+ t.Fatalf("empty = %v, want nil", got)
+ }
+}
+
+func TestCertForMatchesExactlyOneLabel(t *testing.T) {
+ w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} },
+ autocert.DirCache(t.TempDir()), nil)
+ w.refresh()
+ cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+
+ cases := []struct {
+ sni string
+ want bool
+ }{
+ {"kass.abs.oncarlos.com", true}, // exactly one label under parent
+ {"NEW.abs.oncarlos.com.", true}, // never-deployed name, case+dot normalized: instant
+ {"abs.oncarlos.com", false}, // the parent itself (wildcard doesn't cover apex)
+ {"x.y.abs.oncarlos.com", false}, // two labels deep
+ {"kass.bab.oncarlos.com", false}, // parent without a loaded cert (bab not in parents)
+ {"slug.go.tito.io", false}, // static parent present but no cert loaded yet
+ }
+ for _, c := range cases {
+ if got := w.certFor(c.sni) != nil; got != c.want {
+ t.Errorf("certFor(%q) served=%v, want %v", c.sni, got, c.want)
+ }
+ }
+
+ // An expired wildcard is never served — fall back to per-host issuance.
+ expired, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(-time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = expired
+ w.mu.Unlock()
+ if w.certFor("kass.abs.oncarlos.com") != nil {
+ t.Fatal("expired wildcard was served")
+ }
+}
+
+func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) {
+ cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ raw, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ back, err := parseWildcardPEM(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !back.Leaf.NotAfter.Equal(cert.Leaf.NotAfter) {
+ t.Fatalf("roundtrip NotAfter = %v, want %v", back.Leaf.NotAfter, cert.Leaf.NotAfter)
+ }
+ if _, err := parseWildcardPEM([]byte("junk")); err == nil {
+ t.Fatal("parseWildcardPEM(junk): want error")
+ }
+
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, autocert.DirCache(t.TempDir()), nil)
+ if !w.needsObtain("abs.oncarlos.com", time.Now()) {
+ t.Fatal("no cert loaded: needsObtain should be true")
+ }
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+ if w.needsObtain("abs.oncarlos.com", time.Now()) {
+ t.Fatal("60 days left: needsObtain should be false")
+ }
+ if !w.needsObtain("abs.oncarlos.com", cert.Leaf.NotAfter.Add(-15*24*time.Hour)) {
+ t.Fatal("15 days left (<30): needsObtain should be true")
+ }
+}
+
+func TestLoadCachedAndRefreshWake(t *testing.T) {
+ dir := t.TempDir()
+ cache := autocert.DirCache(dir)
+ cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ raw, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), raw); err != nil {
+ t.Fatal(err)
+ }
+
+ w := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, cache, nil)
+ if !w.loadCached(context.Background(), "abs.oncarlos.com") {
+ t.Fatal("loadCached: want true for cached parent")
+ }
+ if w.loadCached(context.Background(), "bab.oncarlos.com") {
+ t.Fatal("loadCached: want false for uncached parent")
+ }
+
+ // refresh derives the parent and, since its cert is already loaded and
+ // fresh, does NOT wake the run loop; a parent with no cert does.
+ w.refresh()
+ select {
+ case <-w.wake:
+ t.Fatal("refresh with fresh cert should not wake")
+ default:
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, autocert.DirCache(t.TempDir()), nil)
+ w2.refresh()
+ select {
+ case <-w2.wake:
+ default:
+ t.Fatal("refresh with missing cert should wake the run loop")
+ }
+}
+
+func TestLoadCachedRejectsWrongCoverage(t *testing.T) {
+ // (a) a cert valid for the WRONG parent, stashed under abs.oncarlos.com's
+ // key — loadCached must refuse it and store nothing.
+ wrongParent, wrongParentKey := selfSignedWildcard(t, "bab.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ rawWrongParent, err := encodeWildcardPEM(wrongParent, wrongParentKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cache := autocert.DirCache(t.TempDir())
+ if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawWrongParent); err != nil {
+ t.Fatal(err)
+ }
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, nil)
+ if w.loadCached(context.Background(), "abs.oncarlos.com") {
+ t.Fatal("loadCached: want false for cert covering the wrong parent")
+ }
+ w.mu.Lock()
+ _, stored := w.certs["abs.oncarlos.com"]
+ w.mu.Unlock()
+ if stored {
+ t.Fatal("loadCached: must not store a cert that doesn't cover the parent")
+ }
+
+ // (b) a cert whose stored key does NOT match the leaf's public key.
+ cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawMismatch, err := encodeWildcardPEM(cert, otherKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cache2 := autocert.DirCache(t.TempDir())
+ if err := cache2.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawMismatch); err != nil {
+ t.Fatal(err)
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache2, nil)
+ if w2.loadCached(context.Background(), "abs.oncarlos.com") {
+ t.Fatal("loadCached: want false for key/leaf mismatch")
+ }
+ w2.mu.Lock()
+ _, stored2 := w2.certs["abs.oncarlos.com"]
+ w2.mu.Unlock()
+ if stored2 {
+ t.Fatal("loadCached: must not store a cert whose key doesn't match its leaf")
+ }
+}
+
+// fakeDNS records present/cleanup calls — the seam obtain talks through.
+type fakeDNS struct {
+ presented []string // fqdn|value
+ cleaned []string // fqdn
+ failWith error
+}
+
+func (f *fakeDNS) present(_ context.Context, fqdn, value string) (func(context.Context) error, error) {
+ if f.failWith != nil {
+ return nil, f.failWith
+ }
+ f.presented = append(f.presented, fqdn+"|"+value)
+ return func(context.Context) error {
+ f.cleaned = append(f.cleaned, fqdn)
+ return nil
+ }, nil
+}
+
+func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) {
+ dir := t.TempDir()
+ cache := autocert.DirCache(dir)
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k1, err := w1.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k2, err := w2.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !k1.Equal(k2) {
+ t.Fatal("account key not stable across managers sharing a cache")
+ }
+}
+
+// A cache backend fault must NOT mint a fresh ACME account — only a genuine
+// miss does (a fault treated as a miss would register a new account per
+// hiccup).
+type faultyCache struct{ autocert.Cache }
+
+func (f faultyCache) Get(context.Context, string) ([]byte, error) {
+ return nil, errors.New("backend down")
+}
+
+func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{})
+ if _, err := w.accountKey(context.Background()); err == nil {
+ t.Fatal("backend fault: want error, got a (freshly minted) key")
+ }
+}
+
+// TestPerParentBackoff exercises the backoff bookkeeping directly (no live
+// ACME, no spinning run()'s loop): a parent whose nextAttempt is in the
+// future is skipped, a past nextAttempt clears the skip, and a success
+// (delete) clears the entry entirely.
+func TestPerParentBackoff(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ autocert.DirCache(t.TempDir()), &fakeDNS{})
+
+ if w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("no nextAttempt recorded yet: backingOff should be false")
+ }
+
+ // Simulate the failure path: run() sets nextAttempt an hour out.
+ w.mu.Lock()
+ w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(time.Hour)
+ w.mu.Unlock()
+ if !w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("nextAttempt an hour in the future: backingOff should be true")
+ }
+
+ // Once the window has passed, the same entry no longer backs off.
+ w.mu.Lock()
+ w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(-time.Minute)
+ w.mu.Unlock()
+ if w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("nextAttempt in the past: backingOff should be false")
+ }
+
+ // Simulate the success path: run() deletes the entry outright.
+ w.mu.Lock()
+ w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(time.Hour)
+ delete(w.nextAttempt, "abs.oncarlos.com")
+ w.mu.Unlock()
+ if w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("nextAttempt deleted on success: backingOff should be false")
+ }
+}
+
+func TestWrappedGetCertificateFallsThrough(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} },
+ autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+
+ fallbackHit := ""
+ inner := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ fallbackHit = hello.ServerName
+ return nil, errors.New("inner path")
+ }
+ wrapped := wrapGetCertificate(w, inner)
+
+ got, err := wrapped(&tls.ClientHelloInfo{ServerName: "new.abs.oncarlos.com"})
+ if err != nil || got != cert {
+ t.Fatalf("covered name: got (%v, %v), want the wildcard", got, err)
+ }
+ if fallbackHit != "" {
+ t.Fatalf("covered name reached the inner path (%q)", fallbackHit)
+ }
+ if _, err := wrapped(&tls.ClientHelloInfo{ServerName: "custom.example.com"}); err == nil || fallbackHit != "custom.example.com" {
+ t.Fatalf("uncovered name must fall through to inner; fallbackHit=%q err=%v", fallbackHit, err)
+ }
+
+ // A tls-alpn-01 validation ClientHello for a COVERED name must still
+ // reach autocert's challenge cert, not the wildcard — otherwise that
+ // validation fails every time.
+ fallbackHit = ""
+ if _, err := wrapped(&tls.ClientHelloInfo{ServerName: "new.abs.oncarlos.com", SupportedProtos: []string{"acme-tls/1"}}); err == nil || fallbackHit != "new.abs.oncarlos.com" {
+ t.Fatalf("tls-alpn-01 hello for covered name must reach inner; fallbackHit=%q err=%v", fallbackHit, err)
+ }
+}
+
+func TestNewWildcardFromEnv(t *testing.T) {
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{})
+ cache := autocert.DirCache(t.TempDir())
+
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "")
+ t.Setenv("PLATFORM_APPS_DOMAIN", "")
+ t.Setenv("CARLOS_DNS_PROVIDER", "")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
+ if w := newWildcardFromEnv(e, cache); w != nil {
+ t.Fatal("nothing configured: want nil (per-host only, the historical default)")
+ }
+ // Domains but no provider → off (logged), never a broken half-config.
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ if w := newWildcardFromEnv(e, cache); w != nil {
+ t.Fatal("domains without provider: want nil")
+ }
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
+ w := newWildcardFromEnv(e, cache)
+ if w == nil {
+ t.Fatal("configured: want a manager")
+ }
+ if !reflect.DeepEqual(w.staticDomains, []string{"go.tito.io"}) {
+ t.Fatalf("staticDomains = %v", w.staticDomains)
+ }
+}
+
+func TestOnReloadHookFires(t *testing.T) {
+ reg := openReg(t) // edge_test.go's existing registry fixture
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fired := 0
+ e.OnReload(func() { fired++ })
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if fired != 1 {
+ t.Fatalf("hook fired %d times, want 1", fired)
+ }
+}
+
+// TestCurrentHostsPlatformOnly guards the wildcard-safety filter: only
+// PLATFORM-minted routes (Platform==true) contribute to currentHosts, since
+// parentsFor derives wildcard parents from that list. A custom domain
+// shaped like <x>.<y>.<apps-domain> that somehow landed in the route table
+// (Platform==false) must never mint a wildcard parent and burn an ACME
+// issuance for it — only the routes the domains-reconcile pass itself
+// minted are trusted for that.
+func TestCurrentHostsPlatformOnly(t *testing.T) {
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{
+ "app.acct.oncarlos.com": {route: registry.Route{Host: "app.acct.oncarlos.com", Platform: true}},
+ "custom.example.com": {route: registry.Route{Host: "custom.example.com", Platform: false}},
+ "evil.acct2.oncarlos.com": {route: registry.Route{Host: "evil.acct2.oncarlos.com", Platform: false}},
+ })
+ got := e.currentHosts()
+ want := []string{"app.acct.oncarlos.com"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("currentHosts = %v, want %v", got, want)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index fc7f61b14..53743493c 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -56,6 +56,13 @@
// platform domains minted, no bucket manifests read, no health stamps —
// zero behavior change, same litestream-convention gate as the loops
// above.
+// - CARLOS_WILDCARD_DOMAINS — comma list of static wildcard-cert parent
+// domains for the edge (tito: go.tito.io); per-account parents under
+// PLATFORM_APPS_DOMAIN are derived automatically (edge wildcard.go)
+// - CARLOS_DNS_PROVIDER / CARLOS_DNSIMPLE_TOKEN — DNS-01 backend for
+// wildcard certs: unset/dnsimple needs the token (SSM → unit env),
+// route53 rides the box IAM role; nothing set = wildcards off,
+// per-host HTTP-01 only
// - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks
// compare against. Default: this box's public IPv4 from IMDS
// (best-effort; if neither is knowable, DNS stamping stands down