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/status.py ADDED
@@ -0,0 +1,523 @@
1
+ """proxyctl status — 系统状态面板(并发采集数据,顺序打印)"""
2
+
3
+ import os
4
+ import platform
5
+ import re
6
+ import subprocess
7
+ import socket
8
+ from concurrent.futures import ThreadPoolExecutor
9
+
10
+ IS_MACOS = platform.system() == "Darwin"
11
+ IS_LINUX = platform.system() == "Linux"
12
+
13
+
14
+ RED = "\033[0;31m"
15
+ GREEN = "\033[0;32m"
16
+ YELLOW = "\033[0;33m"
17
+ CYAN = "\033[0;36m"
18
+ BOLD = "\033[1m"
19
+ NC = "\033[0m"
20
+
21
+ HOME = os.path.expanduser("~")
22
+
23
+
24
+ # ── 基础工具 ──────────────────────────────────────────────────────────────────
25
+
26
+ def _port_listening(port: int) -> bool:
27
+ try:
28
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
29
+ return True
30
+ except OSError:
31
+ return False
32
+
33
+
34
+ def _launchctl_pid(label: str, *, sudo: bool = False) -> str:
35
+ cmd = (["sudo"] if sudo else []) + ["launchctl", "print", label]
36
+ r = subprocess.run(cmd, capture_output=True, text=True)
37
+ for line in r.stdout.splitlines():
38
+ if "pid =" in line:
39
+ return line.split()[-1]
40
+ return ""
41
+
42
+
43
+ def _launchctl_runs(label: str) -> str:
44
+ r = subprocess.run(["launchctl", "print", label], capture_output=True, text=True)
45
+ for line in r.stdout.splitlines():
46
+ if "runs =" in line:
47
+ return line.split()[-1]
48
+ return ""
49
+
50
+
51
+ def _launchctl_running(label: str, *, sudo: bool = False) -> bool:
52
+ cmd = (["sudo"] if sudo else []) + ["launchctl", "print", label]
53
+ return subprocess.run(cmd, capture_output=True).returncode == 0
54
+
55
+
56
+ def _ifconfig_ip(iface: str) -> str:
57
+ r = subprocess.run(["ifconfig", iface], capture_output=True, text=True)
58
+ for line in r.stdout.splitlines():
59
+ parts = line.split()
60
+ if parts and parts[0] == "inet" and len(parts) >= 2:
61
+ return parts[1]
62
+ return ""
63
+
64
+
65
+ # ── 数据采集函数(可并发) ────────────────────────────────────────────────────
66
+
67
+ def _gather_engine(engine) -> dict:
68
+ """采集引擎进程信息:PID、运行次数、运行时间。"""
69
+ if IS_LINUX:
70
+ # systemd --user:用 systemctl show 获取 PID
71
+ r = subprocess.run(
72
+ ["systemctl", "--user", "show", engine.unit, "-p", "MainPID", "--value"],
73
+ capture_output=True, text=True
74
+ )
75
+ pid = r.stdout.strip()
76
+ daemon_up = bool(pid and pid != "0")
77
+ runs = ""
78
+ etime = ""
79
+ if daemon_up:
80
+ r2 = subprocess.run(["ps", "-o", "etime=", "-p", pid],
81
+ capture_output=True, text=True)
82
+ etime = r2.stdout.strip()
83
+ return {"pid": pid, "runs": runs, "daemon_up": daemon_up, "etime": etime}
84
+
85
+ # macOS: launchctl
86
+ pid = _launchctl_pid(engine.label)
87
+ runs = _launchctl_runs(engine.label)
88
+ daemon_up = bool(pid and pid != "0")
89
+ etime = ""
90
+ if daemon_up:
91
+ r = subprocess.run(["ps", "-o", "etime=", "-p", pid],
92
+ capture_output=True, text=True)
93
+ etime = r.stdout.strip()
94
+ return {"pid": pid, "runs": runs, "daemon_up": daemon_up, "etime": etime}
95
+
96
+
97
+ def _gather_ports(claude_proxy_label: str) -> dict:
98
+ """采集端口监听状态。"""
99
+ ports = [(p, desc, _port_listening(p))
100
+ for p, desc in [(7890, "proxy"), (9090, "api")]]
101
+ cp_running = False
102
+ cp_pid = ""
103
+ cp_port = False
104
+ if IS_MACOS:
105
+ cp_label = f"system/{claude_proxy_label}"
106
+ cp_running = _launchctl_running(cp_label, sudo=True)
107
+ # fallback:兼容 sb 遗留的 com.singbox.claude-proxy
108
+ if not cp_running and claude_proxy_label != "com.singbox.claude-proxy":
109
+ cp_label = "system/com.singbox.claude-proxy"
110
+ cp_running = _launchctl_running(cp_label, sudo=True)
111
+ cp_pid = _launchctl_pid(cp_label, sudo=True) if cp_running else ""
112
+ cp_port = _port_listening(7891) if cp_running else False
113
+ return {"ports": ports, "cp_running": cp_running,
114
+ "cp_pid": cp_pid, "cp_port": cp_port}
115
+
116
+
117
+ def _gather_tun(engine, daemon_up: bool) -> dict:
118
+ """采集 TUN 专属数据(Linux 最小集不启用 TUN,返回空数据)。"""
119
+ tun_iface = addr = mtu = ""
120
+ fakeip = hijack = ""
121
+ excludes = []
122
+ route_iface = ""
123
+
124
+ if not IS_MACOS:
125
+ # Linux 最小集:proxy-only mode,无 TUN
126
+ return {"tun_iface": "", "addr": "", "mtu": "",
127
+ "fakeip": "off", "hijack": "", "excludes": [],
128
+ "route_iface": ""}
129
+
130
+ if daemon_up:
131
+ r = subprocess.run(["ifconfig", "-l"], capture_output=True, text=True)
132
+ for iface in r.stdout.split():
133
+ if not iface.startswith("utun"):
134
+ continue
135
+ ri = subprocess.run(["ifconfig", iface], capture_output=True, text=True)
136
+ if "198.18." in ri.stdout or "fdfe:dcba" in ri.stdout:
137
+ tun_iface = iface
138
+ addr = _ifconfig_ip(iface)
139
+ for line in ri.stdout.splitlines():
140
+ if "mtu" in line:
141
+ mtu = line.split()[-1]
142
+ break
143
+
144
+ # 引擎配置细节
145
+ fakeip = hijack = ""
146
+ excludes = []
147
+ try:
148
+ if engine.name == "mihomo":
149
+ import yaml
150
+ cfg = yaml.safe_load(open(engine.config))
151
+ tun = cfg.get("tun", {})
152
+ dns = cfg.get("dns", {})
153
+ fakeip = "on" if dns.get("enhanced-mode") == "fake-ip" else "off"
154
+ hijack = " ".join(tun.get("dns-hijack", [])) or "none"
155
+ excludes = tun.get("route-exclude-address", [])
156
+ else:
157
+ import json
158
+ cfg = json.load(open(engine.config))
159
+ tun_cfg = next((i for i in cfg.get("inbounds", [])
160
+ if i.get("type") == "tun"), {})
161
+ fakeip = "on" if any(r.get("server") == "fakeip-dns"
162
+ for r in cfg.get("dns", {}).get("rules", [])) else "off"
163
+ hijack = "any:53" if tun_cfg.get("auto_redirect") else "via inbound"
164
+ excludes = tun_cfg.get("route_exclude_address", [])
165
+ except Exception:
166
+ pass
167
+
168
+ # 默认路由出口
169
+ route_iface = ""
170
+ if daemon_up:
171
+ r = subprocess.run(["route", "-n", "get", "default"],
172
+ capture_output=True, text=True)
173
+ for line in r.stdout.splitlines():
174
+ if "interface:" in line:
175
+ route_iface = line.split()[-1]
176
+
177
+ return {"tun_iface": tun_iface, "addr": addr, "mtu": mtu,
178
+ "fakeip": fakeip, "hijack": hijack, "excludes": excludes,
179
+ "route_iface": route_iface}
180
+
181
+
182
+ def _gather_proxy_settings() -> dict:
183
+ """采集系统代理设置(仅 macOS,Linux 返回空)。"""
184
+ if not IS_MACOS:
185
+ return {"active_svc": "", "info": {}}
186
+
187
+ # 找活跃网络服务
188
+ active_svc = ""
189
+ for svc in ["Wi-Fi", "USB 10/100/1000 LAN", "Thunderbolt Bridge", "Ethernet"]:
190
+ r = subprocess.run(["networksetup", "-getinfo", svc],
191
+ capture_output=True, text=True)
192
+ if "IP address: " in r.stdout and any(
193
+ l.startswith("IP address:") and len(l.split()) > 2
194
+ for l in r.stdout.splitlines()):
195
+ active_svc = svc
196
+ break
197
+ if not active_svc:
198
+ r = subprocess.run(["networksetup", "-listallnetworkservices"],
199
+ capture_output=True, text=True)
200
+ svcs = [s for s in r.stdout.splitlines()[1:] if not s.startswith("*")]
201
+ active_svc = svcs[0] if svcs else ""
202
+
203
+ info = {}
204
+ for proto, flag in [("http", "-getwebproxy"),
205
+ ("https", "-getsecurewebproxy"),
206
+ ("socks", "-getsocksfirewallproxy")]:
207
+ r = subprocess.run(["networksetup", flag, active_svc],
208
+ capture_output=True, text=True)
209
+ on = port = ""
210
+ for line in r.stdout.splitlines():
211
+ if line.startswith("Enabled:"):
212
+ on = line.split()[-1]
213
+ elif line.startswith("Port:"):
214
+ port = line.split()[-1]
215
+ info[proto] = (on, port)
216
+
217
+ return {"active_svc": active_svc, "info": info}
218
+
219
+
220
+ def _gather_dns(dns_lock_label: str) -> dict:
221
+ """采集 DNS 状态(macOS: scutil --dns,Linux: 简化检测)。"""
222
+ dns_up = _port_listening(53)
223
+
224
+ if not IS_MACOS:
225
+ # Linux 最小集:不劫持 DNS,只检查 53 端口
226
+ return {"dns_up": dns_up, "lock_up": False, "sys_dns": "",
227
+ "resolvers": [], "overrides": []}
228
+
229
+ lock_up = _launchctl_running(f"system/{dns_lock_label}")
230
+ # fallback:兼容 sb 遗留的 com.singbox.dns-lock
231
+ if not lock_up and dns_lock_label != "com.singbox.dns-lock":
232
+ lock_up = _launchctl_running("system/com.singbox.dns-lock")
233
+
234
+ r = subprocess.run(["scutil", "--dns"], capture_output=True, text=True)
235
+ sys_dns = ""
236
+ in_r1 = False
237
+ for line in r.stdout.splitlines():
238
+ if "resolver #1" in line:
239
+ in_r1 = True
240
+ if in_r1 and "nameserver[0]" in line:
241
+ sys_dns = line.split()[-1]
242
+ break
243
+
244
+ overrides = []
245
+ if os.path.isdir("/etc/resolver"):
246
+ for rf in os.listdir("/etc/resolver"):
247
+ full = f"/etc/resolver/{rf}"
248
+ if os.path.isfile(full):
249
+ with open(full) as f:
250
+ if any(line.startswith("nameserver") for line in f):
251
+ overrides.append(rf)
252
+
253
+ return {"dns_up": dns_up, "lock_up": lock_up,
254
+ "sys_dns": sys_dns, "overrides": overrides}
255
+
256
+
257
+ def _gather_network(engine) -> dict:
258
+ """采集通用网络环境:默认出口网卡 + IP。
259
+
260
+ 本机特例(企业 VPN 接口、Tailscale peer、TUIC relay 解析路径等)走
261
+ StatusSection 插件,不在 core 里采集。
262
+ """
263
+ default_iface = ""
264
+ default_ip = ""
265
+
266
+ if IS_MACOS:
267
+ r = subprocess.run(["route", "-n", "get", "default"],
268
+ capture_output=True, text=True)
269
+ for line in r.stdout.splitlines():
270
+ if "interface:" in line:
271
+ default_iface = line.split()[-1]
272
+ if default_iface:
273
+ default_ip = _ifconfig_ip(default_iface)
274
+ else:
275
+ # Linux:从 ip route 获取默认出口
276
+ r = subprocess.run(["ip", "route", "show", "default"],
277
+ capture_output=True, text=True)
278
+ parts = r.stdout.split()
279
+ for i, tok in enumerate(parts):
280
+ if tok == "dev" and i + 1 < len(parts):
281
+ default_iface = parts[i + 1]
282
+ break
283
+ if default_iface:
284
+ r2 = subprocess.run(
285
+ ["ip", "-4", "addr", "show", default_iface],
286
+ capture_output=True, text=True)
287
+ for line in r2.stdout.splitlines():
288
+ if "inet " in line:
289
+ default_ip = line.split()[1].split("/")[0]
290
+ break
291
+
292
+ return {"default_iface": default_iface, "default_ip": default_ip}
293
+
294
+
295
+ # ── 打印函数(顺序执行,使用采集结果) ───────────────────────────────────────
296
+
297
+ def _print_engine(engine, mode: str, d_engine: dict, d_ports: dict):
298
+ if mode == "tun":
299
+ mode_tag = f"{GREEN}tun{NC}"
300
+ elif mode == "proxy":
301
+ mode_tag = f"{CYAN}proxy{NC}"
302
+ else:
303
+ mode_tag = f"{YELLOW}{mode}{NC}"
304
+
305
+ print(f"{BOLD}引擎{NC} {GREEN}{engine.name}{NC} · {mode_tag}")
306
+
307
+ if d_engine["daemon_up"]:
308
+ runs_str = (f" {YELLOW}runs={d_engine['runs']}{NC}"
309
+ if d_engine["runs"] and d_engine["runs"] != "1" else "")
310
+ print(f" daemon {GREEN}✓{NC} PID {d_engine['pid']} "
311
+ f"uptime {d_engine['etime'] or '?'}{runs_str}")
312
+ else:
313
+ print(f" daemon — stopped")
314
+
315
+ port_parts = []
316
+ for port_num, desc, ok in d_ports["ports"]:
317
+ label = f"{desc}:{port_num}"
318
+ if ok:
319
+ port_parts.append(f"{GREEN}{label}{NC}")
320
+ elif d_engine["daemon_up"]:
321
+ port_parts.append(f"{RED}{label}✗{NC}")
322
+ else:
323
+ port_parts.append(f"{label}—")
324
+ print(f" ports {' '.join(port_parts)}")
325
+
326
+ if d_ports["cp_running"]:
327
+ if d_ports["cp_port"]:
328
+ print(f" claude {GREEN}✓{NC} PID {d_ports['cp_pid'] or '?'} :7891")
329
+ else:
330
+ print(f" claude {YELLOW}✓{NC} daemon up, {RED}port 7891 not listening{NC}")
331
+ else:
332
+ print(f" claude — not running")
333
+
334
+
335
+ def _print_tun(engine, d_tun: dict):
336
+ print(f"\n{BOLD}TUN{NC}")
337
+ tun_iface = d_tun["tun_iface"]
338
+ if tun_iface:
339
+ print(f" iface {GREEN}{tun_iface}{NC} "
340
+ f"{d_tun['addr'] or '?'} mtu={d_tun['mtu'] or '?'}")
341
+ else:
342
+ print(f" iface {RED}✗ 未找到 TUN 接口{NC}")
343
+
344
+ if d_tun["fakeip"]:
345
+ color = GREEN if d_tun["fakeip"] == "on" else YELLOW
346
+ print(f" fakeip {color}{d_tun['fakeip']}{NC}")
347
+ if d_tun["hijack"]:
348
+ print(f" hijack {d_tun['hijack']}")
349
+ excludes = d_tun["excludes"]
350
+ if excludes:
351
+ shown = " ".join(excludes[:6])
352
+ extra = "..." if len(excludes) > 6 else ""
353
+ print(f" exclude {shown}{extra}")
354
+
355
+ ri = d_tun["route_iface"]
356
+ if ri:
357
+ if tun_iface and ri == tun_iface:
358
+ print(f" route default via {GREEN}{ri}{NC}")
359
+ else:
360
+ print(f" route default via {YELLOW}{ri}{NC} (非 TUN)")
361
+
362
+
363
+ def _print_proxy_settings(d_proxy: dict, daemon_up: bool, mode: str):
364
+ print(f"\n{BOLD}系统代理{NC}")
365
+ active_svc = d_proxy["active_svc"]
366
+ if not active_svc:
367
+ return
368
+
369
+ any_on = False
370
+ proxy_parts = []
371
+ bad_port = False
372
+ for proto, (on, port) in d_proxy["info"].items():
373
+ if on == "Yes":
374
+ any_on = True
375
+ if port == "7890":
376
+ proxy_parts.append(f"{GREEN}{proto.upper()}:{port}{NC}")
377
+ else:
378
+ proxy_parts.append(f"{YELLOW}{proto.upper()}:{port}{NC}")
379
+ bad_port = True
380
+
381
+ if any_on:
382
+ print(f" {active_svc}: {' '.join(proxy_parts)}")
383
+ if bad_port:
384
+ print(f" {YELLOW}⚠ 端口 ≠ 7890,可能指向其他代理{NC}")
385
+ else:
386
+ if daemon_up and mode == "proxy":
387
+ print(f" {RED}✗{NC} 未开启 (proxy 模式需要开启)")
388
+ elif daemon_up:
389
+ print(f" — 未开启 (tun 模式不需要)")
390
+ else:
391
+ print(f" — 未开启")
392
+
393
+
394
+ def _print_dns(daemon_up: bool, d_dns: dict, mode: str):
395
+ """打印 DNS 状态段。
396
+
397
+ 始终显示 listen/system/lock 的实际状态。
398
+ mode 决定的是"异常判定标准":
399
+ - tun/mixed:DNS 必须指向 127.0.0.1,否则标红
400
+ - proxy:只显示事实,不判定对错
401
+ """
402
+ dns_hijack = mode in ("tun", "mixed")
403
+
404
+ print(f"\n{BOLD}DNS{NC}")
405
+
406
+ # listen: 53 端口是否在监听
407
+ if d_dns["dns_up"]:
408
+ print(f" listen {GREEN}127.0.0.1:53{NC}")
409
+ elif daemon_up and dns_hijack:
410
+ # tun/mixed 模式下 53 没起来才算错
411
+ print(f" listen {RED}127.0.0.1:53 ✗{NC}")
412
+ elif daemon_up:
413
+ # proxy 模式下 53 没起来是正常的
414
+ print(f" listen — (proxy 模式,不需要)")
415
+ else:
416
+ print(f" listen — (daemon stopped)")
417
+
418
+ # system: 当前系统 DNS 指向
419
+ sys_dns = d_dns.get("sys_dns", "")
420
+ if dns_hijack:
421
+ if sys_dns == "127.0.0.1":
422
+ tag = f"{GREEN}→ 127.0.0.1{NC}" if daemon_up else \
423
+ f"{RED}→ 127.0.0.1{NC} (daemon 未运行,DNS 将不可用!)"
424
+ else:
425
+ tag = (f"{RED}→ {sys_dns or 'unknown'}{NC} (应为 127.0.0.1)"
426
+ if daemon_up else f"→ {sys_dns or 'unknown'}")
427
+ else:
428
+ tag = f"→ {sys_dns or 'DHCP'}"
429
+ print(f" system {tag}")
430
+
431
+ # lock: dns-lock watchdog 状态
432
+ if d_dns.get("lock_up"):
433
+ tag = f"{GREEN}✓{NC} running" if daemon_up else \
434
+ f"{YELLOW}✓{NC} running (daemon 未运行,建议 proxyctl dns-unlock)"
435
+ else:
436
+ tag = "— not running"
437
+ print(f" lock {tag}")
438
+
439
+ if d_dns.get("overrides"):
440
+ print(f" {YELLOW}⚠ /etc/resolver/ 覆盖: {' '.join(d_dns['overrides'])}{NC}")
441
+
442
+
443
+ def _print_network(d_net: dict, ctx: dict | None = None, registry=None):
444
+ """打印网络状态段(通用部分):默认出口网卡 + IP。
445
+
446
+ 其他信息(企业 VPN、Tailscale、TUIC relay 等)由插件的 StatusSection 提供,
447
+ 在打印完通用部分后由 core 统一调度。
448
+ """
449
+ print(f"\n{BOLD}网络{NC}")
450
+
451
+ iface = d_net.get("default_iface", "")
452
+ ip = d_net.get("default_ip", "")
453
+ if iface and ip:
454
+ print(f" {iface:<8s}{ip}")
455
+ elif iface:
456
+ print(f" {iface:<8s}{YELLOW}no IP{NC}")
457
+ else:
458
+ print(f" default {YELLOW}无默认路由{NC}")
459
+
460
+
461
+ # ── 主入口 ────────────────────────────────────────────────────────────────────
462
+
463
+ def cmd_status(engine, api: str, api_secret: str,
464
+ config: dict, mode: str = "", registry=None):
465
+ """proxyctl status — 并发采集数据,顺序打印状态面板。
466
+
467
+ Args:
468
+ engine: Backend 实例
469
+ api: Clash API 基础 URL
470
+ api_secret: Clash API Bearer token
471
+ config: 全局配置字典
472
+ mode: 代理模式字符串(tun/proxy/mixed)
473
+ registry: PluginRegistry,提供 status_sections(VPN/Tailscale/TUIC relay 等)
474
+ """
475
+ dns_lock_label = config.get("dns_lock_label", "com.proxyctl.dns-lock")
476
+ claude_proxy_label = config.get("claude_proxy_label", "com.proxyctl.claude-proxy")
477
+
478
+ # 全部 section 并发采集,拿到一个打印一个
479
+ with ThreadPoolExecutor(max_workers=8) as pool:
480
+ f_engine = pool.submit(_gather_engine, engine)
481
+ f_ports = pool.submit(_gather_ports, claude_proxy_label)
482
+ f_tun = pool.submit(_gather_tun, engine, True)
483
+ f_proxy = pool.submit(_gather_proxy_settings)
484
+ f_dns = pool.submit(_gather_dns, dns_lock_label)
485
+ f_network = pool.submit(_gather_network, engine)
486
+
487
+ # 插件 status_sections:每个 section 并发跑 gather
488
+ ctx = {"engine": engine.name, "mode": mode, "config": config}
489
+ sections = []
490
+ if registry is not None:
491
+ sections = registry.collect("status_sections", ctx=ctx)
492
+ f_sections = [(s, pool.submit(s.gather, ctx)) for s in sections]
493
+
494
+ d_engine = f_engine.result()
495
+ d_ports = f_ports.result()
496
+ _print_engine(engine, mode, d_engine, d_ports)
497
+ daemon_up = d_engine["daemon_up"]
498
+
499
+ if mode in ("tun", "mixed"):
500
+ _print_tun(engine, f_tun.result())
501
+
502
+ _print_proxy_settings(f_proxy.result(), daemon_up, mode)
503
+ _print_dns(daemon_up, f_dns.result(), mode)
504
+ _print_network(f_network.result())
505
+
506
+ # 插件 sections(VPN/Tailscale/TUIC relay 等本机特例都走这里)
507
+ for section, future in f_sections:
508
+ try:
509
+ data = future.result()
510
+ section.render(ctx, data)
511
+ except Exception as e:
512
+ import sys
513
+ sys.stderr.write(
514
+ f"[plugin warning] status_section {section.name} failed: {e}\n"
515
+ )
516
+
517
+ # 环境变量代理
518
+ env_parts = [f"{var}={os.environ[var]}"
519
+ for var in ("http_proxy", "https_proxy", "all_proxy")
520
+ if os.environ.get(var)]
521
+ if env_parts:
522
+ print(f"\n{BOLD}ENV{NC}")
523
+ print(f" {' '.join(env_parts)}")