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/__init__.py +3 -0
- proxyctl/audit.py +385 -0
- proxyctl/builtin_plugins/__init__.py +5 -0
- proxyctl/builtin_plugins/connectivity_basic.py +35 -0
- proxyctl/builtin_plugins/corp_network.py +57 -0
- proxyctl/check.py +761 -0
- proxyctl/cli.py +1355 -0
- proxyctl/core/__init__.py +1 -0
- proxyctl/core/plugin.py +287 -0
- proxyctl/engine/__init__.py +12 -0
- proxyctl/engine/base.py +85 -0
- proxyctl/engine/mihomo.py +127 -0
- proxyctl/engine/singbox.py +135 -0
- proxyctl/status.py +523 -0
- proxyctl/trace.py +558 -0
- proxyctl-0.1.0.dist-info/METADATA +218 -0
- proxyctl-0.1.0.dist-info/RECORD +20 -0
- proxyctl-0.1.0.dist-info/WHEEL +4 -0
- proxyctl-0.1.0.dist-info/entry_points.txt +2 -0
- proxyctl-0.1.0.dist-info/licenses/LICENSE +21 -0
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)}")
|