From 05a2f139c9dbe75cc000adca9da65c31efd1de32 Mon Sep 17 00:00:00 2001 From: tomholford Date: Wed, 22 Apr 2026 00:46:56 -0700 Subject: [PATCH] reverseproxy: wire WebTransport pump into ServeHTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the http reverse-proxy transport with a webtransport boolean that opts the upstream into WebTransport passthrough. Must be combined with versions: ["3"]; WebTransport rides on HTTP/3 exclusively. When enabled, Handler.ServeHTTP detects Extended CONNECT with :protocol=webtransport early — before any of the normal round-trip machinery — and branches to serveWebTransport, which: 1. Pulls the *webtransport.Server off caddyhttp.Server (via WebTransportServer()) and errors out cleanly if HTTP/3 isn't enabled on the frontend. 2. Picks a single upstream through the configured load-balancer. No retries: a failed dial closes the client session and returns. 3. Walks the response-writer Unwrap() chain to reach the raw http3 writer and calls webtransport.Server.Upgrade to terminate the incoming session. 4. Uses dialUpstreamWebTransport to open a session to the selected upstream, forwarding request headers on the Extended CONNECT. 5. Runs runWebTransportPump between the two sessions and blocks until both close. The transport's wtTLSConfig is built at Provision time from the existing TLS config (same path h3Transport already uses) and reused for every session. Tests: adds TestWebTransport_ReverseProxyEndToEnd which spins up a single Caddy instance with two HTTP/3 servers — one proxy on :9443, one terminating echo upstream on :9444 — and drives a real webtransport.Dialer through the proxy to assert end-to-end bidirectional-stream echo. --- caddytest/integration/webtransport_test.go | 138 ++++++++++++++++++ .../caddyhttp/reverseproxy/httptransport.go | 20 +++ .../caddyhttp/reverseproxy/reverseproxy.go | 20 +++ .../reverseproxy/webtransport_transport.go | 90 ++++++++++++ 4 files changed, 268 insertions(+) diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go index 695a35147..17294e697 100644 --- a/caddytest/integration/webtransport_test.go +++ b/caddytest/integration/webtransport_test.go @@ -148,3 +148,141 @@ func TestWebTransport_EchoHandlerBidi(t *testing.T) { t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload) } } + +// TestWebTransport_ReverseProxyEndToEnd spins up a single Caddy instance +// running two HTTP/3 servers: one on :9443 acting as the WebTransport +// reverse proxy, and one on :9444 acting as the terminating echo +// upstream. A real webtransport.Dialer dials the proxy; the pump should +// bridge to the upstream so bytes written on a bidi stream are echoed. +func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip() + } + tester := caddytest.NewTester(t) + tester.InitServer(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":9443"], + "protocols": ["h3"], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "webtransport": true, + "tls": {"insecure_skip_verify": true} + }, + "upstreams": [{"dial": "127.0.0.1:9444"}] + } + ] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + }, + "upstream": { + "listen": [":9444"], + "protocols": ["h3"], + "routes": [ + {"handle": [{"handler": "webtransport"}]} + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": { + "certificate_authorities": { + "local": {"install_trust": false} + } + } + } +}`, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Retry briefly while both listeners finish binding. + var ( + sess *webtransport.Session + rsp *http.Response + err error + ) + deadline := time.Now().Add(3 * time.Second) + for { + rsp, sess, err = dialer.Dial(ctx, "https://127.0.0.1:9443/", nil) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("webtransport dial through proxy failed after retries: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + defer sess.CloseWithError(0, "") + + if rsp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", rsp.StatusCode) + } + + str, err := sess.OpenStreamSync(ctx) + if err != nil { + t.Fatalf("open stream through proxy: %v", err) + } + const payload = "reverse-proxied via the pump" + if _, err := io.WriteString(str, payload); err != nil { + t.Fatalf("write: %v", err) + } + if err := str.Close(); err != nil { + t.Fatalf("close write: %v", err) + } + got, err := io.ReadAll(str) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != payload { + t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload) + } +} diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go index c65bd6185..c2ed4e22a 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -138,6 +138,20 @@ type HTTPTransport struct { // to change or removal while experimental. Versions []string `json:"versions,omitempty"` + // WebTransport enables reverse-proxying of WebTransport sessions + // (https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) to + // the upstream. Requires Versions to be exactly ["3"]. When + // enabled, the frontend Caddy server must itself be serving HTTP/3, + // and any Extended CONNECT request with :protocol=webtransport will + // have its streams and datagrams pumped between the client and the + // upstream — bypassing the normal HTTP round-trip path. + // + // EXPERIMENTAL: subject to change or removal. The upstream + // WebTransport protocol draft is still evolving; this lands with + // whatever draft version the webtransport-go library supports at + // build time. + WebTransport bool `json:"webtransport,omitempty"` + // Specify the address to bind to when connecting to an upstream. In other words, // it is the address the upstream sees as the remote address. LocalAddress string `json:"local_address,omitempty"` @@ -504,6 +518,12 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e return nil, fmt.Errorf("if HTTP/3 is enabled to the upstream, no other HTTP versions are supported") } + // WebTransport rides on HTTP/3 exclusively and reuses the TLS client + // config built for h3Transport above. + if h.WebTransport && !(len(h.Versions) == 1 && h.Versions[0] == "3") { + return nil, fmt.Errorf("webtransport requires versions to be exactly [\"3\"]") + } + // if h2/c is enabled, configure it explicitly if slices.Contains(h.Versions, "2") || slices.Contains(h.Versions, "h2c") { if err := http2.ConfigureTransport(rt); err != nil { diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index cefe645ee..a2e52d5e0 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -224,6 +224,12 @@ type Handler struct { CB CircuitBreaker `json:"-"` DynamicUpstreams UpstreamSource `json:"-"` + // webtransportEnabled is set at Provision time to true iff + // Transport is *HTTPTransport with WebTransport enabled. Checked on + // the ServeHTTP hot path so non-WT transports skip the type + // assertion on every request. + webtransportEnabled bool + // transportHeaderOps is a set of header operations provided // by the transport at provision time, if the transport // implements TransportHeaderOpsProvider. These ops are @@ -293,6 +299,12 @@ func (h *Handler) Provision(ctx caddy.Context) error { h.ResponseBuffers = respBuffers } } + + // Cache WebTransport enablement so ServeHTTP can short-circuit + // the per-request type assertion on non-WT paths. + if ht, ok := h.Transport.(*HTTPTransport); ok { + h.webtransportEnabled = ht.WebTransport + } } if h.LoadBalancing != nil && h.LoadBalancing.SelectionPolicyRaw != nil { mod, err := ctx.LoadModule(h.LoadBalancing, "SelectionPolicyRaw") @@ -452,6 +464,14 @@ func (h *Handler) Cleanup() error { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + // WebTransport: HTTP/3 Extended CONNECT with :protocol=webtransport + // can't flow through the normal HTTP round-trip — the session hosts + // many QUIC streams and datagrams that need bidirectional pumping. + // Branch out early before anything else touches the request. + if h.webtransportEnabled && isWebTransportExtendedConnect(r) { + return h.serveWebTransport(w, r) + } + // prepare the request for proxying; this is needed only once clonedReq, err := h.prepareRequest(r, repl) if err != nil { diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index c3bb6f9ac..8f564ac00 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -17,12 +17,102 @@ package reverseproxy import ( "context" "crypto/tls" + "errors" + "fmt" "net/http" "github.com/quic-go/quic-go" "github.com/quic-go/webtransport-go" + "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + caddywt "github.com/caddyserver/caddy/v2/modules/caddyhttp/webtransport" ) +// isWebTransportExtendedConnect reports whether r is an HTTP/3 Extended +// CONNECT that requests a WebTransport session. Does not check whether +// WebTransport proxying is configured; callers gate on Handler state. +func isWebTransportExtendedConnect(r *http.Request) bool { + return r.ProtoMajor == 3 && r.Method == http.MethodConnect && r.Proto == caddywt.Protocol +} + +// serveWebTransport handles a WebTransport Extended CONNECT: selects an +// upstream, upgrades the client-side session, dials the upstream-side +// session, and runs the session pump until both sides close. +// +// Unlike the regular HTTP proxy path, there are no retries: a failed +// dial closes the client's session and returns (so the handler chain +// can finish). Requests that reach this function are already known to +// be WebTransport; callers should gate with isWebTransportProxyRequest. +func (h *Handler) serveWebTransport(w http.ResponseWriter, r *http.Request) error { + srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) + if !ok || srv == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: no caddyhttp.Server in request context")) + } + wtServer, ok := srv.WebTransportServer().(*webtransport.Server) + if !ok || wtServer == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: HTTP/3 is not enabled on this server; WebTransport requires H3")) + } + + // Select an upstream via the configured LB policy. No retries. + upstreams := h.Upstreams + if h.LoadBalancing == nil || h.LoadBalancing.SelectionPolicy == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: load balancer is not configured")) + } + upstream := h.LoadBalancing.SelectionPolicy.Select(upstreams, r, w) + if upstream == nil { + return caddyhttp.Error(http.StatusBadGateway, + errors.New("webtransport: no upstream available")) + } + + // Reach the naked http3 response writer so Upgrade's type assertions + // succeed through Caddy's wrapper chain. + naked, ok := caddyhttp.UnwrapResponseWriterAs[caddywt.Writer](w) + if !ok { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: response writer does not support WebTransport upgrade")) + } + + clientSess, err := wtServer.Upgrade(naked, r) + if err != nil { + h.logger.Debug("webtransport client upgrade failed", zap.Error(err)) + return caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err)) + } + + ht := h.Transport.(*HTTPTransport) + upstreamURL := buildWebTransportUpstreamURL(upstream.Dial, r) + _, upstreamSess, err := dialUpstreamWebTransport(r.Context(), ht.h3Transport.TLSClientConfig, upstreamURL, r.Header.Clone()) + if err != nil { + h.logger.Error("webtransport upstream dial failed", + zap.String("upstream", upstreamURL), + zap.Error(err)) + _ = clientSess.CloseWithError(0, "upstream dial failed") + return nil + } + + runWebTransportPump(clientSess, upstreamSess, h.logger) + return nil +} + +// buildWebTransportUpstreamURL constructs an https:// URL for the dialer +// using the upstream's Dial address (host:port) and the request's path +// + raw query. Scheme is fixed to https since WebTransport-over-H3 +// requires TLS. +func buildWebTransportUpstreamURL(dial string, r *http.Request) string { + path := r.URL.Path + if path == "" { + path = "/" + } + if r.URL.RawQuery != "" { + return fmt.Sprintf("https://%s%s?%s", dial, path, r.URL.RawQuery) + } + return fmt.Sprintf("https://%s%s", dial, path) +} + // dialUpstreamWebTransport opens a WebTransport session to the upstream at // urlStr (an https URL), forwarding reqHdr as headers on the Extended // CONNECT request. The returned session is owned by the caller and must be