whistle.easy-proxy 1.0.0 → 1.2.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/README.md CHANGED
@@ -61,6 +61,7 @@ open http://local.whistlejs.com/plugin.easy-proxy/
61
61
  | `easy-proxy accel dns` | 刷新 DNS 缓存 |
62
62
  | `easy-proxy host add <域名> <IP>` | 添加手动 IP 覆盖 |
63
63
  | `easy-proxy health` | 综合健康检查 |
64
+ | `easy-proxy mirror` | 开发镜像源加速(npm/pip/go,302 重定向到国内镜像) |
64
65
  | `easy-proxy status` | 查看整体状态 |
65
66
 
66
67
  **完整命令参考** → [docs/cli.md](docs/cli.md)
@@ -136,7 +137,7 @@ TCP 隧道 → IP 直连 + TLS 握手
136
137
  |------|---------|
137
138
  | GitHub 无法访问 | `easy-proxy start --init && easy-proxy accel on && easy-proxy proxy on` |
138
139
  | 访问速度慢 | `easy-proxy accel dns` 刷新缓存,或在 Web 面板测速选 IP |
139
- | 证书不受信任 | `easy-proxy cert install` 重新安装根证书 |
140
+ | 证书不受信任 | `easy-proxy start` 重新安装根证书 |
140
141
 
141
142
  **完整故障排查** → [docs/troubleshooting.md](docs/troubleshooting.md)
142
143
 
package/lib/cert-check.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * whistle 证书路径: ~/.WhistleAppData/.whistle/certs/root.crt
6
6
  * macOS 信任检查: security find-certificate
7
7
  * Windows 信任检查: certutil
8
- * 有效期解析: 优先用 openssl(macOS 内置),回退到 node-forge
8
+ * 有效期解析: openssl(macOS/Linux 内置),Windows 用 certifi 的 CA 包
9
9
  */
10
10
 
11
11
  const fs = require('node:fs');
@@ -63,50 +63,102 @@ function parseCertWithOpenssl(certPath) {
63
63
  }
64
64
 
65
65
  /**
66
- * 用 node-forge 解析证书有效期(回退方案)
66
+ * 用 openssl 解析证书扩展,检查 whistle 根证书是否符合 Python 3.13+/OpenSSL 3
67
+ * 的严格校验要求。老版 whistle 生成的根证书有三个已知缺陷,浏览器不报错但
68
+ * Python 会拒收:1. Basic Constraints 未标 critical 2. 缺 Subject Key Identifier
69
+ * 3. 缺 Authority Key Identifier
67
70
  * @param {string} certPath
68
- * @returns {{notBefore: Date, notAfter: Date, subject: string}|null}
71
+ * @returns {string[]|null} 缺陷列表,null 表示无法解析
69
72
  */
70
- function parseCertWithForge(certPath) {
73
+ function checkCertCompliance(certPath) {
71
74
  try {
72
- var forge = require('node-forge');
73
- var pem = fs.readFileSync(certPath, 'utf-8');
74
- var cert = forge.pki.certificateFromPem(pem);
75
- var cn = cert.subject.attributes.find((a) => a.name === 'commonName');
76
- return {
77
- subject: cn ? cn.value : '',
78
- notBefore: cert.validity.notBefore,
79
- notAfter: cert.validity.notAfter,
80
- };
75
+ var text = execSync(`openssl x509 -in "${certPath}" -noout -text`, {
76
+ encoding: 'utf-8',
77
+ });
78
+ var issues = [];
79
+ if (!/Basic Constraints:\s*critical/.test(text)) {
80
+ issues.push(
81
+ /Basic Constraints/.test(text) ? 'Basic Constraints 未标 critical' : '缺 Basic Constraints'
82
+ );
83
+ }
84
+ if (!/Subject Key Identifier/.test(text)) issues.push('缺 Subject Key Identifier');
85
+ if (!/Authority Key Identifier/.test(text)) issues.push('缺 Authority Key Identifier');
86
+ return issues;
81
87
  } catch (_e) {
82
88
  return null;
83
89
  }
84
90
  }
85
91
 
92
+ /**
93
+ * 用原私钥重签根证书,补齐严格校验所需的扩展(key 不变,叶子证书链依然有效)。
94
+ * whistle(含 2.10.10)生成的根证书有三个已知缺陷:Basic Constraints 未标
95
+ * critical、缺 SKI、缺 AKI,Python 3.13+ 等严格校验的应用会拒收。
96
+ * @param {string} certPath - whistle 根证书路径(须有同名 root.key)
97
+ * @returns {boolean}
98
+ */
99
+ function fixCertCompliance(certPath) {
100
+ try {
101
+ var forge = require('node-forge');
102
+ var cert = forge.pki.certificateFromPem(fs.readFileSync(certPath, 'utf-8'));
103
+ var keyPath = path.join(path.dirname(certPath), 'root.key');
104
+ var key = forge.pki.privateKeyFromPem(fs.readFileSync(keyPath, 'utf-8'));
105
+ // pem 解析出的私钥可能不带 publicKey 属性,从 n/e 重建
106
+ var pk = key.publicKey || forge.pki.rsa.setPublicKey(key.n, key.e);
107
+ var ski = forge.pki.getPublicKeyFingerprint(pk, {
108
+ type: 'SubjectPublicKeyInfo',
109
+ md: forge.md.sha256.create(),
110
+ encoding: 'binary',
111
+ });
112
+ cert.setExtensions([
113
+ { name: 'basicConstraints', cA: true, critical: true },
114
+ {
115
+ name: 'keyUsage',
116
+ keyCertSign: true,
117
+ digitalSignature: true,
118
+ nonRepudiation: true,
119
+ keyEncipherment: true,
120
+ dataEncipherment: true,
121
+ critical: true,
122
+ },
123
+ { name: 'subjectKeyIdentifier', subjectKeyIdentifier: ski },
124
+ // 自签名 CA:authorityKeyIdentifier 与 subjectKeyIdentifier 相同
125
+ { name: 'authorityKeyIdentifier', authorityKeyIdentifier: ski },
126
+ ]);
127
+ cert.sign(key, forge.md.sha256.create());
128
+ fs.writeFileSync(certPath, forge.pki.certificateToPem(cert));
129
+ // 不删合并 CA 包:重签后 root.crt 的 mtime 更新,ensureMergedCaBundle 的
130
+ // staleness 检查会自动重建(直接 unlink 会让环境变量悬空指向不存在的文件)
131
+ return true;
132
+ } catch (_e) {
133
+ return false;
134
+ }
135
+ }
136
+
86
137
  /**
87
138
  * 检查证书是否已安装到系统信任库
88
- * @param {string} subject - 证书 CN(用于搜索)
139
+ * @param {string} subject - openssl 输出的 subject 行(如 "subject= /CN=whistle.xxx/...")
140
+ * @param {string} certPath - 证书文件路径(macOS 用于精确信任校验)
89
141
  * @returns {boolean}
90
142
  */
91
- function checkTrusted(subject) {
143
+ function checkTrusted(subject, certPath) {
92
144
  if (IS_MACOS) {
93
- // 检查 login keychain 和 system keychain
94
- try {
95
- var keyword = subject ? subject.split('.')[0] : 'whistle';
96
- execSync(`security find-certificate -c "${keyword}" 2>/dev/null`, { encoding: 'utf-8' });
97
- return true;
98
- } catch (_e) {
99
- // 在 login keychain 中未找到,检查 system keychain
145
+ // 精确校验:verify-cert 走真实信任链评估(存在但未被信任也会返回 false)
146
+ if (certPath) {
100
147
  try {
101
- execSync(
102
- 'security find-certificate -c "whistle" /Library/Keychains/System.keychain 2>/dev/null',
103
- { encoding: 'utf-8' }
104
- );
148
+ execSync(`security verify-cert -c "${certPath}" 2>&1`, { encoding: 'utf-8' });
105
149
  return true;
106
- } catch (_e2) {
150
+ } catch (_e) {
107
151
  return false;
108
152
  }
109
153
  }
154
+ // 回退:按 CN 搜索 keychain
155
+ try {
156
+ var cn = (subject.match(/CN=([^/]+)/) || [])[1] || 'whistle';
157
+ execSync(`security find-certificate -c "${cn}" 2>/dev/null`, { encoding: 'utf-8' });
158
+ return true;
159
+ } catch (_e) {
160
+ return false;
161
+ }
110
162
  }
111
163
  if (IS_WINDOWS) {
112
164
  try {
@@ -119,6 +171,131 @@ function checkTrusted(subject) {
119
171
  return false;
120
172
  }
121
173
 
174
+ // 项目配置目录:~/.easy-proxy/(合并 CA 文件等)
175
+ const EASY_PROXY_DIR = path.join(HOME, '.easy-proxy');
176
+ const MERGED_CA_PATH = path.join(EASY_PROXY_DIR, 'cert', 'ca-bundle.pem');
177
+
178
+ /**
179
+ * 生成合并 CA 文件(系统 CA + whistle 根证书),供 Python/Node 等不走系统
180
+ * 信任库的应用使用。源文件比合并文件新(系统 CA 更新 / whistle 证书重签)
181
+ * 则重建。已导出的环境变量指向固定路径,文件重建后依然有效。
182
+ * @param {string} certPath - whistle 根证书路径
183
+ * @returns {string|null} 合并文件路径,失败返回 null
184
+ */
185
+ function ensureMergedCaBundle(certPath) {
186
+ var merged = MERGED_CA_PATH;
187
+ try {
188
+ var sources = [certPath];
189
+ if (IS_MACOS) {
190
+ // /etc/ssl/cert.pem 是系统自带的 CA 包,直接复用
191
+ sources.push('/etc/ssl/cert.pem');
192
+ } else if (IS_WINDOWS) {
193
+ // certifi 的 CA 包(requests/httpx 都带 certifi)
194
+ sources.push(
195
+ execSync('python -c "import certifi; print(certifi.where())"', {
196
+ encoding: 'utf-8',
197
+ windowsHide: true,
198
+ }).trim()
199
+ );
200
+ } else {
201
+ sources.push('/etc/ssl/certs/ca-certificates.crt');
202
+ }
203
+ // 任一源文件比合并文件新(系统 CA 更新 / whistle 证书重签)则重建
204
+ if (fs.existsSync(merged)) {
205
+ var mergedMtime = fs.statSync(merged).mtimeMs;
206
+ var stale = sources.some(function (src) {
207
+ try {
208
+ return fs.statSync(src).mtimeMs > mergedMtime;
209
+ } catch (_e) {
210
+ return true; // 源文件读不到,视为过期
211
+ }
212
+ });
213
+ if (!stale) return merged;
214
+ }
215
+ var base = fs.readFileSync(sources[1], 'utf-8');
216
+ // whistle 生成的 root.crt 是 CRLF 行尾,Node/NODE_EXTRA_CA_CERTS 加载器会拒收混入的 CRLF 证书
217
+ var whistlePem = fs.readFileSync(certPath, 'utf-8').replace(/\r/g, '');
218
+ fs.mkdirSync(path.dirname(merged), { recursive: true });
219
+ // 原子写:先写临时文件再 rename,避免写一半崩溃留下坏文件
220
+ var tmp = merged + '.tmp-' + process.pid;
221
+ fs.writeFileSync(tmp, base + '\n' + whistlePem);
222
+ fs.renameSync(tmp, merged);
223
+ return merged;
224
+ } catch (_e) {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * 提示 Python/Node 等不走系统信任库的应用所需的 CA 环境变量。
231
+ * whistle 根证书装进系统钥匙串后浏览器正常,但 Python(certifi)、Node(内置 CA)
232
+ * 不读系统信任库,仍会报证书异常,需设置环境变量指向合并 CA 文件。
233
+ */
234
+ var APP_CA_VARS = [
235
+ ['REQUESTS_CA_BUNDLE', 'Python requests/httpx'],
236
+ ['SSL_CERT_FILE', 'Python 原生 ssl/urllib'],
237
+ ['NODE_EXTRA_CA_CERTS', 'Node.js'],
238
+ ];
239
+
240
+ function printAppCaHints(certPath) {
241
+ var needs = APP_CA_VARS.filter(function (n) {
242
+ return !process.env[n[0]];
243
+ });
244
+ if (!needs.length) return;
245
+
246
+ var merged = ensureMergedCaBundle(certPath);
247
+ if (!merged) return;
248
+ console.log(
249
+ `${c.yellow}⚠ Python/Node 等不读系统信任库,需设置以下环境变量(建议写入 ~/.zshrc):${c.reset}`
250
+ );
251
+ needs.forEach(function (n) {
252
+ console.log(` export ${n[0]}=${merged}${c.gray} # ${n[1]}${c.reset}`);
253
+ });
254
+ }
255
+
256
+ /**
257
+ * 将 Python/Node 所需的 CA 环境变量写入 ~/.zshrc(幂等)。
258
+ * 变量已存在且指向合并文件则跳过;指向其他路径则视为用户自行配置,仅提示不覆盖。
259
+ * @param {string} merged - 合并 CA 文件路径
260
+ * @returns {{added: string[], conflicts: string[]}|null} 新增与冲突的变量名,失败返回 null
261
+ */
262
+ function writeShellExports(merged) {
263
+ if (IS_WINDOWS) return null;
264
+ var zshrc = path.join(HOME, '.zshrc');
265
+ try {
266
+ var content = fs.existsSync(zshrc) ? fs.readFileSync(zshrc, 'utf-8') : '';
267
+ var added = [];
268
+ var conflicts = [];
269
+ // 需要写入的变量 = zshrc 里还没定义的
270
+ APP_CA_VARS.forEach(function (n) {
271
+ var lineRe = new RegExp('^\\s*(export\\s+)?' + n[0] + '=', 'm');
272
+ if (!lineRe.test(content)) added.push(n);
273
+ else if (content.indexOf(n[0] + '=' + merged) === -1) conflicts.push(n[0]);
274
+ });
275
+ if (added.length) {
276
+ // 包一层 -f 检查:证书/合并文件被清理后,环境变量自动失效,不会指向不存在的文件
277
+ var block =
278
+ '\n# easy-proxy: whistle MITM CA(由 easy-proxy start 维护,证书缺失时自动跳过)\n' +
279
+ 'if [ -f "' +
280
+ merged +
281
+ '" ]; then\n';
282
+ added.forEach(function (n) {
283
+ block += ' export ' + n[0] + '=' + merged + ' # ' + n[1] + '\n';
284
+ });
285
+ block += 'fi\n';
286
+ fs.appendFileSync(zshrc, block);
287
+ }
288
+ return {
289
+ added: added.map(function (n) {
290
+ return n[0];
291
+ }),
292
+ conflicts: conflicts,
293
+ };
294
+ } catch (_e) {
295
+ return null;
296
+ }
297
+ }
298
+
122
299
  // ANSI 颜色(与 bin CLI 一致)
123
300
  var c = {
124
301
  reset: '\x1b[0m',
@@ -133,21 +310,42 @@ var c = {
133
310
  /**
134
311
  * 检查证书状态并输出
135
312
  * @param {boolean} [compact] - 紧凑模式(用于 health 命令,不输出标题)
313
+ * @param {boolean} [autoInstall] - 自动管理模式(start 用):未信任时自动安装
136
314
  * @returns {Promise<void>}
137
315
  */
138
- async function checkCertStatus(compact) {
316
+ async function checkCertStatus(compact, autoInstall) {
139
317
  var certPath = findCertFile();
140
318
  if (!certPath) {
141
319
  console.log(`${(compact ? ' ' : '') + c.red}✖${c.reset} 证书: 未找到(whistle 可能未启动过)`);
142
320
  if (!compact) {
143
321
  console.log(`${c.gray} 运行 easy-proxy start 启动 whistle(会自动生成证书)${c.reset}`);
144
- console.log(`${c.gray} 然后运行 easy-proxy cert install 安装证书到系统信任库${c.reset}`);
322
+ console.log(`${c.gray} 然后运行 easy-proxy start 安装证书到系统信任库${c.reset}`);
145
323
  }
146
324
  return;
147
325
  }
148
326
 
327
+ // 合并 CA 文件可能被删除(证书更新/清理时),静默重建,
328
+ // 避免已导出的 REQUESTS_CA_BUNDLE 等环境变量指向不存在的文件
329
+ ensureMergedCaBundle(certPath);
330
+
331
+ // 合规校验(Python 3.13+/OpenSSL 3 会拒收不合规的 CA 证书),自动修复后重新解析
332
+ // 先修复合规性(重签会作废合并文件),合并文件在重签后下方重建
333
+ var issues = checkCertCompliance(certPath);
334
+ if (issues && issues.length) {
335
+ if (fixCertCompliance(certPath)) {
336
+ ensureMergedCaBundle(certPath);
337
+ if (!compact) {
338
+ console.log(`${c.yellow}🔧${c.reset} 证书: 已重签修复(${issues.join('、')})`);
339
+ }
340
+ } else {
341
+ console.log(
342
+ `${(compact ? ' ' : '') + c.yellow}⚠${c.reset} 证书: 不合规(${issues.join('、')}),自动修复失败`
343
+ );
344
+ }
345
+ }
346
+
149
347
  // 解析有效期
150
- var info = parseCertWithOpenssl(certPath) || parseCertWithForge(certPath);
348
+ var info = parseCertWithOpenssl(certPath);
151
349
  if (!info) {
152
350
  console.log(`${(compact ? ' ' : '') + c.yellow}⚠${c.reset} 证书: 已存在(无法解析有效期)`);
153
351
  console.log(`${c.gray} 路径: ${certPath}${c.reset}`);
@@ -156,9 +354,26 @@ async function checkCertStatus(compact) {
156
354
 
157
355
  var now = new Date();
158
356
  var daysLeft = Math.floor((info.notAfter.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
159
- var trusted = checkTrusted(info.subject);
357
+ var trusted = checkTrusted(info.subject, certPath);
160
358
  var dateStr = info.notAfter.toISOString().slice(0, 10);
161
359
 
360
+ // 自动管理模式(start):未信任则用 whistle 内置安装器装进系统信任库 + 写 zshrc 环境变量
361
+ if (autoInstall && !trusted) {
362
+ try {
363
+ var installRootCA = require('whistle/bin/ca');
364
+ installRootCA(certPath);
365
+ var merged = ensureMergedCaBundle(certPath);
366
+ if (merged && writeShellExports(merged)) {
367
+ console.log(`${c.green}✔${c.reset} 根证书已安装,CA 环境变量已写入 ~/.zshrc(新终端生效)`);
368
+ }
369
+ trusted = checkTrusted(info.subject, certPath);
370
+ } catch (e) {
371
+ console.log(
372
+ `${c.yellow}⚠${c.reset} 根证书未信任,自动安装失败: ${e.message}(可运行 easy-proxy w2 ca 手动安装)`
373
+ );
374
+ }
375
+ }
376
+
162
377
  // 状态判断
163
378
  var status, color;
164
379
  if (daysLeft <= 0) {
@@ -200,12 +415,22 @@ async function checkCertStatus(compact) {
200
415
  console.log(`${c.blue}ℹ${c.reset} 颁发对象: ${c.gray}${info.subject}${c.reset}`);
201
416
  }
202
417
  console.log(c.gray + '─'.repeat(40) + c.reset);
418
+ printAppCaHints(certPath);
203
419
  if (daysLeft <= 0) {
204
- console.log(`${c.gray} 运行 easy-proxy cert install 重新安装证书${c.reset}`);
420
+ console.log(`${c.gray} 运行 easy-proxy start 重新安装证书${c.reset}`);
205
421
  } else if (!trusted) {
206
- console.log(`${c.gray} 运行 easy-proxy cert install 安装证书到系统信任库${c.reset}`);
422
+ console.log(`${c.gray} 运行 easy-proxy start 安装证书到系统信任库${c.reset}`);
207
423
  }
208
424
  }
209
425
  }
210
426
 
211
- module.exports = { checkCertStatus, findCertFile, parseCertWithOpenssl };
427
+ module.exports = {
428
+ checkCertStatus,
429
+ findCertFile,
430
+ parseCertWithOpenssl,
431
+ checkCertCompliance,
432
+ fixCertCompliance,
433
+ ensureMergedCaBundle,
434
+ writeShellExports,
435
+ checkTrusted,
436
+ };
@@ -72,9 +72,7 @@ function registerAccel(program) {
72
72
  title('加速状态');
73
73
  sep();
74
74
  info(
75
- '加速功能: ' +
76
- (cfg.enabled ? `${c.green}✓ 已启用` : `${c.red}✗ 已关闭`) +
77
- c.reset
75
+ '加速功能: ' + (cfg.enabled ? `${c.green}✓ 已启用` : `${c.red}✗ 已关闭`) + c.reset
78
76
  );
79
77
  info(
80
78
  'SNI 改写: ' +
@@ -3,12 +3,12 @@ const { registerAccel } = require('./accel');
3
3
  const { registerHost } = require('./host');
4
4
  const { registerRule } = require('./rule');
5
5
  const { registerProxy } = require('./proxy');
6
- const { registerCert } = require('./cert');
7
6
  const { registerHealth } = require('./health');
8
7
  const { registerStatus } = require('./status');
9
8
  const { registerConfig } = require('./config-cmd');
10
9
  const { registerIntercept } = require('./intercept');
11
10
  const { registerNetwork } = require('./network');
11
+ const { registerMirror } = require('./mirror');
12
12
 
13
13
  function registerAllCommands(program) {
14
14
  registerStart(program);
@@ -18,12 +18,12 @@ function registerAllCommands(program) {
18
18
  registerHost(program);
19
19
  registerRule(program);
20
20
  registerProxy(program);
21
- registerCert(program);
22
21
  registerHealth(program);
23
22
  registerStatus(program);
24
23
  registerConfig(program);
25
24
  registerIntercept(program);
26
25
  registerNetwork(program);
26
+ registerMirror(program);
27
27
  }
28
28
 
29
29
  module.exports = {
@@ -0,0 +1,101 @@
1
+ const { readConfig, writeConfig, c, info, ok, err, title, sep } = require('../helpers');
2
+
3
+ // 镜像开关状态存在主配置 config.json 的 mirrors 字段: { npm: true, pip: false, ... }
4
+ function loadMirrorState() {
5
+ const cfg = readConfig();
6
+ return cfg.mirrors || {};
7
+ }
8
+
9
+ function saveMirrorState(state) {
10
+ const cfg = readConfig();
11
+ cfg.mirrors = state;
12
+ writeConfig(cfg);
13
+ }
14
+
15
+ function registerMirror(program) {
16
+ const mirrorCmd = program.command('mirror').description('开发镜像源加速(npm/pip/go 等,302 重定向到国内镜像)');
17
+
18
+ mirrorCmd
19
+ .command('status')
20
+ .description('查看镜像源状态')
21
+ .action(() => {
22
+ const { MIRROR_RULESETS } = require('../mirror-rulesets');
23
+ const state = loadMirrorState();
24
+ title('镜像源加速');
25
+ sep();
26
+ for (const name of Object.keys(MIRROR_RULESETS)) {
27
+ const on = state[name] === true;
28
+ info(`${on ? `${c.green}✓ 开启` : `${c.red}✗ 关闭`}${c.reset} ${name}`);
29
+ }
30
+ sep();
31
+ info('开关立即同步到 whistle(需 whistle 运行中)');
32
+ });
33
+
34
+ mirrorCmd
35
+ .command('list')
36
+ .description('查看镜像规则内容')
37
+ .action(() => {
38
+ const { MIRROR_RULESETS } = require('../mirror-rulesets');
39
+ title('内置镜像规则');
40
+ sep();
41
+ for (const [name, rule] of Object.entries(MIRROR_RULESETS)) {
42
+ info(`${c.bold}${name}${c.reset}`);
43
+ rule
44
+ .split('\n')
45
+ .filter((l) => !l.startsWith('#'))
46
+ .forEach((l) => info(` ${l}`));
47
+ }
48
+ sep();
49
+ });
50
+
51
+ mirrorCmd
52
+ .command('on [name]')
53
+ .description('启用镜像(无参数=全部启用)')
54
+ .action(async (name) => {
55
+ const { MIRROR_RULESETS } = require('../mirror-rulesets');
56
+ if (name && !MIRROR_RULESETS[name]) {
57
+ err(`未知镜像: ${name}(可用: ${Object.keys(MIRROR_RULESETS).join(', ')})`);
58
+ return;
59
+ }
60
+ const state = loadMirrorState();
61
+ if (name) state[name] = true;
62
+ else for (const k of Object.keys(MIRROR_RULESETS)) state[k] = true;
63
+ saveMirrorState(state);
64
+
65
+ const { syncMirrors } = require('../mirror-sync');
66
+ try {
67
+ await syncMirrors();
68
+ ok(name ? `镜像 ${name} 已开启` : '所有镜像已开启');
69
+ } catch (e) {
70
+ info('whistle 未运行,配置已保存,下次 start 自动生效');
71
+ }
72
+ });
73
+
74
+ mirrorCmd
75
+ .command('off [name]')
76
+ .description('关闭镜像(无参数=全部关闭)')
77
+ .action(async (name) => {
78
+ const { MIRROR_RULESETS } = require('../mirror-rulesets');
79
+ if (name && !MIRROR_RULESETS[name]) {
80
+ err(`未知镜像: ${name}(可用: ${Object.keys(MIRROR_RULESETS).join(', ')})`);
81
+ return;
82
+ }
83
+ const state = loadMirrorState();
84
+ if (name) delete state[name];
85
+ else for (const k of Object.keys(MIRROR_RULESETS)) delete state[k];
86
+ saveMirrorState(state);
87
+
88
+ const { syncMirrors } = require('../mirror-sync');
89
+ try {
90
+ await syncMirrors();
91
+ ok(name ? `镜像 ${name} 已关闭` : '所有镜像已关闭');
92
+ } catch (_e) {
93
+ info('whistle 未运行,配置已保存,下次 start 自动生效');
94
+ }
95
+ });
96
+ }
97
+
98
+ module.exports = {
99
+ registerMirror,
100
+ loadMirrorState,
101
+ };
@@ -1,4 +1,15 @@
1
- const { localW2Path, getWhistleStatus, info, ok, warn, err, title, sep, c, editInEditor } = require('../helpers');
1
+ const {
2
+ localW2Path,
3
+ getWhistleStatus,
4
+ info,
5
+ ok,
6
+ warn,
7
+ err,
8
+ title,
9
+ sep,
10
+ c,
11
+ editInEditor,
12
+ } = require('../helpers');
2
13
 
3
14
  function enableSystemProxy() {
4
15
  const { execSync } = require('node:child_process');
@@ -142,7 +153,14 @@ function registerProxy(program) {
142
153
  info('未配置代理');
143
154
  } else {
144
155
  proxies.forEach((p) => {
145
- info(`${p.name}: ${p.url}`);
156
+ const state = p.disabled ? `${c.yellow}(已停用)${c.reset}` : '';
157
+ const addr = p.disabled ? `# ${p.url}` : p.url;
158
+ info(`${p.name}: ${addr} ${state}`);
159
+ if (p.disabledUrls && p.disabledUrls.length > 0) {
160
+ p.disabledUrls.forEach((u) => {
161
+ info(` # ${u}`);
162
+ });
163
+ }
146
164
  if (p.domains.length > 0) {
147
165
  p.domains.forEach((d) => {
148
166
  info(` ${d}`);
@@ -176,98 +194,65 @@ function registerProxy(program) {
176
194
  });
177
195
 
178
196
  // ---- proxy bypass ----
179
- const bypassCmd = proxyCmd.command('bypass').description('管理跳过代理的域名');
180
-
181
- bypassCmd
182
- .command('show')
183
- .description('查看当前跳过列表')
197
+ // 一行一个域名,# 注释;全删 = 恢复默认列表
198
+ const bypassCmd = proxyCmd
199
+ .command('bypass')
200
+ .description('编辑跳过代理的域名列表(打开 $EDITOR,一行一个域名)')
184
201
  .action(() => {
185
202
  const proxyConfig = require('../proxy-config');
186
- const config = proxyConfig.loadConfig();
187
- const domains = config.bypassDomains || proxyConfig.getDefaultBypass();
188
- title('跳过代理的域名');
189
- sep();
190
- if (domains.length === 0) {
191
- info('(空)');
192
- } else {
193
- domains.forEach((d) => {
194
- info(` ${d}`);
195
- });
196
- }
197
- if (!config.bypassDomains) {
198
- info(`${c.gray}(使用默认列表)${c.reset}`);
199
- }
200
- sep();
201
- });
203
+ const oldList = proxyConfig.getEffectiveBypass();
202
204
 
203
- bypassCmd
204
- .command('set <domains>')
205
- .description('设置跳过列表(逗号分隔,如: 127.0.0.1,localhost,*.local)')
206
- .action((domains) => {
207
- const proxyConfig = require('../proxy-config');
208
- const list = domains
209
- .split(',')
210
- .map((d) => d.trim())
211
- .filter(Boolean);
212
- if (list.length === 0) {
213
- err('请提供至少一个域名');
214
- return;
215
- }
216
- proxyConfig.saveConfig({ bypassDomains: list });
217
- ok(`已设置跳过列表: ${list.join(', ')}`);
218
- info('重新开启系统代理后生效: easy-proxy proxy on');
219
- });
205
+ const buildContent = (domains) =>
206
+ [
207
+ '# 跳过代理的域名(一行一个,# 开头为注释)',
208
+ '# 清空所有域名 = 恢复默认列表;保存后需重新 proxy on 生效',
209
+ '',
210
+ ...domains,
211
+ ].join('\n');
220
212
 
221
- bypassCmd
222
- .command('add <domain>')
223
- .description('添加一个跳过域名')
224
- .action((domain) => {
225
- const proxyConfig = require('../proxy-config');
226
- const config = proxyConfig.loadConfig();
227
- const list = config.bypassDomains
228
- ? config.bypassDomains.slice()
229
- : proxyConfig.getDefaultBypass().slice();
230
- if (list.indexOf(domain) >= 0) {
231
- warn(`已存在: ${domain}`);
232
- return;
213
+ let content = buildContent(oldList);
214
+ let newList;
215
+ while (true) {
216
+ let editedText;
217
+ try {
218
+ editedText = editInEditor(content, 'bypass');
219
+ } catch (_e) {
220
+ // 编辑器非零退出码(如 vi :q!)视为用户取消
221
+ info('已取消');
222
+ return;
223
+ }
224
+ const list = [];
225
+ const seen = new Set();
226
+ for (const raw of editedText.replace(/\r/g, '').split('\n')) {
227
+ const line = raw.trim();
228
+ if (!line || line.startsWith('#')) continue;
229
+ const key = line.toLowerCase();
230
+ if (seen.has(key)) continue;
231
+ seen.add(key);
232
+ list.push(line);
233
+ }
234
+ // 清空 = 恢复默认列表
235
+ if (list.length === 0) {
236
+ proxyConfig.saveConfig({ bypassDomains: null });
237
+ ok(`已清空,恢复默认列表(${proxyConfig.getEffectiveBypass().length} 个域名)`);
238
+ return;
239
+ }
240
+ if (list.some((d) => /^https?:\/\//i.test(d))) {
241
+ err('域名不应包含 http:// 或 https:// 前缀,请修正后重新保存...');
242
+ content = editedText;
243
+ continue;
244
+ }
245
+ newList = list;
246
+ break;
233
247
  }
234
- list.push(domain);
235
- proxyConfig.saveConfig({ bypassDomains: list });
236
- ok(`已添加: ${domain}`);
237
- info('重新开启系统代理后生效: easy-proxy proxy on');
238
- });
239
248
 
240
- bypassCmd
241
- .command('remove <domain>')
242
- .description('移除一个跳过域名')
243
- .action((domain) => {
244
- const proxyConfig = require('../proxy-config');
245
- const config = proxyConfig.loadConfig();
246
- if (!config.bypassDomains) {
247
- warn('未自定义跳过列表,当前使用默认列表');
248
- return;
249
- }
250
- const index = config.bypassDomains.indexOf(domain);
251
- if (index < 0) {
252
- warn(`未找到: ${domain}`);
249
+ if (JSON.stringify(oldList) === JSON.stringify(newList)) {
250
+ info('无改动');
253
251
  return;
254
252
  }
255
- config.bypassDomains.splice(index, 1);
256
- proxyConfig.saveConfig({ bypassDomains: config.bypassDomains });
257
- ok(`已移除: ${domain}`);
258
- info('重新开启系统代理后生效: easy-proxy proxy on');
259
- });
260
253
 
261
- bypassCmd
262
- .command('reset')
263
- .description('重置为默认跳过列表')
264
- .action(() => {
265
- const proxyConfig = require('../proxy-config');
266
- proxyConfig.saveConfig({ bypassDomains: null });
267
- ok('已重置为默认列表');
268
- proxyConfig.getDefaultBypass().forEach((d) => {
269
- info(` ${d}`);
270
- });
254
+ proxyConfig.saveConfig({ bypassDomains: newList });
255
+ ok(`已保存跳过列表(${newList.length} 个域名)`);
271
256
  info('重新开启系统代理后生效: easy-proxy proxy on');
272
257
  });
273
258
  }
@@ -4,13 +4,19 @@ const { enableSystemProxy, disableSystemProxy } = require('./proxy');
4
4
  function registerStart(program) {
5
5
  program
6
6
  .command('start')
7
- .description('启动 whistle(自动设置代理和安装证书)')
7
+ .description('启动 whistle(自动设置代理和管理证书)')
8
8
  .option('--init', '初始化(安装证书 + 设置代理)')
9
9
  .action(async (opts) => {
10
10
  const { execSync } = require('node:child_process');
11
11
  try {
12
+ // NODE_EXTRA_CA_CERTS 可能指向被删的合并文件,先自愈再调 w2(Node 进程启动时加载)
13
+ const { findCertFile, ensureMergedCaBundle, checkCertStatus } = require('../../cert-check');
14
+ const certPath = findCertFile();
15
+ if (certPath) ensureMergedCaBundle(certPath);
16
+
12
17
  const args = opts.init ? 'start --init' : 'start';
13
- execSync(`"${localW2Path}" ${args}`, { stdio: 'inherit' });
18
+ // --dnsServer 绕过系统 DNS(ISP DNS 可能被污染,如 nodejs.org 解析到假 IP 导致 502 socket hang up)
19
+ execSync(`"${localW2Path}" ${args} --dnsServer 223.5.5.5`, { stdio: 'inherit' });
14
20
  ok('whistle 已启动');
15
21
 
16
22
  try {
@@ -19,6 +25,20 @@ function registerStart(program) {
19
25
  err(`开启系统代理失败: ${e.message}`);
20
26
  }
21
27
 
28
+ // 证书自动管理:whistle 异步生成证书(RSA 密钥生成需数秒),轮询等待后自愈
29
+ let newCertPath = findCertFile();
30
+ if (!newCertPath) {
31
+ for (let i = 0; i < 10 && !newCertPath; i++) {
32
+ await new Promise((r) => setTimeout(r, 1000));
33
+ newCertPath = findCertFile();
34
+ }
35
+ }
36
+ if (newCertPath) {
37
+ await checkCertStatus(true, true);
38
+ } else {
39
+ info('证书: 未找到(whistle 生成超时,下次启动自动重试)');
40
+ }
41
+
22
42
  const upstream = require('../upstream-proxy');
23
43
  const proxies = upstream.loadProxies();
24
44
  if (proxies.length > 0) {
@@ -47,6 +67,15 @@ function registerStart(program) {
47
67
  }
48
68
  if (count > 0) ok(`已同步 ${count} 个自定义规则`);
49
69
  }
70
+
71
+ // 内置镜像规则集(按开关状态同步,默认 npm/pip/go 开启)
72
+ try {
73
+ const { syncMirrors } = require('../mirror-sync');
74
+ const { added } = await syncMirrors();
75
+ if (added.length > 0) ok(`已同步 ${added.length} 个镜像规则集`);
76
+ } catch (e) {
77
+ debug('Mirror sync failed:', e.message);
78
+ }
50
79
  } catch (e) {
51
80
  err(`启动 whistle 失败: ${e.message}`);
52
81
  process.exit(1);
@@ -17,9 +17,7 @@ function registerStatus(program) {
17
17
  (ws.running ? `${c.green}✓ 运行中${c.reset}` : `${c.red}✗ 未运行${c.reset}`) +
18
18
  (ws.port ? `(端口: ${ws.port})` : '')
19
19
  );
20
- info(
21
- `加速功能: ${cfg.enabled ? `${c.green}✓ 已启用` : `${c.red}✗ 已关闭`}${c.reset}`
22
- );
20
+ info(`加速功能: ${cfg.enabled ? `${c.green}✓ 已启用` : `${c.red}✗ 已关闭`}${c.reset}`);
23
21
  info(
24
22
  '拦截规则: ' +
25
23
  (interceptOn ? `${c.green}✓ 已启用` : `${c.red}✗ 已关闭`) +
@@ -41,7 +39,7 @@ function registerStatus(program) {
41
39
  info('常用命令: easy-proxy start --init (启动 whistle + 代理 + 证书)');
42
40
  info(' easy-proxy intercept list (查看拦截规则)');
43
41
  info(' easy-proxy proxy on / easy-proxy proxy off (切换系统代理)');
44
- info(' easy-proxy cert install (安装证书)');
42
+ info(' easy-proxy start (安装证书)');
45
43
  });
46
44
  }
47
45
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 内置镜像源规则集
3
+ * 每个 Key 是一个规则集名(easy-proxy mirror on/off <name>),value 是 whistle 规则文本。
4
+ *
5
+ * 原理:whistle 302 重定向。工具仍请求官方源,whistle 把请求重定向到国内镜像,
6
+ * 包管理器自动跟随重定向,无需改 .npmrc / pip.conf / GOPROXY 等工具配置。
7
+ *
8
+ * 已实测验证的链路:npm(302→npmmirror→tarball 下载成功)、pip(simple 页 + files.pythonhosted
9
+ * 文件下载成功)、go(proxy.golang.org → goproxy.cn)。Homebrew 的 formulae.brew.sh
10
+ * 重定向到清华镜像目录结构对不上,且 brew 本身走 GitHub(由 GitHub 加速覆盖),不内置。
11
+ */
12
+
13
+ const MIRROR_RULESETS = {
14
+ npm: [
15
+ '# npm registry → npmmirror(npmpack/tarball 均跟随 302)',
16
+ '# 仅重定向 GET:npm publish(PUT/POST)直达官方源,不发镜像',
17
+ '^registry.npmjs.org/*** redirect://https://registry.npmmirror.com/$1 includeFilter://m:get',
18
+ '^registry.yarnpkg.com/*** redirect://https://registry.npmmirror.com/$1 includeFilter://m:get',
19
+ ].join('\n'),
20
+
21
+ pip: [
22
+ '# pip → 清华 TUNA 镜像',
23
+ '# index 页(/simple/)和包文件(files.pythonhosted.org/packages/...)都要重定向,',
24
+ '# 否则 index 能拿到但下载 whl 还是走官方源',
25
+ '^pypi.org/simple/*** redirect://https://pypi.tuna.tsinghua.edu.cn/simple/$1',
26
+ '^files.pythonhosted.org/*** redirect://https://pypi.tuna.tsinghua.edu.cn/packages/$1',
27
+ ].join('\n'),
28
+
29
+ go: [
30
+ '# Go module proxy → goproxy.cn',
31
+ '^proxy.golang.org/*** redirect://https://goproxy.cn/$1',
32
+ ].join('\n'),
33
+
34
+ node: [
35
+ '# Node.js 二进制分发 → 清华镜像(fnm/nvm/n/volta 通用,工具自动跟随 302,无需设 FNM_NODE_DIST_MIRROR)',
36
+ '^nodejs.org/dist/*** redirect://https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/$1',
37
+ ].join('\n'),
38
+
39
+ conda: [
40
+ '# conda → 清华 anaconda 镜像',
41
+ '# 需配合工具配置 conda config --add channels 后再用,纯重定向只覆盖 repo.anaconda.com 主源',
42
+ '^repo.anaconda.com/pkgs/main/*** redirect://https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/$1',
43
+ '^conda.anaconda.org/*** redirect://https://mirrors.tuna.tsinghua.edu.cn/anaconda-cloud/$1',
44
+ ].join('\n'),
45
+
46
+ huggingface: [
47
+ '# HuggingFace → hf-mirror.com(模型/数据集下载)',
48
+ '^huggingface.co/*** redirect://https://hf-mirror.com/$1',
49
+ ].join('\n'),
50
+ };
51
+
52
+ // 默认启用的镜像规则集(easy-proxy start 同步时自动 select)
53
+ const DEFAULT_ENABLED = ['npm', 'pip', 'go'];
54
+
55
+ module.exports = {
56
+ MIRROR_RULESETS,
57
+ DEFAULT_ENABLED,
58
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 镜像规则集 → whistle 同步
3
+ * 规则集名统一加 "mirror-" 前缀放独立分组,关闭 = 从 whistle 删除规则集,
4
+ * 不污染用户的自定义规则空间。
5
+ */
6
+
7
+ const MIRROR_GROUP = '\rEasyProxy 镜像加速';
8
+ const RULESET_PREFIX = 'mirror-';
9
+
10
+ /**
11
+ * 把当前开关状态同步到 whistle(规则集存在性 = 开关状态)
12
+ * @returns {Promise<{added: string[], removed: string[]}>}
13
+ */
14
+ async function syncMirrors() {
15
+ const api = require('./rule-api');
16
+ const { MIRROR_RULESETS } = require('./mirror-rulesets');
17
+ const { loadMirrorState } = require('./commands/mirror');
18
+ const state = loadMirrorState();
19
+
20
+ await api.createGroup(MIRROR_GROUP);
21
+ const added = [];
22
+ const removed = [];
23
+
24
+ for (const [name, rule] of Object.entries(MIRROR_RULESETS)) {
25
+ const rulesetName = RULESET_PREFIX + name;
26
+ if (state[name] === true) {
27
+ await api.addRuleset(rulesetName, rule, MIRROR_GROUP);
28
+ await api.selectRuleset(rulesetName, rule);
29
+ added.push(rulesetName);
30
+ } else {
31
+ // 关闭 = 删除规则集(不存在时 remove 返回错误,忽略)
32
+ try {
33
+ await api.removeRuleset(rulesetName);
34
+ removed.push(rulesetName);
35
+ } catch (_e) {
36
+ // 本来就没开,跳过
37
+ }
38
+ }
39
+ }
40
+
41
+ return { added, removed };
42
+ }
43
+
44
+ module.exports = {
45
+ syncMirrors,
46
+ MIRROR_GROUP,
47
+ RULESET_PREFIX,
48
+ };
@@ -28,14 +28,9 @@ function getEffectiveBypass() {
28
28
  return bypassDomains?.length > 0 ? bypassDomains : DEFAULT_BYPASS_DOMAINS;
29
29
  }
30
30
 
31
- function getDefaultBypass() {
32
- return DEFAULT_BYPASS_DOMAINS;
33
- }
34
-
35
31
  module.exports = {
36
32
  DEFAULT_BYPASS_DOMAINS,
37
33
  loadConfig: loadProxyConfig,
38
34
  saveConfig: saveProxyConfig,
39
35
  getEffectiveBypass,
40
- getDefaultBypass,
41
36
  };
@@ -6,6 +6,7 @@
6
6
  * - 每个代理占一块,第1行=名称,第2行=URL,第3行起=域名列表
7
7
  * - 多个代理之间用空行分隔
8
8
  * - 以 # 开头的行是注释(整行),会被忽略
9
+ * - URL 行前加 # 注释(如 `# http://...`)可停用该代理,块结构保留,重新编辑时仍显示
9
10
  * - 支持 \r\n 和 \n 换行
10
11
  */
11
12
 
@@ -30,19 +31,18 @@ function dedupeDomains(domains) {
30
31
  }
31
32
 
32
33
  /**
33
- * 将文本分割为「原始行号 + 内容」的数组,同时处理 \r\n 和注释过滤
34
+ * 将文本分割为「原始行号 + 内容 + 是否注释」的数组,同时处理 \r\n
35
+ * 保留注释行(标记 isComment),用于识别「注释掉的代理地址」来停用代理
34
36
  * 保留原始行号用于错误提示
35
37
  * @param {string} text
36
- * @returns {Array<{lineNo: number, content: string}>}
38
+ * @returns {Array<{lineNo: number, content: string, isComment: boolean}>}
37
39
  */
38
40
  function splitLinesWithNumbers(text) {
39
41
  const rawLines = text.replace(/\r/g, '').split('\n');
40
42
  const result = [];
41
43
  for (let i = 0; i < rawLines.length; i++) {
42
- const line = rawLines[i].trim();
43
- // 过滤整行注释
44
- if (line.startsWith('#')) continue;
45
- result.push({ lineNo: i + 1, content: line });
44
+ const content = rawLines[i].trim();
45
+ result.push({ lineNo: i + 1, content, isComment: content.startsWith('#') });
46
46
  }
47
47
  return result;
48
48
  }
@@ -52,7 +52,7 @@ function splitLinesWithNumbers(text) {
52
52
  /**
53
53
  * 解析文本格式的上游代理配置
54
54
  * @param {string} text - 编辑器中的文本内容
55
- * @returns {Array<{name: string, url: string, domains: string[]}>}
55
+ * @returns {Array<{name: string, url: string, domains: string[], disabled?: boolean}>}
56
56
  * @throws {Error} 格式错误时抛出,错误信息包含具体位置和原因
57
57
  */
58
58
  function parse(text) {
@@ -63,43 +63,59 @@ function parse(text) {
63
63
  let i = 0;
64
64
 
65
65
  while (i < lines.length) {
66
- // 跳过连续空行
67
- if (lines[i].content === '') {
66
+ // 跳过连续空行和注释行(块开始前的注释)
67
+ while (i < lines.length && (lines[i].content === '' || lines[i].isComment)) {
68
68
  i++;
69
- continue;
70
69
  }
70
+ if (i >= lines.length) break;
71
71
 
72
72
  // 第 1 行:代理名称
73
73
  const nameLine = lines[i];
74
74
  const name = nameLine.content;
75
- if (!name) {
76
- throw new Error(`第 ${nameLine.lineNo} 行: 代理名称不能为空`);
77
- }
78
75
  i++;
79
76
 
80
- // 第 2 行:代理 URL
81
- if (i >= lines.length || lines[i].content === '') {
82
- throw new Error(`第 ${nameLine.lineNo} 行: 代理 "${name}" 缺少 URL`);
83
- }
84
- const urlLine = lines[i];
85
- const url = urlLine.content;
86
- if (!/^https?:\/\//i.test(url)) {
87
- throw new Error(`第 ${urlLine.lineNo} 行: 代理 URL 必须以 http:// 或 https:// 开头`);
88
- }
89
- i++;
90
-
91
- // 第 3 行起:域名列表(直到空行或结尾)
77
+ // 第 2 行起:地址行 + 域名行(直到空行或结尾)
78
+ // 地址行:以 http(s):// 开头的行;被 # 注释的为备用地址,未注释的为生效地址
79
+ // 域名行:其他非空、非注释的行
80
+ const addresses = [];
92
81
  const domains = [];
93
82
  while (i < lines.length && lines[i].content !== '') {
94
- const domain = lines[i].content;
95
- domains.push(domain);
83
+ const line = lines[i];
84
+ if (line.isComment) {
85
+ const candidate = line.content.replace(/^#+\s*/, '');
86
+ if (/^https?:\/\//i.test(candidate)) {
87
+ addresses.push({ value: candidate, disabled: true });
88
+ }
89
+ // 普通注释行忽略
90
+ } else if (/^https?:\/\//i.test(line.content)) {
91
+ addresses.push({ value: line.content, disabled: false });
92
+ } else {
93
+ domains.push(line.content);
94
+ }
96
95
  i++;
97
96
  }
98
97
 
98
+ const activeUrls = addresses.filter((a) => !a.disabled);
99
+ const disabledUrls = addresses.filter((a) => a.disabled).map((a) => a.value);
100
+ if (activeUrls.length === 0 && disabledUrls.length === 0) {
101
+ throw new Error(`第 ${nameLine.lineNo} 行: 代理 "${name}" 缺少 URL`);
102
+ }
103
+ if (activeUrls.length > 1) {
104
+ throw new Error(
105
+ `第 ${nameLine.lineNo} 行: 代理 "${name}" 有多个生效地址,只能有一个(其余用 # 注释)`
106
+ );
107
+ }
108
+
109
+ // 未注释地址为生效地址;全被注释则整个代理停用
110
+ const disabled = activeUrls.length === 0;
111
+ const url = activeUrls.length > 0 ? activeUrls[0].value : disabledUrls[0];
112
+
99
113
  proxies.push({
100
114
  name,
101
115
  url,
102
116
  domains: dedupeDomains(domains),
117
+ ...(disabledUrls.length > 0 ? { disabledUrls } : {}),
118
+ ...(disabled ? { disabled: true } : {}),
103
119
  });
104
120
  }
105
121
 
@@ -118,14 +134,23 @@ function parse(text) {
118
134
 
119
135
  /**
120
136
  * 将代理数组格式化为文本(不含注释头部)
121
- * @param {Array<{name: string, url: string, domains: string[]}>} proxies
137
+ * 地址输出顺序:备用地址(disabledUrls,以 `# ` 前缀)→ 生效地址(url,仅非停用)
138
+ * 停用的代理(disabled)其地址全部以 `# ` 前缀输出,重新解析后可恢复
139
+ * @param {Array<{name: string, url: string, domains: string[], disabled?: boolean, disabledUrls?: string[]}>} proxies
122
140
  * @returns {string}
123
141
  */
124
142
  function format(proxies) {
125
143
  if (!proxies || proxies.length === 0) return '';
126
144
 
127
145
  const blocks = proxies.map((p) => {
128
- const lines = [p.name, p.url];
146
+ const addrLines = (p.disabledUrls || []).map((u) => `# ${u}`);
147
+ if (p.disabled) {
148
+ // 兼容旧数据:无 disabledUrls 时,用 url 作为注释地址输出
149
+ if (addrLines.length === 0 && p.url) addrLines.push(`# ${p.url}`);
150
+ } else if (p.url) {
151
+ addrLines.push(p.url);
152
+ }
153
+ const lines = [p.name, ...addrLines];
129
154
  if (p.domains && p.domains.length > 0) {
130
155
  lines.push(...p.domains);
131
156
  }
@@ -175,10 +200,21 @@ function validate(proxies) {
175
200
 
176
201
  // 域名 trim + 去重
177
202
  const domains = dedupeDomains(
178
- (proxy.domains || []).map((d) => (d || '').trim()).filter(Boolean),
203
+ (proxy.domains || []).map((d) => (d || '').trim()).filter(Boolean)
204
+ );
205
+
206
+ // 备用地址 trim + 去重
207
+ const disabledUrls = dedupeDomains(
208
+ (proxy.disabledUrls || []).map((d) => (d || '').trim()).filter(Boolean)
179
209
  );
180
210
 
181
- return { name, url, domains };
211
+ return {
212
+ name,
213
+ url,
214
+ domains,
215
+ ...(disabledUrls.length > 0 ? { disabledUrls } : {}),
216
+ ...(proxy.disabled ? { disabled: true } : {}),
217
+ };
182
218
  });
183
219
  }
184
220
 
@@ -191,9 +227,11 @@ function buildEditContent(proxies) {
191
227
  const header = [
192
228
  '# EasyProxy 上游代理配置',
193
229
  '# 格式说明:',
194
- '# - 每个代理占一块,第一行是代理名称,第二行是代理地址,第三行起是域名列表',
230
+ '# - 每个代理占一块,第一行是代理名称,其后是代理地址行(可多行),最后是域名列表',
231
+ '# - 地址行以 http:// 或 https:// 开头;# 注释掉的为备用地址,未注释的为生效地址',
195
232
  '# - 多个代理之间用空行分隔',
196
233
  '# - 以 # 开头的行是注释,会被忽略',
234
+ '# - 所有地址都被注释 = 停用该代理(块会保留,可随时恢复)',
197
235
  '# 示例:',
198
236
  '# corp-proxy',
199
237
  '# http://10.0.0.1:8080',
@@ -201,6 +239,15 @@ function buildEditContent(proxies) {
201
239
  '# google.com',
202
240
  '#',
203
241
  '# home-proxy',
242
+ '# # http://192.168.1.100:3128',
243
+ '# http://192.168.1.101:3128',
244
+ '# internal.company.com',
245
+ '# corp-proxy',
246
+ '# http://10.0.0.1:8080',
247
+ '# github.com',
248
+ '# google.com',
249
+ '#',
250
+ '# home-proxy',
204
251
  '# http://192.168.1.100:3128',
205
252
  '# internal.company.com',
206
253
  ].join('\n');
@@ -40,6 +40,8 @@ function generateRules(proxies) {
40
40
  const lines = ['# EasyProxy 上游代理规则(自动生成,请勿手动编辑)'];
41
41
  let hasRules = false;
42
42
  proxies.forEach((proxy) => {
43
+ // 停用的代理不生成规则(用户通过注释地址行停用)
44
+ if (proxy.disabled) return;
43
45
  const proxyUrl = toProxyProtocol(proxy.url);
44
46
  proxy.domains.forEach((domain) => {
45
47
  lines.push(`${domain} ${proxyUrl}`);
package/lib/config.js CHANGED
@@ -21,6 +21,8 @@ const DEFAULT_CONFIG = {
21
21
  hosts: [],
22
22
  upstreamProxies: [],
23
23
  proxyBypassDomains: null,
24
+ // 内置镜像源开关(默认启用,见 lib/cli/mirror-rulesets.js)
25
+ mirrors: { npm: true, pip: true, go: true, node: true },
24
26
  };
25
27
 
26
28
  function ensureDir() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whistle.easy-proxy",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "EasyProxy whistle plugin — GitHub acceleration, SNI rewrite, hosts management",
5
5
  "main": "index.js",
6
6
  "homepage": "https://github.com/sqlwwx/easy-proxy",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "author": "sqlwwx",
15
15
  "license": "MIT",
16
+ "packageManager": "pnpm@11.18.0",
16
17
  "files": [
17
18
  "index.js",
18
19
  "bin/",
@@ -38,9 +39,10 @@
38
39
  "hosts"
39
40
  ],
40
41
  "dependencies": {
41
- "chalk": "^4.1.2",
42
- "commander": "^12.1.0",
43
- "whistle": "^2.10.8"
42
+ "chalk": "^6.0.1",
43
+ "commander": "^15.0.0",
44
+ "node-forge": "^1.4.0",
45
+ "whistle": "^2.10.10"
44
46
  },
45
47
  "engines": {
46
48
  "node": ">=22.0.0"
@@ -50,15 +52,17 @@
50
52
  "access": "public",
51
53
  "registry": "https://registry.npmjs.org/"
52
54
  },
53
- "devDependencies": {
54
- "@biomejs/biome": "^2.5.7"
55
- },
56
55
  "scripts": {
56
+ "preinstall": "npx only-allow pnpm",
57
57
  "start": "easy-proxy start",
58
58
  "stop": "easy-proxy stop",
59
59
  "test": "node --test",
60
60
  "health": "easy-proxy health",
61
61
  "format": "biome format . --write",
62
- "format:check": "biome format ."
62
+ "format:check": "biome format .",
63
+ "prepublishOnly": "npm test"
64
+ },
65
+ "devDependencies": {
66
+ "@biomejs/biome": "^2.5.14"
63
67
  }
64
- }
68
+ }
@@ -1,35 +0,0 @@
1
- const { localW2Path, ok, err, info } = require('../helpers');
2
-
3
- function registerCert(program) {
4
- const certCmd = program.command('cert').description('证书管理');
5
-
6
- certCmd
7
- .command('install')
8
- .description('安装根证书(调用 w2 ca)')
9
- .option('-f, --force', '强制重新生成证书')
10
- .action((opts) => {
11
- const { execSync } = require('node:child_process');
12
- try {
13
- if (opts.force) {
14
- info('强制重新生成证书...');
15
- }
16
- execSync(`"${localW2Path}" ca`, { stdio: 'inherit' });
17
- ok('证书安装完成');
18
- } catch (e) {
19
- err(`证书安装失败: ${e.message}`);
20
- process.exit(1);
21
- }
22
- });
23
-
24
- certCmd
25
- .command('check')
26
- .description('检查证书安装状态和有效期')
27
- .action(async () => {
28
- const { checkCertStatus } = require('../../cert-check');
29
- await checkCertStatus();
30
- });
31
- }
32
-
33
- module.exports = {
34
- registerCert,
35
- };