Files
Kim Vinberg aKa Uldtot d0f505afd5 On branch main
Initial commit

 Changes to be committed:
	new file:   api.php
	new file:   index.php
	new file:   readme.md
2026-01-25 14:05:51 +01:00

710 lines
21 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); // justér til dit domæne i prod
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$VALID_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SOA', 'SRV', 'TXT', 'CAA'];
function normalizeDomain(string $input): string
{
$d = trim($input);
$d = preg_replace('#^https?://#i', '', $d);
$d = explode('/', $d)[0] ?? $d;
$d = trim($d, " \t\n\r\0\x0B.");
return $d;
}
/**
* DoH via JSON endpoint (Google style): https://dns.google/resolve?name=...&type=...
*/
function dohJsonQuery(string $baseUrl, string $name, string $type, int $timeoutMs = 2500): array
{
$url = $baseUrl . '?name=' . rawurlencode($name) . '&type=' . rawurlencode($type);
$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-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);