-
-
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; | |
} |
You are welcome to suggest a patch to fix those bugs :)
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;
I saw this referenced in your comment on php.net
I really like the minimal approach, personally I ran into a few issues I've been unable to solve. Not expecting anything just wanted to make the note in case if was of interest to you:
For more reasonable length responses for multi answer records, it seems there's an extra character that doesn't get trimmed out in the response. Query TXT records for northskytech.com, for example. The first array item is correct, but the other 3 have an extra leading character.
Less important but something I noticed: For multi-answer records, like querying all TXT records for GitHub.com, it returns no results (I suspect the response is too large for the buffer?)
In either case, appreciate you sharing your solution, thank you!