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