Skip to content

Add PROXY protocol support to Native and Swoole adapters#44

Open
loks0n wants to merge 6 commits intomainfrom
feat/proxy-protocol
Open

Add PROXY protocol support to Native and Swoole adapters#44
loks0n wants to merge 6 commits intomainfrom
feat/proxy-protocol

Conversation

@loks0n
Copy link
Copy Markdown
Contributor

@loks0n loks0n commented Apr 18, 2026

Summary

  • Parse HAProxy PROXY v1 (text) and v2 (binary) preambles so resolvers see the real client address when the DNS server sits behind an L4 proxy or load balancer (AWS NLB, HAProxy, nginx, Envoy, …).
  • Enabled per-adapter via a new enableProxyProtocol flag; works on UDP datagrams and TCP connections for both Native and Swoole.
  • Detection is per connection/datagram — traffic starting with a PROXY signature is parsed and stripped, anything else is handled as direct DNS so health checks and direct clients keep working.

Implementation notes

  • New Utopia\DNS\ProxyProtocol parser with detect() / parse() and a ProxyProtocolHeader value object (version, family, source/dest address, source/dest port, bytes consumed).
  • Native adapter 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.
  • Swoole adapter disables open_length_check when PROXY is on and manages buffering/framing in Connect/Receive/Close handlers.
  • New DecodingException under Utopia\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.
  • Manual smoke test behind an HAProxy/NLB listener (not covered by CI).

🤖 Generated with Claude Code

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-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 18, 2026

Greptile Summary

This 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 Native and Swoole monolithic adapters into focused NativeTcp/NativeUdp and SwooleTcp/SwooleUdp pairs, with a new Composite fan-out adapter and a well-tested ProxyProtocol/ProxyProtocolStream parsing layer.

The concerns raised in previous review rounds (the detect() null/falsy conflation and misleading proxied flag naming) are fully resolved: UDP adapters now delegate to ProxyProtocolStream::unwrapDatagram() which correctly throws on a partial prefix, and TCP adapters centralise all state in TcpMessageStream + ProxyProtocolStream. Remaining findings are P2 style suggestions.

Confidence Score: 5/5

Safe 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

Filename Overview
src/DNS/ProxyProtocol.php New stateless PROXY protocol v1/v2 parser; detect(), decode(), and all family/address/port decoding paths look correct and match the HAProxy spec.
src/DNS/ProxyProtocolStream.php Stateful per-TCP-connection PROXY preamble resolver and stateless UDP unwrapDatagram helper; correctly handles null (partial), 0 (direct), and 1/2 (detected) return values from detect().
src/DNS/TcpMessageStream.php Clean pipeline for buffering, PROXY preamble stripping, and DNS length-prefix framing; MAX_BUFFER_SIZE comment is slightly inaccurate for the theoretical v2 edge case.
src/DNS/Adapter/NativeTcp.php New TCP-only adapter using ext-sockets; delegates PROXY and framing to TcpMessageStream; the writeFramed EAGAIN busy-wait blocks the single-threaded event loop.
src/DNS/Adapter/NativeUdp.php New UDP-only adapter; correctly uses ProxyProtocolStream::unwrapDatagram() which handles null (too-short) by throwing, resolving the previous null/falsy detect() issue.
src/DNS/Adapter/SwooleTcp.php New Swoole TCP adapter using TcpMessageStream for framing and PROXY handling; Connect/Receive/Close handlers are correct; fallback stream creation in Receive guards against missed Connect events.
src/DNS/Adapter/SwooleUdp.php New Swoole UDP adapter; correctly uses ProxyProtocolStream::unwrapDatagram() and replies back to the transport peer (proxy), not the PROXY-declared source address.
src/DNS/Adapter/Composite.php New fan-out adapter; correctly propagates setProxyProtocol to all children; designed for the Swoole UDP+TCP pattern where only one child owns the event loop.
src/DNS/Adapter.php Base adapter now exposes enableProxyProtocol flag with setProxyProtocol/hasProxyProtocol; onPacket renamed to onMessage with improved docblock.
src/DNS/Server.php Minimal change: adds setProxyProtocol() delegation to adapter and renames onPacket to onMessage; existing telemetry and error handling untouched.
src/DNS/Exception/ProxyProtocol/DecodingException.php Thin exception class extending RuntimeException; correctly placed under the ProxyProtocol exception namespace.

Reviews (5): Last reviewed commit: "Adapter is transport; Server is DNS. Spl..." | Re-trigger Greptile

Comment thread src/DNS/Adapter/Native.php Outdated
Comment thread src/DNS/Adapter/Swoole.php Outdated
Comment thread src/DNS/Adapter/Swoole.php Outdated
Comment thread src/DNS/ProxyProtocol.php Outdated
loks0n and others added 5 commits April 18, 2026 21:21
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant