cfpilot-scanner 0.4.0 → 0.6.4

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.
Files changed (3) hide show
  1. package/README.md +198 -198
  2. package/dist/cli.js +223 -78
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,198 +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
- > ⚠️ 这是最容易踩的坑,也是「结果看起来正常但导入客户端全是 -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
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
@@ -406,69 +406,6 @@ function readIpFile(text) {
406
406
  return out;
407
407
  }
408
408
 
409
- // src/candidates.ts
410
- var DEFAULT_CANDIDATE_SAMPLE = 2e4;
411
- var DEFAULT_CANDIDATE_SEED = 24301;
412
- var DEFAULT_BRIDGE_SAMPLE = 6556;
413
- function normalizeCidrInput(cidr) {
414
- if (cidr === void 0) return [];
415
- const list = Array.isArray(cidr) ? cidr : [cidr];
416
- const out = [];
417
- for (const item of list) {
418
- if (typeof item !== "string") continue;
419
- for (const part of item.split(/[\n,]+/)) {
420
- const value = part.trim();
421
- if (value) out.push(value);
422
- }
423
- }
424
- return out;
425
- }
426
- function isIpv6Cidr(cidr) {
427
- return cidr.includes(":");
428
- }
429
- function filterByFamily(cidrs, family) {
430
- if (family === "both") return cidrs;
431
- const wantIpv6 = family === "v6";
432
- return cidrs.filter((cidr) => isIpv6Cidr(cidr) === wantIpv6);
433
- }
434
- async function loadCloudflareCidrs(family) {
435
- let ipv4 = BUILTIN_IPV4_CIDRS;
436
- let ipv6 = BUILTIN_IPV6_CIDRS;
437
- try {
438
- const ranges = await fetchCloudflareCidrs();
439
- if (ranges.ipv4.length > 0) ipv4 = ranges.ipv4;
440
- if (ranges.ipv6.length > 0) ipv6 = ranges.ipv6;
441
- } catch {
442
- }
443
- if (family === "v4") return [...ipv4];
444
- if (family === "v6") return [...ipv6];
445
- return [...ipv4, ...ipv6];
446
- }
447
- async function resolveCandidates(opts = {}) {
448
- const sample = opts.sample ?? DEFAULT_CANDIDATE_SAMPLE;
449
- const seed = opts.seed ?? DEFAULT_CANDIDATE_SEED;
450
- const family = opts.family ?? "both";
451
- const customCidrs = normalizeCidrInput(opts.cidr);
452
- if (customCidrs.length > 0) {
453
- return expandCidrs(filterByFamily(customCidrs, family), { sample, seed });
454
- }
455
- const cidrs = await loadCloudflareCidrs(family);
456
- return expandCidrs(cidrs, { sample, seed });
457
- }
458
- function normalizeCandidateList(input) {
459
- if (!Array.isArray(input) || input.length === 0) return [];
460
- const lines = input.filter((item) => typeof item === "string");
461
- if (lines.length === 0) return [];
462
- const seen = /* @__PURE__ */ new Set();
463
- const out = [];
464
- for (const ip of readIpFile(lines.join("\n"))) {
465
- if (seen.has(ip)) continue;
466
- seen.add(ip);
467
- out.push(ip);
468
- }
469
- return out;
470
- }
471
-
472
409
  // src/net-env.ts
473
410
  import * as net from "node:net";
474
411
  import { execFile } from "node:child_process";
@@ -532,6 +469,44 @@ function isFakeIp(ip) {
532
469
  const [a, b] = parts;
533
470
  return a === 198 && (b === 18 || b === 19);
534
471
  }
472
+ function isPublicAddress(ip) {
473
+ const value = ip.trim();
474
+ if (!value) return false;
475
+ if (!value.includes(":")) {
476
+ const parts = value.split(".");
477
+ if (parts.length !== 4) return false;
478
+ const nums = [];
479
+ for (const p of parts) {
480
+ if (!/^\d{1,3}$/.test(p)) return false;
481
+ const n = Number(p);
482
+ if (n > 255) return false;
483
+ nums.push(n);
484
+ }
485
+ const [a, b] = nums;
486
+ if (a === 0) return false;
487
+ if (a === 10) return false;
488
+ if (a === 127) return false;
489
+ if (a >= 224) return false;
490
+ if (a === 169 && b === 254) return false;
491
+ if (a === 172 && b >= 16 && b <= 31) return false;
492
+ if (a === 192 && b === 168) return false;
493
+ if (a === 100 && b >= 64 && b <= 127) return false;
494
+ if (a === 198 && (b === 18 || b === 19)) return false;
495
+ return true;
496
+ }
497
+ const lower = value.toLowerCase();
498
+ if (lower.startsWith("::ffff:") && lower.includes(".")) {
499
+ return isPublicAddress(lower.slice(7));
500
+ }
501
+ const groups = lower.split(":");
502
+ if (groups.length < 3) return false;
503
+ const first = groups[0];
504
+ if (!/^[0-9a-f]{1,4}$/.test(first)) return false;
505
+ const firstByte = Number.parseInt(first.slice(0, 2).padEnd(2, "0"), 16);
506
+ if (firstByte < 32 || firstByte > 63) return false;
507
+ if (lower.startsWith("2001:db8:")) return false;
508
+ return true;
509
+ }
535
510
  async function detectProxyInterference(host) {
536
511
  const findings = [];
537
512
  const listening = [];
@@ -574,6 +549,74 @@ function isMsysPollutedPath(value) {
574
549
  return looksWindows || hasMsysHint;
575
550
  }
576
551
 
552
+ // src/candidates.ts
553
+ var DEFAULT_CANDIDATE_SAMPLE = 2e4;
554
+ var DEFAULT_CANDIDATE_SEED = 24301;
555
+ var DEFAULT_BRIDGE_SAMPLE = 6556;
556
+ function normalizeCidrInput(cidr) {
557
+ if (cidr === void 0) return [];
558
+ const list = Array.isArray(cidr) ? cidr : [cidr];
559
+ const out = [];
560
+ for (const item of list) {
561
+ if (typeof item !== "string") continue;
562
+ for (const part of item.split(/[\n,]+/)) {
563
+ const value = part.trim();
564
+ if (value) out.push(value);
565
+ }
566
+ }
567
+ return out;
568
+ }
569
+ function isIpv6Cidr(cidr) {
570
+ return cidr.includes(":");
571
+ }
572
+ function filterByFamily(cidrs, family) {
573
+ if (family === "both") return cidrs;
574
+ const wantIpv6 = family === "v6";
575
+ return cidrs.filter((cidr) => isIpv6Cidr(cidr) === wantIpv6);
576
+ }
577
+ async function loadCloudflareCidrs(family) {
578
+ let ipv4 = BUILTIN_IPV4_CIDRS;
579
+ let ipv6 = BUILTIN_IPV6_CIDRS;
580
+ try {
581
+ const ranges = await fetchCloudflareCidrs();
582
+ if (ranges.ipv4.length > 0) ipv4 = ranges.ipv4;
583
+ if (ranges.ipv6.length > 0) ipv6 = ranges.ipv6;
584
+ } catch {
585
+ }
586
+ if (family === "v4") return [...ipv4];
587
+ if (family === "v6") return [...ipv6];
588
+ return [...ipv4, ...ipv6];
589
+ }
590
+ async function resolveCandidates(opts = {}) {
591
+ const sample = opts.sample ?? DEFAULT_CANDIDATE_SAMPLE;
592
+ const seed = opts.seed ?? DEFAULT_CANDIDATE_SEED;
593
+ const family = opts.family ?? "both";
594
+ const customCidrs = normalizeCidrInput(opts.cidr);
595
+ if (customCidrs.length > 0) {
596
+ return expandCidrs(filterByFamily(customCidrs, family), { sample, seed });
597
+ }
598
+ const cidrs = await loadCloudflareCidrs(family);
599
+ return expandCidrs(cidrs, { sample, seed });
600
+ }
601
+ function normalizeCandidateList(input) {
602
+ if (!Array.isArray(input) || input.length === 0) return [];
603
+ const lines = input.filter((item) => typeof item === "string");
604
+ if (lines.length === 0) return [];
605
+ const seen = /* @__PURE__ */ new Set();
606
+ const out = [];
607
+ for (const ip of readIpFile(lines.join("\n"))) {
608
+ if (seen.has(ip)) continue;
609
+ seen.add(ip);
610
+ const isIpv4Literal = /^\d{1,3}(\.\d{1,3}){3}$/.test(ip);
611
+ const isIpv6Literal = ip.includes(":");
612
+ if (isIpv4Literal || isIpv6Literal) {
613
+ if (!isPublicAddress(ip)) continue;
614
+ }
615
+ out.push(ip);
616
+ }
617
+ return out;
618
+ }
619
+
577
620
  // src/speed-url.ts
578
621
  import * as tls from "node:tls";
579
622
  var SPEED_URL_PRESETS = [
@@ -1307,10 +1350,20 @@ async function pool(items, concurrency, worker) {
1307
1350
  await Promise.all(runners);
1308
1351
  return out;
1309
1352
  }
1353
+ async function withRetry(attempts, fn, isOk = (r) => r.ok === true) {
1354
+ const total = Math.max(1, Math.floor(attempts));
1355
+ let last;
1356
+ for (let i = 0; i < total; i++) {
1357
+ last = await fn();
1358
+ if (isOk(last)) return last;
1359
+ }
1360
+ return last;
1361
+ }
1310
1362
  async function runScan(options) {
1311
1363
  const started = Date.now();
1312
1364
  const concurrency = options.concurrency ?? 300;
1313
1365
  const tcpTimeoutMs = options.tcpTimeoutMs ?? 1200;
1366
+ const retries = options.retries ?? 2;
1314
1367
  const tlsConcurrency = options.tlsConcurrency ?? 64;
1315
1368
  const tlsTimeoutMs = options.tlsTimeoutMs ?? 4e3;
1316
1369
  const maxLatencyMs = options.maxLatencyMs ?? 0;
@@ -1328,7 +1381,7 @@ async function runScan(options) {
1328
1381
  report({ type: "stage", stage: 1, name: "TCP \u63E1\u624B\u521D\u7B5B", total: candidates.length });
1329
1382
  let done = 0;
1330
1383
  const tcpResults = await pool(candidates, concurrency, async (ip) => {
1331
- const result = await tcpPing(ip, options.port, tcpTimeoutMs);
1384
+ const result = await withRetry(retries, () => tcpPing(ip, options.port, tcpTimeoutMs));
1332
1385
  done++;
1333
1386
  if (done % 500 === 0 || done === candidates.length) {
1334
1387
  report({
@@ -1346,7 +1399,11 @@ async function runScan(options) {
1346
1399
  report({ type: "stage", stage: 1, name: "TLS \u63E1\u624B\u7B5B\u9009", total: filtered.length });
1347
1400
  let tlsDone = 0;
1348
1401
  const tlsResults = await pool(filtered, tlsConcurrency, async (item) => {
1349
- const probe = await probeTlsHandshake(item.ip, options.port, options.host, tlsTimeoutMs);
1402
+ const probe = await withRetry(
1403
+ retries,
1404
+ () => probeTlsHandshake(item.ip, options.port, options.host, tlsTimeoutMs),
1405
+ (r) => r.tlsMs > 0
1406
+ );
1350
1407
  tlsDone++;
1351
1408
  if (tlsDone % 200 === 0 || tlsDone === filtered.length) {
1352
1409
  report({
@@ -1374,12 +1431,9 @@ async function runScan(options) {
1374
1431
  const probeResults = await pool(stage1, probeConcurrency, async (item) => {
1375
1432
  const probeFn = strictProbe ? probeWsPing : probeWs101;
1376
1433
  const probePath = strictProbe ? strictProbePath : options.wsPath ?? "/__edge_probe";
1377
- const result = await probeFn(
1378
- item.ip,
1379
- options.port,
1380
- options.host,
1381
- probeTimeoutMs,
1382
- probePath
1434
+ const result = await withRetry(
1435
+ retries,
1436
+ () => probeFn(item.ip, options.port, options.host, probeTimeoutMs, probePath)
1383
1437
  );
1384
1438
  probeDone++;
1385
1439
  if (result.pongVerified) verifiedCount++;
@@ -1532,6 +1586,65 @@ async function postResults(callbackUrl, token, entries, timeoutMs = 15e3) {
1532
1586
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
1533
1587
  }
1534
1588
  }
1589
+ async function fetchScannerToken(panelHost, password, timeoutMs = 25e3) {
1590
+ let base = panelHost.trim();
1591
+ if (!base) return { ok: false, error: "\u7F3A\u5C11\u63A7\u5236\u9762\u57DF\u540D\uFF08--panel\uFF09" };
1592
+ if (!/^https?:\/\//i.test(base)) base = "https://" + base;
1593
+ base = base.replace(/\/$/, "");
1594
+ try {
1595
+ const loginRes = await fetch(base + "/api/v1/login", {
1596
+ method: "POST",
1597
+ headers: { "Content-Type": "application/json" },
1598
+ body: JSON.stringify({ password }),
1599
+ signal: AbortSignal.timeout(timeoutMs)
1600
+ });
1601
+ if (loginRes.status === 401) {
1602
+ return { ok: false, error: "\u9762\u677F\u5BC6\u7801\u4E0D\u6B63\u786E" };
1603
+ }
1604
+ if (!loginRes.ok) {
1605
+ const text = await loginRes.text().catch(() => "");
1606
+ return { ok: false, error: `\u767B\u5F55\u5931\u8D25 HTTP ${loginRes.status} ${text.slice(0, 120)}` };
1607
+ }
1608
+ const headersWithCookies = loginRes.headers;
1609
+ const raw = headersWithCookies.getSetCookie?.() ?? [];
1610
+ const cookie = raw.map((c) => (c.split(";")[0] ?? "").trim()).filter(Boolean).join("; ");
1611
+ if (!cookie) {
1612
+ const single = loginRes.headers.get("set-cookie");
1613
+ if (!single) {
1614
+ return { ok: false, error: "\u767B\u5F55\u6210\u529F\u4F46\u670D\u52A1\u7AEF\u6CA1\u6709\u4E0B\u53D1\u4F1A\u8BDD Cookie" };
1615
+ }
1616
+ return await takeToken(base, (single.split(";")[0] ?? "").trim(), timeoutMs);
1617
+ }
1618
+ return await takeToken(base, cookie, timeoutMs);
1619
+ } catch (err) {
1620
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1621
+ }
1622
+ }
1623
+ async function takeToken(base, cookie, timeoutMs) {
1624
+ const res = await fetch(base + "/api/v1/scanner-token", {
1625
+ method: "POST",
1626
+ headers: { Cookie: cookie },
1627
+ signal: AbortSignal.timeout(timeoutMs)
1628
+ });
1629
+ if (!res.ok) {
1630
+ const text2 = await res.text().catch(() => "");
1631
+ return { ok: false, error: `\u83B7\u53D6\u4EE4\u724C\u5931\u8D25 HTTP ${res.status} ${text2.slice(0, 120)}` };
1632
+ }
1633
+ const text = await res.text();
1634
+ if (text.trimStart().startsWith("<")) {
1635
+ return {
1636
+ ok: false,
1637
+ error: "\u9762\u677F\u8FD4\u56DE\u4E86\u7F51\u9875\u800C\u4E0D\u662F\u63A5\u53E3\u6570\u636E\uFF0C\u8BF7\u786E\u8BA4 --panel \u586B\u7684\u662F\u63A7\u5236\u9762\u57DF\u540D"
1638
+ };
1639
+ }
1640
+ try {
1641
+ const data = JSON.parse(text);
1642
+ if (!data.token) return { ok: false, error: "\u9762\u677F\u6CA1\u6709\u8FD4\u56DE\u4EE4\u724C" };
1643
+ return { ok: true, token: data.token };
1644
+ } catch {
1645
+ return { ok: false, error: "\u4EE4\u724C\u54CD\u5E94\u89E3\u6790\u5931\u8D25\uFF1A" + text.slice(0, 120) };
1646
+ }
1647
+ }
1535
1648
 
1536
1649
  // src/server.ts
1537
1650
  import { createServer } from "node:http";
@@ -1804,7 +1917,7 @@ async function handleRequest(req, res) {
1804
1917
  }
1805
1918
 
1806
1919
  // src/version.ts
1807
- var VERSION = "0.4.0";
1920
+ var VERSION = "0.6.4";
1808
1921
  var NAME = "cfpilot-scanner";
1809
1922
 
1810
1923
  // src/cli.ts
@@ -1823,8 +1936,11 @@ CFPilot \u672C\u5730\u771F\u8FDE\u63A5\u4F18\u9009\u5668 \u2014\u2014 \u7528\u4F
1823
1936
  scan \u9009\u9879:
1824
1937
  --host <domain> \u5FC5\u586B\uFF1A\u4F60\u7684 Worker \u81EA\u5B9A\u4E49\u57DF\u540D\uFF08\u4F5C\u4E3A TLS SNI \u4E0E HTTP Host\uFF09
1825
1938
  --port <port> \u7AEF\u53E3\uFF0C\u9ED8\u8BA4 443
1826
- --token <token> \u56DE\u586B\u4EE4\u724C\uFF08\u9762\u677F\u751F\u6210\u7684\u4E00\u6B21\u6027\u4EE4\u724C\u6216 SCANNER_TOKEN\uFF09
1827
- --callback <url> \u56DE\u586B\u5730\u5740\uFF0C\u9ED8\u8BA4 https://<host>/api/v1/preferred-ips
1939
+ --panel <domain> \u63A7\u5236\u9762\u57DF\u540D\uFF08\u5982 cpmanager.pages.dev\uFF09\uFF0C\u56DE\u586B\u5FC5\u586B
1940
+ \u4E0E --host \u4E0D\u540C\uFF1A--host \u662F\u6570\u636E\u9762\uFF0C\u9762\u677F\u63A5\u53E3\u5728\u63A7\u5236\u9762
1941
+ --panel-password <pw> \u9762\u677F\u5BC6\u7801\uFF0C\u914D\u5408 --panel \u81EA\u52A8\u83B7\u53D6\u4E34\u65F6\u4EE4\u724C
1942
+ --token <token> \u56DE\u586B\u4EE4\u724C\uFF08\u4E0D\u60F3\u7ED9\u5BC6\u7801\u65F6\u624B\u52A8\u6307\u5B9A\uFF09
1943
+ --callback <url> \u81EA\u5B9A\u4E49\u56DE\u586B\u5730\u5740\uFF08\u9ED8\u8BA4\u7531 --panel \u63A8\u5BFC\uFF09
1828
1944
  --no-callback \u53EA\u6253\u5370\u7ED3\u679C\uFF0C\u4E0D\u56DE\u586B
1829
1945
  --path <path> \u9636\u6BB5\u4E8C\u6821\u9A8C\u7684 WebSocket \u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
1830
1946
  \uFF08\u5BF9\u63A5\u5176\u5B83\u9879\u76EE\u65F6\u586B\u5B83\u81EA\u5DF1\u7684 WS \u8DEF\u5F84\uFF0C\u4F8B\u5982 /vless \u6216 /<uuid>\uFF09
@@ -1838,6 +1954,8 @@ scan \u9009\u9879:
1838
1954
  --speed-conns <n> \u6D4B\u901F\u5E76\u53D1\u8FDE\u63A5\u6570\uFF0C\u9ED8\u8BA4 3
1839
1955
  \u5355\u6761 TCP \u8FDE\u63A5\u53D7\u62E5\u585E\u7A97\u53E3\u9650\u5236\uFF0C\u591A\u8FDE\u63A5\u66F4\u63A5\u8FD1\u771F\u5B9E\u5E26\u5BBD
1840
1956
  --speed-duration <ms> \u6D4B\u901F\u65F6\u957F\uFF0C\u9ED8\u8BA4 6000
1957
+ --retries <n> \u6BCF\u4E2A\u63A2\u6D4B\u7684\u5C1D\u8BD5\u6B21\u6570\uFF08\u542B\u9996\u6B21\uFF09\uFF0C\u9ED8\u8BA4 2
1958
+ \u771F\u673A\u5076\u53D1\u5931\u8D25\u7387 3~5%\uFF0C\u91CD\u8BD5\u53D6\u7B2C\u4E00\u6B21\u6210\u529F\u7684\u7ED3\u679C
1841
1959
  --no-verify \u9636\u6BB5\u4E8C\u53EA\u505A 101 \u63E1\u624B\uFF0C\u8DF3\u8FC7\u54CD\u5E94\u4F53\u6821\u9A8C
1842
1960
  \uFF08\u9ED8\u8BA4\u6821\u9A8C\uFF1A\u53D1 PING \u5E76\u9A8C\u8BC1\u6570\u636E\u9762\u56DE {"pong":true}\uFF09
1843
1961
  --no-speed \u8DF3\u8FC7\u6D4B\u901F\uFF0C\u53EA\u6309\u5EF6\u8FDF\u6392\u5E8F
@@ -1845,7 +1963,7 @@ scan \u9009\u9879:
1845
1963
  --concurrency <n> \u9636\u6BB5\u4E00 TCP \u5E76\u53D1\uFF0C\u9ED8\u8BA4 300
1846
1964
  --tls-concurrency <n> \u9636\u6BB5\u4E00 TLS \u63E1\u624B\u5E76\u53D1\uFF0C\u9ED8\u8BA4 64
1847
1965
  TLS \u63E1\u624B\u662F CPU \u5BC6\u96C6\u578B\u64CD\u4F5C\uFF0C\u8FC7\u9AD8\u4F1A\u8BA9\u672C\u673A\u6210\u4E3A\u74F6\u9888
1848
- --sample <n> \u5019\u9009 IP \u91C7\u6837\u6570\uFF0C\u9ED8\u8BA4 20000
1966
+ --sample <n> \u5019\u9009 IP \u91C7\u6837\u6570\uFF0C\u9ED8\u8BA4 6556\uFF08\u4E0E\u754C\u9762\u4E00\u81F4\uFF1B\u4E0D\u5B9C\u8D85\u8FC7 23000\uFF09
1849
1967
  \u5EFA\u8BAE 6556 \u8D77\uFF1A\u9636\u6BB5\u4E00\u6309 TLS \u63E1\u624B\u8017\u65F6\u7B5B\u9009\uFF0C\u800C\u63E1\u624B\u5FEB\u6162
1850
1968
  \u4E0E TCP \u5EF6\u8FDF\u51E0\u4E4E\u65E0\u5173\uFF08\u5B9E\u6D4B TCP 9ms \u7684 IP \u53EF\u80FD\u8981 3.5 \u79D2\uFF09\uFF0C
1851
1969
  \u91C7\u6837\u5C0F\u4E86\u6839\u672C\u78B0\u4E0D\u5230\u63E1\u624B\u5FEB\u7684 IP
@@ -1960,7 +2078,7 @@ async function resolveCandidatesFromArgs(args) {
1960
2078
  return readIpFile(text);
1961
2079
  }
1962
2080
  return resolveCandidates({
1963
- sample: flagNumber(args, "sample", 2e4),
2081
+ sample: flagNumber(args, "sample", 6556),
1964
2082
  cidr: flagList(args, "cidr"),
1965
2083
  seed: flagNumber(args, "seed", 24301)
1966
2084
  });
@@ -2024,14 +2142,40 @@ async function commandScan(args) {
2024
2142
  }
2025
2143
  const port = flagNumber(args, "port", 443);
2026
2144
  const top = flagNumber(args, "top", 15);
2027
- const token = flagString(args, "token");
2145
+ let token = flagString(args, "token");
2028
2146
  const noCallback = flagBool(args, "no-callback");
2029
2147
  const wsPath = normalizeWsPath(flagString(args, "path"), "/__edge_probe");
2030
2148
  const probePath = normalizeWsPath(flagString(args, "probe-path"), "/__edge_probe");
2031
2149
  const noSpeed = flagBool(args, "no-speed");
2032
2150
  const verifyPayload = !flagBool(args, "no-verify");
2033
2151
  const countries = normalizeCountryFilter(flagList(args, "countries"));
2034
- const callback = noCallback ? "" : flagString(args, "callback", token ? `https://${host}/api/v1/preferred-ips` : "");
2152
+ const panelHost = flagString(args, "panel");
2153
+ const panelPassword = flagString(args, "panel-password");
2154
+ const callbackBase = panelHost || host;
2155
+ let autoTokenUsed = false;
2156
+ if (!token && !noCallback && panelHost && panelPassword) {
2157
+ if (!quiet) {
2158
+ process.stdout.write(`\u6B63\u5728\u5411 ${panelHost} \u7533\u8BF7\u4E34\u65F6\u4EE4\u724C\u2026
2159
+ `);
2160
+ }
2161
+ const tk = await fetchScannerToken(panelHost, panelPassword);
2162
+ if (tk.ok && tk.token) {
2163
+ token = tk.token;
2164
+ autoTokenUsed = true;
2165
+ if (!quiet) {
2166
+ process.stdout.write("\u2713 \u5DF2\u83B7\u53D6\u4E34\u65F6\u4EE4\u724C\uFF0810 \u5206\u949F\u6709\u6548\uFF09\n");
2167
+ }
2168
+ } else if (!quiet) {
2169
+ process.stderr.write(`\u26A0 \u81EA\u52A8\u83B7\u53D6\u4EE4\u724C\u5931\u8D25\uFF1A${tk.error}
2170
+ `);
2171
+ process.stderr.write(" \u53EF\u6539\u7528 --token <\u4EE4\u724C> \u624B\u52A8\u6307\u5B9A\n");
2172
+ }
2173
+ }
2174
+ const callback = noCallback ? "" : flagString(
2175
+ args,
2176
+ "callback",
2177
+ token || autoTokenUsed ? `https://${callbackBase}/api/v1/preferred-ips` : ""
2178
+ );
2035
2179
  if (!quiet) {
2036
2180
  process.stdout.write(`
2037
2181
  ${NAME} v${VERSION}
@@ -2121,6 +2265,7 @@ ${NAME} v${VERSION}
2121
2265
  skipSpeed,
2122
2266
  verifyPayload,
2123
2267
  concurrency: flagNumber(args, "concurrency", 300),
2268
+ retries: flagNumber(args, "retries", 2),
2124
2269
  tcpTimeoutMs: flagNumber(args, "timeout", 1200),
2125
2270
  tlsConcurrency: flagNumber(args, "tls-concurrency", 64),
2126
2271
  tlsTimeoutMs: flagNumber(args, "tls-timeout", 4e3),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cfpilot-scanner",
3
- "version": "0.4.0",
3
+ "version": "0.6.4",
4
4
  "description": "CFPilot 优选器:用你自己的域名做 WebSocket 101 真连接校验,只保留真正可用的 Cloudflare IP;兼容任意 VLESS-WS 项目",
5
5
  "license": "MIT",
6
6
  "author": "CFPilot Contributors",