<?php namespace App\Classes\Protocol; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use App\Classes\Protocol as BaseProtocol; use App\Models\{Address,Domain,Mailer}; /** * Respond to DNS queries and provide addresses to FTN nodes. * http://ftsc.org/docs/fts-5004.001 * * This implementation doesnt support EDNS nor DNSSEC. * * If using bind, the following configuration is required: * options { * validate-except * { * "ftn"; * }; * }; * * and optionally * server <IP ADDRESS> { * edns no; * }; */ final class DNS extends BaseProtocol { private const LOGKEY = 'PD-'; /* CONSTS */ public const PORT = 53; private const DEFAULT_TTL = 86400; private const TLD = 'ftn'; private BaseProtocol\DNS\Query $query; // DNS Response codes public const DNS_NOERROR = 0; // No error public const DNS_FORMERR = 1; // Format Error - The dns server could not read / understand the query public const DNS_SERVFAIL = 2; // Server Failure - There was a dns error with the dns server public const DNS_NAMEERR = 3; // Name Error - This specifies that the domain name in the query does not exist, it is only valid from an authoritative server public const DNS_NOTIMPLEMENTED = 4;// Not implemented - The requested query is not supported by the dns server public const DNS_REFUSED = 5; // Refused - The dns server refuses to process the dns query // DNS Query Classes public const DNS_QUERY_IN = 1; // Internet (this is the main one that is used) // DNS Query Types public const DNS_TYPE_A = 1; // A Records public const DNS_TYPE_NS = 2; // NS Records public const DNS_TYPE_CNAME = 5; // CNAME Records public const DNS_TYPE_SOA = 6; // SOA Records public const DNS_TYPE_MX = 15; // MX Records public const DNS_TYPE_TXT = 16; // TXT Records public const DNS_TYPE_AAAA = 28; // AAAA Records public const DNS_TYPE_SRV = 33; // SRV Records public const DNS_TYPE_OPT = 41; // OPT Records public const DNS_TYPE_DS = 43; // DS Records (Delegation signer RFC 4034) protected function protocol_init(): int { // N/A return 0; } /** * Handle a DNS query * * https://www.ietf.org/rfc/rfc1035.txt * https://www.ietf.org/rfc/rfc2308.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 bool $force_queue Not used here * @return int * @throws \Exception */ public function protocol_session(bool $force_queue=FALSE): int { Log::debug(sprintf('%s:+ DNS Query',self::LOGKEY)); try { $this->query = new BaseProtocol\DNS\Query($this->client->read(0,512)); } catch (\Exception $e) { Log::notice(sprintf('%s:! Ignoring bad DNS query (%s)',self::LOGKEY,$e->getMessage())); return FALSE; } 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) { Log::notice(sprintf('%s:! We only service Internet queries [%d]',self::LOGKEY,$this->query->class)); return $this->reply(self::DNS_NOTIMPLEMENTED,[],$this->soa()); } $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, $this->soa(), [], [serialize($this->domain_split(config('fido.dns_ns'))) => 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(config('fido.dns_ns'))) => 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: case self::DNS_TYPE_SRV: case self::DNS_TYPE_TXT: Log::debug(sprintf('%s:= Looking for record [%s] for [%s]',self::LOGKEY,$this->query->type,$this->query->domain)); $labels = clone($this->query->labels); $mailer = ''; // If this is a SRV record query if ($this->query->type === self::DNS_TYPE_SRV) { if ($labels->skip(1)->first() !== '_tcp') return $this->reply(self::DNS_NAMEERR); switch ($labels->first()) { case '_binkp': $mailer = Mailer::where('name','BINKP')->sole(); break; case '_ifcico': $mailer = Mailer::where('name','EMSI')->sole(); break; default: return $this->reply(self::DNS_NAMEERR); } $labels->shift(2); } // 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(); else $p = 0; // We'll assume f0 if (! is_null($f=$this->parse('f',$labels->first()))) $labels->shift(); else $f = 0; 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 the query doesnt end with .ftn, then the remainder of the query is the domain name if (($labels->search(self::TLD) !== FALSE)) $d = $labels->shift(); else $d = ''; // Make sure we have a root/base domain if (! $labels->count()) return $this->nameerr(); $rootdn = $labels->join('.'); if (! $d && ($rootdn !== self::TLD)) $d = Domain::where('dnsdomain',$rootdn)->single()?->name; $ao = Address::findFTN(sprintf('%d:%d/%d.%d@%s',$z,$n,$f,$p,$d)); // Check we have the right record if ((! $ao) || (($rootdn !== self::TLD) && ((! $ao->zone->domain->dnsdomain) || ($ao->zone->domain->dnsdomain !== $rootdn)))) { Log::alert(sprintf('%s:= No DNS record for [%d:%d/%d.%d@%s]',self::LOGKEY,$z,$n,$f,$p,$d)); return $this->nameerr(); } switch ($this->query->type) { case self::DNS_TYPE_SRV: if (($ao->system->address) && ($xx=$ao->system->mailers->where('id',$mailer->id)->pop())) { Log::info(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->address,$ao->ftn)); return $this->reply( self::DNS_NOERROR, [serialize([ 0, // priority 1, // weight $xx->pivot->port, $this->domain_split($ao->system->address), ]) => self::DNS_TYPE_SRV]); } else { Log::alert(sprintf('%s:! No/incomplete hostname/port details for [%d] for DNS query [%s]',self::LOGKEY,$ao->system->id,$ao->ftn)); return $this->nodata(); } case self::DNS_TYPE_TXT: Log::info(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->name,$ao->ftn)); return $this->reply( self::DNS_NOERROR, [serialize($ao->system->name) => self::DNS_TYPE_TXT]); default: Log::info(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->address ?: 'NO ADDRESS',$ao->ftn)); return (! $ao->system->address) ? $this->nodata() : $this->reply( self::DNS_NOERROR, [serialize($this->domain_split($ao->system->address)) => self::DNS_TYPE_CNAME]); } // Other attributes return NOTIMPL default: Log::notice(sprintf('%s:! We dont support DNS query types [%d]',self::LOGKEY,$this->query->type)); return $this->reply(self::DNS_NOTIMPLEMENTED,[],$this->soa()); } } /** * 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)); } /** * 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; } private function nameerr(): int { Log::notice(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$this->query->domain)); return $this->reply(self::DNS_NAMEERR,[],$this->soa()); } private function nodata(): int { Log::notice(sprintf('%s:! DNS query for a resource we dont manage [%s] in our zone(s)',self::LOGKEY,$this->query->domain)); return $this->reply(self::DNS_NOERROR,[],$this->soa()); } /** * 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] <= Address::ADDRESS_FIELD_MAX)) ? $m[1] : NULL; } /** * Return a DNS response * * @param int $code * @param array $answer * @param array $authority * @param array $additional * @return bool * @throws \Exception */ private function reply(int $code,array $answer=[],array $authority=[],array $additional=[]): bool { $header = (1 << 15); // 1b: Query/Response $header |= ($this->query->header & 0xf) << 11; // 4b: Opcode $header |= (1 << 10); // 1b: Authoritative Answer $header |= (0 << 9); // 1b: Truncated $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 & 0xf); // 4b: Result Code $q = $this->query->dns ? 1 : 0; $r = count($answer); $nscount = count($authority); $arcount = count($additional); $reply = pack('nnnnnn',$this->query->id,$header,$q,$r,$nscount,$arcount); // Return the answer if ($r) { // 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) { $rr = $this->rr($this->compress(12),unserialize($item),$type,self::DEFAULT_TTL); $reply .= $rr; } } else { $reply .= $this->query->dns; } 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]', self::LOGKEY, socket_strerror(socket_last_error()), $this->client->address_remote, $this->client->port_remote )); return FALSE; } return TRUE; } /** * 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); // TTL $reply .= pack('N',$ttl); // Answer $a = ''; switch ($type) { case self::DNS_TYPE_NS: case self::DNS_TYPE_CNAME: case self::DNS_TYPE_A: case self::DNS_TYPE_AAAA: $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; case self::DNS_TYPE_SRV: $a .= pack('nnn',$ars[0],$ars[1],$ars[2]); $a .= $ars[3]; break; case self::DNS_TYPE_TXT: $a .= pack('C',strlen($ars)).$ars; break; } $reply .= pack('n',strlen($a)).$a; return $reply; } private function soa(): array { return [serialize([ $this->domain_split(config('fido.dns_ns')), $this->domain_split(Str::replace('@','.',config('app.mail.mail_from','nobody@'.gethostname()))), 1, // Serial self::DEFAULT_TTL, // Refresh self::DEFAULT_TTL, // Retry self::DEFAULT_TTL*7,// Expire self::DEFAULT_TTL // Minimum cache ]) => self::DNS_TYPE_SOA]; } }