Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 142 additions & 13 deletions lib/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,48 @@ export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TT
* type this bridge does not serve, or because the target is a hostname rather
* than an address. Those are NODATA, not NXDOMAIN.
*/
/**
* A CNAME answer, plus the leaf addresses when the caller found any.
*
* Two owner names appear in one message: the question's name owns the CNAME,
* and the CNAME's target owns the addresses. Only the first can use the 0xc00c
* pointer — it is the only name already in the message — so the target is
* written out in full for each leaf. Uncompressed is legal, and a handful of
* spare bytes is a fair price for not hand-rolling a compression table.
*/
export function buildChainResponse(query, buf, { cname, addresses = [], ttl = DEFAULT_TTL } = {}) {
const question = buf.subarray(12, query.questionEnd);
const wantsV6 = query.type === TYPE_AAAA;
const target = encodeName(cname);

const head = Buffer.alloc(12);
head.writeUInt16BE(0xc00c, 0); // the question's name, by pointer
head.writeUInt16BE(TYPE_CNAME, 2);
head.writeUInt16BE(CLASS_IN, 4);
head.writeUInt32BE(ttl, 6);
head.writeUInt16BE(target.length, 10);

const parts = [head, target];
let answers = 1;
for (const address of addresses) {
const rdata = wantsV6 ? ipv6(address) : ipv4(address);
if (!rdata) continue;
const leaf = Buffer.alloc(10);
leaf.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 0);
leaf.writeUInt16BE(CLASS_IN, 2);
leaf.writeUInt32BE(ttl, 4);
leaf.writeUInt16BE(rdata.length, 8);
parts.push(target, leaf, rdata);
answers += 1;
}

return Buffer.concat([
header(query.id, { rcode: RCODE_OK, answers, recursionDesired: query.recursionDesired }),
question,
...parts,
]);
}

export function buildResponse(query, buf, address, ttl = DEFAULT_TTL, exists = Boolean(address)) {
const question = buf.subarray(12, query.questionEnd);
const wantsV6 = query.type === TYPE_AAAA;
Expand Down Expand Up @@ -435,6 +477,82 @@ export function mayHaveCname({ exists, address }) {
return Boolean(exists) && !address;
}

/**
* The bare hostname inside a stored target, or null when there isn't one.
*
* The other half of `targetAddress`. Most names in the registry are pointed at
* a host rather than an address — `seo.rank` targets `dev.profullstack.com` —
* and refusing to say so was the bug that made every such name look
* unregistered: the bridge answered an authoritative NOERROR with no records,
* which a client is entitled to treat as final.
*
* A CNAME expresses exactly this, and it costs this bridge no clearnet DNS. It
* is routed per-TLD, so the target is not a name that comes back here — the
* machine's own resolver chases it, which is what a CNAME is for.
*
* A target naming a port is null on purpose. No CNAME can carry `:8080`, and
* sending the client to port 80 of the right host is a worse answer than
* admitting there is nothing here to say.
*/
export function targetHostname(target) {
const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
if (!raw || targetAddress(raw)) return null;
// A colon is a port or a malformed v6 literal; a slash is a path. Neither
// survives the trip into an owner name, so neither is guessed at.
if (raw.includes(":") || raw.includes("/")) return null;
const host = raw.toLowerCase().replace(/\.$/, "");
const label = "[a-z0-9]([a-z0-9-]*[a-z0-9])?";
return new RegExp(`^${label}(\\.${label})+$`).test(host) ? host : null;
}

/**
* Everything an address question needs, from the registry.
*
* The old path asked two separate questions — `answerPolicy` for the target,
* then `answerRecords` for a CNAME — and between them dropped the two cases
* that cover most of the registry. A published A/AAAA record was never
* consulted for an address question, because addresses came only from `target`;
* and a `target` naming a host produced nothing at all. Both surfaced as an
* authoritative NOERROR with no answers, so the name looked dead while the
* registry held a perfectly good answer for it.
*
* The cheap question is asked first and usually ends it: a name pointed at a
* bare address needs no record set, and every page load comes through here.
* Only a name with nothing to say yet is worth the second round trip — the
* same bargain the old CNAME lookup already struck.
*/
export async function addressAnswer(name, options = {}) {
const { parkingAddress, wantsV6 = false } = options;
const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra });

const result = await resolveName(name, options);
const exists = result.status === "live" || result.status === "parked";
if (!exists) return { exists: false, kind: "nxdomain", records: [], address: null, cname: null };

// Parking is checked before anything the registry published: a parked name's
// whole job is to reach the page explaining that it is for sale.
if (result.status === "parked") {
return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata");
}

const address = targetAddress(result.target);
if (address) return plan("address", { address });

const full = await resolveName(name, { ...options, records: true });
const of = (type) => (full.records || []).filter((r) => r?.type === type);

// An address the owner published beats a CNAME to somewhere that holds one:
// it is the more specific statement, and it saves the client a lookup.
const published = of(wantsV6 ? "AAAA" : "A");
if (published.length) return plan("records", { records: published });

const cnames = of("CNAME");
if (cnames.length) return plan("records", { records: cnames });

const host = targetHostname(result.target);
return host ? plan("chain", { cname: host }) : plan("nodata");
}

/* -------------------------------------------------------------------- server */

/**
Expand Down Expand Up @@ -476,8 +594,8 @@ export async function answerPolicy(name, options = {}) {
* carries an address and nothing else, so the port is dropped here — a name
* whose target names a non-default port cannot be served by the resolver path
* at all, because there is no way to say "port 8080" in an A or AAAA record and
* the browser will go to 80 regardless. A hostname target is null for the same
* reason: turning it into an address would mean this bridge doing clearnet DNS.
* the browser will go to 80 regardless. A hostname target is null here because
* an A record cannot hold one; `targetHostname` is the other half of the answer.
*/
export function targetAddress(target) {
const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
Expand Down Expand Up @@ -529,17 +647,28 @@ export function createServer(options = {}) {
reply = buildRecordResponse(query, msg, found?.records || [], { ttl, exists });
} else if (query.class === CLASS_IN) {
const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA;
const policy = await answerPolicy(query.name, { ...options, wantsAddress }).catch(() => null);
if (policy) ({ exists, address } = policy);
// A name that is here with no address to give may still have published a
// CNAME, which is the one record that can answer an address question.
// Handing it back lets the client chase it through its own resolver — the
// only party here that may do clearnet DNS — instead of the NODATA that
// made a pointed name look broken.
if (wantsAddress && mayHaveCname(policy || {})) {
const found = await answerRecords(query.name, { ...options, type: "CNAME" }).catch(() => null);
if (found?.records?.length) {
reply = buildRecordResponse(query, msg, found.records, { ttl, exists });
if (!wantsAddress) {
// HTTPS/SVCB and friends: the name's existence is the whole answer, and
// getting it wrong denies the name for every other question too.
const policy = await answerPolicy(query.name, { ...options, wantsAddress: false }).catch(() => null);
if (policy) ({ exists } = policy);
} else {
const plan = await addressAnswer(query.name, {
...options, wantsV6: query.type === TYPE_AAAA,
}).catch(() => null);
exists = Boolean(plan?.exists);
if (plan?.kind === "records") {
reply = buildRecordResponse(query, msg, plan.records, { ttl, exists });
} else if (plan?.kind === "chain") {
// No leaf addresses are attached here. Unlike a catch-all bridge, this
// one is routed per-TLD, so the CNAME's target is not a name that
// comes back to it: the machine's own resolver — a full recursive one
// — chases it. Resolving it here would be this bridge doing clearnet
// DNS to answer a question the system can already answer itself.
reply = buildChainResponse(query, msg, { cname: plan.cname, ttl });
address = plan.cname;
} else {
address = plan?.address || null;
}
}
}
Expand Down
Loading
Loading