diff --git a/app/Classes/Protocol/DNS.php b/app/Classes/Protocol/DNS.php index 8599e1e..d3eddc0 100644 --- a/app/Classes/Protocol/DNS.php +++ b/app/Classes/Protocol/DNS.php @@ -2,18 +2,39 @@ namespace App\Classes\Protocol; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use App\Classes\Protocol as BaseProtocol; use App\Classes\Sock\SocketClient; use App\Http\Controllers\DomainController; -use App\Models\Address; +use App\Models\{Address,Domain}; +/** + * Respond to DNS queries and provide addresses to FTN nodes. + * + * This implementation doesnt support EDNS nor DNSSEC. + * + * If using bind, the following configuration is required: + * options { + * validate-except + * { + * "ftn"; + * }; + * }; + * + * and optionally + * server { + * edns no; + * }; + */ final class DNS extends BaseProtocol { private const LOGKEY = 'PD-'; + private const DEFAULT_TTL = 86400; + private const TLD = 'ftn'; + private BaseProtocol\DNS\Query $query; // DNS Response codes @@ -36,22 +57,8 @@ final class DNS extends BaseProtocol public const DNS_TYPE_TXT = 16; // TXT Records public const DNS_TYPE_AAAA = 28; // AAAA Records - - /** - * Split a domain into a DNS domain string - * - * @param string $domain - * @return string - */ - private function domain_split(string $domain): string - { - $a = ''; - - foreach (explode('.',$domain) as $item) - $a .= pack('C',strlen($item)).$item; - - return $a; - } + public const DNS_TYPE_OPT = 41; // OPT Records + public const DNS_TYPE_DS = 43; // DS Records (Delegation signer RFC 4034) public function onConnect(SocketClient $client): ?int { @@ -88,38 +95,6 @@ final class DNS extends BaseProtocol * * @return int * @throws \Exception - * - * 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 protocol_session(): int { @@ -127,46 +102,7 @@ final class DNS extends BaseProtocol $this->query = new BaseProtocol\DNS\Query($this->client->read(0,512)); - // If there is no query count, then its an error - if ($this->query->qdcount !== 1) { - Log::error(sprintf('%s:! DNS query doesnt have the right number of queries [%d]',self::LOGKEY,$this->query->qdcount)); - return $this->reply(self::DNS_FORMERR); - } - - // We need a minimum of f.n.z.d.root - if ($this->query->labels->count() < 5) - return $this->nameerr($this->query->labels); - - $labels = clone($this->query->labels); - - // 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); + Log::info(sprintf('%s:= DNS Query from [%s] for [%s]',self::LOGKEY,$this->client->address_remote,$this->query->domain)); // If the wrong class if ($this->query->class !== self::DNS_QUERY_IN) { @@ -174,24 +110,103 @@ final class DNS extends BaseProtocol return $this->reply(self::DNS_NOTIMPLEMENTED); } - // Check the class + $dos = Domain::select(['id','name','dnsdomain'])->active(); + $ourdomains = $dos + ->pluck('name') + ->transform(function($item) { return sprintf('%s.%s',$item,self::TLD); }) + ->merge($dos->pluck('dnsdomain')) + ->merge([self::TLD]) + ->filter() + ->sortBy(function($item) { return substr_count($item,'.'); }) + ->reverse(); + + $query_domain = $this->query->domain; + + // If the query is not for our domains, return NAMEERR + if (($do=$ourdomains->search(function ($item) use ($query_domain) { return preg_match("/${item}$/",$query_domain); })) === FALSE) { + Log::alert(sprintf('%s:= DNS Query not for our domains',self::LOGKEY)); + return $this->nameerr(); + } + + // Action on the query type switch ($this->query->type) { + // Return the SOA/NS records + case self::DNS_TYPE_SOA: + Log::info(sprintf('%s:= Returning SOA for [%s]',self::LOGKEY,$this->query->domain)); + + return $this->reply( + self::DNS_NOERROR, + [serialize([ + $this->domain_split(gethostname()), + $this->domain_split(Str::replace('@','.',config('app.mail.mail_from','nobody@'.gethostname()))), + 1, + self::DEFAULT_TTL, + self::DEFAULT_TTL, + self::DEFAULT_TTL, + self::DEFAULT_TTL + ]) => self::DNS_TYPE_SOA], + [], + [serialize($this->domain_split(gethostname())) => self::DNS_TYPE_NS], + ); + + case self::DNS_TYPE_NS: + Log::info(sprintf('%s:= Returning NS for [%s]',self::LOGKEY,$this->query->domain)); + + return $this->reply( + self::DNS_NOERROR, + [serialize($this->domain_split(gethostname())) => self::DNS_TYPE_NS]); + + // Respond to A/AAAA/CNAME queries, with value or NAMEERR 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->query->domain, - [$this->domain_split($ao->system->mailer_address) => self::DNS_TYPE_CNAME]); - break; + Log::info(sprintf('%s:= Looking for record [%s] for [%s]',self::LOGKEY,$this->query->type,$this->query->domain)); + $labels = clone($this->query->labels); + + // 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(); + + if (is_null($n=$this->parse('n',$labels->shift()))) + return $this->nameerr(); + + if (is_null($z=$this->parse('z',$labels->shift()))) + return $this->nameerr(); + + if (is_null($d=$labels->shift())) + return $this->nameerr(); + + // Make sure we have a root/base domain + if (! $labels->count()) + return $this->nameerr(); + + $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 !== self::TLD) && ((! $ao->zone->domain->dnsdomain) || ($ao->zone->domain->dnsdomain !== $d.'.'.$rootdn)))) { + Log::alert(sprintf('%s:= No DNS record for [%d:%d/%d.%d@%s]',self::LOGKEY,$z,$n,$f,$p,$d)); + return $this->nameerr(); + } + + Log::debug(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->mailer_address,$ao->ftn)); + + return $this->reply( + self::DNS_NOERROR, + [serialize($this->domain_split($ao->system->mailer_address)) => self::DNS_TYPE_CNAME]); + + // Other attributes return NOTIMPL default: Log::error(sprintf('%s:! We dont support DNS query types [%d]',self::LOGKEY,$this->query->type)); - $this->reply(self::DNS_NOTIMPLEMENTED); - } - return self::DNS_NOERROR; + return $this->reply(self::DNS_NOTIMPLEMENTED); + } } /** @@ -205,9 +220,9 @@ final class DNS extends BaseProtocol return pack('n',$offset | (3 << 14)); } - private function nameerr(Collection $labels): int + private function nameerr(): int { - Log::error(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$labels->join('.'))); + Log::error(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$this->query->domain)); return $this->reply(self::DNS_NAMEERR); } @@ -232,44 +247,53 @@ final class DNS extends BaseProtocol * Return a DNS response * * @param int $code - * @param string $question * @param array $answer + * @param array $authority * @return bool * @throws \Exception */ - private function reply(int $code,string $question='',array $answer=[]): bool + private function reply(int $code,array $answer=[],array $authority=[],array $additional=[]): bool { $header = (1 << 15); // 1b: Query/Response - $header |= (0 << 11); // 4b: Opcode - $header |= (0 << 10); // 1b: Authoritative Answer + $header |= ($this->query->header & 0xf) << 11; // 4b: Opcode + $header |= (1 << 10); // 1b: Authoritative Answer $header |= (0 << 9); // 1b: Truncated - $header |= (0 << 8); // 1b: Recursion Desired (in queries) + $header |= ((($this->query->header >> 8) & 1) << 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 + $header |= ($code & 0xf); // 4b: Result Code - $q = $question ? 1 : 0; + $q = $this->query->dns ? 1 : 0; $r = count($answer); - $nscount = 0; - $arcount = 0; + $nscount = count($authority); + $arcount = count($additional); $reply = pack('nnnnnn',$this->query->id,$header,$q,$r,$nscount,$arcount); // Return the answer if ($r) { // Question - $reply .= $question; + $reply .= $this->query->dns; // @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),$item,$type,300); + foreach ($answer as $item => $type) { + $rr = $this->rr($this->compress(12),unserialize($item),$type,self::DEFAULT_TTL); + $reply .= $rr; + } } else { - $reply .= $question; + $reply .= $this->query->dns; } - // nscount - //$reply .= $this->rr($this->domain_split('net1.fsxnet.nz'),["a","root-servers","net"],self::DNS_TYPE_NS,300); + foreach ($authority as $item => $type) { + $rr = $this->rr($this->compress(12),unserialize($item),$type,self::DEFAULT_TTL); + $reply .= $rr; + } + + foreach ($additional as $item => $type) { + $rr = $this->rr($this->compress(12),unserialize($item),$type,self::DEFAULT_TTL); + $reply .= $rr; + } if (! $this->client->send($reply,0)) { Log::error(sprintf('%s:! Error [%s] sending DNS reply to [%s:%d]', @@ -285,6 +309,24 @@ final class DNS extends BaseProtocol return TRUE; } + /** + * Split a domain into a DNS domain string + * + * @param string $domain + * @return string + */ + private function domain_split(string $domain): string + { + $a = ''; + + foreach (explode('.',$domain) as $item) + $a .= pack('C',strlen($item)).$item; + + $a .= "\x00"; + + return $a; + } + /** * Return a DNS Resource Record * @@ -294,7 +336,7 @@ final class DNS extends BaseProtocol * @param int $ttl - Time to live * @return string */ - private function rr(string $query,string $ars,int $type,int $ttl): string + private function rr(string $query,mixed $ars,int $type,int $ttl): string { // Reference the domain query in the question $reply = $query; @@ -305,22 +347,26 @@ final class DNS extends BaseProtocol // Internet $reply .= pack('n',self::DNS_QUERY_IN); - $reply .= pack('n',0); - // TTL - $reply .= pack('n',$ttl); + $reply .= pack('N',$ttl); // Answer $a = ''; switch ($type) { - case self::DNS_TYPE_CNAME: case self::DNS_TYPE_NS: - $a = $ars."\x00"; - break; - + case self::DNS_TYPE_CNAME: case self::DNS_TYPE_A: case self::DNS_TYPE_AAAA: - $a = $ars; + $a .= $ars; + + break; + + case self::DNS_TYPE_SOA: + $a .= $ars[0]; + $a .= $ars[1]; + $a .= pack('NNNNN',$ars[2],$ars[3],$ars[4],$ars[5],$ars[6]); + + break; } $reply .= pack('n',strlen($a)).$a; diff --git a/app/Classes/Protocol/DNS/Query.php b/app/Classes/Protocol/DNS/Query.php index bfc445f..f9003fc 100644 --- a/app/Classes/Protocol/DNS/Query.php +++ b/app/Classes/Protocol/DNS/Query.php @@ -3,15 +3,23 @@ namespace App\Classes\Protocol\DNS; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; final class Query { + private const LOGKEY = 'PDQ'; + private string $buf; private int $class; - private string $domain; + private string $dns; private int $id; private int $type; + private int $arcount; + private int $qdcount; + + private RR $additional; + private Collection $labels; // https://github.com/guyinatuxedo/dns-fuzzer/blob/master/dns.md @@ -34,6 +42,8 @@ final class Query $this->id = $header['id']; $this->qdcount = $header['qdcount']; + $this->arcount = $header['arcount']; + $this->header = $header['header']; // Get the domain elements $this->labels = collect(); @@ -49,19 +59,40 @@ final class Query $this->type = $result['type']; $this->class = $result['class']; - $this->domain = substr($this->buf,$x=$this->header_len(),$rx_ptr-$x); + $this->dns = substr($this->buf,$this->header_len(),$rx_ptr-$this->header_len()); + + // Do we have additional records + if ($this->arcount) { + // Additional records, EDNS: https://datatracker.ietf.org/doc/html/rfc6891 + if (($haystack = strstr(substr($this->buf,$rx_ptr+1+10),"\x00",true)) !== FALSE) { + Log::error(sprintf('%s:! DNS additional record format error?',self::LOGKEY)); + // @todo catch this + } + + $this->additional = new RR(substr($this->buf,$rx_ptr,(strlen($haystack) === 0) ? NULL : strlen($haystack))); + $rx_ptr += $this->additional->length; + } + + if (strlen($this->buf) !== $rx_ptr) { + dd(['query remaining'=>strlen($this->buf)-$rx_ptr,'hex'=>hex_dump(substr($this->buf,$rx_ptr))]); + } } public function __get($key) { switch ($key) { case 'class': - case 'domain': + case 'dns': case 'id': case 'labels': case 'qdcount': + case 'arcount': + case 'header': case 'type': return $this->{$key}; + + case 'domain': + return $this->labels->join('.'); } } diff --git a/app/Classes/Protocol/DNS/RR.php b/app/Classes/Protocol/DNS/RR.php new file mode 100644 index 0000000..4081a66 --- /dev/null +++ b/app/Classes/Protocol/DNS/RR.php @@ -0,0 +1,75 @@ +buf = $buf; + $this->labels = collect(); + + $i = 0; + + $domain = strstr($buf,"\x00",TRUE); + $i += strlen($domain)+1; + + $this->type = Arr::get(unpack('n',substr($buf,$i,2)),1); + $this->class = Arr::get(unpack('n',substr($buf,$i+2,2)),1); + $i += 4; + + switch ($this->type) { + case DNS::DNS_TYPE_CNAME: + case DNS::DNS_TYPE_NS: + case DNS::DNS_TYPE_DS: + case DNS::DNS_TYPE_SOA: + $i = 0; + + while (($len=ord(substr($domain,$i++,1))) !== 0x00) { + $this->labels->push(substr($buf,$i,$len)); + $i += $len; + } + + break; + + case DNS::DNS_TYPE_OPT: + // Domain is 0x00 + $this->ttl = Arr::get(unpack('N',substr($buf,$i,4)),1); + $this->rddata_len = Arr::get(unpack('n',substr($buf,$i+4,2)),1); + $this->rddata = substr($buf,$i+6,$this->rddata_len); + + break; + + default: + dd(['unknown type:'.$this->type,'buf'=>$this->buf]); + } + } + + public function __get(string $key): mixed + { + switch ($key) { + case 'length': + return strlen($this->buf); + + default: + throw new \Exception(sprintf('%s:Unknown key [%s]',self::LOGKEY,$key)); + } + } + + public function __tostring(): string + { + return $this->buf; + } +} \ No newline at end of file