mirror of
https://github.com/caddyserver/caddy.git
synced 2026-11-11 19:37:55 +00:00
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.
1207 lines
37 KiB
Go
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
|
|
}
|