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 +2 -1
- package/lib/cert-check.js +259 -34
- package/lib/cli/commands/accel.js +1 -3
- package/lib/cli/commands/index.js +2 -2
- package/lib/cli/commands/mirror.js +101 -0
- package/lib/cli/commands/proxy.js +70 -85
- package/lib/cli/commands/start.js +31 -2
- package/lib/cli/commands/status.js +2 -4
- package/lib/cli/mirror-rulesets.js +58 -0
- package/lib/cli/mirror-sync.js +48 -0
- package/lib/cli/proxy-config.js +0 -5
- package/lib/cli/upstream-editor.js +79 -32
- package/lib/cli/upstream-proxy.js +2 -0
- package/lib/config.js +2 -0
- package/package.json +13 -9
- package/lib/cli/commands/cert.js +0 -35
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
|
|
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
|
-
* 有效期解析:
|
|
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
|
-
* 用
|
|
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 {
|
|
71
|
+
* @returns {string[]|null} 缺陷列表,null 表示无法解析
|
|
69
72
|
*/
|
|
70
|
-
function
|
|
73
|
+
function checkCertCompliance(certPath) {
|
|
71
74
|
try {
|
|
72
|
-
var
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
var
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 -
|
|
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
|
-
//
|
|
94
|
-
|
|
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 (
|
|
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
|
|
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)
|
|
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
|
|
420
|
+
console.log(`${c.gray} 运行 easy-proxy start 重新安装证书${c.reset}`);
|
|
205
421
|
} else if (!trusted) {
|
|
206
|
-
console.log(`${c.gray} 运行 easy-proxy
|
|
422
|
+
console.log(`${c.gray} 运行 easy-proxy start 安装证书到系统信任库${c.reset}`);
|
|
207
423
|
}
|
|
208
424
|
}
|
|
209
425
|
}
|
|
210
426
|
|
|
211
|
-
module.exports = {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
.
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|
package/lib/cli/proxy-config.js
CHANGED
|
@@ -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
|
-
* 将文本分割为「原始行号 +
|
|
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
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
95
|
-
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whistle.easy-proxy",
|
|
3
|
-
"version": "1.
|
|
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": "^
|
|
42
|
-
"commander": "^
|
|
43
|
-
"
|
|
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
|
+
}
|
package/lib/cli/commands/cert.js
DELETED
|
@@ -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
|
-
};
|