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()