Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
# Docs: [link](https://docs.roadrunner.dev/http/http)

## PROXY Protocol

Plain HTTP and HTTPS TCP listeners have separate PROXY protocol settings. Both support v1 and v2:

```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 `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.
9 changes: 9 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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{}
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
59 changes: 59 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 23 additions & 14 deletions servers/http11/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
13 changes: 13 additions & 0 deletions servers/https/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 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"`
// Redirect when enabled forces all http connections to switch to https.
Expand Down Expand Up @@ -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
}

Expand Down
12 changes: 12 additions & 0 deletions servers/https/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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())
Expand Down
9 changes: 7 additions & 2 deletions servers/https/https.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,19 @@ 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
*/
if s.cfg.EnableACME() {
s.log.Debug("https(acme) server was started", "address", s.cfg.Address)
err = s.https.ServeTLS(
l,
listener,
"",
"",
)
Expand All @@ -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,
)
Expand Down
28 changes: 28 additions & 0 deletions servers/https/https_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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())
})
}
}
Loading
Loading