Skip to content

Commit ac3cf99

Browse files
ralyodioclaude
andauthored
fix(dns): answer the address a name actually has (#268)
Most of the registry could not be resolved by our own resolver. Every name pointed at a host — seo.rank, chovy.hacker, alt.2600, all of them — came back as an authoritative NOERROR with no answers, which a client is entitled to treat as final. That is the worst shape a failure can take. `dig` said the name existed, nothing could reach it, and no log anywhere reported an error. google.com resolved the whole time, so the machine looked healthy. Two independent causes, both on the address path: - RECORD_TYPES covered CNAME, MX and TXT only, so a published A or AAAA record was never consulted for an address question. alt.2600 publishes an AAAA in the registry and still answered nothing. - targetAddress() returns null for a hostname, and nothing picked up after it. The comment said turning a host into an address would mean the bridge doing clearnet DNS — but it already forwards clearnet queries upstream, so that reasoning had gone stale. addressAnswer() replaces the old answerPolicy-then-look-for-a-CNAME pair with one plan: target address, published A/AAAA, published CNAME, then a CNAME synthesised from a hostname target. The cheap question is still asked first, so a name pointed at a bare IP costs exactly one registry call and no record fetch — the fast path every page load takes. A bare CNAME would not have been enough. This bridge sets RA=0, so a stub handed a dangling CNAME has been told in the same breath that nobody will chase it; systemd-resolved reports that as a name with no address. buildChainResponse() emits the leaf alongside it, best-effort, so a slow upstream costs the extra record and never the answer. DoH gets the same treatment. Its own comment said a name that resolves over the bridge and not over DoH is the failure that endpoint exists to remove, and it carried the identical gap. Verified against the live registry before any test was written: seo.rank. 30 IN CNAME dev.profullstack.com. dev.profullstack.com. 30 IN A 67.205.189.229 alt.2600. 300 IN AAAA 2604:a880:400:d1:0:4:c3fe:1 One existing test changed rather than added to: it had recorded "a live name pointed at a hostname is NODATA" as correct. It was the bug. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0071c3b commit ac3cf99

4 files changed

Lines changed: 513 additions & 36 deletions

File tree

src/dns.mjs

Lines changed: 172 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import dgram from "node:dgram";
1919
import { isIP } from "node:net";
20+
import { Resolver } from "node:dns/promises";
2021

2122
export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh";
2223
export const DEFAULT_PARKING_HOST = "moshcoding.com";
@@ -281,6 +282,48 @@ export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TT
281282
return Buffer.concat([head, question, ...encoded]);
282283
}
283284

285+
/**
286+
* A CNAME answer, plus the leaf addresses when we could find them.
287+
*
288+
* Two owner names appear in one message: the question's name owns the CNAME,
289+
* and the CNAME's target owns the addresses. Only the first can use the 0xc00c
290+
* pointer — it is the only name already in the message — so the target is
291+
* written out in full for each leaf. Uncompressed is legal, and a handful of
292+
* spare bytes is a fair price for not hand-rolling a compression table.
293+
*/
294+
export function buildChainResponse(query, buf, { cname, addresses = [], ttl = DEFAULT_TTL } = {}) {
295+
const question = buf.subarray(12, query.questionEnd);
296+
const wantsV6 = query.type === TYPE_AAAA;
297+
const target = encodeName(cname);
298+
299+
const head = Buffer.alloc(12);
300+
head.writeUInt16BE(0xc00c, 0); // the question's name, by pointer
301+
head.writeUInt16BE(TYPE_CNAME, 2);
302+
head.writeUInt16BE(CLASS_IN, 4);
303+
head.writeUInt32BE(ttl, 6);
304+
head.writeUInt16BE(target.length, 10);
305+
306+
const parts = [head, target];
307+
let answers = 1;
308+
for (const address of addresses) {
309+
const rdata = wantsV6 ? ipv6(address) : ipv4(address);
310+
if (!rdata) continue;
311+
const leaf = Buffer.alloc(10);
312+
leaf.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 0);
313+
leaf.writeUInt16BE(CLASS_IN, 2);
314+
leaf.writeUInt32BE(ttl, 4);
315+
leaf.writeUInt16BE(rdata.length, 8);
316+
parts.push(target, leaf, rdata);
317+
answers += 1;
318+
}
319+
320+
return Buffer.concat([
321+
header(query.id, { rcode: RCODE_OK, answers, recursionDesired: query.recursionDesired }),
322+
question,
323+
...parts,
324+
]);
325+
}
326+
284327
/**
285328
* Build an address-record response for the family the query asked for.
286329
*
@@ -495,8 +538,8 @@ export async function answerFor(name, options = {}) {
495538
* carries an address and nothing else, so the port is dropped here — a name
496539
* whose target names a non-default port cannot be served by the resolver path
497540
* at all, because there is no way to say "port 8080" in an A or AAAA record and
498-
* the browser will go to 80 regardless. A hostname target is null for the same
499-
* reason: turning it into an address would mean this bridge doing clearnet DNS.
541+
* the browser will go to 80 regardless. A hostname target is null here because
542+
* an A record cannot hold one; `targetHostname` is the other half of the answer.
500543
*/
501544
export function targetAddress(target) {
502545
const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
@@ -514,6 +557,30 @@ export function targetAddress(target) {
514557
return null;
515558
}
516559

560+
/**
561+
* The bare hostname inside a stored target, or null when there isn't one.
562+
*
563+
* The other half of `targetAddress`. Most names in the registry are pointed at
564+
* a host, not an address — `seo.rank` targets `dev.profullstack.com` — and
565+
* refusing to say so was the bug that made every such name look unregistered.
566+
* A CNAME expresses exactly this and costs us no clearnet DNS: the client
567+
* chases it, which is what a CNAME is for.
568+
*
569+
* A target naming a port is null on purpose. No CNAME can carry `:8080`, and
570+
* sending the client to port 80 of the right host is a worse answer than
571+
* admitting there is nothing here to say.
572+
*/
573+
export function targetHostname(target) {
574+
const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
575+
if (!raw || targetAddress(raw)) return null;
576+
// A colon is a port or a malformed v6 literal; a slash is a path. Neither
577+
// survives the trip into an owner name, so neither is guessed at.
578+
if (raw.includes(":") || raw.includes("/")) return null;
579+
const host = raw.toLowerCase().replace(/\.$/, "");
580+
const label = "[a-z0-9]([a-z0-9-]*[a-z0-9])?";
581+
return new RegExp(`^${label}(\\.${label})+$`).test(host) ? host : null;
582+
}
583+
517584
/**
518585
* Is an address question on this name worth a second look for a CNAME?
519586
*
@@ -526,6 +593,89 @@ export function mayHaveCname({ exists, address }) {
526593
return Boolean(exists) && !address;
527594
}
528595

596+
/**
597+
* Everything an address question needs, from a single registry lookup.
598+
*
599+
* The old path asked two separate questions — `answerPolicy` for the target,
600+
* then `answerRecords` for a CNAME — and between them dropped the two cases
601+
* that cover most of the registry. A published A/AAAA record was never
602+
* consulted at all (addresses came only from `target`), and a hostname target
603+
* produced nothing. Both surfaced as an authoritative NOERROR with no answers,
604+
* which a client is entitled to treat as final: the name looked dead while the
605+
* registry held a perfectly good answer for it.
606+
*
607+
* The cheap question is asked first and usually ends it: a name pointed at a
608+
* bare address needs no record set, and every page load on the machine comes
609+
* through here. Only a name that has nothing to say yet is worth the second
610+
* round trip — which is the same bargain the old path struck for CNAMEs, held
611+
* to here so the common case did not get slower in exchange for being right.
612+
*/
613+
export async function addressAnswer(name, options = {}) {
614+
const { parkingAddress, wantsV6 = false } = options;
615+
const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra });
616+
617+
const result = await resolveName(name, options);
618+
const exists = result.status === "live" || result.status === "parked";
619+
if (!exists) return { exists: false, kind: "nxdomain", records: [], address: null, cname: null };
620+
621+
// Parking is checked before anything the registry published: a parked name's
622+
// whole job is to reach the page explaining that it is for sale.
623+
if (result.status === "parked") {
624+
return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata");
625+
}
626+
627+
const address = targetAddress(result.target);
628+
if (address) return plan("address", { address });
629+
630+
const full = await resolveName(name, { ...options, records: true });
631+
const of = (type) => (full.records || []).filter((r) => r?.type === type);
632+
633+
// An address the owner published beats a CNAME to somewhere that holds one:
634+
// it is the more specific statement, and it saves the client a lookup.
635+
const published = of(wantsV6 ? "AAAA" : "A");
636+
if (published.length) return plan("records", { records: published });
637+
638+
const cnames = of("CNAME");
639+
if (cnames.length) return plan("records", { records: cnames });
640+
641+
const host = targetHostname(result.target);
642+
return host ? plan("chain", { cname: host }) : plan("nodata");
643+
}
644+
645+
/**
646+
* The addresses a clearnet hostname holds, for finishing a CNAME chain.
647+
*
648+
* A bare CNAME is a legal answer and a useless one here. This bridge sets RA=0
649+
* — it offers no recursion — so a stub that receives a dangling CNAME has been
650+
* told, in the same breath, that nobody will chase it. systemd-resolved reports
651+
* that as a name with no address, which is indistinguishable from broken.
652+
*
653+
* Best-effort by design: the chain is a courtesy on top of a CNAME that is
654+
* already correct, so an upstream that is slow or silent costs the extra
655+
* records, never the answer.
656+
*/
657+
export async function resolveChain(hostname, { upstreams = [], wantsV6 = false, timeoutMs = 2000 } = {}) {
658+
const servers = upstreams.map(resolverServer).filter(Boolean);
659+
if (!hostname || !servers.length) return [];
660+
try {
661+
const resolver = new Resolver({ timeout: timeoutMs, tries: 1 });
662+
resolver.setServers(servers);
663+
const found = await (wantsV6 ? resolver.resolve6(hostname) : resolver.resolve4(hostname));
664+
return Array.isArray(found) ? found : [];
665+
} catch {
666+
return [];
667+
}
668+
}
669+
670+
/** An upstream in `1.2.3.4#5353` form, as node's resolver wants to read it. */
671+
function resolverServer(upstream) {
672+
const [address, portText] = String(upstream).split("#");
673+
const family = isIP(address);
674+
if (!family) return null;
675+
const port = Number(portText) || 53;
676+
return port === 53 ? address : `${family === 6 ? `[${address}]` : address}:${port}`;
677+
}
678+
529679
/**
530680
* Start the bridge. Returns { port, address, close() }.
531681
*
@@ -908,19 +1058,28 @@ export function createServer(options = {}) {
9081058
});
9091059
} else if (query.class === CLASS_IN) {
9101060
const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA;
911-
const policy = await answerPolicy(query.name, { ...options, wantsAddress }).catch(() => null);
912-
if (policy) ({ exists, address } = policy);
913-
// A name that is here with no address to give may still have published a
914-
// CNAME, which is the one record that can answer an address question.
915-
// Handing it back lets the client chase the name through its own
916-
// resolver — the only party here that may do clearnet DNS — instead of
917-
// getting the NODATA that made a pointed name look broken.
918-
if (wantsAddress && mayHaveCname(policy || {})) {
919-
const found = await answerRecords(query.name, { ...options, type: "CNAME" }).catch(() => null);
920-
if (found?.records?.length) {
921-
reply = buildRecordResponse(query, msg, found.records, {
1061+
if (!wantsAddress) {
1062+
// HTTPS/SVCB and friends: the name's existence is the whole answer, and
1063+
// getting it wrong here denies the name for every other question too.
1064+
const policy = await answerPolicy(query.name, { ...options, wantsAddress: false }).catch(() => null);
1065+
if (policy) ({ exists } = policy);
1066+
} else {
1067+
const plan = await addressAnswer(query.name, {
1068+
...options, wantsV6: query.type === TYPE_AAAA,
1069+
}).catch(() => null);
1070+
exists = Boolean(plan?.exists);
1071+
if (plan?.kind === "records") {
1072+
reply = buildRecordResponse(query, msg, plan.records, {
9221073
ttl, exists, limit: maxResponseBytes || UDP_SAFE_BYTES,
9231074
});
1075+
} else if (plan?.kind === "chain") {
1076+
const addresses = await resolveChain(plan.cname, {
1077+
upstreams, wantsV6: query.type === TYPE_AAAA, timeoutMs: forwardTimeoutMs,
1078+
});
1079+
reply = buildChainResponse(query, msg, { cname: plan.cname, addresses, ttl });
1080+
address = addresses[0] || plan.cname;
1081+
} else {
1082+
address = plan?.address || null;
9241083
}
9251084
}
9261085
}

src/doh.mjs

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
// keeps no per-query record of who asked what.
1717

1818
import {
19-
answerRecords, buildRecordResponse, buildResponse, capResponse, clientKey, createBanList,
20-
createRateLimiter, forwardQuery, isOurs, answerPolicy, mayHaveCname, parseQuery, refusalReason,
19+
addressAnswer, answerRecords, buildChainResponse, buildRecordResponse, buildResponse,
20+
capResponse, clientKey, createBanList, resolveChain,
21+
createRateLimiter, forwardQuery, isOurs, answerPolicy, parseQuery, refusalReason,
2122
RECORD_TYPES, TYPE_A, TYPE_AAAA, DEFAULT_TTL, UDP_SAFE_BYTES,
2223
} from "./dns.mjs";
2324

@@ -172,26 +173,46 @@ export function createDohHandler({
172173
}
173174

174175
const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA;
175-
const policy = await answerPolicy(query.name, {
176-
registryBase, fetchImpl, parkingAddress, wantsAddress,
177-
}).catch(() => ({ exists: false, address: null }));
178-
179-
// Same second look for a CNAME as the UDP path, on the same condition: only
180-
// when the name is here and has nothing else to answer with.
181-
const cname = wantsAddress && mayHaveCname(policy)
182-
? await answerRecords(query.name, { registryBase, fetchImpl, type: "CNAME" })
183-
.catch(() => ({ records: [] }))
184-
: null;
185-
186-
onQuery({ name: query.name, type: query.type, address: policy.address });
176+
if (!wantsAddress) {
177+
const policy = await answerPolicy(query.name, {
178+
registryBase, fetchImpl, parkingAddress, wantsAddress: false,
179+
}).catch(() => ({ exists: false, address: null }));
180+
onQuery({ name: query.name, type: query.type, address: null });
181+
return {
182+
status: 200,
183+
headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) },
184+
body: buildResponse(query, decoded.message, null, ttl, policy.exists),
185+
};
186+
}
187+
188+
// The same plan the UDP path follows, for the same reason the split above
189+
// has to stay a split: a published record or a hostname target must resolve
190+
// identically here, or this endpoint reintroduces the gap it exists to close.
191+
const plan = await addressAnswer(query.name, {
192+
registryBase, fetchImpl, parkingAddress, wantsV6: query.type === TYPE_AAAA,
193+
}).catch(() => ({ exists: false, kind: "nxdomain", records: [], address: null, cname: null }));
194+
195+
const answer = async () => {
196+
if (plan.kind === "records") {
197+
return buildRecordResponse(query, decoded.message, plan.records, {
198+
ttl, exists: plan.exists, limit: maxResponseBytes || UDP_SAFE_BYTES,
199+
});
200+
}
201+
if (plan.kind === "chain") {
202+
const addresses = await resolveChain(plan.cname, {
203+
upstreams, wantsV6: query.type === TYPE_AAAA, timeoutMs: forwardTimeoutMs,
204+
});
205+
return buildChainResponse(query, decoded.message, { cname: plan.cname, addresses, ttl });
206+
}
207+
return buildResponse(query, decoded.message, plan.address, ttl, plan.exists);
208+
};
209+
210+
const encoded = await answer();
211+
onQuery({ name: query.name, type: query.type, address: plan.address || plan.cname || null });
187212
return {
188213
status: 200,
189214
headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) },
190-
body: cname?.records?.length
191-
? buildRecordResponse(query, decoded.message, cname.records, {
192-
ttl, exists: policy.exists, limit: maxResponseBytes || UDP_SAFE_BYTES,
193-
})
194-
: buildResponse(query, decoded.message, policy.address, ttl, policy.exists),
215+
body: encoded,
195216
};
196217
};
197218
}

0 commit comments

Comments
 (0)