proxyctl 0.1.0__py3-none-any.whl

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/check.py ADDED
@@ -0,0 +1,761 @@
1
+ """proxyctl check — 全面健康检查 (4 个阶段并发执行)
2
+
3
+ 通用骨架,所有"本机感知"内容(测哪些 URL、关心哪些组、探哪些出口)都从
4
+ PluginRegistry 收集,core 不感知具体业务。
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import platform
10
+ import re as _re_mod
11
+ import socket
12
+ import subprocess
13
+ import time
14
+ import urllib.parse
15
+ from concurrent.futures import ThreadPoolExecutor
16
+
17
+ IS_MACOS = platform.system() == "Darwin"
18
+
19
+
20
+ RED = "\033[0;31m"
21
+ GREEN = "\033[0;32m"
22
+ YELLOW = "\033[0;33m"
23
+ CYAN = "\033[0;36m"
24
+ BOLD = "\033[1m"
25
+ DIM = "\033[2m"
26
+ NC = "\033[0m"
27
+
28
+ HOME = os.path.expanduser("~")
29
+
30
+
31
+ def _port_listening(port: int) -> bool:
32
+ try:
33
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
34
+ return True
35
+ except OSError:
36
+ return False
37
+
38
+
39
+ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8) -> tuple:
40
+ """
41
+ 测试 URL 可达性。
42
+ mode=proxy: 走 socks5h://127.0.0.1:7890
43
+ mode=direct: 绕过所有代理 (--noproxy '*')
44
+ 返回 (ok: bool, line: str),调用方负责 print。
45
+ """
46
+ env = {k: v for k, v in os.environ.items()
47
+ if k not in ("http_proxy", "https_proxy", "all_proxy",
48
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
49
+ cmd = ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
50
+ "--max-time", str(timeout)]
51
+ if mode == "proxy":
52
+ cmd += ["--proxy", "socks5h://127.0.0.1:7890"]
53
+ else:
54
+ cmd += ["--noproxy", "*"]
55
+ cmd.append(url)
56
+
57
+ r = subprocess.run(cmd, capture_output=True, text=True, env=env)
58
+ code = r.stdout.strip()
59
+
60
+ if code == "000" or not code:
61
+ return False, f" {RED}✗{NC} {desc:<18s} {url:<44s} {RED}timeout{NC}"
62
+ elif code.startswith(("2", "3", "4")):
63
+ return True, f" {GREEN}✓{NC} {desc:<18s} {url:<44s} {GREEN}{code}{NC}"
64
+ elif code.startswith("5"):
65
+ # 5xx = 链路通了,服务端报错,不算代理故障
66
+ return True, f" {YELLOW}✓{NC} {desc:<18s} {url:<44s} {YELLOW}{code} (server error){NC}"
67
+ else:
68
+ return False, f" {YELLOW}?{NC} {desc:<18s} {url:<44s} {YELLOW}{code}{NC}"
69
+
70
+
71
+ def _test_dns(desc: str, server: str, domain: str) -> tuple:
72
+ """通用 DNS 可达性测试:dig @server domain(用于 CheckTarget mode='dns')。
73
+
74
+ url 格式:dns:<server>:<domain>
75
+ """
76
+ r = subprocess.run(
77
+ ["dig", f"@{server}", "+short", "+timeout=3", domain],
78
+ capture_output=True, text=True, timeout=5
79
+ )
80
+ ok = r.returncode == 0 and r.stdout.strip()
81
+ if ok:
82
+ return True, f" {GREEN}✓{NC} {desc:<18s} {server:<44s} {GREEN}ok{NC}"
83
+ else:
84
+ return False, f" {RED}✗{NC} {desc:<18s} {server:<44s} {RED}timeout{NC}"
85
+
86
+
87
+ def _test_tcp(host: str, port: int, desc: str) -> tuple:
88
+ """返回 (ok: bool, line: str),调用方负责 print。"""
89
+ addr = f"{host}:{port}"
90
+ try:
91
+ with socket.create_connection((host, port), timeout=3):
92
+ return True, f" {GREEN}✓{NC} {desc:<18s} {addr:<44s} {GREEN}ok{NC}"
93
+ except OSError:
94
+ return False, f" {RED}✗{NC} {desc:<18s} {addr:<44s} {RED}unreachable{NC}"
95
+
96
+
97
+ def _proxy_groups_section(api_base: str, api_secret: str,
98
+ groups: list[str] | None = None) -> bool:
99
+ """检查并自动修复代理组,打印节点明细。返回是否全部正常。
100
+
101
+ Args:
102
+ groups: 要展示的组名列表。None 时回退到 ["proxy"]。
103
+ """
104
+ r = subprocess.run(
105
+ ["curl", "-s", "--noproxy", "*",
106
+ "-H", f"Authorization: Bearer {api_secret}",
107
+ f"{api_base}/proxies"],
108
+ capture_output=True, text=True, timeout=5
109
+ )
110
+ if not r.stdout.strip():
111
+ print(f" {YELLOW}—{NC} Clash API 不可达")
112
+ return False
113
+
114
+ try:
115
+ data = json.loads(r.stdout)
116
+ except Exception:
117
+ print(f" {YELLOW}—{NC} API 响应解析失败")
118
+ return False
119
+
120
+ proxies = data.get("proxies", {})
121
+
122
+ from datetime import datetime, timezone
123
+
124
+ def get_delay(name: str, src: dict = None) -> int:
125
+ """取节点延迟;若 name 是组则穿透到它的 now 节点。"""
126
+ p = (src or proxies).get(name, {})
127
+ # 如果是组(有 all 字段),穿透到 now 节点
128
+ if p.get("all") and p.get("now"):
129
+ p = proxies.get(p["now"], {})
130
+ h = p.get("history", [])
131
+ return h[-1].get("delay", 0) if h else -1
132
+
133
+ def ds(d: int) -> str:
134
+ if d < 0: return f"{YELLOW}—{NC}"
135
+ if d == 0: return f"{RED}✗{NC}"
136
+ if d < 200: return f"{GREEN}{d}{NC}"
137
+ if d < 500: return f"{YELLOW}{d}{NC}"
138
+ return f"{RED}{d}{NC}"
139
+
140
+ def group_tested_ago(members: list) -> str:
141
+ """返回组内最近一次测试时间,格式如 '3m ago';无数据返回空字符串。"""
142
+ latest = None
143
+ for m in members:
144
+ h = proxies.get(m, {}).get("history", [])
145
+ if not h:
146
+ continue
147
+ t_str = h[-1].get("time", "")
148
+ if not t_str:
149
+ continue
150
+ try:
151
+ t = datetime.fromisoformat(t_str.replace("Z", "+00:00"))
152
+ if latest is None or t > latest:
153
+ latest = t
154
+ except ValueError:
155
+ pass
156
+ if latest is None:
157
+ return ""
158
+ diff = int((datetime.now(timezone.utc) - latest).total_seconds())
159
+ if diff < 60:
160
+ return f"{diff}s ago"
161
+ if diff < 3600:
162
+ return f"{diff // 60}m ago"
163
+ return f"{diff // 3600}h ago"
164
+
165
+ # 输出所有组
166
+ def dw(s: str) -> int:
167
+ """计算终端显示宽度(中文双宽)。"""
168
+ return sum(2 if '\u4e00' <= c <= '\u9fff' else 1 for c in s)
169
+
170
+ def print_members(members: list, now: str):
171
+ """打印组成员列表(4 列表格)。"""
172
+ if not members:
173
+ return
174
+ max_w = max(dw(m) for m in members)
175
+ col_w = max_w + 8
176
+ line = " "
177
+ col = 0
178
+ for m in members:
179
+ d = get_delay(m)
180
+ marker = "→" if m == now else " "
181
+ raw_d = str(d) if d > 0 else ("✗" if d == 0 else "—")
182
+ raw = f"{marker}{m}:{raw_d}"
183
+ pad = max(1, col_w - dw(raw))
184
+ line += f"{marker}{m}:{ds(d)}{' ' * pad}"
185
+ col += 1
186
+ if col >= 4:
187
+ print(line.rstrip())
188
+ line = " "
189
+ col = 0
190
+ if col > 0:
191
+ print(line.rstrip())
192
+
193
+ def print_group(gname: str):
194
+ """打印单个组的摘要 + 成员;若 selector 成员也是组则递归展开。"""
195
+ g = proxies.get(gname)
196
+ if not g:
197
+ return
198
+ _type_map = {"URLTest": "url", "Selector": "sel", "Fallback": "fb", "LoadBalance": "lb"}
199
+ gtype = _type_map.get(g.get("type"), g.get("type", "?")[:3].lower())
200
+ gnow = g.get("now", "?")
201
+ gnow_d = get_delay(gnow)
202
+ gmembers = g.get("all", [])
203
+
204
+ # selector/fallback 成员如果是子组,用子组内部的节点做 alive/dead 统计
205
+ if g.get("type") in ("Selector", "Fallback"):
206
+ alive = dead = nodata = total = 0
207
+ for m in gmembers:
208
+ sub = proxies.get(m, {})
209
+ if sub.get("all"):
210
+ # 子组:统计它里面的叶子节点
211
+ for leaf in sub["all"]:
212
+ d = get_delay(leaf)
213
+ total += 1
214
+ if d > 0: alive += 1
215
+ elif d == 0: dead += 1
216
+ else: nodata += 1
217
+ else:
218
+ # 普通节点
219
+ d = get_delay(m)
220
+ total += 1
221
+ if d > 0: alive += 1
222
+ elif d == 0: dead += 1
223
+ else: nodata += 1
224
+ else:
225
+ alive = sum(1 for m in gmembers if get_delay(m) > 0)
226
+ dead = sum(1 for m in gmembers if get_delay(m) == 0)
227
+ nodata = sum(1 for m in gmembers if get_delay(m) < 0)
228
+ total = len(gmembers)
229
+ counts = []
230
+ if alive: counts.append(f"{GREEN}{alive}✓{NC}")
231
+ if dead: counts.append(f"{RED}{dead}✗{NC}")
232
+ if nodata: counts.append(f"{YELLOW}{nodata}—{NC}")
233
+ count_str = "/".join(counts)
234
+ tested = group_tested_ago(gmembers)
235
+ tested_str = f" {DIM}{tested}{NC}" if tested else ""
236
+ print(f" {CYAN}{gname}{NC}({gtype}) → {BOLD}{gnow}{NC} "
237
+ f"{ds(gnow_d)}ms [{count_str} of {total}]{tested_str}")
238
+
239
+ # selector/fallback 的成员如果也是组,展开子组详情
240
+ if g.get("type") in ("Selector", "Fallback"):
241
+ print_members(gmembers, gnow)
242
+ for m in gmembers:
243
+ sub = proxies.get(m)
244
+ if sub and sub.get("all"):
245
+ sub_type = "url" if sub.get("type") == "URLTest" else "sel"
246
+ sub_now = sub.get("now", "?")
247
+ sub_now_d = get_delay(sub_now)
248
+ sub_members = sub.get("all", [])
249
+ s_alive = sum(1 for x in sub_members if get_delay(x) > 0)
250
+ s_dead = sum(1 for x in sub_members if get_delay(x) == 0)
251
+ s_nodata = sum(1 for x in sub_members if get_delay(x) < 0)
252
+ sc = []
253
+ if s_alive: sc.append(f"{GREEN}{s_alive}✓{NC}")
254
+ if s_dead: sc.append(f"{RED}{s_dead}✗{NC}")
255
+ if s_nodata: sc.append(f"{YELLOW}{s_nodata}—{NC}")
256
+ st = group_tested_ago(sub_members)
257
+ st_str = f" {DIM}{st}{NC}" if st else ""
258
+ active = " ←" if m == gnow else ""
259
+ print(f" {CYAN}{m}{NC}({sub_type}) → {BOLD}{sub_now}{NC} "
260
+ f"{ds(sub_now_d)}ms [{'/'.join(sc)} of {len(sub_members)}]{st_str}{active}")
261
+ print_members(sub_members, sub_now)
262
+ else:
263
+ print_members(gmembers, gnow)
264
+
265
+ # 跟踪哪些组已经作为 selector 子组展开过,避免重复
266
+ shown = set()
267
+ for gname in (groups or ["proxy"]):
268
+ if gname in shown:
269
+ continue
270
+ g = proxies.get(gname)
271
+ if not g:
272
+ continue
273
+ print_group(gname)
274
+ # selector/fallback 展开的子组标记为已显示
275
+ if g.get("type") in ("Selector", "Fallback"):
276
+ for m in g.get("all", []):
277
+ if proxies.get(m, {}).get("all"):
278
+ shown.add(m)
279
+
280
+
281
+ return True
282
+
283
+
284
+ def _ipgeo(ip: str, cache_file: str, api_secret: str) -> str:
285
+ """查询 IP 归属地,带文件缓存。返回 'city,country|org' 格式。"""
286
+ if not ip:
287
+ return ""
288
+ if os.path.isfile(cache_file):
289
+ for line in open(cache_file):
290
+ if line.startswith(f"{ip}|"):
291
+ return line.split("|", 1)[1].strip()
292
+ env = {k: v for k, v in os.environ.items()
293
+ if k not in ("http_proxy", "https_proxy", "all_proxy",
294
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
295
+ r = subprocess.run(
296
+ ["curl", "-s", "--max-time", "6", "--proxy", "socks5h://127.0.0.1:7890",
297
+ f"https://ipinfo.io/{ip}/json"],
298
+ capture_output=True, text=True, env=env, timeout=10
299
+ )
300
+ if not r.stdout:
301
+ return ""
302
+ try:
303
+ d = json.loads(r.stdout)
304
+ city = d.get("city", "")
305
+ country = d.get("country", "")
306
+ org = d.get("org", "")
307
+ if org:
308
+ parts = org.split(" ", 1)
309
+ org = parts[1] if len(parts) > 1 else org
310
+ loc = ",".join(filter(None, [city, country]))
311
+ result = f"{loc}|{org}"
312
+ with open(cache_file, "a") as f:
313
+ f.write(f"{ip}|{result}\n")
314
+ return result
315
+ except Exception:
316
+ return ""
317
+
318
+
319
+ def _fmt_ip(ip: str, geo: str) -> str:
320
+ if not ip:
321
+ return f"{YELLOW}{'failed':<15s}{NC}"
322
+ loc = geo.split("|")[0] if geo else ""
323
+ org = geo.split("|")[1] if geo and "|" in geo else ""
324
+ detail = ", ".join(filter(None, [loc, org]))
325
+ if detail:
326
+ return f"{ip:<15s} {CYAN}{detail}{NC}"
327
+ return f"{ip:<15s}"
328
+
329
+
330
+ def cmd_bench(api: str, api_secret: str, groups: list = None,
331
+ default_groups: list[str] | None = None):
332
+ """
333
+ proxyctl bench [group...] — 对指定代理组的全部节点发起测速,默认测全部组。
334
+
335
+ 参数:
336
+ api -- Clash API base URL (e.g. http://127.0.0.1:9090)
337
+ api_secret -- Clash API Bearer token
338
+ groups -- 命令行指定的组名列表;None 表示用 default_groups
339
+ default_groups -- 未指定 groups 时的默认组列表(由插件 check_groups 提供)
340
+ """
341
+ DEFAULT_GROUPS = default_groups or ["proxy"]
342
+ TEST_URL = "https://www.gstatic.com/generate_204"
343
+ TIMEOUT_MS = 5000 # 每个节点的测速超时(毫秒)
344
+ MAX_WORKERS = 16 # 并发测速线程数
345
+
346
+ target_groups = groups if groups else DEFAULT_GROUPS
347
+
348
+ # ── 拉取代理列表 ─────────────────────────────────────────────────────────
349
+ r = subprocess.run(
350
+ ["curl", "-s", "--noproxy", "*",
351
+ "-H", f"Authorization: Bearer {api_secret}",
352
+ f"{api}/proxies"],
353
+ capture_output=True, text=True, timeout=5
354
+ )
355
+ if not r.stdout.strip():
356
+ print(f" {YELLOW}—{NC} Clash API 不可达")
357
+ return
358
+
359
+ try:
360
+ proxies = json.loads(r.stdout).get("proxies", {})
361
+ except Exception:
362
+ print(f" {YELLOW}—{NC} API 响应解析失败")
363
+ return
364
+
365
+ # ── 收集待测节点(多组间去重,保持顺序) ────────────────────────────────
366
+ group_members: dict = {}
367
+ for gname in target_groups:
368
+ g = proxies.get(gname)
369
+ if not g:
370
+ print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC} 不存在,跳过")
371
+ continue
372
+ members = g.get("all", [])
373
+ if not members:
374
+ print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC} 无成员")
375
+ continue
376
+ group_members[gname] = members
377
+
378
+ if not group_members:
379
+ print(f" {RED}✗{NC} 无可测组")
380
+ return
381
+
382
+ # 去重合并,保留首次出现顺序
383
+ seen: set = set()
384
+ all_nodes: list = []
385
+ for members in group_members.values():
386
+ for m in members:
387
+ if m not in seen:
388
+ all_nodes.append(m)
389
+ seen.add(m)
390
+
391
+ total = len(all_nodes)
392
+ group_names = ", ".join(group_members.keys())
393
+ print(f"{BOLD}测速{NC} 组: {CYAN}{group_names}{NC} 节点: {BOLD}{total}{NC}")
394
+
395
+ # ── 并发测速,实时进度条 ──────────────────────────────────────────────────
396
+ import threading
397
+ done_count = [0]
398
+ lock = threading.Lock()
399
+
400
+ def _test_node(name: str):
401
+ """调用 Clash API 测单节点延迟,结果写回引擎 history。"""
402
+ encoded_name = urllib.parse.quote(name, safe="")
403
+ encoded_url = urllib.parse.quote(TEST_URL, safe="")
404
+ endpoint = (f"{api}/proxies/{encoded_name}/delay"
405
+ f"?url={encoded_url}&timeout={TIMEOUT_MS}")
406
+ subprocess.run(
407
+ ["curl", "-s", "--noproxy", "*", "--max-time",
408
+ str(TIMEOUT_MS // 1000 + 3),
409
+ "-H", f"Authorization: Bearer {api_secret}",
410
+ endpoint],
411
+ capture_output=True, text=True, timeout=TIMEOUT_MS // 1000 + 5
412
+ )
413
+ with lock:
414
+ done_count[0] += 1
415
+ n = done_count[0]
416
+ bar_len = 24
417
+ filled = int(bar_len * n / total)
418
+ bar = f"{GREEN}{'█' * filled}{NC}{'░' * (bar_len - filled)}"
419
+ print(f"\r [{bar}] {n}/{total}", end="", flush=True)
420
+
421
+ with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
422
+ list(pool.map(_test_node, all_nodes))
423
+
424
+ print() # 结束进度行
425
+
426
+ # ── 重新拉取并展示最新延迟 ────────────────────────────────────────────────
427
+ print()
428
+ _proxy_groups_section(api, api_secret, groups=list(group_members.keys()))
429
+
430
+
431
+ def _fetch_probe(probe, env_clean: dict) -> str:
432
+ """根据 OutboundProbe 配置发起一次 IP 查询,返回提取后的 IP。"""
433
+ cmd = ["curl", "-s", "--max-time", str(probe.timeout)]
434
+ if probe.mode == "proxy":
435
+ cmd += ["--proxy", "socks5h://127.0.0.1:7890"]
436
+ else:
437
+ cmd += ["--noproxy", "*"]
438
+ cmd.append(probe.url)
439
+ try:
440
+ r = subprocess.run(cmd, capture_output=True, text=True,
441
+ env=env_clean, timeout=probe.timeout + 4)
442
+ except subprocess.TimeoutExpired:
443
+ return ""
444
+ text = (r.stdout or "").strip()
445
+ if probe.extract_re:
446
+ m = _re_mod.search(probe.extract_re, text)
447
+ return m.group(0) if m else ""
448
+ return text
449
+
450
+
451
+ def cmd_check(engine, api: str, api_secret: str,
452
+ config: dict, mode_str: str = "", registry=None):
453
+ """proxyctl check — 4 阶段全面健康检查。
454
+
455
+ Args:
456
+ engine: Backend 实例
457
+ api: Clash API 基础 URL
458
+ api_secret: Clash API Bearer token
459
+ config: 全局配置字典
460
+ mode_str: 代理模式字符串(tun/proxy/mixed)
461
+ registry: PluginRegistry,提供 check_targets/check_outbound_probes/check_groups
462
+ """
463
+ dns_lock_label = config.get("dns_lock_label", "com.proxyctl.dns-lock")
464
+ claude_proxy_label = config.get("claude_proxy_label", "com.proxyctl.claude-proxy")
465
+ sb_dir = config.get("config_dir", f"{HOME}/.config") + "/proxyctl"
466
+ corp_dns = config.get("corp_dns", {}) or {}
467
+ fail = False
468
+
469
+ import threading
470
+
471
+ env_clean = {k: v for k, v in os.environ.items()
472
+ if k not in ("http_proxy", "https_proxy", "all_proxy",
473
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
474
+
475
+ # [4/4] 出口 IP 探测:从插件收集到的 probes 决定有哪些出口
476
+ probes = []
477
+ if registry is not None:
478
+ probes = registry.collect("check_outbound_probes", ctx={})
479
+ probe_ips: dict[str, str] = {p.name: "" for p in probes}
480
+
481
+ def _make_fetcher(probe):
482
+ def _run():
483
+ probe_ips[probe.name] = _fetch_probe(probe, env_clean)
484
+ return _run
485
+
486
+ ip_threads = [threading.Thread(target=_make_fetcher(p)) for p in probes]
487
+ for t in ip_threads:
488
+ t.start()
489
+
490
+ # ── 1. 基础状态 ──────────────────────────────────────────────────────────
491
+ mode = mode_str # 由调用方传入
492
+ if mode == "tun":
493
+ mode_tag = f"{GREEN}tun{NC}"
494
+ elif mode == "proxy":
495
+ mode_tag = f"{CYAN}proxy{NC}"
496
+ else:
497
+ mode_tag = f"{YELLOW}{mode}{NC}"
498
+ print(f"{BOLD}[1/4] 基础状态{NC} {BOLD}{GREEN}{engine.name}{NC} · {mode_tag}")
499
+
500
+ # daemon 检测(平台感知)
501
+ daemon_up = False
502
+ pid = ""
503
+ if IS_MACOS:
504
+ r = subprocess.run(["launchctl", "print", engine.label],
505
+ capture_output=True, text=True)
506
+ pid = next((l.split()[-1] for l in r.stdout.splitlines() if "pid =" in l), "")
507
+ daemon_up = bool(pid and pid != "0")
508
+ else:
509
+ r = subprocess.run(["systemctl", "--user", "show", engine.unit,
510
+ "-p", "MainPID", "--value"],
511
+ capture_output=True, text=True)
512
+ pid = r.stdout.strip()
513
+ daemon_up = bool(pid and pid != "0")
514
+
515
+ if daemon_up:
516
+ r2 = subprocess.run(["ps", "-o", "etime=", "-p", pid],
517
+ capture_output=True, text=True)
518
+ etime = r2.stdout.strip()
519
+ print(f" {GREEN}✓{NC} daemon PID {pid}, uptime {etime or '?'}")
520
+ else:
521
+ print(f" {RED}✗{NC} daemon not running — 执行 proxyctl start")
522
+ return
523
+
524
+ # 端口检测(proxy 模式不检查 53)
525
+ dns_hijack = mode in ("tun", "mixed")
526
+ check_ports = [(53, "dns"), (7890, "proxy"), (9090, "api")] if dns_hijack \
527
+ else [(7890, "proxy"), (9090, "api")]
528
+ ok_ports, fail_ports = [], []
529
+ for port, desc in check_ports:
530
+ if _port_listening(port):
531
+ ok_ports.append(f"{desc}:{port}")
532
+ else:
533
+ fail_ports.append(f"{desc}:{port}")
534
+ fail = True
535
+
536
+ # claude-proxy(仅 macOS)
537
+ cp_status = ""
538
+ if IS_MACOS:
539
+ cp_label = f"system/{claude_proxy_label}"
540
+ r = subprocess.run(["sudo", "launchctl", "print", cp_label], capture_output=True)
541
+ cp_found = r.returncode == 0
542
+ # fallback:兼容 sb 遗留 label
543
+ if not cp_found and claude_proxy_label != "com.singbox.claude-proxy":
544
+ r = subprocess.run(["sudo", "launchctl", "print",
545
+ "system/com.singbox.claude-proxy"], capture_output=True)
546
+ cp_found = r.returncode == 0
547
+ if cp_found:
548
+ cp_status = (f"{GREEN}claude-proxy✓{NC}" if _port_listening(7891)
549
+ else f"{YELLOW}claude-proxy(no-port){NC}")
550
+ else:
551
+ cp_status = f"{YELLOW}claude-proxy✗{NC}"
552
+
553
+ if ok_ports:
554
+ parts = f" {GREEN}✓{NC} ports: {' '.join(ok_ports)}"
555
+ if cp_status:
556
+ parts += f" {cp_status}"
557
+ print(parts)
558
+ if fail_ports:
559
+ parts = f" {RED}✗{NC} missing: {' '.join(fail_ports)}"
560
+ if cp_status and not ok_ports:
561
+ parts += f" {cp_status}"
562
+ print(parts)
563
+
564
+ # 网络环境状态行
565
+ if IS_MACOS:
566
+ en0_r = subprocess.run(["ifconfig", "en0"], capture_output=True, text=True)
567
+ en0_addr = next((l.split()[1] for l in en0_r.stdout.splitlines()
568
+ if l.strip().startswith("inet ") and len(l.split()) >= 2), "")
569
+ else:
570
+ # Linux:获取默认出口 IP
571
+ en0_addr = ""
572
+ r_route = subprocess.run(["ip", "route", "show", "default"],
573
+ capture_output=True, text=True)
574
+ default_iface = ""
575
+ for i, tok in enumerate(r_route.stdout.split()):
576
+ if tok == "dev" and i + 1 < len(r_route.stdout.split()):
577
+ default_iface = r_route.stdout.split()[i + 1]
578
+ break
579
+ if default_iface:
580
+ r_ip = subprocess.run(["ip", "-4", "addr", "show", default_iface],
581
+ capture_output=True, text=True)
582
+ for line in r_ip.stdout.splitlines():
583
+ if "inet " in line:
584
+ en0_addr = line.split()[1].split("/")[0]
585
+ break
586
+ # 企业网络检测:仅当配置了 corp_dns.server 时启用
587
+ corp_net = False
588
+ corp_via = ""
589
+ corp_server = corp_dns.get("server", "")
590
+ corp_test_domain = corp_dns.get("test_domain", "")
591
+ corp_prefix = corp_dns.get("ip_prefix", "") # 如 "30." "10.0."
592
+
593
+ if corp_server and corp_prefix:
594
+ # 检测是否在企业网络中(通过 IP 前缀匹配)
595
+ if en0_addr.startswith(corp_prefix):
596
+ corp_net = True
597
+ corp_via = f"直连({en0_addr})"
598
+ elif IS_MACOS:
599
+ r2 = subprocess.run(["ifconfig", "-l"], capture_output=True, text=True)
600
+ for iface in r2.stdout.split():
601
+ if not iface.startswith("utun"):
602
+ continue
603
+ ri = subprocess.run(["ifconfig", iface], capture_output=True, text=True)
604
+ for line in ri.stdout.splitlines():
605
+ if f"inet {corp_prefix}" in line:
606
+ parts = line.split()
607
+ if len(parts) >= 2:
608
+ corp_net = True
609
+ corp_via = f"VPN({iface},{parts[1]})"
610
+ break
611
+ if corp_net:
612
+ break
613
+
614
+ infra = corp_via if corp_net else f"{YELLOW}no-corp{NC}"
615
+
616
+ # DNS:仅在 tun/mixed 模式下检查系统 DNS 是否指向 127.0.0.1
617
+ dns_hijack = mode in ("tun", "mixed")
618
+ dns_bad = False
619
+
620
+ if dns_hijack and IS_MACOS:
621
+ r3 = subprocess.run(["scutil", "--dns"], capture_output=True, text=True)
622
+ sys_dns = ""
623
+ for line in r3.stdout.splitlines():
624
+ if "nameserver[0]" in line:
625
+ sys_dns = line.split()[-1]
626
+ break
627
+ if sys_dns == "127.0.0.1":
628
+ infra += " DNS✓"
629
+ else:
630
+ infra += f" {RED}DNS→{sys_dns or '?'}{NC}"
631
+ dns_bad = True
632
+ fail = True
633
+ elif dns_hijack:
634
+ infra += f" {YELLOW}DNS(需手动检查){NC}"
635
+ else:
636
+ infra += " DNS(proxy模式)"
637
+
638
+ # dns-lock 看门狗状态 — 任何模式下都重要(这是观察 watchdog 是否在跑的关键指标)
639
+ if IS_MACOS:
640
+ r4 = subprocess.run(["launchctl", "print", f"system/{dns_lock_label}"],
641
+ capture_output=True)
642
+ # fallback:兼容 sb 时代的 com.singbox.dns-lock label
643
+ lock_ok = r4.returncode == 0
644
+ if not lock_ok and dns_lock_label != "com.singbox.dns-lock":
645
+ r4b = subprocess.run(["launchctl", "print",
646
+ "system/com.singbox.dns-lock"],
647
+ capture_output=True)
648
+ lock_ok = r4b.returncode == 0
649
+ infra += (" lock✓" if lock_ok else f" {YELLOW}no-lock{NC}")
650
+
651
+ # 最近 watchdog tuic-recover/tuic-stuck 时间(昨天加的观察点)
652
+ for log_path in (f"{sb_dir}/dns-watchdog.log",
653
+ f"{HOME}/.config/sing-box/dns-watchdog.log"):
654
+ if os.path.isfile(log_path):
655
+ try:
656
+ for line in reversed(open(log_path).readlines()):
657
+ if "[tuic-recover]" in line or "[tuic-stuck]" in line:
658
+ ts = " ".join(line.split()[:2])
659
+ infra += f" {CYAN}last-recover:{ts}{NC}"
660
+ break
661
+ else:
662
+ continue
663
+ break
664
+ except (OSError, ValueError):
665
+ pass
666
+ print(f" {infra}")
667
+
668
+ # ── 2. 代理组 ─────────────────────────────────────────────────────────────
669
+ print(f"{BOLD}[2/4] 代理组{NC}")
670
+ groups = []
671
+ if registry is not None:
672
+ groups = registry.collect("check_groups")
673
+ _proxy_groups_section(api, api_secret, groups=groups)
674
+
675
+ # ── 3. 连通性 ─────────────────────────────────────────────────────────────
676
+ # 从所有插件收集 check_targets。corp-network 等内置插件根据 ctx.corp_net 决定是否启用。
677
+ ctx = {"corp_net": corp_net, "mode": mode, "engine": engine.name}
678
+ targets = []
679
+ if registry is not None:
680
+ targets = registry.collect("check_targets", ctx=ctx)
681
+
682
+ # 应用 only_when 过滤
683
+ def _target_enabled(t):
684
+ if not getattr(t, "only_when", None):
685
+ return True
686
+ try:
687
+ return bool(t.only_when(ctx))
688
+ except Exception:
689
+ return True
690
+ targets = [t for t in targets if _target_enabled(t)]
691
+
692
+ # [3/4] 连通性:每个测试完成立刻打印,不等其他测试
693
+ print(f"{BOLD}[3/4] 连通性{NC}")
694
+
695
+ if not targets:
696
+ print(f" {YELLOW}—{NC} 无连通性测试项(请在 ~/.config/proxyctl/plugins/ "
697
+ f"放插件提供 check_targets)")
698
+
699
+ results = [None] * len(targets)
700
+ ready = [threading.Event() for _ in targets]
701
+
702
+ def _run_test(idx, target):
703
+ try:
704
+ if target.mode == "dns":
705
+ # url 格式: dns:<server>:<domain>
706
+ _, server, domain = target.url.split(":", 2)
707
+ ok, line = _test_dns(target.name, server, domain)
708
+ elif target.mode == "tcp":
709
+ parts = target.url.removeprefix("tcp:").rsplit(":", 1)
710
+ ok, line = _test_tcp(parts[0], int(parts[1]), target.name)
711
+ else:
712
+ ok, line = _test_url(target.url, target.name,
713
+ target.mode, target.timeout)
714
+ except Exception as e:
715
+ ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
716
+ results[idx] = (line, ok)
717
+ ready[idx].set()
718
+
719
+ if targets:
720
+ with ThreadPoolExecutor(max_workers=8) as pool:
721
+ for idx, target in enumerate(targets):
722
+ pool.submit(_run_test, idx, target)
723
+ for idx in range(len(targets)):
724
+ ready[idx].wait()
725
+ line, ok = results[idx]
726
+ print(line)
727
+ if not ok:
728
+ fail = True
729
+
730
+ # [4/4] 出口 IP:等 IP 线程结束并展示每个 probe 的结果
731
+ print(f"{BOLD}[4/4] 出口 IP{NC}")
732
+ for t in ip_threads:
733
+ t.join()
734
+
735
+ cache_file = os.path.join(sb_dir, ".ipgeo-cache")
736
+ if not probes:
737
+ print(f" {YELLOW}—{NC} 无出口探测项")
738
+ for probe in probes:
739
+ ip = probe_ips.get(probe.name, "")
740
+ geo = _ipgeo(ip, cache_file, api_secret)
741
+ print(f" {probe.name:<7s}{_fmt_ip(ip, geo)}")
742
+
743
+ # 分流校验:当同时有 proxy 出口和 direct 出口时比较
744
+ proxy_probe_ip = next((probe_ips[p.name] for p in probes
745
+ if p.mode == "proxy" and p.name == "proxy"), "")
746
+ direct_probe_ip = next((probe_ips[p.name] for p in probes
747
+ if p.mode == "direct"), "")
748
+ if proxy_probe_ip and direct_probe_ip:
749
+ if proxy_probe_ip != direct_probe_ip:
750
+ print(f" {GREEN}✓{NC} 分流正常")
751
+ else:
752
+ print(f" {YELLOW}!{NC} 出口相同 — 检查分流规则")
753
+
754
+ # ── 结果 ──────────────────────────────────────────────────────────────────
755
+ print()
756
+ if not fail:
757
+ print(f"{GREEN}{BOLD}All checks passed.{NC}")
758
+ else:
759
+ print(f"{YELLOW}{BOLD}Some checks failed.{NC}")
760
+ if dns_bad:
761
+ print(f"{CYAN}DNS 异常,执行 {BOLD}sb fix{NC}{CYAN} 修复。{NC}")