diff --git a/README.md b/README.md index 9d84389..477e90f 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,8 @@ $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), +The server listens on 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. 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 diff --git a/composer.json b/composer.json index 9ba2d5b..2f2a37d 100755 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "scripts": { "lint": "./vendor/bin/pint --test --config pint.json", "format": "./vendor/bin/pint --config pint.json", - "check": "./vendor/bin/phpstan analyse --level 8 -c phpstan.neon src tests", + "check": "./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..535208e 100644 --- a/src/DNS/Adapter/Native.php +++ b/src/DNS/Adapter/Native.php @@ -8,31 +8,46 @@ class Native extends Adapter { - protected Socket $server; + protected Socket $udpServer; - /** @var callable(string $buffer, string $ip, int $port): string */ + protected ?Socket $tcpServer = null; + + /** @var array */ + protected array $tcpClients = []; + + /** @var array */ + protected array $tcpBuffers = []; + + /** @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 - */ - 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 = 8192 + ) { $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 +63,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 +75,93 @@ 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); + $readSockets = [$this->udpServer]; - if ($len > 0) { - $answer = call_user_func($this->onPacket, $buf, $ip, $port); + if ($this->tcpServer) { + $readSockets[] = $this->tcpServer; + } - if (socket_sendto($this->server, $answer, strlen($answer), 0, $ip, $port) === false) { - printf('Error in socket\n'); + foreach ($this->tcpClients as $client) { + $readSockets[] = $client; + } + + $write = []; + $except = []; + + $changed = socket_select($readSockets, $write, $except, null); + + if ($changed === false) { + 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] = ''; + } + + continue; + } + + // Remaining readable sockets are TCP clients. + $this->handleTcpClient($socket); } } } @@ -94,4 +175,97 @@ 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; + } + + $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; + + 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, null); + + if ($answer !== '') { + $this->sendTcpResponse($client, $answer); + } + } + } + } + + protected function sendTcpResponse(Socket $client, string $payload): void + { + $frame = pack('n', strlen($payload)) . $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; + } + } + + protected function closeTcpClient(Socket $client): void + { + $id = spl_object_id($client); + + unset($this->tcpClients[$id], $this->tcpBuffers[$id]); + + @socket_close($client); + } } diff --git a/src/DNS/Adapter/Swoole.php b/src/DNS/Adapter/Swoole.php index 6f76281..dd940f9 100644 --- a/src/DNS/Adapter/Swoole.php +++ b/src/DNS/Adapter/Swoole.php @@ -5,22 +5,38 @@ use Swoole\Runtime; use Utopia\DNS\Adapter; use Swoole\Server; +use Swoole\Server\Port; class Swoole extends Adapter { 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; + /** @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) - { - $this->host = $host; - $this->port = $port; + public function __construct( + protected string $host = '0.0.0.0', + protected int $port = 53, + protected bool $enableTcp = true + ) { $this->server = new Server($this->host, $this->port, SWOOLE_PROCESS, SWOOLE_SOCK_UDP); + + 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, + ]); + } + } } /** @@ -31,30 +47,55 @@ public function __construct(string $host = '0.0.0.0', int $port = 53) 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, null); + + 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..26febee 100644 --- a/src/DNS/Message.php +++ b/src/DNS/Message.php @@ -183,7 +183,7 @@ public static function decode(string $packet): self return new self($header, $questions, $answers, $authority, $additional); } - public function encode(): string + public function encode(?int $maxSize = null): string { $packet = $this->header->encode(); @@ -203,6 +203,23 @@ public function encode(): string $packet .= $additional->encode($packet); } + // Apply truncation if size limit is set and exceeded + if ($maxSize !== null && strlen($packet) > $maxSize) { + $truncated = self::response( + $this->header, + $this->header->responseCode, + questions: $this->questions, + answers: [], + authority: [], + additional: [], + authoritative: $this->header->authoritative, + truncated: true, + recursionAvailable: $this->header->recursionAvailable + ); + + return $truncated->encode(); + } + return $packet; } } diff --git a/src/DNS/Message/Header.php b/src/DNS/Message/Header.php index 66a4220..733434b 100644 --- a/src/DNS/Message/Header.php +++ b/src/DNS/Message/Header.php @@ -39,14 +39,28 @@ 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']; return new self( - id: $values['id'], + id: $id, isResponse: (bool) (($flags >> 15) & 0x1), opcode: ($flags >> 11) & 0xF, authoritative: (bool) (($flags >> 10) & 0x1), @@ -54,10 +68,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 16f60aa..6d6fe60 100644 --- a/src/DNS/Server.php +++ b/src/DNS/Server.php @@ -165,10 +165,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 { $startTime = microtime(true); Console::info("[PACKET] Received packet of " . strlen($buffer) . " bytes from $ip:$port"); @@ -252,7 +253,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) { Console::error("[ERROR] Failed to encode message: " . $e->getMessage()); Console::error("[ERROR] Packet dump: " . bin2hex($buffer)); @@ -297,7 +298,6 @@ public function start(): void Console::success('[DNS] Server is ready to accept connections'); - /** @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/tests/e2e/DNS/ClientTest.php b/tests/e2e/DNS/ClientTest.php index e2e6731..800fe9b 100644 --- a/tests/e2e/DNS/ClientTest.php +++ b/tests/e2e/DNS/ClientTest.php @@ -12,6 +12,21 @@ 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); + } + public function testARecords(): void { $client = new Client('127.0.0.1', self::PORT); @@ -255,4 +270,35 @@ public function testInvalidServer(): void $this->fail('IPv6 threw unexpected error'); } } + + public function testTcpFallbackAfterUdpTruncation(): void + { + $client = new Client('127.0.0.1', self::PORT); + + // Query for a TXT record that has large response (would trigger truncation over UDP) + $question = new Question('large.localhost', Record::TYPE_TXT); + $query = Message::query($question); + + // First try UDP - should get TC flag if response is large + $udpResponse = $client->query($query); + + // If truncated, retry with TCP + if ($udpResponse->header->truncated) { + $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 more data than UDP + $this->assertGreaterThan( + count($udpResponse->answers), + count($tcpResponse->answers), + 'TCP should return more answers than truncated UDP' + ); + } else { + // If not truncated, that's fine - the response fit in UDP + $this->addToAssertionCount(1); + } + } } 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/MessageTest.php b/tests/unit/DNS/MessageTest.php index 07af773..e98ba91 100644 --- a/tests/unit/DNS/MessageTest.php +++ b/tests/unit/DNS/MessageTest.php @@ -290,4 +290,74 @@ public function testDecodeNxDomainWithAuthority(): void $this->assertSame(900, $soa->ttl); $this->assertSame('ns1.example.com hostmaster.example.com 1 3600 900 604800 300', $soa->rdata); } + + 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 + $this->assertTrue($decoded->header->truncated, 'TC flag should be set on truncated response'); + + // Verify sections are cleared when truncated + $this->assertCount(0, $decoded->answers, 'Answers should be cleared when truncated'); + $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 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)); + } + + 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); + } }