Add PROXY protocol v1/v2 support for multi-hop deployments

New internal/proxyproto package implements PROXY protocol parsing and
writing without buffering past the header boundary (reads exact byte
counts so the connection is correctly positioned for SNI extraction).

Parser: auto-detects v1 (text) and v2 (binary) by first byte. Parses
TCP4/TCP6 for both versions plus v2 LOCAL command. Enforces max header
sizes and read deadlines.

Writer: generates v2 binary headers for IPv4 and IPv6 with PROXY
command.

Server integration:
- Receive: when listener.ProxyProtocol is true, parses PROXY header
  before firewall check. Real client IP from header is used for
  firewall evaluation and logging. Malformed headers cause RST.
- Send: when route.SendProxyProtocol is true, writes PROXY v2 header
  to backend before forwarding the ClientHello bytes.

Tests cover v1/v2 parsing, malformed rejection, timeout, round-trip
write+parse, and five server integration tests: receive with valid
header, receive with garbage, send verification, send-disabled
verification, and firewall evaluation using the real client IP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:28:49 -07:00
parent ed94548dfa
commit 1ad9a1a43b
5 changed files with 1025 additions and 6 deletions

View File

@@ -14,6 +14,7 @@ import (
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
"git.wntrmute.dev/kyle/mc-proxy/internal/firewall"
"git.wntrmute.dev/kyle/mc-proxy/internal/proxy"
"git.wntrmute.dev/kyle/mc-proxy/internal/proxyproto"
"git.wntrmute.dev/kyle/mc-proxy/internal/sni"
)
@@ -266,6 +267,20 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn, ls *ListenerStat
}
addr := addrPort.Addr()
// Parse PROXY protocol header if enabled on this listener.
if ls.ProxyProtocol {
hdr, err := proxyproto.Parse(conn, time.Now().Add(5*time.Second))
if err != nil {
s.logger.Debug("PROXY protocol parse failed", "addr", addr, "error", err)
return
}
if hdr.Command == proxyproto.CommandProxy {
addr = hdr.SrcAddr.Addr()
addrPort = hdr.SrcAddr
s.logger.Debug("PROXY protocol", "real_addr", addr, "peer_addr", remoteAddr)
}
}
if s.fw.Blocked(addr) {
s.logger.Debug("blocked by firewall", "addr", addr)
return
@@ -289,12 +304,12 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn, ls *ListenerStat
s.logger.Error("L7 mode not yet implemented", "hostname", hostname)
return
default:
s.handleL4(ctx, conn, ls, addr, hostname, route, peeked)
s.handleL4(ctx, conn, addr, addrPort, hostname, route, peeked)
}
}
// handleL4 handles an L4 (passthrough) connection.
func (s *Server) handleL4(ctx context.Context, conn net.Conn, _ *ListenerState, addr netip.Addr, hostname string, route RouteInfo, peeked []byte) {
func (s *Server) handleL4(ctx context.Context, conn net.Conn, addr netip.Addr, clientAddrPort netip.AddrPort, hostname string, route RouteInfo, peeked []byte) {
backendConn, err := net.DialTimeout("tcp", route.Backend, s.cfg.Proxy.ConnectTimeout.Duration)
if err != nil {
s.logger.Error("backend dial failed", "hostname", hostname, "backend", route.Backend, "error", err)
@@ -302,6 +317,15 @@ func (s *Server) handleL4(ctx context.Context, conn net.Conn, _ *ListenerState,
}
defer backendConn.Close()
// Send PROXY protocol v2 header to backend if configured.
if route.SendProxyProtocol {
backendAddrPort, _ := netip.ParseAddrPort(backendConn.RemoteAddr().String())
if err := proxyproto.WriteV2(backendConn, clientAddrPort, backendAddrPort); err != nil {
s.logger.Error("writing PROXY protocol header", "hostname", hostname, "error", err)
return
}
}
s.logger.Debug("proxying", "addr", addr, "hostname", hostname, "backend", route.Backend)
result, err := proxy.Relay(ctx, conn, backendConn, peeked, s.cfg.Proxy.IdleTimeout.Duration)