Skip to content

Instantly share code, notes, and snippets.

@bohwaz
Last active October 25, 2024 09:20
Show Gist options
  • Save bohwaz/ddc61c4f7e031c3221a89981e70b830c to your computer and use it in GitHub Desktop.
Save bohwaz/ddc61c4f7e031c3221a89981e70b830c to your computer and use it in GitHub Desktop.
PHP script to retrieve a DNS record from a custom nameserver
<?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;
}
@GermanAizek
Copy link

Many thanks!!!!!!!

@Rixafy
Copy link

Rixafy commented Sep 30, 2024

@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?

@bohwaz
Copy link
Author

bohwaz commented Sep 30, 2024

@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 :)

@Rixafy
Copy link

Rixafy commented Sep 30, 2024

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.

@Rixafy
Copy link

Rixafy commented Oct 2, 2024

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).

@bohwaz
Copy link
Author

bohwaz commented Oct 2, 2024

No problem :) But publishing your fiber version would be nice!

@Rixafy
Copy link

Rixafy commented Oct 2, 2024

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;
}

@bohwaz
Copy link
Author

bohwaz commented Oct 2, 2024

Thanks!

@Rixafy
Copy link

Rixafy commented Oct 24, 2024

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;
	};

@Rixafy
Copy link

Rixafy commented Oct 25, 2024

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;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment