1717
1818import dgram from "node:dgram" ;
1919import { isIP } from "node:net" ;
20+ import { Resolver } from "node:dns/promises" ;
2021
2122export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh" ;
2223export 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 */
501544export function targetAddress ( target ) {
502545 const raw = String ( target || "" ) . trim ( ) . replace ( / ^ h t t p s ? : \/ \/ / 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 ( / ^ h t t p s ? : \/ \/ / 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 }
0 commit comments