Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Clean, ad-free sysadmin tools — DNS, email authentication, TLS, certificates,
## Features

### DNS
- **Lookup** — A, AAAA, MX, TXT, NS, CNAME, SOA, PTR, SRV, or ALL, via Cloudflare DNS-over-HTTPS. IP input auto-switches to reverse (PTR).
- **Lookup** — A, AAAA, MX, TXT, NS, CNAME, SOA, PTR, SRV, or ALL, with a **nameserver picker**: Cloudflare / Google / DNS.SB (DoH from your browser), the zone's **authoritative** nameserver, or any **custom** server — the last two speak real DNS over TCP/53 through `/api/dig` (browsers can't). IP input auto-switches to reverse (PTR).
- **Propagation** — the same record across Cloudflare, Google, and DNS.SB, with a consistency verdict. Runs entirely in your browser (these are the public resolvers that expose a browser-usable JSON + CORS DoH API).
- **CAA** — which CAs may issue certificates for a domain (RFC3597-parsed).
- **DNSSEC** — signed/unsigned status from the AD flag + DS/DNSKEY presence.
Expand Down Expand Up @@ -118,7 +118,7 @@ Hosting is effectively free: static files are unlimited on Pages, and the Functi
The real concern is abuse of the API endpoints — several proxy third-party services (`/api/crtsh`, `/api/rbl`, `/api/asn`, `/api/tenant`, `/api/headers`, `/api/tls`) that could rate-limit or ban your IP under load. Two defenses:

1. **Code-level (already in the repo):** `functions/_middleware.js` restricts `/api/*` to read methods (POST → 405), rejects oversized URLs, and **requires a same-origin browser fetch** (`Sec-Fetch-Site`) — so `curl`, scripts, and other sites are turned away with a 403. It's stateless, so it's free and always on. (This stops casual/scripted abuse; a determined attacker can still forge the header, which is what the rate-limit rule below is for.)
It also **edge-caches** successful GET responses per endpoint (crtsh/asn/whois/tenant 1h, rbl/tls 10m, headers 5m) via Cloudflare's Cache API, so repeated lookups of the same domain/IP are served from cache and never re-hit the upstream. Responses carry `X-Cache: HIT|MISS`.
It also **edge-caches** successful GET responses per endpoint (crtsh/asn/whois/tenant 1h, rbl/tls 10m, headers 5m, dig 1m) via Cloudflare's Cache API, so repeated lookups of the same domain/IP are served from cache and never re-hit the upstream. Responses carry `X-Cache: HIT|MISS`.
2. **Rate limiting (set this up once, free):** In the Cloudflare dashboard for the zone → **Security → WAF → Rate limiting rules → Create rule**:
- **If incoming requests match:** `URI Path` `contains` `/api/`
- **Rate:** `20` requests per `10` seconds, counting by client IP
Expand Down
1 change: 1 addition & 0 deletions functions/_middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const CACHE_TTL = {
'/api/whois': 3600,
'/api/tenant': 3600,
'/api/rbl': 600,
'/api/dig': 60,
'/api/tls': 600,
'/api/headers': 300,
};
Expand Down
113 changes: 113 additions & 0 deletions functions/api/dig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// functions/api/dig.js — query a caller-chosen nameserver over DNS/TCP :53
// (like `dig @ns`), which browsers can't do themselves. No user input is
// ever logged. Returns the same JSON shape as DoH: { Status, Answer: [...] }.

import { connect } from 'cloudflare:sockets';
import { isBlockedHost } from '../../lib/parse.mjs';
import { TYPE_NUMS, RCODES, encodeQuery, parseResponse } from '../../lib/dnswire.mjs';

const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};

function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...CORS },
});
}

// Query names may contain underscores (_dmarc, _sip._tls) and arpa labels.
const NAME_RE = /^[a-z0-9._-]{1,253}$/i;
// Nameserver: hostname or IP literal (IPv6 allowed bare or bracketed).
const NS_RE = /^\[?[a-z0-9:.-]{1,253}\]?$/i;
const MAX_MSG = 65535;

async function dnsTcpQuery(ns, name, typeNum, timeoutMs = 8000) {
const socket = connect({ hostname: ns.replace(/^\[|\]$/g, ''), port: 53 });
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
});
const work = (async () => {
const id = Math.floor(Math.random() * 0x10000);
const msg = encodeQuery(name, typeNum, id);
// TCP framing: 2-byte big-endian length prefix.
const framed = new Uint8Array(2 + msg.length);
framed[0] = msg.length >> 8;
framed[1] = msg.length & 0xff;
framed.set(msg, 2);
const writer = socket.writable.getWriter();
await writer.write(framed);
writer.releaseLock();

const reader = socket.readable.getReader();
const chunks = [];
let total = 0;
let expect = -1;
for (;;) {
if (expect < 0 && total >= 2) {
const head = chunks[0].length >= 2 ? chunks[0] : concat(chunks, total);
expect = (head[0] << 8) | head[1];
if (expect > MAX_MSG) throw new Error('oversized');
}
if (expect >= 0 && total >= expect + 2) break;
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
total += value.length;
if (total > MAX_MSG + 2) throw new Error('oversized');
}
const all = concat(chunks, total);
if (expect < 0 || total < expect + 2) throw new Error('short read');
const body = all.subarray(2, 2 + expect);
const parsed = parseResponse(body);
if (parsed.id !== id) throw new Error('id mismatch');
return parsed;
})();
try {
return await Promise.race([work, timeout]);
} finally {
clearTimeout(timer);
try { socket.close(); } catch (e) { /* already closed */ }
}
}

function concat(chunks, total) {
const buf = new Uint8Array(total);
let off = 0;
for (const c of chunks) { buf.set(c, off); off += c.length; }
return buf;
}

export async function onRequest(context) {
const { request } = context;
if (request.method === 'OPTIONS') return new Response(null, { headers: CORS });

const params = new URL(request.url).searchParams;
const name = (params.get('name') || '').trim();
const type = (params.get('type') || 'A').trim().toUpperCase();
const ns = (params.get('ns') || '').trim();

if (!name || !NAME_RE.test(name)) return json({ error: 'Missing or invalid name.' }, 400);
const typeNum = TYPE_NUMS[type];
if (!typeNum) return json({ error: `Unsupported record type ${type}.` }, 400);
if (!ns || !NS_RE.test(ns)) return json({ error: 'Missing or invalid nameserver.' }, 400);
if (isBlockedHost(ns)) return json({ error: 'That nameserver is a private or reserved address.' }, 400);

try {
const parsed = await dnsTcpQuery(ns, name, typeNum);
return json({
Status: parsed.rcode,
StatusName: parsed.rcodeName,
Server: ns,
Answer: parsed.answers,
});
} catch (e) {
// No user data in log.
console.error('dig query failed.');
return json({ error: 'Could not get an answer from that nameserver (unreachable, refused, or timed out). It must speak standard DNS on port 53 — a DoH-only endpoint won’t answer here.' }, 424);
}
}
112 changes: 103 additions & 9 deletions js/dns.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,67 @@ const TYPE_NUM = { 1: 'A', 2: 'NS', 5: 'CNAME', 6: 'SOA', 12: 'PTR', 15: 'MX', 1

let dnsSelectedType = 'A';

// ---------- Lookup nameserver picker ----------
// Presets query DoH straight from the browser (only these public resolvers
// send CORS headers). "Authoritative" and "Custom" need real DNS on TCP/53,
// which a browser can't speak — those go through our /api/dig function.
const LOOKUP_SERVERS = [
{ key: 'cloudflare', label: 'Cloudflare (1.1.1.1)', doh: 'https://cloudflare-dns.com/dns-query' },
{ key: 'google', label: 'Google (8.8.8.8)', doh: 'https://dns.google/resolve' },
{ key: 'dnssb', label: 'DNS.SB (185.222.222.222)', doh: 'https://doh.sb/dns-query' },
{ key: 'auth', label: 'Authoritative (zone NS)' },
{ key: 'custom', label: 'Custom nameserver…' },
];
let dnsServerKey = 'cloudflare';
let dnsCustomNS = '';
const authNSCache = new Map();

// Find the zone's nameserver by walking up from the query name.
async function findAuthNS(name) {
const cached = authNSCache.get(name);
if (cached) return cached;
let zone = name.replace(/\.$/, '');
for (let i = 0; i < 5; i++) {
const d = await window.dohQuery(zone, 'NS');
const servers = (d.Answer || []).filter((a) => a.type === 2).map((a) => String(a.data).replace(/\.$/, ''));
if (servers.length) { authNSCache.set(name, servers.sort()[0]); return authNSCache.get(name); }
// Not a zone apex — the SOA in the authority section names the zone; else strip a label.
const soa = (d.Authority || []).find((a) => a.type === 6);
const parent = soa && soa.name !== zone ? soa.name : zone.split('.').slice(1).join('.');
if (!parent || parent === zone || !parent.includes('.')) break;
zone = parent;
}
throw new Error(`Could not determine the authoritative nameserver for ${name}.`);
}

// Query `name`/`type` against whatever server is selected. Returns DoH-shaped
// JSON ({ Answer: [...] }, plus Server when it went through /api/dig).
async function lookupDNS(name, type) {
const server = LOOKUP_SERVERS.find((s) => s.key === dnsServerKey) || LOOKUP_SERVERS[0];
if (server.doh) {
const sep = server.doh.includes('?') ? '&' : '?';
const res = await fetch(`${server.doh}${sep}name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`,
{ headers: { Accept: 'application/dns-json' } });
if (!res.ok) throw new Error(`DNS query failed (${res.status})`);
return res.json();
}
let ns;
if (server.key === 'custom') {
// Accept a pasted DoH URL too (https://dns.google/dns-query → dns.google);
// whatever we end up with is queried over classic DNS on TCP/53, which
// every major public resolver serves alongside DoH.
ns = window.hostFromInput(dnsCustomNS).replace(/\.$/, '');
if (!ns) throw new Error('Enter a nameserver hostname or IP address.');
if (!window.isDomain(ns) && !window.isIP(ns)) throw new Error('The custom nameserver must be a hostname or IP address (it will be queried over standard DNS on port 53).');
} else {
ns = await findAuthNS(name);
}
const res = await fetch(`/api/dig?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}&ns=${encodeURIComponent(ns)}`);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) throw new Error(data.error || `dig query failed (${res.status})`);
return data;
}

function reverseInAddr(ip) {
ip = (ip || '').trim();
if (window.isIPv4(ip)) {
Expand Down Expand Up @@ -45,15 +106,40 @@ function renderLookupControls(panel, isPtr) {
const pills = types.map((t) =>
`<button class="pill${t === dnsSelectedType ? ' active' : ''}" data-type="${t}">${t}</button>`
).join('');
panel.innerHTML = `<div class="pills">${pills}</div><div class="result" id="dns-lookup-result"></div>`;
const options = LOOKUP_SERVERS.map((s) =>
`<option value="${s.key}"${s.key === dnsServerKey ? ' selected' : ''}>${s.label}</option>`
).join('');
panel.innerHTML =
`<div class="pills">${pills}</div>` +
`<div class="pills"><label class="field-label" style="margin:0">Server ` +
`<select id="dns-ns-select" class="text-input">${options}</select></label>` +
`<input type="text" id="dns-ns-custom" class="text-input" placeholder="ns1.example.com or 9.9.9.9" ` +
`title="Queried over standard DNS (TCP port 53). Pasting a DoH URL is fine — its hostname is used." ` +
`value="${window.escapeHtml(dnsCustomNS)}" style="width:16rem;display:${dnsServerKey === 'custom' ? '' : 'none'}"></div>` +
`<div class="result" id="dns-lookup-result"></div>`;
const rerun = () => {
const q = document.getElementById('query').value.trim();
if (q) doLookup(q, panel, dnsSelectedType);
};
panel.querySelectorAll('.pill').forEach((p) => {
p.addEventListener('click', () => {
dnsSelectedType = p.dataset.type;
panel.querySelectorAll('.pill').forEach((x) => x.classList.toggle('active', x === p));
const q = document.getElementById('query').value.trim();
doLookup(q, panel, dnsSelectedType);
rerun();
});
});
const sel = panel.querySelector('#dns-ns-select');
const custom = panel.querySelector('#dns-ns-custom');
sel.addEventListener('change', () => {
dnsServerKey = sel.value;
custom.style.display = dnsServerKey === 'custom' ? '' : 'none';
if (dnsServerKey === 'custom' && !dnsCustomNS.trim()) { custom.focus(); return; }
rerun();
});
custom.addEventListener('change', () => { dnsCustomNS = custom.value; rerun(); });
custom.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); dnsCustomNS = custom.value; rerun(); }
});
}

async function doLookup(query, panel, type) {
Expand All @@ -64,30 +150,38 @@ async function doLookup(query, panel, type) {
if (type === 'ALL') {
const wanted = DNS_TYPES.filter((t) => t !== 'PTR');
const results = await Promise.all(wanted.map((t) =>
window.dohQuery(query, t).then((d) => ({ t, d })).catch(() => ({ t, d: null }))
lookupDNS(query, t).then((d) => ({ t, d })).catch(() => ({ t, d: null }))
));
let html = '';
let any = false;
let server = '';
results.forEach(({ t, d }) => {
if (d && d.Server) server = d.Server;
const rows = (d && d.Answer) ? d.Answer.filter((a) => typeName(a.type) === t) : [];
if (rows.length) { any = true; html += recordCard(t, rows); }
});
out.innerHTML = any ? html : `<div class="summary grey">No records found for ${window.escapeHtml(query)}.</div>`;
out.innerHTML = any ? html + serverNote(server) : `<div class="summary grey">No records found for ${window.escapeHtml(query)}.</div>`;
window.showResult(out, out.innerHTML);
} else {
const data = await window.dohQuery(lookupName, type);
const data = await lookupDNS(lookupName, type);
const rows = (data.Answer || []).filter((a) => typeName(a.type) === type);
if (!rows.length) {
out.innerHTML = `<div class="summary grey">No ${type} records found for ${window.escapeHtml(query)}.</div>`;
out.innerHTML = `<div class="summary grey">No ${type} records found for ${window.escapeHtml(query)}.</div>` + serverNote(data.Server);
} else {
window.showResult(out, recordCard(type, rows));
window.showResult(out, recordCard(type, rows) + serverNote(data.Server));
}
}
} catch (e) {
window.showError(out, `Could not reach the DNS resolver. ${e.message || ''}`.trim());
window.showError(out, `${e.message || 'Could not reach the DNS resolver.'}`.trim());
}
}

// Shown when the answer came from a specific nameserver via /api/dig.
function serverNote(server) {
if (!server) return '';
return `<div class="note">Answered by <strong>${window.escapeHtml(server)}</strong> — queried over DNS/TCP through the sysadminstuff.net edge, since browsers can&#39;t speak port 53.</div>`;
}

function recordCard(type, rows) {
const body = `<table><thead><tr><th>Name</th><th>TTL</th><th>Type</th><th>Data</th></tr></thead><tbody>` +
rows.map((r) =>
Expand Down
Loading
Loading