/ src / library / mail / AsyncDnsResolver.php
<?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;
    }
}
X