From 835fe398da526a8a0b2ce80570e391f9416850af Mon Sep 17 00:00:00 2001 From: Bretton Date: Fri, 21 Aug 2026 19:59:41 -0700 Subject: [PATCH] =?UTF-8?q?fix(hostrouter):=20serve=20the=20TLS-ask=20gate?= =?UTF-8?q?=20host-agnostically=20=E2=80=94=20421s=20were=20denying=20ever?= =?UTF-8?q?y=20handle=20cert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caddy's on-demand TLS ask calls http://tidepool:80/.well-known/tidepool-tls-ask, addressing this process by its container DNS name — so the request carries Host "tidepool", which the v2 host router recognizes as neither the bridge surface nor the user origin and refuses with 421. Every new bridged-handle certificate issuance and every renewal has been denied since the v2 deploy (observed in production 2026-08-22: 6 sampled refusals in 47h; cached certs masked the breakage). DEPLOY.md §4 claimed the gate was unaffected; it wasn't. The fix: HostRouterOptions.HostAgnosticPaths — paths served by the service handler BEFORE any Host judgment. main.go lists exactly one, the ask gate, via the new identity.TLSAskPath constant shared with the route registration so the two can't drift. The exemption is per-path, not per-host: an unrecognized Host asking for anything else keeps the 421 posture, and the ask endpoint itself is a read-only "is this domain a handle I'd serve" answer that was globally reachable pre-v2. Repro test first: TestHostRouter_HostAgnosticPaths exercises Caddy's exact request shape (docker-DNS Host + ask path -> 200 from the service surface), plus the negatives (other paths on that Host still 421, recognized hosts unchanged). Red before the fix, green after; mutation bite-check (exemption disabled) reddens it; personas/identity/cmd/tidepool suites pass DB-backed. Co-Authored-By: Claude Fable 5 --- DEPLOY.md | 10 ++++-- cmd/tidepool/main.go | 7 +++- internal/identity/handles.go | 7 ++++ internal/personas/hostrouter.go | 20 +++++++++++ internal/personas/hostrouter_test.go | 51 ++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 663fea4..b90861b 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -443,8 +443,14 @@ actors are minted lazily on first federating interaction. `tdpl.io`, the per-instance wildcard blocks, and the on-demand catch-all are untouched by v2. The `on_demand_tls ask` gate still points at -`http://tidepool:80/.well-known/tidepool-tls-ask`, which is served on the -bridge Host (`cmd/tidepool/main.go:165`) and is unaffected by anything above. +`http://tidepool:80/.well-known/tidepool-tls-ask` — but note that URL names +the CONTAINER, so the request arrives carrying `Host: tidepool`, a name the +v2 host router recognizes as neither surface. The gate only keeps working +because the router serves `identity.TLSAskPath` host-agnostically, before any +Host judgment (`HostAgnosticPaths`, `internal/personas/hostrouter.go`). An +earlier revision of this section claimed the gate was "unaffected" by the +host router; it was not — the 421s it produced denied issuance for every new +handle cert and renewal until the exemption landed (2026-08-22). --- diff --git a/cmd/tidepool/main.go b/cmd/tidepool/main.go index 7ad8719..14da06c 100644 --- a/cmd/tidepool/main.go +++ b/cmd/tidepool/main.go @@ -276,7 +276,7 @@ func run(logger *slog.Logger) error { // Cert-issuance gate for TLS-terminating proxies with on-demand // issuance (production Caddy asks here before requesting a cert for a // bridged-handle subdomain; see docker-compose.prod.yml header). - router.Get("/.well-known/tidepool-tls-ask", identity.TLSAskHandler(resolver, logger)) + router.Get(identity.TLSAskPath, identity.TLSAskHandler(resolver, logger)) // The bridge's own did:web document (404 when a non-did:web // BRIDGE_SERVICE_DID is provisioned). router.Get("/.well-known/did.json", identity.DIDWebHandler(serviceDID, cfg.BridgeHostname)) @@ -695,6 +695,11 @@ func run(logger *slog.Logger) error { UserHost: userHost.Host, UserHandler: personasService, DevFallthrough: cfg.APHostFallthroughDev, + // Caddy's on-demand TLS ask reaches this process by its container + // DNS name (the compose ask URL is http://tidepool:80/...), so the + // cert gate must answer under a Host neither surface claims — + // refusing it denies issuance for every bridged handle. + HostAgnosticPaths: []string{identity.TLSAskPath}, }) if err != nil { return fmt.Errorf("host router: %w", err) diff --git a/internal/identity/handles.go b/internal/identity/handles.go index d66db17..646c801 100644 --- a/internal/identity/handles.go +++ b/internal/identity/handles.go @@ -130,6 +130,13 @@ func WellKnownDIDHandler(resolver Resolver, logger *slog.Logger) http.HandlerFun } } +// TLSAskPath is where TLSAskHandler is served. It is a named constant +// because the path is load-bearing in two places that must agree: the route +// registration, and the host router's host-agnostic exemption — the proxy's +// `ask` URL addresses this process by its container DNS name, so the request +// arrives under a Host the router would otherwise refuse. +const TLSAskPath = "/.well-known/tidepool-tls-ask" + // TLSAskHandler gates on-demand TLS certificate issuance for the bridged // handle space: GET /.well-known/tidepool-tls-ask?domain= answers // 200 iff the hostname is a handle the bridge would serve (resolvable, not diff --git a/internal/personas/hostrouter.go b/internal/personas/hostrouter.go index c680fad..d6c9329 100644 --- a/internal/personas/hostrouter.go +++ b/internal/personas/hostrouter.go @@ -30,6 +30,14 @@ type HostRouterOptions struct { // refusing them. A laptop is reached by IP, tunnel hostname, or whatever // the tunnel minted this morning; a production deployment is not. DevFallthrough bool + // HostAgnosticPaths are served by ServiceHandler regardless of Host. + // This exists for infrastructure the edge proxy addresses by the + // container's own DNS name rather than a public hostname: Caddy's + // on-demand TLS `ask` URL is http://tidepool:80/..., so its requests + // carry Host "tidepool" — a name neither surface claims — and judging + // them by Host silently denies certificate issuance for every bridged + // handle. Match is on the exact request path. + HostAgnosticPaths []string // Logger receives a sampled warning for refused Hosts. Nil uses // slog.Default(). Logger *slog.Logger @@ -65,12 +73,17 @@ func NewHostRouter(opts HostRouterOptions) (http.Handler, error) { if logger == nil { logger = slog.Default() } + hostAgnostic := make(map[string]bool, len(opts.HostAgnosticPaths)) + for _, path := range opts.HostAgnosticPaths { + hostAgnostic[path] = true + } return &hostRouter{ serviceHost: normalizeHost(opts.ServiceHost), serviceHandler: opts.ServiceHandler, userHost: normalizeHost(opts.UserHost), userHandler: opts.UserHandler, devFallthrough: opts.DevFallthrough, + hostAgnostic: hostAgnostic, logger: logger, refusalLog: ratelimit.NewSampler(misdirectedLogInterval), }, nil @@ -82,11 +95,18 @@ type hostRouter struct { userHost string userHandler http.Handler devFallthrough bool + hostAgnostic map[string]bool logger *slog.Logger refusalLog *ratelimit.Sampler } func (h *hostRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Host-agnostic paths are judged before the Host is: they belong to the + // service surface under ANY name, including ones neither bucket claims. + if h.hostAgnostic[r.URL.Path] { + h.serviceHandler.ServeHTTP(w, r) + return + } host := normalizeHost(r.Host) switch { case host == h.userHost && h.isServiceHost(host): diff --git a/internal/personas/hostrouter_test.go b/internal/personas/hostrouter_test.go index 7df25e3..fb0f7b1 100644 --- a/internal/personas/hostrouter_test.go +++ b/internal/personas/hostrouter_test.go @@ -442,3 +442,54 @@ func TestHostRouter_ComposedFallbackOnlyOn404(t *testing.T) { }) } } + +// TestHostRouter_HostAgnosticPaths: Caddy's on-demand TLS ask reaches this +// process addressed by its DOCKER DNS NAME — the compose `ask` URL is +// http://tidepool:80/.well-known/tidepool-tls-ask, so the request carries +// Host "tidepool", a name that is neither configured surface. The ask gate is +// infrastructure: it must answer for whatever name the edge proxy happens to +// reach the container by, because refusing it with 421 silently breaks every +// NEW handle-cert issuance and every renewal (observed in production +// 2026-08-22). Paths listed as host-agnostic therefore go to the service +// handler before any Host judgment; every other path on the same unrecognized +// Host keeps the 421 posture. +func TestHostRouter_HostAgnosticPaths(t *testing.T) { + const askPath = "/.well-known/tidepool-tls-ask" + + service := newMarker("service") + user := newMarker("user") + router, err := NewHostRouter(HostRouterOptions{ + ServiceHost: serviceHost, + ServiceHandler: service, + UserHost: userHost, + UserHandler: user, + HostAgnosticPaths: []string{askPath}, + }) + require.NoError(t, err) + + for _, host := range []string{ + "tidepool", // the compose service name — production's actual ask Host + "tidepool:80", // with the port the ask URL names + "anything.example", // any other name the proxy might be told to use + } { + t.Run("ask via "+host, func(t *testing.T) { + rec := routeHost(t, router, "http", host, askPath+"?domain=alice.lemmy-world."+serviceHost) + assert.Equal(t, http.StatusOK, rec.Code, + "the TLS ask must be served regardless of Host; a 421 here denies certificate issuance") + assert.Equal(t, "service", rec.Header().Get("X-Handler"), + "the ask gate lives on the service surface") + }) + } + + // The exemption is the PATH, not the Host: the same unrecognized name + // asking for anything else is still refused. + rec := routeHost(t, router, "http", "tidepool", "/ap/inbox") + assert.Equal(t, http.StatusMisdirectedRequest, rec.Code, + "an unrecognized Host must stay refused for every path not listed as host-agnostic") + + // And on the recognized surfaces nothing changes: the ask path was + // already served for the bridge hostname. + rec = routeHost(t, router, "https", serviceHost, askPath) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "service", rec.Header().Get("X-Handler")) +} -- 2.51.2