From 8d86214d1cd8a812ac61b3b277de4e09ebf58f8f Mon Sep 17 00:00:00 2001 From: tomholford Date: Thu, 23 Apr 2026 11:06:16 -0700 Subject: [PATCH] caddyhttp: move WebTransport echo handler to integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Francis pointed out in review of #7669 that the echo handler — which exists solely as a test upstream for the WebTransport reverse-proxy tests — should not be a full-fledged module registered in every Caddy binary. Mirroring the mockdns_test.go pattern, move it into a _test.go file under caddytest/integration/. The module ID http.handlers.webtransport is now registered only when the integration test binary is built, which is when caddytest/integration/webtransport_test.go references it by ID string in its JSON configs. Production Caddy builds no longer include it. Changes: * New file: caddytest/integration/webtransport_echo_test.go — contains the WebTransportEcho handler, its types and interface guards, the isWebTransportEchoUpgrade helper, and the unit tests that used to live in the deleted package's handler_test.go. * Deleted: modules/caddyhttp/webtransport/ (handler.go + handler_test.go). * Removed the blank import from modules/caddyhttp/standard/imports.go. The Protocol const and Writer interface that this package used to export were inlined into reverseproxy's own files in a preceding commit, so nothing else depends on the deleted package. --- .../integration/webtransport_echo_test.go | 197 ++++++++++++++++++ modules/caddyhttp/standard/imports.go | 1 - modules/caddyhttp/webtransport/handler.go | 145 ------------- .../caddyhttp/webtransport/handler_test.go | 69 ------ 4 files changed, 197 insertions(+), 215 deletions(-) create mode 100644 caddytest/integration/webtransport_echo_test.go delete mode 100644 modules/caddyhttp/webtransport/handler.go delete mode 100644 modules/caddyhttp/webtransport/handler_test.go diff --git a/caddytest/integration/webtransport_echo_test.go b/caddytest/integration/webtransport_echo_test.go new file mode 100644 index 000000000..58a66244e --- /dev/null +++ b/caddytest/integration/webtransport_echo_test.go @@ -0,0 +1,197 @@ +// 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 is the :protocol pseudo-header value for an +// HTTP/3 Extended CONNECT that establishes a WebTransport session. +const webtransportEchoProtocol = "webtransport" + +// 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 +} + +// 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 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/modules/caddyhttp/standard/imports.go b/modules/caddyhttp/standard/imports.go index ac5b2f3e2..6617941c6 100644 --- a/modules/caddyhttp/standard/imports.go +++ b/modules/caddyhttp/standard/imports.go @@ -22,5 +22,4 @@ 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 deleted file mode 100644 index 07c5e9f32..000000000 --- a/modules/caddyhttp/webtransport/handler.go +++ /dev/null @@ -1,145 +0,0 @@ -// 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 deleted file mode 100644 index 095e59cd5..000000000 --- a/modules/caddyhttp/webtransport/handler_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// 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") - } -}