From 9ab83a78a65a22e8f9f4093593d5a74ad3351c48 Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Sat, 5 Sep 2026 23:30:20 +0200 Subject: [PATCH 1/2] feat: support PROXY protocol v1 and v2 --- README.md | 42 +++ config/config.go | 9 + go.mod | 1 + go.sum | 2 + schema.json | 59 +++++ servers/http11/http.go | 37 ++- servers/https/config.go | 13 + servers/https/config_test.go | 12 + servers/https/https.go | 9 +- servers/https/https_test.go | 28 ++ servers/proxyprotocol/http_test.go | 279 ++++++++++++++++++++ servers/proxyprotocol/proxyprotocol.go | 82 ++++++ servers/proxyprotocol/proxyprotocol_test.go | 273 +++++++++++++++++++ tests/go.mod | 1 + tests/go.sum | 2 + tests/proxy_protocol_test.go | 234 ++++++++++++++++ 16 files changed, 1067 insertions(+), 16 deletions(-) create mode 100644 servers/proxyprotocol/http_test.go create mode 100644 servers/proxyprotocol/proxyprotocol.go create mode 100644 servers/proxyprotocol/proxyprotocol_test.go create mode 100644 tests/proxy_protocol_test.go diff --git a/README.md b/README.md index ab736399..5464b667 100644 --- a/README.md +++ b/README.md @@ -1 +1,43 @@ # Docs: [link](https://docs.roadrunner.dev/http/http) + +## PROXY Protocol + +Enable PROXY protocol v1/v2 independently for plain HTTP and HTTPS TCP listeners: + +```yaml +http: + address: 0.0.0.0:8080 + proxy_protocol: + trusted_proxies: ["10.20.0.0/24"] + read_header_timeout: 5s + ssl: + address: 0.0.0.0:8443 + cert: server.pem + key: server-key.pem + proxy_protocol: + trusted_proxies: ["10.30.0.10"] + read_header_timeout: 5s +``` + +Omit a block to leave that listener unchanged. When enabled, `trusted_proxies` +must explicitly list the immediate proxies' IP addresses or CIDRs. Trusted peers +must send a PROXY header, including health checks; all other connections are +dropped. There is no mixed direct/proxied mode on an enabled listener. The header +timeout defaults to 5s when omitted or zero; negative values are invalid. + +For HTTPS, send the PROXY header **before** the TLS handshake. HTTP/1.1, h2c, +TLS HTTP/2, and existing Go-middleware WebSocket upgrades retain their normal +behavior. TCP4/TCP6 headers set the client address seen by handlers, access logs, +and PHP's `REMOTE_ADDR`. Valid v1 `UNKNOWN` and v2 `LOCAL` headers instead retain +the socket addresses. TLVs are ignored; they do not change TLS state or URL scheme. +PROXY metadata applies to the whole connection, so a proxy must not multiplex +different client identities onto one backend connection. + +These options do not affect FastCGI, HTTP/3, or CertMagic's temporary ACME challenge +listeners. Route challenge traffic without PROXY headers to those listeners. +`proxy_ip_parser` and `http.trusted_subnets` remain separate HTTP forwarding-header +settings. With PROXY enabled, `RemoteAddr` identifies the advertised client rather +than the immediate proxy; account for that when configuring forwarding-header trust. + +The parser (`go-proxyproto` v0.15.0) may reject fragmented v1 headers and limits +the v2 address/TLV payload to 4096 bytes. Prefer v2 where the proxy supports it. diff --git a/config/config.go b/config/config.go index adc1c0ca..49771950 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "github.com/roadrunner-server/http/v6/servers/fcgi" "github.com/roadrunner-server/http/v6/servers/http3" "github.com/roadrunner-server/http/v6/servers/https" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" "github.com/roadrunner-server/errors" "github.com/roadrunner-server/pool/v2/pool" @@ -17,6 +18,8 @@ type Config struct { RawBody bool `mapstructure:"raw_body"` // Host and port to handle as http server. Address string `mapstructure:"address"` + // ProxyProtocol applies only to the plain HTTP listener. + ProxyProtocol *proxyprotocol.Config `mapstructure:"proxy_protocol"` // AccessLogs turn on/off, logged at Info log level, default: false AccessLogs bool `mapstructure:"access_logs"` // List of the middleware names (order will be preserved) @@ -73,6 +76,12 @@ func (c *Config) EnableFCGI() bool { // InitDefaults must populate HTTP values using given HTTP source. Must return error if HTTP is not valid. func (c *Config) InitDefaults() error { + if c.ProxyProtocol != nil { + if err := c.ProxyProtocol.InitDefaults(c.Address); err != nil { + return errors.E(errors.Op("http.proxy_protocol"), err) + } + } + if c.Pool == nil { c.Pool = &pool.Config{} } diff --git a/go.mod b/go.mod index 27bf9a8c..f2d6a525 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/caddyserver/certmagic v0.25.4 github.com/google/go-cmp v0.7.0 github.com/mholt/acmez v1.2.0 + github.com/pires/go-proxyproto v0.15.0 github.com/prometheus/client_golang v1.24.1 github.com/quic-go/quic-go v0.62.0 github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14 diff --git a/go.sum b/go.sum index 101236da..bbd58e55 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= diff --git a/schema.json b/schema.json index d26610a9..5b7fdd6f 100644 --- a/schema.json +++ b/schema.json @@ -5,6 +5,11 @@ "title": "roadrunner-http", "type": "object", "additionalProperties": false, + "dependentRequired": { + "proxy_protocol": [ + "address" + ] + }, "properties": { "address": { "description": "Host and/or port to listen on for HTTP traffic. If omitted, RoadRunner will not listen for HTTP requests.", @@ -22,6 +27,10 @@ "minimum": 100, "maximum": 599 }, + "proxy_protocol": { + "description": "Requires PROXY protocol on the plain HTTP TCP listener only. Omit to disable.", + "$ref": "#/$defs/ProxyProtocol" + }, "max_request_size": { "description": "Maximum request size in MB. Defaults to 1 GB if zero or omitted.", "type": "integer", @@ -205,6 +214,35 @@ } }, "$defs": { + "ProxyProtocol": { + "description": "PROXY protocol v1/v2. Trusted peers must send a header; every other peer is rejected. TCP4/TCP6 headers supply client addresses; UNKNOWN/LOCAL retain socket addresses.", + "type": "object", + "additionalProperties": false, + "required": [ + "trusted_proxies" + ], + "properties": { + "trusted_proxies": { + "description": "Explicit IP addresses or CIDRs of immediate TCP peers allowed to send PROXY headers. This is separate from trust for HTTP forwarding headers.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "examples": [ + "10.20.0.10", + "10.20.0.0/24", + "2001:db8::/32" + ] + } + }, + "read_header_timeout": { + "description": "Time allowed to read the PROXY header. Omitted or zero means 5s; negative values are invalid. Does not control HTTP or TLS timeouts.", + "$ref": "https://raw.githubusercontent.com/roadrunner-server/roadrunner/refs/heads/master/schemas/config/3.0.schema.json#/definitions/Duration", + "default": "5s" + } + } + }, "Uploads": { "type": "object", "additionalProperties": false, @@ -250,6 +288,23 @@ "description": "Settings required to set up manual or automatic HTTPS for your server. Either `key` and `cert` *or* `acme` is required, but not both.", "type": "object", "additionalProperties": false, + "dependentSchemas": { + "proxy_protocol": { + "anyOf": [ + { + "required": [ + "key", + "cert" + ] + }, + { + "required": [ + "acme" + ] + } + ] + } + }, "dependentRequired": { "key": [ "cert" @@ -326,6 +381,10 @@ "email" ] }, + "proxy_protocol": { + "description": "Requires a PROXY header before TLS on the application HTTPS listener only. Does not wrap temporary ACME challenge listeners. Omit to disable.", + "$ref": "#/$defs/ProxyProtocol" + }, "redirect": { "description": "Whether to automatically redirect from HTTP to HTTPS.", "type": "boolean", diff --git a/servers/http11/http.go b/servers/http11/http.go index f7462d74..9394e3bf 100644 --- a/servers/http11/http.go +++ b/servers/http11/http.go @@ -16,14 +16,16 @@ import ( "github.com/roadrunner-server/errors" "github.com/roadrunner-server/http/v6/config" "github.com/roadrunner-server/http/v6/middleware" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" ) type Server struct { - log *slog.Logger - http *http.Server - address string - redirect bool - redirectPort int + log *slog.Logger + http *http.Server + address string + redirect bool + redirectPort int + proxyProtocol *proxyprotocol.Config } func NewHTTPServer(handler http.Handler, cfg *config.Config, errLog *log.Logger, log *slog.Logger) servers.InternalServer[any] { @@ -40,10 +42,11 @@ func NewHTTPServer(handler http.Handler, cfg *config.Config, errLog *log.Logger, protocols.SetHTTP1(true) protocols.SetUnencryptedHTTP2(true) return &Server{ - log: log, - redirect: redirect, - redirectPort: redirectPort, - address: cfg.Address, + log: log, + redirect: redirect, + redirectPort: redirectPort, + address: cfg.Address, + proxyProtocol: cfg.ProxyProtocol, http: &http.Server{ Handler: handler, Protocols: protocols, @@ -57,10 +60,11 @@ func NewHTTPServer(handler http.Handler, cfg *config.Config, errLog *log.Logger, } } return &Server{ - log: log, - redirect: redirect, - redirectPort: redirectPort, - address: cfg.Address, + log: log, + redirect: redirect, + redirectPort: redirectPort, + address: cfg.Address, + proxyProtocol: cfg.ProxyProtocol, http: &http.Server{ ReadTimeout: time.Minute * 5, WriteTimeout: time.Minute * 5, @@ -89,9 +93,14 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { if err != nil { return errors.E(op, err) } + defer func() { _ = l.Close() }() + listener, err := s.proxyProtocol.Wrap(l) + if err != nil { + return errors.E(op, err) + } s.log.Debug("http server was started", "address", s.address) - err = s.http.Serve(l) + err = s.http.Serve(listener) if err != nil && !stderr.Is(err, http.ErrServerClosed) { return errors.E(op, err) } diff --git a/servers/https/config.go b/servers/https/config.go index e20c429e..5acbe481 100644 --- a/servers/https/config.go +++ b/servers/https/config.go @@ -9,6 +9,7 @@ import ( rrerrors "github.com/roadrunner-server/errors" "github.com/roadrunner-server/http/v6/acme" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" ) type ClientAuthType string @@ -47,6 +48,8 @@ func (h2 *HTTP2) EnableHTTP2() bool { type SSL struct { // Address to listen as HTTPS server, defaults to 0.0.0.0:443. Address string + // ProxyProtocol is read before TLS on the application listener, not ACME challenge listeners. + ProxyProtocol *proxyprotocol.Config `mapstructure:"proxy_protocol"` // ACME configuration Acme *acme.Config `mapstructure:"acme"` // Redirect when enabled forces all http connections to switch to https. @@ -86,6 +89,16 @@ func (s *SSL) InitDefaults() error { s.Address = "127.0.0.1:443" } + if s.ProxyProtocol != nil { + const op = rrerrors.Op("http.ssl.proxy_protocol") + if s.Acme == nil && (s.Cert == "" || s.Key == "") { + return rrerrors.E(op, "requires an enabled HTTPS listener (cert/key or acme)") + } + if err := s.ProxyProtocol.InitDefaults(s.Address); err != nil { + return rrerrors.E(op, err) + } + } + return nil } diff --git a/servers/https/config_test.go b/servers/https/config_test.go index 0ad8b850..6ea4e00e 100644 --- a/servers/https/config_test.go +++ b/servers/https/config_test.go @@ -3,8 +3,10 @@ package https import ( "path/filepath" "testing" + "time" "github.com/roadrunner-server/http/v6/acme" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -205,6 +207,16 @@ func TestSSL_InitDefaultsACME(t *testing.T) { assert.Equal(t, "rr_cache_dir", valid.Acme.CacheDir) } +func TestSSL_InitDefaultsACMEProxyProtocol(t *testing.T) { + cfg := &SSL{ + Acme: &acme.Config{Email: "user@example.com", Domains: []string{"example.com"}}, + ProxyProtocol: &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1"}}, + } + + require.NoError(t, cfg.InitDefaults()) + assert.Equal(t, 5*time.Second, cfg.ProxyProtocol.ReadHeaderTimeout) +} + func TestSSL_EnableACME(t *testing.T) { assert.False(t, (*SSL)(nil).EnableACME()) assert.False(t, (&SSL{}).EnableACME()) diff --git a/servers/https/https.go b/servers/https/https.go index 625deba1..61c867cd 100644 --- a/servers/https/https.go +++ b/servers/https/https.go @@ -105,6 +105,11 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { if err != nil { return errors.E(op, err) } + defer func() { _ = l.Close() }() + listener, err := s.cfg.ProxyProtocol.Wrap(l) + if err != nil { + return errors.E(op, err) + } /* ACME powered server @@ -112,7 +117,7 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { if s.cfg.EnableACME() { s.log.Debug("https(acme) server was started", "address", s.cfg.Address) err = s.https.ServeTLS( - l, + listener, "", "", ) @@ -125,7 +130,7 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { s.log.Debug("https server was started", "address", s.cfg.Address) err = s.https.ServeTLS( - l, + listener, s.cfg.Cert, s.cfg.Key, ) diff --git a/servers/https/https_test.go b/servers/https/https_test.go index f07700b1..657eb201 100644 --- a/servers/https/https_test.go +++ b/servers/https/https_test.go @@ -20,6 +20,7 @@ import ( "time" "github.com/roadrunner-server/http/v6/api" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -286,3 +287,30 @@ func TestServeBadAddress(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "invalid Protocol") } + +func TestServeClosesListenerOnSetupError(t *testing.T) { + for _, mode := range []string{"disabled", "enabled", "uninitialized"} { + t.Run(mode, func(t *testing.T) { + listener, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + address := listener.Addr().String() + require.NoError(t, listener.Close()) + + cfg := &SSL{Address: address, Cert: "missing.pem", Key: "missing.key"} + if mode != "disabled" { + cfg.ProxyProtocol = &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1"}} + if mode == "enabled" { + require.NoError(t, cfg.InitDefaults()) + } + } + srv, err := NewHTTPSServer(http.NotFoundHandler(), cfg, nil, nil, discardLogger()) + require.NoError(t, err) + require.Error(t, srv.Serve(nil, nil)) + + // ServeTLS can fail before net/http takes ownership of the listener. + listener, err = new(net.ListenConfig).Listen(t.Context(), "tcp", address) + require.NoError(t, err) + require.NoError(t, listener.Close()) + }) + } +} diff --git a/servers/proxyprotocol/http_test.go b/servers/proxyprotocol/http_test.go new file mode 100644 index 00000000..bdf29ae2 --- /dev/null +++ b/servers/proxyprotocol/http_test.go @@ -0,0 +1,279 @@ +package proxyprotocol_test + +import ( + "bufio" + "crypto/tls" + "errors" + "io" + "log" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/roadrunner-server/http/v6/handler" + "github.com/roadrunner-server/http/v6/middleware" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/websocket" +) + +func TestHTTPRejectsPeers(t *testing.T) { + for _, scheme := range []string{"http", "https"} { + for _, tt := range []struct { + name, preface string + untrusted, request bool + }{ + {"malformed", "PROXY TCP4 invalid 198.51.100.2 12345 443\r\n", false, true}, + {"headerless", "", false, true}, + {"untrusted", proxyLine, true, true}, + {"silent", "", false, false}, + {"partialv2", v2Frame(0x21, 0x11, strings.Repeat("\x00", 12))[:20], false, false}, + } { + t.Run(scheme+"/"+tt.name, func(t *testing.T) { + var calls atomic.Int32 + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(ts.Close) + ts.Config.ErrorLog = log.New(io.Discard, "", 0) + cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1", "::1"}, ReadHeaderTimeout: 100 * time.Millisecond} + if tt.untrusted { + cfg.TrustedProxies = []string{"192.0.2.1"} // Trusting the advertised client must not trust the socket peer. + } + require.NoError(t, cfg.InitDefaults(ts.Listener.Addr().String())) + var err error + ts.Listener, err = cfg.Wrap(ts.Listener) + require.NoError(t, err) + if scheme == "https" { + ts.StartTLS() + } else { + ts.Start() + } + conn := dialHTTPPeer(t, ts) + wire := tt.preface + if tt.request && scheme == "http" { + wire += payload + } + if wire != "" { + _, err = io.WriteString(conn, wire) + if !tt.untrusted { // An untrusted socket can be closed before its first write. + require.NoError(t, err) + } + } + if tt.request && scheme == "https" { + err = tlsHTTPPeer(t, ts, conn).HandshakeContext(t.Context()) + require.Error(t, err, "PROXY must precede TLS, not be parsed inside it") + } + assertPeerClosed(t, conn) + ts.Close() + assert.Zero(t, calls.Load()) + }) + } + } +} + +func TestHTTPSlowPeerAndClose(t *testing.T) { + for _, scheme := range []string{"http", "https"} { + t.Run(scheme, func(t *testing.T) { + accepted, finished := make(chan struct{}, 2), make(chan struct{}, 2) + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Remote-Addr", r.RemoteAddr) + w.Header().Set("X-Request-URI", handler.URI(r)) + })) + t.Cleanup(ts.Close) + ts.Config.ConnState = func(_ net.Conn, state http.ConnState) { + // RemoteAddr here would parse the header in the accept loop and block it. + if state == http.StateNew { + accepted <- struct{}{} + } + if state == http.StateClosed { + finished <- struct{}{} + } + } + cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1", "::1"}, ReadHeaderTimeout: 30 * time.Second} + require.NoError(t, cfg.InitDefaults(ts.Listener.Addr().String())) + var err error + ts.Listener, err = cfg.Wrap(ts.Listener) + require.NoError(t, err) + if scheme == "https" { + ts.StartTLS() + } else { + ts.Start() + } + slow := dialHTTPPeer(t, ts) + select { + case <-accepted: + case <-time.After(5 * time.Second): + t.Fatal("silent peer was not accepted") + } + assertHTTPResponse(t, proxyHTTPPeer(t, ts)) // Its 5s deadline is far below the pending header's 30s timeout. + select { + case <-finished: + t.Fatal("a connection closed before Server.Close") + default: + } + closed := make(chan error, 1) + go func() { closed <- ts.Config.Close() }() + select { + case err := <-closed: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Server.Close blocked on a pending PROXY header") + } + assertPeerClosed(t, slow) + for range 2 { + select { + case <-finished: + case <-time.After(5 * time.Second): + t.Fatal("connection goroutine did not finish after Server.Close") + } + } + }) + } +} + +func TestHTTPHeaderDeadlineRestored(t *testing.T) { + for _, scheme := range []string{"http", "https"} { + t.Run(scheme, func(t *testing.T) { + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Remote-Addr", r.RemoteAddr) + w.Header().Set("X-Request-URI", handler.URI(r)) + })) + t.Cleanup(ts.Close) + cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1", "::1"}, ReadHeaderTimeout: 100 * time.Millisecond} + require.NoError(t, cfg.InitDefaults(ts.Listener.Addr().String())) + var err error + ts.Listener, err = cfg.Wrap(ts.Listener) + require.NoError(t, err) + if scheme == "https" { + ts.StartTLS() + } else { + ts.Start() + } + conn := proxyHTTPPeer(t, ts) + assertHTTPResponse(t, conn) + // The response synchronizes completed header processing; this timer tests deadline expiry, not packet ordering. + timer := time.NewTimer(2 * cfg.ReadHeaderTimeout) + defer timer.Stop() + <-timer.C + assertHTTPResponse(t, conn) // Same socket, without another PROXY header. + }) + } +} + +func TestHTTPWebSocket(t *testing.T) { + for _, scheme := range []string{"ws", "wss"} { + t.Run(scheme, func(t *testing.T) { + done := make(chan error, 1) + handler := websocket.Handler(func(ws *websocket.Conn) { + err := ws.SetDeadline(time.Now().Add(5 * time.Second)) + var text string + if err == nil { + err = websocket.Message.Receive(ws, &text) + } + if err == nil { + err = websocket.Message.Send(ws, ws.Request().RemoteAddr+" "+text) + } + done <- err + }) + ts := httptest.NewUnstartedServer(middleware.NewLogMiddleware(handler, true, slog.New(slog.NewTextHandler(io.Discard, nil)))) + t.Cleanup(ts.Close) + cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1", "::1"}} + require.NoError(t, cfg.InitDefaults(ts.Listener.Addr().String())) + var err error + ts.Listener, err = cfg.Wrap(ts.Listener) + require.NoError(t, err) + if scheme == "wss" { + ts.StartTLS() + } else { + ts.Start() + } + conn := proxyHTTPPeer(t, ts) + wsCfg, err := websocket.NewConfig(scheme+"://"+ts.Listener.Addr().String()+"/", ts.URL) + require.NoError(t, err) + ws, err := websocket.NewClient(wsCfg, conn) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + select { + case err := <-done: + assert.NoError(t, err) + case <-time.After(5 * time.Second): + t.Error("hijacked WebSocket handler did not finish") + } + }) + require.NoError(t, websocket.Message.Send(ws, "hello")) + var text string + require.NoError(t, websocket.Message.Receive(ws, &text)) + assert.Equal(t, "192.0.2.1:12345 hello", text) + }) + } +} + +func dialHTTPPeer(t *testing.T, ts *httptest.Server) net.Conn { + t.Helper() + conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(t.Context(), "tcp", ts.Listener.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + return conn +} + +func tlsHTTPPeer(t *testing.T, ts *httptest.Server, conn net.Conn) *tls.Conn { + t.Helper() + cfg := ts.Client().Transport.(*http.Transport).TLSClientConfig.Clone() + var err error + cfg.ServerName, _, err = net.SplitHostPort(ts.Listener.Addr().String()) + require.NoError(t, err) + return tls.Client(conn, cfg) +} + +func proxyHTTPPeer(t *testing.T, ts *httptest.Server) net.Conn { + t.Helper() + conn := dialHTTPPeer(t, ts) + // The SSL TLV claims TLS and certificate verification, but is not authentication. + addresses := "\xc0\x00\x02\x01\xc6\x33\x64\x02\x30\x39\x01\xbb" + _, err := io.WriteString(conn, v2Frame(0x21, 0x11, addresses+"\x20\x00\x05\x07\x00\x00\x00\x00")) + require.NoError(t, err) + if ts.TLS != nil { + secure := tlsHTTPPeer(t, ts, conn) + require.NoError(t, secure.HandshakeContext(t.Context())) + conn = secure + } + return conn +} + +func assertPeerClosed(t *testing.T, conn net.Conn) { + t.Helper() + n, err := conn.Read(make([]byte, 1)) + require.Zero(t, n, "rejected peer received application bytes") + require.Error(t, err) + var timeout net.Error + require.False(t, errors.As(err, &timeout) && timeout.Timeout(), "client safety deadline is not a peer close: %v", err) +} + +func assertHTTPResponse(t *testing.T, conn net.Conn) { + t.Helper() + _, err := io.WriteString(conn, payload) + require.NoError(t, err) + res, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + defer func() { _ = res.Body.Close() }() + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "192.0.2.1:12345", res.Header.Get("X-Remote-Addr")) + scheme := "http" + if _, ok := conn.(*tls.Conn); ok { + scheme = "https" + } + assert.Equal(t, scheme+"://example.test/", res.Header.Get("X-Request-URI")) + assert.Empty(t, body) +} diff --git a/servers/proxyprotocol/proxyprotocol.go b/servers/proxyprotocol/proxyprotocol.go new file mode 100644 index 00000000..ab005dfd --- /dev/null +++ b/servers/proxyprotocol/proxyprotocol.go @@ -0,0 +1,82 @@ +package proxyprotocol + +import ( + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "github.com/pires/go-proxyproto" +) + +// Config enables PROXY protocol on one TCP application listener. +type Config struct { + TrustedProxies []string `mapstructure:"trusted_proxies"` + ReadHeaderTimeout time.Duration `mapstructure:"read_header_timeout"` + + policy proxyproto.ConnPolicyFunc +} + +// InitDefaults validates the listener address and compiles its trusted-peer policy. +func (c *Config) InitDefaults(address string) error { + c.policy = nil + _, port, err := net.SplitHostPort(strings.TrimPrefix(address, "tcp://")) + if err != nil { + return fmt.Errorf("requires a TCP listen address: %w", err) + } + if _, err = strconv.ParseUint(port, 10, 16); err != nil { + return fmt.Errorf("invalid TCP listen port: %w", err) + } + if len(c.TrustedProxies) == 0 { + return errors.New("trusted_proxies must contain at least one IP address or CIDR") + } + if c.ReadHeaderTimeout < 0 { + return errors.New("read_header_timeout must not be negative") + } + if c.ReadHeaderTimeout == 0 { + c.ReadHeaderTimeout = 5 * time.Second + } + + policy, err := proxyproto.TrustProxyHeaderFromRanges(c.TrustedProxies) + if err != nil { + return fmt.Errorf("trusted_proxies: %w", err) + } + c.policy = func(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + // Match the kernel peer's IP, not its interface zone (which the policy cannot parse). + if addr, ok := opts.Upstream.(*net.TCPAddr); ok && addr.Zone != "" { + peer := *addr + peer.Zone = "" + opts.Upstream = &peer + } + return policy(opts) + } + return nil +} + +// Wrap leaves a nil configuration disabled. Otherwise InitDefaults must have succeeded. +func (c *Config) Wrap(listener net.Listener) (net.Listener, error) { + if c == nil { + return listener, nil + } + if c.policy == nil { + return nil, errors.New("proxy_protocol configuration is not initialized") + } + if listener.Addr().Network() != "tcp" { + return nil, errors.New("proxy_protocol requires a TCP listener") + } + + return &proxyproto.Listener{ + Listener: listener, + ConnPolicy: c.policy, + ReadHeaderTimeout: c.ReadHeaderTimeout, + ValidateHeader: func(h *proxyproto.Header) error { + // LOCAL (including v1 UNKNOWN) retains socket addresses, regardless of transport. + if h.Command.IsLocal() || h.TransportProtocol == proxyproto.TCPv4 || h.TransportProtocol == proxyproto.TCPv6 { + return nil + } + return errors.New("proxy_protocol requires TCP4 or TCP6 client addresses") + }, + }, nil +} diff --git a/servers/proxyprotocol/proxyprotocol_test.go b/servers/proxyprotocol/proxyprotocol_test.go new file mode 100644 index 00000000..86710dc5 --- /dev/null +++ b/servers/proxyprotocol/proxyprotocol_test.go @@ -0,0 +1,273 @@ +package proxyprotocol_test + +import ( + "errors" + "io" + "net" + "strings" + "testing" + "time" + + proxyproto "github.com/pires/go-proxyproto" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + proxyLine = "PROXY TCP4 192.0.2.1 198.51.100.2 12345 443\r\n" + payload = "GET / HTTP/1.1\r\nHost: example.test\r\n\r\n" +) + +func TestConfigInitDefaults(t *testing.T) { + for _, tt := range []struct { + name string + proxies []string + address string + timeout time.Duration + invalid bool + }{ + {"ipv4", []string{"127.0.0.1"}, "127.0.0.1:8080", 0, false}, + {"ipv6", []string{"::1"}, "[::1]:8080", 0, false}, + {"cidrs and tcp prefix", []string{"192.0.2.0/24", "2001:db8::/32"}, "tcp://127.0.0.1:8080", 0, false}, + {"wildcard and explicit timeout", []string{"127.0.0.1"}, ":0", time.Second, false}, + {"empty list", nil, ":8080", 0, true}, + {"empty entry", []string{""}, ":8080", 0, true}, + {"blank entry", []string{" "}, ":8080", 0, true}, + {"hostname", []string{"localhost"}, ":8080", 0, true}, + {"invalid ip", []string{"999.0.0.1"}, ":8080", 0, true}, + {"invalid cidr", []string{"127.0.0.1/33"}, ":8080", 0, true}, + {"mixed valid and invalid", []string{"127.0.0.1", "invalid"}, ":8080", 0, true}, + {"negative timeout", []string{"127.0.0.1"}, ":8080", -time.Second, true}, + {"empty address", []string{"127.0.0.1"}, "", 0, true}, + {"missing port", []string{"127.0.0.1"}, "127.0.0.1", 0, true}, + {"invalid port", []string{"127.0.0.1"}, "127.0.0.1:invalid", 0, true}, + {"port out of range", []string{"127.0.0.1"}, "127.0.0.1:65536", 0, true}, + {"unix address", []string{"127.0.0.1"}, "unix:///tmp/proxy.sock", 0, true}, + {"udp address", []string{"127.0.0.1"}, "udp://127.0.0.1:8080", 0, true}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &proxyprotocol.Config{TrustedProxies: tt.proxies, ReadHeaderTimeout: tt.timeout} + err := cfg.InitDefaults(tt.address) + if tt.invalid { + require.Error(t, err) + _, err = cfg.Wrap(&scriptedListener{addr: &net.TCPAddr{}}) + assert.Error(t, err, "failed initialization must not enable the listener") + return + } + require.NoError(t, err) + want := tt.timeout + if want == 0 { + want = 5 * time.Second + } + assert.Equal(t, want, cfg.ReadHeaderTimeout) + }) + } +} + +func TestConfigWrap(t *testing.T) { + raw := tcpListener(t) + unix := &scriptedListener{addr: &net.UnixAddr{Net: "unix", Name: "unused"}} + for _, ln := range []net.Listener{raw, unix} { + wrapped, err := (*proxyprotocol.Config)(nil).Wrap(ln) + require.NoError(t, err) + assert.Same(t, ln, wrapped) + } + + cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1"}, ReadHeaderTimeout: time.Second} + _, err := cfg.Wrap(raw) + require.Error(t, err, "populated but uninitialized configuration must fail closed") + + for _, timeout := range []time.Duration{0, 123 * time.Millisecond} { + cfg.ReadHeaderTimeout = timeout + require.NoError(t, cfg.InitDefaults(raw.Addr().String())) + wrapped, err := cfg.Wrap(raw) + require.NoError(t, err) + require.IsType(t, &proxyproto.Listener{}, wrapped) + assert.Equal(t, cfg.ReadHeaderTimeout, wrapped.(*proxyproto.Listener).ReadHeaderTimeout) + assert.Equal(t, raw.Addr(), wrapped.Addr()) + } + _, err = cfg.Wrap(unix) + assert.Error(t, err) +} + +func TestWrapHeaders(t *testing.T) { + ipv4 := string(net.ParseIP("192.0.2.1").To4()) + string(net.ParseIP("198.51.100.2").To4()) + "\x30\x39\x01\xbb" + ipv6 := string(net.ParseIP("2001:db8::1").To16()) + string(net.ParseIP("2001:db8::2").To16()) + "\x30\x39\x01\xbb" + unix := strings.Repeat("\x00", 216) + for _, tt := range []struct { + name, header, remote, local string + reject bool + }{ + {"v1 ipv4", proxyLine, "192.0.2.1:12345", "198.51.100.2:443", false}, + {"v1 ipv6", "PROXY TCP6 2001:db8::1 2001:db8::2 12345 443\r\n", "[2001:db8::1]:12345", "[2001:db8::2]:443", false}, + {"v2 ipv4", v2Frame(0x21, 0x11, ipv4), "192.0.2.1:12345", "198.51.100.2:443", false}, + {"v2 ipv6", v2Frame(0x21, 0x21, ipv6), "[2001:db8::1]:12345", "[2001:db8::2]:443", false}, + {"v2 opaque tlv", v2Frame(0x21, 0x11, ipv4+"\xe0\x00\x03abc"), "192.0.2.1:12345", "198.51.100.2:443", false}, + {"v1 unknown", "PROXY UNKNOWN\r\n", "", "", false}, + {"v2 local", v2Frame(0x20, 0x00, ""), "", "", false}, + {"v2 local opaque payload", v2Frame(0x20, 0x00, "\xff\x00opaque"), "", "", false}, + {"v2 local ignores advertised transport", v2Frame(0x20, 0x12, ipv4), "", "", false}, + {"headerless", "", "", "", true}, + {"malformed", "PROXY TCP4 invalid 198.51.100.2 12345 443\r\n", "", "", true}, + {"udp4", v2Frame(0x21, 0x12, ipv4), "", "", true}, + {"udp6", v2Frame(0x21, 0x22, ipv6), "", "", true}, + {"unix stream", v2Frame(0x21, 0x31, unix), "", "", true}, + {"unix datagram", v2Frame(0x21, 0x32, unix), "", "", true}, + } { + t.Run(tt.name, func(t *testing.T) { + server, client := tcpPair(t, time.Second) + _, err := io.WriteString(client, tt.header+payload) + require.NoError(t, err) + // Half-close complete input so parser rejection cannot be a read timeout. + require.NoError(t, client.CloseWrite()) + body, err := io.ReadAll(server) + if tt.reject { + require.Error(t, err) + assert.Empty(t, body, "rejected connections must not expose application bytes") + var timeout net.Error + if errors.As(err, &timeout) { + assert.False(t, timeout.Timeout(), "a safety deadline is not a rejection") + } + return + } + require.NoError(t, err) + assert.Equal(t, payload, string(body), "only the coalesced application payload should remain") + remote, local := tt.remote, tt.local + if remote == "" { + remote, local = client.LocalAddr().String(), client.RemoteAddr().String() + } + assert.Equal(t, remote, server.RemoteAddr().String()) + assert.Equal(t, local, server.LocalAddr().String()) + }) + } +} + +func TestWrapHeaderTimeout(t *testing.T) { + server, _ := tcpPair(t, 50*time.Millisecond) + start := time.Now() + n, err := server.Read(make([]byte, 1)) + require.Error(t, err) + assert.Zero(t, n) + assert.Less(t, time.Since(start), 2*time.Second, "header timeout must fire before the 5s safety deadline") +} + +func TestWrapDropsUntrustedAndContinues(t *testing.T) { + for _, tt := range []struct { + name, trusted, good, bad, zone string + }{ + {"ipv4 literal", "192.0.2.10", "192.0.2.10", "192.0.2.11", ""}, + {"ipv4 cidr", "192.0.2.0/24", "192.0.2.10", "198.51.100.10", ""}, + {"ipv6 literal", "2001:db8::10", "2001:db8::10", "2001:db8::11", ""}, + {"ipv6 cidr", "2001:db8:1::/48", "2001:db8:1::10", "2001:db8:2::10", ""}, + {"scoped ipv6 literal", "fe80::10", "fe80::10", "fe80::11", "eth0"}, + {"scoped ipv6 cidr", "fe80::/64", "fe80::10", "fe80:0:0:1::10", "eth0"}, + } { + t.Run(tt.name, func(t *testing.T) { + ln := &scriptedListener{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8080}} + t.Cleanup(func() { _ = ln.Close() }) + writes := make([]<-chan error, 0, 3) + for _, peer := range []struct{ ip, wire string }{ + {tt.bad, payload}, + {tt.bad, proxyLine + payload}, + {tt.good, proxyLine + payload}, + } { + server, client := net.Pipe() + t.Cleanup(func() { _ = client.Close() }) + ln.conns = append(ln.conns, &addrConn{ + Conn: server, local: ln.addr, + remote: &net.TCPAddr{IP: net.ParseIP(peer.ip), Port: 1234, Zone: tt.zone}, + }) + require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second))) + written := make(chan error, 1) + writes = append(writes, written) + go func() { + _, err := io.WriteString(client, peer.wire) + written <- err + }() + } + cfg := &proxyprotocol.Config{ + TrustedProxies: []string{"203.0.113.254", tt.trusted}, ReadHeaderTimeout: time.Second, + } + require.NoError(t, cfg.InitDefaults(ln.Addr().String())) + wrapped, err := cfg.Wrap(ln) + require.NoError(t, err) + conn, err := wrapped.Accept() + require.NoError(t, err, "dropping peers must not terminate Accept") + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + body := make([]byte, len(payload)) + _, err = io.ReadFull(conn, body) + require.NoError(t, err) + assert.Equal(t, payload, string(body)) + assert.Equal(t, "192.0.2.1:12345", conn.RemoteAddr().String()) + for i, written := range writes { + if i < 2 { + assert.ErrorIs(t, <-written, io.ErrClosedPipe, "untrusted peers must be closed, not time out") + } else { + assert.NoError(t, <-written) + } + } + }) + } +} + +func v2Frame(command, transport byte, body string) string { + return "\r\n\r\n\x00\r\nQUIT\n" + string([]byte{command, transport, byte((len(body) >> 8) & 0xff), byte(len(body) & 0xff)}) + body +} + +func tcpListener(t *testing.T) net.Listener { + t.Helper() + ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + require.NoError(t, ln.(*net.TCPListener).SetDeadline(time.Now().Add(5*time.Second))) + return ln +} + +func tcpPair(t *testing.T, timeout time.Duration) (net.Conn, *net.TCPConn) { + t.Helper() + ln := tcpListener(t) + cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1"}, ReadHeaderTimeout: timeout} + require.NoError(t, cfg.InitDefaults(ln.Addr().String())) + wrapped, err := cfg.Wrap(ln) + require.NoError(t, err) + client, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(t.Context(), "tcp", ln.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second))) + server, err := wrapped.Accept() + require.NoError(t, err) + t.Cleanup(func() { _ = server.Close() }) + require.NoError(t, server.SetDeadline(time.Now().Add(5*time.Second))) + return server, client.(*net.TCPConn) +} + +type addrConn struct { + net.Conn + local, remote net.Addr +} + +func (c *addrConn) LocalAddr() net.Addr { return c.local } +func (c *addrConn) RemoteAddr() net.Addr { return c.remote } + +type scriptedListener struct { + addr net.Addr + conns []net.Conn + next int +} + +func (l *scriptedListener) Addr() net.Addr { return l.addr } +func (l *scriptedListener) Close() error { + for _, conn := range l.conns { + _ = conn.Close() + } + return nil +} +func (l *scriptedListener) Accept() (net.Conn, error) { + if l.next == len(l.conns) { + return nil, net.ErrClosed + } + conn := l.conns[l.next] + l.next++ + return conn, nil +} diff --git a/tests/go.mod b/tests/go.mod index 14fc0819..69e24eb0 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -58,6 +58,7 @@ require ( github.com/miekg/dns v1.1.73 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect github.com/prometheus/client_golang v1.24.1 // indirect github.com/prometheus/client_model v0.6.3 // indirect github.com/prometheus/common v0.71.0 // indirect diff --git a/tests/go.sum b/tests/go.sum index d2531a2c..33d06ea4 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -76,6 +76,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= diff --git a/tests/proxy_protocol_test.go b/tests/proxy_protocol_test.go new file mode 100644 index 00000000..12a3d645 --- /dev/null +++ b/tests/proxy_protocol_test.go @@ -0,0 +1,234 @@ +package tests + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "tests/helpers" + + httpPlugin "github.com/roadrunner-server/http/v6" + "github.com/roadrunner-server/server/v6" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +const proxyProtocolConfig = `version: "3" +server: + command: "php php_test_files/http/client.php ip pipes" + relay: pipes +http: + pool: + num_workers: 1 + destroy_timeout: 1s +%s` + +func TestProxyProtocolServe(t *testing.T) { + cert, key, roots := proxyProtocolTLS(t) + type request struct{ name, protocol, header, remoteAddr string } + for _, tt := range []struct { + name string + plainProxy, sslProxy, h2c bool + requests []request + }{ + { + name: "plain_only", plainProxy: true, + requests: []request{ + {"http1_v1_ipv4", "http1", "PROXY TCP4 198.51.100.7 192.0.2.1 12345 80\r\n", "198.51.100.7"}, + {"unknown_socket_address", "http1", "PROXY UNKNOWN\r\n", "127.0.0.1"}, + {"tls_direct", "https1", "", "127.0.0.1"}, + }, + }, + { + name: "both_h2c", plainProxy: true, sslProxy: true, h2c: true, + requests: []request{ + {"h2c_v2_ipv6", "h2c", proxyProtocolV2("2001:db8::7"), "2001:db8::7"}, + {"http1_v2_ipv4", "http1", proxyProtocolV2("198.51.100.7"), "198.51.100.7"}, + {"local_socket_address", "https2", "\r\n\r\n\x00\r\nQUIT\n\x20\x00\x00\x00", "127.0.0.1"}, + }, + }, + { + name: "ssl_only", sslProxy: true, h2c: true, + requests: []request{ + {"plain_direct", "http1", "", "127.0.0.1"}, + {"https1_v1_ipv6", "https1", "PROXY TCP6 2001:db8::7 2001:db8::1 12345 443\r\n", "2001:db8::7"}, + {"https2_v2_ipv4", "https2", proxyProtocolV2("198.51.100.7"), "198.51.100.7"}, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + plainAddr, sslAddr := proxyProtocolAddresses(t) + section := fmt.Sprintf(" address: %s\n ssl:\n address: %s\n cert: %q\n key: %q\n", plainAddr, sslAddr, cert, key) + if tt.sslProxy { + section += " proxy_protocol: {trusted_proxies: [127.0.0.1/32], read_header_timeout: 1s}\n" + } + if tt.plainProxy { + section += " proxy_protocol: {trusted_proxies: [127.0.0.1]}\n" + } + if tt.h2c { + section += " http2: {h2c: true}\n" + } + helpers.Start(t, "", []any{&server.Plugin{}, &httpPlugin.Plugin{}}, + helpers.WithInlineConfig(fmt.Sprintf(proxyProtocolConfig, section)), + helpers.WithObservedLogger(), helpers.WithTCPProbe(plainAddr)) + helpers.WaitListener(t, "tcp", sslAddr) + + for _, req := range tt.requests { + t.Run(req.name, func(t *testing.T) { + var dials atomic.Int32 + dial := func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + dials.Add(1) + // Write once per TCP connection, before the transport starts TLS or HTTP. + err = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err == nil { + _, err = io.WriteString(conn, req.header) + } + if err == nil { + err = conn.SetWriteDeadline(time.Time{}) + } + if err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil + } + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DialContext: dial, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}, + ForceAttemptHTTP2: req.protocol == "https2", + }, + } + if req.protocol == "h2c" { + client.Transport = &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + return dial(ctx, network, addr) + }, + } + } + t.Cleanup(client.CloseIdleConnections) + url, major := "http://"+plainAddr, 1 + secure := strings.HasPrefix(req.protocol, "https") + if secure { + url = "https://" + sslAddr + } + if req.protocol == "h2c" || req.protocol == "https2" { + major = 2 + } + for range 2 { + res := clientGet(t, client, url) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, req.remoteAddr, res.Body) + require.Equal(t, major, res.ProtoMajor) + if secure { + require.NotNil(t, res.TLS) + require.NotEmpty(t, res.TLS.VerifiedChains) + if major == 2 { + require.Equal(t, "h2", res.TLS.NegotiatedProtocol) + } + } else { + require.Nil(t, res.TLS) + } + } + require.Equal(t, int32(1), dials.Load(), "keepalive must reuse the connection and its single PROXY header") + }) + } + }) + } +} + +func TestProxyProtocolInit(t *testing.T) { + cert, key, _ := proxyProtocolTLS(t) + plain := " address: 127.0.0.1:0\n" + ssl := fmt.Sprintf(" ssl:\n address: 127.0.0.1:0\n cert: %q\n key: %q\n", cert, key) + for _, tt := range []struct{ name, section string }{ + {"empty_plain", plain + " proxy_protocol: {}\n"}, + {"empty_ssl", ssl + " proxy_protocol: {}\n"}, + {"plain_without_http", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1]}\n"}, + {"ssl_without_tls", plain + " ssl:\n proxy_protocol: {trusted_proxies: [127.0.0.1]}\n"}, + {"negative_timeout", plain + " proxy_protocol: {trusted_proxies: [127.0.0.1], read_header_timeout: -1s}\n"}, + {"invalid_timeout", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1], read_header_timeout: later}\n"}, + {"invalid_ip", plain + " proxy_protocol: {trusted_proxies: [localhost]}\n"}, + {"invalid_cidr", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1/99]}\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + _ = helpers.StartExpectInitError(t, "", []any{&server.Plugin{}, &httpPlugin.Plugin{}}, + helpers.WithInlineConfig(fmt.Sprintf(proxyProtocolConfig, tt.section)), helpers.WithObservedLogger()) + }) + } +} + +func proxyProtocolTLS(t *testing.T) (string, string, *x509.CertPool) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + cert := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + require.NoError(t, err) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + dir := t.TempDir() + certPath, keyPath := filepath.Join(dir, "localhost.pem"), filepath.Join(dir, "localhost.key") + require.NoError(t, os.WriteFile(certPath, certPEM, 0o600)) + require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600)) + roots := x509.NewCertPool() + require.True(t, roots.AppendCertsFromPEM(certPEM)) + return certPath, keyPath, roots +} + +func proxyProtocolAddresses(t *testing.T) (string, string) { + t.Helper() + // Plugin servers are private; reserve both ports together, then release for Serve. + plain, err := new(net.ListenConfig).Listen(t.Context(), "tcp4", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = plain.Close() }() + ssl, err := new(net.ListenConfig).Listen(t.Context(), "tcp4", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = ssl.Close() }() + return plain.Addr().String(), ssl.Addr().String() +} + +func proxyProtocolV2(ip string) string { + src, dst, family := net.ParseIP(ip), net.ParseIP("2001:db8::1"), byte(0x21) + length := byte(36) + if v4 := src.To4(); v4 != nil { + src, dst, family = v4, net.IPv4(192, 0, 2, 1).To4(), 0x11 + length = 12 + } + // v2 PROXY, INET{,6}/STREAM, address length, addresses, ports 12345 -> 443. + header := []byte("\r\n\r\n\x00\r\nQUIT\n\x21") + header = append(header, family, 0, length) + header = append(header, src...) + header = append(header, dst...) + return string(append(header, 0x30, 0x39, 0x01, 0xbb)) +} From deca8d51deb3c5b50ae3102cd2d2ce6ec787448e Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Sat, 5 Sep 2026 23:53:51 +0200 Subject: [PATCH 2/2] test: strengthen PROXY protocol checks --- README.md | 39 +++++++---------- servers/https/config.go | 2 +- servers/proxyprotocol/http_test.go | 47 +++++---------------- servers/proxyprotocol/proxyprotocol.go | 8 ++-- servers/proxyprotocol/proxyprotocol_test.go | 23 ++++++++-- tests/proxy_protocol_test.go | 25 +++++------ 6 files changed, 63 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 5464b667..d8502e90 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## PROXY Protocol -Enable PROXY protocol v1/v2 independently for plain HTTP and HTTPS TCP listeners: +Plain HTTP and HTTPS TCP listeners have separate PROXY protocol settings. Both support v1 and v2: ```yaml http: @@ -19,25 +19,18 @@ http: read_header_timeout: 5s ``` -Omit a block to leave that listener unchanged. When enabled, `trusted_proxies` -must explicitly list the immediate proxies' IP addresses or CIDRs. Trusted peers -must send a PROXY header, including health checks; all other connections are -dropped. There is no mixed direct/proxied mode on an enabled listener. The header -timeout defaults to 5s when omitted or zero; negative values are invalid. - -For HTTPS, send the PROXY header **before** the TLS handshake. HTTP/1.1, h2c, -TLS HTTP/2, and existing Go-middleware WebSocket upgrades retain their normal -behavior. TCP4/TCP6 headers set the client address seen by handlers, access logs, -and PHP's `REMOTE_ADDR`. Valid v1 `UNKNOWN` and v2 `LOCAL` headers instead retain -the socket addresses. TLVs are ignored; they do not change TLS state or URL scheme. -PROXY metadata applies to the whole connection, so a proxy must not multiplex -different client identities onto one backend connection. - -These options do not affect FastCGI, HTTP/3, or CertMagic's temporary ACME challenge -listeners. Route challenge traffic without PROXY headers to those listeners. -`proxy_ip_parser` and `http.trusted_subnets` remain separate HTTP forwarding-header -settings. With PROXY enabled, `RemoteAddr` identifies the advertised client rather -than the immediate proxy; account for that when configuring forwarding-header trust. - -The parser (`go-proxyproto` v0.15.0) may reject fragmented v1 headers and limits -the v2 address/TLV payload to 4096 bytes. Prefer v2 where the proxy supports it. +Omit `proxy_protocol` to leave that listener unchanged. An enabled listener accepts connections only from `trusted_proxies`. This list must contain the IP addresses or CIDR ranges of the immediate proxies. Each connection must start with a PROXY header, including health check connections. The listener drops all other connections. + +If you omit `read_header_timeout` or set it to zero, the timeout is `5s`. Negative values are invalid. + +For HTTPS, send the PROXY header before the TLS handshake. HTTP/1.1, h2c, TLS HTTP/2, and WebSocket upgrades through Go middleware continue to work. + +TCP4 and TCP6 headers set the client address in handlers, access logs, and PHP's `REMOTE_ADDR`. Valid v1 `UNKNOWN` and v2 `LOCAL` headers retain the socket addresses. The parser ignores TLVs. They do not change TLS state or the URL scheme. + +PROXY metadata applies to the whole connection. A proxy must use separate backend connections for different client identities. + +These settings do not affect FastCGI, HTTP/3, or CertMagic's temporary ACME challenge listeners. Send challenge traffic to those listeners without PROXY headers. + +`proxy_ip_parser` and `http.trusted_subnets` control HTTP forwarding headers separately. With PROXY enabled, `RemoteAddr` contains the advertised client address instead of the immediate proxy address. Check forwarding header trust settings for this address change. + +The parser (`go-proxyproto` v0.15.0) can reject fragmented v1 headers. It limits the v2 address and TLV payload to 4096 bytes. Use v2 if the proxy supports it. diff --git a/servers/https/config.go b/servers/https/config.go index 5acbe481..3f8be071 100644 --- a/servers/https/config.go +++ b/servers/https/config.go @@ -48,7 +48,7 @@ func (h2 *HTTP2) EnableHTTP2() bool { type SSL struct { // Address to listen as HTTPS server, defaults to 0.0.0.0:443. Address string - // ProxyProtocol is read before TLS on the application listener, not ACME challenge listeners. + // ProxyProtocol applies before TLS on the application listener. ACME challenge listeners are separate. ProxyProtocol *proxyprotocol.Config `mapstructure:"proxy_protocol"` // ACME configuration Acme *acme.Config `mapstructure:"acme"` diff --git a/servers/proxyprotocol/http_test.go b/servers/proxyprotocol/http_test.go index bdf29ae2..a147452c 100644 --- a/servers/proxyprotocol/http_test.go +++ b/servers/proxyprotocol/http_test.go @@ -45,7 +45,8 @@ func TestHTTPRejectsPeers(t *testing.T) { ts.Config.ErrorLog = log.New(io.Discard, "", 0) cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1", "::1"}, ReadHeaderTimeout: 100 * time.Millisecond} if tt.untrusted { - cfg.TrustedProxies = []string{"192.0.2.1"} // Trusting the advertised client must not trust the socket peer. + // Trust applies to the socket peer, not the address in the header. + cfg.TrustedProxies = []string{"192.0.2.1"} } require.NoError(t, cfg.InitDefaults(ts.Listener.Addr().String())) var err error @@ -89,7 +90,7 @@ func TestHTTPSlowPeerAndClose(t *testing.T) { })) t.Cleanup(ts.Close) ts.Config.ConnState = func(_ net.Conn, state http.ConnState) { - // RemoteAddr here would parse the header in the accept loop and block it. + // RemoteAddr would block the accept loop until the header arrives. if state == http.StateNew { accepted <- struct{}{} } @@ -113,7 +114,8 @@ func TestHTTPSlowPeerAndClose(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("silent peer was not accepted") } - assertHTTPResponse(t, proxyHTTPPeer(t, ts)) // Its 5s deadline is far below the pending header's 30s timeout. + // This request must finish before the silent peer's 30s header timeout. + assertHTTPResponse(t, proxyHTTPPeer(t, ts)) select { case <-finished: t.Fatal("a connection closed before Server.Close") @@ -139,40 +141,11 @@ func TestHTTPSlowPeerAndClose(t *testing.T) { } } -func TestHTTPHeaderDeadlineRestored(t *testing.T) { - for _, scheme := range []string{"http", "https"} { - t.Run(scheme, func(t *testing.T) { - ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Remote-Addr", r.RemoteAddr) - w.Header().Set("X-Request-URI", handler.URI(r)) - })) - t.Cleanup(ts.Close) - cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1", "::1"}, ReadHeaderTimeout: 100 * time.Millisecond} - require.NoError(t, cfg.InitDefaults(ts.Listener.Addr().String())) - var err error - ts.Listener, err = cfg.Wrap(ts.Listener) - require.NoError(t, err) - if scheme == "https" { - ts.StartTLS() - } else { - ts.Start() - } - conn := proxyHTTPPeer(t, ts) - assertHTTPResponse(t, conn) - // The response synchronizes completed header processing; this timer tests deadline expiry, not packet ordering. - timer := time.NewTimer(2 * cfg.ReadHeaderTimeout) - defer timer.Stop() - <-timer.C - assertHTTPResponse(t, conn) // Same socket, without another PROXY header. - }) - } -} - func TestHTTPWebSocket(t *testing.T) { for _, scheme := range []string{"ws", "wss"} { t.Run(scheme, func(t *testing.T) { done := make(chan error, 1) - handler := websocket.Handler(func(ws *websocket.Conn) { + wsHandler := websocket.Handler(func(ws *websocket.Conn) { err := ws.SetDeadline(time.Now().Add(5 * time.Second)) var text string if err == nil { @@ -183,7 +156,7 @@ func TestHTTPWebSocket(t *testing.T) { } done <- err }) - ts := httptest.NewUnstartedServer(middleware.NewLogMiddleware(handler, true, slog.New(slog.NewTextHandler(io.Discard, nil)))) + ts := httptest.NewUnstartedServer(middleware.NewLogMiddleware(wsHandler, true, slog.New(slog.NewTextHandler(io.Discard, nil)))) t.Cleanup(ts.Close) cfg := &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1", "::1"}} require.NoError(t, cfg.InitDefaults(ts.Listener.Addr().String())) @@ -238,7 +211,7 @@ func tlsHTTPPeer(t *testing.T, ts *httptest.Server, conn net.Conn) *tls.Conn { func proxyHTTPPeer(t *testing.T, ts *httptest.Server) net.Conn { t.Helper() conn := dialHTTPPeer(t, ts) - // The SSL TLV claims TLS and certificate verification, but is not authentication. + // The SSL TLV must not change TLS state or the request scheme. addresses := "\xc0\x00\x02\x01\xc6\x33\x64\x02\x30\x39\x01\xbb" _, err := io.WriteString(conn, v2Frame(0x21, 0x11, addresses+"\x20\x00\x05\x07\x00\x00\x00\x00")) require.NoError(t, err) @@ -255,8 +228,8 @@ func assertPeerClosed(t *testing.T, conn net.Conn) { n, err := conn.Read(make([]byte, 1)) require.Zero(t, n, "rejected peer received application bytes") require.Error(t, err) - var timeout net.Error - require.False(t, errors.As(err, &timeout) && timeout.Timeout(), "client safety deadline is not a peer close: %v", err) + timeout, ok := errors.AsType[net.Error](err) + require.False(t, ok && timeout.Timeout(), "client safety deadline is not a peer close: %v", err) } func assertHTTPResponse(t *testing.T, conn net.Conn) { diff --git a/servers/proxyprotocol/proxyprotocol.go b/servers/proxyprotocol/proxyprotocol.go index ab005dfd..94e3fae2 100644 --- a/servers/proxyprotocol/proxyprotocol.go +++ b/servers/proxyprotocol/proxyprotocol.go @@ -19,7 +19,7 @@ type Config struct { policy proxyproto.ConnPolicyFunc } -// InitDefaults validates the listener address and compiles its trusted-peer policy. +// InitDefaults validates the TCP address and builds the proxy trust policy. func (c *Config) InitDefaults(address string) error { c.policy = nil _, port, err := net.SplitHostPort(strings.TrimPrefix(address, "tcp://")) @@ -44,7 +44,7 @@ func (c *Config) InitDefaults(address string) error { return fmt.Errorf("trusted_proxies: %w", err) } c.policy = func(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { - // Match the kernel peer's IP, not its interface zone (which the policy cannot parse). + // The trust policy cannot parse IPv6 interface zones. if addr, ok := opts.Upstream.(*net.TCPAddr); ok && addr.Zone != "" { peer := *addr peer.Zone = "" @@ -55,7 +55,7 @@ func (c *Config) InitDefaults(address string) error { return nil } -// Wrap leaves a nil configuration disabled. Otherwise InitDefaults must have succeeded. +// Wrap returns the original listener for a nil config. Other configs require InitDefaults. func (c *Config) Wrap(listener net.Listener) (net.Listener, error) { if c == nil { return listener, nil @@ -72,7 +72,7 @@ func (c *Config) Wrap(listener net.Listener) (net.Listener, error) { ConnPolicy: c.policy, ReadHeaderTimeout: c.ReadHeaderTimeout, ValidateHeader: func(h *proxyproto.Header) error { - // LOCAL (including v1 UNKNOWN) retains socket addresses, regardless of transport. + // LOCAL and v1 UNKNOWN retain the socket addresses. if h.Command.IsLocal() || h.TransportProtocol == proxyproto.TCPv4 || h.TransportProtocol == proxyproto.TCPv6 { return nil } diff --git a/servers/proxyprotocol/proxyprotocol_test.go b/servers/proxyprotocol/proxyprotocol_test.go index 86710dc5..6623a1f8 100644 --- a/servers/proxyprotocol/proxyprotocol_test.go +++ b/servers/proxyprotocol/proxyprotocol_test.go @@ -119,14 +119,13 @@ func TestWrapHeaders(t *testing.T) { server, client := tcpPair(t, time.Second) _, err := io.WriteString(client, tt.header+payload) require.NoError(t, err) - // Half-close complete input so parser rejection cannot be a read timeout. + // Close input so parser rejection cannot depend on a read timeout. require.NoError(t, client.CloseWrite()) body, err := io.ReadAll(server) if tt.reject { require.Error(t, err) assert.Empty(t, body, "rejected connections must not expose application bytes") - var timeout net.Error - if errors.As(err, &timeout) { + if timeout, ok := errors.AsType[net.Error](err); ok { assert.False(t, timeout.Timeout(), "a safety deadline is not a rejection") } return @@ -152,6 +151,22 @@ func TestWrapHeaderTimeout(t *testing.T) { assert.Less(t, time.Since(start), 2*time.Second, "header timeout must fire before the 5s safety deadline") } +func TestWrapHeaderDeadlineRestored(t *testing.T) { + server, client := tcpPair(t, 100*time.Millisecond) + _, err := io.WriteString(client, proxyLine) + require.NoError(t, err) + require.Equal(t, "192.0.2.1:12345", server.RemoteAddr().String()) + + // Wait past the header deadline without HTTP resetting it. + time.Sleep(200 * time.Millisecond) + _, err = io.WriteString(client, payload) + require.NoError(t, err) + body := make([]byte, len(payload)) + _, err = io.ReadFull(server, body) + require.NoError(t, err) + assert.Equal(t, payload, string(body)) +} + func TestWrapDropsUntrustedAndContinues(t *testing.T) { for _, tt := range []struct { name, trusted, good, bad, zone string @@ -217,7 +232,7 @@ func v2Frame(command, transport byte, body string) string { func tcpListener(t *testing.T) net.Listener { t.Helper() - ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + ln, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:0") require.NoError(t, err) t.Cleanup(func() { _ = ln.Close() }) require.NoError(t, ln.(*net.TCPListener).SetDeadline(time.Now().Add(5*time.Second))) diff --git a/tests/proxy_protocol_test.go b/tests/proxy_protocol_test.go index 12a3d645..160da3f7 100644 --- a/tests/proxy_protocol_test.go +++ b/tests/proxy_protocol_test.go @@ -162,19 +162,20 @@ func TestProxyProtocolInit(t *testing.T) { cert, key, _ := proxyProtocolTLS(t) plain := " address: 127.0.0.1:0\n" ssl := fmt.Sprintf(" ssl:\n address: 127.0.0.1:0\n cert: %q\n key: %q\n", cert, key) - for _, tt := range []struct{ name, section string }{ - {"empty_plain", plain + " proxy_protocol: {}\n"}, - {"empty_ssl", ssl + " proxy_protocol: {}\n"}, - {"plain_without_http", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1]}\n"}, - {"ssl_without_tls", plain + " ssl:\n proxy_protocol: {trusted_proxies: [127.0.0.1]}\n"}, - {"negative_timeout", plain + " proxy_protocol: {trusted_proxies: [127.0.0.1], read_header_timeout: -1s}\n"}, - {"invalid_timeout", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1], read_header_timeout: later}\n"}, - {"invalid_ip", plain + " proxy_protocol: {trusted_proxies: [localhost]}\n"}, - {"invalid_cidr", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1/99]}\n"}, + for _, tt := range []struct{ name, section, wantError string }{ + {"empty_plain", plain + " proxy_protocol: {}\n", "trusted_proxies must contain at least one IP address or CIDR"}, + {"empty_ssl", ssl + " proxy_protocol: {}\n", "trusted_proxies must contain at least one IP address or CIDR"}, + {"plain_without_http", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1]}\n", "requires a TCP listen address"}, + {"ssl_without_tls", plain + " ssl:\n proxy_protocol: {trusted_proxies: [127.0.0.1]}\n", "requires an enabled HTTPS listener"}, + {"negative_timeout", plain + " proxy_protocol: {trusted_proxies: [127.0.0.1], read_header_timeout: -1s}\n", "read_header_timeout must not be negative"}, + {"invalid_timeout", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1], read_header_timeout: later}\n", "'ssl.proxy_protocol.read_header_timeout' time: invalid duration"}, + {"invalid_ip", plain + " proxy_protocol: {trusted_proxies: [localhost]}\n", `given string "localhost" is not a valid IP address`}, + {"invalid_cidr", ssl + " proxy_protocol: {trusted_proxies: [127.0.0.1/99]}\n", `given string "127.0.0.1/99" is not a valid IP range`}, } { t.Run(tt.name, func(t *testing.T) { - _ = helpers.StartExpectInitError(t, "", []any{&server.Plugin{}, &httpPlugin.Plugin{}}, + err := helpers.StartExpectInitError(t, "", []any{&server.Plugin{}, &httpPlugin.Plugin{}}, helpers.WithInlineConfig(fmt.Sprintf(proxyProtocolConfig, tt.section)), helpers.WithObservedLogger()) + require.ErrorContains(t, err, tt.wantError) }) } } @@ -208,7 +209,7 @@ func proxyProtocolTLS(t *testing.T) (string, string, *x509.CertPool) { func proxyProtocolAddresses(t *testing.T) (string, string) { t.Helper() - // Plugin servers are private; reserve both ports together, then release for Serve. + // Reserve distinct ports, then release them for the plugin listeners. plain, err := new(net.ListenConfig).Listen(t.Context(), "tcp4", "127.0.0.1:0") require.NoError(t, err) defer func() { _ = plain.Close() }() @@ -225,7 +226,7 @@ func proxyProtocolV2(ip string) string { src, dst, family = v4, net.IPv4(192, 0, 2, 1).To4(), 0x11 length = 12 } - // v2 PROXY, INET{,6}/STREAM, address length, addresses, ports 12345 -> 443. + // Encode v2 PROXY with TCP addresses and ports 12345 and 443. header := []byte("\r\n\r\n\x00\r\nQUIT\n\x21") header = append(header, family, 0, length) header = append(header, src...)