diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go new file mode 100644 index 000000000..695a35147 --- /dev/null +++ b/caddytest/integration/webtransport_test.go @@ -0,0 +1,150 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration + +import ( + "context" + "crypto/tls" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +// TestWebTransport_EchoHandlerBidi spins up Caddy with an HTTP/3 listener +// that terminates a WebTransport session via the http.handlers.webtransport +// echo handler, then dials it with a real webtransport.Dialer and asserts +// an end-to-end bidirectional-stream round-trip. This exercises the +// serveH3AcceptLoop path (webtransport.Server.ServeQUICConn instead of +// http3.Server.ServeListener) and the UnwrapResponseWriterAs helper. +func TestWebTransport_EchoHandlerBidi(t *testing.T) { + if testing.Short() { + t.Skip() + } + tester := caddytest.NewTester(t) + tester.InitServer(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "srv0": { + "listen": [":9443"], + "protocols": ["h3"], + "routes": [ + { + "handle": [{"handler": "webtransport"}] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": { + "certificate_authorities": { + "local": {"install_trust": false} + } + } + } +}`, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test uses a local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + // Connect. Give the freshly-reconfigured server a brief window to be + // ready on the UDP port; retry a handful of times instead of racing. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + rsp *http.Response + sess *webtransport.Session + err error + ) + deadline := time.Now().Add(3 * time.Second) + for { + rsp, sess, err = dialer.Dial(ctx, "https://127.0.0.1:9443/", nil) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("webtransport dial failed after retries: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + defer sess.CloseWithError(0, "") + + if rsp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", rsp.StatusCode) + } + + // Open a bidirectional stream and send payload; expect it echoed back. + str, err := sess.OpenStreamSync(ctx) + if err != nil { + t.Fatalf("open stream: %v", err) + } + + const payload = "hello webtransport" + if _, err := io.WriteString(str, payload); err != nil { + t.Fatalf("write: %v", err) + } + if err := str.Close(); err != nil { + t.Fatalf("close send: %v", err) + } + + got, err := io.ReadAll(str) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != payload { + t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload) + } +} diff --git a/listeners.go b/listeners.go index 6031f98e4..4885a001a 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 d9d9b9f84..611520d59 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/server.go b/modules/caddyhttp/server.go index bf2b9994e..f71b93b9f 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -304,6 +304,7 @@ type Server struct { server *http.Server h3server *http3.Server + wtServer *webtransport.Server addresses []caddy.NetworkAddress trustedProxies IPRangeSource @@ -822,16 +823,36 @@ func (s *Server) serveHTTP3(addr caddy.NetworkAddress, tlsCfg *tls.Config) error // create HTTP/3 server if not done already if s.h3server == nil { s.h3server = s.buildHTTP3Server(tlsCfg) + s.wtServer = s.buildWebTransportServer() } s.quicListeners = append(s.quicListeners, h3ln) - //nolint:errcheck - go s.h3server.ServeListener(h3ln) + go s.serveH3AcceptLoop(h3ln) return nil } +// serveH3AcceptLoop accepts incoming QUIC connections from the HTTP/3 +// listener and dispatches each to the WebTransport-aware serve loop. +// webtransport.Server.ServeQUICConn wraps http3.Server: non-WebTransport +// streams are transparently forwarded to the normal HTTP/3 request path +// (at the cost of one varint peek per stream), so behavior for non-WT +// clients is unchanged. This replaces http3.Server.ServeListener's +// accept loop so webtransport.Server.Upgrade has the per-connection +// session manager state it requires. +func (s *Server) serveH3AcceptLoop(h3ln http3.QUICListener) { + for { + conn, err := h3ln.Accept(s.ctx) + if err != nil { + return + } + go func() { + _ = s.wtServer.ServeQUICConn(conn) + }() + } +} + // buildHTTP3Server constructs the http3.Server used by this server for HTTP/3. // WebTransport support is advertised in SETTINGS and the underlying *quic.Conn // is stashed in each request's context, which is a prerequisite for any @@ -854,6 +875,14 @@ func (s *Server) buildHTTP3Server(tlsCfg *tls.Config) *http3.Server { return h3 } +// buildWebTransportServer constructs the webtransport.Server that wraps +// the http3.Server. It owns the per-connection session state needed by +// webtransport.Server.Upgrade and demultiplexes WebTransport streams +// from normal HTTP/3 streams on each accepted QUIC connection. +func (s *Server) buildWebTransportServer() *webtransport.Server { + return &webtransport.Server{H3: s.h3server} +} + // configureServer applies/binds the registered callback functions to the server. func (s *Server) configureServer(server *http.Server) { for _, f := range s.connStateFuncs { @@ -1130,6 +1159,22 @@ func (s *Server) Listeners() []net.Listener { return s.listeners } // Name returns the server's name. func (s *Server) Name() string { return s.name } +// WebTransportServer returns the server's underlying WebTransport +// serving state as an opaque value. Modules that import +// github.com/quic-go/webtransport-go may type-assert it to +// *webtransport.Server. Returns nil if HTTP/3 is not in use. +// +// This is exposed as any so caddyhttp's public API does not leak the +// upstream webtransport-go type to packages that don't use it. +// +// EXPERIMENTAL: Subject to change or removal. +func (s *Server) WebTransportServer() any { + if s.wtServer == nil { + return nil + } + return s.wtServer +} + // PrepareRequest fills the request r for use in a Caddy HTTP handler chain. w and s can // be nil, but the handlers will lose response placeholders and access to the server. func PrepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter, s *Server) *http.Request { diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index 3c3341b78..323ab756c 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -1034,3 +1034,21 @@ func TestServer_BuildHTTP3ServerAppliesHandlerAndTLS(t *testing.T) { assert.Same(t, tlsCfg, h3.TLSConfig, "http3.Server.TLSConfig should be the config passed in") assert.Equal(t, 4096, h3.MaxHeaderBytes) } + +// TestServer_BuildWebTransportServerWrapsHTTP3Server asserts that the +// webtransport.Server wraps the correct http3.Server. +func TestServer_BuildWebTransportServerWrapsHTTP3Server(t *testing.T) { + s := &Server{} + s.h3server = s.buildHTTP3Server(&tls.Config{}) + wt := s.buildWebTransportServer() + + assert.NotNil(t, wt, "expected non-nil webtransport.Server") + assert.Same(t, s.h3server, wt.H3, "webtransport.Server should wrap this server's http3.Server") +} + +// TestServer_WebTransportServerNilUntilH3 asserts the accessor returns nil +// when HTTP/3 has not been configured. +func TestServer_WebTransportServerNilUntilH3(t *testing.T) { + s := &Server{} + assert.Nil(t, s.WebTransportServer(), "expected nil before HTTP/3 setup") +} diff --git a/modules/caddyhttp/standard/imports.go b/modules/caddyhttp/standard/imports.go index 6617941c6..ac5b2f3e2 100644 --- a/modules/caddyhttp/standard/imports.go +++ b/modules/caddyhttp/standard/imports.go @@ -22,4 +22,5 @@ import ( _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/tracing" + _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/webtransport" ) diff --git a/modules/caddyhttp/webtransport/handler.go b/modules/caddyhttp/webtransport/handler.go new file mode 100644 index 000000000..07c5e9f32 --- /dev/null +++ b/modules/caddyhttp/webtransport/handler.go @@ -0,0 +1,145 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package webtransport is an EXPERIMENTAL HTTP handler that terminates a +// WebTransport session (draft-ietf-webtrans-http3) on top of Caddy's HTTP/3 +// server and echoes bytes on each bidirectional stream. It exists mainly as +// a test upstream for the WebTransport reverse-proxy transport. Behavior +// and configuration are subject to change without notice. +package webtransport + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +func init() { + caddy.RegisterModule(Handler{}) +} + +// Protocol is the :protocol pseudo-header value sent by a client that wants +// to establish a WebTransport session over HTTP/3 Extended CONNECT. +const Protocol = "webtransport" + +// Writer is the interface satisfied by the naked HTTP/3 response writer. +// webtransport.Server.Upgrade performs these assertions itself; callers +// can use caddyhttp.UnwrapResponseWriterAs[Writer] to reach it past +// Caddy's ResponseWriter wrapping chain before calling Upgrade. +type Writer interface { + http.ResponseWriter + http3.Settingser + http3.HTTPStreamer +} + +// Handler terminates an incoming WebTransport session and echoes bytes on +// each bidirectional stream. EXPERIMENTAL: intended primarily as a test +// upstream for the WebTransport reverse-proxy transport. +type Handler struct { + logger *zap.Logger +} + +// CaddyModule returns the Caddy module information. +func (Handler) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.webtransport", + New: func() caddy.Module { return new(Handler) }, + } +} + +// Provision sets up the handler. +func (h *Handler) Provision(ctx caddy.Context) error { + h.logger = ctx.Logger() + return nil +} + +// ServeHTTP upgrades the request to a WebTransport session and echoes bytes +// on each accepted bidirectional stream. Non-WebTransport requests are +// passed through to the next handler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + if !isWebTransportUpgrade(r) { + return next.ServeHTTP(w, r) + } + + srv, ok := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) + if !ok || srv == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: caddyhttp.Server not in request context")) + } + wtServer, ok := srv.WebTransportServer().(*webtransport.Server) + if !ok || wtServer == nil { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: HTTP/3 is not enabled on this server")) + } + + naked, ok := caddyhttp.UnwrapResponseWriterAs[Writer](w) + if !ok { + return caddyhttp.Error(http.StatusInternalServerError, + errors.New("webtransport: underlying writer does not support WebTransport upgrade")) + } + + session, err := wtServer.Upgrade(naked, r) + if err != nil { + h.logger.Debug("webtransport upgrade failed", zap.Error(err)) + return caddyhttp.Error(http.StatusBadRequest, + fmt.Errorf("webtransport upgrade: %w", err)) + } + + h.echoStreams(session) + return nil +} + +// echoStreams accepts bidirectional streams on session until the session +// ends, and echoes bytes on each one. +func (h *Handler) echoStreams(session *webtransport.Session) { + ctx := session.Context() + for { + str, err := session.AcceptStream(ctx) + if err != nil { + return + } + go func(s *webtransport.Stream) { + // io.Copy from the stream back to itself echoes everything + // received on this bidirectional stream. When the peer closes + // its send side we observe EOF and close our send side too. + if _, err := io.Copy(s, s); err != nil && h.logger != nil { + h.logger.Debug("webtransport echo stream error", zap.Error(err)) + } + _ = s.Close() + }(str) + } +} + +// isWebTransportUpgrade reports whether r is an HTTP/3 Extended CONNECT that +// requests a WebTransport session. The quic-go/http3 server places the +// :protocol pseudo-header value in r.Proto for CONNECT requests. +func isWebTransportUpgrade(r *http.Request) bool { + return r.ProtoMajor == 3 && + r.Method == http.MethodConnect && + r.Proto == Protocol +} + +// Interface guards. +var ( + _ caddy.Provisioner = (*Handler)(nil) + _ caddyhttp.MiddlewareHandler = (*Handler)(nil) +) diff --git a/modules/caddyhttp/webtransport/handler_test.go b/modules/caddyhttp/webtransport/handler_test.go new file mode 100644 index 000000000..095e59cd5 --- /dev/null +++ b/modules/caddyhttp/webtransport/handler_test.go @@ -0,0 +1,69 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webtransport + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsWebTransportUpgrade(t *testing.T) { + cases := []struct { + name string + proto string + major int + meth string + want bool + }{ + {"h3 connect webtransport", "webtransport", 3, http.MethodConnect, true}, + {"h3 connect websocket", "websocket", 3, http.MethodConnect, false}, + {"h2 connect webtransport", "webtransport", 2, http.MethodConnect, false}, + {"h3 GET", "HTTP/3.0", 3, http.MethodGet, false}, + {"h3 connect missing :protocol", "HTTP/3.0", 3, http.MethodConnect, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(tc.meth, "/", nil) + r.ProtoMajor = tc.major + r.Proto = tc.proto + if got := isWebTransportUpgrade(r); got != tc.want { + t.Errorf("isWebTransportUpgrade = %v, want %v", got, tc.want) + } + }) + } +} + +// nextNoop is a stand-in for the next handler. It records whether it was +// invoked, used to assert that non-WebTransport requests pass through. +type nextNoop struct{ called bool } + +func (n *nextNoop) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + n.called = true + return nil +} + +func TestHandler_PassesThroughNonWebTransportRequests(t *testing.T) { + h := &Handler{} + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + nx := &nextNoop{} + if err := h.ServeHTTP(w, r, nx); err != nil { + t.Fatalf("ServeHTTP returned error: %v", err) + } + if !nx.called { + t.Error("expected next handler to be invoked for non-WebTransport request") + } +}