From 8685d527f88901b2f6f02a05ee832912e656764f Mon Sep 17 00:00:00 2001 From: Chris Muench <8158602+chrismuench@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:49:39 -0700 Subject: [PATCH 1/2] DNS Lookup: nameserver picker (DoH presets, authoritative, custom via /api/dig) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lookup subtab now has a Server dropdown: - Cloudflare / Google / DNS.SB — DoH straight from the browser, as before (the only public resolvers with a CORS-enabled JSON API). - Authoritative — walks up from the query name via NS/SOA to find the zone's nameserver, then queries it directly. - Custom… — any nameserver hostname or IP, like `dig @ns1.example.com`. Browsers can't speak DNS on port 53, so the last two go through a new /api/dig Pages Function that does DNS over TCP via cloudflare:sockets, using a minimal wire-format codec in lib/dnswire.mjs (encode one question; decode answers incl. name compression, A/AAAA/NS/CNAME/SOA/ PTR/MX/TXT/SRV/CAA rdata). Strict input validation, private/reserved nameservers blocked, 64KB cap, 8s timeout, 60s edge cache. Responses match the DoH JSON shape so the frontend rendering is unchanged; a note shows which server answered. Co-Authored-By: Claude Fable 5 --- README.md | 4 +- functions/_middleware.js | 1 + functions/api/dig.js | 113 ++++++++++++++++++++++++ js/dns.js | 108 +++++++++++++++++++++-- lib/dnswire.mjs | 180 +++++++++++++++++++++++++++++++++++++++ tests/e2e.mjs | 21 +++++ tests/smoke.mjs | 43 ++++++++++ 7 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 functions/api/dig.js create mode 100644 lib/dnswire.mjs diff --git a/README.md b/README.md index ae32a4c..d4ea654 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/functions/_middleware.js b/functions/_middleware.js index 40cb134..0b47200 100644 --- a/functions/_middleware.js +++ b/functions/_middleware.js @@ -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, }; diff --git a/functions/api/dig.js b/functions/api/dig.js new file mode 100644 index 0000000..3d8dffa --- /dev/null +++ b/functions/api/dig.js @@ -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).' }, 424); + } +} diff --git a/js/dns.js b/js/dns.js index 696d77a..409729c 100644 --- a/js/dns.js +++ b/js/dns.js @@ -5,6 +5,64 @@ 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') { + ns = dnsCustomNS.trim().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.'); + } 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)) { @@ -45,15 +103,39 @@ function renderLookupControls(panel, isPtr) { const pills = types.map((t) => `` ).join(''); - panel.innerHTML = `
${pills}
`; + const options = LOOKUP_SERVERS.map((s) => + `` + ).join(''); + panel.innerHTML = + `
${pills}
` + + `
` + + `
` + + `
`; + 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) { @@ -64,30 +146,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 : `
No records found for ${window.escapeHtml(query)}.
`; + out.innerHTML = any ? html + serverNote(server) : `
No records found for ${window.escapeHtml(query)}.
`; 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 = `
No ${type} records found for ${window.escapeHtml(query)}.
`; + out.innerHTML = `
No ${type} records found for ${window.escapeHtml(query)}.
` + 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 `
Answered by ${window.escapeHtml(server)} — queried over DNS/TCP through the sysadminstuff.net edge, since browsers can't speak port 53.
`; +} + function recordCard(type, rows) { const body = `` + rows.map((r) => diff --git a/lib/dnswire.mjs b/lib/dnswire.mjs new file mode 100644 index 0000000..1c3a2bd --- /dev/null +++ b/lib/dnswire.mjs @@ -0,0 +1,180 @@ +// lib/dnswire.mjs — minimal DNS wire-format codec for the /api/dig function. +// Pure logic (no I/O) so the smoke tests can exercise it directly. +// +// Only what a "dig @ns" lookup needs: encode one question, decode a response's +// answer/authority records into the same JSON shape DoH returns +// ({ name, type, TTL, data }), with name-compression support. + +export const TYPE_NUMS = { + A: 1, NS: 2, CNAME: 5, SOA: 6, PTR: 12, MX: 15, TXT: 16, AAAA: 28, SRV: 33, CAA: 257, +}; + +// Build a single-question query message (no TCP length prefix). +export function encodeQuery(name, typeNum, id = 0) { + const labels = String(name).replace(/\.$/, '').split('.'); + const parts = []; + for (const label of labels) { + if (!label.length || label.length > 63) throw new Error('Invalid name.'); + parts.push(label.length); + for (let i = 0; i < label.length; i++) parts.push(label.charCodeAt(i) & 0xff); + } + parts.push(0); + const buf = new Uint8Array(12 + parts.length + 4); + const dv = new DataView(buf.buffer); + dv.setUint16(0, id & 0xffff); + dv.setUint16(2, 0x0100); // RD + dv.setUint16(4, 1); // QDCOUNT + buf.set(parts, 12); + dv.setUint16(12 + parts.length, typeNum); + dv.setUint16(12 + parts.length + 2, 1); // IN + return buf; +} + +// Decode a (possibly compressed) name starting at `off`. +// Returns { name, next } where next is the offset after the name in place. +function decodeName(buf, off) { + const labels = []; + let next = -1; + let jumps = 0; + while (true) { + if (off >= buf.length) throw new Error('Truncated name.'); + const len = buf[off]; + if (len === 0) { if (next < 0) next = off + 1; break; } + if ((len & 0xc0) === 0xc0) { + if (off + 1 >= buf.length) throw new Error('Truncated pointer.'); + if (next < 0) next = off + 2; + off = ((len & 0x3f) << 8) | buf[off + 1]; + if (++jumps > 64) throw new Error('Compression loop.'); + continue; + } + if (len > 63 || off + 1 + len > buf.length) throw new Error('Bad label.'); + let s = ''; + for (let i = off + 1; i <= off + len; i++) { + const c = buf[i]; + s += (c >= 33 && c <= 126 && c !== 46) ? String.fromCharCode(c) : `\\${String(c).padStart(3, '0')}`; + } + labels.push(s); + off += 1 + len; + if (labels.length > 128) throw new Error('Name too long.'); + } + return { name: labels.join('.'), next }; +} + +// DNS character-strings (TXT): ..., rendered dig-style in quotes. +function decodeStrings(buf, off, end) { + const out = []; + while (off < end) { + const len = buf[off]; + if (off + 1 + len > end) throw new Error('Bad character-string.'); + let s = ''; + for (let i = off + 1; i <= off + len; i++) { + const c = buf[i]; + s += (c >= 32 && c <= 126) ? (c === 34 || c === 92 ? '\\' + String.fromCharCode(c) : String.fromCharCode(c)) : `\\${String(c).padStart(3, '0')}`; + } + out.push(`"${s}"`); + off += 1 + len; + } + return out.join(' '); +} + +function ipv4(buf, off) { + return `${buf[off]}.${buf[off + 1]}.${buf[off + 2]}.${buf[off + 3]}`; +} + +function ipv6(buf, off) { + const groups = []; + for (let i = 0; i < 8; i++) groups.push(((buf[off + i * 2] << 8) | buf[off + i * 2 + 1]).toString(16)); + // Compress the longest run of zero groups, RFC 5952 style. + let best = -1, bestLen = 0; + for (let i = 0; i < 8; i++) { + if (groups[i] !== '0') continue; + let j = i; + while (j < 8 && groups[j] === '0') j++; + if (j - i > bestLen) { best = i; bestLen = j - i; } + i = j; + } + if (bestLen < 2) return groups.join(':'); + const head = groups.slice(0, best).join(':'); + const tail = groups.slice(best + bestLen).join(':'); + return `${head}::${tail}`; +} + +function decodeRdata(typeNum, buf, off, len) { + const dv = new DataView(buf.buffer, buf.byteOffset); + const end = off + len; + switch (typeNum) { + case 1: // A + if (len !== 4) throw new Error('Bad A rdata.'); + return ipv4(buf, off); + case 28: // AAAA + if (len !== 16) throw new Error('Bad AAAA rdata.'); + return ipv6(buf, off); + case 2: case 5: case 12: // NS, CNAME, PTR + return decodeName(buf, off).name + '.'; + case 15: { // MX + const pref = dv.getUint16(off); + return `${pref} ${decodeName(buf, off + 2).name}.`; + } + case 6: { // SOA + const m = decodeName(buf, off); + const r = decodeName(buf, m.next); + const nums = []; + for (let i = 0; i < 5; i++) nums.push(dv.getUint32(r.next + i * 4)); + return `${m.name}. ${r.name}. ${nums.join(' ')}`; + } + case 16: // TXT + return decodeStrings(buf, off, end); + case 33: { // SRV + const pri = dv.getUint16(off), wt = dv.getUint16(off + 2), port = dv.getUint16(off + 4); + return `${pri} ${wt} ${port} ${decodeName(buf, off + 6).name}.`; + } + case 257: { // CAA + const flags = buf[off]; + const tagLen = buf[off + 1]; + let tag = ''; + for (let i = 0; i < tagLen; i++) tag += String.fromCharCode(buf[off + 2 + i]); + let value = ''; + for (let i = off + 2 + tagLen; i < end; i++) value += String.fromCharCode(buf[i]); + return `${flags} ${tag} "${value}"`; + } + default: { // unknown → hex + let hex = ''; + for (let i = off; i < end; i++) hex += buf[i].toString(16).padStart(2, '0'); + return hex; + } + } +} + +export const RCODES = { + 0: 'NOERROR', 1: 'FORMERR', 2: 'SERVFAIL', 3: 'NXDOMAIN', 4: 'NOTIMP', 5: 'REFUSED', +}; + +// Parse a full response message (no TCP length prefix). +// Returns { id, rcode, rcodeName, answers: [{name, type, TTL, data}] }. +export function parseResponse(buf) { + if (buf.length < 12) throw new Error('Truncated header.'); + const dv = new DataView(buf.buffer, buf.byteOffset); + const id = dv.getUint16(0); + const flags = dv.getUint16(2); + if (!(flags & 0x8000)) throw new Error('Not a response.'); + const rcode = flags & 0x0f; + const qd = dv.getUint16(4); + const an = dv.getUint16(6); + let off = 12; + for (let i = 0; i < qd; i++) { + off = decodeName(buf, off).next + 4; + } + const answers = []; + for (let i = 0; i < an; i++) { + const n = decodeName(buf, off); + if (n.next + 10 > buf.length) throw new Error('Truncated record.'); + const typeNum = dv.getUint16(n.next); + const ttl = dv.getUint32(n.next + 4); + const rdLen = dv.getUint16(n.next + 8); + const rdOff = n.next + 10; + if (rdOff + rdLen > buf.length) throw new Error('Truncated rdata.'); + answers.push({ name: n.name, type: typeNum, TTL: ttl, data: decodeRdata(typeNum, buf, rdOff, rdLen) }); + off = rdOff + rdLen; + } + return { id, rcode, rcodeName: RCODES[rcode] || String(rcode), answers }; +} diff --git a/tests/e2e.mjs b/tests/e2e.mjs index 15990cd..7b3a20b 100644 --- a/tests/e2e.mjs +++ b/tests/e2e.mjs @@ -80,6 +80,27 @@ async function main() { okUpstream('whois .eu raw fallback 200', status, status === 200, `status ${status}`); okUpstream('whois .eu returns raw text', status, json && json.raw && json.source, JSON.stringify(json).slice(0, 120)); } + // dig — DNS over TCP/53 against a chosen nameserver (live network) + { + const { status, json } = await getJson('/api/dig?name=example.com&type=A&ns=8.8.8.8'); + okUpstream('dig A via 8.8.8.8 200', status, status === 200, `status ${status}`); + okUpstream('dig returns Answer array', status, json && Array.isArray(json.Answer), JSON.stringify(json).slice(0, 120)); + okUpstream('dig echoes server', status, json && json.Server === '8.8.8.8', JSON.stringify(json).slice(0, 120)); + } + // dig validation — OUR checks, always run + { + const { status } = await getJson('/api/dig?name=example.com&type=A'); + ok('dig missing ns 400', status === 400); + } + { + const { status } = await getJson('/api/dig?name=example.com&type=BOGUS&ns=8.8.8.8'); + ok('dig bad type 400', status === 400); + } + { + const { status } = await getJson('/api/dig?name=example.com&type=A&ns=10.0.0.1'); + ok('dig blocks private nameserver 400', status === 400); + } + // whois missing param — this is OUR validation, always checked { const { status, json } = await getJson('/api/whois'); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index f594c6b..853910a 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -14,6 +14,7 @@ const require = createRequire(import.meta.url); // core.js is a UMD-style classic script; require() picks up its module.exports. const core = require(join(__dirname, '..', 'js', 'core.js')); const parse = await import('../lib/parse.mjs'); +const dnswire = await import('../lib/dnswire.mjs'); // ---- tiny test harness ---- let passed = 0; @@ -203,6 +204,48 @@ eq('whois referral absent', parse.parseWhoisReferral('% IANA WHOIS server\nstatu eq('whois referral garbage rejected', parse.parseWhoisReferral('whois: not a host!\n'), ''); eq('whois referral empty input', parse.parseWhoisReferral(''), ''); +// ================= lib/dnswire.mjs: DNS wire codec ================= +{ + const q = dnswire.encodeQuery('example.com', 1, 0x1234); + eq('dnswire query length', q.length, 29); + eq('dnswire query header', [...q.slice(0, 12)], [0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + eq('dnswire query qname', [...q.slice(12, 25)], + [7, ...[...'example'].map((c) => c.charCodeAt(0)), 3, ...[...'com'].map((c) => c.charCodeAt(0)), 0]); + eq('dnswire query qtype/qclass', [...q.slice(25)], [0, 1, 0, 1]); + + // Hand-built response: example.com with A, MX, TXT, AAAA answers, all using + // a compression pointer (0xc00c) back to the question name. + const s = (str) => [...str].map((c) => c.charCodeAt(0)); + const resp = new Uint8Array([ + 0x12, 0x34, 0x81, 0x80, 0, 1, 0, 4, 0, 0, 0, 0, // header: QR|RD|RA, NOERROR + 7, ...s('example'), 3, ...s('com'), 0, 0, 1, 0, 1, // question: example.com A IN + 0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 1, 0x2c, 0, 4, 93, 184, 216, 34, // A 300 93.184.216.34 + 0xc0, 0x0c, 0, 15, 0, 1, 0, 0, 1, 0x2c, 0, 9, 0, 10, 4, ...s('mail'), 0xc0, 0x0c, // MX 10 mail.example.com. + 0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 0x3c, 0, 12, 11, ...s('hello world'), // TXT "hello world" + 0xc0, 0x0c, 0, 28, 0, 1, 0, 0, 0, 0x3c, 0, 16, 0x26, 0x06, 0x47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x11, 0x11, // AAAA + ]); + const parsed = dnswire.parseResponse(resp); + eq('dnswire rcode', parsed.rcodeName, 'NOERROR'); + eq('dnswire answer count', parsed.answers.length, 4); + eq('dnswire A answer', parsed.answers[0], { name: 'example.com', type: 1, TTL: 300, data: '93.184.216.34' }); + eq('dnswire MX data', parsed.answers[1].data, '10 mail.example.com.'); + eq('dnswire TXT data', parsed.answers[2].data, '"hello world"'); + eq('dnswire AAAA data', parsed.answers[3].data, '2606:4700::1111'); + + const nx = dnswire.parseResponse(new Uint8Array([0, 1, 0x81, 0x83, 0, 0, 0, 0, 0, 0, 0, 0])); + eq('dnswire NXDOMAIN', nx.rcodeName, 'NXDOMAIN'); + + let threw = false; + try { dnswire.parseResponse(new Uint8Array([0, 1, 0x81])); } catch (e) { threw = true; } + check('dnswire truncated throws', threw); + threw = false; + // Compression pointer that points at itself must not loop forever. + try { + dnswire.parseResponse(new Uint8Array([0, 1, 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0, 0xc0, 0x0c, 0, 1, 0, 1])); + } catch (e) { threw = true; } + check('dnswire pointer loop throws', threw); +} + // ================= lib/parse.mjs: bgpview shaping ================= const asnFromIp = parse.shapeAsnFromIp({ data: { ip: '1.1.1.1', prefixes: [{ prefix: '1.1.1.0/24', name: 'APNIC-LABS', asn: { asn: 13335, name: 'CLOUDFLARENET', description: 'Cloudflare', country_code: 'US' } }] }, From a3dc5a8eaff4a6e072d0c42d0283e7639e76f2c9 Mon Sep 17 00:00:00 2001 From: Chris Muench <8158602+chrismuench@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:53:36 -0700 Subject: [PATCH 2/2] DNS custom nameserver: tolerate pasted DoH URLs, clarify port-53 contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom entry is always queried over classic DNS on TCP/53 (the dig @server contract) — which every major DoH provider also serves. If someone pastes a DoH URL, extract its hostname via hostFromInput; if the host truly only speaks DoH, the error now says so. Co-Authored-By: Claude Fable 5 --- functions/api/dig.js | 2 +- js/dns.js | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/functions/api/dig.js b/functions/api/dig.js index 3d8dffa..44e26c9 100644 --- a/functions/api/dig.js +++ b/functions/api/dig.js @@ -108,6 +108,6 @@ export async function onRequest(context) { } 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).' }, 424); + 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); } } diff --git a/js/dns.js b/js/dns.js index 409729c..4750217 100644 --- a/js/dns.js +++ b/js/dns.js @@ -51,9 +51,12 @@ async function lookupDNS(name, type) { } let ns; if (server.key === 'custom') { - ns = dnsCustomNS.trim().replace(/\.$/, ''); + // 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.'); + 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); } @@ -111,6 +114,7 @@ function renderLookupControls(panel, isPtr) { `
` + `
` + `
`; const rerun = () => {
NameTTLTypeData