reverseproxy: wire WebTransport pump into ServeHTTP

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.
This commit is contained in:
tomholford 2026-04-22 00:46:56 -07:00
parent 75b35fd2b0
commit 05a2f139c9
4 changed files with 268 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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