whistle.easy-proxy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ const https = require('node:https');
2
+ const net = require('node:net');
3
+ const { debug } = require('../debug');
4
+ const defaultHosts = require('./default-hosts');
5
+
6
+ let CONFIG = null;
7
+
8
+ // host -> { ip, ts, all }
9
+ const cache = new Map();
10
+
11
+ function setConfig(config) {
12
+ CONFIG = config;
13
+ }
14
+
15
+ function ttl() {
16
+ return (CONFIG?.ipCacheTtl ? CONFIG.ipCacheTtl : 600) * 1000;
17
+ }
18
+
19
+ function fresh(host) {
20
+ const e = cache.get(host);
21
+ if (e && Date.now() - e.ts < ttl()) return e.ip;
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * DoH (RFC 8484 JSON) resolve real IPv4.
27
+ * Direct-connects bootstrap IP with servername = DoH hostname,
28
+ * so it's immune to client-side DNS pollution and avoids GFW SNI detection.
29
+ */
30
+ function dohResolve(host) {
31
+ const doh = CONFIG?.doh || {
32
+ bootstrapIp: '8.8.8.8',
33
+ host: 'dns.google',
34
+ path: '/resolve',
35
+ port: 443,
36
+ };
37
+ const qs = `?name=${encodeURIComponent(host)}&type=A`;
38
+ const reqOpts = {
39
+ host: doh.bootstrapIp,
40
+ port: doh.port || 443,
41
+ servername: doh.host,
42
+ path: doh.path + qs,
43
+ method: 'GET',
44
+ headers: {
45
+ Host: doh.host,
46
+ Accept: 'application/dns-json',
47
+ },
48
+ lookup: (_h, _o, cb) => cb(null, doh.bootstrapIp, 4),
49
+ timeout: 5000,
50
+ };
51
+
52
+ return new Promise((resolve, reject) => {
53
+ try {
54
+ const req = https.request(reqOpts, (res) => {
55
+ let body = '';
56
+ res.on('data', (c) => (body += c));
57
+ res.on('end', () => {
58
+ try {
59
+ const json = JSON.parse(body);
60
+ if (json.Status !== 0 || !Array.isArray(json.Answer)) {
61
+ return resolve([]);
62
+ }
63
+ const ips = json.Answer.filter((a) => a.type === 1).map((a) => a.data);
64
+ resolve(ips);
65
+ } catch (e) {
66
+ reject(new Error(`DoH response parse error: ${e.message}`));
67
+ }
68
+ });
69
+ });
70
+ req.on('error', (e) => reject(new Error(`DoH request error: ${e.message}`)));
71
+ req.on('timeout', () => req.destroy(new Error('DoH timeout')));
72
+ req.end();
73
+ } catch (e) {
74
+ reject(new Error(`DoH setup error: ${e.message}`));
75
+ }
76
+ });
77
+ }
78
+
79
+ async function refresh(host) {
80
+ try {
81
+ const ips = await dohResolve(host);
82
+ if (ips?.length) {
83
+ cache.set(host, { ip: ips[0], ts: Date.now(), all: ips });
84
+ return ips[0];
85
+ }
86
+ } catch (e) {
87
+ debug('DoH resolve failed for', `${host}:`, e.message);
88
+ // DoH failed, keep old cache or fallback
89
+ }
90
+ const curated = defaultHosts.curated[host];
91
+ if (curated?.length) {
92
+ cache.set(host, { ip: curated[0], ts: Date.now(), all: curated });
93
+ return curated[0];
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * 将 DoH 解析结果缓存到内存(供 rule-actions 的 SNI 处理器调用)。
100
+ * 用于非 GitHub 域名的 IP 缓存。
101
+ */
102
+ function cacheDohResult(host, ips) {
103
+ if (!ips?.length) return;
104
+ cache.set(host, { ip: ips[0], ts: Date.now(), all: ips });
105
+ }
106
+
107
+ function userOverride(host) {
108
+ if (!CONFIG || !Array.isArray(CONFIG.hosts)) return null;
109
+ for (const h of CONFIG.hosts) {
110
+ if (h.pattern === host && h.ip) return h.ip;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Synchronous best-IP resolution: user override > cache > curated fallback.
117
+ * Triggers async DoH refresh in background.
118
+ */
119
+ function resolve(host) {
120
+ const ov = userOverride(host);
121
+ if (ov) return ov;
122
+
123
+ const cached = fresh(host);
124
+ if (cached) return cached;
125
+
126
+ const curated = defaultHosts.curated[host];
127
+ const fallback = curated?.length ? curated[0] : null;
128
+
129
+ refresh(host).catch((e) => {
130
+ debug('Background refresh failed for', `${host}:`, e.message);
131
+ });
132
+
133
+ return fallback;
134
+ }
135
+
136
+ /**
137
+ * TCP connection latency in ms.
138
+ */
139
+ function measureLatency(ip, port) {
140
+ port = port || 443;
141
+ return new Promise((resolve) => {
142
+ const start = process.hrtime.bigint();
143
+ const sock = net.connect({ host: ip, port }, () => {
144
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
145
+ sock.destroy();
146
+ resolve(Math.round(ms * 100) / 100);
147
+ });
148
+ sock.on('error', () => resolve(-1));
149
+ sock.setTimeout(3000, () => {
150
+ sock.destroy();
151
+ resolve(-1);
152
+ });
153
+ });
154
+ }
155
+
156
+ function clearCache() {
157
+ cache.clear();
158
+ }
159
+
160
+ function snapshot() {
161
+ const out = {};
162
+ cache.forEach((v, k) => (out[k] = v));
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * Get all candidate IPs for a host with source labels.
168
+ * Returns [{ ip, source: 'doh'|'curated'|'override'|'cached', latency: null }]
169
+ */
170
+ async function getAllCandidates(host) {
171
+ const seen = new Set();
172
+ const candidates = [];
173
+
174
+ const ov = userOverride(host);
175
+ if (ov && !seen.has(ov)) {
176
+ seen.add(ov);
177
+ candidates.push({ ip: ov, source: 'override', latency: null });
178
+ }
179
+
180
+ try {
181
+ const dohIps = await dohResolve(host);
182
+ if (dohIps?.length) {
183
+ cache.set(host, { ip: dohIps[0], ts: Date.now(), all: dohIps });
184
+ for (const ip of dohIps) {
185
+ if (!seen.has(ip)) {
186
+ seen.add(ip);
187
+ candidates.push({ ip, source: 'doh', latency: null });
188
+ }
189
+ }
190
+ }
191
+ } catch (e) {
192
+ debug('getAllCandidates DoH failed for', `${host}:`, e.message);
193
+ }
194
+
195
+ const cachedEntry = cache.get(host);
196
+ if (cachedEntry?.all) {
197
+ for (const ip of cachedEntry.all) {
198
+ if (!seen.has(ip)) {
199
+ seen.add(ip);
200
+ candidates.push({ ip, source: 'cached', latency: null });
201
+ }
202
+ }
203
+ }
204
+
205
+ const curated = defaultHosts.curated[host];
206
+ if (curated?.length) {
207
+ for (const ip of curated) {
208
+ if (!seen.has(ip)) {
209
+ seen.add(ip);
210
+ candidates.push({ ip, source: 'curated', latency: null });
211
+ }
212
+ }
213
+ }
214
+
215
+ return candidates;
216
+ }
217
+
218
+ module.exports = {
219
+ setConfig,
220
+ resolve,
221
+ refresh,
222
+ cacheDohResult,
223
+ measureLatency,
224
+ clearCache,
225
+ snapshot,
226
+ dohResolve,
227
+ getAllCandidates,
228
+ };
@@ -0,0 +1,344 @@
1
+ /**
2
+ * 拦截规则动作执行器
3
+ *
4
+ * 根据匹配到的动作集合,执行对应的拦截行为:
5
+ * abort / success / redirect → 直接返回响应
6
+ * sni → 解析 IP + SNI 改写直连
7
+ * proxy → host 映射(whistle host://)
8
+ * requestReplace → 请求头修改
9
+ * responseReplace → 响应头修改
10
+ * cacheDays → 响应缓存(TTL 轮转 key)
11
+ * tampermonkeyScript/script → HTML 注入脚本
12
+ * status → HTTP 状态码
13
+ * proxy_backup → 备用代理(模板变量替换)
14
+ */
15
+
16
+ const dns = require('node:dns');
17
+ const sniAgent = require('./sni-agent');
18
+
19
+ // ---- 系统 DNS 解析(非 GitHub 域名兜底) ----
20
+
21
+ function resolveSystemDns(host) {
22
+ return new Promise((resolve) => {
23
+ dns.resolve4(host, (err, addresses) => {
24
+ if (err || !addresses || !addresses.length) resolve(null);
25
+ else resolve(addresses[0]);
26
+ });
27
+ });
28
+ }
29
+
30
+ // ---- 请求头 / 响应头处理 ----
31
+
32
+ /**
33
+ * 将 headers 对象中的特殊值标准化。
34
+ * "[remove]" → 空字符串(whistle 中空值表示删除头)
35
+ */
36
+ function normalizeHeaders(headers) {
37
+ const out = {};
38
+ if (!headers || typeof headers !== 'object') return out;
39
+ for (const key of Object.keys(headers)) {
40
+ const val = headers[key];
41
+ if (val === '[remove]') {
42
+ out[key] = '';
43
+ } else {
44
+ out[key] = val;
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * 直接修改 whistle req 的请求头(用于 SNI forward 前的请求头篡改)
52
+ */
53
+ function applyRequestHeadersToReq(req, headers) {
54
+ const normalized = normalizeHeaders(headers);
55
+ for (const key of Object.keys(normalized)) {
56
+ const lower = key.toLowerCase();
57
+ if (normalized[key] === '') {
58
+ delete req.headers[lower];
59
+ } else {
60
+ req.headers[lower] = normalized[key];
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 修改上游响应头(用于 SNI forward 后的响应头篡改)
67
+ */
68
+ function applyResponseHeadersToRes(res, headers) {
69
+ const normalized = normalizeHeaders(headers);
70
+ if (!res?.headers) return;
71
+ for (const key of Object.keys(normalized)) {
72
+ const lower = key.toLowerCase();
73
+ if (normalized[key] === '') {
74
+ delete res.headers[lower];
75
+ } else {
76
+ res.headers[lower] = normalized[key];
77
+ }
78
+ }
79
+ }
80
+
81
+ // ---- 构建 whistle 原生规则 ----
82
+
83
+ /**
84
+ * 将动作集合转为 whistle 原生规则文本行数组。
85
+ * 用于 proxy / 纯 header 修改 / cache / script 注入等场景。
86
+ */
87
+ function buildWhistleRules(actions, host, _groups) {
88
+ const rules = [];
89
+
90
+ // 请求头修改
91
+ if (actions.requestReplace?.headers) {
92
+ const headers = normalizeHeaders(actions.requestReplace.headers);
93
+ if (Object.keys(headers).length > 0) {
94
+ rules.push(`reqHeaders://${JSON.stringify(headers)}`);
95
+ }
96
+ }
97
+
98
+ // 响应头修改
99
+ if (actions.responseReplace) {
100
+ const respHeaders = {};
101
+ if (actions.responseReplace.headers) {
102
+ Object.assign(respHeaders, normalizeHeaders(actions.responseReplace.headers));
103
+ }
104
+ if (actions.responseReplace.doDownload) {
105
+ respHeaders['content-disposition'] = 'attachment';
106
+ }
107
+ if (Object.keys(respHeaders).length > 0) {
108
+ rules.push(`resHeaders://${JSON.stringify(respHeaders)}`);
109
+ }
110
+ }
111
+
112
+ // 缓存 (TTL 轮转 key: 每 cacheDays 天换一个 key)
113
+ if (actions.cacheDays && actions.cacheDays > 0) {
114
+ const period = Math.floor(Date.now() / (actions.cacheDays * 86400000));
115
+ rules.push(`cache://${host}_${period}`);
116
+ }
117
+
118
+ // 状态码
119
+ if (actions.status) {
120
+ rules.push(`status://${actions.status}`);
121
+ }
122
+
123
+ // 脚本注入
124
+ const scripts = [];
125
+ if (actions.tampermonkeyScript) scripts.push(actions.tampermonkeyScript);
126
+ if (actions.script) scripts.push(actions.script);
127
+ if (scripts.length > 0) {
128
+ const html = scripts.map((s) => `<script src="${s}"></script>`).join('');
129
+ // whistle resAppend:// 内联内容用 base64 编码
130
+ rules.push(`resAppend://${Buffer.from(html).toString('base64')}`);
131
+ }
132
+
133
+ return rules;
134
+ }
135
+
136
+ // ---- 动作处理器 ----
137
+
138
+ /**
139
+ * 主入口:根据匹配结果执行动作。
140
+ *
141
+ * @param {object} req whistle PluginServerRequest
142
+ * @param {object} client whistle ServerResponse
143
+ * @param {function} next pass-through 回调
144
+ * @param {object} matched matchRules 返回值 { actions, groups, matches }
145
+ * @param {object} ctx { host, path, config, resolver }
146
+ * @returns {Promise|undefined}
147
+ */
148
+ async function applyActions(req, client, next, matched, ctx) {
149
+ const actions = matched.actions || {};
150
+ const groups = matched.groups || null;
151
+ const host = ctx.host;
152
+
153
+ // --- 1. abort(中断请求)---
154
+ if (actions.abort === true) {
155
+ client.statusCode = actions.status || 204;
156
+ client.end();
157
+ return;
158
+ }
159
+
160
+ // --- 2. success(直接返回成功响应)---
161
+ if (actions.success !== undefined) {
162
+ handleSuccess(client, actions);
163
+ return;
164
+ }
165
+
166
+ // --- 3. redirect(重定向)---
167
+ if (actions.redirect) {
168
+ client.statusCode = 302;
169
+ client.setHeader('location', actions.redirect);
170
+ client.end();
171
+ return;
172
+ }
173
+
174
+ // --- 4. sni(SNI 改写直连)---
175
+ if (actions.sni !== undefined) {
176
+ return await handleSni(req, client, next, actions, groups, ctx);
177
+ }
178
+
179
+ // --- 5. proxy(host 映射)---
180
+ if (actions.proxy) {
181
+ return handleProxyAction(req, client, next, actions, host, groups);
182
+ }
183
+
184
+ // --- 6. 其他动作(纯 header / cache / script,走 whistle 原生规则)---
185
+ const rules = buildWhistleRules(actions, host, groups);
186
+ if (rules.length > 0) {
187
+ req.setReqRules(rules.join('\n'));
188
+ }
189
+ req.passThrough();
190
+ }
191
+
192
+ // ---- success 处理器 ----
193
+
194
+ function handleSuccess(client, actions) {
195
+ const success = actions.success;
196
+ let body = '';
197
+ let contentType = 'text/plain; charset=utf-8';
198
+
199
+ if (success === true) {
200
+ // 简单成功
201
+ body = '';
202
+ } else if (typeof success === 'object') {
203
+ if (success.script) {
204
+ body = success.script;
205
+ contentType = 'application/javascript; charset=utf-8';
206
+ } else if (success.body) {
207
+ body = success.body;
208
+ }
209
+ } else if (typeof success === 'string') {
210
+ body = success;
211
+ }
212
+
213
+ client.statusCode = actions.status || 200;
214
+ client.setHeader('content-type', contentType);
215
+ client.setHeader('content-length', Buffer.byteLength(body));
216
+ client.end(body);
217
+ }
218
+
219
+ // ---- SNI 处理器 ----
220
+
221
+ async function handleSni(req, _client, next, actions, groups, ctx) {
222
+ const host = ctx.host;
223
+ const sniValue = actions.sni;
224
+
225
+ // sni: "none" → 不改写 SNI,仅做直连(或配合其他动作走 whistle 规则)
226
+ if (sniValue === 'none') {
227
+ const otherActions = Object.assign({}, actions);
228
+ delete otherActions.sni;
229
+ const rules = buildWhistleRules(otherActions, host, groups);
230
+ if (rules.length > 0) {
231
+ req.setReqRules(rules.join('\n'));
232
+ }
233
+ req.passThrough();
234
+ return;
235
+ }
236
+
237
+ // 解析 IP: 先用已有 resolver(GitHub 域名有缓存/curated),再 DoH,最后系统 DNS
238
+ let ip = null;
239
+ if (ctx.resolver && typeof ctx.resolver.resolve === 'function') {
240
+ ip = ctx.resolver.resolve(host);
241
+ }
242
+ if (!ip && ctx.resolver && typeof ctx.resolver.dohResolve === 'function') {
243
+ try {
244
+ const ips = await ctx.resolver.dohResolve(host);
245
+ if (ips?.length) {
246
+ ip = ips[0];
247
+ // 缓存结果
248
+ if (typeof ctx.resolver.cacheDohResult === 'function') {
249
+ ctx.resolver.cacheDohResult(host, ips);
250
+ }
251
+ }
252
+ } catch (_e) {
253
+ /* DoH 失败 */
254
+ }
255
+ }
256
+ if (!ip) {
257
+ ip = await resolveSystemDns(host);
258
+ }
259
+ if (!ip) {
260
+ // 无法解析 IP,放行
261
+ return next();
262
+ }
263
+
264
+ // 请求头篡改(SNI forward 前修改原始请求头)
265
+ if (actions.requestReplace?.headers) {
266
+ applyRequestHeadersToReq(req, actions.requestReplace.headers);
267
+ }
268
+ if (actions.requestReplace?.doDownload) {
269
+ // 请求侧 doDownload: 无实际请求头修改需要
270
+ }
271
+
272
+ // 响应头篡改回调
273
+ let onResponse = null;
274
+ if (actions.responseReplace) {
275
+ const respHeaders = {};
276
+ if (actions.responseReplace.headers) {
277
+ Object.assign(respHeaders, normalizeHeaders(actions.responseReplace.headers));
278
+ }
279
+ if (actions.responseReplace.doDownload) {
280
+ respHeaders['content-disposition'] = 'attachment';
281
+ }
282
+ if (Object.keys(respHeaders).length > 0) {
283
+ onResponse = (res) => {
284
+ applyResponseHeadersToRes(res, respHeaders);
285
+ };
286
+ }
287
+ }
288
+
289
+ // proxy_backup 模板替换(记录备用代理,当前版本仅记录不自动切换)
290
+ if (actions.proxy_backup && groups) {
291
+ // 替换模板变量 ${var}
292
+ const _backup = require('./rule-engine').substituteTemplate(actions.proxy_backup, groups);
293
+ // TODO: 实现主连接失败后自动切换到 proxy_backup
294
+ // 当前版本: 仅记录,不自动切换
295
+ }
296
+
297
+ // SNI 直连转发
298
+ sniAgent.forward(
299
+ req,
300
+ {
301
+ domain: host,
302
+ ip: ip,
303
+ port: 443,
304
+ servername: sniValue,
305
+ },
306
+ onResponse
307
+ );
308
+ }
309
+
310
+ // ---- proxy 处理器 ----
311
+
312
+ function handleProxyAction(req, _client, _next, actions, host, groups) {
313
+ const proxyHost = actions.proxy;
314
+
315
+ // 构建 whistle 规则
316
+ const rules = [`${host} host://${proxyHost}`];
317
+
318
+ // 附加其他动作(header 修改、cache 等)
319
+ const otherActions = Object.assign({}, actions);
320
+ delete otherActions.proxy;
321
+ delete otherActions.backup;
322
+ delete otherActions.desc;
323
+ delete otherActions.remark;
324
+ delete otherActions.test;
325
+
326
+ const extraRules = buildWhistleRules(otherActions, host, groups);
327
+ for (let i = 0; i < extraRules.length; i++) {
328
+ rules.push(extraRules[i]);
329
+ }
330
+
331
+ // backup 主机列表(当前版本仅记录,不自动切换)
332
+ // TODO: 实现主代理失败后自动尝试 backup 主机
333
+
334
+ req.setReqRules(rules.join('\n'));
335
+ req.passThrough();
336
+ }
337
+
338
+ module.exports = {
339
+ applyActions,
340
+ buildWhistleRules,
341
+ normalizeHeaders,
342
+ handleSuccess,
343
+ resolveSystemDns,
344
+ };