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