edge: fix :80 tunnel-host regression, own wildcard-refusal + pin created_at
Final whole-branch review findings: - httpFallbackHandler gated on lookupUpstream alone, which has no tunnel branch, so http://<tunnel-host>/ regressed to unknownHost instead of the https redirect Allowed's own tunnel special-case implies it should get. - Allowed() now refuses any "*."-prefixed host locally instead of resting on autocert's own IDN rejection of "*" to keep the certable set bounded. - New reconcile test pins wildcard_domains.CreatedAt stability across repeated passes over an unchanged claim set — the skip-upsert-when- unchanged guard is what keeps Fingerprint (which hashes created_at for this table, unlike route_aliases) from forcing an edge reload every poll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+99
−1
internal/domains/aliases_test.go+49 −0internal/edge/alias_test.go+35 −0internal/edge/edge.go+15 −1
diff --git a/internal/domains/aliases_test.go b/internal/domains/aliases_test.go| index fc96b90f1..58f39f12d 100644 |
| --- a/internal/domains/aliases_test.go |
| +++ b/internal/domains/aliases_test.go |
| @@ -266,6 +266,55 @@ func TestReconcileAliasesIdempotent(t *testing.T) { |
| } |
| } |
| +// TestReconcileWildcardCreatedAtStable is the final-review pin: unlike |
| +// route_aliases (UpsertAlias's ON CONFLICT deliberately leaves created_at out |
| +// of its SET list, so the DB itself preserves it), UpsertWildcardDomain DOES |
| +// overwrite created_at on conflict — wildcard_domains.created_at is only kept |
| +// stable by the reconcile pass's own skip-upsert-when-unchanged guard (an |
| +// unchanged host/account/app row is never re-upserted). That guard matters |
| +// because Fingerprint hashes wildcard_domains.created_at (unlike |
| +// route_aliases, which excludes it): a created_at that bumped on every pass |
| +// would move the fingerprint on every 2s poll and force a needless edge |
| +// reload forever, even with a completely unchanged claim set. |
| +func TestReconcileWildcardCreatedAtStable(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "woodstar-relay.bab.oncarlos.com", "bab", "woodstar-relay") |
| + putClaim(t, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", |
| + Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", |
| + Wildcard: true, Catchall: true}) |
| + |
| + if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, err := reg.ListWildcardDomains() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(first) != 1 || first[0].Host != "woodstar.app" { |
| + t.Fatalf("wildcard_domains after first pass = %+v, want one row for woodstar.app", first) |
| + } |
| + firstCreatedAt := first[0].CreatedAt |
| + |
| + // Second pass, later clock, same claim set: CreatedAt must be preserved, |
| + // not bumped — a bump here would move Fingerprint's wildcard_domains |
| + // section every poll and force a reload→refresh loop with nothing |
| + // actually changed. |
| + if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000+3600, 0) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + second, err := reg.ListWildcardDomains() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(second) != 1 || second[0].Host != "woodstar.app" { |
| + t.Fatalf("wildcard_domains after second pass = %+v, want one row for woodstar.app", second) |
| + } |
| + if second[0].CreatedAt != firstCreatedAt { |
| + t.Fatalf("wildcard_domains CreatedAt changed on re-run: first=%d second=%d", firstCreatedAt, second[0].CreatedAt) |
| + } |
| +} |
| + |
| func TestReconcileDerivesWildcardState(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
diff --git a/internal/edge/alias_test.go b/internal/edge/alias_test.go| index 1b342fe3c..638fd13a3 100644 |
| --- a/internal/edge/alias_test.go |
| +++ b/internal/edge/alias_test.go |
| @@ -431,6 +431,12 @@ func TestHostPolicyExcludesWildcardFallback(t *testing.T) { |
| if e.Allowed("random.woodstar.app") { |
| t.Fatalf("Allowed(random.woodstar.app) = true, want false — still no exact row for it") |
| } |
| + // The wildcard label itself must never be certable — even though it is |
| + // the exact map key backing the catch-all fallback, refused locally |
| + // rather than resting on autocert's own IDN rejection of "*". |
| + if e.Allowed("*.woodstar.app") { |
| + t.Fatalf("Allowed(*.woodstar.app) = true, want false — the wildcard label itself must never be certable") |
| + } |
| } |
| // TestHTTPFallbackRedirectsWildcardAlias is the round-2 review fix: the :80 |
| @@ -483,3 +489,32 @@ func TestHTTPFallbackRedirectsWildcardAlias(t *testing.T) { |
| t.Fatalf("nowhere.example.com: status = %d, want 404 (unknownHost, no fallback URL)", rec.Code) |
| } |
| } |
| + |
| +// TestHTTPFallbackRedirectsTunnelHost is the final-review fix: the old inline |
| +// :80 closure gated on Allowed(host), which special-cases the reserved tunnel |
| +// hostname (see Allowed's own e.tunnels check); the extracted handler gates |
| +// on lookupUpstream alone, which has no tunnel branch at all, so plain |
| +// http://<tunnel-host>/ regressed to unknownHost instead of the classic |
| +// redirect into the hub's own TLS handshake on 443. |
| +func TestHTTPFallbackRedirectsTunnelHost(t *testing.T) { |
| + e, err := New(openReg(t), nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableTunnels(NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("reload: %v", err) |
| + } |
| + h := e.httpFallbackHandler() |
| + |
| + req := httptest.NewRequest(http.MethodGet, "http://tunnel.test/register", nil) |
| + req.Host = "tunnel.test" |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusFound { |
| + t.Fatalf("tunnel.test: status = %d, want 302 — the reserved tunnel hostname answers on 443 and must redirect like any other routable host", rec.Code) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "https://tunnel.test/register" { |
| + t.Fatalf("tunnel.test: Location = %q, want https://tunnel.test/register", loc) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 45c369c7d..7d94d86d1 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -536,6 +536,11 @@ func (e *Edge) currentHosts() []string { |
| // 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 { |
| + // Never certable directly: refuse the wildcard label itself rather than |
| + // resting on autocert's own IDN rejection of "*" to keep the bounded set. |
| + if strings.HasPrefix(host, "*.") { |
| + return false |
| + } |
| // The reserved tunnel hostname is not a registry route, but remote hosts |
| // dial it over TLS on this same 443 listener — so it must be Allowed, and |
| // autocert will mint for it. That is intended: it is a hostname the |
| @@ -1061,7 +1066,16 @@ func (e *Edge) httpFallbackHandler() http.Handler { |
| if h, _, err := net.SplitHostPort(host); err == nil { |
| host = h |
| } |
| - if _, ok := e.lookupUpstream(host); !ok { |
| + // The reserved tunnel hostname is answered by the hub itself on 443 |
| + // (ServeHTTP, and Allowed's same check for issuance) but is not a |
| + // registry route, so lookupUpstream alone never sees it — without this, |
| + // plain http://<tunnel-host>/ fell through to unknownHost instead of |
| + // redirecting like every other routable host. |
| + routable := e.tunnels != nil && host == e.tunnels.Host() |
| + if !routable { |
| + _, routable = e.lookupUpstream(host) |
| + } |
| + if !routable { |
| e.unknownHost(w, r) |
| return |
| } |