diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index 01a53b7..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"defects":{"Utopia\\Tests\\ClientTest::testARecords":4,"Utopia\\Tests\\ClientTest::testAAAARecords":4,"Utopia\\Tests\\ClientTest::testCNAMERecords":4,"Utopia\\Tests\\ClientTest::testTXTRecords":4,"Utopia\\Tests\\ClientTest::testNSRecords":4,"Utopia\\Tests\\DNS\\Resolver\\CloudflareTest::testResolveGoogleA":4,"Utopia\\Tests\\DNS\\Resolver\\CloudflareTest::testResolveGoogleAAAA":4,"Utopia\\Tests\\DNS\\Resolver\\GoogleTest::testResolveGoogleA":4,"Utopia\\Tests\\DNS\\Resolver\\GoogleTest::testResolveGoogleAAAA":4},"times":{"Utopia\\Tests\\ClientTest::testARecords":5.006,"Utopia\\Tests\\ClientTest::testAAAARecords":5.003,"Utopia\\Tests\\ClientTest::testCNAMERecords":5.002,"Utopia\\Tests\\ClientTest::testTXTRecords":5.004,"Utopia\\Tests\\ClientTest::testNSRecords":5.003,"Utopia\\DNS\\Tests\\RecordTest::testConstructorDefaults":0.003,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetName":0,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetTTL":0,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetClass":0,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetTypeForA":0.001,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetRdata":0,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetPriority":0,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetWeight":0,"Utopia\\DNS\\Tests\\RecordTest::testSetAndGetPort":0,"Utopia\\DNS\\Tests\\RecordTest::testToString":0,"Utopia\\DNS\\Tests\\RecordTest::testMXRecord":0,"Utopia\\DNS\\Tests\\RecordTest::testSRVRecord":0,"Utopia\\DNS\\Tests\\RecordTest::testGetArrayCopyBasic":0,"Utopia\\DNS\\Tests\\RecordTest::testGetArrayCopyWithOptionalFields":0,"Utopia\\DNS\\Tests\\RecordTest::testGetArrayCopyExcludesUnsetOptionalFields":0,"Utopia\\Tests\\DNS\\Resolver\\CloudflareTest::testResolveGoogleA":0.002,"Utopia\\Tests\\DNS\\Resolver\\CloudflareTest::testResolveGoogleAAAA":0,"Utopia\\Tests\\DNS\\Resolver\\GoogleTest::testResolveGoogleA":0,"Utopia\\Tests\\DNS\\Resolver\\GoogleTest::testResolveGoogleAAAA":0,"Utopia\\DNS\\Tests\\ZoneTest::testExampleComZoneFile":0.003,"Utopia\\DNS\\Tests\\ZoneTest::testRedHatZoneFile":0.001,"Utopia\\DNS\\Tests\\ZoneTest::testOracle1ZoneFile":0.001,"Utopia\\DNS\\Tests\\ZoneTest::testOracle2ZoneFile":0.001,"Utopia\\DNS\\Tests\\ZoneTest::testLocalhostZoneFile":0,"Utopia\\DNS\\Tests\\ZoneTest::testValidateValidZoneWithDirectives":0,"Utopia\\DNS\\Tests\\ZoneTest::testValidateUnsupportedDirective":0.001,"Utopia\\DNS\\Tests\\ZoneTest::testValidateInvalidTTL":0,"Utopia\\DNS\\Tests\\ZoneTest::testValidateUnknownRecordType":0,"Utopia\\DNS\\Tests\\ZoneTest::testValidateMXRecordMissingPriority":0,"Utopia\\DNS\\Tests\\ZoneTest::testValidateSRVRecordMissingPart":0,"Utopia\\DNS\\Tests\\ZoneTest::testValidateBlankAndCommentLines":0,"Utopia\\DNS\\Tests\\ZoneTest::testValidateZeroTTL":0,"Utopia\\DNS\\Tests\\ZoneTest::testImportWithDirectivesAndAutoQualification":0.001,"Utopia\\DNS\\Tests\\ZoneTest::testImportIgnoresUnknownDirective":0,"Utopia\\DNS\\Tests\\ZoneTest::testImportSkipsMalformedLines":0,"Utopia\\DNS\\Tests\\ZoneTest::testExportBasic":0,"Utopia\\DNS\\Tests\\ZoneTest::testImportExportRoundTrip":0}} \ No newline at end of file diff --git a/README.md b/README.md index 9d84389..d2e93c9 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,15 @@ $adapter = new Native('0.0.0.0', 5300); $zone = new Zone( name: 'example.test', records: [ - new Record(name: 'example.test', type: Record::TYPE_A, rdata: '127.0.0.1', ttl: 60), + new Record(name: 'example.test', type: Record::TYPE_A, rdata: '192.0.2.1', ttl: 60), new Record(name: 'www.example.test', type: Record::TYPE_CNAME, rdata: 'example.test', ttl: 60), - new Record(name: 'example.test', type: Record::TYPE_TXT, rdata: '"demo record"', ttl: 60), - ], - soa: new Record( - name: 'example.test', - type: Record::TYPE_SOA, - rdata: 'ns1.example.test hostmaster.example.test 1 7200 1800 1209600 3600', - ttl: 60 - ), + new Record( + name: 'example.test', + type: Record::TYPE_SOA, + rdata: 'ns1.example.test hostmaster.example.test 1 7200 1800 1209600 3600', + ttl: 60 + ), + ] ); $server = new Server($adapter, new Memory($zone)); @@ -55,7 +54,7 @@ $server->setDebug(true); $server->start(); ``` -The server listens on UDP port `5300` and answers queries for `example.test` from the in-memory zone. Implement the [`Utopia\DNS\Resolver`](src/DNS/Resolver.php) interface to serve records from databases, APIs, or other stores. +The server listens on both UDP and TCP port `5300` (RFC 5966) and answers queries for `example.test` from the in-memory zone. Implement the [`Utopia\DNS\Resolver`](src/DNS/Resolver.php) interface to serve records from databases, APIs, or other stores. ## Resolvers - `Memory`: authoritative resolver backed by a `Zone` object diff --git a/composer.json b/composer.json index 44f3681..2411e3b 100755 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "scripts": { "format": "./vendor/bin/pint --config pint.json", "format:check": "./vendor/bin/pint --test --config pint.json", - "analyze": "./vendor/bin/phpstan analyse --level 8 -c phpstan.neon src tests", + "analyze": "./vendor/bin/phpstan analyse --level max -c phpstan.neon src tests", "test": "./vendor/bin/phpunit --configuration phpunit.xml" }, "authors": [ diff --git a/docker-compose.yml b/docker-compose.yml index abbcd4c..7c79bf5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,6 @@ services: - dns ports: - '5300:5300/udp' + - '5300:5300/tcp' networks: dns: diff --git a/src/DNS/Adapter.php b/src/DNS/Adapter.php index f0199c5..86e5eb0 100644 --- a/src/DNS/Adapter.php +++ b/src/DNS/Adapter.php @@ -15,8 +15,8 @@ abstract public function onWorkerStart(callable $callback): void; /** * Packet handler * - * @param callable(string $buffer, string $ip, int $port): string $callback - * @phpstan-param callable(string $buffer, string $ip, int $port):string $callback + * @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; diff --git a/src/DNS/Adapter/Native.php b/src/DNS/Adapter/Native.php index 82b0d1e..5635996 100644 --- a/src/DNS/Adapter/Native.php +++ b/src/DNS/Adapter/Native.php @@ -8,31 +8,65 @@ class Native extends Adapter { - protected Socket $server; + /** + * 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 */ + protected array $tcpClients = []; + + /** @var array */ + protected array $tcpBuffers = []; + + /** @var array Track last activity time per TCP client for idle timeout */ + protected array $tcpLastActivity = []; - /** @var callable(string $buffer, string $ip, int $port): string */ + /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ protected mixed $onPacket; /** @var list */ protected array $onWorkerStart = []; - protected string $host; - protected int $port; - /** - * @param string $host - * @param int $port + * @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(string $host = '0.0.0.0', int $port = 8053) - { - $this->host = $host; - $this->port = $port; + public function __construct( + protected string $host = '0.0.0.0', + 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.'); } - $this->server = $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; + } } /** @@ -48,7 +82,7 @@ public function onWorkerStart(callable $callback): void /** * @param callable $callback - * @phpstan-param callable(string $buffer, string $ip, int $port):string $callback + * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize):string $callback */ public function onPacket(callable $callback): void { @@ -60,27 +94,98 @@ public function onPacket(callable $callback): void */ public function start(): void { - if (socket_bind($this->server, $this->host, $this->port) == false) { + 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->onWorkerStart as $callback) { \call_user_func($callback, 0); } /** @phpstan-ignore-next-line */ while (1) { - $buf = ''; - $ip = ''; - $port = null; - $len = socket_recvfrom($this->server, $buf, 1024 * 4, 0, $ip, $port); + // 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; + } - if ($len > 0) { - $answer = call_user_func($this->onPacket, $buf, $ip, $port); + $write = []; + $except = []; - if (socket_sendto($this->server, $answer, strlen($answer), 0, $ip, $port) === false) { - printf('Error in socket\n'); + // Use 1 second timeout for socket_select to periodically check idle connections + $changed = socket_select($readSockets, $write, $except, 1); + + if ($changed === false || $changed === 0) { + continue; + } + + 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)) { + $answer = call_user_func($this->onPacket, $buf, $ip, $port, 512); + + if ($answer !== '') { + socket_sendto($this->udpServer, $answer, strlen($answer), 0, $ip, $port); + } + } + + 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(); + } + + continue; } + + // Remaining readable sockets are TCP clients. + $this->handleTcpClient($socket); } } } @@ -94,4 +199,149 @@ public function getName(): string { return 'native'; } + + protected function handleTcpClient(Socket $client): 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); + } + + return; + } + + // Update activity timestamp for idle timeout tracking + $this->tcpLastActivity[$clientId] = time(); + + $currentBufferSize = strlen($this->tcpBuffers[$clientId] ?? ''); + $chunkSize = strlen($chunk); + + if ($currentBufferSize + $chunkSize > $this->maxTcpBufferSize) { + printf("TCP buffer size limit exceeded for client %d\n", $clientId); + $this->closeTcpClient($client); + return; + } + + $this->tcpBuffers[$clientId] = ($this->tcpBuffers[$clientId] ?? '') . $chunk; + + 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; + } + + // 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 (strlen($this->tcpBuffers[$clientId]) < ($payloadLength + 2)) { + return; + } + + $message = substr($this->tcpBuffers[$clientId], 2, $payloadLength); + $this->tcpBuffers[$clientId] = substr($this->tcpBuffers[$clientId], $payloadLength + 2); + + $ip = ''; + $port = 0; + socket_getpeername($client, $ip, $port); + + if (is_string($ip) && is_int($port)) { + $answer = call_user_func($this->onPacket, $message, $ip, $port, self::MAX_TCP_MESSAGE_SIZE); + + if ($answer !== '') { + $this->sendTcpResponse($client, $answer); + } + } + } + } + + /** + * 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; + + 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->closeTcpClient($client); + return; + } + + $sent += $written; + } + } + + /** + * 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. + */ + protected function closeIdleTcpClients(): void + { + $now = time(); + + foreach ($this->tcpClients as $id => $client) { + $lastActivity = $this->tcpLastActivity[$id] ?? 0; + + if (($now - $lastActivity) > $this->tcpIdleTimeout) { + $this->closeTcpClient($client); + } + } + } + + protected function closeTcpClient(Socket $client): void + { + $id = spl_object_id($client); + + unset($this->tcpClients[$id], $this->tcpBuffers[$id], $this->tcpLastActivity[$id]); + + @socket_close($client); + } } diff --git a/src/DNS/Adapter/Swoole.php b/src/DNS/Adapter/Swoole.php index b806f86..a2c8ce4 100644 --- a/src/DNS/Adapter/Swoole.php +++ b/src/DNS/Adapter/Swoole.php @@ -5,30 +5,50 @@ use Swoole\Runtime; use Utopia\DNS\Adapter; use Swoole\Server; +use Swoole\Server\Port; 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; + protected Server $server; - /** @var callable(string $buffer, string $ip, int $port): string */ - protected mixed $onPacket; + protected ?Port $tcpPort = null; - protected string $host; - protected int $port; - protected int $numWorkers; - protected int $maxCoroutines; + /** @var callable(string $buffer, string $ip, int $port, ?int $maxResponseSize): string */ + protected mixed $onPacket; - public function __construct(string $host = '0.0.0.0', int $port = 53, int $numWorkers = 1, int $maxCoroutines = 3000) - { - $this->host = $host; - $this->port = $port; - $this->numWorkers = $numWorkers; - $this->maxCoroutines = $maxCoroutines; + 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 + ) { $this->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; + $this->tcpPort->set([ + 'open_length_check' => true, + 'package_length_type' => 'n', + 'package_length_offset' => 0, + 'package_body_offset' => 2, + 'package_max_length' => 65537, + ]); + } + } } /** @@ -39,30 +59,55 @@ public function __construct(string $host = '0.0.0.0', int $port = 53, int $numWo public function onWorkerStart(callable $callback): void { $this->server->on('WorkerStart', function ($server, $workerId) use ($callback) { - \call_user_func($callback, $workerId); + if (is_int($workerId)) { + \call_user_func($callback, $workerId); + } }); } /** * @param callable $callback - * @phpstan-param callable(string $buffer, string $ip, int $port):string $callback + * @phpstan-param callable(string $buffer, string $ip, int $port, ?int $maxResponseSize):string $callback */ public function onPacket(callable $callback): void { $this->onPacket = $callback; + // UDP handler - enforces 512-byte limit per RFC 1035 $this->server->on('Packet', function ($server, $data, $clientInfo) { - $ip = $clientInfo['address'] ?? ''; - $port = $clientInfo['port'] ?? ''; - $answer = \call_user_func($this->onPacket, $data, $ip, $port); - - // Swoole UDP sockets reject zero-length payloads; skip responding instead. - if ($answer === '') { + if (!is_string($data) || !is_array($clientInfo)) { return; } - $server->sendto($ip, $port, $answer); + $ip = is_string($clientInfo['address'] ?? null) ? $clientInfo['address'] : ''; + $port = is_int($clientInfo['port'] ?? null) ? $clientInfo['port'] : 0; + + $response = \call_user_func($this->onPacket, $data, $ip, $port, 512); + + if ($response !== '' && $server instanceof Server) { + $server->sendto($ip, $port, $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) { + $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); + } + }); + } } /** diff --git a/src/DNS/Client.php b/src/DNS/Client.php index 75acb3c..df7edbb 100644 --- a/src/DNS/Client.php +++ b/src/DNS/Client.php @@ -7,22 +7,22 @@ class Client { - /** @var \Socket */ - protected $socket; - protected string $server; - protected int $port; - protected int $timeout; - - public function __construct(string $server = '127.0.0.1', int $port = 53, int $timeout = 5) - { + public function __construct( + protected string $server = '127.0.0.1', + protected int $port = 53, + protected int $timeout = 5, + protected bool $useTcp = false, + /** @var \Socket|null */ + protected ?\Socket $socket = null + ) { $validator = new IP(IP::ALL); // IPv4 + IPv6 if (!$validator->isValid($server)) { throw new Exception('Server must be an IP address.'); } - $this->server = $server; - $this->port = $port; - $this->timeout = $timeout; + if ($this->useTcp) { + return; + } $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); @@ -43,6 +43,14 @@ public function __construct(string $server = '127.0.0.1', int $port = 53, int $t */ public function query(Message $message): Message { + if ($this->useTcp) { + return $this->queryTcp($message); + } + + if (!$this->socket instanceof \Socket) { + throw new Exception('UDP socket not initialized.'); + } + $packet = $message->encode(); if (socket_sendto($this->socket, $packet, strlen($packet), 0, $this->server, $this->port) === false) { throw new Exception('Failed to send data: ' . socket_strerror(socket_last_error($this->socket))); @@ -60,15 +68,119 @@ public function query(Message $message): Message throw new Exception("Failed to receive data from $this->server: $errorMessage (Error code: $error)"); } - if (empty($data)) { + if (empty($data) || !is_string($data)) { throw new Exception("Empty response received from $this->server:$this->port"); } - $response = Message::decode($data); - if ($response->header->id !== $message->header->id) { - throw new Exception("Mismatched DNS transaction ID. Expected {$message->header->id}, got {$response->header->id}"); + return $this->decodeResponse($message, $data); + } + + protected function queryTcp(Message $message): Message + { + $targetHost = $this->formatTcpHost($this->server); + $uri = "tcp://{$targetHost}:{$this->port}"; + + $errno = 0; + $errstr = ''; + $socket = @stream_socket_client($uri, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT); + + if ($socket === false) { + $errCode = is_int($errno) ? $errno : 0; + $errMsg = is_string($errstr) ? $errstr : 'Unknown error'; + throw new Exception("Failed to connect to {$this->server}:{$this->port} over TCP: $errMsg ($errCode)"); + } + + try { + stream_set_timeout($socket, $this->timeout); + + $packet = $message->encode(); + $frame = pack('n', strlen($packet)) . $packet; + + $written = fwrite($socket, $frame); + + if ($written === false || $written < strlen($frame)) { + throw new Exception('Failed to send full TCP DNS query.'); + } + + $lengthBytes = $this->readBytes($socket, 2); + + if (strlen($lengthBytes) !== 2) { + throw new Exception('Failed to read DNS TCP length prefix.'); + } + + $unpacked = unpack('nlen', $lengthBytes); + $length = (is_array($unpacked) && isset($unpacked['len']) && is_int($unpacked['len'])) ? $unpacked['len'] : 0; + + if ($length === 0) { + throw new Exception('Received empty DNS TCP response.'); + } + + $payload = $this->readBytes($socket, $length); + + if (strlen($payload) !== $length) { + throw new Exception('Incomplete DNS TCP response received.'); + } + + return $this->decodeResponse($message, $payload); + } finally { + fclose($socket); + } + } + + protected function decodeResponse(Message $query, string $payload): Message + { + $response = Message::decode($payload); + + if ($response->header->id !== $query->header->id) { + throw new Exception("Mismatched DNS transaction ID. Expected {$query->header->id}, got {$response->header->id}"); } return $response; } + + protected function readBytes(mixed $socket, int $length): string + { + if (!is_resource($socket)) { + return ''; + } + + $data = ''; + + while (strlen($data) < $length) { + $remaining = $length - strlen($data); + + if ($remaining <= 0) { + break; + } + + $chunk = fread($socket, max(1, $remaining)); + + if ($chunk === false) { + break; + } + + if ($chunk === '') { + $meta = stream_get_meta_data($socket); + + if (!empty($meta['timed_out']) || !empty($meta['eof'])) { + break; + } + + continue; + } + + $data .= $chunk; + } + + return $data; + } + + protected function formatTcpHost(string $host): string + { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return '[' . $host . ']'; + } + + return $host; + } } diff --git a/src/DNS/Message.php b/src/DNS/Message.php index 0d74123..fb1eb34 100644 --- a/src/DNS/Message.php +++ b/src/DNS/Message.php @@ -183,8 +183,21 @@ public static function decode(string $packet): self return new self($header, $questions, $answers, $authority, $additional); } - public function encode(): string + /** + * Encode the message to a binary DNS packet. + * + * When maxSize is specified, truncation follows RFC 1035 Section 6.2 and RFC 2181 Section 9: + * - Truncation starts at the end and works forward (additional → authority → answers) + * - TC flag is only set when required RRSets (answers) couldn't be fully included + * - Complete RRSets are preserved; partial RRSets are omitted entirely + * - Questions are always preserved + * + * @param int|null $maxSize Maximum packet size (e.g., 512 for UDP per RFC 1035) + * @return string The encoded DNS packet + */ + public function encode(?int $maxSize = null): string { + // Build full packet first $packet = $this->header->encode(); foreach ($this->questions as $question) { @@ -203,6 +216,101 @@ public function encode(): string $packet .= $additional->encode($packet); } - return $packet; + // No truncation needed + if ($maxSize === null || strlen($packet) <= $maxSize) { + return $packet; + } + + // RFC-compliant truncation: work backward from end + // Per RFC 1035 Section 6.2 and RFC 2181 Section 9 + return $this->encodeWithTruncation($maxSize); + } + + /** + * Encode with RFC-compliant truncation strategy. + * + * Truncation order per RFC 1035 Section 6.2: + * 1. Drop additional section first + * 2. If still too big, drop authority section + * 3. If still too big, include as many complete answer RRSets as fit, set TC + * + * TC flag is only set when answer section data is truncated (RFC 2181 Section 9). + */ + private function encodeWithTruncation(int $maxSize): string + { + // Step 1: Try without additional section + $withoutAdditional = self::response( + $this->header, + $this->header->responseCode, + questions: $this->questions, + answers: $this->answers, + authority: $this->authority, + additional: [], + authoritative: $this->header->authoritative, + truncated: false, + recursionAvailable: $this->header->recursionAvailable + ); + + $packet = $withoutAdditional->encode(); + if (strlen($packet) <= $maxSize) { + return $packet; + } + + // Step 2: Try without authority section + $withoutAuthority = self::response( + $this->header, + $this->header->responseCode, + questions: $this->questions, + answers: $this->answers, + authority: [], + additional: [], + authoritative: $this->header->authoritative, + truncated: false, + recursionAvailable: $this->header->recursionAvailable + ); + + $packet = $withoutAuthority->encode(); + if (strlen($packet) <= $maxSize) { + return $packet; + } + + // Step 3: Truncate answers - find how many complete records fit + // Build base packet with header + questions + $basePacket = $this->header->encode(); + foreach ($this->questions as $question) { + $basePacket .= $question->encode(); + } + + $fittingAnswers = []; + $tempPacket = $basePacket; + + foreach ($this->answers as $answer) { + $encodedAnswer = $answer->encode($tempPacket); + if (strlen($tempPacket) + strlen($encodedAnswer) <= $maxSize) { + $tempPacket .= $encodedAnswer; + $fittingAnswers[] = $answer; + } else { + // This answer doesn't fit, stop here + break; + } + } + + // Determine if we need to set TC flag + // Per RFC 2181 Section 9: TC is set only when required RRSet data couldn't fit + $needsTruncation = count($fittingAnswers) < count($this->answers); + + $truncatedResponse = self::response( + $this->header, + $this->header->responseCode, + questions: $this->questions, + answers: $fittingAnswers, + authority: [], + additional: [], + authoritative: $this->header->authoritative, + truncated: $needsTruncation, + recursionAvailable: $this->header->recursionAvailable + ); + + return $truncatedResponse->encode(); } } diff --git a/src/DNS/Message/Domain.php b/src/DNS/Message/Domain.php index 58481bb..1e89fae 100644 --- a/src/DNS/Message/Domain.php +++ b/src/DNS/Message/Domain.php @@ -74,11 +74,15 @@ public static function encode(string $name): string /** * Decode a domain name from DNS wire format, handling compression pointers. * + * Per RFC 1035 Section 4.1.4, compression pointers allow domain names to + * reference earlier occurrences in the packet. This implementation tracks + * visited pointer positions to prevent infinite loops from malicious packets. + * * @param string $data Full DNS packet * @param int $offset Current read offset (updated to first byte after the name) * @return string Decoded domain name in dotted form * - * @throws DecodingException when the packet is malformed. + * @throws DecodingException when the packet is malformed or contains pointer loops. */ public static function decode(string $data, int &$offset): string { @@ -86,15 +90,15 @@ public static function decode(string $data, int &$offset): string $jumped = false; $pos = $offset; $dataLength = strlen($data); - $loopGuard = 0; - while (true) { - if ($loopGuard++ > $dataLength) { - throw new DecodingException( - 'Possible compression pointer loop while decoding domain name' - ); - } + // Track visited pointer positions to detect loops (RFC 1035 compliance) + // This is more reliable than iteration counting as it catches actual cycles + $visitedPointers = []; + + // Maximum labels per RFC 1035 (127 labels * 63 chars + separators = 255 max) + $labelCount = 0; + while (true) { if ($pos >= $dataLength) { throw new DecodingException( 'Unexpected end of data while decoding domain name' @@ -117,11 +121,28 @@ public static function decode(string $data, int &$offset): string } $pointer = (($len & 0x3F) << 8) | ord($data[$pos + 1]); + + // RFC 1035: Pointer must reference earlier in packet (forward refs invalid) + if ($pointer >= $pos) { + throw new DecodingException( + 'Compression pointer must reference earlier position in packet' + ); + } + if ($pointer >= $dataLength) { throw new DecodingException( 'Compression pointer out of bounds in domain name' ); } + + // Detect pointer loops by tracking visited positions + if (isset($visitedPointers[$pointer])) { + throw new DecodingException( + 'Compression pointer loop detected in domain name' + ); + } + $visitedPointers[$pointer] = true; + if (!$jumped) { $offset = $pos + 2; } @@ -130,6 +151,14 @@ public static function decode(string $data, int &$offset): string continue; } + // Check for reserved label type (RFC 1035: bits 6-7 indicate label type) + // 00 = standard label, 11 = compression pointer, 01/10 = reserved + if (($len & 0xC0) !== 0) { + throw new DecodingException( + 'Reserved label type encountered in domain name' + ); + } + if ($pos + 1 + $len > $dataLength) { throw new DecodingException( 'Label length exceeds remaining data while decoding domain name' @@ -137,8 +166,15 @@ public static function decode(string $data, int &$offset): string } $labels[] = substr($data, $pos + 1, $len); + $labelCount++; $pos += $len + 1; + if ($labelCount > self::MAX_LABELS) { + throw new DecodingException( + 'Domain name exceeds maximum label count' + ); + } + if (!$jumped) { $offset = $pos; } diff --git a/src/DNS/Message/Header.php b/src/DNS/Message/Header.php index 66a4220..099e60d 100644 --- a/src/DNS/Message/Header.php +++ b/src/DNS/Message/Header.php @@ -30,6 +30,15 @@ public function __construct( } } + /** + * Decode DNS header from wire format. + * + * Per RFC 1035 Section 4.1.1, the Z bits (bits 4-6 of the flags field) + * MUST be zero. While the RFC says these bits should be ignored on + * receipt, we validate them to detect malformed or malicious packets. + * + * @throws DecodingException if header is malformed or Z bits are non-zero + */ public static function decode(string $data, int $offset = 0): self { if (strlen($data) < $offset + self::LENGTH) { @@ -39,14 +48,35 @@ public static function decode(string $data, int $offset = 0): self $chunk = substr($data, $offset, self::LENGTH); $values = unpack('nid/nflags/nqdcount/nancount/nnscount/narcount', $chunk); - if (!is_array($values)) { + if ( + !is_array($values) + || !isset($values['id'], $values['flags'], $values['qdcount'], $values['ancount'], $values['nscount'], $values['arcount']) + || !is_int($values['id']) + || !is_int($values['flags']) + || !is_int($values['qdcount']) + || !is_int($values['ancount']) + || !is_int($values['nscount']) + || !is_int($values['arcount']) + ) { throw new DecodingException('Failed to unpack DNS header'); } + $id = $values['id']; $flags = $values['flags']; + $qdcount = $values['qdcount']; + $ancount = $values['ancount']; + $nscount = $values['nscount']; + $arcount = $values['arcount']; + + // RFC 1035 Section 4.1.1: Z bits (bits 4-6) MUST be zero + // Z bits are at positions 4, 5, 6 counting from bit 0 (rightmost) + $zBits = ($flags >> 4) & 0x7; + if ($zBits !== 0) { + throw new DecodingException('Reserved Z bits must be zero per RFC 1035'); + } return new self( - id: $values['id'], + id: $id, isResponse: (bool) (($flags >> 15) & 0x1), opcode: ($flags >> 11) & 0xF, authoritative: (bool) (($flags >> 10) & 0x1), @@ -54,10 +84,10 @@ public static function decode(string $data, int $offset = 0): self recursionDesired: (bool) (($flags >> 8) & 0x1), recursionAvailable: (bool) (($flags >> 7) & 0x1), responseCode: $flags & 0xF, - questionCount: $values['qdcount'], - answerCount: $values['ancount'], - authorityCount: $values['nscount'], - additionalCount: $values['arcount'] + questionCount: $qdcount, + answerCount: $ancount, + authorityCount: $nscount, + additionalCount: $arcount ); } diff --git a/src/DNS/Message/Question.php b/src/DNS/Message/Question.php index e928067..6cdfff1 100644 --- a/src/DNS/Message/Question.php +++ b/src/DNS/Message/Question.php @@ -29,14 +29,14 @@ public static function decode(string $data, int &$offset = 0): self } $typeData = unpack('ntype', substr($data, $offset, 2)); - if (!is_array($typeData) || !array_key_exists('type', $typeData)) { + if (!is_array($typeData) || !array_key_exists('type', $typeData) || !is_int($typeData['type'])) { throw new DecodingException('Failed to unpack question type'); } $type = $typeData['type']; $offset += 2; $classData = unpack('nclass', substr($data, $offset, 2)); - if (!is_array($classData) || !array_key_exists('class', $classData)) { + if (!is_array($classData) || !array_key_exists('class', $classData) || !is_int($classData['class'])) { throw new DecodingException('Failed to unpack question class'); } $class = $classData['class']; diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 650ab4e..2618e7b 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -144,28 +144,28 @@ public static function decode(string $data, int &$offset): self throw new DecodingException('Truncated RR header'); } $typeData = unpack('ntype', substr($data, $offset, 2)); - if (!is_array($typeData) || !array_key_exists('type', $typeData)) { + if (!is_array($typeData) || !array_key_exists('type', $typeData) || !is_int($typeData['type'])) { throw new DecodingException('Failed to unpack record type'); } $type = $typeData['type']; $offset += 2; $classData = unpack('nclass', substr($data, $offset, 2)); - if (!is_array($classData) || !array_key_exists('class', $classData)) { + if (!is_array($classData) || !array_key_exists('class', $classData) || !is_int($classData['class'])) { throw new DecodingException('Failed to unpack record class'); } $class = $classData['class']; $offset += 2; $ttlData = unpack('Nttl', substr($data, $offset, 4)); - if (!is_array($ttlData) || !array_key_exists('ttl', $ttlData)) { + if (!is_array($ttlData) || !array_key_exists('ttl', $ttlData) || !is_int($ttlData['ttl'])) { throw new DecodingException('Failed to unpack record TTL'); } $ttl = $ttlData['ttl']; $offset += 4; $rdLengthData = unpack('nlength', substr($data, $offset, 2)); - if (!is_array($rdLengthData) || !array_key_exists('length', $rdLengthData)) { + if (!is_array($rdLengthData) || !array_key_exists('length', $rdLengthData) || !is_int($rdLengthData['length'])) { throw new DecodingException('Failed to unpack record length'); } $rdlength = $rdLengthData['length']; @@ -216,7 +216,7 @@ public static function decode(string $data, int &$offset): self throw new DecodingException('Invalid MX RDATA length: ' . strlen($rdataRaw)); } $priorityData = unpack('npriority', substr($rdataRaw, 0, 2)); - if (!is_array($priorityData) || !array_key_exists('priority', $priorityData)) { + if (!is_array($priorityData) || !array_key_exists('priority', $priorityData) || !is_int($priorityData['priority'])) { throw new DecodingException('Failed to unpack MX priority'); } $priority = $priorityData['priority']; @@ -231,13 +231,13 @@ public static function decode(string $data, int &$offset): self $priorityData = unpack('npriority', substr($rdataRaw, 0, 2)); $weightData = unpack('nweight', substr($rdataRaw, 2, 2)); $portData = unpack('nport', substr($rdataRaw, 4, 2)); - if (!is_array($priorityData) || !array_key_exists('priority', $priorityData)) { + if (!is_array($priorityData) || !array_key_exists('priority', $priorityData) || !is_int($priorityData['priority'])) { throw new DecodingException('Failed to unpack SRV priority'); } - if (!is_array($weightData) || !array_key_exists('weight', $weightData)) { + if (!is_array($weightData) || !array_key_exists('weight', $weightData) || !is_int($weightData['weight'])) { throw new DecodingException('Failed to unpack SRV weight'); } - if (!is_array($portData) || !array_key_exists('port', $portData)) { + if (!is_array($portData) || !array_key_exists('port', $portData) || !is_int($portData['port'])) { throw new DecodingException('Failed to unpack SRV port'); } $priority = $priorityData['priority']; @@ -258,12 +258,24 @@ public static function decode(string $data, int &$offset): self } $fields = unpack('Nserial/Nrefresh/Nretry/Nexpire/Nminimum', $timingData); - if (!is_array($fields)) { + if ( + !is_array($fields) + || !isset($fields['serial'], $fields['refresh'], $fields['retry'], $fields['expire'], $fields['minimum']) + || !is_int($fields['serial']) + || !is_int($fields['refresh']) + || !is_int($fields['retry']) + || !is_int($fields['expire']) + || !is_int($fields['minimum']) + ) { throw new DecodingException('Unable to unpack SOA timings'); } // Convert signed to unsigned for serial $serial = $fields['serial']; + $refresh = $fields['refresh']; + $retry = $fields['retry']; + $expire = $fields['expire']; + $minimum = $fields['minimum']; if ($serial < 0) { $serial += 4294967296; } @@ -273,10 +285,10 @@ public static function decode(string $data, int &$offset): self $mname, $rname, $serial, - $fields['refresh'], - $fields['retry'], - $fields['expire'], - $fields['minimum'] + $refresh, + $retry, + $expire, + $minimum ); break; @@ -433,7 +445,7 @@ private function encodeRdata(string $packet): string } return pack('nnn', $priority, $weight, $port) . - Domain::encode($this->rdata); + Domain::encode($this->rdata); case self::TYPE_TXT: $len = strlen($this->rdata); diff --git a/src/DNS/Server.php b/src/DNS/Server.php index 08962f2..85b0495 100644 --- a/src/DNS/Server.php +++ b/src/DNS/Server.php @@ -159,10 +159,11 @@ protected function handleError(Throwable $error): void * @param string $buffer * @param string $ip * @param int $port + * @param int|null $maxResponseSize * * @return string */ - protected function onPacket(string $buffer, string $ip, int $port): string + protected function onPacket(string $buffer, string $ip, int $port, ?int $maxResponseSize = null): string { $span = Span::init('dns.packet'); $span->set('client.ip', $ip); @@ -183,7 +184,7 @@ protected function onPacket(string $buffer, string $ip, int $port): string Message::RCODE_FORMERR, authoritative: false ); - return $response->encode(); + return $response->encode($maxResponseSize); } catch (Throwable $e) { $span->setError($e); $this->handleError($e); @@ -193,6 +194,17 @@ protected function onPacket(string $buffer, string $ip, int $port): string $this->duration?->record($decodeDuration, ['phase' => 'decode']); $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, + Message::RCODE_NOTIMP, + authoritative: false + ); + return $response->encode($maxResponseSize); + } + $question = $query->questions[0] ?? null; if ($question === null) { $response = Message::response( @@ -200,7 +212,7 @@ protected function onPacket(string $buffer, string $ip, int $port): string Message::RCODE_FORMERR, authoritative: false ); - return $response->encode(); + return $response->encode($maxResponseSize); } $span->set('dns.question.name', $question->name); @@ -235,7 +247,7 @@ protected function onPacket(string $buffer, string $ip, int $port): string // 3. Encode response $encodeStart = microtime(true); try { - return $response->encode(); + return $response->encode($maxResponseSize); } catch (Throwable $e) { $span->setError($e); $this->handleError($e); @@ -246,7 +258,7 @@ protected function onPacket(string $buffer, string $ip, int $port): string questions: $query->questions, authoritative: false ); - return $response->encode(); + return $response->encode($maxResponseSize); } finally { $encodeDuration = microtime(true) - $encodeStart; $this->duration?->record($encodeDuration, [ @@ -274,7 +286,6 @@ protected function onPacket(string $buffer, string $ip, int $port): string public function start(): void { try { - /** @phpstan-var \Closure(string $buffer, string $ip, int $port):string $onPacket */ $onPacket = $this->onPacket(...); $this->adapter->onPacket($onPacket); $this->adapter->start(); diff --git a/src/DNS/Validator/DNS.php b/src/DNS/Validator/DNS.php index ded9829..14a6bcc 100644 --- a/src/DNS/Validator/DNS.php +++ b/src/DNS/Validator/DNS.php @@ -58,7 +58,7 @@ public function getDescription(): string $records = implode("', '", $this->records); - $countVerbose = match($this->count) { + $countVerbose = match ($this->count) { 1 => 'one', 2 => 'two', 3 => 'three', @@ -117,7 +117,6 @@ public function isValid(mixed $value): bool $query = array_filter($answers, function ($record) { return $record->type === $this->type; }); - } catch (\Exception $e) { $this->reason = self::FAILURE_REASON_QUERY; return false; diff --git a/src/DNS/Zone/Resolver.php b/src/DNS/Zone/Resolver.php index bdaabce..ef1646b 100644 --- a/src/DNS/Zone/Resolver.php +++ b/src/DNS/Zone/Resolver.php @@ -164,12 +164,12 @@ private static function handleExactMatch(array $records, Message $query, Zone $z ); if (!empty($exactTypeRecords)) { - // E1: Return exact type match + // E1: Return exact type match (randomized for load balancing) return Message::response( header: $query->header, responseCode: Message::RCODE_NOERROR, questions: $query->questions, - answers: array_values($exactTypeRecords), + answers: self::randomizeRRSet(array_values($exactTypeRecords)), authoritative: true, recursionAvailable: false ); @@ -215,6 +215,28 @@ private static function handleExactMatch(array $records, Message $query, Zone $z } } + /** + * Randomize RRSet order for load balancing. + * + * Per RFC 2181 Section 5, the order of resource records within an RRSet + * is not significant. By randomizing the order, we help distribute load + * across multiple servers (e.g., multiple A records for the same name). + * + * @param list $records + * @return list + */ + private static function randomizeRRSet(array $records): array + { + if (count($records) <= 1) { + return $records; + } + + // RFC 2181 Section 5: Order within RRSet is not significant + // Randomization helps load balance across multiple A/AAAA records + shuffle($records); + return $records; + } + /** * Check if a query name matches a wildcard record name * @@ -255,7 +277,7 @@ private static function handleWildcardMatch(array $records, Message $query, Zone ); if (!empty($exactTypeRecords)) { - // Synthesize records with the query name + // Synthesize records with the query name (randomized for load balancing) $synthesizedRecords = array_map( fn ($r) => $r->withName($question->name), $exactTypeRecords @@ -265,7 +287,7 @@ private static function handleWildcardMatch(array $records, Message $query, Zone header: $query->header, responseCode: Message::RCODE_NOERROR, questions: $query->questions, - answers: array_values($synthesizedRecords), + answers: self::randomizeRRSet(array_values($synthesizedRecords)), authoritative: true, recursionAvailable: false ); diff --git a/tests/e2e/DNS/ClientTest.php b/tests/e2e/DNS/ClientTest.php index e2e6731..7213f4e 100644 --- a/tests/e2e/DNS/ClientTest.php +++ b/tests/e2e/DNS/ClientTest.php @@ -12,6 +12,26 @@ final class ClientTest extends TestCase { public const int PORT = 5300; + public function testTcpQueries(): void + { + $client = new Client('127.0.0.1', self::PORT, 5, true); + + $response = $client->query(Message::query( + new Question('dev2.appwrite.io', Record::TYPE_A) + )); + + $records = $response->answers; + + $this->assertCount(2, $records); + $this->assertSame('dev2.appwrite.io', $records[0]->name); + $this->assertSame(Record::TYPE_A, $records[0]->type); + $this->assertSame(Record::CLASS_IN, $records[0]->class); + $this->assertSame(1800, $records[0]->ttl); + // RRSet order is randomized for load balancing per RFC 2181 + $rdataValues = array_map(fn ($r) => $r->rdata, $records); + $this->assertEqualsCanonicalizing(['142.6.0.1', '142.6.0.2'], $rdataValues); + } + public function testARecords(): void { $client = new Client('127.0.0.1', self::PORT); @@ -38,8 +58,9 @@ public function testARecords(): void $this->assertSame(Record::CLASS_IN, $records[0]->class); $this->assertSame(1800, $records[0]->ttl); $this->assertSame(Record::TYPE_A, $records[0]->type); - $this->assertSame('142.6.0.1', $records[0]->rdata); - $this->assertSame('142.6.0.2', $records[1]->rdata); + // RRSet order is randomized for load balancing per RFC 2181 + $rdataValues = array_map(fn ($r) => $r->rdata, $records); + $this->assertEqualsCanonicalizing(['142.6.0.1', '142.6.0.2'], $rdataValues); $response = $client->query(Message::query( new Question('dev3.appwrite.io', Record::TYPE_A) @@ -68,8 +89,9 @@ public function testAAAARecords(): void $records = $response->answers; $this->assertCount(2, $records); - $this->assertSame('2001:db8::ff00:0:1', $records[0]->rdata); - $this->assertSame('2001:db8::ff00:0:2', $records[1]->rdata); + // RRSet order is randomized for load balancing per RFC 2181 + $rdataValues = array_map(fn ($r) => $r->rdata, $records); + $this->assertEqualsCanonicalizing(['2001:db8::ff00:0:1', '2001:db8::ff00:0:2'], $rdataValues); $response = $client->query(Message::query( new Question('dev3.appwrite.io', Record::TYPE_AAAA) @@ -255,4 +277,34 @@ public function testInvalidServer(): void $this->fail('IPv6 threw unexpected error'); } } + + public function testTcpFallbackAfterUdpTruncation(): void + { + // Query for large.localhost TXT records - the response is large enough + // to always trigger truncation over UDP (8 TXT records > 512 bytes) + $question = new Question('large.localhost', Record::TYPE_TXT); + $query = Message::query($question); + + // UDP query should be truncated (TC flag set) due to 512-byte limit + $udpClient = new Client('127.0.0.1', self::PORT); + $udpResponse = $udpClient->query($query); + $this->assertTrue($udpResponse->header->truncated, 'UDP response should be truncated for large response'); + + // TCP query should return full response without truncation + $tcpClient = new Client('127.0.0.1', self::PORT, useTcp: true); + $tcpResponse = $tcpClient->query($query); + + // TCP response should not be truncated + $this->assertFalse($tcpResponse->header->truncated, 'TCP response should not be truncated'); + + // TCP response should have all 8 TXT records from the zone file + $this->assertCount(8, $tcpResponse->answers, 'TCP should return all 8 TXT records'); + + // TCP response should have more answers than truncated UDP + $this->assertGreaterThan( + count($udpResponse->answers), + count($tcpResponse->answers), + 'TCP should return more answers than truncated UDP' + ); + } } diff --git a/tests/resources/server.php b/tests/resources/server.php index ff63108..0e15140 100644 --- a/tests/resources/server.php +++ b/tests/resources/server.php @@ -4,9 +4,12 @@ use Utopia\DNS\Server; use Utopia\DNS\Adapter\Swoole; +use Utopia\DNS\Message; use Utopia\DNS\Message\Record; -use Utopia\DNS\Resolver\Memory; +use Utopia\DNS\Resolver; use Utopia\DNS\Zone; +use Utopia\DNS\Zone\File; +use Utopia\DNS\Zone\Resolver as ZoneResolver; use Utopia\Span\Span; use Utopia\Span\Storage; use Utopia\Span\Exporter; @@ -45,7 +48,7 @@ new Record(name: 'delegated.appwrite.io', type: Record::TYPE_NS, rdata: 'ns2.test.io', ttl: 30), ]; -$zone = new Zone( +$appwriteZone = new Zone( name: 'appwrite.io', records: $records, soa: new Record( @@ -56,7 +59,56 @@ ) ); -$dns = new Server($server, new Memory($zone)); +// Load the localhost zone from zone file (contains large.localhost TXT records for TCP truncation tests) +$localhostZoneContent = (string) file_get_contents(__DIR__ . '/zone-valid-localhost.txt'); +$localhostZone = File::import($localhostZoneContent); + +/** + * Simple multi-zone resolver for testing purposes + */ +$multiZoneResolver = new class([$appwriteZone, $localhostZone]) implements Resolver { + /** @param list $zones */ + public function __construct(private readonly array $zones) + { + } + + public function resolve(Message $query): Message + { + $question = $query->questions[0] ?? null; + if ($question === null) { + return Message::response( + header: $query->header, + responseCode: Message::RCODE_FORMERR, + authoritative: true, + ); + } + + // Find the matching zone for this query + $queryName = strtolower($question->name); + foreach ($this->zones as $zone) { + $zoneName = $zone->name; + if ($queryName === $zoneName || str_ends_with($queryName, '.' . $zoneName)) { + return ZoneResolver::lookup($query, $zone); + } + } + + // No matching zone found - return NXDOMAIN with first zone's SOA + return Message::response( + header: $query->header, + responseCode: Message::RCODE_NXDOMAIN, + questions: $query->questions, + authority: [$this->zones[0]->soa], + authoritative: true, + ); + } + + public function getName(): string + { + return 'multi-zone-memory'; + } +}; + +$dns = new Server($server, $multiZoneResolver); $dns->setDebug(false); $dns->onWorkerStart(function (Server $server, int $workerId) { diff --git a/tests/resources/zone-valid-localhost.txt b/tests/resources/zone-valid-localhost.txt index cc0309f..3fd3e07 100644 --- a/tests/resources/zone-valid-localhost.txt +++ b/tests/resources/zone-valid-localhost.txt @@ -8,4 +8,12 @@ $ORIGIN localhost. ) @ 86400 IN NS @ @ 86400 IN A 127.0.0.1 -@ 86400 IN AAAA ::1 \ No newline at end of file +@ 86400 IN AAAA ::1 +large 300 IN TXT "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua" +large 300 IN TXT "Ut enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat" +large 300 IN TXT "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur" +large 300 IN TXT "Excepteur sint occaecat cupidatat non proident sunt in culpa qui officia deserunt mollit anim id est laborum" +large 300 IN TXT "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium totam rem aperiam" +large 300 IN TXT "Eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo" +large 300 IN TXT "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit sed quia consequuntur magni dolores" +large 300 IN TXT "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet consectetur adipisci velit" \ No newline at end of file diff --git a/tests/unit/DNS/Message/DomainTest.php b/tests/unit/DNS/Message/DomainTest.php index 7cdca30..a3acbf0 100644 --- a/tests/unit/DNS/Message/DomainTest.php +++ b/tests/unit/DNS/Message/DomainTest.php @@ -72,13 +72,89 @@ public function testDecodeCompressionPointer(): void $this->assertSame(strlen($first) + strlen($pointer), $offset); } + /** + * Test that self-referencing compression pointers are rejected. + * + * Per RFC 1035 Section 4.1.4, compression pointers must point to + * earlier positions in the packet. A pointer at offset 0 pointing + * to offset 0 is invalid (would create infinite loop). + */ public function testDecodePointerLoopRaisesException(): void { - $data = "\xC0\x00"; // pointer loops to itself + $data = "\xC0\x00"; // pointer at offset 0 pointing to offset 0 (self-reference) + $offset = 0; + + $this->expectException(DecodingException::class); + $this->expectExceptionMessage('Compression pointer must reference earlier position'); + + Domain::decode($data, $offset); + } + + /** + * Test that forward-referencing compression pointers are rejected. + * + * Per RFC 1035 Section 4.1.4, compression pointers must point backward. + * Forward references can create loops and are not valid DNS packets. + */ + public function testDecodeForwardPointerRaisesException(): void + { + // Packet: pointer at offset 0 pointing to offset 5, which doesn't exist yet + $data = "\xC0\x05\x03www\x00"; $offset = 0; $this->expectException(DecodingException::class); - $this->expectExceptionMessage('Possible compression pointer loop'); + $this->expectExceptionMessage('Compression pointer must reference earlier position'); + + Domain::decode($data, $offset); + } + + /** + * Test that pointer cycles are prevented by forward reference validation. + * + * With strict backward-pointer validation per RFC 1035, true pointer + * cycles become impossible. This test verifies that a potential cycle + * is caught by the forward reference check before it can loop. + */ + public function testDecodePointerCyclePreventedByForwardCheck(): void + { + // Attempting to create a cycle: offset 0 -> offset 4 -> offset 0 + // But offset 0 -> offset 4 is a forward reference, caught immediately + $data = "\xC0\x04\x00\x00\xC0\x00"; + $offset = 0; + + $this->expectException(DecodingException::class); + $this->expectExceptionMessage('Compression pointer must reference earlier position'); + + Domain::decode($data, $offset); + } + + /** + * Test that visiting the same backward pointer twice is detected. + * + * Even with strict backward-only pointers, we track visited positions + * to catch any edge cases that might slip through. + */ + public function testDecodeRevisitedPointerRaisesException(): void + { + // Create a packet where we start mid-stream and encounter a pointer + // that we've already visited in this decode operation. + // Structure: [label "a"][pointer to 0][label "b"][null] + // Then start decoding at offset 2 (the pointer) + $data = "\x01a\xC0\x00\x01b\x00"; + // offset 0: label "a" + // offset 2: pointer to offset 0 + // offset 4: label "b" + // offset 6: null terminator + + // When we decode starting at offset 2: + // - We see pointer to offset 0 + // - We follow to offset 0, see label "a" + // - We advance to offset 2, see pointer to offset 0 AGAIN + // - This is a revisit of pointer target 0 + $offset = 2; + + $this->expectException(DecodingException::class); + $this->expectExceptionMessage('Compression pointer loop detected'); Domain::decode($data, $offset); } diff --git a/tests/unit/DNS/MessageTest.php b/tests/unit/DNS/MessageTest.php index 07af773..9bb9889 100644 --- a/tests/unit/DNS/MessageTest.php +++ b/tests/unit/DNS/MessageTest.php @@ -290,4 +290,169 @@ public function testDecodeNxDomainWithAuthority(): void $this->assertSame(900, $soa->ttl); $this->assertSame('ns1.example.com hostmaster.example.com 1 3600 900 604800 300', $soa->rdata); } + + /** + * Tests RFC-compliant truncation behavior per RFC 1035 Section 6.2 and RFC 2181 Section 9. + * + * Truncation should: + * 1. Work backward from the end (additional → authority → answers) + * 2. Preserve as many complete answer RRSets as fit + * 3. Only set TC flag when required data (answers) couldn't fully fit + */ + public function testEncodeTruncatesWhenExceedingMaxSize(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0x1234); + + // Create a response with many answers that will exceed 512 bytes + $answers = []; + for ($i = 0; $i < 100; $i++) { + $answers[] = new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.' . ($i % 256) . '.' . ($i % 256)); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: [], + additional: [] + ); + + // Encode with 512-byte limit (UDP max per RFC 1035) + $truncated = $response->encode(512); + $decoded = Message::decode($truncated); + + // Verify TC flag is set (RFC 2181 Section 9: TC set when answers couldn't fit) + $this->assertTrue($decoded->header->truncated, 'TC flag should be set when answers are truncated'); + + // RFC 1035 Section 6.2: Preserve as many complete answer records as fit + $this->assertGreaterThan(0, count($decoded->answers), 'Should include answers that fit within size limit'); + $this->assertLessThan(100, count($decoded->answers), 'Not all answers should fit'); + + // Verify other sections are cleared per RFC truncation order + $this->assertCount(0, $decoded->authority, 'Authority should be cleared when truncated'); + $this->assertCount(0, $decoded->additional, 'Additional should be cleared when truncated'); + + // Verify question is always preserved + $this->assertCount(1, $decoded->questions); + $this->assertSame($query->questions[0]->name, $decoded->questions[0]->name); + + // Verify truncated packet is within size limit + $this->assertLessThanOrEqual(512, strlen($truncated)); + } + + /** + * Tests that additional section is dropped first without setting TC flag. + * Per RFC 2181 Section 9: TC should NOT be set merely because extra info couldn't fit. + */ + public function testTruncationDropsAdditionalSectionFirst(): void + { + $question = new Question('example.com', Record::TYPE_MX); + $query = Message::query($question, id: 0x5678); + + // Small answers that fit, but additional section pushes over limit + $answers = [ + new Record('example.com', Record::TYPE_MX, Record::CLASS_IN, 300, 'mail.example.com', priority: 10), + ]; + + // Large additional section (glue records) + $additional = []; + for ($i = 0; $i < 50; $i++) { + $additional[] = new Record('mail' . $i . '.example.com', Record::TYPE_A, Record::CLASS_IN, 300, '192.168.1.' . $i); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: [], + additional: $additional + ); + + $truncated = $response->encode(512); + $decoded = Message::decode($truncated); + + // TC should NOT be set - answers fit, only additional was dropped + $this->assertFalse($decoded->header->truncated, 'TC should NOT be set when only additional section is dropped'); + + // Answers should be preserved + $this->assertCount(1, $decoded->answers); + $this->assertSame('example.com', $decoded->answers[0]->name); + + // Additional section should be dropped + $this->assertCount(0, $decoded->additional); + } + + /** + * Tests that authority section is dropped after additional, before answers. + */ + public function testTruncationDropsAuthoritySectionSecond(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0x9ABC); + + // Small answer that fits + $answers = [ + new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.1'), + ]; + + // Authority section with NS records + $authority = []; + for ($i = 0; $i < 30; $i++) { + $authority[] = new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 3600, 'ns' . $i . '.example.com'); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: $authority, + additional: [] + ); + + $truncated = $response->encode(512); + $decoded = Message::decode($truncated); + + // TC should NOT be set - answers fit, only authority was dropped + $this->assertFalse($decoded->header->truncated, 'TC should NOT be set when only authority section is dropped'); + + // Answers should be preserved + $this->assertCount(1, $decoded->answers); + + // Authority section should be dropped + $this->assertCount(0, $decoded->authority); + } + + public function testEncodeWithoutMaxSizeDoesNotTruncate(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0x1234); + + $answers = []; + for ($i = 0; $i < 5; $i++) { + $answers[] = new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.' . $i); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: [], + additional: [] + ); + + // Encode without size limit + $encoded = $response->encode(); + $decoded = Message::decode($encoded); + + // Verify TC flag is NOT set + $this->assertFalse($decoded->header->truncated, 'TC flag should not be set on non-truncated response'); + + // Verify all answers are preserved + $this->assertCount(5, $decoded->answers); + } } diff --git a/tests/unit/DNS/Zone/ResolverTest.php b/tests/unit/DNS/Zone/ResolverTest.php index 7951ef6..0146a09 100644 --- a/tests/unit/DNS/Zone/ResolverTest.php +++ b/tests/unit/DNS/Zone/ResolverTest.php @@ -183,6 +183,10 @@ public function testLookupSynthesizesWildcardCname(): void $this->assertSame($wildcard->rdata, $answer->rdata); } + /** + * Test that multiple A records for the same name are all returned. + * Note: RRSet order is randomized for load balancing (RFC 2181 Section 5). + */ public function testLookupReturnsMultipleExactTypeRecords(): void { $soa = new Record( @@ -202,9 +206,24 @@ public function testLookupReturnsMultipleExactTypeRecords(): void $this->assertSame(Message::RCODE_NOERROR, $response->header->responseCode); $this->assertCount(2, $response->answers); - $this->assertSame([$aPrimary, $aSecondary], $response->answers); + + // Check both records are present (order may vary due to RRSet randomization) + $rdatas = array_map(fn ($r) => $r->rdata, $response->answers); + $this->assertContains('203.0.113.10', $rdatas); + $this->assertContains('203.0.113.20', $rdatas); + + // Verify all records have correct type and name + foreach ($response->answers as $answer) { + $this->assertSame('www.example.com', $answer->name); + $this->assertSame(Record::TYPE_A, $answer->type); + } } + /** + * Test that wildcard MX records preserve priority after synthesis. + * Note: RRSet order is randomized for load balancing (RFC 2181 Section 5), + * so we check for record presence without assuming specific order. + */ public function testLookupSynthesizesWildcardMxPreservingPriority(): void { $soa = new Record( @@ -236,18 +255,23 @@ public function testLookupSynthesizesWildcardMxPreservingPriority(): void $this->assertSame(Message::RCODE_NOERROR, $response->header->responseCode); $this->assertCount(2, $response->answers); - $synthesizedPrimary = $response->answers[0]; - $synthesizedSecondary = $response->answers[1]; - - $this->assertSame('api.example.com', $synthesizedPrimary->name); - $this->assertSame(Record::TYPE_MX, $synthesizedPrimary->type); - $this->assertSame(10, $synthesizedPrimary->priority); - $this->assertSame('mail1.example.com', $synthesizedPrimary->rdata); - - $this->assertSame('api.example.com', $synthesizedSecondary->name); - $this->assertSame(Record::TYPE_MX, $synthesizedSecondary->type); - $this->assertSame(20, $synthesizedSecondary->priority); - $this->assertSame('mail2.example.com', $synthesizedSecondary->rdata); + + // Extract priorities and rdata for order-independent comparison + $answers = $response->answers; + $priorities = array_map(fn ($r) => $r->priority, $answers); + $rdatas = array_map(fn ($r) => $r->rdata, $answers); + + // Both records should have synthesized name + foreach ($answers as $answer) { + $this->assertSame('api.example.com', $answer->name); + $this->assertSame(Record::TYPE_MX, $answer->type); + } + + // Check both expected MX records are present (order may vary due to RRSet randomization) + $this->assertContains(10, $priorities); + $this->assertContains(20, $priorities); + $this->assertContains('mail1.example.com', $rdatas); + $this->assertContains('mail2.example.com', $rdatas); } public function testLookupReturnsReferralForDelegatedSubdomain(): void