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:
parent
e69b1ad6c1
commit
f159dbf816
4 changed files with 400 additions and 34 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
271
src/lib/dns-server.js
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue