463 lines
13 KiB
PHP
463 lines
13 KiB
PHP
<?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];
|
|
}
|
|
} |