true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT_MS => $timeoutMs, CURLOPT_CONNECTTIMEOUT_MS => min(1000, $timeoutMs), CURLOPT_HTTPHEADER => [ 'Accept: application/dns-json', 'User-Agent: MyDNSChecker/1.0' ], ]); $started = microtime(true); $raw = curl_exec($ch); $curlErr = curl_error($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $timeMs = (int)round((microtime(true) - $started) * 1000); if ($raw === false) { return ['ok' => false, 'httpCode' => $httpCode, 'timeMs' => $timeMs, 'error' => $curlErr ?: 'curl error']; } $json = json_decode($raw, true); if (!is_array($json)) { return ['ok' => false, 'httpCode' => $httpCode, 'timeMs' => $timeMs, 'error' => 'invalid json response']; } return ['ok' => true, 'httpCode' => $httpCode, 'timeMs' => $timeMs, 'json' => $json]; } /** * Build DNS query wire (RFC1035) for DoH RFC8484 endpoints (?dns=base64url). */ function buildDnsQueryWire(string $qname, int $qtype): string { $id = random_int(0, 65535); $flags = 0x0100; // recursion desired $qdcount = 1; $ancount = 0; $nscount = 0; $arcount = 0; $header = pack('nnnnnn', $id, $flags, $qdcount, $ancount, $nscount, $arcount); $labels = explode('.', trim($qname, '.')); $qnameBin = ''; foreach ($labels as $label) { $len = strlen($label); if ($len < 1 || $len > 63) throw new RuntimeException('Invalid label length'); $qnameBin .= chr($len) . $label; } $qnameBin .= "\x00"; $qclass = 1; // IN $question = $qnameBin . pack('nn', $qtype, $qclass); return $header . $question; } function base64url_encode(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } function dnsSkipName(string $wire, int &$offset): void { $n = strlen($wire); while ($offset < $n) { $len = ord($wire[$offset]); if (($len & 0xC0) === 0xC0) { // pointer $offset += 2; return; } $offset++; if ($len === 0) return; $offset += $len; } } function dnsReadName(string $wire, int &$offset, int $depth = 0): string { $n = strlen($wire); if ($depth > 15) return '.'; // safety $labels = []; while ($offset < $n) { $len = ord($wire[$offset]); if (($len & 0xC0) === 0xC0) { // pointer if ($offset + 1 >= $n) break; $b2 = ord($wire[$offset + 1]); $ptr = (($len & 0x3F) << 8) | $b2; $offset += 2; // consume pointer bytes in main stream $ptrOffset = $ptr; $labels[] = rtrim(dnsReadName($wire, $ptrOffset, $depth + 1), '.'); break; } $offset++; if ($len === 0) break; if ($offset + $len > $n) break; $labels[] = substr($wire, $offset, $len); $offset += $len; } $name = implode('.', array_filter($labels, fn ($x) => $x !== '')); return ($name === '') ? '.' : ($name . '.'); } function dnsTypeToString(int $type): string { return match ($type) { 1 => 'A', 2 => 'NS', 5 => 'CNAME', 6 => 'SOA', 12 => 'PTR', 15 => 'MX', 16 => 'TXT', 28 => 'AAAA', 33 => 'SRV', 257 => 'CAA', default => 'TYPE' . $type, }; } /** * Parse DNS wire response answers (RFC1035). Handles name compression. * Returns: [ ['type'=>'MX','ttl'=>300,'data'=>'10 mail.example.com.'], ... ] */ function parseDnsResponseAnswers(string $wire): array { $n = strlen($wire); if ($n < 12) return []; $hdr = unpack('nid/nflags/nqd/nan/nns/nar', substr($wire, 0, 12)); $qd = (int)($hdr['qd'] ?? 0); $an = (int)($hdr['an'] ?? 0); $offset = 12; // Skip questions for ($i = 0; $i < $qd; $i++) { dnsSkipName($wire, $offset); if ($offset + 4 > $n) return []; $offset += 4; // QTYPE + QCLASS } $answers = []; for ($i = 0; $i < $an; $i++) { dnsSkipName($wire, $offset); if ($offset + 10 > $n) break; $rr = unpack('ntype/nclass/Nttl/nrdlen', substr($wire, $offset, 10)); $offset += 10; $type = (int)$rr['type']; $ttl = (int)$rr['ttl']; $rdlen = (int)$rr['rdlen']; if ($offset + $rdlen > $n) break; $rdataOffset = $offset; $rdata = substr($wire, $offset, $rdlen); $offset += $rdlen; $typeStr = dnsTypeToString($type); $data = null; if ($type === 1 && $rdlen === 4) { $data = inet_ntop($rdata); } elseif ($type === 28 && $rdlen === 16) { $data = inet_ntop($rdata); } elseif (in_array($type, [5, 2, 12], true)) { // CNAME/NS/PTR $tmp = $rdataOffset; $data = dnsReadName($wire, $tmp); } elseif ($type === 15 && $rdlen >= 3) { // MX $pref = unpack('n', substr($wire, $rdataOffset, 2))[1]; $tmp = $rdataOffset + 2; $exchange = dnsReadName($wire, $tmp); $data = $pref . ' ' . $exchange; } elseif ($type === 16 && $rdlen >= 1) { // TXT (one or more strings) $tmp = $rdataOffset; $parts = []; $end = $rdataOffset + $rdlen; while ($tmp < $end) { $l = ord($wire[$tmp]); $tmp++; if ($tmp + $l > $end) break; $parts[] = substr($wire, $tmp, $l); $tmp += $l; } $data = implode(' ', $parts); } elseif ($type === 6) { // SOA $tmp = $rdataOffset; $mname = dnsReadName($wire, $tmp); $rname = dnsReadName($wire, $tmp); if ($tmp + 20 <= $rdataOffset + $rdlen) { $soa = unpack('Nserial/Nrefresh/Nretry/Nexpire/Nminimum', substr($wire, $tmp, 20)); $data = sprintf( "%s %s serial=%u refresh=%u retry=%u expire=%u minimum=%u", $mname, $rname, $soa['serial'], $soa['refresh'], $soa['retry'], $soa['expire'], $soa['minimum'] ); } else { $data = $mname . ' ' . $rname; } } elseif ($type === 33 && $rdlen >= 7) { // SRV $prio = unpack('n', substr($wire, $rdataOffset, 2))[1]; $weight = unpack('n', substr($wire, $rdataOffset + 2, 2))[1]; $port = unpack('n', substr($wire, $rdataOffset + 4, 2))[1]; $tmp = $rdataOffset + 6; $target = dnsReadName($wire, $tmp); $data = sprintf("%u %u %u %s", $prio, $weight, $port, $target); } elseif ($type === 257 && $rdlen >= 3) { // CAA $flags = ord($wire[$rdataOffset]); $tagLen = ord($wire[$rdataOffset + 1]); $tag = substr($wire, $rdataOffset + 2, $tagLen); $value = substr($wire, $rdataOffset + 2 + $tagLen, ($rdlen - 2 - $tagLen)); $data = sprintf("flags=%u tag=%s value=%s", $flags, $tag, $value); } else { $data = 'RDATA(' . $rdlen . ' bytes)'; } $answers[] = [ 'type' => $typeStr, 'ttl' => $ttl, 'data' => $data, ]; } return $answers; } function dohWireQuery(string $endpoint, string $name, int $qtype, int $timeoutMs = 2500): array { $wire = buildDnsQueryWire($name, $qtype); $dnsParam = base64url_encode($wire); $url = $endpoint . '?dns=' . rawurlencode($dnsParam); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT_MS => $timeoutMs, CURLOPT_CONNECTTIMEOUT_MS => min(1000, $timeoutMs), CURLOPT_HTTPHEADER => [ 'Accept: application/dns-message', 'User-Agent: MyDNSChecker/1.0' ], ]); $started = microtime(true); $raw = curl_exec($ch); $curlErr = curl_error($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $timeMs = (int)round((microtime(true) - $started) * 1000); if ($raw === false) { return ['ok' => false, 'httpCode' => $httpCode, 'timeMs' => $timeMs, 'error' => $curlErr ?: 'curl error']; } $answers = parseDnsResponseAnswers($raw); return ['ok' => true, 'httpCode' => $httpCode, 'timeMs' => $timeMs, 'answers' => $answers]; } function qtypeToInt(string $type): int { return match ($type) { 'A' => 1, 'NS' => 2, 'CNAME' => 5, 'SOA' => 6, 'PTR' => 12, 'MX' => 15, 'TXT' => 16, 'AAAA' => 28, 'SRV' => 33, 'CAA' => 257, default => 1, }; } function typeToStringFromGoogle($t): ?string { if (is_int($t)) return dnsTypeToString($t); if (is_string($t) && $t !== '') return $t; return null; } // ---- input ---- $method = $_SERVER['REQUEST_METHOD']; $input = []; if ($method === 'POST') { $raw = file_get_contents('php://input'); $decoded = json_decode($raw, true); if (is_array($decoded)) $input = $decoded; } else { $input = $_GET; } $domain = normalizeDomain((string)($input['domain'] ?? '')); $type = strtoupper((string)($input['type'] ?? 'A')); $expected = trim((string)($input['expected'] ?? '')); if ($domain === '') { http_response_code(400); echo json_encode(['error' => 'domain is required']); exit; } if (!in_array($type, $VALID_TYPES, true)) { http_response_code(400); echo json_encode(['error' => 'invalid type']); exit; } // Resolver-liste (DNS over HTTPS) – med countryCode til flag i frontend $resolvers = [ [ 'name' => 'Local server DNS', 'country' => 'Germany', // Optional 'country_code' => 'DE', // Optional 'city' => 'Frankfurt', 'kind' => 'system', ], // Germany [ 'name' => 'dnsforge', 'country' => 'Germany', 'country_code' => 'DE', 'city' => 'Frankfurt', 'kind' => 'wire', 'endpoint' => 'https://dnsforge.de/dns-query', ], [ 'name' => 'Digitale Gesellschaft', 'country' => 'Switzerland', 'country_code' => 'CH', 'city' => 'Zurich', 'kind' => 'wire', 'endpoint' => 'https://dns.digitale-gesellschaft.ch/dns-query', ], // USA [ 'name' => 'Google DNS', 'country' => 'United States', 'country_code' => 'US', 'city' => 'Global', 'kind' => 'json', 'endpoint' => 'https://dns.google/resolve', ], [ 'name' => 'Cloudflare', 'country' => 'United States', 'country_code' => 'US', 'city' => 'Global', 'kind' => 'wire', 'endpoint' => 'https://cloudflare-dns.com/dns-query', ], [ 'name' => 'OpenDNS (Cisco)', 'country' => 'United States', 'country_code' => 'US', 'city' => 'Global', 'kind' => 'wire', 'endpoint' => 'https://doh.opendns.com/dns-query', ], // Switzerland [ 'name' => 'Quad9', 'country' => 'Switzerland', 'country_code' => 'CH', 'city' => 'Zurich', 'kind' => 'wire', 'endpoint' => 'https://dns.quad9.net/dns-query', ], [ 'name' => 'Quad9 (ECS disabled)', 'country' => 'Switzerland', 'country_code' => 'CH', 'city' => 'Zurich', 'kind' => 'wire', 'endpoint' => 'https://dns10.quad9.net/dns-query', ], // France [ 'name' => 'FDN', 'country' => 'France', 'country_code' => 'FR', 'city' => 'Paris', 'kind' => 'wire', 'endpoint' => 'https://dns.fdn.fr/dns-query', ], [ 'name' => 'NextDNS', 'country' => 'France', 'country_code' => 'FR', 'city' => 'EU', 'kind' => 'wire', 'endpoint' => 'https://dns.nextdns.io/dns-query', ], // Netherlands [ 'name' => 'Mullvad DNS', 'country' => 'Netherlands', 'country_code' => 'NL', 'city' => 'Amsterdam', 'kind' => 'wire', 'endpoint' => 'https://dns.mullvad.net/dns-query', ], [ 'name' => 'NLnet Labs (Unbound)', 'country' => 'Netherlands', 'country_code' => 'NL', 'city' => 'Amsterdam', 'kind' => 'wire', 'endpoint' => 'https://unbound.net/dns-query', ], // Global [ 'name' => 'AdGuard', 'country' => 'Global', 'country_code' => 'GL', 'city' => 'Anycast', 'kind' => 'wire', 'endpoint' => 'https://dns.adguard.com/dns-query', ], [ 'name' => 'CleanBrowsing (Security)', 'country' => 'Global', 'country_code' => 'GL', 'city' => 'Anycast', 'kind' => 'wire', 'endpoint' => 'https://doh.cleanbrowsing.org/doh/security-filter/', ], [ 'name' => 'DNS.SB', 'country' => 'Global', 'country_code' => 'GL', 'city' => 'Anycast', 'kind' => 'wire', 'endpoint' => 'https://doh.dns.sb/dns-query', ], ]; $qtypeInt = qtypeToInt($type); $results = []; foreach ($resolvers as $r) { $base = [ 'resolver' => $r['name'], 'country' => $r['country'] ?? null, 'countryCode' => $r['country_code'] ?? null, 'city' => $r['city'] ?? null, ]; try { if ($r['kind'] === 'system') { $started = microtime(true); $map = [ 'A' => DNS_A, 'AAAA' => DNS_AAAA, 'CNAME' => DNS_CNAME, 'MX' => DNS_MX, 'NS' => DNS_NS, 'TXT' => DNS_TXT, 'SOA' => DNS_SOA, 'SRV' => DNS_SRV, 'CAA' => DNS_CAA, 'PTR' => DNS_PTR, ]; $dnsType = $map[$type] ?? DNS_A; $records = @dns_get_record($domain, $dnsType); $timeMs = (int) round((microtime(true) - $started) * 1000); if (!is_array($records) || count($records) === 0) { $results[] = [ 'resolver' => $r['name'], 'country' => $r['country'], 'city' => $r['city'], 'status' => 'NO RESULT', 'timeMs' => $timeMs, 'answers' => [], 'matched' => null, ]; continue; } $answers = []; foreach ($records as $rec) { $data = null; switch ($type) { case 'A': $data = $rec['ip'] ?? null; break; case 'AAAA': $data = $rec['ipv6'] ?? null; break; case 'CNAME': $data = $rec['target'] ?? null; break; case 'MX': $data = ($rec['pri'] ?? '') . ' ' . ($rec['target'] ?? ''); break; case 'NS': case 'PTR': $data = $rec['target'] ?? null; break; case 'TXT': $data = $rec['txt'] ?? null; break; case 'SOA': $data = ($rec['mname'] ?? '') . ' ' . ($rec['rname'] ?? ''); break; case 'SRV': $data = ($rec['pri'] ?? '') . ' ' . ($rec['weight'] ?? '') . ' ' . ($rec['port'] ?? '') . ' ' . ($rec['target'] ?? ''); break; case 'CAA': $data = ($rec['flags'] ?? '') . ' ' . ($rec['tag'] ?? '') . ' ' . ($rec['value'] ?? ''); break; } $answers[] = [ 'type' => $type, 'ttl' => $rec['ttl'] ?? null, 'data' => $data, ]; } $matched = null; if ($expected !== '') { $matched = false; foreach ($answers as $a) { if (is_string($a['data']) && strcasecmp(trim($a['data']), $expected) === 0) { $matched = true; break; } } } $results[] = [ 'resolver' => $r['name'], 'country' => $r['country'], 'city' => $r['city'], 'status' => 'OK', 'timeMs' => $timeMs, 'answers' => $answers, 'matched' => $matched, ]; continue; } if ($r['kind'] === 'json') { $resp = dohJsonQuery($r['endpoint'], $domain, $type); if (!$resp['ok']) { $results[] = $base + [ 'status' => 'ERROR', 'timeMs' => $resp['timeMs'] ?? null, 'error' => $resp['error'] ?? 'unknown error', 'answers' => [], 'matched' => null, ]; continue; } $json = $resp['json']; $answers = []; foreach (($json['Answer'] ?? []) as $a) { $answers[] = [ 'ttl' => $a['TTL'] ?? null, 'data' => $a['data'] ?? null, 'type' => typeToStringFromGoogle($a['type'] ?? null), ]; } $matched = null; if ($expected !== '') { $matched = false; foreach ($answers as $a) { if (is_string($a['data']) && strcasecmp($a['data'], $expected) === 0) { $matched = true; break; } } } $results[] = $base + [ 'status' => 'OK', 'timeMs' => $resp['timeMs'] ?? null, 'rcode' => $json['Status'] ?? null, 'answers' => $answers, 'matched' => $matched, ]; } else { $resp = dohWireQuery($r['endpoint'], $domain, $qtypeInt); if (!$resp['ok']) { $results[] = $base + [ 'status' => 'ERROR', 'timeMs' => $resp['timeMs'] ?? null, 'error' => $resp['error'] ?? 'unknown error', 'answers' => [], 'matched' => null, ]; continue; } $answers = $resp['answers'] ?? []; $matched = null; if ($expected !== '') { $matched = false; foreach ($answers as $a) { if (is_string($a['data']) && strcasecmp($a['data'], $expected) === 0) { $matched = true; break; } } } $results[] = $base + [ 'status' => 'OK', 'timeMs' => $resp['timeMs'] ?? null, 'answers' => $answers, 'matched' => $matched, ]; } } catch (Throwable $e) { $results[] = $base + [ 'status' => 'ERROR', 'error' => $e->getMessage(), 'answers' => [], 'matched' => null, ]; } } echo json_encode([ 'domain' => $domain, 'type' => $type, 'expected' => ($expected !== '' ? $expected : null), 'results' => $results, ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);