diff --git a/README.md b/README.md index 07ad2243..9e57390c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Listeners receive queries over any supported protocol. Routers, groups and modif **Deployment** - Linux network namespace support — listen in one netns, resolve in another - Firewall mark (fwmark) and interface binding (SO_BINDTODEVICE) for policy routing and VRF +- PROXY protocol v1/v2 support for preserving client IPs behind load balancers - Admin listener with expvar metrics (Prometheus-compatible) - Query/response logging, syslog integration - Platform independent — written in Go diff --git a/adminlistener.go b/adminlistener.go index 51bfe4d6..a3a9de3a 100644 --- a/adminlistener.go +++ b/adminlistener.go @@ -88,6 +88,7 @@ func (s *AdminListener) startTCP() error { if err != nil { return err } + ln = proxyProtocolListener(ln, s.opt.ProxyProtocol) defer ln.Close() return s.httpServer.ServeTLS(ln, "", "") } diff --git a/cmd/routedns/config.go b/cmd/routedns/config.go index e2872d9c..fe95ab89 100644 --- a/cmd/routedns/config.go +++ b/cmd/routedns/config.go @@ -37,7 +37,8 @@ type listener struct { AllowDoH bool `toml:"allow-doh"` // Allow ODoH listeners to also handle DoH queries to /dns-query NetNS string `toml:"netns"` // Linux network namespace name or absolute path FWMark uint32 `toml:"fwmark"` // Linux firewall mark (SO_MARK) for the listening socket - BindInterface string `toml:"bind-if"` // Linux network interface to bind the socket to (SO_BINDTODEVICE) + BindInterface string `toml:"bind-if"` // Linux network interface to bind the socket to (SO_BINDTODEVICE) + ProxyProtocol bool `toml:"proxy-protocol"` // Enable PROXY protocol v1/v2 header parsing on TCP listeners Frontend dohFrontend } diff --git a/cmd/routedns/main.go b/cmd/routedns/main.go index 35a4cf3c..1a6534a0 100644 --- a/cmd/routedns/main.go +++ b/cmd/routedns/main.go @@ -227,6 +227,7 @@ func start(opt options, args []string) error { AllowedNet: allowedNet, NetNS: netns, SocketOptions: rdns.SocketOptions{FWMark: l.FWMark, BindInterface: l.BindInterface}, + ProxyProtocol: l.ProxyProtocol, } switch l.Protocol { diff --git a/dnslistener.go b/dnslistener.go index 94f2cb95..e4a2170d 100644 --- a/dnslistener.go +++ b/dnslistener.go @@ -28,6 +28,9 @@ type ListenOptions struct { // Linux socket options for fwmark and interface binding. SocketOptions SocketOptions + + // Enable PROXY protocol v1/v2 header parsing on incoming TCP connections. + ProxyProtocol bool } // NewDNSListener returns an instance of either a UDP or TCP DNS listener. @@ -46,7 +49,7 @@ func NewDNSListener(id, addr, net string, opt ListenOptions, resolver Resolver) // Start the DNS listener. func (s DNSListener) Start() error { Log.Info("starting listener", "id", s.id, "protocol", s.Net, "addr", s.Addr) - if (s.opt.NetNS != nil && s.opt.NetNS.Name != "") || s.opt.SocketOptions.active() { + if (s.opt.NetNS != nil && s.opt.NetNS.Name != "") || s.opt.SocketOptions.active() || (s.opt.ProxyProtocol && strings.HasPrefix(s.Net, "tcp")) { return s.startWithSocketSetup() } return s.ListenAndServe() @@ -59,7 +62,7 @@ func (s DNSListener) startWithSocketSetup() error { if err != nil { return err } - s.Server.Listener = ln + s.Server.Listener = proxyProtocolListener(ln, s.opt.ProxyProtocol) case strings.HasPrefix(s.Net, "udp"): pc, err := ListenPacketInNetNS(context.Background(), s.opt.NetNS, s.Net, s.Addr, s.opt.SocketOptions) if err != nil { diff --git a/doc/configuration.md b/doc/configuration.md index b7ca72fd..f86151cb 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -53,6 +53,7 @@ - [SOCKS5 Proxy Support](#socks5-proxy-support) - [Network Namespace Support](#network-namespace-support) - [Firewall Mark and Interface Binding](#firewall-mark-and-interface-binding) + - [PROXY Protocol Support](#proxy-protocol-support) - [Templates](#templates) ## Overview @@ -134,6 +135,7 @@ Common options for all listeners: - `netns` - Linux network namespace for the listening socket. Can be a name (looked up in `/var/run/netns/`) or an absolute path (e.g. `/proc/PID/ns/net`). Optional, Linux only. See [Network Namespace Support](#network-namespace-support). - `fwmark` - Linux firewall mark (`SO_MARK`) to set on the listening socket. Used for netfilter matching and policy routing. Optional, Linux only, integer. See [Firewall Mark and Interface Binding](#firewall-mark-and-interface-binding). - `bind-if` - Bind the listening socket to a specific network interface (`SO_BINDTODEVICE`). Useful for VRFs or restricting a listener to one interface. Optional, Linux only. See [Firewall Mark and Interface Binding](#firewall-mark-and-interface-binding). +- `proxy-protocol` - Enable PROXY protocol v1/v2 header parsing on incoming connections. When enabled, the real client IP from the PROXY header (sent by an upstream load balancer) is used instead of the direct connection's remote address. Only applies to TCP-based listeners (`tcp`, `dot`, `doh` with TCP transport). Optional, defaults to `false`. See [PROXY Protocol Support](#proxy-protocol-support). Secure listeners, such as DNS-over-TLS, DNS-over-HTTPS, DNS-over-DTLS, DNS-over-QUIC and Admin support additional options to configure certificates, keys and peer validation. @@ -2234,6 +2236,48 @@ fwmark = 12 Example config files: [fwmark-bind-if.toml](../cmd/routedns/example-config/fwmark-bind-if.toml) +### PROXY Protocol Support + +When RouteDNS is deployed behind a load balancer (e.g., HAProxy, NGINX, or cloud load balancers), the real client IP is normally lost because all connections appear to come from the load balancer. The [PROXY protocol](https://www.haproxy.org/download/2.0/doc/proxy-protocol.txt) solves this by having the load balancer prepend a header with the original client IP to each TCP connection. + +Setting `proxy-protocol = true` on a listener enables parsing of PROXY protocol v1 (text) and v2 (binary) headers. The real client IP from the header is then used throughout the pipeline — in logging, ACLs, routing, and any other component that uses the client IP. + +This option only applies to TCP-based listeners: `tcp`, `dot`, `doh` (with TCP transport), `admin` (with TCP transport), and `odoh`. UDP-based listeners (`udp`, `doq`, `dtls`, DoH with QUIC transport) are not affected. + +**TCP listener behind a load balancer:** + +```toml +[listeners.tcp-behind-lb] +address = ":53" +protocol = "tcp" +resolver = "upstream" +proxy-protocol = true +``` + +**DoT listener behind a load balancer:** + +```toml +[listeners.dot-behind-lb] +address = ":853" +protocol = "dot" +resolver = "upstream" +server-crt = "/path/to/server.crt" +server-key = "/path/to/server.key" +proxy-protocol = true +``` + +**DoH listener behind a load balancer:** + +```toml +[listeners.doh-behind-lb] +address = ":443" +protocol = "doh" +resolver = "upstream" +server-crt = "/path/to/server.crt" +server-key = "/path/to/server.key" +proxy-protocol = true +``` + ## Templates Some groups support templates, i.e. allow placeholder in text fields that will be populated at runtime with data from a query. This can for example be used in the extended error text returned from a blocklist. In that case, the configuration would set a text with placeholders like this `"Blocked {{ .Question }} with ID {{ .ID }} because reasons"`. The placeholders in between `{{` and `}}` would then be replaced with data from the query when a query is blocked and the response returned. The template syntax is explained in more detail [here](https://pkg.go.dev/text/template). diff --git a/dohlistener.go b/dohlistener.go index 984c1b50..12280572 100644 --- a/dohlistener.go +++ b/dohlistener.go @@ -126,6 +126,7 @@ func (s *DoHListener) startTCP() error { if err != nil { return err } + ln = proxyProtocolListener(ln, s.opt.ProxyProtocol) defer ln.Close() if s.opt.NoTLS { return s.httpServer.Serve(ln) diff --git a/dotlistener.go b/dotlistener.go index df6f8a24..8bfc71c0 100644 --- a/dotlistener.go +++ b/dotlistener.go @@ -51,12 +51,12 @@ func (s DoTListener) Start() error { "id", s.id, "protocol", "dot", "addr", s.Addr) - if (s.opt.NetNS != nil && s.opt.NetNS.Name != "") || s.opt.SocketOptions.active() { + if (s.opt.NetNS != nil && s.opt.NetNS.Name != "") || s.opt.SocketOptions.active() || s.opt.ProxyProtocol { ln, err := ListenInNetNS(context.Background(), s.opt.NetNS, "tcp", s.Addr, s.opt.SocketOptions) if err != nil { return err } - s.Server.Listener = tls.NewListener(ln, s.Server.TLSConfig) + s.Server.Listener = tls.NewListener(proxyProtocolListener(ln, s.opt.ProxyProtocol), s.Server.TLSConfig) return s.ActivateAndServe() } return s.ListenAndServe() diff --git a/go.mod b/go.mod index ae339f11..ed087741 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/miekg/dns v1.1.69 github.com/oschwald/maxminddb-golang v1.13.1 github.com/pion/dtls/v3 v3.1.2 + github.com/pires/go-proxyproto v0.11.0 github.com/pkg/errors v0.9.1 github.com/quic-go/quic-go v0.57.1 github.com/redis/go-redis/v9 v9.17.2 diff --git a/go.sum b/go.sum index b705456b..767b1e6f 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/odohlistener.go b/odohlistener.go index b12c9713..268a4108 100644 --- a/odohlistener.go +++ b/odohlistener.go @@ -84,8 +84,9 @@ func NewODoHListener(id, addr string, opt ODoHListenerOptions, resolver Resolver } dohOpt := DoHListenerOptions{ - TLSConfig: opt.TLSConfig, - customMux: mux, + ListenOptions: opt.ListenOptions, + TLSConfig: opt.TLSConfig, + customMux: mux, } dohListen, err := NewDoHListener(id, addr, dohOpt, resolver) if err != nil { diff --git a/proxyproto.go b/proxyproto.go new file mode 100644 index 00000000..b115eda5 --- /dev/null +++ b/proxyproto.go @@ -0,0 +1,18 @@ +package rdns + +import ( + "net" + + proxyproto "github.com/pires/go-proxyproto" +) + +// proxyProtocolListener wraps a net.Listener with PROXY protocol v1/v2 +// header parsing if enabled. When enabled, accepted connections return +// the real client IP from RemoteAddr() as conveyed by an upstream load +// balancer via the PROXY protocol header. +func proxyProtocolListener(ln net.Listener, enabled bool) net.Listener { + if !enabled { + return ln + } + return &proxyproto.Listener{Listener: ln} +}