Enhancements to DNS server and notes for usage with bind

This commit is contained in:
Deon George 2023-06-12 21:51:55 +10:00
parent b1c62ae227
commit ccf01a1b23
3 changed files with 281 additions and 129 deletions

View File

@ -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 <IP ADDRESS> {
* 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;

View File

@ -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('.');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Classes\Protocol\DNS;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use App\Classes\Protocol\DNS;
final class RR
{
private const LOGKEY = 'PDR';
public Collection $labels;
public int $type;
public int $class;
private string $buf;
public function __construct(string $buf)
{
$this->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;
}
}