<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library\mail;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
/**
* Looks up Domain Name System (DNS) records without freezing the mail
* server while it waits for an answer. The mail daemon runs every
* connection through one cooperative event loop, so the ordinary PHP
* dns_get_record call is dangerous there: it blocks until the network
* answers, and a spam sender whose domain has slow or dead DNS can
* stall the whole server on a single lookup. This resolver instead
* sends the query itself over a non-blocking socket and, while it
* waits, hands the event loop a turn (the same cooperative yield the
* mail server already uses for contended file locks) so other
* connections keep moving. Every lookup is bounded by a timeout, after
* which it gives up and reports no records, so a lookup that never
* answers can never wedge the loop. Outside the event loop (command
* line tools and unit tests, where there is no fiber) it simply waits
* on the socket up to the same timeout, so those callers behave as
* before.
*
* The query and answer wire format is the standard one (RFC 1035, the
* base DNS specification), with an Extension Mechanisms for DNS
* (EDNS0, RFC 6891) option attached to each query that raises the
* answer-size limit so a large TXT record such as a DomainKeys
* Identified Mail (DKIM) public key arrives in one datagram instead
* of being truncated.
*/
class AsyncDnsResolver
{
/**
* Address record type: an Internet Protocol version 4 address
* (RFC 1035). Answers carry an "ip" field.
*/
const TYPE_A = 1;
/**
* Mail exchanger record type (RFC 1035): the servers that accept
* mail for a domain. Answers carry "pri" and "target" fields.
*/
const TYPE_MX = 15;
/**
* Text record type (RFC 1035), used here for DKIM public keys,
* Sender Policy Framework (SPF) records, and DMARC policies.
* Answers carry a "txt" field.
*/
const TYPE_TXT = 16;
/**
* Address record type: an Internet Protocol version 6 address
* (RFC 3596). Answers carry an "ipv6" field.
*/
const TYPE_AAAA = 28;
/**
* Pseudo record type that carries the EDNS0 option block
* (RFC 6891). It is never requested on its own; one is added to
* the additional section of every query we send.
*/
const TYPE_OPT = 41;
/**
* The Internet record class (RFC 1035); the only class used for
* ordinary mail lookups.
*/
const CLASS_INTERNET = 1;
/**
* The well known User Datagram Protocol port that name servers
* listen on.
*/
const DNS_PORT = 53;
/**
* Answer size in bytes we advertise through EDNS0. A plain DNS
* datagram is capped at 512 bytes, which truncates many DKIM
* keys; 1232 is the community-recommended safe size that avoids
* fragmentation on the public Internet while comfortably holding
* a normal key.
*/
const UDP_BUFFER = 1232;
/**
* The largest a single name label (the part between dots) may be,
* in bytes (RFC 1035). A longer one marks a malformed name.
*/
const MAX_LABEL_LENGTH = 63;
/**
* Upper bound on how many labels and compression jumps a name may
* take while being read, so a hand-crafted packet that points a
* name at itself cannot spin forever.
*/
const MAX_NAME_HOPS = 128;
/**
* The two high bits set in a length byte mark it as a compression
* pointer rather than a label length (RFC 1035 section 4.1.4).
*/
const COMPRESSION_MASK = 0xC0;
/**
* Mask for the low six bits of the first byte of a compression
* pointer, which are the high bits of the offset it points to.
*/
const COMPRESSION_OFFSET_HIGH = 0x3F;
/**
* The query flag that asks the name server to do the recursive
* work of chasing the answer (RFC 1035): recursion desired.
*/
const FLAG_RECURSION_DESIRED = 0x0100;
/**
* The answer flag that says the reply did not fit and was cut
* short (RFC 1035): the truncation bit.
*/
const FLAG_TRUNCATED = 0x0200;
/**
* Mask for the four low bits of the answer flags, the response
* code that says whether the lookup succeeded (RFC 1035).
*/
const RCODE_MASK = 0x000F;
/**
* Length in bytes of the fixed header at the front of every DNS
* message (RFC 1035).
*/
const HEADER_LENGTH = 12;
/**
* Largest value a sixteen-bit query identifier can take, used to
* pick a random id for each query.
*/
const QUERY_ID_MAX = 0xFFFF;
/**
* Number of microseconds in one second, for splitting a
* fractional timeout into the whole and fractional parts
* stream_select wants.
*/
const MICROSECONDS_PER_SECOND = 1000000;
/**
* Name server addresses read once from the system resolver file,
* kept so the file is not reread on every lookup.
* @var array
*/
public static $servers = null;
/**
* Looks up records of one type for a name and returns them in the
* same array-of-arrays shape the built-in dns_get_record gives, so
* callers need no other change. Returns an empty array when the
* name does not resolve, when the answer says there are no such
* records, or when no name server answers within the timeout; all
* of those mean the same thing to the mail checks, namely treat
* the domain as having no such record.
*
* @param string $name the domain name to look up
* @param int $type one of the TYPE_ constants for the record kind
* @param int $timeout seconds to wait in total before giving up;
* when null the configured MAIL_DNS_TIMEOUT is used
* @return array a list of records, each an associative array with
* the fields for its type, or an empty list
*/
public static function query($name, $type, $timeout = null)
{
$name = trim((string) $name, " \t\n\r\0.");
if ($name === '') {
return [];
}
if ($timeout === null) {
$timeout = C\MAIL_DNS_TIMEOUT;
}
list($id, $packet) = self::buildQuery($name, $type);
if ($packet === '') {
return [];
}
$deadline = microtime(true) + $timeout;
foreach (self::nameservers() as $server) {
$records = self::queryServer($server, $packet, $id, $type,
$deadline);
if ($records !== null) {
return $records;
}
if (microtime(true) >= $deadline) {
break;
}
}
/* No name server answered in time. The lookup is abandoned so
the loop is not held up, but a name that never resolves is
worth a line: it is how a recurrence of the stalled-loop
trouble would show up before it could do harm. */
L\crawlLog("MailDns: no name server answered for " . $name .
" within " . $timeout . "s");
return [];
}
/**
* Builds the raw bytes of a query for one name and record type,
* including the EDNS0 option that raises the answer size limit.
*
* @param string $name the domain name to ask about
* @param int $type one of the TYPE_ constants
* @return array a pair of the random query id and the packet
* bytes; the bytes are empty when the name cannot be encoded
*/
public static function buildQuery($name, $type)
{
$question = self::encodeName($name);
if ($question === '') {
return [0, ''];
}
$id = random_int(0, self::QUERY_ID_MAX);
/* one question, no answers, no authority records, and one
additional record which is the EDNS0 option below */
$header = pack('n6', $id, self::FLAG_RECURSION_DESIRED, 1, 0, 0,
1);
$question .= pack('n2', $type, self::CLASS_INTERNET);
return [$id, $header . $question . self::ednsOption()];
}
/**
* Encodes a domain name into the wire form: each dot-separated
* label written as a length byte then its bytes, ended by a zero
* byte for the root.
*
* @param string $name the domain name
* @return string the encoded name, or an empty string when a
* label is empty or too long to be valid
*/
public static function encodeName($name)
{
$name = trim((string) $name, '.');
if ($name === '') {
return "\x00";
}
$encoded = '';
foreach (explode('.', $name) as $label) {
$length = strlen($label);
if ($length === 0 || $length > self::MAX_LABEL_LENGTH) {
return '';
}
$encoded .= chr($length) . $label;
}
return $encoded . "\x00";
}
/**
* Reads a domain name starting at a given offset in a message,
* following compression pointers, and advances the offset past the
* name as it appeared here (not past any pointer target). Names
* are returned lowercased and dot-joined.
*
* @param string $data the whole message bytes
* @param int &$offset position to start at; updated to just after
* the name in place
* @return string the decoded name, possibly empty for the root
*/
public static function readName($data, &$offset)
{
$labels = [];
$jumped = false;
$after_pointer = 0;
$hops = 0;
$length = strlen($data);
while ($hops < self::MAX_NAME_HOPS) {
$hops++;
if ($offset < 0 || $offset >= $length) {
break;
}
$marker = ord($data[$offset]);
if (($marker & self::COMPRESSION_MASK) ===
self::COMPRESSION_MASK) {
if ($offset + 1 >= $length) {
break;
}
$pointer = (($marker & self::COMPRESSION_OFFSET_HIGH)
<< 8) | ord($data[$offset + 1]);
if (!$jumped) {
$after_pointer = $offset + 2;
$jumped = true;
}
$offset = $pointer;
continue;
}
if ($marker === 0) {
$offset++;
break;
}
$labels[] = strtolower(substr($data, $offset + 1, $marker));
$offset += 1 + $marker;
}
if ($jumped) {
$offset = $after_pointer;
}
return implode('.', $labels);
}
/**
* Parses a reply, returning the records in the answer section that
* match the type asked for, in the dns_get_record array shape. A
* reply whose id does not match the query, whose response code
* reports an error, or that was truncated yields an empty list, so
* a bad or partial answer reads as no records.
*
* @param string $data the reply bytes
* @param int $expected_id the id of the query this answers
* @param int $type one of the TYPE_ constants asked for
* @return array the matching records, each an associative array
*/
public static function parseResponse($data, $expected_id, $type)
{
if (strlen($data) < self::HEADER_LENGTH) {
return [];
}
$header = unpack('nid/nflags/nqd/nan/nns/nar',
substr($data, 0, self::HEADER_LENGTH));
if ($header['id'] !== $expected_id) {
return [];
}
if (($header['flags'] & self::FLAG_TRUNCATED) !== 0) {
return [];
}
if (($header['flags'] & self::RCODE_MASK) !== 0) {
return [];
}
$offset = self::HEADER_LENGTH;
for ($i = 0; $i < $header['qd']; $i++) {
self::readName($data, $offset);
$offset += 4;
}
$records = [];
for ($i = 0; $i < $header['an']; $i++) {
$record = self::readAnswer($data, $offset, $type);
if ($record !== null) {
$records[] = $record;
}
}
return $records;
}
/**
* Reads one answer record at the offset and advances the offset
* past it. Returns the record as an associative array when it is
* of the wanted type, or null when it is some other type (still
* advancing past it).
*
* @param string $data the whole reply bytes
* @param int &$offset position of the record; updated past it
* @param int $type the TYPE_ constant the caller wants
* @return array|null the record, or null to skip it
*/
private static function readAnswer($data, &$offset, $type)
{
self::readName($data, $offset);
if ($offset + 10 > strlen($data)) {
$offset = strlen($data);
return null;
}
$meta = unpack('ntype/nclass/Nttl/nlength',
substr($data, $offset, 10));
$offset += 10;
$rdata_start = $offset;
$offset += $meta['length'];
if ($meta['type'] !== $type) {
return null;
}
$ttl = $meta['ttl'];
if ($type === self::TYPE_TXT) {
return ['type' => 'TXT', 'ttl' => $ttl,
'txt' => self::readText($data, $rdata_start,
$meta['length'])];
}
if ($type === self::TYPE_A) {
$address = @inet_ntop(substr($data, $rdata_start, 4));
if ($address === false) {
return null;
}
return ['type' => 'A', 'ttl' => $ttl, 'ip' => $address];
}
if ($type === self::TYPE_AAAA) {
$address = @inet_ntop(substr($data, $rdata_start, 16));
if ($address === false) {
return null;
}
return ['type' => 'AAAA', 'ttl' => $ttl,
'ipv6' => $address];
}
if ($type === self::TYPE_MX) {
$priority = unpack('n',
substr($data, $rdata_start, 2))[1];
$target_offset = $rdata_start + 2;
$target = self::readName($data, $target_offset);
return ['type' => 'MX', 'ttl' => $ttl, 'pri' => $priority,
'target' => $target];
}
return null;
}
/**
* Reads a text record's data, which is one or more runs each of a
* length byte then that many bytes, and joins the runs into one
* string (a long key is often split into several runs).
*
* @param string $data the whole reply bytes
* @param int $start where this record's data begins
* @param int $length how many bytes the record's data spans
* @return string the joined text
*/
private static function readText($data, $start, $length)
{
$text = '';
$position = $start;
$end = $start + $length;
while ($position < $end) {
$run = ord($data[$position]);
$position++;
if ($run > 0) {
$text .= substr($data, $position, $run);
$position += $run;
}
}
return $text;
}
/**
* Sends the query to one name server and waits, cooperatively when
* inside the event loop, for its reply up to the shared deadline.
* Returns the parsed records (possibly an empty list for a valid
* "no records" answer) or null when this server did not answer in
* time, which tells the caller to try the next server.
*
* @param string $server the name server address
* @param string $packet the query bytes to send
* @param int $id the query id to match the reply against
* @param int $type the TYPE_ constant asked for
* @param float $deadline microtime after which to give up
* @return array|null the records, or null on no timely answer
*/
private static function queryServer($server, $packet, $id, $type,
$deadline)
{
$errno = 0;
$errstr = '';
$socket = @stream_socket_client('udp://' . $server . ':' .
self::DNS_PORT, $errno, $errstr, 0,
STREAM_CLIENT_CONNECT);
if (!$socket) {
return null;
}
stream_set_blocking($socket, false);
@fwrite($socket, $packet);
if (!self::awaitReadable($socket, $deadline)) {
@fclose($socket);
return null;
}
$reply = @fread($socket, self::UDP_BUFFER);
@fclose($socket);
if ($reply === false || $reply === '') {
return null;
}
return self::parseResponse($reply, $id, $type);
}
/**
* Waits until a socket has data to read or the deadline passes.
* Inside the event loop (a fiber) it peeks without blocking and,
* finding nothing yet, yields the loop a turn before trying again,
* so waiting on the network never freezes other connections.
* Outside a fiber it blocks on the socket up to the remaining
* time. Returns whether the socket became readable.
*
* @param resource $socket the open, non-blocking socket
* @param float $deadline microtime after which to give up
* @return bool true if the socket is readable, false on timeout
*/
private static function awaitReadable($socket, $deadline)
{
$in_fiber = (\Fiber::getCurrent() !== null);
while (true) {
$remaining = $deadline - microtime(true);
if ($remaining <= 0) {
return false;
}
$reads = [$socket];
$writes = [];
$excepts = [];
if ($in_fiber) {
$ready = @stream_select($reads, $writes, $excepts, 0,
0);
if ($ready === false) {
return false;
}
if ($ready > 0) {
return true;
}
\Fiber::suspend();
continue;
}
$seconds = (int) floor($remaining);
$micro = (int) (($remaining - $seconds) *
self::MICROSECONDS_PER_SECOND);
$ready = @stream_select($reads, $writes, $excepts,
$seconds, $micro);
if ($ready === false || $ready === 0) {
return false;
}
return true;
}
}
/**
* Builds the EDNS0 additional record that advertises the larger
* answer size we can accept. It is an OPT pseudo-record on the
* root name with the buffer size in the class field and an empty
* data section.
*
* @return string the encoded option record bytes
*/
private static function ednsOption()
{
/* root name, OPT type, our buffer size in place of a class,
a zero extended-rcode-and-flags word, and no option data */
return "\x00" . pack('n2', self::TYPE_OPT, self::UDP_BUFFER) .
pack('N', 0) . pack('n', 0);
}
/**
* Returns the name server addresses to ask, read once from the
* system resolver configuration (/etc/resolv.conf) so the mail
* server uses the same servers the rest of the machine does. When
* that file names none, the configured fallback server is used.
*
* @return array the name server addresses in order of preference
*/
private static function nameservers()
{
if (self::$servers !== null) {
return self::$servers;
}
$servers = [];
$path = '/etc/resolv.conf';
if (is_readable($path)) {
$lines = @file($path, FILE_IGNORE_NEW_LINES |
FILE_SKIP_EMPTY_LINES);
if (is_array($lines)) {
foreach ($lines as $line) {
$line = trim($line);
if (stripos($line, 'nameserver ') === 0) {
$address = trim(substr($line, strlen(
'nameserver ')));
if ($address !== '') {
$servers[] = $address;
}
}
}
}
}
if (empty($servers)) {
$servers[] = C\MAIL_DNS_FALLBACK_SERVER;
}
self::$servers = $servers;
return $servers;
}
}