Files
Francis LavoieandClaude Opus 5.5 9e5d4d42b1 reverseproxy: test WebSocket upgrades over Unix sockets (#7657)
* reverseproxy: Test that WebSockets + unix-sockets works

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close temp file before reusing its path for the socket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Strengthen unix socket WebSocket test, reuse tempSocketName helper

Check that upstream bytes sent with the 101 are delivered, echo a payload larger than any buffer, and verify the upstream connection closes when the client disconnects. Reuse the existing tempSocketName helper for all unix socket tests, which also closes the temp file descriptor the older tests leaked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:22:28 +10:00

1291 lines
31 KiB
Go

package integration
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/textproto"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/caddyserver/caddy/v2/caddytest"
)
func TestSRVReverseProxy(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
"admin": {
"listen": "localhost:2999"
},
"apps": {
"pki": {
"certificate_authorities": {
"local": {
"install_trust": false
}
}
},
"http": {
"grace_period": 1,
"servers": {
"srv0": {
"listen": [
":18080"
],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"dynamic_upstreams": {
"source": "srv",
"name": "srv.host.service.consul"
}
}
]
}
]
}
}
}
}
}
`, "json")
}
func TestDialWithPlaceholderUnix(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
socketName := tempSocketName(t)
server := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("Hello, World!"))
}),
}
unixListener, err := net.Listen("unix", socketName)
if err != nil {
t.Errorf("failed to listen on the socket: %s", err)
return
}
go server.Serve(unixListener)
t.Cleanup(func() {
server.Close()
})
runtime.Gosched() // Allow other goroutines to run
tester := caddytest.NewTester(t)
tester.InitServer(`
{
"admin": {
"listen": "localhost:2999"
},
"apps": {
"pki": {
"certificate_authorities": {
"local": {
"install_trust": false
}
}
},
"http": {
"grace_period": 1,
"servers": {
"srv0": {
"listen": [
":18080"
],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "unix/{http.request.header.X-Caddy-Upstream-Dial}"
}
]
}
]
}
]
}
}
}
}
}
`, "json")
req, err := http.NewRequest(http.MethodGet, "http://localhost:18080", nil)
if err != nil {
t.Fail()
return
}
req.Header.Set("X-Caddy-Upstream-Dial", socketName)
tester.AssertResponse(req, 200, "Hello, World!")
}
func TestReverseProxyWithPlaceholderDialAddress(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
"admin": {
"listen": "localhost:2999"
},
"apps": {
"pki": {
"certificate_authorities": {
"local": {
"install_trust": false
}
}
},
"http": {
"grace_period": 1,
"servers": {
"srv0": {
"listen": [
":18080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "static_response",
"body": "Hello, World!"
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
},
"srv1": {
"listen": [
":9080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "{http.request.header.X-Caddy-Upstream-Dial}"
}
]
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
}
}
}
}
}
`, "json")
req, err := http.NewRequest(http.MethodGet, "http://localhost:9080", nil)
if err != nil {
t.Fail()
return
}
req.Header.Set("X-Caddy-Upstream-Dial", "localhost:18080")
tester.AssertResponse(req, 200, "Hello, World!")
}
func TestReverseProxyWithPlaceholderTCPDialAddress(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
"admin": {
"listen": "localhost:2999"
},
"apps": {
"pki": {
"certificate_authorities": {
"local": {
"install_trust": false
}
}
},
"http": {
"grace_period": 1,
"servers": {
"srv0": {
"listen": [
":18080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "static_response",
"body": "Hello, World!"
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
},
"srv1": {
"listen": [
":9080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "tcp/{http.request.header.X-Caddy-Upstream-Dial}:18080"
}
]
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
}
}
}
}
}
`, "json")
req, err := http.NewRequest(http.MethodGet, "http://localhost:9080", nil)
if err != nil {
t.Fail()
return
}
req.Header.Set("X-Caddy-Upstream-Dial", "localhost")
tester.AssertResponse(req, 200, "Hello, World!")
}
func TestReverseProxyHealthCheck(t *testing.T) {
// Start lightweight backend servers so they're ready before Caddy's
// active health checker runs; this avoids a startup race where the
// health checker probes backends that haven't yet begun accepting
// connections and marks them unhealthy.
//
// This mirrors how health checks are typically used in practice (to a separate
// backend service) and avoids probing the same Caddy instance while it's still
// provisioning and not ready to accept connections.
// backend server that responds to proxied requests
helloSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte("Hello, World!"))
}),
}
ln0, err := net.Listen("tcp", "127.0.0.1:2020")
if err != nil {
t.Fatalf("failed to listen on 127.0.0.1:2020: %v", err)
}
go helloSrv.Serve(ln0)
t.Cleanup(func() { helloSrv.Close(); ln0.Close() })
// backend server that serves health checks
healthSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte("ok"))
}),
}
ln1, err := net.Listen("tcp", "127.0.0.1:2021")
if err != nil {
t.Fatalf("failed to listen on 127.0.0.1:2021: %v", err)
}
go healthSrv.Serve(ln1)
t.Cleanup(func() { healthSrv.Close(); ln1.Close() })
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy {
to localhost:2020
health_uri /health
health_port 2021
health_interval 10ms
health_timeout 100ms
health_passes 1
health_fails 1
}
}
`, "caddyfile")
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
}
// TestReverseProxyHealthCheckPortUsed verifies that health_port is actually
// used for active health checks and not the upstream's main port. This is a
// regression test for https://github.com/caddyserver/caddy/issues/7524.
func TestReverseProxyHealthCheckPortUsed(t *testing.T) {
// upstream server: serves proxied requests normally, but returns 503 for
// /health so that if health checks mistakenly hit this port the upstream
// gets marked unhealthy and the proxy returns 503.
upstreamSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/health" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("Hello, World!"))
}),
}
ln0, err := net.Listen("tcp", "127.0.0.1:2022")
if err != nil {
t.Fatalf("failed to listen on 127.0.0.1:2022: %v", err)
}
go upstreamSrv.Serve(ln0)
t.Cleanup(func() { upstreamSrv.Close(); ln0.Close() })
// separate health check server on the configured health_port: returns 200
// so the upstream is marked healthy only if health checks go to this port.
healthSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte("ok"))
}),
}
ln1, err := net.Listen("tcp", "127.0.0.1:2023")
if err != nil {
t.Fatalf("failed to listen on 127.0.0.1:2023: %v", err)
}
go healthSrv.Serve(ln1)
t.Cleanup(func() { healthSrv.Close(); ln1.Close() })
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy {
to localhost:2022
health_uri /health
health_port 2023
health_interval 10ms
health_timeout 100ms
health_passes 1
health_fails 1
}
}
`, "caddyfile")
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
}
func TestReverseProxyHealthCheckUnixSocket(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
tester := caddytest.NewTester(t)
socketName := tempSocketName(t)
server := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.URL.Path, "/health") {
w.Write([]byte("ok"))
return
}
w.Write([]byte("Hello, World!"))
}),
}
unixListener, err := net.Listen("unix", socketName)
if err != nil {
t.Errorf("failed to listen on the socket: %s", err)
return
}
go server.Serve(unixListener)
t.Cleanup(func() {
server.Close()
})
runtime.Gosched() // Allow other goroutines to run
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy {
to unix/%s
health_uri /health
health_port 2021
health_interval 2s
health_timeout 5s
}
}
`, socketName), "caddyfile")
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
}
func TestReverseProxyHealthCheckUnixSocketWithoutPort(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
tester := caddytest.NewTester(t)
socketName := tempSocketName(t)
server := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.URL.Path, "/health") {
w.Write([]byte("ok"))
return
}
w.Write([]byte("Hello, World!"))
}),
}
unixListener, err := net.Listen("unix", socketName)
if err != nil {
t.Errorf("failed to listen on the socket: %s", err)
return
}
go server.Serve(unixListener)
t.Cleanup(func() {
server.Close()
})
runtime.Gosched() // Allow other goroutines to run
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy {
to unix/%s
health_uri /health
health_interval 2s
health_timeout 5s
}
}
`, socketName), "caddyfile")
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
}
// TestReverseProxyRetryMatchStatusCode verifies that lb_retry_match with a
// CEL expression matching on {rp.status_code} causes the request to be
// retried on the next upstream when the first upstream returns a matching
// status code
func TestReverseProxyRetryMatchStatusCode(t *testing.T) {
// Bad upstream: returns 502
badSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
}
badLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go badSrv.Serve(badLn)
t.Cleanup(func() { badSrv.Close(); badLn.Close() })
// Good upstream: returns 200
goodSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}),
}
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go goodSrv.Serve(goodLn)
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy %s %s {
lb_policy round_robin
lb_retries 1
lb_retry_match {
expression `+"`{rp.status_code} in [502, 503]`"+`
}
}
}
`, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile")
tester.AssertGetResponse("http://localhost:9080/", 200, "ok")
}
// TestReverseProxyRetryMatchHeader verifies that lb_retry_match with a CEL
// expression matching on {rp.header.*} causes the request to be retried when
// the upstream sets a matching response header
func TestReverseProxyRetryMatchHeader(t *testing.T) {
var badHits atomic.Int32
// Bad upstream: returns 200 but signals retry via header
badSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
badHits.Add(1)
w.Header().Set("X-Upstream-Retry", "true")
w.Write([]byte("bad"))
}),
}
badLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go badSrv.Serve(badLn)
t.Cleanup(func() { badSrv.Close(); badLn.Close() })
// Good upstream: returns 200 without retry header
goodSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("good"))
}),
}
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go goodSrv.Serve(goodLn)
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy %s %s {
lb_policy round_robin
lb_retries 1
lb_retry_match {
expression `+"`{rp.header.X-Upstream-Retry} == \"true\"`"+`
}
}
}
`, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile")
tester.AssertGetResponse("http://localhost:9080/", 200, "good")
if badHits.Load() != 1 {
t.Errorf("bad upstream hits: got %d, want 1", badHits.Load())
}
}
// TestReverseProxyRetryMatchCombined verifies that a CEL expression combining
// request path matching with response status code matching works correctly -
// only retrying when both conditions are met
func TestReverseProxyRetryMatchCombined(t *testing.T) {
// Upstream: returns 502 for all requests
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); ln.Close() })
// Good upstream
goodSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}),
}
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go goodSrv.Serve(goodLn)
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy %s %s {
lb_policy round_robin
lb_retries 1
lb_retry_match {
expression `+"`path('/retry*') && {rp.status_code} in [502, 503]`"+`
}
}
}
`, goodLn.Addr().String(), ln.Addr().String()), "caddyfile")
// /retry path matches the expression - should retry to good upstream
tester.AssertGetResponse("http://localhost:9080/retry", 200, "ok")
// /other path does NOT match - should return the 502
req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/other", nil)
tester.AssertResponse(req, 502, "")
}
// TestReverseProxyRetryMatchIsTransportError verifies that the
// {rp.is_transport_error} == true CEL function correctly identifies transport errors
// and allows retrying them alongside response-based matching
func TestReverseProxyRetryMatchIsTransportError(t *testing.T) {
// Good upstream: returns 200
goodSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}),
}
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go goodSrv.Serve(goodLn)
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
// Broken upstream: accepts connections but closes immediately
brokenLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
t.Cleanup(func() { brokenLn.Close() })
go func() {
for {
conn, err := brokenLn.Accept()
if err != nil {
return
}
conn.Close()
}
}()
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy %s %s {
lb_policy round_robin
lb_retries 1
lb_retry_match {
expression `+"`{rp.is_transport_error} || {rp.status_code} in [502, 503]`"+`
}
}
}
`, goodLn.Addr().String(), brokenLn.Addr().String()), "caddyfile")
// Transport error on broken upstream should be retried to good upstream
tester.AssertGetResponse("http://localhost:9080/", 200, "ok")
}
func TestReverseProxySNIPlaceHolder(t *testing.T) {
configTemplate := `
{
skip_install_trust
local_certs
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
localhost example.com {
@proxied header X-Transport caddy
respond @proxied {http.request.tls.server_name}
reverse_proxy 127.0.0.1:9443 {
header_up X-Transport caddy
header_up Host {host}
transport http {
versions %s
tls_server_name {header.X-SNI}
tls_insecure_skip_verify
}
}
}
`
for _, versions := range []string{"1.1 2", "3"} {
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(configTemplate, versions), "caddyfile")
req, err := http.NewRequest("GET", "https://localhost:9443", nil)
if err != nil {
t.Errorf("failed to create request %s", err)
return
}
req.Header.Set("X-SNI", "example.com")
tester.AssertResponse(req, 200, "example.com")
}
}
func TestReverseProxyNormalizeWebSocketHeaders(t *testing.T) {
// 503s if it doesn't see "WebSocket" in a request
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
// http.Server would canonicalize these headers, so plain tcp:
errc := make(chan error, 1)
defer func() {
for range errc {
}
}()
defer ln.Close()
handleConn := func(conn net.Conn) error {
defer conn.Close()
rd := textproto.NewReader(bufio.NewReader(io.LimitReader(conn, 4096)))
status := 503
for {
line, err := rd.ReadLine()
if err != nil {
return fmt.Errorf("failed to read line: %v", err)
}
if len(line) == 0 {
break
}
if strings.Contains(line, "WebSocket") {
status = 200
}
}
res := fmt.Sprintf("HTTP/1.1 %d %s\r\nConnection: close\r\n\r\n", status, http.StatusText(status))
if _, err = conn.Write([]byte(res)); err != nil {
return fmt.Errorf("failed to write: %v", err)
}
return nil
}
go func() {
defer close(errc)
for {
conn, err := ln.Accept()
if err != nil {
if !errors.Is(err, net.ErrClosed) {
errc <- fmt.Errorf("failed to accept: %v", err)
}
return
}
if err := handleConn(conn); err != nil {
errc <- fmt.Errorf("failed to handle: %v", err)
}
}
}()
checkServer := func() {
select {
case err := <-errc:
t.Fatalf("failed to serve test request: %v", err)
default:
}
}
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
local_certs
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy http://%s {
# test compatibility with header manipulations
header_up X-Add-Header "1"
}
}`, ln.Addr()), "caddyfile")
req, err := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
// Only the first WebSocket header needs the manual assignment,
// but mixing in .Set is likely to be more confusing.
req.Header["Connection"] = []string{"Upgrade"}
req.Header["Upgrade"] = []string{"websocket"}
// 1. resembling a browser via http/1.1
req.Header["Sec-WebSocket-Version"] = []string{"13"}
tester.AssertResponse(req, 200, "")
checkServer()
delete(req.Header, "Sec-WebSocket-Version")
// 2. canonicalized is okay
req.Header["Sec-Websocket-Version"] = []string{"13"}
tester.AssertResponse(req, 200, "")
checkServer()
delete(req.Header, "Sec-Websocket-Version")
// 3. unknown header doesn't receive special handling
req.Header["Sec-Websocket-Unnormalizedkey"] = []string{"13"}
tester.AssertResponse(req, 503, "")
checkServer()
}
func TestWeightedRoundRobinSelectionValidation(t *testing.T) {
configTemplate := `
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [":18080"],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"load_balancing": {
"selection_policy": {
"policy": "weighted_round_robin",
"weights": %s
}
},
"upstreams": [
{"dial": "localhost:18081"},
{"dial": "localhost:18082"}
]
}
]
}
]
}
}
}
}
}`
tests := []struct {
name string
weights string
errMsg string
}{
{
name: "negative weight",
weights: "[-1, 2]",
errMsg: "weight of an upstream cannot be negative",
},
{
name: "zero total weight",
weights: "[0, 0]",
errMsg: "requires at least one upstream with a positive weight",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caddytest.AssertLoadError(
t,
fmt.Sprintf(configTemplate, tc.weights),
"json",
tc.errMsg,
)
})
}
}
func TestReverseProxyResponseHandling(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/header", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Length", "500")
writer.WriteHeader(500)
_ = http.NewResponseController(writer).Flush()
panic(http.ErrAbortHandler)
})
mux.HandleFunc("/partial", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Length", "500")
writer.WriteHeader(500)
_, _ = io.WriteString(writer, "partial")
_ = http.NewResponseController(writer).Flush()
panic(http.ErrAbortHandler)
})
mux.HandleFunc("/full", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Length", "500")
writer.WriteHeader(500)
for range 125 {
_, _ = io.WriteString(writer, "full")
}
_ = http.NewResponseController(writer).Flush()
})
mux.HandleFunc("/empty", func(writer http.ResponseWriter, request *http.Request) {
panic(http.ErrAbortHandler)
})
ts := httptest.NewUnstartedServer(mux)
ts.Start()
t.Cleanup(func() {
ts.Close()
})
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy %s
}
`, ts.URL), "caddyfile")
for _, tc := range []struct {
endpoint string
status int
bodyType int // 0 = no body, 1 = partial, 2 = full
body string
}{
{
endpoint: "/header",
status: 500,
bodyType: 0,
body: "",
},
{
endpoint: "/partial",
status: 500,
bodyType: 1,
body: "partial",
},
{
endpoint: "/full",
status: 500,
bodyType: 2,
body: strings.Repeat("full", 125),
},
{
endpoint: "/empty",
status: 502,
bodyType: 2,
body: "",
},
} {
req, err := http.NewRequest("GET", "http://localhost:9080"+tc.endpoint, nil)
if err != nil {
t.Fatalf("unable to create request %s for endpoint %s", err, tc.endpoint)
}
resp, err := tester.Client.Do(req)
if err != nil {
t.Fatalf("request failed: %s for endpoint %s", err, tc.endpoint)
}
if resp.StatusCode != tc.status {
t.Fatalf("unexpected status code for %s: got %d, want %d", tc.endpoint, resp.StatusCode, tc.status)
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
switch tc.bodyType {
case 0, 1:
if err == nil {
t.Fatalf("expected error reading body for %s, got none", tc.endpoint)
}
case 2:
if err != nil {
t.Fatalf("error reading body for %s: %s", tc.endpoint, err)
}
}
if string(body) != tc.body {
t.Fatalf("unexpected body for %s: got %q, want %q", tc.endpoint, string(body), tc.body)
}
}
}
// TestReverseProxyUnlimitedRequestBuffersRespectMaxSize verifies that a request
// body cut short by request_body max_size is rejected with 413 instead of being
// forwarded to the upstream as if it were complete.
func TestReverseProxyUnlimitedRequestBuffersRespectMaxSize(t *testing.T) {
var forwarded atomic.Int32
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := io.ReadAll(r.Body); err == nil {
forwarded.Add(1)
}
}),
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); ln.Close() })
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
request_body {
max_size 10
}
reverse_proxy %s {
request_buffers unlimited
}
}
`, ln.Addr().String()), "caddyfile")
req, err := http.NewRequest(http.MethodPost, "http://localhost:9080/", strings.NewReader("abcdefghijklm"))
if err != nil {
t.Fatalf("creating request: %v", err)
}
res := tester.AssertResponseCode(req, http.StatusRequestEntityTooLarge)
res.Body.Close()
if n := forwarded.Load(); n != 0 {
t.Errorf("upstream received %d complete request bodies, want 0", n)
}
}
// TestReverseProxyWebSocketUpgradeUnixSocket verifies that a WebSocket
// upgrade is proxied to a unix socket upstream, that the upgraded
// connection carries data in both directions, and that closing the
// client side tears down the upstream connection
func TestReverseProxyWebSocketUpgradeUnixSocket(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
socketName := tempSocketName(t)
// sent in the same write as the 101 response, so it may arrive in the
// same read as the response headers and must not be dropped by the proxy
const greeting = "hello from upstream"
// closed once the upstream's echo loop ends, i.e. when the proxy
// closes the upstream connection
upstreamDone := make(chan struct{})
// upstream completes the handshake, then echoes everything it reads
backend := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
http.Error(w, "missing websocket upgrade headers", http.StatusBadRequest)
return
}
wsKey := req.Header.Get("Sec-WebSocket-Key")
if wsKey == "" {
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
return
}
conn, brw, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer close(upstreamDone)
defer conn.Close()
_, _ = brw.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: " + computeWebSocketAccept(wsKey) + "\r\n" +
"\r\n" +
greeting)
if brw.Flush() != nil {
return
}
_, _ = io.Copy(conn, brw)
}),
}
unixListener, err := net.Listen("unix", socketName)
if err != nil {
t.Fatalf("failed to listen on unix socket: %v", err)
}
go backend.Serve(unixListener)
t.Cleanup(func() {
_ = backend.Close()
_ = unixListener.Close()
})
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy unix/%s
}
`, socketName), "caddyfile")
conn, err := net.Dial("tcp", "127.0.0.1:9080")
if err != nil {
t.Fatalf("failed to dial caddy listener: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
const wsKey = "dGhlIHNhbXBsZSBub25jZQ=="
if _, err := io.WriteString(conn, "GET /ws HTTP/1.1\r\n"+
"Host: localhost:9080\r\n"+
"Connection: Upgrade\r\n"+
"Upgrade: websocket\r\n"+
"Sec-WebSocket-Version: 13\r\n"+
"Sec-WebSocket-Key: "+wsKey+"\r\n"+
"\r\n"); err != nil {
t.Fatalf("failed to send websocket handshake request: %v", err)
}
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, nil)
if err != nil {
t.Fatalf("failed to read handshake response: %v", err)
}
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("unexpected status: got %d, want %d", resp.StatusCode, http.StatusSwitchingProtocols)
}
if got := resp.Header.Get("Upgrade"); !strings.EqualFold(got, "websocket") {
t.Fatalf("unexpected Upgrade header: %q", got)
}
if got, want := resp.Header.Get("Sec-WebSocket-Accept"), computeWebSocketAccept(wsKey); got != want {
t.Fatalf("unexpected Sec-WebSocket-Accept header: got %q, want %q", got, want)
}
gotGreeting := make([]byte, len(greeting))
if _, err := io.ReadFull(br, gotGreeting); err != nil {
t.Fatalf("failed to read upstream greeting: %v", err)
}
if string(gotGreeting) != greeting {
t.Fatalf("unexpected greeting: got %q, want %q", gotGreeting, greeting)
}
// the payload doesn't need to be framed since the proxy just tunnels
// bytes; make it much larger than any single read or write buffer, and
// write it concurrently so the echo can't back up and deadlock
payload := make([]byte, 256*1024)
_, _ = rand.Read(payload)
writeErr := make(chan error, 1)
go func() {
_, err := conn.Write(payload)
writeErr <- err
}()
echo := make([]byte, len(payload))
if _, err := io.ReadFull(br, echo); err != nil {
t.Fatalf("failed to read echo from upgraded connection: %v", err)
}
if err := <-writeErr; err != nil {
t.Fatalf("failed to write to upgraded connection: %v", err)
}
if !bytes.Equal(echo, payload) {
t.Fatal("echoed payload does not match what was sent")
}
// closing the client should make the proxy close the upstream connection
_ = conn.Close()
select {
case <-upstreamDone:
case <-time.After(5 * time.Second):
t.Fatal("upstream connection was not closed after the client disconnected")
}
}
func computeWebSocketAccept(wsKey string) string {
h := sha1.Sum([]byte(wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
return base64.StdEncoding.EncodeToString(h[:])
}