import * as dgram from 'node:dgram'
import * as dnsPacket from 'dns-packet'
// Cloudflare, Google, OpenDNS — queried in parallel for NS discovery
const PUBLIC_RESOLVERS = ['1.1.1.1', '8.8.8.8', '208.67.222.222']
const QUERY_TIMEOUT_MS = 3000
// RD (recursion desired) flag bit
const RD_FLAG = 0x0100
/**
* Send a raw DNS query to a specific server and parse the response.
* recursive=true sets RD=1 (for public resolvers); false sets RD=0 (for authoritative NS).
*/
function queryDNS(
name: string,
type: dnsPacket.RecordType,
server: string,
port = 53,
recursive = false,
): Promise<dnsPacket.Packet> {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4')
const timer = setTimeout(() => {
socket.close()
reject(new Error(`DNS query timed out: ${type} ${name} @${server}`))
}, QUERY_TIMEOUT_MS)
const query = dnsPacket.encode({
type: 'query',
id: Math.floor(Math.random() * 65535),
flags: recursive ? RD_FLAG : 0,
questions: [{ type, name, class: 'IN' }],
})
socket.on('message', (msg) => {
clearTimeout(timer)
socket.close()
try {
resolve(dnsPacket.decode(msg))
} catch (err) {
reject(new Error(`Failed to decode DNS response for ${type} ${name}: ${err}`))
}
})
socket.on('error', (err) => {
clearTimeout(timer)
socket.close()
reject(err)
})
socket.send(query, 0, query.length, port, server)
})
}
/**
* Extract IPv4 glue records from the additional section of a DNS response.
*/
function extractGlueRecords(response: dnsPacket.Packet): Map<string, string[]> {
const glue = new Map<string, string[]>()
for (const record of response.additionals ?? []) {
if (record.type === 'A' && 'data' in record && typeof record.data === 'string') {
const name = record.name.toLowerCase().replace(/\.$/, '')
const existing = glue.get(name) ?? []
existing.push(record.data)
glue.set(name, existing)
}
}
return glue
}
function extractNSFromAnswer(response: dnsPacket.Packet): string[] {
return (response.answers ?? [])
.filter((record): record is dnsPacket.Answer & { type: 'NS'; data: string } => {
return record.type === 'NS' && typeof record.data === 'string'
})
.map((record) => record.data.toLowerCase().replace(/\.$/, ''))
}
/**
* Discover authoritative nameserver IPs for a given name.
*
* Walks up the label hierarchy (e.g. _wisp.example.com → example.com) querying
* all three public resolvers in parallel (RD=1) until NS records appear in the
* answer section. NS records only exist at zone apexes, so subdomains and
* underscore labels are skipped automatically.
*/
async function getAuthoritativeServers(name: string): Promise<string[]> {
const labels = name.split('.')
for (let i = 0; i <= labels.length - 2; i++) {
const candidate = labels.slice(i).join('.')
let response: dnsPacket.Packet
try {
response = await Promise.any(PUBLIC_RESOLVERS.map((r) => queryDNS(candidate, 'NS', r, 53, true)))
} catch {
continue
}
const nsNames = extractNSFromAnswer(response)
if (nsNames.length === 0) continue
const glue = extractGlueRecords(response)
const ips: string[] = []
for (const ns of nsNames) {
const glueIps = glue.get(ns)
if (glueIps) ips.push(...glueIps)
}
if (ips.length > 0) return ips
// No glue — resolve NS hostnames via public resolvers in parallel
await Promise.allSettled(
nsNames.slice(0, 3).map(async (ns) => {
try {
const aResp = await Promise.any(PUBLIC_RESOLVERS.map((r) => queryDNS(ns, 'A', r, 53, true)))
for (const record of aResp.answers ?? []) {
if (record.type === 'A' && 'data' in record && typeof record.data === 'string') {
ips.push(record.data)
}
}
} catch {}
}),
)
if (ips.length > 0) return ips
}
throw new Error(`No NS records found for ${name}`)
}
/**
* Query a DNS record directly from the domain's authoritative nameservers.
* NS discovery uses public resolvers (cached, fast); the actual record query
* goes direct to the NS with RD=0 for an authoritative answer.
*/
async function authoritativeResolve(name: string, type: dnsPacket.RecordType): Promise<dnsPacket.Packet> {
const servers = await getAuthoritativeServers(name)
const shuffledServers = [...servers].sort(() => Math.random() - 0.5)
let lastError: Error | null = null
for (const server of shuffledServers) {
try {
return await queryDNS(name, type, server)
} catch (err) {
lastError = err as Error
}
}
throw lastError ?? new Error(`All nameservers failed for ${type} ${name}`)
}
async function authoritativeResolveTxt(domain: string): Promise<string[][]> {
const response = await authoritativeResolve(domain, 'TXT')
const records: string[][] = []
for (const answer of response.answers ?? []) {
if (answer.type === 'TXT' && 'data' in answer) {
const chunks = Array.isArray(answer.data) ? answer.data : [answer.data]
records.push(chunks.map((d) => (Buffer.isBuffer(d) ? d.toString('utf-8') : String(d))))
}
}
return records
}
async function authoritativeResolveCname(domain: string): Promise<string[]> {
const response = await authoritativeResolve(domain, 'CNAME')
return (response.answers ?? [])
.filter((record): record is dnsPacket.Answer & { type: 'CNAME'; data: string } => {
return record.type === 'CNAME' && typeof record.data === 'string'
})
.map((record) => record.data.toLowerCase().replace(/\.$/, ''))
}
/**
* Result of a domain verification process
*/
export interface VerificationResult {
/** Whether the verification was successful */
verified: boolean
/** Error message if verification failed */
error?: string
/** Warning message (e.g. duplicate records detected) */
warning?: string
/** DNS records found during verification */
found?: {
/** TXT records found (used for domain verification) */
txt?: string[]
/** CNAME record found (used for domain pointing) */
cname?: string
}
}
/**
* Verify domain ownership via TXT record at _wisp.{domain}.
* Expected value: the user's DID (did:plc:xxx or did:web:xxx).
*/
export const verifyDomainOwnership = async (domain: string, expectedDid: string): Promise<VerificationResult> => {
try {
const txtDomain = `_wisp.${domain}`
const records = await authoritativeResolveTxt(txtDomain)
const foundTxtValues = records.map((r) => r.join(''))
if (foundTxtValues.some((v) => v === expectedDid)) {
const extras = foundTxtValues.filter((v) => v !== expectedDid)
const warning =
extras.length > 0
? `Multiple TXT records found at ${txtDomain}. Remove the extra record(s) to avoid issues: ${extras.join(', ')}`
: undefined
return { verified: true, warning, found: { txt: foundTxtValues } }
}
return {
verified: false,
error: `TXT record at ${txtDomain} does not match expected DID. Expected: ${expectedDid}`,
found: { txt: foundTxtValues },
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
verified: false,
error: `DNS lookup failed: ${message}`,
found: { txt: [] },
}
}
}
/**
* Verify CNAME record points to {hash}.dns.wisp.place.
*/
export const verifyCNAME = async (domain: string, expectedHash: string): Promise<VerificationResult> => {
try {
const expectedTarget = `${expectedHash}.dns.wisp.place`
const cnames = await authoritativeResolveCname(domain)
const foundCname = cnames[0] ?? null
if (!foundCname) {
return { verified: false, error: `No CNAME record found for ${domain}`, found: { cname: '' } }
}
if (foundCname === expectedTarget.toLowerCase()) {
return { verified: true, found: { cname: foundCname } }
}
return {
verified: false,
error: `CNAME for ${domain} points to ${foundCname}, expected ${expectedTarget}`,
found: { cname: foundCname },
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
verified: false,
error: `DNS lookup failed: ${message}`,
found: { cname: '' },
}
}
}
/**
* Verify a custom domain by checking both TXT ownership proof and CNAME routing.
* TXT is authoritative — CNAME is advisory (may be flattened by providers like Cloudflare).
*/
export const verifyCustomDomain = async (
domain: string,
expectedDid: string,
expectedHash: string,
): Promise<VerificationResult> => {
const txtResult = await verifyDomainOwnership(domain, expectedDid)
if (!txtResult.verified) {
return txtResult
}
const cnameResult = await verifyCNAME(domain, expectedHash)
return {
verified: true,
warning: txtResult.warning,
found: {
txt: txtResult.found?.txt,
cname: cnameResult.found?.cname,
},
}
}