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")) +}