From e9c0db32dcd92f851994e1e57ccf8a200e368d7f Mon Sep 17 00:00:00 2001 From: Adphi <25206920+Adphi@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:14:05 +0100 Subject: [PATCH] feat(proxyproto): add proxy protocol support (#7738) Signed-off-by: Adphi --- core/dnsserver/config.go | 7 ++ core/dnsserver/server.go | 14 ++- core/dnsserver/server_grpc.go | 4 + core/dnsserver/server_https.go | 4 + core/dnsserver/server_https3.go | 12 ++- core/dnsserver/server_quic.go | 5 + core/dnsserver/server_tls.go | 4 + core/dnsserver/zdirectives.go | 1 + core/plugin/zplugin.go | 1 + go.mod | 1 + go.sum | 2 + plugin/pkg/proxyproto/proxyproto.go | 136 ++++++++++++++++++++++++++++ plugin/proxyproto/README.md | 63 +++++++++++++ plugin/proxyproto/setup.go | 81 +++++++++++++++++ plugin/proxyproto/setup_test.go | 57 ++++++++++++ 15 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 plugin/pkg/proxyproto/proxyproto.go create mode 100644 plugin/proxyproto/README.md create mode 100644 plugin/proxyproto/setup.go create mode 100644 plugin/proxyproto/setup_test.go diff --git a/core/dnsserver/config.go b/core/dnsserver/config.go index fcf9c95ce..d928326be 100644 --- a/core/dnsserver/config.go +++ b/core/dnsserver/config.go @@ -10,6 +10,8 @@ import ( "github.com/coredns/caddy" "github.com/coredns/coredns/plugin" "github.com/coredns/coredns/request" + + "github.com/pires/go-proxyproto" ) // Config configuration for a single server. @@ -66,6 +68,11 @@ type Config struct { // This is nil if not specified, allowing for a default to be used. MaxQUICWorkerPoolSize *int + // ProxyProtoConnPolicy is the function that will be used to + // configure the PROXY protocol settings on listeners. + // If nil, PROXY protocol is disabled. + ProxyProtoConnPolicy proxyproto.ConnPolicyFunc + // MaxGRPCStreams defines the maximum number of concurrent streams per gRPC connection. // This is nil if not specified, allowing for a default to be used. MaxGRPCStreams *int diff --git a/core/dnsserver/server.go b/core/dnsserver/server.go index 3f7441dfc..2b9aad944 100644 --- a/core/dnsserver/server.go +++ b/core/dnsserver/server.go @@ -16,6 +16,7 @@ import ( "github.com/coredns/coredns/plugin/metrics/vars" "github.com/coredns/coredns/plugin/pkg/edns" "github.com/coredns/coredns/plugin/pkg/log" + cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto" "github.com/coredns/coredns/plugin/pkg/rcode" "github.com/coredns/coredns/plugin/pkg/reuseport" "github.com/coredns/coredns/plugin/pkg/trace" @@ -24,6 +25,7 @@ import ( "github.com/miekg/dns" ot "github.com/opentracing/opentracing-go" + "github.com/pires/go-proxyproto" ) // Server represents an instance of a server, which serves @@ -37,6 +39,8 @@ type Server struct { ReadTimeout time.Duration // Read timeout for TCP WriteTimeout time.Duration // Write timeout for TCP + connPolicy proxyproto.ConnPolicyFunc // Proxy Protocol connection policy function + server [2]*dns.Server // 0 is a net.Listener, 1 is a net.PacketConn (a *UDPConn) in our case. m sync.Mutex // protects the servers @@ -123,6 +127,9 @@ func NewServer(addr string, group []*Config) (*Server, error) { } } site.pluginChain = stack + if site.ProxyProtoConnPolicy != nil { + s.connPolicy = site.ProxyProtoConnPolicy + } } if !s.debug { @@ -181,6 +188,9 @@ func (s *Server) Listen() (net.Listener, error) { if err != nil { return nil, err } + if s.connPolicy != nil { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } return l, nil } @@ -195,7 +205,9 @@ func (s *Server) ListenPacket() (net.PacketConn, error) { if err != nil { return nil, err } - + if s.connPolicy != nil { + p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy} + } return p, nil } diff --git a/core/dnsserver/server_grpc.go b/core/dnsserver/server_grpc.go index 4bfa59988..0fd377072 100644 --- a/core/dnsserver/server_grpc.go +++ b/core/dnsserver/server_grpc.go @@ -15,6 +15,7 @@ import ( "github.com/grpc-ecosystem/grpc-opentracing/go/otgrpc" "github.com/miekg/dns" "github.com/opentracing/opentracing-go" + "github.com/pires/go-proxyproto" "golang.org/x/net/netutil" "google.golang.org/grpc" "google.golang.org/grpc/peer" @@ -136,6 +137,9 @@ func (s *ServergRPC) Listen() (net.Listener, error) { if err != nil { return nil, err } + if s.connPolicy != nil { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } return l, nil } diff --git a/core/dnsserver/server_https.go b/core/dnsserver/server_https.go index 0d522a051..1fe33d415 100644 --- a/core/dnsserver/server_https.go +++ b/core/dnsserver/server_https.go @@ -19,6 +19,7 @@ import ( "github.com/coredns/coredns/plugin/pkg/reuseport" "github.com/coredns/coredns/plugin/pkg/transport" + "github.com/pires/go-proxyproto" "golang.org/x/net/netutil" ) @@ -136,6 +137,9 @@ func (s *ServerHTTPS) Listen() (net.Listener, error) { if err != nil { return nil, err } + if s.connPolicy != nil { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } return l, nil } diff --git a/core/dnsserver/server_https3.go b/core/dnsserver/server_https3.go index ea36abbda..c34c511cf 100644 --- a/core/dnsserver/server_https3.go +++ b/core/dnsserver/server_https3.go @@ -13,6 +13,7 @@ import ( "github.com/coredns/coredns/plugin/metrics/vars" "github.com/coredns/coredns/plugin/pkg/dnsutil" "github.com/coredns/coredns/plugin/pkg/doh" + cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto" "github.com/coredns/coredns/plugin/pkg/response" "github.com/coredns/coredns/plugin/pkg/reuseport" "github.com/coredns/coredns/plugin/pkg/transport" @@ -89,7 +90,7 @@ func NewServerHTTPS3(addr string, group []*Config) (*ServerHTTPS3, error) { TLSConfig: tlsConfig, EnableDatagrams: true, QUICConfig: qconf, - //Logger: stdlog.New(&loggerAdapter{}, "", 0), TODO: Fix it + // Logger: stdlog.New(&loggerAdapter{}, "", 0), TODO: Fix it } sh := &ServerHTTPS3{ @@ -110,7 +111,14 @@ var _ caddy.GracefulServer = &ServerHTTPS3{} // ListenPacket opens the UDP socket for QUIC. func (s *ServerHTTPS3) ListenPacket() (net.PacketConn, error) { - return reuseport.ListenPacket("udp", s.Addr[len(transport.HTTPS3+"://"):]) + p, err := reuseport.ListenPacket("udp", s.Addr[len(transport.HTTPS3+"://"):]) + if err != nil { + return nil, err + } + if s.connPolicy != nil { + p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy} + } + return p, nil } // ServePacket starts serving QUIC+HTTP/3 on an existing UDP socket. diff --git a/core/dnsserver/server_quic.go b/core/dnsserver/server_quic.go index d05db8536..d4561e770 100644 --- a/core/dnsserver/server_quic.go +++ b/core/dnsserver/server_quic.go @@ -11,6 +11,7 @@ import ( "github.com/coredns/coredns/plugin/metrics/vars" clog "github.com/coredns/coredns/plugin/pkg/log" + cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto" "github.com/coredns/coredns/plugin/pkg/reuseport" "github.com/coredns/coredns/plugin/pkg/transport" @@ -241,6 +242,10 @@ func (s *ServerQUIC) ListenPacket() (net.PacketConn, error) { return nil, err } + if s.connPolicy != nil { + p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy} + } + s.m.Lock() defer s.m.Unlock() diff --git a/core/dnsserver/server_tls.go b/core/dnsserver/server_tls.go index 83c560e69..c68d6d0d0 100644 --- a/core/dnsserver/server_tls.go +++ b/core/dnsserver/server_tls.go @@ -12,6 +12,7 @@ import ( "github.com/coredns/coredns/plugin/pkg/transport" "github.com/miekg/dns" + "github.com/pires/go-proxyproto" ) // ServerTLS represents an instance of a TLS-over-DNS-server. @@ -79,6 +80,9 @@ func (s *ServerTLS) Listen() (net.Listener, error) { if err != nil { return nil, err } + if s.connPolicy != nil { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } return l, nil } diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index c356740c1..f6496280e 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -15,6 +15,7 @@ var Directives = []string{ "geoip", "cancel", "tls", + "proxyproto", "quic", "grpc_server", "https", diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index d080cc5f9..476d8c1dc 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -46,6 +46,7 @@ import ( _ "github.com/coredns/coredns/plugin/nomad" _ "github.com/coredns/coredns/plugin/nsid" _ "github.com/coredns/coredns/plugin/pprof" + _ "github.com/coredns/coredns/plugin/proxyproto" _ "github.com/coredns/coredns/plugin/quic" _ "github.com/coredns/coredns/plugin/ready" _ "github.com/coredns/coredns/plugin/reload" diff --git a/go.mod b/go.mod index 71d745f84..bc4529068 100644 --- a/go.mod +++ b/go.mod @@ -146,6 +146,7 @@ require ( github.com/outcaste-io/ristretto v0.2.3 // indirect github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index 7006f6123..08fd7d1b5 100644 --- a/go.sum +++ b/go.sum @@ -298,6 +298,8 @@ github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8i github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= diff --git a/plugin/pkg/proxyproto/proxyproto.go b/plugin/pkg/proxyproto/proxyproto.go new file mode 100644 index 000000000..5f9bea063 --- /dev/null +++ b/plugin/pkg/proxyproto/proxyproto.go @@ -0,0 +1,136 @@ +package proxyproto + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "net" + "time" + + clog "github.com/coredns/coredns/plugin/pkg/log" + + "github.com/pires/go-proxyproto" +) + +var ( + _ net.PacketConn = (*PacketConn)(nil) + _ net.Addr = (*Addr)(nil) +) + +type PacketConn struct { + net.PacketConn + ConnPolicy proxyproto.ConnPolicyFunc + ValidateHeader proxyproto.Validator + ReadHeaderTimeout time.Duration +} + +func (c *PacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + for { + n, addr, err = c.PacketConn.ReadFrom(p) + if err != nil { + return n, addr, err + } + n, addr, err = c.readFrom(p[:n], addr) + if err != nil { + // drop invalid packet as returning error would cause the ReadFrom caller to exit + // which could result in DoS if an attacker sends intentional invalid packets + clog.Warningf("dropping invalid Proxy Protocol packet from %s: %v", addr.String(), err) + continue + } + return n, addr, nil + } +} + +func (c *PacketConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { + if pa, ok := addr.(*Addr); ok { + addr = pa.u + } + return c.PacketConn.WriteTo(p, addr) +} + +func (c *PacketConn) readFrom(p []byte, addr net.Addr) (_ int, _ net.Addr, err error) { + var policy proxyproto.Policy + if c.ConnPolicy != nil { + policy, err = c.ConnPolicy(proxyproto.ConnPolicyOptions{ + Upstream: addr, + Downstream: c.LocalAddr(), + }) + if err != nil { + return 0, nil, fmt.Errorf("applying Proxy Protocol connection policy: %w", err) + } + } + if policy == proxyproto.SKIP { + return len(p), addr, nil + } + header, payload, err := parseProxyProtocol(p) + if err != nil { + return 0, nil, err + } + if header != nil && c.ValidateHeader != nil { + if err := c.ValidateHeader(header); err != nil { + return 0, nil, fmt.Errorf("validating Proxy Protocol header: %w", err) + } + } + switch policy { + case proxyproto.REJECT: + if header != nil { + return 0, nil, errors.New("connection rejected by Proxy Protocol connection policy") + } + case proxyproto.REQUIRE: + if header == nil { + return 0, nil, errors.New("PROXY Protocol header required but not present") + } + fallthrough + case proxyproto.USE: + if header != nil { + srcAddr, _, _ := header.UDPAddrs() + addr = &Addr{u: addr, r: srcAddr} + } + default: + } + copy(p, payload) + return len(payload), addr, nil +} + +type Addr struct { + u net.Addr + r net.Addr +} + +func (a *Addr) Network() string { + return a.u.Network() +} + +func (a *Addr) String() string { + return a.r.String() +} + +func parseProxyProtocol(packet []byte) (*proxyproto.Header, []byte, error) { + reader := bufio.NewReader(bytes.NewReader(packet)) + + header, err := proxyproto.Read(reader) + if err != nil { + if errors.Is(err, proxyproto.ErrNoProxyProtocol) { + return nil, packet, nil + } + return nil, nil, fmt.Errorf("parsing Proxy Protocol header (packet size: %d): %w", len(packet), err) + } + + if header.Version != 2 { + return nil, nil, fmt.Errorf("unsupported Proxy Protocol version %d (only v2 supported for UDP)", header.Version) + } + + _, _, ok := header.UDPAddrs() + if !ok { + return nil, nil, fmt.Errorf("PROXY Protocol header is not UDP type (transport protocol: 0x%x)", header.TransportProtocol) + } + + headerLen := len(packet) - reader.Buffered() + if headerLen < 0 || headerLen > len(packet) { + return nil, nil, fmt.Errorf("invalid header length: %d", headerLen) + } + + payload := packet[headerLen:] + return header, payload, nil +} diff --git a/plugin/proxyproto/README.md b/plugin/proxyproto/README.md new file mode 100644 index 000000000..1e8d9c1ec --- /dev/null +++ b/plugin/proxyproto/README.md @@ -0,0 +1,63 @@ +# proxyproto + +## Name + +*proxyproto* - add [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) support. + +## Description + +This plugin adds support for the PROXY protocol version 1 and 2. It allows CoreDNS to receive +connections from a load balancer or proxy that uses the PROXY protocol to forward the original +client's IP address and port information. + +## Syntax + +~~~ txt +proxyproto { + allow + default +} +~~~ + +If `allow` is unspecified, PROXY protocol headers are accepted from all IP addresses. +The `default` option controls how connections from sources not listed in `allow` are handled. +If `default` is unspecified, it defaults to `ignore`. +The possible values are: +- `use`: accept and use PROXY protocol headers from these sources +- `ignore`: accept and ignore PROXY protocol headers from other sources +- `reject`: reject connections with PROXY protocol headers from other sources +- `skip`: skip PROXY protocol processing for connections from other sources, treating them as normal connections preserving the PROXY protocol headers. + + +## Examples + +In this configuration, we allow PROXY protocol connections from all IP addresses: +~~~ corefile +. { + proxyproto + forward . /etc/resolv.conf +} +~~~ + +In this configuration, we only allow PROXY protocol connections from the specified CIDR ranges +and ignore proxy protocol headers from other sources: +~~~ corefile +. { + proxyproto { + allow 192.168.1.1/32 192.168.0.1/32 + } + forward . /etc/resolv.conf +} +~~~ + +In this configuration, we only allow PROXY protocol headers from the specified CIDR ranges and reject +connections without valid PROXY protocol headers from those sources: +~~~ corefile +. { + proxyproto { + allow 192.168.1.1/32 + default reject + } + forward . /etc/resolv.conf +} +~~~ diff --git a/plugin/proxyproto/setup.go b/plugin/proxyproto/setup.go new file mode 100644 index 000000000..1a94c4eb1 --- /dev/null +++ b/plugin/proxyproto/setup.go @@ -0,0 +1,81 @@ +package proxyproto + +import ( + "errors" + "fmt" + "net" + "strings" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/pires/go-proxyproto" +) + +func init() { plugin.Register("proxyproto", setup) } + +func setup(c *caddy.Controller) error { + config := dnsserver.GetConfig(c) + if config.ProxyProtoConnPolicy != nil { + return plugin.Error("proxyproto", errors.New("proxy protocol already configured for this server instance")) + } + var ( + allowedIPNets []*net.IPNet + policy = proxyproto.IGNORE + ) + for c.Next() { + args := c.RemainingArgs() + if len(args) != 0 { + return plugin.Error("proxyproto", c.ArgErr()) + } + for c.NextBlock() { + switch c.Val() { + case "allow": + for _, v := range c.RemainingArgs() { + _, ipnet, err := net.ParseCIDR(v) + if err != nil { + return plugin.Error("proxyproto", fmt.Errorf("%s: %w", v, err)) + } + allowedIPNets = append(allowedIPNets, ipnet) + } + case "default": + v := c.RemainingArgs() + if len(v) != 1 { + return plugin.Error("proxyproto", c.ArgErr()) + } + switch strings.ToLower(v[0]) { + case "use": + policy = proxyproto.USE + case "ignore": + policy = proxyproto.IGNORE + case "reject": + policy = proxyproto.REJECT + case "skip": + policy = proxyproto.SKIP + default: + return plugin.Error("proxyproto", c.ArgErr()) + } + default: + return c.Errf("unknown option '%s'", c.Val()) + } + } + } + config.ProxyProtoConnPolicy = func(connPolicyOptions proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + if len(allowedIPNets) == 0 { + return proxyproto.USE, nil + } + h, _, _ := net.SplitHostPort(connPolicyOptions.Upstream.String()) + ip := net.ParseIP(h) + if ip == nil { + return proxyproto.REJECT, nil + } + for _, ipnet := range allowedIPNets { + if ipnet.Contains(ip) { + return proxyproto.USE, nil + } + } + return policy, nil + } + return nil +} diff --git a/plugin/proxyproto/setup_test.go b/plugin/proxyproto/setup_test.go new file mode 100644 index 000000000..f4acea2d6 --- /dev/null +++ b/plugin/proxyproto/setup_test.go @@ -0,0 +1,57 @@ +package proxyproto + +import ( + "strings" + "testing" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" +) + +func TestSetup(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedRoot string // expected root, set to the controller. Empty for negative cases. + expectedErrContent string // substring from the expected error. Empty for positive cases. + config bool + }{ + // positive + {"proxyproto", false, "", "", true}, + {"proxyproto {\nallow 127.0.0.1/8 ::1/128\n}", false, "", "", true}, + {"proxyproto {\nallow 127.0.0.1/8 ::1/128\ndefault ignore\n}", false, "", "", true}, + // Allow without any IPs is also valid + {"proxyproto {\nallow\n}", false, "", "", true}, + // negative + {"proxyproto {\nunknown\n}", true, "", "unknown option", false}, + {"proxyproto extra_arg", true, "", "Wrong argument", false}, + {"proxyproto {\nallow invalid_ip\n}", true, "", "invalid CIDR address", false}, + {"proxyproto {\nallow 127.0.0.1/8\ndefault invalid_policy\n}", true, "", "Wrong argument", false}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + err := setup(c) + cfg := dnsserver.GetConfig(c) + + if test.config && cfg.ProxyProtoConnPolicy == nil { + t.Errorf("Test %d: Expected ProxyProtoConnPolicy to be configured for input %s", i, test.input) + } + if !test.config && cfg.ProxyProtoConnPolicy != nil { + t.Errorf("Test %d: Expected ProxyProtoConnPolicy to NOT be configured for input %s", i, test.input) + } + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + } + } + } +}