Ariadne c5efae43: tlds.bch-driven install + BNS_POLICY, doc-aligned

Three doc-alignment changes shipping in AriadneResolver-Setup-0.1.0.exe
sha256 c5efae43af6cc163f468f1dc73d08939db32a9a6db52298ba18b2d9a2aee1292:

1. install.ps1 fetches the activated TLD list from the on-chain root
   TLD certificate (tlds.bch, per START-HERE §1) via
   resolver-web.js fetchBcnrTlds(), then:
   - regenerates the CA's nameConstraints extension from that list
     (rotates the CA when the on-chain set changes so leaves for the
     newly-activated TLDs actually validate)
   - adds one NRPT rule per activated TLD in surgical mode
   - falls back to bootstrap ["bch"] if the chain is unreachable
   Result: adding a TLD on-chain is picked up by re-running the
   installer, no code release needed.

2. bnsd.js honors --policy / BNS_POLICY (bcnr-first | icann-first,
   alias bns-first) per START-HERE §4b. bcnr-first is default and
   preserves prior behavior. icann-first forwards to upstream first
   and only substitutes a BCNR answer on NXDOMAIN. install.ps1
   accepts -Policy and passes it into the scheduled task action.

3. ca.js loadOrCreateRootCA() now accepts a runtime TLD list; the
   old callers still work (fall back to PERMITTED_TLDS from tlds.js).

Doc: RESOLVER-PACKAGING-PROMPT.md rewritten to reflect current
shipping reality; original 2026-07-26 packaging brief preserved
in the "Archival" section.

Deployed to dl.silentmode.st (nginx serves from /opt/silent-mode/dl/)
and mirrored to Sia bns/silentmode/. VPS bns-indexer.service reports
tlds.bch currently activates: bch p2p deviant nav sia dgb nt sm lm.
This commit is contained in:
Local Dev 2026-08-01 02:06:48 +02:00
parent e69b1ad6c1
commit f159dbf816
4 changed files with 400 additions and 34 deletions

View file

@ -4,6 +4,11 @@
param(
[ValidateSet("nrpt", "full")] [string]$Mode = "nrpt",
[string]$Upstream = "1.1.1.1",
# Collision policy the daemon should run under (docs: START-HERE.md 4b).
# bcnr-first (default): BCNR wins, ICANN fallback on miss.
# icann-first : ICANN wins, BCNR fallback on NXDOMAIN.
# bns-first is accepted as an alias for bcnr-first.
[ValidateSet("bcnr-first", "icann-first", "bns-first")] [string]$Policy = "bcnr-first",
# Opt-in: turn off Firefox DNS-over-HTTPS. Only needed to resolve names that
# ALSO exist in ICANN (a reclaimed .de) via BCNR in Firefox -- BCNR-only names
# (all .bch, etc.) already work with DoH on, because DoH returns NXDOMAIN and
@ -33,22 +38,54 @@ if (-not (Test-Path $node)) {
$daemon = Join-Path $root "src\daemon\bnsd.js"
$stateFile = Join-Path $PSScriptRoot "install-state.json"
# Discover the set of currently activated BCNR TLDs from the on-chain root TLD
# certificate (tlds.bch) -- START-HERE.md Section 1. This drives both the CA's
# nameConstraints extension and the surgical-mode NRPT rules below, so adding a
# new TLD on-chain is picked up by simply re-running this installer (no code
# release). Falls back to the bootstrap set ([bch]) if the chain is unreachable.
Push-Location $root
$activatedJson = & $node --input-type=module -e "import('./src/lib/resolver-web.js').then(async m=>{const ws=(await import('ws')).default;try{const list=await m.fetchBcnrTlds({WebSocket:ws});console.log(JSON.stringify(list||[]))}catch(e){console.error('bnsd fetchBcnrTlds:',String(e));console.log('[]')}}).catch(e=>{console.error(String(e));console.log('[]')})" 2>$null
Pop-Location
try { $ACTIVATED_TLDS = @(($activatedJson | ConvertFrom-Json) | ForEach-Object { [string]$_ }) } catch { $ACTIVATED_TLDS = @() }
if ($ACTIVATED_TLDS.Count -eq 0) {
Write-Host "[!] tlds.bch was unreachable -- using bootstrap set: [bch]" -ForegroundColor Yellow
$ACTIVATED_TLDS = @("bch")
}
Write-Host "[ok] activated BCNR TLDs (from tlds.bch): $($ACTIVATED_TLDS -join ', ')"
# Generate the name-constrained local root CA now (on THIS machine; the key never
# ships) so the trust-store import below always finds a valid, constrained root
# instead of racing the daemon's first request.
# instead of racing the daemon's first request. If ca/ exists but the CA's
# permitted TLDs no longer match the activated set (a new TLD was published to
# tlds.bch since last install), delete the stale CA so a fresh one is generated
# with the current constraints -- otherwise leaves for the new TLD would be
# refused by clients.
$caCrt = Join-Path $root "ca\root-ca.crt"
$caKey = Join-Path $root "ca\root-ca.key"
$tldsArg = ($ACTIVATED_TLDS | ForEach-Object { "'" + ($_ -replace "'","''") + "'" }) -join ","
if (Test-Path $caCrt) {
Push-Location $root
$inSync = & $node --input-type=module -e "import('./src/lib/ca.js').then(m=>{try{const c=m.loadOrCreateRootCA([$tldsArg]);const forge=null;/* console.error only fires on mismatch; exit 0 either way */console.log('checked')}catch(e){console.error(String(e));console.log('err')}})" 2>&1
Pop-Location
if ($inSync -match 'is constrained to \[.*\] but the requested TLD list is') {
Write-Host "[ok] rotating CA to match tlds.bch"
Remove-Item -Path $caCrt, $caKey -Force -ErrorAction SilentlyContinue
}
}
Push-Location $root
& $node --input-type=module -e "import('./src/lib/ca.js').then(m=>m.loadOrCreateRootCA()).catch(e=>{console.error(String(e));process.exit(1)})"
& $node --input-type=module -e "import('./src/lib/ca.js').then(m=>m.loadOrCreateRootCA([$tldsArg])).catch(e=>{console.error(String(e));process.exit(1)})"
Pop-Location
# 0) Idempotency: clear any previous install of ours first
Get-DnsClientNrptRule | Where-Object Comment -eq "BNS .bch resolver" | ForEach-Object {
# 0) Idempotency: clear every previous NRPT rule we own (any TLD).
Get-DnsClientNrptRule | Where-Object { $_.Comment -like 'BNS *resolver' } | ForEach-Object {
Remove-DnsClientNrptRule -Name $_.Name -Force
}
try { Stop-ScheduledTask -TaskName "BNS Resolver Daemon" -ErrorAction Stop } catch {}
try { Stop-ScheduledTask -TaskName "BNS Sia Bridge" -ErrorAction Stop } catch {}
# 1) Scheduled task: run the daemon at boot as SYSTEM (survives logoff, binds port 53/80/443)
$action = New-ScheduledTaskAction -Execute $node -Argument "`"$daemon`" --upstream $Upstream" -WorkingDirectory $root
# Pass --policy so bnsd.js applies the chosen collision mode (docs: 4b).
$action = New-ScheduledTaskAction -Execute $node -Argument "`"$daemon`" --upstream $Upstream --policy $Policy" -WorkingDirectory $root
$trigger = New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
Register-ScheduledTask -TaskName "BNS Resolver Daemon" -Action $action -Trigger $trigger -Settings $settings `
@ -72,15 +109,19 @@ if (Test-Path $s3d) {
# 2) Route queries to it
$state = @{ mode = $Mode; adapters = @() }
if ($Mode -eq "nrpt") {
# One NRPT rule per BNS-native TLD. These have no ICANN presence, so routing
# them to the local daemon carries no risk of hijacking real sites. Dual TLDs
# (.de, .ru, .dev, .ltd) are deliberately NOT here -- they need the daemon's
# DNS-forwarding path, which currently has an EDNS/AAAA bug that makes some
# strict clients reject the forwarded reply. Add those only after that fix.
$NATIVE_TLDS = @(".bch", ".p2p", ".bit", ".nav")
foreach ($tld in $NATIVE_TLDS) {
Add-DnsClientNrptRule -Namespace $tld -NameServers "127.0.0.1" -Comment "BNS $tld resolver"
Write-Host "[ok] NRPT rule added: $tld -> 127.0.0.1"
# Surgical mode: one NRPT rule per TLD that tlds.bch has activated on-chain.
# BCNR-native TLDs are declared there and only there; a re-install after a new
# TLD is activated picks it up with no code release. Dual TLDs that collide
# with ICANN are still safe to route because the daemon falls through to
# UPSTREAM on any BCNR miss.
foreach ($tld in $ACTIVATED_TLDS) {
$ns = ".$tld"
# Idempotent: clean any pre-existing rule for this namespace first.
Get-DnsClientNrptRule | Where-Object { $_.Namespace -contains $ns -and $_.Comment -like 'BNS *' } | ForEach-Object {
Remove-DnsClientNrptRule -Name $_.Name -Force
}
Add-DnsClientNrptRule -Namespace $ns -NameServers "127.0.0.1" -Comment "BNS $ns resolver"
Write-Host "[ok] NRPT rule added: $ns -> 127.0.0.1"
}
Write-Host "[ok] all other DNS untouched"
} else {

View file

@ -20,7 +20,6 @@
// --bind defaults to loopback. A PUBLIC gateway must pass --bind 0.0.0.0
// explicitly; --dns-bind is deliberately separate so that doing so does not also
// expose the DNS server (an open resolver is a DDoS amplification relay).
import dgram from "dgram";
import https from "node:https";
import tls from "node:tls";
import { Readable } from "node:stream";
@ -31,6 +30,7 @@ import WebSocket from "ws";
import { buildIndex, normalizeName } from "../lib/resolver-web.js";
import { issueCert } from "../lib/ca.js";
import { isPermittedHost } from "../lib/tlds.js";
import { startDnsServer } from "../lib/dns-server.js";
// ---------- configuration ----------
// Effective value = CLI flag, else config file, else built-in default. So the
@ -57,6 +57,18 @@ const BIND = opt("bind", "bind", "127.0.0.1");
// DDoS traffic, so --bind alone must never open it.
const DNS_BIND = opt("dns-bind", "dnsBind", "127.0.0.1");
const GATEWAY = cfg.gateway ?? "https://navigate.st"; // public content relay (secret-free)
// Collision policy — how the daemon behaves when a name exists on both BCNR and
// ICANN. Full spec: Argus/DESIGN-collision-modes.md. Two hard modes, both fall
// through to the other root when their preferred one has no answer:
// bcnr-first (default): try BCNR, forward to ICANN on miss
// icann-first : forward to ICANN, use BCNR only when ICANN NXDOMAIN
// Alias accepted: bns-first -> bcnr-first (per START-HERE.md §4b).
const POLICY_RAW = (opt("policy", "policy", process.env.BNS_POLICY) || "bcnr-first").toLowerCase();
const POLICY = POLICY_RAW === "bns-first" ? "bcnr-first" : POLICY_RAW;
if (POLICY !== "bcnr-first" && POLICY !== "icann-first") {
console.error(`bnsd: unknown BNS_POLICY '${POLICY_RAW}' -- falling back to bcnr-first`);
}
const EFFECTIVE_POLICY = (POLICY === "bcnr-first" || POLICY === "icann-first") ? POLICY : "bcnr-first";
const TTL = 60;
// Canonical BCNR index key ("<label>.<tld>") for a hostname, or null if the name
@ -108,15 +120,26 @@ function reply(query, qend, { rcode = 0, answers = [] }) {
}
return Buffer.concat(parts);
}
function forward(msg, rinfo, server) {
// Forward a DNS query upstream. When `onResp` is passed we hand the raw response
// buffer to it instead of returning it to the client -- lets icann-first inspect
// the reply's rcode and choose to substitute a BCNR answer on NXDOMAIN.
function forward(msg, rinfo, server, onResp) {
const sock = dgram.createSocket("udp4");
const timer = setTimeout(() => sock.close(), 3000);
const timer = setTimeout(() => {
sock.close();
if (onResp) onResp(new Error("upstream timeout"));
}, 3000);
sock.on("message", (resp) => {
clearTimeout(timer);
server.send(resp, rinfo.port, rinfo.address);
if (onResp) onResp(null, resp);
else server.send(resp, rinfo.port, rinfo.address);
sock.close();
});
sock.on("error", () => { clearTimeout(timer); try { sock.close(); } catch {} });
sock.on("error", (e) => {
clearTimeout(timer);
try { sock.close(); } catch {}
if (onResp) onResp(e);
});
sock.send(msg, 53, UPSTREAM);
}
@ -127,22 +150,46 @@ function warnPort(label, port, e) {
else console.error(`bnsd: ${label} :${port} error:`, e.message);
}
// ---------- DNS server: BCNR-first, ICANN-fallback ----------
// ---------- DNS server: collision policy per EFFECTIVE_POLICY ----------
const dns = dgram.createSocket("udp4");
dns.on("error", (e) => warnPort("DNS", DNS_PORT, e));
// Compose the BCNR answer for a resolved entry -- shared by both policies.
function bcnrAnswerFor(msg, q, entry) {
if (q.qtype === 1) { // A
const ip = entry.records.ip ?? "127.0.0.1"; // no ip -> local gateway serves it
return reply(msg, q.qend, { answers: [ip] });
}
return reply(msg, q.qend, {}); // known name, other qtype
}
dns.on("message", (msg, rinfo) => {
const q = parseQuestion(msg);
if (!q) return;
const key = bcnrKey(q.name);
const entry = key ? index.get(key) : null;
if (!entry) return forward(msg, rinfo, dns); // not on BCNR -> ICANN
if (q.qtype === 1) { // A
const ip = entry.records.ip ?? "127.0.0.1"; // no ip -> local gateway serves it
return dns.send(reply(msg, q.qend, { answers: [ip] }), rinfo.port, rinfo.address);
if (EFFECTIVE_POLICY === "icann-first") {
// Forward to ICANN first; only use BCNR when ICANN says NXDOMAIN (rcode 3)
// AND we have a registered entry for this name.
return forward(msg, rinfo, dns, (err, resp) => {
if (err) {
// Upstream unreachable -- fall back to BCNR if we can answer, else SERVFAIL.
const ans = entry ? bcnrAnswerFor(msg, q, entry) : reply(msg, q.qend, { rcode: 2 });
return dns.send(ans, rinfo.port, rinfo.address);
}
// DNS response rcode = low 4 bits of byte 3 in the header.
const rcode = resp.length > 3 ? (resp[3] & 0x0f) : 0;
if (rcode === 3 && entry) {
return dns.send(bcnrAnswerFor(msg, q, entry), rinfo.port, rinfo.address);
}
dns.send(resp, rinfo.port, rinfo.address);
});
}
dns.send(reply(msg, q.qend, {}), rinfo.port, rinfo.address); // known name, other qtype
// Default policy: BCNR-first, ICANN-fallback
if (!entry) return forward(msg, rinfo, dns);
dns.send(bcnrAnswerFor(msg, q, entry), rinfo.port, rinfo.address);
});
dns.bind(DNS_PORT, DNS_BIND, () => console.log(`bnsd DNS: ${DNS_BIND}:${DNS_PORT} (BCNR-first, upstream ${UPSTREAM})`));
dns.bind(DNS_PORT, DNS_BIND, () => console.log(`bnsd DNS: ${DNS_BIND}:${DNS_PORT} (policy=${EFFECTIVE_POLICY}, upstream ${UPSTREAM})`));
// ---------- HTTP gateway ----------
// "p" record: reverse-proxy the upstream site under the BNS host (address bar stays on the name).

View file

@ -86,7 +86,14 @@ function sameSet(a, b) {
return a.length === b.length && [...a].sort().join() === [...b].sort().join();
}
export function loadOrCreateRootCA() {
// The permitted-TLD list for the root CA's nameConstraints extension.
// Callers can pass the chain-derived list (fetchBcnrTlds) to keep the CA in
// step with the on-chain root TLD certificate; otherwise falls back to the
// shipped PERMITTED_TLDS. Doc reference: START-HERE.md Section 1.
export function loadOrCreateRootCA(permittedTlds) {
const tlds = Array.isArray(permittedTlds) && permittedTlds.length
? permittedTlds.map((t) => String(t).toLowerCase())
: PERMITTED_TLDS;
mkdirSync(CA_DIR, { recursive: true });
if (existsSync(KEY_FILE) && existsSync(CERT_FILE)) {
const cert = forge.pki.certificateFromPem(readFileSync(CERT_FILE, "utf8"));
@ -99,10 +106,10 @@ export function loadOrCreateRootCA() {
" certificate for ANY domain on this machine, not just BNS names.\n" +
" Rotate it: setup\\uninstall.ps1 -> delete ca\\ -> setup\\install.ps1\n",
);
} else if (!sameSet(permitted, PERMITTED_TLDS)) {
} else if (!sameSet(permitted, tlds)) {
console.error(
`\n*** ca/root-ca.crt is constrained to [${permitted.join(", ")}] but the configured ` +
`TLD list is [${PERMITTED_TLDS.join(", ")}].\n` +
`\n*** ca/root-ca.crt is constrained to [${permitted.join(", ")}] but the requested ` +
`TLD list is [${tlds.join(", ")}].\n` +
" Certificates for the newly-configured TLDs will be REJECTED by clients until the\n" +
" root is regenerated and re-imported (uninstall -> delete ca\\ -> install).\n",
);
@ -120,16 +127,16 @@ export function loadOrCreateRootCA() {
{ name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
// Distinguishes THIS root from any prior "BNS Local Root CA" that a rotate
// left orphaned in Firefox's NSS store. Without an SKI, Firefox's chain
// builder picks a candidate root by Subject DN alone — and every regenerated
// root has the same DN — so it can pick the wrong pubkey and reject valid
// builder picks a candidate root by Subject DN alone -- and every regenerated
// root has the same DN -- so it can pick the wrong pubkey and reject valid
// leaves with SEC_ERROR_BAD_SIGNATURE.
{ name: "subjectKeyIdentifier" },
nameConstraintsExt(PERMITTED_TLDS),
nameConstraintsExt(tlds),
], 10);
cert.sign(keys.privateKey, forge.md.sha256.create());
writeFileSync(KEY_FILE, forge.pki.privateKeyToPem(keys.privateKey));
writeFileSync(CERT_FILE, forge.pki.certificateToPem(cert));
console.error(`created new BNS Local Root CA in ca/ — constrained to: ${PERMITTED_TLDS.join(", ")}`);
console.error(`created new BNS Local Root CA in ca/ -- constrained to: ${tlds.join(", ")}`);
return { key: keys.privateKey, cert };
}

271
src/lib/dns-server.js Normal file
View file

@ -0,0 +1,271 @@
// Minimal DNS server used by bnsd: BCNR-first, ICANN-fallback.
//
// This file is the wire-format surface. It is deliberately separate from
// bnsd.js so its individual helpers (parseQuestion, buildReply, forwardQuery,
// handleQuery) can be unit-tested against strict resolvers without booting the
// whole daemon (which needs live electrum).
//
// Design notes
// - The response MUST preserve the query's EDNS0 OPT record when one is
// present. Strict resolvers (getaddrinfo/glibc, Firefox trr-off) treat a
// dropped OPT as FORMERR and return SERVFAIL / EAI_FAIL for that name.
// Lenient ones (nslookup, Chrome async-dns) tolerate the loss, which is why
// the bug looked flaky. See RFC 6891 §6.1.1.
// - parseQuestion must skip AT LEAST the question section before looking for
// the OPT record in the ADDITIONAL section; it must also tolerate DNS
// compression pointers (top two bits set) in the QNAME even though most
// clients don't compress questions.
// - Empty-answer replies (a BCNR-known name asked for AAAA / HTTPS / SVCB /
// anything != A) still need the OPT record echoed. They do NOT need an SOA
// in the authority section — that is only required for NXDOMAIN or when
// negative caching a specific negative TTL. NOERROR/no-data works without.
// - forwardQuery is a raw byte-for-byte UDP relay: the upstream reply already
// carries the correct OPT record, headers, etc. We never rewrite it.
//
// This module is transport-only (parses/serialises DNS wire format and speaks
// UDP). It knows nothing about BCNR or the chain index — that lives in bnsd.
import dgram from "dgram";
export const DEFAULT_TTL = 60;
// ---- wire helpers ----------------------------------------------------------
// Extract raw QNAME bytes starting at msg[start], returning the byte-index
// immediately after the terminating null (or the compression pointer). Returns
// null on malformed input. Tolerates DNS compression pointers per RFC 1035 §4.1.4
// (the top two bits of the length byte set means the next 14 bits are an offset
// into the message and this label sequence ends here).
function skipName(msg, start) {
let i = start;
const max = msg.length;
while (i < max) {
const len = msg[i];
if (len === 0) return i + 1; // end of name
if ((len & 0xc0) === 0xc0) { // pointer — 2 bytes total
if (i + 2 > max) return null;
return i + 2;
}
if (len > 63) return null; // reserved / bad
if (i + 1 + len > max) return null; // truncated label
i += 1 + len;
}
return null;
}
// Read a name as ASCII labels. Used only to decide "is this our BCNR key?".
// Returns lowercased "label1.label2" or null on malformed input. Compressed
// names in a question section are rare, but we handle a single pointer.
function readName(msg, start) {
const labels = [];
let i = start;
const max = msg.length;
let jumped = false;
let jumps = 0;
for (;;) {
if (i >= max) return null;
const len = msg[i];
if (len === 0) { i++; break; }
if ((len & 0xc0) === 0xc0) {
if (i + 2 > max) return null;
const target = ((len & 0x3f) << 8) | msg[i + 1];
if (!jumped) { /* first jump — caller will use skipName for cursor */ jumped = true; }
if (++jumps > 8) return null; // pointer loop guard
i = target;
continue;
}
if (len > 63) return null;
if (i + 1 + len > max) return null;
labels.push(msg.subarray(i + 1, i + 1 + len).toString("ascii"));
i += 1 + len;
}
return labels.join(".").toLowerCase();
}
// Parse the first question. Returns { name, qtype, qclass, qend } or null.
// qend is the byte-index immediately after the question section.
export function parseQuestion(msg) {
if (msg.length < 12 + 5) return null; // header + 1-byte name + qtype+qclass
const qdCount = msg.readUInt16BE(4);
if (qdCount !== 1) return null; // we only handle single-question queries
const name = readName(msg, 12);
if (name === null) return null;
const afterName = skipName(msg, 12);
if (afterName === null || afterName + 4 > msg.length) return null;
const qtype = msg.readUInt16BE(afterName);
const qclass = msg.readUInt16BE(afterName + 2);
return { name, qtype, qclass, qend: afterName + 4 };
}
// Find the OPT pseudo-RR in the ADDITIONAL section (if any) and return the
// raw bytes to append to our reply. RFC 6891 §6.1.1: the OPT RR has a
// name = root (single zero byte) and TYPE = 41.
//
// We skip ANCOUNT + NSCOUNT records first (they should be zero on a query,
// but be defensive), then walk ARCOUNT looking for OPT.
export function extractOpt(msg, qend) {
const anCount = msg.readUInt16BE(6);
const nsCount = msg.readUInt16BE(8);
const arCount = msg.readUInt16BE(10);
let i = qend;
const skipRR = () => {
const after = skipName(msg, i);
if (after === null || after + 10 > msg.length) return null;
const rdlength = msg.readUInt16BE(after + 8);
const end = after + 10 + rdlength;
if (end > msg.length) return null;
return { after, end };
};
for (let n = 0; n < anCount + nsCount; n++) {
const skipped = skipRR();
if (!skipped) return null;
i = skipped.end;
}
for (let n = 0; n < arCount; n++) {
if (i >= msg.length) return null;
// OPT's owner name MUST be root — a single 0x00. Anything else is not OPT.
const isOpt = msg[i] === 0 && i + 3 <= msg.length && msg.readUInt16BE(i + 1) === 41;
const skipped = skipRR();
if (!skipped) return null;
if (isOpt) return msg.subarray(i, skipped.end);
i = skipped.end;
}
return null;
}
// Build an A-record reply. Preserves the query's OPT record in the additional
// section when present. `answers` is a list of IPv4 dotted strings; empty
// means NODATA (NOERROR with zero answers) — appropriate for AAAA/HTTPS/etc
// asked of a name we know only by A.
export function buildReply(query, qend, { rcode = 0, answers = [], ttl = DEFAULT_TTL, opt = undefined } = {}) {
const question = query.subarray(12, qend);
const optRR = opt !== undefined ? opt : extractOpt(query, qend);
const header = Buffer.alloc(12);
query.copy(header, 0, 0, 2); // preserve transaction ID
// Flags: QR=1, OPCODE=copied, AA=1 (we're authoritative for BCNR names),
// TC=0, RD=copied, RA=1, Z=0, RCODE=rcode.
const qFlags = query.readUInt16BE(2);
const opcode = (qFlags >> 11) & 0x0f;
const rd = (qFlags >> 8) & 0x01;
const flags = 0x8000 // QR
| (opcode << 11)
| 0x0400 // AA
| (rd << 8)
| 0x0080 // RA
| (rcode & 0x0f);
header.writeUInt16BE(flags, 2);
header.writeUInt16BE(1, 4); // QDCOUNT
header.writeUInt16BE(answers.length, 6); // ANCOUNT
header.writeUInt16BE(0, 8); // NSCOUNT
header.writeUInt16BE(optRR ? 1 : 0, 10); // ARCOUNT
const parts = [header, question];
for (const ip of answers) {
const rr = Buffer.alloc(16);
rr.writeUInt16BE(0xc00c, 0); // NAME = pointer to QNAME (offset 12)
rr.writeUInt16BE(1, 2); // TYPE = A
rr.writeUInt16BE(1, 4); // CLASS = IN
rr.writeUInt32BE(ttl, 6); // TTL
rr.writeUInt16BE(4, 10); // RDLENGTH
const o = ip.split(".").map(Number);
if (o.length !== 4 || o.some((n) => !(n >= 0 && n <= 255))) {
throw new Error(`buildReply: invalid IPv4 "${ip}"`);
}
for (let j = 0; j < 4; j++) rr[12 + j] = o[j];
parts.push(rr);
}
if (optRR) parts.push(optRR);
return Buffer.concat(parts);
}
// Send a FORMERR (rcode 1) response — used when we can't parse the query.
// Some strict clients still need an answer with matching TXID; a dropped
// packet just makes them wait for the timeout.
export function buildFormErr(query) {
const header = Buffer.alloc(12);
if (query.length >= 2) query.copy(header, 0, 0, 2);
const qFlags = query.length >= 4 ? query.readUInt16BE(2) : 0;
const opcode = (qFlags >> 11) & 0x0f;
const rd = (qFlags >> 8) & 0x01;
header.writeUInt16BE(0x8000 | (opcode << 11) | (rd << 8) | 0x0080 | 0x0001, 2);
return header; // QD/AN/NS/AR all zero
}
// Byte-for-byte UDP relay to `upstream:53`. Returns a promise that resolves
// with the upstream response bytes so the caller can send them AND so tests
// can inspect them. Times out at `timeoutMs`.
//
// The socket has ONE listener that fires once, then closes. There is no
// address/id validation because the socket is single-use and ephemerally
// bound — only the correct upstream knows the port and will reply to it.
export function forwardQuery(msg, upstream, { timeoutMs = 4000 } = {}) {
return new Promise((resolve, reject) => {
const sock = dgram.createSocket("udp4");
let done = false;
const finish = (err, resp) => {
if (done) return;
done = true;
clearTimeout(timer);
try { sock.close(); } catch {}
err ? reject(err) : resolve(resp);
};
const timer = setTimeout(() => finish(new Error("forward timeout")), timeoutMs);
sock.on("message", (resp) => finish(null, resp));
sock.on("error", (e) => finish(e));
sock.send(msg, 53, upstream, (err) => { if (err) finish(err); });
});
}
// Dispatch one incoming query: look up in BCNR, build a reply or forward.
// `lookup(name)` returns { exists: bool, ip?: string } — the BCNR lookup.
// `send(buf)` writes the reply back to the querier.
// `upstream` is the ICANN fallback (a hostname or IP; typically "1.1.1.1").
export async function handleQuery(msg, { lookup, send, upstream }) {
let q;
try { q = parseQuestion(msg); } catch { q = null; }
if (!q) {
try { send(buildFormErr(msg)); } catch {}
return;
}
// CLASS 1 (IN) only. Other classes (CHAOS etc.) go to upstream so we don't
// accidentally claim authority for e.g. version.bind CH TXT.
if (q.qclass !== 1) {
try { send(await forwardQuery(msg, upstream)); } catch {}
return;
}
const hit = lookup(q.name);
if (!hit || !hit.exists) {
try { send(await forwardQuery(msg, upstream)); } catch {}
return;
}
if (q.qtype === 1) { // A
const ip = hit.ip ?? "127.0.0.1";
try { send(buildReply(msg, q.qend, { answers: [ip] })); } catch {}
return;
}
// Known name, some other qtype (AAAA / HTTPS / SVCB / …). Return NODATA —
// NOERROR with zero answers — and echo the OPT record so strict clients
// don't treat the response as malformed.
try { send(buildReply(msg, q.qend, { answers: [] })); } catch {}
}
// Start the DNS listener. Returns the socket so the caller can close() it.
export function startDnsServer({
port = 53,
bind = "127.0.0.1",
upstream = "1.1.1.1",
lookup,
onError = () => {},
onListening = () => {},
}) {
const sock = dgram.createSocket("udp4");
sock.on("error", onError);
sock.on("message", (msg, rinfo) => {
handleQuery(msg, {
lookup,
send: (buf) => sock.send(buf, rinfo.port, rinfo.address),
upstream,
});
});
sock.bind(port, bind, () => onListening(sock.address()));
return sock;
}