self::MAX_CNAME_DEPTH) { return false; } // Request both the matching forward type AND CNAME in one query so we see // the whole picture at each hop. If the hostname is a direct A/AAAA, we // see that and match immediately; if it's a CNAME, we see the target and // recurse. $type = IpUtil::isIpv6($ip) ? DNS_AAAA | DNS_CNAME : DNS_A | DNS_CNAME; $records = []; try { // @-suppress: dns_get_record emits a PHP warning on NXDOMAIN, which we'd // rather just treat as "no match". The return value (empty array or false) // tells us the same thing without polluting the error log. $records = @dns_get_record($hostname, $type); } catch (\Throwable $e) { // Some PHP configurations throw on resolver failure instead of returning false. // We treat those as "no match" and log once per (hostname, ip) since callers // cache the result — we won't spam the log even for a permanently-broken name. Log::insert('PowerDns:Resolver', ['hostname' => $hostname, 'ip' => $ip], $e->getMessage()); return false; } if (! is_array($records)) { // dns_get_record returns false on resolver failure. Same semantics as above. return false; } // Convert target to binary once, outside the loop. inet_pton normalises // "2001:db8::1" and "2001:0db8:0000:0000:0000:0000:0000:0001" to the same // bytes, so we can compare regardless of how the resolver formatted its reply. $targetBin = @inet_pton($ip); foreach ($records as $r) { $t = $r['type'] ?? null; if ($t === 'CNAME') { // CNAME hop: recurse on the target. We don't use a visited-set to // detect cycles — MAX_CNAME_DEPTH is a simpler, sufficient guard. $next = $r['target'] ?? null; if ($next && self::resolveInternal(rtrim($next, '.'), $ip, $depth + 1)) { return true; } continue; } // A records expose the address under 'ip', AAAA records under 'ipv6'. // Only one of these will be set per record; the other is null. $candidate = $r['ip'] ?? ($r['ipv6'] ?? null); if ($candidate && $targetBin !== false && @inet_pton($candidate) === $targetBin) { return true; } } return false; } }