Add PROXY protocol support to Native and Swoole adapters#44
Add PROXY protocol support to Native and Swoole adapters#44
Conversation
Parse HAProxy PROXY v1 (text) and v2 (binary) preambles on incoming UDP datagrams and TCP connections so resolvers see the real client address when the server sits behind an L4 proxy or load balancer. Enabled via the `enableProxyProtocol` flag on each adapter. Detection is per connection/datagram: traffic that starts with a PROXY signature is parsed, traffic without one is handled as direct DNS so health checks and direct clients keep working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds HAProxy PROXY protocol v1 (text) and v2 (binary) support to the DNS server, enabling resolvers to see real client addresses when the server sits behind an L4 load balancer. The implementation cleanly refactors the existing The concerns raised in previous review rounds (the Confidence Score: 5/5Safe to merge; all previous P1 concerns are resolved and only minor P2 style suggestions remain. All blocking issues from prior review rounds are addressed. The two inline comments (EAGAIN busy-wait and MAX_BUFFER_SIZE comment accuracy) are P2 quality suggestions that don't affect correctness for realistic traffic. 166 unit tests pass including 25 new PROXY protocol cases, PHPStan at max level is clean, and the parsing logic is solid. src/DNS/Adapter/NativeTcp.php (writeFramed EAGAIN handling), src/DNS/TcpMessageStream.php (MAX_BUFFER_SIZE comment) Important Files Changed
Reviews (5): Last reviewed commit: "Adapter is transport; Server is DNS. Spl..." | Re-trigger Greptile |
… expand tests - Collapse ProxyProtocol + ProxyProtocolHeader into a single final readonly value object that mirrors the Message/Header pattern (constants, static decode()/detect()). - Hot-path detect() now short-circuits on first-byte mismatch so non-PROXY DNS traffic exits after a single byte comparison. - v2 header parsing uses a single unpack() with offset instead of three ord()/substr() calls. - Rename parse() → decode() to match the rest of the codebase. - Grow test suite to 80+ cases across detect/decode/streaming/fuzz: per-byte boundary coverage, v1 port boundaries, malformed v1 shapes, v2 family/transport combinations, TLV suffix handling, incomplete buffers, random and v2-shaped fuzz buffers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rotocolStream - Server::setProxyProtocol(enabled: true) is now the user-facing knob. Drop the per-adapter constructor flag; the Server propagates to the adapter via a new Adapter::setProxyProtocol(bool) base-class method. - Extract the TCP state machine and UDP datagram unwrap into ProxyProtocolStream. Each adapter owns the stateful stream per TCP fd but the parsing logic lives in one place. - Native and Swoole TCP handlers now use the shared stream; UDP handlers use the stateless unwrapDatagram() helper. - TCP PROXY parsing necessarily stays co-located with adapter framing (the preamble precedes the DNS length prefix and Swoole's open_length_check would misread it), but the actual protocol logic is no longer duplicated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pipes
Rework the Adapter contract so transport and protocol are cleanly split:
- onPacket is replaced by three hooks that deliver raw bytes:
* onUdpPacket(callback): unchanged UDP datagram contract (callback
returns response bytes).
* onTcpReceive(callback): callback receives (fd, raw bytes, peer ip,
peer port). Framing is no longer the adapter's problem.
* onTcpClose(callback): lifecycle notification so the Server can drop
per-fd state.
* sendTcp(fd, data) / closeTcp(fd): explicit send/close primitives
used by the Server when dispatching framed responses.
- Server owns per-fd TCP buffers, length-prefix framing, PROXY preamble
resolution (via ProxyProtocolStream), oversize/zero-length guards, and
reply dispatch. All protocol logic now lives in one file.
- Native adapter drops the buffer/framing/PROXY state tracking and
becomes a thin socket_select-driven byte pipe. Swoole adapter drops
the duplicate framing paths; open_length_check is always off because
framing is handled upstream now. The kernel-level length-check
optimization is negligible for DNS workloads and not compatible with
PROXY anyway.
- New unit tests cover Server TCP/UDP integration with a fake adapter:
direct queries, chunked framing, multi-frame chunks, PROXY v1/v2
consumption, direct-when-PROXY-enabled, malformed-preamble close,
oversize buffer close, zero-length frame close, and per-fd state
cleanup on close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server had three transport-specific protected methods (onPacket, onUdpPacket, onTcpReceive, onTcpClose) mixing the public extension surface with internal adapter dispatchers. Rework so: - onMessage(buffer, ip, port, max): the sole protected hook. Called once per decoded DNS query regardless of transport. Subclasses override this to customize message handling. - dispatchUdp / dispatchTcpReceive / dispatchTcpClose: private internal dispatchers that the adapter calls back into. They normalize transport events (datagrams, byte chunks, connection closes) into onMessage invocations but are not part of the extension surface. Server's conceptual contract is now: "DNS messages in, DNS responses out" — no TCP/UDP leakage in the public API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… adapters.
Adapter contract collapses to a single hook:
onMessage(callable(bytes, ip, port, max): response): void
Adapter now owns all transport concerns — UDP framing, TCP framing,
PROXY protocol preamble handling — and emits complete DNS messages
with the real client address. Server no longer knows TCP from UDP; it
just decodes, resolves, and encodes.
Split adapters (one Adapter == one transport):
- NativeUdp / NativeTcp: ext-sockets, own socket_select loop each.
- SwooleUdp / SwooleTcp: built on Swoole; support a shared
Swoole\Server instance so UDP+TCP can co-host on the same port via
a single event loop.
- Composite: fans onMessage / onWorkerStart / setProxyProtocol out
to multiple underlying adapters. Used to run UDP+TCP together
under one Server.
Shared helper:
- TcpMessageStream: per-connection byte-stream to DNS-messages
pipeline. Handles buffer accumulation (with a 128KB cap), optional
PROXY preamble resolution via ProxyProtocolStream, and
length-prefix framing. Both NativeTcp and SwooleTcp delegate to
it, so DNS-over-TCP logic is implemented once.
Note: Swoole TCP now always runs through userland framing
(open_length_check disabled). The kernel-level optimization was
incompatible with PROXY protocol anyway; the cost is negligible for
DNS workloads.
Tests: ServerTest now exercises the message pipeline via a transport-
neutral FakeAdapter. TcpMessageStreamTest covers the shared helper
(single/multi/chunked frames, PROXY v1/v2, malformed, overflow).
Previous TCP state tests lived in Server and moved to the stream
helper where they belong.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
enableProxyProtocolflag; works on UDP datagrams and TCP connections for bothNativeandSwoole.Implementation notes
Utopia\DNS\ProxyProtocolparser withdetect()/parse()and aProxyProtocolHeadervalue object (version, family, source/dest address, source/dest port, bytes consumed).Nativeadapter tracks per-fd PROXY state, parses the preamble once, then resumes normal length-prefix framing; UDP replies are still sent to the real peer (the proxy) while the handler sees the parsed client address.Swooleadapter disablesopen_length_checkwhen PROXY is on and manages buffering/framing inConnect/Receive/Closehandlers.DecodingExceptionunderUtopia\DNS\Exception\ProxyProtocol.Test plan
composer test(unit): 166 tests / 556 assertions pass, including 25 new cases for v1 TCP4/TCP6/UNKNOWN, v2 INET/INET6/UNIX/LOCAL/TLV, partial buffers, auto-detect fallthrough, and malformed inputs.composer analyze(PHPStan level max): clean.composer format:check(Pint / PSR-12): clean.🤖 Generated with Claude Code