From 3909cc187c86c6ed321ea1cffbb1b690c216d016 Mon Sep 17 00:00:00 2001 From: myohannes Date: Thu, 15 Oct 2026 22:28:43 -0400 Subject: [PATCH] plugin/proxyproto: expose PPv2 TLVs to downstream plugins as metadata (#8641) The proxyproto plugin parsed PROXY protocol v2 headers only to recover the real client address; the TLV vector was discarded, so downstream plugins had no way to read values such as the AWS VPC endpoint ID, SSL client CN, or vendor-specific custom TLVs. Add an opt-in `tlv_metadata` option. When set, the connection's raw PPv2 TLV vector is captured and the plugin, acting as a metadata.Provider, publishes it under the `proxyproto/tlv` label as a hex-encoded string. Consumers decode and split it with proxyproto.SplitTLVs. The option is off by default, so existing deployments skip the extra per-connection processing and behave exactly as before. PROXY protocol v1 connections, which carry no TLVs, are unaffected. Capture spans all transports: dns:// (UDP and TCP), tls:// (DoT), https:// (DoH), grpc://, quic:// (DoQ), and https3:// (DoH3). QUIC-based transports require udp_session_tracking, since the PPv2 header arrives as a standalone first datagram ahead of the QUIC handshake. Includes unit tests for capture, the metadata provider (direct and through the real metadata chain), v1 compatibility, and option parsing, plus README and man page updates. Fixes #8633 Signed-off-by: Mekias Yohannes Co-authored-by: Mekias Yohannes --- core/dnsserver/config.go | 6 + core/dnsserver/server.go | 12 +- core/dnsserver/server_grpc.go | 22 +- core/dnsserver/server_https.go | 27 +- core/dnsserver/server_https3.go | 9 +- core/dnsserver/server_quic.go | 2 +- core/dnsserver/server_tls.go | 7 +- man/coredns-proxyproto.7 | 62 ++- plugin/pkg/proxyproto/proxyproto.go | 58 ++- plugin/pkg/proxyproto/tlv_listener.go | 107 ++++++ plugin/pkg/proxyproto/tlv_test.go | 518 ++++++++++++++++++++++++++ plugin/proxyproto/README.md | 40 ++ plugin/proxyproto/metadata.go | 50 +++ plugin/proxyproto/metadata_test.go | 158 ++++++++ plugin/proxyproto/setup.go | 14 + plugin/proxyproto/setup_test.go | 34 ++ request/request.go | 20 +- request/request_test.go | 83 +++++ 18 files changed, 1209 insertions(+), 20 deletions(-) create mode 100644 plugin/pkg/proxyproto/tlv_listener.go create mode 100644 plugin/pkg/proxyproto/tlv_test.go create mode 100644 plugin/proxyproto/metadata.go create mode 100644 plugin/proxyproto/metadata_test.go diff --git a/core/dnsserver/config.go b/core/dnsserver/config.go index a93bba698..2eeddfb8b 100644 --- a/core/dnsserver/config.go +++ b/core/dnsserver/config.go @@ -106,6 +106,12 @@ type Config struct { // Has no effect unless ProxyProtoUDPSessionTrackingTTL is positive. ProxyProtoUDPSessionTrackingMaxSessions int + // ProxyProtoTLVMetadata, when true, captures the PPv2 TLV vector and exposes + // it to downstream plugins as the "proxyproto/tlv" metadata label (raw hex). + // Opt-in: off by default so existing listeners skip the extra TLV processing. + // Has no effect unless ProxyProtoConnPolicy is also set. + ProxyProtoTLVMetadata bool + // 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 5a14f9b3d..bb4d1ac08 100644 --- a/core/dnsserver/server.go +++ b/core/dnsserver/server.go @@ -84,6 +84,7 @@ type Server struct { connPolicy proxyproto.ConnPolicyFunc // Proxy Protocol connection policy function udpSessionTrackingTTL time.Duration // TTL for UDP PPv2 session tracking (0 = disabled) udpSessionTrackingMaxSessions int // LRU cap for UDP session tracking (0 = default) + proxyProtoTLVMetadata bool // capture PPv2 TLVs and expose as metadata (opt-in) 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 @@ -191,6 +192,9 @@ func NewServer(addr string, group []*Config) (*Server, error) { if site.ProxyProtoUDPSessionTrackingMaxSessions > 0 { s.udpSessionTrackingMaxSessions = site.ProxyProtoUDPSessionTrackingMaxSessions } + if site.ProxyProtoTLVMetadata { + s.proxyProtoTLVMetadata = true + } if site.UDPDecorateWriterFunc != nil { s.udpDecorateWriterFunc = site.UDPDecorateWriterFunc } @@ -259,7 +263,11 @@ func (s *Server) Listen() (net.Listener, error) { return nil, err } if s.connPolicy != nil { - l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + if s.proxyProtoTLVMetadata { + l = cproxyproto.NewTLVListener(l, s.connPolicy) + } else { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } } return l, nil } @@ -276,7 +284,7 @@ func (s *Server) ListenPacket() (net.PacketConn, error) { return nil, err } if s.connPolicy != nil { - p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy, UDPSessionTrackingTTL: s.udpSessionTrackingTTL, UDPSessionTrackingMaxSessions: s.udpSessionTrackingMaxSessions} + p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy, UDPSessionTrackingTTL: s.udpSessionTrackingTTL, UDPSessionTrackingMaxSessions: s.udpSessionTrackingMaxSessions, CaptureTLVs: s.proxyProtoTLVMetadata} } return p, nil } diff --git a/core/dnsserver/server_grpc.go b/core/dnsserver/server_grpc.go index 9f3b667e6..b890c4742 100644 --- a/core/dnsserver/server_grpc.go +++ b/core/dnsserver/server_grpc.go @@ -10,6 +10,7 @@ import ( "github.com/coredns/caddy" "github.com/coredns/coredns/pb" "github.com/coredns/coredns/plugin/pkg/dnsutil" + cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto" "github.com/coredns/coredns/plugin/pkg/reuseport" "github.com/coredns/coredns/plugin/pkg/transport" @@ -134,7 +135,11 @@ func (s *ServergRPC) Listen() (net.Listener, error) { return nil, err } if s.connPolicy != nil { - l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + if s.proxyProtoTLVMetadata { + l = cproxyproto.NewTLVListener(l, s.connPolicy) + } else { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } } return l, nil } @@ -183,12 +188,21 @@ func (s *ServergRPC) Query(ctx context.Context, in *pb.DnsPacket) (*pb.DnsPacket return nil, errors.New("no peer in gRPC context") } - a, ok := p.Addr.(*net.TCPAddr) - if !ok { + // The peer address is normally a *net.TCPAddr. When proxyproto tlv_metadata + // is enabled, the TLV-capturing listener surfaces a *cproxyproto.Addr whose + // RemoteAddr carries the PPv2 TLVs; accept it so the metadata provider can + // read them. Validate the real client address is TCP in both cases. + var remoteAddr net.Addr + switch pa := p.Addr.(type) { + case *cproxyproto.Addr: + remoteAddr = pa + case *net.TCPAddr: + remoteAddr = pa + default: return nil, fmt.Errorf("no TCP peer in gRPC context: %v", p.Addr) } - w := &gRPCresponse{localAddr: s.listenAddr, remoteAddr: a, Msg: msg} + w := &gRPCresponse{localAddr: s.listenAddr, remoteAddr: remoteAddr, Msg: msg} if tsig := msg.IsTsig(); tsig != nil { if s.TsigSecret == nil { diff --git a/core/dnsserver/server_https.go b/core/dnsserver/server_https.go index 5d43dfe15..098705c42 100644 --- a/core/dnsserver/server_https.go +++ b/core/dnsserver/server_https.go @@ -15,6 +15,7 @@ import ( "github.com/coredns/coredns/plugin/pkg/dnsutil" "github.com/coredns/coredns/plugin/pkg/doh" clog "github.com/coredns/coredns/plugin/pkg/log" + 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" @@ -56,6 +57,10 @@ func (l *loggerAdapter) Write(p []byte) (n int, err error) { // Plugins can access the original HTTP request to retrieve headers, client IP, and metadata. type HTTPRequestKey struct{} +// connContextKey is the context key under which the accepted net.Conn is stashed +// via http.Server.ConnContext, so ServeHTTP can recover PPv2 TLVs from it. +type connContextKey struct{} + // NewServerHTTPS returns a new CoreDNS HTTPS server and compiles all plugins in to it. func NewServerHTTPS(addr string, group []*Config) (*ServerHTTPS, error) { tlsConfig, err := sharedTLSConfig(addr, group) @@ -129,6 +134,14 @@ func NewServerHTTPS(addr string, group []*Config) (*ServerHTTPS, error) { } sh.httpsServer.Handler = sh + // When PPv2 TLV metadata is enabled, stash the accepted connection in the + // request context so ServeHTTP can recover the TLVs captured by the listener. + if s.proxyProtoTLVMetadata { + sh.httpsServer.ConnContext = func(ctx context.Context, c net.Conn) context.Context { + return context.WithValue(ctx, connContextKey{}, c) + } + } + return sh, nil } @@ -163,7 +176,11 @@ func (s *ServerHTTPS) Listen() (net.Listener, error) { return nil, err } if s.connPolicy != nil { - l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + if s.proxyProtoTLVMetadata { + l = cproxyproto.NewTLVListener(l, s.connPolicy) + } else { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } } return l, nil } @@ -228,6 +245,14 @@ func (s *ServerHTTPS) ServeHTTP(w http.ResponseWriter, r *http.Request) { request: r, } + // If the listener captured PPv2 TLVs, surface them via RemoteAddr so the + // proxyproto metadata provider exposes them, same as the other transports. + if conn, ok := r.Context().Value(connContextKey{}).(net.Conn); ok { + if a := cproxyproto.AddrFromConn(conn); a != nil { + dw.raddr = a + } + } + if tsig := msg.IsTsig(); tsig != nil { if s.TsigSecret == nil { dw.tsigStatus = dns.ErrSecret diff --git a/core/dnsserver/server_https3.go b/core/dnsserver/server_https3.go index b22440198..9962c3bb7 100644 --- a/core/dnsserver/server_https3.go +++ b/core/dnsserver/server_https3.go @@ -156,7 +156,7 @@ func (s *ServerHTTPS3) ListenPacket() (net.PacketConn, error) { return nil, err } if s.connPolicy != nil { - p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy} + p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy, UDPSessionTrackingTTL: s.udpSessionTrackingTTL, UDPSessionTrackingMaxSessions: s.udpSessionTrackingMaxSessions, CaptureTLVs: s.proxyProtoTLVMetadata} } return p, nil } @@ -247,6 +247,13 @@ func (s *ServerHTTPS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { request: r, } + // If proxyproto captured PPv2 TLVs, http3 stores the QUIC connection's + // RemoteAddr (our *Addr) under RemoteAddrContextKey; use it so the metadata + // provider exposes the TLVs, same as the other transports. + if ra, ok := r.Context().Value(http3.RemoteAddrContextKey).(*cproxyproto.Addr); ok { + dw.raddr = ra + } + if tsig := msg.IsTsig(); tsig != nil { if s.TsigSecret == nil { dw.tsigStatus = dns.ErrSecret diff --git a/core/dnsserver/server_quic.go b/core/dnsserver/server_quic.go index 5713b0922..891fdf5ce 100644 --- a/core/dnsserver/server_quic.go +++ b/core/dnsserver/server_quic.go @@ -298,7 +298,7 @@ func (s *ServerQUIC) ListenPacket() (net.PacketConn, error) { } if s.connPolicy != nil { - p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy} + p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy, UDPSessionTrackingTTL: s.udpSessionTrackingTTL, UDPSessionTrackingMaxSessions: s.udpSessionTrackingMaxSessions, CaptureTLVs: s.proxyProtoTLVMetadata} } s.m.Lock() diff --git a/core/dnsserver/server_tls.go b/core/dnsserver/server_tls.go index 8bf6682d7..cc5ed0ac1 100644 --- a/core/dnsserver/server_tls.go +++ b/core/dnsserver/server_tls.go @@ -7,6 +7,7 @@ import ( "time" "github.com/coredns/caddy" + cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto" "github.com/coredns/coredns/plugin/pkg/reuseport" "github.com/coredns/coredns/plugin/pkg/transport" @@ -77,7 +78,11 @@ func (s *ServerTLS) Listen() (net.Listener, error) { return nil, err } if s.connPolicy != nil { - l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + if s.proxyProtoTLVMetadata { + l = cproxyproto.NewTLVListener(l, s.connPolicy) + } else { + l = &proxyproto.Listener{Listener: l, ConnPolicy: s.connPolicy} + } } return l, nil } diff --git a/man/coredns-proxyproto.7 b/man/coredns-proxyproto.7 index bbf0ca95e..07ed8acb2 100644 --- a/man/coredns-proxyproto.7 +++ b/man/coredns-proxyproto.7 @@ -1,9 +1,9 @@ -.\" Generated by Mmark Markdown Processer - mmark.miek.nl +.\" Generated by Mmark Markdown Processor - mmark.miek.nl .TH "COREDNS-PROXYPROTO" 7 "October 2026" "CoreDNS" "CoreDNS Plugins" .SH "NAME" .PP -\fIproxyproto\fP - add PROXY protocol +\fIproxyproto\fP \- add PROXY protocol \[la]https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt\[ra] support. .SH "DESCRIPTION" @@ -21,6 +21,7 @@ proxyproto { allow default udp\_session\_tracking [max\_sessions] + tlv\_metadata } .fi @@ -47,12 +48,42 @@ The \fB\fCudp_session_tracking [max_sessions]\fR option enables UDP s for Cloudflare Spectrum's PROXY Protocol v2 over UDP. Spectrum sends the PPv2 header as a standalone first datagram (with no DNS payload). Subsequent datagrams from the same client arrive without any header. When this option is set to a positive duration, the real client address from -the header-only datagram is cached (keyed by the Spectrum-side remote address) for that duration +the header\-only datagram is cached (keyed by the Spectrum\-side remote address) for that duration and automatically applied to all subsequent headerless datagrams within that window. The TTL is refreshed on each matching packet. The optional \fB\fCmax_sessions\fR argument caps the number of concurrent sessions in the LRU cache (default: 10240). This option has no effect for TCP connections. +.PP +The \fB\fCtlv_metadata\fR option exposes the PROXY Protocol v2 TLV +\[la]https://www.haproxy.org/download/2.3/doc/proxy-protocol.txt\[ra] +(Type\-Length\-Value) vector from the header to downstream plugins via the +\fImetadata\fP +\[la]../metadata/README.md\[ra] plugin. When set, and the \fImetadata\fP plugin is enabled, the +raw TLV vector is published under the label \fB\fCproxyproto/tlv\fR as a hex\-encoded string. Consumers +decode the hex and split it with \fB\fCproxyproto.SplitTLVs\fR from +\fB\fCgithub.com/pires/go-proxyproto\fR to read individual TLVs (e.g. the AWS VPC endpoint ID, SSL +client CN, or vendor\-specific custom TLVs). This option is opt\-in: without it, TLVs are not +captured and no extra per\-connection processing occurs. It applies to all transports: \fB\fCdns://\fR +(UDP and TCP), \fB\fCtls://\fR (DoT), \fB\fChttps://\fR (DoH), \fB\fCgrpc://\fR, \fB\fCquic://\fR (DoQ), and \fB\fChttps3://\fR +(DoH3). + +.PP +For the QUIC\-based transports (\fB\fCquic://\fR and \fB\fChttps3://\fR), the PPv2 header arrives as a +standalone first datagram ahead of the QUIC handshake, so TLV capture additionally requires +\fB\fCudp_session_tracking\fR to be enabled; the cached session links the subsequent headerless QUIC +datagrams to the header's source and TLVs. + +.SH "METADATA" +.PP +When the \fB\fCtlv_metadata\fR option and the \fImetadata\fP plugin are both enabled, the following metadata +label is made available: + +.IP \(bu 4 +\fB\fCproxyproto/tlv\fR: the raw PPv2 TLV vector, hex\-encoded. Only set when the request's connection +carried a PPv2 header with at least one TLV. + + .SH "EXAMPLES" .PP In this configuration, we allow PROXY protocol connections from all IP addresses: @@ -107,8 +138,8 @@ connections without valid PROXY protocol headers from those sources: .RE .PP -In this configuration, we enable UDP session tracking for Cloudflare Spectrum's PPv2-over-UDP -with a 28-second TTL (slightly shorter than Spectrum's 30-second UDP idle timeout) and the +In this configuration, we enable UDP session tracking for Cloudflare Spectrum's PPv2\-over\-UDP +with a 28\-second TTL (slightly shorter than Spectrum's 30\-second UDP idle timeout) and the default session cap of 10240: .PP @@ -144,3 +175,24 @@ In this configuration, the session cap is raised to 20480: .fi .RE +.PP +In this configuration, PPv2 TLVs are exposed to downstream plugins as metadata. The \fImetadata\fP +plugin must also be enabled. Here the \fIlog\fP plugin records the raw TLV vector: + +.PP +.RS + +.nf +\&. { + metadata + proxyproto { + allow 192.168.1.1/32 + tlv\_metadata + } + forward . /etc/resolv.conf + log . "{remote} {/proxyproto/tlv}" +} + +.fi +.RE + diff --git a/plugin/pkg/proxyproto/proxyproto.go b/plugin/pkg/proxyproto/proxyproto.go index be362babc..593139aea 100644 --- a/plugin/pkg/proxyproto/proxyproto.go +++ b/plugin/pkg/proxyproto/proxyproto.go @@ -59,6 +59,10 @@ type PacketConn struct { // positive. UDPSessionTrackingMaxSessions int + // CaptureTLVs populates Addr.rawTLVs from the PPv2 header when set. Opt-in, + // so connections without it skip the extra TLV processing. + CaptureTLVs bool + // sessionCache is a thread-safe expirable LRU; lazily initialized on // first use when UDPSessionTrackingTTL > 0. sessionCache *expirable.LRU[string, *proxyproto.Header] @@ -131,7 +135,7 @@ func (c *PacketConn) readFrom(p []byte, addr net.Addr) (_ int, _ net.Addr, err e case proxyproto.USE: if header != nil { if !header.Command.IsLocal() { - addr = &Addr{u: addr, r: header.SourceAddr} + addr = &Addr{u: addr, r: header.SourceAddr, rawTLVs: c.tlvsFor(header)} if c.UDPSessionTrackingTTL > 0 { // Cache the real source address for subsequent headerless datagrams. @@ -150,7 +154,7 @@ func (c *PacketConn) readFrom(p []byte, addr net.Addr) (_ int, _ net.Addr, err e } else if c.UDPSessionTrackingTTL > 0 { // No header present – look for a cached header for this remote. if cachedHeader, ok := c.lookupSession(addr); ok { - addr = &Addr{u: addr, r: cachedHeader.SourceAddr} + addr = &Addr{u: addr, r: cachedHeader.SourceAddr, rawTLVs: c.tlvsFor(cachedHeader)} } } default: @@ -162,6 +166,17 @@ func (c *PacketConn) readFrom(p []byte, addr net.Addr) (_ int, _ net.Addr, err e type Addr struct { u net.Addr r net.Addr + // rawTLVs is the uninterpreted PPv2 TLV vector. It rides on Addr only to + // cross the miekg/dns ResponseWriter boundary; the proxyproto plugin reads + // it back off RemoteAddr and publishes it as request metadata. + rawTLVs []byte +} + +// NewAddr returns an Addr whose Network reflects underlying, whose String +// reflects real (the PROXY-reported client), and that carries the raw PPv2 TLV +// vector (may be nil). +func NewAddr(underlying, real net.Addr, rawTLVs []byte) *Addr { + return &Addr{u: underlying, r: real, rawTLVs: rawTLVs} } func (a *Addr) Network() string { @@ -172,6 +187,45 @@ func (a *Addr) String() string { return a.r.String() } +// RawTLVs returns the raw PPv2 TLV vector carried with this address, or nil. +func (a *Addr) RawTLVs() []byte { + return a.rawTLVs +} + +// Unwrap returns the underlying real client address (a *net.TCPAddr or +// *net.UDPAddr). Callers that classify by concrete transport/family type — +// e.g. request.Proto and request.Family — unwrap through this so an Addr is +// treated as its real transport rather than an unknown net.Addr. +func (a *Addr) Unwrap() net.Addr { + return a.r +} + +// tlvsFor returns the header's raw TLVs when TLV capture is enabled, else nil. +func (c *PacketConn) tlvsFor(header *proxyproto.Header) []byte { + if !c.CaptureTLVs { + return nil + } + return rawTLVs(header) +} + +// rawTLVs returns the header's TLV vector as uninterpreted bytes, or nil if +// the header carries no TLVs. It uses the library's public API; NOOP padding +// is dropped, which does not matter to consumers that re-split the vector. +func rawTLVs(header *proxyproto.Header) []byte { + if header == nil { + return nil + } + tlvs, err := header.TLVs() + if err != nil || len(tlvs) == 0 { + return nil + } + raw, err := proxyproto.JoinTLVs(tlvs) + if err != nil { + return nil + } + return raw +} + func parseProxyProtocol(packet []byte) (*proxyproto.Header, []byte, error) { reader := bufio.NewReader(bytes.NewReader(packet)) diff --git a/plugin/pkg/proxyproto/tlv_listener.go b/plugin/pkg/proxyproto/tlv_listener.go new file mode 100644 index 000000000..0676acf7d --- /dev/null +++ b/plugin/pkg/proxyproto/tlv_listener.go @@ -0,0 +1,107 @@ +package proxyproto + +import ( + "net" + + "github.com/pires/go-proxyproto" +) + +// NewTLVListener wraps l with a proxyproto.Listener using connPolicy, and makes +// accepted connections surface the PPv2 TLV vector via RemoteAddr. The TLVs +// ride on the address only to cross the miekg/dns boundary; the proxyproto +// plugin reads them back and publishes them as request metadata. +func NewTLVListener(l net.Listener, connPolicy proxyproto.ConnPolicyFunc) net.Listener { + return &TLVListener{inner: &proxyproto.Listener{Listener: l, ConnPolicy: connPolicy}} +} + +// TLVListener is a net.Listener whose connections surface PPv2 TLVs via +// RemoteAddr. Construct one with NewTLVListener. +type TLVListener struct { + inner net.Listener +} + +func (l *TLVListener) Addr() net.Addr { return l.inner.Addr() } +func (l *TLVListener) Close() error { return l.inner.Close() } + +// Accept returns a connection whose RemoteAddr is a *Addr carrying the PPv2 +// TLVs, when the accepted connection is a *proxyproto.Conn with a header. +func (l *TLVListener) Accept() (net.Conn, error) { + c, err := l.inner.Accept() + if err != nil { + return nil, err + } + pc, ok := c.(*proxyproto.Conn) + if !ok { + return c, nil + } + return &tlvConn{Conn: pc, pc: pc}, nil +} + +// tlvConn overrides RemoteAddr to carry the connection's PPv2 TLV vector. +type tlvConn struct { + net.Conn + pc *proxyproto.Conn +} + +// RemoteAddr returns the real client address, wrapped in *Addr with the PPv2 +// TLVs when the header carries any. ProxyHeader triggers the connection's +// one-time header processing; policy handling stays inside go-proxyproto. +func (c *tlvConn) RemoteAddr() net.Addr { + if a := AddrFromConn(c.pc); a != nil { + return a + } + return c.pc.RemoteAddr() +} + +// AddrFromConn returns an *Addr carrying the connection's PPv2 TLVs, or nil if +// none are available. It first checks whether RemoteAddr already yields an +// *Addr (our tlvConn's RemoteAddr is promoted through wrappers such as +// *tls.Conn and the connection-limiting conn, which all delegate RemoteAddr), +// then falls back to unwrapping to a *proxyproto.Conn. Transports that do not +// use RemoteAddr as the capture path (e.g. DoH) call this to recover the TLVs. +func AddrFromConn(conn net.Conn) *Addr { + if conn == nil { + return nil + } + if a, ok := conn.RemoteAddr().(*Addr); ok { + return a + } + pc := underlyingProxyConn(conn) + if pc == nil { + return nil + } + header := pc.ProxyHeader() + // Discard the protocol block for LOCAL commands (PROXY protocol spec); the + // UDP path excludes LOCAL too. Publishing LOCAL TLVs would give consumers + // context that should have been ignored. + if header == nil || header.Command.IsLocal() { + return nil + } + raw := rawTLVs(header) + if raw == nil { + return nil + } + base := pc.RemoteAddr() + return &Addr{u: base, r: base, rawTLVs: raw} +} + +// netConner is satisfied by *tls.Conn (and similar wrappers) that expose their +// underlying connection. +type netConner interface{ NetConn() net.Conn } + +// underlyingProxyConn unwraps TLS and tlvConn layers to find a *proxyproto.Conn. +func underlyingProxyConn(conn net.Conn) *proxyproto.Conn { + for i := 0; i < 4 && conn != nil; i++ { + switch c := conn.(type) { + case *proxyproto.Conn: + return c + case *tlvConn: + conn = c.pc + case netConner: + conn = c.NetConn() + default: + return nil + } + } + return nil +} diff --git a/plugin/pkg/proxyproto/tlv_test.go b/plugin/pkg/proxyproto/tlv_test.go new file mode 100644 index 000000000..26519ae91 --- /dev/null +++ b/plugin/pkg/proxyproto/tlv_test.go @@ -0,0 +1,518 @@ +package proxyproto + +import ( + "bytes" + "io" + "net" + "testing" + "time" + + proxyproto "github.com/pires/go-proxyproto" +) + +// TestV1HeaderNoTLVs verifies PROXY protocol v1 (which has no TLVs) is handled +// cleanly: capture is a no-op, with no error or panic. +func TestV1HeaderNoTLVs(t *testing.T) { + h := &proxyproto.Header{ + Version: 1, + Command: proxyproto.PROXY, + TransportProtocol: proxyproto.TCPv4, + SourceAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000}, + DestinationAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53}, + } + if got := rawTLVs(h); got != nil { + t.Fatalf("rawTLVs(v1) = %v, want nil", got) + } + pc := &PacketConn{CaptureTLVs: true} + if got := pc.tlvsFor(h); got != nil { + t.Fatalf("tlvsFor(v1) = %v, want nil", got) + } + if tlvs, err := h.TLVs(); err != nil || len(tlvs) != 0 { + t.Fatalf("v1 TLVs() = %v, %v; want empty, nil", tlvs, err) + } +} + +// TestAddrFromConnCommand covers both PROXY and LOCAL commands: PROXY publishes +// the TLVs, LOCAL must not (the PROXY protocol spec requires discarding the +// protocol block for LOCAL, even though go-proxyproto retains the parsed TLVs). +func TestAddrFromConnCommand(t *testing.T) { + tlvs := []proxyproto.TLV{{Type: 0xEA, Value: []byte{0x01, 0x02}}} + tests := []struct { + name string + command proxyproto.ProtocolVersionAndCommand + wantTLV bool + }{ + {"PROXY", proxyproto.PROXY, true}, + {"LOCAL", proxyproto.LOCAL, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := &proxyproto.Header{ + Version: 2, + Command: tc.command, + TransportProtocol: proxyproto.TCPv4, + SourceAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000}, + DestinationAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53}, + } + if err := h.SetTLVs(tlvs); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + raw, _ := h.Format() + pc := proxyproto.NewConn(&preloadedConn{buf: raw}) + defer pc.Close() + + a := AddrFromConn(pc) + if tc.wantTLV { + if a == nil || len(a.RawTLVs()) == 0 { + t.Fatalf("AddrFromConn(%s) did not return TLVs", tc.name) + } + } else if a != nil { + t.Fatalf("AddrFromConn(%s) = %v, want nil (TLVs must not be published)", tc.name, a) + } + }) + } +} + +// fakeListener returns a preset sequence of conns from Accept. +type fakeListener struct { + conns []net.Conn + i int + addr net.Addr + closed bool +} + +func (l *fakeListener) Accept() (net.Conn, error) { + if l.i >= len(l.conns) { + return nil, net.ErrClosed + } + c := l.conns[l.i] + l.i++ + return c, nil +} +func (l *fakeListener) Close() error { l.closed = true; return nil } +func (l *fakeListener) Addr() net.Addr { + if l.addr != nil { + return l.addr + } + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 53} +} + +func TestNewTLVListenerAcceptWrapsProxyConn(t *testing.T) { + src := &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000} + dst := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53} + h := &proxyproto.Header{ + Version: 2, Command: proxyproto.PROXY, TransportProtocol: proxyproto.TCPv4, + SourceAddr: src, DestinationAddr: dst, + } + if err := h.SetTLVs([]proxyproto.TLV{{Type: 0xEA, Value: []byte{0x01, 0x02}}}); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + raw, _ := h.Format() + pc := proxyproto.NewConn(&preloadedConn{buf: raw}) + + // Inject a fake inner listener that hands back the *proxyproto.Conn, so we + // test TLVListener.Accept's wrapping without driving the real proxyproto + // Listener's own header/policy handling. + l := &TLVListener{inner: &fakeListener{conns: []net.Conn{pc}}} + defer l.Close() + _ = l.Addr() + + c, err := l.Accept() + if err != nil { + t.Fatalf("Accept: %v", err) + } + a, ok := c.RemoteAddr().(*Addr) + if !ok { + t.Fatalf("Accept conn RemoteAddr type = %T, want *Addr", c.RemoteAddr()) + } + got, err := proxyproto.SplitTLVs(a.RawTLVs()) + if err != nil || len(got) != 1 || !bytes.Equal(got[0].Value, []byte{0x01, 0x02}) { + t.Fatalf("TLV mismatch: %+v err=%v", got, err) + } + c.Close() +} + +func TestTLVListenerAcceptPassesThroughNonProxyConn(t *testing.T) { + // A non-*proxyproto.Conn is returned unchanged. + plain := plainRemoteConn{src: &net.TCPAddr{IP: net.ParseIP("198.51.100.1"), Port: 7000}} + l := &TLVListener{inner: &fakeListener{conns: []net.Conn{plain}}} + c, err := l.Accept() + if err != nil { + t.Fatalf("Accept: %v", err) + } + if _, ok := c.RemoteAddr().(*Addr); ok { + t.Fatal("non-proxy conn should pass through without *Addr") + } +} + +func TestNewTLVListenerConstructor(t *testing.T) { + // NewTLVListener returns a *TLVListener wrapping a proxyproto.Listener. + base := &fakeListener{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9953}} + l := NewTLVListener(base, nil) + if l.Addr().String() != base.Addr().String() { + t.Fatalf("Addr = %s, want %s", l.Addr(), base.Addr()) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !base.closed { + t.Fatal("expected inner listener to be closed") + } +} + +func TestNewAddr(t *testing.T) { + u := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53} + r := &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000} + a := NewAddr(u, r, []byte{0xaa}) + if a.Network() != u.Network() { + t.Errorf("Network = %q, want %q", a.Network(), u.Network()) + } + if a.String() != r.String() { + t.Errorf("String = %q, want %q", a.String(), r.String()) + } + if !bytes.Equal(a.RawTLVs(), []byte{0xaa}) { + t.Errorf("RawTLVs = %v, want [aa]", a.RawTLVs()) + } +} + +// tlsLikeConn wraps a net.Conn and exposes NetConn, mimicking *tls.Conn so the +// netConner branch of underlyingProxyConn is exercised. +type tlsLikeConn struct { + net.Conn + inner net.Conn +} + +func (c *tlsLikeConn) NetConn() net.Conn { return c.inner } + +func TestAddrFromConnUnwrapsTLSLayer(t *testing.T) { + src := &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000} + dst := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53} + h := &proxyproto.Header{ + Version: 2, Command: proxyproto.PROXY, TransportProtocol: proxyproto.TCPv4, + SourceAddr: src, DestinationAddr: dst, + } + if err := h.SetTLVs([]proxyproto.TLV{{Type: 0xEA, Value: []byte{0x07}}}); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + raw, _ := h.Format() + pc := proxyproto.NewConn(&preloadedConn{buf: raw}) + + // tlsLike -> *proxyproto.Conn. RemoteAddr on the plain tls-like wrapper does + // NOT promote the proxy header, so AddrFromConn must fall through to the + // NetConn-unwrap path to reach the *proxyproto.Conn. + wrapped := &tlsLikeConn{Conn: plainRemoteConn{src: dst}, inner: pc} + + a := AddrFromConn(wrapped) + if a == nil { + t.Fatal("AddrFromConn did not unwrap tls-like layer") + } + got, err := proxyproto.SplitTLVs(a.RawTLVs()) + if err != nil || len(got) != 1 || !bytes.Equal(got[0].Value, []byte{0x07}) { + t.Fatalf("TLV mismatch after unwrap: %+v err=%v", got, err) + } + pc.Close() +} + +func TestAddrFromConnUnknownConn(t *testing.T) { + // A conn that is neither *proxyproto.Conn, *tlvConn, nor a netConner, and + // whose RemoteAddr is a plain addr: AddrFromConn returns nil. + if a := AddrFromConn(plainRemoteConn{src: &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 5}}); a != nil { + t.Fatalf("AddrFromConn(unknown) = %v, want nil", a) + } +} + +// plainRemoteConn is a net.Conn whose RemoteAddr is a plain *net.TCPAddr. +type plainRemoteConn struct { + net.Conn + src net.Addr +} + +func (c plainRemoteConn) RemoteAddr() net.Addr { return c.src } + +// preloadedConn is a net.Conn whose Read drains a fixed byte buffer (the PPv2 +// header), avoiding net.Pipe synchronization in unit tests. +type preloadedConn struct { + net.Conn + buf []byte + off int + remote net.Addr +} + +func (c *preloadedConn) Read(p []byte) (int, error) { + if c.off >= len(c.buf) { + return 0, io.EOF + } + n := copy(p, c.buf[c.off:]) + c.off += n + return n, nil +} +func (c *preloadedConn) Write(p []byte) (int, error) { return len(p), nil } +func (c *preloadedConn) Close() error { return nil } +func (c *preloadedConn) RemoteAddr() net.Addr { + if c.remote != nil { + return c.remote + } + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1} +} +func (c *preloadedConn) LocalAddr() net.Addr { + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 53} +} +func (c *preloadedConn) SetDeadline(time.Time) error { return nil } +func (c *preloadedConn) SetReadDeadline(time.Time) error { return nil } +func (c *preloadedConn) SetWriteDeadline(time.Time) error { return nil } + +// ppv2UDPPacketWithTLVs builds a valid PPv2 UDP datagram carrying the given +// TLVs followed by the DNS payload, using the library's own formatter. +func ppv2UDPPacketWithTLVs(t *testing.T, src, dst *net.UDPAddr, tlvs []proxyproto.TLV, payload []byte) []byte { + t.Helper() + h := &proxyproto.Header{ + Version: 2, + Command: proxyproto.PROXY, + TransportProtocol: proxyproto.UDPv4, + SourceAddr: src, + DestinationAddr: dst, + } + if len(tlvs) > 0 { + if err := h.SetTLVs(tlvs); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + } + raw, err := h.Format() + if err != nil { + t.Fatalf("Format: %v", err) + } + return append(raw, payload...) +} + +func usePolicy(proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + return proxyproto.USE, nil +} + +func TestReadFromCapturesTLVsWhenEnabled(t *testing.T) { + src := udpAddr("192.168.1.10", 40000) + dst := udpAddr("10.0.0.1", 53) + payload := []byte("dns-payload") + tlvs := []proxyproto.TLV{ + {Type: proxyproto.PP2_TYPE_AUTHORITY, Value: []byte("example.org")}, + {Type: 0xEA, Value: []byte{0x01, 0x02, 0x03}}, + } + packet := ppv2UDPPacketWithTLVs(t, src, dst, tlvs, payload) + + pc := &PacketConn{PacketConn: &singlePacketConn{}, ConnPolicy: usePolicy, CaptureTLVs: true} + n, addr, err := pc.readFrom(packet, udpAddr("203.0.113.9", 5000)) + if err != nil { + t.Fatalf("readFrom: %v", err) + } + if !bytes.Equal(packet[:n], payload) { + t.Fatalf("payload = %q, want %q", packet[:n], payload) + } + a, ok := addr.(*Addr) + if !ok { + t.Fatalf("addr type = %T, want *Addr", addr) + } + raw := a.RawTLVs() + if len(raw) == 0 { + t.Fatal("expected raw TLVs to be captured") + } + // Round-trip: the captured bytes must re-split to the TLVs we sent. + got, err := proxyproto.SplitTLVs(raw) + if err != nil { + t.Fatalf("SplitTLVs: %v", err) + } + if len(got) != len(tlvs) { + t.Fatalf("got %d TLVs, want %d", len(got), len(tlvs)) + } + for i, want := range tlvs { + if got[i].Type != want.Type || !bytes.Equal(got[i].Value, want.Value) { + t.Errorf("TLV %d = {%#x,%v}, want {%#x,%v}", i, got[i].Type, got[i].Value, want.Type, want.Value) + } + } +} + +func TestReadFromSkipsTLVsWhenDisabled(t *testing.T) { + src := udpAddr("192.168.1.10", 40000) + dst := udpAddr("10.0.0.1", 53) + tlvs := []proxyproto.TLV{{Type: 0xEA, Value: []byte{0x09}}} + packet := ppv2UDPPacketWithTLVs(t, src, dst, tlvs, []byte("x")) + + pc := &PacketConn{PacketConn: &singlePacketConn{}, ConnPolicy: usePolicy} // CaptureTLVs defaults to false + _, addr, err := pc.readFrom(packet, udpAddr("203.0.113.9", 5000)) + if err != nil { + t.Fatalf("readFrom: %v", err) + } + a, ok := addr.(*Addr) + if !ok { + t.Fatalf("addr type = %T, want *Addr", addr) + } + if a.RawTLVs() != nil { + t.Fatalf("expected no TLVs captured when disabled, got %v", a.RawTLVs()) + } +} + +func TestReadFromNoTLVsPresent(t *testing.T) { + src := udpAddr("192.168.1.10", 40000) + dst := udpAddr("10.0.0.1", 53) + packet := ppv2UDPPacketWithTLVs(t, src, dst, nil, []byte("x")) + + pc := &PacketConn{PacketConn: &singlePacketConn{}, ConnPolicy: usePolicy, CaptureTLVs: true} + _, addr, err := pc.readFrom(packet, udpAddr("203.0.113.9", 5000)) + if err != nil { + t.Fatalf("readFrom: %v", err) + } + a, ok := addr.(*Addr) + if !ok { + t.Fatalf("addr type = %T, want *Addr", addr) + } + if a.RawTLVs() != nil { + t.Fatalf("expected nil TLVs when header has none, got %v", a.RawTLVs()) + } +} + +func TestRawTLVsHelper(t *testing.T) { + // nil header -> nil + if got := rawTLVs(nil); got != nil { + t.Fatalf("rawTLVs(nil) = %v, want nil", got) + } + // header with no TLVs -> nil + h := &proxyproto.Header{Version: 2} + if got := rawTLVs(h); got != nil { + t.Fatalf("rawTLVs(no tlvs) = %v, want nil", got) + } + // header with TLVs -> re-splittable bytes + tlvs := []proxyproto.TLV{{Type: proxyproto.PP2_TYPE_UNIQUE_ID, Value: []byte("abc")}} + if err := h.SetTLVs(tlvs); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + raw := rawTLVs(h) + got, err := proxyproto.SplitTLVs(raw) + if err != nil { + t.Fatalf("SplitTLVs: %v", err) + } + if len(got) != 1 || got[0].Type != proxyproto.PP2_TYPE_UNIQUE_ID || !bytes.Equal(got[0].Value, []byte("abc")) { + t.Fatalf("round-trip mismatch: %+v", got) + } +} + +func TestTLVConnRemoteAddrCarriesTLVs(t *testing.T) { + src := &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000} + dst := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53} + tlvs := []proxyproto.TLV{{Type: 0xEA, Value: []byte{0xaa, 0xbb}}} + h := &proxyproto.Header{ + Version: 2, + Command: proxyproto.PROXY, + TransportProtocol: proxyproto.TCPv4, + SourceAddr: src, + DestinationAddr: dst, + } + if err := h.SetTLVs(tlvs); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + raw, err := h.Format() + if err != nil { + t.Fatalf("Format: %v", err) + } + + client, server := net.Pipe() + go func() { + client.Write(raw) + // Keep the pipe open; ProxyHeader only needs the header bytes. + }() + + pc := proxyproto.NewConn(server) + tc := &tlvConn{Conn: pc, pc: pc} + + addr := tc.RemoteAddr() + a, ok := addr.(*Addr) + if !ok { + t.Fatalf("RemoteAddr type = %T, want *Addr", addr) + } + if a.String() != src.String() { + t.Errorf("RemoteAddr = %s, want %s", a.String(), src) + } + got, err := proxyproto.SplitTLVs(a.RawTLVs()) + if err != nil { + t.Fatalf("SplitTLVs: %v", err) + } + if len(got) != 1 || got[0].Type != 0xEA || !bytes.Equal(got[0].Value, []byte{0xaa, 0xbb}) { + t.Fatalf("TLV round-trip mismatch: %+v", got) + } + tc.Close() +} + +func TestTLVConnRemoteAddrNoHeader(t *testing.T) { + // A connection with no PROXY header: RemoteAddr falls back to the plain + // address, not an *Addr. + client, server := net.Pipe() + go func() { + client.Write([]byte("not a proxy header")) + }() + pc := proxyproto.NewConn(server) + tc := &tlvConn{Conn: pc, pc: pc} + if _, ok := tc.RemoteAddr().(*Addr); ok { + t.Fatal("expected plain addr when no PROXY header present") + } + tc.Close() +} + +// delegatingConn embeds a net.Conn and promotes its RemoteAddr, mimicking the +// wrapper chain (tls.Conn, connection limiter) the DoH path sees. +type delegatingConn struct { + net.Conn +} + +func TestAddrFromConnDirect(t *testing.T) { + src := &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000} + dst := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53} + h := &proxyproto.Header{ + Version: 2, Command: proxyproto.PROXY, TransportProtocol: proxyproto.TCPv4, + SourceAddr: src, DestinationAddr: dst, + } + if err := h.SetTLVs([]proxyproto.TLV{{Type: 0xEA, Value: []byte{0x01}}}); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + raw, _ := h.Format() + client, server := net.Pipe() + go func() { client.Write(raw) }() + pc := proxyproto.NewConn(server) + + // Direct *proxyproto.Conn. + if a := AddrFromConn(pc); a == nil || len(a.RawTLVs()) == 0 { + t.Fatal("AddrFromConn(*proxyproto.Conn) did not recover TLVs") + } + pc.Close() +} + +func TestAddrFromConnThroughWrapper(t *testing.T) { + src := &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000} + dst := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53} + h := &proxyproto.Header{ + Version: 2, Command: proxyproto.PROXY, TransportProtocol: proxyproto.TCPv4, + SourceAddr: src, DestinationAddr: dst, + } + if err := h.SetTLVs([]proxyproto.TLV{{Type: 0xEA, Value: []byte{0x02, 0x03}}}); err != nil { + t.Fatalf("SetTLVs: %v", err) + } + raw, _ := h.Format() + client, server := net.Pipe() + go func() { client.Write(raw) }() + pc := proxyproto.NewConn(server) + tc := &tlvConn{Conn: pc, pc: pc} + // Wrap in a conn that only promotes RemoteAddr (like tls.Conn / limiter). + wrapped := &delegatingConn{Conn: tc} + + a := AddrFromConn(wrapped) + if a == nil { + t.Fatal("AddrFromConn through wrapper returned nil") + } + got, err := proxyproto.SplitTLVs(a.RawTLVs()) + if err != nil || len(got) != 1 || !bytes.Equal(got[0].Value, []byte{0x02, 0x03}) { + t.Fatalf("TLV mismatch through wrapper: %+v err=%v", got, err) + } + tc.Close() +} + +func TestAddrFromConnNil(t *testing.T) { + if a := AddrFromConn(nil); a != nil { + t.Fatalf("AddrFromConn(nil) = %v, want nil", a) + } +} diff --git a/plugin/proxyproto/README.md b/plugin/proxyproto/README.md index c2c4c5f39..4efe93bb3 100644 --- a/plugin/proxyproto/README.md +++ b/plugin/proxyproto/README.md @@ -17,6 +17,7 @@ proxyproto { allow default udp_session_tracking [max_sessions] + tlv_metadata } ~~~ @@ -40,6 +41,30 @@ refreshed on each matching packet. The optional `max_sessions` argument caps the concurrent sessions in the LRU cache (default: 10240). This option has no effect for TCP connections. +The `tlv_metadata` option exposes the PROXY Protocol v2 [TLV](https://www.haproxy.org/download/2.3/doc/proxy-protocol.txt) +(Type-Length-Value) vector from the header to downstream plugins via the +[*metadata*](../metadata/README.md) plugin. When set, and the *metadata* plugin is enabled, the +raw TLV vector is published under the label `proxyproto/tlv` as a hex-encoded string. Consumers +decode the hex and split it with `proxyproto.SplitTLVs` from +`github.com/pires/go-proxyproto` to read individual TLVs (e.g. the AWS VPC endpoint ID, SSL +client CN, or vendor-specific custom TLVs). This option is opt-in: without it, TLVs are not +captured and no extra per-connection processing occurs. It applies to all transports: `dns://` +(UDP and TCP), `tls://` (DoT), `https://` (DoH), `grpc://`, `quic://` (DoQ), and `https3://` +(DoH3). + +For the QUIC-based transports (`quic://` and `https3://`), the PPv2 header arrives as a +standalone first datagram ahead of the QUIC handshake, so TLV capture additionally requires +`udp_session_tracking` to be enabled; the cached session links the subsequent headerless QUIC +datagrams to the header's source and TLVs. + +## Metadata + +When the `tlv_metadata` option and the *metadata* plugin are both enabled, the following metadata +label is made available: + +- `proxyproto/tlv`: the raw PPv2 TLV vector, hex-encoded. Only set when the request's connection + carried a PPv2 header with at least one TLV. + ## Examples In this configuration, we allow PROXY protocol connections from all IP addresses: @@ -101,3 +126,18 @@ In this configuration, the session cap is raised to 20480: forward . /etc/resolv.conf } ~~~ + +In this configuration, PPv2 TLVs are exposed to downstream plugins as metadata. The *metadata* +plugin must also be enabled. Here the *log* plugin records the raw TLV vector: + +~~~ corefile +. { + metadata + proxyproto { + allow 192.168.1.1/32 + tlv_metadata + } + forward . /etc/resolv.conf + log . "{remote} {/proxyproto/tlv}" +} +~~~ diff --git a/plugin/proxyproto/metadata.go b/plugin/proxyproto/metadata.go new file mode 100644 index 000000000..d41179509 --- /dev/null +++ b/plugin/proxyproto/metadata.go @@ -0,0 +1,50 @@ +package proxyproto + +import ( + "context" + "encoding/hex" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metadata" + cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// handler is a pass-through plugin that implements metadata.Provider to expose +// the PPv2 TLV vector captured by the listener. It is only added to the chain +// when the tlv_metadata option is set. +type handler struct { + Next plugin.Handler +} + +// Name implements plugin.Handler. +func (h handler) Name() string { return "proxyproto" } + +// ServeDNS implements plugin.Handler; it only passes the request on. +func (h handler) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) +} + +// Metadata implements metadata.Provider. It publishes the raw PPv2 TLV vector +// as the "proxyproto/tlv" label, hex-encoded, when the current request carries +// TLVs. Consumers decode the hex and split it with proxyproto.SplitTLVs. +func (h handler) Metadata(ctx context.Context, state request.Request) context.Context { + a, ok := state.W.RemoteAddr().(*cproxyproto.Addr) + if !ok { + return ctx + } + raw := a.RawTLVs() + if len(raw) == 0 { + return ctx + } + v := hex.EncodeToString(raw) + metadata.SetValueFunc(ctx, "proxyproto/tlv", func() string { return v }) + return ctx +} + +var ( + _ plugin.Handler = handler{} + _ metadata.Provider = handler{} +) diff --git a/plugin/proxyproto/metadata_test.go b/plugin/proxyproto/metadata_test.go new file mode 100644 index 000000000..2600d29fb --- /dev/null +++ b/plugin/proxyproto/metadata_test.go @@ -0,0 +1,158 @@ +package proxyproto + +import ( + "context" + "encoding/hex" + "net" + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metadata" + cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + proxyproto "github.com/pires/go-proxyproto" +) + +// TestMetadataThroughChain drives metadata.Collect + the chain with the real +// handler as Provider, asserting a downstream plugin reads proxyproto/tlv. +func TestMetadataThroughChain(t *testing.T) { + tlvs := []proxyproto.TLV{{Type: 0xEA, Value: []byte{0xca, 0xfe}}} + raw, err := proxyproto.JoinTLVs(tlvs) + if err != nil { + t.Fatalf("JoinTLVs: %v", err) + } + addr := cproxyproto.NewAddr( + &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53}, + &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000}, + raw, + ) + w := &tlvResponseWriter{addr: addr} + + // Terminal plugin captures what it reads from the metadata label. + var gotLabel string + var gotPresent bool + terminal := plugin.HandlerFunc(func(ctx context.Context, _ dns.ResponseWriter, _ *dns.Msg) (int, error) { + if f := metadata.ValueFunc(ctx, "proxyproto/tlv"); f != nil { + gotPresent = true + gotLabel = f() + } + return dns.RcodeSuccess, nil + }) + + // proxyproto handler sits between metadata and the terminal, and is also the + // Provider the metadata plugin collects from. + h := handler{Next: terminal} + meta := metadata.Metadata{ + Zones: []string{"."}, + Providers: []metadata.Provider{h}, + Next: h, + } + + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeA) + ctx := meta.Collect(context.Background(), request.Request{W: w, Req: m}) + if _, err := meta.ServeDNS(ctx, w, m); err != nil { + t.Fatalf("ServeDNS: %v", err) + } + + if !gotPresent { + t.Fatal("downstream plugin did not see proxyproto/tlv label") + } + if want := hex.EncodeToString(raw); gotLabel != want { + t.Fatalf("proxyproto/tlv = %q, want %q", gotLabel, want) + } +} + +// nextHandler records that it was called and returns a sentinel rcode. +type nextHandler struct{ called bool } + +func (h *nextHandler) Name() string { return "next" } +func (h *nextHandler) ServeDNS(_ context.Context, _ dns.ResponseWriter, _ *dns.Msg) (int, error) { + h.called = true + return dns.RcodeSuccess, nil +} + +func TestHandlerName(t *testing.T) { + if got := (handler{}).Name(); got != "proxyproto" { + t.Fatalf("Name = %q, want %q", got, "proxyproto") + } +} + +func TestHandlerServeDNSPassthrough(t *testing.T) { + next := &nextHandler{} + h := handler{Next: next} + rc, err := h.ServeDNS(context.Background(), &test.ResponseWriter{}, new(dns.Msg)) + if err != nil { + t.Fatalf("ServeDNS err = %v", err) + } + if rc != dns.RcodeSuccess { + t.Fatalf("rcode = %d, want %d", rc, dns.RcodeSuccess) + } + if !next.called { + t.Fatal("expected next handler to be called") + } +} + +// tlvResponseWriter is a test ResponseWriter whose RemoteAddr returns a +// cproxyproto.Addr carrying the given raw TLV bytes. +type tlvResponseWriter struct { + test.ResponseWriter + addr net.Addr +} + +func (w *tlvResponseWriter) RemoteAddr() net.Addr { return w.addr } + +func TestMetadataPublishesTLV(t *testing.T) { + // Build raw TLVs the way the listener would capture them. + tlvs := []proxyproto.TLV{{Type: 0xEA, Value: []byte{0x01, 0x02}}} + raw, err := proxyproto.JoinTLVs(tlvs) + if err != nil { + t.Fatalf("JoinTLVs: %v", err) + } + addr := cproxyproto.NewAddr( + &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53}, + &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000}, + raw, + ) + + w := &tlvResponseWriter{addr: addr} + ctx := metadata.ContextWithMetadata(context.Background()) + h := handler{} + ctx = h.Metadata(ctx, request.Request{W: w, Req: new(dns.Msg)}) + + f := metadata.ValueFunc(ctx, "proxyproto/tlv") + if f == nil { + t.Fatal("expected proxyproto/tlv metadata to be set") + } + if got, want := f(), hex.EncodeToString(raw); got != want { + t.Fatalf("proxyproto/tlv = %q, want %q", got, want) + } +} + +func TestMetadataNoTLVNoLabel(t *testing.T) { + // RemoteAddr is a plain addr (not cproxyproto.Addr): no label published. + w := &tlvResponseWriter{addr: &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234}} + ctx := metadata.ContextWithMetadata(context.Background()) + ctx = handler{}.Metadata(ctx, request.Request{W: w, Req: new(dns.Msg)}) + if f := metadata.ValueFunc(ctx, "proxyproto/tlv"); f != nil { + t.Fatalf("expected no label for plain addr, got %q", f()) + } +} + +func TestMetadataAddrWithoutTLVNoLabel(t *testing.T) { + // cproxyproto.Addr present but carrying no TLVs: no label. + addr := cproxyproto.NewAddr( + &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 53}, + &net.TCPAddr{IP: net.ParseIP("192.168.1.10"), Port: 40000}, + nil, + ) + w := &tlvResponseWriter{addr: addr} + ctx := metadata.ContextWithMetadata(context.Background()) + ctx = handler{}.Metadata(ctx, request.Request{W: w, Req: new(dns.Msg)}) + if f := metadata.ValueFunc(ctx, "proxyproto/tlv"); f != nil { + t.Fatalf("expected no label when Addr has no TLVs, got %q", f()) + } +} diff --git a/plugin/proxyproto/setup.go b/plugin/proxyproto/setup.go index 58ce598e9..9d86f1223 100644 --- a/plugin/proxyproto/setup.go +++ b/plugin/proxyproto/setup.go @@ -28,6 +28,7 @@ func setup(c *caddy.Controller) error { defaultSet bool sessionTrackingTTL time.Duration sessionTrackingMaxSessions int + tlvMetadata bool ) for c.Next() { args := c.RemainingArgs() @@ -79,6 +80,11 @@ func setup(c *caddy.Controller) error { } sessionTrackingMaxSessions = n } + case "tlv_metadata": + if len(c.RemainingArgs()) != 0 { + return plugin.Error("proxyproto", c.ArgErr()) + } + tlvMetadata = true default: return c.Errf("unknown option '%s'", c.Val()) } @@ -105,5 +111,13 @@ func setup(c *caddy.Controller) error { } config.ProxyProtoUDPSessionTrackingTTL = sessionTrackingTTL config.ProxyProtoUDPSessionTrackingMaxSessions = sessionTrackingMaxSessions + config.ProxyProtoTLVMetadata = tlvMetadata + if tlvMetadata { + // Add a pass-through handler that implements metadata.Provider so the + // captured PPv2 TLVs are exposed as the "proxyproto/tlv" label. + config.AddPlugin(func(next plugin.Handler) plugin.Handler { + return handler{Next: next} + }) + } return nil } diff --git a/plugin/proxyproto/setup_test.go b/plugin/proxyproto/setup_test.go index 154c01055..edab6f537 100644 --- a/plugin/proxyproto/setup_test.go +++ b/plugin/proxyproto/setup_test.go @@ -88,6 +88,40 @@ func TestSetup(t *testing.T) { } } +func TestTLVMetadataFlag(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectTLV bool + }{ + {"proxyproto {\ntlv_metadata\n}", false, true}, + {"proxyproto {\nallow 10.0.0.0/8\ntlv_metadata\n}", false, true}, + // absent -> disabled + {"proxyproto {\nallow 10.0.0.0/8\n}", false, false}, + // takes no arguments + {"proxyproto {\ntlv_metadata on\n}", true, false}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + err := setup(c) + if test.shouldErr && err == nil { + t.Errorf("Test %d: expected error, got none for %q", i, test.input) + continue + } + if !test.shouldErr && err != nil { + t.Errorf("Test %d: unexpected error for %q: %v", i, test.input, err) + continue + } + if test.shouldErr { + continue + } + cfg := dnsserver.GetConfig(c) + if cfg.ProxyProtoTLVMetadata != test.expectTLV { + t.Errorf("Test %d: ProxyProtoTLVMetadata = %v, want %v for %q", i, cfg.ProxyProtoTLVMetadata, test.expectTLV, test.input) + } + } +} + func TestDefaultPolicyWithoutAllow(t *testing.T) { c := caddy.NewTestController( "dns", diff --git a/request/request.go b/request/request.go index d11cb0f68..6a6ff0550 100644 --- a/request/request.go +++ b/request/request.go @@ -110,12 +110,26 @@ func (r *Request) RemoteAddr() string { return r.W.RemoteAddr().String() } // LocalAddr returns the net.Addr of the server handling the current request. func (r *Request) LocalAddr() string { return r.W.LocalAddr().String() } +// unwrapAddr returns the underlying net.Addr for addresses that wrap a real +// transport address (implementing Unwrap() net.Addr), so transport/family +// classification sees the concrete *net.TCPAddr or *net.UDPAddr. Plain +// addresses are returned unchanged. +func unwrapAddr(a net.Addr) net.Addr { + if u, ok := a.(interface{ Unwrap() net.Addr }); ok { + if inner := u.Unwrap(); inner != nil { + return inner + } + } + return a +} + // Proto gets the protocol used as the transport. This will be udp or tcp. func (r *Request) Proto() string { - if _, ok := r.W.RemoteAddr().(*net.UDPAddr); ok { + ra := unwrapAddr(r.W.RemoteAddr()) + if _, ok := ra.(*net.UDPAddr); ok { return "udp" } - if _, ok := r.W.RemoteAddr().(*net.TCPAddr); ok { + if _, ok := ra.(*net.TCPAddr); ok { return "tcp" } return "udp" @@ -128,7 +142,7 @@ func (r *Request) Family() int { } var a net.IP - ip := r.W.RemoteAddr() + ip := unwrapAddr(r.W.RemoteAddr()) if i, ok := ip.(*net.UDPAddr); ok { a = i.IP } diff --git a/request/request_test.go b/request/request_test.go index 7c1ffa097..b31040dc7 100644 --- a/request/request_test.go +++ b/request/request_test.go @@ -2,6 +2,7 @@ package request import ( "fmt" + "net" "testing" "github.com/coredns/coredns/plugin/test" @@ -73,6 +74,88 @@ func TestRequestProto(t *testing.T) { } } +// unwrapAddrWriter is a ResponseWriter whose RemoteAddr wraps a real transport +// address behind Unwrap(), mirroring the proxyproto TLV address. +type unwrapAddrWriter struct { + test.ResponseWriter + addr net.Addr +} + +func (w *unwrapAddrWriter) RemoteAddr() net.Addr { return w.addr } + +// wrappedAddr implements net.Addr + Unwrap() net.Addr. +type wrappedAddr struct{ inner net.Addr } + +func (a wrappedAddr) Network() string { return a.inner.Network() } +func (a wrappedAddr) String() string { return a.inner.String() } +func (a wrappedAddr) Unwrap() net.Addr { return a.inner } + +// TestRequestProtoFamilyUnwrap ensures a RemoteAddr that wraps a real transport +// address (via Unwrap) is still classified by its underlying type, so a TCP +// connection is not misread as UDP (which would impose the 512-byte limit). +func TestRequestProtoFamilyUnwrap(t *testing.T) { + tcp4 := &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234} + w := &unwrapAddrWriter{addr: wrappedAddr{inner: tcp4}} + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + req := Request{W: w, Req: m} + + if got := req.Proto(); got != "tcp" { + t.Errorf("Proto() = %q, want tcp (wrapped TCP addr misclassified)", got) + } + if got := req.Family(); got != 1 { + t.Errorf("Family() = %d, want 1 (IPv4)", got) + } +} + +// TestRequestScrubTCPWrappedAddr is the P1 regression: a large reply over a +// connection whose RemoteAddr wraps a TCP address (as the proxyproto TLV +// address does) must NOT be truncated — the wrapped addr must still classify as +// TCP, not fall through to the UDP 512-byte limit. A UDP-wrapped addr still +// truncates, proving the transport is honored rather than bypassed. +func TestRequestScrubTCPWrappedAddr(t *testing.T) { + largeReply := func(m *dns.Msg) *dns.Msg { + reply := new(dns.Msg) + reply.SetReply(m) + for i := 1; i < 200; i++ { + reply.Answer = append(reply.Answer, test.SRV( + fmt.Sprintf("large.example.com. 10 IN SRV 0 0 80 10-0-0-%d.default.pod.k8s.example.com.", i))) + } + return reply + } + + tcp := &net.TCPAddr{IP: net.ParseIP("10.240.0.1"), Port: 40212} + udp := &net.UDPAddr{IP: net.ParseIP("10.240.0.1"), Port: 40212} + + tests := []struct { + name string + w dns.ResponseWriter + wantTruncated bool + }{ + {"plain tcp", &test.ResponseWriter{TCP: true}, false}, + {"tlv-wrapped tcp", &unwrapAddrWriter{addr: wrappedAddr{inner: tcp}}, false}, + {"tlv-wrapped udp", &unwrapAddrWriter{addr: wrappedAddr{inner: udp}}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := new(dns.Msg) + m.SetQuestion("large.example.com.", dns.TypeSRV) + req := Request{W: tc.w, Req: m} + + reply := largeReply(m) + req.Scrub(reply) + + if reply.Truncated != tc.wantTruncated { + t.Errorf("Truncated = %v, want %v", reply.Truncated, tc.wantTruncated) + } + if !tc.wantTruncated && len(reply.Answer) != 199 { + t.Errorf("TCP reply lost answers: got %d, want 199 (unexpected truncation)", len(reply.Answer)) + } + }) + } +} + // TestRequestSizeAndDo tests the SizeAndDo method func TestRequestSizeAndDo(t *testing.T) { st := testRequest()