diff --git a/caddyconfig/httpcaddyfile/serveroptions.go b/caddyconfig/httpcaddyfile/serveroptions.go index 005cf81d3..fd122e0d2 100644 --- a/caddyconfig/httpcaddyfile/serveroptions.go +++ b/caddyconfig/httpcaddyfile/serveroptions.go @@ -48,6 +48,7 @@ type serverOptions struct { KeepAliveCount int MaxHeaderBytes int EnableFullDuplex bool + EnableWebTransport bool ExpectedUnderscoreHeaders []string Protocols []string StrictSNIHost *bool @@ -219,6 +220,12 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) { } serverOpts.EnableFullDuplex = true + case "enable_webtransport": + if d.NextArg() { + return nil, d.ArgErr() + } + serverOpts.EnableWebTransport = true + case "expected_underscore_headers": args := d.RemainingArgs() if len(args) == 0 { @@ -388,6 +395,7 @@ func applyServerOptions( server.KeepAliveCount = opts.KeepAliveCount server.MaxHeaderBytes = opts.MaxHeaderBytes server.EnableFullDuplex = opts.EnableFullDuplex + server.EnableWebTransport = opts.EnableWebTransport server.ExpectedUnderscoreHeaders = opts.ExpectedUnderscoreHeaders server.Protocols = opts.Protocols server.StrictSNIHost = opts.StrictSNIHost diff --git a/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest index 4991b308e..88e957402 100644 --- a/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest +++ b/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest @@ -12,6 +12,7 @@ } max_header_size 100MB enable_full_duplex + enable_webtransport log_credentials protocols h1 h2 h2c h3 strict_sni_host @@ -54,6 +55,7 @@ foo.com { "keepalive_count": 10, "max_header_bytes": 100000000, "enable_full_duplex": true, + "enable_webtransport": true, "routes": [ { "match": [ diff --git a/caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest b/caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest deleted file mode 100644 index c55eb1945..000000000 --- a/caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest +++ /dev/null @@ -1,47 +0,0 @@ -:8443 - -reverse_proxy https://backend:9443 { - transport http { - versions 3 - webtransport - tls_insecure_skip_verify - } -} ----------- -{ - "apps": { - "http": { - "servers": { - "srv0": { - "listen": [ - ":8443" - ], - "routes": [ - { - "handle": [ - { - "handler": "reverse_proxy", - "transport": { - "protocol": "http", - "tls": { - "insecure_skip_verify": true - }, - "versions": [ - "3" - ], - "webtransport": true - }, - "upstreams": [ - { - "dial": "backend:9443" - } - ] - } - ] - } - ] - } - } - } - } -} diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go index ae146a3bb..6efe17e94 100644 --- a/caddytest/integration/webtransport_test.go +++ b/caddytest/integration/webtransport_test.go @@ -63,6 +63,7 @@ func TestWebTransport_EchoHandlerBidi(t *testing.T) { "srv0": { "listen": [":9443"], "protocols": ["h3"], + "enable_webtransport": true, "routes": [ { "handle": [{"handler": "webtransport"}] @@ -181,6 +182,7 @@ func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) { "proxy": { "listen": [":9443"], "protocols": ["h3"], + "enable_webtransport": true, "routes": [ { "handle": [ @@ -189,7 +191,6 @@ func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) { "transport": { "protocol": "http", "versions": ["3"], - "webtransport": true, "tls": {"insecure_skip_verify": true} }, "upstreams": [{"dial": "127.0.0.1:9444"}] @@ -207,6 +208,7 @@ func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) { "upstream": { "listen": [":9444"], "protocols": ["h3"], + "enable_webtransport": true, "routes": [ {"handle": [{"handler": "webtransport"}]} ], @@ -329,6 +331,7 @@ func TestWebTransport_ReverseProxyForwardsHeaders(t *testing.T) { "proxy": { "listen": [":9443"], "protocols": ["h3"], + "enable_webtransport": true, "routes": [ { "handle": [ @@ -337,7 +340,6 @@ func TestWebTransport_ReverseProxyForwardsHeaders(t *testing.T) { "transport": { "protocol": "http", "versions": ["3"], - "webtransport": true, "tls": {"insecure_skip_verify": true} }, "headers": { @@ -451,6 +453,7 @@ func TestWebTransport_UpstreamDialFailureSurfaces5xx(t *testing.T) { "proxy": { "listen": [":9443"], "protocols": ["h3"], + "enable_webtransport": true, "routes": [ { "handle": [ @@ -459,7 +462,6 @@ func TestWebTransport_UpstreamDialFailureSurfaces5xx(t *testing.T) { "transport": { "protocol": "http", "versions": ["3"], - "webtransport": true, "tls": {"insecure_skip_verify": true} }, "upstreams": [{"dial": "127.0.0.1:%d"}] @@ -580,6 +582,7 @@ func TestWebTransport_InFlightRequestsTracked(t *testing.T) { "proxy": { "listen": [":9443"], "protocols": ["h3"], + "enable_webtransport": true, "routes": [ { "handle": [ @@ -588,7 +591,6 @@ func TestWebTransport_InFlightRequestsTracked(t *testing.T) { "transport": { "protocol": "http", "versions": ["3"], - "webtransport": true, "tls": {"insecure_skip_verify": true} }, "upstreams": [{"dial": "%s"}] diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index 92ba48399..8716babe3 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -1315,15 +1315,6 @@ func (h *HTTPTransport) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } - case "webtransport": - // Accepts no arguments: `webtransport` alone enables it. - // Exclusivity with `versions 3` is enforced at Provision - // time so parsing is order-independent. - if d.NextArg() { - return d.Errf("webtransport does not take arguments") - } - h.WebTransport = true - case "compression": if d.NextArg() { if d.Val() == "off" { diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go index caac9f641..d2645deed 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -139,20 +139,6 @@ type HTTPTransport struct { // to change or removal while experimental. Versions []string `json:"versions,omitempty"` - // WebTransport enables reverse-proxying of WebTransport sessions - // (https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) to - // the upstream. Requires Versions to be exactly ["3"]. When - // enabled, the frontend Caddy server must itself be serving HTTP/3, - // and any Extended CONNECT request with :protocol=webtransport will - // have its streams and datagrams pumped between the client and the - // upstream — bypassing the normal HTTP round-trip path. - // - // EXPERIMENTAL: subject to change or removal. The upstream - // WebTransport protocol draft is still evolving; this lands with - // whatever draft version the webtransport-go library supports at - // build time. - WebTransport bool `json:"webtransport,omitempty"` - // Specify the address to bind to when connecting to an upstream. In other words, // it is the address the upstream sees as the remote address. LocalAddress string `json:"local_address,omitempty"` @@ -539,12 +525,6 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e return nil, fmt.Errorf("if HTTP/3 is enabled to the upstream, no other HTTP versions are supported") } - // WebTransport rides on HTTP/3 exclusively and reuses the TLS client - // config built for h3Transport above. - if h.WebTransport && !(len(h.Versions) == 1 && h.Versions[0] == "3") { - return nil, fmt.Errorf("webtransport requires versions to be exactly [\"3\"]") - } - // if h2/c is enabled, configure it explicitly if slices.Contains(h.Versions, "2") || slices.Contains(h.Versions, "h2c") { if err := http2.ConfigureTransport(rt); err != nil { diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 5a7b7e0cd..2e8c4f1ba 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -274,12 +274,6 @@ type Handler struct { CB CircuitBreaker `json:"-"` DynamicUpstreams UpstreamSource `json:"-"` - // webtransportEnabled is set at Provision time to true iff - // Transport is *HTTPTransport with WebTransport enabled. Checked on - // the ServeHTTP hot path so non-WT transports skip the type - // assertion on every request. - webtransportEnabled bool - // transportHeaderOps is a set of header operations provided // by the transport at provision time, if the transport // implements TransportHeaderOpsProvider. These ops are @@ -349,12 +343,6 @@ func (h *Handler) Provision(ctx caddy.Context) error { h.ResponseBuffers = respBuffers } } - - // Cache WebTransport enablement so ServeHTTP can short-circuit - // the per-request type assertion on non-WT paths. - if ht, ok := h.Transport.(*HTTPTransport); ok { - h.webtransportEnabled = ht.WebTransport - } } if h.LoadBalancing != nil && h.LoadBalancing.SelectionPolicyRaw != nil { mod, err := ctx.LoadModule(h.LoadBalancing, "SelectionPolicyRaw") @@ -550,8 +538,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht // WebTransport: HTTP/3 Extended CONNECT with :protocol=webtransport // can't flow through the normal HTTP round-trip — the session hosts // many QUIC streams and datagrams that need bidirectional pumping. - // Branch out early before anything else touches the request. - if h.webtransportEnabled && isWebTransportExtendedConnect(r) { + // Detect it here the same way the handler detects a WebSocket + // upgrade: by request shape, not by a per-handler config flag. The + // underlying *webtransport.Server only exists when the parent + // server has enable_webtransport set, so serveWebTransport fails + // fast and clearly if a WT request reaches a non-WT server. + if isWebTransportExtendedConnect(r) { return h.serveWebTransport(w, r) } diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index 02b0e6b9c..0cd3ff7e7 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -86,7 +86,7 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro wtServer, ok := srv.WebTransportServer().(*webtransport.Server) if !ok || wtServer == nil { return caddyhttp.Error(http.StatusInternalServerError, - errors.New("webtransport: HTTP/3 is not enabled on this server; WebTransport requires H3")) + errors.New("webtransport: server has enable_webtransport=false or HTTP/3 is not enabled")) } if h.LoadBalancing == nil || h.LoadBalancing.SelectionPolicy == nil { @@ -139,11 +139,24 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro errors.New("webtransport: response writer does not support WebTransport upgrade")) } + // A WT CONNECT reached this handler because the parent server has + // enable_webtransport=true. But the handler's transport still has to + // speak HTTP/3 to dial WT upstream. Fail fast and clearly if it + // doesn't, the same way we'd fail on an unreachable upstream. + ht, ok := h.Transport.(*HTTPTransport) + if !ok { + return caddyhttp.Error(http.StatusBadGateway, + errors.New("webtransport: requires the 'http' transport with versions [\"3\"]")) + } + if ht.h3Transport == nil { + return caddyhttp.Error(http.StatusBadGateway, + errors.New("webtransport: transport does not include HTTP/3; set versions to [\"3\"]")) + } + // Dial the upstream BEFORE upgrading the client. If the upstream is // unreachable or refuses the CONNECT, a proper 5xx goes back over the // H3 stream and the client's Dial sees the real status — instead of // an already-upgraded session closing immediately. - ht := h.Transport.(*HTTPTransport) upstreamURL := buildWebTransportUpstreamURL(dialInfo.Address, clonedReq) upstreamResp, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, clonedReq.Header) if err != nil { diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index f71b93b9f..c4d99d183 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -125,6 +125,27 @@ type Server struct { // TODO: This is an EXPERIMENTAL feature. Subject to change or removal. EnableFullDuplex bool `json:"enable_full_duplex,omitempty"` + // EnableWebTransport enables WebTransport (draft-ietf-webtrans-http3) + // on this server's HTTP/3 listener. When true, the HTTP/3 server + // advertises WebTransport in SETTINGS, enables HTTP/3 DATAGRAMs and + // QUIC stream-reset partial delivery, and dispatches each QUIC + // connection through webtransport.Server.ServeQUICConn so that + // handlers can upgrade Extended CONNECT requests with + // `:protocol=webtransport`. When false, the HTTP/3 path is + // bit-for-bit identical to the pre-WebTransport behavior: clients + // that don't speak WebTransport see nothing new. + // + // This is a server-level opt-in that matches how other + // protocol-level features are enabled (see `protocols`, + // `allow_0rtt`, `enable_full_duplex`). Handlers that want to proxy + // or terminate WebTransport sessions auto-detect the request shape + // once this is on — no per-handler configuration is needed. + // + // Requires HTTP/3. + // + // TODO: This is an EXPERIMENTAL feature. Subject to change or removal. + EnableWebTransport bool `json:"enable_webtransport,omitempty"` + // A list of header field names containing underscores that should // be preserved instead of being dropped. By default, Caddy drops // ALL headers with underscores to prevent ambiguity with @@ -823,7 +844,9 @@ func (s *Server) serveHTTP3(addr caddy.NetworkAddress, tlsCfg *tls.Config) error // create HTTP/3 server if not done already if s.h3server == nil { s.h3server = s.buildHTTP3Server(tlsCfg) - s.wtServer = s.buildWebTransportServer() + if s.EnableWebTransport { + s.wtServer = s.buildWebTransportServer() + } } s.quicListeners = append(s.quicListeners, h3ln) @@ -834,14 +857,17 @@ func (s *Server) serveHTTP3(addr caddy.NetworkAddress, tlsCfg *tls.Config) error } // serveH3AcceptLoop accepts incoming QUIC connections from the HTTP/3 -// listener and dispatches each to the WebTransport-aware serve loop. -// webtransport.Server.ServeQUICConn wraps http3.Server: non-WebTransport -// streams are transparently forwarded to the normal HTTP/3 request path -// (at the cost of one varint peek per stream), so behavior for non-WT -// clients is unchanged. This replaces http3.Server.ServeListener's -// accept loop so webtransport.Server.Upgrade has the per-connection -// session manager state it requires. +// listener. When EnableWebTransport is false, the listener is handed +// directly to http3.Server — the code path is identical to pre-WebTransport +// Caddy. When true, each connection is dispatched through +// webtransport.Server.ServeQUICConn, which demultiplexes WebTransport +// streams from normal HTTP/3 streams (forwarding the latter to the +// http3.Server request path at the cost of one varint peek per stream). func (s *Server) serveH3AcceptLoop(h3ln http3.QUICListener) { + if !s.EnableWebTransport { + _ = s.h3server.ServeListener(h3ln) + return + } for { conn, err := h3ln.Accept(s.ctx) if err != nil { @@ -853,25 +879,32 @@ func (s *Server) serveH3AcceptLoop(h3ln http3.QUICListener) { } } -// buildHTTP3Server constructs the http3.Server used by this server for HTTP/3. -// WebTransport support is advertised in SETTINGS and the underlying *quic.Conn -// is stashed in each request's context, which is a prerequisite for any -// WebTransport-aware handler or transport to call webtransport.Server.Upgrade. -// The extra SETTINGS and ConnContext hook are harmless for clients that do not -// speak WebTransport. +// buildHTTP3Server constructs the http3.Server used by this server for +// HTTP/3. When EnableWebTransport is true, the server is additionally +// configured for WebTransport: WT enablement is advertised in SETTINGS, +// DATAGRAMs are enabled, QUIC stream-reset partial delivery is enabled, +// and a ConnContext hook stashes the *quic.Conn in each request's context +// so handlers can call webtransport.Server.Upgrade. When false, none of +// those modifications are applied and the returned server is +// bit-for-bit identical to the pre-WebTransport implementation. func (s *Server) buildHTTP3Server(tlsCfg *tls.Config) *http3.Server { + qc := &quic.Config{ + Versions: []quic.Version{quic.Version1, quic.Version2}, + Tracer: h3qlog.DefaultConnectionTracer, + } + if s.EnableWebTransport { + qc.EnableStreamResetPartialDelivery = true + } h3 := &http3.Server{ Handler: s, TLSConfig: tlsCfg, MaxHeaderBytes: s.MaxHeaderBytes, - QUICConfig: &quic.Config{ - Versions: []quic.Version{quic.Version1, quic.Version2}, - Tracer: h3qlog.DefaultConnectionTracer, - EnableStreamResetPartialDelivery: true, - }, - IdleTimeout: time.Duration(s.IdleTimeout), + QUICConfig: qc, + IdleTimeout: time.Duration(s.IdleTimeout), + } + if s.EnableWebTransport { + webtransport.ConfigureHTTP3Server(h3) } - webtransport.ConfigureHTTP3Server(h3) return h3 } @@ -879,6 +912,7 @@ func (s *Server) buildHTTP3Server(tlsCfg *tls.Config) *http3.Server { // the http3.Server. It owns the per-connection session state needed by // webtransport.Server.Upgrade and demultiplexes WebTransport streams // from normal HTTP/3 streams on each accepted QUIC connection. +// Only constructed when EnableWebTransport is true. func (s *Server) buildWebTransportServer() *webtransport.Server { return &webtransport.Server{H3: s.h3server} } diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index 323ab756c..1fccd7e56 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -1005,13 +1005,14 @@ func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) assert.Equal(t, clientIP, "90.100.110.120") } -// TestServer_BuildHTTP3ServerEnablesWebTransport asserts that the http3.Server -// Caddy builds advertises WebTransport in its SETTINGS and wires the -// prerequisites webtransport.Server.Upgrade relies on: DATAGRAM support, -// a non-nil ConnContext hook (used to stash the underlying *quic.Conn for -// Upgrade to retrieve), and QUIC stream reset partial delivery. +// TestServer_BuildHTTP3ServerEnablesWebTransport asserts that with +// EnableWebTransport=true the http3.Server advertises WebTransport in +// its SETTINGS and wires the prerequisites webtransport.Server.Upgrade +// relies on: DATAGRAM support, a non-nil ConnContext hook (used to stash +// the underlying *quic.Conn for Upgrade to retrieve), and QUIC +// stream-reset partial delivery. func TestServer_BuildHTTP3ServerEnablesWebTransport(t *testing.T) { - s := &Server{} + s := &Server{EnableWebTransport: true} h3 := s.buildHTTP3Server(&tls.Config{}) assert.NotNil(t, h3, "expected non-nil http3.Server") @@ -1022,6 +1023,23 @@ func TestServer_BuildHTTP3ServerEnablesWebTransport(t *testing.T) { assert.True(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery is required by webtransport-go") } +// TestServer_BuildHTTP3ServerWithoutWebTransport asserts that with +// EnableWebTransport=false (the default) the http3.Server does NOT +// advertise WebTransport and does not enable the related QUIC/HTTP/3 +// features. This is the load-bearing regression guard: non-WT HTTP/3 +// deployments must pay zero cost for the WebTransport feature. +func TestServer_BuildHTTP3ServerWithoutWebTransport(t *testing.T) { + s := &Server{} + h3 := s.buildHTTP3Server(&tls.Config{}) + + assert.NotNil(t, h3) + assert.False(t, h3.EnableDatagrams, "EnableDatagrams must be false when WebTransport is disabled") + assert.Empty(t, h3.AdditionalSettings, "AdditionalSettings must be empty when WebTransport is disabled") + assert.Nil(t, h3.ConnContext, "ConnContext must be nil when WebTransport is disabled") + assert.NotNil(t, h3.QUICConfig) + assert.False(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery must be false when WebTransport is disabled") +} + // TestServer_BuildHTTP3ServerAppliesHandlerAndTLS is a smoke test for the // non-WebTransport fields of the constructed http3.Server, guarding against a // refactor accidentally dropping them. @@ -1038,7 +1056,7 @@ func TestServer_BuildHTTP3ServerAppliesHandlerAndTLS(t *testing.T) { // TestServer_BuildWebTransportServerWrapsHTTP3Server asserts that the // webtransport.Server wraps the correct http3.Server. func TestServer_BuildWebTransportServerWrapsHTTP3Server(t *testing.T) { - s := &Server{} + s := &Server{EnableWebTransport: true} s.h3server = s.buildHTTP3Server(&tls.Config{}) wt := s.buildWebTransportServer()