PageSourceSearch

https://powerline.ai/tools/js/dns.js

js powerline.ai collected 2026-10-05 14:06:34 UTC 7,485 bytes, 185 lines download raw bytes

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.