OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / dns-txt.js

300 lines · javascript ref:
// 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))
    })
  }
}