@marcel91200 / caddy Mirror · read-only / commits / c7b36dc5
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 03:30 UTC

caddyhttp, reverseproxy: experimental WebTransport passthrough (#7669)

* caddy: pass ListenQUIC options as a struct Collapse the TLS config, packet-conn wrappers, and Allow0RTT arguments into ListenQUICOptions so the signature matches Listen's ctx/portOffset/ ListenConfig prefix. Zero-value opts keep the historical default of allowing 0-RTT. The API remains experimental. * caddyhttp: advertise WebTransport in HTTP/3 SETTINGS Add github.com/quic-go/webtransport-go dep (built on quic-go, same maintainers) and call webtransport.ConfigureHTTP3Server on the http3.Server. This advertises WebTransport enablement in SETTINGS, enables HTTP/3 DATAGRAMs, and stashes the *quic.Conn in each request's context — a prerequisite for a later WebTransport-aware handler or reverse-proxy transport to call webtransport.Server.Upgrade. Also enable QUIC stream reset partial delivery, required by webtransport-go. No user-visible behavior change: clients that don't speak WebTransport ignore the extra SETTINGS, and no handler yet calls Upgrade. Extract the http3.Server construction into buildHTTP3Server so the SETTINGS assertions can be unit-tested without a live UDP listener. * caddyhttp: add UnwrapResponseWriterAs helper Go's type assertion `x.(T)` does not follow Unwrap() http.ResponseWriter chains. Caddy wraps the writer multiple times (logging recorder, intercept, encode, etc.), so code that needs interfaces implemented only by the raw writer owned by the HTTP server — for example the http3.Settingser/HTTPStreamer interfaces that webtransport.Server.Upgrade type-asserts — cannot see through those wrappers. UnwrapResponseWriterAs walks the Unwrap() chain and returns the first writer that satisfies the requested interface (or the zero value if none do). Mirrors the traversal http.ResponseController performs internally. Used by upcoming WebTransport handler and reverse-proxy transport. * caddyhttp: add terminating WebTransport handler Introduces http.handlers.webtransport, an EXPERIMENTAL handler that terminates an incoming WebTransport session on top of Caddy's HTTP/3 server and echoes bytes on each bidirectional stream. Primary use case is as a test upstream for the forthcoming WebTransport reverse-proxy transport; it also serves as the minimal proof that the server-side WebTransport wiring works end-to-end. Plumbing changes: * caddyhttp.Server gains a *webtransport.Server field alongside h3server. It's built in buildWebTransportServer(), wrapping the existing http3.Server. Exposed via WebTransportServer() any on the Server, so the caddyhttp public API does not name the upstream webtransport-go type (per AGENTS.md). * serveHTTP3 now runs a custom accept loop (serveH3AcceptLoop) that dispatches each accepted QUIC connection to webtransport.Server.ServeQUICConn instead of http3.Server.ServeListener. The WebTransport server transparently forwards non-WT streams to the underlying http3 request handler (cost: one varint peek per stream), so behavior for non-WT clients is unchanged. * ListenQUIC enables EnableDatagrams and EnableStreamResetPartialDelivery on the QUIC listener config. These are capability bits negotiated during the QUIC handshake and are prerequisites for any WebTransport session; they do not force usage so non-WT H3 traffic is unaffected. * Stop path closes wtServer after h3server.Shutdown to clean up any remaining WebTransport session state. The handler uses caddyhttp.UnwrapResponseWriterAs to reach the naked http3.Settingser/HTTPStreamer writer through Caddy's wrapping chain before calling webtransport.Server.Upgrade. Includes unit tests for request-shape detection plus an integration test (caddytest/integration/webtransport_test.go) that spins up a Caddy HTTP/3 server with the handler, dials it with a real webtransport.Dialer, and asserts end-to-end bidirectional-stream echo. * reverseproxy: add WebTransport upstream dialer helper dialUpstreamWebTransport is a thin wrapper around webtransport.Dialer.Dial that sets the QUIC config flags WebTransport requires (EnableDatagrams, EnableStreamResetPartialDelivery) and forwards request headers on the Extended CONNECT. Intended as an internal building block for the upcoming WebTransport reverse-proxy transport; not yet wired into ServeHTTP. Unit-tested against an in-process webtransport.Server with a freshly minted self-signed certificate. Covers: successful dial, header forwarding, and connection-refused against an unbound port. * reverseproxy: add WebTransport session pump runWebTransportPump bridges two WebTransport sessions so every bidirectional stream, unidirectional stream, and datagram opened on one side is mirrored on the other. Uses six goroutines (bidi both ways, uni both ways, datagrams both ways) and blocks until both sessions end. Close propagation: when either session ends, the peer is closed via CloseWithError. The code/message are read from the closing session's stored close state (by probing AcceptStream with a short timeout), since Receive{Datagram,UniStream} return the underlying stream error rather than the SessionError and can win the propagation race. Close propagation is best-effort for client-initiated close through a Dialer-dedicated QUIC conn: webtransport-go tears down the QUIC connection immediately after CloseWithError, so the upstream may observe a QUIC ApplicationError before the WT_CLOSE_SESSION capsule is parsed. The pump still closes the peer session; only the specific error code may not survive. Not yet wired into ServeHTTP. Tests: topology of client -> frontend -> upstream where frontend runs the pump. Exercises bidi both ways, uni client-to-upstream, datagram round-trip, CloseWithError propagation both ways, and a basic goroutine-leak check. * reverseproxy: wire WebTransport pump into ServeHTTP Extends the http reverse-proxy transport with a webtransport boolean that opts the upstream into WebTransport passthrough. Must be combined with versions: ["3"]; WebTransport rides on HTTP/3 exclusively. When enabled, Handler.ServeHTTP detects Extended CONNECT with :protocol=webtransport early — before any of the normal round-trip machinery — and branches to serveWebTransport, which: 1. Pulls the *webtransport.Server off caddyhttp.Server (via WebTransportServer()) and errors out cleanly if HTTP/3 isn't enabled on the frontend. 2. Picks a single upstream through the configured load-balancer. No retries: a failed dial closes the client session and returns. 3. Walks the response-writer Unwrap() chain to reach the raw http3 writer and calls webtransport.Server.Upgrade to terminate the incoming session. 4. Uses dialUpstreamWebTransport to open a session to the selected upstream, forwarding request headers on the Extended CONNECT. 5. Runs runWebTransportPump between the two sessions and blocks until both close. The transport's wtTLSConfig is built at Provision time from the existing TLS config (same path h3Transport already uses) and reused for every session. Tests: adds TestWebTransport_ReverseProxyEndToEnd which spins up a single Caddy instance with two HTTP/3 servers — one proxy on :9443, one terminating echo upstream on :9444 — and drives a real webtransport.Dialer through the proxy to assert end-to-end bidirectional-stream echo. * reverseproxy: add webtransport Caddyfile subdirective Adds a `webtransport` subdirective to the `transport http {}` block of reverse_proxy that sets the new WebTransport bool on the transport. Takes no arguments; exclusivity with versions 3 is enforced at Provision time so parse order doesn't matter. Example: reverse_proxy https://backend:9443 { transport http { versions 3 webtransport tls_insecure_skip_verify } } Includes a Caddyfile-to-JSON adapt test round-tripping the new subdirective. * reverseproxy: apply standard request preparation to WebTransport CONNECT The WebTransport proxy path previously bypassed the request-preparation pipeline that normal reverse-proxy traffic runs through. Reuse it so `header_up`, `X-Forwarded-For`/`Host`/`Proto`, `Via`, `Rewrite`, the `{http.reverse_proxy.upstream.*}` placeholders, dynamic upstreams, `countFailure`, and the `{http.reverse_proxy.duration{_ms}}` timing placeholder all behave the same as on the regular path. Retries, `handle_response`, and response-header ops are intentionally not run here — a WebTransport session has no HTTP response body to post-process and is not idempotent. Integration test exercises the header-forwarding contract end-to-end through a standalone (non-Caddy) WebTransport upstream so the forwarded Extended CONNECT can be inspected. * reverseproxy: dial WebTransport upstream before upgrading client Reorder serveWebTransport so the upstream is dialed first. If the upstream is unreachable or refuses the CONNECT, a proper 5xx is returned from the handler — the client's Dial() surfaces the real status instead of a successful upgrade followed by an opaque session close. Also apply `h.Headers.Response` (gated by `Require`, if configured) against the upstream response status/headers; the ops run on the client-visible response headers, which webtransport.Server.Upgrade flushes with the 200 OK. If the client-side upgrade fails after the upstream dial succeeded, close the upstream session cleanly. Integration test drives a dial to an unbound loopback port and asserts the client sees a 5xx status instead of a bare session close. * reverseproxy: track WebTransport sessions in upstream in-flight counters Bracket the pump's lifetime with Host.countRequest(±1) and incInFlightRequest/decInFlightRequest so WT sessions participate in the same accounting as the normal proxy path: - MaxRequests gating (Upstream.Full) now blocks WT sessions past the cap, instead of silently failing open. - LeastConn / FirstAvailable selection sees WT load, instead of seeing busy upstreams as idle. - Admin /reverse_proxy/upstreams reports WT sessions under num_requests. Integration test holds an upstream session open via a standalone WT server, polls the admin API to assert num_requests increments during the session and drops back to 0 after close. * reverseproxy: extract shared upstream-selection helpers The WebTransport proxy path in serveWebTransport duplicated the dynamic-upstream-fallback block and the {http.reverse_proxy.upstream.*} replacer-variable block from proxyLoopIteration. Francis flagged this as a maintenance burden in review of #7669. Extract two helpers: * resolveUpstreams(r) returns the candidate upstream set — dynamic when configured (with provisioning + fallback-on-error), static otherwise. Caller runs the LB selection policy, since the two call sites diverge on how selection failure is reported (retry loop vs. fast 502 for long-lived WT sessions). * setUpstreamReplacerVars(repl, up, di) publishes the seven placeholders describing the selected upstream. Both are used by proxyLoopIteration and serveWebTransport with identical semantics to the inlined code they replace. No behavior change for either path. * reverseproxy: inline WebTransport protocol const and writer interface Francis pointed out in review of #7669 that importing the whole modules/caddyhttp/webtransport package solely to pull in one constant and one interface wasn't worthwhile. Move both into webtransport_transport.go as unexported identifiers (webtransportProtocol, webtransportWriter). This removes reverseproxy's dependency on the caddywt package and clears the way for moving the echo handler itself out of the production module tree. No behavior change. * caddyhttp: move WebTransport echo handler to integration tests Francis pointed out in review of #7669 that the echo handler — which exists solely as a test upstream for the WebTransport reverse-proxy tests — should not be a full-fledged module registered in every Caddy binary. Mirroring the mockdns_test.go pattern, move it into a _test.go file under caddytest/integration/. The module ID http.handlers.webtransport is now registered only when the integration test binary is built, which is when caddytest/integration/webtransport_test.go references it by ID string in its JSON configs. Production Caddy builds no longer include it. Changes: * New file: caddytest/integration/webtransport_echo_test.go — contains the WebTransportEcho handler, its types and interface guards, the isWebTransportEchoUpgrade helper, and the unit tests that used to live in the deleted package's handler_test.go. * Deleted: modules/caddyhttp/webtransport/ (handler.go + handler_test.go). * Removed the blank import from modules/caddyhttp/standard/imports.go. The Protocol const and Writer interface that this package used to export were inlined into reverseproxy's own files in a preceding commit, so nothing else depends on the deleted package. * caddyhttp, reverseproxy: gate WebTransport behind enable_webtransport server flag steadytao raised an architectural concern in review of #7669: the PR put experimental WebTransport handling directly into Caddy's core HTTP/3 accept path, so every HTTP/3 deployment paid for the feature whether or not they used it. Collapse the enablement surface to a single server-level opt-in that matches Caddy's existing precedent for protocol-level features (`protocols`, `allow_0rtt`, `enable_full_duplex`), and detect the request shape at the handler the same way `reverse_proxy` detects a WebSocket upgrade today — no per-handler config flag. Core HTTP/3 path changes (modules/caddyhttp/server.go): * New `EnableWebTransport bool` field on Server, marked EXPERIMENTAL. * buildHTTP3Server now only calls webtransport.ConfigureHTTP3Server and sets EnableStreamResetPartialDelivery when the flag is true. When false, the constructed http3.Server is bit-for-bit identical to the pre-WebTransport implementation. * wtServer is constructed only when the flag is true. * serveH3AcceptLoop falls back to http3.Server.ServeListener when the flag is false — no varint peek, no per-connection dispatch. Caddyfile wiring (caddyconfig/httpcaddyfile/serveroptions.go): * New `enable_webtransport` global server option, modeled on `enable_full_duplex`. Reverse-proxy simplifications (modules/caddyhttp/reverseproxy/): * Removed HTTPTransport.WebTransport field and its Provision-time exclusivity check (no longer needed; H3 is validated separately). * Removed the `webtransport` Caddyfile subdirective under `transport http { }` — this neutralizes the prior commit that introduced it. * Removed Handler.webtransportEnabled cache. ServeHTTP now branches on isWebTransportExtendedConnect(r) alone, matching how the WebSocket upgrade branch works. * serveWebTransport gains fail-fast guards with clear errors when the parent server has enable_webtransport=false or when the handler's transport does not include HTTP/3. Tests: * Existing TestServer_BuildHTTP3ServerEnablesWebTransport now sets EnableWebTransport=true explicitly; new TestServer_BuildHTTP3ServerWithoutWebTransport locks in the regression guard that flag-off produces the pre-PR http3.Server. * Integration tests updated: enable_webtransport: true added to every H3 server block; "webtransport": true dropped from the reverse_proxy transport JSON (auto-detected now). * Caddyfile adapt test for the deleted `webtransport` subdirective is removed; `enable_webtransport` is added to the existing global_server_options_single adapt test alongside its peers. No runtime behavior change when enable_webtransport is false. Diff against master on the core HTTP/3 path is effectively zero in that configuration. * caddyhttp: micro-benchmark HTTP/3 server construction with/without WebTransport Adds a hermetic pair of benchmarks on buildHTTP3Server to provide quantitative evidence for the claim that deployments with enable_webtransport=false pay no cost for the WebTransport feature. Results on Apple M4, go1.25, -count=5: BenchmarkBuildHTTP3Server_WebTransportOff ~70 ns/op 392 B/op 3 allocs/op BenchmarkBuildHTTP3Server_WebTransportOn ~144 ns/op 600 B/op 6 allocs/op The Off path is about half the cost on every dimension, confirming that the work skipped when the flag is false is the webtransport.ConfigureHTTP3Server call plus EnableStreamResetPartialDelivery. Absolute cost is a one-time per-server setup so either branch is negligible in practice, but the asymmetry locks in a regression guard: a future refactor that accidentally re-enables the WT configuration unconditionally would show up as a jump in the Off numbers. This benchmark does not exercise the per-stream dispatch cost inside webtransport.Server.ServeQUICConn — that would require a full QUIC setup to measure in isolation and is follow-up work. * reverseproxy: collapse WebTransport handler into main proxy loop The WT path duplicated upstream resolution, LB selection, header ops, replacer vars, and in-flight counters. Route WT through the shared ServeHTTP -> proxyLoopIteration -> reverseProxy flow and swap RoundTrip for a small webTransportHijack that only does WT-specific work (writer unwrap, upstream dial, client upgrade, pump). Rename roundtripSucceededError -> terminalError. The existing name described when it was emitted (after a successful round-trip); the new name describes its contract with the retry loop (stop looping, propagate error unchanged). The WebTransport upgrade case is a second natural caller for that same signal. Comes with two behavior improvements that fall out of the collapse: - WT upstream dial failures now surface as DialError, so the loop can fail over across upstreams like normal proxies (today: 502). - Passive health checks apply to WT dials (dial-failure countFailure and UnhealthyLatency on dial duration) via the shared path. Addresses reviewer feedback that the duplicated setup was a maintenance risk. * reverseproxy: inline upstream-resolution helpers back into the proxy loop The earlier #7669 revision extracted resolveUpstreams and setUpstreamReplacerVars to share between the WebTransport path and the normal proxy path. A later commit collapsed the WebTransport path into the main proxy loop, so the helpers became single-callers — they no longer earn their abstraction. Inline both back into proxyLoopIteration. The dynamic-upstream block absorbs the CachingUpstreamSource cache-reset logic added by #7662, which was preserved through the rebase. No behavior change. * caddyhttp: clarify WebTransportServer doc covers both disabled paths WebTransportServer returns nil for two distinct configurations: EnableWebTransport=false on the server, and HTTP/3 not in use. The prior doc only mentioned the latter. Spell out both, per review. * reverseproxy: drop nil-logger guard in runWebTransportPump Production and test callers both pass a non-nil *zap.Logger. The defensive nil check silently swapped in a no-op logger, which can mask a real bug at the call site. Document the precondition in the doc comment instead, per review. * reverseproxy: use sync.WaitGroup.Go in WebTransport pump Replace the wg.Add(6) + six "go func() { defer wg.Done(); fn() }()" calls with sync.WaitGroup.Go (Go 1.25+, the project's minimum version). Same number of goroutines; less boilerplate. * caddyhttp: drop defensive checks in UnwrapResponseWriterAs Per review, the nil-pointer guard and the next == w self-reference guard defended against shapes that no in-tree ResponseWriter wrapper produces. Drop both, simplifying the loop. Also remove the self-reference test that exercised the now-removed guard. * reverseproxy: use r.URL.RequestURI() to build the WebTransport upstream URL Replace buildWebTransportUpstreamURL with an inline call to r.URL.RequestURI() at the single call site. RequestURI handles the empty-path → "/" fixup and query suffixing already, and uses EscapedPath so encoded path segments (e.g. %2F) are preserved on the upstream — fixing a small correctness gap where the previous helper read r.URL.Path (decoded). Aligns with how Caddy's normal proxy path lets stdlib render the outbound request line via URL.RequestURI semantics. * reverseproxy: use wg.Go in spliceBidi * reverseproxy: expand SNI placeholders on the WebTransport upstream dial The normal HTTP/3 path expands placeholders in tls_server_name at dial time via a custom h3Transport.Dial hook (#7737). The WebTransport path dials through its own webtransport.Dialer and bypasses that hook, so a placeholder like {http.request.host} reached the QUIC ClientHello literally. Expand it in webTransportHijack before dialing, cloning the shared TLS config so concurrent sessions are unaffected. Adds an integration test asserting the upstream observes the resolved SNI rather than the literal "{...}" string. * reverseproxy: bump webtransport-go to v0.11.0 (WebTransport draft-15) webtransport-go v0.11.0 implements draft-15, which renames the Extended CONNECT :protocol token from "webtransport" to "webtransport-h3" (the server keeps accepting the legacy token for older clients). Detect both tokens in the reverse-proxy upgrade check and in the test echo handler. Pulls quic-go v0.59.1 -> v0.60.0, required by webtransport-go v0.11.0; its only breaking change is requiring Go 1.25 (already met), with no API changes affecting Caddy core. Also picks up the v0.11.0 fix for a stream-close race on concurrent session close, relevant to the long-lived pump. * caddyhttp: log WebTransport QUIC connection serve errors * caddy: gate QUIC WebTransport capabilities behind an explicit ListenQUIC parameter * caddyhttp: fall back to plain HTTP/3 when a reused QUIC listener lacks WebTransport * caddyhttp: enable WebTransport via a webtransport config object Replace the enable_webtransport bool with a presence-gated WebTransportConfig object so later options can be added without renaming the key. JSON is "webtransport": {}, Caddyfile is webtransport (optional empty block). * reverseproxy: use origReq for the client WebTransport upgrade Upgrade is the client-facing session: CheckOrigin and the CONNECT shape must come from the downstream request. The upstream-directed clone remains used for the dial URL, header_up, and SNI expansion. * reverseproxy: relay WebTransport application protocol negotiation Parse WT-Available-Protocols from the downstream request, set it on the upstream Dialer, and copy WT-Protocol from the upstream 200 onto the client response. The shared webtransport.Server does not select a protocol of its own, so MOQ/MediaMTX clients receive the upstream's choice instead of WT_ALPN_ERROR. * caddytest: skip origin check on standalone WebTransport backends header_up Host rewrites used by proxy tests would 400 the upstream Upgrade via CheckOrigin. Test backends are not browsers. * caddytest: cover MediaMTX Host-rewrite + moqt-19 through the WT proxy Both of elee's #7669 reports fire together: header_up rewrites Host (Upgrade must use origReq) and the client offers moqt-19 (the proxy must relay WT-Available-Protocols / WT-Protocol). A bidi echo after negotiation proves the session is usable, not just headers. * caddyhttp: break self-referential Unwrap loops A wrapper whose Unwrap returns itself would spin forever. Restore the cycle-break mholt applied on #7669, without bringing back the nil-pointer guard Weidi asked to drop. * caddyhttp: close WebTransport sessions before HTTP/3 shutdown ServeQUICConn waits for active WT sessions, and with the default eternal grace period HTTP/3 Shutdown would hang until every client disconnected. Close the WebTransport server first so stop/reload can drain. * caddyhttp: serve plain HTTP/3 when a reused listener lacks WebTransport A pooled QUIC listener created without DATAGRAMs cannot upgrade WT sessions. Falling back on the WT-configured http3.Server still advertised SETTINGS the socket cannot honor. Build a separate plain HTTP/3 server for that listener and assert it omits WT SETTINGS. * caddyhttp: visit Unwrap writers by pointer identity Comparing writers with == panics on uncomparable types and only catches a one-node cycle. Track pointer identity, bound the walk, and add tests for an uncomparable self-unwrap and an A→B→A cycle. RecordHijackedStatus lets Upgrade write through the naked HTTP/3 writer while the access-log recorder still observes the 200. * reverseproxy: honor prepared Host, header_down, and WT bookkeeping Dialer.Dial always sets Host from the URL, so header_up Host never reached the upstream. Dial the real address via DialAddr and put the prepared Host on the CONNECT. Parse WT-Available-Protocols from the prepared request so header_up applies to negotiation. Copy filtered upstream response headers before header_down, record the hijacked 200 for access logs, and run the same latency / circuit-breaker / passive-health path as a normal round-trip. * caddytest: cover WT reload, access-log status, Host, and header ops Reload with an eternal grace period must close an in-flight session. Access logs must record 200 after Upgrade. header_up Host and WT-Available-Protocols must reach the upstream; header_down and non-hop upstream headers must reach the client. * reverseproxy: build WebTransport against quic-go v0.63 webtransport-go v0.13.0 renames Dialer to Transport. v0.11.1 does not compile against quic-go v0.63. * reverseproxy: close the upstream body when a WebTransport dial fails Dial can return a response with an error when the peer rejects the CONNECT. Close that body before the error is returned. * caddyhttp: close WebTransport sessions without closing the HTTP/3 server webtransport.Server.Close also closes the shared HTTP/3 server and aborts every QUIC connection before the grace period applies. Close the registered sessions, then let HTTP/3 shutdown drain ordinary requests. * chore: modernise Signed-off-by: Zen Dodd <mail@steadytao.com> * reverseproxy: preserve WT-Available-Protocols across upstream retries The proxy loop reuses the request and only recopies headers when header ops are configured. Deleting the offer on that map hid it from the next upstream. * caddyhttp: close WebTransport sessions when the grace period ends Eternal grace leaves an active session until the peer ends it. A configured grace period closes it at the deadline, while the socket is still up, so the close capsule can be delivered. * reverseproxy: use terminalError when refusing an Incremental response The proxy-loop collapse renamed roundtripSucceededError to terminalError. Master's Incremental refusal still returned the old type, so the rebased package did not compile. * caddyhttp: don't let idle HTTP/3 conns pin WebTransport shutdown - Count in-flight requests on ServeQUICConn connections and close idle ones when shutdown starts - Delay that close by a second so a just-finished response can be read - Retain plain HTTP/3 fallbacks for listeners without datagrams and shut them down with the same grace period * reverseproxy: wait for WebTransport stream workers - Join bidi and uni splice workers after the accept loops return - Cancel the opposite copy on a hard splice error --------- Signed-off-by: Zen Dodd <mail@steadytao.com> Co-authored-by: tomholford <tomholford@users.noreply.github.com> Co-authored-by: Zen Dodd <mail@steadytao.com>

tomholford committed Oct 13, 2026 at 12:24 UTC c7b36dc55e1d2751aa771039c29ec105a85f8bd5
20 files changed +4207 -63
caddyconfig/httpcaddyfile/serveroptions.go
+11
@@ -53,6 +53,7 @@ type serverOptions struct {
53 KeepAliveCount int
54 MaxHeaderBytes int
55 EnableFullDuplex bool
56 + WebTransport *caddyhttp.WebTransportConfig
57 ExpectedUnderscoreHeaders []string
58 ExpectedDotHeaders []string
59 Protocols []string
@@ -272,6 +273,15 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
273 }
274 serverOpts.EnableFullDuplex = true
275
276 + case "webtransport":
277 + if d.NextArg() {
278 + return nil, d.ArgErr()
279 + }
280 + for nesting := d.Nesting(); d.NextBlock(nesting); {
281 + return nil, d.Errf("unrecognized webtransport option: %s", d.Val())
282 + }
283 + serverOpts.WebTransport = new(caddyhttp.WebTransportConfig)
284 +
285 case "expected_underscore_headers":
286 args := d.RemainingArgs()
287 if len(args) == 0 {
@@ -453,6 +463,7 @@ func applyServerOptions(
463 server.KeepAliveCount = opts.KeepAliveCount
464 server.MaxHeaderBytes = opts.MaxHeaderBytes
465 server.EnableFullDuplex = opts.EnableFullDuplex
466 + server.WebTransport = opts.WebTransport
467 server.ExpectedUnderscoreHeaders = opts.ExpectedUnderscoreHeaders
468 server.ExpectedDotHeaders = opts.ExpectedDotHeaders
469 server.Protocols = opts.Protocols
caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest
+2
@@ -12,6 +12,7 @@
12 }
13 max_header_size 100MB
14 enable_full_duplex
15 + webtransport
16 log_credentials
17 protocols h1 h2 h2c h3
18 strict_sni_host
@@ -54,6 +55,7 @@ foo.com {
55 "keepalive_count": 10,
56 "max_header_bytes": 100000000,
57 "enable_full_duplex": true,
58 + "webtransport": {},
59 "routes": [
60 {
61 "match": [
caddytest/integration/webtransport_echo_test.go new
+204
@@ -0,0 +1,204 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package integration
16 +
17 +import (
18 + "errors"
19 + "fmt"
20 + "io"
21 + "net/http"
22 + "net/http/httptest"
23 + "testing"
24 +
25 + "github.com/quic-go/quic-go/http3"
26 + "github.com/quic-go/webtransport-go"
27 + "go.uber.org/zap"
28 +
29 + "github.com/caddyserver/caddy/v2"
30 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
31 +)
32 +
33 +// This file provides a terminating WebTransport handler used exclusively
34 +// as a test upstream for the WebTransport reverse-proxy integration
35 +// tests in webtransport_test.go. Keeping it in a _test.go file (mirroring
36 +// mockdns_test.go) means the http.handlers.webtransport module is only
37 +// registered in the integration test binary — it does not ship in
38 +// production Caddy builds.
39 +
40 +func init() {
41 + caddy.RegisterModule(WebTransportEcho{})
42 +}
43 +
44 +// webtransportEchoWriter is the naked HTTP/3 response-writer shape that
45 +// webtransport.Server.Upgrade type-asserts on.
46 +type webtransportEchoWriter interface {
47 + http.ResponseWriter
48 + http3.Settingser
49 + http3.HTTPStreamer
50 +}
51 +
52 +// WebTransportEcho terminates an incoming WebTransport session and echoes
53 +// bytes on each accepted bidirectional stream. Registered as
54 +// `http.handlers.webtransport` in the integration test binary.
55 +type WebTransportEcho struct {
56 + logger *zap.Logger
57 +}
58 +
59 +// CaddyModule returns the Caddy module information.
60 +func (WebTransportEcho) CaddyModule() caddy.ModuleInfo {
61 + return caddy.ModuleInfo{
62 + ID: "http.handlers.webtransport",
63 + New: func() caddy.Module { return new(WebTransportEcho) },
64 + }
65 +}
66 +
67 +// Provision sets up the handler.
68 +func (h *WebTransportEcho) Provision(ctx caddy.Context) error {
69 + h.logger = ctx.Logger()
70 + return nil
71 +}
72 +
73 +// ServeHTTP upgrades the request to a WebTransport session and echoes
74 +// bytes on each accepted bidirectional stream. Non-WebTransport requests
75 +// are passed through to the next handler.
76 +func (h *WebTransportEcho) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
77 + if !isWebTransportEchoUpgrade(r) {
78 + return next.ServeHTTP(w, r)
79 + }
80 +
81 + srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
82 + if !ok || srv == nil {
83 + return caddyhttp.Error(http.StatusInternalServerError,
84 + errors.New("webtransport: caddyhttp.Server not in request context"))
85 + }
86 + wtServer, ok := srv.WebTransportServer().(*webtransport.Server)
87 + if !ok || wtServer == nil {
88 + return caddyhttp.Error(http.StatusInternalServerError,
89 + errors.New("webtransport: HTTP/3 is not enabled on this server"))
90 + }
91 +
92 + naked, ok := caddyhttp.UnwrapResponseWriterAs[webtransportEchoWriter](w)
93 + if !ok {
94 + return caddyhttp.Error(http.StatusInternalServerError,
95 + errors.New("webtransport: underlying writer does not support WebTransport upgrade"))
96 + }
97 +
98 + session, err := wtServer.Upgrade(naked, r)
99 + if err != nil {
100 + h.logger.Debug("webtransport upgrade failed", zap.Error(err))
101 + return caddyhttp.Error(http.StatusBadRequest,
102 + fmt.Errorf("webtransport upgrade: %w", err))
103 + }
104 + unregister := srv.RegisterWebTransportSession(webTransportEchoCloser{sess: session})
105 + defer unregister()
106 +
107 + h.echoStreams(session)
108 + return nil
109 +}
110 +
111 +// webTransportEchoCloser closes a terminating test session on shutdown.
112 +type webTransportEchoCloser struct {
113 + sess *webtransport.Session
114 +}
115 +
116 +func (w webTransportEchoCloser) Close() error {
117 + return w.sess.CloseWithError(0, "server shutting down")
118 +}
119 +
120 +// echoStreams accepts bidirectional streams on session until the session
121 +// ends, and echoes bytes on each one.
122 +func (h *WebTransportEcho) echoStreams(session *webtransport.Session) {
123 + ctx := session.Context()
124 + for {
125 + str, err := session.AcceptStream(ctx)
126 + if err != nil {
127 + return
128 + }
129 + go func(s *webtransport.Stream) {
130 + // io.Copy from the stream back to itself echoes everything
131 + // received on this bidirectional stream. When the peer closes
132 + // its send side we observe EOF and close our send side too.
133 + if _, err := io.Copy(s, s); err != nil && h.logger != nil {
134 + h.logger.Debug("webtransport echo stream error", zap.Error(err))
135 + }
136 + _ = s.Close()
137 + }(str)
138 + }
139 +}
140 +
141 +// isWebTransportEchoUpgrade reports whether r is an HTTP/3 Extended
142 +// CONNECT that requests a WebTransport session. The quic-go/http3 server
143 +// places the :protocol pseudo-header value in r.Proto for CONNECT requests.
144 +func isWebTransportEchoUpgrade(r *http.Request) bool {
145 + return r.ProtoMajor == 3 && r.Method == http.MethodConnect &&
146 + (r.Proto == "webtransport" || r.Proto == "webtransport-h3")
147 +}
148 +
149 +// Interface guards.
150 +var (
151 + _ caddy.Provisioner = (*WebTransportEcho)(nil)
152 + _ caddyhttp.MiddlewareHandler = (*WebTransportEcho)(nil)
153 +)
154 +
155 +// --- unit tests ------------------------------------------------------------
156 +
157 +func TestIsWebTransportEchoUpgrade(t *testing.T) {
158 + cases := []struct {
159 + name string
160 + proto string
161 + major int
162 + meth string
163 + want bool
164 + }{
165 + {"h3 connect webtransport", "webtransport", 3, http.MethodConnect, true},
166 + {"h3 connect webtransport-h3", "webtransport-h3", 3, http.MethodConnect, true},
167 + {"h3 connect websocket", "websocket", 3, http.MethodConnect, false},
168 + {"h2 connect webtransport", "webtransport", 2, http.MethodConnect, false},
169 + {"h3 GET", "HTTP/3.0", 3, http.MethodGet, false},
170 + {"h3 connect missing :protocol", "HTTP/3.0", 3, http.MethodConnect, false},
171 + }
172 + for _, tc := range cases {
173 + t.Run(tc.name, func(t *testing.T) {
174 + r := httptest.NewRequest(tc.meth, "/", nil)
175 + r.ProtoMajor = tc.major
176 + r.Proto = tc.proto
177 + if got := isWebTransportEchoUpgrade(r); got != tc.want {
178 + t.Errorf("isWebTransportEchoUpgrade = %v, want %v", got, tc.want)
179 + }
180 + })
181 + }
182 +}
183 +
184 +// echoNextNoop is a stand-in for the next handler. It records whether it
185 +// was invoked, used to assert that non-WebTransport requests pass through.
186 +type echoNextNoop struct{ called bool }
187 +
188 +func (n *echoNextNoop) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
189 + n.called = true
190 + return nil
191 +}
192 +
193 +func TestWebTransportEcho_PassesThroughNonWebTransportRequests(t *testing.T) {
194 + h := &WebTransportEcho{}
195 + r := httptest.NewRequest(http.MethodGet, "/", nil)
196 + w := httptest.NewRecorder()
197 + nx := &echoNextNoop{}
198 + if err := h.ServeHTTP(w, r, nx); err != nil {
199 + t.Fatalf("ServeHTTP returned error: %v", err)
200 + }
201 + if !nx.called {
202 + t.Error("expected next handler to be invoked for non-WebTransport request")
203 + }
204 +}
caddytest/integration/webtransport_test.go new
+1408
@@ -0,0 +1,1408 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package integration
16 +
17 +import (
18 + "context"
19 + "crypto/ecdsa"
20 + "crypto/elliptic"
21 + "crypto/rand"
22 + "crypto/tls"
23 + "crypto/x509"
24 + "crypto/x509/pkix"
25 + "encoding/json"
26 + "fmt"
27 + "io"
28 + "math/big"
29 + "net"
30 + "net/http"
31 + "net/http/httptest"
32 + "os"
33 + "strings"
34 + "testing"
35 + "time"
36 +
37 + "github.com/quic-go/quic-go"
38 + "github.com/quic-go/quic-go/http3"
39 + "github.com/quic-go/webtransport-go"
40 +
41 + "github.com/caddyserver/caddy/v2/caddytest"
42 +)
43 +
44 +const wtProxyListen = "https://127.0.0.1:9443/"
45 +
46 +// TestWebTransport_EchoHandlerBidi spins up Caddy with an HTTP/3 listener
47 +// that terminates a WebTransport session via the http.handlers.webtransport
48 +// echo handler, then dials it with a real webtransport.Transport and asserts
49 +// an end-to-end bidirectional-stream round-trip. This exercises the
50 +// serveH3AcceptLoop path (webtransport.Server.ServeQUICConn instead of
51 +// http3.Server.ServeListener) and the UnwrapResponseWriterAs helper.
52 +func TestWebTransport_EchoHandlerBidi(t *testing.T) {
53 + if testing.Short() {
54 + t.Skip()
55 + }
56 + caddytest.NewTester(t).InitServer(wtJSON(`"srv0": `+wtH3Server(":9443", `{"handler": "webtransport"}`)), "json")
57 +
58 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
59 + defer cancel()
60 + rsp, sess := dialWT(t, ctx, nil, nil)
61 + defer sess.CloseWithError(0, "")
62 + if rsp.StatusCode != http.StatusOK {
63 + t.Fatalf("unexpected status: %d", rsp.StatusCode)
64 + }
65 + echoBidi(t, ctx, sess, "hello webtransport")
66 +}
67 +
68 +// TestWebTransport_ReverseProxyEndToEnd spins up a single Caddy instance
69 +// running two HTTP/3 servers: one on :9443 acting as the WebTransport
70 +// reverse proxy, and one on :9444 acting as the terminating echo
71 +// upstream. A real webtransport.Transport dials the proxy; the pump should
72 +// bridge to the upstream so bytes written on a bidi stream are echoed.
73 +func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) {
74 + if testing.Short() {
75 + t.Skip()
76 + }
77 + caddytest.NewTester(t).InitServer(wtJSON(
78 + `"proxy": `+wtH3Server(":9443", wtReverseProxyHandler(`"upstreams": [{"dial": "127.0.0.1:9444"}]`))+
79 + `, "upstream": `+wtH3Server(":9444", `{"handler": "webtransport"}`),
80 + ), "json")
81 +
82 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
83 + defer cancel()
84 + rsp, sess := dialWT(t, ctx, nil, nil)
85 + defer sess.CloseWithError(0, "")
86 + if rsp.StatusCode != http.StatusOK {
87 + t.Fatalf("unexpected status: %d", rsp.StatusCode)
88 + }
89 + echoBidi(t, ctx, sess, "reverse-proxied via the pump")
90 +}
91 +
92 +// TestWebTransport_ReverseProxyForwardsHeaders proves that the WebTransport
93 +// proxy path applies the same request-preparation pipeline as the normal
94 +// reverse_proxy path: `headers.request.set` lands on the upstream CONNECT,
95 +// X-Forwarded-For is added, and a Via header is appended. The upstream here
96 +// is a standalone webtransport.Server (not another Caddy) so we can observe
97 +// the raw headers of the Extended CONNECT that Caddy forwarded.
98 +func TestWebTransport_ReverseProxyForwardsHeaders(t *testing.T) {
99 + if testing.Short() {
100 + t.Skip()
101 + }
102 +
103 + gotHeaders := make(chan http.Header, 1)
104 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
105 + select {
106 + case gotHeaders <- r.Header.Clone():
107 + default:
108 + }
109 + _ = sess.CloseWithError(0, "")
110 + })
111 + t.Cleanup(stopUpstream)
112 +
113 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
114 + "headers": {"request": {"set": {"X-Caddy-Test": ["caddy-wt-hdr"]}}},
115 + "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
116 +
117 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
118 + defer cancel()
119 + _, sess := dialWT(t, ctx, nil, nil)
120 + defer sess.CloseWithError(0, "")
121 +
122 + select {
123 + case hdr := <-gotHeaders:
124 + if got := hdr.Get("X-Caddy-Test"); got != "caddy-wt-hdr" {
125 + t.Errorf("upstream did not receive `headers.request.set` value; got X-Caddy-Test=%q", got)
126 + }
127 + if got := hdr.Get("X-Forwarded-For"); !strings.Contains(got, "127.0.0.1") {
128 + t.Errorf("upstream did not receive X-Forwarded-For=127.0.0.1; got %q", got)
129 + }
130 + if got := hdr.Get("Via"); got == "" {
131 + t.Errorf("upstream did not receive Via header")
132 + }
133 + case <-time.After(3 * time.Second):
134 + t.Fatal("upstream did not observe forwarded CONNECT headers in time")
135 + }
136 +}
137 +
138 +// TestWebTransport_ReverseProxyForwardsPreparedHost proves header_up Host
139 +// is the CONNECT :authority the upstream sees. Transport.Dial would otherwise
140 +// overwrite Host from the dial URL.
141 +func TestWebTransport_ReverseProxyForwardsPreparedHost(t *testing.T) {
142 + if testing.Short() {
143 + t.Skip()
144 + }
145 +
146 + gotHost := make(chan string, 1)
147 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
148 + select {
149 + case gotHost <- r.Host:
150 + default:
151 + }
152 + _ = sess.CloseWithError(0, "")
153 + })
154 + t.Cleanup(stopUpstream)
155 +
156 + wantHost := fmt.Sprintf("127.0.0.1:%d", upstreamAddr.Port)
157 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
158 + "headers": {"request": {"set": {"Host": [%q]}}},
159 + "upstreams": [{"dial": "127.0.0.1:%d"}]`, wantHost, upstreamAddr.Port)))
160 +
161 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
162 + defer cancel()
163 + _, sess := dialWT(t, ctx, nil, nil)
164 + defer sess.CloseWithError(0, "")
165 +
166 + select {
167 + case got := <-gotHost:
168 + if got != wantHost {
169 + t.Errorf("upstream Host = %q, want %q", got, wantHost)
170 + }
171 + case <-time.After(3 * time.Second):
172 + t.Fatal("upstream did not observe Host in time")
173 + }
174 +}
175 +
176 +// TestWebTransport_ReverseProxyHeaderUpAvailableProtocols proves protocol
177 +// negotiation reads the prepared request, so header_up can inject
178 +// WT-Available-Protocols even when the client omitted the offer.
179 +func TestWebTransport_ReverseProxyHeaderUpAvailableProtocols(t *testing.T) {
180 + if testing.Short() {
181 + t.Skip()
182 + }
183 +
184 + gotHdr := make(chan http.Header, 1)
185 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
186 + select {
187 + case gotHdr <- r.Header.Clone():
188 + default:
189 + }
190 + _ = sess.CloseWithError(0, "")
191 + }, "moqt-19")
192 + t.Cleanup(stopUpstream)
193 +
194 + // Client offers moqt-19 (so it will accept that selection). header_up
195 + // replaces the prepared offer with moqt-19 plus a marker token. Parsing
196 + // origReq would hide the marker from the upstream.
197 + offer := `"moqt-19", "x-caddy-test"`
198 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
199 + "headers": {"request": {"set": {"WT-Available-Protocols": [%q]}}},
200 + "upstreams": [{"dial": "127.0.0.1:%d"}]`, offer, upstreamAddr.Port)))
201 +
202 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
203 + defer cancel()
204 + rsp, sess := dialWT(t, ctx, nil, []string{"moqt-19"})
205 + defer sess.CloseWithError(0, "")
206 + if rsp != nil {
207 + defer rsp.Body.Close()
208 + }
209 + if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
210 + t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
211 + }
212 + select {
213 + case hdr := <-gotHdr:
214 + if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "x-caddy-test") {
215 + t.Errorf("upstream WT-Available-Protocols = %q, want header_up marker x-caddy-test", got)
216 + }
217 + case <-time.After(3 * time.Second):
218 + t.Fatal("upstream did not observe CONNECT headers in time")
219 + }
220 +}
221 +
222 +// TestWebTransport_ReverseProxyHeaderDown copies non-hop upstream response
223 +// headers onto the client 200 and applies header_down.
224 +func TestWebTransport_ReverseProxyHeaderDown(t *testing.T) {
225 + if testing.Short() {
226 + t.Skip()
227 + }
228 +
229 + upstreamAddr, stopUpstream := startStandaloneWebTransportBeforeUpgrade(t,
230 + func(w http.ResponseWriter, _ *http.Request) {
231 + w.Header().Set("X-Upstream", "from-backend")
232 + },
233 + func(sess *webtransport.Session, _ *http.Request) {
234 + _ = sess.CloseWithError(0, "")
235 + },
236 + )
237 + t.Cleanup(stopUpstream)
238 +
239 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
240 + "headers": {"response": {"set": {"X-Down": ["from-caddy"]}}},
241 + "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
242 +
243 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
244 + defer cancel()
245 + rsp, sess := dialWT(t, ctx, nil, nil)
246 + defer sess.CloseWithError(0, "")
247 + if rsp != nil {
248 + defer rsp.Body.Close()
249 + }
250 + if got := rsp.Header.Get("X-Upstream"); got != "from-backend" {
251 + t.Errorf("X-Upstream = %q, want from-backend", got)
252 + }
253 + if got := rsp.Header.Get("X-Down"); got != "from-caddy" {
254 + t.Errorf("X-Down = %q, want from-caddy", got)
255 + }
256 +}
257 +
258 +// TestWebTransport_ReverseProxyAccessLogStatus records a hijacked 200 on
259 +// the response recorder so access logs do not report status 0.
260 +func TestWebTransport_ReverseProxyAccessLogStatus(t *testing.T) {
261 + if testing.Short() {
262 + t.Skip()
263 + }
264 +
265 + logFile := t.TempDir() + "/access.log"
266 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, _ *http.Request) {
267 + _ = sess.CloseWithError(0, "")
268 + })
269 + t.Cleanup(stopUpstream)
270 +
271 + cfg := fmt.Sprintf(`{
272 + "admin": {"listen": "localhost:2999"},
273 + "logging": {
274 + "logs": {
275 + "default": {
276 + "exclude": ["http.log.access"]
277 + },
278 + "wtaccess": {
279 + "writer": {"output": "file", "filename": %q},
280 + "encoder": {"format": "json"},
281 + "include": ["http.log.access"]
282 + }
283 + }
284 + },
285 + "apps": {
286 + "http": {
287 + "http_port": 9080,
288 + "https_port": 9443,
289 + "grace_period": 1,
290 + "servers": {
291 + "proxy": {
292 + "listen": [":9443"],
293 + "protocols": ["h3"],
294 + "webtransport": {},
295 + "logs": {"default_logger_name": "wtaccess"},
296 + "routes": [{"handle": [%s]}],
297 + "tls_connection_policies": [{
298 + "certificate_selection": {"any_tag": ["cert0"]},
299 + "default_sni": "a.caddy.localhost"
300 + }]
301 + }
302 + }
303 + },
304 + "tls": {
305 + "certificates": {
306 + "load_files": [{
307 + "certificate": "/a.caddy.localhost.crt",
308 + "key": "/a.caddy.localhost.key",
309 + "tags": ["cert0"]
310 + }]
311 + }
312 + },
313 + "pki": {"certificate_authorities": {"local": {"install_trust": false}}}
314 + }
315 +}`, logFile, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
316 +
317 + caddytest.NewTester(t).InitServer(cfg, "json")
318 +
319 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
320 + defer cancel()
321 + rsp, sess := dialWT(t, ctx, nil, nil)
322 + if rsp != nil {
323 + _ = rsp.Body.Close()
324 + }
325 + _ = sess.CloseWithError(0, "")
326 +
327 + deadline := time.Now().Add(3 * time.Second)
328 + for {
329 + b, err := os.ReadFile(logFile)
330 + if err == nil && strings.Contains(string(b), `"status":200`) {
331 + return
332 + }
333 + if time.Now().After(deadline) {
334 + t.Fatalf("access log did not record status 200; file=%q err=%v", b, err)
335 + }
336 + time.Sleep(50 * time.Millisecond)
337 + }
338 +}
339 +
340 +// TestWebTransport_ReverseProxyUpgradeUsesDownstreamRequest proves that
341 +// the client-facing Upgrade uses origReq, not the upstream-directed clone.
342 +// header_up rewrites Host to the upstream address while the client sends
343 +// Origin matching the downstream Host; CheckOrigin would fail if Upgrade
344 +// saw the rewritten Host.
345 +func TestWebTransport_ReverseProxyUpgradeUsesDownstreamRequest(t *testing.T) {
346 + if testing.Short() {
347 + t.Skip()
348 + }
349 +
350 + upgraded := make(chan struct{}, 1)
351 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, _ *http.Request) {
352 + select {
353 + case upgraded <- struct{}{}:
354 + default:
355 + }
356 + _ = sess.CloseWithError(0, "")
357 + })
358 + t.Cleanup(stopUpstream)
359 +
360 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
361 + "headers": {"request": {"set": {"Host": ["127.0.0.1:%d"]}}},
362 + "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port, upstreamAddr.Port)))
363 +
364 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
365 + defer cancel()
366 + _, sess := dialWT(t, ctx, http.Header{"Origin": []string{"https://127.0.0.1:9443"}}, nil)
367 + defer sess.CloseWithError(0, "")
368 +
369 + select {
370 + case <-upgraded:
371 + case <-time.After(3 * time.Second):
372 + t.Fatal("upstream did not observe a WebTransport session in time")
373 + }
374 +}
375 +
376 +// TestWebTransport_ReverseProxyNegotiatesApplicationProtocol proves the
377 +// proxy relays WT-Available-Protocols to the upstream and copies the
378 +// upstream's WT-Protocol choice back to the client (draft-ietf-webtrans-http3
379 +// §3.3). Without this, a MOQ client such as MediaMTX closes with WT_ALPN_ERROR.
380 +func TestWebTransport_ReverseProxyNegotiatesApplicationProtocol(t *testing.T) {
381 + if testing.Short() {
382 + t.Skip()
383 + }
384 +
385 + gotHeaders := make(chan http.Header, 1)
386 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
387 + select {
388 + case gotHeaders <- r.Header.Clone():
389 + default:
390 + }
391 + _ = sess.CloseWithError(0, "")
392 + }, "moqt-19")
393 + t.Cleanup(stopUpstream)
394 +
395 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
396 +
397 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
398 + defer cancel()
399 + rsp, sess := dialWT(t, ctx, nil, []string{"moqt-19"})
400 + defer sess.CloseWithError(0, "")
401 + if rsp != nil {
402 + defer rsp.Body.Close()
403 + }
404 +
405 + if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
406 + t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
407 + }
408 + if got := rsp.Header.Get("WT-Protocol"); got == "" {
409 + t.Error("client response missing WT-Protocol")
410 + }
411 +
412 + select {
413 + case hdr := <-gotHeaders:
414 + if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "moqt-19") {
415 + t.Errorf("upstream WT-Available-Protocols = %q, want to contain moqt-19", got)
416 + }
417 + case <-time.After(3 * time.Second):
418 + t.Fatal("upstream did not observe a WebTransport session in time")
419 + }
420 +}
421 +
422 +// TestWebTransport_RetryPreservesApplicationProtocol fails the first
423 +// upstream dial and retries. Round-robin picks index 1 first. That dial
424 +// fails, and the second upstream must still see WT-Available-Protocols.
425 +// Deleting the offer on the request header removes it for every later
426 +// attempt, because the proxy loop reuses the request and does not
427 +// recopy headers when no header ops are configured.
428 +func TestWebTransport_RetryPreservesApplicationProtocol(t *testing.T) {
429 + if testing.Short() {
430 + t.Skip()
431 + }
432 +
433 + badPort, stopBad := startClosingQUICServer(t)
434 + t.Cleanup(stopBad)
435 +
436 + gotHeaders := make(chan http.Header, 1)
437 + goodAddr, stopGood := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
438 + select {
439 + case gotHeaders <- r.Header.Clone():
440 + default:
441 + }
442 + _ = sess.CloseWithError(0, "")
443 + }, "moqt-19")
444 + t.Cleanup(stopGood)
445 +
446 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
447 + "load_balancing": {
448 + "selection_policy": {"policy": "round_robin"},
449 + "retries": 1
450 + },
451 + "upstreams": [
452 + {"dial": "127.0.0.1:%d"},
453 + {"dial": "127.0.0.1:%d"}
454 + ]`, goodAddr.Port, badPort)))
455 +
456 + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
457 + defer cancel()
458 + rsp, sess := dialWT(t, ctx, nil, []string{"moqt-19"})
459 + defer sess.CloseWithError(0, "")
460 + if rsp != nil {
461 + defer rsp.Body.Close()
462 + }
463 +
464 + if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
465 + t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
466 + }
467 +
468 + select {
469 + case hdr := <-gotHeaders:
470 + if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "moqt-19") {
471 + t.Errorf("second upstream WT-Available-Protocols = %q, want to contain moqt-19", got)
472 + }
473 + case <-time.After(5 * time.Second):
474 + t.Fatal("second upstream did not observe a WebTransport session")
475 + }
476 +}
477 +
478 +// TestWebTransport_ReverseProxyMOQLike is the MediaMTX scenario from #7669:
479 +// the client offers moqt-19 AND header_up rewrites Host. Both of elee's
480 +// bugs fire together — Upgrade must use origReq (CheckOrigin against the
481 +// rewritten Host would 400) and WT-Available-Protocols must be relayed so
482 +// the upstream selects moqt-19 instead of closing with WT_ALPN_ERROR.
483 +func TestWebTransport_ReverseProxyMOQLike(t *testing.T) {
484 + if testing.Short() {
485 + t.Skip()
486 + }
487 +
488 + gotHeaders := make(chan http.Header, 1)
489 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
490 + select {
491 + case gotHeaders <- r.Header.Clone():
492 + default:
493 + }
494 + str, err := sess.AcceptStream(sess.Context())
495 + if err != nil {
496 + return
497 + }
498 + _, _ = io.Copy(str, str)
499 + _ = str.Close()
500 + }, "moqt-19")
501 + t.Cleanup(stopUpstream)
502 +
503 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
504 + "headers": {"request": {"set": {"Host": ["127.0.0.1:%d"]}}},
505 + "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port, upstreamAddr.Port)))
506 +
507 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
508 + defer cancel()
509 + rsp, sess := dialWT(t, ctx, http.Header{"Origin": []string{"https://127.0.0.1:9443"}}, []string{"moqt-19"})
510 + defer sess.CloseWithError(0, "")
511 + if rsp != nil {
512 + defer rsp.Body.Close()
513 + }
514 +
515 + if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
516 + t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
517 + }
518 + echoBidi(t, ctx, sess, "moq-like through the proxy")
519 +
520 + select {
521 + case hdr := <-gotHeaders:
522 + if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "moqt-19") {
523 + t.Errorf("upstream WT-Available-Protocols = %q, want to contain moqt-19", got)
524 + }
525 + case <-time.After(3 * time.Second):
526 + t.Fatal("upstream did not observe a WebTransport session in time")
527 + }
528 +}
529 +
530 +// TestWebTransport_ReverseProxyExpandsSNIPlaceholder proves that a
531 +// placeholder in the transport's tls_server_name (here driven off a request
532 +// header) is expanded per session before the WebTransport upstream dial, so
533 +// the upstream observes the resolved SNI rather than the literal "{...}"
534 +// string. The normal HTTP/3 path handles this via a custom h3Transport.Dial
535 +// hook (#7737); the WebTransport path dials through its own Transport and so
536 +// must expand the placeholder itself.
537 +func TestWebTransport_ReverseProxyExpandsSNIPlaceholder(t *testing.T) {
538 + if testing.Short() {
539 + t.Skip()
540 + }
541 +
542 + gotSNI := make(chan string, 1)
543 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
544 + sni := ""
545 + if r.TLS != nil {
546 + sni = r.TLS.ServerName
547 + }
548 + select {
549 + case gotSNI <- sni:
550 + default:
551 + }
552 + _ = sess.CloseWithError(0, "")
553 + })
554 + t.Cleanup(stopUpstream)
555 +
556 + startWTProxy(t, wtReverseProxyHandler(
557 + fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port),
558 + `"insecure_skip_verify": true, "server_name": "{http.request.header.X-SNI}"`,
559 + ))
560 +
561 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
562 + defer cancel()
563 + _, sess := dialWT(t, ctx, http.Header{"X-Sni": []string{"sni.example.com"}}, nil)
564 + defer sess.CloseWithError(0, "")
565 +
566 + select {
567 + case got := <-gotSNI:
568 + if got != "sni.example.com" {
569 + t.Errorf("upstream observed SNI %q; want the expanded placeholder value \"sni.example.com\"", got)
570 + }
571 + case <-time.After(3 * time.Second):
572 + t.Fatal("upstream did not observe a WebTransport session in time")
573 + }
574 +}
575 +
576 +// TestWebTransport_UpstreamDialFailureSurfaces5xx proves the WT path dials
577 +// the upstream BEFORE upgrading the client, so an unreachable upstream
578 +// returns a proper 5xx on the client's Dial call (webtransport-go surfaces
579 +// it via RequirementsNotMetError or similar with the response attached) —
580 +// not a successful Dial followed by an opaque session close.
581 +func TestWebTransport_UpstreamDialFailureSurfaces5xx(t *testing.T) {
582 + if testing.Short() {
583 + t.Skip()
584 + }
585 + l, err := net.ListenPacket("udp", "127.0.0.1:0")
586 + if err != nil {
587 + t.Fatal(err)
588 + }
589 + deadPort := l.LocalAddr().(*net.UDPAddr).Port
590 + _ = l.Close()
591 +
592 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, deadPort)))
593 +
594 + outer, cancel := context.WithTimeout(context.Background(), 6*time.Second)
595 + defer cancel()
596 +
597 + var (
598 + rsp *http.Response
599 + dialErr error
600 + sess *webtransport.Session
601 + )
602 + deadline := time.Now().Add(3 * time.Second)
603 + for {
604 + ctx, c := context.WithTimeout(outer, 2*time.Second)
605 + rsp, sess, dialErr = newWTDialer(nil).Dial(ctx, wtProxyListen, nil)
606 + c()
607 + if dialErr != nil {
608 + break
609 + }
610 + // Happy path dial isn't allowed here — the upstream is dead.
611 + sess.CloseWithError(0, "")
612 + if time.Now().After(deadline) {
613 + t.Fatal("expected Dial to fail against unreachable upstream, got success")
614 + }
615 + time.Sleep(100 * time.Millisecond)
616 + }
617 +
618 + // The exact error type varies with webtransport-go versions, but the
619 + // response (if attached) should carry a 5xx status — proving the
620 + // proxy returned an error status instead of upgrading + closing.
621 + t.Logf("observed dial error: %v", dialErr)
622 + if rsp != nil && rsp.StatusCode < 500 {
623 + t.Errorf("expected 5xx status from proxy on upstream failure; got %d", rsp.StatusCode)
624 + }
625 +}
626 +
627 +// TestWebTransport_InFlightRequestsTracked proves the WT proxy path
628 +// increments upstream.Host.NumRequests for the session's lifetime and
629 +// decrements after it ends, so MaxRequests gating, LeastConn/FirstAvailable
630 +// LB, and the admin /reverse_proxy/upstreams endpoint reflect WT load.
631 +func TestWebTransport_InFlightRequestsTracked(t *testing.T) {
632 + if testing.Short() {
633 + t.Skip()
634 + }
635 +
636 + // Upstream blocks on a release channel until the test finishes probing
637 + // the admin API; this keeps the session alive long enough to observe
638 + // num_requests > 0.
639 + release := make(chan struct{})
640 + t.Cleanup(func() {
641 + select {
642 + case <-release:
643 + default:
644 + close(release)
645 + }
646 + })
647 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
648 + <-release
649 + _ = sess.CloseWithError(0, "")
650 + })
651 + t.Cleanup(stopUpstream)
652 +
653 + upstreamDial := fmt.Sprintf("127.0.0.1:%d", upstreamAddr.Port)
654 + startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "%s"}]`, upstreamDial)))
655 +
656 + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
657 + defer cancel()
658 + _, sess := dialWT(t, ctx, nil, nil)
659 +
660 + if !waitForUpstreamRequests(t, upstreamDial, 1, 2*time.Second) {
661 + t.Fatal("upstream num_requests never reached >= 1 while session was active")
662 + }
663 +
664 + _ = sess.CloseWithError(0, "")
665 + close(release)
666 +
667 + if !waitForUpstreamRequests(t, upstreamDial, 0, 2*time.Second) {
668 + t.Fatal("upstream num_requests did not drop to 0 after session closed")
669 + }
670 +}
671 +
672 +// TestWebTransport_EternalGraceKeepsActiveSession holds an in-flight
673 +// WebTransport session and reloads Caddy to a config with no HTTP servers.
674 +// The default grace period is eternal, so the reload returns and the
675 +// session stays up until the client ends it.
676 +func TestWebTransport_EternalGraceKeepsActiveSession(t *testing.T) {
677 + if testing.Short() {
678 + t.Skip()
679 + }
680 +
681 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
682 + <-sess.Context().Done()
683 + })
684 + t.Cleanup(stopUpstream)
685 +
686 + tester := caddytest.NewTester(t)
687 + tester.InitServer(wtJSONEternalGrace(`"proxy": `+wtH3Server(":9443",
688 + wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))), "json")
689 +
690 + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
691 + defer cancel()
692 + _, sess := dialWT(t, ctx, nil, nil)
693 +
694 + reloaded := make(chan struct{})
695 + go func() {
696 + tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
697 + close(reloaded)
698 + }()
699 + select {
700 + case <-reloaded:
701 + case <-time.After(3 * time.Second):
702 + t.Fatal("reload did not return while the WebTransport session was still open")
703 + }
704 + select {
705 + case <-sess.Context().Done():
706 + t.Fatal("eternal grace closed the active WebTransport session")
707 + case <-time.After(300 * time.Millisecond):
708 + }
709 +
710 + _ = sess.CloseWithError(0, "")
711 + waitForUDPPortFree(t, 9443)
712 +}
713 +
714 +// TestWebTransport_GracePeriodClosesSession holds a session across a reload
715 +// with a finite grace period. The session stays open during the window and
716 +// is closed once the deadline passes.
717 +func TestWebTransport_GracePeriodClosesSession(t *testing.T) {
718 + if testing.Short() {
719 + t.Skip()
720 + }
721 +
722 + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
723 + <-sess.Context().Done()
724 + })
725 + t.Cleanup(stopUpstream)
726 +
727 + grace := time.Second
728 + tester := caddytest.NewTester(t)
729 + tester.InitServer(wtJSONWithHTTPPrefix(
730 + `"proxy": `+wtH3Server(":9443",
731 + wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port))),
732 + fmt.Sprintf(`"grace_period": %d,`, grace.Nanoseconds()),
733 + ), "json")
734 +
735 + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
736 + defer cancel()
737 + _, sess := dialWT(t, ctx, nil, nil)
738 + defer sess.CloseWithError(0, "")
739 +
740 + reloadStart := time.Now()
741 + go tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
742 +
743 + select {
744 + case <-sess.Context().Done():
745 + t.Fatal("WebTransport session closed before the grace period")
746 + case <-time.After(200 * time.Millisecond):
747 + }
748 +
749 + select {
750 + case <-sess.Context().Done():
751 + if time.Since(reloadStart) < grace-300*time.Millisecond {
752 + t.Fatalf("session closed after %s, before grace period %s", time.Since(reloadStart), grace)
753 + }
754 + case <-time.After(grace + time.Second):
755 + t.Fatal("WebTransport session was not closed when the grace period ended")
756 + }
757 + waitForUDPPortFree(t, 9443)
758 +}
759 +
760 +// TestWebTransport_ShutdownDrainsPlainHTTP3 holds a WebTransport session and
761 +// a plain HTTP/3 request open, then reloads. The HTTP/3 request finishes
762 +// during the grace period while the session is still open. The session is
763 +// closed when the grace period ends.
764 +func TestWebTransport_ShutdownDrainsPlainHTTP3(t *testing.T) {
765 + if testing.Short() {
766 + t.Skip()
767 + }
768 +
769 + started := make(chan struct{})
770 + release := make(chan struct{})
771 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
772 + close(started)
773 + <-release
774 + _, _ = io.WriteString(w, "slow-ok")
775 + }))
776 + t.Cleanup(upstream.Close)
777 +
778 + wtUpstream, stopWT := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
779 + <-sess.Context().Done()
780 + })
781 + t.Cleanup(stopWT)
782 +
783 + routes := fmt.Sprintf(`{
784 + "match": [{"path": ["/slow"]}],
785 + "handle": [{
786 + "handler": "reverse_proxy",
787 + "upstreams": [{"dial": "%s"}]
788 + }]
789 + }, {
790 + "handle": [%s]
791 + }`, upstream.Listener.Addr().String(),
792 + wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, wtUpstream.Port)))
793 +
794 + grace := 2 * time.Second
795 + tester := caddytest.NewTester(t)
796 + tester.InitServer(wtJSONWithHTTPPrefix(
797 + `"proxy": `+wtH3ServerRoutes(":9443", routes),
798 + fmt.Sprintf(`"grace_period": %d,`, grace.Nanoseconds()),
799 + ), "json")
800 +
801 + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
802 + defer cancel()
803 + _, sess := dialWT(t, ctx, nil, nil)
804 + defer sess.CloseWithError(0, "")
805 +
806 + tr := &http3.Transport{
807 + TLSClientConfig: &tls.Config{
808 + InsecureSkipVerify: true, //nolint:gosec // local CA
809 + ServerName: "a.caddy.localhost",
810 + NextProtos: []string{http3.NextProtoH3},
811 + },
812 + QUICConfig: &quic.Config{
813 + EnableDatagrams: true,
814 + EnableStreamResetPartialDelivery: true,
815 + },
816 + }
817 + t.Cleanup(func() { _ = tr.Close() })
818 +
819 + type h3Result struct {
820 + body string
821 + err error
822 + }
823 + got := make(chan h3Result, 1)
824 + go func() {
825 + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:9443/slow", nil)
826 + if err != nil {
827 + got <- h3Result{err: err}
828 + return
829 + }
830 + resp, err := tr.RoundTrip(req)
831 + if err != nil {
832 + got <- h3Result{err: err}
833 + return
834 + }
835 + defer resp.Body.Close()
836 + b, err := io.ReadAll(resp.Body)
837 + if err != nil {
838 + got <- h3Result{err: err}
839 + return
840 + }
841 + if resp.StatusCode != http.StatusOK {
842 + got <- h3Result{err: fmt.Errorf("status %d", resp.StatusCode)}
843 + return
844 + }
845 + got <- h3Result{body: string(b)}
846 + }()
847 +
848 + select {
849 + case <-started:
850 + case res := <-got:
851 + t.Fatalf("plain HTTP/3 request failed before the handler started: %v", res.err)
852 + case <-time.After(3 * time.Second):
853 + t.Fatal("plain HTTP/3 request did not reach the upstream")
854 + }
855 +
856 + reloadStart := time.Now()
857 + reloadDone := make(chan struct{})
858 + go func() {
859 + tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
860 + close(reloadDone)
861 + }()
862 +
863 + select {
864 + case <-sess.Context().Done():
865 + t.Fatal("WebTransport session closed before the plain HTTP/3 request finished")
866 + case <-time.After(200 * time.Millisecond):
867 + }
868 + close(release)
869 +
870 + select {
871 + case res := <-got:
872 + if res.err != nil {
873 + t.Fatalf("plain HTTP/3 request aborted during grace: %v", res.err)
874 + }
875 + if res.body != "slow-ok" {
876 + t.Fatalf("plain HTTP/3 body = %q", res.body)
877 + }
878 + case <-time.After(2 * time.Second):
879 + t.Fatal("plain HTTP/3 request did not finish during grace")
880 + }
881 + select {
882 + case <-sess.Context().Done():
883 + t.Fatal("WebTransport session closed while the grace period was still running")
884 + default:
885 + }
886 +
887 + select {
888 + case <-sess.Context().Done():
889 + if time.Since(reloadStart) < grace-300*time.Millisecond {
890 + t.Fatalf("session closed after %s, before grace period %s", time.Since(reloadStart), grace)
891 + }
892 + case <-time.After(grace + time.Second):
893 + t.Fatal("WebTransport session was not closed when the grace period ended")
894 + }
895 +
896 + _ = tr.Close()
897 + select {
898 + case <-reloadDone:
899 + case <-time.After(time.Second):
900 + t.Fatal("reload did not finish after HTTP/3 drained")
901 + }
902 + waitForUDPPortFree(t, 9443)
903 +}
904 +
905 +// TestWebTransport_IdleHTTP3DoesNotPinConfig completes one HTTP/3 request
906 +// and leaves the QUIC connection open. Eternal grace must drop that idle
907 +// connection on reload instead of retaining the old listener.
908 +func TestWebTransport_IdleHTTP3DoesNotPinConfig(t *testing.T) {
909 + if testing.Short() {
910 + t.Skip()
911 + }
912 +
913 + tester := caddytest.NewTester(t)
914 + tester.InitServer(wtJSONEternalGrace(`"proxy": `+wtH3Server(":9443",
915 + `{"handler":"static_response","body":"ok"}`)), "json")
916 +
917 + tr := &http3.Transport{
918 + TLSClientConfig: &tls.Config{
919 + InsecureSkipVerify: true, //nolint:gosec // local CA
920 + ServerName: "a.caddy.localhost",
921 + NextProtos: []string{http3.NextProtoH3},
922 + },
923 + QUICConfig: &quic.Config{
924 + EnableDatagrams: true,
925 + EnableStreamResetPartialDelivery: true,
926 + },
927 + }
928 + t.Cleanup(func() { _ = tr.Close() })
929 +
930 + resp, err := tr.RoundTrip(mustNewRequest(t, "https://127.0.0.1:9443/"))
931 + if err != nil {
932 + t.Fatalf("http/3 request: %v", err)
933 + }
934 + body, err := io.ReadAll(resp.Body)
935 + resp.Body.Close()
936 + if err != nil {
937 + t.Fatalf("read body: %v", err)
938 + }
939 + if resp.StatusCode != http.StatusOK || string(body) != "ok" {
940 + t.Fatalf("response = %d %q", resp.StatusCode, body)
941 + }
942 +
943 + go tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
944 +
945 + // The client transport is still open. An idle keepalive must not hold
946 + // the packet conn for the whole QUIC idle timeout.
947 + waitForUDPPortFree(t, 9443)
948 +}
949 +
950 +// TestWebTransport_FallbackRequestSurvivesReload enables WebTransport on a
951 +// reused QUIC listener that was created without datagrams. The plain HTTP/3
952 +// fallback has to drain an in-flight request across reload.
953 +func TestWebTransport_FallbackRequestSurvivesReload(t *testing.T) {
954 + if testing.Short() {
955 + t.Skip()
956 + }
957 +
958 + started := make(chan struct{})
959 + release := make(chan struct{})
960 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
961 + close(started)
962 + <-release
963 + _, _ = io.WriteString(w, "fallback-ok")
964 + }))
965 + t.Cleanup(upstream.Close)
966 +
967 + grace := 3 * time.Second
968 + graceJSON := fmt.Sprintf(`"grace_period": %d,`, grace.Nanoseconds())
969 + tester := caddytest.NewTester(t)
970 + tester.InitServer(wtJSONWithHTTPPrefix(
971 + `"proxy": `+h3ServerNoWebTransport(":9443", `{"handler":"static_response","body":"plain"}`),
972 + graceJSON,
973 + ), "json")
974 +
975 + routes := fmt.Sprintf(`{
976 + "match": [{"path": ["/slow"]}],
977 + "handle": [{
978 + "handler": "reverse_proxy",
979 + "upstreams": [{"dial": "%s"}]
980 + }]
981 + }`, upstream.Listener.Addr().String())
982 + tester.InitServer(wtJSONWithHTTPPrefix(
983 + `"proxy": `+wtH3ServerRoutes(":9443", routes),
984 + graceJSON,
985 + ), "json")
986 +
987 + if serverAdvertisesDatagrams(t) {
988 + t.Fatal("reused listener advertises datagrams; fallback path was not taken")
989 + }
990 +
991 + tr := &http3.Transport{
992 + TLSClientConfig: &tls.Config{
993 + InsecureSkipVerify: true, //nolint:gosec // local CA
994 + ServerName: "a.caddy.localhost",
995 + NextProtos: []string{http3.NextProtoH3},
996 + },
997 + }
998 + t.Cleanup(func() { _ = tr.Close() })
999 +
1000 + type h3Result struct {
1001 + body string
1002 + err error
1003 + }
1004 + got := make(chan h3Result, 1)
1005 + go func() {
1006 + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:9443/slow", nil)
1007 + if err != nil {
1008 + got <- h3Result{err: err}
1009 + return
1010 + }
1011 + resp, err := tr.RoundTrip(req)
1012 + if err != nil {
1013 + got <- h3Result{err: err}
1014 + return
1015 + }
1016 + defer resp.Body.Close()
1017 + b, err := io.ReadAll(resp.Body)
1018 + if err != nil {
1019 + got <- h3Result{err: err}
1020 + return
1021 + }
1022 + if resp.StatusCode != http.StatusOK {
1023 + got <- h3Result{err: fmt.Errorf("status %d", resp.StatusCode)}
1024 + return
1025 + }
1026 + got <- h3Result{body: string(b)}
1027 + }()
1028 +
1029 + select {
1030 + case <-started:
1031 + case res := <-got:
1032 + t.Fatalf("fallback request failed before the handler started: %v", res.err)
1033 + case <-time.After(3 * time.Second):
1034 + t.Fatal("fallback request did not reach the upstream")
1035 + }
1036 +
1037 + go tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
1038 +
1039 + select {
1040 + case res := <-got:
1041 + t.Fatalf("fallback request aborted before release: %v", res.err)
1042 + case <-time.After(200 * time.Millisecond):
1043 + }
1044 + close(release)
1045 +
1046 + select {
1047 + case res := <-got:
1048 + if res.err != nil {
1049 + t.Fatalf("fallback request aborted during grace: %v", res.err)
1050 + }
1051 + if res.body != "fallback-ok" {
1052 + t.Fatalf("fallback body = %q", res.body)
1053 + }
1054 + case <-time.After(2 * time.Second):
1055 + t.Fatal("fallback request did not finish during grace")
1056 + }
1057 + waitForUDPPortFree(t, 9443)
1058 +}
1059 +
1060 +func wtH3ServerRoutes(listen, routesJSON string) string {
1061 + return fmt.Sprintf(`{
1062 + "listen": [%q],
1063 + "protocols": ["h3"],
1064 + "webtransport": {},
1065 + "routes": [%s],
1066 + "tls_connection_policies": [
1067 + {
1068 + "certificate_selection": {"any_tag": ["cert0"]},
1069 + "default_sni": "a.caddy.localhost"
1070 + }
1071 + ]
1072 +}`, listen, routesJSON)
1073 +}
1074 +
1075 +func startWTProxy(t *testing.T, handlerJSON string) {
1076 + t.Helper()
1077 + caddytest.NewTester(t).InitServer(wtJSON(`"proxy": `+wtH3Server(":9443", handlerJSON)), "json")
1078 +}
1079 +
1080 +func wtJSON(serversJSON string) string {
1081 + return wtJSONWithHTTPPrefix(serversJSON, `"grace_period": 1,`)
1082 +}
1083 +
1084 +// wtJSONEternalGrace omits grace_period. Shutdown waits until active
1085 +// connections finish, which is the default. A WebTransport session stays
1086 +// open until the client ends it.
1087 +func wtJSONEternalGrace(serversJSON string) string {
1088 + return wtJSONWithHTTPPrefix(serversJSON, "")
1089 +}
1090 +
1091 +func wtJSONWithHTTPPrefix(serversJSON, httpPrefix string) string {
1092 + return fmt.Sprintf(`{
1093 + "admin": {"listen": "localhost:2999"},
1094 + "apps": {
1095 + "http": {
1096 + "http_port": 9080,
1097 + "https_port": 9443,
1098 + %s
1099 + "servers": { %s }
1100 + },
1101 + "tls": {
1102 + "certificates": {
1103 + "load_files": [
1104 + {
1105 + "certificate": "/a.caddy.localhost.crt",
1106 + "key": "/a.caddy.localhost.key",
1107 + "tags": ["cert0"]
1108 + }
1109 + ]
1110 + }
1111 + },
1112 + "pki": {"certificate_authorities": {"local": {"install_trust": false}}}
1113 + }
1114 +}`, httpPrefix, serversJSON)
1115 +}
1116 +
1117 +func h3ServerNoWebTransport(listen, handlerJSON string) string {
1118 + return fmt.Sprintf(`{
1119 + "listen": [%q],
1120 + "protocols": ["h3"],
1121 + "routes": [{"handle": [%s]}],
1122 + "tls_connection_policies": [
1123 + {
1124 + "certificate_selection": {"any_tag": ["cert0"]},
1125 + "default_sni": "a.caddy.localhost"
1126 + }
1127 + ]
1128 +}`, listen, handlerJSON)
1129 +}
1130 +
1131 +func serverAdvertisesDatagrams(t *testing.T) bool {
1132 + t.Helper()
1133 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
1134 + defer cancel()
1135 + conn, err := quic.DialAddr(ctx, "127.0.0.1:9443", &tls.Config{
1136 + InsecureSkipVerify: true, //nolint:gosec // local CA
1137 + ServerName: "a.caddy.localhost",
1138 + NextProtos: []string{http3.NextProtoH3},
1139 + }, nil)
1140 + if err != nil {
1141 + t.Fatalf("datagram probe: %v", err)
1142 + }
1143 + defer conn.CloseWithError(0, "")
1144 + return conn.ConnectionState().SupportsDatagrams.Remote
1145 +}
1146 +
1147 +func mustNewRequest(t *testing.T, url string) *http.Request {
1148 + t.Helper()
1149 + req, err := http.NewRequest(http.MethodGet, url, nil)
1150 + if err != nil {
1151 + t.Fatalf("new request: %v", err)
1152 + }
1153 + return req
1154 +}
1155 +
1156 +func wtH3Server(listen, handlerJSON string) string {
1157 + return fmt.Sprintf(`{
1158 + "listen": [%q],
1159 + "protocols": ["h3"],
1160 + "webtransport": {},
1161 + "routes": [{"handle": [%s]}],
1162 + "tls_connection_policies": [
1163 + {
1164 + "certificate_selection": {"any_tag": ["cert0"]},
1165 + "default_sni": "a.caddy.localhost"
1166 + }
1167 + ]
1168 +}`, listen, handlerJSON)
1169 +}
1170 +
1171 +func wtReverseProxyHandler(extraJSON string, tlsExtra ...string) string {
1172 + tls := `"insecure_skip_verify": true`
1173 + if len(tlsExtra) > 0 && tlsExtra[0] != "" {
1174 + tls = tlsExtra[0]
1175 + }
1176 + body := fmt.Sprintf(`"handler": "reverse_proxy",
1177 + "transport": {
1178 + "protocol": "http",
1179 + "versions": ["3"],
1180 + "tls": {%s}
1181 + }`, tls)
1182 + if extraJSON != "" {
1183 + body += ",\n " + extraJSON
1184 + }
1185 + return "{" + body + "}"
1186 +}
1187 +
1188 +func newWTDialer(protocols []string) *webtransport.Transport {
1189 + return &webtransport.Transport{
1190 + TLSClientConfig: &tls.Config{
1191 + InsecureSkipVerify: true, //nolint:gosec // local CA
1192 + ServerName: "a.caddy.localhost",
1193 + NextProtos: []string{http3.NextProtoH3},
1194 + },
1195 + QUICConfig: &quic.Config{
1196 + EnableDatagrams: true,
1197 + EnableStreamResetPartialDelivery: true,
1198 + },
1199 + ApplicationProtocols: protocols,
1200 + }
1201 +}
1202 +
1203 +func dialWT(t *testing.T, ctx context.Context, hdr http.Header, protocols []string) (*http.Response, *webtransport.Session) {
1204 + t.Helper()
1205 + dialer := newWTDialer(protocols)
1206 + deadline := time.Now().Add(3 * time.Second)
1207 + for {
1208 + rsp, sess, err := dialer.Dial(ctx, wtProxyListen, hdr)
1209 + if err == nil {
1210 + return rsp, sess
1211 + }
1212 + if time.Now().After(deadline) {
1213 + t.Fatalf("webtransport dial failed after retries: %v", err)
1214 + }
1215 + time.Sleep(100 * time.Millisecond)
1216 + }
1217 +}
1218 +
1219 +func echoBidi(t *testing.T, ctx context.Context, sess *webtransport.Session, payload string) {
1220 + t.Helper()
1221 + str, err := sess.OpenStreamSync(ctx)
1222 + if err != nil {
1223 + t.Fatalf("open stream: %v", err)
1224 + }
1225 + if _, err := io.WriteString(str, payload); err != nil {
1226 + t.Fatalf("write: %v", err)
1227 + }
1228 + if err := str.Close(); err != nil {
1229 + t.Fatalf("close send: %v", err)
1230 + }
1231 + got, err := io.ReadAll(str)
1232 + if err != nil {
1233 + t.Fatalf("read: %v", err)
1234 + }
1235 + if string(got) != payload {
1236 + t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload)
1237 + }
1238 +}
1239 +
1240 +// waitForUpstreamRequests polls the admin /reverse_proxy/upstreams endpoint
1241 +// until the entry for dial has exactly wantRequests in-flight, or timeout.
1242 +// Returns true on match.
1243 +func waitForUpstreamRequests(t *testing.T, dial string, wantRequests int, timeout time.Duration) bool {
1244 + t.Helper()
1245 + deadline := time.Now().Add(timeout)
1246 + for time.Now().Before(deadline) {
1247 + rsp, err := http.Get("http://localhost:2999/reverse_proxy/upstreams")
1248 + if err != nil {
1249 + time.Sleep(50 * time.Millisecond)
1250 + continue
1251 + }
1252 + var entries []struct {
1253 + Address string `json:"address"`
1254 + NumRequests int `json:"num_requests"`
1255 + }
1256 + err = json.NewDecoder(rsp.Body).Decode(&entries)
1257 + _ = rsp.Body.Close()
1258 + if err != nil {
1259 + time.Sleep(50 * time.Millisecond)
1260 + continue
1261 + }
1262 + for _, e := range entries {
1263 + if e.Address == dial && e.NumRequests == wantRequests {
1264 + return true
1265 + }
1266 + }
1267 + time.Sleep(50 * time.Millisecond)
1268 + }
1269 + return false
1270 +}
1271 +
1272 +// waitForUDPPortFree returns once addr can be bound. Shutdown keeps the
1273 +// packet conn open until WebTransport accept loops finish, and the next
1274 +// test listens on the same port.
1275 +func waitForUDPPortFree(t *testing.T, port int) {
1276 + t.Helper()
1277 + deadline := time.Now().Add(3 * time.Second)
1278 + for time.Now().Before(deadline) {
1279 + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port})
1280 + if err == nil {
1281 + _ = conn.Close()
1282 + return
1283 + }
1284 + time.Sleep(20 * time.Millisecond)
1285 + }
1286 + t.Fatalf("UDP port %d still in use", port)
1287 +}
1288 +
1289 +// startClosingQUICServer accepts QUIC handshakes and closes them. The
1290 +// WebTransport dial fails quickly, so a retry can run without waiting
1291 +// out a handshake timeout.
1292 +func startClosingQUICServer(t *testing.T) (int, func()) {
1293 + t.Helper()
1294 + ln, err := quic.ListenAddr("127.0.0.1:0", newSelfSignedTLSConfig(t, "localhost"), &quic.Config{
1295 + EnableDatagrams: true,
1296 + EnableStreamResetPartialDelivery: true,
1297 + })
1298 + if err != nil {
1299 + t.Fatal(err)
1300 + }
1301 + ctx, cancel := context.WithCancel(context.Background())
1302 + go func() {
1303 + for {
1304 + conn, err := ln.Accept(ctx)
1305 + if err != nil {
1306 + return
1307 + }
1308 + _ = conn.CloseWithError(0, "unavailable")
1309 + }
1310 + }()
1311 + return ln.Addr().(*net.UDPAddr).Port, func() {
1312 + cancel()
1313 + _ = ln.Close()
1314 + }
1315 +}
1316 +
1317 +// startStandaloneWebTransport starts a webtransport.Server on a random UDP
1318 +// port with a self-signed cert. handler runs after a successful Upgrade.
1319 +// Returns the listener addr and a shutdown func.
1320 +func startStandaloneWebTransport(t *testing.T, handler func(s *webtransport.Session, r *http.Request), protocols ...string) (*net.UDPAddr, func()) {
1321 + return startStandaloneWebTransportBeforeUpgrade(t, nil, handler, protocols...)
1322 +}
1323 +
1324 +func startStandaloneWebTransportBeforeUpgrade(t *testing.T, beforeUpgrade func(http.ResponseWriter, *http.Request), handler func(s *webtransport.Session, r *http.Request), protocols ...string) (*net.UDPAddr, func()) {
1325 + t.Helper()
1326 + tlsCfg := newSelfSignedTLSConfig(t, "localhost")
1327 +
1328 + mux := http.NewServeMux()
1329 + h3 := &http3.Server{
1330 + TLSConfig: tlsCfg,
1331 + Handler: mux,
1332 + QUICConfig: &quic.Config{
1333 + EnableDatagrams: true,
1334 + EnableStreamResetPartialDelivery: true,
1335 + },
1336 + }
1337 + webtransport.ConfigureHTTP3Server(h3)
1338 + wtServer := &webtransport.Server{
1339 + H3: h3,
1340 + ApplicationProtocols: protocols,
1341 + // Test backends are not browsers; skip same-origin checks so
1342 + // header_up Host rewrites used by proxy tests do not 400 the
1343 + // upstream Upgrade.
1344 + CheckOrigin: func(*http.Request) bool { return true },
1345 + }
1346 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
1347 + if beforeUpgrade != nil {
1348 + beforeUpgrade(w, r)
1349 + }
1350 + sess, err := wtServer.Upgrade(w, r)
1351 + if err != nil {
1352 + t.Logf("standalone WebTransport upgrade failed: %v", err)
1353 + w.WriteHeader(http.StatusBadRequest)
1354 + return
1355 + }
1356 + handler(sess, r)
1357 + })
1358 +
1359 + udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
1360 + if err != nil {
1361 + t.Fatal(err)
1362 + }
1363 + conn, err := net.ListenUDP("udp", udpAddr)
1364 + if err != nil {
1365 + t.Fatal(err)
1366 + }
1367 + servErr := make(chan error, 1)
1368 + go func() { servErr <- wtServer.Serve(conn) }()
1369 + shutdown := func() {
1370 + _ = wtServer.Close()
1371 + <-servErr
1372 + _ = conn.Close()
1373 + }
1374 + return conn.LocalAddr().(*net.UDPAddr), shutdown
1375 +}
1376 +
1377 +// newSelfSignedTLSConfig produces a self-signed TLS config suitable for
1378 +// 127.0.0.1 and the given common name, with the H3 ALPN advertised.
1379 +func newSelfSignedTLSConfig(t *testing.T, cn string) *tls.Config {
1380 + t.Helper()
1381 + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
1382 + if err != nil {
1383 + t.Fatal(err)
1384 + }
1385 + tmpl := x509.Certificate{
1386 + SerialNumber: big.NewInt(1),
1387 + Subject: pkix.Name{CommonName: cn},
1388 + NotBefore: time.Now().Add(-time.Minute),
1389 + NotAfter: time.Now().Add(time.Hour),
1390 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
1391 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
1392 + DNSNames: []string{cn},
1393 + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
1394 + IsCA: true,
1395 + }
1396 + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, priv.Public(), priv)
1397 + if err != nil {
1398 + t.Fatal(err)
1399 + }
1400 + cert, err := x509.ParseCertificate(der)
1401 + if err != nil {
1402 + t.Fatal(err)
1403 + }
1404 + return &tls.Config{
1405 + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv, Leaf: cert}},
1406 + NextProtos: []string{http3.NextProtoH3},
1407 + }
1408 +}
go.mod
+1
@@ -23,6 +23,7 @@ require (
23 github.com/mholt/acmez/v3 v3.1.7
24 github.com/prometheus/client_golang v1.24.1
25 github.com/quic-go/quic-go v0.63.0
26 + github.com/quic-go/webtransport-go v0.13.0
27 github.com/smallstep/certificates v0.30.2
28 github.com/smallstep/nosql v0.8.0
29 github.com/smallstep/truststore v0.13.0
go.sum
+2
@@ -291,6 +291,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
291 github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
292 github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A=
293 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
294 +github.com/quic-go/webtransport-go v0.13.0 h1:RJLrTUHlTj8jJaQlQJUy0z0Mf7u1fVM0I6L1b9pe2M0=
295 +github.com/quic-go/webtransport-go v0.13.0/go.mod h1:K83X9YHbAqgSLO6ikS6BXCMdWOvqh9JTHALulvb2JVk=
296 github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
297 github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
298 github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
listeners.go
+51 -14
@@ -425,15 +425,34 @@ func JoinNetworkAddress(network, host, port string) string {
425 return a
426 }
427
428 +// ListenQUICOptions holds optional parameters for ListenQUIC.
429 +// A zero value matches historical defaults: 0-RTT is allowed,
430 +// WebTransport QUIC capabilities are off, and no packet-conn
431 +// wrappers are applied.
432 +//
433 +// NOTE: This API is EXPERIMENTAL and may be changed or removed.
434 +type ListenQUICOptions struct {
435 + TLSConfig *tls.Config
436 + PacketConnWrappers []PacketConnWrapper
437 + Allow0RTT *bool
438 + EnableWebTransport bool
439 +}
440 +
441 // ListenQUIC returns a http3.QUICEarlyListener suitable for use in a Caddy module.
442 //
443 // The network will be transformed into a QUIC-compatible type if the same address can be used with
444 // different networks. Currently this just means that for tcp, udp will be used with the same
445 // address instead.
446 //
447 +// ctx, portOffset, and config match Listen. Additional QUIC-specific
448 +// parameters are passed in opts.
449 +//
450 +// The underlying listener is pooled per address: the first creator's configuration
451 +// (Allow0RTT, WebTransport capabilities) wins; subsequent callers reuse that listener.
452 +//
453 // NOTE: This API is EXPERIMENTAL and may be changed or removed.
454 // NOTE: user should close the returned listener twice, once to stop accepting new connections, the second time to free up the packet conn.
436 -func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config net.ListenConfig, tlsConf *tls.Config, pcWrappers []PacketConnWrapper, allow0rttconf *bool) (http3.QUICListener, error) {
455 +func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config net.ListenConfig, opts ListenQUICOptions) (http3.QUICListener, error) {
456 lnKey := listenerKey("quic"+na.Network, na.JoinHostPort(portOffset))
457
458 sharedEarlyListener, _, err := listenerPool.LoadOrNew(lnKey, func() (Destructor, error) {
@@ -445,7 +464,7 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config
464 ln := lnAny.(net.PacketConn)
465
466 h3ln := ln
448 - if len(pcWrappers) == 0 {
467 + if len(opts.PacketConnWrappers) == 0 {
468 for {
469 // retrieve the underlying socket, so quic-go can optimize.
470 if unwrapper, ok := h3ln.(interface{ Unwrap() net.PacketConn }); ok {
@@ -456,12 +475,12 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config
475 }
476 } else {
477 // wrap packet conn before QUIC
459 - for _, pcWrapper := range pcWrappers {
478 + for _, pcWrapper := range opts.PacketConnWrappers {
479 h3ln = pcWrapper.WrapPacketConn(h3ln)
480 }
481 }
482
464 - sqs := newSharedQUICState(tlsConf)
483 + sqs := newSharedQUICState(opts.TLSConfig)
484 // http3.ConfigureTLSConfig only uses this field and tls App sets this field as well
485 //nolint:gosec
486 quicTlsConfig := &tls.Config{
@@ -476,15 +495,19 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config
495 VerifySourceAddress: func(addr net.Addr) bool { return !limiter.Allow() },
496 }
497 allow0rtt := true
479 - if allow0rttconf != nil {
480 - allow0rtt = *allow0rttconf
498 + if opts.Allow0RTT != nil {
499 + allow0rtt = *opts.Allow0RTT
500 }
501 + // EnableDatagrams and EnableStreamResetPartialDelivery are required by
502 + // WebTransport; only enable them when WebTransport support is requested.
503 earlyLn, err := tr.ListenEarly(
504 http3.ConfigureTLSConfig(quicTlsConfig),
505 &quic.Config{
485 - InitialPacketSize: 1200,
486 - Allow0RTT: allow0rtt,
487 - Tracer: h3qlog.DefaultConnectionTracer,
506 + InitialPacketSize: 1200,
507 + Allow0RTT: allow0rtt,
508 + Tracer: h3qlog.DefaultConnectionTracer,
509 + EnableDatagrams: opts.EnableWebTransport,
510 + EnableStreamResetPartialDelivery: opts.EnableWebTransport,
511 },
512 )
513 if err != nil {
@@ -492,7 +515,13 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config
515 }
516 // TODO: figure out when to close the listener and the transport
517 // using the original net.PacketConn to close them properly
495 - return &sharedQuicListener{EarlyListener: earlyLn, packetConn: ln, sqs: sqs, key: lnKey}, nil
518 + return &sharedQuicListener{
519 + EarlyListener: earlyLn,
520 + packetConn: ln,
521 + sqs: sqs,
522 + key: lnKey,
523 + supportsWebTransport: opts.EnableWebTransport,
524 + }, nil
525 })
526 if err != nil {
527 return nil, err
@@ -500,7 +529,7 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config
529
530 sql := sharedEarlyListener.(*sharedQuicListener)
531 // add current tls.Config to sqs, so GetConfigForClient will always return the latest tls.Config in case of context cancellation
503 - ctx, cancel := sql.sqs.addState(tlsConf)
532 + ctx, cancel := sql.sqs.addState(opts.TLSConfig)
533
534 return &fakeCloseQuicListener{
535 sharedQuicListener: sql,
@@ -594,9 +623,17 @@ func (sqs *sharedQUICState) addState(tlsConfig *tls.Config) (context.Context, co
623 // sharedQuicListener is like sharedListener, but for quic.EarlyListeners.
624 type sharedQuicListener struct {
625 *quic.EarlyListener
597 - packetConn net.PacketConn // we have to hold these because quic-go won't close listeners it didn't create
598 - sqs *sharedQUICState
599 - key string
626 + packetConn net.PacketConn // we have to hold these because quic-go won't close listeners it didn't create
627 + sqs *sharedQUICState
628 + key string
629 + supportsWebTransport bool // set by the first creator; subsequent pool reuses keep this value
630 +}
631 +
632 +// SupportsWebTransport reports whether the shared QUIC listener was created
633 +// with WebTransport capabilities (DATAGRAM support and stream-reset partial
634 +// delivery). The value is fixed by the first creator of a pooled listener.
635 +func (sql *sharedQuicListener) SupportsWebTransport() bool {
636 + return sql.supportsWebTransport
637 }
638
639 // Destruct closes the underlying QUIC listener and its associated net.PacketConn.
listeners_test.go
+92
@@ -15,7 +15,9 @@
15 package caddy
16
17 import (
18 + "context"
19 "crypto/tls"
20 + "net"
21 "reflect"
22 "testing"
23
@@ -710,3 +712,93 @@ func TestSplitUnixSocketPermissionsBits(t *testing.T) {
712 }
713 }
714 }
715 +
716 +// TestListenQUICOptions_ZeroValueAllows0RTT checks that ListenQUIC accepts a
717 +// zero-value options struct (historical default: 0-RTT allowed) and that the
718 +// returned listener is released by closing it twice, per ListenQUIC's contract.
719 +func TestListenQUICOptions_ZeroValueAllows0RTT(t *testing.T) {
720 + na, err := ParseNetworkAddress("udp/127.0.0.1:0")
721 + if err != nil {
722 + t.Fatalf("ParseNetworkAddress: %v", err)
723 + }
724 +
725 + ln, err := na.ListenQUIC(context.Background(), 0, net.ListenConfig{}, ListenQUICOptions{
726 + TLSConfig: &tls.Config{}, //nolint:gosec // test listener; no client handshake
727 + })
728 + if err != nil {
729 + t.Fatalf("ListenQUIC: %v", err)
730 + }
731 + if err := ln.Close(); err != nil {
732 + t.Errorf("first Close: %v", err)
733 + }
734 + if err := ln.Close(); err != nil {
735 + t.Errorf("second Close: %v", err)
736 + }
737 +}
738 +
739 +// TestListenQUICWebTransportCapability verifies that WebTransport QUIC
740 +// capabilities are fixed by the first creator of a pooled QUIC listener.
741 +func TestListenQUICWebTransportCapability(t *testing.T) {
742 + closeTwice := func(t *testing.T, ln interface{ Close() error }) {
743 + t.Helper()
744 + if err := ln.Close(); err != nil {
745 + t.Errorf("first Close: %v", err)
746 + }
747 + if err := ln.Close(); err != nil {
748 + t.Errorf("second Close: %v", err)
749 + }
750 + }
751 + supportsWT := func(t *testing.T, ln any) bool {
752 + t.Helper()
753 + wt, ok := ln.(interface{ SupportsWebTransport() bool })
754 + if !ok {
755 + t.Fatal("listener does not implement SupportsWebTransport()")
756 + }
757 + return wt.SupportsWebTransport()
758 + }
759 +
760 + for _, tc := range []struct {
761 + name string
762 + first, want bool
763 + }{
764 + {name: "off-then-on", first: false, want: false},
765 + {name: "on-then-off", first: true, want: true},
766 + } {
767 + t.Run(tc.name, func(t *testing.T) {
768 + probe, err := net.ListenPacket("udp", "127.0.0.1:0")
769 + if err != nil {
770 + t.Fatalf("ListenPacket probe: %v", err)
771 + }
772 + port := uint(probe.LocalAddr().(*net.UDPAddr).Port)
773 + _ = probe.Close()
774 +
775 + na := NetworkAddress{Network: "udp", Host: "127.0.0.1", StartPort: port, EndPort: port}
776 + tlsConf := &tls.Config{}
777 + listen := func(enable bool) (interface{ Close() error }, error) {
778 + return na.ListenQUIC(context.Background(), 0, net.ListenConfig{}, ListenQUICOptions{
779 + TLSConfig: tlsConf,
780 + EnableWebTransport: enable,
781 + })
782 + }
783 +
784 + ln1, err := listen(tc.first)
785 + if err != nil {
786 + t.Fatalf("first ListenQUIC: %v", err)
787 + }
788 + ln2, err := listen(!tc.first)
789 + if err != nil {
790 + closeTwice(t, ln1)
791 + t.Fatalf("second ListenQUIC: %v", err)
792 + }
793 + defer closeTwice(t, ln1)
794 + defer closeTwice(t, ln2)
795 +
796 + if got := supportsWT(t, ln1); got != tc.want {
797 + t.Errorf("first listener SupportsWebTransport() = %v, want %v", got, tc.want)
798 + }
799 + if got := supportsWT(t, ln2); got != tc.want {
800 + t.Errorf("second listener SupportsWebTransport() = %v, want %v (pool reuses first creator's config)", got, tc.want)
801 + }
802 + })
803 + }
804 +}
modules/caddyhttp/app.go
+24
@@ -815,6 +815,25 @@ func (app *App) stop(exiting bool) error {
815 }
816 }
817
818 + // ServeQUICConn connections are not in the HTTP/3 server's set, so
819 + // Shutdown does not wait for them and, with none tracked, it closes
820 + // the server immediately. webtransport.Server.Close would do that
821 + // too. waitWebTransportConns keeps the sockets up until the accept
822 + // loops finish. GracePeriod 0 is eternal: the wait ends when the
823 + // peer leaves, and the session is not force-closed. A finite grace
824 + // period closes sessions at the deadline, while the sockets are
825 + // still up, so the close capsule can reach the client.
826 + // The close below is for a session whose loop already returned.
827 + // Fallback HTTP/3 servers are drained at the same time so an
828 + // active request on a reused non-WebTransport listener gets this
829 + // grace period instead of dying when the packet conn closes.
830 + fbErr := make(chan error, 1)
831 + go func() {
832 + fbErr <- server.shutdownFallbackHTTP3(ctx)
833 + }()
834 + server.waitWebTransportConns(ctx)
835 + server.closeWebTransportSessions()
836 +
837 if err := server.h3server.Shutdown(ctx); err != nil {
838 if cause := context.Cause(ctx); cause != nil && errors.Is(err, context.DeadlineExceeded) {
839 err = cause
@@ -823,6 +842,11 @@ func (app *App) stop(exiting bool) error {
842 zap.Error(err),
843 zap.Strings("addresses", server.Listen))
844 }
845 + if err := <-fbErr; err != nil {
846 + app.logger.Error("HTTP/3 fallback shutdown",
847 + zap.Error(err),
848 + zap.Strings("addresses", server.Listen))
849 + }
850
851 // close the underlying net.PacketConns now
852 // see the comment for ListenQUIC
modules/caddyhttp/responsewriter.go
+66
@@ -21,6 +21,7 @@ import (
21 "io"
22 "net"
23 "net/http"
24 + "reflect"
25
26 "github.com/caddyserver/caddy/v2"
27 )
@@ -60,6 +61,71 @@ func (rww *ResponseWriterWrapper) Unwrap() http.ResponseWriter {
61 return rww.ResponseWriter
62 }
63
64 +// UnwrapResponseWriterAs walks w through its Unwrap() http.ResponseWriter
65 +// chain and returns the first writer that satisfies T (along with true).
66 +// If no writer in the chain satisfies T, it returns the zero value of T
67 +// and false. This mirrors how http.ResponseController traverses wrapped
68 +// writers internally and is useful when code needs to reach interfaces
69 +// implemented only by the raw writer owned by the HTTP server — for
70 +// example, Extended CONNECT or WebTransport helpers that perform their
71 +// own type assertions and cannot see past a wrapper.
72 +func UnwrapResponseWriterAs[T any](w http.ResponseWriter) (T, bool) {
73 + var zero T
74 + seen := make(map[uintptr]struct{})
75 + // Bound the walk so a cycle of uncomparable (non-pointer) writers
76 + // cannot hang. Production wrapper stacks are a handful of layers.
77 + for range 64 {
78 + if w == nil {
79 + return zero, false
80 + }
81 + if t, ok := any(w).(T); ok {
82 + return t, true
83 + }
84 + if p := responseWriterPointer(w); p != 0 {
85 + if _, dup := seen[p]; dup {
86 + return zero, false
87 + }
88 + seen[p] = struct{}{}
89 + }
90 + u, ok := w.(interface{ Unwrap() http.ResponseWriter })
91 + if !ok {
92 + return zero, false
93 + }
94 + w = u.Unwrap()
95 + }
96 + return zero, false
97 +}
98 +
99 +func responseWriterPointer(w http.ResponseWriter) uintptr {
100 + v := reflect.ValueOf(w)
101 + switch v.Kind() {
102 + case reflect.Pointer, reflect.Map, reflect.Chan, reflect.Func, reflect.Slice, reflect.UnsafePointer:
103 + if v.IsNil() {
104 + return 0
105 + }
106 + return v.Pointer()
107 + default:
108 + return 0
109 + }
110 +}
111 +
112 +// RecordHijackedStatus records that a status was already flushed to the
113 +// client by a writer further down the unwrap chain (for example
114 +// webtransport.Server.Upgrade writing through the naked HTTP/3 writer).
115 +// It updates any wrapping responseRecorder so access logs and metrics
116 +// observe the client-visible status, without writing headers again.
117 +func RecordHijackedStatus(w http.ResponseWriter, status int) {
118 + rec, ok := UnwrapResponseWriterAs[*responseRecorder](w)
119 + if !ok || rec == nil {
120 + return
121 + }
122 + if rec.statusCode == 0 {
123 + rec.statusCode = status
124 + }
125 + rec.wroteHeader = true
126 + rec.stream = true
127 +}
128 +
129 // ErrNotImplemented is returned when an underlying
130 // ResponseWriter does not implement the required method.
131 var ErrNotImplemented = fmt.Errorf("method not implemented")
modules/caddyhttp/responsewriter_test.go
+145
@@ -307,3 +307,148 @@ func TestResponseRecorderNestedDetach(t *testing.T) {
307 })
308 }
309 }
310 +
311 +// targetIface is an interface that only the innermost writer in the tests
312 +// below implements; it's used to assert UnwrapResponseWriterAs walks past
313 +// outer wrappers to find it.
314 +type targetIface interface {
315 + http.ResponseWriter
316 + magic() string
317 +}
318 +
319 +type targetWriter struct {
320 + baseRespWriter
321 +}
322 +
323 +func (*targetWriter) magic() string { return "ok" }
324 +
325 +// plainWrapper wraps an http.ResponseWriter and forwards only the mandatory
326 +// methods. It implements Unwrap() so the helper can traverse it.
327 +type plainWrapper struct{ inner http.ResponseWriter }
328 +
329 +func (p *plainWrapper) Header() http.Header { return p.inner.Header() }
330 +func (p *plainWrapper) Write(b []byte) (int, error) { return p.inner.Write(b) }
331 +func (p *plainWrapper) WriteHeader(statusCode int) { p.inner.WriteHeader(statusCode) }
332 +func (p *plainWrapper) Unwrap() http.ResponseWriter { return p.inner }
333 +
334 +func TestUnwrapResponseWriterAs(t *testing.T) {
335 + inner := &targetWriter{}
336 + for _, tc := range []struct {
337 + name string
338 + w http.ResponseWriter
339 + ok bool
340 + }{
341 + {"direct", inner, true},
342 + {"single wrapper", &ResponseWriterWrapper{ResponseWriter: inner}, true},
343 + {"multiple wrappers", &plainWrapper{inner: &ResponseWriterWrapper{ResponseWriter: &plainWrapper{inner: inner}}}, true},
344 + {"not found", &ResponseWriterWrapper{ResponseWriter: &baseRespWriter{}}, false},
345 + } {
346 + t.Run(tc.name, func(t *testing.T) {
347 + got, ok := UnwrapResponseWriterAs[targetIface](tc.w)
348 + if ok != tc.ok {
349 + t.Fatalf("ok = %v, want %v", ok, tc.ok)
350 + }
351 + if ok && got.magic() != "ok" {
352 + t.Errorf("unexpected writer returned: %v", got)
353 + }
354 + })
355 + }
356 +}
357 +
358 +type selfUnwrapWriter struct{ baseRespWriter }
359 +
360 +func (s *selfUnwrapWriter) Unwrap() http.ResponseWriter { return s }
361 +
362 +func TestUnwrapResponseWriterAs_StopsOnSelfReference(t *testing.T) {
363 + // A wrapper whose Unwrap returns itself must not loop forever.
364 + loop := &selfUnwrapWriter{}
365 + done := make(chan struct{})
366 + go func() {
367 + defer close(done)
368 + _, _ = UnwrapResponseWriterAs[targetIface](loop)
369 + }()
370 + select {
371 + case <-done:
372 + case <-time.After(time.Second):
373 + t.Fatal("UnwrapResponseWriterAs hung on self-referential Unwrap")
374 + }
375 +}
376 +
377 +// uncomparableWriter contains a slice so the concrete type is not comparable.
378 +// The previous `next == w` guard panics on this shape. Methods use value
379 +// receivers so the wrapper is stored as a non-pointer in the interface.
380 +type uncomparableWriter struct {
381 + hdr http.Header
382 + _ []byte
383 +}
384 +
385 +func (u uncomparableWriter) Header() http.Header { return u.hdr }
386 +func (u uncomparableWriter) Write(b []byte) (int, error) { return len(b), nil }
387 +func (u uncomparableWriter) WriteHeader(int) {}
388 +func (u uncomparableWriter) Unwrap() http.ResponseWriter { return u }
389 +
390 +func TestUnwrapResponseWriterAs_UncomparableWriter(t *testing.T) {
391 + w := uncomparableWriter{}
392 + done := make(chan struct{})
393 + var panicked any
394 + go func() {
395 + defer close(done)
396 + defer func() { panicked = recover() }()
397 + _, _ = UnwrapResponseWriterAs[targetIface](w)
398 + }()
399 + select {
400 + case <-done:
401 + if panicked != nil {
402 + t.Fatalf("panicked on uncomparable writer: %v", panicked)
403 + }
404 + case <-time.After(time.Second):
405 + t.Fatal("UnwrapResponseWriterAs hung on uncomparable self-unwrap")
406 + }
407 +}
408 +
409 +type cycleWriter struct {
410 + baseRespWriter
411 + next http.ResponseWriter
412 +}
413 +
414 +func (c *cycleWriter) Unwrap() http.ResponseWriter { return c.next }
415 +
416 +func TestUnwrapResponseWriterAs_StopsOnIndirectCycle(t *testing.T) {
417 + a := &cycleWriter{}
418 + b := &cycleWriter{next: a}
419 + a.next = b
420 + done := make(chan struct{})
421 + go func() {
422 + defer close(done)
423 + _, _ = UnwrapResponseWriterAs[targetIface](a)
424 + }()
425 + select {
426 + case <-done:
427 + case <-time.After(time.Second):
428 + t.Fatal("UnwrapResponseWriterAs hung on A→B→A unwrap cycle")
429 + }
430 +}
431 +
432 +type headerSpy struct {
433 + baseRespWriter
434 + status int
435 +}
436 +
437 +func (h *headerSpy) WriteHeader(code int) { h.status = code }
438 +
439 +func TestRecordHijackedStatus(t *testing.T) {
440 + inner := &headerSpy{}
441 + rec := NewResponseRecorder(inner, new(bytes.Buffer), nil)
442 + wrapped := &ResponseWriterWrapper{ResponseWriter: rec}
443 +
444 + if rec.Status() != 0 {
445 + t.Fatalf("status = %d, want 0 before hijack", rec.Status())
446 + }
447 + RecordHijackedStatus(wrapped, http.StatusOK)
448 + if rec.Status() != http.StatusOK {
449 + t.Fatalf("status = %d, want 200", rec.Status())
450 + }
451 + if inner.status != 0 {
452 + t.Errorf("WriteHeader(%d) called on inner writer; hijack must not write again", inner.status)
453 + }
454 +}
modules/caddyhttp/reverseproxy/passive_health_test.go
+42
@@ -22,6 +22,48 @@ import (
22 "github.com/caddyserver/caddy/v2"
23 )
24
25 +type recordCB struct {
26 + status int
27 + lat time.Duration
28 + n int
29 +}
30 +
31 +func (c *recordCB) OK() bool { return true }
32 +
33 +func (c *recordCB) RecordMetric(statusCode int, latency time.Duration) {
34 + c.status = statusCode
35 + c.lat = latency
36 + c.n++
37 +}
38 +
39 +func TestRecordUpstreamRoundTrip(t *testing.T) {
40 + h, cancel := newPassiveHandler(t, 1, time.Minute)
41 + defer cancel()
42 + h.HealthChecks.Passive.UnhealthyLatency = caddy.Duration(time.Millisecond)
43 + u, cleanup := provisionedStaticUpstream(t, h, "127.0.0.1:9")
44 + defer cleanup()
45 + cb := &recordCB{}
46 + u.cb = cb
47 +
48 + repl := caddy.NewReplacer()
49 + lat := 5 * time.Millisecond
50 + h.recordUpstreamRoundTrip(DialInfo{Upstream: u}, repl, 200, lat)
51 +
52 + if cb.n != 1 || cb.status != 200 {
53 + t.Errorf("circuit breaker RecordMetric calls=%d status=%d, want 1 / 200", cb.n, cb.status)
54 + }
55 + if cb.lat != lat {
56 + t.Errorf("circuit breaker latency = %v, want %v", cb.lat, lat)
57 + }
58 + if got := u.Host.Fails(); got != 1 {
59 + t.Errorf("passive fails = %d, want 1 (unhealthy latency)", got)
60 + }
61 + got, _ := repl.Get("http.reverse_proxy.upstream.latency")
62 + if got != lat {
63 + t.Errorf("latency placeholder = %v, want %v", got, lat)
64 + }
65 +}
66 +
67 // newPassiveHandler builds a minimal Handler with passive health checks
68 // configured and a live caddy.Context so the fail-forgetter goroutine can
69 // be cancelled cleanly. The caller must call cancel() when done.
modules/caddyhttp/reverseproxy/reverseproxy.go
+59 -37
@@ -853,12 +853,13 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h
853 return true, nil
854 }
855
856 - // if the roundtrip was successful, don't retry the request or
857 - // ding the health status of the upstream (an error can still
858 - // occur after the roundtrip if, for example, a response handler
859 - // after the roundtrip returns an error)
860 - if succ, ok := proxyErr.(roundtripSucceededError); ok {
861 - return true, succ.error
856 + // if the handler has already committed a client-visible response
857 + // (e.g. a successful roundtrip whose handle_response route errored,
858 + // or a WebTransport upgrade that flushed 200 OK and hijacked the
859 + // stream), don't retry against another upstream and don't ding the
860 + // upstream's health status
861 + if term, ok := proxyErr.(terminalError); ok {
862 + return true, term.error
863 }
864
865 // remember this failure (if enabled); response-based retries
@@ -1161,6 +1162,18 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe
1162 server := req.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
1163 shouldLogCredentials := server.Logs != nil && server.Logs.ShouldLogCredentials
1164
1165 + // WebTransport: Extended CONNECT :protocol=webtransport can't flow
1166 + // through the normal HTTP round-trip — the session hosts many QUIC
1167 + // streams and datagrams that need bidirectional pumping. Swap
1168 + // RoundTrip for a small hijack helper. Upstream dial failures
1169 + // surface as DialError so the loop can retry across upstreams;
1170 + // pre-upgrade misconfig and post-upgrade failures return
1171 + // terminalError because the response is already committed to the
1172 + // client or no upstream can fix the condition.
1173 + if isWebTransportExtendedConnect(origReq) {
1174 + return h.webTransportHijack(rw, req, origReq, repl, di, server)
1175 + }
1176 +
1177 // Forward 1xx status codes, backported from https://github.com/golang/go/pull/53164
1178 var (
1179 roundTripMutex sync.Mutex
@@ -1236,30 +1249,7 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe
1249 )
1250 }
1251
1239 - // duration until upstream wrote response headers (roundtrip duration)
1240 - repl.Set("http.reverse_proxy.upstream.latency", duration)
1241 - repl.Set("http.reverse_proxy.upstream.latency_ms", duration.Seconds()*1e3) // multiply seconds to preserve decimal (see #4666)
1242 -
1243 - // update circuit breaker on current conditions
1244 - if di.Upstream.cb != nil {
1245 - di.Upstream.cb.RecordMetric(res.StatusCode, duration)
1246 - }
1247 -
1248 - // perform passive health checks (if enabled)
1249 - if h.HealthChecks != nil && h.HealthChecks.Passive != nil {
1250 - // strike if the status code matches one that is "bad"
1251 - for _, badStatus := range h.HealthChecks.Passive.UnhealthyStatus {
1252 - if caddyhttp.StatusCodeMatches(res.StatusCode, badStatus) {
1253 - h.countFailure(di.Upstream)
1254 - }
1255 - }
1256 -
1257 - // strike if the roundtrip took too long
1258 - if h.HealthChecks.Passive.UnhealthyLatency > 0 &&
1259 - duration >= time.Duration(h.HealthChecks.Passive.UnhealthyLatency) {
1260 - h.countFailure(di.Upstream)
1261 - }
1262 - }
1252 + h.recordUpstreamRoundTrip(di, repl, res.StatusCode, duration)
1253
1254 // if enabled, buffer the response body
1255 if h.ResponseBuffers != 0 && !caddyhttp.IsIncremental(res.Header) {
@@ -1355,10 +1345,10 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe
1345 res.Body.Close()
1346 }
1347
1358 - // wrap any route error in roundtripSucceededError so caller knows that
1359 - // the roundtrip was successful and to not retry
1348 + // wrap any route error in terminalError so the outer loop knows
1349 + // the response is committed and must not be retried
1350 if routeErr != nil {
1361 - return roundtripSucceededError{routeErr}
1351 + return terminalError{routeErr}
1352 }
1353
1354 // we're done handling the response, and we don't want to
@@ -1422,7 +1412,7 @@ func (h *Handler) finalizeResponse(
1412 if responseHasContent(req, res) && h.ResponseBuffers != 0 &&
1413 (responseWasIncremental || caddyhttp.IsIncremental(res.Header)) {
1414 res.Body.Close()
1425 - return roundtripSucceededError{h.refuseIncremental(rw, res.Header)}
1415 + return terminalError{h.refuseIncremental(rw, res.Header)}
1416 }
1417
1418 copyHeader(rw.Header(), res.Header)
@@ -1740,6 +1730,31 @@ func copyHeader(dst, src http.Header) {
1730 }
1731 }
1732
1733 +// recordUpstreamRoundTrip publishes latency placeholders, records circuit
1734 +// breaker metrics, and applies passive health-check strikes for one
1735 +// completed upstream attempt. Used by both the HTTP round-trip path and
1736 +// the WebTransport hijack path.
1737 +func (h *Handler) recordUpstreamRoundTrip(di DialInfo, repl *caddy.Replacer, status int, duration time.Duration) {
1738 + repl.Set("http.reverse_proxy.upstream.latency", duration)
1739 + repl.Set("http.reverse_proxy.upstream.latency_ms", duration.Seconds()*1e3) // multiply seconds to preserve decimal (see #4666)
1740 +
1741 + if di.Upstream != nil && di.Upstream.cb != nil {
1742 + di.Upstream.cb.RecordMetric(status, duration)
1743 + }
1744 +
1745 + if h.HealthChecks != nil && h.HealthChecks.Passive != nil {
1746 + for _, badStatus := range h.HealthChecks.Passive.UnhealthyStatus {
1747 + if caddyhttp.StatusCodeMatches(status, badStatus) {
1748 + h.countFailure(di.Upstream)
1749 + }
1750 + }
1751 + if h.HealthChecks.Passive.UnhealthyLatency > 0 &&
1752 + duration >= time.Duration(h.HealthChecks.Passive.UnhealthyLatency) {
1753 + h.countFailure(di.Upstream)
1754 + }
1755 + }
1756 +}
1757 +
1758 // allHeaderValues gets all values for a given header field,
1759 // joined by a comma and space if more than one is set. If the
1760 // header field is nil, then the omit is true, meaning some
@@ -2171,9 +2186,16 @@ func matcherSetReferencesResponse(matcherSet caddyhttp.MatcherSet) bool {
2186 return false
2187 }
2188
2174 -// roundtripSucceededError is an error type that is returned if the
2175 -// roundtrip succeeded, but an error occurred after-the-fact.
2176 -type roundtripSucceededError struct{ error }
2189 +// terminalError signals that the proxy loop must stop and the wrapped
2190 +// error must be propagated unchanged. It is emitted in any situation
2191 +// where the handler has committed enough client-visible state that
2192 +// retrying against another upstream would be unsafe — for example, a
2193 +// handle_response route that ran after a successful round-trip, or a
2194 +// WebTransport upgrade that already flushed 200 OK and hijacked the
2195 +// stream. The inner error may be nil to signal terminal success.
2196 +type terminalError struct{ error }
2197 +
2198 +func (e terminalError) Unwrap() error { return e.error }
2199
2200 // retryableResponseError is returned when the upstream response matched
2201 // a retry_match entry, indicating the request should be retried with the
modules/caddyhttp/reverseproxy/webtransport_pump.go new
+232
@@ -0,0 +1,232 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package reverseproxy
16 +
17 +import (
18 + "context"
19 + "errors"
20 + "io"
21 + "sync"
22 + "time"
23 +
24 + "github.com/quic-go/webtransport-go"
25 + "go.uber.org/zap"
26 +)
27 +
28 +// runWebTransportPump bridges two WebTransport sessions so that every
29 +// bidirectional stream, unidirectional stream, and datagram opened on one
30 +// side is mirrored onto the other. It blocks until both sessions end.
31 +// logger must be non-nil; callers pass either h.logger or zap.NewNop().
32 +//
33 +// Close propagation: when either session ends with a SessionError, the
34 +// error code and message are forwarded to the peer via CloseWithError.
35 +// When a session ends without a SessionError (context cancelled or
36 +// connection dropped), the peer is closed with code 0. Each side's close
37 +// is propagated at most once.
38 +//
39 +// EXPERIMENTAL: this helper is an internal building block for the
40 +// WebTransport reverse-proxy transport and may change.
41 +func runWebTransportPump(clientSess, upstreamSess *webtransport.Session, logger *zap.Logger) {
42 + p := &webtransportPump{
43 + client: clientSess,
44 + upstream: upstreamSess,
45 + logger: logger,
46 + }
47 + p.run()
48 +}
49 +
50 +type webtransportPump struct {
51 + client, upstream *webtransport.Session
52 + logger *zap.Logger
53 +
54 + closeClientOnce sync.Once
55 + closeUpstreamOnce sync.Once
56 + // streams owns every bidi and uni splice. run waits for them after
57 + // the accept loops return so the handler does not unregister the
58 + // session while a copy is still unwinding.
59 + streams sync.WaitGroup
60 +}
61 +
62 +func (p *webtransportPump) run() {
63 + var wg sync.WaitGroup
64 + closeClient := func(cause error) {
65 + p.propagateClose(p.client, p.upstream, &p.closeClientOnce, cause)
66 + }
67 + closeUpstream := func(cause error) {
68 + p.propagateClose(p.upstream, p.client, &p.closeUpstreamOnce, cause)
69 + }
70 +
71 + wg.Go(func() { p.acceptBidi(p.client, p.upstream, closeUpstream) })
72 + wg.Go(func() { p.acceptBidi(p.upstream, p.client, closeClient) })
73 + wg.Go(func() { p.acceptUni(p.client, p.upstream, closeUpstream) })
74 + wg.Go(func() { p.acceptUni(p.upstream, p.client, closeClient) })
75 + wg.Go(func() { p.pumpDatagrams(p.client, p.upstream, closeUpstream) })
76 + wg.Go(func() { p.pumpDatagrams(p.upstream, p.client, closeClient) })
77 + wg.Wait()
78 + p.streams.Wait()
79 +}
80 +
81 +// propagateClose closes target once with a code/message derived from
82 +// cause. If cause carries a *webtransport.SessionError (the common case —
83 +// Accept{,Uni}Stream returns it directly when the peer closed the
84 +// session), its code/message are used. Otherwise, typically the
85 +// datagram loop won the race to detect the close and its error lacks
86 +// the code, so we fall back to probing peer for its stored close state
87 +// via a short AcceptStream.
88 +func (p *webtransportPump) propagateClose(target, peer *webtransport.Session, once *sync.Once, cause error) {
89 + once.Do(func() {
90 + code, msg, ok := closeCodeFromErr(cause)
91 + if !ok {
92 + code, msg, _ = codeFromSession(peer)
93 + }
94 + _ = target.CloseWithError(code, msg)
95 + })
96 +}
97 +
98 +// codeFromSession reads the peer's stored SessionError by waiting for the
99 +// session's context to be cancelled — by that point webtransport-go has
100 +// set closeErr — and then calling AcceptStream, which returns it via its
101 +// initial closeErr check without blocking. Used only on the close path
102 +// when the caller's own error didn't carry the code (e.g. ReceiveDatagram
103 +// returned a context error).
104 +func codeFromSession(sess *webtransport.Session) (webtransport.SessionErrorCode, string, bool) {
105 + select {
106 + case <-sess.Context().Done():
107 + case <-time.After(200 * time.Millisecond):
108 + return 0, "", false
109 + }
110 + _, err := sess.AcceptStream(context.Background())
111 + return closeCodeFromErr(err)
112 +}
113 +
114 +// acceptBidi loops on src.AcceptStream and, for each accepted
115 +// bidirectional stream, opens a matching stream on dst and pipes bytes in
116 +// both directions. When src ends, it invokes propagate to close dst.
117 +func (p *webtransportPump) acceptBidi(src, dst *webtransport.Session, propagate func(error)) {
118 + ctx := src.Context()
119 + for {
120 + srcStr, err := src.AcceptStream(ctx)
121 + if err != nil {
122 + propagate(err)
123 + return
124 + }
125 + dstStr, err := dst.OpenStreamSync(ctx)
126 + if err != nil {
127 + p.logger.Debug("webtransport: open upstream bidi failed", zap.Error(err))
128 + srcStr.CancelRead(0)
129 + srcStr.CancelWrite(0)
130 + propagate(err)
131 + return
132 + }
133 + p.streams.Go(func() { p.spliceBidi(srcStr, dstStr) })
134 + }
135 +}
136 +
137 +// spliceBidi copies bytes between two bidirectional streams until both
138 +// sides observe EOF or an error.
139 +func (p *webtransportPump) spliceBidi(a, b *webtransport.Stream) {
140 + var wg sync.WaitGroup
141 + wg.Go(func() {
142 + p.spliceOne(b, a, "webtransport bidi splice a->b")
143 + })
144 + wg.Go(func() {
145 + p.spliceOne(a, b, "webtransport bidi splice b->a")
146 + })
147 + wg.Wait()
148 +}
149 +
150 +// acceptUni loops on src.AcceptUniStream and, for each stream, opens a
151 +// matching unidirectional stream on dst and pipes bytes through.
152 +func (p *webtransportPump) acceptUni(src, dst *webtransport.Session, propagate func(error)) {
153 + ctx := src.Context()
154 + for {
155 + recv, err := src.AcceptUniStream(ctx)
156 + if err != nil {
157 + propagate(err)
158 + return
159 + }
160 + send, err := dst.OpenUniStreamSync(ctx)
161 + if err != nil {
162 + p.logger.Debug("webtransport: open upstream uni failed", zap.Error(err))
163 + recv.CancelRead(0)
164 + propagate(err)
165 + return
166 + }
167 + p.streams.Go(func() {
168 + if _, err := streamCopy(send, recv); err != nil && !isExpectedEOF(err) {
169 + p.logger.Debug("webtransport uni splice", zap.Error(err))
170 + recv.CancelRead(0)
171 + send.CancelWrite(0)
172 + }
173 + _ = send.Close()
174 + })
175 + }
176 +}
177 +
178 +// spliceOne copies src into dst. A hard error cancels both reads so the
179 +// opposite copy, which may be blocked in Read, can finish. A clean EOF
180 +// only closes dst's write side; the other direction may still be sending.
181 +func (p *webtransportPump) spliceOne(dst, src *webtransport.Stream, failMsg string) {
182 + if _, err := streamCopy(dst, src); err != nil && !isExpectedEOF(err) {
183 + p.logger.Debug(failMsg, zap.Error(err))
184 + src.CancelRead(0)
185 + dst.CancelRead(0)
186 + }
187 + _ = dst.Close()
188 +}
189 +
190 +// pumpDatagrams forwards datagrams from src to dst until src ends. Unlike
191 +// streams, datagrams are unreliable, so SendDatagram errors are best-effort
192 +// and are not treated as fatal for the session.
193 +func (p *webtransportPump) pumpDatagrams(src, dst *webtransport.Session, propagate func(error)) {
194 + ctx := src.Context()
195 + for {
196 + data, err := src.ReceiveDatagram(ctx)
197 + if err != nil {
198 + propagate(err)
199 + return
200 + }
201 + if err := dst.SendDatagram(data); err != nil {
202 + p.logger.Debug("webtransport send datagram", zap.Error(err))
203 + }
204 + }
205 +}
206 +
207 +// streamCopy is io.Copy. Tests replace it to hold a worker open across shutdown.
208 +var streamCopy = io.Copy
209 +
210 +// closeCodeFromErr extracts a SessionErrorCode + message from err if it
211 +// represents a session close. The bool is false when err is nil or not
212 +// a *webtransport.SessionError.
213 +func closeCodeFromErr(err error) (webtransport.SessionErrorCode, string, bool) {
214 + sessErr, ok := errors.AsType[*webtransport.SessionError](err)
215 + if ok {
216 + return sessErr.ErrorCode, sessErr.Message, true
217 + }
218 + return 0, "", false
219 +}
220 +
221 +// isExpectedEOF reports whether err is one we don't need to log: plain
222 +// EOF, context cancellation, or an already-closed session.
223 +func isExpectedEOF(err error) bool {
224 + if err == nil {
225 + return true
226 + }
227 + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
228 + return true
229 + }
230 + var sessErr *webtransport.SessionError
231 + return errors.As(err, &sessErr)
232 +}
modules/caddyhttp/reverseproxy/webtransport_pump_test.go new
+525
@@ -0,0 +1,525 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package reverseproxy
16 +
17 +import (
18 + "context"
19 + "crypto/tls"
20 + "errors"
21 + "fmt"
22 + "io"
23 + "net"
24 + "net/http"
25 + "runtime"
26 + "sync"
27 + "testing"
28 + "time"
29 +
30 + "github.com/quic-go/webtransport-go"
31 + "go.uber.org/zap"
32 +)
33 +
34 +// pumpTestTopology spins up:
35 +//
36 +// client → frontend(Server F) → upstream(Server U)
37 +//
38 +// Server U is the real upstream; its handler is provided by the test.
39 +// Server F's handler dials U and runs runWebTransportPump between the two
40 +// sessions, so the client (who dials F) effectively talks to U through
41 +// the pump.
42 +type pumpTestTopology struct {
43 + frontendAddr *net.UDPAddr
44 + clientTLS *tls.Config
45 + shutdown func()
46 +}
47 +
48 +func newPumpTestTopology(t *testing.T, upstreamHandler func(*webtransport.Session, *http.Request)) *pumpTestTopology {
49 + t.Helper()
50 +
51 + uAddr, uRoot, uShutdown := startTestWebTransportServer(t, upstreamHandler)
52 +
53 + fAddr, fRoot, fShutdown := startTestWebTransportServer(t, func(clientSess *webtransport.Session, _ *http.Request) {
54 + // Dial U.
55 + dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
56 + defer cancel()
57 + url := fmt.Sprintf("https://localhost:%d/", uAddr.Port)
58 + _, upstreamSess, err := dialUpstreamWebTransport(dialCtx, clientTLSFor(uRoot), url, nil, nil, "")
59 + if err != nil {
60 + t.Errorf("frontend: dial upstream: %v", err)
61 + _ = clientSess.CloseWithError(0, "upstream dial failed")
62 + return
63 + }
64 + runWebTransportPump(clientSess, upstreamSess, zap.NewNop())
65 + })
66 +
67 + return &pumpTestTopology{
68 + frontendAddr: fAddr,
69 + clientTLS: clientTLSFor(fRoot),
70 + shutdown: func() {
71 + fShutdown()
72 + uShutdown()
73 + },
74 + }
75 +}
76 +
77 +// dialFrontend returns a fresh session dialed against the frontend server.
78 +func (tt *pumpTestTopology) dialFrontend(t *testing.T) *webtransport.Session {
79 + t.Helper()
80 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
81 + defer cancel()
82 + url := fmt.Sprintf("https://localhost:%d/", tt.frontendAddr.Port)
83 + _, sess, err := dialUpstreamWebTransport(ctx, tt.clientTLS, url, nil, nil, "")
84 + if err != nil {
85 + t.Fatalf("client dial frontend: %v", err)
86 + }
87 + return sess
88 +}
89 +
90 +// echoUpstream is a ready-made upstream handler that echoes bytes on every
91 +// bidirectional stream it's given.
92 +func echoUpstream(sess *webtransport.Session, _ *http.Request) {
93 + ctx := sess.Context()
94 + for {
95 + str, err := sess.AcceptStream(ctx)
96 + if err != nil {
97 + return
98 + }
99 + go func(s *webtransport.Stream) {
100 + _, _ = io.Copy(s, s)
101 + _ = s.Close()
102 + }(str)
103 + }
104 +}
105 +
106 +func TestPump_BidiStreamClientToUpstream(t *testing.T) {
107 + if testing.Short() {
108 + t.Skip()
109 + }
110 + tt := newPumpTestTopology(t, echoUpstream)
111 + t.Cleanup(tt.shutdown)
112 +
113 + sess := tt.dialFrontend(t)
114 + defer sess.CloseWithError(0, "")
115 +
116 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
117 + defer cancel()
118 + str, err := sess.OpenStreamSync(ctx)
119 + if err != nil {
120 + t.Fatalf("open stream: %v", err)
121 + }
122 +
123 + const payload = "hello from client"
124 + if _, err := io.WriteString(str, payload); err != nil {
125 + t.Fatalf("write: %v", err)
126 + }
127 + if err := str.Close(); err != nil {
128 + t.Fatalf("close write: %v", err)
129 + }
130 + got, err := io.ReadAll(str)
131 + if err != nil {
132 + t.Fatalf("read: %v", err)
133 + }
134 + if string(got) != payload {
135 + t.Fatalf("echo mismatch: got %q want %q", got, payload)
136 + }
137 +}
138 +
139 +func TestPump_BidiStreamUpstreamToClient(t *testing.T) {
140 + if testing.Short() {
141 + t.Skip()
142 + }
143 + // Upstream opens a stream toward the client and sends data.
144 + serverReady := make(chan struct{})
145 + const payload = "hello from upstream"
146 + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) {
147 + defer close(serverReady)
148 + ctx, cancel := context.WithTimeout(sess.Context(), 5*time.Second)
149 + defer cancel()
150 + str, err := sess.OpenStreamSync(ctx)
151 + if err != nil {
152 + t.Errorf("upstream open: %v", err)
153 + return
154 + }
155 + if _, err := io.WriteString(str, payload); err != nil {
156 + t.Errorf("upstream write: %v", err)
157 + return
158 + }
159 + _ = str.Close()
160 + // Keep the session alive briefly so the stream can be drained client-side.
161 + <-sess.Context().Done()
162 + })
163 + t.Cleanup(tt.shutdown)
164 +
165 + sess := tt.dialFrontend(t)
166 + defer sess.CloseWithError(0, "")
167 +
168 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
169 + defer cancel()
170 + str, err := sess.AcceptStream(ctx)
171 + if err != nil {
172 + t.Fatalf("client accept: %v", err)
173 + }
174 + got, err := io.ReadAll(str)
175 + if err != nil {
176 + t.Fatalf("client read: %v", err)
177 + }
178 + if string(got) != payload {
179 + t.Fatalf("bytes mismatch: got %q want %q", got, payload)
180 + }
181 +}
182 +
183 +func TestPump_UniStreamClientToUpstream(t *testing.T) {
184 + if testing.Short() {
185 + t.Skip()
186 + }
187 + // Upstream: accept one uni stream and echo its bytes on a new uni stream
188 + // back to the client.
189 + const payload = "uni from client"
190 + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) {
191 + ctx := sess.Context()
192 + recv, err := sess.AcceptUniStream(ctx)
193 + if err != nil {
194 + return
195 + }
196 + data, err := io.ReadAll(recv)
197 + if err != nil {
198 + t.Errorf("upstream read uni: %v", err)
199 + return
200 + }
201 + send, err := sess.OpenUniStreamSync(ctx)
202 + if err != nil {
203 + t.Errorf("upstream open uni: %v", err)
204 + return
205 + }
206 + if _, err := send.Write(data); err != nil {
207 + t.Errorf("upstream write uni: %v", err)
208 + }
209 + _ = send.Close()
210 + <-sess.Context().Done()
211 + })
212 + t.Cleanup(tt.shutdown)
213 +
214 + sess := tt.dialFrontend(t)
215 + defer sess.CloseWithError(0, "")
216 +
217 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
218 + defer cancel()
219 + sendStr, err := sess.OpenUniStreamSync(ctx)
220 + if err != nil {
221 + t.Fatalf("client open uni: %v", err)
222 + }
223 + if _, err := sendStr.Write([]byte(payload)); err != nil {
224 + t.Fatalf("client write uni: %v", err)
225 + }
226 + _ = sendStr.Close()
227 +
228 + recvStr, err := sess.AcceptUniStream(ctx)
229 + if err != nil {
230 + t.Fatalf("client accept uni: %v", err)
231 + }
232 + got, err := io.ReadAll(recvStr)
233 + if err != nil {
234 + t.Fatalf("client read uni: %v", err)
235 + }
236 + if string(got) != payload {
237 + t.Fatalf("uni echo mismatch: got %q want %q", got, payload)
238 + }
239 +}
240 +
241 +func TestPump_Datagram(t *testing.T) {
242 + if testing.Short() {
243 + t.Skip()
244 + }
245 + // Upstream echoes whatever datagram it receives.
246 + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) {
247 + ctx := sess.Context()
248 + for {
249 + d, err := sess.ReceiveDatagram(ctx)
250 + if err != nil {
251 + return
252 + }
253 + _ = sess.SendDatagram(d)
254 + }
255 + })
256 + t.Cleanup(tt.shutdown)
257 +
258 + sess := tt.dialFrontend(t)
259 + defer sess.CloseWithError(0, "")
260 +
261 + // Datagrams are unreliable. Retry a few times to get one round-trip.
262 + payload := []byte("dgram")
263 + deadline := time.Now().Add(3 * time.Second)
264 + for {
265 + if time.Now().After(deadline) {
266 + t.Fatal("no datagram echo observed within deadline")
267 + }
268 + if err := sess.SendDatagram(payload); err != nil {
269 + t.Fatalf("send datagram: %v", err)
270 + }
271 + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
272 + got, err := sess.ReceiveDatagram(ctx)
273 + cancel()
274 + if err == nil && string(got) == string(payload) {
275 + return
276 + }
277 + }
278 +}
279 +
280 +func TestPump_CloseWithErrorPropagatesClientToUpstream(t *testing.T) {
281 + if testing.Short() {
282 + t.Skip()
283 + }
284 + upstreamSawErr := make(chan error, 1)
285 + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) {
286 + // Use a fresh long-lived context so we don't race with
287 + // sess.Context() firing and getting a context error instead of
288 + // the session-level error.
289 + _, err := sess.AcceptStream(context.Background())
290 + upstreamSawErr <- err
291 + })
292 + t.Cleanup(tt.shutdown)
293 +
294 + sess := tt.dialFrontend(t)
295 +
296 + // Client closes with a specific code; pump should propagate to upstream.
297 + const code webtransport.SessionErrorCode = 4242
298 + const msg = "client bye"
299 + if err := sess.CloseWithError(code, msg); err != nil {
300 + t.Fatalf("client close: %v", err)
301 + }
302 +
303 + select {
304 + case err := <-upstreamSawErr:
305 + if err == nil {
306 + t.Fatal("upstream expected error after client close; got nil")
307 + }
308 + // Close propagation is best-effort for a client-initiated close:
309 + // webtransport-go's Transport tears down the dedicated QUIC connection
310 + // immediately after CloseWithError, and on the pump's server-side
311 + // session the WT_CLOSE_SESSION capsule can lose the race to the
312 + // QUIC close — in which case parseNextCapsule stores a non-
313 + // SessionError and the code is unrecoverable. The invariant we
314 + // can reliably enforce is "upstream observed a session-terminating
315 + // error." If the code did survive, assert it matches.
316 + var sessErr *webtransport.SessionError
317 + if errors.As(err, &sessErr) && sessErr.ErrorCode != 0 {
318 + if sessErr.ErrorCode != code || sessErr.Message != msg {
319 + t.Errorf("upstream saw code=%d msg=%q, want code=%d msg=%q",
320 + sessErr.ErrorCode, sessErr.Message, code, msg)
321 + }
322 + } else {
323 + t.Logf("upstream saw %T: %v (code lost to QUIC-close race; close propagation itself is verified)", err, err)
324 + }
325 + case <-time.After(3 * time.Second):
326 + t.Fatal("upstream did not observe close in time")
327 + }
328 +}
329 +
330 +func TestPump_CloseWithErrorPropagatesUpstreamToClient(t *testing.T) {
331 + if testing.Short() {
332 + t.Skip()
333 + }
334 + const code webtransport.SessionErrorCode = 9000
335 + const msg = "upstream bye"
336 + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) {
337 + _ = sess.CloseWithError(code, msg)
338 + })
339 + t.Cleanup(tt.shutdown)
340 +
341 + sess := tt.dialFrontend(t)
342 +
343 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
344 + defer cancel()
345 + _, err := sess.AcceptStream(ctx)
346 + var sessErr *webtransport.SessionError
347 + if !errors.As(err, &sessErr) {
348 + t.Fatalf("expected SessionError, got %T: %v", err, err)
349 + }
350 + if sessErr.ErrorCode != code || sessErr.Message != msg {
351 + t.Errorf("client saw code=%d msg=%q, want code=%d msg=%q",
352 + sessErr.ErrorCode, sessErr.Message, code, msg)
353 + }
354 +}
355 +
356 +// TestPump_SessionLifecycle_NoGoroutineLeak sanity-checks that after both
357 +// sessions end, the pump's goroutines unwind. We compare goroutine counts
358 +// before and after, with a small tolerance because the Go runtime has
359 +// background goroutines we can't synchronize with.
360 +func TestPump_SessionLifecycle_NoGoroutineLeak(t *testing.T) {
361 + if testing.Short() {
362 + t.Skip()
363 + }
364 + before := runtime.NumGoroutine()
365 +
366 + // Drive a fast session+close cycle.
367 + var wg sync.WaitGroup
368 + wg.Add(1)
369 + go func() {
370 + defer wg.Done()
371 + tt := newPumpTestTopology(t, echoUpstream)
372 + sess := tt.dialFrontend(t)
373 + _ = sess.CloseWithError(0, "")
374 + // Allow close to propagate and goroutines to exit.
375 + time.Sleep(200 * time.Millisecond)
376 + tt.shutdown()
377 + }()
378 + wg.Wait()
379 +
380 + // Give the runtime a moment to finish tearing down.
381 + deadline := time.Now().Add(2 * time.Second)
382 + var after int
383 + for {
384 + after = runtime.NumGoroutine()
385 + if after <= before+8 || time.Now().After(deadline) {
386 + break
387 + }
388 + time.Sleep(50 * time.Millisecond)
389 + }
390 + // Allow some slack — test infrastructure itself keeps a few goroutines.
391 + if after > before+16 {
392 + t.Errorf("possible goroutine leak: before=%d after=%d", before, after)
393 + }
394 +}
395 +
396 +// TestPump_StreamResetReleasesWorkers resets many bidi streams without
397 +// closing the session. The opposite copy must not stay blocked, or each
398 +// reset leaks a worker for the life of the session.
399 +func TestPump_StreamResetReleasesWorkers(t *testing.T) {
400 + if testing.Short() {
401 + t.Skip()
402 + }
403 + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) {
404 + ctx := sess.Context()
405 + for {
406 + if _, err := sess.AcceptStream(ctx); err != nil {
407 + return
408 + }
409 + }
410 + })
411 + t.Cleanup(tt.shutdown)
412 +
413 + sess := tt.dialFrontend(t)
414 + defer sess.CloseWithError(0, "")
415 +
416 + // Let the session-level accept loops settle before counting.
417 + time.Sleep(100 * time.Millisecond)
418 + before := runtime.NumGoroutine()
419 +
420 + const n = 15
421 + for i := range n {
422 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
423 + str, err := sess.OpenStreamSync(ctx)
424 + cancel()
425 + if err != nil {
426 + t.Fatalf("open stream %d: %v", i, err)
427 + }
428 + if _, err := str.Write([]byte("x")); err != nil {
429 + t.Fatalf("write stream %d: %v", i, err)
430 + }
431 + str.CancelWrite(0)
432 + }
433 +
434 + deadline := time.Now().Add(2 * time.Second)
435 + var after int
436 + for {
437 + after = runtime.NumGoroutine()
438 + if after <= before+8 || time.Now().After(deadline) {
439 + break
440 + }
441 + time.Sleep(50 * time.Millisecond)
442 + }
443 + if after > before+8 {
444 + t.Fatalf("stream workers still running after reset: before=%d after=%d", before, after)
445 + }
446 +}
447 +
448 +// TestPump_ShutdownWaitsForBlockedStream closes the session while a stream
449 +// copy is still inside the pump. The pump must not return until that worker
450 +// finishes.
451 +func TestPump_ShutdownWaitsForBlockedStream(t *testing.T) {
452 + if testing.Short() {
453 + t.Skip()
454 + }
455 +
456 + blocked := make(chan struct{})
457 + release := make(chan struct{})
458 + var once sync.Once
459 + orig := streamCopy
460 + streamCopy = func(dst io.Writer, src io.Reader) (int64, error) {
461 + once.Do(func() { close(blocked) })
462 + <-release
463 + return orig(dst, src)
464 + }
465 + t.Cleanup(func() { streamCopy = orig })
466 +
467 + pumpDone := make(chan struct{})
468 + uAddr, uRoot, uShutdown := startTestWebTransportServer(t, func(sess *webtransport.Session, _ *http.Request) {
469 + <-sess.Context().Done()
470 + })
471 + t.Cleanup(uShutdown)
472 + fAddr, fRoot, fShutdown := startTestWebTransportServer(t, func(clientSess *webtransport.Session, _ *http.Request) {
473 + defer close(pumpDone)
474 + dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
475 + defer cancel()
476 + url := fmt.Sprintf("https://localhost:%d/", uAddr.Port)
477 + _, upstreamSess, err := dialUpstreamWebTransport(dialCtx, clientTLSFor(uRoot), url, nil, nil, "")
478 + if err != nil {
479 + t.Errorf("frontend: dial upstream: %v", err)
480 + _ = clientSess.CloseWithError(0, "upstream dial failed")
481 + return
482 + }
483 + runWebTransportPump(clientSess, upstreamSess, zap.NewNop())
484 + })
485 + t.Cleanup(fShutdown)
486 +
487 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
488 + defer cancel()
489 + url := fmt.Sprintf("https://localhost:%d/", fAddr.Port)
490 + _, sess, err := dialUpstreamWebTransport(ctx, clientTLSFor(fRoot), url, nil, nil, "")
491 + if err != nil {
492 + t.Fatalf("client dial: %v", err)
493 + }
494 +
495 + strCtx, strCancel := context.WithTimeout(context.Background(), 2*time.Second)
496 + str, err := sess.OpenStreamSync(strCtx)
497 + strCancel()
498 + if err != nil {
499 + t.Fatalf("open stream: %v", err)
500 + }
501 + if _, err := str.Write([]byte("x")); err != nil {
502 + t.Fatalf("write: %v", err)
503 + }
504 + defer str.CancelRead(0)
505 +
506 + select {
507 + case <-blocked:
508 + case <-time.After(2 * time.Second):
509 + t.Fatal("stream worker did not start")
510 + }
511 +
512 + _ = sess.CloseWithError(0, "")
513 + select {
514 + case <-pumpDone:
515 + t.Fatal("pump returned while a stream worker was still blocked")
516 + case <-time.After(300 * time.Millisecond):
517 + }
518 +
519 + close(release)
520 + select {
521 + case <-pumpDone:
522 + case <-time.After(2 * time.Second):
523 + t.Fatal("pump did not return after the stream worker finished")
524 + }
525 +}
modules/caddyhttp/reverseproxy/webtransport_transport.go new
+300
@@ -0,0 +1,300 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package reverseproxy
16 +
17 +import (
18 + "context"
19 + "crypto/tls"
20 + "errors"
21 + "fmt"
22 + "net/http"
23 + "net/url"
24 + "strings"
25 + "time"
26 +
27 + "github.com/dunglas/httpsfv"
28 + "github.com/quic-go/quic-go"
29 + "github.com/quic-go/quic-go/http3"
30 + "github.com/quic-go/webtransport-go"
31 +
32 + "github.com/caddyserver/caddy/v2"
33 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
34 +)
35 +
36 +// Go canonical MIME keys (http.CanonicalHeaderKey). Spec names are
37 +// WT-Available-Protocols / WT-Protocol; HTTP/3 on the wire is lowercase.
38 +const (
39 + wtAvailableProtocolsHeader = "Wt-Available-Protocols"
40 + wtProtocolHeader = "Wt-Protocol"
41 +)
42 +
43 +// webtransportProtocol values are the :protocol pseudo-header tokens sent
44 +// by a client that wants to establish a WebTransport session over an HTTP/3
45 +// Extended CONNECT. draft-15 (webtransport-go v0.11.0+) uses "webtransport-h3";
46 +// older draft clients use the legacy "webtransport" token, which the
47 +// webtransport-go server still accepts. Detect both.
48 +const (
49 + webtransportProtocol = "webtransport"
50 + webtransportProtocolDraft15 = "webtransport-h3"
51 +)
52 +
53 +// webtransportWriter is the naked HTTP/3 response-writer shape that
54 +// webtransport.Server.Upgrade type-asserts on. Caddy's
55 +// UnwrapResponseWriterAs walks the ResponseWriter wrapper chain to this
56 +// type before calling Upgrade.
57 +type webtransportWriter interface {
58 + http.ResponseWriter
59 + http3.Settingser
60 + http3.HTTPStreamer
61 +}
62 +
63 +// isWebTransportExtendedConnect reports whether r is an HTTP/3 Extended
64 +// CONNECT that requests a WebTransport session. Does not check whether
65 +// WebTransport proxying is configured; callers gate on Handler state.
66 +func isWebTransportExtendedConnect(r *http.Request) bool {
67 + return r.ProtoMajor == 3 && r.Method == http.MethodConnect &&
68 + (r.Proto == webtransportProtocol || r.Proto == webtransportProtocolDraft15)
69 +}
70 +
71 +// webTransportHijack runs inside reverseProxy in place of RoundTrip when
72 +// the request is a WebTransport Extended CONNECT. The outer proxy loop
73 +// has already resolved the upstream set, selected an upstream, filled
74 +// DialInfo, published reverse_proxy.upstream.* placeholders, applied
75 +// transport and user request-header ops, cloned the request, directed
76 +// the request URL at the upstream, and bumped in-flight counters — so
77 +// this function only does WT-specific plumbing: upstream WT dial,
78 +// client upgrade, and session pumping.
79 +//
80 +// Error semantics match the outer loop's retry contract:
81 +// - Pre-dial misconfiguration (WT not enabled on the server, writer
82 +// stack unsupported, handler transport is not HTTP/3) returns
83 +// terminalError — no upstream can fix these conditions.
84 +// - Upstream dial failure returns DialError — safe to retry across
85 +// upstreams because no client-visible bytes have been written.
86 +// - Post-upgrade failures return terminalError because the 200 OK
87 +// has been flushed and the stream is hijacked.
88 +//
89 +// Requests that reach this function are already known to be WebTransport;
90 +// callers should gate with isWebTransportExtendedConnect. origReq is the
91 +// downstream request (Upgrade / CheckOrigin); req is the upstream-directed
92 +// clone (URL, header_up, SNI expansion).
93 +func (h *Handler) webTransportHijack(rw http.ResponseWriter, req *http.Request, origReq *http.Request, repl *caddy.Replacer, di DialInfo, server *caddyhttp.Server) error {
94 + wtServer, ok := server.WebTransportServer().(*webtransport.Server)
95 + if !ok || wtServer == nil {
96 + return terminalError{caddyhttp.Error(http.StatusInternalServerError,
97 + errors.New("webtransport: server has webtransport disabled or HTTP/3 is not enabled"))}
98 + }
99 +
100 + // Reach the naked http3 response writer so Upgrade's type assertions
101 + // succeed through Caddy's wrapper chain. Done before dialing so we
102 + // fail fast if the writer stack is unexpectedly incompatible.
103 + naked, ok := caddyhttp.UnwrapResponseWriterAs[webtransportWriter](rw)
104 + if !ok {
105 + return terminalError{caddyhttp.Error(http.StatusInternalServerError,
106 + errors.New("webtransport: response writer does not support WebTransport upgrade"))}
107 + }
108 +
109 + // A WT CONNECT reached this handler because the parent server has
110 + // webtransport enabled. The upstream dial still needs the HTTP/3
111 + // transport's TLS config (versions must include "3").
112 + ht, ok := h.Transport.(*HTTPTransport)
113 + if !ok || ht.h3Transport == nil {
114 + return terminalError{caddyhttp.Error(http.StatusBadGateway,
115 + errors.New("webtransport: requires the http transport with versions [\"3\"]"))}
116 + }
117 + tlsCfg := ht.h3Transport.TLSClientConfig
118 + if tlsCfg == nil {
119 + tlsCfg = new(tls.Config)
120 + }
121 +
122 + // Expand SNI placeholders (e.g. tls_server_name {http.request.host}) per
123 + // session. The normal HTTP/3 path does this via a custom h3Transport.Dial
124 + // hook (#7737); the WebTransport path dials through its own Transport and
125 + // bypasses that hook, so expand here. Clone first — the transport's TLS
126 + // config is shared across sessions and must not be mutated in place.
127 + if strings.Contains(tlsCfg.ServerName, "{") {
128 + tlsCfg = tlsCfg.Clone()
129 + tlsCfg.ServerName = repl.ReplaceAll(tlsCfg.ServerName, "")
130 + }
131 +
132 + // Dial the upstream BEFORE upgrading the client. If the upstream is
133 + // unreachable or refuses the CONNECT, a proper 5xx goes back over the
134 + // H3 stream and the client's Dial sees the real status — instead of
135 + // an already-upgraded session closing immediately. DialError so the
136 + // outer proxy loop can fail over to another upstream, same as any
137 + // other dial failure.
138 + //
139 + // WebTransport over HTTP/3 always uses https; RequestURI preserves
140 + // the request's encoded path and query.
141 + //
142 + // Application protocol negotiation (draft-ietf-webtrans-http3 §3.3)
143 + // is relayed, not chosen here: parse the offer from the prepared
144 + // request (so transport and header_up ops apply), put it on the
145 + // upstream Transport, and copy the upstream's WT-Protocol onto the
146 + // client 200. Strip the offer from a clone of the forwarded headers
147 + // so the Transport remashals a spec-correct list. The proxy loop
148 + // reuses this request on retries and only recopies headers when
149 + // header ops are configured, so the original map has to stay intact
150 + // for the next upstream. The shared webtransport.Server keeps
151 + // ApplicationProtocols empty so Caddy does not independently select
152 + // a protocol.
153 + offered := parseWTAvailableProtocols(req.Header)
154 + dialHdr := req.Header.Clone()
155 + dialHdr.Del(wtAvailableProtocolsHeader)
156 + upstreamURL := "https://" + di.Address + req.URL.RequestURI()
157 + dialStart := time.Now()
158 + upstreamResp, upstreamSess, err := dialUpstreamWebTransport(req.Context(), tlsCfg, upstreamURL, dialHdr, offered, req.Host)
159 + if err != nil {
160 + return DialError{fmt.Errorf("webtransport upstream dial: %w", err)}
161 + }
162 + defer upstreamResp.Body.Close()
163 + latency := time.Since(dialStart)
164 +
165 + // Copy filtered upstream headers, then apply header_down. Upgrade
166 + // flushes rw.Header() with the 200, so this must happen first.
167 + applyWebTransportResponseHeaders(rw, upstreamResp, h, repl)
168 +
169 + clientSess, err := wtServer.Upgrade(naked, origReq)
170 + if err != nil {
171 + _ = upstreamSess.CloseWithError(0, "client upgrade failed")
172 + return terminalError{caddyhttp.Error(http.StatusBadRequest,
173 + fmt.Errorf("webtransport upgrade: %w", err))}
174 + }
175 + unregister := server.RegisterWebTransportSession(webTransportSessionCloser{sess: clientSess})
176 + defer unregister()
177 + caddyhttp.RecordHijackedStatus(rw, http.StatusOK)
178 +
179 + runWebTransportPump(clientSess, upstreamSess, h.logger)
180 + h.recordUpstreamRoundTrip(di, repl, upstreamResp.StatusCode, latency)
181 + repl.Set("http.reverse_proxy.upstream.duration", time.Since(dialStart))
182 + repl.Set("http.reverse_proxy.upstream.duration_ms", time.Since(dialStart).Seconds()*1e3)
183 + return nil
184 +}
185 +
186 +// webTransportSessionCloser closes one downstream WebTransport session
187 +// during server shutdown. io.Closer keeps *webtransport.Session out of
188 +// caddyhttp's exported API.
189 +type webTransportSessionCloser struct {
190 + sess *webtransport.Session
191 +}
192 +
193 +func (w webTransportSessionCloser) Close() error {
194 + return w.sess.CloseWithError(0, "server shutting down")
195 +}
196 +
197 +// dialUpstreamWebTransport opens a WebTransport session to the upstream at
198 +// urlStr (an https URL), forwarding reqHdr as headers on the Extended
199 +// CONNECT request. The returned session is owned by the caller and must be
200 +// closed when no longer in use. Return-value order matches
201 +// webtransport.Transport.Dial: (response, session, error).
202 +// applicationProtocols is the client's WT-Available-Protocols offer,
203 +// forwarded so the Transport will accept the upstream's WT-Protocol choice.
204 +// host, if non-empty, is the prepared Host / :authority for the CONNECT
205 +// (header_up Host); the QUIC dial still uses the URL host.
206 +//
207 +// EXPERIMENTAL: this helper is an internal building block for the upcoming
208 +// WebTransport reverse-proxy transport. Shape and behavior may change.
209 +func dialUpstreamWebTransport(ctx context.Context, tlsCfg *tls.Config, urlStr string, reqHdr http.Header, applicationProtocols []string, host string) (*http.Response, *webtransport.Session, error) {
210 + u, err := url.Parse(urlStr)
211 + if err != nil {
212 + return nil, nil, err
213 + }
214 + dialAddr := u.Host
215 + if host != "" {
216 + // Transport.Dial always sets req.Host from the URL host, so put the
217 + // prepared Host there. DialAddr keeps the QUIC dial on the real
218 + // upstream address.
219 + u.Host = host
220 + urlStr = u.String()
221 + }
222 + d := &webtransport.Transport{
223 + TLSClientConfig: tlsCfg,
224 + ApplicationProtocols: applicationProtocols,
225 + QUICConfig: &quic.Config{
226 + EnableDatagrams: true,
227 + EnableStreamResetPartialDelivery: true,
228 + },
229 + DialAddr: func(ctx context.Context, _ string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
230 + return quic.DialAddrEarly(ctx, dialAddr, tlsCfg, cfg)
231 + },
232 + }
233 + return finishUpstreamWebTransportDial(d.Dial(ctx, urlStr, reqHdr))
234 +}
235 +
236 +// finishUpstreamWebTransportDial closes a rejected dial. Transport.Dial
237 +// returns a non-nil response with an error when the peer rejects the
238 +// Extended CONNECT, and the body must be closed. The session is nil on
239 +// that path; close it too if a future dial returns both.
240 +func finishUpstreamWebTransportDial(rsp *http.Response, sess *webtransport.Session, err error) (*http.Response, *webtransport.Session, error) {
241 + if err == nil {
242 + return rsp, sess, nil
243 + }
244 + if rsp != nil && rsp.Body != nil {
245 + _ = rsp.Body.Close()
246 + }
247 + if sess != nil {
248 + _ = sess.CloseWithError(0, "")
249 + }
250 + return nil, nil, err
251 +}
252 +
253 +// applyWebTransportResponseHeaders copies non-hop upstream response headers
254 +// onto the client writer and then applies configured header_down ops, matching
255 +// finalizeResponse for regular proxy responses.
256 +func applyWebTransportResponseHeaders(rw http.ResponseWriter, upstream *http.Response, h *Handler, repl *caddy.Replacer) {
257 + hdr := upstream.Header.Clone()
258 + if hdr == nil {
259 + hdr = make(http.Header)
260 + }
261 + removeConnectionHeaders(hdr)
262 + for _, hop := range hopHeaders {
263 + hdr.Del(hop)
264 + }
265 + if h.Headers != nil && h.Headers.Response != nil {
266 + if h.Headers.Response.Require == nil ||
267 + h.Headers.Response.Require.Match(upstream.StatusCode, hdr) {
268 + h.Headers.Response.ApplyTo(hdr, repl)
269 + }
270 + }
271 + copyHeader(rw.Header(), hdr)
272 +}
273 +
274 +// parseWTAvailableProtocols extracts application-protocol tokens from a
275 +// WT-Available-Protocols structured-field list. Missing or malformed
276 +// values are ignored, matching draft-ietf-webtrans-http3 (treat the
277 +// field as absent).
278 +func parseWTAvailableProtocols(h http.Header) []string {
279 + vals := h.Values(wtAvailableProtocolsHeader)
280 + if len(vals) == 0 {
281 + return nil
282 + }
283 + list, err := httpsfv.UnmarshalList(vals)
284 + if err != nil {
285 + return nil
286 + }
287 + var out []string
288 + for _, item := range list {
289 + i, ok := item.(httpsfv.Item)
290 + if !ok {
291 + return nil
292 + }
293 + p, ok := i.Value.(string)
294 + if !ok {
295 + return nil
296 + }
297 + out = append(out, p)
298 + }
299 + return out
300 +}
modules/caddyhttp/reverseproxy/webtransport_transport_test.go new
+449
@@ -0,0 +1,449 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package reverseproxy
16 +
17 +import (
18 + "context"
19 + "crypto/ecdsa"
20 + "crypto/elliptic"
21 + "crypto/rand"
22 + "crypto/tls"
23 + "crypto/x509"
24 + "crypto/x509/pkix"
25 + "fmt"
26 + "io"
27 + "math/big"
28 + "net"
29 + "net/http"
30 + "net/http/httptest"
31 + "testing"
32 + "time"
33 +
34 + "github.com/quic-go/quic-go"
35 + "github.com/quic-go/quic-go/http3"
36 + "github.com/quic-go/webtransport-go"
37 +
38 + "github.com/caddyserver/caddy/v2"
39 + "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers"
40 +)
41 +
42 +// startTestWebTransportServer starts an in-process WebTransport server on a
43 +// random UDP port with a freshly minted self-signed certificate. handler is
44 +// invoked once the CONNECT request has been upgraded to a session.
45 +//
46 +// Returns the UDP addr and a shutdown func. Tests should call the shutdown
47 +// func via t.Cleanup (or explicitly with defer).
48 +func startTestWebTransportServer(t *testing.T, handler func(s *webtransport.Session, r *http.Request), protocols ...string) (addr *net.UDPAddr, trustRoot *x509.Certificate, shutdown func()) {
49 + t.Helper()
50 +
51 + trustRoot, tlsCfg := generateSelfSignedTLS(t, "localhost")
52 +
53 + mux := http.NewServeMux()
54 + h3 := &http3.Server{
55 + TLSConfig: tlsCfg,
56 + Handler: mux,
57 + QUICConfig: &quic.Config{
58 + EnableDatagrams: true,
59 + EnableStreamResetPartialDelivery: true,
60 + },
61 + }
62 + // Advertise WebTransport in SETTINGS so the client's requirement
63 + // checks pass. (This is what caddyhttp.Server.buildHTTP3Server does
64 + // internally for the real server.)
65 + webtransport.ConfigureHTTP3Server(h3)
66 +
67 + wtServer := &webtransport.Server{H3: h3, ApplicationProtocols: protocols}
68 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
69 + sess, err := wtServer.Upgrade(w, r)
70 + if err != nil {
71 + t.Logf("test server upgrade failed: %v", err)
72 + w.WriteHeader(http.StatusBadRequest)
73 + return
74 + }
75 + handler(sess, r)
76 + })
77 +
78 + udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
79 + if err != nil {
80 + t.Fatal(err)
81 + }
82 + udpConn, err := net.ListenUDP("udp", udpAddr)
83 + if err != nil {
84 + t.Fatal(err)
85 + }
86 +
87 + servErr := make(chan error, 1)
88 + go func() {
89 + servErr <- wtServer.Serve(udpConn)
90 + }()
91 +
92 + shutdown = func() {
93 + _ = wtServer.Close()
94 + <-servErr
95 + _ = udpConn.Close()
96 + }
97 + return udpConn.LocalAddr().(*net.UDPAddr), trustRoot, shutdown
98 +}
99 +
100 +// startRejectingWebTransportServer advertises WebTransport but answers the
101 +// Extended CONNECT with status instead of upgrading. Transport.Dial then
102 +// returns a non-nil response and an error.
103 +func startRejectingWebTransportServer(t *testing.T, status int) (addr *net.UDPAddr, trustRoot *x509.Certificate, shutdown func()) {
104 + t.Helper()
105 +
106 + trustRoot, tlsCfg := generateSelfSignedTLS(t, "localhost")
107 + h3 := &http3.Server{
108 + TLSConfig: tlsCfg,
109 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110 + w.WriteHeader(status)
111 + }),
112 + QUICConfig: &quic.Config{
113 + EnableDatagrams: true,
114 + EnableStreamResetPartialDelivery: true,
115 + },
116 + }
117 + webtransport.ConfigureHTTP3Server(h3)
118 + wtServer := &webtransport.Server{H3: h3}
119 +
120 + udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
121 + if err != nil {
122 + t.Fatal(err)
123 + }
124 + udpConn, err := net.ListenUDP("udp", udpAddr)
125 + if err != nil {
126 + t.Fatal(err)
127 + }
128 + servErr := make(chan error, 1)
129 + go func() {
130 + servErr <- wtServer.Serve(udpConn)
131 + }()
132 + shutdown = func() {
133 + _ = wtServer.Close()
134 + <-servErr
135 + _ = udpConn.Close()
136 + }
137 + return udpConn.LocalAddr().(*net.UDPAddr), trustRoot, shutdown
138 +}
139 +
140 +type bodyCloseRecorder struct {
141 + closed bool
142 +}
143 +
144 +func (b *bodyCloseRecorder) Read([]byte) (int, error) { return 0, io.EOF }
145 +
146 +func (b *bodyCloseRecorder) Close() error {
147 + b.closed = true
148 + return nil
149 +}
150 +
151 +func generateSelfSignedTLS(t *testing.T, commonName string) (*x509.Certificate, *tls.Config) {
152 + t.Helper()
153 + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
154 + if err != nil {
155 + t.Fatal(err)
156 + }
157 + template := x509.Certificate{
158 + SerialNumber: big.NewInt(1),
159 + Subject: pkix.Name{CommonName: commonName},
160 + NotBefore: time.Now().Add(-time.Minute),
161 + NotAfter: time.Now().Add(time.Hour),
162 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
163 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
164 + DNSNames: []string{commonName},
165 + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
166 + IsCA: true,
167 + }
168 + der, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv)
169 + if err != nil {
170 + t.Fatal(err)
171 + }
172 + cert, err := x509.ParseCertificate(der)
173 + if err != nil {
174 + t.Fatal(err)
175 + }
176 + tlsCert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv, Leaf: cert}
177 + return cert, &tls.Config{Certificates: []tls.Certificate{tlsCert}, NextProtos: []string{http3.NextProtoH3}}
178 +}
179 +
180 +func clientTLSFor(cert *x509.Certificate) *tls.Config {
181 + pool := x509.NewCertPool()
182 + pool.AddCert(cert)
183 + return &tls.Config{RootCAs: pool, NextProtos: []string{http3.NextProtoH3}}
184 +}
185 +
186 +func TestDialUpstreamWebTransport_Succeeds(t *testing.T) {
187 + if testing.Short() {
188 + t.Skip()
189 + }
190 + addr, root, shutdown := startTestWebTransportServer(t, func(sess *webtransport.Session, _ *http.Request) {
191 + _ = sess.CloseWithError(0, "")
192 + })
193 + t.Cleanup(shutdown)
194 +
195 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
196 + defer cancel()
197 +
198 + url := fmt.Sprintf("https://localhost:%d/", addr.Port)
199 + rsp, sess, err := dialUpstreamWebTransport(ctx, clientTLSFor(root), url, nil, nil, "")
200 + if err != nil {
201 + t.Fatalf("dial failed: %v", err)
202 + }
203 + defer sess.CloseWithError(0, "")
204 + if rsp != nil {
205 + defer rsp.Body.Close()
206 + }
207 + if rsp.StatusCode != http.StatusOK {
208 + t.Fatalf("unexpected status %d", rsp.StatusCode)
209 + }
210 +}
211 +
212 +func TestDialUpstreamWebTransport_RejectedConnectClosesBody(t *testing.T) {
213 + body := &bodyCloseRecorder{}
214 + rsp, sess, err := finishUpstreamWebTransportDial(&http.Response{
215 + StatusCode: http.StatusForbidden,
216 + Body: body,
217 + }, nil, fmt.Errorf("received status %d", http.StatusForbidden))
218 + if err == nil {
219 + t.Fatal("expected dial error")
220 + }
221 + if rsp != nil || sess != nil {
222 + t.Fatalf("rejected dial returned rsp=%v sess=%v", rsp, sess)
223 + }
224 + if !body.closed {
225 + t.Fatal("response body was not closed")
226 + }
227 +
228 + if testing.Short() {
229 + t.Skip()
230 + }
231 + addr, root, shutdown := startRejectingWebTransportServer(t, http.StatusForbidden)
232 + t.Cleanup(shutdown)
233 +
234 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
235 + defer cancel()
236 + url := fmt.Sprintf("https://localhost:%d/", addr.Port)
237 + rsp, sess, err = dialUpstreamWebTransport(ctx, clientTLSFor(root), url, nil, nil, "")
238 + if err == nil {
239 + t.Fatal("expected rejected CONNECT to fail")
240 + }
241 + if rsp != nil || sess != nil {
242 + t.Fatalf("rejected CONNECT returned rsp=%v sess=%v; body must be closed before return", rsp, sess)
243 + }
244 +}
245 +
246 +func TestDialUpstreamWebTransport_ForwardsHeaders(t *testing.T) {
247 + if testing.Short() {
248 + t.Skip()
249 + }
250 + gotUA := make(chan string, 1)
251 + addr, root, shutdown := startTestWebTransportServer(t, func(sess *webtransport.Session, r *http.Request) {
252 + gotUA <- r.Header.Get("User-Agent")
253 + _ = sess.CloseWithError(0, "")
254 + })
255 + t.Cleanup(shutdown)
256 +
257 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
258 + defer cancel()
259 + hdr := http.Header{"User-Agent": []string{"caddy-wt-test"}}
260 +
261 + url := fmt.Sprintf("https://localhost:%d/", addr.Port)
262 + rsp, sess, err := dialUpstreamWebTransport(ctx, clientTLSFor(root), url, hdr, nil, "")
263 + if err != nil {
264 + t.Fatalf("dial failed: %v", err)
265 + }
266 + defer sess.CloseWithError(0, "")
267 + if rsp != nil {
268 + defer rsp.Body.Close()
269 + }
270 +
271 + select {
272 + case got := <-gotUA:
273 + if got != "caddy-wt-test" {
274 + t.Errorf("User-Agent not forwarded; got %q", got)
275 + }
276 + case <-time.After(time.Second):
277 + t.Fatal("server handler did not observe User-Agent header in time")
278 + }
279 +}
280 +
281 +func TestDialUpstreamWebTransport_BadAddress(t *testing.T) {
282 + if testing.Short() {
283 + t.Skip()
284 + }
285 + // No server on this port; expect a dial error within the context deadline.
286 + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
287 + defer cancel()
288 + // Use a loopback port we picked at random and left unbound.
289 + freePort := pickFreeUDPPort(t)
290 + url := fmt.Sprintf("https://127.0.0.1:%d/", freePort)
291 + _, _, err := dialUpstreamWebTransport(ctx, &tls.Config{InsecureSkipVerify: true}, url, nil, nil, "") //nolint:gosec // test only
292 + if err == nil {
293 + t.Fatal("expected error dialing unbound port, got nil")
294 + }
295 +}
296 +
297 +func TestParseWTAvailableProtocols(t *testing.T) {
298 + header := func(v string) http.Header {
299 + h := make(http.Header)
300 + if v != "" {
301 + h.Set(wtAvailableProtocolsHeader, v)
302 + }
303 + return h
304 + }
305 + for i, tc := range []struct {
306 + name string
307 + hdr http.Header
308 + want []string
309 + }{
310 + {name: "missing", hdr: http.Header{}},
311 + {
312 + name: "single",
313 + hdr: header(`"moqt-19"`),
314 + want: []string{"moqt-19"},
315 + },
316 + {
317 + name: "preference order",
318 + hdr: header(`"moqt-19", "moqt-18"`),
319 + want: []string{"moqt-19", "moqt-18"},
320 + },
321 + {
322 + name: "malformed",
323 + hdr: header("not a structured field"),
324 + },
325 + } {
326 + got := parseWTAvailableProtocols(tc.hdr)
327 + if len(got) != len(tc.want) {
328 + t.Errorf("test %d (%s): got %v, want %v", i, tc.name, got, tc.want)
329 + continue
330 + }
331 + for j := range got {
332 + if got[j] != tc.want[j] {
333 + t.Errorf("test %d (%s): got %v, want %v", i, tc.name, got, tc.want)
334 + break
335 + }
336 + }
337 + }
338 +}
339 +
340 +func TestDialUpstreamWebTransport_NegotiatesProtocol(t *testing.T) {
341 + if testing.Short() {
342 + t.Skip()
343 + }
344 + addr, root, shutdown := startTestWebTransportServer(t, func(sess *webtransport.Session, _ *http.Request) {
345 + _ = sess.CloseWithError(0, "")
346 + }, "moqt-19")
347 + t.Cleanup(shutdown)
348 +
349 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
350 + defer cancel()
351 +
352 + url := fmt.Sprintf("https://localhost:%d/", addr.Port)
353 + rsp, sess, err := dialUpstreamWebTransport(ctx, clientTLSFor(root), url, nil, []string{"moqt-19"}, "")
354 + if err != nil {
355 + t.Fatalf("dial failed: %v", err)
356 + }
357 + defer sess.CloseWithError(0, "")
358 + if rsp != nil {
359 + defer rsp.Body.Close()
360 + }
361 + if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
362 + t.Errorf("ApplicationProtocol = %q, want moqt-19", got)
363 + }
364 + if got := rsp.Header.Get(wtProtocolHeader); got == "" {
365 + t.Error("expected WT-Protocol on the response")
366 + }
367 +}
368 +
369 +func TestDialUpstreamWebTransport_PreparedHost(t *testing.T) {
370 + if testing.Short() {
371 + t.Skip()
372 + }
373 + gotHost := make(chan string, 1)
374 + addr, root, shutdown := startTestWebTransportServer(t, func(sess *webtransport.Session, r *http.Request) {
375 + gotHost <- r.Host
376 + _ = sess.CloseWithError(0, "")
377 + })
378 + t.Cleanup(shutdown)
379 +
380 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
381 + defer cancel()
382 + url := fmt.Sprintf("https://localhost:%d/", addr.Port)
383 + wantHost := "wt.example.test"
384 + rsp, sess, err := dialUpstreamWebTransport(ctx, clientTLSFor(root), url, nil, nil, wantHost)
385 + if err != nil {
386 + t.Fatalf("dial failed: %v", err)
387 + }
388 + defer sess.CloseWithError(0, "")
389 + if rsp != nil {
390 + defer rsp.Body.Close()
391 + }
392 + select {
393 + case got := <-gotHost:
394 + if got != wantHost {
395 + t.Errorf("Host = %q, want %q", got, wantHost)
396 + }
397 + case <-time.After(time.Second):
398 + t.Fatal("server handler did not observe Host in time")
399 + }
400 +}
401 +
402 +func TestApplyWebTransportResponseHeaders(t *testing.T) {
403 + rw := httptest.NewRecorder()
404 + up := &http.Response{
405 + StatusCode: http.StatusOK,
406 + Header: http.Header{
407 + "X-Upstream": []string{"yes"},
408 + "Connection": []string{"keep-alive"},
409 + "Keep-Alive": []string{"timeout=5"},
410 + wtProtocolHeader: []string{"moqt-19"},
411 + },
412 + }
413 + h := &Handler{
414 + Headers: &headers.Handler{
415 + Response: &headers.RespHeaderOps{
416 + HeaderOps: &headers.HeaderOps{
417 + Set: http.Header{"X-Down": []string{"from-caddy"}},
418 + },
419 + },
420 + },
421 + }
422 + applyWebTransportResponseHeaders(rw, up, h, caddy.NewReplacer())
423 + if got := rw.Header().Get("X-Upstream"); got != "yes" {
424 + t.Errorf("X-Upstream = %q, want yes", got)
425 + }
426 + if got := rw.Header().Get("X-Down"); got != "from-caddy" {
427 + t.Errorf("X-Down = %q, want from-caddy", got)
428 + }
429 + if got := rw.Header().Get(wtProtocolHeader); got != "moqt-19" {
430 + t.Errorf("WT-Protocol = %q, want moqt-19", got)
431 + }
432 + if got := rw.Header().Get("Keep-Alive"); got != "" {
433 + t.Errorf("hop-by-hop Keep-Alive leaked: %q", got)
434 + }
435 +}
436 +
437 +// pickFreeUDPPort returns a local UDP port that was free when picked. The
438 +// caller should use it immediately — there's no guarantee another process
439 +// hasn't bound it in the interim.
440 +func pickFreeUDPPort(t *testing.T) int {
441 + t.Helper()
442 + l, err := net.ListenPacket("udp", "127.0.0.1:0")
443 + if err != nil {
444 + t.Fatal(err)
445 + }
446 + port := l.LocalAddr().(*net.UDPAddr).Port
447 + _ = l.Close()
448 + return port
449 +}
modules/caddyhttp/server.go
+415 -12
@@ -28,11 +28,15 @@ import (
28 "runtime"
29 "slices"
30 "strings"
31 + "sync"
32 "sync/atomic"
33 "time"
34
35 "github.com/caddyserver/certmagic"
36 + "github.com/quic-go/quic-go"
37 "github.com/quic-go/quic-go/http3"
38 + h3qlog "github.com/quic-go/quic-go/http3/qlog"
39 + "github.com/quic-go/webtransport-go"
40 "go.uber.org/zap"
41 "go.uber.org/zap/zapcore"
42
@@ -162,6 +166,24 @@ type Server struct {
166 // TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
167 EnableFullDuplex bool `json:"enable_full_duplex,omitempty"`
168
169 + // WebTransport, if non-nil, enables WebTransport (draft-ietf-webtrans-http3)
170 + // on this server's HTTP/3 listener. When set, the HTTP/3 server
171 + // advertises WebTransport in SETTINGS, enables HTTP/3 DATAGRAMs and
172 + // QUIC stream-reset partial delivery, and dispatches each QUIC
173 + // connection through webtransport.Server.ServeQUICConn so that
174 + // handlers can upgrade Extended CONNECT requests with
175 + // `:protocol=webtransport`. When nil, the HTTP/3 path is
176 + // bit-for-bit identical to the pre-WebTransport behavior: clients
177 + // that don't speak WebTransport see nothing new.
178 + //
179 + // The object is currently empty; it exists so future options can be
180 + // added without renaming the config key. Specifying `{}` enables it.
181 + //
182 + // Requires HTTP/3.
183 + //
184 + // TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
185 + WebTransport *WebTransportConfig `json:"webtransport,omitempty"`
186 +
187 // A list of header field names containing underscores that should
188 // be preserved instead of being dropped. By default, Caddy drops
189 // ALL headers with underscores to prevent ambiguity with
@@ -384,8 +406,31 @@ type Server struct {
406
407 server *http.Server
408 h3server *http3.Server
409 + wtServer *webtransport.Server
410 addresses []caddy.NetworkAddress
411
412 + // wtSessions are upgraded WebTransport sessions. A finite grace
413 + // period closes them when the deadline passes, without closing the
414 + // shared HTTP/3 server. Eternal grace leaves them until the peer
415 + // ends the session. wtConns counts ServeQUICConn accept loops.
416 + // wtStopping is set before waiting on wtConns so Add cannot race
417 + // with Wait.
418 + //
419 + // rawConns are the QUIC connections passed to ServeQUICConn. reqs
420 + // counts in-flight HTTP/3 requests on that connection, including a
421 + // WebTransport extended CONNECT that is still inside ServeHTTP.
422 + // Shutdown closes connections with no in-flight requests so an idle
423 + // keepalive cannot retain the old config for the QUIC idle timeout.
424 + // h3Fallbacks are plain HTTP/3 servers used when a pooled listener
425 + // cannot offer WebTransport. Shutdown must drain them itself;
426 + // s.h3server does not track their connections.
427 + wtServeMu sync.Mutex
428 + wtStopping bool
429 + wtConns sync.WaitGroup
430 + wtSessions map[io.Closer]struct{}
431 + rawConns map[*quic.Conn]*rawQUICConn
432 + h3Fallbacks []*http3.Server
433 +
434 trustedProxies IPRangeSource
435
436 shutdownAt atomic.Pointer[time.Time]
@@ -553,6 +598,13 @@ var (
598 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
599 start := time.Now()
600
601 + // Count HTTP/3 requests on connections ServeQUICConn owns. http3.Server
602 + // does not track those, so shutdown uses this to tell an idle keepalive
603 + // from work that still has to drain.
604 + if finish := s.trackRawHTTP3Request(r); finish != nil {
605 + defer finish()
606 + }
607 +
608 // If there are listener wrappers that process tls connections but don't return a *tls.Conn, this field will be nil.
609 if r.TLS == nil {
610 if tlsConnStateFunc, ok := r.Context().Value(tlsConnectionStateFuncCtxKey).(func() *tls.ConnectionState); ok {
@@ -1060,33 +1112,144 @@ func (s *Server) serveHTTP3(addr caddy.NetworkAddress, tlsCfg *tls.Config) error
1112 return fmt.Errorf("starting HTTP/3 QUIC listener: %v", err)
1113 }
1114 addr.Network = h3net
1063 - h3ln, err := addr.ListenQUIC(s.ctx, 0, net.ListenConfig{}, tlsCfg, s.packetConnWrappers, s.Allow0RTT)
1115 + h3ln, err := addr.ListenQUIC(s.ctx, 0, net.ListenConfig{}, caddy.ListenQUICOptions{
1116 + TLSConfig: tlsCfg,
1117 + PacketConnWrappers: s.packetConnWrappers,
1118 + Allow0RTT: s.Allow0RTT,
1119 + EnableWebTransport: s.webTransportEnabled(),
1120 + })
1121 if err != nil {
1122 return fmt.Errorf("starting HTTP/3 QUIC listener: %v", err)
1123 }
1124
1125 // create HTTP/3 server if not done already
1126 if s.h3server == nil {
1070 - s.h3server = &http3.Server{
1071 - Handler: s,
1072 - TLSConfig: tlsCfg,
1073 - MaxHeaderBytes: s.MaxHeaderBytes,
1074 - // QUICConfig is deliberately unset: http3.Server only reads it when it
1075 - // creates its own listener (ListenAndServe/Serve). We bring our own
1076 - // listener and call ServeListener, so anything set here is ignored.
1077 - // The QUIC settings that actually apply are in NetworkAddress.ListenQUIC.
1078 - IdleTimeout: time.Duration(s.IdleTimeout),
1127 + s.h3server = s.buildHTTP3Server(tlsCfg)
1128 + if s.webTransportEnabled() {
1129 + s.wtServer = &webtransport.Server{H3: s.h3server}
1130 }
1131 }
1132
1133 s.quicListeners = append(s.quicListeners, h3ln)
1134
1084 - //nolint:errcheck
1085 - go s.h3server.ServeListener(h3ln)
1135 + go s.serveH3AcceptLoop(h3ln)
1136
1137 return nil
1138 }
1139
1140 +// serveH3AcceptLoop accepts incoming QUIC connections from the HTTP/3
1141 +// listener. When WebTransport is not enabled, the listener is handed
1142 +// directly to http3.Server — the code path is identical to pre-WebTransport
1143 +// Caddy. When enabled, each connection is dispatched through
1144 +// webtransport.Server.ServeQUICConn, which demultiplexes WebTransport
1145 +// streams from normal HTTP/3 streams (forwarding the latter to the
1146 +// http3.Server request path at the cost of one varint peek per stream).
1147 +//
1148 +// If WebTransport is enabled but the underlying QUIC listener was created
1149 +// without DATAGRAM support (e.g. a pooled listener reused after a reload that
1150 +// turns on webtransport), fall back to plain HTTP/3 with a warning so
1151 +// that H3 is not silently broken by webtransport.Server.ServeQUICConn rejecting
1152 +// every connection.
1153 +func (s *Server) serveH3AcceptLoop(h3ln http3.QUICListener) {
1154 + h3 := s.http3ServerForListener(h3ln)
1155 + if h3 != s.h3server || s.wtServer == nil {
1156 + _ = h3.ServeListener(h3ln)
1157 + return
1158 + }
1159 + for {
1160 + conn, err := h3ln.Accept(s.ctx)
1161 + if err != nil {
1162 + return
1163 + }
1164 + s.wtServeMu.Lock()
1165 + if s.wtStopping {
1166 + s.wtServeMu.Unlock()
1167 + _ = conn.CloseWithError(0, "")
1168 + continue
1169 + }
1170 + if s.rawConns == nil {
1171 + s.rawConns = make(map[*quic.Conn]*rawQUICConn)
1172 + }
1173 + s.rawConns[conn] = &rawQUICConn{}
1174 + s.wtConns.Add(1)
1175 + s.wtServeMu.Unlock()
1176 + go func(conn *quic.Conn) {
1177 + defer s.untrackRawConn(conn)
1178 + defer s.wtConns.Done()
1179 + err := s.wtServer.ServeQUICConn(conn)
1180 + if err != nil && !errors.Is(err, http.ErrServerClosed) {
1181 + if c := s.logger.Check(zapcore.ErrorLevel, "serving WebTransport QUIC connection"); c != nil {
1182 + c.Write(zap.Error(err))
1183 + }
1184 + }
1185 + }(conn)
1186 + }
1187 +}
1188 +
1189 +// http3ServerForListener returns the HTTP/3 server that should serve h3ln.
1190 +// When WebTransport is off, or when the listener was created without the
1191 +// QUIC capabilities WT needs (pooled listener reused after a reload), this
1192 +// is a plain HTTP/3 server that does not advertise WT in SETTINGS. The
1193 +// latter case logs a warning: the config asked for WebTransport but this
1194 +// socket cannot provide it.
1195 +func (s *Server) http3ServerForListener(h3ln http3.QUICListener) *http3.Server {
1196 + if !s.webTransportEnabled() {
1197 + return s.h3server
1198 + }
1199 + if wtLn, ok := h3ln.(interface{ SupportsWebTransport() bool }); ok && !wtLn.SupportsWebTransport() {
1200 + if c := s.logger.Check(zapcore.WarnLevel, "WebTransport unavailable: QUIC listener was created without DATAGRAM support; restart Caddy to apply webtransport"); c != nil {
1201 + c.Write(zap.Stringer("address", h3ln.Addr()))
1202 + }
1203 + var tlsCfg *tls.Config
1204 + if s.h3server != nil {
1205 + tlsCfg = s.h3server.TLSConfig
1206 + }
1207 + plain := s.buildHTTP3ServerWithWebTransport(tlsCfg, false)
1208 + s.wtServeMu.Lock()
1209 + s.h3Fallbacks = append(s.h3Fallbacks, plain)
1210 + s.wtServeMu.Unlock()
1211 + return plain
1212 + }
1213 + return s.h3server
1214 +}
1215 +
1216 +// buildHTTP3Server constructs the http3.Server used by this server for
1217 +// HTTP/3. When WebTransport is enabled, the server is additionally
1218 +// configured for WebTransport: WT enablement is advertised in SETTINGS,
1219 +// DATAGRAMs are enabled, QUIC stream-reset partial delivery is enabled,
1220 +// and a ConnContext hook stashes the *quic.Conn in each request's context
1221 +// so handlers can call webtransport.Server.Upgrade. When disabled, none of
1222 +// those modifications are applied and the returned server is
1223 +// bit-for-bit identical to the pre-WebTransport implementation.
1224 +func (s *Server) buildHTTP3Server(tlsCfg *tls.Config) *http3.Server {
1225 + return s.buildHTTP3ServerWithWebTransport(tlsCfg, s.webTransportEnabled())
1226 +}
1227 +
1228 +func (s *Server) buildHTTP3ServerWithWebTransport(tlsCfg *tls.Config, enableWT bool) *http3.Server {
1229 + qc := &quic.Config{
1230 + Versions: []quic.Version{quic.Version1, quic.Version2},
1231 + InitialPacketSize: 1200,
1232 + Tracer: h3qlog.DefaultConnectionTracer,
1233 + }
1234 + h3 := &http3.Server{
1235 + Handler: s,
1236 + TLSConfig: tlsCfg,
1237 + MaxHeaderBytes: s.MaxHeaderBytes,
1238 + QUICConfig: qc,
1239 + IdleTimeout: time.Duration(s.IdleTimeout),
1240 + }
1241 + if enableWT {
1242 + qc.EnableStreamResetPartialDelivery = true
1243 + // Stash the conn before ConfigureHTTP3Server wraps ConnContext, so
1244 + // ServeHTTP can attribute the request to this raw connection.
1245 + h3.ConnContext = func(ctx context.Context, c *quic.Conn) context.Context {
1246 + return context.WithValue(ctx, rawQUICConnCtxKey, c)
1247 + }
1248 + webtransport.ConfigureHTTP3Server(h3)
1249 + }
1250 + return h3
1251 +}
1252 +
1253 // configureServer applies/binds the registered callback functions to the server.
1254 func (s *Server) configureServer(server *http.Server) {
1255 for _, f := range s.connStateFuncs {
@@ -1377,6 +1540,246 @@ func (s *Server) Listeners() []net.Listener { return s.listeners }
1540 // Name returns the server's name.
1541 func (s *Server) Name() string { return s.name }
1542
1543 +// WebTransportConfig configures WebTransport on a server.
1544 +// Currently empty; specifying the object enables the feature.
1545 +// Reserved for future options.
1546 +//
1547 +// TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
1548 +type WebTransportConfig struct{}
1549 +
1550 +// webTransportEnabled reports whether this server has opted in to
1551 +// WebTransport. Presence of the config object is the enablement signal.
1552 +func (s *Server) webTransportEnabled() bool {
1553 + return s != nil && s.WebTransport != nil
1554 +}
1555 +
1556 +// WebTransportServer returns the server's underlying WebTransport
1557 +// serving state as an opaque value. Modules that import
1558 +// github.com/quic-go/webtransport-go may type-assert it to
1559 +// *webtransport.Server. Returns nil if WebTransport is not enabled
1560 +// on this server (WebTransport is nil or HTTP/3 is not in use).
1561 +//
1562 +// This is exposed as any so caddyhttp's public API does not leak the
1563 +// upstream webtransport-go type to packages that don't use it.
1564 +//
1565 +// EXPERIMENTAL: Subject to change or removal.
1566 +func (s *Server) WebTransportServer() any {
1567 + if s.wtServer == nil {
1568 + return nil
1569 + }
1570 + return s.wtServer
1571 +}
1572 +
1573 +// RegisterWebTransportSession tracks c so shutdown can close the session
1574 +// without closing the shared HTTP/3 server. webtransport.Server.Close also
1575 +// calls http3.Server.Close, which aborts ordinary HTTP/3 requests on the
1576 +// same server and waits with no grace deadline. The returned function
1577 +// removes c from the set and does not close it.
1578 +//
1579 +// EXPERIMENTAL: Subject to change or removal.
1580 +func (s *Server) RegisterWebTransportSession(c io.Closer) (unregister func()) {
1581 + if s == nil || c == nil {
1582 + return func() {}
1583 + }
1584 + s.wtServeMu.Lock()
1585 + if s.wtSessions == nil {
1586 + s.wtSessions = make(map[io.Closer]struct{})
1587 + }
1588 + s.wtSessions[c] = struct{}{}
1589 + s.wtServeMu.Unlock()
1590 + return func() {
1591 + s.wtServeMu.Lock()
1592 + delete(s.wtSessions, c)
1593 + s.wtServeMu.Unlock()
1594 + }
1595 +}
1596 +
1597 +// rawQUICConn is the shutdown state of one ServeQUICConn connection.
1598 +type rawQUICConn struct {
1599 + reqs int
1600 + closing bool
1601 +}
1602 +
1603 +// rawQUICConnCtxKey is the request-context key for the *quic.Conn serving
1604 +// a WebTransport-enabled HTTP/3 connection. The key is set from ConnContext
1605 +// before webtransport.ConfigureHTTP3Server wraps it.
1606 +type rawQUICConnCtxKeyType struct{}
1607 +
1608 +var rawQUICConnCtxKey = rawQUICConnCtxKeyType{}
1609 +
1610 +// trackRawHTTP3Request counts r against the raw QUIC connection that is
1611 +// serving it. The returned function drops the count after the HTTP/3 stack
1612 +// finishes the stream and, once shutdown has started, closes the connection
1613 +// when nothing is left in flight. Closing from inside ServeHTTP would abort
1614 +// the response: quic-go flushes and closes the stream only after the handler
1615 +// returns. It returns nil when r is not served by a tracked ServeQUICConn
1616 +// connection.
1617 +func (s *Server) trackRawHTTP3Request(r *http.Request) func() {
1618 + if s == nil || !s.webTransportEnabled() || r == nil {
1619 + return nil
1620 + }
1621 + c, ok := r.Context().Value(rawQUICConnCtxKey).(*quic.Conn)
1622 + if !ok || c == nil {
1623 + return nil
1624 + }
1625 + s.wtServeMu.Lock()
1626 + st := s.rawConns[c]
1627 + if st == nil || st.closing {
1628 + s.wtServeMu.Unlock()
1629 + return nil
1630 + }
1631 + st.reqs++
1632 + s.wtServeMu.Unlock()
1633 + // The request context is cancelled when the HTTP/3 stream's context
1634 + // ends. That happens after this handler returns, so wait in the
1635 + // background. Waiting here would deadlock the stream close.
1636 + ctx := r.Context()
1637 + return func() { go s.finishRawHTTP3Request(c, ctx) }
1638 +}
1639 +
1640 +// finishRawHTTP3Request drops one in-flight request once ctx ends. When
1641 +// shutdown is in progress and this was the last request, the connection is
1642 +// closed after a short delay so an idle keepalive cannot pin eternal grace.
1643 +// Closing immediately aborts a response the client has not read: quic-go
1644 +// turns a CONNECTION_CLOSE into a stream read error even if the bytes were
1645 +// already buffered.
1646 +func (s *Server) finishRawHTTP3Request(c *quic.Conn, ctx context.Context) {
1647 + if ctx != nil {
1648 + <-ctx.Done()
1649 + }
1650 + s.wtServeMu.Lock()
1651 + st := s.rawConns[c]
1652 + if st == nil {
1653 + s.wtServeMu.Unlock()
1654 + return
1655 + }
1656 + if st.reqs > 0 {
1657 + st.reqs--
1658 + }
1659 + closeNow := s.wtStopping && st.reqs == 0 && !st.closing
1660 + if closeNow {
1661 + st.closing = true
1662 + }
1663 + s.wtServeMu.Unlock()
1664 + if !closeNow {
1665 + return
1666 + }
1667 + timer := time.NewTimer(time.Second)
1668 + defer timer.Stop()
1669 + select {
1670 + case <-c.Context().Done():
1671 + case <-timer.C:
1672 + _ = c.CloseWithError(0, "")
1673 + }
1674 +}
1675 +
1676 +// closeIdleRawConns closes tracked connections that have no in-flight
1677 +// HTTP/3 request. An idle keepalive would otherwise keep ServeQUICConn
1678 +// running for the QUIC idle timeout, and eternal grace would wait that
1679 +// whole time.
1680 +func (s *Server) closeIdleRawConns() {
1681 + if s == nil {
1682 + return
1683 + }
1684 + s.wtServeMu.Lock()
1685 + var idle []*quic.Conn
1686 + for c, st := range s.rawConns {
1687 + if st.reqs == 0 && !st.closing {
1688 + st.closing = true
1689 + idle = append(idle, c)
1690 + }
1691 + }
1692 + s.wtServeMu.Unlock()
1693 + for _, c := range idle {
1694 + _ = c.CloseWithError(0, "")
1695 + }
1696 +}
1697 +
1698 +func (s *Server) untrackRawConn(c *quic.Conn) {
1699 + s.wtServeMu.Lock()
1700 + delete(s.rawConns, c)
1701 + s.wtServeMu.Unlock()
1702 +}
1703 +
1704 +// shutdownFallbackHTTP3 drains plain HTTP/3 servers created for listeners
1705 +// that cannot offer WebTransport. Each uses ServeListener, so its own
1706 +// Shutdown applies the grace period. The shared h3server does not see
1707 +// those connections.
1708 +func (s *Server) shutdownFallbackHTTP3(ctx context.Context) error {
1709 + if s == nil {
1710 + return nil
1711 + }
1712 + s.wtServeMu.Lock()
1713 + fallbacks := s.h3Fallbacks
1714 + s.h3Fallbacks = nil
1715 + s.wtServeMu.Unlock()
1716 + var errs []error
1717 + for _, fb := range fallbacks {
1718 + if fb == nil {
1719 + continue
1720 + }
1721 + if err := fb.Shutdown(ctx); err != nil {
1722 + errs = append(errs, err)
1723 + }
1724 + }
1725 + return errors.Join(errs...)
1726 +}
1727 +
1728 +// closeWebTransportSessions closes sessions registered with
1729 +// RegisterWebTransportSession. It does not close QUIC connections or the
1730 +// HTTP/3 server.
1731 +func (s *Server) closeWebTransportSessions() {
1732 + if s == nil {
1733 + return
1734 + }
1735 + s.wtServeMu.Lock()
1736 + closers := make([]io.Closer, 0, len(s.wtSessions))
1737 + for c := range s.wtSessions {
1738 + closers = append(closers, c)
1739 + }
1740 + s.wtSessions = nil
1741 + s.wtServeMu.Unlock()
1742 + for _, c := range closers {
1743 + _ = c.Close()
1744 + }
1745 +}
1746 +
1747 +// waitWebTransportConns waits until ServeQUICConn loops finish, or until
1748 +// ctx ends. Those connections are not tracked by http3.Server.Shutdown
1749 +// because webtransport.Server serves them through NewRawServerConn.
1750 +//
1751 +// When ctx ends first, registered sessions are closed while the packet
1752 +// conns are still open so the close capsule can reach the peer. The
1753 +// loops then get a short window to exit before the caller drops the
1754 +// sockets. A nil ctx.Done (eternal grace) waits until the peer leaves
1755 +// and does not force-close.
1756 +func (s *Server) waitWebTransportConns(ctx context.Context) {
1757 + if s == nil {
1758 + return
1759 + }
1760 + s.wtServeMu.Lock()
1761 + s.wtStopping = true
1762 + s.wtServeMu.Unlock()
1763 + s.closeIdleRawConns()
1764 +
1765 + done := make(chan struct{})
1766 + go func() {
1767 + s.wtConns.Wait()
1768 + close(done)
1769 + }()
1770 + select {
1771 + case <-done:
1772 + return
1773 + case <-ctx.Done():
1774 + }
1775 +
1776 + s.closeWebTransportSessions()
1777 + select {
1778 + case <-done:
1779 + case <-time.After(time.Second):
1780 + }
1781 +}
1782 +
1783 // PrepareRequest fills the request r for use in a Caddy HTTP handler chain. w and s can
1784 // be nil, but the handlers will lose response placeholders and access to the server.
1785 func PrepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter, s *Server) *http.Request {
modules/caddyhttp/server_bench_test.go new
+55
@@ -0,0 +1,55 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package caddyhttp
16 +
17 +import (
18 + "crypto/tls"
19 + "testing"
20 +)
21 +
22 +// BenchmarkBuildHTTP3Server_WebTransportOff measures the cost of
23 +// constructing the HTTP/3 server for a deployment that does NOT opt in
24 +// to WebTransport. This is the primary cost comparison steadytao asked
25 +// about: any non-zero delta vs. pre-WebTransport Caddy would need to be
26 +// justified, and the implementation is structured so the delta is
27 +// exactly the branch check on webTransportEnabled.
28 +//
29 +// This benchmark does not exercise the per-stream dispatch cost
30 +// (which is inside webtransport-go / quic-go and would require a full
31 +// QUIC setup to measure in isolation). The meaningful regression guard
32 +// is whether buildHTTP3Server with the flag off does the same work
33 +// as on pre-PR master.
34 +func BenchmarkBuildHTTP3Server_WebTransportOff(b *testing.B) {
35 + s := &Server{}
36 + tlsCfg := &tls.Config{}
37 + b.ResetTimer()
38 + for b.Loop() {
39 + _ = s.buildHTTP3Server(tlsCfg)
40 + }
41 +}
42 +
43 +// BenchmarkBuildHTTP3Server_WebTransportOn measures the same
44 +// construction with WebTransport enabled. The cost difference vs. the
45 +// Off variant is the one-time setup webtransport.ConfigureHTTP3Server
46 +// performs (AdditionalSettings, ConnContext, EnableDatagrams, etc.)
47 +// plus setting EnableStreamResetPartialDelivery on the QUIC config.
48 +func BenchmarkBuildHTTP3Server_WebTransportOn(b *testing.B) {
49 + s := &Server{WebTransport: new(WebTransportConfig)}
50 + tlsCfg := &tls.Config{}
51 + b.ResetTimer()
52 + for b.Loop() {
53 + _ = s.buildHTTP3Server(tlsCfg)
54 + }
55 +}
modules/caddyhttp/server_test.go
+124
@@ -4,6 +4,7 @@ import (
4 "bufio"
5 "bytes"
6 "context"
7 + "crypto/tls"
8 "errors"
9 "fmt"
10 "io"
@@ -17,6 +18,8 @@ import (
18 "time"
19
20 "github.com/caddyserver/caddy/v2"
21 + "github.com/quic-go/quic-go"
22 + "github.com/quic-go/quic-go/http3"
23 "github.com/stretchr/testify/assert"
24 "github.com/stretchr/testify/require"
25 "go.uber.org/zap"
@@ -1571,6 +1574,127 @@ func TestServer_ServeHTTP_IdleTimeouts(t *testing.T) {
1574 }
1575 }
1576
1577 +// TestServer_BuildHTTP3ServerEnablesWebTransport asserts that with
1578 +// WebTransport set the http3.Server advertises WebTransport in
1579 +// its SETTINGS and wires the prerequisites webtransport.Server.Upgrade
1580 +// relies on: DATAGRAM support, a non-nil ConnContext hook (used to stash
1581 +// the underlying *quic.Conn for Upgrade to retrieve), and QUIC
1582 +// stream-reset partial delivery.
1583 +func TestServer_BuildHTTP3ServerEnablesWebTransport(t *testing.T) {
1584 + s := &Server{WebTransport: new(WebTransportConfig)}
1585 + h3 := s.buildHTTP3Server(&tls.Config{})
1586 +
1587 + assert.NotNil(t, h3, "expected non-nil http3.Server")
1588 + assert.True(t, h3.EnableDatagrams, "EnableDatagrams must be true for WebTransport DATAGRAMs")
1589 + assert.NotEmpty(t, h3.AdditionalSettings, "AdditionalSettings must advertise WebTransport enablement")
1590 + assert.NotNil(t, h3.ConnContext, "ConnContext must be set so webtransport.Server.Upgrade can retrieve the *quic.Conn")
1591 + assert.NotNil(t, h3.QUICConfig, "QUICConfig must be set")
1592 + assert.True(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery is required by webtransport-go")
1593 +}
1594 +
1595 +// TestServer_BuildHTTP3ServerWithoutWebTransport asserts that with
1596 +// WebTransport unset (the default) the http3.Server does NOT
1597 +// advertise WebTransport and does not enable the related QUIC/HTTP/3
1598 +// features. This is the load-bearing regression guard: non-WT HTTP/3
1599 +// deployments must pay zero cost for the WebTransport feature.
1600 +func TestServer_BuildHTTP3ServerWithoutWebTransport(t *testing.T) {
1601 + s := &Server{}
1602 + h3 := s.buildHTTP3Server(&tls.Config{})
1603 +
1604 + assert.NotNil(t, h3)
1605 + assert.False(t, h3.EnableDatagrams, "EnableDatagrams must be false when WebTransport is disabled")
1606 + assert.Empty(t, h3.AdditionalSettings, "AdditionalSettings must be empty when WebTransport is disabled")
1607 + assert.Nil(t, h3.ConnContext, "ConnContext must be nil when WebTransport is disabled")
1608 + assert.NotNil(t, h3.QUICConfig)
1609 + assert.False(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery must be false when WebTransport is disabled")
1610 +}
1611 +
1612 +// TestServer_BuildHTTP3ServerAppliesHandlerAndTLS is a smoke test for the
1613 +// non-WebTransport fields of the constructed http3.Server, guarding against a
1614 +// refactor accidentally dropping them.
1615 +func TestServer_BuildHTTP3ServerAppliesHandlerAndTLS(t *testing.T) {
1616 + s := &Server{MaxHeaderBytes: 4096}
1617 + tlsCfg := &tls.Config{}
1618 + h3 := s.buildHTTP3Server(tlsCfg)
1619 +
1620 + assert.Same(t, s, h3.Handler, "http3.Server.Handler should be the caddyhttp.Server itself")
1621 + assert.Same(t, tlsCfg, h3.TLSConfig, "http3.Server.TLSConfig should be the config passed in")
1622 + assert.Equal(t, 4096, h3.MaxHeaderBytes)
1623 +}
1624 +
1625 +// TestServer_WebTransportServerNilUntilH3 asserts the accessor returns nil
1626 +// when HTTP/3 has not been configured.
1627 +func TestServer_WebTransportServerNilUntilH3(t *testing.T) {
1628 + s := &Server{}
1629 + assert.Nil(t, s.WebTransportServer(), "expected nil before HTTP/3 setup")
1630 +}
1631 +
1632 +// stubQUICListenerWithoutWT implements http3.QUICListener and reports that
1633 +// WebTransport is not supported. Accept returns immediately with an error so
1634 +// that h3server.ServeListener exits promptly when the fallback path is taken.
1635 +type stubQUICListenerWithoutWT struct{}
1636 +
1637 +func (stubQUICListenerWithoutWT) Accept(context.Context) (*quic.Conn, error) {
1638 + return nil, errors.New("stub: no connections")
1639 +}
1640 +
1641 +func (stubQUICListenerWithoutWT) Addr() net.Addr {
1642 + return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9443}
1643 +}
1644 +
1645 +func (stubQUICListenerWithoutWT) Close() error { return nil }
1646 +
1647 +func (stubQUICListenerWithoutWT) SupportsWebTransport() bool { return false }
1648 +
1649 +// TestServeH3AcceptLoopFallsBackWhenListenerLacksWebTransport verifies that
1650 +// when WebTransport is enabled but the QUIC listener was created without
1651 +// DATAGRAM support, serveH3AcceptLoop falls back to plain HTTP/3 with a
1652 +// warning instead of calling webtransport.Server.ServeQUICConn. wtServer is
1653 +// intentionally nil so the WebTransport branch cannot be silently taken; the
1654 +// logged warning proves the fallback path was used.
1655 +func TestServeH3AcceptLoopFallsBackWhenListenerLacksWebTransport(t *testing.T) {
1656 + core, logs := observer.New(zapcore.WarnLevel)
1657 + s := &Server{
1658 + WebTransport: new(WebTransportConfig),
1659 + logger: zap.New(core),
1660 + h3server: &http3.Server{},
1661 + // wtServer left nil: WebTransport path would nil-panic
1662 + }
1663 + // Must not panic: fallback uses a plain ServeListener, not wtServer.
1664 + s.serveH3AcceptLoop(stubQUICListenerWithoutWT{})
1665 +
1666 + if logs.FilterMessageSnippet("WebTransport unavailable").Len() != 1 {
1667 + t.Errorf("expected fallback warning to be logged, got logs: %v", logs.All())
1668 + }
1669 +}
1670 +
1671 +// TestHTTP3ServerForListenerOmitsWebTransportSettingsWhenListenerLacksSupport
1672 +// is the SETTINGS half of the fallback: the server used for that listener
1673 +// must not advertise WebTransport, even though the parent Server opted in.
1674 +func TestHTTP3ServerForListenerOmitsWebTransportSettingsWhenListenerLacksSupport(t *testing.T) {
1675 + core, logs := observer.New(zapcore.WarnLevel)
1676 + tlsCfg := &tls.Config{}
1677 + s := &Server{
1678 + WebTransport: new(WebTransportConfig),
1679 + logger: zap.New(core),
1680 + }
1681 + s.h3server = s.buildHTTP3Server(tlsCfg)
1682 + assert.True(t, s.h3server.EnableDatagrams)
1683 + assert.NotEmpty(t, s.h3server.AdditionalSettings)
1684 +
1685 + plain := s.http3ServerForListener(stubQUICListenerWithoutWT{})
1686 + if plain == s.h3server {
1687 + t.Fatal("fallback must not reuse the WebTransport-configured HTTP/3 server")
1688 + }
1689 + assert.False(t, plain.EnableDatagrams, "fallback must not enable DATAGRAMs")
1690 + assert.Empty(t, plain.AdditionalSettings, "fallback must not advertise WebTransport SETTINGS")
1691 + assert.Nil(t, plain.ConnContext)
1692 + assert.False(t, plain.QUICConfig.EnableStreamResetPartialDelivery)
1693 + if logs.FilterMessageSnippet("WebTransport unavailable").Len() != 1 {
1694 + t.Errorf("expected fallback warning to be logged, got logs: %v", logs.All())
1695 + }
1696 +}
1697 +
1698 func TestServerLogLevel(t *testing.T) {
1699 for _, tc := range []struct {
1700 name string