cfpilot-scanner 0.1.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 +198 -89
- package/dist/cli.js +721 -54
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,89 +1,198 @@
|
|
|
1
|
-
# cfpilot-scanner
|
|
2
|
-
|
|
3
|
-
**CFPilot 本地真连接优选器** —— 用你自己的 Worker 域名做 SNI/Host,完成
|
|
4
|
-
**WebSocket 101 真连接校验**,只保留真正能连通的 Cloudflare 优选 IP,
|
|
5
|
-
彻底消灭客户端里的 `-1`。
|
|
6
|
-
|
|
7
|
-
> 绝大多数"优选 IP"工具只做 TCP 握手(端口通就算可用),
|
|
8
|
-
> 结果填进客户端大量 `-1`。本工具会真的发起 TLS + WebSocket 升级,
|
|
9
|
-
> 走完你自己 Worker 的入站路径,只有返回 `101` 的 IP 才会被保留。
|
|
10
|
-
|
|
11
|
-
## 安装
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npx -y cfpilot-scanner --help
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
无需安装,`npx` 直接运行;也可 `npm i -g cfpilot-scanner`。
|
|
18
|
-
|
|
19
|
-
## 快速开始
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
# 1. 在你的面板里生成一次性扫描令牌(有效期短,用完即废)
|
|
23
|
-
# 2. 执行优选(域名必须是绑好自定义域名的那个)
|
|
24
|
-
npx -y cfpilot-scanner scan --host cfpilot.example.com --token <令牌>
|
|
25
|
-
|
|
26
|
-
# 结果会自动回填到面板的「手动优选 IP」池
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
只打印不回填:
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
npx -y cfpilot-scanner scan --host cfpilot.example.com --no-callback --top 20
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
##
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
1
|
+
# cfpilot-scanner
|
|
2
|
+
|
|
3
|
+
**CFPilot 本地真连接优选器** —— 用你自己的 Worker 域名做 SNI/Host,完成
|
|
4
|
+
**WebSocket 101 真连接校验**,只保留真正能连通的 Cloudflare 优选 IP,
|
|
5
|
+
彻底消灭客户端里的 `-1`。
|
|
6
|
+
|
|
7
|
+
> 绝大多数"优选 IP"工具只做 TCP 握手(端口通就算可用),
|
|
8
|
+
> 结果填进客户端大量 `-1`。本工具会真的发起 TLS + WebSocket 升级,
|
|
9
|
+
> 走完你自己 Worker 的入站路径,只有返回 `101` 的 IP 才会被保留。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx -y cfpilot-scanner --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
无需安装,`npx` 直接运行;也可 `npm i -g cfpilot-scanner`。
|
|
18
|
+
|
|
19
|
+
## 快速开始
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 1. 在你的面板里生成一次性扫描令牌(有效期短,用完即废)
|
|
23
|
+
# 2. 执行优选(域名必须是绑好自定义域名的那个)
|
|
24
|
+
npx -y cfpilot-scanner scan --host cfpilot.example.com --token <令牌>
|
|
25
|
+
|
|
26
|
+
# 结果会自动回填到面板的「手动优选 IP」池
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
只打印不回填:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx -y cfpilot-scanner scan --host cfpilot.example.com --no-callback --top 20
|
|
33
|
+
```
|
|
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
|
+
|
|
67
|
+
## 对接任意 VLESS-WS 项目(CFNext / cfnew / BPB / edgetunnel)
|
|
68
|
+
|
|
69
|
+
不依赖 CFPilot 自己的探针端点,只要知道你项目的 **WebSocket 路径**即可:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# CFNext:路径通常就是 UUID(或面板里配置的 path)
|
|
73
|
+
npx -y cfpilot-scanner scan --host your-domain.com --path /<uuid> --no-callback
|
|
74
|
+
|
|
75
|
+
# 常见约定:/vless
|
|
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
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**路径必须与目标项目实际的 WebSocket 路径完全一致**,填错一个 IP 都选不出来。
|
|
83
|
+
不知道就分别试 `/` 和 `/vless`,能出结果的那个就是对的。
|
|
84
|
+
|
|
85
|
+
- 阶段二会对 `<path>` 发起真实的 **TLS + WebSocket 101** 握手,只有 101 才算可用
|
|
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
|
+
太短会被慢启动拉低,太长浪费时间。
|
|
129
|
+
|
|
130
|
+
## 输出:直接可复制的列表
|
|
131
|
+
|
|
132
|
+
扫描结束后会额外打印一段,**一行一个,直接粘进同类项目的「自定义 IP / 优选 IP」输入框**:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
── 可直接复制(粘贴到「自定义 IP / 优选 IP」输入框)──
|
|
136
|
+
104.19.73.179:443#LAX
|
|
137
|
+
104.17.242.168:443#LAX
|
|
138
|
+
104.16.59.243:443#LAX
|
|
139
|
+
104.27.19.224:443#AMS
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
(`IP:端口#机房` 是这类项目通用的语法;优选几个就有几行,不用再手动删改)
|
|
143
|
+
|
|
144
|
+
## 三阶扫描
|
|
145
|
+
|
|
146
|
+
| 阶段 | 做什么 | 默认开销 |
|
|
147
|
+
|------|--------|----------|
|
|
148
|
+
| 一阶 · 延迟探测 | 采样大量 IP 做 TCP 连接测延迟 | 采样 20000,并发 300 |
|
|
149
|
+
| 二阶 · 真连接校验 | 对候选发起 **TLS + WebSocket 101** 握手 | 120 个候选 |
|
|
150
|
+
| 三阶 · 下行测速 | 通过 `/__edge_probe` 流式下载测真实速度 | 30 个候选,5 MB |
|
|
151
|
+
|
|
152
|
+
只有通过二阶的 IP 才算"可用",三阶用于排序。
|
|
153
|
+
|
|
154
|
+
## 常用参数
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
--host <domain> 你的 Worker 自定义域名(SNI 与 Host)
|
|
158
|
+
--token <token> 面板生成的一次性令牌
|
|
159
|
+
--top <n> 保留条数(默认 15)
|
|
160
|
+
--concurrency <n> 一阶并发(默认 300)
|
|
161
|
+
--sample <n> 候选采样数(默认 20000)
|
|
162
|
+
--cidr <a,b> 自定义网段
|
|
163
|
+
--ip-file <path> 使用本地 IP 列表(支持 CloudflareSpeedTest 的 result.csv)
|
|
164
|
+
--max-latency <ms> 一阶延迟上限
|
|
165
|
+
--json 以 JSON 输出
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## 本地面板桥接
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npx -y cfpilot-scanner serve
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
在 `127.0.0.1:19527` 起一个本地服务,面板里点「本地真连接优选」会自动唤醒它,
|
|
175
|
+
通过 SSE 实时推送进度,不需要手动复制令牌。
|
|
176
|
+
|
|
177
|
+
## 输出示例
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
阶段一:采样 20000 个候选,并发 300 …
|
|
181
|
+
阶段二:120 个候选进入 101 校验 …
|
|
182
|
+
阶段三:30 个候选测速(5 MB)…
|
|
183
|
+
✅ 可用 14 个,已回填面板
|
|
184
|
+
|
|
185
|
+
104.17.242.168:443 12ms 18.4 MB/s
|
|
186
|
+
104.19.73.179:443 18ms 15.1 MB/s
|
|
187
|
+
...
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## 为什么需要它
|
|
191
|
+
|
|
192
|
+
随机抽取的 Cloudflare 官方 IP 里,能真正完成 WebSocket 升级的比例只有约 1/4,
|
|
193
|
+
而且**因地区、运营商、时间而异**。只有用你自己的域名实测过,
|
|
194
|
+
才能得到当前网络下真正可用的那批 IP。
|
|
195
|
+
|
|
196
|
+
## 许可
|
|
197
|
+
|
|
198
|
+
MIT
|
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,18 +363,246 @@ 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) {
|
|
337
602
|
const match = /cf-ray:\s*[0-9a-f]+-([A-Z]{3})/i.exec(text);
|
|
338
603
|
return match ? match[1].toUpperCase() : "";
|
|
339
604
|
}
|
|
340
|
-
function probeWs101(ip, port, host, timeoutMs = 3e3) {
|
|
605
|
+
function probeWs101(ip, port, host, timeoutMs = 3e3, path = "/__edge_probe") {
|
|
341
606
|
return new Promise((resolve) => {
|
|
342
607
|
const started = Date.now();
|
|
343
608
|
const key = crypto.randomBytes(16).toString("base64");
|
|
@@ -354,7 +619,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3) {
|
|
|
354
619
|
resolve({ ...result, latencyMs: Date.now() - started });
|
|
355
620
|
};
|
|
356
621
|
const request = [
|
|
357
|
-
`GET
|
|
622
|
+
`GET ${path} HTTP/1.1`,
|
|
358
623
|
`Host: ${host}`,
|
|
359
624
|
`Upgrade: websocket`,
|
|
360
625
|
`Connection: Upgrade`,
|
|
@@ -364,7 +629,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3) {
|
|
|
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,
|
|
@@ -405,7 +670,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3) {
|
|
|
405
670
|
});
|
|
406
671
|
});
|
|
407
672
|
}
|
|
408
|
-
function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
|
|
673
|
+
function measureDownload(ip, port, host, bytes, timeoutMs = 12e3, path = "/__edge_probe") {
|
|
409
674
|
return new Promise((resolve) => {
|
|
410
675
|
const started = Date.now();
|
|
411
676
|
let headerEnd = -1;
|
|
@@ -423,14 +688,14 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
|
|
|
423
688
|
resolve({ ...result, ms: Date.now() - started });
|
|
424
689
|
};
|
|
425
690
|
const request = [
|
|
426
|
-
`GET
|
|
691
|
+
`GET ${path}?bytes=${bytes} HTTP/1.1`,
|
|
427
692
|
`Host: ${host}`,
|
|
428
693
|
`User-Agent: ${USER_AGENT}`,
|
|
429
694
|
`Accept: */*`,
|
|
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,
|
|
@@ -463,6 +728,11 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
|
|
|
463
728
|
socket.on("error", (err) => {
|
|
464
729
|
finish({ ok: false, bytes: received, speedMBps: 0, error: err.message });
|
|
465
730
|
});
|
|
731
|
+
const settle = () => {
|
|
732
|
+
if (settled) return;
|
|
733
|
+
const seconds = Math.max(1e-3, (Date.now() - started) / 1e3);
|
|
734
|
+
finish({ ok: received > 0, bytes: received, speedMBps: received / 1024 / 1024 / seconds });
|
|
735
|
+
};
|
|
466
736
|
socket.on("data", (chunk) => {
|
|
467
737
|
if (headerEnd < 0) {
|
|
468
738
|
buffer += chunk.toString("latin1");
|
|
@@ -475,19 +745,224 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
|
|
|
475
745
|
const body = Buffer.from(buffer, "latin1").subarray(headerEnd);
|
|
476
746
|
received += body.byteLength;
|
|
477
747
|
headerEnd = 0;
|
|
478
|
-
|
|
748
|
+
} else {
|
|
749
|
+
received += chunk.byteLength;
|
|
479
750
|
}
|
|
480
|
-
received
|
|
751
|
+
if (received >= bytes) settle();
|
|
481
752
|
});
|
|
753
|
+
socket.on("end", settle);
|
|
754
|
+
socket.on("close", settle);
|
|
755
|
+
});
|
|
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
|
+
}
|
|
482
898
|
});
|
|
483
899
|
}
|
|
484
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
|
+
}
|
|
959
|
+
|
|
485
960
|
// src/scan.ts
|
|
486
961
|
async function tcpPing(ip, port, timeoutMs) {
|
|
487
962
|
return new Promise((resolve) => {
|
|
488
963
|
const started = Date.now();
|
|
489
964
|
let settled = false;
|
|
490
|
-
const socket = new
|
|
965
|
+
const socket = new net2.Socket();
|
|
491
966
|
const done = (ok) => {
|
|
492
967
|
if (settled) return;
|
|
493
968
|
settled = true;
|
|
@@ -554,7 +1029,7 @@ async function runScan(options) {
|
|
|
554
1029
|
report({ type: "stage", stage: 2, name: "WebSocket 101 \u771F\u8FDE\u63A5\u6821\u9A8C", total: stage1.length });
|
|
555
1030
|
let probeDone = 0;
|
|
556
1031
|
const probeResults = await pool(stage1, probeConcurrency, async (item) => {
|
|
557
|
-
const result = await probeWs101(item.ip, options.port, options.host, probeTimeoutMs);
|
|
1032
|
+
const result = await probeWs101(item.ip, options.port, options.host, probeTimeoutMs, options.wsPath ?? "/__edge_probe");
|
|
558
1033
|
probeDone++;
|
|
559
1034
|
report({
|
|
560
1035
|
type: "progress",
|
|
@@ -566,34 +1041,93 @@ async function runScan(options) {
|
|
|
566
1041
|
return { ...item, probe: result };
|
|
567
1042
|
});
|
|
568
1043
|
const wsOk = probeResults.filter((r) => r.probe.ok);
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
let
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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);
|
|
1070
|
+
let speedResults;
|
|
1071
|
+
const speedMode = options.speedMode ?? "probe";
|
|
1072
|
+
if (options.skipSpeed) {
|
|
1073
|
+
report({ type: "stage", stage: 3, name: "\u8DF3\u8FC7\u6D4B\u901F\uFF08\u6309\u5EF6\u8FDF\u6392\u5E8F\uFF09", total: stage2.length });
|
|
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 };
|
|
587
1100
|
});
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1101
|
+
} else {
|
|
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 });
|
|
1103
|
+
let speedDone = 0;
|
|
1104
|
+
speedResults = await pool(stage2, speedConcurrency, async (item) => {
|
|
1105
|
+
const result = await measureDownload(
|
|
1106
|
+
item.ip,
|
|
1107
|
+
options.port,
|
|
1108
|
+
options.host,
|
|
1109
|
+
speedBytes,
|
|
1110
|
+
speedTimeoutMs,
|
|
1111
|
+
options.probePath ?? "/__edge_probe"
|
|
1112
|
+
);
|
|
1113
|
+
speedDone++;
|
|
1114
|
+
report({
|
|
1115
|
+
type: "progress",
|
|
1116
|
+
stage: 3,
|
|
1117
|
+
done: speedDone,
|
|
1118
|
+
total: stage2.length,
|
|
1119
|
+
message: `\u6D4B\u901F ${speedDone}/${stage2.length} ${result.speedMBps.toFixed(1)} MB/s`
|
|
1120
|
+
});
|
|
1121
|
+
return { ...item, speed: result.ok ? result.speedMBps : 0 };
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
const entries = speedResults.filter((r) => options.skipSpeed ? true : Boolean(r.speed && r.speed > 0)).map((r) => ({
|
|
591
1125
|
ip: r.ip,
|
|
592
1126
|
port: options.port,
|
|
593
1127
|
colo: r.probe.colo || "UNK",
|
|
594
1128
|
latency: r.latency,
|
|
595
|
-
speed: Number(r.
|
|
596
|
-
})).sort((a, b) => b.speed - a.speed || a.latency - b.latency).slice(0, top);
|
|
1129
|
+
speed: r.speed ? Number(r.speed.toFixed(2)) : 0
|
|
1130
|
+
})).sort((a, b) => options.skipSpeed ? a.latency - b.latency : b.speed - a.speed || a.latency - b.latency).slice(0, top);
|
|
597
1131
|
for (const entry of entries) {
|
|
598
1132
|
report({ type: "result", entry });
|
|
599
1133
|
}
|
|
@@ -604,12 +1138,17 @@ async function runScan(options) {
|
|
|
604
1138
|
tcpOk: tcpOk.length,
|
|
605
1139
|
wsOk: wsOk.length,
|
|
606
1140
|
speedOk: entries.length,
|
|
607
|
-
elapsedMs: Date.now() - started
|
|
1141
|
+
elapsedMs: Date.now() - started,
|
|
1142
|
+
countries: appliedCountries,
|
|
1143
|
+
wsFiltered: wsFiltered.length
|
|
608
1144
|
}
|
|
609
1145
|
};
|
|
610
1146
|
report({ type: "done", count: entries.length });
|
|
611
1147
|
return summary;
|
|
612
1148
|
}
|
|
1149
|
+
function formatCopyable(entries) {
|
|
1150
|
+
return entries.map((e) => `${e.ip}:${e.port}#${e.colo}`).join("\n");
|
|
1151
|
+
}
|
|
613
1152
|
function formatEntries(entries) {
|
|
614
1153
|
return entries.map((e) => `${e.ip}:${e.port}#${e.colo}_${e.latency}_${e.speed}`);
|
|
615
1154
|
}
|
|
@@ -639,7 +1178,7 @@ async function postResults(callbackUrl, token, entries, timeoutMs = 15e3) {
|
|
|
639
1178
|
|
|
640
1179
|
// src/server.ts
|
|
641
1180
|
import { createServer } from "node:http";
|
|
642
|
-
var BRIDGE_VERSION = "0.
|
|
1181
|
+
var BRIDGE_VERSION = "0.2.0";
|
|
643
1182
|
var DEFAULT_BRIDGE_PORT = 19527;
|
|
644
1183
|
function normalizeSample(value) {
|
|
645
1184
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
@@ -839,6 +1378,8 @@ async function handleRequest(req, res) {
|
|
|
839
1378
|
host: body.host.trim(),
|
|
840
1379
|
port: body.port ?? 443,
|
|
841
1380
|
candidates: plan.candidates,
|
|
1381
|
+
// 国家过滤(可多选):不传 = 全量;无效代码在 normalizeCountryFilter 里被丢弃
|
|
1382
|
+
countries: Array.isArray(body.countries) ? body.countries : void 0,
|
|
842
1383
|
top: body.top ?? 15,
|
|
843
1384
|
onProgress: (event) => broadcast(job, event)
|
|
844
1385
|
});
|
|
@@ -906,7 +1447,7 @@ async function handleRequest(req, res) {
|
|
|
906
1447
|
}
|
|
907
1448
|
|
|
908
1449
|
// src/version.ts
|
|
909
|
-
var VERSION = "0.
|
|
1450
|
+
var VERSION = "0.2.0";
|
|
910
1451
|
var NAME = "cfpilot-scanner";
|
|
911
1452
|
|
|
912
1453
|
// src/cli.ts
|
|
@@ -928,9 +1469,24 @@ scan \u9009\u9879:
|
|
|
928
1469
|
--token <token> \u56DE\u586B\u4EE4\u724C\uFF08\u9762\u677F\u751F\u6210\u7684\u4E00\u6B21\u6027\u4EE4\u724C\u6216 SCANNER_TOKEN\uFF09
|
|
929
1470
|
--callback <url> \u56DE\u586B\u5730\u5740\uFF0C\u9ED8\u8BA4 https://<host>/api/v1/preferred-ips
|
|
930
1471
|
--no-callback \u53EA\u6253\u5370\u7ED3\u679C\uFF0C\u4E0D\u56DE\u586B
|
|
1472
|
+
--path <path> \u9636\u6BB5\u4E8C\u6821\u9A8C\u7684 WebSocket \u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
|
|
1473
|
+
\uFF08\u5BF9\u63A5\u5176\u5B83\u9879\u76EE\u65F6\u586B\u5B83\u81EA\u5DF1\u7684 WS \u8DEF\u5F84\uFF0C\u4F8B\u5982 /vless \u6216 /<uuid>\uFF09
|
|
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
|
|
931
1485
|
--top <n> \u8FD4\u56DE\u6761\u6570\uFF0C\u9ED8\u8BA4 15
|
|
932
1486
|
--concurrency <n> \u9636\u6BB5\u4E00\u5E76\u53D1\uFF0C\u9ED8\u8BA4 300
|
|
933
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
|
|
934
1490
|
--cidr <a,b> \u81EA\u5B9A\u4E49\u7F51\u6BB5\uFF08\u53EF\u91CD\u590D\uFF0C\u7528\u9017\u53F7\u5206\u9694\uFF09
|
|
935
1491
|
--ip-file <path> \u4F7F\u7528\u672C\u5730 IP \u5217\u8868\u6587\u4EF6
|
|
936
1492
|
--max-latency <ms> \u9636\u6BB5\u4E00\u5EF6\u8FDF\u4E0A\u9650\uFF0C\u9ED8\u8BA4 0\uFF08\u4E0D\u9650\uFF09
|
|
@@ -950,6 +1506,35 @@ serve \u9009\u9879:
|
|
|
950
1506
|
${NAME} scan --host cfpilot.example.com --token abc123
|
|
951
1507
|
${NAME} serve
|
|
952
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
|
+
}
|
|
953
1538
|
async function main() {
|
|
954
1539
|
const args = parseArgs(process.argv.slice(2));
|
|
955
1540
|
const command = args.command && !args.command.startsWith("-") ? args.command : "";
|
|
@@ -1057,6 +1642,11 @@ function printTable(entries) {
|
|
|
1057
1642
|
`);
|
|
1058
1643
|
});
|
|
1059
1644
|
}
|
|
1645
|
+
function printCopyable(entries) {
|
|
1646
|
+
if (entries.length === 0) return;
|
|
1647
|
+
process.stdout.write("\n\u2500\u2500 \u53EF\u76F4\u63A5\u590D\u5236\uFF08\u7C98\u8D34\u5230\u300C\u81EA\u5B9A\u4E49 IP / \u4F18\u9009 IP\u300D\u8F93\u5165\u6846\uFF09\u2500\u2500\n");
|
|
1648
|
+
process.stdout.write(formatCopyable(entries) + "\n");
|
|
1649
|
+
}
|
|
1060
1650
|
async function commandScan(args) {
|
|
1061
1651
|
const host = flagString(args, "host");
|
|
1062
1652
|
const quiet = flagBool(args, "quiet");
|
|
@@ -1070,6 +1660,10 @@ async function commandScan(args) {
|
|
|
1070
1660
|
const top = flagNumber(args, "top", 15);
|
|
1071
1661
|
const token = flagString(args, "token");
|
|
1072
1662
|
const noCallback = flagBool(args, "no-callback");
|
|
1663
|
+
const wsPath = normalizeWsPath(flagString(args, "path"), "/__edge_probe");
|
|
1664
|
+
const probePath = normalizeWsPath(flagString(args, "probe-path"), "/__edge_probe");
|
|
1665
|
+
const noSpeed = flagBool(args, "no-speed");
|
|
1666
|
+
const countries = normalizeCountryFilter(flagList(args, "countries"));
|
|
1073
1667
|
const callback = noCallback ? "" : flagString(args, "callback", token ? `https://${host}/api/v1/preferred-ips` : "");
|
|
1074
1668
|
if (!quiet) {
|
|
1075
1669
|
process.stdout.write(`
|
|
@@ -1077,8 +1671,31 @@ ${NAME} v${VERSION}
|
|
|
1077
1671
|
`);
|
|
1078
1672
|
process.stdout.write(`\u76EE\u6807\u57DF\u540D\uFF1A${host}:${port}\uFF08\u5C06\u4F5C\u4E3A TLS SNI \u4E0E HTTP Host\uFF09
|
|
1079
1673
|
`);
|
|
1080
|
-
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
|
+
}
|
|
1081
1697
|
}
|
|
1698
|
+
if (!quiet) process.stdout.write("\u6B63\u5728\u51C6\u5907 Cloudflare \u7F51\u6BB5\u2026\n");
|
|
1082
1699
|
const candidates = await resolveCandidatesFromArgs(args);
|
|
1083
1700
|
if (candidates.length === 0) {
|
|
1084
1701
|
process.stderr.write("\u5019\u9009 IP \u4E3A\u7A7A\uFF0C\u8BF7\u68C0\u67E5 --ip-file / --cidr \u53C2\u6570\n");
|
|
@@ -1086,10 +1703,55 @@ ${NAME} v${VERSION}
|
|
|
1086
1703
|
}
|
|
1087
1704
|
if (!quiet) process.stdout.write(`\u5019\u9009 IP\uFF1A${candidates.length} \u4E2A
|
|
1088
1705
|
`);
|
|
1706
|
+
let skipSpeed = noSpeed;
|
|
1707
|
+
let speedMode = "probe";
|
|
1708
|
+
let speedUrl = flagString(args, "speed-url", DEFAULT_SPEED_URL);
|
|
1709
|
+
if (!skipSpeed) {
|
|
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";
|
|
1733
|
+
if (!quiet) {
|
|
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");
|
|
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
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1089
1747
|
const summary = await runScan({
|
|
1090
1748
|
host,
|
|
1091
1749
|
port,
|
|
1092
1750
|
candidates,
|
|
1751
|
+
countries,
|
|
1752
|
+
wsPath,
|
|
1753
|
+
probePath,
|
|
1754
|
+
skipSpeed,
|
|
1093
1755
|
concurrency: flagNumber(args, "concurrency", 300),
|
|
1094
1756
|
tcpTimeoutMs: flagNumber(args, "timeout", 1200),
|
|
1095
1757
|
maxLatencyMs: flagNumber(args, "max-latency", 0),
|
|
@@ -1098,6 +1760,10 @@ ${NAME} v${VERSION}
|
|
|
1098
1760
|
speedLimit: flagNumber(args, "speed-limit", 30),
|
|
1099
1761
|
speedTimeoutMs: flagNumber(args, "speed-timeout", 12e3),
|
|
1100
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),
|
|
1101
1767
|
top,
|
|
1102
1768
|
onProgress: (event) => printProgress(event, quiet)
|
|
1103
1769
|
});
|
|
@@ -1108,6 +1774,7 @@ ${NAME} v${VERSION}
|
|
|
1108
1774
|
);
|
|
1109
1775
|
} else {
|
|
1110
1776
|
printTable(summary.entries);
|
|
1777
|
+
printCopyable(summary.entries);
|
|
1111
1778
|
}
|
|
1112
1779
|
if (callback && token) {
|
|
1113
1780
|
const result = await postResults(callback, token, formatEntries(summary.entries));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cfpilot-scanner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CFPilot
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "CFPilot 优选器:用你自己的域名做 WebSocket 101 真连接校验,只保留真正可用的 Cloudflare IP;兼容任意 VLESS-WS 项目",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CFPilot Contributors",
|
|
7
7
|
"repository": {
|