On branch main
Initial commit Changes to be committed: new file: api.php new file: index.php new file: readme.md
This commit is contained in:
709
api.php
Normal file
709
api.php
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
<?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);
|
||||||
325
index.php
Normal file
325
index.php
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>DNS Propagation Checker</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.glass {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, rgba(255, 255, 255, .05), rgba(255, 255, 255, .12), rgba(255, 255, 255, .05));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
padding-top: .45rem !important;
|
||||||
|
padding-bottom: .45rem !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="border-b border-white/10 bg-white/5 glass">
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-6">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-slate-100">
|
||||||
|
DNS Propagation Checker
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-sm leading-relaxed text-slate-300">
|
||||||
|
This tool performs global DNS lookups using public DNS resolvers in multiple countries.
|
||||||
|
Results are shown in a compact overview with TTL and record data displayed directly, making it easy to compare DNS responses across locations.
|
||||||
|
A resolver is only marked as OK when it actually returns valid DNS answers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
|
<span class="rounded-full border border-white/10 bg-slate-950/30 px-2 py-1 text-slate-300">
|
||||||
|
🔒 Privacy-friendly · No data logging
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="mt-6 rounded-2xl border border-white/10 bg-white/5 p-4 glass">
|
||||||
|
<form id="form" class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
|
||||||
|
<div class="md:col-span-5">
|
||||||
|
<label class="text-sm text-slate-300">Domain</label>
|
||||||
|
<input id="domain" required class="w-full rounded-xl border border-white/10 bg-slate-950/50 px-3 py-2.5" placeholder="example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="text-sm text-slate-300">Type</label>
|
||||||
|
<select id="type" class="w-full rounded-xl border border-white/10 bg-slate-950/50 px-3 py-2.5">
|
||||||
|
<option>A</option>
|
||||||
|
<option>AAAA</option>
|
||||||
|
<!--<option>CNAME</option> -->
|
||||||
|
<option>MX</option>
|
||||||
|
<option>NS</option>
|
||||||
|
<option>TXT</option>
|
||||||
|
<!-- <option>SOA</option> -->
|
||||||
|
<!-- <option>SRV</option> -->
|
||||||
|
<!-- <option>CAA</option> -->
|
||||||
|
<!-- <option>PTR</option> -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-5">
|
||||||
|
<label class="text-sm text-slate-300">Expected value (optional)</label>
|
||||||
|
<input id="expected" class="w-full rounded-xl border border-white/10 bg-slate-950/50 px-3 py-2.5" placeholder="93.184.216.34" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-12 flex flex-wrap gap-3 mt-2 items-center justify-between">
|
||||||
|
<button id="submitBtn" class="inline-flex items-center justify-center gap-2 rounded-lg
|
||||||
|
bg-slate-200 text-slate-900
|
||||||
|
px-4 py-2 text-sm font-medium
|
||||||
|
hover:bg-white
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-white/40
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition">
|
||||||
|
🔎 Run lookup
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 text-sm text-slate-300">
|
||||||
|
<label><input id="onlyErrors" type="checkbox"> Errors only</label>
|
||||||
|
<label><input id="onlyMismatches" type="checkbox"> Mismatches only</label>
|
||||||
|
<input id="filterText" class="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm" placeholder="Filter…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div id="summaryBadges" class="mt-4 flex flex-wrap gap-2"></div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="mt-4 rounded-2xl border border-white/10 bg-white/5 glass overflow-hidden">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-slate-950/70 border-b border-white/10">
|
||||||
|
<tr class="text-left text-slate-300">
|
||||||
|
<th class="px-4">Resolver</th>
|
||||||
|
<th class="px-4">Status</th>
|
||||||
|
<th class="px-4">Time</th>
|
||||||
|
<th class="px-4">TTL</th>
|
||||||
|
<th class="px-4">Data</th>
|
||||||
|
<th class="px-4">Match</th>
|
||||||
|
<th class="px-4">Detaljer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rows">
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-4 text-slate-400">Run lookup to see results…</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-10 border-t border-white/10 bg-white/5 text-center">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-4 text-xs text-slate-400">
|
||||||
|
© <span id="year"></span> <a href="https://dicm.dk/">dicm.dk</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-opdater årstal
|
||||||
|
document.getElementById('year').textContent = new Date().getFullYear();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function flagFromISO2(code) {
|
||||||
|
if (!code || code.length !== 2) return "";
|
||||||
|
const cc = code.toUpperCase();
|
||||||
|
// regional indicator symbols
|
||||||
|
const A = 0x1F1E6;
|
||||||
|
const first = cc.charCodeAt(0) - 65 + A;
|
||||||
|
const second = cc.charCodeAt(1) - 65 + A;
|
||||||
|
return String.fromCodePoint(first) + String.fromCodePoint(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const rowsEl = $("rows");
|
||||||
|
const summaryEl = $("summaryBadges");
|
||||||
|
|
||||||
|
let lastResponse = null;
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
function hasAnswers(it) {
|
||||||
|
return Array.isArray(it?.answers) && it.answers.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveStatus(it) {
|
||||||
|
if (it?.status === "OK" && !hasAnswers(it)) return "NO_RESULT";
|
||||||
|
return it?.status || "ERROR";
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(text, cls) {
|
||||||
|
return `<span class="rounded-full border px-2 py-0.5 text-xs ${cls}">${text}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeData(answers) {
|
||||||
|
if (!answers || !answers.length) return {
|
||||||
|
ttl: "—",
|
||||||
|
data: "—"
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ttl: answers[0].ttl ?? "—",
|
||||||
|
data: answers.map(a => a.data).filter(Boolean).slice(0, 3).join(" <br><br> ")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- rendering ---------- */
|
||||||
|
|
||||||
|
function renderSummary(resp) {
|
||||||
|
const results = resp.results || [];
|
||||||
|
const ok = results.filter(r => effectiveStatus(r) === "OK").length;
|
||||||
|
const err = results.length - ok;
|
||||||
|
|
||||||
|
const expected = !!resp.expected;
|
||||||
|
const matches = expected ? results.filter(r => r.matched === true && effectiveStatus(r) === "OK").length : 0;
|
||||||
|
const mismatches = expected ? results.filter(r => r.matched === false && effectiveStatus(r) === "OK").length : 0;
|
||||||
|
|
||||||
|
summaryEl.innerHTML = [
|
||||||
|
badge(`${results.length} resolvers`, "border-white/10 bg-white/5"),
|
||||||
|
badge(`${ok} OK`, "border-emerald-400/30 bg-emerald-400/10 text-emerald-200"),
|
||||||
|
badge(`${err} errors`, err ? "border-rose-400/30 bg-rose-400/10 text-rose-200" : "border-white/10 bg-white/5"),
|
||||||
|
expected ? badge(`${matches} matches`, "border-emerald-400/30 bg-emerald-400/10 text-emerald-200") : "",
|
||||||
|
expected ? badge(`${mismatches} mismatches`, mismatches ? "border-amber-400/30 bg-amber-400/10 text-amber-200" : "border-white/10 bg-white/5") : ""
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderRows(resp) {
|
||||||
|
lastResponse = resp;
|
||||||
|
renderSummary(resp);
|
||||||
|
|
||||||
|
const filter = $("filterText").value.toLowerCase();
|
||||||
|
const onlyErrors = $("onlyErrors").checked;
|
||||||
|
const onlyMismatches = $("onlyMismatches").checked && resp.expected;
|
||||||
|
|
||||||
|
const items = resp.results.filter(it => {
|
||||||
|
const st = effectiveStatus(it);
|
||||||
|
if (onlyErrors && st === "OK") return false;
|
||||||
|
if (onlyMismatches && it.matched !== false) return false;
|
||||||
|
if (!filter) return true;
|
||||||
|
return JSON.stringify(it).toLowerCase().includes(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
rowsEl.innerHTML = `<tr><td colspan="7" class="px-4 py-4 text-slate-400">Ingen resultater</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsEl.innerHTML = items.map(it => {
|
||||||
|
const st = effectiveStatus(it);
|
||||||
|
const statusBadge =
|
||||||
|
st === "OK" ? badge("OK", "border-emerald-400/30 bg-emerald-400/10 text-emerald-200") :
|
||||||
|
st === "NO RESULT" ? badge("NO RESULT", "border-rose-400/30 bg-rose-400/10 text-rose-200") :
|
||||||
|
badge("ERROR", "border-rose-400/30 bg-rose-400/10 text-rose-200");
|
||||||
|
|
||||||
|
const {
|
||||||
|
ttl,
|
||||||
|
data
|
||||||
|
} = summarizeData(it.answers);
|
||||||
|
|
||||||
|
const match =
|
||||||
|
resp.expected ?
|
||||||
|
it.matched === true ? badge("match", "border-emerald-400/30 bg-emerald-400/10 text-emerald-200") :
|
||||||
|
it.matched === false ? badge("no", "border-amber-400/30 bg-amber-400/10 text-amber-200") :
|
||||||
|
"—" :
|
||||||
|
"—";
|
||||||
|
|
||||||
|
const flag = flagFromISO2(it.countryCode);
|
||||||
|
const country = it.country || "";
|
||||||
|
const city = it.city ? ` · ${it.city}` : "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="border-b border-white/5 hover:bg-white/5">
|
||||||
|
<td class="px-4 font-semibold"> <div class="font-semibold text-slate-100">
|
||||||
|
${flag ? flag + " " : ""}${escapeHtml(country)} — ${escapeHtml(it.resolver)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400">${escapeHtml(it.city || "")}</div></td>
|
||||||
|
<td class="px-4">${statusBadge}</td>
|
||||||
|
<td class="px-4">${it.timeMs ?? "—"}ms</td>
|
||||||
|
<td class="px-4">${ttl}</td>
|
||||||
|
<td class="px-4 mono max-w-[520px]">${data}</td>
|
||||||
|
<td class="px-4">${match}</td>
|
||||||
|
<td class="px-4">
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer text-xs underline">vis</summary>
|
||||||
|
<pre class="mono text-xs mt-2 max-h-56 overflow-auto">${JSON.stringify(it, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- events ---------- */
|
||||||
|
|
||||||
|
$("form").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
rowsEl.innerHTML = `<tr><td colspan="7" class="px-4 py-4">⏳ Running</td></tr>`;
|
||||||
|
|
||||||
|
const url = new URL("./api.php", location.href);
|
||||||
|
url.searchParams.set("domain", $("domain").value);
|
||||||
|
url.searchParams.set("type", $("type").value);
|
||||||
|
if ($("expected").value.trim()) url.searchParams.set("expected", $("expected").value.trim());
|
||||||
|
|
||||||
|
const r = await fetch(url);
|
||||||
|
const data = await r.json();
|
||||||
|
renderRows(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
["filterText", "onlyErrors", "onlyMismatches"].forEach(id =>
|
||||||
|
$(id).addEventListener("input", () => lastResponse && renderRows(lastResponse))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user