From bfbd27362173c84f4f198660c319ac09d0fa4540 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:09:21 +0100 Subject: [PATCH 1/6] Add PROXY protocol support to Native and Swoole adapters 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) --- README.md | 24 ++ src/DNS/Adapter/Native.php | 112 ++++++- src/DNS/Adapter/Swoole.php | 202 +++++++++-- .../ProxyProtocol/DecodingException.php | 10 + src/DNS/ProxyProtocol.php | 316 ++++++++++++++++++ src/DNS/ProxyProtocolHeader.php | 32 ++ tests/unit/DNS/ProxyProtocolTest.php | 238 +++++++++++++ 7 files changed, 902 insertions(+), 32 deletions(-) create mode 100644 src/DNS/Exception/ProxyProtocol/DecodingException.php create mode 100644 src/DNS/ProxyProtocol.php create mode 100644 src/DNS/ProxyProtocolHeader.php create mode 100644 tests/unit/DNS/ProxyProtocolTest.php diff --git a/README.md b/README.md index d2e93c9..a6c60c4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,30 @@ Resolvers can be combined with any adapter. Implementing the `Resolver` interfac Adapters are responsible only for receiving and returning raw packets. They call back into the server with the payload, source IP, and port so your resolver logic stays isolated. +## PROXY protocol + +When the DNS server sits behind a proxy or load balancer that speaks the [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) (AWS NLB, HAProxy, nginx, Envoy, etc.), enable it on the adapter so resolver callbacks see the real client address instead of the proxy's. Both v1 (text) and v2 (binary) headers are parsed, and the feature applies to both UDP datagrams and TCP connections. + +```php +$adapter = new Native( + host: '0.0.0.0', + port: 5300, + enableProxyProtocol: true, +); + +// Swoole equivalent +$adapter = new Swoole( + host: '0.0.0.0', + port: 5300, + enableProxyProtocol: true, +); +``` + +Detection is per-connection (TCP) or per-datagram (UDP): traffic that begins with a PROXY v1/v2 signature is parsed and the real client address is passed to the resolver; traffic without a signature is handled as a direct DNS request. This keeps health checks and direct clients working during rollouts. + +> [!WARNING] +> Only enable PROXY protocol on listeners that are exclusively reachable by trusted proxies. An untrusted client can forge a PROXY header to spoof its source address. Bind to an internal network interface or enforce network-level ACLs. + ## DNS client The bundled client can query any DNS server and returns fully decoded messages. diff --git a/src/DNS/Adapter/Native.php b/src/DNS/Adapter/Native.php index 5635996..23ab717 100644 --- a/src/DNS/Adapter/Native.php +++ b/src/DNS/Adapter/Native.php @@ -5,6 +5,8 @@ use Exception; use Socket; use Utopia\DNS\Adapter; +use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; +use Utopia\DNS\ProxyProtocol; class Native extends Adapter { @@ -27,6 +29,12 @@ class Native extends Adapter /** @var array Track last activity time per TCP client for idle timeout */ protected array $tcpLastActivity = []; + /** @var array Whether the PROXY header has been consumed for this TCP client */ + protected array $tcpProxyParsed = []; + + /** @var array Real client address (PROXY-aware) per TCP client */ + protected array $tcpClientAddress = []; + /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ protected mixed $onPacket; @@ -41,6 +49,7 @@ class Native extends Adapter * @param int $maxTcpBufferSize Maximum buffer size per TCP client * @param int $maxTcpFrameSize Maximum DNS message size over TCP * @param int $tcpIdleTimeout Seconds before idle TCP connections are closed (RFC 7766) + * @param bool $enableProxyProtocol Auto-detect a PROXY protocol (v1 or v2) preamble on each connection/datagram. Connections without a preamble are treated as direct. Only enable when the listener is reachable solely from trusted proxies — untrusted clients could forge the source address. */ public function __construct( protected string $host = '0.0.0.0', @@ -49,7 +58,8 @@ public function __construct( protected int $maxTcpClients = 100, protected int $maxTcpBufferSize = 16384, protected int $maxTcpFrameSize = 65535, - protected int $tcpIdleTimeout = 30 + protected int $tcpIdleTimeout = 30, + protected bool $enableProxyProtocol = false, ) { $server = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); @@ -147,10 +157,33 @@ public function start(): void $len = socket_recvfrom($this->udpServer, $buf, 1024 * 4, 0, $ip, $port); if ($len > 0 && is_string($buf) && is_string($ip) && is_int($port)) { + // Reply goes back to the actual UDP peer (the proxy), not the parsed client. + $replyIp = $ip; + $replyPort = $port; + + if ($this->enableProxyProtocol && ProxyProtocol::detect($buf)) { + try { + $header = ProxyProtocol::parse($buf); + } catch (ProxyDecodingException) { + continue; + } + + if ($header === null) { + continue; + } + + if ($header->sourceAddress !== null && $header->sourcePort !== null) { + $ip = $header->sourceAddress; + $port = $header->sourcePort; + } + + $buf = substr($buf, $header->bytesConsumed); + } + $answer = call_user_func($this->onPacket, $buf, $ip, $port, 512); if ($answer !== '') { - socket_sendto($this->udpServer, $answer, strlen($answer), 0, $ip, $port); + socket_sendto($this->udpServer, $answer, strlen($answer), 0, $replyIp, $replyPort); } } @@ -179,6 +212,15 @@ public function start(): void $this->tcpClients[$id] = $client; $this->tcpBuffers[$id] = ''; $this->tcpLastActivity[$id] = time(); + $this->tcpProxyParsed[$id] = false; + + $peerIp = ''; + $peerPort = 0; + socket_getpeername($client, $peerIp, $peerPort); + $this->tcpClientAddress[$id] = [ + 'ip' => is_string($peerIp) ? $peerIp : '', + 'port' => is_int($peerPort) ? $peerPort : 0, + ]; } continue; @@ -230,6 +272,42 @@ protected function handleTcpClient(Socket $client): void $this->tcpBuffers[$clientId] = ($this->tcpBuffers[$clientId] ?? '') . $chunk; + if ($this->enableProxyProtocol && !($this->tcpProxyParsed[$clientId] ?? false)) { + $detected = ProxyProtocol::detect($this->tcpBuffers[$clientId]); + + // Not enough bytes to decide yet; wait for more. + if ($detected === null) { + return; + } + + if ($detected === 0) { + // Definitely not a PROXY preamble — treat this connection as direct. + $this->tcpProxyParsed[$clientId] = true; + } else { + try { + $header = ProxyProtocol::parse($this->tcpBuffers[$clientId]); + } catch (ProxyDecodingException) { + $this->closeTcpClient($client); + return; + } + + if ($header === null) { + // PROXY signature matched but payload is still incomplete. + return; + } + + $this->tcpBuffers[$clientId] = substr($this->tcpBuffers[$clientId], $header->bytesConsumed); + $this->tcpProxyParsed[$clientId] = true; + + if ($header->sourceAddress !== null && $header->sourcePort !== null) { + $this->tcpClientAddress[$clientId] = [ + 'ip' => $header->sourceAddress, + 'port' => $header->sourcePort, + ]; + } + } + } + while (strlen($this->tcpBuffers[$clientId]) >= 2) { $unpacked = unpack('n', substr($this->tcpBuffers[$clientId], 0, 2)); $payloadLength = (is_array($unpacked) && array_key_exists(1, $unpacked) && is_int($unpacked[1])) ? $unpacked[1] : 0; @@ -255,16 +333,22 @@ protected function handleTcpClient(Socket $client): void $message = substr($this->tcpBuffers[$clientId], 2, $payloadLength); $this->tcpBuffers[$clientId] = substr($this->tcpBuffers[$clientId], $payloadLength + 2); - $ip = ''; - $port = 0; - socket_getpeername($client, $ip, $port); + $address = $this->tcpClientAddress[$clientId] ?? null; - if (is_string($ip) && is_int($port)) { - $answer = call_user_func($this->onPacket, $message, $ip, $port, self::MAX_TCP_MESSAGE_SIZE); + if ($address === null) { + $ip = ''; + $port = 0; + socket_getpeername($client, $ip, $port); + $address = [ + 'ip' => is_string($ip) ? $ip : '', + 'port' => is_int($port) ? $port : 0, + ]; + } - if ($answer !== '') { - $this->sendTcpResponse($client, $answer); - } + $answer = call_user_func($this->onPacket, $message, $address['ip'], $address['port'], self::MAX_TCP_MESSAGE_SIZE); + + if ($answer !== '') { + $this->sendTcpResponse($client, $answer); } } } @@ -340,7 +424,13 @@ protected function closeTcpClient(Socket $client): void { $id = spl_object_id($client); - unset($this->tcpClients[$id], $this->tcpBuffers[$id], $this->tcpLastActivity[$id]); + unset( + $this->tcpClients[$id], + $this->tcpBuffers[$id], + $this->tcpLastActivity[$id], + $this->tcpProxyParsed[$id], + $this->tcpClientAddress[$id], + ); @socket_close($client); } diff --git a/src/DNS/Adapter/Swoole.php b/src/DNS/Adapter/Swoole.php index a2c8ce4..66d7bd1 100644 --- a/src/DNS/Adapter/Swoole.php +++ b/src/DNS/Adapter/Swoole.php @@ -6,6 +6,8 @@ use Utopia\DNS\Adapter; use Swoole\Server; use Swoole\Server\Port; +use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; +use Utopia\DNS\ProxyProtocol; class Swoole extends Adapter { @@ -15,6 +17,9 @@ class Swoole extends Adapter */ public const int MAX_TCP_MESSAGE_SIZE = 65535; + /** Hard cap when PROXY protocol is enabled, before the DNS length prefix can be validated. */ + public const int MAX_TCP_BUFFER_SIZE = 131072; + protected Server $server; protected ?Port $tcpPort = null; @@ -22,12 +27,23 @@ class Swoole extends Adapter /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ protected mixed $onPacket; + /** + * Per-fd TCP state for PROXY-aware streams. + * + * @var array + */ + protected array $tcpState = []; + + /** + * @param bool $enableProxyProtocol Auto-detect a PROXY protocol (v1 or v2) preamble on each connection/datagram. Connections without a preamble are treated as direct. Only enable when the listener is reachable solely from trusted proxies — untrusted clients could forge the source address. + */ public function __construct( protected string $host = '0.0.0.0', protected int $port = 53, protected int $numWorkers = 1, protected int $maxCoroutines = 3000, - protected bool $enableTcp = true + protected bool $enableTcp = true, + protected bool $enableProxyProtocol = false, ) { $this->server = new Server($this->host, $this->port, SWOOLE_PROCESS, SWOOLE_SOCK_UDP); $this->server->set([ @@ -40,13 +56,22 @@ public function __construct( if ($port instanceof Port) { $this->tcpPort = $port; - $this->tcpPort->set([ - 'open_length_check' => true, - 'package_length_type' => 'n', - 'package_length_offset' => 0, - 'package_body_offset' => 2, - 'package_max_length' => 65537, - ]); + + if ($this->enableProxyProtocol) { + // Disable length-prefix framing: PROXY header sits before the DNS length prefix, + // so we must buffer and parse manually. + $this->tcpPort->set([ + 'open_length_check' => false, + ]); + } else { + $this->tcpPort->set([ + 'open_length_check' => true, + 'package_length_type' => 'n', + 'package_length_offset' => 0, + 'package_body_offset' => 2, + 'package_max_length' => 65537, + ]); + } } } } @@ -79,35 +104,170 @@ public function onPacket(callable $callback): void return; } - $ip = is_string($clientInfo['address'] ?? null) ? $clientInfo['address'] : ''; - $port = is_int($clientInfo['port'] ?? null) ? $clientInfo['port'] : 0; + $peerIp = is_string($clientInfo['address'] ?? null) ? $clientInfo['address'] : ''; + $peerPort = is_int($clientInfo['port'] ?? null) ? $clientInfo['port'] : 0; + $ip = $peerIp; + $port = $peerPort; + $payload = $data; - $response = \call_user_func($this->onPacket, $data, $ip, $port, 512); + if ($this->enableProxyProtocol && ProxyProtocol::detect($payload)) { + try { + $header = ProxyProtocol::parse($payload); + } catch (ProxyDecodingException) { + return; + } + + if ($header === null) { + return; + } + + if ($header->sourceAddress !== null && $header->sourcePort !== null) { + $ip = $header->sourceAddress; + $port = $header->sourcePort; + } + + $payload = substr($payload, $header->bytesConsumed); + } + + $response = \call_user_func($this->onPacket, $payload, $ip, $port, 512); if ($response !== '' && $server instanceof Server) { - $server->sendto($ip, $port, $response); + // Reply goes back to the actual UDP peer (the proxy), not the parsed client. + $server->sendto($peerIp, $peerPort, $response); } }); - // TCP handler - supports larger responses with length-prefixed framing per RFC 5966 if ($this->tcpPort instanceof Port) { - $this->tcpPort->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { + if ($this->enableProxyProtocol) { + $this->registerProxiedTcpHandlers(); + } else { + $this->registerDirectTcpHandlers(); + } + } + } + + protected function registerDirectTcpHandlers(): void + { + $this->tcpPort?->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { + $info = $server->getClientInfo($fd, $reactorId); + if (!is_array($info)) { + return; + } + + $payload = substr($data, 2); // strip 2-byte length prefix + $ip = is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; + $port = is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; + + $response = \call_user_func($this->onPacket, $payload, $ip, $port, self::MAX_TCP_MESSAGE_SIZE); + + if ($response !== '') { + $server->send($fd, pack('n', strlen($response)) . $response); + } + }); + } + + protected function registerProxiedTcpHandlers(): void + { + $port = $this->tcpPort; + if (!$port instanceof Port) { + return; + } + + $port->on('Connect', function (Server $server, int $fd) { + $info = $server->getClientInfo($fd); + $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; + $portNum = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; + + $this->tcpState[$fd] = [ + 'buffer' => '', + 'proxied' => false, + 'ip' => $ip, + 'port' => $portNum, + ]; + }); + + $port->on('Close', function (Server $server, int $fd) { + unset($this->tcpState[$fd]); + }); + + $port->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { + if (!isset($this->tcpState[$fd])) { $info = $server->getClientInfo($fd, $reactorId); - if (!is_array($info)) { + $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; + $portNum = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; + + $this->tcpState[$fd] = [ + 'buffer' => '', + 'proxied' => false, + 'ip' => $ip, + 'port' => $portNum, + ]; + } + + $state = &$this->tcpState[$fd]; + $state['buffer'] .= $data; + + if (strlen($state['buffer']) > self::MAX_TCP_BUFFER_SIZE) { + $server->close($fd); + return; + } + + if (!$state['proxied']) { + $detected = ProxyProtocol::detect($state['buffer']); + + // Not enough bytes to decide; wait for more. + if ($detected === null) { return; } - $payload = substr($data, 2); // strip 2-byte length prefix - $ip = is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; - $port = is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; + if ($detected === 0) { + // Definitely not PROXY — treat as direct DNS on this connection. + $state['proxied'] = true; + } else { + try { + $header = ProxyProtocol::parse($state['buffer']); + } catch (ProxyDecodingException) { + $server->close($fd); + return; + } - $response = \call_user_func($this->onPacket, $payload, $ip, $port, self::MAX_TCP_MESSAGE_SIZE); + if ($header === null) { + return; + } + + $state['buffer'] = substr($state['buffer'], $header->bytesConsumed); + $state['proxied'] = true; + + if ($header->sourceAddress !== null && $header->sourcePort !== null) { + $state['ip'] = $header->sourceAddress; + $state['port'] = $header->sourcePort; + } + } + } + + while (strlen($state['buffer']) >= 2) { + $unpacked = unpack('n', substr($state['buffer'], 0, 2)); + $payloadLength = (is_array($unpacked) && array_key_exists(1, $unpacked) && is_int($unpacked[1])) ? $unpacked[1] : 0; + + if ($payloadLength === 0 || $payloadLength > self::MAX_TCP_MESSAGE_SIZE) { + $server->close($fd); + return; + } + + if (strlen($state['buffer']) < $payloadLength + 2) { + return; + } + + $message = substr($state['buffer'], 2, $payloadLength); + $state['buffer'] = substr($state['buffer'], $payloadLength + 2); + + $response = \call_user_func($this->onPacket, $message, $state['ip'], $state['port'], self::MAX_TCP_MESSAGE_SIZE); if ($response !== '') { $server->send($fd, pack('n', strlen($response)) . $response); } - }); - } + } + }); } /** diff --git a/src/DNS/Exception/ProxyProtocol/DecodingException.php b/src/DNS/Exception/ProxyProtocol/DecodingException.php new file mode 100644 index 0000000..b3a0b35 --- /dev/null +++ b/src/DNS/Exception/ProxyProtocol/DecodingException.php @@ -0,0 +1,10 @@ += self::V1_MAX_LENGTH) { + throw new DecodingException('PROXY v1 header missing CRLF within 107 bytes.'); + } + return null; + } + + $lineLength = $terminator + 2; + + if ($lineLength > self::V1_MAX_LENGTH) { + throw new DecodingException('PROXY v1 header exceeds 107 bytes.'); + } + + $line = substr($buffer, 0, $terminator); + $parts = explode(' ', $line); + + if ($parts[0] !== 'PROXY') { + throw new DecodingException('PROXY v1 header missing PROXY token.'); + } + + $proto = $parts[1] ?? ''; + + if ($proto === 'UNKNOWN') { + return new ProxyProtocolHeader( + version: 1, + isLocal: false, + family: ProxyProtocolHeader::FAMILY_UNKNOWN, + sourceAddress: null, + sourcePort: null, + destinationAddress: null, + destinationPort: null, + bytesConsumed: $lineLength, + ); + } + + if ($proto !== 'TCP4' && $proto !== 'TCP6') { + throw new DecodingException('PROXY v1 header has unsupported protocol: ' . $proto); + } + + if (count($parts) !== 6) { + throw new DecodingException('PROXY v1 header is malformed.'); + } + + [, , $srcAddr, $dstAddr, $srcPort, $dstPort] = $parts; + + $expectedFlag = $proto === 'TCP4' ? FILTER_FLAG_IPV4 : FILTER_FLAG_IPV6; + + if (filter_var($srcAddr, FILTER_VALIDATE_IP, $expectedFlag) === false) { + throw new DecodingException('PROXY v1 invalid source address: ' . $srcAddr); + } + + if (filter_var($dstAddr, FILTER_VALIDATE_IP, $expectedFlag) === false) { + throw new DecodingException('PROXY v1 invalid destination address: ' . $dstAddr); + } + + $srcPortInt = self::parsePort($srcPort, 'source'); + $dstPortInt = self::parsePort($dstPort, 'destination'); + + return new ProxyProtocolHeader( + version: 1, + isLocal: false, + family: $proto, + sourceAddress: $srcAddr, + sourcePort: $srcPortInt, + destinationAddress: $dstAddr, + destinationPort: $dstPortInt, + bytesConsumed: $lineLength, + ); + } + + private static function parseV2(string $buffer): ?ProxyProtocolHeader + { + if (strlen($buffer) < self::V2_FIXED_HEADER_LENGTH) { + return null; + } + + $verCmd = ord($buffer[12]); + $famTrans = ord($buffer[13]); + + $version = ($verCmd & 0xF0) >> 4; + $command = $verCmd & 0x0F; + + if ($version !== 2) { + throw new DecodingException('PROXY v2 header has invalid version: ' . $version); + } + + if ($command !== 0 && $command !== 1) { + throw new DecodingException('PROXY v2 header has invalid command: ' . $command); + } + + $addressFamily = ($famTrans & 0xF0) >> 4; + $transport = $famTrans & 0x0F; + + $payloadLengthData = unpack('n', substr($buffer, 14, 2)); + + if ($payloadLengthData === false || !isset($payloadLengthData[1]) || !is_int($payloadLengthData[1])) { + throw new DecodingException('PROXY v2 header has invalid payload length.'); + } + + $payloadLength = $payloadLengthData[1]; + $totalLength = self::V2_FIXED_HEADER_LENGTH + $payloadLength; + + if (strlen($buffer) < $totalLength) { + return null; + } + + $isLocal = $command === 0; + + // LOCAL connections carry no usable address info (health checks). + if ($isLocal) { + return new ProxyProtocolHeader( + version: 2, + isLocal: true, + family: ProxyProtocolHeader::FAMILY_UNKNOWN, + sourceAddress: null, + sourcePort: null, + destinationAddress: null, + destinationPort: null, + bytesConsumed: $totalLength, + ); + } + + $payload = substr($buffer, self::V2_FIXED_HEADER_LENGTH, $payloadLength); + + return match (true) { + $addressFamily === 0x1 && ($transport === 0x1 || $transport === 0x2) => + self::parseV2Inet($payload, $transport, $totalLength, 4), + $addressFamily === 0x2 && ($transport === 0x1 || $transport === 0x2) => + self::parseV2Inet($payload, $transport, $totalLength, 16), + $addressFamily === 0x3 && ($transport === 0x1 || $transport === 0x2) => + self::parseV2Unix($payload, $totalLength), + default => new ProxyProtocolHeader( + version: 2, + isLocal: false, + family: ProxyProtocolHeader::FAMILY_UNKNOWN, + sourceAddress: null, + sourcePort: null, + destinationAddress: null, + destinationPort: null, + bytesConsumed: $totalLength, + ), + }; + } + + private static function parseV2Inet(string $payload, int $transport, int $totalLength, int $addrSize): ProxyProtocolHeader + { + $expected = ($addrSize * 2) + 4; + + if (strlen($payload) < $expected) { + throw new DecodingException('PROXY v2 INET payload too short.'); + } + + $srcRaw = substr($payload, 0, $addrSize); + $dstRaw = substr($payload, $addrSize, $addrSize); + + $srcAddr = inet_ntop($srcRaw); + $dstAddr = inet_ntop($dstRaw); + + if ($srcAddr === false || $dstAddr === false) { + throw new DecodingException('PROXY v2 INET address could not be decoded.'); + } + + $ports = unpack('nsrc/ndst', substr($payload, $addrSize * 2, 4)); + + if ($ports === false || !is_int($ports['src'] ?? null) || !is_int($ports['dst'] ?? null)) { + throw new DecodingException('PROXY v2 INET ports could not be decoded.'); + } + + $ipv4 = $addrSize === 4; + + if ($transport === 0x1) { + $family = $ipv4 ? ProxyProtocolHeader::FAMILY_TCP4 : ProxyProtocolHeader::FAMILY_TCP6; + } else { + $family = $ipv4 ? ProxyProtocolHeader::FAMILY_UDP4 : ProxyProtocolHeader::FAMILY_UDP6; + } + + return new ProxyProtocolHeader( + version: 2, + isLocal: false, + family: $family, + sourceAddress: $srcAddr, + sourcePort: $ports['src'], + destinationAddress: $dstAddr, + destinationPort: $ports['dst'], + bytesConsumed: $totalLength, + ); + } + + private static function parseV2Unix(string $payload, int $totalLength): ProxyProtocolHeader + { + if (strlen($payload) < 216) { + throw new DecodingException('PROXY v2 UNIX payload too short.'); + } + + $src = rtrim(substr($payload, 0, 108), "\x00"); + $dst = rtrim(substr($payload, 108, 108), "\x00"); + + return new ProxyProtocolHeader( + version: 2, + isLocal: false, + family: ProxyProtocolHeader::FAMILY_UNIX, + sourceAddress: $src !== '' ? $src : null, + sourcePort: null, + destinationAddress: $dst !== '' ? $dst : null, + destinationPort: null, + bytesConsumed: $totalLength, + ); + } + + private static function parsePort(string $value, string $label): int + { + if ($value === '' || !ctype_digit($value)) { + throw new DecodingException('PROXY v1 invalid ' . $label . ' port: ' . $value); + } + + $port = (int) $value; + + if ($port < 0 || $port > 65535) { + throw new DecodingException('PROXY v1 ' . $label . ' port out of range: ' . $value); + } + + return $port; + } +} diff --git a/src/DNS/ProxyProtocolHeader.php b/src/DNS/ProxyProtocolHeader.php new file mode 100644 index 0000000..14ae4eb --- /dev/null +++ b/src/DNS/ProxyProtocolHeader.php @@ -0,0 +1,32 @@ +assertSame(1, ProxyProtocol::detect("PROXY TCP4 1.2.3.4 5.6.7.8 1111 2222\r\n")); + } + + public function testDetectV2(): void + { + $this->assertSame(2, ProxyProtocol::detect(ProxyProtocol::V2_SIGNATURE . "\x21\x11\x00\x0C")); + } + + public function testDetectPartialV1ReturnsNull(): void + { + $this->assertNull(ProxyProtocol::detect('PROX')); + } + + public function testDetectPartialV2ReturnsNull(): void + { + $this->assertNull(ProxyProtocol::detect("\r\n\r\n\x00")); + } + + public function testDetectNonProxy(): void + { + $this->assertSame(0, ProxyProtocol::detect("\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00")); + } + + public function testParseV1Tcp4(): void + { + $header = "PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n"; + $result = ProxyProtocol::parse($header); + + $this->assertInstanceOf(ProxyProtocolHeader::class, $result); + $this->assertSame(1, $result->version); + $this->assertFalse($result->isLocal); + $this->assertSame(ProxyProtocolHeader::FAMILY_TCP4, $result->family); + $this->assertSame('192.168.1.1', $result->sourceAddress); + $this->assertSame('10.0.0.1', $result->destinationAddress); + $this->assertSame(56324, $result->sourcePort); + $this->assertSame(443, $result->destinationPort); + $this->assertSame(strlen($header), $result->bytesConsumed); + } + + public function testParseV1Tcp6(): void + { + $header = "PROXY TCP6 2001:db8::1 2001:db8::2 65535 53\r\n"; + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocolHeader::FAMILY_TCP6, $result->family); + $this->assertSame('2001:db8::1', $result->sourceAddress); + $this->assertSame('2001:db8::2', $result->destinationAddress); + $this->assertSame(65535, $result->sourcePort); + $this->assertSame(53, $result->destinationPort); + } + + public function testParseV1Unknown(): void + { + $header = "PROXY UNKNOWN\r\n"; + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocolHeader::FAMILY_UNKNOWN, $result->family); + $this->assertNull($result->sourceAddress); + $this->assertNull($result->destinationAddress); + $this->assertSame(strlen($header), $result->bytesConsumed); + } + + public function testParseV1UnknownWithExtraTokensIgnored(): void + { + // Per spec, receivers must ignore everything past UNKNOWN on the line. + $header = "PROXY UNKNOWN ff:ff::1 aa:aa::2 1234 5678\r\n"; + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocolHeader::FAMILY_UNKNOWN, $result->family); + $this->assertSame(strlen($header), $result->bytesConsumed); + } + + public function testParseV1ReturnsNullWhenCrlfMissing(): void + { + $this->assertNull(ProxyProtocol::parse('PROXY TCP4 1.2.3.4 5.6.7.8 1 2')); + } + + public function testParseV1ThrowsWhenTooLong(): void + { + $this->expectException(DecodingException::class); + ProxyProtocol::parse('PROXY TCP4 ' . str_repeat('a', 120)); + } + + public function testParseV1InvalidAddressThrows(): void + { + $this->expectException(DecodingException::class); + ProxyProtocol::parse("PROXY TCP4 999.999.999.999 10.0.0.1 1 2\r\n"); + } + + public function testParseV1InvalidPortThrows(): void + { + $this->expectException(DecodingException::class); + ProxyProtocol::parse("PROXY TCP4 1.2.3.4 5.6.7.8 70000 80\r\n"); + } + + public function testParseV1Ipv4ForTcp6Mismatch(): void + { + $this->expectException(DecodingException::class); + ProxyProtocol::parse("PROXY TCP6 1.2.3.4 5.6.7.8 1 2\r\n"); + } + + public function testParseV2Inet4(): void + { + $payload = inet_pton('192.168.1.1') . inet_pton('10.0.0.1') . pack('nn', 56324, 443); + $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x11) . pack('n', strlen($payload)) . $payload; + + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertSame(2, $result->version); + $this->assertSame(ProxyProtocolHeader::FAMILY_TCP4, $result->family); + $this->assertSame('192.168.1.1', $result->sourceAddress); + $this->assertSame('10.0.0.1', $result->destinationAddress); + $this->assertSame(56324, $result->sourcePort); + $this->assertSame(443, $result->destinationPort); + $this->assertSame(strlen($header), $result->bytesConsumed); + } + + public function testParseV2Inet6Udp(): void + { + $payload = inet_pton('2001:db8::1') . inet_pton('2001:db8::2') . pack('nn', 53, 5353); + $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x22) . pack('n', strlen($payload)) . $payload; + + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocolHeader::FAMILY_UDP6, $result->family); + $this->assertSame('2001:db8::1', $result->sourceAddress); + $this->assertSame('2001:db8::2', $result->destinationAddress); + } + + public function testParseV2Local(): void + { + // LOCAL command (0x20): no address, but still has a payload length field we must respect + $payload = str_repeat("\x00", 12); + $header = ProxyProtocol::V2_SIGNATURE . chr(0x20) . chr(0x00) . pack('n', strlen($payload)) . $payload; + + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertTrue($result->isLocal); + $this->assertNull($result->sourceAddress); + $this->assertSame(strlen($header), $result->bytesConsumed); + } + + public function testParseV2Unix(): void + { + $srcPath = str_pad('/var/run/src.sock', 108, "\x00"); + $dstPath = str_pad('/var/run/dst.sock', 108, "\x00"); + $payload = $srcPath . $dstPath; + $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x31) . pack('n', strlen($payload)) . $payload; + + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocolHeader::FAMILY_UNIX, $result->family); + $this->assertSame('/var/run/src.sock', $result->sourceAddress); + $this->assertSame('/var/run/dst.sock', $result->destinationAddress); + } + + public function testParseV2WithTlvSuffixIsConsumedByLength(): void + { + // Emulate a TLV appended to the addresses. Parser should skip past it + // via the payload length so bytesConsumed covers the TLV too. + $addrPayload = inet_pton('192.168.1.1') . inet_pton('10.0.0.1') . pack('nn', 1, 2); + $tlv = "\x03\x00\x04ABCD"; // type=3, len=4, value=ABCD + $payload = $addrPayload . $tlv; + $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x11) . pack('n', strlen($payload)) . $payload; + + $result = ProxyProtocol::parse($header); + + $this->assertNotNull($result); + $this->assertSame('192.168.1.1', $result->sourceAddress); + $this->assertSame(strlen($header), $result->bytesConsumed); + } + + public function testParseV2ReturnsNullWhenPayloadIncomplete(): void + { + $payload = inet_pton('192.168.1.1') . inet_pton('10.0.0.1') . pack('nn', 1, 2); + $full = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x11) . pack('n', strlen($payload)) . $payload; + $truncated = substr($full, 0, strlen($full) - 4); + + $this->assertNull(ProxyProtocol::parse($truncated)); + } + + public function testParseV2InvalidVersionThrows(): void + { + $header = ProxyProtocol::V2_SIGNATURE . chr(0x31) . chr(0x11) . pack('n', 0); + $this->expectException(DecodingException::class); + ProxyProtocol::parse($header); + } + + public function testParseNonProxyThrows(): void + { + $this->expectException(DecodingException::class); + ProxyProtocol::parse("HELLO WORLD\r\n"); + } + + public function testDetectRejectsDnsPacketStartingWithNonPrefixByte(): void + { + // Typical DNS query header: ID 0x1234, flags 0x0100, 1 question, 0 answers... + $dnsPacket = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; + $this->assertSame(0, ProxyProtocol::detect($dnsPacket)); + } + + public function testDetectRejectsDnsPacketStartingWithCrButNotV2(): void + { + // Crafted: starts with 0x0D (same as v2 sig first byte) but second byte differs. + $this->assertSame(0, ProxyProtocol::detect("\x0D\xFF\x00\x01")); + } + + public function testDetectPartialSingleByteOfV1(): void + { + $this->assertNull(ProxyProtocol::detect('P')); + } + + public function testDetectFullV1Prefix(): void + { + // Exactly "PROXY " with no trailing data is a prefix match, not a full line yet. + $this->assertSame(1, ProxyProtocol::detect('PROXY ')); + } +} From aaa8dc172f8798ffb6de3a6944250f7c01b63de0 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:21:46 +0100 Subject: [PATCH 2/6] Refactor PROXY protocol parser: merge value object, tighten hot path, expand tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/DNS/Adapter/Native.php | 4 +- src/DNS/Adapter/Swoole.php | 4 +- src/DNS/ProxyProtocol.php | 368 ++++++++++++-------- src/DNS/ProxyProtocolHeader.php | 32 -- tests/unit/DNS/ProxyProtocolTest.php | 483 ++++++++++++++++++++++----- 5 files changed, 637 insertions(+), 254 deletions(-) delete mode 100644 src/DNS/ProxyProtocolHeader.php diff --git a/src/DNS/Adapter/Native.php b/src/DNS/Adapter/Native.php index 23ab717..c60ce07 100644 --- a/src/DNS/Adapter/Native.php +++ b/src/DNS/Adapter/Native.php @@ -163,7 +163,7 @@ public function start(): void if ($this->enableProxyProtocol && ProxyProtocol::detect($buf)) { try { - $header = ProxyProtocol::parse($buf); + $header = ProxyProtocol::decode($buf); } catch (ProxyDecodingException) { continue; } @@ -285,7 +285,7 @@ protected function handleTcpClient(Socket $client): void $this->tcpProxyParsed[$clientId] = true; } else { try { - $header = ProxyProtocol::parse($this->tcpBuffers[$clientId]); + $header = ProxyProtocol::decode($this->tcpBuffers[$clientId]); } catch (ProxyDecodingException) { $this->closeTcpClient($client); return; diff --git a/src/DNS/Adapter/Swoole.php b/src/DNS/Adapter/Swoole.php index 66d7bd1..cd6114e 100644 --- a/src/DNS/Adapter/Swoole.php +++ b/src/DNS/Adapter/Swoole.php @@ -112,7 +112,7 @@ public function onPacket(callable $callback): void if ($this->enableProxyProtocol && ProxyProtocol::detect($payload)) { try { - $header = ProxyProtocol::parse($payload); + $header = ProxyProtocol::decode($payload); } catch (ProxyDecodingException) { return; } @@ -225,7 +225,7 @@ protected function registerProxiedTcpHandlers(): void $state['proxied'] = true; } else { try { - $header = ProxyProtocol::parse($state['buffer']); + $header = ProxyProtocol::decode($state['buffer']); } catch (ProxyDecodingException) { $server->close($fd); return; diff --git a/src/DNS/ProxyProtocol.php b/src/DNS/ProxyProtocol.php index 7e4f74c..90bf1e1 100644 --- a/src/DNS/ProxyProtocol.php +++ b/src/DNS/ProxyProtocol.php @@ -5,71 +5,141 @@ use Utopia\DNS\Exception\ProxyProtocol\DecodingException; /** - * Parser for the HAProxy PROXY protocol (v1 text and v2 binary). + * Parsed HAProxy PROXY protocol preamble (v1 text or v2 binary). * - * Used to recover the original client address when a DNS server is fronted by - * a proxy or load balancer that prepends the PROXY header to each connection - * (TCP) or packet (UDP). Spec: - * https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt + * Used to recover the original client address when a DNS server is fronted + * by a proxy or load balancer that prepends a PROXY header to each TCP + * connection or UDP datagram. + * + * Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt */ -final class ProxyProtocol +final readonly class ProxyProtocol { - /** v1 header starts with this ASCII prefix. */ + public const int VERSION_1 = 1; + + public const int VERSION_2 = 2; + + public const int COMMAND_LOCAL = 0x0; + + public const int COMMAND_PROXY = 0x1; + + public const int ADDRESS_FAMILY_UNSPEC = 0x0; + + public const int ADDRESS_FAMILY_INET = 0x1; + + public const int ADDRESS_FAMILY_INET6 = 0x2; + + public const int ADDRESS_FAMILY_UNIX = 0x3; + + public const int TRANSPORT_UNSPEC = 0x0; + + public const int TRANSPORT_STREAM = 0x1; + + public const int TRANSPORT_DGRAM = 0x2; + + public const string FAMILY_TCP4 = 'TCP4'; + + public const string FAMILY_TCP6 = 'TCP6'; + + public const string FAMILY_UDP4 = 'UDP4'; + + public const string FAMILY_UDP6 = 'UDP6'; + + public const string FAMILY_UNIX = 'UNIX'; + + public const string FAMILY_UNKNOWN = 'UNKNOWN'; + + /** v1 preamble is an ASCII line starting with this prefix. */ public const string V1_PREFIX = "PROXY "; - /** v2 header starts with this 12-byte binary signature. */ + /** v1 preamble (including CRLF) is at most 107 bytes per spec. */ + public const int V1_MAX_LENGTH = 107; + + /** v2 preamble starts with this 12-byte binary signature. */ public const string V2_SIGNATURE = "\r\n\r\n\x00\r\nQUIT\n"; - /** Per spec, v1 line (including CRLF) is at most 107 bytes. */ - public const int V1_MAX_LENGTH = 107; + /** v2 fixed header: 12-byte signature + 2-byte ver/cmd+fam/trans + 2-byte length. */ + public const int V2_HEADER_LENGTH = 16; + + public const int V2_SIGNATURE_LENGTH = 12; + + /** v2 INET address payload: 4-byte src + 4-byte dst + 2-byte src port + 2-byte dst port. */ + public const int V2_INET_PAYLOAD_LENGTH = 12; - /** v2 fixed header (signature + 4 bytes) before the address payload. */ - public const int V2_FIXED_HEADER_LENGTH = 16; + /** v2 INET6 address payload: 16-byte src + 16-byte dst + 2-byte src port + 2-byte dst port. */ + public const int V2_INET6_PAYLOAD_LENGTH = 36; + + /** v2 UNIX address payload: 108-byte src path + 108-byte dst path. */ + public const int V2_UNIX_PAYLOAD_LENGTH = 216; + + public function __construct( + public int $version, + public bool $isLocal, + public string $family, + public ?string $sourceAddress, + public ?int $sourcePort, + public ?string $destinationAddress, + public ?int $destinationPort, + public int $bytesConsumed, + ) { + } /** * Detect which PROXY protocol version the buffer starts with. * - * Returns 1 or 2 on a full match, 0 when the buffer is definitely not a - * PROXY header, and null when the buffer is a valid prefix of a signature - * but more bytes are needed to decide. + * Returns 1 or 2 on a full-signature match, 0 when the buffer is + * definitely not a PROXY preamble, and null when the buffer is a valid + * prefix of either signature but too short to decide. + * + * Hot path: the common case is non-PROXY DNS traffic, where the first + * byte is a random transaction ID byte. This returns 0 after a single + * byte comparison in that case. */ public static function detect(string $buffer): ?int { - $length = strlen($buffer); - - if ($length === 0) { + if ($buffer === '') { return null; } - if (str_starts_with($buffer, self::V2_SIGNATURE)) { - return 2; - } + $first = $buffer[0]; - if ($length < strlen(self::V2_SIGNATURE) && str_starts_with(self::V2_SIGNATURE, $buffer)) { - return null; - } + // v1 starts with ASCII 'P'. v2 starts with 0x0D ('\r'). Anything else is not PROXY. + if ($first === 'P') { + if (\str_starts_with($buffer, self::V1_PREFIX)) { + return self::VERSION_1; + } + + if (\strlen($buffer) < 6 && \str_starts_with(self::V1_PREFIX, $buffer)) { + return null; + } - if (str_starts_with($buffer, self::V1_PREFIX)) { - return 1; + return 0; } - if ($length < strlen(self::V1_PREFIX) && str_starts_with(self::V1_PREFIX, $buffer)) { - return null; + if ($first === "\r") { + if (\str_starts_with($buffer, self::V2_SIGNATURE)) { + return self::VERSION_2; + } + + if (\strlen($buffer) < self::V2_SIGNATURE_LENGTH && \str_starts_with(self::V2_SIGNATURE, $buffer)) { + return null; + } + + return 0; } return 0; } /** - * Parse the PROXY header at the beginning of the buffer. + * Decode a PROXY preamble from the beginning of the buffer. * - * Returns null when more bytes are needed. Throws DecodingException if - * the buffer begins with a PROXY signature but the header is malformed. - * Callers must first ensure the buffer actually starts with a PROXY - * signature (for example via {@see detect()}); this method does not - * silently skip non-PROXY data. + * Returns null when the buffer is a valid partial preamble but more bytes + * are needed. Throws {@see DecodingException} on malformed input or when + * the buffer does not start with a PROXY signature. Callers that mix + * PROXY and direct traffic should call {@see detect()} first. */ - public static function parse(string $buffer): ?ProxyProtocolHeader + public static function decode(string $buffer): ?self { $version = self::detect($buffer); @@ -81,17 +151,17 @@ public static function parse(string $buffer): ?ProxyProtocolHeader throw new DecodingException('Buffer does not start with a PROXY protocol signature.'); } - return $version === 1 - ? self::parseV1($buffer) - : self::parseV2($buffer); + return $version === self::VERSION_1 + ? self::decodeV1($buffer) + : self::decodeV2($buffer); } - private static function parseV1(string $buffer): ?ProxyProtocolHeader + private static function decodeV1(string $buffer): ?self { - $terminator = strpos($buffer, "\r\n"); + $terminator = \strpos($buffer, "\r\n"); if ($terminator === false) { - if (strlen($buffer) >= self::V1_MAX_LENGTH) { + if (\strlen($buffer) >= self::V1_MAX_LENGTH) { throw new DecodingException('PROXY v1 header missing CRLF within 107 bytes.'); } return null; @@ -103,8 +173,8 @@ private static function parseV1(string $buffer): ?ProxyProtocolHeader throw new DecodingException('PROXY v1 header exceeds 107 bytes.'); } - $line = substr($buffer, 0, $terminator); - $parts = explode(' ', $line); + $line = \substr($buffer, 0, $terminator); + $parts = \explode(' ', $line); if ($parts[0] !== 'PROXY') { throw new DecodingException('PROXY v1 header missing PROXY token.'); @@ -112,11 +182,12 @@ private static function parseV1(string $buffer): ?ProxyProtocolHeader $proto = $parts[1] ?? ''; + // Per spec: receivers MUST ignore everything past UNKNOWN on the line. if ($proto === 'UNKNOWN') { - return new ProxyProtocolHeader( - version: 1, + return new self( + version: self::VERSION_1, isLocal: false, - family: ProxyProtocolHeader::FAMILY_UNKNOWN, + family: self::FAMILY_UNKNOWN, sourceAddress: null, sourcePort: null, destinationAddress: null, @@ -129,81 +200,91 @@ private static function parseV1(string $buffer): ?ProxyProtocolHeader throw new DecodingException('PROXY v1 header has unsupported protocol: ' . $proto); } - if (count($parts) !== 6) { + if (\count($parts) !== 6) { throw new DecodingException('PROXY v1 header is malformed.'); } [, , $srcAddr, $dstAddr, $srcPort, $dstPort] = $parts; - $expectedFlag = $proto === 'TCP4' ? FILTER_FLAG_IPV4 : FILTER_FLAG_IPV6; + $ipFlag = $proto === 'TCP4' ? \FILTER_FLAG_IPV4 : \FILTER_FLAG_IPV6; - if (filter_var($srcAddr, FILTER_VALIDATE_IP, $expectedFlag) === false) { + if (\filter_var($srcAddr, \FILTER_VALIDATE_IP, $ipFlag) === false) { throw new DecodingException('PROXY v1 invalid source address: ' . $srcAddr); } - if (filter_var($dstAddr, FILTER_VALIDATE_IP, $expectedFlag) === false) { + if (\filter_var($dstAddr, \FILTER_VALIDATE_IP, $ipFlag) === false) { throw new DecodingException('PROXY v1 invalid destination address: ' . $dstAddr); } - $srcPortInt = self::parsePort($srcPort, 'source'); - $dstPortInt = self::parsePort($dstPort, 'destination'); - - return new ProxyProtocolHeader( - version: 1, + return new self( + version: self::VERSION_1, isLocal: false, family: $proto, sourceAddress: $srcAddr, - sourcePort: $srcPortInt, + sourcePort: self::decodeV1Port($srcPort, 'source'), destinationAddress: $dstAddr, - destinationPort: $dstPortInt, + destinationPort: self::decodeV1Port($dstPort, 'destination'), bytesConsumed: $lineLength, ); } - private static function parseV2(string $buffer): ?ProxyProtocolHeader + private static function decodeV1Port(string $value, string $label): int { - if (strlen($buffer) < self::V2_FIXED_HEADER_LENGTH) { - return null; + if (!\ctype_digit($value) || ($value[0] === '0' && $value !== '0')) { + throw new DecodingException('PROXY v1 invalid ' . $label . ' port: ' . $value); } - $verCmd = ord($buffer[12]); - $famTrans = ord($buffer[13]); + $port = (int) $value; - $version = ($verCmd & 0xF0) >> 4; - $command = $verCmd & 0x0F; + if ($port > 65535) { + throw new DecodingException('PROXY v1 ' . $label . ' port out of range: ' . $value); + } + + return $port; + } - if ($version !== 2) { - throw new DecodingException('PROXY v2 header has invalid version: ' . $version); + private static function decodeV2(string $buffer): ?self + { + if (\strlen($buffer) < self::V2_HEADER_LENGTH) { + return null; } - if ($command !== 0 && $command !== 1) { - throw new DecodingException('PROXY v2 header has invalid command: ' . $command); + // Single unpack call: ver/cmd byte, family/transport byte, 2-byte length. + $fields = \unpack('CverCmd/CfamTrans/nlength', $buffer, self::V2_SIGNATURE_LENGTH); + + if ($fields === false + || !\is_int($fields['verCmd'] ?? null) + || !\is_int($fields['famTrans'] ?? null) + || !\is_int($fields['length'] ?? null) + ) { + throw new DecodingException('PROXY v2 header could not be unpacked.'); } - $addressFamily = ($famTrans & 0xF0) >> 4; - $transport = $famTrans & 0x0F; + $verCmd = $fields['verCmd']; + $famTrans = $fields['famTrans']; + $payloadLength = $fields['length']; - $payloadLengthData = unpack('n', substr($buffer, 14, 2)); + if (($verCmd & 0xF0) !== 0x20) { + throw new DecodingException('PROXY v2 header has invalid version.'); + } - if ($payloadLengthData === false || !isset($payloadLengthData[1]) || !is_int($payloadLengthData[1])) { - throw new DecodingException('PROXY v2 header has invalid payload length.'); + $command = $verCmd & 0x0F; + + if ($command !== self::COMMAND_LOCAL && $command !== self::COMMAND_PROXY) { + throw new DecodingException('PROXY v2 header has invalid command: ' . $command); } - $payloadLength = $payloadLengthData[1]; - $totalLength = self::V2_FIXED_HEADER_LENGTH + $payloadLength; + $totalLength = self::V2_HEADER_LENGTH + $payloadLength; - if (strlen($buffer) < $totalLength) { + if (\strlen($buffer) < $totalLength) { return null; } - $isLocal = $command === 0; - - // LOCAL connections carry no usable address info (health checks). - if ($isLocal) { - return new ProxyProtocolHeader( - version: 2, + if ($command === self::COMMAND_LOCAL) { + return new self( + version: self::VERSION_2, isLocal: true, - family: ProxyProtocolHeader::FAMILY_UNKNOWN, + family: self::FAMILY_UNKNOWN, sourceAddress: null, sourcePort: null, destinationAddress: null, @@ -212,62 +293,70 @@ private static function parseV2(string $buffer): ?ProxyProtocolHeader ); } - $payload = substr($buffer, self::V2_FIXED_HEADER_LENGTH, $payloadLength); - - return match (true) { - $addressFamily === 0x1 && ($transport === 0x1 || $transport === 0x2) => - self::parseV2Inet($payload, $transport, $totalLength, 4), - $addressFamily === 0x2 && ($transport === 0x1 || $transport === 0x2) => - self::parseV2Inet($payload, $transport, $totalLength, 16), - $addressFamily === 0x3 && ($transport === 0x1 || $transport === 0x2) => - self::parseV2Unix($payload, $totalLength), - default => new ProxyProtocolHeader( - version: 2, - isLocal: false, - family: ProxyProtocolHeader::FAMILY_UNKNOWN, - sourceAddress: null, - sourcePort: null, - destinationAddress: null, - destinationPort: null, - bytesConsumed: $totalLength, + $addressFamily = ($famTrans & 0xF0) >> 4; + $transport = $famTrans & 0x0F; + + if ($transport !== self::TRANSPORT_STREAM && $transport !== self::TRANSPORT_DGRAM) { + return self::opaqueV2($totalLength); + } + + return match ($addressFamily) { + self::ADDRESS_FAMILY_INET => self::decodeV2Inet( + $buffer, + $transport, + $totalLength, + self::V2_INET_PAYLOAD_LENGTH, + 4, ), + self::ADDRESS_FAMILY_INET6 => self::decodeV2Inet( + $buffer, + $transport, + $totalLength, + self::V2_INET6_PAYLOAD_LENGTH, + 16, + ), + self::ADDRESS_FAMILY_UNIX => self::decodeV2Unix( + $buffer, + $payloadLength, + $totalLength, + ), + default => self::opaqueV2($totalLength), }; } - private static function parseV2Inet(string $payload, int $transport, int $totalLength, int $addrSize): ProxyProtocolHeader + private static function decodeV2Inet(string $buffer, int $transport, int $totalLength, int $minPayload, int $addrSize): self { - $expected = ($addrSize * 2) + 4; - - if (strlen($payload) < $expected) { - throw new DecodingException('PROXY v2 INET payload too short.'); + if ($totalLength - self::V2_HEADER_LENGTH < $minPayload) { + throw new DecodingException('PROXY v2 INET payload too short for declared family.'); } - $srcRaw = substr($payload, 0, $addrSize); - $dstRaw = substr($payload, $addrSize, $addrSize); + $offset = self::V2_HEADER_LENGTH; - $srcAddr = inet_ntop($srcRaw); - $dstAddr = inet_ntop($dstRaw); + $srcRaw = \substr($buffer, $offset, $addrSize); + $dstRaw = \substr($buffer, $offset + $addrSize, $addrSize); + $srcAddr = \inet_ntop($srcRaw); + $dstAddr = \inet_ntop($dstRaw); if ($srcAddr === false || $dstAddr === false) { throw new DecodingException('PROXY v2 INET address could not be decoded.'); } - $ports = unpack('nsrc/ndst', substr($payload, $addrSize * 2, 4)); + $ports = \unpack('nsrc/ndst', $buffer, $offset + ($addrSize * 2)); - if ($ports === false || !is_int($ports['src'] ?? null) || !is_int($ports['dst'] ?? null)) { + if ($ports === false || !\is_int($ports['src'] ?? null) || !\is_int($ports['dst'] ?? null)) { throw new DecodingException('PROXY v2 INET ports could not be decoded.'); } - $ipv4 = $addrSize === 4; + $isInet6 = $addrSize === 16; - if ($transport === 0x1) { - $family = $ipv4 ? ProxyProtocolHeader::FAMILY_TCP4 : ProxyProtocolHeader::FAMILY_TCP6; + if ($transport === self::TRANSPORT_STREAM) { + $family = $isInet6 ? self::FAMILY_TCP6 : self::FAMILY_TCP4; } else { - $family = $ipv4 ? ProxyProtocolHeader::FAMILY_UDP4 : ProxyProtocolHeader::FAMILY_UDP6; + $family = $isInet6 ? self::FAMILY_UDP6 : self::FAMILY_UDP4; } - return new ProxyProtocolHeader( - version: 2, + return new self( + version: self::VERSION_2, isLocal: false, family: $family, sourceAddress: $srcAddr, @@ -278,19 +367,20 @@ private static function parseV2Inet(string $payload, int $transport, int $totalL ); } - private static function parseV2Unix(string $payload, int $totalLength): ProxyProtocolHeader + private static function decodeV2Unix(string $buffer, int $payloadLength, int $totalLength): self { - if (strlen($payload) < 216) { + if ($payloadLength < self::V2_UNIX_PAYLOAD_LENGTH) { throw new DecodingException('PROXY v2 UNIX payload too short.'); } - $src = rtrim(substr($payload, 0, 108), "\x00"); - $dst = rtrim(substr($payload, 108, 108), "\x00"); + $offset = self::V2_HEADER_LENGTH; + $src = \rtrim(\substr($buffer, $offset, 108), "\x00"); + $dst = \rtrim(\substr($buffer, $offset + 108, 108), "\x00"); - return new ProxyProtocolHeader( - version: 2, + return new self( + version: self::VERSION_2, isLocal: false, - family: ProxyProtocolHeader::FAMILY_UNIX, + family: self::FAMILY_UNIX, sourceAddress: $src !== '' ? $src : null, sourcePort: null, destinationAddress: $dst !== '' ? $dst : null, @@ -299,18 +389,22 @@ private static function parseV2Unix(string $payload, int $totalLength): ProxyPro ); } - private static function parsePort(string $value, string $label): int + /** + * v2 headers with an unknown family or transport are passed through as + * opaque frames: we still advance past them so downstream parsing lines + * up, but the address info is discarded. + */ + private static function opaqueV2(int $totalLength): self { - if ($value === '' || !ctype_digit($value)) { - throw new DecodingException('PROXY v1 invalid ' . $label . ' port: ' . $value); - } - - $port = (int) $value; - - if ($port < 0 || $port > 65535) { - throw new DecodingException('PROXY v1 ' . $label . ' port out of range: ' . $value); - } - - return $port; + return new self( + version: self::VERSION_2, + isLocal: false, + family: self::FAMILY_UNKNOWN, + sourceAddress: null, + sourcePort: null, + destinationAddress: null, + destinationPort: null, + bytesConsumed: $totalLength, + ); } } diff --git a/src/DNS/ProxyProtocolHeader.php b/src/DNS/ProxyProtocolHeader.php deleted file mode 100644 index 14ae4eb..0000000 --- a/src/DNS/ProxyProtocolHeader.php +++ /dev/null @@ -1,32 +0,0 @@ -assertNull(ProxyProtocol::detect('')); + } + + public function testDetectFullV1Match(): void { - $this->assertSame(1, ProxyProtocol::detect("PROXY TCP4 1.2.3.4 5.6.7.8 1111 2222\r\n")); + $this->assertSame( + ProxyProtocol::VERSION_1, + ProxyProtocol::detect("PROXY TCP4 1.2.3.4 5.6.7.8 1111 2222\r\n") + ); } - public function testDetectV2(): void + public function testDetectFullV2Match(): void { - $this->assertSame(2, ProxyProtocol::detect(ProxyProtocol::V2_SIGNATURE . "\x21\x11\x00\x0C")); + $header = ProxyProtocol::V2_SIGNATURE . "\x21\x11\x00\x0C"; + $this->assertSame(ProxyProtocol::VERSION_2, ProxyProtocol::detect($header)); } - public function testDetectPartialV1ReturnsNull(): void + /** @return iterable */ + public static function partialV1Provider(): iterable { - $this->assertNull(ProxyProtocol::detect('PROX')); + yield 'single P' => ['P']; + yield 'two chars' => ['PR']; + yield 'three chars' => ['PRO']; + yield 'four chars' => ['PROX']; + yield 'five chars' => ['PROXY']; } - public function testDetectPartialV2ReturnsNull(): void + #[DataProvider('partialV1Provider')] + public function testDetectPartialV1ReturnsNull(string $buffer): void { - $this->assertNull(ProxyProtocol::detect("\r\n\r\n\x00")); + $this->assertNull(ProxyProtocol::detect($buffer)); + } + + /** @return iterable */ + public static function partialV2Provider(): iterable + { + for ($i = 1; $i < ProxyProtocol::V2_SIGNATURE_LENGTH; $i++) { + yield "first {$i} bytes" => [substr(ProxyProtocol::V2_SIGNATURE, 0, $i)]; + } + } + + #[DataProvider('partialV2Provider')] + public function testDetectPartialV2ReturnsNull(string $buffer): void + { + $this->assertNull(ProxyProtocol::detect($buffer)); + } + + public function testDetectRejectsDnsQueryHeader(): void + { + // Standard DNS query: ID 0x1234, flags 0x0100, qd=1, everything else 0. + $dnsPacket = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; + $this->assertSame(0, ProxyProtocol::detect($dnsPacket)); } - public function testDetectNonProxy(): void + public function testDetectRejectsCrPrefixButNotV2(): void { - $this->assertSame(0, ProxyProtocol::detect("\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00")); + // Second byte differs from 0x0A. + $this->assertSame(0, ProxyProtocol::detect("\r\xFF\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00")); } - public function testParseV1Tcp4(): void + public function testDetectRejectsPPrefixButNotV1(): void + { + // Starts with 'P' but not "PROXY ". + $this->assertSame(0, ProxyProtocol::detect("PATH /\r\n")); + } + + public function testDetectRejectsNonPnonCrFirstByte(): void + { + $this->assertSame(0, ProxyProtocol::detect("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")); + } + + /** @return iterable */ + public static function detectBoundaryProvider(): iterable + { + yield 'v1 exact prefix' => [ProxyProtocol::VERSION_1, 'PROXY ']; + yield 'v1 prefix + CRLF' => [ProxyProtocol::VERSION_1, "PROXY \r\n"]; // Malformed body but prefix matches + yield 'v2 exact signature' => [ProxyProtocol::VERSION_2, ProxyProtocol::V2_SIGNATURE]; + yield 'v2 signature + junk' => [ProxyProtocol::VERSION_2, ProxyProtocol::V2_SIGNATURE . "\x00\x00\x00\x00"]; + } + + #[DataProvider('detectBoundaryProvider')] + public function testDetectBoundary(?int $expected, string $buffer): void + { + $this->assertSame($expected, ProxyProtocol::detect($buffer)); + } + + // --------------------------------------------------------------------- + // decode() — v1 + // --------------------------------------------------------------------- + + public function testDecodeV1Tcp4(): void { $header = "PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n"; - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); - $this->assertInstanceOf(ProxyProtocolHeader::class, $result); - $this->assertSame(1, $result->version); + $this->assertNotNull($result); + $this->assertSame(ProxyProtocol::VERSION_1, $result->version); $this->assertFalse($result->isLocal); - $this->assertSame(ProxyProtocolHeader::FAMILY_TCP4, $result->family); + $this->assertSame(ProxyProtocol::FAMILY_TCP4, $result->family); $this->assertSame('192.168.1.1', $result->sourceAddress); $this->assertSame('10.0.0.1', $result->destinationAddress); $this->assertSame(56324, $result->sourcePort); @@ -50,81 +121,145 @@ public function testParseV1Tcp4(): void $this->assertSame(strlen($header), $result->bytesConsumed); } - public function testParseV1Tcp6(): void + public function testDecodeV1Tcp6(): void { $header = "PROXY TCP6 2001:db8::1 2001:db8::2 65535 53\r\n"; - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); - $this->assertSame(ProxyProtocolHeader::FAMILY_TCP6, $result->family); + $this->assertSame(ProxyProtocol::FAMILY_TCP6, $result->family); $this->assertSame('2001:db8::1', $result->sourceAddress); $this->assertSame('2001:db8::2', $result->destinationAddress); $this->assertSame(65535, $result->sourcePort); $this->assertSame(53, $result->destinationPort); } - public function testParseV1Unknown(): void + /** @return iterable */ + public static function v1PortBoundaryProvider(): iterable + { + yield 'zero src, zero dst' => ["PROXY TCP4 1.2.3.4 5.6.7.8 0 0\r\n", 0, 0]; + yield 'max src, min dst' => ["PROXY TCP4 1.2.3.4 5.6.7.8 65535 1\r\n", 65535, 1]; + yield 'min src, max dst' => ["PROXY TCP4 1.2.3.4 5.6.7.8 1 65535\r\n", 1, 65535]; + yield 'both middle' => ["PROXY TCP4 1.2.3.4 5.6.7.8 8080 8081\r\n", 8080, 8081]; + } + + #[DataProvider('v1PortBoundaryProvider')] + public function testDecodeV1PortBoundaries(string $header, int $expectedSrc, int $expectedDst): void + { + $result = ProxyProtocol::decode($header); + $this->assertNotNull($result); + $this->assertSame($expectedSrc, $result->sourcePort); + $this->assertSame($expectedDst, $result->destinationPort); + } + + public function testDecodeV1Unknown(): void { $header = "PROXY UNKNOWN\r\n"; - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); - $this->assertSame(ProxyProtocolHeader::FAMILY_UNKNOWN, $result->family); + $this->assertSame(ProxyProtocol::FAMILY_UNKNOWN, $result->family); $this->assertNull($result->sourceAddress); + $this->assertNull($result->sourcePort); $this->assertNull($result->destinationAddress); + $this->assertNull($result->destinationPort); $this->assertSame(strlen($header), $result->bytesConsumed); } - public function testParseV1UnknownWithExtraTokensIgnored(): void + public function testDecodeV1UnknownIgnoresTrailingTokens(): void { - // Per spec, receivers must ignore everything past UNKNOWN on the line. + // Per spec: receivers MUST ignore everything past UNKNOWN on the line. $header = "PROXY UNKNOWN ff:ff::1 aa:aa::2 1234 5678\r\n"; - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); - $this->assertSame(ProxyProtocolHeader::FAMILY_UNKNOWN, $result->family); + $this->assertSame(ProxyProtocol::FAMILY_UNKNOWN, $result->family); $this->assertSame(strlen($header), $result->bytesConsumed); } - public function testParseV1ReturnsNullWhenCrlfMissing(): void + public function testDecodeV1ReturnsNullWhenCrlfMissing(): void { - $this->assertNull(ProxyProtocol::parse('PROXY TCP4 1.2.3.4 5.6.7.8 1 2')); + $this->assertNull(ProxyProtocol::decode('PROXY TCP4 1.2.3.4 5.6.7.8 1 2')); } - public function testParseV1ThrowsWhenTooLong(): void + public function testDecodeV1DoesNotConsumeTrailingData(): void { - $this->expectException(DecodingException::class); - ProxyProtocol::parse('PROXY TCP4 ' . str_repeat('a', 120)); + // Preamble is followed by a 4-byte payload; bytesConsumed must cover only the preamble. + $preamble = "PROXY TCP4 1.2.3.4 5.6.7.8 1 2\r\n"; + $payload = "\x00\x0eABCD"; + $result = ProxyProtocol::decode($preamble . $payload); + $this->assertNotNull($result); + $this->assertSame(strlen($preamble), $result->bytesConsumed); } - public function testParseV1InvalidAddressThrows(): void + /** @return iterable */ + public static function malformedV1Provider(): iterable + { + yield 'non-digit port' => ["PROXY TCP4 1.2.3.4 5.6.7.8 abc 80\r\n"]; + yield 'leading zero port' => ["PROXY TCP4 1.2.3.4 5.6.7.8 080 81\r\n"]; + yield 'negative port' => ["PROXY TCP4 1.2.3.4 5.6.7.8 -1 80\r\n"]; + yield 'port out of range' => ["PROXY TCP4 1.2.3.4 5.6.7.8 70000 80\r\n"]; + yield 'invalid IPv4' => ["PROXY TCP4 999.999.999.999 10.0.0.1 1 2\r\n"]; + yield 'invalid IPv6' => ["PROXY TCP6 zz::1 2001:db8::2 1 2\r\n"]; + yield 'IPv4 under TCP6' => ["PROXY TCP6 1.2.3.4 5.6.7.8 1 2\r\n"]; + yield 'IPv6 under TCP4' => ["PROXY TCP4 2001:db8::1 10.0.0.1 1 2\r\n"]; + yield 'missing destination address' => ["PROXY TCP4 1.2.3.4 1 2\r\n"]; + yield 'extra token' => ["PROXY TCP4 1.2.3.4 5.6.7.8 1 2 3\r\n"]; + yield 'unsupported protocol' => ["PROXY HTTP 1.2.3.4 5.6.7.8 1 2\r\n"]; + yield 'double space' => ["PROXY TCP4 1.2.3.4 5.6.7.8 1 2\r\n"]; + yield 'empty port' => ["PROXY TCP4 1.2.3.4 5.6.7.8 2\r\n"]; + } + + #[DataProvider('malformedV1Provider')] + public function testDecodeV1MalformedThrows(string $header): void { $this->expectException(DecodingException::class); - ProxyProtocol::parse("PROXY TCP4 999.999.999.999 10.0.0.1 1 2\r\n"); + ProxyProtocol::decode($header); } - public function testParseV1InvalidPortThrows(): void + public function testDecodeV1ThrowsWhenTooLong(): void { + // 120+ bytes with no CRLF — exceeds the 107-byte cap. + $buffer = 'PROXY TCP4 ' . str_repeat('a', 120); $this->expectException(DecodingException::class); - ProxyProtocol::parse("PROXY TCP4 1.2.3.4 5.6.7.8 70000 80\r\n"); + ProxyProtocol::decode($buffer); } - public function testParseV1Ipv4ForTcp6Mismatch(): void + public function testDecodeV1ThrowsWhenLineLongerThan107Bytes(): void { + // CRLF is present, but line length exceeds spec maximum. + $longAddress = str_repeat('a', 100); + $header = "PROXY TCP4 {$longAddress} 5.6.7.8 1 2\r\n"; + $this->assertGreaterThan(ProxyProtocol::V1_MAX_LENGTH, strlen($header)); + $this->expectException(DecodingException::class); - ProxyProtocol::parse("PROXY TCP6 1.2.3.4 5.6.7.8 1 2\r\n"); + ProxyProtocol::decode($header); + } + + // --------------------------------------------------------------------- + // decode() — v2 + // --------------------------------------------------------------------- + + private static function buildV2(int $verCmd, int $famTrans, string $payload): string + { + return ProxyProtocol::V2_SIGNATURE + . chr($verCmd) + . chr($famTrans) + . pack('n', strlen($payload)) + . $payload; } - public function testParseV2Inet4(): void + public function testDecodeV2Inet4Tcp(): void { $payload = inet_pton('192.168.1.1') . inet_pton('10.0.0.1') . pack('nn', 56324, 443); - $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x11) . pack('n', strlen($payload)) . $payload; + $header = self::buildV2(0x21, 0x11, $payload); - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); - $this->assertSame(2, $result->version); - $this->assertSame(ProxyProtocolHeader::FAMILY_TCP4, $result->family); + $this->assertSame(ProxyProtocol::VERSION_2, $result->version); + $this->assertFalse($result->isLocal); + $this->assertSame(ProxyProtocol::FAMILY_TCP4, $result->family); $this->assertSame('192.168.1.1', $result->sourceAddress); $this->assertSame('10.0.0.1', $result->destinationAddress); $this->assertSame(56324, $result->sourcePort); @@ -132,26 +267,53 @@ public function testParseV2Inet4(): void $this->assertSame(strlen($header), $result->bytesConsumed); } - public function testParseV2Inet6Udp(): void + public function testDecodeV2Inet4Udp(): void + { + $payload = inet_pton('127.0.0.1') . inet_pton('127.0.0.2') . pack('nn', 0, 65535); + $header = self::buildV2(0x21, 0x12, $payload); + + $result = ProxyProtocol::decode($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocol::FAMILY_UDP4, $result->family); + $this->assertSame(0, $result->sourcePort); + $this->assertSame(65535, $result->destinationPort); + } + + public function testDecodeV2Inet6Tcp(): void + { + $payload = inet_pton('fe80::1') . inet_pton('fe80::2') . pack('nn', 11111, 22222); + $header = self::buildV2(0x21, 0x21, $payload); + + $result = ProxyProtocol::decode($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocol::FAMILY_TCP6, $result->family); + $this->assertSame('fe80::1', $result->sourceAddress); + $this->assertSame('fe80::2', $result->destinationAddress); + } + + public function testDecodeV2Inet6Udp(): void { $payload = inet_pton('2001:db8::1') . inet_pton('2001:db8::2') . pack('nn', 53, 5353); - $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x22) . pack('n', strlen($payload)) . $payload; + $header = self::buildV2(0x21, 0x22, $payload); - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); - $this->assertSame(ProxyProtocolHeader::FAMILY_UDP6, $result->family); + $this->assertSame(ProxyProtocol::FAMILY_UDP6, $result->family); $this->assertSame('2001:db8::1', $result->sourceAddress); $this->assertSame('2001:db8::2', $result->destinationAddress); } - public function testParseV2Local(): void + public function testDecodeV2Local(): void { - // LOCAL command (0x20): no address, but still has a payload length field we must respect + // LOCAL commands may carry any payload (health checks). We still + // honour payload length, skip the body and return bytesConsumed. $payload = str_repeat("\x00", 12); - $header = ProxyProtocol::V2_SIGNATURE . chr(0x20) . chr(0x00) . pack('n', strlen($payload)) . $payload; + $header = self::buildV2(0x20, 0x00, $payload); - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); $this->assertTrue($result->isLocal); @@ -159,80 +321,239 @@ public function testParseV2Local(): void $this->assertSame(strlen($header), $result->bytesConsumed); } - public function testParseV2Unix(): void + public function testDecodeV2LocalEmptyPayload(): void + { + $header = self::buildV2(0x20, 0x00, ''); + $result = ProxyProtocol::decode($header); + + $this->assertNotNull($result); + $this->assertTrue($result->isLocal); + $this->assertSame(ProxyProtocol::V2_HEADER_LENGTH, $result->bytesConsumed); + } + + public function testDecodeV2Unix(): void { $srcPath = str_pad('/var/run/src.sock', 108, "\x00"); $dstPath = str_pad('/var/run/dst.sock', 108, "\x00"); $payload = $srcPath . $dstPath; - $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x31) . pack('n', strlen($payload)) . $payload; + $header = self::buildV2(0x21, 0x31, $payload); - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); - $this->assertSame(ProxyProtocolHeader::FAMILY_UNIX, $result->family); + $this->assertSame(ProxyProtocol::FAMILY_UNIX, $result->family); $this->assertSame('/var/run/src.sock', $result->sourceAddress); $this->assertSame('/var/run/dst.sock', $result->destinationAddress); + $this->assertNull($result->sourcePort); + $this->assertNull($result->destinationPort); + } + + public function testDecodeV2UnixEmptyPaths(): void + { + $payload = str_repeat("\x00", 216); + $header = self::buildV2(0x21, 0x31, $payload); + + $result = ProxyProtocol::decode($header); + + $this->assertNotNull($result); + $this->assertNull($result->sourceAddress); + $this->assertNull($result->destinationAddress); + } + + /** @return iterable */ + public static function v2UnknownFamilyTransportProvider(): iterable + { + yield 'UNSPEC family, STREAM transport' => [0x01]; + yield 'INET family, UNSPEC transport' => [0x10]; + yield 'INET family, reserved transport 3' => [0x13]; + yield 'reserved family 4, UNSPEC transport' => [0x40]; + yield 'reserved family 0xF, DGRAM transport' => [0xF2]; + } + + #[DataProvider('v2UnknownFamilyTransportProvider')] + public function testDecodeV2UnknownFamilyOrTransportIsOpaque(int $famTrans): void + { + $payload = str_repeat("\x00", 12); + $header = self::buildV2(0x21, $famTrans, $payload); + + $result = ProxyProtocol::decode($header); + + $this->assertNotNull($result); + $this->assertSame(ProxyProtocol::FAMILY_UNKNOWN, $result->family); + $this->assertFalse($result->isLocal); + $this->assertSame(strlen($header), $result->bytesConsumed); } - public function testParseV2WithTlvSuffixIsConsumedByLength(): void + public function testDecodeV2WithTlvSuffixRespectsLength(): void { - // Emulate a TLV appended to the addresses. Parser should skip past it - // via the payload length so bytesConsumed covers the TLV too. $addrPayload = inet_pton('192.168.1.1') . inet_pton('10.0.0.1') . pack('nn', 1, 2); - $tlv = "\x03\x00\x04ABCD"; // type=3, len=4, value=ABCD + $tlv = "\x03\x00\x04ABCD"; $payload = $addrPayload . $tlv; - $header = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x11) . pack('n', strlen($payload)) . $payload; + $header = self::buildV2(0x21, 0x11, $payload); - $result = ProxyProtocol::parse($header); + $result = ProxyProtocol::decode($header); $this->assertNotNull($result); $this->assertSame('192.168.1.1', $result->sourceAddress); $this->assertSame(strlen($header), $result->bytesConsumed); } - public function testParseV2ReturnsNullWhenPayloadIncomplete(): void + /** @return iterable */ + public static function v2IncompleteProvider(): iterable { - $payload = inet_pton('192.168.1.1') . inet_pton('10.0.0.1') . pack('nn', 1, 2); + $payload = inet_pton('1.2.3.4') . inet_pton('5.6.7.8') . pack('nn', 1, 2); $full = ProxyProtocol::V2_SIGNATURE . chr(0x21) . chr(0x11) . pack('n', strlen($payload)) . $payload; - $truncated = substr($full, 0, strlen($full) - 4); - $this->assertNull(ProxyProtocol::parse($truncated)); + yield 'header only, missing length bytes' => [substr($full, 0, 14)]; + yield 'header + half length' => [substr($full, 0, 15)]; + yield 'truncated address payload' => [substr($full, 0, strlen($full) - 1)]; + yield 'signature only' => [ProxyProtocol::V2_SIGNATURE]; + yield 'missing final byte' => [substr($full, 0, strlen($full) - 1)]; } - public function testParseV2InvalidVersionThrows(): void + #[DataProvider('v2IncompleteProvider')] + public function testDecodeV2ReturnsNullWhenIncomplete(string $buffer): void { - $header = ProxyProtocol::V2_SIGNATURE . chr(0x31) . chr(0x11) . pack('n', 0); + $this->assertNull(ProxyProtocol::decode($buffer)); + } + + public function testDecodeV2InvalidVersionThrows(): void + { + $header = self::buildV2(0x31, 0x11, ''); $this->expectException(DecodingException::class); - ProxyProtocol::parse($header); + ProxyProtocol::decode($header); } - public function testParseNonProxyThrows(): void + public function testDecodeV2InvalidCommandThrows(): void { + // Version 2 (high nibble 0x2), command 0x5 (invalid). + $header = self::buildV2(0x25, 0x11, ''); $this->expectException(DecodingException::class); - ProxyProtocol::parse("HELLO WORLD\r\n"); + ProxyProtocol::decode($header); } - public function testDetectRejectsDnsPacketStartingWithNonPrefixByte(): void + public function testDecodeV2InetPayloadTooShortThrows(): void { - // Typical DNS query header: ID 0x1234, flags 0x0100, 1 question, 0 answers... - $dnsPacket = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; - $this->assertSame(0, ProxyProtocol::detect($dnsPacket)); + // Declared INET+STREAM but only 4 bytes of payload — not enough. + $header = self::buildV2(0x21, 0x11, str_repeat("\x00", 4)); + $this->expectException(DecodingException::class); + ProxyProtocol::decode($header); + } + + public function testDecodeV2UnixPayloadTooShortThrows(): void + { + $header = self::buildV2(0x21, 0x31, str_repeat("\x00", 100)); + $this->expectException(DecodingException::class); + ProxyProtocol::decode($header); + } + + public function testDecodeRejectsBufferNotStartingWithProxySignature(): void + { + $this->expectException(DecodingException::class); + ProxyProtocol::decode("HELLO WORLD\r\n"); } - public function testDetectRejectsDnsPacketStartingWithCrButNotV2(): void + public function testDecodeReturnsNullForEmptyBuffer(): void { - // Crafted: starts with 0x0D (same as v2 sig first byte) but second byte differs. - $this->assertSame(0, ProxyProtocol::detect("\x0D\xFF\x00\x01")); + $this->assertNull(ProxyProtocol::decode('')); } - public function testDetectPartialSingleByteOfV1(): void + // --------------------------------------------------------------------- + // Streaming / chunked reads + // --------------------------------------------------------------------- + + public function testDecodeV1StreamingProducesNullUntilCrlfSeen(): void + { + $full = "PROXY TCP4 1.2.3.4 5.6.7.8 1 2\r\n"; + $accumulator = ''; + + for ($i = 0; $i < strlen($full) - 1; $i++) { + $accumulator .= $full[$i]; + $this->assertNull( + ProxyProtocol::decode($accumulator), + "Expected null at byte {$i}" + ); + } + + $accumulator .= $full[strlen($full) - 1]; + $this->assertNotNull(ProxyProtocol::decode($accumulator)); + } + + public function testDecodeV2StreamingProducesNullUntilTotalLength(): void + { + $payload = inet_pton('1.2.3.4') . inet_pton('5.6.7.8') . pack('nn', 1, 2); + $full = self::buildV2(0x21, 0x11, $payload); + + for ($i = 1; $i < strlen($full); $i++) { + $this->assertNull( + ProxyProtocol::decode(substr($full, 0, $i)), + "Expected null at partial length {$i}" + ); + } + + $this->assertNotNull(ProxyProtocol::decode($full)); + } + + // --------------------------------------------------------------------- + // Fuzz: random inputs never crash; they either decode, return null, or throw. + // --------------------------------------------------------------------- + + public function testFuzzRandomBuffersDoNotCrash(): void { - $this->assertNull(ProxyProtocol::detect('P')); + for ($i = 0; $i < 200; $i++) { + $length = random_int(0, 128); + $buffer = $length > 0 ? random_bytes($length) : ''; + + try { + ProxyProtocol::decode($buffer); + } catch (DecodingException) { + // Expected for malformed input. + } + + // Reaching this point means the parser did not crash on the buffer. + $this->addToAssertionCount(1); + } } - public function testDetectFullV1Prefix(): void + public function testFuzzRandomV2LikeBuffersDoNotCrash(): void + { + for ($i = 0; $i < 100; $i++) { + $addrLength = random_int(0, 64); + $payload = $addrLength > 0 ? random_bytes($addrLength) : ''; + $verCmd = random_int(0, 255); + $famTrans = random_int(0, 255); + $buffer = self::buildV2($verCmd, $famTrans, $payload); + + try { + ProxyProtocol::decode($buffer); + } catch (DecodingException) { + // Expected for malformed input. + } + + // Reaching this point means the parser did not crash on the buffer. + $this->addToAssertionCount(1); + } + } + + // --------------------------------------------------------------------- + // Value object invariants + // --------------------------------------------------------------------- + + public function testProxyProtocolIsReadonly(): void { - // Exactly "PROXY " with no trailing data is a prefix match, not a full line yet. - $this->assertSame(1, ProxyProtocol::detect('PROXY ')); + $instance = new ProxyProtocol( + version: ProxyProtocol::VERSION_1, + isLocal: false, + family: ProxyProtocol::FAMILY_TCP4, + sourceAddress: '1.2.3.4', + sourcePort: 80, + destinationAddress: '5.6.7.8', + destinationPort: 443, + bytesConsumed: 40, + ); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line intentionally asserting readonly enforcement */ + $instance->version = ProxyProtocol::VERSION_2; } } From 5cf858ae40b846cf11e3af8b3e579ad32560d235 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:37:27 +0100 Subject: [PATCH 3/6] Move PROXY protocol config to Server; dedupe adapter state via ProxyProtocolStream - 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) --- README.md | 17 +- src/DNS/Adapter.php | 26 ++ src/DNS/Adapter/Native.php | 70 ++---- src/DNS/Adapter/Swoole.php | 109 ++++----- src/DNS/ProxyProtocolStream.php | 132 ++++++++++ src/DNS/Server.php | 18 ++ tests/unit/DNS/ProxyProtocolStreamTest.php | 272 +++++++++++++++++++++ 7 files changed, 528 insertions(+), 116 deletions(-) create mode 100644 src/DNS/ProxyProtocolStream.php create mode 100644 tests/unit/DNS/ProxyProtocolStreamTest.php diff --git a/README.md b/README.md index a6c60c4..71b6be0 100644 --- a/README.md +++ b/README.md @@ -72,21 +72,12 @@ Adapters are responsible only for receiving and returning raw packets. They call ## PROXY protocol -When the DNS server sits behind a proxy or load balancer that speaks the [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) (AWS NLB, HAProxy, nginx, Envoy, etc.), enable it on the adapter so resolver callbacks see the real client address instead of the proxy's. Both v1 (text) and v2 (binary) headers are parsed, and the feature applies to both UDP datagrams and TCP connections. +When the DNS server sits behind a proxy or load balancer that speaks the [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) (AWS NLB, HAProxy, nginx, Envoy, etc.), enable it on the `Server` so resolver callbacks see the real client address instead of the proxy's. Both v1 (text) and v2 (binary) headers are parsed, and the feature applies to both UDP datagrams and TCP connections. ```php -$adapter = new Native( - host: '0.0.0.0', - port: 5300, - enableProxyProtocol: true, -); - -// Swoole equivalent -$adapter = new Swoole( - host: '0.0.0.0', - port: 5300, - enableProxyProtocol: true, -); +$server = new Server($adapter, $resolver); +$server->setProxyProtocol(enabled: true); +$server->start(); ``` Detection is per-connection (TCP) or per-datagram (UDP): traffic that begins with a PROXY v1/v2 signature is parsed and the real client address is passed to the resolver; traffic without a signature is handled as a direct DNS request. This keeps health checks and direct clients working during rollouts. diff --git a/src/DNS/Adapter.php b/src/DNS/Adapter.php index 86e5eb0..c9e1ebb 100644 --- a/src/DNS/Adapter.php +++ b/src/DNS/Adapter.php @@ -4,6 +4,32 @@ abstract class Adapter { + /** + * Whether the adapter should treat incoming traffic as potentially + * prefixed with a PROXY protocol (v1/v2) preamble. Configured via + * {@see Server::setProxyProtocol()} (or directly via + * {@see setProxyProtocol()}). + */ + protected bool $enableProxyProtocol = false; + + /** + * Toggle PROXY protocol awareness. + * + * Enabling this makes the adapter look for a PROXY preamble at the + * start of every UDP datagram and TCP connection; traffic without a + * preamble is still handled as direct DNS so health checks and direct + * clients keep working. + */ + public function setProxyProtocol(bool $enabled): void + { + $this->enableProxyProtocol = $enabled; + } + + public function hasProxyProtocol(): bool + { + return $this->enableProxyProtocol; + } + /** * Worker start * diff --git a/src/DNS/Adapter/Native.php b/src/DNS/Adapter/Native.php index c60ce07..7b75e9f 100644 --- a/src/DNS/Adapter/Native.php +++ b/src/DNS/Adapter/Native.php @@ -6,7 +6,7 @@ use Socket; use Utopia\DNS\Adapter; use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; -use Utopia\DNS\ProxyProtocol; +use Utopia\DNS\ProxyProtocolStream; class Native extends Adapter { @@ -29,8 +29,8 @@ class Native extends Adapter /** @var array Track last activity time per TCP client for idle timeout */ protected array $tcpLastActivity = []; - /** @var array Whether the PROXY header has been consumed for this TCP client */ - protected array $tcpProxyParsed = []; + /** @var array PROXY preamble resolver per TCP client */ + protected array $tcpProxyStreams = []; /** @var array Real client address (PROXY-aware) per TCP client */ protected array $tcpClientAddress = []; @@ -49,7 +49,6 @@ class Native extends Adapter * @param int $maxTcpBufferSize Maximum buffer size per TCP client * @param int $maxTcpFrameSize Maximum DNS message size over TCP * @param int $tcpIdleTimeout Seconds before idle TCP connections are closed (RFC 7766) - * @param bool $enableProxyProtocol Auto-detect a PROXY protocol (v1 or v2) preamble on each connection/datagram. Connections without a preamble are treated as direct. Only enable when the listener is reachable solely from trusted proxies — untrusted clients could forge the source address. */ public function __construct( protected string $host = '0.0.0.0', @@ -59,7 +58,6 @@ public function __construct( protected int $maxTcpBufferSize = 16384, protected int $maxTcpFrameSize = 65535, protected int $tcpIdleTimeout = 30, - protected bool $enableProxyProtocol = false, ) { $server = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); @@ -161,23 +159,17 @@ public function start(): void $replyIp = $ip; $replyPort = $port; - if ($this->enableProxyProtocol && ProxyProtocol::detect($buf)) { + if ($this->enableProxyProtocol) { try { - $header = ProxyProtocol::decode($buf); + $header = ProxyProtocolStream::unwrapDatagram($buf); } catch (ProxyDecodingException) { continue; } - if ($header === null) { - continue; - } - - if ($header->sourceAddress !== null && $header->sourcePort !== null) { + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { $ip = $header->sourceAddress; $port = $header->sourcePort; } - - $buf = substr($buf, $header->bytesConsumed); } $answer = call_user_func($this->onPacket, $buf, $ip, $port, 512); @@ -212,7 +204,10 @@ public function start(): void $this->tcpClients[$id] = $client; $this->tcpBuffers[$id] = ''; $this->tcpLastActivity[$id] = time(); - $this->tcpProxyParsed[$id] = false; + + if ($this->enableProxyProtocol) { + $this->tcpProxyStreams[$id] = new ProxyProtocolStream(); + } $peerIp = ''; $peerPort = 0; @@ -272,39 +267,26 @@ protected function handleTcpClient(Socket $client): void $this->tcpBuffers[$clientId] = ($this->tcpBuffers[$clientId] ?? '') . $chunk; - if ($this->enableProxyProtocol && !($this->tcpProxyParsed[$clientId] ?? false)) { - $detected = ProxyProtocol::detect($this->tcpBuffers[$clientId]); + $stream = $this->tcpProxyStreams[$clientId] ?? null; - // Not enough bytes to decide yet; wait for more. - if ($detected === null) { + if ($stream !== null && $stream->state() === ProxyProtocolStream::STATE_UNRESOLVED) { + try { + $state = $stream->resolve($this->tcpBuffers[$clientId]); + } catch (ProxyDecodingException) { + $this->closeTcpClient($client); return; } - if ($detected === 0) { - // Definitely not a PROXY preamble — treat this connection as direct. - $this->tcpProxyParsed[$clientId] = true; - } else { - try { - $header = ProxyProtocol::decode($this->tcpBuffers[$clientId]); - } catch (ProxyDecodingException) { - $this->closeTcpClient($client); - return; - } - - if ($header === null) { - // PROXY signature matched but payload is still incomplete. - return; - } - - $this->tcpBuffers[$clientId] = substr($this->tcpBuffers[$clientId], $header->bytesConsumed); - $this->tcpProxyParsed[$clientId] = true; + if ($state === ProxyProtocolStream::STATE_UNRESOLVED) { + return; + } - if ($header->sourceAddress !== null && $header->sourcePort !== null) { - $this->tcpClientAddress[$clientId] = [ - 'ip' => $header->sourceAddress, - 'port' => $header->sourcePort, - ]; - } + $header = $stream->header(); + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { + $this->tcpClientAddress[$clientId] = [ + 'ip' => $header->sourceAddress, + 'port' => $header->sourcePort, + ]; } } @@ -428,7 +410,7 @@ protected function closeTcpClient(Socket $client): void $this->tcpClients[$id], $this->tcpBuffers[$id], $this->tcpLastActivity[$id], - $this->tcpProxyParsed[$id], + $this->tcpProxyStreams[$id], $this->tcpClientAddress[$id], ); diff --git a/src/DNS/Adapter/Swoole.php b/src/DNS/Adapter/Swoole.php index cd6114e..636a03a 100644 --- a/src/DNS/Adapter/Swoole.php +++ b/src/DNS/Adapter/Swoole.php @@ -7,7 +7,7 @@ use Swoole\Server; use Swoole\Server\Port; use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; -use Utopia\DNS\ProxyProtocol; +use Utopia\DNS\ProxyProtocolStream; class Swoole extends Adapter { @@ -30,20 +30,16 @@ class Swoole extends Adapter /** * Per-fd TCP state for PROXY-aware streams. * - * @var array + * @var array */ protected array $tcpState = []; - /** - * @param bool $enableProxyProtocol Auto-detect a PROXY protocol (v1 or v2) preamble on each connection/datagram. Connections without a preamble are treated as direct. Only enable when the listener is reachable solely from trusted proxies — untrusted clients could forge the source address. - */ public function __construct( protected string $host = '0.0.0.0', protected int $port = 53, protected int $numWorkers = 1, protected int $maxCoroutines = 3000, protected bool $enableTcp = true, - protected bool $enableProxyProtocol = false, ) { $this->server = new Server($this->host, $this->port, SWOOLE_PROCESS, SWOOLE_SOCK_UDP); $this->server->set([ @@ -56,22 +52,6 @@ public function __construct( if ($port instanceof Port) { $this->tcpPort = $port; - - if ($this->enableProxyProtocol) { - // Disable length-prefix framing: PROXY header sits before the DNS length prefix, - // so we must buffer and parse manually. - $this->tcpPort->set([ - 'open_length_check' => false, - ]); - } else { - $this->tcpPort->set([ - 'open_length_check' => true, - 'package_length_type' => 'n', - 'package_length_offset' => 0, - 'package_body_offset' => 2, - 'package_max_length' => 65537, - ]); - } } } } @@ -98,6 +78,8 @@ public function onPacket(callable $callback): void { $this->onPacket = $callback; + $this->configureTcpListener(); + // UDP handler - enforces 512-byte limit per RFC 1035 $this->server->on('Packet', function ($server, $data, $clientInfo) { if (!is_string($data) || !is_array($clientInfo)) { @@ -110,23 +92,17 @@ public function onPacket(callable $callback): void $port = $peerPort; $payload = $data; - if ($this->enableProxyProtocol && ProxyProtocol::detect($payload)) { + if ($this->enableProxyProtocol) { try { - $header = ProxyProtocol::decode($payload); + $header = ProxyProtocolStream::unwrapDatagram($payload); } catch (ProxyDecodingException) { return; } - if ($header === null) { - return; - } - - if ($header->sourceAddress !== null && $header->sourcePort !== null) { + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { $ip = $header->sourceAddress; $port = $header->sourcePort; } - - $payload = substr($payload, $header->bytesConsumed); } $response = \call_user_func($this->onPacket, $payload, $ip, $port, 512); @@ -146,6 +122,33 @@ public function onPacket(callable $callback): void } } + /** + * When PROXY is enabled we must disable Swoole's length-check framing: + * the preamble sits before the DNS length prefix and would be misread. + * When PROXY is disabled the kernel-level length-check is a big win. + */ + protected function configureTcpListener(): void + { + if (!$this->tcpPort instanceof Port) { + return; + } + + if ($this->enableProxyProtocol) { + $this->tcpPort->set([ + 'open_length_check' => false, + ]); + return; + } + + $this->tcpPort->set([ + 'open_length_check' => true, + 'package_length_type' => 'n', + 'package_length_offset' => 0, + 'package_body_offset' => 2, + 'package_max_length' => 65537, + ]); + } + protected function registerDirectTcpHandlers(): void { $this->tcpPort?->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { @@ -180,7 +183,7 @@ protected function registerProxiedTcpHandlers(): void $this->tcpState[$fd] = [ 'buffer' => '', - 'proxied' => false, + 'stream' => new ProxyProtocolStream(), 'ip' => $ip, 'port' => $portNum, ]; @@ -198,7 +201,7 @@ protected function registerProxiedTcpHandlers(): void $this->tcpState[$fd] = [ 'buffer' => '', - 'proxied' => false, + 'stream' => new ProxyProtocolStream(), 'ip' => $ip, 'port' => $portNum, ]; @@ -212,36 +215,24 @@ protected function registerProxiedTcpHandlers(): void return; } - if (!$state['proxied']) { - $detected = ProxyProtocol::detect($state['buffer']); + $stream = $state['stream']; + + if ($stream->state() === ProxyProtocolStream::STATE_UNRESOLVED) { + try { + $resolvedState = $stream->resolve($state['buffer']); + } catch (ProxyDecodingException) { + $server->close($fd); + return; + } - // Not enough bytes to decide; wait for more. - if ($detected === null) { + if ($resolvedState === ProxyProtocolStream::STATE_UNRESOLVED) { return; } - if ($detected === 0) { - // Definitely not PROXY — treat as direct DNS on this connection. - $state['proxied'] = true; - } else { - try { - $header = ProxyProtocol::decode($state['buffer']); - } catch (ProxyDecodingException) { - $server->close($fd); - return; - } - - if ($header === null) { - return; - } - - $state['buffer'] = substr($state['buffer'], $header->bytesConsumed); - $state['proxied'] = true; - - if ($header->sourceAddress !== null && $header->sourcePort !== null) { - $state['ip'] = $header->sourceAddress; - $state['port'] = $header->sourcePort; - } + $header = $stream->header(); + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { + $state['ip'] = $header->sourceAddress; + $state['port'] = $header->sourcePort; } } diff --git a/src/DNS/ProxyProtocolStream.php b/src/DNS/ProxyProtocolStream.php new file mode 100644 index 0000000..b351fd8 --- /dev/null +++ b/src/DNS/ProxyProtocolStream.php @@ -0,0 +1,132 @@ +state !== self::STATE_UNRESOLVED) { + return $this->state; + } + + $version = ProxyProtocol::detect($buffer); + + if ($version === null) { + return self::STATE_UNRESOLVED; + } + + if ($version === 0) { + $this->state = self::STATE_DIRECT; + return $this->state; + } + + $header = ProxyProtocol::decode($buffer); + + if ($header === null) { + return self::STATE_UNRESOLVED; + } + + $this->header = $header; + $buffer = \substr($buffer, $header->bytesConsumed); + $this->state = self::STATE_PROXIED; + + return $this->state; + } + + public function state(): int + { + return $this->state; + } + + public function header(): ?ProxyProtocol + { + return $this->header; + } + + public function isResolved(): bool + { + return $this->state !== self::STATE_UNRESOLVED; + } + + public function isProxied(): bool + { + return $this->state === self::STATE_PROXIED; + } + + /** + * Strip a PROXY preamble from a single UDP datagram. + * + * Returns the parsed header when a preamble was present and consumed + * (the buffer is stripped in place). Returns null when the datagram + * does not start with a PROXY signature — callers should treat it as a + * direct datagram. Throws when the datagram starts with a signature + * but the preamble is malformed or incomplete; callers should drop the + * datagram in that case (unlike TCP, UDP has no "wait for more"). + * + * @throws DecodingException + */ + public static function unwrapDatagram(string &$buffer): ?ProxyProtocol + { + $version = ProxyProtocol::detect($buffer); + + if ($version === 0) { + return null; + } + + if ($version === null) { + throw new DecodingException('PROXY datagram is too short to classify.'); + } + + $header = ProxyProtocol::decode($buffer); + + if ($header === null) { + throw new DecodingException('PROXY datagram preamble is incomplete.'); + } + + $buffer = \substr($buffer, $header->bytesConsumed); + + return $header; + } +} diff --git a/src/DNS/Server.php b/src/DNS/Server.php index 5345baa..8521f15 100644 --- a/src/DNS/Server.php +++ b/src/DNS/Server.php @@ -67,6 +67,8 @@ class Server protected bool $debug = false; + protected bool $enableProxyProtocol = false; + /** * Telemetry metrics */ @@ -140,6 +142,22 @@ public function setDebug(bool $status): self return $this; } + /** + * Expect a PROXY protocol (v1 or v2) preamble on each UDP datagram and + * TCP connection. Traffic without a preamble is still handled as direct + * DNS, so health checks and direct clients keep working. + * + * Only enable when the listener is reachable solely from trusted + * proxies — untrusted clients can forge a PROXY preamble to spoof their + * source address. + */ + public function setProxyProtocol(bool $enabled): self + { + $this->enableProxyProtocol = $enabled; + $this->adapter->setProxyProtocol($enabled); + return $this; + } + /** * Handle Error * diff --git a/tests/unit/DNS/ProxyProtocolStreamTest.php b/tests/unit/DNS/ProxyProtocolStreamTest.php new file mode 100644 index 0000000..df42121 --- /dev/null +++ b/tests/unit/DNS/ProxyProtocolStreamTest.php @@ -0,0 +1,272 @@ +assertFalse($stream->isResolved()); + $this->assertFalse($stream->isProxied()); + $this->assertNull($stream->header()); + $this->assertSame(ProxyProtocolStream::STATE_UNRESOLVED, $stream->state()); + } + + public function testResolveWithDnsLikeBufferTransitionsToDirect(): void + { + $stream = new ProxyProtocolStream(); + $buffer = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; + $original = $buffer; + + $state = $stream->resolve($buffer); + + $this->assertSame(ProxyProtocolStream::STATE_DIRECT, $state); + $this->assertTrue($stream->isResolved()); + $this->assertFalse($stream->isProxied()); + $this->assertNull($stream->header()); + $this->assertSame($original, $buffer, 'DIRECT state must not modify buffer'); + } + + public function testResolveWithCompleteV1PreambleConsumesAndResolves(): void + { + $stream = new ProxyProtocolStream(); + $preamble = "PROXY TCP4 1.2.3.4 5.6.7.8 111 222\r\n"; + $payload = "\x00\x0aHELLODNS!"; + $buffer = $preamble . $payload; + + $state = $stream->resolve($buffer); + + $this->assertSame(ProxyProtocolStream::STATE_PROXIED, $state); + $this->assertTrue($stream->isResolved()); + $this->assertTrue($stream->isProxied()); + $this->assertSame($payload, $buffer, 'preamble bytes should be stripped from buffer'); + + $header = $stream->header(); + $this->assertNotNull($header); + $this->assertSame('1.2.3.4', $header->sourceAddress); + $this->assertSame(111, $header->sourcePort); + } + + public function testResolveWithCompleteV2PreambleConsumesAndResolves(): void + { + $stream = new ProxyProtocolStream(); + $addrPayload = inet_pton('10.0.0.1') . inet_pton('10.0.0.2') . pack('nn', 5000, 53); + $preamble = ProxyProtocol::V2_SIGNATURE + . chr(0x21) + . chr(0x11) + . pack('n', strlen($addrPayload)) + . $addrPayload; + $payload = "REMAINING"; + $buffer = $preamble . $payload; + + $state = $stream->resolve($buffer); + + $this->assertSame(ProxyProtocolStream::STATE_PROXIED, $state); + $this->assertSame($payload, $buffer); + $this->assertNotNull($stream->header()); + $this->assertSame('10.0.0.1', $stream->header()->sourceAddress); + } + + public function testResolveWithPartialPreambleStaysUnresolved(): void + { + $stream = new ProxyProtocolStream(); + $buffer = 'PROXY TCP4 1.2.3.4 5.6.7.8'; + + $state = $stream->resolve($buffer); + + $this->assertSame(ProxyProtocolStream::STATE_UNRESOLVED, $state); + $this->assertFalse($stream->isResolved()); + $this->assertSame('PROXY TCP4 1.2.3.4 5.6.7.8', $buffer, 'unresolved must not modify buffer'); + } + + public function testResolveIsIdempotentAfterDirectResolution(): void + { + $stream = new ProxyProtocolStream(); + $buffer = "\x00\x01\x00\x00"; + $stream->resolve($buffer); + + // Subsequent calls with different buffers should return the cached state. + $newBuffer = 'anything'; + $state = $stream->resolve($newBuffer); + $this->assertSame(ProxyProtocolStream::STATE_DIRECT, $state); + $this->assertSame('anything', $newBuffer); + } + + public function testResolveIsIdempotentAfterProxiedResolution(): void + { + $stream = new ProxyProtocolStream(); + $buffer = "PROXY TCP4 1.2.3.4 5.6.7.8 1 2\r\nHELLO"; + $stream->resolve($buffer); + $this->assertSame('HELLO', $buffer); + + // A second call must not strip more bytes. + $state = $stream->resolve($buffer); + $this->assertSame(ProxyProtocolStream::STATE_PROXIED, $state); + $this->assertSame('HELLO', $buffer); + } + + public function testResolveChunkedV1AcrossManyCalls(): void + { + $full = "PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n"; + $trailing = "\x00\x04TAIL"; + + $stream = new ProxyProtocolStream(); + $buffer = ''; + + for ($i = 0; $i < strlen($full) - 1; $i++) { + $buffer .= $full[$i]; + $this->assertSame( + ProxyProtocolStream::STATE_UNRESOLVED, + $stream->resolve($buffer), + "Expected unresolved at byte {$i}" + ); + } + + // Deliver the last preamble byte plus the trailing data in one chunk. + $buffer .= $full[strlen($full) - 1] . $trailing; + $state = $stream->resolve($buffer); + + $this->assertSame(ProxyProtocolStream::STATE_PROXIED, $state); + $this->assertSame($trailing, $buffer); + $this->assertSame('192.168.1.1', $stream->header()?->sourceAddress); + } + + public function testResolveChunkedV2AcrossManyCalls(): void + { + $payload = inet_pton('1.2.3.4') . inet_pton('5.6.7.8') . pack('nn', 1, 2); + $full = ProxyProtocol::V2_SIGNATURE + . chr(0x21) + . chr(0x11) + . pack('n', strlen($payload)) + . $payload; + + $stream = new ProxyProtocolStream(); + $buffer = ''; + + for ($i = 0; $i < strlen($full) - 1; $i++) { + $buffer .= $full[$i]; + $this->assertSame( + ProxyProtocolStream::STATE_UNRESOLVED, + $stream->resolve($buffer), + "Expected unresolved at byte {$i}" + ); + } + + $buffer .= $full[strlen($full) - 1]; + $this->assertSame(ProxyProtocolStream::STATE_PROXIED, $stream->resolve($buffer)); + $this->assertSame('', $buffer); + } + + public function testResolveThrowsOnMalformedPreamble(): void + { + $stream = new ProxyProtocolStream(); + $buffer = "PROXY TCP4 not-an-ip 5.6.7.8 1 2\r\n"; + + $this->expectException(DecodingException::class); + $stream->resolve($buffer); + } + + public function testResolveThrowOnMalformedLeavesStreamUnresolved(): void + { + $stream = new ProxyProtocolStream(); + $buffer = "PROXY TCP4 not-an-ip 5.6.7.8 1 2\r\n"; + + try { + $stream->resolve($buffer); + $this->fail('Expected DecodingException'); + } catch (DecodingException) { + // Expected. + } + + // Stream stays unresolved so callers can close the connection. + $this->assertFalse($stream->isResolved()); + } + + // --------------------------------------------------------------------- + // unwrapDatagram() (stateless UDP use) + // --------------------------------------------------------------------- + + public function testUnwrapDatagramReturnsNullForDirectDns(): void + { + $buffer = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; + $original = $buffer; + + $header = ProxyProtocolStream::unwrapDatagram($buffer); + + $this->assertNull($header); + $this->assertSame($original, $buffer, 'direct datagram must not be modified'); + } + + public function testUnwrapDatagramStripsCompleteV1Preamble(): void + { + $preamble = "PROXY TCP4 1.2.3.4 5.6.7.8 1 2\r\n"; + $payload = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; + $buffer = $preamble . $payload; + + $header = ProxyProtocolStream::unwrapDatagram($buffer); + + $this->assertNotNull($header); + $this->assertSame('1.2.3.4', $header->sourceAddress); + $this->assertSame($payload, $buffer); + } + + public function testUnwrapDatagramStripsCompleteV2Preamble(): void + { + $addrPayload = inet_pton('10.0.0.1') . inet_pton('10.0.0.2') . pack('nn', 53, 5353); + $preamble = ProxyProtocol::V2_SIGNATURE + . chr(0x21) + . chr(0x12) // UDP4 + . pack('n', strlen($addrPayload)) + . $addrPayload; + $payload = 'DNSDATA'; + $buffer = $preamble . $payload; + + $header = ProxyProtocolStream::unwrapDatagram($buffer); + + $this->assertNotNull($header); + $this->assertSame(ProxyProtocol::FAMILY_UDP4, $header->family); + $this->assertSame($payload, $buffer); + } + + public function testUnwrapDatagramThrowsOnIncompleteDatagram(): void + { + // Datagram begins with 'P' but is too short to be a complete PROXY signature. + $buffer = 'PRO'; + + $this->expectException(DecodingException::class); + ProxyProtocolStream::unwrapDatagram($buffer); + } + + public function testUnwrapDatagramThrowsOnIncompleteV2Datagram(): void + { + // Datagram starts with v2 signature but payload is truncated. + $addrPayload = inet_pton('1.2.3.4') . inet_pton('5.6.7.8') . pack('nn', 1, 2); + $preamble = ProxyProtocol::V2_SIGNATURE + . chr(0x21) + . chr(0x11) + . pack('n', strlen($addrPayload)) + . substr($addrPayload, 0, -2); // chop off part of the ports + + $this->expectException(DecodingException::class); + ProxyProtocolStream::unwrapDatagram($preamble); + } + + public function testUnwrapDatagramThrowsOnMalformedPreamble(): void + { + $buffer = "PROXY TCP4 not-an-ip 5.6.7.8 1 2\r\nDNSDATA"; + + $this->expectException(DecodingException::class); + ProxyProtocolStream::unwrapDatagram($buffer); + } +} From f1c662afec5a3806dcfcd0ce8761423ade00a49a Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:47:34 +0100 Subject: [PATCH 4/6] Move TCP framing and PROXY parsing into Server; adapters become byte 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) --- src/DNS/Adapter.php | 78 +++++-- src/DNS/Adapter/Native.php | 378 +++++++++++------------------- src/DNS/Adapter/Swoole.php | 228 ++++--------------- src/DNS/Server.php | 175 +++++++++++--- tests/unit/DNS/ServerTest.php | 417 ++++++++++++++++++++++++++++++++++ 5 files changed, 783 insertions(+), 493 deletions(-) create mode 100644 tests/unit/DNS/ServerTest.php diff --git a/src/DNS/Adapter.php b/src/DNS/Adapter.php index c9e1ebb..1598227 100644 --- a/src/DNS/Adapter.php +++ b/src/DNS/Adapter.php @@ -2,24 +2,25 @@ namespace Utopia\DNS; +/** + * Transport adapter contract. + * + * Adapters are responsible for the wire — receiving bytes from UDP + * datagrams and TCP connections, and sending bytes back. All DNS-level + * concerns (length-prefix framing, PROXY protocol, response generation) + * live in {@see Server}. + */ abstract class Adapter { /** - * Whether the adapter should treat incoming traffic as potentially - * prefixed with a PROXY protocol (v1/v2) preamble. Configured via - * {@see Server::setProxyProtocol()} (or directly via - * {@see setProxyProtocol()}). + * Whether incoming traffic may be prefixed with a PROXY protocol + * preamble. The adapter itself does not parse PROXY — this flag just + * informs the adapter about how to configure its transport (e.g. + * Swoole's kernel-level length-check framing is incompatible with a + * PROXY preamble and must be disabled when this is true). */ protected bool $enableProxyProtocol = false; - /** - * Toggle PROXY protocol awareness. - * - * Enabling this makes the adapter look for a PROXY preamble at the - * start of every UDP datagram and TCP connection; traffic without a - * preamble is still handled as direct DNS so health checks and direct - * clients keep working. - */ public function setProxyProtocol(bool $enabled): void { $this->enableProxyProtocol = $enabled; @@ -31,7 +32,7 @@ public function hasProxyProtocol(): bool } /** - * Worker start + * Worker start callback. Invoked once per worker. * * @param callable(int $workerId): void $callback * @phpstan-param callable(int $workerId): void $callback @@ -39,22 +40,59 @@ public function hasProxyProtocol(): bool abstract public function onWorkerStart(callable $callback): void; /** - * Packet handler + * Register the UDP datagram handler. + * + * The callback is invoked with the full datagram payload and the + * transport peer's IP/port. It returns the bytes to send back, or an + * empty string to send nothing. * * @param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string $callback - * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize):string $callback + * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string $callback */ - abstract public function onPacket(callable $callback): void; + abstract public function onUdpPacket(callable $callback): void; /** - * Start the DNS server + * Register the TCP receive handler. + * + * The callback is invoked with a connection identifier, a chunk of + * freshly-received bytes, and the transport peer's IP/port. The chunk + * may contain partial or multiple DNS messages; framing is the + * handler's responsibility. Responses are sent via {@see sendTcp()}, + * not via a return value. + * + * @param callable(int $fd, string $bytes, string $ip, int $port): void $callback + * @phpstan-param callable(int $fd, string $bytes, string $ip, int $port): void $callback */ - abstract public function start(): void; + abstract public function onTcpReceive(callable $callback): void; /** - * Get the name of the adapter + * Register a TCP close handler so listeners can drop per-connection + * state. Called once per connection identifier after the underlying + * socket has been closed (by either end). * - * @return string + * @param callable(int $fd): void $callback + * @phpstan-param callable(int $fd): void $callback + */ + abstract public function onTcpClose(callable $callback): void; + + /** + * Send bytes on a TCP connection identified by $fd. Silently no-ops if + * the connection is no longer open. + */ + abstract public function sendTcp(int $fd, string $data): void; + + /** + * Forcefully close a TCP connection identified by $fd. + */ + abstract public function closeTcp(int $fd): void; + + /** + * Start the DNS server. + */ + abstract public function start(): void; + + /** + * Get the name of the adapter. */ abstract public function getName(): string; } diff --git a/src/DNS/Adapter/Native.php b/src/DNS/Adapter/Native.php index 7b75e9f..20a01dc 100644 --- a/src/DNS/Adapter/Native.php +++ b/src/DNS/Adapter/Native.php @@ -5,49 +5,36 @@ use Exception; use Socket; use Utopia\DNS\Adapter; -use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; -use Utopia\DNS\ProxyProtocolStream; class Native extends Adapter { - /** - * Maximum DNS TCP message size per RFC 1035 Section 4.2.2 - * TCP uses 2-byte length prefix, so max payload is 65535 bytes - */ - public const int MAX_TCP_MESSAGE_SIZE = 65535; - protected Socket $udpServer; protected ?Socket $tcpServer = null; - /** @var array */ + /** @var array Active TCP client sockets, keyed by fd id. */ protected array $tcpClients = []; - /** @var array */ - protected array $tcpBuffers = []; - - /** @var array Track last activity time per TCP client for idle timeout */ + /** @var array Last activity timestamp per TCP client for idle timeout. */ protected array $tcpLastActivity = []; - /** @var array PROXY preamble resolver per TCP client */ - protected array $tcpProxyStreams = []; + /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ + protected mixed $onUdpPacket; - /** @var array Real client address (PROXY-aware) per TCP client */ - protected array $tcpClientAddress = []; + /** @var callable(int $fd, string $bytes, string $ip, int $port): void */ + protected mixed $onTcpReceive; - /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ - protected mixed $onPacket; + /** @var callable(int $fd): void */ + protected mixed $onTcpClose; /** @var list */ - protected array $onWorkerStart = []; + protected array $onWorkerStartCallbacks = []; /** * @param string $host Host to bind to * @param int $port Port to listen on * @param bool $enableTcp Enable TCP support (RFC 5966) * @param int $maxTcpClients Maximum concurrent TCP clients - * @param int $maxTcpBufferSize Maximum buffer size per TCP client - * @param int $maxTcpFrameSize Maximum DNS message size over TCP * @param int $tcpIdleTimeout Seconds before idle TCP connections are closed (RFC 7766) */ public function __construct( @@ -55,11 +42,8 @@ public function __construct( protected int $port = 8053, protected bool $enableTcp = true, protected int $maxTcpClients = 100, - protected int $maxTcpBufferSize = 16384, - protected int $maxTcpFrameSize = 65535, protected int $tcpIdleTimeout = 30, ) { - $server = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); if (!$server) { throw new Exception('Could not start server.'); @@ -77,29 +61,64 @@ public function __construct( } } - /** - * Worker start callback - * - * @param callable(int $workerId): void $callback - * @phpstan-param callable(int $workerId): void $callback - */ public function onWorkerStart(callable $callback): void { - $this->onWorkerStart[] = $callback; + $this->onWorkerStartCallbacks[] = $callback; } - /** - * @param callable $callback - * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize):string $callback - */ - public function onPacket(callable $callback): void + public function onUdpPacket(callable $callback): void { - $this->onPacket = $callback; + $this->onUdpPacket = $callback; + } + + public function onTcpReceive(callable $callback): void + { + $this->onTcpReceive = $callback; + } + + public function onTcpClose(callable $callback): void + { + $this->onTcpClose = $callback; + } + + public function sendTcp(int $fd, string $data): void + { + $client = $this->tcpClients[$fd] ?? null; + if ($client === null) { + return; + } + + $total = strlen($data); + $sent = 0; + + while ($sent < $total) { + $written = @socket_write($client, substr($data, $sent)); + + if ($written === false) { + $error = socket_last_error($client); + + if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { + socket_clear_error($client); + usleep(1000); + continue; + } + + $this->closeTcpInternal($client); + return; + } + + $sent += $written; + } + } + + public function closeTcp(int $fd): void + { + $client = $this->tcpClients[$fd] ?? null; + if ($client !== null) { + $this->closeTcpInternal($client); + } } - /** - * Start the DNS server - */ public function start(): void { if (socket_bind($this->udpServer, $this->host, $this->port) == false) { @@ -118,7 +137,7 @@ public function start(): void socket_set_nonblock($this->tcpServer); } - foreach ($this->onWorkerStart as $callback) { + foreach ($this->onWorkerStartCallbacks as $callback) { \call_user_func($callback, 0); } @@ -140,7 +159,6 @@ public function start(): void $write = []; $except = []; - // Use 1 second timeout for socket_select to periodically check idle connections $changed = socket_select($readSockets, $write, $except, 1); if ($changed === false || $changed === 0) { @@ -149,271 +167,133 @@ public function start(): void foreach ($readSockets as $socket) { if ($socket === $this->udpServer) { - $buf = ''; - $ip = ''; - $port = 0; - $len = socket_recvfrom($this->udpServer, $buf, 1024 * 4, 0, $ip, $port); - - if ($len > 0 && is_string($buf) && is_string($ip) && is_int($port)) { - // Reply goes back to the actual UDP peer (the proxy), not the parsed client. - $replyIp = $ip; - $replyPort = $port; - - if ($this->enableProxyProtocol) { - try { - $header = ProxyProtocolStream::unwrapDatagram($buf); - } catch (ProxyDecodingException) { - continue; - } - - if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { - $ip = $header->sourceAddress; - $port = $header->sourcePort; - } - } - - $answer = call_user_func($this->onPacket, $buf, $ip, $port, 512); - - if ($answer !== '') { - socket_sendto($this->udpServer, $answer, strlen($answer), 0, $replyIp, $replyPort); - } - } - + $this->handleUdp(); continue; } if ($this->tcpServer !== null && $socket === $this->tcpServer) { - $client = @socket_accept($this->tcpServer); - - if ($client instanceof Socket) { - if (count($this->tcpClients) >= $this->maxTcpClients) { - @socket_close($client); - continue; - } - - if (@socket_set_nonblock($client) === false) { - @socket_close($client); - continue; - } - - socket_set_option($client, SOL_SOCKET, SO_KEEPALIVE, 1); - socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 5, 'usec' => 0]); - socket_set_option($client, SOL_SOCKET, SO_SNDTIMEO, ['sec' => 5, 'usec' => 0]); - - $id = spl_object_id($client); - $this->tcpClients[$id] = $client; - $this->tcpBuffers[$id] = ''; - $this->tcpLastActivity[$id] = time(); - - if ($this->enableProxyProtocol) { - $this->tcpProxyStreams[$id] = new ProxyProtocolStream(); - } - - $peerIp = ''; - $peerPort = 0; - socket_getpeername($client, $peerIp, $peerPort); - $this->tcpClientAddress[$id] = [ - 'ip' => is_string($peerIp) ? $peerIp : '', - 'port' => is_int($peerPort) ? $peerPort : 0, - ]; - } - + $this->acceptTcp(); continue; } - // Remaining readable sockets are TCP clients. - $this->handleTcpClient($socket); + $this->readTcp($socket); } } } - /** - * Get the name of the adapter - * - * @return string - */ public function getName(): string { return 'native'; } - protected function handleTcpClient(Socket $client): void + protected function handleUdp(): void { - $clientId = spl_object_id($client); - - $chunk = @socket_read($client, 8192, PHP_BINARY_READ); - - if ($chunk === '' || $chunk === false) { - $error = socket_last_error($client); - - if ($chunk === '' || !in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { - $this->closeTcpClient($client); - } + $buf = ''; + $ip = ''; + $port = 0; + $len = socket_recvfrom($this->udpServer, $buf, 1024 * 4, 0, $ip, $port); + if ($len === false || $len <= 0 || !is_string($buf) || !is_string($ip) || !is_int($port)) { return; } - // Update activity timestamp for idle timeout tracking - $this->tcpLastActivity[$clientId] = time(); + $response = \call_user_func($this->onUdpPacket, $buf, $ip, $port, 512); - $currentBufferSize = strlen($this->tcpBuffers[$clientId] ?? ''); - $chunkSize = strlen($chunk); + if ($response !== '') { + socket_sendto($this->udpServer, $response, strlen($response), 0, $ip, $port); + } + } - if ($currentBufferSize + $chunkSize > $this->maxTcpBufferSize) { - printf("TCP buffer size limit exceeded for client %d\n", $clientId); - $this->closeTcpClient($client); + protected function acceptTcp(): void + { + if ($this->tcpServer === null) { return; } - $this->tcpBuffers[$clientId] = ($this->tcpBuffers[$clientId] ?? '') . $chunk; - - $stream = $this->tcpProxyStreams[$clientId] ?? null; + $client = @socket_accept($this->tcpServer); - if ($stream !== null && $stream->state() === ProxyProtocolStream::STATE_UNRESOLVED) { - try { - $state = $stream->resolve($this->tcpBuffers[$clientId]); - } catch (ProxyDecodingException) { - $this->closeTcpClient($client); - return; - } - - if ($state === ProxyProtocolStream::STATE_UNRESOLVED) { - return; - } - - $header = $stream->header(); - if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { - $this->tcpClientAddress[$clientId] = [ - 'ip' => $header->sourceAddress, - 'port' => $header->sourcePort, - ]; - } + if (!$client instanceof Socket) { + return; } - while (strlen($this->tcpBuffers[$clientId]) >= 2) { - $unpacked = unpack('n', substr($this->tcpBuffers[$clientId], 0, 2)); - $payloadLength = (is_array($unpacked) && array_key_exists(1, $unpacked) && is_int($unpacked[1])) ? $unpacked[1] : 0; - - // Close connection for invalid zero-length payloads - if ($payloadLength === 0) { - $this->closeTcpClient($client); - return; - } + if (count($this->tcpClients) >= $this->maxTcpClients) { + @socket_close($client); + return; + } - // DNS TCP messages have a 2-byte length prefix (max 65535), but we enforce - // a stricter limit to prevent memory exhaustion from malicious clients - if ($payloadLength > $this->maxTcpFrameSize) { - printf("Invalid TCP frame size %d for client %d\n", $payloadLength, $clientId); - $this->closeTcpClient($client); - return; - } + if (@socket_set_nonblock($client) === false) { + @socket_close($client); + return; + } - if (strlen($this->tcpBuffers[$clientId]) < ($payloadLength + 2)) { - return; - } + socket_set_option($client, SOL_SOCKET, SO_KEEPALIVE, 1); + socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 5, 'usec' => 0]); + socket_set_option($client, SOL_SOCKET, SO_SNDTIMEO, ['sec' => 5, 'usec' => 0]); - $message = substr($this->tcpBuffers[$clientId], 2, $payloadLength); - $this->tcpBuffers[$clientId] = substr($this->tcpBuffers[$clientId], $payloadLength + 2); + $fd = spl_object_id($client); + $this->tcpClients[$fd] = $client; + $this->tcpLastActivity[$fd] = time(); + } - $address = $this->tcpClientAddress[$clientId] ?? null; + protected function readTcp(Socket $client): void + { + $fd = spl_object_id($client); - if ($address === null) { - $ip = ''; - $port = 0; - socket_getpeername($client, $ip, $port); - $address = [ - 'ip' => is_string($ip) ? $ip : '', - 'port' => is_int($port) ? $port : 0, - ]; - } + $chunk = @socket_read($client, 8192, PHP_BINARY_READ); - $answer = call_user_func($this->onPacket, $message, $address['ip'], $address['port'], self::MAX_TCP_MESSAGE_SIZE); + if ($chunk === '' || $chunk === false) { + $error = socket_last_error($client); - if ($answer !== '') { - $this->sendTcpResponse($client, $answer); + if ($chunk === '' || !in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { + $this->closeTcpInternal($client); } - } - } - /** - * Send a TCP DNS response with length prefix. - * - * Per RFC 1035 Section 4.2.2, TCP messages use a 2-byte length prefix. - * This limits maximum message size to 65535 bytes. Oversized responses - * are rejected to prevent silent data corruption from integer overflow. - */ - protected function sendTcpResponse(Socket $client, string $payload): void - { - $payloadLength = strlen($payload); - - // RFC 1035: TCP uses 2-byte length prefix, max 65535 bytes - if ($payloadLength > self::MAX_TCP_MESSAGE_SIZE) { - // This should not happen if truncation is working correctly - // Log and close connection rather than send corrupted data - printf( - "TCP response too large (%d bytes > %d max), dropping\n", - $payloadLength, - self::MAX_TCP_MESSAGE_SIZE - ); - $this->closeTcpClient($client); return; } - $frame = pack('n', $payloadLength) . $payload; - $total = strlen($frame); - $sent = 0; + $this->tcpLastActivity[$fd] = time(); - while ($sent < $total) { - $written = @socket_write($client, substr($frame, $sent)); + $ip = ''; + $port = 0; + socket_getpeername($client, $ip, $port); - if ($written === false) { - $error = socket_last_error($client); - - if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { - socket_clear_error($client); - usleep(1000); - continue; - } - - $this->closeTcpClient($client); - return; - } - - $sent += $written; - } + \call_user_func( + $this->onTcpReceive, + $fd, + $chunk, + is_string($ip) ? $ip : '', + is_int($port) ? $port : 0, + ); } /** - * Close idle TCP connections per RFC 7766 Section 6.2.3 - * - * Servers should close idle connections to free resources. - * This prevents resource exhaustion from slow or abandoned clients. + * Close idle TCP connections per RFC 7766 Section 6.2.3. */ protected function closeIdleTcpClients(): void { $now = time(); - foreach ($this->tcpClients as $id => $client) { - $lastActivity = $this->tcpLastActivity[$id] ?? 0; + foreach ($this->tcpClients as $fd => $client) { + $lastActivity = $this->tcpLastActivity[$fd] ?? 0; if (($now - $lastActivity) > $this->tcpIdleTimeout) { - $this->closeTcpClient($client); + $this->closeTcpInternal($client); } } } - protected function closeTcpClient(Socket $client): void + protected function closeTcpInternal(Socket $client): void { - $id = spl_object_id($client); - - unset( - $this->tcpClients[$id], - $this->tcpBuffers[$id], - $this->tcpLastActivity[$id], - $this->tcpProxyStreams[$id], - $this->tcpClientAddress[$id], - ); + $fd = spl_object_id($client); + + if (!isset($this->tcpClients[$fd])) { + return; + } + + unset($this->tcpClients[$fd], $this->tcpLastActivity[$fd]); @socket_close($client); + + \call_user_func($this->onTcpClose, $fd); } } diff --git a/src/DNS/Adapter/Swoole.php b/src/DNS/Adapter/Swoole.php index 636a03a..aa92913 100644 --- a/src/DNS/Adapter/Swoole.php +++ b/src/DNS/Adapter/Swoole.php @@ -6,33 +6,21 @@ use Utopia\DNS\Adapter; use Swoole\Server; use Swoole\Server\Port; -use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; -use Utopia\DNS\ProxyProtocolStream; class Swoole extends Adapter { - /** - * Maximum DNS TCP message size per RFC 1035 Section 4.2.2 - * TCP uses 2-byte length prefix, so max payload is 65535 bytes - */ - public const int MAX_TCP_MESSAGE_SIZE = 65535; - - /** Hard cap when PROXY protocol is enabled, before the DNS length prefix can be validated. */ - public const int MAX_TCP_BUFFER_SIZE = 131072; - protected Server $server; protected ?Port $tcpPort = null; /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ - protected mixed $onPacket; + protected mixed $onUdpPacket; - /** - * Per-fd TCP state for PROXY-aware streams. - * - * @var array - */ - protected array $tcpState = []; + /** @var callable(int $fd, string $bytes, string $ip, int $port): void */ + protected mixed $onTcpReceive; + + /** @var callable(int $fd): void */ + protected mixed $onTcpClose; public function __construct( protected string $host = '0.0.0.0', @@ -52,15 +40,16 @@ public function __construct( if ($port instanceof Port) { $this->tcpPort = $port; + // TCP framing and PROXY parsing live in Server, so Swoole's + // kernel-level length-check is not applicable here. The + // cost (userland buffering) is negligible for DNS loads. + $this->tcpPort->set([ + 'open_length_check' => false, + ]); } } } - /** - * Worker start callback - * - * @param callable(int $workerId): void $callback - */ public function onWorkerStart(callable $callback): void { $this->server->on('WorkerStart', function ($server, $workerId) use ($callback) { @@ -70,211 +59,72 @@ public function onWorkerStart(callable $callback): void }); } - /** - * @param callable $callback - * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize):string $callback - */ - public function onPacket(callable $callback): void + public function onUdpPacket(callable $callback): void { - $this->onPacket = $callback; - - $this->configureTcpListener(); + $this->onUdpPacket = $callback; - // UDP handler - enforces 512-byte limit per RFC 1035 $this->server->on('Packet', function ($server, $data, $clientInfo) { if (!is_string($data) || !is_array($clientInfo)) { return; } - $peerIp = is_string($clientInfo['address'] ?? null) ? $clientInfo['address'] : ''; - $peerPort = is_int($clientInfo['port'] ?? null) ? $clientInfo['port'] : 0; - $ip = $peerIp; - $port = $peerPort; - $payload = $data; - - if ($this->enableProxyProtocol) { - try { - $header = ProxyProtocolStream::unwrapDatagram($payload); - } catch (ProxyDecodingException) { - return; - } - - if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { - $ip = $header->sourceAddress; - $port = $header->sourcePort; - } - } + $ip = is_string($clientInfo['address'] ?? null) ? $clientInfo['address'] : ''; + $port = is_int($clientInfo['port'] ?? null) ? $clientInfo['port'] : 0; - $response = \call_user_func($this->onPacket, $payload, $ip, $port, 512); + $response = \call_user_func($this->onUdpPacket, $data, $ip, $port, 512); if ($response !== '' && $server instanceof Server) { - // Reply goes back to the actual UDP peer (the proxy), not the parsed client. - $server->sendto($peerIp, $peerPort, $response); + $server->sendto($ip, $port, $response); } }); - - if ($this->tcpPort instanceof Port) { - if ($this->enableProxyProtocol) { - $this->registerProxiedTcpHandlers(); - } else { - $this->registerDirectTcpHandlers(); - } - } } - /** - * When PROXY is enabled we must disable Swoole's length-check framing: - * the preamble sits before the DNS length prefix and would be misread. - * When PROXY is disabled the kernel-level length-check is a big win. - */ - protected function configureTcpListener(): void + public function onTcpReceive(callable $callback): void { - if (!$this->tcpPort instanceof Port) { - return; - } + $this->onTcpReceive = $callback; - if ($this->enableProxyProtocol) { - $this->tcpPort->set([ - 'open_length_check' => false, - ]); + if (!$this->tcpPort instanceof Port) { return; } - $this->tcpPort->set([ - 'open_length_check' => true, - 'package_length_type' => 'n', - 'package_length_offset' => 0, - 'package_body_offset' => 2, - 'package_max_length' => 65537, - ]); - } - - protected function registerDirectTcpHandlers(): void - { - $this->tcpPort?->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { + $this->tcpPort->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { $info = $server->getClientInfo($fd, $reactorId); - if (!is_array($info)) { - return; - } - - $payload = substr($data, 2); // strip 2-byte length prefix - $ip = is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; - $port = is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; - - $response = \call_user_func($this->onPacket, $payload, $ip, $port, self::MAX_TCP_MESSAGE_SIZE); + $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; + $port = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; - if ($response !== '') { - $server->send($fd, pack('n', strlen($response)) . $response); - } + \call_user_func($this->onTcpReceive, $fd, $data, $ip, $port); }); } - protected function registerProxiedTcpHandlers(): void + public function onTcpClose(callable $callback): void { - $port = $this->tcpPort; - if (!$port instanceof Port) { + $this->onTcpClose = $callback; + + if (!$this->tcpPort instanceof Port) { return; } - $port->on('Connect', function (Server $server, int $fd) { - $info = $server->getClientInfo($fd); - $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; - $portNum = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; - - $this->tcpState[$fd] = [ - 'buffer' => '', - 'stream' => new ProxyProtocolStream(), - 'ip' => $ip, - 'port' => $portNum, - ]; - }); - - $port->on('Close', function (Server $server, int $fd) { - unset($this->tcpState[$fd]); + $this->tcpPort->on('Close', function (Server $server, int $fd) { + \call_user_func($this->onTcpClose, $fd); }); + } - $port->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { - if (!isset($this->tcpState[$fd])) { - $info = $server->getClientInfo($fd, $reactorId); - $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; - $portNum = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; - - $this->tcpState[$fd] = [ - 'buffer' => '', - 'stream' => new ProxyProtocolStream(), - 'ip' => $ip, - 'port' => $portNum, - ]; - } - - $state = &$this->tcpState[$fd]; - $state['buffer'] .= $data; - - if (strlen($state['buffer']) > self::MAX_TCP_BUFFER_SIZE) { - $server->close($fd); - return; - } - - $stream = $state['stream']; - - if ($stream->state() === ProxyProtocolStream::STATE_UNRESOLVED) { - try { - $resolvedState = $stream->resolve($state['buffer']); - } catch (ProxyDecodingException) { - $server->close($fd); - return; - } - - if ($resolvedState === ProxyProtocolStream::STATE_UNRESOLVED) { - return; - } - - $header = $stream->header(); - if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { - $state['ip'] = $header->sourceAddress; - $state['port'] = $header->sourcePort; - } - } - - while (strlen($state['buffer']) >= 2) { - $unpacked = unpack('n', substr($state['buffer'], 0, 2)); - $payloadLength = (is_array($unpacked) && array_key_exists(1, $unpacked) && is_int($unpacked[1])) ? $unpacked[1] : 0; - - if ($payloadLength === 0 || $payloadLength > self::MAX_TCP_MESSAGE_SIZE) { - $server->close($fd); - return; - } - - if (strlen($state['buffer']) < $payloadLength + 2) { - return; - } - - $message = substr($state['buffer'], 2, $payloadLength); - $state['buffer'] = substr($state['buffer'], $payloadLength + 2); - - $response = \call_user_func($this->onPacket, $message, $state['ip'], $state['port'], self::MAX_TCP_MESSAGE_SIZE); + public function sendTcp(int $fd, string $data): void + { + $this->server->send($fd, $data); + } - if ($response !== '') { - $server->send($fd, pack('n', strlen($response)) . $response); - } - } - }); + public function closeTcp(int $fd): void + { + $this->server->close($fd); } - /** - * Start the DNS server - */ public function start(): void { Runtime::enableCoroutine(); $this->server->start(); } - /** - * Get the name of the adapter - * - * @return string - */ public function getName(): string { return 'swoole'; diff --git a/src/DNS/Server.php b/src/DNS/Server.php index 8521f15..6c58681 100644 --- a/src/DNS/Server.php +++ b/src/DNS/Server.php @@ -4,6 +4,7 @@ use Throwable; use Utopia\DNS\Exception\Message\PartialDecodingException; +use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; use Utopia\Span\Span; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; @@ -59,6 +60,20 @@ class Server { + /** RFC 1035: UDP replies are capped at 512 bytes (EDNS0 aside). */ + public const int UDP_MAX_RESPONSE_SIZE = 512; + + /** RFC 1035: TCP frames use a 2-byte length prefix, so max 65535 bytes. */ + public const int TCP_MAX_MESSAGE_SIZE = 65535; + + /** + * Hard cap on per-connection TCP buffer size. Prevents slow-loris style + * attacks from consuming unbounded memory while a preamble or frame is + * being accumulated. Must fit at least one max-sized DNS frame plus a + * PROXY v1 preamble (107 bytes). + */ + public const int TCP_MAX_BUFFER_SIZE = 131072; + protected Adapter $adapter; protected Resolver $resolver; @@ -69,9 +84,15 @@ class Server protected bool $enableProxyProtocol = false; - /** - * Telemetry metrics - */ + /** @var array Per-fd TCP receive buffer. */ + protected array $tcpBuffers = []; + + /** @var array Per-fd PROXY preamble resolver (only if enabled). */ + protected array $tcpProxyStreams = []; + + /** @var array Per-fd effective client address (peer or PROXY-resolved). */ + protected array $tcpAddresses = []; + protected ?Histogram $duration = null; protected ?Counter $queriesTotal = null; protected ?Counter $responsesTotal = null; @@ -83,11 +104,6 @@ public function __construct(Adapter $adapter, Resolver $resolver) $this->setTelemetry(new NoTelemetry()); } - /** - * Set telemetry adapter - * - * @param Telemetry $telemetry - */ public function setTelemetry(Telemetry $telemetry): void { $this->duration = $telemetry->createHistogram( @@ -97,7 +113,6 @@ public function setTelemetry(Telemetry $telemetry): void ['ExplicitBucketBoundaries' => [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1]] ); - // Initialize additional telemetry metrics $this->queriesTotal = $telemetry->createCounter('dns.queries.total'); $this->responsesTotal = $telemetry->createCounter('dns.responses.total'); } @@ -130,12 +145,6 @@ public function onWorkerStart(callable $handler): self return $this; } - /** - * Set Debug Mode - * - * @param bool $status - * @return self - */ public function setDebug(bool $status): self { $this->debug = $status; @@ -158,12 +167,6 @@ public function setProxyProtocol(bool $enabled): self return $this; } - /** - * Handle Error - * - * @param Throwable $error - * @return void - */ protected function handleError(Throwable $error): void { foreach ($this->errors as $handler) { @@ -172,14 +175,7 @@ protected function handleError(Throwable $error): void } /** - * Handle packet - * - * @param string $buffer - * @param string $ip - * @param int $port - * @param int|null $maxResponseSize - * - * @return string + * Handle a complete DNS message. */ protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string { @@ -190,7 +186,6 @@ protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResp $response = null; try { - // 1. Parse Message. $decodeStart = microtime(true); try { $query = Message::decode($buffer); @@ -214,7 +209,6 @@ protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResp $span->set('dns.duration.decode', $decodeDuration); // RFC 1035: Only OPCODE 0 (QUERY) is supported - // Return NOTIMP for other opcodes (IQUERY=1 is obsolete, STATUS=2, others reserved) if ($query->header->opcode !== 0) { $response = Message::response( $query->header, @@ -242,7 +236,6 @@ protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResp 'type' => $question->type ?? null, ]); - // 2. Resolve query $resolveStart = microtime(true); try { $response = $this->resolver->resolve($query); @@ -264,7 +257,6 @@ protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResp ]); $span->set('dns.duration.resolve', $resolveDuration); - // 3. Encode response $encodeStart = microtime(true); try { return $response->encode($maxResponseSize); @@ -303,11 +295,124 @@ protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResp } } + /** + * UDP adapter callback. Strips a PROXY preamble (if enabled) and + * delegates to {@see onPacket()}. + */ + protected function onUdpPacket(string $buffer, string $ip, int $port, ?int $maxResponseSize): string + { + if ($this->enableProxyProtocol) { + try { + $header = ProxyProtocolStream::unwrapDatagram($buffer); + } catch (ProxyDecodingException $e) { + $this->handleError($e); + return ''; + } + + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { + $ip = $header->sourceAddress; + $port = $header->sourcePort; + } + } + + return $this->onPacket($buffer, $ip, $port, $maxResponseSize); + } + + /** + * TCP adapter callback. Buffers bytes, consumes a PROXY preamble when + * present, extracts length-prefixed DNS frames, and sends responses + * back via the adapter. + */ + protected function onTcpReceive(int $fd, string $bytes, string $ip, int $port): void + { + if (!isset($this->tcpBuffers[$fd])) { + $this->tcpBuffers[$fd] = ''; + $this->tcpAddresses[$fd] = ['ip' => $ip, 'port' => $port]; + if ($this->enableProxyProtocol) { + $this->tcpProxyStreams[$fd] = new ProxyProtocolStream(); + } + } + + $buffer = $this->tcpBuffers[$fd] . $bytes; + + if (strlen($buffer) > self::TCP_MAX_BUFFER_SIZE) { + $this->adapter->closeTcp($fd); + return; + } + + $stream = $this->tcpProxyStreams[$fd] ?? null; + + if ($stream !== null && $stream->state() === ProxyProtocolStream::STATE_UNRESOLVED) { + try { + $state = $stream->resolve($buffer); + } catch (ProxyDecodingException $e) { + $this->handleError($e); + $this->adapter->closeTcp($fd); + return; + } + + if ($state === ProxyProtocolStream::STATE_UNRESOLVED) { + $this->tcpBuffers[$fd] = $buffer; + return; + } + + $header = $stream->header(); + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { + $this->tcpAddresses[$fd] = [ + 'ip' => $header->sourceAddress, + 'port' => $header->sourcePort, + ]; + } + } + + while (strlen($buffer) >= 2) { + $unpacked = unpack('n', substr($buffer, 0, 2)); + $frameLength = (is_array($unpacked) && is_int($unpacked[1] ?? null)) ? $unpacked[1] : 0; + + // RFC 1035 / 7766: 0-length frames are invalid; oversize frames are either misframed or hostile. + if ($frameLength === 0 || $frameLength > self::TCP_MAX_MESSAGE_SIZE) { + $this->adapter->closeTcp($fd); + return; + } + + if (strlen($buffer) < $frameLength + 2) { + break; + } + + $message = substr($buffer, 2, $frameLength); + $buffer = substr($buffer, $frameLength + 2); + + $address = $this->tcpAddresses[$fd]; + $response = $this->onPacket($message, $address['ip'], $address['port'], self::TCP_MAX_MESSAGE_SIZE); + + if ($response !== '') { + if (strlen($response) > self::TCP_MAX_MESSAGE_SIZE) { + // Truncation should have been applied already; if not, bail rather than corrupt framing. + $this->adapter->closeTcp($fd); + return; + } + $this->adapter->sendTcp($fd, pack('n', strlen($response)) . $response); + } + } + + $this->tcpBuffers[$fd] = $buffer; + } + + protected function onTcpClose(int $fd): void + { + unset( + $this->tcpBuffers[$fd], + $this->tcpProxyStreams[$fd], + $this->tcpAddresses[$fd], + ); + } + public function start(): void { try { - $onPacket = $this->onPacket(...); - $this->adapter->onPacket($onPacket); + $this->adapter->onUdpPacket($this->onUdpPacket(...)); + $this->adapter->onTcpReceive($this->onTcpReceive(...)); + $this->adapter->onTcpClose($this->onTcpClose(...)); $this->adapter->start(); } catch (Throwable $error) { $this->handleError($error); diff --git a/tests/unit/DNS/ServerTest.php b/tests/unit/DNS/ServerTest.php new file mode 100644 index 0000000..130f0a9 --- /dev/null +++ b/tests/unit/DNS/ServerTest.php @@ -0,0 +1,417 @@ +lastIp = $ip; + $this->lastPort = $port; + return parent::onPacket($buffer, $ip, $port, $maxResponseSize); + } +} + +/** + * @phpstan-type UdpCall array{buffer: string, ip: string, port: int, maxResponseSize: ?int} + */ +final class ServerTest extends TestCase +{ + public function testUdpDirectQueryPassesPeerAddressThrough(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + + $server->start(); + + $query = $this->buildQuery('example.com'); + $response = $adapter->deliverUdp($query, '203.0.113.9', 9876); + + $this->assertNotSame('', $response); + $decoded = Message::decode($response); + $this->assertSame('203.0.113.9', $server->lastIp); + $this->assertSame(9876, $server->lastPort); + $this->assertCount(1, $decoded->answers); + } + + public function testUdpStripsPxyV1WhenProxyProtocolEnabled(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $server->start(); + + $query = $this->buildQuery('example.com'); + $preamble = "PROXY TCP4 198.51.100.7 10.0.0.1 55555 53\r\n"; + $adapter->deliverUdp($preamble . $query, '10.0.0.254', 4444); + + $this->assertSame('198.51.100.7', $server->lastIp); + $this->assertSame(55555, $server->lastPort); + } + + public function testUdpTreatsUnwrappedDatagramAsDirectWhenProxyEnabled(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $server->start(); + + $query = $this->buildQuery('example.com'); + $adapter->deliverUdp($query, '192.0.2.1', 12345); + + $this->assertSame('192.0.2.1', $server->lastIp); + $this->assertSame(12345, $server->lastPort); + } + + public function testUdpDropsMalformedProxyDatagram(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $errors = []; + $server->error(function (\Throwable $e) use (&$errors) { + $errors[] = $e; + }); + $server->start(); + + $query = $this->buildQuery('example.com'); + $response = $adapter->deliverUdp("PROXY TCP4 bogus 10.0.0.1 1 2\r\n" . $query, '10.0.0.254', 1); + + $this->assertSame('', $response); + $this->assertNull($server->lastIp); + $this->assertCount(1, $errors); + } + + public function testTcpSingleFrameDeliveredAndResponded(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->start(); + + $query = $this->buildQuery('example.com'); + $frame = pack('n', strlen($query)) . $query; + + $adapter->deliverTcp(1, $frame, '198.51.100.10', 40000); + + $sent = $adapter->sentTcp(1); + $this->assertCount(1, $sent); + $this->assertSame('198.51.100.10', $server->lastIp); + $this->assertSame(40000, $server->lastPort); + + $response = $sent[0]; + $unpacked = unpack('n', substr($response, 0, 2)); + $this->assertIsArray($unpacked); + $this->assertSame(strlen($response) - 2, $unpacked[1]); + + $decoded = Message::decode(substr($response, 2)); + $this->assertCount(1, $decoded->answers); + } + + public function testTcpFrameDeliveredAcrossMultipleChunks(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->start(); + + $query = $this->buildQuery('example.com'); + $frame = pack('n', strlen($query)) . $query; + + $chunks = str_split($frame, 3); + foreach ($chunks as $i => $chunk) { + $adapter->deliverTcp(7, $chunk, '203.0.113.1', 1234); + + if ($i < count($chunks) - 1) { + $this->assertSame([], $adapter->sentTcp(7), 'response must not fire until frame complete'); + } + } + + $this->assertCount(1, $adapter->sentTcp(7)); + } + + public function testTcpMultipleFramesInSingleChunk(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->start(); + + $q1 = $this->buildQuery('a.example'); + $q2 = $this->buildQuery('b.example'); + $payload = pack('n', strlen($q1)) . $q1 . pack('n', strlen($q2)) . $q2; + + $adapter->deliverTcp(5, $payload, '203.0.113.2', 2345); + + $this->assertCount(2, $adapter->sentTcp(5)); + } + + public function testTcpConsumesProxyV1PreambleBeforeFraming(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $server->start(); + + $query = $this->buildQuery('example.com'); + $preamble = "PROXY TCP4 192.0.2.10 10.0.0.1 55000 443\r\n"; + $frame = pack('n', strlen($query)) . $query; + + $adapter->deliverTcp(10, $preamble . $frame, '10.0.0.254', 4444); + + $this->assertCount(1, $adapter->sentTcp(10)); + $this->assertSame('192.0.2.10', $server->lastIp); + $this->assertSame(55000, $server->lastPort); + } + + public function testTcpConsumesProxyV2PreambleBeforeFraming(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $server->start(); + + $addrPayload = inet_pton('198.51.100.5') . inet_pton('10.0.0.1') . pack('nn', 11000, 53); + $preamble = ProxyProtocol::V2_SIGNATURE + . chr(0x21) + . chr(0x11) + . pack('n', strlen($addrPayload)) + . $addrPayload; + $query = $this->buildQuery('example.com'); + $frame = pack('n', strlen($query)) . $query; + + $adapter->deliverTcp(11, $preamble . $frame, '10.0.0.254', 0); + + $this->assertCount(1, $adapter->sentTcp(11)); + $this->assertSame('198.51.100.5', $server->lastIp); + } + + public function testTcpDirectConnectionWorksWhenProxyProtocolEnabled(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $server->start(); + + $query = $this->buildQuery('example.com'); + $frame = pack('n', strlen($query)) . $query; + + $adapter->deliverTcp(12, $frame, '203.0.113.20', 4000); + + $this->assertCount(1, $adapter->sentTcp(12)); + $this->assertSame('203.0.113.20', $server->lastIp); + } + + public function testTcpClosesOnMalformedProxyPreamble(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $server->start(); + + $adapter->deliverTcp(13, "PROXY TCP4 bogus 10.0.0.1 1 2\r\nrest", '10.0.0.254', 0); + + $this->assertTrue($adapter->wasClosed(13)); + } + + public function testTcpClosesOnOversizeBuffer(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->start(); + + // Send a declared huge frame length; body won't fit past TCP_MAX_BUFFER_SIZE. + $adapter->deliverTcp(14, pack('n', 65535) . str_repeat('A', Server::TCP_MAX_BUFFER_SIZE), '203.0.113.30', 1); + + $this->assertTrue($adapter->wasClosed(14)); + } + + public function testTcpClosesOnZeroLengthFrame(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->start(); + + $adapter->deliverTcp(15, pack('n', 0), '203.0.113.31', 1); + + $this->assertTrue($adapter->wasClosed(15)); + } + + public function testOnTcpCloseClearsState(): void + { + $adapter = new FakeAdapter(); + $resolver = new EchoResolver(); + $server = new RecordingServer($adapter, $resolver); + $server->setProxyProtocol(true); + $server->start(); + + // Start a partial PROXY preamble to allocate server-side state. + $adapter->deliverTcp(16, 'PROXY TCP4 1', '10.0.0.254', 0); + // Close connection. + $adapter->closeConnection(16); + + // Re-use the same fd for a new connection. State must not leak. + $query = $this->buildQuery('example.com'); + $frame = pack('n', strlen($query)) . $query; + $adapter->deliverTcp(16, $frame, '203.0.113.40', 5000); + + $this->assertCount(1, $adapter->sentTcp(16)); + $this->assertSame('203.0.113.40', $server->lastIp); + } + + private function buildQuery(string $name): string + { + $query = Message::query(new Question($name, Record::TYPE_A)); + return $query->encode(); + } +} + +final class EchoResolver implements Resolver +{ + public ?string $lastIp = null; + + public ?int $lastPort = null; + + public function getName(): string + { + return 'echo'; + } + + public function resolve(Message $query): Message + { + return Message::response( + header: $query->header, + responseCode: Message::RCODE_NOERROR, + questions: $query->questions, + answers: [ + new Record( + name: $query->questions[0]->name, + type: Record::TYPE_A, + rdata: '127.0.0.1', + ttl: 60, + ), + ], + authoritative: true, + ); + } +} + +/** + * Test double that records the server's interactions with the adapter. + * Tests simulate incoming traffic via deliverUdp() / deliverTcp() and + * inspect sent bytes via sentTcp(). + */ +final class FakeAdapter extends Adapter +{ + /** @var callable(string, string, int, ?int): string */ + public $onUdpPacket; + + /** @var callable(int, string, string, int): void */ + public $onTcpReceive; + + /** @var callable(int): void */ + public $onTcpClose; + + /** @var array> */ + private array $sent = []; + + /** @var array */ + private array $closed = []; + + public function onWorkerStart(callable $callback): void + { + // No-op. + } + + public function onUdpPacket(callable $callback): void + { + $this->onUdpPacket = $callback; + } + + public function onTcpReceive(callable $callback): void + { + $this->onTcpReceive = $callback; + } + + public function onTcpClose(callable $callback): void + { + $this->onTcpClose = $callback; + } + + public function sendTcp(int $fd, string $data): void + { + $this->sent[$fd][] = $data; + } + + public function closeTcp(int $fd): void + { + $this->closed[$fd] = true; + \call_user_func($this->onTcpClose, $fd); + } + + public function start(): void + { + // No-op for tests. + } + + public function getName(): string + { + return 'fake'; + } + + public function deliverUdp(string $buffer, string $ip, int $port): string + { + return \call_user_func($this->onUdpPacket, $buffer, $ip, $port, 512); + } + + public function deliverTcp(int $fd, string $bytes, string $ip, int $port): void + { + \call_user_func($this->onTcpReceive, $fd, $bytes, $ip, $port); + } + + public function closeConnection(int $fd): void + { + $this->closed[$fd] = true; + \call_user_func($this->onTcpClose, $fd); + unset($this->sent[$fd]); + } + + /** @return list */ + public function sentTcp(int $fd): array + { + return $this->sent[$fd] ?? []; + } + + public function wasClosed(int $fd): bool + { + return $this->closed[$fd] ?? false; + } +} From 9a95b6eeab41051fb08c5ca682cd75b801c9a77e Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:50:27 +0100 Subject: [PATCH 5/6] Collapse Server hooks to single onMessage extension point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/DNS/Server.php | 26 ++++++++++++++++---------- tests/unit/DNS/ServerTest.php | 4 ++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/DNS/Server.php b/src/DNS/Server.php index 6c58681..a3a9e61 100644 --- a/src/DNS/Server.php +++ b/src/DNS/Server.php @@ -176,8 +176,14 @@ protected function handleError(Throwable $error): void /** * Handle a complete DNS message. + * + * Called once per decoded query, regardless of transport (UDP or TCP). + * Subclasses override this hook to customize message handling; by + * default it runs the decode/resolve/encode pipeline against the + * configured resolver. $ip and $port are the real client address + * (already resolved from PROXY protocol when enabled). */ - protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string + protected function onMessage(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string { $span = Span::init('dns.packet'); $span->set('client.ip', $ip); @@ -297,9 +303,9 @@ protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResp /** * UDP adapter callback. Strips a PROXY preamble (if enabled) and - * delegates to {@see onPacket()}. + * delegates to {@see onMessage()}. */ - protected function onUdpPacket(string $buffer, string $ip, int $port, ?int $maxResponseSize): string + private function dispatchUdp(string $buffer, string $ip, int $port, ?int $maxResponseSize): string { if ($this->enableProxyProtocol) { try { @@ -315,7 +321,7 @@ protected function onUdpPacket(string $buffer, string $ip, int $port, ?int $maxR } } - return $this->onPacket($buffer, $ip, $port, $maxResponseSize); + return $this->onMessage($buffer, $ip, $port, $maxResponseSize); } /** @@ -323,7 +329,7 @@ protected function onUdpPacket(string $buffer, string $ip, int $port, ?int $maxR * present, extracts length-prefixed DNS frames, and sends responses * back via the adapter. */ - protected function onTcpReceive(int $fd, string $bytes, string $ip, int $port): void + private function dispatchTcpReceive(int $fd, string $bytes, string $ip, int $port): void { if (!isset($this->tcpBuffers[$fd])) { $this->tcpBuffers[$fd] = ''; @@ -383,7 +389,7 @@ protected function onTcpReceive(int $fd, string $bytes, string $ip, int $port): $buffer = substr($buffer, $frameLength + 2); $address = $this->tcpAddresses[$fd]; - $response = $this->onPacket($message, $address['ip'], $address['port'], self::TCP_MAX_MESSAGE_SIZE); + $response = $this->onMessage($message, $address['ip'], $address['port'], self::TCP_MAX_MESSAGE_SIZE); if ($response !== '') { if (strlen($response) > self::TCP_MAX_MESSAGE_SIZE) { @@ -398,7 +404,7 @@ protected function onTcpReceive(int $fd, string $bytes, string $ip, int $port): $this->tcpBuffers[$fd] = $buffer; } - protected function onTcpClose(int $fd): void + private function dispatchTcpClose(int $fd): void { unset( $this->tcpBuffers[$fd], @@ -410,9 +416,9 @@ protected function onTcpClose(int $fd): void public function start(): void { try { - $this->adapter->onUdpPacket($this->onUdpPacket(...)); - $this->adapter->onTcpReceive($this->onTcpReceive(...)); - $this->adapter->onTcpClose($this->onTcpClose(...)); + $this->adapter->onUdpPacket($this->dispatchUdp(...)); + $this->adapter->onTcpReceive($this->dispatchTcpReceive(...)); + $this->adapter->onTcpClose($this->dispatchTcpClose(...)); $this->adapter->start(); } catch (Throwable $error) { $this->handleError($error); diff --git a/tests/unit/DNS/ServerTest.php b/tests/unit/DNS/ServerTest.php index 130f0a9..f8ff5df 100644 --- a/tests/unit/DNS/ServerTest.php +++ b/tests/unit/DNS/ServerTest.php @@ -22,11 +22,11 @@ class RecordingServer extends Server public ?int $lastPort = null; - protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string + protected function onMessage(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string { $this->lastIp = $ip; $this->lastPort = $port; - return parent::onPacket($buffer, $ip, $port, $maxResponseSize); + return parent::onMessage($buffer, $ip, $port, $maxResponseSize); } } From 6f02e4fe977e60740617a7b932769914f202fbbc Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:10:24 +0100 Subject: [PATCH 6/6] Adapter is transport; Server is DNS. Split UDP/TCP into per-transport adapters. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/DNS/Adapter.php | 66 ++--- src/DNS/Adapter/Composite.php | 69 +++++ src/DNS/Adapter/Native.php | 299 -------------------- src/DNS/Adapter/NativeTcp.php | 279 ++++++++++++++++++ src/DNS/Adapter/NativeUdp.php | 130 +++++++++ src/DNS/Adapter/Swoole.php | 132 --------- src/DNS/Adapter/SwooleTcp.php | 157 +++++++++++ src/DNS/Adapter/SwooleUdp.php | 114 ++++++++ src/DNS/Server.php | 152 +--------- src/DNS/TcpMessageStream.php | 117 ++++++++ tests/resources/server.php | 12 +- tests/unit/DNS/ServerTest.php | 358 +++++------------------- tests/unit/DNS/TcpMessageStreamTest.php | 178 ++++++++++++ 13 files changed, 1148 insertions(+), 915 deletions(-) create mode 100644 src/DNS/Adapter/Composite.php delete mode 100644 src/DNS/Adapter/Native.php create mode 100644 src/DNS/Adapter/NativeTcp.php create mode 100644 src/DNS/Adapter/NativeUdp.php delete mode 100644 src/DNS/Adapter/Swoole.php create mode 100644 src/DNS/Adapter/SwooleTcp.php create mode 100644 src/DNS/Adapter/SwooleUdp.php create mode 100644 src/DNS/TcpMessageStream.php create mode 100644 tests/unit/DNS/TcpMessageStreamTest.php diff --git a/src/DNS/Adapter.php b/src/DNS/Adapter.php index 1598227..fc9db10 100644 --- a/src/DNS/Adapter.php +++ b/src/DNS/Adapter.php @@ -5,19 +5,18 @@ /** * Transport adapter contract. * - * Adapters are responsible for the wire — receiving bytes from UDP - * datagrams and TCP connections, and sending bytes back. All DNS-level - * concerns (length-prefix framing, PROXY protocol, response generation) - * live in {@see Server}. + * Adapters own the wire — UDP sockets, TCP sockets, connection framing, + * and PROXY protocol preamble handling. From the {@see Server}'s + * perspective, DNS messages appear as generic "request with a client + * address"; the adapter hides everything below. */ abstract class Adapter { /** * Whether incoming traffic may be prefixed with a PROXY protocol - * preamble. The adapter itself does not parse PROXY — this flag just - * informs the adapter about how to configure its transport (e.g. - * Swoole's kernel-level length-check framing is incompatible with a - * PROXY preamble and must be disabled when this is true). + * preamble. When enabled, the adapter strips the preamble (if + * present) and reports the real client address through the + * {@see onMessage()} callback. */ protected bool $enableProxyProtocol = false; @@ -40,51 +39,22 @@ public function hasProxyProtocol(): bool abstract public function onWorkerStart(callable $callback): void; /** - * Register the UDP datagram handler. + * Register the DNS message handler. * - * The callback is invoked with the full datagram payload and the - * transport peer's IP/port. It returns the bytes to send back, or an - * empty string to send nothing. + * Invoked once per complete DNS message, regardless of transport. + * The adapter has already stripped any PROXY preamble and extracted + * the framed message; $ip/$port reflect the real client address. * - * @param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string $callback - * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string $callback - */ - abstract public function onUdpPacket(callable $callback): void; - - /** - * Register the TCP receive handler. + * The callback returns the response bytes to send back to the + * client, or an empty string to suppress the response. * - * The callback is invoked with a connection identifier, a chunk of - * freshly-received bytes, and the transport peer's IP/port. The chunk - * may contain partial or multiple DNS messages; framing is the - * handler's responsibility. Responses are sent via {@see sendTcp()}, - * not via a return value. - * - * @param callable(int $fd, string $bytes, string $ip, int $port): void $callback - * @phpstan-param callable(int $fd, string $bytes, string $ip, int $port): void $callback - */ - abstract public function onTcpReceive(callable $callback): void; - - /** - * Register a TCP close handler so listeners can drop per-connection - * state. Called once per connection identifier after the underlying - * socket has been closed (by either end). + * $maxResponseSize is the maximum response size appropriate for the + * transport (UDP: 512 per RFC 1035 unless EDNS0; TCP: 65535). * - * @param callable(int $fd): void $callback - * @phpstan-param callable(int $fd): void $callback - */ - abstract public function onTcpClose(callable $callback): void; - - /** - * Send bytes on a TCP connection identified by $fd. Silently no-ops if - * the connection is no longer open. - */ - abstract public function sendTcp(int $fd, string $data): void; - - /** - * Forcefully close a TCP connection identified by $fd. + * @param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string $callback + * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string $callback */ - abstract public function closeTcp(int $fd): void; + abstract public function onMessage(callable $callback): void; /** * Start the DNS server. diff --git a/src/DNS/Adapter/Composite.php b/src/DNS/Adapter/Composite.php new file mode 100644 index 0000000..fe195ec --- /dev/null +++ b/src/DNS/Adapter/Composite.php @@ -0,0 +1,69 @@ + */ + protected array $adapters; + + public function __construct(Adapter ...$adapters) + { + $this->adapters = array_values($adapters); + } + + public function setProxyProtocol(bool $enabled): void + { + parent::setProxyProtocol($enabled); + foreach ($this->adapters as $adapter) { + $adapter->setProxyProtocol($enabled); + } + } + + public function onWorkerStart(callable $callback): void + { + foreach ($this->adapters as $adapter) { + $adapter->onWorkerStart($callback); + } + } + + public function onMessage(callable $callback): void + { + foreach ($this->adapters as $adapter) { + $adapter->onMessage($callback); + } + } + + public function start(): void + { + foreach ($this->adapters as $adapter) { + $adapter->start(); + } + } + + public function getName(): string + { + $names = array_map(fn (Adapter $a) => $a->getName(), $this->adapters); + return 'composite(' . implode('+', $names) . ')'; + } + + /** + * @return list + */ + public function getAdapters(): array + { + return $this->adapters; + } +} diff --git a/src/DNS/Adapter/Native.php b/src/DNS/Adapter/Native.php deleted file mode 100644 index 20a01dc..0000000 --- a/src/DNS/Adapter/Native.php +++ /dev/null @@ -1,299 +0,0 @@ - Active TCP client sockets, keyed by fd id. */ - protected array $tcpClients = []; - - /** @var array Last activity timestamp per TCP client for idle timeout. */ - protected array $tcpLastActivity = []; - - /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ - protected mixed $onUdpPacket; - - /** @var callable(int $fd, string $bytes, string $ip, int $port): void */ - protected mixed $onTcpReceive; - - /** @var callable(int $fd): void */ - protected mixed $onTcpClose; - - /** @var list */ - protected array $onWorkerStartCallbacks = []; - - /** - * @param string $host Host to bind to - * @param int $port Port to listen on - * @param bool $enableTcp Enable TCP support (RFC 5966) - * @param int $maxTcpClients Maximum concurrent TCP clients - * @param int $tcpIdleTimeout Seconds before idle TCP connections are closed (RFC 7766) - */ - public function __construct( - protected string $host = '0.0.0.0', - protected int $port = 8053, - protected bool $enableTcp = true, - protected int $maxTcpClients = 100, - protected int $tcpIdleTimeout = 30, - ) { - $server = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); - if (!$server) { - throw new Exception('Could not start server.'); - } - $this->udpServer = $server; - - if ($this->enableTcp) { - $tcp = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - if (!$tcp) { - throw new Exception('Could not start TCP server.'); - } - - socket_set_option($tcp, SOL_SOCKET, SO_REUSEADDR, 1); - $this->tcpServer = $tcp; - } - } - - public function onWorkerStart(callable $callback): void - { - $this->onWorkerStartCallbacks[] = $callback; - } - - public function onUdpPacket(callable $callback): void - { - $this->onUdpPacket = $callback; - } - - public function onTcpReceive(callable $callback): void - { - $this->onTcpReceive = $callback; - } - - public function onTcpClose(callable $callback): void - { - $this->onTcpClose = $callback; - } - - public function sendTcp(int $fd, string $data): void - { - $client = $this->tcpClients[$fd] ?? null; - if ($client === null) { - return; - } - - $total = strlen($data); - $sent = 0; - - while ($sent < $total) { - $written = @socket_write($client, substr($data, $sent)); - - if ($written === false) { - $error = socket_last_error($client); - - if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { - socket_clear_error($client); - usleep(1000); - continue; - } - - $this->closeTcpInternal($client); - return; - } - - $sent += $written; - } - } - - public function closeTcp(int $fd): void - { - $client = $this->tcpClients[$fd] ?? null; - if ($client !== null) { - $this->closeTcpInternal($client); - } - } - - public function start(): void - { - if (socket_bind($this->udpServer, $this->host, $this->port) == false) { - throw new Exception('Could not bind server to a server.'); - } - - if ($this->tcpServer) { - if (socket_bind($this->tcpServer, $this->host, $this->port) == false) { - throw new Exception('Could not bind TCP server.'); - } - - if (socket_listen($this->tcpServer, 128) == false) { - throw new Exception('Could not listen on TCP server.'); - } - - socket_set_nonblock($this->tcpServer); - } - - foreach ($this->onWorkerStartCallbacks as $callback) { - \call_user_func($callback, 0); - } - - /** @phpstan-ignore-next-line */ - while (1) { - // RFC 7766 Section 6.2.3: Close idle TCP connections - $this->closeIdleTcpClients(); - - $readSockets = [$this->udpServer]; - - if ($this->tcpServer) { - $readSockets[] = $this->tcpServer; - } - - foreach ($this->tcpClients as $client) { - $readSockets[] = $client; - } - - $write = []; - $except = []; - - $changed = socket_select($readSockets, $write, $except, 1); - - if ($changed === false || $changed === 0) { - continue; - } - - foreach ($readSockets as $socket) { - if ($socket === $this->udpServer) { - $this->handleUdp(); - continue; - } - - if ($this->tcpServer !== null && $socket === $this->tcpServer) { - $this->acceptTcp(); - continue; - } - - $this->readTcp($socket); - } - } - } - - public function getName(): string - { - return 'native'; - } - - protected function handleUdp(): void - { - $buf = ''; - $ip = ''; - $port = 0; - $len = socket_recvfrom($this->udpServer, $buf, 1024 * 4, 0, $ip, $port); - - if ($len === false || $len <= 0 || !is_string($buf) || !is_string($ip) || !is_int($port)) { - return; - } - - $response = \call_user_func($this->onUdpPacket, $buf, $ip, $port, 512); - - if ($response !== '') { - socket_sendto($this->udpServer, $response, strlen($response), 0, $ip, $port); - } - } - - protected function acceptTcp(): void - { - if ($this->tcpServer === null) { - return; - } - - $client = @socket_accept($this->tcpServer); - - if (!$client instanceof Socket) { - return; - } - - if (count($this->tcpClients) >= $this->maxTcpClients) { - @socket_close($client); - return; - } - - if (@socket_set_nonblock($client) === false) { - @socket_close($client); - return; - } - - socket_set_option($client, SOL_SOCKET, SO_KEEPALIVE, 1); - socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 5, 'usec' => 0]); - socket_set_option($client, SOL_SOCKET, SO_SNDTIMEO, ['sec' => 5, 'usec' => 0]); - - $fd = spl_object_id($client); - $this->tcpClients[$fd] = $client; - $this->tcpLastActivity[$fd] = time(); - } - - protected function readTcp(Socket $client): void - { - $fd = spl_object_id($client); - - $chunk = @socket_read($client, 8192, PHP_BINARY_READ); - - if ($chunk === '' || $chunk === false) { - $error = socket_last_error($client); - - if ($chunk === '' || !in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { - $this->closeTcpInternal($client); - } - - return; - } - - $this->tcpLastActivity[$fd] = time(); - - $ip = ''; - $port = 0; - socket_getpeername($client, $ip, $port); - - \call_user_func( - $this->onTcpReceive, - $fd, - $chunk, - is_string($ip) ? $ip : '', - is_int($port) ? $port : 0, - ); - } - - /** - * Close idle TCP connections per RFC 7766 Section 6.2.3. - */ - protected function closeIdleTcpClients(): void - { - $now = time(); - - foreach ($this->tcpClients as $fd => $client) { - $lastActivity = $this->tcpLastActivity[$fd] ?? 0; - - if (($now - $lastActivity) > $this->tcpIdleTimeout) { - $this->closeTcpInternal($client); - } - } - } - - protected function closeTcpInternal(Socket $client): void - { - $fd = spl_object_id($client); - - if (!isset($this->tcpClients[$fd])) { - return; - } - - unset($this->tcpClients[$fd], $this->tcpLastActivity[$fd]); - - @socket_close($client); - - \call_user_func($this->onTcpClose, $fd); - } -} diff --git a/src/DNS/Adapter/NativeTcp.php b/src/DNS/Adapter/NativeTcp.php new file mode 100644 index 0000000..867da9a --- /dev/null +++ b/src/DNS/Adapter/NativeTcp.php @@ -0,0 +1,279 @@ + Active client sockets keyed by spl_object_id. */ + protected array $clients = []; + + /** @var array Per-client message stream. */ + protected array $streams = []; + + /** @var array Last-activity timestamp for idle-timeout enforcement. */ + protected array $lastActivity = []; + + protected ?Socket $server = null; + + /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ + protected mixed $onMessage; + + /** @var list */ + protected array $onWorkerStartCallbacks = []; + + /** + * @param string $host Host to bind to + * @param int $port Port to listen on + * @param int $maxClients Maximum concurrent TCP clients + * @param int $idleTimeout Seconds before idle TCP connections are closed (RFC 7766) + */ + public function __construct( + protected string $host = '0.0.0.0', + protected int $port = 8053, + protected int $maxClients = 100, + protected int $idleTimeout = 30, + ) { + $socket = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if (!$socket) { + throw new Exception('Could not create TCP socket.'); + } + + socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1); + $this->server = $socket; + } + + public function onWorkerStart(callable $callback): void + { + $this->onWorkerStartCallbacks[] = $callback; + } + + public function onMessage(callable $callback): void + { + $this->onMessage = $callback; + } + + public function start(): void + { + if ($this->server === null) { + throw new Exception('TCP server socket is not available.'); + } + + if (socket_bind($this->server, $this->host, $this->port) === false) { + throw new Exception('Could not bind TCP server.'); + } + + if (socket_listen($this->server, 128) === false) { + throw new Exception('Could not listen on TCP server.'); + } + + socket_set_nonblock($this->server); + + foreach ($this->onWorkerStartCallbacks as $callback) { + \call_user_func($callback, 0); + } + + /** @phpstan-ignore-next-line */ + while (1) { + $this->closeIdleClients(); + + $readSockets = [$this->server]; + foreach ($this->clients as $client) { + $readSockets[] = $client; + } + + $write = []; + $except = []; + + // 1s timeout keeps the idle sweep responsive. + $changed = socket_select($readSockets, $write, $except, 1); + if ($changed === false || $changed === 0) { + continue; + } + + foreach ($readSockets as $sock) { + if ($sock === $this->server) { + $this->acceptClient(); + continue; + } + + $this->readClient($sock); + } + } + } + + public function getName(): string + { + return 'native-tcp'; + } + + public function getServerSocket(): ?Socket + { + return $this->server; + } + + /** + * @return list + */ + public function getClientSockets(): array + { + return array_values($this->clients); + } + + protected function acceptClient(): void + { + if ($this->server === null) { + return; + } + + $client = @socket_accept($this->server); + + if (!$client instanceof Socket) { + return; + } + + if (count($this->clients) >= $this->maxClients) { + @socket_close($client); + return; + } + + if (@socket_set_nonblock($client) === false) { + @socket_close($client); + return; + } + + socket_set_option($client, SOL_SOCKET, SO_KEEPALIVE, 1); + socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 5, 'usec' => 0]); + socket_set_option($client, SOL_SOCKET, SO_SNDTIMEO, ['sec' => 5, 'usec' => 0]); + + $peerIp = ''; + $peerPort = 0; + socket_getpeername($client, $peerIp, $peerPort); + + $fd = spl_object_id($client); + $this->clients[$fd] = $client; + $this->lastActivity[$fd] = time(); + $this->streams[$fd] = new TcpMessageStream( + peerIp: is_string($peerIp) ? $peerIp : '', + peerPort: is_int($peerPort) ? $peerPort : 0, + enableProxyProtocol: $this->enableProxyProtocol, + ); + } + + public function readClient(Socket $client): void + { + $fd = spl_object_id($client); + + $chunk = @socket_read($client, 8192, PHP_BINARY_READ); + + if ($chunk === '' || $chunk === false) { + $error = socket_last_error($client); + + if ($chunk === '' || !in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { + $this->closeClient($client); + } + + return; + } + + $this->lastActivity[$fd] = time(); + + $stream = $this->streams[$fd] ?? null; + if ($stream === null) { + $this->closeClient($client); + return; + } + + try { + foreach ($stream->feed($chunk) as [$message, $ip, $port]) { + $response = \call_user_func($this->onMessage, $message, $ip, $port, TcpMessageStream::MAX_MESSAGE_SIZE); + + if ($response !== '') { + $this->writeFramed($client, $response); + } + } + } catch (ProxyDecodingException | MessageDecodingException) { + $this->closeClient($client); + } + } + + protected function writeFramed(Socket $client, string $response): void + { + $length = strlen($response); + + if ($length > TcpMessageStream::MAX_MESSAGE_SIZE) { + // Truncation should have been applied upstream; oversize payloads + // would silently corrupt framing via the 2-byte length prefix. + $this->closeClient($client); + return; + } + + $frame = pack('n', $length) . $response; + $total = strlen($frame); + $sent = 0; + + while ($sent < $total) { + $written = @socket_write($client, substr($frame, $sent)); + + if ($written === false) { + $error = socket_last_error($client); + + if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { + socket_clear_error($client); + usleep(1000); + continue; + } + + $this->closeClient($client); + return; + } + + $sent += $written; + } + } + + /** + * Close idle TCP connections per RFC 7766 § 6.2.3. + */ + protected function closeIdleClients(): void + { + $now = time(); + + foreach ($this->clients as $fd => $client) { + $last = $this->lastActivity[$fd] ?? 0; + + if (($now - $last) > $this->idleTimeout) { + $this->closeClient($client); + } + } + } + + protected function closeClient(Socket $client): void + { + $fd = spl_object_id($client); + + if (!isset($this->clients[$fd])) { + return; + } + + unset($this->clients[$fd], $this->lastActivity[$fd], $this->streams[$fd]); + + @socket_close($client); + } +} diff --git a/src/DNS/Adapter/NativeUdp.php b/src/DNS/Adapter/NativeUdp.php new file mode 100644 index 0000000..6fadf0b --- /dev/null +++ b/src/DNS/Adapter/NativeUdp.php @@ -0,0 +1,130 @@ + */ + protected array $onWorkerStartCallbacks = []; + + public function __construct( + protected string $host = '0.0.0.0', + protected int $port = 8053, + ) { + $socket = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + if (!$socket) { + throw new Exception('Could not create UDP socket.'); + } + $this->socket = $socket; + } + + public function onWorkerStart(callable $callback): void + { + $this->onWorkerStartCallbacks[] = $callback; + } + + public function onMessage(callable $callback): void + { + $this->onMessage = $callback; + } + + public function start(): void + { + if (socket_bind($this->socket, $this->host, $this->port) === false) { + throw new Exception('Could not bind UDP server.'); + } + + foreach ($this->onWorkerStartCallbacks as $callback) { + \call_user_func($callback, 0); + } + + /** @phpstan-ignore-next-line */ + while (1) { + $readSockets = [$this->socket]; + $write = []; + $except = []; + + $changed = socket_select($readSockets, $write, $except, null); + if ($changed === false || $changed === 0) { + continue; + } + + $this->handleReadable(); + } + } + + public function getName(): string + { + return 'native-udp'; + } + + /** + * Non-blocking single iteration — useful for composite adapters that + * run their own select loop over the sockets exposed via + * {@see getSocket()}. + */ + public function handleReadable(): void + { + $buf = ''; + $ip = ''; + $port = 0; + $len = socket_recvfrom($this->socket, $buf, 1024 * 4, 0, $ip, $port); + + if ($len === false || $len <= 0 || !is_string($buf) || !is_string($ip) || !is_int($port)) { + return; + } + + // Reply goes back to the actual UDP peer, not the PROXY-declared source. + $replyIp = $ip; + $replyPort = $port; + + if ($this->enableProxyProtocol) { + try { + $header = ProxyProtocolStream::unwrapDatagram($buf); + } catch (ProxyDecodingException) { + return; + } + + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { + $ip = $header->sourceAddress; + $port = $header->sourcePort; + } + } + + $response = \call_user_func($this->onMessage, $buf, $ip, $port, self::UDP_MAX_MESSAGE_SIZE); + + if ($response !== '') { + socket_sendto($this->socket, $response, strlen($response), 0, $replyIp, $replyPort); + } + } + + public function getSocket(): Socket + { + return $this->socket; + } +} diff --git a/src/DNS/Adapter/Swoole.php b/src/DNS/Adapter/Swoole.php deleted file mode 100644 index aa92913..0000000 --- a/src/DNS/Adapter/Swoole.php +++ /dev/null @@ -1,132 +0,0 @@ -server = new Server($this->host, $this->port, SWOOLE_PROCESS, SWOOLE_SOCK_UDP); - $this->server->set([ - 'worker_num' => $this->numWorkers, - 'max_coroutine' => $this->maxCoroutines, - ]); - - if ($this->enableTcp) { - $port = $this->server->addListener($this->host, $this->port, SWOOLE_SOCK_TCP); - - if ($port instanceof Port) { - $this->tcpPort = $port; - // TCP framing and PROXY parsing live in Server, so Swoole's - // kernel-level length-check is not applicable here. The - // cost (userland buffering) is negligible for DNS loads. - $this->tcpPort->set([ - 'open_length_check' => false, - ]); - } - } - } - - public function onWorkerStart(callable $callback): void - { - $this->server->on('WorkerStart', function ($server, $workerId) use ($callback) { - if (is_int($workerId)) { - \call_user_func($callback, $workerId); - } - }); - } - - public function onUdpPacket(callable $callback): void - { - $this->onUdpPacket = $callback; - - $this->server->on('Packet', function ($server, $data, $clientInfo) { - if (!is_string($data) || !is_array($clientInfo)) { - return; - } - - $ip = is_string($clientInfo['address'] ?? null) ? $clientInfo['address'] : ''; - $port = is_int($clientInfo['port'] ?? null) ? $clientInfo['port'] : 0; - - $response = \call_user_func($this->onUdpPacket, $data, $ip, $port, 512); - - if ($response !== '' && $server instanceof Server) { - $server->sendto($ip, $port, $response); - } - }); - } - - public function onTcpReceive(callable $callback): void - { - $this->onTcpReceive = $callback; - - if (!$this->tcpPort instanceof Port) { - return; - } - - $this->tcpPort->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { - $info = $server->getClientInfo($fd, $reactorId); - $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; - $port = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; - - \call_user_func($this->onTcpReceive, $fd, $data, $ip, $port); - }); - } - - public function onTcpClose(callable $callback): void - { - $this->onTcpClose = $callback; - - if (!$this->tcpPort instanceof Port) { - return; - } - - $this->tcpPort->on('Close', function (Server $server, int $fd) { - \call_user_func($this->onTcpClose, $fd); - }); - } - - public function sendTcp(int $fd, string $data): void - { - $this->server->send($fd, $data); - } - - public function closeTcp(int $fd): void - { - $this->server->close($fd); - } - - public function start(): void - { - Runtime::enableCoroutine(); - $this->server->start(); - } - - public function getName(): string - { - return 'swoole'; - } -} diff --git a/src/DNS/Adapter/SwooleTcp.php b/src/DNS/Adapter/SwooleTcp.php new file mode 100644 index 0000000..749fae2 --- /dev/null +++ b/src/DNS/Adapter/SwooleTcp.php @@ -0,0 +1,157 @@ + Per-fd message stream. */ + protected array $streams = []; + + /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ + protected mixed $onMessage; + + public function __construct( + protected string $host = '0.0.0.0', + protected int $tcpPort = 53, + protected int $numWorkers = 1, + protected int $maxCoroutines = 3000, + ?Server $server = null, + ) { + if ($server === null) { + $this->server = new Server($this->host, $this->tcpPort, SWOOLE_PROCESS, SWOOLE_SOCK_TCP); + $this->server->set([ + 'worker_num' => $this->numWorkers, + 'max_coroutine' => $this->maxCoroutines, + ]); + $this->port = $this->server; + $this->owned = true; + } else { + $this->server = $server; + $listener = $server->addListener($this->host, $this->tcpPort, SWOOLE_SOCK_TCP); + if (!$listener instanceof Port) { + throw new \RuntimeException('Could not add TCP listener to Swoole server.'); + } + $this->port = $listener; + $this->owned = false; + } + + $this->port->set([ + 'open_length_check' => false, + ]); + } + + public function onWorkerStart(callable $callback): void + { + $this->server->on('WorkerStart', function ($server, $workerId) use ($callback) { + if (is_int($workerId)) { + \call_user_func($callback, $workerId); + } + }); + } + + public function onMessage(callable $callback): void + { + $this->onMessage = $callback; + + $this->port->on('Connect', function (Server $server, int $fd) { + $info = $server->getClientInfo($fd); + $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; + $port = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; + + $this->streams[$fd] = new TcpMessageStream( + peerIp: $ip, + peerPort: $port, + enableProxyProtocol: $this->enableProxyProtocol, + ); + }); + + $this->port->on('Close', function (Server $server, int $fd) { + unset($this->streams[$fd]); + }); + + $this->port->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) { + $stream = $this->streams[$fd] ?? null; + + if ($stream === null) { + $info = $server->getClientInfo($fd, $reactorId); + $ip = is_array($info) && is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; + $port = is_array($info) && is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; + + $stream = new TcpMessageStream( + peerIp: $ip, + peerPort: $port, + enableProxyProtocol: $this->enableProxyProtocol, + ); + $this->streams[$fd] = $stream; + } + + try { + foreach ($stream->feed($data) as [$message, $ip, $port]) { + $response = \call_user_func( + $this->onMessage, + $message, + $ip, + $port, + TcpMessageStream::MAX_MESSAGE_SIZE, + ); + + if ($response !== '') { + if (strlen($response) > TcpMessageStream::MAX_MESSAGE_SIZE) { + $server->close($fd); + return; + } + $server->send($fd, pack('n', strlen($response)) . $response); + } + } + } catch (ProxyDecodingException | MessageDecodingException) { + $server->close($fd); + } + }); + } + + public function start(): void + { + if ($this->owned) { + Runtime::enableCoroutine(); + $this->server->start(); + } + } + + public function getName(): string + { + return 'swoole-tcp'; + } + + public function getServer(): Server + { + return $this->server; + } +} diff --git a/src/DNS/Adapter/SwooleUdp.php b/src/DNS/Adapter/SwooleUdp.php new file mode 100644 index 0000000..56e7caa --- /dev/null +++ b/src/DNS/Adapter/SwooleUdp.php @@ -0,0 +1,114 @@ +server = new Server($this->host, $this->port, SWOOLE_PROCESS, SWOOLE_SOCK_UDP); + $this->server->set([ + 'worker_num' => $this->numWorkers, + 'max_coroutine' => $this->maxCoroutines, + ]); + $this->owned = true; + } else { + $this->server = $server; + $this->owned = false; + } + } + + public function onWorkerStart(callable $callback): void + { + $this->server->on('WorkerStart', function ($server, $workerId) use ($callback) { + if (is_int($workerId)) { + \call_user_func($callback, $workerId); + } + }); + } + + public function onMessage(callable $callback): void + { + $this->onMessage = $callback; + + $this->server->on('Packet', function ($server, $data, $clientInfo) { + if (!is_string($data) || !is_array($clientInfo)) { + return; + } + + $peerIp = is_string($clientInfo['address'] ?? null) ? $clientInfo['address'] : ''; + $peerPort = is_int($clientInfo['port'] ?? null) ? $clientInfo['port'] : 0; + $ip = $peerIp; + $port = $peerPort; + $payload = $data; + + if ($this->enableProxyProtocol) { + try { + $header = ProxyProtocolStream::unwrapDatagram($payload); + } catch (ProxyDecodingException) { + return; + } + + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { + $ip = $header->sourceAddress; + $port = $header->sourcePort; + } + } + + $response = \call_user_func($this->onMessage, $payload, $ip, $port, self::UDP_MAX_MESSAGE_SIZE); + + if ($response !== '' && $server instanceof Server) { + // Reply goes back to the actual UDP peer (the proxy), not the parsed client. + $server->sendto($peerIp, $peerPort, $response); + } + }); + } + + public function start(): void + { + if ($this->owned) { + Runtime::enableCoroutine(); + $this->server->start(); + } + } + + public function getName(): string + { + return 'swoole-udp'; + } + + public function getServer(): Server + { + return $this->server; + } +} diff --git a/src/DNS/Server.php b/src/DNS/Server.php index a3a9e61..d34cb7c 100644 --- a/src/DNS/Server.php +++ b/src/DNS/Server.php @@ -4,7 +4,6 @@ use Throwable; use Utopia\DNS\Exception\Message\PartialDecodingException; -use Utopia\DNS\Exception\ProxyProtocol\DecodingException as ProxyDecodingException; use Utopia\Span\Span; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; @@ -60,20 +59,6 @@ class Server { - /** RFC 1035: UDP replies are capped at 512 bytes (EDNS0 aside). */ - public const int UDP_MAX_RESPONSE_SIZE = 512; - - /** RFC 1035: TCP frames use a 2-byte length prefix, so max 65535 bytes. */ - public const int TCP_MAX_MESSAGE_SIZE = 65535; - - /** - * Hard cap on per-connection TCP buffer size. Prevents slow-loris style - * attacks from consuming unbounded memory while a preamble or frame is - * being accumulated. Must fit at least one max-sized DNS frame plus a - * PROXY v1 preamble (107 bytes). - */ - public const int TCP_MAX_BUFFER_SIZE = 131072; - protected Adapter $adapter; protected Resolver $resolver; @@ -82,17 +67,6 @@ class Server protected bool $debug = false; - protected bool $enableProxyProtocol = false; - - /** @var array Per-fd TCP receive buffer. */ - protected array $tcpBuffers = []; - - /** @var array Per-fd PROXY preamble resolver (only if enabled). */ - protected array $tcpProxyStreams = []; - - /** @var array Per-fd effective client address (peer or PROXY-resolved). */ - protected array $tcpAddresses = []; - protected ?Histogram $duration = null; protected ?Counter $queriesTotal = null; protected ?Counter $responsesTotal = null; @@ -162,7 +136,6 @@ public function setDebug(bool $status): self */ public function setProxyProtocol(bool $enabled): self { - $this->enableProxyProtocol = $enabled; $this->adapter->setProxyProtocol($enabled); return $this; } @@ -177,11 +150,10 @@ protected function handleError(Throwable $error): void /** * Handle a complete DNS message. * - * Called once per decoded query, regardless of transport (UDP or TCP). - * Subclasses override this hook to customize message handling; by - * default it runs the decode/resolve/encode pipeline against the - * configured resolver. $ip and $port are the real client address - * (already resolved from PROXY protocol when enabled). + * Called once per decoded query, regardless of transport. $ip and + * $port are the real client address (already resolved from PROXY + * protocol when enabled). Subclasses override this hook to customize + * message handling; the default runs decode → resolve → encode. */ protected function onMessage(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string { @@ -301,124 +273,10 @@ protected function onMessage(string $buffer, string $ip, int $port, ?int $maxRes } } - /** - * UDP adapter callback. Strips a PROXY preamble (if enabled) and - * delegates to {@see onMessage()}. - */ - private function dispatchUdp(string $buffer, string $ip, int $port, ?int $maxResponseSize): string - { - if ($this->enableProxyProtocol) { - try { - $header = ProxyProtocolStream::unwrapDatagram($buffer); - } catch (ProxyDecodingException $e) { - $this->handleError($e); - return ''; - } - - if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { - $ip = $header->sourceAddress; - $port = $header->sourcePort; - } - } - - return $this->onMessage($buffer, $ip, $port, $maxResponseSize); - } - - /** - * TCP adapter callback. Buffers bytes, consumes a PROXY preamble when - * present, extracts length-prefixed DNS frames, and sends responses - * back via the adapter. - */ - private function dispatchTcpReceive(int $fd, string $bytes, string $ip, int $port): void - { - if (!isset($this->tcpBuffers[$fd])) { - $this->tcpBuffers[$fd] = ''; - $this->tcpAddresses[$fd] = ['ip' => $ip, 'port' => $port]; - if ($this->enableProxyProtocol) { - $this->tcpProxyStreams[$fd] = new ProxyProtocolStream(); - } - } - - $buffer = $this->tcpBuffers[$fd] . $bytes; - - if (strlen($buffer) > self::TCP_MAX_BUFFER_SIZE) { - $this->adapter->closeTcp($fd); - return; - } - - $stream = $this->tcpProxyStreams[$fd] ?? null; - - if ($stream !== null && $stream->state() === ProxyProtocolStream::STATE_UNRESOLVED) { - try { - $state = $stream->resolve($buffer); - } catch (ProxyDecodingException $e) { - $this->handleError($e); - $this->adapter->closeTcp($fd); - return; - } - - if ($state === ProxyProtocolStream::STATE_UNRESOLVED) { - $this->tcpBuffers[$fd] = $buffer; - return; - } - - $header = $stream->header(); - if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { - $this->tcpAddresses[$fd] = [ - 'ip' => $header->sourceAddress, - 'port' => $header->sourcePort, - ]; - } - } - - while (strlen($buffer) >= 2) { - $unpacked = unpack('n', substr($buffer, 0, 2)); - $frameLength = (is_array($unpacked) && is_int($unpacked[1] ?? null)) ? $unpacked[1] : 0; - - // RFC 1035 / 7766: 0-length frames are invalid; oversize frames are either misframed or hostile. - if ($frameLength === 0 || $frameLength > self::TCP_MAX_MESSAGE_SIZE) { - $this->adapter->closeTcp($fd); - return; - } - - if (strlen($buffer) < $frameLength + 2) { - break; - } - - $message = substr($buffer, 2, $frameLength); - $buffer = substr($buffer, $frameLength + 2); - - $address = $this->tcpAddresses[$fd]; - $response = $this->onMessage($message, $address['ip'], $address['port'], self::TCP_MAX_MESSAGE_SIZE); - - if ($response !== '') { - if (strlen($response) > self::TCP_MAX_MESSAGE_SIZE) { - // Truncation should have been applied already; if not, bail rather than corrupt framing. - $this->adapter->closeTcp($fd); - return; - } - $this->adapter->sendTcp($fd, pack('n', strlen($response)) . $response); - } - } - - $this->tcpBuffers[$fd] = $buffer; - } - - private function dispatchTcpClose(int $fd): void - { - unset( - $this->tcpBuffers[$fd], - $this->tcpProxyStreams[$fd], - $this->tcpAddresses[$fd], - ); - } - public function start(): void { try { - $this->adapter->onUdpPacket($this->dispatchUdp(...)); - $this->adapter->onTcpReceive($this->dispatchTcpReceive(...)); - $this->adapter->onTcpClose($this->dispatchTcpClose(...)); + $this->adapter->onMessage($this->onMessage(...)); $this->adapter->start(); } catch (Throwable $error) { $this->handleError($error); diff --git a/src/DNS/TcpMessageStream.php b/src/DNS/TcpMessageStream.php new file mode 100644 index 0000000..71cd17b --- /dev/null +++ b/src/DNS/TcpMessageStream.php @@ -0,0 +1,117 @@ +proxyStream = $enableProxyProtocol ? new ProxyProtocolStream() : null; + } + + /** + * Feed newly-received bytes and iterate over any complete DNS + * messages that are now available. + * + * Each yielded value is a tuple [message bytes, peer ip, peer port]. + * The peer fields reflect the current resolved address — for a + * PROXY-enabled stream they switch from the transport peer to the + * PROXY-declared source after the preamble is consumed. + * + * @return Generator + * + * @throws ProxyDecodingException if the PROXY preamble is malformed. + * @throws MessageDecodingException if framing is invalid or the buffer overflows. + */ + public function feed(string $bytes): Generator + { + $this->buffer .= $bytes; + + if (\strlen($this->buffer) > self::MAX_BUFFER_SIZE) { + throw new MessageDecodingException('TCP buffer exceeded maximum size.'); + } + + if ($this->proxyStream !== null && $this->proxyStream->state() === ProxyProtocolStream::STATE_UNRESOLVED) { + $state = $this->proxyStream->resolve($this->buffer); + + if ($state === ProxyProtocolStream::STATE_UNRESOLVED) { + return; + } + + $header = $this->proxyStream->header(); + if ($header !== null && $header->sourceAddress !== null && $header->sourcePort !== null) { + $this->peerIp = $header->sourceAddress; + $this->peerPort = $header->sourcePort; + } + } + + while (\strlen($this->buffer) >= 2) { + $unpacked = \unpack('n', \substr($this->buffer, 0, 2)); + $frameLength = (\is_array($unpacked) && \is_int($unpacked[1] ?? null)) ? $unpacked[1] : 0; + + if ($frameLength === 0) { + throw new MessageDecodingException('TCP frame announced zero length.'); + } + + if ($frameLength > self::MAX_MESSAGE_SIZE) { + throw new MessageDecodingException("TCP frame length {$frameLength} exceeds maximum."); + } + + if (\strlen($this->buffer) < $frameLength + 2) { + return; + } + + $message = \substr($this->buffer, 2, $frameLength); + $this->buffer = \substr($this->buffer, $frameLength + 2); + + yield [$message, $this->peerIp, $this->peerPort]; + } + } + + public function peerIp(): string + { + return $this->peerIp; + } + + public function peerPort(): int + { + return $this->peerPort; + } +} diff --git a/tests/resources/server.php b/tests/resources/server.php index 0e15140..5501186 100644 --- a/tests/resources/server.php +++ b/tests/resources/server.php @@ -3,7 +3,8 @@ require __DIR__ . '/../../vendor/autoload.php'; use Utopia\DNS\Server; -use Utopia\DNS\Adapter\Swoole; +use Utopia\DNS\Adapter\SwooleUdp; +use Utopia\DNS\Adapter\SwooleTcp; use Utopia\DNS\Message; use Utopia\DNS\Message\Record; use Utopia\DNS\Resolver; @@ -22,7 +23,12 @@ Span::addExporter(new Exporter\Stdout()); $port = (int) (getenv('PORT') ?: 5300); -$server = new Swoole('0.0.0.0', $port); + +// Share one Swoole\Server between the UDP adapter and the TCP adapter so +// both listeners run under a single event loop on the same port. +$udpAdapter = new SwooleUdp('0.0.0.0', $port); +$tcpAdapter = new SwooleTcp('0.0.0.0', $port, server: $udpAdapter->getServer()); +$adapter = new \Utopia\DNS\Adapter\Composite($udpAdapter, $tcpAdapter); $records = [ // Single A @@ -108,7 +114,7 @@ public function getName(): string } }; -$dns = new Server($server, $multiZoneResolver); +$dns = new Server($adapter, $multiZoneResolver); $dns->setDebug(false); $dns->onWorkerStart(function (Server $server, int $workerId) { diff --git a/tests/unit/DNS/ServerTest.php b/tests/unit/DNS/ServerTest.php index f8ff5df..5232fb7 100644 --- a/tests/unit/DNS/ServerTest.php +++ b/tests/unit/DNS/ServerTest.php @@ -7,299 +7,128 @@ use Utopia\DNS\Message; use Utopia\DNS\Message\Question; use Utopia\DNS\Message\Record; -use Utopia\DNS\ProxyProtocol; use Utopia\DNS\Resolver; use Utopia\DNS\Server; -/** - * Server subclass that captures the (ip, port) pair passed to each DNS - * packet. The resolver interface intentionally does not see peer info — - * this hook is how the tests assert on PROXY-resolved addresses. - */ -class RecordingServer extends Server -{ - public ?string $lastIp = null; - - public ?int $lastPort = null; - - protected function onMessage(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string - { - $this->lastIp = $ip; - $this->lastPort = $port; - return parent::onMessage($buffer, $ip, $port, $maxResponseSize); - } -} - -/** - * @phpstan-type UdpCall array{buffer: string, ip: string, port: int, maxResponseSize: ?int} - */ final class ServerTest extends TestCase { - public function testUdpDirectQueryPassesPeerAddressThrough(): void + public function testMessageIsDecodedResolvedEncoded(): void { $adapter = new FakeAdapter(); $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - + $server = new Server($adapter, $resolver); $server->start(); $query = $this->buildQuery('example.com'); - $response = $adapter->deliverUdp($query, '203.0.113.9', 9876); + $response = $adapter->deliver($query, '203.0.113.9', 9876); - $this->assertNotSame('', $response); $decoded = Message::decode($response); - $this->assertSame('203.0.113.9', $server->lastIp); - $this->assertSame(9876, $server->lastPort); $this->assertCount(1, $decoded->answers); + $this->assertSame('example.com', $decoded->answers[0]->name); } - public function testUdpStripsPxyV1WhenProxyProtocolEnabled(): void - { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->setProxyProtocol(true); - $server->start(); - - $query = $this->buildQuery('example.com'); - $preamble = "PROXY TCP4 198.51.100.7 10.0.0.1 55555 53\r\n"; - $adapter->deliverUdp($preamble . $query, '10.0.0.254', 4444); - - $this->assertSame('198.51.100.7', $server->lastIp); - $this->assertSame(55555, $server->lastPort); - } - - public function testUdpTreatsUnwrappedDatagramAsDirectWhenProxyEnabled(): void + public function testPeerAddressIsForwardedToResolverHook(): void { $adapter = new FakeAdapter(); $resolver = new EchoResolver(); $server = new RecordingServer($adapter, $resolver); - $server->setProxyProtocol(true); $server->start(); $query = $this->buildQuery('example.com'); - $adapter->deliverUdp($query, '192.0.2.1', 12345); + $adapter->deliver($query, '203.0.113.9', 9876); - $this->assertSame('192.0.2.1', $server->lastIp); - $this->assertSame(12345, $server->lastPort); + $this->assertSame('203.0.113.9', $server->lastIp); + $this->assertSame(9876, $server->lastPort); } - public function testUdpDropsMalformedProxyDatagram(): void + public function testMalformedMessageReturnsFormerr(): void { $adapter = new FakeAdapter(); $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->setProxyProtocol(true); + $server = new Server($adapter, $resolver); $errors = []; $server->error(function (\Throwable $e) use (&$errors) { $errors[] = $e; }); $server->start(); - $query = $this->buildQuery('example.com'); - $response = $adapter->deliverUdp("PROXY TCP4 bogus 10.0.0.1 1 2\r\n" . $query, '10.0.0.254', 1); + // Minimal valid header (12 bytes) but with question count > 0 and no question section. + $malformed = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; + $response = $adapter->deliver($malformed, '127.0.0.1', 1234); - $this->assertSame('', $response); - $this->assertNull($server->lastIp); - $this->assertCount(1, $errors); + $decoded = Message::decode($response); + $this->assertSame(Message::RCODE_FORMERR, $decoded->header->responseCode); } - public function testTcpSingleFrameDeliveredAndResponded(): void + public function testNonQueryOpcodeReturnsNotimp(): void { $adapter = new FakeAdapter(); $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); + $server = new Server($adapter, $resolver); $server->start(); - $query = $this->buildQuery('example.com'); - $frame = pack('n', strlen($query)) . $query; - - $adapter->deliverTcp(1, $frame, '198.51.100.10', 40000); - - $sent = $adapter->sentTcp(1); - $this->assertCount(1, $sent); - $this->assertSame('198.51.100.10', $server->lastIp); - $this->assertSame(40000, $server->lastPort); - - $response = $sent[0]; - $unpacked = unpack('n', substr($response, 0, 2)); - $this->assertIsArray($unpacked); - $this->assertSame(strlen($response) - 2, $unpacked[1]); + // Opcode 2 (STATUS) in flags field. + $packet = "\x12\x34\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + $response = $adapter->deliver($packet, '127.0.0.1', 1234); - $decoded = Message::decode(substr($response, 2)); - $this->assertCount(1, $decoded->answers); + $decoded = Message::decode($response); + $this->assertSame(Message::RCODE_NOTIMP, $decoded->header->responseCode); } - public function testTcpFrameDeliveredAcrossMultipleChunks(): void + public function testResolverExceptionReturnsServfail(): void { $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); + $resolver = new ThrowingResolver(); + $server = new Server($adapter, $resolver); + $errors = []; + $server->error(function (\Throwable $e) use (&$errors) { + $errors[] = $e; + }); $server->start(); $query = $this->buildQuery('example.com'); - $frame = pack('n', strlen($query)) . $query; - - $chunks = str_split($frame, 3); - foreach ($chunks as $i => $chunk) { - $adapter->deliverTcp(7, $chunk, '203.0.113.1', 1234); - - if ($i < count($chunks) - 1) { - $this->assertSame([], $adapter->sentTcp(7), 'response must not fire until frame complete'); - } - } - - $this->assertCount(1, $adapter->sentTcp(7)); - } + $response = $adapter->deliver($query, '127.0.0.1', 1234); - public function testTcpMultipleFramesInSingleChunk(): void - { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->start(); - - $q1 = $this->buildQuery('a.example'); - $q2 = $this->buildQuery('b.example'); - $payload = pack('n', strlen($q1)) . $q1 . pack('n', strlen($q2)) . $q2; - - $adapter->deliverTcp(5, $payload, '203.0.113.2', 2345); - - $this->assertCount(2, $adapter->sentTcp(5)); + $decoded = Message::decode($response); + $this->assertSame(Message::RCODE_SERVFAIL, $decoded->header->responseCode); + $this->assertCount(1, $errors); } - public function testTcpConsumesProxyV1PreambleBeforeFraming(): void + public function testSetProxyProtocolPropagatesToAdapter(): void { $adapter = new FakeAdapter(); $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->setProxyProtocol(true); - $server->start(); - - $query = $this->buildQuery('example.com'); - $preamble = "PROXY TCP4 192.0.2.10 10.0.0.1 55000 443\r\n"; - $frame = pack('n', strlen($query)) . $query; - - $adapter->deliverTcp(10, $preamble . $frame, '10.0.0.254', 4444); - - $this->assertCount(1, $adapter->sentTcp(10)); - $this->assertSame('192.0.2.10', $server->lastIp); - $this->assertSame(55000, $server->lastPort); - } + $server = new Server($adapter, $resolver); - public function testTcpConsumesProxyV2PreambleBeforeFraming(): void - { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); + $this->assertFalse($adapter->hasProxyProtocol()); $server->setProxyProtocol(true); - $server->start(); - - $addrPayload = inet_pton('198.51.100.5') . inet_pton('10.0.0.1') . pack('nn', 11000, 53); - $preamble = ProxyProtocol::V2_SIGNATURE - . chr(0x21) - . chr(0x11) - . pack('n', strlen($addrPayload)) - . $addrPayload; - $query = $this->buildQuery('example.com'); - $frame = pack('n', strlen($query)) . $query; - - $adapter->deliverTcp(11, $preamble . $frame, '10.0.0.254', 0); - - $this->assertCount(1, $adapter->sentTcp(11)); - $this->assertSame('198.51.100.5', $server->lastIp); + $this->assertTrue($adapter->hasProxyProtocol()); + $server->setProxyProtocol(false); + $this->assertFalse($adapter->hasProxyProtocol()); } - public function testTcpDirectConnectionWorksWhenProxyProtocolEnabled(): void - { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->setProxyProtocol(true); - $server->start(); - - $query = $this->buildQuery('example.com'); - $frame = pack('n', strlen($query)) . $query; - - $adapter->deliverTcp(12, $frame, '203.0.113.20', 4000); - - $this->assertCount(1, $adapter->sentTcp(12)); - $this->assertSame('203.0.113.20', $server->lastIp); - } - - public function testTcpClosesOnMalformedProxyPreamble(): void - { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->setProxyProtocol(true); - $server->start(); - - $adapter->deliverTcp(13, "PROXY TCP4 bogus 10.0.0.1 1 2\r\nrest", '10.0.0.254', 0); - - $this->assertTrue($adapter->wasClosed(13)); - } - - public function testTcpClosesOnOversizeBuffer(): void - { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->start(); - - // Send a declared huge frame length; body won't fit past TCP_MAX_BUFFER_SIZE. - $adapter->deliverTcp(14, pack('n', 65535) . str_repeat('A', Server::TCP_MAX_BUFFER_SIZE), '203.0.113.30', 1); - - $this->assertTrue($adapter->wasClosed(14)); - } - - public function testTcpClosesOnZeroLengthFrame(): void + private function buildQuery(string $name): string { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->start(); - - $adapter->deliverTcp(15, pack('n', 0), '203.0.113.31', 1); - - $this->assertTrue($adapter->wasClosed(15)); + return Message::query(new Question($name, Record::TYPE_A))->encode(); } +} - public function testOnTcpCloseClearsState(): void - { - $adapter = new FakeAdapter(); - $resolver = new EchoResolver(); - $server = new RecordingServer($adapter, $resolver); - $server->setProxyProtocol(true); - $server->start(); - - // Start a partial PROXY preamble to allocate server-side state. - $adapter->deliverTcp(16, 'PROXY TCP4 1', '10.0.0.254', 0); - // Close connection. - $adapter->closeConnection(16); - - // Re-use the same fd for a new connection. State must not leak. - $query = $this->buildQuery('example.com'); - $frame = pack('n', strlen($query)) . $query; - $adapter->deliverTcp(16, $frame, '203.0.113.40', 5000); +class RecordingServer extends Server +{ + public ?string $lastIp = null; - $this->assertCount(1, $adapter->sentTcp(16)); - $this->assertSame('203.0.113.40', $server->lastIp); - } + public ?int $lastPort = null; - private function buildQuery(string $name): string + protected function onMessage(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string { - $query = Message::query(new Question($name, Record::TYPE_A)); - return $query->encode(); + $this->lastIp = $ip; + $this->lastPort = $port; + return parent::onMessage($buffer, $ip, $port, $maxResponseSize); } } final class EchoResolver implements Resolver { - public ?string $lastIp = null; - - public ?int $lastPort = null; - public function getName(): string { return 'echo'; @@ -324,62 +153,42 @@ public function resolve(Message $query): Message } } -/** - * Test double that records the server's interactions with the adapter. - * Tests simulate incoming traffic via deliverUdp() / deliverTcp() and - * inspect sent bytes via sentTcp(). - */ -final class FakeAdapter extends Adapter +final class ThrowingResolver implements Resolver { - /** @var callable(string, string, int, ?int): string */ - public $onUdpPacket; - - /** @var callable(int, string, string, int): void */ - public $onTcpReceive; - - /** @var callable(int): void */ - public $onTcpClose; - - /** @var array> */ - private array $sent = []; - - /** @var array */ - private array $closed = []; - - public function onWorkerStart(callable $callback): void - { - // No-op. - } - - public function onUdpPacket(callable $callback): void + public function getName(): string { - $this->onUdpPacket = $callback; + return 'throwing'; } - public function onTcpReceive(callable $callback): void + public function resolve(Message $query): Message { - $this->onTcpReceive = $callback; + throw new \RuntimeException('resolver failed'); } +} - public function onTcpClose(callable $callback): void - { - $this->onTcpClose = $callback; - } +/** + * Minimal transport-neutral adapter for Server unit tests: call + * {@see deliver()} to simulate a message arriving on the wire and + * receive the response the Server would send back. + */ +final class FakeAdapter extends Adapter +{ + /** @var callable(string, string, int, ?int): string */ + private $onMessage; - public function sendTcp(int $fd, string $data): void + public function onWorkerStart(callable $callback): void { - $this->sent[$fd][] = $data; + // Not used in unit tests. } - public function closeTcp(int $fd): void + public function onMessage(callable $callback): void { - $this->closed[$fd] = true; - \call_user_func($this->onTcpClose, $fd); + $this->onMessage = $callback; } public function start(): void { - // No-op for tests. + // Tests drive the adapter via deliver(); no loop needed. } public function getName(): string @@ -387,31 +196,8 @@ public function getName(): string return 'fake'; } - public function deliverUdp(string $buffer, string $ip, int $port): string - { - return \call_user_func($this->onUdpPacket, $buffer, $ip, $port, 512); - } - - public function deliverTcp(int $fd, string $bytes, string $ip, int $port): void - { - \call_user_func($this->onTcpReceive, $fd, $bytes, $ip, $port); - } - - public function closeConnection(int $fd): void - { - $this->closed[$fd] = true; - \call_user_func($this->onTcpClose, $fd); - unset($this->sent[$fd]); - } - - /** @return list */ - public function sentTcp(int $fd): array - { - return $this->sent[$fd] ?? []; - } - - public function wasClosed(int $fd): bool + public function deliver(string $buffer, string $ip, int $port, ?int $maxResponseSize = 512): string { - return $this->closed[$fd] ?? false; + return \call_user_func($this->onMessage, $buffer, $ip, $port, $maxResponseSize); } } diff --git a/tests/unit/DNS/TcpMessageStreamTest.php b/tests/unit/DNS/TcpMessageStreamTest.php new file mode 100644 index 0000000..c4f32e8 --- /dev/null +++ b/tests/unit/DNS/TcpMessageStreamTest.php @@ -0,0 +1,178 @@ +feed($frame), preserve_keys: false); + + $this->assertCount(1, $results); + $this->assertSame([$message, '198.51.100.10', 40000], $results[0]); + } + + public function testFramesSplitAcrossChunksAccumulate(): void + { + $stream = new TcpMessageStream('203.0.113.1', 1234); + $message = str_repeat('A', 40); + $frame = pack('n', strlen($message)) . $message; + + $pieces = str_split($frame, 5); + $collected = []; + + foreach ($pieces as $i => $piece) { + foreach ($stream->feed($piece) as $result) { + $collected[] = $result; + } + if ($i < count($pieces) - 1) { + $this->assertSame([], $collected, "should not emit before frame complete (piece {$i})"); + } + } + + $this->assertCount(1, $collected); + $this->assertSame($message, $collected[0][0]); + } + + public function testMultipleFramesInSingleFeedEmitMultipleMessages(): void + { + $stream = new TcpMessageStream('1.2.3.4', 53); + $payload = ''; + foreach (['ONE', 'TWO', 'THREE'] as $m) { + $payload .= pack('n', strlen($m)) . $m; + } + + $results = iterator_to_array($stream->feed($payload), preserve_keys: false); + + $this->assertCount(3, $results); + $this->assertSame('ONE', $results[0][0]); + $this->assertSame('TWO', $results[1][0]); + $this->assertSame('THREE', $results[2][0]); + } + + public function testZeroLengthFrameThrows(): void + { + $stream = new TcpMessageStream('1.2.3.4', 53); + + $this->expectException(MessageDecodingException::class); + foreach ($stream->feed(pack('n', 0)) as $_) { + // consume generator + } + } + + public function testOversizeFrameThrows(): void + { + $stream = new TcpMessageStream('1.2.3.4', 53); + + $this->expectException(MessageDecodingException::class); + foreach ($stream->feed(pack('n', TcpMessageStream::MAX_MESSAGE_SIZE + 1)) as $_) { + } + } + + public function testBufferOverflowThrows(): void + { + $stream = new TcpMessageStream('1.2.3.4', 53, enableProxyProtocol: true); + + // PROXY preamble is pending (not resolved), so no framing runs. Feed + // more bytes than the buffer cap — should reject. + $this->expectException(MessageDecodingException::class); + foreach ($stream->feed(str_repeat('x', TcpMessageStream::MAX_BUFFER_SIZE + 1)) as $_) { + } + } + + public function testProxyV1PreambleUpdatesPeerAddress(): void + { + $stream = new TcpMessageStream('10.0.0.254', 443, enableProxyProtocol: true); + + $preamble = "PROXY TCP4 192.0.2.10 10.0.0.1 55000 443\r\n"; + $message = 'DNS-PAYLOAD'; + $frame = pack('n', strlen($message)) . $message; + + $results = iterator_to_array($stream->feed($preamble . $frame), preserve_keys: false); + + $this->assertCount(1, $results); + $this->assertSame($message, $results[0][0]); + $this->assertSame('192.0.2.10', $results[0][1]); + $this->assertSame(55000, $results[0][2]); + } + + public function testProxyV2PreambleUpdatesPeerAddress(): void + { + $stream = new TcpMessageStream('10.0.0.254', 0, enableProxyProtocol: true); + + $addrPayload = inet_pton('198.51.100.5') . inet_pton('10.0.0.1') . pack('nn', 11000, 53); + $preamble = ProxyProtocol::V2_SIGNATURE + . chr(0x21) + . chr(0x11) + . pack('n', strlen($addrPayload)) + . $addrPayload; + + $message = 'DNS-MESSAGE'; + $frame = pack('n', strlen($message)) . $message; + + $results = iterator_to_array($stream->feed($preamble . $frame), preserve_keys: false); + + $this->assertCount(1, $results); + $this->assertSame('198.51.100.5', $results[0][1]); + } + + public function testDirectConnectionWorksWhenProxyEnabled(): void + { + $stream = new TcpMessageStream('203.0.113.20', 4000, enableProxyProtocol: true); + + $message = 'DNS-PAYLOAD'; + $frame = pack('n', strlen($message)) . $message; + + $results = iterator_to_array($stream->feed($frame), preserve_keys: false); + + $this->assertCount(1, $results); + $this->assertSame('203.0.113.20', $results[0][1]); + } + + public function testMalformedProxyPreambleThrows(): void + { + $stream = new TcpMessageStream('10.0.0.254', 0, enableProxyProtocol: true); + + $this->expectException(ProxyDecodingException::class); + foreach ($stream->feed("PROXY TCP4 bogus 10.0.0.1 1 2\r\nrest") as $_) { + } + } + + public function testPeerAddressAccessors(): void + { + $stream = new TcpMessageStream('1.2.3.4', 5678); + + $this->assertSame('1.2.3.4', $stream->peerIp()); + $this->assertSame(5678, $stream->peerPort()); + } + + public function testPartialPreambleKeepsBufferForNextCall(): void + { + $stream = new TcpMessageStream('10.0.0.254', 0, enableProxyProtocol: true); + + $preamble = "PROXY TCP4 192.0.2.10 10.0.0.1 55000 443\r\n"; + $message = 'DNS'; + $frame = pack('n', strlen($message)) . $message; + + $pieces = str_split($preamble . $frame, 4); + $collected = []; + foreach ($pieces as $piece) { + foreach ($stream->feed($piece) as $result) { + $collected[] = $result; + } + } + + $this->assertCount(1, $collected); + $this->assertSame('192.0.2.10', $collected[0][1]); + } +}