Initial commit Changes to be committed: new file: api.php new file: index.php new file: readme.md
325 lines
11 KiB
PHP
325 lines
11 KiB
PHP
<!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> |