Skip to content

Commit b035e02

Browse files
committed
v2
1 parent 72a6a91 commit b035e02

File tree

42 files changed

+2240
-627
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2240
-627
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@conet.project/conet-proxy",
33

4-
"version": "0.20.3",
4+
"version": "0.21.5",
55

66
"license": "UNLICENSED",
77
"files": [
@@ -13,9 +13,11 @@
1313
"scripts": {
1414
"lint": "echo 'no linter available'",
1515
"test": "echo 'no linter available'",
16-
"build": "yarn install;tsc --project ./tsconfig.build.json && cpr src/localServer/workers build/localServer/workers -o && copyfiles -u 1 src/index.d.ts build && copyfiles -u 1 src/favicon.ico build/localServer/workers/",
16+
"build:win": "yarn install;tsc --project ./tsconfig.build.json && cpr src/localServer/workers build/localServer/workers -o && copyfiles -u 1 src/index.d.ts build && copyfiles -u 1 src/favicon.ico build/localServer/workers/",
1717
"clean": "rimraf ./node_modules ./build",
1818
"local": "node build/localServer/index",
19+
"build": "tsc --project ./tsconfig.build.json ; yarn copy",
20+
"copy": "cp -r src/localServer/workers build/localServer/workers; cp src/index.d.ts build ; cp src/favicon.ico build/localServer/workers/",
1921
"buildRun": "tsc --project ./tsconfig.build.json && copyfiles -u 1 src/favicon.ico build/localServer/workers/ && copyfiles -u 2 src/localServer/workers/utilities/*.js build/localServer/workers/utilities && copyfiles -u 1 src/index.d.ts build && node build/localServer/index",
2022
"build:docker": "docker buildx build --platform linux/amd64,linux/arm64 --file Dockerfile . --push --tag conetnetwork/conet-daemon:latest",
2123
"build:test": "tsc --project ./tsconfig.build2.json"
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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

Comments
 (0)