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.
This commit is contained in:
tomholford 2026-04-23 11:11:16 -07:00
parent 8d86214d1c
commit 43adbee168
10 changed files with 117 additions and 124 deletions

View file

@ -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

View file

@ -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": [

View file

@ -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"
}
]
}
]
}
]
}
}
}
}
}

View file

@ -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"}]

View file

@ -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" {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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 {

View file

@ -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}
}

View file

@ -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()