|
| 1 | +/** |
| 2 | + * dnsAdapter.ts |
| 3 | + * DNS-over-HTTPS resolver providing a Node.js `dns`-like surface: resolve4, resolve6, lookup. |
| 4 | + * - 多 IP 直连 DoH(Google / Cloudflare / OpenDNS),自动 failover |
| 5 | + * - 本地正缓存 5 分钟,负缓存 30 秒 |
| 6 | + * - 并发去重(singleflight) |
| 7 | + * - Sticky 选址(对多 A/AAAA:在缓存期内固定选同一 IP,并放在结果数组首位) |
| 8 | + */ |
| 9 | + |
| 10 | +import * as https from 'https'; |
| 11 | + |
| 12 | +/** ===== 配置 / 环境变量 ===== */ |
| 13 | +const DEFAULT_TIMEOUT_MS = Number(process.env.DOH_TIMEOUT_MS ?? 3000); |
| 14 | +const STICKY_MODE: 'hash' | 'none' = (process.env.DOH_STICKY as any) === 'none' ? 'none' : 'hash'; |
| 15 | +const STICKY_SALT = String(process.env.DOH_STICKY_SALT ?? ''); |
| 16 | + |
| 17 | +/** ===== DoH 端点(直连 IP + SNI + Host)===== */ |
| 18 | +type DohEndpoint = { |
| 19 | + ip: string; // 连接用 IP |
| 20 | + host: string; // TLS SNI + Host 头 |
| 21 | + jsonPathFor: (name: string, rrtype: 'A' | 'AAAA') => string; |
| 22 | + headers?: Record<string, string>; |
| 23 | +}; |
| 24 | + |
| 25 | +const DOH_ENDPOINTS: DohEndpoint[] = [ |
| 26 | + // Google |
| 27 | + { ip: '8.8.8.8', host: 'dns.google', jsonPathFor: (n, t) => `/resolve?name=${encodeURIComponent(n)}&type=${t}` }, |
| 28 | + { ip: '8.8.4.4', host: 'dns.google', jsonPathFor: (n, t) => `/resolve?name=${encodeURIComponent(n)}&type=${t}` }, |
| 29 | + |
| 30 | + // Cloudflare |
| 31 | + { ip: '1.1.1.1', host: 'cloudflare-dns.com', jsonPathFor: (n, t) => `/dns-query?name=${encodeURIComponent(n)}&type=${t}` }, |
| 32 | + { ip: '1.0.0.1', host: 'cloudflare-dns.com', jsonPathFor: (n, t) => `/dns-query?name=${encodeURIComponent(n)}&type=${t}` }, |
| 33 | + |
| 34 | + // OpenDNS / Cisco |
| 35 | + { ip: '208.67.222.222', host: 'doh.opendns.com', jsonPathFor: (n, t) => `/dns-query?name=${encodeURIComponent(n)}&type=${t}` }, |
| 36 | + { ip: '208.67.220.220', host: 'doh.opendns.com', jsonPathFor: (n, t) => `/dns-query?name=${encodeURIComponent(n)}&type=${t}` }, |
| 37 | +]; |
| 38 | + |
| 39 | +/** ===== 类型定义 ===== */ |
| 40 | +type DnsJsonAnswer = { name: string; type: number; TTL?: number; data: string }; |
| 41 | +type DnsJsonResponse = { Status: number; Answer?: DnsJsonAnswer[] }; |
| 42 | + |
| 43 | +export type LookupEntry = { address: string; family: 4 | 6 }; |
| 44 | + |
| 45 | +/** ===== 缓存与控制 ===== */ |
| 46 | +const POS_TTL_MS = 300_000; // 正缓存:5 分钟 |
| 47 | +const NEG_TTL_MS = 30_000; // 负缓存:30 秒 |
| 48 | + |
| 49 | +type PosCacheRecord = { |
| 50 | + expiresAt: number; |
| 51 | + v4?: string[]; // 已按 Sticky 选址,把选中地址置于首位 |
| 52 | + v6?: string[]; |
| 53 | + chosenV4?: string; // 为了直观调试,可记录本期选中项 |
| 54 | + chosenV6?: string; |
| 55 | +}; |
| 56 | +const posCache = new Map<string, PosCacheRecord>(); // key: `${name}::A` 或 `${name}::AAAA` |
| 57 | + |
| 58 | +type NegCacheRecord = { expiresAt: number }; |
| 59 | +const negCache = new Map<string, NegCacheRecord>(); // key: `${name}::A` 或 `${name}::AAAA` |
| 60 | + |
| 61 | +// 并发去重(同域同 RR 正在解析时复用同一 Promise) |
| 62 | +const inflight = new Map<string, Promise<string[]>>(); |
| 63 | + |
| 64 | +/** ===== 小工具:哈希与 Sticky 选址 ===== */ |
| 65 | +function hash32(s: string): number { |
| 66 | + // 简单高效的 32-bit 哈希(FNV 或 BKDR 的轻量变体) |
| 67 | + let h = 2166136261 >>> 0; |
| 68 | + for (let i = 0; i < s.length; i++) { |
| 69 | + h ^= s.charCodeAt(i); |
| 70 | + h = Math.imul(h, 16777619) >>> 0; |
| 71 | + } |
| 72 | + return h >>> 0; |
| 73 | +} |
| 74 | + |
| 75 | +function pickSticky(addresses: string[], key: string): { chosen: string, ordered: string[] } { |
| 76 | + if (addresses.length <= 1 || STICKY_MODE === 'none') { |
| 77 | + return { chosen: addresses[0] ?? '', ordered: addresses.slice() }; |
| 78 | + } |
| 79 | + const idx = hash32(STICKY_SALT + '|' + key) % addresses.length; |
| 80 | + const chosen = addresses[idx]; |
| 81 | + if (!chosen) return { chosen: addresses[0], ordered: addresses.slice() }; |
| 82 | + |
| 83 | + // 将 chosen 放在首位,保持其余相对顺序 |
| 84 | + const rest = addresses.filter(a => a !== chosen); |
| 85 | + return { chosen, ordered: [chosen, ...rest] }; |
| 86 | +} |
| 87 | + |
| 88 | +/** ===== DoH 拉取(多端点 failover)===== */ |
| 89 | +function dohFetchJson(name: string, rrtype: 'A' | 'AAAA', timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DnsJsonResponse> { |
| 90 | + const eps = [...DOH_ENDPOINTS]; |
| 91 | + return new Promise((resolve, reject) => { |
| 92 | + let idx = 0; |
| 93 | + const tryNext = () => { |
| 94 | + if (idx >= eps.length) return reject(new Error('All DoH endpoints failed')); |
| 95 | + const ep = eps[idx++]; |
| 96 | + const path = ep.jsonPathFor(name, rrtype); |
| 97 | + const headers = { ...(ep.headers || {}), host: ep.host, accept: 'application/dns-json' }; |
| 98 | + |
| 99 | + const req = https.request( |
| 100 | + { method: 'GET', host: ep.ip, servername: ep.host, path, headers, timeout: timeoutMs }, |
| 101 | + (res) => { |
| 102 | + let data = ''; |
| 103 | + res.setEncoding('utf8'); |
| 104 | + res.on('data', (c) => (data += c)); |
| 105 | + res.on('end', () => { |
| 106 | + try { |
| 107 | + const parsed = JSON.parse(data) as DnsJsonResponse; |
| 108 | + if (parsed.Status !== 0) return tryNext(); |
| 109 | + resolve(parsed); |
| 110 | + } catch { |
| 111 | + tryNext(); |
| 112 | + } |
| 113 | + }); |
| 114 | + } |
| 115 | + ); |
| 116 | + req.on('timeout', () => req.destroy(new Error('timeout'))); |
| 117 | + req.on('error', () => tryNext()); |
| 118 | + req.end(); |
| 119 | + }; |
| 120 | + tryNext(); |
| 121 | + }); |
| 122 | +} |
| 123 | + |
| 124 | +/** ===== 通用解析(含 正/负缓存、并发去重、Sticky)===== */ |
| 125 | +async function resolveGeneric(name: string, rr: 'A' | 'AAAA'): Promise<string[]> { |
| 126 | + const key = `${name}::${rr}`; |
| 127 | + const now = Date.now(); |
| 128 | + |
| 129 | + // 1) 正缓存命中 |
| 130 | + const pos = posCache.get(key); |
| 131 | + if (pos && pos.expiresAt > now) { |
| 132 | + return rr === 'A' ? (pos.v4 ?? []) : (pos.v6 ?? []); |
| 133 | + } |
| 134 | + |
| 135 | + // 2) 负缓存命中 |
| 136 | + const neg = negCache.get(key); |
| 137 | + if (neg && neg.expiresAt > now) { |
| 138 | + const err: any = new Error('ENOTFOUND'); |
| 139 | + err.code = 'ENOTFOUND'; |
| 140 | + throw err; |
| 141 | + } |
| 142 | + |
| 143 | + // 3) 并发去重 |
| 144 | + if (inflight.has(key)) return inflight.get(key)!; |
| 145 | + |
| 146 | + const task = (async () => { |
| 147 | + try { |
| 148 | + const json = await dohFetchJson(name, rr); |
| 149 | + const answers = (json.Answer ?? []).filter(a => (rr === 'A' ? a.type === 1 : a.type === 28)); |
| 150 | + const rawAddrs = answers.map(a => a.data).filter(Boolean); |
| 151 | + |
| 152 | + if (rawAddrs.length === 0) { |
| 153 | + negCache.set(key, { expiresAt: now + NEG_TTL_MS }); |
| 154 | + const err: any = new Error('ENOTFOUND'); |
| 155 | + err.code = 'ENOTFOUND'; |
| 156 | + throw err; |
| 157 | + } |
| 158 | + |
| 159 | + // Sticky 选址(将选中地址放到首位) |
| 160 | + const { chosen, ordered } = pickSticky(rawAddrs, key); |
| 161 | + |
| 162 | + // 写正缓存(固定 5 分钟) |
| 163 | + const record: PosCacheRecord = { |
| 164 | + expiresAt: now + POS_TTL_MS, |
| 165 | + ...(rr === 'A' ? { v4: ordered, chosenV4: chosen } : { v6: ordered, chosenV6: chosen }), |
| 166 | + }; |
| 167 | + |
| 168 | + // 合并同域另一族(便于 getCacheStats 大致查看) |
| 169 | + const existing = posCache.get(key) || { expiresAt: now + POS_TTL_MS }; |
| 170 | + const merged: PosCacheRecord = { ...existing, ...record, expiresAt: now + POS_TTL_MS }; |
| 171 | + posCache.set(key, merged); |
| 172 | + |
| 173 | + return ordered; |
| 174 | + } catch (e) { |
| 175 | + // 网络/超时/DoH 非 0 状态 → 负缓存 |
| 176 | + negCache.set(key, { expiresAt: Date.now() + NEG_TTL_MS }); |
| 177 | + throw e; |
| 178 | + } finally { |
| 179 | + inflight.delete(key); |
| 180 | + } |
| 181 | + })(); |
| 182 | + |
| 183 | + inflight.set(key, task); |
| 184 | + return task; |
| 185 | +} |
| 186 | + |
| 187 | +/** ===== 对外 API ===== */ |
| 188 | +export async function resolve4(name: string): Promise<string[]> { |
| 189 | + return resolveGeneric(name, 'A'); |
| 190 | +} |
| 191 | + |
| 192 | +export async function resolve6(name: string): Promise<string[]> { |
| 193 | + return resolveGeneric(name, 'AAAA'); |
| 194 | +} |
| 195 | + |
| 196 | +export async function lookup( |
| 197 | + name: string, |
| 198 | + opts: { family?: 0 | 4 | 6, all?: boolean } = {} |
| 199 | +): Promise<LookupEntry | LookupEntry[]> { |
| 200 | + const fam = opts.family ?? 4; |
| 201 | + const all = opts.all === true; |
| 202 | + |
| 203 | + const get4 = async () => (await resolve4(name)).map(a => ({ address: a, family: 4 as const })); |
| 204 | + const get6 = async () => (await resolve6(name)).map(a => ({ address: a, family: 6 as const })); |
| 205 | + |
| 206 | + if (all) { |
| 207 | + if (fam === 4) return await get4(); |
| 208 | + if (fam === 6) return await get6(); |
| 209 | + // fam === 0 → v4 + v6 合并 |
| 210 | + return [...await get4().catch(() => []), ...await get6().catch(() => [])]; |
| 211 | + } else { |
| 212 | + // 非 all:返回首个(已按 Sticky 排序,首个即固定地址) |
| 213 | + if (fam === 6) { const r6 = await get6(); if (r6.length) return r6[0]; } |
| 214 | + const r4 = await get4().catch(() => []); if (r4.length) return r4[0]; |
| 215 | + const r6b = await get6().catch(() => []); if (r6b.length) return r6b[0]; |
| 216 | + const err: any = new Error('ENOTFOUND'); |
| 217 | + err.code = 'ENOTFOUND'; |
| 218 | + throw err; |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +/** ===== 工具 ===== */ |
| 223 | +export function getCacheStats() { |
| 224 | + return { |
| 225 | + posSize: posCache.size, |
| 226 | + negSize: negCache.size, |
| 227 | + inflight: inflight.size, |
| 228 | + stickyMode: STICKY_MODE, |
| 229 | + }; |
| 230 | +} |
| 231 | + |
| 232 | +export function clearCache() { |
| 233 | + posCache.clear(); |
| 234 | + negCache.clear(); |
| 235 | +} |
0 commit comments