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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/easy-proxy.js +14 -0
- package/index.js +125 -0
- package/lib/cert-check.js +211 -0
- package/lib/cli/bypass-domains.js +187 -0
- package/lib/cli/commands/accel.js +184 -0
- package/lib/cli/commands/cert.js +35 -0
- package/lib/cli/commands/config-cmd.js +50 -0
- package/lib/cli/commands/health.js +53 -0
- package/lib/cli/commands/host.js +79 -0
- package/lib/cli/commands/index.js +31 -0
- package/lib/cli/commands/intercept.js +222 -0
- package/lib/cli/commands/network.js +32 -0
- package/lib/cli/commands/proxy.js +279 -0
- package/lib/cli/commands/rule.js +352 -0
- package/lib/cli/commands/start.js +98 -0
- package/lib/cli/commands/status.js +50 -0
- package/lib/cli/helpers.js +137 -0
- package/lib/cli/proxy-config.js +41 -0
- package/lib/cli/rule-api.js +230 -0
- package/lib/cli/rule-editor.js +362 -0
- package/lib/cli/rules-file.js +121 -0
- package/lib/cli/upstream-editor.js +218 -0
- package/lib/cli/upstream-proxy.js +74 -0
- package/lib/config.js +204 -0
- package/lib/debug.js +17 -0
- package/lib/network-utils.js +56 -0
- package/lib/plugin/default-hosts.js +48 -0
- package/lib/plugin/default-rules.js +668 -0
- package/lib/plugin/resolver.js +228 -0
- package/lib/plugin/rule-actions.js +344 -0
- package/lib/plugin/rule-engine.js +232 -0
- package/lib/plugin/rule-generator.js +100 -0
- package/lib/plugin/sni-agent.js +54 -0
- package/lib/plugin/ui.js +430 -0
- package/lib/proxy-check.js +67 -0
- package/package.json +64 -0
package/lib/plugin/ui.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
const resolver = require('./resolver');
|
|
2
|
+
const defaultHosts = require('./default-hosts');
|
|
3
|
+
const { saveConfig } = require('../config');
|
|
4
|
+
const { debug } = require('../debug');
|
|
5
|
+
|
|
6
|
+
function sendJson(client, obj, status) {
|
|
7
|
+
const body = JSON.stringify(obj);
|
|
8
|
+
client.statusCode = status || 200;
|
|
9
|
+
client.setHeader('content-type', 'application/json; charset=utf-8');
|
|
10
|
+
client.setHeader('cache-control', 'no-store');
|
|
11
|
+
client.end(body);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readBody(req) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let buf = '';
|
|
17
|
+
req.on('data', (c) => (buf += c));
|
|
18
|
+
req.on('end', () => {
|
|
19
|
+
try {
|
|
20
|
+
resolve(buf ? JSON.parse(buf) : {});
|
|
21
|
+
} catch (e) {
|
|
22
|
+
debug('API body parse error:', e.message);
|
|
23
|
+
resolve({});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
req.on('error', (e) => {
|
|
27
|
+
debug('API request error:', e.message);
|
|
28
|
+
resolve({});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isTarget(host) {
|
|
34
|
+
if (!host) return false;
|
|
35
|
+
for (const d of defaultHosts.domains) {
|
|
36
|
+
if (d === host) return true;
|
|
37
|
+
if (d.startsWith('*.') && host.endsWith(d.slice(1))) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function handleApi(req, client, options, config) {
|
|
43
|
+
const url = (req.url || '/').split('?')[0];
|
|
44
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
45
|
+
|
|
46
|
+
// ---- Status & config ----
|
|
47
|
+
if (url === '/api/status') {
|
|
48
|
+
return sendJson(client, {
|
|
49
|
+
enabled: config.enabled,
|
|
50
|
+
sniRewrite: config.sniRewrite,
|
|
51
|
+
doh: config.doh,
|
|
52
|
+
hosts: config.hosts,
|
|
53
|
+
cached: resolver.snapshot(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Host list with IP & latency ----
|
|
58
|
+
if (url === '/api/hosts') {
|
|
59
|
+
const rows = [];
|
|
60
|
+
for (const d of defaultHosts.domains) {
|
|
61
|
+
const hosts = d.startsWith('*.') ? [d] : [d];
|
|
62
|
+
for (const h of hosts) {
|
|
63
|
+
const ip = resolver.resolve(h);
|
|
64
|
+
let latency = null;
|
|
65
|
+
if (ip) latency = await resolver.measureLatency(ip);
|
|
66
|
+
rows.push({ host: h, ip, latency, target: isTarget(h) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return sendJson(client, { hosts: rows });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- Speed test ----
|
|
73
|
+
if (url === '/api/speed-test' && method === 'POST') {
|
|
74
|
+
const body = await readBody(req);
|
|
75
|
+
const host = body.host;
|
|
76
|
+
if (!host) return sendJson(client, { error: 'host is required' }, 400);
|
|
77
|
+
|
|
78
|
+
const candidates = await resolver.getAllCandidates(host);
|
|
79
|
+
const results = await Promise.all(
|
|
80
|
+
candidates.map(async (c) => {
|
|
81
|
+
const latency = await resolver.measureLatency(c.ip);
|
|
82
|
+
return { ip: c.ip, source: c.source, latency };
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
results.sort((a, b) => {
|
|
87
|
+
if (a.latency === -1 && b.latency === -1) return 0;
|
|
88
|
+
if (a.latency === -1) return 1;
|
|
89
|
+
if (b.latency === -1) return -1;
|
|
90
|
+
return a.latency - b.latency;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return sendJson(client, { host, results });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- Refresh DNS cache ----
|
|
97
|
+
if (url === '/api/refresh' && method === 'POST') {
|
|
98
|
+
const body = await readBody(req);
|
|
99
|
+
if (body.host) {
|
|
100
|
+
await resolver.refresh(body.host);
|
|
101
|
+
} else {
|
|
102
|
+
for (const d of defaultHosts.domains) {
|
|
103
|
+
if (!d.startsWith('*.')) await resolver.refresh(d);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return sendJson(client, { ok: true, cached: resolver.snapshot() });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- Toggle acceleration ----
|
|
110
|
+
if (url === '/api/toggle' && method === 'POST') {
|
|
111
|
+
const body = await readBody(req);
|
|
112
|
+
config.enabled = !!body.enabled;
|
|
113
|
+
saveConfig(options, config);
|
|
114
|
+
return sendJson(client, { ok: true, enabled: config.enabled });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Toggle SNI rewrite ----
|
|
118
|
+
if (url === '/api/toggle-sni' && method === 'POST') {
|
|
119
|
+
const body = await readBody(req);
|
|
120
|
+
config.sniRewrite.enabled = !!body.enabled;
|
|
121
|
+
if (typeof body.defaultSni !== 'undefined')
|
|
122
|
+
config.sniRewrite.defaultSni = body.defaultSni || null;
|
|
123
|
+
saveConfig(options, config);
|
|
124
|
+
return sendJson(client, { ok: true, sniRewrite: config.sniRewrite });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- SNI mapping ----
|
|
128
|
+
if (url === '/api/sni' && method === 'POST') {
|
|
129
|
+
const body = await readBody(req);
|
|
130
|
+
if (body.host) {
|
|
131
|
+
if (body.sni) config.sniRewrite.sniMap[body.host] = body.sni;
|
|
132
|
+
else delete config.sniRewrite.sniMap[body.host];
|
|
133
|
+
saveConfig(options, config);
|
|
134
|
+
}
|
|
135
|
+
return sendJson(client, { ok: true, sniMap: config.sniRewrite.sniMap });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- Add/update host override ----
|
|
139
|
+
if (url === '/api/host' && method === 'POST') {
|
|
140
|
+
const body = await readBody(req);
|
|
141
|
+
if (body.pattern && body.ip) {
|
|
142
|
+
const i = config.hosts.findIndex((h) => h.pattern === body.pattern);
|
|
143
|
+
if (i >= 0) config.hosts[i].ip = body.ip;
|
|
144
|
+
else config.hosts.push({ pattern: body.pattern, ip: body.ip });
|
|
145
|
+
saveConfig(options, config);
|
|
146
|
+
}
|
|
147
|
+
return sendJson(client, { ok: true, hosts: config.hosts });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- Remove host override ----
|
|
151
|
+
if (url === '/api/host-remove' && method === 'POST') {
|
|
152
|
+
const body = await readBody(req);
|
|
153
|
+
if (body.pattern) {
|
|
154
|
+
config.hosts = config.hosts.filter((h) => h.pattern !== body.pattern);
|
|
155
|
+
saveConfig(options, config);
|
|
156
|
+
}
|
|
157
|
+
return sendJson(client, { ok: true, hosts: config.hosts });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- Network utility APIs ----
|
|
161
|
+
if (url === '/api/network/ip') {
|
|
162
|
+
const { getLocalIp } = require('../network-utils');
|
|
163
|
+
return sendJson(client, { ip: getLocalIp() });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (url === '/api/network/delay') {
|
|
167
|
+
const port = req.url && new URL(`http://x${req.url}`).searchParams.get('port');
|
|
168
|
+
const { checkDelay } = require('../network-utils');
|
|
169
|
+
try {
|
|
170
|
+
const delay = await checkDelay(port ? parseInt(port, 10) : undefined);
|
|
171
|
+
return sendJson(client, { delay });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return sendJson(client, { error: e.message }, 500);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- Intercept rules APIs ----
|
|
178
|
+
if (url === '/api/intercept/status') {
|
|
179
|
+
const rules = config.interceptRules || {};
|
|
180
|
+
const hostCount = Object.keys(rules).length;
|
|
181
|
+
let pathCount = 0;
|
|
182
|
+
for (const h of Object.keys(rules)) {
|
|
183
|
+
pathCount += Object.keys(rules[h] || {}).length;
|
|
184
|
+
}
|
|
185
|
+
return sendJson(client, {
|
|
186
|
+
enabled: config.interceptEnabled !== false,
|
|
187
|
+
hostCount: hostCount,
|
|
188
|
+
pathCount: pathCount,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (url === '/api/intercept/rules') {
|
|
193
|
+
return sendJson(client, { rules: config.interceptRules || {} });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (url === '/api/intercept/toggle' && method === 'POST') {
|
|
197
|
+
const body = await readBody(req);
|
|
198
|
+
config.interceptEnabled = !!body.enabled;
|
|
199
|
+
saveConfig(options, config);
|
|
200
|
+
return sendJson(client, { ok: true, enabled: config.interceptEnabled });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return sendJson(client, { error: 'not found' }, 404);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderDashboard(config) {
|
|
207
|
+
const sniOn = config.sniRewrite.enabled;
|
|
208
|
+
const hostsOverride = config.hosts || [];
|
|
209
|
+
return `<!doctype html>
|
|
210
|
+
<html lang="zh-CN">
|
|
211
|
+
<head>
|
|
212
|
+
<meta charset="utf-8" />
|
|
213
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
214
|
+
<title>EasyProxy · GitHub 直连加速</title>
|
|
215
|
+
<style>
|
|
216
|
+
:root { color-scheme: light; }
|
|
217
|
+
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0; background: #f6f7f9; color: #1f2329; }
|
|
218
|
+
header { background: #2b6cff; color: #fff; padding: 14px 20px; display: flex; align-items: center; gap: 12px; }
|
|
219
|
+
header h1 { font-size: 16px; margin: 0; }
|
|
220
|
+
.wrap { padding: 18px 20px; max-width: 1080px; margin: 0 auto; }
|
|
221
|
+
.card { background: #fff; border: 1px solid #e6e8eb; border-radius: 10px; padding: 16px; margin-bottom: 16px; }
|
|
222
|
+
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
223
|
+
.switch { position: relative; width: 44px; height: 24px; }
|
|
224
|
+
.switch input { display: none; }
|
|
225
|
+
.slider { position: absolute; inset: 0; background: #ccc; border-radius: 24px; transition: .2s; }
|
|
226
|
+
.slider:before { content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; }
|
|
227
|
+
.switch input:checked + .slider { background: #2b6cff; }
|
|
228
|
+
.switch input:checked + .slider:before { transform: translateX(20px); }
|
|
229
|
+
button { background: #2b6cff; color: #fff; border: 0; border-radius: 6px; padding: 7px 14px; cursor: pointer; font-size: 13px; }
|
|
230
|
+
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
231
|
+
button.ghost { background: #eef1f5; color: #2b6cff; }
|
|
232
|
+
button.danger { background: #fff; color: #d93025; border: 1px solid #d93025; }
|
|
233
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
234
|
+
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eef0f3; }
|
|
235
|
+
th { color: #6b7280; font-weight: 600; }
|
|
236
|
+
.tag { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; }
|
|
237
|
+
.tag.on { background: #e6f4ea; color: #1e8e3e; }
|
|
238
|
+
.tag.off { background: #fdecea; color: #d93025; }
|
|
239
|
+
.tag.src-doh { background: #e8f0fe; color: #1a73e8; }
|
|
240
|
+
.tag.src-curated { background: #fce8e6; color: #c5221f; }
|
|
241
|
+
.tag.src-override { background: #e6f4ea; color: #1e8e3e; }
|
|
242
|
+
.tag.src-cached { background: #f1f3f5; color: #5f6368; }
|
|
243
|
+
.muted { color: #8a9099; font-size: 12px; }
|
|
244
|
+
input[type=text] { padding: 6px 8px; border: 1px solid #d0d5dd; border-radius: 6px; font-size: 13px; }
|
|
245
|
+
.hint { background: #fff8e6; border: 1px solid #ffe2a8; color: #8a6d3b; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; }
|
|
246
|
+
code { background: #f1f3f5; padding: 1px 5px; border-radius: 4px; }
|
|
247
|
+
.speed-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 100; }
|
|
248
|
+
.speed-modal.open { display: flex; align-items: center; justify-content: center; }
|
|
249
|
+
.speed-panel { background: #fff; border-radius: 12px; padding: 20px; max-width: 640px; width: 90%; box-shadow: 0 4px 24px rgba(0,0,0,.15); }
|
|
250
|
+
.speed-panel h3 { margin: 0 0 12px; font-size: 15px; }
|
|
251
|
+
.speed-panel table { margin-top: 8px; }
|
|
252
|
+
.best-row { background: #e6f4ea; }
|
|
253
|
+
.fail-row { color: #d93025; }
|
|
254
|
+
.overlay-list { margin-top: 8px; }
|
|
255
|
+
.overlay-list .item { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
|
|
256
|
+
</style>
|
|
257
|
+
</head>
|
|
258
|
+
<body>
|
|
259
|
+
<header><h1>EasyProxy · GitHub 直连加速</h1></header>
|
|
260
|
+
<div class="wrap">
|
|
261
|
+
<div class="hint">
|
|
262
|
+
内核默认用 <b>whistle</b> 覆盖 95% 场景(DoH 解析真实 IP + <code>host://</code> 映射,绕过 DNS 污染)。
|
|
263
|
+
<b>SNI 改写</b> 是可选的进阶拦截钩子(连真实 IP、改写 ClientHello 的 SNI 以绕过 GFW SNI 检测),默认关闭。
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<!-- Controls -->
|
|
267
|
+
<div class="card">
|
|
268
|
+
<div class="row" style="justify-content: space-between;">
|
|
269
|
+
<div class="row">
|
|
270
|
+
<label class="switch"><input type="checkbox" id="enabled" ${config.enabled ? 'checked' : ''}/><span class="slider"></span></label>
|
|
271
|
+
<strong>加速总开关</strong>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="row">
|
|
274
|
+
<label class="switch"><input type="checkbox" id="sni" ${sniOn ? 'checked' : ''}/><span class="slider"></span></label>
|
|
275
|
+
<strong>SNI 改写钩子</strong>
|
|
276
|
+
<span class="tag ${sniOn ? 'on' : 'off'}">${sniOn ? '已启用' : '已关闭'}</span>
|
|
277
|
+
</div>
|
|
278
|
+
<button class="ghost" id="refreshAll">刷新全部 IP</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<!-- Domain list -->
|
|
283
|
+
<div class="card">
|
|
284
|
+
<div class="row" style="justify-content: space-between; margin-bottom: 10px;">
|
|
285
|
+
<strong>域名 → 解析 IP / 延迟</strong>
|
|
286
|
+
<span class="muted">点击「测速」对比所有候选 IP,一键选择最优</span>
|
|
287
|
+
</div>
|
|
288
|
+
<table>
|
|
289
|
+
<thead><tr><th>域名</th><th>当前 IP</th><th>延迟(ms)</th><th>SNI 映射</th><th>操作</th></tr></thead>
|
|
290
|
+
<tbody id="hosts"></tbody>
|
|
291
|
+
</table>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<!-- Manual overrides -->
|
|
295
|
+
<div class="card">
|
|
296
|
+
<strong>手动覆盖</strong>
|
|
297
|
+
<div class="row" style="margin-top: 10px;">
|
|
298
|
+
<input type="text" id="pat" placeholder="域名,如 github.com" />
|
|
299
|
+
<input type="text" id="ip" placeholder="IP,如 20.205.243.166" />
|
|
300
|
+
<button id="addHost">添加 host 覆盖</button>
|
|
301
|
+
<span class="muted">优先级高于 DoH / 兜底</span>
|
|
302
|
+
</div>
|
|
303
|
+
${
|
|
304
|
+
hostsOverride.length > 0
|
|
305
|
+
? '<div class="overlay-list"><span class="muted">当前覆盖:</span>' +
|
|
306
|
+
hostsOverride
|
|
307
|
+
.map(
|
|
308
|
+
(h) =>
|
|
309
|
+
'<div class="item"><code>' +
|
|
310
|
+
h.pattern +
|
|
311
|
+
'</code> → <code>' +
|
|
312
|
+
h.ip +
|
|
313
|
+
'</code>' +
|
|
314
|
+
'<button class="danger" style="padding:3px 8px;font-size:12px;" data-rm="' +
|
|
315
|
+
h.pattern +
|
|
316
|
+
'">删除</button></div>'
|
|
317
|
+
)
|
|
318
|
+
.join('') +
|
|
319
|
+
'</div>'
|
|
320
|
+
: ''
|
|
321
|
+
}
|
|
322
|
+
<div class="row" style="margin-top: 12px;">
|
|
323
|
+
<input type="text" id="sniHost" placeholder="域名" />
|
|
324
|
+
<input type="text" id="sniVal" placeholder="改写后的 SNI" />
|
|
325
|
+
<button id="addSni">设置 SNI 映射</button>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<!-- Speed test modal -->
|
|
331
|
+
<div class="speed-modal" id="speedModal">
|
|
332
|
+
<div class="speed-panel">
|
|
333
|
+
<div class="row" style="justify-content:space-between;">
|
|
334
|
+
<h3 id="speedTitle">Speed Test</h3>
|
|
335
|
+
<button class="ghost" id="closeSpeed">关闭</button>
|
|
336
|
+
</div>
|
|
337
|
+
<div id="speedContent"><span class="muted">正在测速...</span></div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<script>
|
|
342
|
+
const BASE = '/plugin.easy-proxy';
|
|
343
|
+
const api = (p, opt) => fetch(BASE + p, opt).then(r => r.json());
|
|
344
|
+
|
|
345
|
+
async function load() {
|
|
346
|
+
const [{ hosts }, status] = await Promise.all([api('/api/hosts'), api('/api/status')]);
|
|
347
|
+
const tb = document.getElementById('hosts');
|
|
348
|
+
tb.innerHTML = '';
|
|
349
|
+
hosts.forEach(h => {
|
|
350
|
+
const tr = document.createElement('tr');
|
|
351
|
+
const sniVal = status.sniRewrite.sniMap[h.host] || '—';
|
|
352
|
+
const isOverride = status.hosts && status.hosts.some(o => o.pattern === h.host);
|
|
353
|
+
const ipDisplay = h.ip
|
|
354
|
+
? (isOverride ? '<span class="tag src-override">覆盖</span> ' + h.ip : h.ip)
|
|
355
|
+
: '<span class="muted">未解析</span>';
|
|
356
|
+
const latDisplay = h.latency == null ? '—' : (h.latency === -1 ? '<span class="fail-row">超时</span>' : h.latency);
|
|
357
|
+
const sniDisplay = sniVal === '—' ? '<span class="muted">—</span>' : sniVal;
|
|
358
|
+
tr.innerHTML = '<td>' + h.host + '</td><td>' + ipDisplay +
|
|
359
|
+
'</td><td>' + latDisplay + '</td><td>' + sniDisplay + '</td>' +
|
|
360
|
+
'<td><button class="ghost" data-h="' + h.host + '">刷新</button> ' +
|
|
361
|
+
'<button class="ghost" data-speed="' + h.host + '">测速</button></td>';
|
|
362
|
+
tb.appendChild(tr);
|
|
363
|
+
});
|
|
364
|
+
tb.querySelectorAll('button[data-h]').forEach(b => b.onclick = async () => {
|
|
365
|
+
b.disabled = true;
|
|
366
|
+
await api('/api/refresh', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ host: b.dataset.h }) });
|
|
367
|
+
load();
|
|
368
|
+
});
|
|
369
|
+
tb.querySelectorAll('button[data-speed]').forEach(b => b.onclick = () => runSpeedTest(b.dataset.speed));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function runSpeedTest(host) {
|
|
373
|
+
const modal = document.getElementById('speedModal');
|
|
374
|
+
const title = document.getElementById('speedTitle');
|
|
375
|
+
const content = document.getElementById('speedContent');
|
|
376
|
+
title.textContent = host + ' · 测速';
|
|
377
|
+
content.innerHTML = '<span class="muted">正在对所有候选 IP 测速...</span>';
|
|
378
|
+
modal.classList.add('open');
|
|
379
|
+
const data = await api('/api/speed-test', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ host }) });
|
|
380
|
+
if (data.error) { content.innerHTML = '<span class="fail-row">测速失败: ' + data.error + '</span>'; return; }
|
|
381
|
+
const best = data.results.find(r => r.latency > 0);
|
|
382
|
+
let html = '<table><thead><tr><th>IP</th><th>来源</th><th>延迟(ms)</th><th>操作</th></tr></thead><tbody>';
|
|
383
|
+
data.results.forEach(r => {
|
|
384
|
+
const isBest = best && r.ip === best.ip && r.latency === best.latency;
|
|
385
|
+
const isFail = r.latency === -1;
|
|
386
|
+
const srcTag = '<span class="tag src-' + r.source + '">' + r.source + '</span>';
|
|
387
|
+
html += '<tr class="' + (isBest ? 'best-row' : (isFail ? 'fail-row' : '')) + '"><td>' + r.ip + '</td><td>' + srcTag +
|
|
388
|
+
'</td><td>' + (isFail ? '超时/不可达' : r.latency) + '</td>' +
|
|
389
|
+
'<td>' + (isFail ? '' : '<button class="ghost" onclick="pickBest('' + host + '','' + r.ip + '')">选为覆盖</button>') + '</td></tr>';
|
|
390
|
+
});
|
|
391
|
+
html += '</tbody></table>';
|
|
392
|
+
if (best) html += '<div style="margin-top:10px;"><span class="tag on">✓ 最优</span> ' + best.ip + ' (' + best.latency + 'ms) ' +
|
|
393
|
+
'<button class="ghost" onclick="pickBest('' + host + '','' + best.ip + '')">一键选用最优</button></div>';
|
|
394
|
+
content.innerHTML = html;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function pickBest(host, ip) {
|
|
398
|
+
api('/api/host', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ pattern: host, ip }) });
|
|
399
|
+
document.getElementById('speedModal').classList.remove('open');
|
|
400
|
+
load();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
document.getElementById('closeSpeed').onclick = () => document.getElementById('speedModal').classList.remove('open');
|
|
404
|
+
document.getElementById('speedModal').onclick = (e) => { if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); };
|
|
405
|
+
document.getElementById('enabled').onchange = e => api('/api/toggle', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ enabled: e.target.checked }) });
|
|
406
|
+
document.getElementById('sni').onchange = e => api('/api/toggle-sni', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ enabled: e.target.checked }) });
|
|
407
|
+
document.getElementById('refreshAll').onclick = async () => { await api('/api/refresh', { method:'POST', headers:{'content-type':'application/json'}, body: '{}' }); load(); };
|
|
408
|
+
document.getElementById('addHost').onclick = async () => {
|
|
409
|
+
const pat = document.getElementById('pat').value.trim(), ip = document.getElementById('ip').value.trim();
|
|
410
|
+
if (!pat || !ip) return;
|
|
411
|
+
await api('/api/host', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ pattern: pat, ip }) });
|
|
412
|
+
load();
|
|
413
|
+
};
|
|
414
|
+
document.getElementById('addSni').onclick = async () => {
|
|
415
|
+
const host = document.getElementById('sniHost').value.trim(), sni = document.getElementById('sniVal').value.trim();
|
|
416
|
+
if (!host) return;
|
|
417
|
+
await api('/api/sni', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ host, sni }) });
|
|
418
|
+
load();
|
|
419
|
+
};
|
|
420
|
+
document.querySelectorAll('button[data-rm]').forEach(b => b.onclick = async () => {
|
|
421
|
+
await api('/api/host-remove', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ pattern: b.dataset.rm }) });
|
|
422
|
+
load();
|
|
423
|
+
});
|
|
424
|
+
load();
|
|
425
|
+
</script>
|
|
426
|
+
</body>
|
|
427
|
+
</html>`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
module.exports = { renderDashboard, handleApi, isTarget };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统代理状态检查模块
|
|
3
|
+
* 检查系统代理是否指向 whistle(127.0.0.1:port)
|
|
4
|
+
*
|
|
5
|
+
* macOS: 通过 scutil --proxy 查询
|
|
6
|
+
* Windows: 通过 reg query 查询注册表
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { exec } = require('node:child_process');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
|
|
12
|
+
const IS_MACOS = os.platform() === 'darwin';
|
|
13
|
+
const IS_WINDOWS = os.platform() === 'win32';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 检查系统代理是否指向指定端口
|
|
17
|
+
* @param {number} port - whistle 端口
|
|
18
|
+
* @returns {Promise<boolean>}
|
|
19
|
+
*/
|
|
20
|
+
function checkSystemProxy(port) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
if (!IS_MACOS && !IS_WINDOWS) {
|
|
23
|
+
resolve(false);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (IS_MACOS) {
|
|
28
|
+
exec('scutil --proxy', (error, stdout) => {
|
|
29
|
+
if (error || !stdout) {
|
|
30
|
+
resolve(false);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
var output = stdout.toString();
|
|
34
|
+
// 无网络连接时 scutil 返回空字典
|
|
35
|
+
if (output.trim() === '<dictionary> {\n}') {
|
|
36
|
+
resolve(false);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
var portStr = `${port}`;
|
|
40
|
+
var httpEnable = /HTTPEnable\s*:\s*1/.test(output);
|
|
41
|
+
var httpPort = new RegExp(`HTTPPort\\s*:\\s*${portStr}`).test(output);
|
|
42
|
+
var httpProxy = /HTTPProxy\s*:\s*127\.0\.0\.1/.test(output);
|
|
43
|
+
var httpsEnable = /HTTPSEnable\s*:\s*1/.test(output);
|
|
44
|
+
var httpsPort = new RegExp(`HTTPSPort\\s*:\\s*${portStr}`).test(output);
|
|
45
|
+
var httpsProxy = /HTTPSProxy\s*:\s*127\.0\.0\.1/.test(output);
|
|
46
|
+
resolve(httpEnable && httpPort && httpProxy && httpsEnable && httpsPort && httpsProxy);
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Windows: 通过注册表查询
|
|
52
|
+
var query =
|
|
53
|
+
'reg query "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"';
|
|
54
|
+
exec(query, (error, stdout) => {
|
|
55
|
+
if (error || !stdout) {
|
|
56
|
+
resolve(false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
var output = stdout.toString();
|
|
60
|
+
var proxyEnabled = /ProxyEnable\s+REG_DWORD\s+0x1/.test(output);
|
|
61
|
+
var proxyMatch = new RegExp(`ProxyServer\\s+REG_SZ\\s+127\\.0\\.0\\.1:${port}`).test(output);
|
|
62
|
+
resolve(proxyEnabled && proxyMatch);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { checkSystemProxy };
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whistle.easy-proxy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "EasyProxy whistle plugin — GitHub acceleration, SNI rewrite, hosts management",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"homepage": "https://github.com/sqlwwx/easy-proxy",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/sqlwwx/easy-proxy.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/sqlwwx/easy-proxy/issues"
|
|
13
|
+
},
|
|
14
|
+
"author": "sqlwwx",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"bin/",
|
|
19
|
+
"lib/",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"easy-proxy": "bin/easy-proxy.js",
|
|
25
|
+
"whistle.easy-proxy": "bin/easy-proxy.js"
|
|
26
|
+
},
|
|
27
|
+
"whistleConfig": {
|
|
28
|
+
"hideShortProtocol": true,
|
|
29
|
+
"hideLongProtocol": true
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"whistle",
|
|
33
|
+
"plugin",
|
|
34
|
+
"easy-proxy",
|
|
35
|
+
"github",
|
|
36
|
+
"acceleration",
|
|
37
|
+
"sni",
|
|
38
|
+
"hosts"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"chalk": "^4.1.2",
|
|
42
|
+
"commander": "^12.1.0",
|
|
43
|
+
"whistle": "^2.10.8"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.0.0"
|
|
47
|
+
},
|
|
48
|
+
"private": false,
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"registry": "https://registry.npmjs.org/"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@biomejs/biome": "^2.5.7"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"start": "easy-proxy start",
|
|
58
|
+
"stop": "easy-proxy stop",
|
|
59
|
+
"test": "node --test",
|
|
60
|
+
"health": "easy-proxy health",
|
|
61
|
+
"format": "biome format . --write",
|
|
62
|
+
"format:check": "biome format ."
|
|
63
|
+
}
|
|
64
|
+
}
|