carlos / platform Public

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>
Paul Campbell 17c3a2cff6ba36058aa7e5bb797ed10fc5a600c7 parent 4472d7f
6 files changed, +295 −5
  • internal/edge/alias_test.go +97 −0
  • internal/edge/edge.go +29 −2
  • internal/edge/wildcard.go +23 −1
  • internal/edge/wildcard_test.go +69 −0
  • internal/registry/registry.go +35 −2
  • internal/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.