your personal website on atproto - mirror blento.app
at 7eb0db3d5e0df6b52341f6c2aaad7e4bfc4e07bf 294 lines 8.7 kB view raw
1// Authoritative DNS verification 2// Queries authoritative nameservers directly instead of relying on cached resolvers. 3// See: https://jacob.gold/posts/stop-telling-users-their-dns-is-wrong/ 4 5const TYPE_A = 1; 6const TYPE_NS = 2; 7const TYPE_CNAME = 5; 8 9interface DnsRecord { 10 type: number; 11 data: string; 12} 13 14// --- Wire format encoding --- 15 16function encodeName(name: string): Uint8Array { 17 const n = name.endsWith('.') ? name.slice(0, -1) : name; 18 const parts = n.split('.'); 19 const bytes: number[] = []; 20 for (const part of parts) { 21 bytes.push(part.length); 22 for (let i = 0; i < part.length; i++) { 23 bytes.push(part.charCodeAt(i)); 24 } 25 } 26 bytes.push(0); 27 return new Uint8Array(bytes); 28} 29 30function buildQuery(name: string, type: number): Uint8Array { 31 const qname = encodeName(name); 32 const msg = new Uint8Array(12 + qname.length + 4); 33 const id = (Math.random() * 0xffff) | 0; 34 msg[0] = id >> 8; 35 msg[1] = id & 0xff; 36 msg[2] = 0x01; // RD=1 37 msg[5] = 0x01; // QDCOUNT=1 38 msg.set(qname, 12); 39 const off = 12 + qname.length; 40 msg[off] = type >> 8; 41 msg[off + 1] = type & 0xff; 42 msg[off + 3] = 0x01; // CLASS IN 43 return msg; 44} 45 46// --- Wire format decoding --- 47 48function decodeName(data: Uint8Array, offset: number): [string, number] { 49 const labels: string[] = []; 50 let pos = offset; 51 let jumped = false; 52 let savedPos = 0; 53 for (let safety = 0; safety < 128 && pos < data.length; safety++) { 54 const len = data[pos]; 55 if (len === 0) { 56 pos++; 57 break; 58 } 59 if ((len & 0xc0) === 0xc0) { 60 if (!jumped) savedPos = pos + 2; 61 pos = ((len & 0x3f) << 8) | data[pos + 1]; 62 jumped = true; 63 continue; 64 } 65 pos++; 66 let label = ''; 67 for (let i = 0; i < len && pos + i < data.length; i++) { 68 label += String.fromCharCode(data[pos + i]); 69 } 70 labels.push(label); 71 pos += len; 72 } 73 return [labels.join('.'), jumped ? savedPos : pos]; 74} 75 76function parseResponse(data: Uint8Array): DnsRecord[] { 77 if (data.length < 12) return []; 78 const qdcount = (data[4] << 8) | data[5]; 79 const ancount = (data[6] << 8) | data[7]; 80 let offset = 12; 81 82 // Skip questions 83 for (let i = 0; i < qdcount && offset < data.length; i++) { 84 const [, off] = decodeName(data, offset); 85 offset = off + 4; 86 } 87 88 const records: DnsRecord[] = []; 89 for (let i = 0; i < ancount && offset + 10 < data.length; i++) { 90 const [, nameEnd] = decodeName(data, offset); 91 offset = nameEnd; 92 const type = (data[offset] << 8) | data[offset + 1]; 93 const rdlength = (data[offset + 8] << 8) | data[offset + 9]; 94 offset += 10; 95 if (offset + rdlength > data.length) break; 96 97 let value = ''; 98 if (type === TYPE_A && rdlength === 4) { 99 value = `${data[offset]}.${data[offset + 1]}.${data[offset + 2]}.${data[offset + 3]}`; 100 } else if (type === TYPE_CNAME || type === TYPE_NS) { 101 [value] = decodeName(data, offset); 102 } 103 if (value) records.push({ type, data: value }); 104 offset += rdlength; 105 } 106 return records; 107} 108 109// --- DNS-over-HTTPS (for finding NS records and as fallback) --- 110 111interface DohAnswer { 112 type: number; 113 data: string; 114} 115 116async function dohQuery(name: string, type: string): Promise<DohAnswer[]> { 117 const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`; 118 const res = await fetch(url, { headers: { Accept: 'application/dns-json' } }); 119 const json: { Answer?: DohAnswer[] } = await res.json(); 120 return json.Answer ?? []; 121} 122 123// --- Find authoritative nameservers (walking up the domain tree) --- 124 125async function findNameservers(domain: string): Promise<string[]> { 126 let current = domain; 127 while (current.includes('.')) { 128 const answers = await dohQuery(current, 'NS'); 129 const ns = answers.filter((a) => a.type === TYPE_NS).map((a) => a.data.replace(/\.$/, '')); 130 if (ns.length > 0) return ns; 131 current = current.substring(current.indexOf('.') + 1); 132 } 133 return []; 134} 135 136// --- Resolve hostname to IPs via DoH --- 137 138async function resolveToIps(hostname: string): Promise<string[]> { 139 const answers = await dohQuery(hostname, 'A'); 140 return answers.filter((a) => a.type === TYPE_A).map((a) => a.data); 141} 142 143// --- Query nameserver directly via TCP (cloudflare:sockets) --- 144 145async function queryViaTcp(serverIp: string, name: string, type: number): Promise<DnsRecord[]> { 146 // @ts-expect-error: cloudflare:sockets is only available in Workers runtime 147 const { connect } = await import('cloudflare:sockets'); 148 149 const query = buildQuery(name, type); 150 const tcpMsg = new Uint8Array(query.length + 2); 151 tcpMsg[0] = (query.length >> 8) & 0xff; 152 tcpMsg[1] = query.length & 0xff; 153 tcpMsg.set(query, 2); 154 155 const socket = connect({ hostname: serverIp, port: 53 }); 156 const writer = socket.writable.getWriter(); 157 await writer.write(tcpMsg); 158 writer.releaseLock(); 159 160 const reader = socket.readable.getReader(); 161 let buffer = new Uint8Array(0); 162 let responseLen = -1; 163 164 const timeout = setTimeout(() => socket.close(), 5000); 165 try { 166 while (true) { 167 const { value, done } = await reader.read(); 168 if (done) break; 169 const chunk = new Uint8Array(value as ArrayBuffer); 170 const newBuf = new Uint8Array(buffer.length + chunk.length); 171 newBuf.set(buffer); 172 newBuf.set(chunk, buffer.length); 173 buffer = newBuf; 174 175 if (responseLen === -1 && buffer.length >= 2) { 176 responseLen = (buffer[0] << 8) | buffer[1]; 177 } 178 if (responseLen >= 0 && buffer.length >= responseLen + 2) break; 179 } 180 } finally { 181 clearTimeout(timeout); 182 reader.releaseLock(); 183 socket.close(); 184 } 185 186 if (buffer.length < 2) return []; 187 return parseResponse(buffer.slice(2, 2 + responseLen)); 188} 189 190// --- Query with TCP, fall back to DoH for local dev --- 191 192async function queryAuthoritative( 193 serverIp: string, 194 name: string, 195 type: number 196): Promise<DnsRecord[]> { 197 try { 198 return await queryViaTcp(serverIp, name, type); 199 } catch { 200 // cloudflare:sockets not available (local dev) — fall back to DoH 201 const typeStr = type === TYPE_A ? 'A' : type === TYPE_CNAME ? 'CNAME' : 'NS'; 202 return (await dohQuery(name, typeStr)).map((a) => ({ type: a.type, data: a.data })); 203 } 204} 205 206// --- Main verification --- 207 208export interface DnsVerifyResult { 209 ok: boolean; 210 error?: string; 211 hint?: string; 212} 213 214function normalize(s: string): string { 215 return s.replace(/\.$/, '').toLowerCase(); 216} 217 218export async function verifyDomainDns( 219 domain: string, 220 expectedTarget: string 221): Promise<DnsVerifyResult> { 222 // Find authoritative nameservers 223 const nameservers = await findNameservers(domain); 224 if (nameservers.length === 0) { 225 return { ok: false, error: `Could not find nameservers for "${domain}".` }; 226 } 227 228 // Try each nameserver until one responds 229 for (const ns of nameservers) { 230 const ips = await resolveToIps(ns); 231 if (ips.length === 0) continue; 232 233 const nsIp = ips[0]; 234 235 // Check CNAME records 236 const cnameRecords = await queryAuthoritative(nsIp, domain, TYPE_CNAME); 237 const cnames = cnameRecords.filter((r) => r.type === TYPE_CNAME); 238 239 if (cnames.length > 0) { 240 if (cnames.length > 1) { 241 return { 242 ok: false, 243 error: `Multiple CNAME records found for "${domain}": ${cnames.map((r) => r.data).join(', ')}. Remove duplicate records so only one remains.` 244 }; 245 } 246 const got = normalize(cnames[0].data); 247 const want = normalize(expectedTarget); 248 if (got !== want) { 249 return { 250 ok: false, 251 error: `CNAME for "${domain}" points to "${got}" instead of "${want}".`, 252 hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.` 253 }; 254 } 255 return { ok: true }; 256 } 257 258 // No CNAME — check A records (root/apex domains with CNAME flattening or A records) 259 const aRecords = await queryAuthoritative(nsIp, domain, TYPE_A); 260 const aValues = aRecords.filter((r) => r.type === TYPE_A); 261 262 if (aValues.length > 0) { 263 // Resolve the expected target to get its IPs for comparison 264 const expectedIps = await resolveToIps(expectedTarget); 265 if (expectedIps.length === 0) { 266 return { 267 ok: false, 268 error: `Could not resolve "${expectedTarget}" to verify A records.` 269 }; 270 } 271 const expectedSet = new Set(expectedIps); 272 const unexpected = aValues.filter((r) => !expectedSet.has(r.data)); 273 if (unexpected.length > 0) { 274 return { 275 ok: false, 276 error: `A record(s) for "${domain}" include unexpected IPs: ${unexpected.map((r) => r.data).join(', ')}. Expected only IPs matching "${expectedTarget}" (${expectedIps.join(', ')}).`, 277 hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.` 278 }; 279 } 280 return { ok: true }; 281 } 282 283 // Neither CNAME nor A found 284 return { 285 ok: false, 286 error: `No CNAME or A record found for "${domain}". Please add a CNAME record pointing to "${expectedTarget}".` 287 }; 288 } 289 290 return { 291 ok: false, 292 error: `Could not reach any nameserver for "${domain}".` 293 }; 294}