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 <mekias@amazon.com>
Co-authored-by: Mekias Yohannes <mekias@amazon.com>
This commit is contained in:
myohannes
2026-10-15 22:28:43 -04:00
committed by GitHub
parent 21660a66b5
commit 3909cc187c
18 changed files with 1209 additions and 20 deletions

View File

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