proxyctl 0.5.2__tar.gz → 0.5.3__tar.gz
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.
- {proxyctl-0.5.2 → proxyctl-0.5.3}/.gitignore +4 -1
- {proxyctl-0.5.2 → proxyctl-0.5.3}/PKG-INFO +31 -5
- {proxyctl-0.5.2 → proxyctl-0.5.3}/README.md +30 -4
- {proxyctl-0.5.2 → proxyctl-0.5.3}/pyproject.toml +1 -1
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/autostart.py +12 -6
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/check.py +82 -28
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/explain.py +64 -2
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/suggest_rules.py +45 -9
- {proxyctl-0.5.2 → proxyctl-0.5.3}/LICENSE +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/man/proxyctl.1 +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/cli.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/status.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/subscription.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/suggest.py +0 -0
- {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/trace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyctl
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: Proxy configuration lifecycle management for macOS and Linux
|
|
5
5
|
Project-URL: Homepage, https://github.com/crhan/proxyctl
|
|
6
6
|
Project-URL: Issues, https://github.com/crhan/proxyctl/issues
|
|
@@ -107,7 +107,7 @@ proxyctl 把 agent 友好度做成一等公民。完整接入协议见
|
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
109
|
proxyctl agent-guide # Agent 入门 markdown(注入当前路径/端口)
|
|
110
|
-
proxyctl agent-guide --list-sections #
|
|
110
|
+
proxyctl agent-guide --list-sections # 19 个段,按需取小块(省 token)
|
|
111
111
|
proxyctl --version --json # schema_version + supported_features 探测
|
|
112
112
|
proxyctl commands --json # 全部命令元数据(机读)
|
|
113
113
|
proxyctl commands --schema # 上面 JSON 的 JSON Schema
|
|
@@ -191,6 +191,30 @@ proxyctl recover # 不重启引擎,热重载 + 清 fakeip +
|
|
|
191
191
|
proxyctl recover --dry-run # 看 3 个 Clash API endpoint 再决定
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
### `doctor` —— 5 项健康分 + 引导建议(v0.5.0+)
|
|
195
|
+
```bash
|
|
196
|
+
proxyctl doctor # 5 项布尔健康分 + suggestions (warn/advisory)
|
|
197
|
+
proxyctl doctor --json # data.suggestions[] 含 21 条规则的检测结果
|
|
198
|
+
proxyctl doctor --suggest-only # 跳过 score 探测,仅跑建议引擎 (~0.18s)
|
|
199
|
+
proxyctl doctor --since 0.4.7 # 屏蔽指定版本后引入的规则 (CI 平滑迁移)
|
|
200
|
+
proxyctl doctor --no-suggest # 关闭建议引擎,恢复 v0.4.x 极简体验
|
|
201
|
+
proxyctl explain suggestion:<id> # 每条规则有触发条件 + 修复路径 topic
|
|
202
|
+
```
|
|
203
|
+
21 条规则覆盖订阅过期 / autostart binary 与 PATH 漂移 / API 安全配置 /
|
|
204
|
+
GeoIP 数据时效 / 代理组健康。详见 [AGENTS.md](AGENTS.md) "Doctor suggestions" 段。
|
|
205
|
+
用户屏蔽某条告警:`~/.config/proxyctl/suggestions.ignore`(一行一个 id 或 fingerprint)。
|
|
206
|
+
|
|
207
|
+
### `autostart` —— 自动启动 unit 管理(v0.5.0+)
|
|
208
|
+
```bash
|
|
209
|
+
proxyctl autostart # plist/unit 当前状态
|
|
210
|
+
proxyctl autostart inspect --json # 结构化输出含 binary/version/enabled/config_dir
|
|
211
|
+
proxyctl autostart sync --dry-run # 预览 PlanStep[]
|
|
212
|
+
proxyctl autostart sync # 同步 plist/unit 到当前 PATH binary + config_dir
|
|
213
|
+
```
|
|
214
|
+
当 doctor 报 `autostart.binary_mismatch` / `version_mismatch` /
|
|
215
|
+
`config_dir_mismatch` 时一键修复。macOS plist 用 plistlib 原地改保留
|
|
216
|
+
KeepAlive / RunAtLoad 等用户定制;Linux ExecStart 缺失时拒绝执行防误覆盖。
|
|
217
|
+
|
|
194
218
|
---
|
|
195
219
|
|
|
196
220
|
## 安装
|
|
@@ -202,7 +226,7 @@ uv tool install proxyctl # uv(推荐)
|
|
|
202
226
|
pipx install proxyctl # 或 pipx
|
|
203
227
|
pip install --user proxyctl # 或 pip
|
|
204
228
|
|
|
205
|
-
proxyctl --version # → proxyctl v0.
|
|
229
|
+
proxyctl --version # → proxyctl v0.5.1
|
|
206
230
|
proxyctl --help
|
|
207
231
|
```
|
|
208
232
|
|
|
@@ -268,13 +292,14 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
|
|
|
268
292
|
| `start / stop / restart / restart-clean` | 启停引擎 + DNS/代理注入 | ✅ | ✅ |
|
|
269
293
|
| `status` | 一站式系统面板 | — | ✅ |
|
|
270
294
|
| `check` | 4 阶段健康检查 | — | ✅ |
|
|
271
|
-
| `doctor` | 5 项健康打分 | — | ✅ |
|
|
295
|
+
| `doctor` | 5 项健康打分 + 21 条引导建议 (v0.5.0+) | — | ✅ |
|
|
272
296
|
| `trace <domain>` | 域名链路诊断 | — | ✅ |
|
|
273
297
|
| `audit [days] [apply]` | 日志驱动配置优化 | ✅ | ✅ |
|
|
274
298
|
| `bench [groups]` | 节点测速 (NDJSON) | — | ✅ |
|
|
275
299
|
| `fix` | 修复 DNS / 代理 / 热重载 | ✅ | ✅ |
|
|
276
300
|
| `recover` | 切网后软恢复 | ✅ | ✅ |
|
|
277
301
|
| `mode tun\|proxy` | 切换 TUN / 代理模式 | ✅ | ✅ |
|
|
302
|
+
| `autostart [inspect\|sync]` | 自动启动 unit 管理 (v0.5.0+) | ✅ | ✅ |
|
|
278
303
|
| `dns-lock / dns-unlock` | 启停 DNS 看门狗 | ✅ | ✅ |
|
|
279
304
|
| `daemon` | 管理 extra_daemons | ✅ | ✅ |
|
|
280
305
|
| `agent-guide / commands / explain` | agent 自描述三件套 | — | ✅ |
|
|
@@ -290,6 +315,7 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
|
|
|
290
315
|
| audit (含 apply) | ✅ | ✅ |
|
|
291
316
|
| recover (切网软恢复) | ✅ | ✅ |
|
|
292
317
|
| mode tun \| proxy 切换 | ✅ | ✅ |
|
|
318
|
+
| autostart inspect / sync | ✅ plistlib | ✅ systemd unit ExecStart |
|
|
293
319
|
| dns-lock 看门狗 | ✅ | N/A* |
|
|
294
320
|
|
|
295
321
|
\* Linux 下系统 DNS 由 systemd-resolved / NetworkManager / resolvconf
|
|
@@ -328,7 +354,7 @@ sing-box JSON config 解析、`trace` 的 sing-box 日志 grep、`engine` /
|
|
|
328
354
|
git clone https://github.com/crhan/proxyctl.git
|
|
329
355
|
cd proxyctl
|
|
330
356
|
uv sync --group dev # 装运行 + 测试依赖
|
|
331
|
-
uv run pytest # 跑
|
|
357
|
+
uv run pytest # 跑 685 个测试
|
|
332
358
|
uv run proxyctl status # 用本地源码版本
|
|
333
359
|
|
|
334
360
|
export PROXYCTL_DEBUG=1 # 调试模式
|
|
@@ -78,7 +78,7 @@ proxyctl 把 agent 友好度做成一等公民。完整接入协议见
|
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
80
|
proxyctl agent-guide # Agent 入门 markdown(注入当前路径/端口)
|
|
81
|
-
proxyctl agent-guide --list-sections #
|
|
81
|
+
proxyctl agent-guide --list-sections # 19 个段,按需取小块(省 token)
|
|
82
82
|
proxyctl --version --json # schema_version + supported_features 探测
|
|
83
83
|
proxyctl commands --json # 全部命令元数据(机读)
|
|
84
84
|
proxyctl commands --schema # 上面 JSON 的 JSON Schema
|
|
@@ -162,6 +162,30 @@ proxyctl recover # 不重启引擎,热重载 + 清 fakeip +
|
|
|
162
162
|
proxyctl recover --dry-run # 看 3 个 Clash API endpoint 再决定
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
+
### `doctor` —— 5 项健康分 + 引导建议(v0.5.0+)
|
|
166
|
+
```bash
|
|
167
|
+
proxyctl doctor # 5 项布尔健康分 + suggestions (warn/advisory)
|
|
168
|
+
proxyctl doctor --json # data.suggestions[] 含 21 条规则的检测结果
|
|
169
|
+
proxyctl doctor --suggest-only # 跳过 score 探测,仅跑建议引擎 (~0.18s)
|
|
170
|
+
proxyctl doctor --since 0.4.7 # 屏蔽指定版本后引入的规则 (CI 平滑迁移)
|
|
171
|
+
proxyctl doctor --no-suggest # 关闭建议引擎,恢复 v0.4.x 极简体验
|
|
172
|
+
proxyctl explain suggestion:<id> # 每条规则有触发条件 + 修复路径 topic
|
|
173
|
+
```
|
|
174
|
+
21 条规则覆盖订阅过期 / autostart binary 与 PATH 漂移 / API 安全配置 /
|
|
175
|
+
GeoIP 数据时效 / 代理组健康。详见 [AGENTS.md](AGENTS.md) "Doctor suggestions" 段。
|
|
176
|
+
用户屏蔽某条告警:`~/.config/proxyctl/suggestions.ignore`(一行一个 id 或 fingerprint)。
|
|
177
|
+
|
|
178
|
+
### `autostart` —— 自动启动 unit 管理(v0.5.0+)
|
|
179
|
+
```bash
|
|
180
|
+
proxyctl autostart # plist/unit 当前状态
|
|
181
|
+
proxyctl autostart inspect --json # 结构化输出含 binary/version/enabled/config_dir
|
|
182
|
+
proxyctl autostart sync --dry-run # 预览 PlanStep[]
|
|
183
|
+
proxyctl autostart sync # 同步 plist/unit 到当前 PATH binary + config_dir
|
|
184
|
+
```
|
|
185
|
+
当 doctor 报 `autostart.binary_mismatch` / `version_mismatch` /
|
|
186
|
+
`config_dir_mismatch` 时一键修复。macOS plist 用 plistlib 原地改保留
|
|
187
|
+
KeepAlive / RunAtLoad 等用户定制;Linux ExecStart 缺失时拒绝执行防误覆盖。
|
|
188
|
+
|
|
165
189
|
---
|
|
166
190
|
|
|
167
191
|
## 安装
|
|
@@ -173,7 +197,7 @@ uv tool install proxyctl # uv(推荐)
|
|
|
173
197
|
pipx install proxyctl # 或 pipx
|
|
174
198
|
pip install --user proxyctl # 或 pip
|
|
175
199
|
|
|
176
|
-
proxyctl --version # → proxyctl v0.
|
|
200
|
+
proxyctl --version # → proxyctl v0.5.1
|
|
177
201
|
proxyctl --help
|
|
178
202
|
```
|
|
179
203
|
|
|
@@ -239,13 +263,14 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
|
|
|
239
263
|
| `start / stop / restart / restart-clean` | 启停引擎 + DNS/代理注入 | ✅ | ✅ |
|
|
240
264
|
| `status` | 一站式系统面板 | — | ✅ |
|
|
241
265
|
| `check` | 4 阶段健康检查 | — | ✅ |
|
|
242
|
-
| `doctor` | 5 项健康打分 | — | ✅ |
|
|
266
|
+
| `doctor` | 5 项健康打分 + 21 条引导建议 (v0.5.0+) | — | ✅ |
|
|
243
267
|
| `trace <domain>` | 域名链路诊断 | — | ✅ |
|
|
244
268
|
| `audit [days] [apply]` | 日志驱动配置优化 | ✅ | ✅ |
|
|
245
269
|
| `bench [groups]` | 节点测速 (NDJSON) | — | ✅ |
|
|
246
270
|
| `fix` | 修复 DNS / 代理 / 热重载 | ✅ | ✅ |
|
|
247
271
|
| `recover` | 切网后软恢复 | ✅ | ✅ |
|
|
248
272
|
| `mode tun\|proxy` | 切换 TUN / 代理模式 | ✅ | ✅ |
|
|
273
|
+
| `autostart [inspect\|sync]` | 自动启动 unit 管理 (v0.5.0+) | ✅ | ✅ |
|
|
249
274
|
| `dns-lock / dns-unlock` | 启停 DNS 看门狗 | ✅ | ✅ |
|
|
250
275
|
| `daemon` | 管理 extra_daemons | ✅ | ✅ |
|
|
251
276
|
| `agent-guide / commands / explain` | agent 自描述三件套 | — | ✅ |
|
|
@@ -261,6 +286,7 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
|
|
|
261
286
|
| audit (含 apply) | ✅ | ✅ |
|
|
262
287
|
| recover (切网软恢复) | ✅ | ✅ |
|
|
263
288
|
| mode tun \| proxy 切换 | ✅ | ✅ |
|
|
289
|
+
| autostart inspect / sync | ✅ plistlib | ✅ systemd unit ExecStart |
|
|
264
290
|
| dns-lock 看门狗 | ✅ | N/A* |
|
|
265
291
|
|
|
266
292
|
\* Linux 下系统 DNS 由 systemd-resolved / NetworkManager / resolvconf
|
|
@@ -299,7 +325,7 @@ sing-box JSON config 解析、`trace` 的 sing-box 日志 grep、`engine` /
|
|
|
299
325
|
git clone https://github.com/crhan/proxyctl.git
|
|
300
326
|
cd proxyctl
|
|
301
327
|
uv sync --group dev # 装运行 + 测试依赖
|
|
302
|
-
uv run pytest # 跑
|
|
328
|
+
uv run pytest # 跑 685 个测试
|
|
303
329
|
uv run proxyctl status # 用本地源码版本
|
|
304
330
|
|
|
305
331
|
export PROXYCTL_DEBUG=1 # 调试模式
|
|
@@ -218,7 +218,7 @@ def inspect_runtime(static_result: dict[str, Any], backend, *,
|
|
|
218
218
|
r"^\S+\s+\S+\s+(\S+)\s+(\S+\s+\S+)\s+with\s+go(\S+)\s+(\S+)",
|
|
219
219
|
raw)
|
|
220
220
|
if m:
|
|
221
|
-
out["autostart_version"] = m.group(1)
|
|
221
|
+
out["autostart_version"] = m.group(1).lstrip("v")
|
|
222
222
|
except (subprocess.TimeoutExpired, OSError):
|
|
223
223
|
pass
|
|
224
224
|
|
|
@@ -439,17 +439,23 @@ def to_suggestions(inspect_result: dict[str, Any] | None, *,
|
|
|
439
439
|
})
|
|
440
440
|
|
|
441
441
|
# 5. version_mismatch —— 优先于 binary_mismatch 之外的独立信号
|
|
442
|
+
# 同一 binary 路径不可能版本不同;直接跳过避免 strip 不一致引发的误报。
|
|
442
443
|
autostart_ver = inspect_result.get("autostart_version")
|
|
443
|
-
|
|
444
|
+
same_binary = bool(binary and path_binary and binary == path_binary)
|
|
445
|
+
a_ver_norm = (autostart_ver or "").lstrip("v")
|
|
446
|
+
p_ver_norm = (path_version or "").lstrip("v")
|
|
447
|
+
if (autostart_ver and path_version
|
|
448
|
+
and not same_binary
|
|
449
|
+
and a_ver_norm != p_ver_norm):
|
|
444
450
|
out.append({
|
|
445
451
|
"id": "autostart.version_mismatch",
|
|
446
452
|
"severity": "advisory",
|
|
447
453
|
"actor": "user",
|
|
448
|
-
"title": (f"autostart 引擎版本 v{
|
|
449
|
-
f"≠ PATH v{
|
|
454
|
+
"title": (f"autostart 引擎版本 v{a_ver_norm} "
|
|
455
|
+
f"≠ PATH v{p_ver_norm}"),
|
|
450
456
|
"evidence": {
|
|
451
|
-
"autostart_version":
|
|
452
|
-
"path_version":
|
|
457
|
+
"autostart_version": a_ver_norm,
|
|
458
|
+
"path_version": p_ver_norm,
|
|
453
459
|
"autostart_binary": binary,
|
|
454
460
|
"path_binary": path_binary,
|
|
455
461
|
},
|
|
@@ -202,6 +202,9 @@ def _proxy_groups_section(api_base: str, api_secret: str,
|
|
|
202
202
|
if col > 0:
|
|
203
203
|
print(line.rstrip())
|
|
204
204
|
|
|
205
|
+
# 跟踪本次 check 输出已经展开过节点列表的子组(跨 print_group 调用复用)
|
|
206
|
+
expanded_subgroups: set[str] = set()
|
|
207
|
+
|
|
205
208
|
def print_group(gname: str):
|
|
206
209
|
"""打印单个组的摘要 + 成员;若 selector 成员也是组则递归展开。"""
|
|
207
210
|
g = proxies.get(gname)
|
|
@@ -248,31 +251,39 @@ def _proxy_groups_section(api_base: str, api_secret: str,
|
|
|
248
251
|
print(f" {CYAN}{gname}{NC}({gtype}) → {BOLD}{gnow}{NC} "
|
|
249
252
|
f"{ds(gnow_d)}ms [{count_str} of {total}]{tested_str}")
|
|
250
253
|
|
|
251
|
-
# selector/fallback
|
|
252
|
-
|
|
254
|
+
# v0.5.4:selector/fallback 类型 + now 指向子组时,
|
|
255
|
+
# 跳过顶层成员列表(避免跟子组节点重复),只展开 now 那一个子组。
|
|
256
|
+
# now 指向真节点时仍打印顶层成员(用户可能想看其他可选项)。
|
|
257
|
+
sub = (proxies.get(gnow) if g.get("type") in ("Selector", "Fallback")
|
|
258
|
+
else None)
|
|
259
|
+
now_is_subgroup = bool(sub and sub.get("all"))
|
|
260
|
+
if g.get("type") in ("Selector", "Fallback") and not now_is_subgroup:
|
|
253
261
|
print_members(gmembers, gnow)
|
|
254
|
-
|
|
255
|
-
sub = proxies.get(m)
|
|
256
|
-
if sub and sub.get("all"):
|
|
257
|
-
sub_type = "url" if sub.get("type") == "URLTest" else "sel"
|
|
258
|
-
sub_now = sub.get("now", "?")
|
|
259
|
-
sub_now_d = get_delay(sub_now)
|
|
260
|
-
sub_members = sub.get("all", [])
|
|
261
|
-
s_alive = sum(1 for x in sub_members if get_delay(x) > 0)
|
|
262
|
-
s_dead = sum(1 for x in sub_members if get_delay(x) == 0)
|
|
263
|
-
s_nodata = sum(1 for x in sub_members if get_delay(x) < 0)
|
|
264
|
-
sc = []
|
|
265
|
-
if s_alive: sc.append(f"{GREEN}{s_alive}✓{NC}")
|
|
266
|
-
if s_dead: sc.append(f"{RED}{s_dead}✗{NC}")
|
|
267
|
-
if s_nodata: sc.append(f"{YELLOW}{s_nodata}—{NC}")
|
|
268
|
-
st = group_tested_ago(sub_members)
|
|
269
|
-
st_str = f" {DIM}{st}{NC}" if st else ""
|
|
270
|
-
active = " ←" if m == gnow else ""
|
|
271
|
-
print(f" {CYAN}{m}{NC}({sub_type}) → {BOLD}{sub_now}{NC} "
|
|
272
|
-
f"{ds(sub_now_d)}ms [{'/'.join(sc)} of {len(sub_members)}]{st_str}{active}")
|
|
273
|
-
print_members(sub_members, sub_now)
|
|
274
|
-
else:
|
|
262
|
+
elif g.get("type") not in ("Selector", "Fallback"):
|
|
275
263
|
print_members(gmembers, gnow)
|
|
264
|
+
if now_is_subgroup:
|
|
265
|
+
sub_type = "url" if sub.get("type") == "URLTest" else "sel"
|
|
266
|
+
sub_now = sub.get("now", "?")
|
|
267
|
+
sub_now_d = get_delay(sub_now)
|
|
268
|
+
sub_members = sub.get("all", [])
|
|
269
|
+
s_alive = sum(1 for x in sub_members if get_delay(x) > 0)
|
|
270
|
+
s_dead = sum(1 for x in sub_members if get_delay(x) == 0)
|
|
271
|
+
s_nodata = sum(1 for x in sub_members if get_delay(x) < 0)
|
|
272
|
+
sc = []
|
|
273
|
+
if s_alive: sc.append(f"{GREEN}{s_alive}✓{NC}")
|
|
274
|
+
if s_dead: sc.append(f"{RED}{s_dead}✗{NC}")
|
|
275
|
+
if s_nodata: sc.append(f"{YELLOW}{s_nodata}—{NC}")
|
|
276
|
+
st = group_tested_ago(sub_members)
|
|
277
|
+
st_str = f" {DIM}{st}{NC}" if st else ""
|
|
278
|
+
already = gnow in expanded_subgroups
|
|
279
|
+
tail = f" {DIM}(详见上方){NC}" if already else ""
|
|
280
|
+
print(f" {CYAN}{gnow}{NC}({sub_type}) → {BOLD}{sub_now}{NC} "
|
|
281
|
+
f"{ds(sub_now_d)}ms [{'/'.join(sc)} of {len(sub_members)}]{st_str}{tail}")
|
|
282
|
+
if not already:
|
|
283
|
+
print_members(sub_members, sub_now)
|
|
284
|
+
expanded_subgroups.add(gnow)
|
|
285
|
+
# 顶层组本身展开完后也标记,避免被下层组重新展开
|
|
286
|
+
expanded_subgroups.add(gname)
|
|
276
287
|
|
|
277
288
|
# 跟踪哪些组已经作为 selector 子组展开过,避免重复
|
|
278
289
|
shown = set()
|
|
@@ -405,19 +416,26 @@ def cmd_bench(api: str, api_secret: str, groups: list = None,
|
|
|
405
416
|
print(f" {YELLOW}—{NC} API 响应解析失败")
|
|
406
417
|
return
|
|
407
418
|
|
|
419
|
+
# v0.5.3:穿透 selector 子组到真叶子节点;伪节点(DIRECT/REJECT)排除
|
|
420
|
+
from proxyctl.suggest_rules import _collect_leaves, PSEUDO_NODE_TYPES
|
|
408
421
|
group_members: dict = {}
|
|
422
|
+
raw_count_total = 0 # 去重前总和(用于显示"省了多少次")
|
|
409
423
|
for gname in target_groups:
|
|
410
424
|
g = proxies.get(gname)
|
|
411
425
|
if not g:
|
|
412
426
|
if not as_json:
|
|
413
427
|
print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC} 不存在,跳过")
|
|
414
428
|
continue
|
|
415
|
-
|
|
416
|
-
|
|
429
|
+
leaves = [
|
|
430
|
+
l for l in _collect_leaves(gname, proxies, seen=set())
|
|
431
|
+
if proxies.get(l, {}).get("type") not in PSEUDO_NODE_TYPES
|
|
432
|
+
]
|
|
433
|
+
if not leaves:
|
|
417
434
|
if not as_json:
|
|
418
|
-
print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC}
|
|
435
|
+
print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC} 无可测叶子节点")
|
|
419
436
|
continue
|
|
420
|
-
group_members[gname] =
|
|
437
|
+
group_members[gname] = leaves
|
|
438
|
+
raw_count_total += len(leaves)
|
|
421
439
|
|
|
422
440
|
if not group_members:
|
|
423
441
|
if as_json:
|
|
@@ -438,9 +456,13 @@ def cmd_bench(api: str, api_secret: str, groups: list = None,
|
|
|
438
456
|
seen.add(m)
|
|
439
457
|
|
|
440
458
|
total = len(all_nodes)
|
|
459
|
+
dedup_saved = raw_count_total - total # 去重省掉的探测次数
|
|
441
460
|
group_names = ", ".join(group_members.keys())
|
|
442
461
|
if not as_json:
|
|
443
|
-
|
|
462
|
+
dedup_note = (f" {DIM}(去重省 {dedup_saved} 次){NC}"
|
|
463
|
+
if dedup_saved > 0 else "")
|
|
464
|
+
print(f"{BOLD}测速{NC} 组: {CYAN}{group_names}{NC} "
|
|
465
|
+
f"节点: {BOLD}{total}{NC}{dedup_note}")
|
|
444
466
|
|
|
445
467
|
import threading
|
|
446
468
|
done_count = [0]
|
|
@@ -496,6 +518,8 @@ def cmd_bench(api: str, api_secret: str, groups: list = None,
|
|
|
496
518
|
summary = {
|
|
497
519
|
"groups": list(group_members.keys()),
|
|
498
520
|
"total": total,
|
|
521
|
+
"raw_count": raw_count_total, # 去重前各组叶子数之和
|
|
522
|
+
"dedup_saved": dedup_saved, # 重复线路省下的探测次数
|
|
499
523
|
"ok_count": ok_count,
|
|
500
524
|
"fail_count": total - ok_count,
|
|
501
525
|
"avg_rtt_ms": (sum(rtts) // len(rtts)) if rtts else None,
|
|
@@ -575,6 +599,13 @@ def _collect_fail_hints(collector: dict, *, dns_bad: bool, failed: bool) -> list
|
|
|
575
599
|
# 保留旧行为:DNS 异常时引导 proxyctl fix
|
|
576
600
|
hints.append("DNS unhealthy — try `proxyctl fix`")
|
|
577
601
|
|
|
602
|
+
dead = stages.get("dead_groups") or []
|
|
603
|
+
if dead:
|
|
604
|
+
names = ",".join(
|
|
605
|
+
f"{d.get('name','?')}({d.get('dead_count')}/{d.get('total_count')})"
|
|
606
|
+
for d in dead if isinstance(d, dict))
|
|
607
|
+
hints.append(f"proxy groups mostly dead: {names} — try `proxyctl bench`")
|
|
608
|
+
|
|
578
609
|
return hints
|
|
579
610
|
|
|
580
611
|
|
|
@@ -859,6 +890,26 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
859
890
|
if as_json:
|
|
860
891
|
collector["stages"]["groups"] = groups_data
|
|
861
892
|
|
|
893
|
+
# 任何组 ≥70% 节点挂掉的全局判定(不限于 check_groups 显示的组)
|
|
894
|
+
# 与 doctor 的 proxy_group.mostly_dead suggestion 共用底层规则。
|
|
895
|
+
from proxyctl import suggest_rules as _sr
|
|
896
|
+
proxies_payload = _sr.fetch_proxies(api, api_secret, timeout=1.0)
|
|
897
|
+
dead_groups = _sr.proxy_group_rules(proxies_payload)
|
|
898
|
+
if dead_groups:
|
|
899
|
+
for s in dead_groups:
|
|
900
|
+
ev = s.get("evidence") or {}
|
|
901
|
+
print(f" {RED}✗{NC} {ev.get('group_name','?')}: "
|
|
902
|
+
f"{ev.get('dead_count')}/{ev.get('total_count')} 节点不可达 "
|
|
903
|
+
f"({ev.get('dead_pct_at_check')}%)")
|
|
904
|
+
fail = True
|
|
905
|
+
collector["stages"]["dead_groups"] = [
|
|
906
|
+
{"name": (s.get("evidence") or {}).get("group_name"),
|
|
907
|
+
"dead_count": (s.get("evidence") or {}).get("dead_count"),
|
|
908
|
+
"total_count": (s.get("evidence") or {}).get("total_count"),
|
|
909
|
+
"dead_pct": (s.get("evidence") or {}).get("dead_pct_at_check")}
|
|
910
|
+
for s in dead_groups
|
|
911
|
+
]
|
|
912
|
+
|
|
862
913
|
# ── 3. 连通性 ─────────────────────────────────────────────────────────────
|
|
863
914
|
# 从所有插件收集 check_targets。corp-network 等内置插件根据 ctx.corp_net 决定是否启用。
|
|
864
915
|
ctx = {"corp_net": corp_net, "mode": mode, "engine": engine.name}
|
|
@@ -966,6 +1017,9 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
966
1017
|
if dns_bad:
|
|
967
1018
|
print(f"{CYAN}DNS 异常,执行 {BOLD}sb fix{NC}{CYAN} 修复。{NC}")
|
|
968
1019
|
|
|
1020
|
+
if not as_json and not as_plain:
|
|
1021
|
+
_sys.exit(0 if not fail else 1)
|
|
1022
|
+
|
|
969
1023
|
if as_json:
|
|
970
1024
|
_sys.stdout = _real_stdout
|
|
971
1025
|
from proxyctl._io import emit_json, envelope, OK, GENERIC
|
|
@@ -91,11 +91,48 @@ def _t_nodes(backend, config) -> TopicCard:
|
|
|
91
91
|
def _t_config(backend, config) -> TopicCard:
|
|
92
92
|
return {
|
|
93
93
|
"topic": "config",
|
|
94
|
-
"summary":
|
|
94
|
+
"summary": (
|
|
95
|
+
"proxyctl 自身配置(不是 mihomo 配置)。控制后端选择、Clash API、端口、"
|
|
96
|
+
"企业 DNS 等。**建议把整个 ~/.config/proxyctl/ 目录纳入 git 追踪**(详见 "
|
|
97
|
+
"explain config-tracking),订阅源 URL / Clash secret 等 secret 文件用 "
|
|
98
|
+
".gitignore 排除,配置改动留得下回放历史。"
|
|
99
|
+
),
|
|
95
100
|
"file": _io_proxyctl_config_path(),
|
|
96
101
|
"edit": f"$EDITOR {_io_proxyctl_config_path()}",
|
|
97
102
|
"verify": "proxyctl config get <key>",
|
|
98
|
-
"next_commands": ["explain
|
|
103
|
+
"next_commands": ["explain config-tracking", "explain ports",
|
|
104
|
+
"explain corp-dns", "explain extra-daemons"],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@topic("config-tracking")
|
|
109
|
+
def _t_config_tracking(backend, config) -> TopicCard:
|
|
110
|
+
cfg_dir = os.path.join(os.path.expanduser("~"), ".config", "proxyctl")
|
|
111
|
+
return {
|
|
112
|
+
"topic": "config-tracking",
|
|
113
|
+
"summary": (
|
|
114
|
+
"用 git 追踪 ~/.config/proxyctl/ 的变化。proxyctl 自己不做版本控制,"
|
|
115
|
+
"但用户的 config.yaml / 插件 / 订阅契约文件经常被多个工具(用户、cron、"
|
|
116
|
+
"proxyctl autostart sync、update-subscription.sh、agent)改动,没有"
|
|
117
|
+
"版本历史时很难 diff 出'谁改了什么'。git 是最便宜的解法。"
|
|
118
|
+
),
|
|
119
|
+
"file": f"{cfg_dir}/.git",
|
|
120
|
+
"edit": (
|
|
121
|
+
f" cd {cfg_dir} && git init\n"
|
|
122
|
+
" # .gitignore 必须排除 secret + 高频抖动文件(agent 别忘):\n"
|
|
123
|
+
" cat > .gitignore <<'EOF'\n"
|
|
124
|
+
" subscription-source.env # 订阅 URL 含 UUID secret\n"
|
|
125
|
+
" subscription.json # 流量/到期高频变化\n"
|
|
126
|
+
" .lock.* # proxyctl 运行时锁文件\n"
|
|
127
|
+
" .ipgeo-* # ipgeo 探针缓存\n"
|
|
128
|
+
" *.bak # config set 自动备份\n"
|
|
129
|
+
" EOF\n"
|
|
130
|
+
" git add . && git commit -m 'init: 本机 proxyctl 配置基线'\n"
|
|
131
|
+
" # 后续改 config 之前:git status 看上次留下了啥;改完 commit"
|
|
132
|
+
),
|
|
133
|
+
"verify": f"cd {cfg_dir} && git log --oneline",
|
|
134
|
+
"next_commands": ["explain config", "explain plugins",
|
|
135
|
+
"config get backend"],
|
|
99
136
|
}
|
|
100
137
|
|
|
101
138
|
|
|
@@ -780,6 +817,31 @@ proxyctl 才能区分「过期 / 网络挂 / 订阅服务方挂」。
|
|
|
780
817
|
- 用户插件目录: `~/.config/proxyctl/plugins/*.py`
|
|
781
818
|
- 锁文件目录: `{lock_dir}/.lock.{{system|config|daemon}}`
|
|
782
819
|
|
|
820
|
+
## Config Tracking — 用户配置目录建议纳入 git
|
|
821
|
+
|
|
822
|
+
`~/.config/proxyctl/` 是用户的私人配置目录(不是 proxyctl 项目源代码)。
|
|
823
|
+
被多方写入:用户手改、`config set`、`proxyctl autostart sync`、订阅脚本、
|
|
824
|
+
cron、agent。**强烈建议把它纳入 git 追踪**,让你能回放"上次配置是什么样"。
|
|
825
|
+
|
|
826
|
+
```bash
|
|
827
|
+
cd {lock_dir} && git init
|
|
828
|
+
# .gitignore 必须排除 secret + 高频抖动文件:
|
|
829
|
+
cat > .gitignore <<'EOF'
|
|
830
|
+
subscription-source.env # 订阅 URL(含 UUID secret,禁止入库)
|
|
831
|
+
subscription.json # 流量/到期高频变化
|
|
832
|
+
.lock.* # proxyctl 运行时锁
|
|
833
|
+
.ipgeo-* # ipgeo 探针缓存
|
|
834
|
+
*.bak # config set 自动备份
|
|
835
|
+
EOF
|
|
836
|
+
git add . && git commit -m 'init: 本机 proxyctl 配置基线'
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
agent 在用户机器上首次跑 proxyctl 时,**应该提示用户是否要做这一步**
|
|
840
|
+
(首次见到 `{lock_dir}` 但无 `.git` 即可提示)。proxyctl 自己不动 git——
|
|
841
|
+
版本控制是用户/agent 的职责,跟拉订阅是同一类边界。
|
|
842
|
+
|
|
843
|
+
详见 `proxyctl explain config-tracking`。
|
|
844
|
+
|
|
783
845
|
## Exit Codes — 退出码
|
|
784
846
|
|
|
785
847
|
```
|
|
@@ -278,6 +278,9 @@ def engine_rules(current_version: str | None,
|
|
|
278
278
|
DEAD_CHECK_GROUP_TYPES = ("URLTest", "Selector", "Fallback", "LoadBalance",
|
|
279
279
|
"Smart")
|
|
280
280
|
|
|
281
|
+
# 伪节点(DIRECT/REJECT/Pass),不参与 dead 统计——它们没有"可达性"概念
|
|
282
|
+
PSEUDO_NODE_TYPES = ("Direct", "Reject", "Pass", "Compatible")
|
|
283
|
+
|
|
281
284
|
# 节点数少于此值的组不报告(小组本来就容易脏)
|
|
282
285
|
MIN_GROUP_SIZE_FOR_DEAD_CHECK = 3
|
|
283
286
|
|
|
@@ -285,6 +288,35 @@ MIN_GROUP_SIZE_FOR_DEAD_CHECK = 3
|
|
|
285
288
|
DEAD_PCT_THRESHOLD = 70.0
|
|
286
289
|
|
|
287
290
|
|
|
291
|
+
def _collect_leaves(group_name: str, proxies: dict[str, Any],
|
|
292
|
+
seen: set[str]) -> list[str]:
|
|
293
|
+
"""穿透 selector/URLTest/Fallback 子组,递归收集所有真叶子节点。
|
|
294
|
+
|
|
295
|
+
返回去重后的叶子节点名列表。伪节点(DIRECT/REJECT)排除。
|
|
296
|
+
seen 跟踪本次调用栈访问过的组名,防止循环引用死循环。
|
|
297
|
+
"""
|
|
298
|
+
if group_name in seen:
|
|
299
|
+
return []
|
|
300
|
+
seen = seen | {group_name}
|
|
301
|
+
info = proxies.get(group_name)
|
|
302
|
+
if not isinstance(info, dict):
|
|
303
|
+
return []
|
|
304
|
+
typ = info.get("type")
|
|
305
|
+
if typ in PSEUDO_NODE_TYPES:
|
|
306
|
+
return []
|
|
307
|
+
if typ not in DEAD_CHECK_GROUP_TYPES:
|
|
308
|
+
# 真叶子(Shadowsocks / Vmess / Trojan / TUIC / Hysteria / ...)
|
|
309
|
+
return [group_name]
|
|
310
|
+
leaves: list[str] = []
|
|
311
|
+
for m in info.get("all") or []:
|
|
312
|
+
if not isinstance(m, str):
|
|
313
|
+
continue
|
|
314
|
+
for leaf in _collect_leaves(m, proxies, seen):
|
|
315
|
+
if leaf not in leaves:
|
|
316
|
+
leaves.append(leaf)
|
|
317
|
+
return leaves
|
|
318
|
+
|
|
319
|
+
|
|
288
320
|
def fetch_proxies(api_base: str, api_secret: str = "", *,
|
|
289
321
|
timeout: float = 0.5) -> dict[str, Any] | None:
|
|
290
322
|
"""调 mihomo `/proxies` API,本地 HTTP,0 外网。
|
|
@@ -320,11 +352,16 @@ def fetch_proxies(api_base: str, api_secret: str = "", *,
|
|
|
320
352
|
|
|
321
353
|
|
|
322
354
|
def proxy_group_rules(proxies_payload: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
323
|
-
"""proxy_group.mostly_dead
|
|
355
|
+
"""proxy_group.mostly_dead 规则(v0.5.4 起穿透子组到叶子统计)。
|
|
324
356
|
|
|
325
357
|
每个挂掉的组输出**独立**一条 suggestion(fingerprint 含 group_name),
|
|
326
358
|
agent 可分别跟踪。
|
|
327
359
|
|
|
360
|
+
v0.5.4 行为变更:判定从"直接成员 delay==0"改为"穿透到叶子节点 delay==0"。
|
|
361
|
+
GLOBAL 这种 selector-of-selectors 组下的 13 个分流子组(电报/苹果/Steam
|
|
362
|
+
等)若无 latency history(mihomo 没给它们测过延迟)不再被算作 dead,
|
|
363
|
+
伪节点(DIRECT/REJECT)也排除。dead_pct 现在基于真叶子节点数。
|
|
364
|
+
|
|
328
365
|
Args:
|
|
329
366
|
proxies_payload: fetch_proxies() 返回;None 表示跳过整组
|
|
330
367
|
"""
|
|
@@ -339,14 +376,13 @@ def proxy_group_rules(proxies_payload: dict[str, Any] | None) -> list[dict[str,
|
|
|
339
376
|
continue
|
|
340
377
|
if info.get("type") not in DEAD_CHECK_GROUP_TYPES:
|
|
341
378
|
continue
|
|
342
|
-
|
|
343
|
-
if
|
|
379
|
+
leaves = _collect_leaves(name, proxies, seen=set())
|
|
380
|
+
if len(leaves) < MIN_GROUP_SIZE_FOR_DEAD_CHECK:
|
|
344
381
|
continue
|
|
345
382
|
dead = 0
|
|
346
|
-
for
|
|
347
|
-
node = proxies.get(
|
|
383
|
+
for leaf in leaves:
|
|
384
|
+
node = proxies.get(leaf)
|
|
348
385
|
if not isinstance(node, dict):
|
|
349
|
-
# 拿不到节点信息时算"未知",不计入死亡
|
|
350
386
|
continue
|
|
351
387
|
history = node.get("history") or []
|
|
352
388
|
if not history:
|
|
@@ -355,19 +391,19 @@ def proxy_group_rules(proxies_payload: dict[str, Any] | None) -> list[dict[str,
|
|
|
355
391
|
last = history[-1] if isinstance(history[-1], dict) else {}
|
|
356
392
|
if last.get("delay", 0) == 0:
|
|
357
393
|
dead += 1
|
|
358
|
-
dead_pct = (dead / len(
|
|
394
|
+
dead_pct = (dead / len(leaves)) * 100.0
|
|
359
395
|
if dead_pct >= DEAD_PCT_THRESHOLD:
|
|
360
396
|
out.append({
|
|
361
397
|
"id": "proxy_group.mostly_dead",
|
|
362
398
|
"severity": "warn",
|
|
363
399
|
"actor": "user",
|
|
364
|
-
"title": (f"代理组 {name} 中 {dead}/{len(
|
|
400
|
+
"title": (f"代理组 {name} 中 {dead}/{len(leaves)} 叶子节点不可达 "
|
|
365
401
|
f"({dead_pct:.0f}%)"),
|
|
366
402
|
"evidence": {
|
|
367
403
|
"group_name": name, # 进 fingerprint 的稳定字段
|
|
368
404
|
"group_type": info.get("type"),
|
|
369
405
|
"dead_count": dead,
|
|
370
|
-
"total_count": len(
|
|
406
|
+
"total_count": len(leaves),
|
|
371
407
|
# dead_pct 不进 fingerprint(抖动字段),仅供人看
|
|
372
408
|
"dead_pct_at_check": round(dead_pct, 1),
|
|
373
409
|
},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|