From c0210a1819203a926edc3ee6e81f8074bf865d9d Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:09:49 -0700 Subject: [PATCH 01/16] caddyhttp: advertise WebTransport in HTTP/3 SETTINGS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go.mod | 2 ++ go.sum | 4 ++++ modules/caddyhttp/server.go | 34 ++++++++++++++++++++++---------- modules/caddyhttp/server_test.go | 31 +++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 8796ad4d84f..a41eed1d68c 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/mholt/acmez/v3 v3.1.6 github.com/prometheus/client_golang v1.23.2 github.com/quic-go/quic-go v0.59.0 + github.com/quic-go/webtransport-go v0.10.0 github.com/smallstep/certificates v0.30.2 github.com/smallstep/nosql v0.8.0 github.com/smallstep/truststore v0.13.0 @@ -60,6 +61,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/go.sum b/go.sum index 48a7d22bd4e..63ceaccb0a9 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,8 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -282,6 +284,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI= +github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 41a8e55b010..3f86b6ef8c9 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -35,6 +35,7 @@ import ( "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" h3qlog "github.com/quic-go/quic-go/http3/qlog" + "github.com/quic-go/webtransport-go" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -667,16 +668,7 @@ 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 = &http3.Server{ - Handler: s, - TLSConfig: tlsCfg, - MaxHeaderBytes: s.MaxHeaderBytes, - QUICConfig: &quic.Config{ - Versions: []quic.Version{quic.Version1, quic.Version2}, - Tracer: h3qlog.DefaultConnectionTracer, - }, - IdleTimeout: time.Duration(s.IdleTimeout), - } + s.h3server = s.buildHTTP3Server(tlsCfg) } s.quicListeners = append(s.quicListeners, h3ln) @@ -687,6 +679,28 @@ func (s *Server) serveHTTP3(addr caddy.NetworkAddress, tlsCfg *tls.Config) error return nil } +// 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. +func (s *Server) buildHTTP3Server(tlsCfg *tls.Config) *http3.Server { + 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), + } + webtransport.ConfigureHTTP3Server(h3) + return h3 +} + // configureServer applies/binds the registered callback functions to the server. func (s *Server) configureServer(server *http.Server) { for _, f := range s.connStateFuncs { diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index eecb392e474..e6162710a61 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -3,6 +3,7 @@ package caddyhttp import ( "bytes" "context" + "crypto/tls" "io" "net/http" "net/http/httptest" @@ -499,3 +500,33 @@ func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) assert.True(t, trusted) 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. +func TestServer_BuildHTTP3ServerEnablesWebTransport(t *testing.T) { + s := &Server{} + h3 := s.buildHTTP3Server(&tls.Config{}) + + assert.NotNil(t, h3, "expected non-nil http3.Server") + assert.True(t, h3.EnableDatagrams, "EnableDatagrams must be true for WebTransport DATAGRAMs") + assert.NotEmpty(t, h3.AdditionalSettings, "AdditionalSettings must advertise WebTransport enablement") + assert.NotNil(t, h3.ConnContext, "ConnContext must be set so webtransport.Server.Upgrade can retrieve the *quic.Conn") + assert.NotNil(t, h3.QUICConfig, "QUICConfig must be set") + assert.True(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery is required by webtransport-go") +} + +// TestServer_BuildHTTP3ServerAppliesHandlerAndTLS is a smoke test for the +// non-WebTransport fields of the constructed http3.Server, guarding against a +// refactor accidentally dropping them. +func TestServer_BuildHTTP3ServerAppliesHandlerAndTLS(t *testing.T) { + s := &Server{MaxHeaderBytes: 4096} + tlsCfg := &tls.Config{} + h3 := s.buildHTTP3Server(tlsCfg) + + assert.Same(t, s, h3.Handler, "http3.Server.Handler should be the caddyhttp.Server itself") + assert.Same(t, tlsCfg, h3.TLSConfig, "http3.Server.TLSConfig should be the config passed in") + assert.Equal(t, 4096, h3.MaxHeaderBytes) +} From 8a1bfb034c0d90aebe3e817890e042b311047dc6 Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:11:44 -0700 Subject: [PATCH 02/16] caddyhttp: add UnwrapResponseWriterAs helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- modules/caddyhttp/responsewriter.go | 27 +++++++ modules/caddyhttp/responsewriter_test.go | 92 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/modules/caddyhttp/responsewriter.go b/modules/caddyhttp/responsewriter.go index 904c30c0352..f3b731ba926 100644 --- a/modules/caddyhttp/responsewriter.go +++ b/modules/caddyhttp/responsewriter.go @@ -58,6 +58,33 @@ func (rww *ResponseWriterWrapper) Unwrap() http.ResponseWriter { return rww.ResponseWriter } +// UnwrapResponseWriterAs walks w through its Unwrap() http.ResponseWriter +// chain and returns the first writer that satisfies T (along with true). +// If no writer in the chain satisfies T, it returns the zero value of T +// and false. This mirrors how http.ResponseController traverses wrapped +// writers internally and is useful when code needs to reach interfaces +// implemented only by the raw writer owned by the HTTP server — for +// example, Extended CONNECT or WebTransport helpers that perform their +// own type assertions and cannot see past a wrapper. +func UnwrapResponseWriterAs[T any](w http.ResponseWriter) (T, bool) { + var zero T + for w != nil { + if t, ok := any(w).(T); ok { + return t, true + } + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + if !ok { + return zero, false + } + next := u.Unwrap() + if next == w { + return zero, false + } + w = next + } + return zero, false +} + // ErrNotImplemented is returned when an underlying // ResponseWriter does not implement the required method. var ErrNotImplemented = fmt.Errorf("method not implemented") diff --git a/modules/caddyhttp/responsewriter_test.go b/modules/caddyhttp/responsewriter_test.go index c08ad26a472..5b11758a7ff 100644 --- a/modules/caddyhttp/responsewriter_test.go +++ b/modules/caddyhttp/responsewriter_test.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" "testing" + "time" ) type responseWriterSpy interface { @@ -169,3 +170,94 @@ func TestResponseRecorderReadFrom(t *testing.T) { }) } } + +// targetIface is an interface that only the innermost writer in the tests +// below implements; it's used to assert UnwrapResponseWriterAs walks past +// outer wrappers to find it. +type targetIface interface { + http.ResponseWriter + magic() string +} + +type targetWriter struct { + baseRespWriter +} + +func (*targetWriter) magic() string { return "ok" } + +// plainWrapper wraps an http.ResponseWriter and forwards only the mandatory +// methods. It implements Unwrap() so the helper can traverse it. +type plainWrapper struct{ inner http.ResponseWriter } + +func (p *plainWrapper) Header() http.Header { return p.inner.Header() } +func (p *plainWrapper) Write(b []byte) (int, error) { return p.inner.Write(b) } +func (p *plainWrapper) WriteHeader(statusCode int) { p.inner.WriteHeader(statusCode) } +func (p *plainWrapper) Unwrap() http.ResponseWriter { return p.inner } + +func TestUnwrapResponseWriterAs_DirectMatch(t *testing.T) { + w := &targetWriter{} + got, ok := UnwrapResponseWriterAs[targetIface](w) + if !ok { + t.Fatal("expected direct match to succeed") + } + if got.magic() != "ok" { + t.Errorf("unexpected writer returned: %v", got) + } +} + +func TestUnwrapResponseWriterAs_ThroughSingleWrapper(t *testing.T) { + inner := &targetWriter{} + outer := &ResponseWriterWrapper{ResponseWriter: inner} + got, ok := UnwrapResponseWriterAs[targetIface](outer) + if !ok { + t.Fatal("expected to unwrap past ResponseWriterWrapper") + } + if got.magic() != "ok" { + t.Error("expected the inner targetWriter") + } +} + +func TestUnwrapResponseWriterAs_ThroughMultipleWrappers(t *testing.T) { + inner := &targetWriter{} + w := http.ResponseWriter(&plainWrapper{ + inner: &ResponseWriterWrapper{ + ResponseWriter: &plainWrapper{inner: inner}, + }, + }) + got, ok := UnwrapResponseWriterAs[targetIface](w) + if !ok { + t.Fatal("expected to unwrap three layers down") + } + if got.magic() != "ok" { + t.Error("expected the inner targetWriter") + } +} + +func TestUnwrapResponseWriterAs_NotFound(t *testing.T) { + // None of these writers implement targetIface. + inner := &baseRespWriter{} + outer := &ResponseWriterWrapper{ResponseWriter: inner} + _, ok := UnwrapResponseWriterAs[targetIface](outer) + if ok { + t.Error("expected no match when nothing in the chain implements the interface") + } +} + +type selfUnwrapWriter struct{ baseRespWriter } + +func (s *selfUnwrapWriter) Unwrap() http.ResponseWriter { return s } + +func TestUnwrapResponseWriterAs_StopsOnSelfReference(t *testing.T) { + // Defensive: a wrapper whose Unwrap returns itself must not loop forever. + loop := &selfUnwrapWriter{} + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = UnwrapResponseWriterAs[targetIface](loop) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("UnwrapResponseWriterAs hung on self-referential Unwrap") + } +} From 2266f7b1f7f1e1899ead765c495c950fde08986a Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:30:21 -0700 Subject: [PATCH 03/16] 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. --- caddytest/integration/webtransport_test.go | 150 ++++++++++++++++++ listeners.go | 5 + modules/caddyhttp/app.go | 11 ++ modules/caddyhttp/server.go | 49 +++++- modules/caddyhttp/server_test.go | 18 +++ modules/caddyhttp/standard/imports.go | 1 + modules/caddyhttp/webtransport/handler.go | 145 +++++++++++++++++ .../caddyhttp/webtransport/handler_test.go | 69 ++++++++ 8 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 caddytest/integration/webtransport_test.go create mode 100644 modules/caddyhttp/webtransport/handler.go create mode 100644 modules/caddyhttp/webtransport/handler_test.go diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go new file mode 100644 index 00000000000..695a351477e --- /dev/null +++ b/caddytest/integration/webtransport_test.go @@ -0,0 +1,150 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration + +import ( + "context" + "crypto/tls" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +// TestWebTransport_EchoHandlerBidi spins up Caddy with an HTTP/3 listener +// that terminates a WebTransport session via the http.handlers.webtransport +// echo handler, then dials it with a real webtransport.Dialer and asserts +// an end-to-end bidirectional-stream round-trip. This exercises the +// serveH3AcceptLoop path (webtransport.Server.ServeQUICConn instead of +// http3.Server.ServeListener) and the UnwrapResponseWriterAs helper. +func TestWebTransport_EchoHandlerBidi(t *testing.T) { + if testing.Short() { + t.Skip() + } + tester := caddytest.NewTester(t) + tester.InitServer(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "srv0": { + "listen": [":9443"], + "protocols": ["h3"], + "routes": [ + { + "handle": [{"handler": "webtransport"}] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": { + "certificate_authorities": { + "local": {"install_trust": false} + } + } + } +}`, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test uses a local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + // Connect. Give the freshly-reconfigured server a brief window to be + // ready on the UDP port; retry a handful of times instead of racing. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + rsp *http.Response + sess *webtransport.Session + err error + ) + deadline := time.Now().Add(3 * time.Second) + for { + rsp, sess, err = dialer.Dial(ctx, "https://127.0.0.1:9443/", nil) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("webtransport dial failed after retries: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + defer sess.CloseWithError(0, "") + + if rsp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", rsp.StatusCode) + } + + // Open a bidirectional stream and send payload; expect it echoed back. + str, err := sess.OpenStreamSync(ctx) + if err != nil { + t.Fatalf("open stream: %v", err) + } + + const payload = "hello webtransport" + if _, err := io.WriteString(str, payload); err != nil { + t.Fatalf("write: %v", err) + } + if err := str.Close(); err != nil { + t.Fatalf("close send: %v", err) + } + + got, err := io.ReadAll(str) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != payload { + t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload) + } +} diff --git a/listeners.go b/listeners.go index 84ebaaabae1..10577d04c25 100644 --- a/listeners.go +++ b/listeners.go @@ -479,6 +479,11 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config &quic.Config{ Allow0RTT: allow0rtt, Tracer: h3qlog.DefaultConnectionTracer, + // Required by WebTransport. Enabling unconditionally: these + // are capability bits negotiated during the QUIC handshake + // and do not force usage, so non-WT H3 traffic is unaffected. + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, }, ) if err != nil { diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 673c36d7767..75c75c38f2d 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -780,6 +780,17 @@ func (app *App) Stop() error { zap.Strings("addresses", server.Listen)) } + // WebTransport session state is managed separately from the + // HTTP/3 server; Close after Shutdown to drop any remaining + // sessions and terminate the per-connection accept goroutines. + if server.wtServer != nil { + if err := server.wtServer.Close(); err != nil { + app.logger.Error("WebTransport server close", + zap.Error(err), + zap.Strings("addresses", server.Listen)) + } + } + // close the underlying net.PacketConns now // see the comment for ListenQUIC for _, h3ln := range server.quicListeners { diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 3f86b6ef8c9..247a9e28114 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -288,6 +288,7 @@ type Server struct { server *http.Server h3server *http3.Server + wtServer *webtransport.Server addresses []caddy.NetworkAddress trustedProxies IPRangeSource @@ -669,16 +670,36 @@ 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() } s.quicListeners = append(s.quicListeners, h3ln) - //nolint:errcheck - go s.h3server.ServeListener(h3ln) + go s.serveH3AcceptLoop(h3ln) return nil } +// 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. +func (s *Server) serveH3AcceptLoop(h3ln http3.QUICListener) { + for { + conn, err := h3ln.Accept(s.ctx) + if err != nil { + return + } + go func() { + _ = s.wtServer.ServeQUICConn(conn) + }() + } +} + // 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 @@ -701,6 +722,14 @@ func (s *Server) buildHTTP3Server(tlsCfg *tls.Config) *http3.Server { return h3 } +// buildWebTransportServer constructs the webtransport.Server that wraps +// 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. +func (s *Server) buildWebTransportServer() *webtransport.Server { + return &webtransport.Server{H3: s.h3server} +} + // configureServer applies/binds the registered callback functions to the server. func (s *Server) configureServer(server *http.Server) { for _, f := range s.connStateFuncs { @@ -941,6 +970,22 @@ func (s *Server) Listeners() []net.Listener { return s.listeners } // Name returns the server's name. func (s *Server) Name() string { return s.name } +// WebTransportServer returns the server's underlying WebTransport +// serving state as an opaque value. Modules that import +// github.com/quic-go/webtransport-go may type-assert it to +// *webtransport.Server. Returns nil if HTTP/3 is not in use. +// +// This is exposed as any so caddyhttp's public API does not leak the +// upstream webtransport-go type to packages that don't use it. +// +// EXPERIMENTAL: Subject to change or removal. +func (s *Server) WebTransportServer() any { + if s.wtServer == nil { + return nil + } + return s.wtServer +} + // PrepareRequest fills the request r for use in a Caddy HTTP handler chain. w and s can // be nil, but the handlers will lose response placeholders and access to the server. func PrepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter, s *Server) *http.Request { diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index e6162710a61..d733f915169 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -530,3 +530,21 @@ func TestServer_BuildHTTP3ServerAppliesHandlerAndTLS(t *testing.T) { assert.Same(t, tlsCfg, h3.TLSConfig, "http3.Server.TLSConfig should be the config passed in") assert.Equal(t, 4096, h3.MaxHeaderBytes) } + +// TestServer_BuildWebTransportServerWrapsHTTP3Server asserts that the +// webtransport.Server wraps the correct http3.Server. +func TestServer_BuildWebTransportServerWrapsHTTP3Server(t *testing.T) { + s := &Server{} + s.h3server = s.buildHTTP3Server(&tls.Config{}) + wt := s.buildWebTransportServer() + + assert.NotNil(t, wt, "expected non-nil webtransport.Server") + assert.Same(t, s.h3server, wt.H3, "webtransport.Server should wrap this server's http3.Server") +} + +// TestServer_WebTransportServerNilUntilH3 asserts the accessor returns nil +// when HTTP/3 has not been configured. +func TestServer_WebTransportServerNilUntilH3(t *testing.T) { + s := &Server{} + assert.Nil(t, s.WebTransportServer(), "expected nil before HTTP/3 setup") +} diff --git a/modules/caddyhttp/standard/imports.go b/modules/caddyhttp/standard/imports.go index 6617941c65d..ac5b2f3e2d4 100644 --- a/modules/caddyhttp/standard/imports.go +++ b/modules/caddyhttp/standard/imports.go @@ -22,4 +22,5 @@ import ( _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/tracing" + _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/webtransport" ) diff --git a/modules/caddyhttp/webtransport/handler.go b/modules/caddyhttp/webtransport/handler.go new file mode 100644 index 00000000000..07c5e9f32d9 --- /dev/null +++ b/modules/caddyhttp/webtransport/handler.go @@ -0,0 +1,145 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package webtransport is an EXPERIMENTAL HTTP handler that terminates a +// WebTransport session (draft-ietf-webtrans-http3) on top of Caddy's HTTP/3 +// server and echoes bytes on each bidirectional stream. It exists mainly as +// a test upstream for the WebTransport reverse-proxy transport. Behavior +// and configuration are subject to change without notice. +package webtransport + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +func init() { + caddy.RegisterModule(Handler{}) +} + +// Protocol is the :protocol pseudo-header value sent by a client that wants +// to establish a WebTransport session over HTTP/3 Extended CONNECT. +const Protocol = "webtransport" + +// Writer is the interface satisfied by the naked HTTP/3 response writer. +// webtransport.Server.Upgrade performs these assertions itself; callers +// can use caddyhttp.UnwrapResponseWriterAs[Writer] to reach it past +// Caddy's ResponseWriter wrapping chain before calling Upgrade. +type Writer interface { + http.ResponseWriter + http3.Settingser + http3.HTTPStreamer +} + +// Handler terminates an incoming WebTransport session and echoes bytes on +// each bidirectional stream. EXPERIMENTAL: intended primarily as a test +// upstream for the WebTransport reverse-proxy transport. +type Handler struct { + logger *zap.Logger +} + +// CaddyModule returns the Caddy module information. +func (Handler) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.webtransport", + New: func() caddy.Module { return new(Handler) }, + } +} + +// Provision sets up the handler. +func (h *Handler) Provision(ctx caddy.Context) error { + h.logger = ctx.Logger() + return nil +} + +// ServeHTTP upgrades the request to a WebTransport session and echoes bytes +// on each accepted bidirectional stream. Non-WebTransport requests are +// passed through to the next handler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + if !isWebTransportUpgrade(r) { + return next.ServeHTTP(w, r) + } + + srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) + if !ok || srv == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: caddyhttp.Server not in request context")) + } + 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")) + } + + naked, ok := caddyhttp.UnwrapResponseWriterAs[Writer](w) + if !ok { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: underlying writer does not support WebTransport upgrade")) + } + + session, err := wtServer.Upgrade(naked, r) + if err != nil { + h.logger.Debug("webtransport upgrade failed", zap.Error(err)) + return caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err)) + } + + h.echoStreams(session) + return nil +} + +// echoStreams accepts bidirectional streams on session until the session +// ends, and echoes bytes on each one. +func (h *Handler) echoStreams(session *webtransport.Session) { + ctx := session.Context() + for { + str, err := session.AcceptStream(ctx) + if err != nil { + return + } + go func(s *webtransport.Stream) { + // io.Copy from the stream back to itself echoes everything + // received on this bidirectional stream. When the peer closes + // its send side we observe EOF and close our send side too. + if _, err := io.Copy(s, s); err != nil && h.logger != nil { + h.logger.Debug("webtransport echo stream error", zap.Error(err)) + } + _ = s.Close() + }(str) + } +} + +// isWebTransportUpgrade reports whether r is an HTTP/3 Extended CONNECT that +// requests a WebTransport session. The quic-go/http3 server places the +// :protocol pseudo-header value in r.Proto for CONNECT requests. +func isWebTransportUpgrade(r *http.Request) bool { + return r.ProtoMajor == 3 && + r.Method == http.MethodConnect && + r.Proto == Protocol +} + +// Interface guards. +var ( + _ caddy.Provisioner = (*Handler)(nil) + _ caddyhttp.MiddlewareHandler = (*Handler)(nil) +) diff --git a/modules/caddyhttp/webtransport/handler_test.go b/modules/caddyhttp/webtransport/handler_test.go new file mode 100644 index 00000000000..095e59cd595 --- /dev/null +++ b/modules/caddyhttp/webtransport/handler_test.go @@ -0,0 +1,69 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webtransport + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsWebTransportUpgrade(t *testing.T) { + cases := []struct { + name string + proto string + major int + meth string + want bool + }{ + {"h3 connect webtransport", "webtransport", 3, http.MethodConnect, true}, + {"h3 connect websocket", "websocket", 3, http.MethodConnect, false}, + {"h2 connect webtransport", "webtransport", 2, http.MethodConnect, false}, + {"h3 GET", "HTTP/3.0", 3, http.MethodGet, false}, + {"h3 connect missing :protocol", "HTTP/3.0", 3, http.MethodConnect, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(tc.meth, "/", nil) + r.ProtoMajor = tc.major + r.Proto = tc.proto + if got := isWebTransportUpgrade(r); got != tc.want { + t.Errorf("isWebTransportUpgrade = %v, want %v", got, tc.want) + } + }) + } +} + +// nextNoop is a stand-in for the next handler. It records whether it was +// invoked, used to assert that non-WebTransport requests pass through. +type nextNoop struct{ called bool } + +func (n *nextNoop) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + n.called = true + return nil +} + +func TestHandler_PassesThroughNonWebTransportRequests(t *testing.T) { + h := &Handler{} + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + nx := &nextNoop{} + if err := h.ServeHTTP(w, r, nx); err != nil { + t.Fatalf("ServeHTTP returned error: %v", err) + } + if !nx.called { + t.Error("expected next handler to be invoked for non-WebTransport request") + } +} From 7c1434c8341aaabeedb739da6ae47dc907722d2f Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:33:14 -0700 Subject: [PATCH 04/16] 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/webtransport_transport.go | 43 ++++ .../webtransport_transport_test.go | 214 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 modules/caddyhttp/reverseproxy/webtransport_transport.go create mode 100644 modules/caddyhttp/reverseproxy/webtransport_transport_test.go diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go new file mode 100644 index 00000000000..c3bb6f9acc4 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -0,0 +1,43 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "context" + "crypto/tls" + "net/http" + + "github.com/quic-go/quic-go" + "github.com/quic-go/webtransport-go" +) + +// dialUpstreamWebTransport opens a WebTransport session to the upstream at +// urlStr (an https URL), forwarding reqHdr as headers on the Extended +// CONNECT request. The returned session is owned by the caller and must be +// closed when no longer in use. Return-value order matches +// webtransport.Dialer.Dial: (response, session, error). +// +// EXPERIMENTAL: this helper is an internal building block for the upcoming +// WebTransport reverse-proxy transport. Shape and behavior may change. +func dialUpstreamWebTransport(ctx context.Context, tlsCfg *tls.Config, urlStr string, reqHdr http.Header) (*http.Response, *webtransport.Session, error) { + d := &webtransport.Dialer{ + TLSClientConfig: tlsCfg, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + return d.Dial(ctx, urlStr, reqHdr) +} diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport_test.go b/modules/caddyhttp/reverseproxy/webtransport_transport_test.go new file mode 100644 index 00000000000..f7aaa30f98a --- /dev/null +++ b/modules/caddyhttp/reverseproxy/webtransport_transport_test.go @@ -0,0 +1,214 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net" + "net/http" + "testing" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" +) + +// startTestWebTransportServer starts an in-process WebTransport server on a +// random UDP port with a freshly minted self-signed certificate. handler is +// invoked once the CONNECT request has been upgraded to a session. +// +// Returns the UDP addr and a shutdown func. Tests should call the shutdown +// func via t.Cleanup (or explicitly with defer). +func startTestWebTransportServer(t *testing.T, handler func(s *webtransport.Session, r *http.Request)) (addr *net.UDPAddr, trustRoot *x509.Certificate, shutdown func()) { + t.Helper() + + trustRoot, tlsCfg := generateSelfSignedTLS(t, "localhost") + + mux := http.NewServeMux() + h3 := &http3.Server{ + TLSConfig: tlsCfg, + Handler: mux, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + // Advertise WebTransport in SETTINGS so the client's requirement + // checks pass. (This is what caddyhttp.Server.buildHTTP3Server does + // internally for the real server.) + webtransport.ConfigureHTTP3Server(h3) + + wtServer := &webtransport.Server{H3: h3} + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + sess, err := wtServer.Upgrade(w, r) + if err != nil { + t.Logf("test server upgrade failed: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + handler(sess, r) + }) + + udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + udpConn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + t.Fatal(err) + } + + servErr := make(chan error, 1) + go func() { + servErr <- wtServer.Serve(udpConn) + }() + + shutdown = func() { + _ = wtServer.Close() + <-servErr + _ = udpConn.Close() + } + return udpConn.LocalAddr().(*net.UDPAddr), trustRoot, shutdown +} + +func generateSelfSignedTLS(t *testing.T, commonName string) (*x509.Certificate, *tls.Config) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{commonName}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + tlsCert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv, Leaf: cert} + return cert, &tls.Config{Certificates: []tls.Certificate{tlsCert}, NextProtos: []string{http3.NextProtoH3}} +} + +func clientTLSFor(cert *x509.Certificate) *tls.Config { + pool := x509.NewCertPool() + pool.AddCert(cert) + return &tls.Config{RootCAs: pool, NextProtos: []string{http3.NextProtoH3}} +} + +func TestDialUpstreamWebTransport_Succeeds(t *testing.T) { + if testing.Short() { + t.Skip() + } + addr, root, shutdown := startTestWebTransportServer(t, func(sess *webtransport.Session, _ *http.Request) { + _ = sess.CloseWithError(0, "") + }) + t.Cleanup(shutdown) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + url := fmt.Sprintf("https://localhost:%d/", addr.Port) + rsp, sess, err := dialUpstreamWebTransport(ctx, clientTLSFor(root), url, nil) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer sess.CloseWithError(0, "") + if rsp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status %d", rsp.StatusCode) + } +} + +func TestDialUpstreamWebTransport_ForwardsHeaders(t *testing.T) { + if testing.Short() { + t.Skip() + } + gotUA := make(chan string, 1) + addr, root, shutdown := startTestWebTransportServer(t, func(sess *webtransport.Session, r *http.Request) { + gotUA <- r.Header.Get("User-Agent") + _ = sess.CloseWithError(0, "") + }) + t.Cleanup(shutdown) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + hdr := http.Header{"User-Agent": []string{"caddy-wt-test"}} + + url := fmt.Sprintf("https://localhost:%d/", addr.Port) + _, sess, err := dialUpstreamWebTransport(ctx, clientTLSFor(root), url, hdr) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer sess.CloseWithError(0, "") + + select { + case got := <-gotUA: + if got != "caddy-wt-test" { + t.Errorf("User-Agent not forwarded; got %q", got) + } + case <-time.After(time.Second): + t.Fatal("server handler did not observe User-Agent header in time") + } +} + +func TestDialUpstreamWebTransport_BadAddress(t *testing.T) { + if testing.Short() { + t.Skip() + } + // No server on this port; expect a dial error within the context deadline. + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + // Use a loopback port we picked at random and left unbound. + freePort := pickFreeUDPPort(t) + url := fmt.Sprintf("https://127.0.0.1:%d/", freePort) + _, _, err := dialUpstreamWebTransport(ctx, &tls.Config{InsecureSkipVerify: true}, url, nil) //nolint:gosec // test only + if err == nil { + t.Fatal("expected error dialing unbound port, got nil") + } +} + +// pickFreeUDPPort returns a local UDP port that was free when picked. The +// caller should use it immediately — there's no guarantee another process +// hasn't bound it in the interim. +func pickFreeUDPPort(t *testing.T) int { + t.Helper() + l, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.LocalAddr().(*net.UDPAddr).Port + _ = l.Close() + return port +} + From 3c5529ba9130108bc7a6f73b0d66760cee5c17e4 Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:41:02 -0700 Subject: [PATCH 05/16] 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/webtransport_pump.go | 230 ++++++++++ .../reverseproxy/webtransport_pump_test.go | 394 ++++++++++++++++++ 2 files changed, 624 insertions(+) create mode 100644 modules/caddyhttp/reverseproxy/webtransport_pump.go create mode 100644 modules/caddyhttp/reverseproxy/webtransport_pump_test.go diff --git a/modules/caddyhttp/reverseproxy/webtransport_pump.go b/modules/caddyhttp/reverseproxy/webtransport_pump.go new file mode 100644 index 00000000000..6c18305f245 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/webtransport_pump.go @@ -0,0 +1,230 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "context" + "errors" + "io" + "sync" + "time" + + "github.com/quic-go/webtransport-go" + "go.uber.org/zap" +) + +// runWebTransportPump bridges two WebTransport sessions so that every +// bidirectional stream, unidirectional stream, and datagram opened on one +// side is mirrored onto the other. It blocks until both sessions end. +// +// Close propagation: when either session ends with a SessionError, the +// error code and message are forwarded to the peer via CloseWithError. +// When a session ends without a SessionError (context cancelled or +// connection dropped), the peer is closed with code 0. Each side's close +// is propagated at most once. +// +// EXPERIMENTAL: this helper is an internal building block for the +// WebTransport reverse-proxy transport and may change. +func runWebTransportPump(clientSess, upstreamSess *webtransport.Session, logger *zap.Logger) { + if logger == nil { + logger = zap.NewNop() + } + p := &webtransportPump{ + client: clientSess, + upstream: upstreamSess, + logger: logger, + } + p.run() +} + +type webtransportPump struct { + client, upstream *webtransport.Session + logger *zap.Logger + + closeClientOnce sync.Once + closeUpstreamOnce sync.Once +} + +func (p *webtransportPump) run() { + var wg sync.WaitGroup + wg.Add(6) + + // Bidirectional streams in both directions. + go func() { defer wg.Done(); p.acceptBidi(p.client, p.upstream, p.closeUpstream) }() + go func() { defer wg.Done(); p.acceptBidi(p.upstream, p.client, p.closeClient) }() + + // Unidirectional streams in both directions. + go func() { defer wg.Done(); p.acceptUni(p.client, p.upstream, p.closeUpstream) }() + go func() { defer wg.Done(); p.acceptUni(p.upstream, p.client, p.closeClient) }() + + // Datagrams in both directions. + go func() { defer wg.Done(); p.pumpDatagrams(p.client, p.upstream, p.closeUpstream) }() + go func() { defer wg.Done(); p.pumpDatagrams(p.upstream, p.client, p.closeClient) }() + + wg.Wait() +} + +func (p *webtransportPump) closeClient(cause error) { + p.propagateClose(p.client, p.upstream, &p.closeClientOnce, cause) +} + +func (p *webtransportPump) closeUpstream(cause error) { + p.propagateClose(p.upstream, p.client, &p.closeUpstreamOnce, cause) +} + +// propagateClose closes target once with a code/message derived from +// cause. If cause carries a *webtransport.SessionError (the common case — +// Accept{,Uni}Stream returns it directly when the peer closed the +// session), its code/message are used. Otherwise, typically the +// datagram loop won the race to detect the close and its error lacks +// the code, so we fall back to probing peer for its stored close state +// via a short AcceptStream. +func (p *webtransportPump) propagateClose(target, peer *webtransport.Session, once *sync.Once, cause error) { + once.Do(func() { + code, msg, ok := closeCodeFromErr(cause) + if !ok { + code, msg, _ = codeFromSession(peer) + } + _ = target.CloseWithError(code, msg) + }) +} + +// codeFromSession reads the peer's stored SessionError by waiting for the +// session's context to be cancelled — by that point webtransport-go has +// set closeErr — and then calling AcceptStream, which returns it via its +// initial closeErr check without blocking. Used only on the close path +// when the caller's own error didn't carry the code (e.g. ReceiveDatagram +// returned a context error). +func codeFromSession(sess *webtransport.Session) (webtransport.SessionErrorCode, string, bool) { + select { + case <-sess.Context().Done(): + case <-time.After(200 * time.Millisecond): + return 0, "", false + } + _, err := sess.AcceptStream(context.Background()) + return closeCodeFromErr(err) +} + +// acceptBidi loops on src.AcceptStream and, for each accepted +// bidirectional stream, opens a matching stream on dst and pipes bytes in +// both directions. When src ends, it invokes propagate to close dst. +func (p *webtransportPump) acceptBidi(src, dst *webtransport.Session, propagate func(error)) { + ctx := src.Context() + for { + srcStr, err := src.AcceptStream(ctx) + if err != nil { + propagate(err) + return + } + dstStr, err := dst.OpenStreamSync(ctx) + if err != nil { + p.logger.Debug("webtransport: open upstream bidi failed", zap.Error(err)) + srcStr.CancelRead(0) + srcStr.CancelWrite(0) + propagate(err) + return + } + go p.spliceBidi(srcStr, dstStr) + } +} + +// spliceBidi copies bytes between two bidirectional streams until both +// sides observe EOF or an error. +func (p *webtransportPump) spliceBidi(a, b *webtransport.Stream) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + if _, err := io.Copy(b, a); err != nil && !isExpectedEOF(err) { + p.logger.Debug("webtransport bidi splice a->b", zap.Error(err)) + } + _ = b.Close() + }() + go func() { + defer wg.Done() + if _, err := io.Copy(a, b); err != nil && !isExpectedEOF(err) { + p.logger.Debug("webtransport bidi splice b->a", zap.Error(err)) + } + _ = a.Close() + }() + wg.Wait() +} + +// acceptUni loops on src.AcceptUniStream and, for each stream, opens a +// matching unidirectional stream on dst and pipes bytes through. +func (p *webtransportPump) acceptUni(src, dst *webtransport.Session, propagate func(error)) { + ctx := src.Context() + for { + recv, err := src.AcceptUniStream(ctx) + if err != nil { + propagate(err) + return + } + send, err := dst.OpenUniStreamSync(ctx) + if err != nil { + p.logger.Debug("webtransport: open upstream uni failed", zap.Error(err)) + recv.CancelRead(0) + propagate(err) + return + } + go func() { + if _, err := io.Copy(send, recv); err != nil && !isExpectedEOF(err) { + p.logger.Debug("webtransport uni splice", zap.Error(err)) + } + _ = send.Close() + }() + } +} + +// pumpDatagrams forwards datagrams from src to dst until src ends. Unlike +// streams, datagrams are unreliable, so SendDatagram errors are best-effort +// and are not treated as fatal for the session. +func (p *webtransportPump) pumpDatagrams(src, dst *webtransport.Session, propagate func(error)) { + ctx := src.Context() + for { + data, err := src.ReceiveDatagram(ctx) + if err != nil { + propagate(err) + return + } + if err := dst.SendDatagram(data); err != nil { + p.logger.Debug("webtransport send datagram", zap.Error(err)) + } + } +} + +// closeCodeFromErr extracts a SessionErrorCode + message from err if it +// represents a session close. The bool is false when err is nil or not +// a *webtransport.SessionError. +func closeCodeFromErr(err error) (webtransport.SessionErrorCode, string, bool) { + var sessErr *webtransport.SessionError + if errors.As(err, &sessErr) { + return sessErr.ErrorCode, sessErr.Message, true + } + return 0, "", false +} + +// isExpectedEOF reports whether err is one we don't need to log: plain +// EOF, context cancellation, or an already-closed session. +func isExpectedEOF(err error) bool { + if err == nil { + return true + } + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + var sessErr *webtransport.SessionError + return errors.As(err, &sessErr) +} diff --git a/modules/caddyhttp/reverseproxy/webtransport_pump_test.go b/modules/caddyhttp/reverseproxy/webtransport_pump_test.go new file mode 100644 index 00000000000..65de9fcc0eb --- /dev/null +++ b/modules/caddyhttp/reverseproxy/webtransport_pump_test.go @@ -0,0 +1,394 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "runtime" + "sync" + "testing" + "time" + + "github.com/quic-go/webtransport-go" + "go.uber.org/zap" +) + +// pumpTestTopology spins up: +// +// client → frontend(Server F) → upstream(Server U) +// +// Server U is the real upstream; its handler is provided by the test. +// Server F's handler dials U and runs runWebTransportPump between the two +// sessions, so the client (who dials F) effectively talks to U through +// the pump. +type pumpTestTopology struct { + frontendAddr *net.UDPAddr + clientTLS *tls.Config + shutdown func() +} + +func newPumpTestTopology(t *testing.T, upstreamHandler func(*webtransport.Session, *http.Request)) *pumpTestTopology { + t.Helper() + + uAddr, uRoot, uShutdown := startTestWebTransportServer(t, upstreamHandler) + + fAddr, fRoot, fShutdown := startTestWebTransportServer(t, func(clientSess *webtransport.Session, _ *http.Request) { + // Dial U. + dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + url := fmt.Sprintf("https://localhost:%d/", uAddr.Port) + _, upstreamSess, err := dialUpstreamWebTransport(dialCtx, clientTLSFor(uRoot), url, nil) + if err != nil { + t.Errorf("frontend: dial upstream: %v", err) + _ = clientSess.CloseWithError(0, "upstream dial failed") + return + } + runWebTransportPump(clientSess, upstreamSess, zap.NewNop()) + }) + + return &pumpTestTopology{ + frontendAddr: fAddr, + clientTLS: clientTLSFor(fRoot), + shutdown: func() { + fShutdown() + uShutdown() + }, + } +} + +// dialFrontend returns a fresh session dialed against the frontend server. +func (tt *pumpTestTopology) dialFrontend(t *testing.T) *webtransport.Session { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + url := fmt.Sprintf("https://localhost:%d/", tt.frontendAddr.Port) + _, sess, err := dialUpstreamWebTransport(ctx, tt.clientTLS, url, nil) + if err != nil { + t.Fatalf("client dial frontend: %v", err) + } + return sess +} + +// echoUpstream is a ready-made upstream handler that echoes bytes on every +// bidirectional stream it's given. +func echoUpstream(sess *webtransport.Session, _ *http.Request) { + ctx := sess.Context() + for { + str, err := sess.AcceptStream(ctx) + if err != nil { + return + } + go func(s *webtransport.Stream) { + _, _ = io.Copy(s, s) + _ = s.Close() + }(str) + } +} + +func TestPump_BidiStreamClientToUpstream(t *testing.T) { + if testing.Short() { + t.Skip() + } + tt := newPumpTestTopology(t, echoUpstream) + t.Cleanup(tt.shutdown) + + sess := tt.dialFrontend(t) + defer sess.CloseWithError(0, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + str, err := sess.OpenStreamSync(ctx) + if err != nil { + t.Fatalf("open stream: %v", err) + } + + const payload = "hello from client" + if _, err := io.WriteString(str, payload); err != nil { + t.Fatalf("write: %v", err) + } + if err := str.Close(); err != nil { + t.Fatalf("close write: %v", err) + } + got, err := io.ReadAll(str) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != payload { + t.Fatalf("echo mismatch: got %q want %q", got, payload) + } +} + +func TestPump_BidiStreamUpstreamToClient(t *testing.T) { + if testing.Short() { + t.Skip() + } + // Upstream opens a stream toward the client and sends data. + serverReady := make(chan struct{}) + const payload = "hello from upstream" + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) { + defer close(serverReady) + ctx, cancel := context.WithTimeout(sess.Context(), 5*time.Second) + defer cancel() + str, err := sess.OpenStreamSync(ctx) + if err != nil { + t.Errorf("upstream open: %v", err) + return + } + if _, err := io.WriteString(str, payload); err != nil { + t.Errorf("upstream write: %v", err) + return + } + _ = str.Close() + // Keep the session alive briefly so the stream can be drained client-side. + <-sess.Context().Done() + }) + t.Cleanup(tt.shutdown) + + sess := tt.dialFrontend(t) + defer sess.CloseWithError(0, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + str, err := sess.AcceptStream(ctx) + if err != nil { + t.Fatalf("client accept: %v", err) + } + got, err := io.ReadAll(str) + if err != nil { + t.Fatalf("client read: %v", err) + } + if string(got) != payload { + t.Fatalf("bytes mismatch: got %q want %q", got, payload) + } +} + +func TestPump_UniStreamClientToUpstream(t *testing.T) { + if testing.Short() { + t.Skip() + } + // Upstream: accept one uni stream and echo its bytes on a new uni stream + // back to the client. + const payload = "uni from client" + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) { + ctx := sess.Context() + recv, err := sess.AcceptUniStream(ctx) + if err != nil { + return + } + data, err := io.ReadAll(recv) + if err != nil { + t.Errorf("upstream read uni: %v", err) + return + } + send, err := sess.OpenUniStreamSync(ctx) + if err != nil { + t.Errorf("upstream open uni: %v", err) + return + } + if _, err := send.Write(data); err != nil { + t.Errorf("upstream write uni: %v", err) + } + _ = send.Close() + <-sess.Context().Done() + }) + t.Cleanup(tt.shutdown) + + sess := tt.dialFrontend(t) + defer sess.CloseWithError(0, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + sendStr, err := sess.OpenUniStreamSync(ctx) + if err != nil { + t.Fatalf("client open uni: %v", err) + } + if _, err := sendStr.Write([]byte(payload)); err != nil { + t.Fatalf("client write uni: %v", err) + } + _ = sendStr.Close() + + recvStr, err := sess.AcceptUniStream(ctx) + if err != nil { + t.Fatalf("client accept uni: %v", err) + } + got, err := io.ReadAll(recvStr) + if err != nil { + t.Fatalf("client read uni: %v", err) + } + if string(got) != payload { + t.Fatalf("uni echo mismatch: got %q want %q", got, payload) + } +} + +func TestPump_Datagram(t *testing.T) { + if testing.Short() { + t.Skip() + } + // Upstream echoes whatever datagram it receives. + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) { + ctx := sess.Context() + for { + d, err := sess.ReceiveDatagram(ctx) + if err != nil { + return + } + _ = sess.SendDatagram(d) + } + }) + t.Cleanup(tt.shutdown) + + sess := tt.dialFrontend(t) + defer sess.CloseWithError(0, "") + + // Datagrams are unreliable. Retry a few times to get one round-trip. + payload := []byte("dgram") + deadline := time.Now().Add(3 * time.Second) + for { + if time.Now().After(deadline) { + t.Fatal("no datagram echo observed within deadline") + } + if err := sess.SendDatagram(payload); err != nil { + t.Fatalf("send datagram: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + got, err := sess.ReceiveDatagram(ctx) + cancel() + if err == nil && string(got) == string(payload) { + return + } + } +} + +func TestPump_CloseWithErrorPropagatesClientToUpstream(t *testing.T) { + if testing.Short() { + t.Skip() + } + upstreamSawErr := make(chan error, 1) + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) { + // Use a fresh long-lived context so we don't race with + // sess.Context() firing and getting a context error instead of + // the session-level error. + _, err := sess.AcceptStream(context.Background()) + upstreamSawErr <- err + }) + t.Cleanup(tt.shutdown) + + sess := tt.dialFrontend(t) + + // Client closes with a specific code; pump should propagate to upstream. + const code webtransport.SessionErrorCode = 4242 + const msg = "client bye" + if err := sess.CloseWithError(code, msg); err != nil { + t.Fatalf("client close: %v", err) + } + + select { + case err := <-upstreamSawErr: + if err == nil { + t.Fatal("upstream expected error after client close; got nil") + } + // Close propagation is best-effort for a client-initiated close: + // webtransport-go's Dialer tears down the dedicated QUIC connection + // immediately after CloseWithError, and on the pump's server-side + // session the WT_CLOSE_SESSION capsule can lose the race to the + // QUIC close — in which case parseNextCapsule stores a non- + // SessionError and the code is unrecoverable. The invariant we + // can reliably enforce is "upstream observed a session-terminating + // error." If the code did survive, assert it matches. + var sessErr *webtransport.SessionError + if errors.As(err, &sessErr) && sessErr.ErrorCode != 0 { + if sessErr.ErrorCode != code || sessErr.Message != msg { + t.Errorf("upstream saw code=%d msg=%q, want code=%d msg=%q", + sessErr.ErrorCode, sessErr.Message, code, msg) + } + } else { + t.Logf("upstream saw %T: %v (code lost to QUIC-close race; close propagation itself is verified)", err, err) + } + case <-time.After(3 * time.Second): + t.Fatal("upstream did not observe close in time") + } +} + +func TestPump_CloseWithErrorPropagatesUpstreamToClient(t *testing.T) { + if testing.Short() { + t.Skip() + } + const code webtransport.SessionErrorCode = 9000 + const msg = "upstream bye" + tt := newPumpTestTopology(t, func(sess *webtransport.Session, _ *http.Request) { + _ = sess.CloseWithError(code, msg) + }) + t.Cleanup(tt.shutdown) + + sess := tt.dialFrontend(t) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := sess.AcceptStream(ctx) + var sessErr *webtransport.SessionError + if !errors.As(err, &sessErr) { + t.Fatalf("expected SessionError, got %T: %v", err, err) + } + if sessErr.ErrorCode != code || sessErr.Message != msg { + t.Errorf("client saw code=%d msg=%q, want code=%d msg=%q", + sessErr.ErrorCode, sessErr.Message, code, msg) + } +} + +// TestPump_SessionLifecycle_NoGoroutineLeak sanity-checks that after both +// sessions end, the pump's goroutines unwind. We compare goroutine counts +// before and after, with a small tolerance because the Go runtime has +// background goroutines we can't synchronize with. +func TestPump_SessionLifecycle_NoGoroutineLeak(t *testing.T) { + if testing.Short() { + t.Skip() + } + before := runtime.NumGoroutine() + + // Drive a fast session+close cycle. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + tt := newPumpTestTopology(t, echoUpstream) + sess := tt.dialFrontend(t) + _ = sess.CloseWithError(0, "") + // Allow close to propagate and goroutines to exit. + time.Sleep(200 * time.Millisecond) + tt.shutdown() + }() + wg.Wait() + + // Give the runtime a moment to finish tearing down. + deadline := time.Now().Add(2 * time.Second) + var after int + for { + after = runtime.NumGoroutine() + if after <= before+8 || time.Now().After(deadline) { + break + } + time.Sleep(50 * time.Millisecond) + } + // Allow some slack — test infrastructure itself keeps a few goroutines. + if after > before+16 { + t.Errorf("possible goroutine leak: before=%d after=%d", before, after) + } +} From 3577e6ed14ade7ce87429f017d564590f92a1e9c Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:46:56 -0700 Subject: [PATCH 06/16] reverseproxy: wire WebTransport pump into ServeHTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- caddytest/integration/webtransport_test.go | 138 ++++++++++++++++++ .../caddyhttp/reverseproxy/httptransport.go | 20 +++ .../caddyhttp/reverseproxy/reverseproxy.go | 20 +++ .../reverseproxy/webtransport_transport.go | 90 ++++++++++++ 4 files changed, 268 insertions(+) diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go index 695a351477e..17294e69789 100644 --- a/caddytest/integration/webtransport_test.go +++ b/caddytest/integration/webtransport_test.go @@ -148,3 +148,141 @@ func TestWebTransport_EchoHandlerBidi(t *testing.T) { t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload) } } + +// TestWebTransport_ReverseProxyEndToEnd spins up a single Caddy instance +// running two HTTP/3 servers: one on :9443 acting as the WebTransport +// reverse proxy, and one on :9444 acting as the terminating echo +// upstream. A real webtransport.Dialer dials the proxy; the pump should +// bridge to the upstream so bytes written on a bidi stream are echoed. +func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip() + } + tester := caddytest.NewTester(t) + tester.InitServer(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":9443"], + "protocols": ["h3"], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "webtransport": true, + "tls": {"insecure_skip_verify": true} + }, + "upstreams": [{"dial": "127.0.0.1:9444"}] + } + ] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + }, + "upstream": { + "listen": [":9444"], + "protocols": ["h3"], + "routes": [ + {"handle": [{"handler": "webtransport"}]} + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": { + "certificate_authorities": { + "local": {"install_trust": false} + } + } + } +}`, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Retry briefly while both listeners finish binding. + var ( + sess *webtransport.Session + rsp *http.Response + err error + ) + deadline := time.Now().Add(3 * time.Second) + for { + rsp, sess, err = dialer.Dial(ctx, "https://127.0.0.1:9443/", nil) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("webtransport dial through proxy failed after retries: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + defer sess.CloseWithError(0, "") + + if rsp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", rsp.StatusCode) + } + + str, err := sess.OpenStreamSync(ctx) + if err != nil { + t.Fatalf("open stream through proxy: %v", err) + } + const payload = "reverse-proxied via the pump" + if _, err := io.WriteString(str, payload); err != nil { + t.Fatalf("write: %v", err) + } + if err := str.Close(); err != nil { + t.Fatalf("close write: %v", err) + } + got, err := io.ReadAll(str) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != payload { + t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload) + } +} diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go index c65bd61859d..c2ed4e22aec 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -138,6 +138,20 @@ 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"` @@ -504,6 +518,12 @@ 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 52d2b1ab30f..2dd1e256bc8 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -224,6 +224,12 @@ 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 @@ -293,6 +299,12 @@ 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") @@ -452,6 +464,14 @@ func (h *Handler) Cleanup() error { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + // 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) { + return h.serveWebTransport(w, r) + } + // prepare the request for proxying; this is needed only once clonedReq, err := h.prepareRequest(r, repl) if err != nil { diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index c3bb6f9acc4..8f564ac0085 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -17,12 +17,102 @@ package reverseproxy import ( "context" "crypto/tls" + "errors" + "fmt" "net/http" "github.com/quic-go/quic-go" "github.com/quic-go/webtransport-go" + "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + caddywt "github.com/caddyserver/caddy/v2/modules/caddyhttp/webtransport" ) +// isWebTransportExtendedConnect reports whether r is an HTTP/3 Extended +// CONNECT that requests a WebTransport session. Does not check whether +// WebTransport proxying is configured; callers gate on Handler state. +func isWebTransportExtendedConnect(r *http.Request) bool { + return r.ProtoMajor == 3 && r.Method == http.MethodConnect && r.Proto == caddywt.Protocol +} + +// serveWebTransport handles a WebTransport Extended CONNECT: selects an +// upstream, upgrades the client-side session, dials the upstream-side +// session, and runs the session pump until both sides close. +// +// Unlike the regular HTTP proxy path, there are no retries: a failed +// dial closes the client's session and returns (so the handler chain +// can finish). Requests that reach this function are already known to +// be WebTransport; callers should gate with isWebTransportProxyRequest. +func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) error { + srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) + if !ok || srv == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: no caddyhttp.Server in request context")) + } + 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")) + } + + // Select an upstream via the configured LB policy. No retries. + upstreams := h.Upstreams + if h.LoadBalancing == nil || h.LoadBalancing.SelectionPolicy == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: load balancer is not configured")) + } + upstream := h.LoadBalancing.SelectionPolicy.Select(upstreams, r, w) + if upstream == nil { + return caddyhttp.Error(http.StatusBadGateway, + errors.New("webtransport: no upstream available")) + } + + // Reach the naked http3 response writer so Upgrade's type assertions + // succeed through Caddy's wrapper chain. + naked, ok := caddyhttp.UnwrapResponseWriterAs[caddywt.Writer](w) + if !ok { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: response writer does not support WebTransport upgrade")) + } + + clientSess, err := wtServer.Upgrade(naked, r) + if err != nil { + h.logger.Debug("webtransport client upgrade failed", zap.Error(err)) + return caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err)) + } + + ht := h.Transport.(*HTTPTransport) + upstreamURL := buildWebTransportUpstreamURL(upstream.Dial, r) + _, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, r.Header.Clone()) + if err != nil { + h.logger.Error("webtransport upstream dial failed", + zap.String("upstream", upstreamURL), + zap.Error(err)) + _ = clientSess.CloseWithError(0, "upstream dial failed") + return nil + } + + runWebTransportPump(clientSess, upstreamSess, h.logger) + return nil +} + +// buildWebTransportUpstreamURL constructs an https:// URL for the dialer +// using the upstream's Dial address (host:port) and the request's path +// + raw query. Scheme is fixed to https since WebTransport-over-H3 +// requires TLS. +func buildWebTransportUpstreamURL(dial string, r *http.Request) string { + path := r.URL.Path + if path == "" { + path = "/" + } + if r.URL.RawQuery != "" { + return fmt.Sprintf("https://%s%s?%s", dial, path, r.URL.RawQuery) + } + return fmt.Sprintf("https://%s%s", dial, path) +} + // dialUpstreamWebTransport opens a WebTransport session to the upstream at // urlStr (an https URL), forwarding reqHdr as headers on the Extended // CONNECT request. The returned session is owned by the caller and must be From eaa11b8f95650a8eadc74b37b3c8f4838d25adc9 Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:50:18 -0700 Subject: [PATCH 07/16] 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. --- .../reverse_proxy_webtransport.caddyfiletest | 47 +++++++++++++++++++ modules/caddyhttp/reverseproxy/caddyfile.go | 9 ++++ 2 files changed, 56 insertions(+) create mode 100644 caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest diff --git a/caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest b/caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest new file mode 100644 index 00000000000..c55eb1945d3 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest @@ -0,0 +1,47 @@ +: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/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index 8716babe336..92ba4839903 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -1315,6 +1315,15 @@ 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" { From d3f8144c6c3b3026c0108db25a206b499a25b45b Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 20:01:23 -0700 Subject: [PATCH 08/16] reverseproxy: apply standard request preparation to WebTransport CONNECT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- caddytest/integration/webtransport_test.go | 214 ++++++++++++++++++ .../reverseproxy/webtransport_transport.go | 92 +++++++- 2 files changed, 294 insertions(+), 12 deletions(-) diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go index 17294e69789..6a8eebbe4ba 100644 --- a/caddytest/integration/webtransport_test.go +++ b/caddytest/integration/webtransport_test.go @@ -16,8 +16,16 @@ package integration import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" "io" + "math/big" + "net" "net/http" "strings" "testing" @@ -286,3 +294,209 @@ func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) { t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload) } } + +// TestWebTransport_ReverseProxyForwardsHeaders proves that the WebTransport +// proxy path applies the same request-preparation pipeline as the normal +// reverse_proxy path: `headers.request.set` lands on the upstream CONNECT, +// X-Forwarded-For is added, and a Via header is appended. The upstream here +// is a standalone webtransport.Server (not another Caddy) so we can observe +// the raw headers of the Extended CONNECT that Caddy forwarded. +func TestWebTransport_ReverseProxyForwardsHeaders(t *testing.T) { + if testing.Short() { + t.Skip() + } + + // Capture the first Extended CONNECT's headers. + gotHeaders := make(chan http.Header, 1) + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) { + select { + case gotHeaders <- r.Header.Clone(): + default: + } + _ = sess.CloseWithError(0, "") + }) + t.Cleanup(stopUpstream) + + config := fmt.Sprintf(`{ + "admin": {"listen": "localhost:2999"}, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":9443"], + "protocols": ["h3"], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "webtransport": true, + "tls": {"insecure_skip_verify": true} + }, + "headers": { + "request": { + "set": {"X-Caddy-Test": ["caddy-wt-hdr"]} + } + }, + "upstreams": [{"dial": "127.0.0.1:%d"}] + } + ] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": {"certificate_authorities": {"local": {"install_trust": false}}} + } +}`, upstreamAddr.Port) + + tester := caddytest.NewTester(t) + tester.InitServer(config, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var sess *webtransport.Session + deadline := time.Now().Add(3 * time.Second) + for { + _, s, err := dialer.Dial(ctx, "https://127.0.0.1:9443/", nil) + if err == nil { + sess = s + break + } + if time.Now().After(deadline) { + t.Fatalf("webtransport dial through proxy failed: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + defer sess.CloseWithError(0, "") + + select { + case hdr := <-gotHeaders: + if got := hdr.Get("X-Caddy-Test"); got != "caddy-wt-hdr" { + t.Errorf("upstream did not receive `headers.request.set` value; got X-Caddy-Test=%q", got) + } + if got := hdr.Get("X-Forwarded-For"); !strings.Contains(got, "127.0.0.1") { + t.Errorf("upstream did not receive X-Forwarded-For=127.0.0.1; got %q", got) + } + if got := hdr.Get("Via"); got == "" { + t.Errorf("upstream did not receive Via header") + } + case <-time.After(3 * time.Second): + t.Fatal("upstream did not observe forwarded CONNECT headers in time") + } +} + +// startStandaloneWebTransport starts a webtransport.Server on a random UDP +// port with a self-signed cert. handler runs after a successful Upgrade. +// Returns the listener addr and a shutdown func. +func startStandaloneWebTransport(t *testing.T, handler func(s *webtransport.Session, r *http.Request)) (*net.UDPAddr, func()) { + t.Helper() + tlsCfg := newSelfSignedTLSConfig(t, "localhost") + + mux := http.NewServeMux() + h3 := &http3.Server{ + TLSConfig: tlsCfg, + Handler: mux, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + webtransport.ConfigureHTTP3Server(h3) + wtServer := &webtransport.Server{H3: h3} + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + sess, err := wtServer.Upgrade(w, r) + if err != nil { + t.Logf("standalone WebTransport upgrade failed: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + handler(sess, r) + }) + + udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + conn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + t.Fatal(err) + } + servErr := make(chan error, 1) + go func() { servErr <- wtServer.Serve(conn) }() + shutdown := func() { + _ = wtServer.Close() + <-servErr + _ = conn.Close() + } + return conn.LocalAddr().(*net.UDPAddr), shutdown +} + +// newSelfSignedTLSConfig produces a self-signed TLS config suitable for +// 127.0.0.1 and the given common name, with the H3 ALPN advertised. +func newSelfSignedTLSConfig(t *testing.T, cn string) *tls.Config { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{cn}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, priv.Public(), priv) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv, Leaf: cert}}, + NextProtos: []string{http3.NextProtoH3}, + } +} diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index 8f564ac0085..47d0dddc7a9 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -20,11 +20,14 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/quic-go/quic-go" "github.com/quic-go/webtransport-go" "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" caddywt "github.com/caddyserver/caddy/v2/modules/caddyhttp/webtransport" ) @@ -42,9 +45,20 @@ func isWebTransportExtendedConnect(r *http.Request) bool { // // Unlike the regular HTTP proxy path, there are no retries: a failed // dial closes the client's session and returns (so the handler chain -// can finish). Requests that reach this function are already known to -// be WebTransport; callers should gate with isWebTransportProxyRequest. +// can finish). The outgoing CONNECT is prepared with the same Rewrite, +// hop-by-hop stripping, X-Forwarded-*/Via, transport- and user-configured +// header ops as the normal proxy path so operators see consistent +// behavior. Requests that reach this function are already known to be +// WebTransport; callers should gate with isWebTransportExtendedConnect. func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) error { + repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + start := time.Now() + defer func() { + d := time.Since(start) + repl.Set("http.reverse_proxy.duration", d) + repl.Set("http.reverse_proxy.duration_ms", d.Seconds()*1e3) + }() + srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) if !ok || srv == nil { return caddyhttp.Error(http.StatusInternalServerError, @@ -56,16 +70,64 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro errors.New("webtransport: HTTP/3 is not enabled on this server; WebTransport requires H3")) } - // Select an upstream via the configured LB policy. No retries. - upstreams := h.Upstreams if h.LoadBalancing == nil || h.LoadBalancing.SelectionPolicy == nil { return caddyhttp.Error(http.StatusInternalServerError, errors.New("webtransport: load balancer is not configured")) } + + // Resolve the candidate upstream set (static or dynamic) and select + // one. WT sessions are long-lived and not idempotent, so there are no + // retries; picking once matches how operators expect WT to behave. + upstreams := h.Upstreams + if h.DynamicUpstreams != nil { + dynUpstreams, err := h.DynamicUpstreams.GetUpstreams(r) + if err != nil { + if c := h.logger.Check(zapcore.WarnLevel, "webtransport: dynamic upstreams failed; falling back to static"); c != nil { + c.Write(zap.Error(err)) + } + } else { + upstreams = dynUpstreams + for _, dUp := range dynUpstreams { + h.provisionUpstream(dUp, true) + } + } + } upstream := h.LoadBalancing.SelectionPolicy.Select(upstreams, r, w) if upstream == nil { - return caddyhttp.Error(http.StatusBadGateway, - errors.New("webtransport: no upstream available")) + return caddyhttp.Error(http.StatusBadGateway, errNoUpstream) + } + + // Resolve per-upstream placeholders (addresses may include them) and + // publish the {http.reverse_proxy.upstream.*} replacer values before + // we commit to upgrading — so any client-side failure logs downstream + // see the selected upstream too. + dialInfo, err := upstream.fillDialInfo(repl) + if err != nil { + return caddyhttp.Error(http.StatusInternalServerError, + fmt.Errorf("webtransport: making dial info: %w", err)) + } + repl.Set("http.reverse_proxy.upstream.address", dialInfo.String()) + repl.Set("http.reverse_proxy.upstream.hostport", dialInfo.Address) + repl.Set("http.reverse_proxy.upstream.host", dialInfo.Host) + repl.Set("http.reverse_proxy.upstream.port", dialInfo.Port) + repl.Set("http.reverse_proxy.upstream.requests", upstream.Host.NumRequests()) + repl.Set("http.reverse_proxy.upstream.max_requests", upstream.MaxRequests) + repl.Set("http.reverse_proxy.upstream.fails", upstream.Host.Fails()) + + // Prepare the outgoing request the same way normal proxying does — + // Rewrite, hop-by-hop stripping, X-Forwarded-*, Via, etc. — then apply + // transport and user header ops. prepareRequest's body-buffering and + // Early-Data paths are no-ops for a CONNECT request (empty body). + clonedReq, err := h.prepareRequest(r, repl) + if err != nil { + return caddyhttp.Error(http.StatusInternalServerError, + fmt.Errorf("webtransport: preparing request: %w", err)) + } + if h.transportHeaderOps != nil { + h.transportHeaderOps.ApplyToRequest(clonedReq) + } + if h.Headers != nil && h.Headers.Request != nil { + h.Headers.Request.ApplyToRequest(clonedReq) } // Reach the naked http3 response writer so Upgrade's type assertions @@ -78,18 +140,24 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro clientSess, err := wtServer.Upgrade(naked, r) if err != nil { - h.logger.Debug("webtransport client upgrade failed", zap.Error(err)) + if c := h.logger.Check(zapcore.DebugLevel, "webtransport client upgrade failed"); c != nil { + c.Write(zap.Error(err)) + } return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("webtransport upgrade: %w", err)) } ht := h.Transport.(*HTTPTransport) - upstreamURL := buildWebTransportUpstreamURL(upstream.Dial, r) - _, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, r.Header.Clone()) + upstreamURL := buildWebTransportUpstreamURL(dialInfo.Address, clonedReq) + _, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, clonedReq.Header) if err != nil { - h.logger.Error("webtransport upstream dial failed", - zap.String("upstream", upstreamURL), - zap.Error(err)) + h.countFailure(upstream) + if c := h.logger.Check(zapcore.ErrorLevel, "webtransport upstream dial failed"); c != nil { + c.Write( + zap.String("upstream", upstreamURL), + zap.Error(err), + ) + } _ = clientSess.CloseWithError(0, "upstream dial failed") return nil } From c5e12a171522d6990d48378570e8e90227a61371 Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 20:05:18 -0700 Subject: [PATCH 09/16] reverseproxy: dial WebTransport upstream before upgrading client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- caddytest/integration/webtransport_test.go | 118 ++++++++++++++++++ .../reverseproxy/webtransport_transport.go | 65 ++++++---- 2 files changed, 162 insertions(+), 21 deletions(-) diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go index 6a8eebbe4ba..edec51fb184 100644 --- a/caddytest/integration/webtransport_test.go +++ b/caddytest/integration/webtransport_test.go @@ -422,6 +422,124 @@ func TestWebTransport_ReverseProxyForwardsHeaders(t *testing.T) { } } +// TestWebTransport_UpstreamDialFailureSurfaces5xx proves the WT path dials +// the upstream BEFORE upgrading the client, so an unreachable upstream +// returns a proper 5xx on the client's Dial call (webtransport-go surfaces +// it via RequirementsNotMetError or similar with the response attached) — +// not a successful Dial followed by an opaque session close. +func TestWebTransport_UpstreamDialFailureSurfaces5xx(t *testing.T) { + if testing.Short() { + t.Skip() + } + // Bind a UDP port then release it so we know nothing is listening. + l, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + deadPort := l.LocalAddr().(*net.UDPAddr).Port + _ = l.Close() + + config := fmt.Sprintf(`{ + "admin": {"listen": "localhost:2999"}, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":9443"], + "protocols": ["h3"], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "webtransport": true, + "tls": {"insecure_skip_verify": true} + }, + "upstreams": [{"dial": "127.0.0.1:%d"}] + } + ] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": {"certificate_authorities": {"local": {"install_trust": false}}} + } +}`, deadPort) + + tester := caddytest.NewTester(t) + tester.InitServer(config, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + // Give the proxy a short window to bind; the upstream dial will then + // fail quickly against the unbound port. + outer, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + var ( + rsp *http.Response + dialErr error + sess *webtransport.Session + ) + deadline := time.Now().Add(3 * time.Second) + for { + ctx, c := context.WithTimeout(outer, 2*time.Second) + rsp, sess, dialErr = dialer.Dial(ctx, "https://127.0.0.1:9443/", nil) + c() + if dialErr != nil { + break + } + // Happy path dial isn't allowed here — the upstream is dead. + sess.CloseWithError(0, "") + if time.Now().After(deadline) { + t.Fatal("expected Dial to fail against unreachable upstream, got success") + } + time.Sleep(100 * time.Millisecond) + } + + // The exact error type varies with webtransport-go versions, but the + // response (if attached) should carry a 5xx status — proving the + // proxy returned an error status instead of upgrading + closing. + t.Logf("observed dial error: %v", dialErr) + if rsp != nil && rsp.StatusCode < 500 { + t.Errorf("expected 5xx status from proxy on upstream failure; got %d", rsp.StatusCode) + } +} + // startStandaloneWebTransport starts a webtransport.Server on a random UDP // port with a self-signed cert. handler runs after a successful Upgrade. // Returns the listener addr and a shutdown func. diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index 47d0dddc7a9..6f9823a7ec9 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -40,16 +40,20 @@ func isWebTransportExtendedConnect(r *http.Request) bool { } // serveWebTransport handles a WebTransport Extended CONNECT: selects an -// upstream, upgrades the client-side session, dials the upstream-side +// upstream, dials the upstream-side session, upgrades the client-side // session, and runs the session pump until both sides close. // -// Unlike the regular HTTP proxy path, there are no retries: a failed -// dial closes the client's session and returns (so the handler chain -// can finish). The outgoing CONNECT is prepared with the same Rewrite, -// hop-by-hop stripping, X-Forwarded-*/Via, transport- and user-configured -// header ops as the normal proxy path so operators see consistent -// behavior. Requests that reach this function are already known to be -// WebTransport; callers should gate with isWebTransportExtendedConnect. +// The upstream is dialed *before* the client is upgraded so that a refused +// or unreachable upstream surfaces as a real 5xx on the client's Dial — +// not as a bare post-upgrade session close. There are no retries: WT +// sessions are long-lived and not idempotent. +// +// The outgoing CONNECT is prepared with the same Rewrite, hop-by-hop +// stripping, X-Forwarded-*/Via, transport- and user-configured header ops +// as the normal proxy path. Response-header ops (gated by `Require`, if +// configured) apply to the headers the client sees on the 200 OK. +// Requests that reach this function are already known to be WebTransport; +// callers should gate with isWebTransportExtendedConnect. func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) error { repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) start := time.Now() @@ -131,25 +135,21 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro } // Reach the naked http3 response writer so Upgrade's type assertions - // succeed through Caddy's wrapper chain. + // succeed through Caddy's wrapper chain. Done before dialing so we + // fail fast if the writer stack is unexpectedly incompatible. naked, ok := caddyhttp.UnwrapResponseWriterAs[caddywt.Writer](w) if !ok { return caddyhttp.Error(http.StatusInternalServerError, errors.New("webtransport: response writer does not support WebTransport upgrade")) } - clientSess, err := wtServer.Upgrade(naked, r) - if err != nil { - if c := h.logger.Check(zapcore.DebugLevel, "webtransport client upgrade failed"); c != nil { - c.Write(zap.Error(err)) - } - return caddyhttp.Error(http.StatusBadRequest, - fmt.Errorf("webtransport upgrade: %w", err)) - } - + // 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) - _, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, clonedReq.Header) + upstreamResp, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, clonedReq.Header) if err != nil { h.countFailure(upstream) if c := h.logger.Check(zapcore.ErrorLevel, "webtransport upstream dial failed"); c != nil { @@ -158,8 +158,31 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro zap.Error(err), ) } - _ = clientSess.CloseWithError(0, "upstream dial failed") - return nil + return caddyhttp.Error(http.StatusBadGateway, + fmt.Errorf("webtransport upstream dial: %w", err)) + } + defer upstreamResp.Body.Close() + + // Response-header ops (gated by Require, if configured) are applied to + // the 200 OK the client will see. webtransport.Server.Upgrade flushes + // w.Header() along with the status, so setting these before Upgrade is + // sufficient. Matching against the upstream response mirrors the normal + // proxy path where upstream response == client response. + if h.Headers != nil && h.Headers.Response != nil { + if h.Headers.Response.Require == nil || + h.Headers.Response.Require.Match(upstreamResp.StatusCode, upstreamResp.Header) { + h.Headers.Response.ApplyTo(w.Header(), repl) + } + } + + clientSess, err := wtServer.Upgrade(naked, r) + if err != nil { + _ = upstreamSess.CloseWithError(0, "client upgrade failed") + if c := h.logger.Check(zapcore.DebugLevel, "webtransport client upgrade failed"); c != nil { + c.Write(zap.Error(err)) + } + return caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err)) } runWebTransportPump(clientSess, upstreamSess, h.logger) From aaf85492a653cf210fad2da2362dbdf66df6409a Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 20:07:29 -0700 Subject: [PATCH 10/16] reverseproxy: track WebTransport sessions in upstream in-flight counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- caddytest/integration/webtransport_test.go | 151 ++++++++++++++++++ .../reverseproxy/webtransport_transport.go | 11 ++ 2 files changed, 162 insertions(+) diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go index edec51fb184..ae146a3bbae 100644 --- a/caddytest/integration/webtransport_test.go +++ b/caddytest/integration/webtransport_test.go @@ -22,6 +22,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/json" "fmt" "io" "math/big" @@ -540,6 +541,156 @@ func TestWebTransport_UpstreamDialFailureSurfaces5xx(t *testing.T) { } } +// TestWebTransport_InFlightRequestsTracked proves the WT proxy path +// increments upstream.Host.NumRequests for the session's lifetime and +// decrements after it ends, so MaxRequests gating, LeastConn/FirstAvailable +// LB, and the admin /reverse_proxy/upstreams endpoint reflect WT load. +func TestWebTransport_InFlightRequestsTracked(t *testing.T) { + if testing.Short() { + t.Skip() + } + + // Upstream blocks on a release channel until the test finishes probing + // the admin API; this keeps the session alive long enough to observe + // num_requests > 0. + release := make(chan struct{}) + // t.Cleanup drains release in case the test bails early. + t.Cleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) { + <-release + _ = sess.CloseWithError(0, "") + }) + t.Cleanup(stopUpstream) + + upstreamDial := fmt.Sprintf("127.0.0.1:%d", upstreamAddr.Port) + config := fmt.Sprintf(`{ + "admin": {"listen": "localhost:2999"}, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":9443"], + "protocols": ["h3"], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "webtransport": true, + "tls": {"insecure_skip_verify": true} + }, + "upstreams": [{"dial": "%s"}] + } + ] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": {"certificate_authorities": {"local": {"install_trust": false}}} + } +}`, upstreamDial) + + tester := caddytest.NewTester(t) + tester.InitServer(config, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + _, sess, err := dialer.Dial(ctx, "https://127.0.0.1:9443/", nil) + if err != nil { + t.Fatalf("proxy Dial failed: %v", err) + } + + // Poll the admin API until we see num_requests >= 1 for our upstream. + if !waitForUpstreamRequests(t, upstreamDial, 1, 2*time.Second) { + t.Fatal("upstream num_requests never reached >= 1 while session was active") + } + + // Close the client session and release the upstream so the server-side + // handler returns; the deferred decrement in serveWebTransport should + // drop num_requests back to 0 once both sides close. + _ = sess.CloseWithError(0, "") + close(release) + + if !waitForUpstreamRequests(t, upstreamDial, 0, 2*time.Second) { + t.Fatal("upstream num_requests did not drop to 0 after session closed") + } +} + +// waitForUpstreamRequests polls the admin /reverse_proxy/upstreams endpoint +// until the entry for dial has exactly wantRequests in-flight, or timeout. +// Returns true on match. +func waitForUpstreamRequests(t *testing.T, dial string, wantRequests int, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + rsp, err := http.Get("http://localhost:2999/reverse_proxy/upstreams") + if err != nil { + time.Sleep(50 * time.Millisecond) + continue + } + var entries []struct { + Address string `json:"address"` + NumRequests int `json:"num_requests"` + } + err = json.NewDecoder(rsp.Body).Decode(&entries) + _ = rsp.Body.Close() + if err != nil { + time.Sleep(50 * time.Millisecond) + continue + } + for _, e := range entries { + if e.Address == dial && e.NumRequests == wantRequests { + return true + } + } + time.Sleep(50 * time.Millisecond) + } + return false +} + // startStandaloneWebTransport starts a webtransport.Server on a random UDP // port with a self-signed cert. handler runs after a successful Upgrade. // Returns the listener addr and a shutdown func. diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index 6f9823a7ec9..d881cf2d285 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -185,6 +185,17 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro fmt.Errorf("webtransport upgrade: %w", err)) } + // Track the session in the same upstream counters the normal proxy + // path maintains: Host.NumRequests drives MaxRequests gating and + // least-connections selection; the per-address in-flight counter + // feeds the admin API's upstream stats. + _ = dialInfo.Upstream.Host.countRequest(1) + incInFlightRequest(dialInfo.Address) + defer func() { + _ = dialInfo.Upstream.Host.countRequest(-1) + decInFlightRequest(dialInfo.Address) + }() + runWebTransportPump(clientSess, upstreamSess, h.logger) return nil } From 8a2b853b2cf138723ff2b813ad00ce09bae340cf Mon Sep 17 00:00:00 2001 From: tomholford Date: Thu, 23 Apr 2026 11:03:20 -0700 Subject: [PATCH 11/16] reverseproxy: extract shared upstream-selection helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../caddyhttp/reverseproxy/reverseproxy.go | 63 ++++++++++++------- .../reverseproxy/webtransport_transport.go | 23 +------ 2 files changed, 41 insertions(+), 45 deletions(-) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 2dd1e256bc8..38a874882d1 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -72,6 +72,43 @@ func getInFlightRequests() map[string]int64 { return copyMap } +// resolveUpstreams returns the candidate upstream set for this request: +// dynamic upstreams when configured (with fallback to static on error), +// static upstreams otherwise. Any dynamic upstream entries are provisioned +// before return. +func (h *Handler) resolveUpstreams(r *http.Request) []*Upstream { + upstreams := h.Upstreams + if h.DynamicUpstreams == nil { + return upstreams + } + dUpstreams, err := h.DynamicUpstreams.GetUpstreams(r) + if err != nil { + if c := h.logger.Check(zapcore.ErrorLevel, "failed getting dynamic upstreams; falling back to static upstreams"); c != nil { + c.Write(zap.Error(err)) + } + return upstreams + } + for _, dUp := range dUpstreams { + h.provisionUpstream(dUp, true) + } + if c := h.logger.Check(zapcore.DebugLevel, "provisioned dynamic upstreams"); c != nil { + c.Write(zap.Int("count", len(dUpstreams))) + } + return dUpstreams +} + +// setUpstreamReplacerVars populates the {http.reverse_proxy.upstream.*} +// placeholders describing the selected upstream. +func setUpstreamReplacerVars(repl *caddy.Replacer, upstream *Upstream, di DialInfo) { + repl.Set("http.reverse_proxy.upstream.address", di.String()) + repl.Set("http.reverse_proxy.upstream.hostport", di.Address) + repl.Set("http.reverse_proxy.upstream.host", di.Host) + repl.Set("http.reverse_proxy.upstream.port", di.Port) + repl.Set("http.reverse_proxy.upstream.requests", upstream.Host.NumRequests()) + repl.Set("http.reverse_proxy.upstream.max_requests", upstream.MaxRequests) + repl.Set("http.reverse_proxy.upstream.fails", upstream.Host.Fails()) +} + func init() { caddy.RegisterModule(Handler{}) } @@ -592,23 +629,7 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h repl *caddy.Replacer, reqHeader http.Header, reqHost string, next caddyhttp.Handler, ) (bool, error) { // get the updated list of upstreams - upstreams := h.Upstreams - if h.DynamicUpstreams != nil { - dUpstreams, err := h.DynamicUpstreams.GetUpstreams(r) - if err != nil { - if c := h.logger.Check(zapcore.ErrorLevel, "failed getting dynamic upstreams; falling back to static upstreams"); c != nil { - c.Write(zap.Error(err)) - } - } else { - upstreams = dUpstreams - for _, dUp := range dUpstreams { - h.provisionUpstream(dUp, true) - } - if c := h.logger.Check(zapcore.DebugLevel, "provisioned dynamic upstreams"); c != nil { - c.Write(zap.Int("count", len(dUpstreams))) - } - } - } + upstreams := h.resolveUpstreams(r) // choose an available upstream upstream := h.LoadBalancing.SelectionPolicy.Select(upstreams, r, w) @@ -643,13 +664,7 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h caddyhttp.SetVar(r.Context(), dialInfoVarKey, dialInfo) // set placeholders with information about this upstream - repl.Set("http.reverse_proxy.upstream.address", dialInfo.String()) - repl.Set("http.reverse_proxy.upstream.hostport", dialInfo.Address) - repl.Set("http.reverse_proxy.upstream.host", dialInfo.Host) - repl.Set("http.reverse_proxy.upstream.port", dialInfo.Port) - repl.Set("http.reverse_proxy.upstream.requests", upstream.Host.NumRequests()) - repl.Set("http.reverse_proxy.upstream.max_requests", upstream.MaxRequests) - repl.Set("http.reverse_proxy.upstream.fails", upstream.Host.Fails()) + setUpstreamReplacerVars(repl, upstream, dialInfo) // mutate request headers according to this upstream; // because we're in a retry loop, we have to copy headers diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index d881cf2d285..b21410bd357 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -82,20 +82,7 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro // Resolve the candidate upstream set (static or dynamic) and select // one. WT sessions are long-lived and not idempotent, so there are no // retries; picking once matches how operators expect WT to behave. - upstreams := h.Upstreams - if h.DynamicUpstreams != nil { - dynUpstreams, err := h.DynamicUpstreams.GetUpstreams(r) - if err != nil { - if c := h.logger.Check(zapcore.WarnLevel, "webtransport: dynamic upstreams failed; falling back to static"); c != nil { - c.Write(zap.Error(err)) - } - } else { - upstreams = dynUpstreams - for _, dUp := range dynUpstreams { - h.provisionUpstream(dUp, true) - } - } - } + upstreams := h.resolveUpstreams(r) upstream := h.LoadBalancing.SelectionPolicy.Select(upstreams, r, w) if upstream == nil { return caddyhttp.Error(http.StatusBadGateway, errNoUpstream) @@ -110,13 +97,7 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro return caddyhttp.Error(http.StatusInternalServerError, fmt.Errorf("webtransport: making dial info: %w", err)) } - repl.Set("http.reverse_proxy.upstream.address", dialInfo.String()) - repl.Set("http.reverse_proxy.upstream.hostport", dialInfo.Address) - repl.Set("http.reverse_proxy.upstream.host", dialInfo.Host) - repl.Set("http.reverse_proxy.upstream.port", dialInfo.Port) - repl.Set("http.reverse_proxy.upstream.requests", upstream.Host.NumRequests()) - repl.Set("http.reverse_proxy.upstream.max_requests", upstream.MaxRequests) - repl.Set("http.reverse_proxy.upstream.fails", upstream.Host.Fails()) + setUpstreamReplacerVars(repl, upstream, dialInfo) // Prepare the outgoing request the same way normal proxying does — // Rewrite, hop-by-hop stripping, X-Forwarded-*, Via, etc. — then apply From 4325098c516b2c7ec379e00a57c22b176712d166 Mon Sep 17 00:00:00 2001 From: tomholford Date: Thu, 23 Apr 2026 11:04:10 -0700 Subject: [PATCH 12/16] 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. --- .../reverseproxy/webtransport_transport.go | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index b21410bd357..26e0a94de9c 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -23,20 +23,35 @@ import ( "time" "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" "github.com/quic-go/webtransport-go" "go.uber.org/zap" "go.uber.org/zap/zapcore" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" - caddywt "github.com/caddyserver/caddy/v2/modules/caddyhttp/webtransport" ) +// webtransportProtocol is the :protocol pseudo-header value sent by a +// client that wants to establish a WebTransport session over an HTTP/3 +// Extended CONNECT. +const webtransportProtocol = "webtransport" + +// webtransportWriter is the naked HTTP/3 response-writer shape that +// webtransport.Server.Upgrade type-asserts on. Caddy's +// UnwrapResponseWriterAs walks the ResponseWriter wrapper chain to this +// type before calling Upgrade. +type webtransportWriter interface { + http.ResponseWriter + http3.Settingser + http3.HTTPStreamer +} + // isWebTransportExtendedConnect reports whether r is an HTTP/3 Extended // CONNECT that requests a WebTransport session. Does not check whether // WebTransport proxying is configured; callers gate on Handler state. func isWebTransportExtendedConnect(r *http.Request) bool { - return r.ProtoMajor == 3 && r.Method == http.MethodConnect && r.Proto == caddywt.Protocol + return r.ProtoMajor == 3 && r.Method == http.MethodConnect && r.Proto == webtransportProtocol } // serveWebTransport handles a WebTransport Extended CONNECT: selects an @@ -118,7 +133,7 @@ func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) erro // Reach the naked http3 response writer so Upgrade's type assertions // succeed through Caddy's wrapper chain. Done before dialing so we // fail fast if the writer stack is unexpectedly incompatible. - naked, ok := caddyhttp.UnwrapResponseWriterAs[caddywt.Writer](w) + naked, ok := caddyhttp.UnwrapResponseWriterAs[webtransportWriter](w) if !ok { return caddyhttp.Error(http.StatusInternalServerError, errors.New("webtransport: response writer does not support WebTransport upgrade")) From 15ca671402e11d8f0902131533ad94b2e42d5476 Mon Sep 17 00:00:00 2001 From: tomholford Date: Thu, 23 Apr 2026 11:06:16 -0700 Subject: [PATCH 13/16] caddyhttp: move WebTransport echo handler to integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integration/webtransport_echo_test.go | 197 ++++++++++++++++++ modules/caddyhttp/standard/imports.go | 1 - modules/caddyhttp/webtransport/handler.go | 145 ------------- .../caddyhttp/webtransport/handler_test.go | 69 ------ 4 files changed, 197 insertions(+), 215 deletions(-) create mode 100644 caddytest/integration/webtransport_echo_test.go delete mode 100644 modules/caddyhttp/webtransport/handler.go delete mode 100644 modules/caddyhttp/webtransport/handler_test.go diff --git a/caddytest/integration/webtransport_echo_test.go b/caddytest/integration/webtransport_echo_test.go new file mode 100644 index 00000000000..58a66244e80 --- /dev/null +++ b/caddytest/integration/webtransport_echo_test.go @@ -0,0 +1,197 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +// This file provides a terminating WebTransport handler used exclusively +// as a test upstream for the WebTransport reverse-proxy integration +// tests in webtransport_test.go. Keeping it in a _test.go file (mirroring +// mockdns_test.go) means the http.handlers.webtransport module is only +// registered in the integration test binary — it does not ship in +// production Caddy builds. + +func init() { + caddy.RegisterModule(WebTransportEcho{}) +} + +// webtransportEchoProtocol is the :protocol pseudo-header value for an +// HTTP/3 Extended CONNECT that establishes a WebTransport session. +const webtransportEchoProtocol = "webtransport" + +// webtransportEchoWriter is the naked HTTP/3 response-writer shape that +// webtransport.Server.Upgrade type-asserts on. +type webtransportEchoWriter interface { + http.ResponseWriter + http3.Settingser + http3.HTTPStreamer +} + +// WebTransportEcho terminates an incoming WebTransport session and echoes +// bytes on each accepted bidirectional stream. Registered as +// `http.handlers.webtransport` in the integration test binary. +type WebTransportEcho struct { + logger *zap.Logger +} + +// CaddyModule returns the Caddy module information. +func (WebTransportEcho) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.webtransport", + New: func() caddy.Module { return new(WebTransportEcho) }, + } +} + +// Provision sets up the handler. +func (h *WebTransportEcho) Provision(ctx caddy.Context) error { + h.logger = ctx.Logger() + return nil +} + +// ServeHTTP upgrades the request to a WebTransport session and echoes +// bytes on each accepted bidirectional stream. Non-WebTransport requests +// are passed through to the next handler. +func (h *WebTransportEcho) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + if !isWebTransportEchoUpgrade(r) { + return next.ServeHTTP(w, r) + } + + srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) + if !ok || srv == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: caddyhttp.Server not in request context")) + } + 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")) + } + + naked, ok := caddyhttp.UnwrapResponseWriterAs[webtransportEchoWriter](w) + if !ok { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: underlying writer does not support WebTransport upgrade")) + } + + session, err := wtServer.Upgrade(naked, r) + if err != nil { + h.logger.Debug("webtransport upgrade failed", zap.Error(err)) + return caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err)) + } + + h.echoStreams(session) + return nil +} + +// echoStreams accepts bidirectional streams on session until the session +// ends, and echoes bytes on each one. +func (h *WebTransportEcho) echoStreams(session *webtransport.Session) { + ctx := session.Context() + for { + str, err := session.AcceptStream(ctx) + if err != nil { + return + } + go func(s *webtransport.Stream) { + // io.Copy from the stream back to itself echoes everything + // received on this bidirectional stream. When the peer closes + // its send side we observe EOF and close our send side too. + if _, err := io.Copy(s, s); err != nil && h.logger != nil { + h.logger.Debug("webtransport echo stream error", zap.Error(err)) + } + _ = s.Close() + }(str) + } +} + +// isWebTransportEchoUpgrade reports whether r is an HTTP/3 Extended +// CONNECT that requests a WebTransport session. The quic-go/http3 server +// places the :protocol pseudo-header value in r.Proto for CONNECT requests. +func isWebTransportEchoUpgrade(r *http.Request) bool { + return r.ProtoMajor == 3 && + r.Method == http.MethodConnect && + r.Proto == webtransportEchoProtocol +} + +// Interface guards. +var ( + _ caddy.Provisioner = (*WebTransportEcho)(nil) + _ caddyhttp.MiddlewareHandler = (*WebTransportEcho)(nil) +) + +// --- unit tests ------------------------------------------------------------ + +func TestIsWebTransportEchoUpgrade(t *testing.T) { + cases := []struct { + name string + proto string + major int + meth string + want bool + }{ + {"h3 connect webtransport", "webtransport", 3, http.MethodConnect, true}, + {"h3 connect websocket", "websocket", 3, http.MethodConnect, false}, + {"h2 connect webtransport", "webtransport", 2, http.MethodConnect, false}, + {"h3 GET", "HTTP/3.0", 3, http.MethodGet, false}, + {"h3 connect missing :protocol", "HTTP/3.0", 3, http.MethodConnect, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(tc.meth, "/", nil) + r.ProtoMajor = tc.major + r.Proto = tc.proto + if got := isWebTransportEchoUpgrade(r); got != tc.want { + t.Errorf("isWebTransportEchoUpgrade = %v, want %v", got, tc.want) + } + }) + } +} + +// echoNextNoop is a stand-in for the next handler. It records whether it +// was invoked, used to assert that non-WebTransport requests pass through. +type echoNextNoop struct{ called bool } + +func (n *echoNextNoop) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + n.called = true + return nil +} + +func TestWebTransportEcho_PassesThroughNonWebTransportRequests(t *testing.T) { + h := &WebTransportEcho{} + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + nx := &echoNextNoop{} + if err := h.ServeHTTP(w, r, nx); err != nil { + t.Fatalf("ServeHTTP returned error: %v", err) + } + if !nx.called { + t.Error("expected next handler to be invoked for non-WebTransport request") + } +} diff --git a/modules/caddyhttp/standard/imports.go b/modules/caddyhttp/standard/imports.go index ac5b2f3e2d4..6617941c65d 100644 --- a/modules/caddyhttp/standard/imports.go +++ b/modules/caddyhttp/standard/imports.go @@ -22,5 +22,4 @@ import ( _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/tracing" - _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/webtransport" ) diff --git a/modules/caddyhttp/webtransport/handler.go b/modules/caddyhttp/webtransport/handler.go deleted file mode 100644 index 07c5e9f32d9..00000000000 --- a/modules/caddyhttp/webtransport/handler.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2015 Matthew Holt and The Caddy Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package webtransport is an EXPERIMENTAL HTTP handler that terminates a -// WebTransport session (draft-ietf-webtrans-http3) on top of Caddy's HTTP/3 -// server and echoes bytes on each bidirectional stream. It exists mainly as -// a test upstream for the WebTransport reverse-proxy transport. Behavior -// and configuration are subject to change without notice. -package webtransport - -import ( - "errors" - "fmt" - "io" - "net/http" - - "github.com/quic-go/quic-go/http3" - "github.com/quic-go/webtransport-go" - "go.uber.org/zap" - - "github.com/caddyserver/caddy/v2" - "github.com/caddyserver/caddy/v2/modules/caddyhttp" -) - -func init() { - caddy.RegisterModule(Handler{}) -} - -// Protocol is the :protocol pseudo-header value sent by a client that wants -// to establish a WebTransport session over HTTP/3 Extended CONNECT. -const Protocol = "webtransport" - -// Writer is the interface satisfied by the naked HTTP/3 response writer. -// webtransport.Server.Upgrade performs these assertions itself; callers -// can use caddyhttp.UnwrapResponseWriterAs[Writer] to reach it past -// Caddy's ResponseWriter wrapping chain before calling Upgrade. -type Writer interface { - http.ResponseWriter - http3.Settingser - http3.HTTPStreamer -} - -// Handler terminates an incoming WebTransport session and echoes bytes on -// each bidirectional stream. EXPERIMENTAL: intended primarily as a test -// upstream for the WebTransport reverse-proxy transport. -type Handler struct { - logger *zap.Logger -} - -// CaddyModule returns the Caddy module information. -func (Handler) CaddyModule() caddy.ModuleInfo { - return caddy.ModuleInfo{ - ID: "http.handlers.webtransport", - New: func() caddy.Module { return new(Handler) }, - } -} - -// Provision sets up the handler. -func (h *Handler) Provision(ctx caddy.Context) error { - h.logger = ctx.Logger() - return nil -} - -// ServeHTTP upgrades the request to a WebTransport session and echoes bytes -// on each accepted bidirectional stream. Non-WebTransport requests are -// passed through to the next handler. -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { - if !isWebTransportUpgrade(r) { - return next.ServeHTTP(w, r) - } - - srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) - if !ok || srv == nil { - return caddyhttp.Error(http.StatusInternalServerError, - errors.New("webtransport: caddyhttp.Server not in request context")) - } - 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")) - } - - naked, ok := caddyhttp.UnwrapResponseWriterAs[Writer](w) - if !ok { - return caddyhttp.Error(http.StatusInternalServerError, - errors.New("webtransport: underlying writer does not support WebTransport upgrade")) - } - - session, err := wtServer.Upgrade(naked, r) - if err != nil { - h.logger.Debug("webtransport upgrade failed", zap.Error(err)) - return caddyhttp.Error(http.StatusBadRequest, - fmt.Errorf("webtransport upgrade: %w", err)) - } - - h.echoStreams(session) - return nil -} - -// echoStreams accepts bidirectional streams on session until the session -// ends, and echoes bytes on each one. -func (h *Handler) echoStreams(session *webtransport.Session) { - ctx := session.Context() - for { - str, err := session.AcceptStream(ctx) - if err != nil { - return - } - go func(s *webtransport.Stream) { - // io.Copy from the stream back to itself echoes everything - // received on this bidirectional stream. When the peer closes - // its send side we observe EOF and close our send side too. - if _, err := io.Copy(s, s); err != nil && h.logger != nil { - h.logger.Debug("webtransport echo stream error", zap.Error(err)) - } - _ = s.Close() - }(str) - } -} - -// isWebTransportUpgrade reports whether r is an HTTP/3 Extended CONNECT that -// requests a WebTransport session. The quic-go/http3 server places the -// :protocol pseudo-header value in r.Proto for CONNECT requests. -func isWebTransportUpgrade(r *http.Request) bool { - return r.ProtoMajor == 3 && - r.Method == http.MethodConnect && - r.Proto == Protocol -} - -// Interface guards. -var ( - _ caddy.Provisioner = (*Handler)(nil) - _ caddyhttp.MiddlewareHandler = (*Handler)(nil) -) diff --git a/modules/caddyhttp/webtransport/handler_test.go b/modules/caddyhttp/webtransport/handler_test.go deleted file mode 100644 index 095e59cd595..00000000000 --- a/modules/caddyhttp/webtransport/handler_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2015 Matthew Holt and The Caddy Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package webtransport - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestIsWebTransportUpgrade(t *testing.T) { - cases := []struct { - name string - proto string - major int - meth string - want bool - }{ - {"h3 connect webtransport", "webtransport", 3, http.MethodConnect, true}, - {"h3 connect websocket", "websocket", 3, http.MethodConnect, false}, - {"h2 connect webtransport", "webtransport", 2, http.MethodConnect, false}, - {"h3 GET", "HTTP/3.0", 3, http.MethodGet, false}, - {"h3 connect missing :protocol", "HTTP/3.0", 3, http.MethodConnect, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - r := httptest.NewRequest(tc.meth, "/", nil) - r.ProtoMajor = tc.major - r.Proto = tc.proto - if got := isWebTransportUpgrade(r); got != tc.want { - t.Errorf("isWebTransportUpgrade = %v, want %v", got, tc.want) - } - }) - } -} - -// nextNoop is a stand-in for the next handler. It records whether it was -// invoked, used to assert that non-WebTransport requests pass through. -type nextNoop struct{ called bool } - -func (n *nextNoop) ServeHTTP(w http.ResponseWriter, r *http.Request) error { - n.called = true - return nil -} - -func TestHandler_PassesThroughNonWebTransportRequests(t *testing.T) { - h := &Handler{} - r := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - nx := &nextNoop{} - if err := h.ServeHTTP(w, r, nx); err != nil { - t.Fatalf("ServeHTTP returned error: %v", err) - } - if !nx.called { - t.Error("expected next handler to be invoked for non-WebTransport request") - } -} From bb8b3eeedf68be5f109870af31aa877f1b851503 Mon Sep 17 00:00:00 2001 From: tomholford Date: Thu, 23 Apr 2026 11:11:16 -0700 Subject: [PATCH 14/16] caddyhttp, reverseproxy: gate WebTransport behind enable_webtransport server flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- caddyconfig/httpcaddyfile/serveroptions.go | 8 ++ ...global_server_options_single.caddyfiletest | 2 + .../reverse_proxy_webtransport.caddyfiletest | 47 ------------ caddytest/integration/webtransport_test.go | 10 ++- modules/caddyhttp/reverseproxy/caddyfile.go | 9 --- .../caddyhttp/reverseproxy/httptransport.go | 20 ----- .../caddyhttp/reverseproxy/reverseproxy.go | 20 ++--- .../reverseproxy/webtransport_transport.go | 17 ++++- modules/caddyhttp/server.go | 76 ++++++++++++++----- modules/caddyhttp/server_test.go | 32 ++++++-- 10 files changed, 117 insertions(+), 124 deletions(-) delete mode 100644 caddytest/integration/caddyfile_adapt/reverse_proxy_webtransport.caddyfiletest diff --git a/caddyconfig/httpcaddyfile/serveroptions.go b/caddyconfig/httpcaddyfile/serveroptions.go index 1febf40974c..7e36f23e6a6 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 Protocols []string StrictSNIHost *bool TrustedProxiesRaw json.RawMessage @@ -218,6 +219,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 "log_credentials": if d.NextArg() { return nil, d.ArgErr() @@ -380,6 +387,7 @@ func applyServerOptions( server.KeepAliveCount = opts.KeepAliveCount server.MaxHeaderBytes = opts.MaxHeaderBytes server.EnableFullDuplex = opts.EnableFullDuplex + server.EnableWebTransport = opts.EnableWebTransport server.Protocols = opts.Protocols server.StrictSNIHost = opts.StrictSNIHost server.TrustedProxiesRaw = opts.TrustedProxiesRaw diff --git a/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest index 4991b308eb8..88e95740292 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 c55eb1945d3..00000000000 --- 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 ae146a3bbae..6efe17e94b2 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 92ba4839903..8716babe336 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 c2ed4e22aec..c65bd61859d 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -138,20 +138,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"` @@ -518,12 +504,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 38a874882d1..589ca8bc61b 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -261,12 +261,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 @@ -336,12 +330,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") @@ -504,8 +492,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 26e0a94de9c..105a5b707d1 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 247a9e28114..30e7a4e7f86 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"` + // Routes describes how this server will handle requests. // Routes are executed sequentially. First a route's matchers // are evaluated, then its grouping. If it matches and has @@ -670,7 +691,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) @@ -681,14 +704,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 { @@ -700,25 +726,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 } @@ -726,6 +759,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 d733f915169..b9af1fc4a60 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -501,13 +501,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") @@ -518,6 +519,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. @@ -534,7 +552,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() From d67425d4c3f61c844740337d37c1f6f6b9a35198 Mon Sep 17 00:00:00 2001 From: tomholford Date: Thu, 23 Apr 2026 11:12:42 -0700 Subject: [PATCH 15/16] caddyhttp: micro-benchmark HTTP/3 server construction with/without WebTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- modules/caddyhttp/server_bench_test.go | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 modules/caddyhttp/server_bench_test.go diff --git a/modules/caddyhttp/server_bench_test.go b/modules/caddyhttp/server_bench_test.go new file mode 100644 index 00000000000..fc1d5248d28 --- /dev/null +++ b/modules/caddyhttp/server_bench_test.go @@ -0,0 +1,55 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddyhttp + +import ( + "crypto/tls" + "testing" +) + +// BenchmarkBuildHTTP3Server_WebTransportOff measures the cost of +// constructing the HTTP/3 server for a deployment that does NOT opt in +// to WebTransport. This is the primary cost comparison steadytao asked +// about: any non-zero delta vs. pre-WebTransport Caddy would need to be +// justified, and the implementation is structured so the delta is +// exactly the branch check on EnableWebTransport. +// +// This benchmark does not exercise the per-stream dispatch cost +// (which is inside webtransport-go / quic-go and would require a full +// QUIC setup to measure in isolation). The meaningful regression guard +// is whether buildHTTP3Server with the flag off does the same work +// as on pre-PR master. +func BenchmarkBuildHTTP3Server_WebTransportOff(b *testing.B) { + s := &Server{} + tlsCfg := &tls.Config{} + b.ResetTimer() + for b.Loop() { + _ = s.buildHTTP3Server(tlsCfg) + } +} + +// BenchmarkBuildHTTP3Server_WebTransportOn measures the same +// construction with WebTransport enabled. The cost difference vs. the +// Off variant is the one-time setup webtransport.ConfigureHTTP3Server +// performs (AdditionalSettings, ConnContext, EnableDatagrams, etc.) +// plus setting EnableStreamResetPartialDelivery on the QUIC config. +func BenchmarkBuildHTTP3Server_WebTransportOn(b *testing.B) { + s := &Server{EnableWebTransport: true} + tlsCfg := &tls.Config{} + b.ResetTimer() + for b.Loop() { + _ = s.buildHTTP3Server(tlsCfg) + } +} From b9ead1973fa6de7e473723f506792aaa356eb1f0 Mon Sep 17 00:00:00 2001 From: tomholford Date: Thu, 23 Apr 2026 22:46:32 -0700 Subject: [PATCH 16/16] 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. --- .../caddyhttp/reverseproxy/reverseproxy.go | 56 +++--- .../reverseproxy/webtransport_transport.go | 159 +++++------------- 2 files changed, 75 insertions(+), 140 deletions(-) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 589ca8bc61b..5daee1e0c16 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -489,18 +489,6 @@ func (h *Handler) Cleanup() error { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) - // 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. - // 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) - } - // prepare the request for proxying; this is needed only once clonedReq, err := h.prepareRequest(r, repl) if err != nil { @@ -689,12 +677,13 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h return true, nil } - // if the roundtrip was successful, don't retry the request or - // ding the health status of the upstream (an error can still - // occur after the roundtrip if, for example, a response handler - // after the roundtrip returns an error) - if succ, ok := proxyErr.(roundtripSucceededError); ok { - return true, succ.error + // if the handler has already committed a client-visible response + // (e.g. a successful roundtrip whose handle_response route errored, + // or a WebTransport upgrade that flushed 200 OK and hijacked the + // stream), don't retry against another upstream and don't ding the + // upstream's health status + if term, ok := proxyErr.(terminalError); ok { + return true, term.error } // remember this failure (if enabled); response-based retries @@ -990,6 +979,18 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe server := req.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) shouldLogCredentials := server.Logs != nil && server.Logs.ShouldLogCredentials + // WebTransport: Extended CONNECT :protocol=webtransport can't flow + // through the normal HTTP round-trip — the session hosts many QUIC + // streams and datagrams that need bidirectional pumping. Swap + // RoundTrip for a small hijack helper. Upstream dial failures + // surface as DialError so the loop can retry across upstreams; + // pre-upgrade misconfig and post-upgrade failures return + // terminalError because the response is already committed to the + // client or no upstream can fix the condition. + if isWebTransportExtendedConnect(origReq) { + return h.webTransportHijack(rw, req, repl, di, server) + } + // Forward 1xx status codes, backported from https://github.com/golang/go/pull/53164 var ( roundTripMutex sync.Mutex @@ -1175,10 +1176,10 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe res.Body.Close() } - // wrap any route error in roundtripSucceededError so caller knows that - // the roundtrip was successful and to not retry + // wrap any route error in terminalError so the outer loop knows + // the response is committed and must not be retried if routeErr != nil { - return roundtripSucceededError{routeErr} + return terminalError{routeErr} } // we're done handling the response, and we don't want to @@ -1757,9 +1758,16 @@ func matcherSetHasExpressionMatcher(matcherSet caddyhttp.MatcherSet) bool { return false } -// roundtripSucceededError is an error type that is returned if the -// roundtrip succeeded, but an error occurred after-the-fact. -type roundtripSucceededError struct{ error } +// terminalError signals that the proxy loop must stop and the wrapped +// error must be propagated unchanged. It is emitted in any situation +// where the handler has committed enough client-visible state that +// retrying against another upstream would be unsafe — for example, a +// handle_response route that ran after a successful round-trip, or a +// WebTransport upgrade that already flushed 200 OK and hijacked the +// stream. The inner error may be nil to signal terminal success. +type terminalError struct{ error } + +func (e terminalError) Unwrap() error { return e.error } // retryableResponseError is returned when the upstream response matched // a retry_match entry, indicating the request should be retried with the diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index 105a5b707d1..64f24fbf312 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -20,13 +20,10 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" "github.com/quic-go/webtransport-go" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" @@ -54,157 +51,87 @@ func isWebTransportExtendedConnect(r *http.Request) bool { return r.ProtoMajor == 3 && r.Method == http.MethodConnect && r.Proto == webtransportProtocol } -// serveWebTransport handles a WebTransport Extended CONNECT: selects an -// upstream, dials the upstream-side session, upgrades the client-side -// session, and runs the session pump until both sides close. +// webTransportHijack runs inside reverseProxy in place of RoundTrip when +// the request is a WebTransport Extended CONNECT. The outer proxy loop +// has already resolved the upstream set, selected an upstream, filled +// DialInfo, published reverse_proxy.upstream.* placeholders, applied +// transport and user request-header ops, cloned the request, directed +// the request URL at the upstream, and bumped in-flight counters — so +// this function only does WT-specific plumbing: upstream WT dial, +// client upgrade, and session pumping. // -// The upstream is dialed *before* the client is upgraded so that a refused -// or unreachable upstream surfaces as a real 5xx on the client's Dial — -// not as a bare post-upgrade session close. There are no retries: WT -// sessions are long-lived and not idempotent. +// Error semantics match the outer loop's retry contract: +// - Pre-dial misconfiguration (WT not enabled on the server, writer +// stack unsupported, handler transport is not HTTP/3) returns +// terminalError — no upstream can fix these conditions. +// - Upstream dial failure returns DialError — safe to retry across +// upstreams because no client-visible bytes have been written. +// - Post-upgrade failures return terminalError because the 200 OK +// has been flushed and the stream is hijacked. // -// The outgoing CONNECT is prepared with the same Rewrite, hop-by-hop -// stripping, X-Forwarded-*/Via, transport- and user-configured header ops -// as the normal proxy path. Response-header ops (gated by `Require`, if -// configured) apply to the headers the client sees on the 200 OK. // Requests that reach this function are already known to be WebTransport; // callers should gate with isWebTransportExtendedConnect. -func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) error { - repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) - start := time.Now() - defer func() { - d := time.Since(start) - repl.Set("http.reverse_proxy.duration", d) - repl.Set("http.reverse_proxy.duration_ms", d.Seconds()*1e3) - }() - - srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) - if !ok || srv == nil { - return caddyhttp.Error(http.StatusInternalServerError, - errors.New("webtransport: no caddyhttp.Server in request context")) - } - wtServer, ok := srv.WebTransportServer().(*webtransport.Server) +func (h *Handler) webTransportHijack(rw http.ResponseWriter, req *http.Request, repl *caddy.Replacer, di DialInfo, server *caddyhttp.Server) error { + wtServer, ok := server.WebTransportServer().(*webtransport.Server) if !ok || wtServer == nil { - return caddyhttp.Error(http.StatusInternalServerError, - errors.New("webtransport: server has enable_webtransport=false or HTTP/3 is not enabled")) - } - - if h.LoadBalancing == nil || h.LoadBalancing.SelectionPolicy == nil { - return caddyhttp.Error(http.StatusInternalServerError, - errors.New("webtransport: load balancer is not configured")) - } - - // Resolve the candidate upstream set (static or dynamic) and select - // one. WT sessions are long-lived and not idempotent, so there are no - // retries; picking once matches how operators expect WT to behave. - upstreams := h.resolveUpstreams(r) - upstream := h.LoadBalancing.SelectionPolicy.Select(upstreams, r, w) - if upstream == nil { - return caddyhttp.Error(http.StatusBadGateway, errNoUpstream) - } - - // Resolve per-upstream placeholders (addresses may include them) and - // publish the {http.reverse_proxy.upstream.*} replacer values before - // we commit to upgrading — so any client-side failure logs downstream - // see the selected upstream too. - dialInfo, err := upstream.fillDialInfo(repl) - if err != nil { - return caddyhttp.Error(http.StatusInternalServerError, - fmt.Errorf("webtransport: making dial info: %w", err)) - } - setUpstreamReplacerVars(repl, upstream, dialInfo) - - // Prepare the outgoing request the same way normal proxying does — - // Rewrite, hop-by-hop stripping, X-Forwarded-*, Via, etc. — then apply - // transport and user header ops. prepareRequest's body-buffering and - // Early-Data paths are no-ops for a CONNECT request (empty body). - clonedReq, err := h.prepareRequest(r, repl) - if err != nil { - return caddyhttp.Error(http.StatusInternalServerError, - fmt.Errorf("webtransport: preparing request: %w", err)) - } - if h.transportHeaderOps != nil { - h.transportHeaderOps.ApplyToRequest(clonedReq) - } - if h.Headers != nil && h.Headers.Request != nil { - h.Headers.Request.ApplyToRequest(clonedReq) + return terminalError{caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: server has enable_webtransport=false or HTTP/3 is not enabled"))} } // Reach the naked http3 response writer so Upgrade's type assertions // succeed through Caddy's wrapper chain. Done before dialing so we // fail fast if the writer stack is unexpectedly incompatible. - naked, ok := caddyhttp.UnwrapResponseWriterAs[webtransportWriter](w) + naked, ok := caddyhttp.UnwrapResponseWriterAs[webtransportWriter](rw) if !ok { - return caddyhttp.Error(http.StatusInternalServerError, - errors.New("webtransport: response writer does not support WebTransport upgrade")) + return terminalError{caddyhttp.Error(http.StatusInternalServerError, + 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. + // speak HTTP/3 to dial the WT upstream. ht, ok := h.Transport.(*HTTPTransport) if !ok { - return caddyhttp.Error(http.StatusBadGateway, - errors.New("webtransport: requires the 'http' transport with versions [\"3\"]")) + return terminalError{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\"]")) + return terminalError{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. - upstreamURL := buildWebTransportUpstreamURL(dialInfo.Address, clonedReq) - upstreamResp, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, clonedReq.Header) + // an already-upgraded session closing immediately. DialError so the + // outer proxy loop can fail over to another upstream, same as any + // other dial failure. + upstreamURL := buildWebTransportUpstreamURL(di.Address, req) + upstreamResp, upstreamSess, err := dialUpstreamWebTransport(req.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, req.Header) if err != nil { - h.countFailure(upstream) - if c := h.logger.Check(zapcore.ErrorLevel, "webtransport upstream dial failed"); c != nil { - c.Write( - zap.String("upstream", upstreamURL), - zap.Error(err), - ) - } - return caddyhttp.Error(http.StatusBadGateway, - fmt.Errorf("webtransport upstream dial: %w", err)) + return DialError{fmt.Errorf("webtransport upstream dial: %w", err)} } defer upstreamResp.Body.Close() - // Response-header ops (gated by Require, if configured) are applied to - // the 200 OK the client will see. webtransport.Server.Upgrade flushes - // w.Header() along with the status, so setting these before Upgrade is - // sufficient. Matching against the upstream response mirrors the normal - // proxy path where upstream response == client response. + // Response-header ops (gated by Require, if configured) apply to the + // 200 OK the client will see. webtransport.Server.Upgrade flushes + // w.Header() along with the status, so setting these before Upgrade + // is sufficient. Matching against the upstream response mirrors the + // normal proxy path where upstream response == client response. if h.Headers != nil && h.Headers.Response != nil { if h.Headers.Response.Require == nil || h.Headers.Response.Require.Match(upstreamResp.StatusCode, upstreamResp.Header) { - h.Headers.Response.ApplyTo(w.Header(), repl) + h.Headers.Response.ApplyTo(rw.Header(), repl) } } - clientSess, err := wtServer.Upgrade(naked, r) + clientSess, err := wtServer.Upgrade(naked, req) if err != nil { _ = upstreamSess.CloseWithError(0, "client upgrade failed") - if c := h.logger.Check(zapcore.DebugLevel, "webtransport client upgrade failed"); c != nil { - c.Write(zap.Error(err)) - } - return caddyhttp.Error(http.StatusBadRequest, - fmt.Errorf("webtransport upgrade: %w", err)) + return terminalError{caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err))} } - // Track the session in the same upstream counters the normal proxy - // path maintains: Host.NumRequests drives MaxRequests gating and - // least-connections selection; the per-address in-flight counter - // feeds the admin API's upstream stats. - _ = dialInfo.Upstream.Host.countRequest(1) - incInFlightRequest(dialInfo.Address) - defer func() { - _ = dialInfo.Upstream.Host.countRequest(-1) - decInFlightRequest(dialInfo.Address) - }() - runWebTransportPump(clientSess, upstreamSess, h.logger) return nil }