Files
Chris Oliver 553b62c3f3 reverseproxy: send every close message before closing tunnel connections (#8371)
Closing one side of a tunnel ends its copy loops, which close the other
side. closeConnectionsLocked sent each close message and closed that
connection before moving on, in map order, so when the client side came
first the upstream was often closed before its close message went out.
Send every close message in the batch first, then close the connections.

The second close then regularly finds the connection already closed.
Treat net.ErrClosed as expected instead of returning it, so reloads with
open websockets no longer log "use of closed network connection" at
ERROR level.

The upstream close message test goes back to running the real cleanup,
which it can now do reliably.
2026-11-08 11:05:28 +00:00

1207 lines
37 KiB
Go

package reverseproxy
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func TestHandlerCopyResponse(t *testing.T) {
h := Handler{}
testdata := []string{
"",
strings.Repeat("a", defaultBufferSize),
strings.Repeat("123456789 123456789 123456789 12", 3000),
}
dst := bytes.NewBuffer(nil)
recorder := httptest.NewRecorder()
recorder.Body = dst
for _, d := range testdata {
src := bytes.NewBuffer([]byte(d))
dst.Reset()
err := h.copyResponse(recorder, src, 0, caddy.Log())
if err != nil {
t.Errorf("failed with error: %v", err)
}
out := dst.String()
if out != d {
t.Errorf("bad read: got %q", out)
}
}
}
func TestSwitchProtocolCopierBufferSize(t *testing.T) {
var wg sync.WaitGroup
var errc = make(chan error, 1)
var dst bytes.Buffer
var sent, received int64
copier := switchProtocolCopier{
user: nopReadWriteCloser{Reader: strings.NewReader("hello")},
backend: nopReadWriteCloser{Writer: &dst},
wg: &wg,
bufferSize: 7,
sent: &sent,
received: &received,
}
buf := copier.buffer()
if got := len(buf); got != 7 {
t.Fatalf("buffer len = %d, want 7", got)
}
wg.Add(1)
go copier.copyToBackend(errc)
wg.Wait()
// The destination cannot be half-closed, so a clean copy reports errCopyDone.
if err := <-errc; !errors.Is(err, errCopyDone) {
t.Fatalf("copyToBackend() error = %v", err)
}
if got := dst.String(); got != "hello" {
t.Fatalf("copied data = %q, want %q", got, "hello")
}
}
// A clean EOF in one direction must be propagated to the destination as a
// write-half close instead of tearing the whole tunnel down.
// See https://github.com/caddyserver/caddy/issues/8026.
func TestSwitchProtocolCopierHalfClose(t *testing.T) {
closeWriteErr := errors.New("no half-close")
for _, tc := range []struct {
name string
dst io.ReadWriteCloser
wantErr error // nil means the half-close was propagated
}{
{
name: "destination supports CloseWrite",
dst: &closeWriteRecorder{},
wantErr: nil,
},
{
name: "destination does not support CloseWrite",
dst: nopReadWriteCloser{Writer: io.Discard},
wantErr: errCopyDone,
},
{
name: "CloseWrite fails",
dst: &closeWriteRecorder{err: closeWriteErr},
wantErr: closeWriteErr,
},
} {
t.Run(tc.name, func(t *testing.T) {
var wg sync.WaitGroup
var sent, received int64
errc := make(chan error, 1)
copier := switchProtocolCopier{
user: nopReadWriteCloser{Reader: strings.NewReader("hello")},
backend: tc.dst,
wg: &wg,
sent: &sent,
received: &received,
}
wg.Add(1)
go copier.copyToBackend(errc)
wg.Wait()
if err := <-errc; !errors.Is(err, tc.wantErr) {
t.Fatalf("copyToBackend() error = %v, want %v", err, tc.wantErr)
}
if rec, ok := tc.dst.(*closeWriteRecorder); ok && !rec.called {
t.Fatal("CloseWrite() was not called on a destination that supports it")
}
})
}
}
// A copy error must be reported as-is and must not be mistaken for a clean
// half-close, so the tunnel is still torn down immediately.
func TestSwitchProtocolCopierErrorIsNotHalfClose(t *testing.T) {
var wg sync.WaitGroup
var sent, received int64
errc := make(chan error, 1)
wantErr := errors.New("write failed")
dst := &closeWriteRecorder{writeErr: wantErr}
copier := switchProtocolCopier{
user: nopReadWriteCloser{Reader: strings.NewReader("hello")},
backend: dst,
wg: &wg,
sent: &sent,
received: &received,
}
wg.Add(1)
go copier.copyToBackend(errc)
wg.Wait()
if err := <-errc; !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if dst.called {
t.Fatal("CloseWrite() was called after a copy error")
}
}
func TestSwitchProtocolCopierDefaultBufferSize(t *testing.T) {
copier := switchProtocolCopier{}
buf := copier.buffer()
if got := len(buf); got != defaultBufferSize {
t.Fatalf("buffer len = %d, want %d", got, defaultBufferSize)
}
}
type nopReadWriteCloser struct {
io.Reader
io.Writer
}
func (nopReadWriteCloser) Close() error { return nil }
type trackingReadWriteCloser struct {
closed chan struct{}
one sync.Once
}
func newTrackingReadWriteCloser() *trackingReadWriteCloser {
return &trackingReadWriteCloser{closed: make(chan struct{})}
}
func (c *trackingReadWriteCloser) Read(_ []byte) (int, error) { return 0, io.EOF }
func (c *trackingReadWriteCloser) Write(p []byte) (int, error) { return len(p), nil }
func (c *trackingReadWriteCloser) Close() error {
c.one.Do(func() {
close(c.closed)
})
return nil
}
func (c *trackingReadWriteCloser) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func TestHandlerCleanupLegacyModeClosesAllConnections(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), 0)
connA := newTrackingReadWriteCloser()
connB := newTrackingReadWriteCloser()
ts.registerConnection(connA, nil, false, "a")
ts.registerConnection(connB, nil, false, "b")
h := &Handler{
tunnelTracker: ts,
StreamDetached: false,
}
if err := h.Cleanup(); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if !connA.isClosed() || !connB.isClosed() {
t.Fatalf("legacy cleanup should close all upgraded connections")
}
}
func TestHandlerCleanupLegacyModeHonorsDelay(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), 40*time.Millisecond)
conn := newTrackingReadWriteCloser()
ts.registerConnection(conn, nil, false, "a")
h := &Handler{
tunnelTracker: ts,
StreamDetached: false,
}
if err := h.Cleanup(); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if conn.isClosed() {
t.Fatal("connection should not close immediately when stream_close_delay is set")
}
select {
case <-conn.closed:
case <-time.After(500 * time.Millisecond):
t.Fatal("connection did not close after stream_close_delay elapsed")
}
}
func TestHandlerCleanupDetachedModeClosesOnlyRemovedUpstreams(t *testing.T) {
const upstreamA = "upstream-a"
const upstreamB = "upstream-b"
// Simulate old+new configs both referencing upstreamA (refcount 2),
// while upstreamB is only referenced by the old config (refcount 1).
hosts.LoadOrStore(upstreamA, struct{}{})
hosts.LoadOrStore(upstreamA, struct{}{})
hosts.LoadOrStore(upstreamB, struct{}{})
t.Cleanup(func() {
_, _ = hosts.Delete(upstreamA)
_, _ = hosts.Delete(upstreamA)
_, _ = hosts.Delete(upstreamB)
})
ts := newTunnelTracker(caddy.Log(), 0)
registerDetachedTunnelTrackers(ts)
connA := newTrackingReadWriteCloser()
connB := newTrackingReadWriteCloser()
ts.registerConnection(connA, nil, true, upstreamA)
ts.registerConnection(connB, nil, true, upstreamB)
h := &Handler{
tunnelTracker: ts,
StreamDetached: true,
Upstreams: UpstreamPool{
&Upstream{Dial: upstreamA},
&Upstream{Dial: upstreamB},
},
}
if err := h.Cleanup(); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if connA.isClosed() {
t.Fatal("connection for detached upstream should remain open")
}
if !connB.isClosed() {
t.Fatal("connection for removed upstream should be closed")
}
}
func TestHandlerUnmarshalCaddyfileStreamLogsBlock(t *testing.T) {
d := caddyfile.NewTestDispenser(`
reverse_proxy localhost:9000 {
stream_logs {
level info
logger_name access
skip_handshake
}
}
`)
var h Handler
if err := h.UnmarshalCaddyfile(d); err != nil {
t.Fatalf("UnmarshalCaddyfile() error = %v", err)
}
if h.StreamLogs == nil {
t.Fatal("expected stream_logs to be configured")
}
if h.StreamLogs.Level != "info" {
t.Fatalf("expected stream_logs.level=info, got %q", h.StreamLogs.Level)
}
if h.StreamLogs.LoggerName != "access" {
t.Fatalf("expected stream_logs.logger_name=access, got %q", h.StreamLogs.LoggerName)
}
if !h.StreamLogs.SkipHandshake {
t.Fatal("expected stream_logs.skip_handshake=true")
}
}
// closeWriteRecorder is a destination that supports closing only its write
// half, and records whether that happened.
type closeWriteRecorder struct {
called bool
err error
writeErr error
}
func (c *closeWriteRecorder) Read([]byte) (int, error) { return 0, io.EOF }
func (c *closeWriteRecorder) Write(p []byte) (int, error) {
if c.writeErr != nil {
return 0, c.writeErr
}
return len(p), nil
}
func (c *closeWriteRecorder) Close() error { return nil }
func (c *closeWriteRecorder) CloseWrite() error {
c.called = true
return c.err
}
// TestHandlerUpgradedStreamHalfClose drives a real upgraded stream through the
// handler and checks that closing one direction is propagated to the other side
// instead of tearing the whole tunnel down.
//
// It mirrors net/http/httputil's TestReverseProxyWebSocketHalfTCP, which covers
// the same class upstream (https://go.dev/issue/35892). Before the fix, the
// "close write" cases failed: the handler returned as soon as the first
// direction reported, and the deferred closes killed the other direction before
// its pending bytes could be delivered.
//
// See https://github.com/caddyserver/caddy/issues/8026.
func TestHandlerUpgradedStreamHalfClose(t *testing.T) {
switch runtime.GOOS {
case "plan9", "js", "wasip1":
t.Skipf("not supported on %s", runtime.GOOS)
}
// Reads carry a deadline so a regression fails the test promptly instead of
// blocking until the whole package times out.
const readTimeout = 10 * time.Second
mustRead := func(t *testing.T, conn *net.TCPConn, msg string) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
b := make([]byte, len(msg))
if _, err := io.ReadFull(conn, b); err != nil {
t.Fatalf("failed to read: %v", err)
}
if got, want := string(b), msg; got != want {
t.Fatalf("got %#q, want %#q", got, want)
}
}
mustReadEOF := func(t *testing.T, conn *net.TCPConn) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
b := make([]byte, 1)
if _, err := conn.Read(b); !errors.Is(err, io.EOF) {
t.Fatalf("read after peer half-close: got %v, want EOF", err)
}
}
mustWrite := func(t *testing.T, conn *net.TCPConn, msg string) {
t.Helper()
if _, err := conn.Write([]byte(msg)); err != nil {
t.Fatalf("failed to write: %v", err)
}
}
mustCloseRead := func(t *testing.T, conn *net.TCPConn) {
t.Helper()
if err := conn.CloseRead(); err != nil {
t.Fatalf("failed to CloseRead: %v", err)
}
}
mustCloseWrite := func(t *testing.T, conn *net.TCPConn) {
t.Helper()
if err := conn.CloseWrite(); err != nil {
t.Fatalf("failed to CloseWrite: %v", err)
}
}
tests := map[string]func(t *testing.T, cli, srv *net.TCPConn){
"backend close read": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseRead(t, srv)
mustWrite(t, srv, "backend sends")
mustRead(t, cli, "backend sends")
},
"backend close write": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseWrite(t, srv)
mustReadEOF(t, cli)
mustWrite(t, cli, "client sends")
mustRead(t, srv, "client sends")
},
"client close read": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseRead(t, cli)
mustWrite(t, cli, "client sends")
mustRead(t, srv, "client sends")
},
"client close write": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseWrite(t, cli)
mustReadEOF(t, srv)
mustWrite(t, srv, "backend sends")
mustRead(t, cli, "backend sends")
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
_, cli, srv := newUpgradedTunnel(t)
test(t, cli, srv)
})
}
}
// newUpgradedTunnel proxies a websocket upgrade from a raw client connection
// through a Handler to a hijacking backend and returns both ends of the
// tunnel. The Handler's tunnel tracker holds the proxied connections.
func newUpgradedTunnel(t *testing.T) (h *Handler, cli, srv *net.TCPConn) {
t.Helper()
srvc := make(chan *net.TCPConn, 1)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, _, err := http.NewResponseController(w).Hijack()
if err != nil {
t.Errorf("backend hijack failed: %v", err)
return
}
tcp, ok := conn.(*net.TCPConn)
if !ok {
conn.Close()
t.Errorf("backend conn is %T, want *net.TCPConn", conn)
return
}
if _, err := io.WriteString(tcp, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); err != nil {
tcp.Close()
t.Errorf("backend upgrade write failed: %v", err)
return
}
srvc <- tcp
}))
t.Cleanup(backend.Close)
h = minimalHandler(0, &Upstream{
Host: new(Host),
Dial: backend.Listener.Addr().String(),
})
h.tunnelTracker = newTunnelTracker(caddy.Log(), 0)
initReverseProxyMetrics(h, prometheus.NewRegistry())
frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Isolate the tunnel from request cancellation, which has its own
// backend-close path.
r = r.WithContext(context.WithoutCancel(r.Context()))
_ = h.ServeHTTP(w, prepareTestRequest(r), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
return nil
}))
}))
t.Cleanup(frontend.Close)
frontendURL, err := url.Parse(frontend.URL)
if err != nil {
t.Fatalf("failed to parse frontend URL: %v", err)
}
addr, err := net.ResolveTCPAddr("tcp", frontendURL.Host)
if err != nil {
t.Fatalf("failed to resolve TCP address: %v", err)
}
cli, err = net.DialTCP("tcp", nil, addr)
if err != nil {
t.Fatalf("failed to dial frontend: %v", err)
}
t.Cleanup(func() { cli.Close() })
req, _ := http.NewRequest(http.MethodGet, frontend.URL, nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
if err := req.Write(cli); err != nil {
t.Fatalf("failed to write upgrade request: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(cli), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatalf("failed to read upgrade response: %v", err)
}
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("status = %d, want 101", resp.StatusCode)
}
select {
case srv = <-srvc:
case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for the backend connection")
}
t.Cleanup(func() { srv.Close() })
return h, cli, srv
}
// A response carrying the Incremental header field (RFC 10036) must be
// forwarded without buffering, whatever its Content-Type and Content-Length.
func TestFlushIntervalIncremental(t *testing.T) {
h := Handler{FlushInterval: caddy.Duration(time.Second)}
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, tc := range []struct {
name string
incremental string
receivedIncremental bool
want time.Duration
}{
{name: "incremental", incremental: "?1", want: -1},
{name: "not incremental", incremental: "?0", want: time.Second},
{name: "absent", want: time.Second},
{name: "removed by header operations", receivedIncremental: true, want: -1},
} {
t.Run(tc.name, func(t *testing.T) {
res := &http.Response{
Header: http.Header{"Content-Type": []string{"application/json"}},
ContentLength: 42,
}
if tc.incremental != "" {
res.Header.Set("Incremental", tc.incremental)
}
if got := h.flushInterval(req, res, tc.receivedIncremental); got != tc.want {
t.Errorf("flushInterval() = %v, want %v", got, tc.want)
}
})
}
}
func TestHandlerCleanupDetachedRemovalWithDelay(t *testing.T) {
const upstream = "detached-removal-with-delay"
hosts.LoadOrStore(upstream, new(Host))
t.Cleanup(func() { _, _ = hosts.Delete(upstream) })
ts := newTunnelTracker(caddy.Log(), time.Hour)
registerDetachedTunnelTrackers(ts)
t.Cleanup(func() { unregisterDetachedTunnelTrackers(ts) })
detached := newTrackingReadWriteCloser()
attached := newTrackingReadWriteCloser()
deleteDetached := ts.registerConnection(detached, nil, true, upstream)
deleteAttached := ts.registerConnection(attached, nil, false, upstream)
t.Cleanup(deleteDetached)
t.Cleanup(deleteAttached)
h := &Handler{tunnelTracker: ts, StreamDetached: true, Upstreams: UpstreamPool{&Upstream{Dial: upstream}}}
if err := h.Cleanup(); err != nil {
t.Fatal(err)
}
if !detached.isClosed() {
t.Error("detached stream remains open after upstream removal")
}
if attached.isClosed() {
t.Error("attached stream closed before stream_close_delay elapsed")
}
}
func TestHandlerCleanupEmptyDetachedTracker(t *testing.T) {
for _, delay := range []time.Duration{0, time.Hour} {
t.Run(delay.String(), func(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), delay)
registerDetachedTunnelTrackers(ts)
t.Cleanup(func() { unregisterDetachedTunnelTrackers(ts) })
// Cover a tracker whose last connection closed before handler cleanup.
conn := newTrackingReadWriteCloser()
ts.registerConnection(conn, nil, true, "upstream")()
if err := (&Handler{tunnelTracker: ts, StreamDetached: true}).Cleanup(); err != nil {
t.Fatal(err)
}
detachedTunnelTrackersMu.Lock()
_, retained := detachedTunnelTrackers[ts]
detachedTunnelTrackersMu.Unlock()
if retained {
t.Error("empty tracker remains globally registered after cleanup")
}
})
}
}
func TestDetachedTrackerRemovalAllowsConcurrentConnectionDeletion(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), 0)
registerDetachedTunnelTrackers(ts)
t.Cleanup(func() { unregisterDetachedTunnelTrackers(ts) })
// An attached stream on another stopped handler can finish while an
// upstream-removal notification closes a detached stream. Its deletion
// must be able to acquire the registry lock and release its tracker lock.
other := newTunnelTracker(caddy.Log(), 0)
otherConn := newTrackingReadWriteCloser()
deleteOther := other.registerConnection(otherConn, nil, false, "other")
if err := other.cleanupAttachedConnections(); err != nil {
t.Fatal(err)
}
deleted := make(chan struct{})
conn := newTrackingReadWriteCloser()
del := ts.registerConnection(conn, func(time.Time) error {
go func() {
deleteOther()
close(deleted)
}()
select {
case <-deleted:
case <-time.After(time.Second):
t.Error("upstream removal blocked another connection's deletion")
}
return nil
}, true, "upstream")
t.Cleanup(del)
if err := ts.cleanupAttachedConnections(); err != nil {
t.Fatal(err)
}
if err := notifyDetachedTunnelTrackersOfUpstreamRemoval("upstream", ts); err != nil {
t.Fatal(err)
}
// On a regression the notification must release its lock before waiting
// for deletion, so the failing test does not leave a blocked goroutine.
<-deleted
if !conn.isClosed() {
t.Error("removed upstream connection was not closed")
}
}
// newStalledWebsocketPeer returns one end of a pipe whose other end never
// reads, with a goroutine stuck writing to it the way a tunnel's copy loop
// would be. net.Pipe is unbuffered and lets only one write through at a
// time, so a close message written to conn waits behind that stuck write,
// just as it does on a socket whose peer stopped reading.
func newStalledWebsocketPeer(t *testing.T) net.Conn {
t.Helper()
conn, peer := net.Pipe()
t.Cleanup(func() {
conn.Close()
peer.Close()
})
go func() { _, _ = conn.Write([]byte("stalled")) }()
return conn
}
// shortenCloseControlWriteTimeout makes a stalled peer time out quickly for
// the rest of the test.
func shortenCloseControlWriteTimeout(t *testing.T) {
t.Helper()
saved := closeControlWriteTimeout
closeControlWriteTimeout = 10 * time.Millisecond
t.Cleanup(func() { closeControlWriteTimeout = saved })
}
func TestHandlerCleanupDoesNotBlockOnStalledWebsocketPeer(t *testing.T) {
conn := newStalledWebsocketPeer(t)
ts := newTunnelTracker(caddy.Log(), 0)
shortenCloseControlWriteTimeout(t)
ts.registerConnection(conn, func(deadline time.Time) error {
return writeCloseControl(conn, false, deadline)
}, false, "upstream")
cleanupDone := make(chan error, 1)
go func() { cleanupDone <- ts.cleanupAttachedConnections() }()
select {
case err := <-cleanupDone:
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Errorf("cleanup = %v, want %v", err, os.ErrDeadlineExceeded)
}
case <-time.After(10 * time.Second):
t.Fatal("cleanup blocked on a websocket peer that stopped reading")
}
}
func TestHandlerCleanupUsesOneDeadlinePerBatch(t *testing.T) {
// The close loop is serial and holds the tracker lock, so a deadline per
// connection would let every stalled peer add another full timeout.
ts := newTunnelTracker(caddy.Log(), 0)
shortenCloseControlWriteTimeout(t)
var deadlines []time.Time
for range 2 {
conn := newStalledWebsocketPeer(t)
ts.registerConnection(conn, func(deadline time.Time) error {
deadlines = append(deadlines, deadline)
return writeCloseControl(conn, false, deadline)
}, false, "upstream")
}
_ = ts.cleanupAttachedConnections()
if len(deadlines) != 2 {
t.Fatalf("got %d graceful closes, want 2", len(deadlines))
}
if !deadlines[0].Equal(deadlines[1]) {
t.Errorf("graceful closes got deadlines %v and %v, want one shared deadline", deadlines[0], deadlines[1])
}
}
func TestUpstreamRemovalUsesOneDeadlineAcrossTrackers(t *testing.T) {
// Removing an upstream notifies every stopped tracker in turn, and each
// close loop holds its own lock, so a deadline per tracker would let
// every tracker with a stalled peer add another full timeout.
const upstream = "upstream-removal-one-deadline"
shortenCloseControlWriteTimeout(t)
var deadlines []time.Time
var trackers []*tunnelTracker
for range 2 {
ts := newTunnelTracker(caddy.Log(), 0)
registerDetachedTunnelTrackers(ts)
t.Cleanup(func() { unregisterDetachedTunnelTrackers(ts) })
conn := newStalledWebsocketPeer(t)
ts.registerConnection(conn, func(deadline time.Time) error {
deadlines = append(deadlines, deadline)
return writeCloseControl(conn, false, deadline)
}, true, upstream)
if err := ts.cleanupAttachedConnections(); err != nil {
t.Fatal(err)
}
trackers = append(trackers, ts)
}
_ = notifyDetachedTunnelTrackersOfUpstreamRemoval(upstream, trackers[0])
if len(deadlines) != 2 {
t.Fatalf("got %d graceful closes, want 2", len(deadlines))
}
if !deadlines[0].Equal(deadlines[1]) {
t.Errorf("trackers got deadlines %v and %v, want one shared deadline", deadlines[0], deadlines[1])
}
}
func TestWriteCloseControlSkipsConnectionsWithoutWriteDeadline(t *testing.T) {
// A write that cannot be bounded could block forever behind a stalled
// peer, so the best-effort close message is skipped instead.
var buf bytes.Buffer
if err := writeCloseControl(&buf, false, time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
if buf.Len() != 0 {
t.Errorf("close message %q was written to a connection without write deadlines", buf.Bytes())
}
}
func TestExtendedConnectReadWriteCloserSetWriteDeadline(t *testing.T) {
rw := &writeDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
rwc := extendedConnectReadWriteCloser{io.NopCloser(strings.NewReader("")), rw}
deadline := time.Now().Add(time.Second)
if err := rwc.SetWriteDeadline(deadline); err != nil {
t.Fatal(err)
}
if !rw.writeDeadline.Equal(deadline) {
t.Errorf("write deadline = %v, want %v", rw.writeDeadline, deadline)
}
// Writers without deadline support report ErrNotSupported, which
// writeCloseControl treats as a reason to skip the close message.
plain := extendedConnectReadWriteCloser{io.NopCloser(strings.NewReader("")), httptest.NewRecorder()}
if err := plain.SetWriteDeadline(deadline); !errors.Is(err, http.ErrNotSupported) {
t.Errorf("SetWriteDeadline on a plain recorder = %v, want ErrNotSupported", err)
}
}
type writeDeadlineRecorder struct {
*httptest.ResponseRecorder
writeDeadline time.Time
}
func (r *writeDeadlineRecorder) SetWriteDeadline(deadline time.Time) error {
r.writeDeadline = deadline
return nil
}
func TestHandlerUpgradeBoundsUpstreamCloseMessage(t *testing.T) {
switch runtime.GOOS {
case "plan9", "js", "wasip1":
t.Skipf("not supported on %s", runtime.GOOS)
}
// net/http hands upgraded HTTP/1 bodies back inside a type that hides the
// socket's deadline methods, so without the connection recorded during
// the round trip the close message to the upstream would be skipped.
h, cli, srv := newUpgradedTunnel(t)
if err := cli.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
t.Fatal(err)
}
if err := srv.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
t.Fatal(err)
}
// Data reaching the backend proves both connections are registered.
if _, err := io.WriteString(cli, "hello"); err != nil {
t.Fatal(err)
}
hello := make([]byte, len("hello"))
if _, err := io.ReadFull(srv, hello); err != nil || string(hello) != "hello" {
t.Fatalf("backend read %q, %v; want %q", hello, err, "hello")
}
// Closing the client side may tear down the tunnel before cleanup closes
// the upstream itself; that must not surface as a cleanup error.
if err := h.tunnelTracker.cleanupAttachedConnections(); err != nil {
t.Errorf("cleanup = %v, want nil", err)
}
frame := make([]byte, 2)
if _, err := io.ReadFull(srv, frame); err != nil {
t.Fatalf("backend did not receive a close message: %v", err)
}
if frame[0] != 0x88 || frame[1]&0x80 == 0 {
t.Fatalf("backend received %x, want a masked close frame", frame)
}
}
func TestUpstreamWriteCloser(t *testing.T) {
// The HTTP/1 path, including the recorded socket, is covered end to end
// by TestHandlerUpgradeBoundsUpstreamCloseMessage. HTTP/2 and HTTP/3 share
// their connection between streams, so the close message must never go
// to a recorded socket for them.
body, _ := net.Pipe()
socket, _ := net.Pipe()
for _, tc := range []struct {
name string
protoMajor int
recorded net.Conn
want io.ReadWriteCloser
}{
{name: "http/1 with recorded socket", protoMajor: 1, recorded: socket, want: socket},
{name: "http/1 without recorded socket", protoMajor: 1, want: body},
{name: "http/2", protoMajor: 2, recorded: socket, want: body},
{name: "http/3", protoMajor: 3, recorded: socket, want: body},
} {
t.Run(tc.name, func(t *testing.T) {
recorded := new(atomic.Pointer[net.Conn])
if tc.recorded != nil {
recorded.Store(&tc.recorded)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(context.WithValue(req.Context(), upstreamConnCtxKey, recorded))
res := &http.Response{ProtoMajor: tc.protoMajor}
if got := upstreamWriteCloser(req, res, body); got != tc.want {
t.Errorf("upstreamWriteCloser() = %v, want %v", got, tc.want)
}
})
}
}
// opaqueUpgradeWriter supports hijacking but hides the recorder from the
// detachment helper, as a middleware wrapper without Unwrap would do.
type opaqueUpgradeWriter struct {
http.ResponseWriter
}
func (w opaqueUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return http.NewResponseController(w.ResponseWriter).Hijack()
}
type rejectingUpgradeWriter struct {
*caddyhttp.ResponseWriterWrapper
}
func (rejectingUpgradeWriter) DetachAfterHijack(detached bool) bool {
return !detached
}
type pipeUpgradeWriter struct {
*httptest.ResponseRecorder
conn net.Conn
}
func (w pipeUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return w.conn, bufio.NewReadWriter(bufio.NewReader(w.conn), bufio.NewWriter(w.conn)), nil
}
func TestUpgradeDetachmentRequiresWriterSupport(t *testing.T) {
for _, tc := range []struct {
name string
wrap func(http.ResponseWriter) http.ResponseWriter
detached bool
}{
{name: "opaque wrapper", wrap: func(w http.ResponseWriter) http.ResponseWriter { return opaqueUpgradeWriter{w} }},
{name: "inner writer rejects detachment", wrap: func(w http.ResponseWriter) http.ResponseWriter {
rejecting := rejectingUpgradeWriter{&caddyhttp.ResponseWriterWrapper{ResponseWriter: w}}
return caddyhttp.NewResponseRecorder(rejecting, nil, nil)
}},
{name: "supported recorder", wrap: func(w http.ResponseWriter) http.ResponseWriter { return w }, detached: true},
} {
t.Run(tc.name, func(t *testing.T) {
front, client := net.Pipe()
back, backend := net.Pipe()
defer front.Close()
defer client.Close()
defer back.Close()
defer backend.Close()
if err := client.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
echoDone := make(chan struct{})
go func() {
defer close(echoDone)
_, _ = io.Copy(backend, backend)
}()
h := &Handler{StreamDetached: true, logger: caddy.Log(), tunnelTracker: newTunnelTracker(caddy.Log(), 0)}
initReverseProxyMetrics(h, prometheus.NewRegistry())
recorder := caddyhttp.NewResponseRecorder(pipeUpgradeWriter{httptest.NewRecorder(), front}, nil, nil)
writer := tc.wrap(recorder)
req := caddyhttp.PrepareRequest(httptest.NewRequest(http.MethodGet, "/upgrade", nil), caddy.NewReplacer(), recorder, &caddyhttp.Server{})
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "test-stream")
res := &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: req.Header.Clone(), Body: back}
handlerDone := make(chan struct{})
go func() {
defer close(handlerDone)
h.handleUpgradeResponse(caddy.Log(), writer, req, res, "detachment-test")
}()
const payload = "echo"
if _, err := io.WriteString(client, payload); err != nil {
t.Fatal(err)
}
received := make([]byte, len(payload))
if _, err := io.ReadFull(client, received); err != nil {
t.Fatal(err)
}
if string(received) != payload {
t.Fatalf("echo = %q, want %q", received, payload)
}
h.tunnelTracker.mu.Lock()
for _, conn := range h.tunnelTracker.connections {
if conn.detached != tc.detached {
t.Errorf("tracked detached = %v, want %v", conn.detached, tc.detached)
}
}
h.tunnelTracker.mu.Unlock()
if tc.detached {
select {
case <-handlerDone:
case <-time.After(time.Second):
t.Error("supported detached tunnel kept the handler running")
}
} else {
select {
case <-handlerDone:
t.Error("handler returned while the writer chain remained attached")
case <-time.After(100 * time.Millisecond):
}
}
client.Close()
backend.Close()
select {
case <-handlerDone:
case <-time.After(time.Second):
t.Fatal("handler did not finish after stream close")
}
<-echoDone
// Wait for detached tunnel cleanup too, so no copy goroutine can
// still be updating counters after this subtest finishes.
deadline := time.Now().Add(time.Second)
for {
h.tunnelTracker.mu.Lock()
remaining := len(h.tunnelTracker.connections)
h.tunnelTracker.mu.Unlock()
if remaining == 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("tunnel connections were not unregistered")
}
time.Sleep(time.Millisecond)
}
// Attached byte counters are safe to inspect after the handler has waited
// for both copying goroutines. Partial detachment must also be undone.
if !tc.detached {
recorders := []caddyhttp.ResponseRecorder{recorder}
if outer, ok := writer.(caddyhttp.ResponseRecorder); ok {
recorders = append(recorders, outer)
}
for _, rr := range recorders {
if rr.Size() != len(payload) {
t.Errorf("attached recorder size = %d, want %d", rr.Size(), len(payload))
}
}
}
})
}
}
func TestUpgradeResponseCleanupOnFailure(t *testing.T) {
body := &closeTrackingBody{Buffer: bytes.NewBufferString("test body")}
res := &http.Response{
StatusCode: http.StatusSwitchingProtocols,
Header: make(http.Header),
Body: body,
}
res.Header.Set("Upgrade", "websocket")
res.Header.Set("Connection", "Upgrade")
// Request with mismatched upgrade type -> should fail check and close body
req, err := http.NewRequest("GET", "http://example.com", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Upgrade", "something-else")
req.Header.Set("Connection", "Upgrade")
recorder := httptest.NewRecorder()
h := &Handler{}
logger := zap.NewNop()
h.handleUpgradeResponse(logger, recorder, req, res, "127.0.0.1:8080")
if !body.closed {
t.Errorf("expected backend response body to be closed on upgrade protocol mismatch, but it was not")
}
}
func TestUpgradeResponseCleanupAfterHijack(t *testing.T) {
for _, tc := range []struct {
name string
flushFailure bool
}{
{name: "handshake flush failure", flushFailure: true},
{name: "buffered backend write failure"},
} {
t.Run(tc.name, func(t *testing.T) {
front, client := net.Pipe()
back, backend := net.Pipe()
for _, conn := range []net.Conn{front, client, back, backend} {
t.Cleanup(func() { conn.Close() })
}
writeFailure := &upgradeWriteErrorConn{writeErr: errors.New("upgrade setup write failed")}
reader := bufio.NewReader(strings.NewReader(""))
writer := bufio.NewWriter(front)
var body io.ReadWriteCloser = back
var wantWrite string
if tc.flushFailure {
writeFailure.Conn = front
writer = bufio.NewWriter(writeFailure)
wantWrite = "HTTP/1.1 101 Switching Protocols\r\n\r\n"
// Flush must have pending bytes; flushing an empty writer
// never reaches the failing connection's Write method.
if _, err := writer.WriteString(wantWrite); err != nil {
t.Fatal(err)
}
} else {
writeFailure.Conn = back
body = writeFailure
wantWrite = "buffered client data"
reader = bufio.NewReader(strings.NewReader(wantWrite))
// Buffered() must be nonzero before the handler checks it.
if _, err := reader.Peek(len(wantWrite)); err != nil {
t.Fatal(err)
}
}
rw := &upgradeFailureHijacker{
ResponseRecorder: httptest.NewRecorder(),
conn: front,
brw: bufio.NewReadWriter(reader, writer),
}
req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "/upgrade", nil))
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "test-stream")
res := &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: req.Header.Clone(), Body: body}
logger := zap.NewNop()
h := &Handler{logger: logger, tunnelTracker: newTunnelTracker(logger, 0)}
for _, peer := range []net.Conn{client, backend} {
if err := peer.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
}
h.handleUpgradeResponse(logger, rw, req, res, "cleanup-test")
if !rw.hijacked {
t.Fatal("setup failed before successful client hijack")
}
if writeFailure.writeCalls != 1 || string(writeFailure.written) != wantWrite {
t.Fatalf("failure injection: got %d writes of %q, want one write of %q",
writeFailure.writeCalls, writeFailure.written, wantWrite)
}
for _, peer := range []struct {
name string
conn net.Conn
}{
{name: "client", conn: client},
{name: "backend", conn: backend},
} {
var data [1]byte
if n, err := peer.conn.Read(data[:]); n != 0 || !errors.Is(err, io.EOF) {
t.Errorf("%s peer read = (%d, %v), want (0, EOF) after setup failure", peer.name, n, err)
}
}
if remaining := len(h.tunnelTracker.connections); remaining != 0 {
t.Errorf("setup failure registered %d tunnel connections", remaining)
}
})
}
}
type upgradeFailureHijacker struct {
*httptest.ResponseRecorder
conn net.Conn
brw *bufio.ReadWriter
hijacked bool
}
func (w *upgradeFailureHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
w.hijacked = true
return w.conn, w.brw, nil
}
type upgradeWriteErrorConn struct {
net.Conn
writeErr error
writeCalls int
written []byte
}
func (c *upgradeWriteErrorConn) Write(p []byte) (int, error) {
c.writeCalls++
c.written = append(c.written, p...)
return 0, c.writeErr
}
type closeTrackingBody struct {
*bytes.Buffer
closed bool
}
func (c *closeTrackingBody) Close() error {
c.closed = true
return nil
}
func TestTunnelTrackerSendsCloseMessagesBeforeClosing(t *testing.T) {
// Closing one side of a tunnel tears down the other, so every close
// message in a batch must go out before any connection is closed.
for _, tc := range []struct {
name string
detached bool
close func(*tunnelTracker) error
}{
{name: "attached", close: (*tunnelTracker).cleanupAttachedConnections},
{name: "detached", detached: true, close: func(ts *tunnelTracker) error {
// closeConnectionsForUpstream only acts on a stopped tracker.
ts.stopped = true
return ts.closeConnectionsForUpstream("upstream", time.Now().Add(time.Second))
}},
} {
t.Run(tc.name, func(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), 0)
var events []string
for _, side := range []string{"client", "upstream"} {
ts.registerConnection(closeRecorder{side: side, events: &events}, func(time.Time) error {
events = append(events, "close message to "+side)
return nil
}, tc.detached, "upstream")
}
if err := tc.close(ts); err != nil {
t.Fatal(err)
}
if len(events) != 4 || !strings.HasPrefix(events[0], "close message") || !strings.HasPrefix(events[1], "close message") {
t.Errorf("events = %q, want both close messages before any connection closes", events)
}
})
}
}
// closeRecorder records when it is closed.
type closeRecorder struct {
nopReadWriteCloser
side string
events *[]string
}
func (c closeRecorder) Close() error {
*c.events = append(*c.events, "closed "+c.side)
return nil
}