diff --git a/caddyconfig/httpcaddyfile/serveroptions.go b/caddyconfig/httpcaddyfile/serveroptions.go index 005cf81d35f..fd122e0d2d6 100644 --- a/caddyconfig/httpcaddyfile/serveroptions.go +++ b/caddyconfig/httpcaddyfile/serveroptions.go @@ -48,6 +48,7 @@ type serverOptions struct { KeepAliveCount int MaxHeaderBytes int EnableFullDuplex bool + EnableWebTransport bool ExpectedUnderscoreHeaders []string Protocols []string StrictSNIHost *bool @@ -219,6 +220,12 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) { } serverOpts.EnableFullDuplex = true + case "enable_webtransport": + if d.NextArg() { + return nil, d.ArgErr() + } + serverOpts.EnableWebTransport = true + case "expected_underscore_headers": args := d.RemainingArgs() if len(args) == 0 { @@ -388,6 +395,7 @@ func applyServerOptions( server.KeepAliveCount = opts.KeepAliveCount server.MaxHeaderBytes = opts.MaxHeaderBytes server.EnableFullDuplex = opts.EnableFullDuplex + server.EnableWebTransport = opts.EnableWebTransport server.ExpectedUnderscoreHeaders = opts.ExpectedUnderscoreHeaders server.Protocols = opts.Protocols server.StrictSNIHost = opts.StrictSNIHost diff --git a/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest index 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/webtransport_echo_test.go b/caddytest/integration/webtransport_echo_test.go new file mode 100644 index 00000000000..b06af4a978d --- /dev/null +++ b/caddytest/integration/webtransport_echo_test.go @@ -0,0 +1,203 @@ +// 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 values are the :protocol pseudo-header tokens for +// an HTTP/3 Extended CONNECT that establishes a WebTransport session. draft-15 +// (webtransport-go v0.11.0+) uses "webtransport-h3"; older draft clients use +// the legacy "webtransport" token. +const ( + webtransportEchoProtocol = "webtransport" + webtransportEchoProtocolDraft15 = "webtransport-h3" +) + +// 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 || r.Proto == webtransportEchoProtocolDraft15) +} + +// 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 webtransport-h3", "webtransport-h3", 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/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go new file mode 100644 index 00000000000..470fc9d0d07 --- /dev/null +++ b/caddytest/integration/webtransport_test.go @@ -0,0 +1,902 @@ +// 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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "fmt" + "io" + "math/big" + "net" + "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"], + "enable_webtransport": true, + "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) + } +} + +// 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"], + "enable_webtransport": true, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "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"], + "enable_webtransport": true, + "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) + } +} + +// 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"], + "enable_webtransport": true, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "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") + } +} + +// TestWebTransport_ReverseProxyExpandsSNIPlaceholder proves that a +// placeholder in the transport's tls_server_name (here driven off a request +// header) is expanded per session before the WebTransport upstream dial, so +// the upstream observes the resolved SNI rather than the literal "{...}" +// string. The normal HTTP/3 path handles this via a custom h3Transport.Dial +// hook (#7737); the WebTransport path dials through its own Dialer and so +// must expand the placeholder itself. +func TestWebTransport_ReverseProxyExpandsSNIPlaceholder(t *testing.T) { + if testing.Short() { + t.Skip() + } + + // Capture the SNI the upstream observed on its TLS handshake. + gotSNI := make(chan string, 1) + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) { + sni := "" + if r.TLS != nil { + sni = r.TLS.ServerName + } + select { + case gotSNI <- sni: + 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"], + "enable_webtransport": true, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "tls": { + "insecure_skip_verify": true, + "server_name": "{http.request.header.X-SNI}" + } + }, + "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() + + // The client sends X-SNI on the Extended CONNECT; the proxy's + // {http.request.header.X-SNI} placeholder resolves to this value and + // becomes the SNI on the upstream dial. + reqHdr := http.Header{"X-Sni": []string{"sni.example.com"}} + + var sess *webtransport.Session + deadline := time.Now().Add(3 * time.Second) + for { + _, s, err := dialer.Dial(ctx, "https://127.0.0.1:9443/", reqHdr) + 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 got := <-gotSNI: + if got != "sni.example.com" { + t.Errorf("upstream observed SNI %q; want the expanded placeholder value \"sni.example.com\"", got) + } + case <-time.After(3 * time.Second): + t.Fatal("upstream did not observe a WebTransport session in time") + } +} + +// 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"], + "enable_webtransport": true, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "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) + } +} + +// 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"], + "enable_webtransport": true, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "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. +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/go.mod b/go.mod index 4c72169695a..88371fd0f0c 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,8 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 github.com/mholt/acmez/v3 v3.1.6 github.com/prometheus/client_golang v1.23.2 - github.com/quic-go/quic-go v0.59.1 + github.com/quic-go/quic-go v0.60.0 + github.com/quic-go/webtransport-go v0.11.0 github.com/smallstep/certificates v0.30.2 github.com/smallstep/nosql v0.8.0 github.com/smallstep/truststore v0.13.0 @@ -64,6 +65,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.5 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/go.sum b/go.sum index 37993678c0a..408d9ee12bf 100644 --- a/go.sum +++ b/go.sum @@ -140,6 +140,8 @@ github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d h1:Mw8vvAx2b5bjIrQIJaVTO25vJY5DMBUO/hJGNLIl+6g= github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d/go.mod h1:9qyjDljBPOWyWCGz7vo3Ek7cdnoG/DVk0Ucle7gWVS8= +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= @@ -285,10 +287,14 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= 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.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= -github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/quic-go/webtransport-go v0.11.0 h1:3afiZq7MHv3gmKCbMwZ8D5M1u0y/1RdONN9KlWp32J0= +github.com/quic-go/webtransport-go v0.11.0/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA= 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/listeners.go b/listeners.go index 6031f98e495..4885a001a19 100644 --- a/listeners.go +++ b/listeners.go @@ -482,6 +482,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 d9d9b9f8425..611520d599a 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -768,6 +768,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/responsewriter.go b/modules/caddyhttp/responsewriter.go index 904c30c0352..9727e7be40f 100644 --- a/modules/caddyhttp/responsewriter.go +++ b/modules/caddyhttp/responsewriter.go @@ -58,6 +58,28 @@ 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 { + if t, ok := any(w).(T); ok { + return t, true + } + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + if !ok { + return zero, false + } + w = u.Unwrap() + } +} + // 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..f11af9b6322 100644 --- a/modules/caddyhttp/responsewriter_test.go +++ b/modules/caddyhttp/responsewriter_test.go @@ -169,3 +169,76 @@ 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") + } +} + diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 81a1ef1eb3c..abbe43cb627 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -711,12 +711,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 @@ -1012,6 +1013,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 @@ -1197,10 +1210,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 @@ -1807,9 +1820,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_pump.go b/modules/caddyhttp/reverseproxy/webtransport_pump.go new file mode 100644 index 00000000000..e6c1627d134 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/webtransport_pump.go @@ -0,0 +1,224 @@ +// 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. +// logger must be non-nil; callers pass either h.logger or zap.NewNop(). +// +// 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) { + 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 + + // Bidirectional streams in both directions. + wg.Go(func() { p.acceptBidi(p.client, p.upstream, p.closeUpstream) }) + wg.Go(func() { p.acceptBidi(p.upstream, p.client, p.closeClient) }) + + // Unidirectional streams in both directions. + wg.Go(func() { p.acceptUni(p.client, p.upstream, p.closeUpstream) }) + wg.Go(func() { p.acceptUni(p.upstream, p.client, p.closeClient) }) + + // Datagrams in both directions. + wg.Go(func() { p.pumpDatagrams(p.client, p.upstream, p.closeUpstream) }) + wg.Go(func() { 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.Go(func() { + if _, err := io.Copy(b, a); err != nil && !isExpectedEOF(err) { + p.logger.Debug("webtransport bidi splice a->b", zap.Error(err)) + } + _ = b.Close() + }) + wg.Go(func() { + 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) + } +} diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go new file mode 100644 index 00000000000..6a42fb46d14 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -0,0 +1,199 @@ +// 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" + "net/http" + "strings" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +// webtransportProtocol values are the :protocol pseudo-header tokens sent +// by a client that wants to establish a WebTransport session over an HTTP/3 +// Extended CONNECT. draft-15 (webtransport-go v0.11.0+) uses "webtransport-h3"; +// older draft clients use the legacy "webtransport" token, which the +// webtransport-go server still accepts. Detect both. +const ( + webtransportProtocol = "webtransport" + webtransportProtocolDraft15 = "webtransport-h3" +) + +// 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 == webtransportProtocol || r.Proto == webtransportProtocolDraft15) +} + +// webTransportTransport is implemented by reverse-proxy transports that +// can carry a WebTransport upstream dial. The handler type-asserts on +// this interface (rather than a concrete *HTTPTransport) so a future +// transport gaining HTTP/3 support can opt in. A nil return means the +// transport is configured but HTTP/3 is not enabled on it. +type webTransportTransport interface { + webTransportTLSConfig() *tls.Config +} + +// webTransportTLSConfig satisfies the webTransportTransport capability +// interface. Returns the HTTP/3 TLS config when HTTP/3 is enabled on +// this transport, or nil otherwise (signaling that this transport +// cannot dial WebTransport). +// +// EXPERIMENTAL: shape may change with the WebTransport feature. +func (h *HTTPTransport) webTransportTLSConfig() *tls.Config { + if h.h3Transport == nil { + return nil + } + return h.h3Transport.TLSClientConfig +} + +// 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. +// +// 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. +// +// Requests that reach this function are already known to be WebTransport; +// callers should gate with isWebTransportExtendedConnect. +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 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](rw) + if !ok { + 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 the WT upstream. + wtt, ok := h.Transport.(webTransportTransport) + if !ok { + return terminalError{caddyhttp.Error(http.StatusBadGateway, + errors.New("webtransport: requires a WebTransport-capable transport (the 'http' transport with versions [\"3\"])"))} + } + tlsCfg := wtt.webTransportTLSConfig() + if tlsCfg == nil { + return terminalError{caddyhttp.Error(http.StatusBadGateway, + errors.New("webtransport: transport does not include HTTP/3; set versions to [\"3\"]"))} + } + + // Expand SNI placeholders (e.g. tls_server_name {http.request.host}) per + // session. The normal HTTP/3 path does this via a custom h3Transport.Dial + // hook (#7737); the WebTransport path dials through its own Dialer and + // bypasses that hook, so expand here. Clone first — the transport's TLS + // config is shared across sessions and must not be mutated in place. + if strings.Contains(tlsCfg.ServerName, "{") { + tlsCfg = tlsCfg.Clone() + tlsCfg.ServerName = repl.ReplaceAll(tlsCfg.ServerName, "") + } + + // 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. DialError so the + // outer proxy loop can fail over to another upstream, same as any + // other dial failure. + // + // WebTransport over HTTP/3 always uses https; RequestURI preserves + // the request's encoded path and query. + upstreamURL := "https://" + di.Address + req.URL.RequestURI() + upstreamResp, upstreamSess, err := dialUpstreamWebTransport(req.Context(), tlsCfg, upstreamURL, req.Header) + if err != nil { + return DialError{fmt.Errorf("webtransport upstream dial: %w", err)} + } + defer upstreamResp.Body.Close() + + // 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(rw.Header(), repl) + } + } + + clientSess, err := wtServer.Upgrade(naked, req) + if err != nil { + _ = upstreamSess.CloseWithError(0, "client upgrade failed") + return terminalError{caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err))} + } + + runWebTransportPump(clientSess, upstreamSess, h.logger) + return nil +} + +// 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 +} + diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 391a0f41a44..1319c8e1c99 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" @@ -124,6 +125,27 @@ type Server struct { // TODO: This is an EXPERIMENTAL feature. Subject to change or removal. EnableFullDuplex bool `json:"enable_full_duplex,omitempty"` + // EnableWebTransport enables WebTransport (draft-ietf-webtrans-http3) + // on this server's HTTP/3 listener. When true, the HTTP/3 server + // advertises WebTransport in SETTINGS, enables HTTP/3 DATAGRAMs and + // QUIC stream-reset partial delivery, and dispatches each QUIC + // connection through webtransport.Server.ServeQUICConn so that + // handlers can upgrade Extended CONNECT requests with + // `:protocol=webtransport`. When false, the HTTP/3 path is + // bit-for-bit identical to the pre-WebTransport behavior: clients + // that don't speak WebTransport see nothing new. + // + // This is a server-level opt-in that matches how other + // protocol-level features are enabled (see `protocols`, + // `allow_0rtt`, `enable_full_duplex`). Handlers that want to proxy + // or terminate WebTransport sessions auto-detect the request shape + // once this is on — no per-handler configuration is needed. + // + // Requires HTTP/3. + // + // TODO: This is an EXPERIMENTAL feature. Subject to change or removal. + EnableWebTransport bool `json:"enable_webtransport,omitempty"` + // A list of header field names containing underscores that should // be preserved instead of being dropped. By default, Caddy drops // ALL headers with underscores to prevent ambiguity with @@ -303,6 +325,7 @@ type Server struct { server *http.Server h3server *http3.Server + wtServer *webtransport.Server addresses []caddy.NetworkAddress trustedProxies IPRangeSource @@ -820,26 +843,80 @@ 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) + if s.EnableWebTransport { + 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. 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 { + return + } + go func() { + _ = s.wtServer.ServeQUICConn(conn) + }() + } +} + +// 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: qc, + IdleTimeout: time.Duration(s.IdleTimeout), + } + if s.EnableWebTransport { + webtransport.ConfigureHTTP3Server(h3) + } + 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. +// Only constructed when EnableWebTransport is true. +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 { @@ -1116,6 +1193,23 @@ 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 WebTransport is not enabled +// on this server (EnableWebTransport=false or 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_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) + } +} diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index 454794a84fe..1fccd7e5669 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "crypto/tls" "io" "net" "net/http" @@ -1003,3 +1004,69 @@ func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) assert.True(t, trusted) assert.Equal(t, clientIP, "90.100.110.120") } + +// 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{EnableWebTransport: true} + 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_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. +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) +} + +// TestServer_BuildWebTransportServerWrapsHTTP3Server asserts that the +// webtransport.Server wraps the correct http3.Server. +func TestServer_BuildWebTransportServerWrapsHTTP3Server(t *testing.T) { + s := &Server{EnableWebTransport: true} + 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") +}