cfpilot-scanner 0.2.0 → 0.3.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 +81 -2
- package/dist/cli.js +657 -42
- package/package.json +41 -41
package/README.md
CHANGED
|
@@ -32,6 +32,38 @@ npx -y cfpilot-scanner scan --host cfpilot.example.com --token <令牌>
|
|
|
32
32
|
npx -y cfpilot-scanner scan --host cfpilot.example.com --no-callback --top 20
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
## 运行前必须关闭代理工具
|
|
36
|
+
|
|
37
|
+
> ⚠️ 这是最容易踩的坑,也是「结果看起来正常但导入客户端全是 -1」的头号原因。
|
|
38
|
+
|
|
39
|
+
本工具测的是**本机直连某个 IP 的真实质量**。
|
|
40
|
+
只要本机有代理工具在接管流量,测出来的就是**到代理的延迟**,而不是到目标 IP 的延迟:
|
|
41
|
+
|
|
42
|
+
- Clash / Mihomo(含 TUN 模式、系统代理)
|
|
43
|
+
- v2rayN / v2rayNG
|
|
44
|
+
- sing-box / Nekoray
|
|
45
|
+
- Surge / Quantumult
|
|
46
|
+
|
|
47
|
+
**症状**:扫描不报错、结果看起来正常,但所有候选 IP 成绩趋于一致
|
|
48
|
+
(常常集中在一个小网段),导入客户端后大量 `-1` —— 因为从未真正直连验证过。
|
|
49
|
+
|
|
50
|
+
工具会在扫描前自动检测,发现异常会打印提醒:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
⚠️ 检测到可能干扰优选的因素:
|
|
54
|
+
本机有代理程序在监听:
|
|
55
|
+
Clash Verge → 127.0.0.1:7897
|
|
56
|
+
→ 关闭这些代理程序,或在其中把本程序加入直连/绕过名单
|
|
57
|
+
DNS 把 your-domain.com 解析到 198.18.0.82(198.18/15 保留段),TUN / fake-ip 正在生效
|
|
58
|
+
→ 关闭代理工具的 TUN 模式与 fake-ip DNS
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
检测到问题**不会阻断扫描**(有些人确实需要走代理,比如域名在本地被污染),
|
|
62
|
+
但结果的可信度由你自己判断。
|
|
63
|
+
|
|
64
|
+
如果必须开着代理,请把它加入直连/绕过名单,
|
|
65
|
+
并确保**待测的 Cloudflare IP 段**走直连而不是代理节点。
|
|
66
|
+
|
|
35
67
|
## 对接任意 VLESS-WS 项目(CFNext / cfnew / BPB / edgetunnel)
|
|
36
68
|
|
|
37
69
|
不依赖 CFPilot 自己的探针端点,只要知道你项目的 **WebSocket 路径**即可:
|
|
@@ -42,11 +74,58 @@ npx -y cfpilot-scanner scan --host your-domain.com --path /<uuid> --no-callback
|
|
|
42
74
|
|
|
43
75
|
# 常见约定:/vless
|
|
44
76
|
npx -y cfpilot-scanner scan --host your-domain.com --path /vless --no-callback
|
|
77
|
+
|
|
78
|
+
# 有些项目把 WebSocket 放在根路径,那就填 /
|
|
79
|
+
npx -y cfpilot-scanner scan --host your-domain.com --path / --no-callback
|
|
45
80
|
```
|
|
46
81
|
|
|
82
|
+
**路径必须与目标项目实际的 WebSocket 路径完全一致**,填错一个 IP 都选不出来。
|
|
83
|
+
不知道就分别试 `/` 和 `/vless`,能出结果的那个就是对的。
|
|
84
|
+
|
|
47
85
|
- 阶段二会对 `<path>` 发起真实的 **TLS + WebSocket 101** 握手,只有 101 才算可用
|
|
48
|
-
-
|
|
49
|
-
-
|
|
86
|
+
- **测速支持任意项目**,不需要对方提供任何端点(见下节)
|
|
87
|
+
- 想控制行为可用 `--speed-mode`、`--speed-url`、`--no-speed`
|
|
88
|
+
|
|
89
|
+
## 测速:两种方式,只对已校验的 IP 生效
|
|
90
|
+
|
|
91
|
+
**关键前提**:测速**只作用于已通过 101 校验的 IP**。这一点很重要 ——
|
|
92
|
+
同类工具常见的失败模式是「只测带宽、不校验能不能服务你的域名」,
|
|
93
|
+
结果选出的 IP 速度很快,导入客户端却全是 `-1`。
|
|
94
|
+
|
|
95
|
+
| 方式 | 什么时候用 | 要求 | 忠实度 |
|
|
96
|
+
|------|-----------|------|--------|
|
|
97
|
+
| `probe` | 目标有 CFPilot 探针端点(如 `/__edge_probe`) | 目标须提供该端点 | 高(走代理真实路径) |
|
|
98
|
+
| `url` | 其它任何项目 | 无 | 中(测「候选 IP 到 Cloudflare 边缘」的带宽) |
|
|
99
|
+
|
|
100
|
+
默认 `--speed-mode auto`:自动探测目标有没有探针端点,有就走 `probe`,没有就切 `url`。
|
|
101
|
+
**因此对接第三方项目时速度不再永远是 0。**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# 强制用公开测速地址(任何项目都能测出速度)
|
|
105
|
+
npx -y cfpilot-scanner scan --host your-domain.com --path /vless \
|
|
106
|
+
--speed-mode url --no-callback
|
|
107
|
+
|
|
108
|
+
# 换成自己的测速地址(需返回大文件)
|
|
109
|
+
npx -y cfpilot-scanner scan --host your-domain.com --path /vless \
|
|
110
|
+
--speed-url "cdn.example.com/big.bin" --no-callback
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 测速精度的几个设计点
|
|
114
|
+
|
|
115
|
+
1. **取滑动窗口峰值,而不是总平均**
|
|
116
|
+
TCP 慢启动会让开头 1~2 秒的速率远低于链路实际能力。
|
|
117
|
+
实测同一 IP:峰值 `51.7 MB/s`、总平均仅 `16.4 MB/s` —— 用平均会严重低估。
|
|
118
|
+
因此默认丢弃前 1 秒的样本,再取窗口速率的最大值。
|
|
119
|
+
|
|
120
|
+
2. **默认 3 条并发连接**(`--speed-conns`)
|
|
121
|
+
单条 TCP 连接受拥塞窗口与 BDP 限制,高延迟线路上往往填不满实际带宽。
|
|
122
|
+
|
|
123
|
+
3. **测速地址可用性预检**
|
|
124
|
+
测速地址本身可能在本网络下不可用。启动时并行探测候选地址,
|
|
125
|
+
自动选用可用的那个,避免把「地址不可用」误判成「所有 IP 速度都是 0」。
|
|
126
|
+
|
|
127
|
+
4. **默认 6 秒测量时长**(`--speed-duration`)
|
|
128
|
+
太短会被慢启动拉低,太长浪费时间。
|
|
50
129
|
|
|
51
130
|
## 输出:直接可复制的列表
|
|
52
131
|
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
2
8
|
|
|
3
9
|
// src/cli.ts
|
|
4
10
|
import { readFileSync } from "node:fs";
|
|
@@ -212,25 +218,56 @@ function expandCidrs(cidrs, options = {}) {
|
|
|
212
218
|
const random = makeRandom(options.seed ?? 24301);
|
|
213
219
|
const parsed = cidrs.map(parseCidr).filter((c) => c !== null);
|
|
214
220
|
if (parsed.length === 0) return [];
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (sample <= 0 || cidr.size <= 2000n) {
|
|
221
|
+
if (sample <= 0) {
|
|
222
|
+
const all = [];
|
|
223
|
+
for (const cidr of parsed) {
|
|
219
224
|
for (let i = 0n; i < cidr.size; i++) {
|
|
220
225
|
const value = cidr.base + i;
|
|
221
|
-
|
|
226
|
+
all.push(cidr.family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
|
|
222
227
|
}
|
|
223
|
-
continue;
|
|
224
228
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
229
|
+
return all;
|
|
230
|
+
}
|
|
231
|
+
const minPerCidr = sample <= 1e3 ? 1 : 64;
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const family of [4, 6]) {
|
|
234
|
+
const group = parsed.filter((c) => c.family === family);
|
|
235
|
+
if (group.length === 0) continue;
|
|
236
|
+
const familyTotal = group.reduce((sum, c) => sum + c.size, 0n);
|
|
237
|
+
if (familyTotal === 0n) continue;
|
|
238
|
+
const hasOtherFamily = parsed.some((c) => c.family !== family);
|
|
239
|
+
const familyBudget = Math.max(1, Math.round(hasOtherFamily ? sample / 2 : sample));
|
|
240
|
+
const plans = group.map((cidr) => {
|
|
241
|
+
const ratio = Number(cidr.size * BigInt(familyBudget) / familyTotal);
|
|
242
|
+
const wanted = Math.max(minPerCidr, Math.min(2e4, ratio));
|
|
243
|
+
return { cidr, count: Math.min(Number(cidr.size), wanted) };
|
|
244
|
+
});
|
|
245
|
+
let familySum = plans.reduce((sum, p) => sum + p.count, 0);
|
|
246
|
+
while (familySum > familyBudget) {
|
|
247
|
+
let target = null;
|
|
248
|
+
for (const plan of plans) {
|
|
249
|
+
if (plan.count > 1 && (!target || plan.count > target.count)) target = plan;
|
|
250
|
+
}
|
|
251
|
+
if (!target) break;
|
|
252
|
+
target.count -= 1;
|
|
253
|
+
familySum -= 1;
|
|
254
|
+
}
|
|
255
|
+
for (const { cidr, count } of plans) {
|
|
256
|
+
if (count >= Number(cidr.size)) {
|
|
257
|
+
for (let i = 0n; i < cidr.size; i++) {
|
|
258
|
+
const value = cidr.base + i;
|
|
259
|
+
out.push(family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const step = Math.floor(Number(cidr.size) / count);
|
|
264
|
+
const offset = Math.floor(random() * step);
|
|
265
|
+
for (let i = 0; i < count; i++) {
|
|
266
|
+
const offsetValue = BigInt(offset + Math.floor(i * step));
|
|
267
|
+
if (offsetValue >= cidr.size) break;
|
|
268
|
+
const value = cidr.base + offsetValue;
|
|
269
|
+
out.push(family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
|
|
270
|
+
}
|
|
234
271
|
}
|
|
235
272
|
}
|
|
236
273
|
return out;
|
|
@@ -326,11 +363,239 @@ function normalizeCandidateList(input) {
|
|
|
326
363
|
return out;
|
|
327
364
|
}
|
|
328
365
|
|
|
329
|
-
// src/
|
|
366
|
+
// src/net-env.ts
|
|
330
367
|
import * as net from "node:net";
|
|
368
|
+
import { execFile } from "node:child_process";
|
|
369
|
+
var PROXY_PORTS = [
|
|
370
|
+
{ port: 7890, name: "Clash / Mihomo\uFF08HTTP\uFF09" },
|
|
371
|
+
{ port: 7891, name: "Clash / Mihomo\uFF08SOCKS\uFF09" },
|
|
372
|
+
{ port: 7897, name: "Clash Verge" },
|
|
373
|
+
{ port: 1080, name: "\u901A\u7528 SOCKS" },
|
|
374
|
+
{ port: 10808, name: "v2rayN\uFF08SOCKS\uFF09" },
|
|
375
|
+
{ port: 10809, name: "v2rayN\uFF08HTTP\uFF09" },
|
|
376
|
+
{ port: 2080, name: "Nekoray / sing-box" },
|
|
377
|
+
{ port: 2081, name: "Nekoray / sing-box" },
|
|
378
|
+
{ port: 8889, name: "Surge" },
|
|
379
|
+
{ port: 6152, name: "Surge\uFF08HTTP\uFF09" }
|
|
380
|
+
];
|
|
381
|
+
function isPortListening(port, timeoutMs = 400) {
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
const socket = new net.Socket();
|
|
384
|
+
let settled = false;
|
|
385
|
+
const done = (open) => {
|
|
386
|
+
if (settled) return;
|
|
387
|
+
settled = true;
|
|
388
|
+
try {
|
|
389
|
+
socket.destroy();
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
resolve(open);
|
|
393
|
+
};
|
|
394
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
395
|
+
socket.once("connect", () => done(true));
|
|
396
|
+
socket.once("error", () => done(false));
|
|
397
|
+
try {
|
|
398
|
+
socket.connect(port, "127.0.0.1");
|
|
399
|
+
} catch {
|
|
400
|
+
done(false);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async function readSystemProxy() {
|
|
405
|
+
if (process.platform !== "win32") return "";
|
|
406
|
+
const query = (key) => new Promise((resolve) => {
|
|
407
|
+
execFile(
|
|
408
|
+
"reg",
|
|
409
|
+
["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "/v", key],
|
|
410
|
+
{ timeout: 3e3, windowsHide: true },
|
|
411
|
+
(err, stdout) => resolve(err ? "" : String(stdout))
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
try {
|
|
415
|
+
const enabled = await query("ProxyEnable");
|
|
416
|
+
if (!/ProxyEnable\s+REG_DWORD\s+0x1/i.test(enabled)) return "";
|
|
417
|
+
const server = await query("ProxyServer");
|
|
418
|
+
const m = /ProxyServer\s+REG_SZ\s+(\S+)/i.exec(server);
|
|
419
|
+
return m ? m[1] : "(\u5DF2\u542F\u7528\uFF0C\u4F46\u672A\u8BFB\u5230\u5730\u5740)";
|
|
420
|
+
} catch {
|
|
421
|
+
return "";
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function isFakeIp(ip) {
|
|
425
|
+
const parts = ip.split(".").map(Number);
|
|
426
|
+
const [a, b] = parts;
|
|
427
|
+
return a === 198 && (b === 18 || b === 19);
|
|
428
|
+
}
|
|
429
|
+
async function detectProxyInterference(host) {
|
|
430
|
+
const findings = [];
|
|
431
|
+
const listening = [];
|
|
432
|
+
await Promise.all(
|
|
433
|
+
PROXY_PORTS.map(async ({ port, name }) => {
|
|
434
|
+
if (await isPortListening(port)) listening.push(`${name} \u2192 127.0.0.1:${port}`);
|
|
435
|
+
})
|
|
436
|
+
);
|
|
437
|
+
if (listening.length > 0) {
|
|
438
|
+
findings.push({
|
|
439
|
+
message: "\u672C\u673A\u6709\u4EE3\u7406\u7A0B\u5E8F\u5728\u76D1\u542C\uFF1A\n " + listening.join("\n "),
|
|
440
|
+
advice: "\u5173\u95ED\u8FD9\u4E9B\u4EE3\u7406\u7A0B\u5E8F\uFF0C\u6216\u5728\u5176\u4E2D\u628A\u672C\u7A0B\u5E8F\u52A0\u5165\u76F4\u8FDE/\u7ED5\u8FC7\u540D\u5355"
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const sysProxy = await readSystemProxy();
|
|
444
|
+
if (sysProxy) {
|
|
445
|
+
findings.push({
|
|
446
|
+
message: `\u7CFB\u7EDF\u4EE3\u7406\u5DF2\u542F\u7528\uFF1A${sysProxy}`,
|
|
447
|
+
advice: "\u5728\u300C\u8BBE\u7F6E \u2192 \u7F51\u7EDC\u548C Internet \u2192 \u4EE3\u7406\u300D\u4E2D\u5173\u95ED\u7CFB\u7EDF\u4EE3\u7406"
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const probeHost = host?.trim() || "www.cloudflare.com";
|
|
451
|
+
try {
|
|
452
|
+
const { resolve4 } = await import("node:dns/promises");
|
|
453
|
+
const ips = await resolve4(probeHost).catch(() => []);
|
|
454
|
+
const fake = ips.find((ip) => isFakeIp(ip));
|
|
455
|
+
if (fake) {
|
|
456
|
+
findings.push({
|
|
457
|
+
message: `DNS \u628A ${probeHost} \u89E3\u6790\u5230 ${fake}\uFF08198.18/15 \u4FDD\u7559\u6BB5\uFF09\uFF0CTUN / fake-ip \u6B63\u5728\u751F\u6548`,
|
|
458
|
+
advice: "\u5173\u95ED\u4EE3\u7406\u5DE5\u5177\u7684 TUN \u6A21\u5F0F\u4E0E fake-ip DNS"
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
return { clean: findings.length === 0, findings };
|
|
464
|
+
}
|
|
465
|
+
function isMsysPollutedPath(value) {
|
|
466
|
+
const looksWindows = /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\");
|
|
467
|
+
const hasMsysHint = /node_modules|bash-win32|@dsh-ops|msys/i.test(value);
|
|
468
|
+
return looksWindows || hasMsysHint;
|
|
469
|
+
}
|
|
331
470
|
|
|
332
|
-
// src/
|
|
471
|
+
// src/speed-url.ts
|
|
333
472
|
import * as tls from "node:tls";
|
|
473
|
+
var SPEED_URL_PRESETS = [
|
|
474
|
+
{ url: "speed.cloudflare.com/__down?bytes=104857600", label: "Cloudflare \u5B98\u65B9" },
|
|
475
|
+
{ url: "cf.090227.xyz/__down?bytes=104857600", label: "CM \u63D0\u4F9B" }
|
|
476
|
+
];
|
|
477
|
+
var DEFAULT_SPEED_URL = SPEED_URL_PRESETS[0].url;
|
|
478
|
+
function parseSpeedUrl(raw, fallbackScheme = "https") {
|
|
479
|
+
const value = (raw ?? "").trim();
|
|
480
|
+
if (!value) throw new Error("\u6D4B\u901F\u5730\u5740\u4E3A\u7A7A");
|
|
481
|
+
const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(value);
|
|
482
|
+
if (schemeMatch) {
|
|
483
|
+
const scheme2 = schemeMatch[1].toLowerCase();
|
|
484
|
+
if (scheme2 !== "http" && scheme2 !== "https") {
|
|
485
|
+
throw new Error(`\u4E0D\u652F\u6301\u7684\u6D4B\u901F\u5730\u5740\u534F\u8BAE\uFF1A${scheme2}\uFF08\u53EA\u652F\u6301 http / https\uFF09`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
let normalized = value;
|
|
489
|
+
if (normalized.startsWith("//")) {
|
|
490
|
+
normalized = `${fallbackScheme}:${normalized}`;
|
|
491
|
+
} else if (!/^https?:\/\//i.test(normalized)) {
|
|
492
|
+
normalized = `${fallbackScheme}://${normalized}`;
|
|
493
|
+
}
|
|
494
|
+
let parsed;
|
|
495
|
+
try {
|
|
496
|
+
parsed = new URL(normalized);
|
|
497
|
+
} catch {
|
|
498
|
+
throw new Error(`\u6D4B\u901F\u5730\u5740\u65E0\u6CD5\u89E3\u6790\uFF1A${value}`);
|
|
499
|
+
}
|
|
500
|
+
const scheme = parsed.protocol.replace(":", "").toLowerCase();
|
|
501
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
502
|
+
throw new Error(`\u4E0D\u652F\u6301\u7684\u6D4B\u901F\u5730\u5740\u534F\u8BAE\uFF1A${scheme}`);
|
|
503
|
+
}
|
|
504
|
+
if (!parsed.hostname) throw new Error(`\u6D4B\u901F\u5730\u5740\u7F3A\u5C11\u57DF\u540D\uFF1A${value}`);
|
|
505
|
+
return {
|
|
506
|
+
raw: value,
|
|
507
|
+
scheme,
|
|
508
|
+
host: parsed.hostname,
|
|
509
|
+
path: `${parsed.pathname}${parsed.search}` || "/"
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function schemeForPort(port) {
|
|
513
|
+
return port === 443 || port === 2053 || port === 2083 || port === 2087 || port === 2096 || port === 8443 ? "https" : "http";
|
|
514
|
+
}
|
|
515
|
+
function checkSpeedUrlAvailable(parsed, timeoutMs = 6e3) {
|
|
516
|
+
return new Promise((resolve) => {
|
|
517
|
+
let settled = false;
|
|
518
|
+
let received = "";
|
|
519
|
+
const finish = (available, status, error) => {
|
|
520
|
+
if (settled) return;
|
|
521
|
+
settled = true;
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
try {
|
|
524
|
+
socket.destroy();
|
|
525
|
+
} catch {
|
|
526
|
+
}
|
|
527
|
+
resolve({ available, status, error });
|
|
528
|
+
};
|
|
529
|
+
const path = parsed.path.includes("bytes=") ? parsed.path.replace(/bytes=\d+/, "bytes=1024") : parsed.path;
|
|
530
|
+
const request = [
|
|
531
|
+
`GET ${path} HTTP/1.1`,
|
|
532
|
+
`Host: ${parsed.host}`,
|
|
533
|
+
`User-Agent: ${USER_AGENT_FOR_CHECK}`,
|
|
534
|
+
"Accept: */*",
|
|
535
|
+
"Connection: close",
|
|
536
|
+
"",
|
|
537
|
+
""
|
|
538
|
+
].join("\r\n");
|
|
539
|
+
const connectOptions = {
|
|
540
|
+
host: parsed.host,
|
|
541
|
+
port: parsed.scheme === "https" ? 443 : 80,
|
|
542
|
+
servername: parsed.host,
|
|
543
|
+
rejectUnauthorized: false
|
|
544
|
+
};
|
|
545
|
+
const socket = parsed.scheme === "https" ? tls.connect(connectOptions, () => socket.write(request)) : (
|
|
546
|
+
// http 明文:用 net 连接
|
|
547
|
+
(() => {
|
|
548
|
+
const net3 = __require("node:net");
|
|
549
|
+
const s = net3.connect({ host: parsed.host, port: 80 }, () => s.write(request));
|
|
550
|
+
return s;
|
|
551
|
+
})()
|
|
552
|
+
);
|
|
553
|
+
const timer = setTimeout(() => finish(false, 0, "\u9884\u68C0\u8D85\u65F6"), timeoutMs);
|
|
554
|
+
socket.setTimeout(timeoutMs, () => finish(false, 0, "\u9884\u68C0\u8D85\u65F6"));
|
|
555
|
+
socket.on("error", (err) => finish(false, 0, err.message));
|
|
556
|
+
socket.on("data", (chunk) => {
|
|
557
|
+
received += chunk.toString("utf8");
|
|
558
|
+
const m = /^HTTP\/1\.[01]\s+(\d{3})/.exec(received);
|
|
559
|
+
if (m) {
|
|
560
|
+
const status = Number(m[1]);
|
|
561
|
+
finish(status === 200 || status === 206, status);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
var USER_AGENT_FOR_CHECK = "CFPilot-Scanner/1.0 (+https://github.com/xmwengxing/CFPilot)";
|
|
567
|
+
async function pickAvailableSpeedUrl(candidates, log) {
|
|
568
|
+
const list = candidates.length > 0 ? candidates : [DEFAULT_SPEED_URL];
|
|
569
|
+
for (const candidate of list) {
|
|
570
|
+
let parsed;
|
|
571
|
+
try {
|
|
572
|
+
parsed = parseSpeedUrl(candidate, "https");
|
|
573
|
+
} catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const result = await checkSpeedUrlAvailable(parsed);
|
|
577
|
+
if (result.available) {
|
|
578
|
+
log?.(`\u6D4B\u901F\u5730\u5740\u53EF\u7528\uFF1A${candidate}`);
|
|
579
|
+
return candidate;
|
|
580
|
+
}
|
|
581
|
+
log?.(`\u6D4B\u901F\u5730\u5740\u4E0D\u53EF\u7528\uFF08${result.error ?? "HTTP " + result.status}\uFF09\uFF1A${candidate}`);
|
|
582
|
+
}
|
|
583
|
+
log?.(`\u6240\u6709\u6D4B\u901F\u5730\u5740\u90FD\u4E0D\u53EF\u7528\uFF0C\u56DE\u9000\u5230\u9ED8\u8BA4\uFF1A${DEFAULT_SPEED_URL}`);
|
|
584
|
+
return DEFAULT_SPEED_URL;
|
|
585
|
+
}
|
|
586
|
+
function withBytes(parsed, bytes) {
|
|
587
|
+
if (/bytes=\d+/.test(parsed.path)) {
|
|
588
|
+
return parsed.path.replace(/bytes=\d+/, `bytes=${bytes}`);
|
|
589
|
+
}
|
|
590
|
+
const sep = parsed.path.includes("?") ? "&" : "?";
|
|
591
|
+
return `${parsed.path}${sep}bytes=${bytes}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/scan.ts
|
|
595
|
+
import * as net2 from "node:net";
|
|
596
|
+
|
|
597
|
+
// src/true-probe.ts
|
|
598
|
+
import * as tls2 from "node:tls";
|
|
334
599
|
import * as crypto from "node:crypto";
|
|
335
600
|
var USER_AGENT = "CFPilot-Scanner/1.0 (+https://github.com/xmwengxing/CFPilot)";
|
|
336
601
|
function extractColo(text) {
|
|
@@ -364,7 +629,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3, path = "/__edge_probe") {
|
|
|
364
629
|
"",
|
|
365
630
|
""
|
|
366
631
|
].join("\r\n");
|
|
367
|
-
const socket =
|
|
632
|
+
const socket = tls2.connect(
|
|
368
633
|
{
|
|
369
634
|
host: ip,
|
|
370
635
|
port,
|
|
@@ -430,7 +695,7 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3, path = "/__edg
|
|
|
430
695
|
"",
|
|
431
696
|
""
|
|
432
697
|
].join("\r\n");
|
|
433
|
-
const socket =
|
|
698
|
+
const socket = tls2.connect(
|
|
434
699
|
{
|
|
435
700
|
host: ip,
|
|
436
701
|
port,
|
|
@@ -489,13 +754,215 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3, path = "/__edg
|
|
|
489
754
|
socket.on("close", settle);
|
|
490
755
|
});
|
|
491
756
|
}
|
|
757
|
+
function measureThroughput(options) {
|
|
758
|
+
const {
|
|
759
|
+
ip,
|
|
760
|
+
port,
|
|
761
|
+
sniHost,
|
|
762
|
+
path,
|
|
763
|
+
requestHost = sniHost,
|
|
764
|
+
durationMs = 6e3,
|
|
765
|
+
warmupMs = 1e3,
|
|
766
|
+
windowMs = 400,
|
|
767
|
+
connections = 3,
|
|
768
|
+
timeoutMs = 2e4
|
|
769
|
+
} = options;
|
|
770
|
+
const connCount = Math.max(1, Math.min(8, connections));
|
|
771
|
+
return new Promise((resolve) => {
|
|
772
|
+
const started = Date.now();
|
|
773
|
+
let settled = false;
|
|
774
|
+
let totalBytes = 0;
|
|
775
|
+
let windowBytes = 0;
|
|
776
|
+
let windowStart = started;
|
|
777
|
+
let peak = 0;
|
|
778
|
+
let openedCount = 0;
|
|
779
|
+
let lastError = "";
|
|
780
|
+
const sockets = [];
|
|
781
|
+
const finish = () => {
|
|
782
|
+
if (settled) return;
|
|
783
|
+
settled = true;
|
|
784
|
+
clearInterval(ticker);
|
|
785
|
+
clearTimeout(timer);
|
|
786
|
+
for (const s of sockets) {
|
|
787
|
+
try {
|
|
788
|
+
s.destroy();
|
|
789
|
+
} catch {
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const elapsedNow = Date.now() - started;
|
|
793
|
+
if (elapsedNow >= warmupMs) {
|
|
794
|
+
const dt = (Date.now() - windowStart) / 1e3;
|
|
795
|
+
if (dt > 0) {
|
|
796
|
+
const speed = windowBytes / dt / 1024 / 1024;
|
|
797
|
+
if (speed > peak) peak = speed;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const elapsed = Math.max(1e-3, (Date.now() - started) / 1e3);
|
|
801
|
+
resolve({
|
|
802
|
+
ok: totalBytes > 0,
|
|
803
|
+
bytes: totalBytes,
|
|
804
|
+
ms: Date.now() - started,
|
|
805
|
+
peakMBps: Number(peak.toFixed(2)),
|
|
806
|
+
avgMBps: Number((totalBytes / 1024 / 1024 / elapsed).toFixed(2)),
|
|
807
|
+
connections: openedCount,
|
|
808
|
+
error: totalBytes > 0 ? void 0 : lastError || "\u672A\u6536\u5230\u6570\u636E"
|
|
809
|
+
});
|
|
810
|
+
};
|
|
811
|
+
const request = [
|
|
812
|
+
`GET ${path} HTTP/1.1`,
|
|
813
|
+
`Host: ${requestHost}`,
|
|
814
|
+
`User-Agent: ${USER_AGENT}`,
|
|
815
|
+
"Accept: */*",
|
|
816
|
+
"Accept-Encoding: identity",
|
|
817
|
+
"Connection: close",
|
|
818
|
+
"",
|
|
819
|
+
""
|
|
820
|
+
].join("\r\n");
|
|
821
|
+
const openOne = () => {
|
|
822
|
+
let headerDone = false;
|
|
823
|
+
let headerBuffer = "";
|
|
824
|
+
const socket = tls2.connect(
|
|
825
|
+
{
|
|
826
|
+
host: ip,
|
|
827
|
+
port,
|
|
828
|
+
servername: sniHost,
|
|
829
|
+
rejectUnauthorized: false,
|
|
830
|
+
ALPNProtocols: ["http/1.1"]
|
|
831
|
+
},
|
|
832
|
+
() => {
|
|
833
|
+
openedCount++;
|
|
834
|
+
socket.write(request);
|
|
835
|
+
}
|
|
836
|
+
);
|
|
837
|
+
sockets.push(socket);
|
|
838
|
+
socket.setTimeout(timeoutMs, () => {
|
|
839
|
+
try {
|
|
840
|
+
socket.destroy();
|
|
841
|
+
} catch {
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
socket.on("error", (err) => {
|
|
845
|
+
lastError = err.message;
|
|
846
|
+
});
|
|
847
|
+
socket.on("data", (chunk) => {
|
|
848
|
+
if (!headerDone) {
|
|
849
|
+
headerBuffer += chunk.toString("latin1");
|
|
850
|
+
const idx = headerBuffer.indexOf("\r\n\r\n");
|
|
851
|
+
if (idx < 0) {
|
|
852
|
+
if (headerBuffer.length > 8192) {
|
|
853
|
+
lastError = "\u54CD\u5E94\u5934\u5F02\u5E38";
|
|
854
|
+
socket.destroy();
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const head = headerBuffer.slice(0, idx);
|
|
859
|
+
const status = /^HTTP\/1\.[01]\s+(\d{3})/.exec(head)?.[1];
|
|
860
|
+
if (status !== "200") {
|
|
861
|
+
lastError = `HTTP ${status ?? "?"}`;
|
|
862
|
+
socket.destroy();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
headerDone = true;
|
|
866
|
+
const body = Buffer.from(headerBuffer, "latin1").subarray(idx + 4);
|
|
867
|
+
totalBytes += body.byteLength;
|
|
868
|
+
windowBytes += body.byteLength;
|
|
869
|
+
headerBuffer = "";
|
|
870
|
+
} else {
|
|
871
|
+
totalBytes += chunk.byteLength;
|
|
872
|
+
windowBytes += chunk.byteLength;
|
|
873
|
+
}
|
|
874
|
+
if (Date.now() - started > durationMs) finish();
|
|
875
|
+
});
|
|
876
|
+
socket.on("end", () => {
|
|
877
|
+
if (sockets.every((s) => s.destroyed || s.readableEnded)) finish();
|
|
878
|
+
});
|
|
879
|
+
socket.on("close", () => {
|
|
880
|
+
if (sockets.every((s) => s.destroyed)) finish();
|
|
881
|
+
});
|
|
882
|
+
};
|
|
883
|
+
const ticker = setInterval(() => {
|
|
884
|
+
const now = Date.now();
|
|
885
|
+
const dt = (now - windowStart) / 1e3;
|
|
886
|
+
if (dt <= 0) return;
|
|
887
|
+
const bytes = windowBytes;
|
|
888
|
+
windowBytes = 0;
|
|
889
|
+
windowStart = now;
|
|
890
|
+
if (now - started < warmupMs) return;
|
|
891
|
+
const speed = bytes / dt / 1024 / 1024;
|
|
892
|
+
if (speed > peak) peak = speed;
|
|
893
|
+
}, windowMs);
|
|
894
|
+
const timer = setTimeout(finish, durationMs + 4e3);
|
|
895
|
+
for (let i = 0; i < connCount; i++) {
|
|
896
|
+
setTimeout(openOne, i * 60);
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// src/countries.ts
|
|
902
|
+
var COUNTRY_COLOS = {
|
|
903
|
+
HK: ["HKG"],
|
|
904
|
+
TW: ["TPE", "KHH"],
|
|
905
|
+
JP: ["NRT", "KIX", "CTS", "FUK"],
|
|
906
|
+
KR: ["ICN", "PUS"],
|
|
907
|
+
SG: ["SIN"],
|
|
908
|
+
MY: ["KUL"],
|
|
909
|
+
TH: ["BKK"],
|
|
910
|
+
VN: ["SGN", "HAN"],
|
|
911
|
+
ID: ["CGK", "DPS"],
|
|
912
|
+
PH: ["MNL"],
|
|
913
|
+
IN: ["BOM", "MAA", "DEL"],
|
|
914
|
+
AU: ["SYD", "MEL", "BNE", "PER"],
|
|
915
|
+
NZ: ["AKL"],
|
|
916
|
+
US: ["LAX", "SJC", "SEA", "ORD", "DFW", "IAD", "EWR", "ATL", "MIA", "DEN", "PHX", "BOS"],
|
|
917
|
+
CA: ["YYZ", "YVR", "YYC"],
|
|
918
|
+
GB: ["LHR", "MAN"],
|
|
919
|
+
DE: ["FRA", "MUC", "BER"],
|
|
920
|
+
FR: ["CDG", "MRS"],
|
|
921
|
+
NL: ["AMS"],
|
|
922
|
+
IT: ["MXP", "FCO"],
|
|
923
|
+
ES: ["MAD", "BCN"],
|
|
924
|
+
SE: ["ARN"],
|
|
925
|
+
CH: ["ZRH"],
|
|
926
|
+
AT: ["VIE"],
|
|
927
|
+
PL: ["WAW"],
|
|
928
|
+
RU: ["DME", "SVO"],
|
|
929
|
+
TR: ["IST"],
|
|
930
|
+
AE: ["DXB"],
|
|
931
|
+
IL: ["TLV"],
|
|
932
|
+
ZA: ["JNB"],
|
|
933
|
+
BR: ["GRU", "GIG"],
|
|
934
|
+
AR: ["EZE"],
|
|
935
|
+
CL: ["SCL"],
|
|
936
|
+
MX: ["MEX"]
|
|
937
|
+
};
|
|
938
|
+
var coloToCountry = /* @__PURE__ */ new Map();
|
|
939
|
+
for (const [country, colos] of Object.entries(COUNTRY_COLOS)) {
|
|
940
|
+
for (const colo of colos) coloToCountry.set(colo, country);
|
|
941
|
+
}
|
|
942
|
+
function countryOfColo(colo) {
|
|
943
|
+
return coloToCountry.get(colo.toUpperCase()) ?? "";
|
|
944
|
+
}
|
|
945
|
+
function isKnownCountry(code) {
|
|
946
|
+
return Object.prototype.hasOwnProperty.call(COUNTRY_COLOS, code.toUpperCase());
|
|
947
|
+
}
|
|
948
|
+
var SUPPORTED_COUNTRIES = Object.keys(COUNTRY_COLOS);
|
|
949
|
+
function normalizeCountryFilter(input) {
|
|
950
|
+
if (!Array.isArray(input)) return [];
|
|
951
|
+
const out = [];
|
|
952
|
+
for (const raw of input) {
|
|
953
|
+
const code = String(raw ?? "").trim().toUpperCase();
|
|
954
|
+
if (!code || out.includes(code)) continue;
|
|
955
|
+
if (isKnownCountry(code)) out.push(code);
|
|
956
|
+
}
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
492
959
|
|
|
493
960
|
// src/scan.ts
|
|
494
961
|
async function tcpPing(ip, port, timeoutMs) {
|
|
495
962
|
return new Promise((resolve) => {
|
|
496
963
|
const started = Date.now();
|
|
497
964
|
let settled = false;
|
|
498
|
-
const socket = new
|
|
965
|
+
const socket = new net2.Socket();
|
|
499
966
|
const done = (ok) => {
|
|
500
967
|
if (settled) return;
|
|
501
968
|
settled = true;
|
|
@@ -574,13 +1041,65 @@ async function runScan(options) {
|
|
|
574
1041
|
return { ...item, probe: result };
|
|
575
1042
|
});
|
|
576
1043
|
const wsOk = probeResults.filter((r) => r.probe.ok);
|
|
577
|
-
const
|
|
1044
|
+
const countryFilter = normalizeCountryFilter(options.countries ?? []);
|
|
1045
|
+
let wsFiltered = wsOk;
|
|
1046
|
+
let appliedCountries = [];
|
|
1047
|
+
if (countryFilter.length > 0 && wsOk.length > 0) {
|
|
1048
|
+
const matched = wsOk.filter((r) => countryFilter.includes(countryOfColo(r.probe.colo || "")));
|
|
1049
|
+
if (matched.length > 0) {
|
|
1050
|
+
wsFiltered = matched;
|
|
1051
|
+
appliedCountries = countryFilter;
|
|
1052
|
+
report({
|
|
1053
|
+
type: "progress",
|
|
1054
|
+
stage: 2,
|
|
1055
|
+
done: wsOk.length,
|
|
1056
|
+
total: wsOk.length,
|
|
1057
|
+
message: `\u56FD\u5BB6\u7B5B\u9009 ${countryFilter.join("/")}\uFF1A\u4FDD\u7559 ${matched.length}/${wsOk.length}\uFF0C\u8FDB\u5165\u6D4B\u901F`
|
|
1058
|
+
});
|
|
1059
|
+
} else {
|
|
1060
|
+
report({
|
|
1061
|
+
type: "progress",
|
|
1062
|
+
stage: 2,
|
|
1063
|
+
done: wsOk.length,
|
|
1064
|
+
total: wsOk.length,
|
|
1065
|
+
message: `\u56FD\u5BB6\u7B5B\u9009 ${countryFilter.join("/")} \u540E\u65E0\u5019\u9009\uFF0C\u5DF2\u5FFD\u7565\uFF08\u91C7\u6837\u592A\u5C0F\uFF1F\u8C03\u5230 500~1000 \u518D\u8BD5\uFF09`
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const stage2 = wsFiltered.sort((a, b) => a.latency - b.latency).slice(0, speedLimit);
|
|
578
1070
|
let speedResults;
|
|
1071
|
+
const speedMode = options.speedMode ?? "probe";
|
|
579
1072
|
if (options.skipSpeed) {
|
|
580
1073
|
report({ type: "stage", stage: 3, name: "\u8DF3\u8FC7\u6D4B\u901F\uFF08\u6309\u5EF6\u8FDF\u6392\u5E8F\uFF09", total: stage2.length });
|
|
581
1074
|
speedResults = stage2.map((r) => ({ ...r }));
|
|
1075
|
+
} else if (speedMode === "url") {
|
|
1076
|
+
report({ type: "stage", stage: 3, name: "\u771F\u5B9E\u4E0B\u884C\u901F\u5EA6\u6D4B\u8BD5\uFF08\u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF09", total: stage2.length });
|
|
1077
|
+
const parsedUrl = parseSpeedUrl(options.speedUrl ?? DEFAULT_SPEED_URL, schemeForPort(options.port));
|
|
1078
|
+
const requestPath = withBytes(parsedUrl, options.speedBytes ?? 104857600);
|
|
1079
|
+
let speedDone = 0;
|
|
1080
|
+
speedResults = await pool(stage2, speedConcurrency, async (item) => {
|
|
1081
|
+
const result = await measureThroughput({
|
|
1082
|
+
ip: item.ip,
|
|
1083
|
+
port: options.port,
|
|
1084
|
+
sniHost: parsedUrl.host,
|
|
1085
|
+
requestHost: parsedUrl.host,
|
|
1086
|
+
path: requestPath,
|
|
1087
|
+
connections: options.speedConnections ?? 3,
|
|
1088
|
+
durationMs: options.speedDurationMs ?? 6e3,
|
|
1089
|
+
timeoutMs: options.speedTimeoutMs ?? 2e4
|
|
1090
|
+
});
|
|
1091
|
+
speedDone++;
|
|
1092
|
+
report({
|
|
1093
|
+
type: "progress",
|
|
1094
|
+
stage: 3,
|
|
1095
|
+
done: speedDone,
|
|
1096
|
+
total: stage2.length,
|
|
1097
|
+
message: `\u6D4B\u901F ${speedDone}/${stage2.length} ${result.peakMBps.toFixed(1)} MB/s`
|
|
1098
|
+
});
|
|
1099
|
+
return { ...item, speed: result.ok ? result.peakMBps : 0 };
|
|
1100
|
+
});
|
|
582
1101
|
} else {
|
|
583
|
-
report({ type: "stage", stage: 3, name: "\u771F\u5B9E\u4E0B\u884C\u901F\u5EA6\u6D4B\u8BD5", total: stage2.length });
|
|
1102
|
+
report({ type: "stage", stage: 3, name: "\u771F\u5B9E\u4E0B\u884C\u901F\u5EA6\u6D4B\u8BD5\uFF08\u9879\u76EE\u63A2\u9488\u7AEF\u70B9\uFF09", total: stage2.length });
|
|
584
1103
|
let speedDone = 0;
|
|
585
1104
|
speedResults = await pool(stage2, speedConcurrency, async (item) => {
|
|
586
1105
|
const result = await measureDownload(
|
|
@@ -599,15 +1118,15 @@ async function runScan(options) {
|
|
|
599
1118
|
total: stage2.length,
|
|
600
1119
|
message: `\u6D4B\u901F ${speedDone}/${stage2.length} ${result.speedMBps.toFixed(1)} MB/s`
|
|
601
1120
|
});
|
|
602
|
-
return { ...item,
|
|
1121
|
+
return { ...item, speed: result.ok ? result.speedMBps : 0 };
|
|
603
1122
|
});
|
|
604
1123
|
}
|
|
605
|
-
const entries = speedResults.filter((r) => options.skipSpeed ? true : Boolean(r.
|
|
1124
|
+
const entries = speedResults.filter((r) => options.skipSpeed ? true : Boolean(r.speed && r.speed > 0)).map((r) => ({
|
|
606
1125
|
ip: r.ip,
|
|
607
1126
|
port: options.port,
|
|
608
1127
|
colo: r.probe.colo || "UNK",
|
|
609
1128
|
latency: r.latency,
|
|
610
|
-
speed: r.
|
|
1129
|
+
speed: r.speed ? Number(r.speed.toFixed(2)) : 0
|
|
611
1130
|
})).sort((a, b) => options.skipSpeed ? a.latency - b.latency : b.speed - a.speed || a.latency - b.latency).slice(0, top);
|
|
612
1131
|
for (const entry of entries) {
|
|
613
1132
|
report({ type: "result", entry });
|
|
@@ -619,7 +1138,9 @@ async function runScan(options) {
|
|
|
619
1138
|
tcpOk: tcpOk.length,
|
|
620
1139
|
wsOk: wsOk.length,
|
|
621
1140
|
speedOk: entries.length,
|
|
622
|
-
elapsedMs: Date.now() - started
|
|
1141
|
+
elapsedMs: Date.now() - started,
|
|
1142
|
+
countries: appliedCountries,
|
|
1143
|
+
wsFiltered: wsFiltered.length
|
|
623
1144
|
}
|
|
624
1145
|
};
|
|
625
1146
|
report({ type: "done", count: entries.length });
|
|
@@ -857,6 +1378,8 @@ async function handleRequest(req, res) {
|
|
|
857
1378
|
host: body.host.trim(),
|
|
858
1379
|
port: body.port ?? 443,
|
|
859
1380
|
candidates: plan.candidates,
|
|
1381
|
+
// 国家过滤(可多选):不传 = 全量;无效代码在 normalizeCountryFilter 里被丢弃
|
|
1382
|
+
countries: Array.isArray(body.countries) ? body.countries : void 0,
|
|
860
1383
|
top: body.top ?? 15,
|
|
861
1384
|
onProgress: (event) => broadcast(job, event)
|
|
862
1385
|
});
|
|
@@ -948,11 +1471,22 @@ scan \u9009\u9879:
|
|
|
948
1471
|
--no-callback \u53EA\u6253\u5370\u7ED3\u679C\uFF0C\u4E0D\u56DE\u586B
|
|
949
1472
|
--path <path> \u9636\u6BB5\u4E8C\u6821\u9A8C\u7684 WebSocket \u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
|
|
950
1473
|
\uFF08\u5BF9\u63A5\u5176\u5B83\u9879\u76EE\u65F6\u586B\u5B83\u81EA\u5DF1\u7684 WS \u8DEF\u5F84\uFF0C\u4F8B\u5982 /vless \u6216 /<uuid>\uFF09
|
|
951
|
-
--probe-path <path> \
|
|
952
|
-
--
|
|
1474
|
+
--probe-path <path> probe \u6A21\u5F0F\u7684\u6D4B\u901F\u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
|
|
1475
|
+
--speed-mode <mode> \u6D4B\u901F\u65B9\u5F0F\uFF1Aauto\uFF08\u9ED8\u8BA4\uFF09/ probe / url
|
|
1476
|
+
auto = \u76EE\u6807\u6709\u63A2\u9488\u7AEF\u70B9\u5C31\u7528\u5B83\uFF0C\u5426\u5219\u81EA\u52A8\u6539\u7528\u516C\u5F00\u6D4B\u901F\u5730\u5740
|
|
1477
|
+
probe = \u53EA\u7528\u76EE\u6807\u9879\u76EE\u7684\u63A2\u9488\u7AEF\u70B9\uFF08\u6700\u5FE0\u5B9E\uFF0C\u8981\u6C42\u5BF9\u65B9\u6709\u8BE5\u7AEF\u70B9\uFF09
|
|
1478
|
+
url = \u53EA\u7528\u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF08\u4EFB\u4F55\u9879\u76EE\u90FD\u80FD\u6D4B\uFF09
|
|
1479
|
+
--speed-url <url> \u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF0C\u9ED8\u8BA4 ${DEFAULT_SPEED_URL}
|
|
1480
|
+
\u53EF\u6362\u6210\u4EFB\u610F\u8FD4\u56DE\u5927\u6587\u4EF6\u7684\u5730\u5740
|
|
1481
|
+
--speed-conns <n> \u6D4B\u901F\u5E76\u53D1\u8FDE\u63A5\u6570\uFF0C\u9ED8\u8BA4 3
|
|
1482
|
+
\u5355\u6761 TCP \u8FDE\u63A5\u53D7\u62E5\u585E\u7A97\u53E3\u9650\u5236\uFF0C\u591A\u8FDE\u63A5\u66F4\u63A5\u8FD1\u771F\u5B9E\u5E26\u5BBD
|
|
1483
|
+
--speed-duration <ms> \u6D4B\u901F\u65F6\u957F\uFF0C\u9ED8\u8BA4 6000
|
|
1484
|
+
--no-speed \u8DF3\u8FC7\u6D4B\u901F\uFF0C\u53EA\u6309\u5EF6\u8FDF\u6392\u5E8F
|
|
953
1485
|
--top <n> \u8FD4\u56DE\u6761\u6570\uFF0C\u9ED8\u8BA4 15
|
|
954
1486
|
--concurrency <n> \u9636\u6BB5\u4E00\u5E76\u53D1\uFF0C\u9ED8\u8BA4 300
|
|
955
1487
|
--sample <n> \u5019\u9009 IP \u91C7\u6837\u6570\uFF0C\u9ED8\u8BA4 20000
|
|
1488
|
+
--countries <a,b> \u56FD\u5BB6 / \u5730\u533A\u8FC7\u6EE4\uFF08\u53EF\u591A\u9009\uFF0C\u9017\u53F7\u5206\u9694\uFF0C\u5982 HK,JP,SG\uFF09
|
|
1489
|
+
\u4E0D\u9009 = \u5168\u91CF\u3002\u673A\u623F\u6765\u81EA 101 \u63E1\u624B\u54CD\u5E94\uFF1B\u7B5B\u5B8C\u4E3A\u7A7A\u4F1A\u81EA\u52A8\u5FFD\u7565
|
|
956
1490
|
--cidr <a,b> \u81EA\u5B9A\u4E49\u7F51\u6BB5\uFF08\u53EF\u91CD\u590D\uFF0C\u7528\u9017\u53F7\u5206\u9694\uFF09
|
|
957
1491
|
--ip-file <path> \u4F7F\u7528\u672C\u5730 IP \u5217\u8868\u6587\u4EF6
|
|
958
1492
|
--max-latency <ms> \u9636\u6BB5\u4E00\u5EF6\u8FDF\u4E0A\u9650\uFF0C\u9ED8\u8BA4 0\uFF08\u4E0D\u9650\uFF09
|
|
@@ -972,6 +1506,35 @@ serve \u9009\u9879:
|
|
|
972
1506
|
${NAME} scan --host cfpilot.example.com --token abc123
|
|
973
1507
|
${NAME} serve
|
|
974
1508
|
`;
|
|
1509
|
+
function normalizeWsPath(raw, fallback) {
|
|
1510
|
+
const value = (raw ?? fallback).trim();
|
|
1511
|
+
if (!value) return fallback;
|
|
1512
|
+
if (isMsysPollutedPath(value)) {
|
|
1513
|
+
process.stderr.write(
|
|
1514
|
+
`
|
|
1515
|
+
\u2717 --path \u88AB MSYS\uFF08Git-Bash\uFF09\u8F6C\u6362\u6210\u4E86 Windows \u8DEF\u5F84\uFF1A
|
|
1516
|
+
\u6536\u5230\u7684\u662F\uFF1A${value}
|
|
1517
|
+
|
|
1518
|
+
\u8FD9\u662F Git-Bash \u7684\u81EA\u52A8\u8DEF\u5F84\u8F6C\u6362\u884C\u4E3A\uFF0C\u4E0D\u662F\u672C\u5DE5\u5177\u7684\u95EE\u9898\u3002
|
|
1519
|
+
\u4E09\u79CD\u7ED5\u5F00\u65B9\u5F0F\uFF0C\u4EFB\u9009\u5176\u4E00\uFF1A
|
|
1520
|
+
1. \u7528\u7B49\u53F7\u5199\u6CD5\uFF1A --path=/vless
|
|
1521
|
+
2. \u52A0\u73AF\u5883\u53D8\u91CF\uFF1A MSYS_NO_PATHCONV=1 cfpilot-scanner scan ...
|
|
1522
|
+
3. \u6362\u7528 PowerShell / CMD \u6267\u884C
|
|
1523
|
+
`
|
|
1524
|
+
);
|
|
1525
|
+
process.exit(1);
|
|
1526
|
+
}
|
|
1527
|
+
if (!value.startsWith("/")) {
|
|
1528
|
+
process.stderr.write(
|
|
1529
|
+
`
|
|
1530
|
+
\u2717 --path \u5FC5\u987B\u4EE5 / \u5F00\u5934\uFF0C\u6536\u5230\u7684\u662F\uFF1A\u300C${value}\u300D
|
|
1531
|
+
\u4F8B\u5982\uFF1A--path /vless \u6216 --path /\uFF08\u76EE\u6807\u9879\u76EE\u7528\u6839\u8DEF\u5F84\u65F6\uFF09
|
|
1532
|
+
`
|
|
1533
|
+
);
|
|
1534
|
+
process.exit(1);
|
|
1535
|
+
}
|
|
1536
|
+
return value;
|
|
1537
|
+
}
|
|
975
1538
|
async function main() {
|
|
976
1539
|
const args = parseArgs(process.argv.slice(2));
|
|
977
1540
|
const command = args.command && !args.command.startsWith("-") ? args.command : "";
|
|
@@ -1097,9 +1660,10 @@ async function commandScan(args) {
|
|
|
1097
1660
|
const top = flagNumber(args, "top", 15);
|
|
1098
1661
|
const token = flagString(args, "token");
|
|
1099
1662
|
const noCallback = flagBool(args, "no-callback");
|
|
1100
|
-
const wsPath = flagString(args, "path", "/__edge_probe");
|
|
1101
|
-
const probePath = flagString(args, "probe-path", "/__edge_probe");
|
|
1663
|
+
const wsPath = normalizeWsPath(flagString(args, "path"), "/__edge_probe");
|
|
1664
|
+
const probePath = normalizeWsPath(flagString(args, "probe-path"), "/__edge_probe");
|
|
1102
1665
|
const noSpeed = flagBool(args, "no-speed");
|
|
1666
|
+
const countries = normalizeCountryFilter(flagList(args, "countries"));
|
|
1103
1667
|
const callback = noCallback ? "" : flagString(args, "callback", token ? `https://${host}/api/v1/preferred-ips` : "");
|
|
1104
1668
|
if (!quiet) {
|
|
1105
1669
|
process.stdout.write(`
|
|
@@ -1107,8 +1671,31 @@ ${NAME} v${VERSION}
|
|
|
1107
1671
|
`);
|
|
1108
1672
|
process.stdout.write(`\u76EE\u6807\u57DF\u540D\uFF1A${host}:${port}\uFF08\u5C06\u4F5C\u4E3A TLS SNI \u4E0E HTTP Host\uFF09
|
|
1109
1673
|
`);
|
|
1110
|
-
process.stdout.write(
|
|
1674
|
+
process.stdout.write(`WS \u8DEF\u5F84\uFF1A${wsPath}
|
|
1675
|
+
`);
|
|
1676
|
+
if (countries.length > 0) {
|
|
1677
|
+
process.stdout.write(`\u56FD\u5BB6\u7B5B\u9009\uFF1A${countries.join(" / ")}\uFF08\u4E0D\u586B\u5219\u5168\u91CF\uFF09
|
|
1678
|
+
`);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
if (!quiet) {
|
|
1682
|
+
const env = await detectProxyInterference(host);
|
|
1683
|
+
if (!env.clean) {
|
|
1684
|
+
process.stdout.write("\n\u26A0\uFE0F \u68C0\u6D4B\u5230\u53EF\u80FD\u5E72\u6270\u4F18\u9009\u7684\u56E0\u7D20\uFF1A\n");
|
|
1685
|
+
for (const f of env.findings) {
|
|
1686
|
+
process.stdout.write(` ${f.message}
|
|
1687
|
+
`);
|
|
1688
|
+
process.stdout.write(` \u2192 ${f.advice}
|
|
1689
|
+
`);
|
|
1690
|
+
}
|
|
1691
|
+
process.stdout.write("\n \u4F18\u9009\u6D4B\u7684\u662F\u300C\u672C\u673A\u76F4\u8FDE\u8BE5 IP\u300D\u7684\u771F\u5B9E\u8D28\u91CF\u3002\n");
|
|
1692
|
+
process.stdout.write(" \u6D41\u91CF\u88AB\u4EE3\u7406\u63A5\u7BA1\u65F6\uFF0C\u7ED3\u679C\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u6240\u6709 IP \u6210\u7EE9\u8D8B\u4E8E\u4E00\u81F4\uFF0C\n");
|
|
1693
|
+
process.stdout.write(" \u5BFC\u5165\u5BA2\u6237\u7AEF\u540E\u5927\u91CF -1\uFF08\u56E0\u4E3A\u4ECE\u672A\u771F\u6B63\u76F4\u8FDE\u9A8C\u8BC1\u8FC7\uFF09\u3002\n");
|
|
1694
|
+
process.stdout.write(" \u2192 \u5EFA\u8BAE\u5173\u95ED\u4EE3\u7406\u5DE5\u5177\uFF08\u542B TUN \u6A21\u5F0F\u4E0E\u7CFB\u7EDF\u4EE3\u7406\uFF09\u540E\u91CD\u8DD1\u3002\n");
|
|
1695
|
+
process.stdout.write(" \u2192 \u6216\u628A\u672C\u7A0B\u5E8F\u4E0E\u5F85\u6D4B IP \u6BB5\u8BBE\u4E3A\u76F4\u8FDE\u3002\n\n");
|
|
1696
|
+
}
|
|
1111
1697
|
}
|
|
1698
|
+
if (!quiet) process.stdout.write("\u6B63\u5728\u51C6\u5907 Cloudflare \u7F51\u6BB5\u2026\n");
|
|
1112
1699
|
const candidates = await resolveCandidatesFromArgs(args);
|
|
1113
1700
|
if (candidates.length === 0) {
|
|
1114
1701
|
process.stderr.write("\u5019\u9009 IP \u4E3A\u7A7A\uFF0C\u8BF7\u68C0\u67E5 --ip-file / --cidr \u53C2\u6570\n");
|
|
@@ -1117,27 +1704,51 @@ ${NAME} v${VERSION}
|
|
|
1117
1704
|
if (!quiet) process.stdout.write(`\u5019\u9009 IP\uFF1A${candidates.length} \u4E2A
|
|
1118
1705
|
`);
|
|
1119
1706
|
let skipSpeed = noSpeed;
|
|
1707
|
+
let speedMode = "probe";
|
|
1708
|
+
let speedUrl = flagString(args, "speed-url", DEFAULT_SPEED_URL);
|
|
1120
1709
|
if (!skipSpeed) {
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1710
|
+
const forcedMode = flagString(args, "speed-mode");
|
|
1711
|
+
if (forcedMode === "url") {
|
|
1712
|
+
speedMode = "url";
|
|
1713
|
+
if (!quiet) process.stdout.write(`\u6D4B\u901F\u65B9\u5F0F\uFF1A\u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF08${speedUrl}\uFF09
|
|
1714
|
+
`);
|
|
1715
|
+
} else if (forcedMode === "probe") {
|
|
1716
|
+
speedMode = "probe";
|
|
1717
|
+
} else {
|
|
1718
|
+
let hasProbe = false;
|
|
1719
|
+
try {
|
|
1720
|
+
const probe = await fetch(`https://${host}${probePath}?bytes=1024`, {
|
|
1721
|
+
method: "GET",
|
|
1722
|
+
signal: AbortSignal.timeout(8e3)
|
|
1723
|
+
});
|
|
1724
|
+
hasProbe = Boolean(probe.headers.get("x-probe-bytes"));
|
|
1725
|
+
} catch {
|
|
1726
|
+
hasProbe = false;
|
|
1727
|
+
}
|
|
1728
|
+
if (hasProbe) {
|
|
1729
|
+
speedMode = "probe";
|
|
1730
|
+
if (!quiet) process.stdout.write("\u6D4B\u901F\u65B9\u5F0F\uFF1A\u9879\u76EE\u63A2\u9488\u7AEF\u70B9\uFF08\u6700\u5FE0\u5B9E\uFF09\n");
|
|
1731
|
+
} else {
|
|
1732
|
+
speedMode = "url";
|
|
1128
1733
|
if (!quiet) {
|
|
1129
|
-
process.stdout.write(
|
|
1130
|
-
|
|
1734
|
+
process.stdout.write(`
|
|
1735
|
+
\u63D0\u793A\uFF1A${probePath} \u4E0D\u662F CFPilot \u63A2\u9488\u7AEF\u70B9\uFF0C\u6539\u7528\u516C\u5F00\u6D4B\u901F\u5730\u5740\u6D4B\u901F
|
|
1736
|
+
`);
|
|
1737
|
+
process.stdout.write(" \uFF08\u4ECD\u4F1A\u5148\u505A 101 \u6821\u9A8C\uFF0C\u56E0\u6B64\u4E0D\u4F1A\u9009\u51FA\u8FDE\u4E0D\u4E0A\u7684 IP\uFF09\n");
|
|
1131
1738
|
}
|
|
1739
|
+
speedUrl = await pickAvailableSpeedUrl(
|
|
1740
|
+
[speedUrl, ...SPEED_URL_PRESETS.map((p) => p.url)],
|
|
1741
|
+
quiet ? void 0 : (m) => process.stdout.write(` ${m}
|
|
1742
|
+
`)
|
|
1743
|
+
);
|
|
1132
1744
|
}
|
|
1133
|
-
} catch {
|
|
1134
|
-
skipSpeed = true;
|
|
1135
1745
|
}
|
|
1136
1746
|
}
|
|
1137
1747
|
const summary = await runScan({
|
|
1138
1748
|
host,
|
|
1139
1749
|
port,
|
|
1140
1750
|
candidates,
|
|
1751
|
+
countries,
|
|
1141
1752
|
wsPath,
|
|
1142
1753
|
probePath,
|
|
1143
1754
|
skipSpeed,
|
|
@@ -1149,6 +1760,10 @@ ${NAME} v${VERSION}
|
|
|
1149
1760
|
speedLimit: flagNumber(args, "speed-limit", 30),
|
|
1150
1761
|
speedTimeoutMs: flagNumber(args, "speed-timeout", 12e3),
|
|
1151
1762
|
speedBytes: flagNumber(args, "speed-bytes", 5 * 1024 * 1024),
|
|
1763
|
+
speedMode,
|
|
1764
|
+
speedUrl,
|
|
1765
|
+
speedConnections: flagNumber(args, "speed-conns", 3),
|
|
1766
|
+
speedDurationMs: flagNumber(args, "speed-duration", 6e3),
|
|
1152
1767
|
top,
|
|
1153
1768
|
onProgress: (event) => printProgress(event, quiet)
|
|
1154
1769
|
});
|
package/package.json
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cfpilot-scanner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CFPilot 优选器:用你自己的域名做 WebSocket 101 真连接校验,只保留真正可用的 Cloudflare IP;兼容任意 VLESS-WS 项目",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"author": "CFPilot Contributors",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/xmwengxing/CFPilot.git"
|
|
10
|
-
},
|
|
11
|
-
"keywords": [
|
|
12
|
-
"cloudflare",
|
|
13
|
-
"ip",
|
|
14
|
-
"optimize",
|
|
15
|
-
"scanner",
|
|
16
|
-
"vless",
|
|
17
|
-
"websocket",
|
|
18
|
-
"cfpilot"
|
|
19
|
-
],
|
|
20
|
-
"type": "module",
|
|
21
|
-
"bin": {
|
|
22
|
-
"cfpilot-scanner": "dist/cli.js"
|
|
23
|
-
},
|
|
24
|
-
"files": [
|
|
25
|
-
"dist",
|
|
26
|
-
"README.md"
|
|
27
|
-
],
|
|
28
|
-
"engines": {
|
|
29
|
-
"node": ">=18.17.0"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "node scripts/build.mjs",
|
|
33
|
-
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
34
|
-
"prepublishOnly": "npm run build"
|
|
35
|
-
},
|
|
36
|
-
"devDependencies": {
|
|
37
|
-
"@types/node": "^26.6.5",
|
|
38
|
-
"esbuild": "^0.28.2",
|
|
39
|
-
"typescript": "^5.9.3"
|
|
40
|
-
}
|
|
41
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cfpilot-scanner",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "CFPilot 优选器:用你自己的域名做 WebSocket 101 真连接校验,只保留真正可用的 Cloudflare IP;兼容任意 VLESS-WS 项目",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "CFPilot Contributors",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/xmwengxing/CFPilot.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"cloudflare",
|
|
13
|
+
"ip",
|
|
14
|
+
"optimize",
|
|
15
|
+
"scanner",
|
|
16
|
+
"vless",
|
|
17
|
+
"websocket",
|
|
18
|
+
"cfpilot"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"cfpilot-scanner": "dist/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.17.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node scripts/build.mjs",
|
|
33
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^26.6.5",
|
|
38
|
+
"esbuild": "^0.28.2",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|