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
16 files changed,
+4259
−7
docs/superpowers/plans/2026-08-05-wildcard-certs.md+2073 −0docs/superpowers/specs/2026-08-05-wildcard-certs-design.md+172 −0go.mod+1 −0go.sum+2 −0infra/modules/region-host/main.tf+42 −0infra/modules/region-host/variables.tf+10 −0internal/domains/reconcile.go+45 −1internal/domains/reconcile_internal_test.go+114 −0internal/edge/dns.go+268 −0internal/edge/dns_test.go+178 −0internal/edge/edge.go+51 −6internal/edge/route53dns.go+182 −0internal/edge/route53dns_test.go+124 −0internal/edge/wildcard.go+570 −0internal/edge/wildcard_test.go+420 −0internal/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 |