edge: claim-derived delegated parents + one-label catch-all request routing
Task 4: wires the last two seams the earlier tasks left open. - newWildcardFromEnv's delegated source reads reg.ListWildcardDomains() instead of nil (a read error leaves the delegated set unchanged for one pass, logged). - refresh()'s delegated filter drops a parent whose composed DNS-01 challenge name would exceed 253 bytes. - Edge.lookupUpstream is the single lookup ServeHTTP and Allowed now share: an exact-match miss folds one label into "*.<parent>", landing on the SAME upstream map entry a catch-all alias row (Task 3) points at — the #151/#152 "identical map value" rule. A wildcard covers exactly one label, mirroring the TLS cert, so two-label hosts and the apex itself both miss. - registry.Fingerprint now covers wildcard_domains (Task 2 deferral): without it, a new delegated parent wouldn't move the edge's 2s Watch-poll digest, so wildcard.go's refresh() (registered via Edge.OnReload, fired from Reload) wouldn't run until the wildcard manager's own 12h tick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+295
−5
internal/edge/alias_test.go+97 −0internal/edge/edge.go+29 −2internal/edge/wildcard.go+23 −1internal/edge/wildcard_test.go+69 −0internal/registry/registry.go+35 −2internal/registry/registry_test.go+42 −0
diff --git a/internal/edge/alias_test.go b/internal/edge/alias_test.go| index 0951bc54f..07429a114 100644 |
| --- a/internal/edge/alias_test.go |
| +++ b/internal/edge/alias_test.go |
| @@ -275,3 +275,100 @@ func TestCurrentHostsSkipsAliases(t *testing.T) { |
| t.Fatalf("currentHosts = %v, must NOT contain an attached hostname", got) |
| } |
| } |
| + |
| +// TestWildcardAliasRequestRouting is Task 4's sub-change 3: a catch-all |
| +// alias row ("*.<host>", the shape Task 3's reconcile pass derives from a |
| +// claim's Catchall+Wildcard capability) must resolve one-label subdomains at |
| +// request time, folding into the SAME upstream map entry the exact primary |
| +// host resolves to (#151/#152's "identical map value" rule — activator |
| +// keying, version header and purge tenancy all read off that one entry). |
| +// Exact hosts always win a collision with the wildcard fallback, a wildcard |
| +// covers exactly one label (mirroring the TLS cert), and the apex must never |
| +// match its own wildcard. |
| +func TestWildcardAliasRequestRouting(t *testing.T) { |
| + backend, seenHost := hostEchoBackend(t, "hello from the primary") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "woodstar-relay.bab.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + |
| + // Entry identity: paul.woodstar.app must resolve to the SAME upstream |
| + // map value as the primary host — same *httputil.ReverseProxy pointer, |
| + // same route row — not a value that merely behaves the same. |
| + m := e.current() |
| + primary, ok := m["woodstar-relay.bab.oncarlos.com"] |
| + if !ok { |
| + t.Fatalf("primary route missing from the upstream table") |
| + } |
| + star, ok := m["*.woodstar.app"] |
| + if !ok { |
| + t.Fatalf("catch-all alias missing from the upstream table") |
| + } |
| + if star.proxy != primary.proxy { |
| + t.Fatalf("*.woodstar.app proxy %p is not the primary's proxy %p", star.proxy, primary.proxy) |
| + } |
| + if star.route.Host != primary.route.Host { |
| + t.Fatalf("*.woodstar.app route.Host = %q, want the primary's %q", star.route.Host, primary.route.Host) |
| + } |
| + |
| + up, ok := e.lookupUpstream("paul.woodstar.app") |
| + if !ok { |
| + t.Fatalf("lookupUpstream(paul.woodstar.app) missed") |
| + } |
| + if up.proxy != primary.proxy { |
| + t.Fatalf("lookupUpstream(paul.woodstar.app) resolved to a different entry than the primary (proxy %p vs %p)", up.proxy, primary.proxy) |
| + } |
| + |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + code, body := getHost(t, srv.URL, "paul.woodstar.app") |
| + if code != http.StatusOK || body != "hello from the primary" { |
| + t.Fatalf("got %d %q, want 200 from the primary's backend", code, body) |
| + } |
| + if *seenHost != "paul.woodstar.app" { |
| + t.Fatalf("backend saw Host %q, want paul.woodstar.app (inbound Host preserved)", *seenHost) |
| + } |
| + |
| + // Two labels deep: a wildcard covers exactly one label, mirroring the |
| + // TLS cert — a.b.woodstar.app must miss. |
| + if _, ok := e.lookupUpstream("a.b.woodstar.app"); ok { |
| + t.Fatalf("lookupUpstream(a.b.woodstar.app) hit, want a miss (two labels — wildcard covers one)") |
| + } |
| + |
| + // The apex itself must never match its own wildcard: "woodstar.app" has |
| + // no dot before "app", so the fallback would (wrongly) probe "*.app", |
| + // never "*.woodstar.app" — this pins that the apex is never routed by |
| + // its own catch-all. |
| + if _, ok := e.lookupUpstream("woodstar.app"); ok { |
| + t.Fatalf("lookupUpstream(woodstar.app) hit, want a miss (apex must not match its own wildcard)") |
| + } |
| + |
| + // An exact alias row wins over the wildcard fallback. |
| + exactBackend, _ := hostEchoBackend(t, "exact wins") |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "other.bab.oncarlos.com", Kind: "service", |
| + Addr: exactBackend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert other: %v", err) |
| + } |
| + upsertAlias(t, reg, "paul.woodstar.app", "other.bab.oncarlos.com") |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("reload: %v", err) |
| + } |
| + up, ok = e.lookupUpstream("paul.woodstar.app") |
| + if !ok { |
| + t.Fatalf("lookupUpstream(paul.woodstar.app) missed after exact alias added") |
| + } |
| + if up.route.Host != "other.bab.oncarlos.com" { |
| + t.Fatalf("lookupUpstream(paul.woodstar.app) route.Host = %q, want the EXACT alias's primary other.bab.oncarlos.com", up.route.Host) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 7bd670660..a64db5841 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -543,10 +543,37 @@ func (e *Edge) Allowed(host string) bool { |
| if e.tunnels != nil && host == e.tunnels.Host() { |
| return true |
| } |
| - _, ok := e.current()[host] |
| + _, ok := e.lookupUpstream(host) |
| return ok |
| } |
| +// lookupUpstream resolves host to its upstream entry — the single lookup |
| +// the serve path (and Allowed's autocert HostPolicy) uses. An exact match |
| +// (a real route, or a plain #151 alias landed in the same map) always wins; |
| +// on a miss, a one-label wildcard key folds in: "*.<parent>" is the shape |
| +// Task 3's reconcile pass derives from a claim's catch-all row, and it lands |
| +// in the SAME map as every other alias (up.alias = true, primary's route + |
| +// proxy verbatim) so this fallback resolves to the primary's own entry — the |
| +// identical map value the #151/#152 agreed rule requires, not a value that |
| +// merely behaves the same. A wildcard matches exactly one label, mirroring |
| +// the TLS cert (wildcard.go's certFor): host[i:] keeps the LEADING dot of |
| +// the FIRST label only, so a two-label host (a.b.woodstar.app) composes |
| +// "*.b.woodstar.app" and misses, and the apex itself (woodstar.app) composes |
| +// "*.app" — never its own "*.woodstar.app" — so it can never match its own |
| +// wildcard. |
| +func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| + m := e.current() |
| + if up, ok := m[host]; ok { |
| + return up, true |
| + } |
| + if i := strings.IndexByte(host, '.'); i > 0 { |
| + if up, ok := m["*"+host[i:]]; ok { |
| + return up, true |
| + } |
| + } |
| + return upstream{}, false |
| +} |
| + |
| func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| host := r.Host |
| if h, _, err := net.SplitHostPort(host); err == nil { |
| @@ -576,7 +603,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| e.tunnels.ServeHTTP(w, r) |
| return |
| } |
| - up, ok := e.current()[host] |
| + up, ok := e.lookupUpstream(host) |
| if !ok { |
| e.unknownHost(w, r) |
| return |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 32ce6e5e1..dcb09200f 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -182,6 +182,10 @@ func (w *wildcardManager) refresh() { |
| if static[d] || (appsSuffix != "" && strings.HasSuffix(d, appsSuffix)) { |
| continue |
| } |
| + if w.delegationZone != "" && len("_acme-challenge."+d+"."+w.delegationZone) > 253 { |
| + log.Printf("edge: delegated wildcard %s dropped: composed challenge name exceeds 253 bytes", d) |
| + continue |
| + } |
| delegated = append(delegated, d) |
| } |
| } |
| @@ -619,7 +623,25 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| log.Printf("edge: CARLOS_ACME_DELEGATION_ZONE %q invalid — delegation off, per-host and direct DNS-01 continue", rawZone) |
| zone = "" |
| } |
| - return newWildcardManager(static, appsDomain, e.currentHosts, nil, zone, cache, dns) |
| + // Delegated source: the claim-derivation pass (Task 3, internal/domains) |
| + // writes wildcard_domains; refresh() reads it fresh on every call, same |
| + // as e.currentHosts above. A read error leaves the delegated set empty |
| + // for this pass only — refresh() runs again on the next reload or 12h |
| + // tick, so a transient registry hiccup never wedges delegation off for |
| + // good, it just costs one pass of visibility (logged below). |
| + delegated := func() []string { |
| + wds, err := e.reg.ListWildcardDomains() |
| + if err != nil { |
| + log.Printf("edge: wildcard domains read failed: %v — delegated set unchanged this pass", err) |
| + return nil |
| + } |
| + out := make([]string, 0, len(wds)) |
| + for _, w := range wds { |
| + out = append(out, w.Host) |
| + } |
| + return out |
| + } |
| + return newWildcardManager(static, appsDomain, e.currentHosts, delegated, zone, cache, dns) |
| } |
| // wrapGetCertificate serves the wildcard for covered names and hands |
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go| index 8ad08d544..9b8baeb64 100644 |
| --- a/internal/edge/wildcard_test.go |
| +++ b/internal/edge/wildcard_test.go |
| @@ -11,6 +11,8 @@ import ( |
| "errors" |
| "math/big" |
| "reflect" |
| + "slices" |
| + "strings" |
| "testing" |
| "time" |
| @@ -541,3 +543,70 @@ func TestNewWildcardFromEnvInvalidDelegationZoneDegrades(t *testing.T) { |
| t.Errorf("delegationZone = %q, want empty (delegation off)", w.delegationZone) |
| } |
| } |
| + |
| +// TestDelegatedSourceReadsRegistry pins Task 4's sub-change 1: newWildcardFromEnv |
| +// must wire its delegated source to a LIVE registry read (Task 2's |
| +// ListWildcardDomains), not the nil #168 left as a placeholder. Two |
| +// wildcard_domains rows, written the way the reconcile pass (Task 3) writes |
| +// them, must both surface as delegated parents after refresh(). |
| +func TestDelegatedSourceReadsRegistry(t *testing.T) { |
| + reg := openReg(t) // edge_test.go's registry fixture |
| + for _, wd := range []registry.WildcardDomain{ |
| + {Host: "woodstar.app", Account: "acct", App: "app", CreatedAt: 1}, |
| + {Host: "example.net", Account: "acct2", App: "app2", CreatedAt: 2}, |
| + } { |
| + if err := reg.UpsertWildcardDomain(wd); err != nil { |
| + t.Fatalf("UpsertWildcardDomain(%s): %v", wd.Host, err) |
| + } |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple") |
| + t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token") |
| + t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "acme.oncarlos.com") |
| + w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| + if w == nil { |
| + t.Fatal("manager should be on") |
| + } |
| + w.refresh() |
| + |
| + w.mu.Lock() |
| + parents := append([]string(nil), w.parents...) |
| + w.mu.Unlock() |
| + for _, host := range []string{"woodstar.app", "example.net"} { |
| + if !slices.Contains(parents, host) { |
| + t.Errorf("parents = %v, want %s (from a wildcard_domains registry row)", parents, host) |
| + } |
| + if !w.isDelegated(host) { |
| + t.Errorf("isDelegated(%s) = false, want true", host) |
| + } |
| + } |
| +} |
| + |
| +// TestRefreshDropsOverlongChallengeNames pins sub-change 2: a delegated host |
| +// whose composed DNS-01 challenge name ("_acme-challenge."+host+"."+zone) |
| +// would exceed the DNS 253-byte FQDN limit must never reach parents — an |
| +// AuthorizeOrder for it would fail at the CA anyway, and dropping it here |
| +// (rather than surfacing that failure through obtain()'s hourly retry loop) |
| +// keeps a single pathological claim from producing an endless retry log line |
| +// for a name that can never succeed. |
| +func TestRefreshDropsOverlongChallengeNames(t *testing.T) { |
| + zone := "acme.oncarlos.com" |
| + // "_acme-challenge." (16) + longHost (262) + "." + zone (17) > 253. |
| + longHost := strings.Repeat("a", 250) + ".example.com" |
| + w := newWildcardManager(nil, "", func() []string { return nil }, |
| + func() []string { return []string{longHost} }, |
| + zone, autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + w.refresh() |
| + |
| + w.mu.Lock() |
| + parents := append([]string(nil), w.parents...) |
| + w.mu.Unlock() |
| + if slices.Contains(parents, longHost) { |
| + t.Fatalf("parents = %v, want %s dropped (composed challenge name exceeds 253 bytes)", parents, longHost) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index df32d490c..ade5e24a8 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1321,8 +1321,9 @@ func (r *Registry) DeleteWildcardDomain(host string) error { |
| return err |
| } |
| -// Fingerprint summarizes the routes, fleets, fleet_boxes and route_aliases |
| -// tables over a fresh connection, for the edge's cheap change poll. It is |
| +// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases and |
| +// wildcard_domains tables over a fresh connection, for the edge's cheap |
| +// change poll. It is |
| // content-accurate: it hashes the actual VALUES of the reload-relevant |
| // columns, not a |
| // length-weighted aggregate, so two DIFFERENT values of the SAME length |
| @@ -1369,6 +1370,17 @@ func (r *Registry) DeleteWildcardDomain(host string) error { |
| // the guarantee true for any future writer that mutates a row into a |
| // byte-identical shape. |
| // |
| +// wildcard_domains (Task 4 carry from Task 2's review) is hashed for the |
| +// same reload-latency reason as fleets/fleet_boxes above: the claim-derived |
| +// delegated wildcard parents (internal/domains' ReconcileAliases, Task 3) |
| +// land here, and wildcard.go's refresh() runs off Edge.OnReload — which |
| +// only fires when Watch's 2s Fingerprint poll actually moves. Without this |
| +// section a new delegated parent (or one going away) would be invisible to |
| +// that poll and wait for the wildcard manager's own 12h tick instead of the |
| +// usual few-second bound every other route/alias mutation gets. All four |
| +// columns are hashed — there is no cadence-stamped column on this table to |
| +// exclude, unlike route_aliases' dns_status/cert_expires_at. |
| +// |
| // The ORDER BY clauses are what make the digest deterministic across polls; |
| // a hypothetical reordering would only cost a redundant reload (idempotent), |
| // never a missed change — the failure mode worth engineering against is the |
| @@ -1471,5 +1483,26 @@ FROM fleet_boxes ORDER BY fleet, label`) |
| return "", err |
| } |
| + // wildcard_domains: see the doc comment above — this is what lets a |
| + // claim-derived delegated wildcard parent reach wildcard.go's refresh() |
| + // on the edge's normal 2s poll instead of waiting on its own 12h tick. |
| + fmt.Fprint(h, "\x1dwildcard_domains\x1e") |
| + wrows, err := db.Query(`SELECT host, account, app, created_at FROM wildcard_domains ORDER BY host`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer wrows.Close() |
| + for wrows.Next() { |
| + var host, account, app string |
| + var createdAt int64 |
| + if err := wrows.Scan(&host, &account, &app, &createdAt); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%d\x1e", host, account, app, createdAt) |
| + } |
| + if err := wrows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil |
| } |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index b06d0734d..0e0ab87ec 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1991,6 +1991,48 @@ func TestFingerprintCoversAliases(t *testing.T) { |
| } |
| } |
| +// TestFingerprintCoversWildcardDomains is Task 4's carry-over from Task 2's |
| +// review: wildcard_domains rows (Task 3's reconcile-derived delegated |
| +// wildcard parents) were entirely absent from Fingerprint, so a new |
| +// delegated parent landing (or leaving) the registry would not move the |
| +// digest the edge's 2s Watch poll keys off — it would sit unrouted until the |
| +// wildcard manager's own 12h tick, rather than reaching wildcard.go's |
| +// refresh() (registered via Edge.OnReload) within the usual 2s bound. This |
| +// pins the fix the same way TestFingerprintCoversAliases pins route_aliases: |
| +// an Upsert and a Delete of a wildcard_domains row must both move the |
| +// fingerprint. |
| +func TestFingerprintCoversWildcardDomains(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + fp1, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertWildcardDomain(WildcardDomain{Host: "woodstar.app", Account: "bab", App: "myapp", CreatedAt: 1}); err != nil { |
| + t.Fatalf("UpsertWildcardDomain: %v", err) |
| + } |
| + fp2, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp2 == fp1 { |
| + t.Fatal("Fingerprint blind to a wildcard_domains UpsertWildcardDomain") |
| + } |
| + |
| + if err := reg.DeleteWildcardDomain("woodstar.app"); err != nil { |
| + t.Fatalf("DeleteWildcardDomain: %v", err) |
| + } |
| + fp3, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp3 == fp2 { |
| + t.Fatal("Fingerprint blind to a DeleteWildcardDomain") |
| + } |
| + if fp3 != fp1 { |
| + t.Fatalf("Fingerprint after add+remove = %s, want back to the original %s", fp3, fp1) |
| + } |
| +} |
| + |
| // TestMigrateIdempotentWithAliases pins migrate()'s additive contract for the |
| // new table: opening an already-migrated registry a second time (CREATE |
| // TABLE IF NOT EXISTS route_aliases) must not error. |