[0x00,'n',1], // ID 'header' => [0x01,'n',1], // Header 'qdcount' => [0x02,'n',1], // Entries in the question 'ancount' => [0x03,'n',1], // Resource Records in the answer 'nscount' => [0x04,'n',1], // Server Resource Records in the answer 'arcount' => [0x05,'n',1], // Resource Records in the addition records section ]; /** * Handle a DNS query * * https://www.ietf.org/rfc/rfc1035.txt * https://github.com/guyinatuxedo/dns-fuzzer/blob/master/dns.md * * labels 63 octets or less * names 255 octets or less * TTL positive values of a signed 32 bit number. * UDP messages 512 octets or less * * @param array $remote * @param string $buf * @param \Socket $socket * @return void * dig +noedns -t CNAME mail.dcml.au @1.1.1.1 ; <<>> DiG 9.10.6 <<>> +noedns -t CNAME mail.dcml.au @1.1.1.1 ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 6473 ;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0 ;; QUESTION SECTION: ;mail.dcml.au. IN CNAME ;; ANSWER SECTION: mail.dcml.au. 300 IN CNAME l.dege.au. ;; Query time: 43 msec ;; SERVER: 1.1.1.1#53(1.1.1.1) ;; WHEN: Thu Apr 20 06:47:55 PST 2023 ;; MSG SIZE rcvd: 51 06:47:54.995929 IP 192.168.130.184.51502 > one.one.one.one.domain: 6473+ CNAME? mail.dcml.au. (30) 0x0000: cc03 d9cc 88cb bcd0 7414 d055 0800 4500 ........t..U..E. 0x0010: 003a eee2 0000 4011 466e c0a8 82b8 0101 .:....@.Fn...... 0x0020: 0101 c92e 0035 0026 bb5f|1949 0120 0001 .....5.&._.I.... 0x0030: 0000 0000 0000 046d 6169 6c04 6463 6d6c .......mail.dcml 0x0040: 0261 7500 0005 0001 .au..... 06:47:55.034171 IP one.one.one.one.domain > 192.168.130.184.51502: 6473$ 1/0/0 CNAME l.dege.au. (51) 0x0000: bcd0 7414 d055 cc03 d9cc 88cb 0800 4588 ..t..U........E. 0x0010: 004f 514a 4000 3a11 a969 0101 0101 c0a8 .OQJ@.:..i...... 0x0020: 82b8 0035 c92e 003b 9274|1949 81a0 0001 ...5...;.t.I.... 0x0030: 0001 0000 0000 046d 6169 6c04 6463 6d6c .......mail.dcml 0x0040: 0261 7500 0005 0001 c00c 0005 0001 0000 .au............. 0x0050: 012c 0009 016c 0464 6567 65c0 16 .,...l.dege.. */ public function onConnect(array $remote,string $buf,\Socket $socket) { Log::debug(sprintf('%s:+ DNS Query',self::LOGKEY)); $header_len = collect(self::header)->sum(function($item) { return $item[2]*2; }); $this->rx_buf = $buf; $this->rx_ptr = 0; // DNS Query header $this->header = unpack(self::unpackheader(self::header),$buf); $this->rx_ptr += $header_len; $this->remote = $remote; $this->socket = $socket; // If there is no query count, then its an error if ($this->header['qdcount'] !== 1) { Log::error(sprintf('%s:! DNS query doesnt have the right number of queries [%d]',self::LOGKEY,$this->header['qdcount'])); $this->reply(self::DNS_FORMERR); return; } // Get the query elements $labels = collect(); while (($len=ord($this->read(1))) !== 0x00) $labels->push($this->read($len)); $this->question = substr($this->rx_buf,$header_len,$this->rx_ptr-$header_len+4); // We need a minimum of f.n.z.d.root if ($labels->count() < 5) { Log::error(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$labels->join('.'))); $this->reply(self::DNS_NAMEERR); return; } // First check that it is a query we can answer // First label should be p.. or f.. if (! is_null($p=$this->parse('p',$labels->first()))) $labels->shift(); if (is_null($f=$this->parse('f',$labels->shift()))) return $this->nameerr($labels); if (is_null($n=$this->parse('n',$labels->shift()))) return $this->nameerr($labels); if (is_null($z=$this->parse('z',$labels->shift()))) return $this->nameerr($labels); if (is_null($d=$labels->shift())) return $this->nameerr($labels); // Make sure we have a root/base domain if (! $labels->count()) return $this->nameerr($labels); $rootdn = $labels->join('.'); $ao = Address::findFTN(sprintf('%d:%d/%d.%d@%s',$z,$n,$f,$p,$d)); // Check we have the right record if ((! $ao) || (! $ao->system->mailer_address) || (($rootdn !== 'ftn') && ((! $ao->zone->domain->dnsdomain) || ($ao->zone->domain->dnsdomain !== $d.'.'.$rootdn)))) return $this->nameerr($labels); // Get the query type/class $result = unpack('ntype/nclass',$x=$this->read(4)); // If the wrong class if ($result['class'] !== self::DNS_QUERY_IN) { Log::error(sprintf('%s:! We only service Internet queries [%d]',self::LOGKEY,$result['class'])); $this->reply(self::DNS_NOTIMPLEMENTED); return; } // Check the class switch ($result['type']) { case self::DNS_TYPE_CNAME: case self::DNS_TYPE_A: case self::DNS_TYPE_AAAA: Log::debug(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->mailer_address,$ao->ftn)); $this->reply( self::DNS_NOERROR, $this->question, [serialize(explode('.',$ao->system->mailer_address)) => self::DNS_TYPE_CNAME]); break; default: Log::error(sprintf('%s:! We dont support DNS query types [%d]',self::LOGKEY,$result['type'])); $this->reply(self::DNS_NOTIMPLEMENTED); } } /** * Return a compression string for a specific offset * * @param int $offset * @return string */ private function compress(int $offset): string { return pack('n',$offset | (3 << 14)); } private function nameerr(Collection $labels) { Log::error(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$labels->join('.'))); $this->reply(self::DNS_NAMEERR); } /** * Parse a label for a fido address nibble * * @param string $prefix * @param string $label * @return string|null */ private function parse(string $prefix,string $label): ?string { $m = []; return (preg_match('/^'.$prefix.'([0-9]+)+/',$label,$m) && ($m[1] <= DomainController::NUMBER_MAX)) ? $m[1] : NULL; } /** * Return a DNS response * * @param int $code * @param string $question * @param array $answer * @return bool */ private function reply(int $code,string $question='',array $answer=[]): bool { $header = (1 << 15); // 1b: Query/Response $header |= (0 << 11); // 4b: Opcode $header |= (0 << 10); // 1b: Authoritative Answer $header |= (0 << 9); // 1b: Truncated $header |= (0 << 8); // 1b: Recursion Desired (in queries) $header |= (0 << 7); // 1b: Recursion Available (in responses) $header |= (0 << 4); // 3b: Zero (future, should be zero) $header |= $code; // 4b: Result Code $q = $question ? 1 : 0; $r = count($answer); $nscount = 0; $arcount = 0; $reply = pack('nnnnnn',$this->header['id'],$header,$q,$r,$nscount,$arcount); // Return the answer if ($r) { // Question $reply .= $question; // @todo In the case we return a CNAME and an A record, this should reference the CNAME domain when returning the A record foreach ($answer as $item => $type) $reply .= $this->rr($this->compress(12),unserialize($item),$type,300); } else { $reply .= $question; } // nscount //$reply .= $this->rr($this->domain_split('net1.fsxnet.nz'),["a","root-servers","net"],self::DNS_TYPE_NS,300); if (! socket_sendto($this->socket,$reply,strlen($reply),0,(string)$this->remote['ip'],(int)$this->remote['port'])) { Log::error(sprintf('%s:! Error [%s] sending DNS reply to [%s:%d]', self::LOGKEY, socket_strerror(socket_last_error()), $this->remote['ip'], $this->remote['port'] )); return FALSE; } return TRUE; } private function domain_split(string $domain): string { $a = ''; foreach (explode('.',$domain) as $item) $a .= pack('C',strlen($item)).$item; return $a; } /** * Read from a rx_buf * * @param int $len * @return string */ private function read(int $len): string { $result = substr($this->rx_buf,$this->rx_ptr,$len); $this->rx_ptr += $len; return $result; } /** * Return a DNS Resource Record * * @param string $query - Domain in the query * @param mixed $ars - Answer resources * @param int $type - Resource type * @param int $ttl - Time to live * @return string */ private function rr(string $query,mixed $ars,int $type,int $ttl): string { // Reference the domain query in the question $reply = $query; // Record Type $reply .= pack('n',$type); // Internet $reply .= pack('n',self::DNS_QUERY_IN); $reply .= pack('n',0); // TTL $reply .= pack('n',$ttl); // Answer $a = ''; switch ($type) { case self::DNS_TYPE_CNAME: case self::DNS_TYPE_NS: foreach ($ars as $item) $a .= pack('C',strlen($item)).$item; $a .= "\x00"; break; case self::DNS_TYPE_A: case self::DNS_TYPE_AAAA: $a .= $ars; } $reply .= pack('n',strlen($a)).$a; return $reply; } /** * Unpack our configured DNS header * * @param array $pack * @return string */ protected static function unpackheader(array $pack): string { return join('/', collect($pack) ->sortBy(function($k,$v) {return $k[0];}) ->transform(function($k,$v) {return $k[1].$v;}) ->values() ->toArray() ); } }