-
-
Save bohwaz/ddc61c4f7e031c3221a89981e70b830c to your computer and use it in GitHub Desktop.
<?php | |
/** | |
* Make a DNS a request to a custom nameserver, this is similar to dns_get_record, but allows you to query any nameserver | |
* Usage: dns_get_record_from('ns.server.tld', 'A', 'mydomain.tld'); | |
* => ['42.42.42.42'] | |
* @author bohwaz | |
*/ | |
function dns_get_record_from(string $server, string $type, string $record): array | |
{ | |
// Source: https://github.com/metaregistrar/php-dns-client/blob/master/DNS/dnsData/dnsTypes.php | |
static $types = [ | |
1 => 'A', | |
2 => 'NS', | |
5 => 'CNAME', | |
6 => 'SOA', | |
12 => 'PTR', | |
15 => 'MX', | |
16 => 'TXT', | |
28 => 'AAAA', | |
255 => 'ANY', | |
]; | |
$typeid = array_search($type, $types, true); | |
if (!$typeid) { | |
throw new \InvalidArgumentException('Invalid type'); | |
} | |
$host = 'udp://' . $server; | |
if (!$socket = @fsockopen($host, 53, $errno, $errstr, 10)) { | |
throw new \RuntimeException('Failed to open socket to ' . $host); | |
} | |
$labels = explode('.', $record); | |
$question_binary = ''; | |
foreach ($labels as $label) { | |
$question_binary .= pack("C", strlen($label)); // size byte first | |
$question_binary .= $label; // then the label | |
} | |
$question_binary .= pack("C", 0); // end it off | |
$id = rand(1,255)|(rand(0,255)<<8); // generate the ID | |
// Set standard codes and flags | |
$flags = (0x0100 & 0x0300) | 0x0020; // recursion & queryspecmask | authenticated data | |
$opcode = 0x0000; // opcode | |
// Build the header | |
$header = ""; | |
$header .= pack("n", $id); | |
$header .= pack("n", $opcode | $flags); | |
$header .= pack("nnnn", 1, 0, 0, 0); | |
$header .= $question_binary; | |
$header .= pack("n", $typeid); | |
$header .= pack("n", 0x0001); // internet class | |
$headersize = strlen($header); | |
$headersizebin = pack("n", $headersize); | |
$request_size = fwrite($socket, $header, $headersize); | |
$rawbuffer = fread($socket, 4096); | |
fclose($socket); | |
if (strlen($rawbuffer) < 12) { | |
throw new \RuntimeException("DNS query return buffer too small"); | |
} | |
$pos = 0; | |
$read = function ($len) use (&$pos, $rawbuffer) { | |
$out = substr($rawbuffer, $pos, $len); | |
$pos += $len; | |
return $out; | |
}; | |
$read_name_pos = function ($offset_orig, $max_len=65536) use ($rawbuffer) { | |
$out = []; | |
$offset = $offset_orig; | |
while (($len = ord(substr($rawbuffer, $offset, 1))) && $len > 0 && ($offset+$len < $offset_orig+$max_len ) ) { | |
$out[] = substr($rawbuffer, $offset + 1, $len); | |
$offset += $len + 1; | |
} | |
return $out; | |
}; | |
$read_name = function() use (&$read, $read_name_pos) { | |
$out = []; | |
while (($len = ord($read(1))) && $len > 0) { | |
if ($len >= 64) { | |
$offset = (($len & 0x3f) << 8) + ord($read(1)); | |
$out = array_merge($out, $read_name_pos($offset)); | |
break; | |
} | |
else { | |
$out[] = $read($len); | |
} | |
} | |
return implode('.', $out); | |
}; | |
$header = unpack("nid/nflags/nqdcount/nancount/nnscount/narcount", $read(12)); | |
$flags = sprintf("%016b\n", $header['flags']); | |
// No answers | |
if (!$header['ancount']) { | |
return []; | |
} | |
$is_authorative = $flags[5] == 1; | |
// Question section | |
if ($header['qdcount']) { | |
// Skip name | |
$read_name(); | |
// skip question part | |
$pos += 4; // 4 => QTYPE + QCLASS | |
} | |
$responses = []; | |
for ($a = 0; $a < $header['ancount']; $a++) { | |
$read_name(); // Skip name | |
$ans_header = unpack("ntype/nclass/Nttl/nlength", $read(10)); | |
$t = $types[$ans_header['type']] ?? null; | |
if ($type != 'ANY' && $t != $type) { | |
// Skip type that was not requested | |
$t = null; | |
} | |
switch ($t) { | |
case 'A': | |
$responses[] = implode(".", unpack("Ca/Cb/Cc/Cd", $read(4))); | |
break; | |
case 'AAAA': | |
$responses[] = implode(':', unpack("H4a/H4b/H4c/H4d/H4e/H4f/H4g/H4h", $read(16))); | |
break; | |
case 'MX': | |
$prio = unpack('nprio', $read(2)); // priority | |
$responses[$prio['prio']] = $read_name(); | |
break; | |
case 'NS': | |
case 'CNAME': | |
case 'PTR': | |
$responses[] = $read_name(); | |
break; | |
case 'TXT': | |
$responses[] = implode($read_name_pos($pos, $ans_header['length'])); | |
$read($ans_header['length']); | |
break; | |
default: | |
// Skip | |
$read($ans_header['length']); | |
break; | |
} | |
} | |
return $responses; | |
} |
I can confirm that for github.com the issue is that the response is truncated (TC) and should be upgraded to TCP. I'll take a look if I can.
My comments:
-
A possible fix for the TXT queries
https://gist.github.com/LatinSuD/1fffa7f3ec9dce5f0dbbed22dbc9266a
I reused the function read_name_pos() to read a series of character-strings, but imposing as a limit the size of record. -
The code may still not handle multiple levels of compression scenarios. See https://serverfault.com/questions/1007283/multiple-levels-of-dns-name-compression.
If you try to handle that beware of the risks (eg: infinite loops). You may want to check rfc9267 for good practices. -
Line 95, the check of
$len < 64
could be changed with$len < 0xc0
. Although it probably doesn't matter, as it is a reserved value.
Thank you @LatinSuD I merged your changes, this fixes the TXT issues for northskytech.com.
I started some work on a function that upgrades to UDP for TC records but its not finished.
Many thanks!!!!!!!
@bohwaz thanks a lot, I really needed this and was wondering what approach to choose, this is perfection...
I had a problem, that ANY fetch returned only a few records, but PHP native DNS function with DNS_ALL returned every record, because it's fetching all records in parallel, and ANY is not recommended.. but I needed custom nameservers.
I have encapsulated it into PHP 8.1 Fibers to make it async because of fetching a lot of records (DNS import scan at domain hosting company) and now it's doing 500 DNS fetches in under 5 seconds.
I'm wondering if you have any plans to release this as a standalone package, because I would enjoy programming it as a packagist library and making it more OOP oriented with built-in async support, because I think such library is missing. Would you be ok if I used core socket code and gave you credits by referencing this gist or your github profile?
@Rixafy absolutely, feel free to do so, just put my name and website (https://bohwaz.net/) in the credits, and tag me / invite me in the project, I'll keep an eye on it.
Great idea to use fibers :)
Awesome, I'll let you know, maybe I could do this during Christmas vacation, or sooner if I'll have some free time or days off, because I'm little busy until then, thank you again.
I just found out about https://github.com/reactphp/dns (it has also async support), so I'll probably won't be doing a library, if this one will be sufficient (I may try it later, but it looks like it's ok for my usage).
No problem :) But publishing your fiber version would be nice!
Yeah, it's nothing special, I'm honestly surprised it's working, because without this, all ~500 fetches took more like a minute, I was getting time outs, but with this code it was like 5 seconds. I'm surprised, because now I'm trying something similar, because I have library for pinging gaming servers, but I can't get it to work in parallel for multiple servers (it's improved but not much).. maybe the difference is just TCP/UDP sockets, I also tried stream_set_blocking, waiting for stream_select changes.. and switching fibers.. but probably TCP cannot be non-blocking in PHP.
function get_records_async(string $server, string $type, array $records): array
{
$types = $type === 'ALL' ? ['A', 'AAAA', 'CNAME', 'CAA', 'MX', 'TXT', 'SRV'] : [$type];
$fibers = [];
foreach ($records as $record) {
foreach ($types as $t) {
$fiber = new Fiber(function() use ($record, $t, $server) {
return dns_get_record_from($server, $t, $record);
});
$fiber->start();
$fibers[] = $fiber;
}
}
// wait
$completedFibers = [];
$completionCount = count($fibers);
while (count($fibers) > 0 && count($completedFibers) < $completionCount){
usleep(1000);
foreach ($fibers as $idx => $fiber){
if ($fiber->isSuspended()){
$fiber->resume();
} else if ($fiber->isTerminated()){
$completedFibers[] = $fiber;
unset($fibers[$idx]);
}
}
}
$responses = [];
foreach ($completedFibers as $fiber) {
$responses = array_merge($responses, $fiber->getReturn());
}
return $responses;
}
Thanks!
Btw @bohwaz there is one bug, took me some time to find it, in $readNamePos, we need to do skip when $len is >= 64, change offset and start again, otherwise it will produce bad MX results (e.g. route1.cloudflare.<random characters from bad offset>
) when there are multiple rows, try it for example with domain minecord.net. I had also another bug with txt record, usually the first/last character was incorrect and I needed to remove it, I'll look at it and let you know.
$read_name_pos = function ($offset_orig, $max_len=65536) use ($rawbuffer) {
$out = [];
$offset = $offset_orig;
while (($len = ord(substr($rawbuffer, $offset, 1))) && $len > 0 && ($offset+$len < $offset_orig+$max_len ) ) {
if ($len >= 64) {
$offset = (($len & 0x3f) << 8) + ord(substr($rawbuffer, $offset + 1, 1));
continue;
}
$out[] = substr($rawbuffer, $offset + 1, $len);
$offset += $len + 1;
}
return $out;
};
Regarding TXT records, correct reading is this (in switch), current behavior will result in random character at the start/end of a record, or maybe some missing parts in long records.
$data = '';
for ($strCount = 0; strlen($data) + (1 + $strCount) < $ans_header['length']; $strCount++) {
$data .= $read(ord($read(1)));
}
$responses[] = $data;
You are welcome to suggest a patch to fix those bugs :)