1// DNS lookups over DNS-over-HTTPS (JSON API). Runs in browsers and in Workers. 2 3export const TYPE_CODES = { A: 1, NS: 2, CNAME: 5, SOA: 6, PTR: 12, MX: 15, TXT: 16, AAAA: 28 }; 4const TYPE_NAMES = Object.fromEntries(Object.entries(TYPE_CODES).map(([k, v]) => [v, k])); 5const RCODES = { 0: 'NOERROR', 1: 'FORMERR', 2: 'SERVFAIL', 3: 'NXDOMAIN', 4: 'NOTIMP', 5: 'REFUSED' }; 6 7export const DEFAULT_RESOLVERS = [ 8 { name: 'Cloudflare', url: 'https://cloudflare-dns.com/dns-query', headers: { accept: 'application/dns-json' } }, 9 { name: 'Google', url: 'https://dns.google/resolve', headers: { accept: 'application/dns-json' } }, 10]; 11 12// Upper bound on distinct lookups per resolver. A full domain check needs 13// about 200; records crafted to fan out further are cut off here. 14export const MAX_QUERIES = 600; 15 16export class DnsError extends Error { 17 constructor(message, name, type) { 18 super(message); 19 this.name = 'DnsError'; 20 this.query = name; 21 this.type = type; 22 } 23} 24 25// TXT answers arrive either as quoted character-strings (`"a" "b"`, with 26// backslash escapes) or already joined. Either way the record's value is the 27// concatenation of its strings, with nothing inserted between them. 28export function decodeTxt(data) { 29 const s = String(data); 30 if (!s.startsWith('"')) return s; 31 let out = ''; 32 let i = 0; 33 while (i < s.length) { 34 if (s[i] !== '"') { i++; continue; } 35 i++; 36 while (i < s.length && s[i] !== '"') { 37 if (s[i] === '\\' && i + 1 < s.length) { 38 const d = s.slice(i + 1, i + 4); 39 if (/^\d{3}$/.test(d)) { out += String.fromCharCode(parseInt(d, 10)); i += 4; continue; } 40 out += s[i + 1]; 41 i += 2; 42 continue; 43 } 44 out += s[i++]; 45 } 46 i++; 47 } 48 return out; 49} 50 51export function normalizeName(name) { 52 return String(name || '').trim().replace(/\.+$/, '').toLowerCase(); 53} 54 55function normalizeAnswer(a) { 56 const type = TYPE_NAMES[a.type] || String(a.type); 57 let data = String(a.data); 58 if (type === 'TXT') data = decodeTxt(data); 59 else if (type === 'CNAME' || type === 'NS' || type === 'PTR') data = normalizeName(data); 60 else if (type === 'MX') { 61 const [pref, ...host] = data.split(/\s+/); 62 return { name: normalizeName(a.name), type, ttl: a.TTL, priority: Number(pref), exchange: normalizeName(host.join(' ')), data }; 63 } 64 return { name: normalizeName(a.name), type, ttl: a.TTL, data }; 65} 66 67/** 68 * Create a resolver. `query(name, type)` resolves to 69 * { name, type, rcode, ok, answers: [...], cnames: [...], resolver } 70 * where `answers` holds only records of the requested type (CNAME hops are 71 * followed by the upstream resolver and listed separately in `cnames`). 72 * Network failures on every upstream throw DnsError. 73 */ 74export function createResolver({ fetch: fetchImpl, resolvers = DEFAULT_RESOLVERS, timeoutMs = 6000, maxQueries = MAX_QUERIES } = {}) { 75 const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args)); 76 const cache = new Map(); 77 let count = 0; 78 79 async function ask(r, name, type) { 80 const url = `${r.url}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`; 81 const ctrl = typeof AbortController === 'function' ? new AbortController() : null; 82 const timer = ctrl ? setTimeout(() => ctrl.abort(), timeoutMs) : null; 83 try { 84 const res = await doFetch(url, { headers: r.headers, signal: ctrl ? ctrl.signal : undefined }); 85 if (!res.ok) throw new DnsError(`${r.name} answered HTTP ${res.status}`, name, type); 86 return await res.json(); 87 } finally { 88 if (timer) clearTimeout(timer); 89 } 90 } 91 92 async function lookup(name, type) { 93 let lastError = null; 94 let lastServfail = null; 95 for (const r of resolvers) { 96 try { 97 count++; 98 const j = await ask(r, name, type); 99 const rcode = RCODES[j.Status] || `RCODE${j.Status}`; 100 const all = (j.Answer || []).map(normalizeAnswer); 101 const out = { 102 name, 103 type, 104 rcode, 105 ok: rcode === 'NOERROR', 106 answers: all.filter((a) => a.type === type), 107 cnames: all.filter((a) => a.type === 'CNAME' && type !== 'CNAME'),
108 resolver: r.name, 109 }; 110 // A SERVFAIL from one upstream is often that upstream's problem (or a 111 // list operator refusing it); give the next one a chance. 112 if (rcode === 'SERVFAIL') { lastServfail = out; continue; } 113 return out; 114 } catch (e) { 115 lastError = e; 116 } 117 } 118 if (lastServfail) return lastServfail; 119 const reason = lastError && lastError.name === 'AbortError' ? 'timed out' : (lastError && lastError.message) || 'failed'; 120 throw new DnsError(`DNS lookup for ${name} (${type}) ${reason}`, name, type); 121 } 122 123 function query(name, type = 'A') { 124 const n = normalizeName(name); 125 const t = String(type).toUpperCase(); 126 const key = `${t} ${n}`; 127 if (!cache.has(key)) { 128 if (cache.size >= maxQueries) return Promise.reject(new DnsError(`stopped after ${maxQueries} DNS lookups`, n, t)); 129 const p = lookup(n, t); 130 cache.set(key, p); 131 p.catch(() => cache.delete(key)); 132 } 133 return cache.get(key); 134 } 135 136 async function txt(name) { 137 const r = await query(name, 'TXT'); 138 return { ...r, records: r.answers.map((a) => a.data) }; 139 } 140 141 return { query, txt, get queries() { return count; } }; 142} 143 144// Hostname checks shared by every domain-taking tool. Accepts pasted input 145// such as "https://www.example.com/path" or "[email protected]". 146export function parseDomainInput(input) { 147 let s = String(input || '').trim().toLowerCase(); 148 if (!s) return { error: 'Enter a domain name.' }; 149 s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, ''); 150 if (s.includes('@')) s = s.slice(s.lastIndexOf('@') + 1); 151 s = s.split(/[/?#\s]/)[0].replace(/:\d+$/, '').replace(/\.+$/, ''); 152 if (!s) return { error: 'Enter a domain name.' }; 153 if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return { error: 'That is an IP address. Enter a domain name instead.' }; 154 let ascii = s; 155 if (/[^\x00-\x7f]/.test(s)) { 156 try { ascii = new URL(`http://${s}`).hostname; } catch { return { error: `"${s}" is not a valid domain name.` }; } 157 } 158 if (ascii.length > 253 || !ascii.includes('.')) return { error: `"${s}" is not a valid domain name.` }; 159 const labels = ascii.split('.'); 160 for (const l of labels) { 161 if (!/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(l)) return { error: `"${s}" is not a valid domain name.` }; 162 } 163 if (!/^[a-z]{2,63}$|^xn--[a-z0-9-]{1,59}$/.test(labels[labels.length - 1])) return { error: `"${s}" does not end in a valid top-level domain.` }; 164 return { domain: ascii }; 165} 166 167export function parseIpv4(input) { 168 const s = String(input || '').trim(); 169 const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s); 170 if (!m) return null; 171 const octets = m.slice(1).map(Number); 172 if (octets.some((o) => o > 255)) return null; 173 if (m.slice(1).some((o) => o.length > 1 && o.startsWith('0'))) return null; 174 return octets; 175} 176 177// Approximate registrable domain: the last two labels, or three under common 178// second-level public suffixes such as co.uk. Good enough to compare domains 179// and to key domain blacklists, which list registered names. 180export function organizationalDomain(domain) { 181 const labels = normalizeName(domain).split('.'); 182 const two = labels.slice(-2).join('.'); 183 if (labels.length > 2 && /^(co|com|net|org|gov|ac|edu|ne|or|go|gob|nic|ltd|plc)\.[a-z]{2}$/.test(two)) return labels.slice(-3).join('.'); 184 return two; 185}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.