// dns-txt.js — a minimal DNS TXT client, because bare-dns segfaults the process.
//
// WHY THIS EXISTS. atproto handles ARE domain names, and a handle is only displayable once it
// verifies BACK to the DID (see handles.js). There are two mechanisms and we try HTTPS well-known
// first, but measured 2026-08-24, every custom-domain handle sampled verifies ONLY by DNS TXT —
// pfrazee.com, dholms.xyz, jay.bsky.team and bsky.app all return did= over TXT while their
// /.well-known/atproto-did is a redirect, a 404 or nothing. Hosted subdomains (*.bsky.social, our own
// *.7rnx.net) are the reverse. So TXT is not a fallback for rare names; it is the primary mechanism
// for most of the interesting identities on the network, and without it they render handle.invalid.
//
// bare-dns cannot be used for it. `bare_dns_resolve_txt` hands c-ares a pointer into a JS arraybuffer
// that nothing references, so any GC during the query frees it and the callback faults:
//
// #0 js_open_handle_scope #1 bare_dns__on_resolve_txt #2 ares_query_dnsrec_cb ... #7 uv_run
//
// That is a SIGSEGV, not an exception — uncatchable, and it took the public backend down 819 times in
// a week. The addon is statically linked into the bare runtime, so it cannot be fixed by patching a
// dependency; it needs a runtime rebuild. Until then, this.
//
// DNS over TCP (RFC 7766), not UDP. Every resolver supports it including the systemd-resolved stub,
// it removes the 512-byte truncation dance entirely, and bare-net is already present — adding
// bare-dgram would mean adding another native addon, which is the class of thing that put us here.
//
// PURE + INJECTED, like handles.js: the codec below is plain Uint8Array work with no I/O so it runs
// under node in the test suite, and the transport takes `connect` as a parameter.
const TYPE_TXT = 16
const CLASS_IN = 1
const HEADER_LEN = 12
const MAX_NAME_LEN = 255
const MAX_LABEL_LEN = 63
// A compressed name could otherwise point at itself and spin forever. The bound is arbitrary but far
// above any legitimate chain.
const MAX_POINTER_JUMPS = 64
export const RCODE = { NOERROR: 0, FORMERR: 1, SERVFAIL: 2, NXDOMAIN: 3, NOTIMP: 4, REFUSED: 5 }
// `nameserver 127.0.0.53` lines, in file order. Everything else in resolv.conf is ignored: we only
// need somewhere to send the query, and options/search are the stub resolver's business.
export function parseResolvConf (text) {
if (typeof text !== 'string') return []
const out = []
for (const raw of text.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('#') || line.startsWith(';')) continue
const m = line.match(/^nameserver\s+(\S+)$/i)
if (m) out.push(m[1])
}
return out
}
// ⛔ ANDROID HAS NO /etc/resolv.conf. netd owns resolution there and `getprop net.dns1` has been empty
// since Android 8, so the handset booted with `DNS TXT handle verification is OFF` — the bare-dns crash
// was fixed and the CAPABILITY never arrived: every custom-domain handle renders handle.invalid on the
// phone, which by our own measurement is most of the interesting identities on the network.
//
// Nothing about the resolver needed changing; it only ever needed somewhere to send the query. So the
// embedder may pass one in. On Android that is ConnectivityManager's own DNS list for the active
// network — the SAME resolver the OS uses, not a third party, which is why this is an env handoff and
// NOT a hardcoded 1.1.1.1: handle verification is the last place to hand a stranger the list of every
// identity we look up (see the note at the resolver's construction in bin/favela-web.js).
//
// Addresses only, no ports, no hostnames: this value is handed straight to connect(), and a hostname
// would need a resolver to resolve — which is the thing we are trying to obtain.
//
// ⛔ A CHARACTER-CLASS TEST IS NOT ENOUGH. `[0-9a-fA-F:.]+` admits `8.8.8.8:53`, because ':' has to be
// allowed for IPv6 — and a port smuggled into the host argument is a connect() to a host that does not
// exist. So the two families are parsed apart.
function isIpv4 (s) {
const parts = s.split('.')
return parts.length === 4 && parts.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
}
function isIpv6 (s) {
if (!s.includes(':')) return false
if ((s.match(/::/g) || []).length > 1) return false
const parts = s.split(':')
if (parts.length > 8) return false
return parts.every((p, i) => {
if (p === '') return true // the '::' compression, and a leading/trailing colon of it
if (i === parts.length - 1 && p.includes('.')) return isIpv4(p) // ::ffff:1.2.3.4
return /^[0-9a-fA-F]{1,4}$/.test(p)
})
}
const isAddress = (s) => typeof s === 'string' && s.length > 0 && s.length <= 45 && (isIpv4(s) || isIpv6(s))
export function parseNameserverList (value) {
if (typeof value !== 'string') return []
return value.split(/[,\s]+/).map((s) => s.trim()).filter(isAddress)
}
// Env first, resolv.conf second. Deliberately not "merge both": if an embedder has said which resolvers
// to use, silently appending the file's would send some queries somewhere it did not choose.
export function chooseNameservers ({ envValue = null, resolvConf = null } = {}) {
const fromEnv = parseNameserverList(envValue)
if (fromEnv.length) return { servers: fromEnv, source: 'env' }
const fromFile = parseResolvConf(resolvConf)
if (fromFile.length) return { servers: fromFile, source: 'resolv.conf' }
return { servers: [], source: 'none' }
}
export function encodeQuery (name, id) {
if (typeof name !== 'string' || !name) throw new Error('name required')
const labels = name.replace(/\.$/, '').split('.')
let qnameLen = 1 // the terminating root label
for (const l of labels) {
const bytes = utf8(l)
if (bytes.length === 0) throw new Error('empty label in ' + name)
if (bytes.length > MAX_LABEL_LEN) throw new Error('label too long in ' + name)
qnameLen += 1 + bytes.length
}
if (qnameLen > MAX_NAME_LEN) throw new Error('name too long: ' + name)
const buf = new Uint8Array(HEADER_LEN + qnameLen + 4)
const view = new DataView(buf.buffer)
view.setUint16(0, id & 0xffff)
view.setUint16(2, 0x0100) // RD — we want the resolver to do the work
view.setUint16(4, 1) // one question
// ancount / nscount / arcount stay zero
let o = HEADER_LEN
for (const l of labels) {
const bytes = utf8(l)
buf[o++] = bytes.length
buf.set(bytes, o)
o += bytes.length
}
buf[o++] = 0
view.setUint16(o, TYPE_TXT); o += 2
view.setUint16(o, CLASS_IN)
return buf
}
// -> { id, rcode, answers } where answers is an array per TXT record of its character-strings, which
// is the shape didFromTxt() already expects (it joins arrays before matching).
export function decodeResponse (buf, expectedId = null) {
if (!(buf instanceof Uint8Array)) throw new Error('response must be a Uint8Array')
if (buf.length < HEADER_LEN) throw new Error('response shorter than a DNS header')
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
const id = view.getUint16(0)
// A response for a different query is not our answer. Cheap, and it is the only thing standing
// between us and an off-path answer on a shared socket.
if (expectedId !== null && id !== expectedId) throw new Error('response id mismatch')
const flags = view.getUint16(2)
const rcode = flags & 0x0f
const qdcount = view.getUint16(4)
const ancount = view.getUint16(6)
let o = HEADER_LEN
for (let i = 0; i < qdcount; i++) {
o = skipName(buf, o)
o += 4 // qtype + qclass
if (o > buf.length) throw new Error('truncated question section')
}
const answers = []
for (let i = 0; i < ancount; i++) {
if (o >= buf.length) break
o = skipName(buf, o)
if (o + 10 > buf.length) throw new Error('truncated answer header')
const type = view.getUint16(o); o += 2
o += 2 // class
o += 4 // ttl
const rdlength = view.getUint16(o); o += 2
if (o + rdlength > buf.length) throw new Error('rdlength past end of response')
if (type === TYPE_TXT) {
const chunks = []
let p = o
const end = o + rdlength
while (p < end) {
const len = buf[p++]
if (p + len > end) throw new Error('TXT character-string past end of rdata')
chunks.push(ascii(buf.subarray(p, p + len)))
p += len
}
answers.push(chunks)
}
o += rdlength
}
return { id, rcode, answers }
}
// Names may be compressed as a pointer to an earlier offset. We never need the name itself here —
// only to step over it — so this resolves pointers just far enough to find where the name ends.
function skipName (buf, offset) {
let o = offset
let jumps = 0
while (true) {
if (o >= buf.length) throw new Error('name past end of response')
const len = buf[o]
if (len === 0) return o + 1
if ((len & 0xc0) === 0xc0) {
// A pointer is two bytes and the name ENDS there, whatever it points at.
if (o + 2 > buf.length) throw new Error('truncated compression pointer')
if (++jumps > MAX_POINTER_JUMPS) throw new Error('too many compression pointers')
return o + 2
}
if ((len & 0xc0) !== 0) throw new Error('reserved label type')
o += 1 + len
}
}
function utf8 (s) {
const out = []
for (const ch of unescape(encodeURIComponent(s))) out.push(ch.charCodeAt(0))
return Uint8Array.from(out)
}
function ascii (bytes) {
let s = ''
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
return s
}
// DNS over TCP frames each message with a two-byte big-endian length.
export function frame (msg) {
const out = new Uint8Array(2 + msg.length)
new DataView(out.buffer).setUint16(0, msg.length)
out.set(msg, 2)
return out
}
/**
* Build the resolveTxt(name) -> string[][] that handles.js takes.
*
* `connect` is injected (bare-net's createConnection in the daemon, a fake in tests) so none of this
* needs a socket to be tested.
*/
export function createTcpResolveTxt ({
connect,
servers = ['127.0.0.1'],
port = 53,
timeoutMs = 5000,
nextId = () => Math.floor(Math.random() * 0xffff)
} = {}) {
if (typeof connect !== 'function') throw new Error('createTcpResolveTxt requires connect')
if (!Array.isArray(servers) || servers.length === 0) throw new Error('at least one server required')
return function resolveTxt (name) {
return new Promise((resolve, reject) => {
const id = nextId()
const query = frame(encodeQuery(name, id))
const socket = connect({ host: servers[0], port })
let settled = false
let expected = -1
let received = new Uint8Array(0)
const finish = (err, value) => {
if (settled) return
settled = true
clearTimeout(timer)
try { socket.destroy() } catch {}
err ? reject(err) : resolve(value)
}
// A resolver that accepts the connection and never answers would otherwise hang the caller
// forever — which is the failure mode that made the bare-dns bug so expensive to notice.
const timer = setTimeout(() => finish(new Error('DNS query timed out after ' + timeoutMs + 'ms')), timeoutMs)
if (typeof timer.unref === 'function') timer.unref()
socket.on('error', (err) => finish(err))
socket.on('close', () => finish(new Error('DNS connection closed before a complete response')))
socket.on('data', (chunk) => {
const next = new Uint8Array(received.length + chunk.length)
next.set(received, 0)
next.set(chunk, received.length)
received = next
if (expected < 0) {
if (received.length < 2) return
expected = (received[0] << 8) | received[1]
}
if (received.length < 2 + expected) return
try {
const { rcode, answers } = decodeResponse(received.subarray(2, 2 + expected), id)
// NXDOMAIN and an empty answer set are both "no record", which is a verdict, not a failure.
if (rcode !== RCODE.NOERROR && rcode !== RCODE.NXDOMAIN) {
return finish(new Error('DNS rcode ' + rcode))
}
finish(null, answers)
} catch (err) {
finish(err)
}
})
socket.on('connect', () => socket.write(query))
})
}
}