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..44e26c9
--- /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). 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 696d77a..4750217 100644
--- a/js/dns.js
+++ b/js/dns.js
@@ -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)) {
@@ -45,15 +106,40 @@ function renderLookupControls(panel, isPtr) {
const pills = types.map((t) =>
``
).join('');
- panel.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 = `
Name
TTL
Type
Data
` +
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' } }] },