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/cli.py ADDED
@@ -0,0 +1,1355 @@
1
+ """proxyctl — Proxy configuration lifecycle management
2
+
3
+ 支持 Mihomo 后端(首发)/ Sing-box 后端(预留)
4
+
5
+ 用法:proxyctl [start|stop|restart|status|log|check|fix|recover|
6
+ mode|audit|trace|bench|dns-lock|dns-unlock]
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import re
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ # ── 平台检测 ────────────────────────────────────────────────────────────────
19
+ PLATFORM = platform.system() # "Darwin" | "Linux"
20
+ IS_MACOS = PLATFORM == "Darwin"
21
+ IS_LINUX = PLATFORM == "Linux"
22
+
23
+ # ── 路径常量 ────────────────────────────────────────────────────────────────
24
+ HOME = os.path.expanduser("~")
25
+ DEFAULT_CONFIG_DIR = os.path.join(HOME, ".config", "proxyctl")
26
+ CONFIG_FILE = os.path.join(DEFAULT_CONFIG_DIR, "config.yaml")
27
+
28
+ # 默认后端:mihomo(首发支持)
29
+ DEFAULT_BACKEND = "mihomo"
30
+
31
+ # ── 颜色 ────────────────────────────────────────────────────────────────────
32
+ RED = "\033[0;31m"
33
+ GREEN = "\033[0;32m"
34
+ YELLOW = "\033[0;33m"
35
+ CYAN = "\033[0;36m"
36
+ BOLD = "\033[1m"
37
+ NC = "\033[0m"
38
+
39
+ # ── 默认配置(可通过 config.yaml 覆盖)───────────────────────────────────────
40
+ DEFAULTS = {
41
+ "backend": "mihomo", # mihomo | singbox
42
+ "api_base": "http://127.0.0.1:9090",
43
+ "api_secret": "", # 必填:Clash API Bearer token
44
+ "config_dir": os.path.join(HOME, ".config"),
45
+ "dns_lock_label": "com.proxyctl.dns-lock",
46
+ }
47
+
48
+ SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__))
49
+ USER_PLUGIN_DIR = os.path.join(DEFAULT_CONFIG_DIR, "plugins")
50
+
51
+
52
+ def load_config() -> dict:
53
+ """加载配置文件,返回合并后的配置字典。"""
54
+ cfg = DEFAULTS.copy()
55
+ if os.path.isfile(CONFIG_FILE):
56
+ try:
57
+ import yaml
58
+ with open(CONFIG_FILE) as f:
59
+ user_cfg = yaml.safe_load(f) or {}
60
+ cfg.update(user_cfg)
61
+ except Exception as e:
62
+ print(f"{YELLOW}警告:读取配置文件失败:{e}{NC}")
63
+ print(f" 使用默认配置,可能需要在 {CONFIG_FILE} 中配置 api_secret")
64
+ return cfg
65
+
66
+
67
+ # ── 后端类 ───────────────────────────────────────────────────────────────────
68
+
69
+ class Backend:
70
+ """后端抽象基类,封装引擎名称、路径和平台差异。"""
71
+
72
+ def __init__(self, name: str, config: dict):
73
+ self.name = name
74
+ self.config = config
75
+ self.config_dir = config.get("config_dir", os.path.join(HOME, ".config"))
76
+
77
+ @property
78
+ def label(self) -> str:
79
+ """macOS launchctl service label。"""
80
+ raise NotImplementedError
81
+
82
+ @property
83
+ def plist(self) -> str:
84
+ """macOS LaunchDaemon plist 路径。"""
85
+ raise NotImplementedError
86
+
87
+ @property
88
+ def unit(self) -> str:
89
+ """Linux systemd user service unit 名称。"""
90
+ raise NotImplementedError
91
+
92
+ @property
93
+ def config_file(self) -> str:
94
+ raise NotImplementedError
95
+
96
+ @property
97
+ def cache_file(self) -> str:
98
+ raise NotImplementedError
99
+
100
+ @property
101
+ def log_file(self) -> str:
102
+ raise NotImplementedError
103
+
104
+
105
+ class MihomoBackend(Backend):
106
+ """Mihomo (Clash) 后端实现"""
107
+
108
+ def __init__(self, config: dict):
109
+ super().__init__("mihomo", config)
110
+
111
+ @property
112
+ def label(self) -> str:
113
+ return "system/com.mihomo.tun"
114
+
115
+ @property
116
+ def plist(self) -> str:
117
+ return "/Library/LaunchDaemons/com.mihomo.tun.plist"
118
+
119
+ @property
120
+ def unit(self) -> str:
121
+ return "mihomo.service"
122
+
123
+ @property
124
+ def config_file(self) -> str:
125
+ return os.path.join(self.config_dir, "mihomo", "config.yaml")
126
+
127
+ @property
128
+ def cache_file(self) -> str:
129
+ return os.path.join(self.config_dir, "mihomo", "cache.db")
130
+
131
+ @property
132
+ def log_file(self) -> str:
133
+ return os.path.join(self.config_dir, "mihomo", "mihomo.log")
134
+
135
+
136
+ class SingboxBackend(Backend):
137
+ """Sing-box 后端实现"""
138
+
139
+ def __init__(self, config: dict):
140
+ super().__init__("singbox", config)
141
+
142
+ @property
143
+ def label(self) -> str:
144
+ return "system/com.singbox.tun"
145
+
146
+ @property
147
+ def plist(self) -> str:
148
+ return "/Library/LaunchDaemons/com.singbox.tun.plist"
149
+
150
+ @property
151
+ def unit(self) -> str:
152
+ return "sing-box.service"
153
+
154
+ @property
155
+ def config_file(self) -> str:
156
+ return os.path.join(self.config_dir, "sing-box", "config.json")
157
+
158
+ @property
159
+ def cache_file(self) -> str:
160
+ return os.path.join(self.config_dir, "sing-box", "cache.db")
161
+
162
+ @property
163
+ def log_file(self) -> str:
164
+ return os.path.join(self.config_dir, "sing-box", "sing-box.log")
165
+
166
+
167
+ def get_backend(config: dict) -> Backend:
168
+ """根据配置返回后端实例。"""
169
+ backend_name = config.get("backend", DEFAULT_BACKEND)
170
+ if backend_name == "mihomo":
171
+ return MihomoBackend(config)
172
+ else:
173
+ return SingboxBackend(config)
174
+
175
+
176
+ # ── 插件加载 ─────────────────────────────────────────────────────────────────
177
+
178
+ def load_plugins(config: dict):
179
+ """加载内置 + 用户插件目录,返回 registry。
180
+
181
+ 用户禁用某插件:在 config.yaml 加 `plugins_disabled: [name1, name2]`。
182
+ 用户插件目录:~/.config/proxyctl/plugins/*.py
183
+ """
184
+ from proxyctl.core.plugin import build_registry
185
+ return build_registry(config, USER_PLUGIN_DIR)
186
+
187
+
188
+ def cmd_plugins(registry):
189
+ """proxyctl plugins — 显示已加载插件 + 加载期错误。"""
190
+ print(f"{BOLD}插件列表{NC} (内置 + ~/.config/proxyctl/plugins/)")
191
+ if not registry.plugins and not registry.errors:
192
+ print(f" {YELLOW}—{NC} 无已加载插件")
193
+ for p in registry.plugins:
194
+ # 简略列出该插件实现了哪些 hook(识别 dataclass 返回的方法)
195
+ hook_names = [
196
+ "check_groups", "check_targets", "check_outbound_probes",
197
+ "dns_hooks", "route_hooks", "status_sections",
198
+ "watchdog_layers", "audit_skip_hosts", "audit_known_proxy_kw",
199
+ ]
200
+ active = []
201
+ for h in hook_names:
202
+ method = getattr(type(p), h, None)
203
+ base_method = getattr(__import__("proxyctl.core.plugin",
204
+ fromlist=["Plugin"]).Plugin, h)
205
+ # 比较函数对象,子类覆盖才算 active
206
+ if method is not None and method is not base_method:
207
+ active.append(h)
208
+ active_str = ", ".join(active) if active else "(no hooks)"
209
+ print(f" {GREEN}✓{NC} {CYAN}{p.name or type(p).__name__}{NC} "
210
+ f"[{type(p).__module__}] {active_str}")
211
+ if registry.errors:
212
+ print(f"\n{YELLOW}加载错误:{NC}")
213
+ for source, err in registry.errors:
214
+ print(f" {RED}✗{NC} {source} {err}")
215
+ print(f"\n用户插件目录: {USER_PLUGIN_DIR}")
216
+ if not os.path.isdir(USER_PLUGIN_DIR):
217
+ print(f" {YELLOW}—{NC} 目录不存在(首次使用请 mkdir -p)")
218
+
219
+
220
+ # ── 基础工具 ─────────────────────────────────────────────────────────────────
221
+
222
+ def run(cmd: list, *, sudo: bool = False, check: bool = False,
223
+ capture: bool = False, stdin_text: str = None) -> subprocess.CompletedProcess:
224
+ """执行系统命令。sudo=True 自动加 sudo,capture=True 返回文本输出。"""
225
+ if sudo:
226
+ cmd = ["sudo"] + cmd
227
+ kw: dict = {"check": check}
228
+ if capture:
229
+ kw["capture_output"] = True
230
+ kw["text"] = True
231
+ if stdin_text is not None:
232
+ kw["input"] = stdin_text
233
+ kw["text"] = True
234
+ return subprocess.run(cmd, **kw)
235
+
236
+
237
+ def run_out(cmd: list, *, sudo: bool = False) -> str:
238
+ """执行命令,返回 stdout;失败返回空字符串。"""
239
+ r = run(cmd, sudo=sudo, capture=True)
240
+ return r.stdout.strip() if r.returncode == 0 else ""
241
+
242
+
243
+ def wait_port(port: int, timeout: float = 10.0) -> bool:
244
+ """轮询直到 127.0.0.1:port 就绪,超时返回 False。"""
245
+ deadline = time.monotonic() + timeout
246
+ while time.monotonic() < deadline:
247
+ try:
248
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
249
+ return True
250
+ except OSError:
251
+ time.sleep(0.3)
252
+ return False
253
+
254
+
255
+ def list_network_services() -> list:
256
+ """返回系统所有网络服务名称(过滤掉 * 前缀的禁用项)。"""
257
+ out = run_out(["networksetup", "-listallnetworkservices"])
258
+ return [s for s in out.splitlines()[1:] if not s.startswith("*")]
259
+
260
+
261
+ def launchctl_running(label: str, *, sudo: bool = False) -> bool:
262
+ """检查 launchctl 服务是否在运行(仅 macOS)。"""
263
+ r = run(["launchctl", "print", label], sudo=sudo, capture=True)
264
+ return r.returncode == 0
265
+
266
+
267
+ # ── 平台感知的服务管理 ──────────────────────────────────────────────────────
268
+
269
+ def service_running(backend: Backend) -> bool:
270
+ """检查引擎服务是否在运行。"""
271
+ if IS_MACOS:
272
+ return launchctl_running(backend.label)
273
+ r = run(["systemctl", "--user", "is-active", "--quiet", backend.unit], capture=True)
274
+ return r.returncode == 0
275
+
276
+
277
+ def service_start(backend: Backend, config: dict) -> subprocess.CompletedProcess:
278
+ """启动引擎服务。
279
+
280
+ Args:
281
+ backend: 后端实例
282
+ config: 全局配置(macOS 需要定位 plist 源文件)
283
+
284
+ Returns:
285
+ subprocess.CompletedProcess
286
+ """
287
+ if IS_MACOS:
288
+ if not os.path.isfile(backend.plist):
289
+ src = os.path.join(DEFAULT_CONFIG_DIR, "launchdaemons",
290
+ os.path.basename(backend.plist))
291
+ if not os.path.isfile(src):
292
+ print(f"{RED}✗{NC} plist 源文件不存在:{src}")
293
+ sys.exit(1)
294
+ run(["cp", src, backend.plist], sudo=True)
295
+ return run(["launchctl", "bootstrap", "system", backend.plist],
296
+ sudo=True, capture=True)
297
+ return run(["systemctl", "--user", "start", backend.unit], capture=True)
298
+
299
+
300
+ def service_stop(backend: Backend) -> subprocess.CompletedProcess:
301
+ """停止引擎服务。"""
302
+ if IS_MACOS:
303
+ return run(["launchctl", "bootout", backend.label], sudo=True, capture=True)
304
+ return run(["systemctl", "--user", "stop", backend.unit], capture=True)
305
+
306
+
307
+ def service_restart(backend: Backend) -> subprocess.CompletedProcess:
308
+ """重启引擎服务。"""
309
+ if IS_MACOS:
310
+ return run(["launchctl", "kickstart", "-k", backend.label],
311
+ sudo=True, capture=True)
312
+ return run(["systemctl", "--user", "restart", backend.unit], capture=True)
313
+
314
+
315
+ def scutil_exec(script: str):
316
+ """向 sudo scutil stdin 写入脚本并执行。"""
317
+ run(["scutil"], sudo=True, stdin_text=script)
318
+
319
+
320
+ def get_mode(backend: Backend) -> str:
321
+ """从配置文件读取当前模式 (tun/proxy/mixed/unknown)。"""
322
+ try:
323
+ if backend.name == "mihomo":
324
+ text = open(backend.config_file).read()
325
+ tun_m = re.search(r'^tun:\s*\n((?:\s+.*\n)*)', text, re.M)
326
+ tun_block = tun_m.group(0) if tun_m else ""
327
+ tun_on = bool(re.search(r'enable:\s*true', tun_block))
328
+ auto_rt = bool(re.search(r'auto-route:\s*true', tun_block))
329
+ fakeip = bool(re.search(r'enhanced-mode:\s*fake-ip', text))
330
+ if tun_on and auto_rt and fakeip:
331
+ return "tun"
332
+ elif not auto_rt and not fakeip:
333
+ return "proxy"
334
+ return "mixed"
335
+ else:
336
+ cfg = json.load(open(backend.config_file))
337
+ ar = True
338
+ for ib in cfg.get("inbounds", []):
339
+ if ib.get("type") == "tun":
340
+ ar = ib.get("auto_route", True)
341
+ break
342
+ fakeip = any(r.get("server") == "fakeip-dns"
343
+ for r in cfg.get("dns", {}).get("rules", []))
344
+ if ar and fakeip:
345
+ return "tun"
346
+ elif not ar and not fakeip:
347
+ return "proxy"
348
+ return "mixed"
349
+ except Exception:
350
+ return "unknown"
351
+
352
+
353
+ def get_primary_resolver() -> str:
354
+ """返回 scutil --dns resolver #1 的第一个 nameserver。"""
355
+ r = subprocess.run(["scutil", "--dns"], capture_output=True, text=True)
356
+ in_r1 = False
357
+ for line in r.stdout.splitlines():
358
+ if "resolver #1" in line:
359
+ in_r1 = True
360
+ if in_r1 and "nameserver[0]" in line:
361
+ return line.split()[-1]
362
+ return ""
363
+
364
+
365
+ # ── DNS 联动 ─────────────────────────────────────────────────────────────────
366
+
367
+ def dns_activate(config: dict):
368
+ """设置系统 DNS → 127.0.0.1(三层防线)。
369
+
370
+ Args:
371
+ config: 全局配置字典,层 2 的 domain fallback 从 corp_dns.domain 读取
372
+ """
373
+ # 层 1: networksetup 对抗 DHCP
374
+ for svc in list_network_services():
375
+ run(["networksetup", "-setdnsservers", svc, "127.0.0.1"])
376
+
377
+ # 层 2: 劫持 AnyConnect 自己的 DNS 条目
378
+ ac_key = "State:/Network/Service/com.cisco.anyconnect/DNS"
379
+ r = subprocess.run(["sudo", "scutil"], input=f"show {ac_key}\n",
380
+ capture_output=True, text=True)
381
+ if "ServerAddresses" in r.stdout:
382
+ domain = search_order = ""
383
+ for line in r.stdout.splitlines():
384
+ if "DomainName" in line and ":" in line and not domain:
385
+ domain = line.split(":", 1)[1].strip()
386
+ elif "SearchOrder" in line and ":" in line and not search_order:
387
+ search_order = line.split(":", 1)[1].strip()
388
+ corp = config.get("corp_dns", {}) or {}
389
+ domain = domain or corp.get("domain", "") or "example.com"
390
+ search_order = search_order or "1"
391
+ scutil_exec(
392
+ f"d.init\n"
393
+ f"d.add DomainName {domain}\n"
394
+ f"d.add SearchDomains * {domain}\n"
395
+ f"d.add SearchOrder # {search_order}\n"
396
+ f"d.add ServerAddresses * 127.0.0.1\n"
397
+ f"d.add SupplementalMatchDomains * \"\" {domain}\n"
398
+ f"set {ac_key}\n"
399
+ )
400
+
401
+ # 层 3: scutil 兜底 order:0
402
+ scutil_exec(
403
+ "d.init\n"
404
+ "d.add ServerAddresses * 127.0.0.1\n"
405
+ "d.add SupplementalMatchOrder # 0\n"
406
+ "set State:/Network/Service/proxyctl-dns-override/DNS\n"
407
+ )
408
+
409
+ run(["dscacheutil", "-flushcache"], sudo=True)
410
+ run(["killall", "-HUP", "mDNSResponder"], sudo=True)
411
+
412
+
413
+ def dns_deactivate(config: dict):
414
+ """还原系统 DNS(清除三层注入)。
415
+
416
+ 静态 IP 和 DHCP 环境均安全:层 1 仅清除手动 DNS 设置,
417
+ 不影响静态配置中已有的 DNS;层 3 根据 corp_dns 配置决定
418
+ 还原为企业 DNS 或直接移除 AnyConnect key。
419
+
420
+ Args:
421
+ config: 全局配置字典,corp_dns.server 用于 AnyConnect DNS 还原
422
+ """
423
+ # 层 1: 清空手动 DNS(networksetup -setdnsservers <svc> empty
424
+ # 效果:移除手动覆盖,恢复为网络服务自身的 DNS 来源——DHCP 或静态配置均可)
425
+ for svc in list_network_services():
426
+ run(["networksetup", "-setdnsservers", svc, "empty"])
427
+
428
+ # 层 2: 删除 scutil 兜底 resolver
429
+ scutil_exec("remove State:/Network/Service/proxyctl-dns-override/DNS\n")
430
+
431
+ # 层 3: 还原 AnyConnect DNS key(如果还指向 127.0.0.1)
432
+ ac_key = "State:/Network/Service/com.cisco.anyconnect/DNS"
433
+ r = subprocess.run(["sudo", "scutil"], input=f"show {ac_key}\n",
434
+ capture_output=True, text=True)
435
+ addr = ""
436
+ for line in r.stdout.splitlines():
437
+ if "0 :" in line:
438
+ addr = line.split(":", 1)[1].strip()
439
+ break
440
+ if addr == "127.0.0.1":
441
+ corp = config.get("corp_dns", {}) or {}
442
+ corp_server = corp.get("server", "")
443
+ if corp_server:
444
+ # 有企业 DNS 配置:还原为企业 DNS 地址
445
+ corp_v6 = corp.get("server_v6", "")
446
+ corp_domain = corp.get("domain", "") or "example.com"
447
+ addrs = f"{corp_server} {corp_v6}" if corp_v6 else corp_server
448
+ scutil_exec(
449
+ "d.init\n"
450
+ f"d.add ServerAddresses * {addrs}\n"
451
+ f"d.add DomainName {corp_domain}\n"
452
+ f"d.add SearchDomains * {corp_domain}\n"
453
+ "d.add SearchOrder # 1\n"
454
+ f"d.add SupplementalMatchDomains * \"\" {corp_domain}\n"
455
+ f"set {ac_key}\n"
456
+ )
457
+ else:
458
+ # 无企业 DNS 配置:直接移除 key,vpnagentd 重连时会自动重建
459
+ scutil_exec(f"remove {ac_key}\n")
460
+
461
+ run(["dscacheutil", "-flushcache"], sudo=True)
462
+ run(["killall", "-HUP", "mDNSResponder"], sudo=True)
463
+
464
+
465
+ def dns_lock_start(config: dict):
466
+ """启动 dns-lock watchdog daemon(仅 macOS)。"""
467
+ if not IS_MACOS:
468
+ return
469
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
470
+ dns_lock_plist_src = os.path.join(DEFAULT_CONFIG_DIR, "launchdaemons", f"{dns_lock_label}.plist")
471
+ dns_lock_plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
472
+
473
+ if os.path.isfile(dns_lock_plist) and not launchctl_running(f"system/{dns_lock_label}"):
474
+ run(["launchctl", "bootstrap", "system", dns_lock_plist], sudo=True)
475
+
476
+
477
+ def dns_lock_stop(config: dict):
478
+ """停止 dns-lock watchdog daemon(仅 macOS)。"""
479
+ if not IS_MACOS:
480
+ return
481
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
482
+ if launchctl_running(f"system/{dns_lock_label}"):
483
+ run(["launchctl", "bootout", f"system/{dns_lock_label}"], sudo=True)
484
+
485
+
486
+ # ── 系统代理联动 ──────────────────────────────────────────────────────────────
487
+
488
+ def proxy_activate():
489
+ """设置系统 HTTP/HTTPS/SOCKS 代理 → 127.0.0.1:7890。"""
490
+ for svc in list_network_services():
491
+ run(["networksetup", "-setwebproxy", svc, "127.0.0.1", "7890"])
492
+ run(["networksetup", "-setsecurewebproxy", svc, "127.0.0.1", "7890"])
493
+ run(["networksetup", "-setsocksfirewallproxy", svc, "127.0.0.1", "7890"])
494
+ run(["networksetup", "-setwebproxystate", svc, "on"])
495
+ run(["networksetup", "-setsecurewebproxystate", svc, "on"])
496
+ run(["networksetup", "-setsocksfirewallproxystate", svc, "on"])
497
+
498
+
499
+ def proxy_deactivate():
500
+ """关闭系统代理。"""
501
+ for svc in list_network_services():
502
+ run(["networksetup", "-setwebproxystate", svc, "off"])
503
+ run(["networksetup", "-setsecurewebproxystate", svc, "off"])
504
+ run(["networksetup", "-setsocksfirewallproxystate", svc, "off"])
505
+
506
+
507
+ # ── 引擎启停共用逻辑 ──────────────────────────────────────────────────────────
508
+
509
+ def _wait_ready(backend: Backend):
510
+ """等待引擎端口就绪。
511
+
512
+ macOS:等 DNS(53) + 代理(7890),因为可能启用 TUN/fakeip。
513
+ Linux(最小集):只等代理(7890),不劫持 DNS。
514
+ """
515
+ if IS_MACOS:
516
+ wait_port(53, timeout=10)
517
+ wait_port(7890, timeout=10)
518
+ time.sleep(3)
519
+
520
+
521
+ # ── 命令:start ───────────────────────────────────────────────────────────────
522
+
523
+ def cmd_start(backend: Backend, config: dict):
524
+ r = service_start(backend, config)
525
+ if r.returncode != 0:
526
+ print(f"{RED}✗{NC} {backend.name} 启动失败")
527
+ print(r.stderr if r.stderr else "")
528
+ sys.exit(1)
529
+
530
+ print(f"{backend.name} started")
531
+ _wait_ready(backend)
532
+
533
+ if IS_MACOS:
534
+ dns_activate(config)
535
+ dns_lock_start(config)
536
+ print("DNS → 127.0.0.1 (已激活)")
537
+ if get_mode(backend) == "proxy":
538
+ proxy_activate()
539
+ print("系统代理 → 127.0.0.1:7890 (已激活)")
540
+
541
+
542
+ # ── 命令:stop ────────────────────────────────────────────────────────────────
543
+
544
+ def cmd_stop(backend: Backend, config: dict):
545
+ if IS_MACOS:
546
+ dns_lock_stop(config)
547
+ dns_deactivate(config)
548
+ proxy_deactivate()
549
+ print("DNS → DHCP (已还原), 系统代理已关闭")
550
+
551
+ service_stop(backend)
552
+ print(f"{backend.name} stopped")
553
+
554
+
555
+ # ── 命令:restart ─────────────────────────────────────────────────────────────
556
+
557
+ def cmd_restart(backend: Backend, config: dict, *, clean: bool = False):
558
+ if clean and os.path.isfile(backend.cache_file):
559
+ os.remove(backend.cache_file)
560
+ # 人工介入后清掉 watchdog 的失败状态,避免误判为"还在触顶窗口内"
561
+ for f in ("/tmp/proxyctl-recover-history", "/tmp/proxyctl-recover-stuck",
562
+ "/tmp/proxyctl-proxy-fail", "/tmp/proxyctl-recover-cooldown",
563
+ "/tmp/sb-recover-history", "/tmp/sb-recover-stuck",
564
+ "/tmp/sb-recover-count", "/tmp/sb-proxy-fail"):
565
+ try: os.remove(f)
566
+ except (FileNotFoundError, PermissionError): pass
567
+ service_restart(backend)
568
+ print(f"{backend.name} restarted{' (cache cleared)' if clean else ''}")
569
+ _wait_ready(backend)
570
+
571
+ if IS_MACOS:
572
+ dns_activate(config)
573
+ dns_lock_start(config)
574
+ print("DNS → 127.0.0.1 (已刷新)")
575
+ if get_mode(backend) == "proxy":
576
+ proxy_activate()
577
+ print("系统代理 → 127.0.0.1:7890")
578
+ else:
579
+ proxy_deactivate()
580
+
581
+
582
+ # ── 命令:fix ─────────────────────────────────────────────────────────────────
583
+
584
+ def cmd_fix(backend: Backend, config: dict):
585
+ """修复引擎状态:运行中则重注入 DNS/代理,已停止则还原系统配置。"""
586
+ api_base = config.get("api_base", DEFAULTS["api_base"])
587
+ api_secret = config.get("api_secret", "")
588
+
589
+ if service_running(backend):
590
+ if IS_MACOS:
591
+ print(f"{BOLD}[引擎运行中] 修复 DNS → 127.0.0.1{NC}")
592
+ before = get_primary_resolver()
593
+ print(f" 修复前 primary resolver: {before or 'unknown'}")
594
+
595
+ dns_activate(config)
596
+ after = get_primary_resolver()
597
+ if after == "127.0.0.1":
598
+ print(f" {GREEN}✓{NC} primary resolver → 127.0.0.1")
599
+ else:
600
+ print(f" {RED}✗{NC} primary resolver 仍为 {after},需手动排查")
601
+
602
+ if get_mode(backend) == "proxy":
603
+ proxy_activate()
604
+ print(f" {GREEN}✓{NC} 系统代理 → 127.0.0.1:7890")
605
+ else:
606
+ print(f"{BOLD}[引擎运行中] 尝试热重载配置{NC}")
607
+
608
+ # 热重载配置(跨平台,通过 Clash API)
609
+ if backend.name == "mihomo":
610
+ subprocess.run(
611
+ ["curl", "-s", "--noproxy", "*", "-X", "PUT",
612
+ f"http://127.0.0.1:9090/configs?force=true",
613
+ "-H", f"Authorization: Bearer {api_secret}",
614
+ "-H", "Content-Type: application/json",
615
+ "-d", json.dumps({"path": backend.config_file})],
616
+ capture_output=True, timeout=8
617
+ )
618
+ print(f" {GREEN}✓{NC} mihomo 配置已热重载")
619
+ subprocess.run(
620
+ ["curl", "-s", "--noproxy", "*", "-X", "PUT",
621
+ f"http://127.0.0.1:9090/cache/fakeip",
622
+ "-H", f"Authorization: Bearer {api_secret}"],
623
+ capture_output=True, timeout=5
624
+ )
625
+ print(f" {GREEN}✓{NC} mihomo DNS 缓存已清空")
626
+
627
+ print()
628
+ if IS_MACOS:
629
+ if before == "127.0.0.1":
630
+ print(f"{GREEN}{BOLD}一切正常,无需修复。{NC}")
631
+ else:
632
+ print(f"{GREEN}{BOLD}修复完成。{NC}")
633
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
634
+ if not launchctl_running(f"system/{dns_lock_label}"):
635
+ print(f"{CYAN}建议执行 {BOLD}proxyctl dns-lock{NC}"
636
+ f"{CYAN} 防止 DNS 再次被覆盖。{NC}")
637
+ else:
638
+ print(f"{GREEN}{BOLD}修复完成。{NC}")
639
+ else:
640
+ if IS_MACOS:
641
+ print(f"{BOLD}[引擎已停止] 还原系统配置为正常状态{NC}")
642
+ before = get_primary_resolver()
643
+
644
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
645
+ if launchctl_running(f"system/{dns_lock_label}"):
646
+ dns_lock_stop(config)
647
+ print(f" {GREEN}✓{NC} dns-lock 看门狗已停止")
648
+
649
+ dns_deactivate(config)
650
+ after = get_primary_resolver()
651
+ print(f" {GREEN}✓{NC} DNS → DHCP ({after or 'DHCP'})")
652
+
653
+ proxy_deactivate()
654
+ print(f" {GREEN}✓{NC} 系统代理已关闭")
655
+
656
+ print()
657
+ if before != "127.0.0.1":
658
+ print(f"{GREEN}{BOLD}一切正常,无需修复。{NC}")
659
+ else:
660
+ print(f"{GREEN}{BOLD}还原完成。{NC}")
661
+ else:
662
+ print(f"{YELLOW}引擎已停止,无需修复。{NC}")
663
+ print(f" 使用 {BOLD}proxyctl start{NC} 启动引擎")
664
+
665
+
666
+ # ── 命令:recover ─────────────────────────────────────────────────────────────
667
+
668
+ def cmd_recover(backend: Backend, config: dict):
669
+ """切网后软恢复(清 DNS 缓存 + 重测代理组,不重启进程)"""
670
+ api_base = config.get("api_base", DEFAULTS["api_base"])
671
+ api_secret = config.get("api_secret", "")
672
+
673
+ if backend.name != "mihomo":
674
+ print(f"{YELLOW}recover 目前只支持 mihomo 后端;当前后端 {backend.name}{NC}")
675
+ print(f"请使用 {BOLD}proxyctl restart{NC}")
676
+ sys.exit(1)
677
+
678
+ if not launchctl_running(backend.label):
679
+ print(f"{RED}✗{NC} {backend.name} 未运行,请先 proxyctl start")
680
+ sys.exit(1)
681
+
682
+ t0 = time.monotonic()
683
+ auth = ["-H", f"Authorization: Bearer {api_secret}"]
684
+
685
+ # 步骤 1: 热重载 config(清 DNS cache)
686
+ print(f"{BOLD}[1/3]{NC} 热重载配置(清 DNS 缓存)")
687
+ r = subprocess.run(
688
+ ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
689
+ "--noproxy", "*", "-X", "PUT",
690
+ f"{api_base}/configs?force=true",
691
+ *auth, "-H", "Content-Type: application/json",
692
+ "-d", json.dumps({"path": backend.config_file})],
693
+ capture_output=True, text=True, timeout=10
694
+ )
695
+ if r.stdout.strip() in ("200", "204"):
696
+ print(f" {GREEN}✓{NC} 配置已重载 (HTTP {r.stdout.strip()})")
697
+ else:
698
+ print(f" {RED}✗{NC} 重载失败 (HTTP {r.stdout.strip() or 'n/a'}),回退到 proxyctl restart")
699
+ sys.exit(2)
700
+
701
+ # 步骤 2: flush fakeip cache
702
+ print(f"{BOLD}[2/3]{NC} 清空 fakeip 缓存")
703
+ subprocess.run(
704
+ ["curl", "-s", "-o", "/dev/null", "--noproxy", "*", "-X", "POST",
705
+ f"{api_base}/cache/fakeip/flush", *auth],
706
+ capture_output=True, timeout=5
707
+ )
708
+ print(f" {GREEN}✓{NC} fakeip 缓存已清空")
709
+
710
+ # 步骤 3: 触发代理组 healthcheck
711
+ print(f"{BOLD}[3/3]{NC} 触发代理组 healthcheck")
712
+ r = subprocess.run(
713
+ ["curl", "-s", "--noproxy", "*", *auth, f"{api_base}/proxies"],
714
+ capture_output=True, text=True, timeout=5
715
+ )
716
+ try:
717
+ proxies = json.loads(r.stdout).get("proxies", {})
718
+ except Exception:
719
+ print(f" {YELLOW}—{NC} 无法拉取 proxies 列表")
720
+ sys.exit(2)
721
+
722
+ import urllib.parse
723
+ TEST_URL = "https://www.gstatic.com/generate_204"
724
+ TIMEOUT_MS = 5000
725
+ groups_to_test = [
726
+ name for name, info in proxies.items()
727
+ if info.get("type") in ("URLTest", "Fallback", "LoadBalance")
728
+ ]
729
+ if not groups_to_test:
730
+ print(f" {YELLOW}—{NC} 无 url-test 类型组")
731
+ else:
732
+ from concurrent.futures import ThreadPoolExecutor
733
+ encoded_url = urllib.parse.quote(TEST_URL, safe="")
734
+
735
+ def _test_group(gname: str) -> tuple:
736
+ encoded = urllib.parse.quote(gname, safe="")
737
+ endpoint = (f"{api_base}/group/{encoded}/delay"
738
+ f"?url={encoded_url}&timeout={TIMEOUT_MS}")
739
+ rr = subprocess.run(
740
+ ["curl", "-s", "--noproxy", "*", "--max-time", "10",
741
+ *auth, endpoint],
742
+ capture_output=True, text=True, timeout=12
743
+ )
744
+ try:
745
+ data = json.loads(rr.stdout) if rr.stdout else {}
746
+ except Exception:
747
+ data = {}
748
+ alive = sum(1 for v in data.values() if isinstance(v, int) and v > 0)
749
+ total = len(data) if data else 0
750
+ return gname, alive, total
751
+
752
+ with ThreadPoolExecutor(max_workers=len(groups_to_test)) as pool:
753
+ results = list(pool.map(_test_group, groups_to_test))
754
+
755
+ for gname, alive, total in results:
756
+ if total == 0:
757
+ mark = f"{RED}✗{NC}"
758
+ detail = "无响应"
759
+ elif alive == 0:
760
+ mark = f"{RED}✗{NC}"
761
+ detail = f"0/{total} 存活"
762
+ elif alive < total:
763
+ mark = f"{YELLOW}—{NC}"
764
+ detail = f"{alive}/{total} 存活"
765
+ else:
766
+ mark = f"{GREEN}✓{NC}"
767
+ detail = f"{alive}/{total} 存活"
768
+ print(f" {mark} {CYAN}{gname}{NC} {detail}")
769
+
770
+ elapsed = time.monotonic() - t0
771
+ print()
772
+ print(f"{GREEN}{BOLD}recover 完成{NC} 耗时 {elapsed:.1f}s")
773
+ print(f"建议运行 {BOLD}proxyctl check{NC} 验证恢复情况;若仍失败请 {BOLD}proxyctl restart{NC}")
774
+
775
+
776
+ # ── 命令:mode ────────────────────────────────────────────────────────────────
777
+
778
+ def cmd_mode(backend: Backend, target: str):
779
+ if not target:
780
+ cur = get_mode(backend)
781
+ print(f"当前:backend={backend.name} mode={cur}")
782
+ print("切换:proxyctl mode tun | proxyctl mode proxy")
783
+ return
784
+
785
+ if target not in ("tun", "proxy"):
786
+ print("用法:proxyctl mode [tun|proxy]")
787
+ print(" tun — 全局接管 (auto_route + fakeip)")
788
+ print(" proxy — 仅代理端口 (7890 + real DNS)")
789
+ sys.exit(1)
790
+
791
+ if backend.name == "mihomo":
792
+ _mode_mihomo(backend.config_file, target)
793
+ else:
794
+ _mode_singbox(backend.config_file, target)
795
+
796
+ if target == "proxy":
797
+ proxy_activate()
798
+ print("系统代理 → 127.0.0.1:7890")
799
+ else:
800
+ proxy_deactivate()
801
+ print("系统代理已关闭")
802
+
803
+
804
+ def _mode_mihomo(config_path: str, target: str):
805
+ text = open(config_path).read()
806
+ if target == "tun":
807
+ text = re.sub(r'(tun:\s*\n(?:\s*#[^\n]*\n)*\s*enable:\s*)false', r'\1true', text)
808
+ text = re.sub(r'(auto-route:\s*)false', r'\1true', text)
809
+ text = re.sub(r'enhanced-mode:\s*redir-host', 'enhanced-mode: fake-ip', text)
810
+ msg = "已切换到 tun 模式 (auto_route + fakeip)"
811
+ else:
812
+ text = re.sub(r'(tun:\s*\n(?:\s*#[^\n]*\n)*\s*enable:\s*)true', r'\1false', text)
813
+ text = re.sub(r'(auto-route:\s*)true', r'\1false', text)
814
+ text = re.sub(r'enhanced-mode:\s*fake-ip', 'enhanced-mode: redir-host', text)
815
+ msg = "已切换到 proxy_only 模式 (7890 + redir-host)"
816
+ open(config_path, "w").write(text)
817
+ print(msg)
818
+ print("执行 proxyctl restart 生效")
819
+
820
+
821
+ def _mode_singbox(config_path: str, target: str):
822
+ cfg = json.load(open(config_path))
823
+ for ib in cfg.get("inbounds", []):
824
+ if ib.get("type") == "tun":
825
+ ib["auto_route"] = (target == "tun")
826
+ break
827
+ for rule in cfg.get("dns", {}).get("rules", []):
828
+ qt = rule.get("query_type", [])
829
+ if "A" in qt and "AAAA" in qt:
830
+ rule["server"] = "fakeip-dns" if target == "tun" else "proxy-dns"
831
+ break
832
+ with open(config_path, "w") as f:
833
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
834
+ f.write("\n")
835
+ if target == "tun":
836
+ print("已切换到 tun 模式 (auto_route + fakeip)")
837
+ else:
838
+ print("已切换到 proxy_only 模式 (7890 + real DNS)")
839
+ print("执行 proxyctl restart 生效")
840
+
841
+
842
+ # ── 命令:dns-lock / dns-unlock ───────────────────────────────────────────────
843
+
844
+ DNS_LOCK_PLIST_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
845
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
846
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
847
+ <plist version="1.0">
848
+ <dict>
849
+ <key>Label</key>
850
+ <string>{label}</string>
851
+ <key>ProgramArguments</key>
852
+ <array>
853
+ <string>/bin/bash</string>
854
+ <string>{watchdog_path}</string>
855
+ </array>
856
+ <key>StartInterval</key>
857
+ <integer>30</integer>
858
+ <key>RunAtLoad</key>
859
+ <true/>
860
+ <key>EnvironmentVariables</key>
861
+ <dict>
862
+ <key>PROXYCTL_CONFIG_DIR</key>
863
+ <string>{config_dir}</string>
864
+ <key>PROXYCTL_API_BASE</key>
865
+ <string>{api_base}</string>
866
+ <key>PROXYCTL_API_SECRET</key>
867
+ <string>{api_secret}</string>
868
+ <key>PROXYCTL_ENGINE_LABEL</key>
869
+ <string>{engine_label}</string>
870
+ <key>PROXYCTL_CORP_DOMAIN</key>
871
+ <string>{corp_domain}</string>
872
+ <key>PROXYCTL_TUIC_HEALTHCHECK</key>
873
+ <string>{tuic_healthcheck}</string>
874
+ </dict>
875
+ <key>StandardOutPath</key>
876
+ <string>{log_path}</string>
877
+ <key>StandardErrorPath</key>
878
+ <string>{log_path}</string>
879
+ </dict>
880
+ </plist>
881
+ """
882
+
883
+
884
+ def _render_dns_lock_plist(backend: Backend, config: dict) -> str:
885
+ """根据 config 渲染 dns-lock plist 内容。
886
+
887
+ 所有路径/label/secret 由 config + 默认值组合得出;plist 内容不依赖任何
888
+ 仓库外文件(无须先复制模板到 ~/.config/proxyctl/launchdaemons/)。
889
+ """
890
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
891
+ watchdog_path = os.path.join(HOME, ".local", "bin", "proxyctl-dns-watchdog")
892
+ corp = (config.get("corp_dns") or {})
893
+ healthcheck_off = (config.get("watchdog") or {}).get("tuic_healthcheck") is False
894
+
895
+ return DNS_LOCK_PLIST_TEMPLATE.format(
896
+ label = dns_lock_label,
897
+ watchdog_path = watchdog_path,
898
+ config_dir = DEFAULT_CONFIG_DIR,
899
+ api_base = config.get("api_base", DEFAULTS["api_base"]),
900
+ api_secret = config.get("api_secret", ""),
901
+ engine_label = backend.label,
902
+ corp_domain = corp.get("domain", ""),
903
+ tuic_healthcheck= "0" if healthcheck_off else "1",
904
+ log_path = os.path.join(DEFAULT_CONFIG_DIR, "dns-watchdog.log"),
905
+ )
906
+
907
+
908
+ def cmd_engine(backend: Backend, target: str, config: dict):
909
+ """proxyctl engine [singbox|mihomo] — 切换代理引擎。
910
+
911
+ 无参时打印当前引擎 + 已部署 plist 状态。
912
+ 切换时执行:停旧 daemon → 撤旧 plist → 装新 plist → 起新 daemon。
913
+ 引擎持久化到 ~/.config/proxyctl/engine 文件。
914
+ """
915
+ engine_file = os.path.join(DEFAULT_CONFIG_DIR, "engine")
916
+ if not target:
917
+ cur = backend.name
918
+ sb_ok = os.path.isfile("/Library/LaunchDaemons/com.singbox.tun.plist")
919
+ mh_ok = os.path.isfile("/Library/LaunchDaemons/com.mihomo.tun.plist")
920
+ print(f"当前引擎: {cur}")
921
+ print(f"已部署 plist: singbox={sb_ok} mihomo={mh_ok}")
922
+ print("切换: proxyctl engine singbox | proxyctl engine mihomo")
923
+ return
924
+
925
+ if target not in ("singbox", "mihomo"):
926
+ print("用法: proxyctl engine [singbox|mihomo]")
927
+ sys.exit(1)
928
+ if target == backend.name:
929
+ print(f"已经是 {target},无需切换")
930
+ return
931
+ if not IS_MACOS:
932
+ print(f"{YELLOW}engine 切换暂仅支持 macOS launchd{NC}")
933
+ sys.exit(1)
934
+
935
+ new_backend_cfg = dict(config)
936
+ new_backend_cfg["backend"] = target
937
+ new_backend = get_backend(new_backend_cfg)
938
+
939
+ plist_src = os.path.join(DEFAULT_CONFIG_DIR, "launchdaemons",
940
+ os.path.basename(new_backend.plist))
941
+ # 预检
942
+ if not os.path.isfile(plist_src):
943
+ print(f"{RED}✗{NC} plist 源文件不存在: {plist_src}")
944
+ sys.exit(1)
945
+ if not os.path.isfile(new_backend.config_file):
946
+ print(f"{RED}✗{NC} 配置文件不存在: {new_backend.config_file}")
947
+ sys.exit(1)
948
+
949
+ print(f"停止 {backend.name} ...")
950
+ dns_lock_stop(config)
951
+ dns_deactivate(config)
952
+ proxy_deactivate()
953
+ run(["launchctl", "bootout", backend.label], sudo=True)
954
+ run(["/bin/rm", "-f", backend.plist], sudo=True)
955
+
956
+ r = run(["/bin/cp", plist_src, new_backend.plist], sudo=True, capture=True)
957
+ if r.returncode != 0:
958
+ print(f"{RED}✗{NC} 部署 plist 失败")
959
+ sys.exit(1)
960
+
961
+ # 持久化引擎选择
962
+ os.makedirs(DEFAULT_CONFIG_DIR, exist_ok=True)
963
+ with open(engine_file, "w") as f:
964
+ f.write(target)
965
+
966
+ print(f"启动 {new_backend.name} ...")
967
+ r = run(["launchctl", "bootstrap", "system", new_backend.plist],
968
+ sudo=True, capture=True)
969
+ if r.returncode != 0:
970
+ print(f"{RED}✗{NC} 启动失败")
971
+ sys.exit(1)
972
+
973
+ _wait_ready(new_backend)
974
+ dns_activate(config)
975
+ dns_lock_start(config)
976
+ print("DNS → 127.0.0.1 (已激活)")
977
+ if get_mode(new_backend) == "proxy":
978
+ proxy_activate()
979
+ print("系统代理 → 127.0.0.1:7890")
980
+ print(f"{GREEN}引擎已切换到 {new_backend.name}{NC}")
981
+ print(f"{CYAN}提示: 把 engine: {target} 写到 ~/.config/proxyctl/config.yaml 保持持久{NC}")
982
+
983
+
984
+ def cmd_daemon(name: str, subcmd: str, config: dict):
985
+ """proxyctl daemon [name] [subcmd] — 管理 config.extra_daemons 中声明的辅助 daemon。
986
+
987
+ config 示例:
988
+ extra_daemons:
989
+ my-secondary:
990
+ label: com.example.my-secondary
991
+ plist_src: /path/to/com.example.my-secondary.plist
992
+ log_path: /path/to/my-secondary.log
993
+ port: 7891
994
+ """
995
+ if not IS_MACOS:
996
+ print(f"{YELLOW}daemon 命令暂仅支持 macOS launchd{NC}")
997
+ return
998
+
999
+ daemons = (config.get("extra_daemons") or {})
1000
+
1001
+ if not name:
1002
+ if not daemons:
1003
+ print(f"{YELLOW}—{NC} config.yaml 中未声明任何 extra_daemons")
1004
+ return
1005
+ print(f"{BOLD}已声明的 daemon:{NC}")
1006
+ for d_name, d_cfg in daemons.items():
1007
+ label = d_cfg.get("label", "?")
1008
+ running = launchctl_running(f"system/{label}", sudo=True)
1009
+ mark = f"{GREEN}✓{NC}" if running else f"{YELLOW}—{NC}"
1010
+ print(f" {mark} {d_name} label={label}")
1011
+ return
1012
+
1013
+ d_cfg = daemons.get(name)
1014
+ if not d_cfg:
1015
+ print(f"{RED}✗{NC} 未声明的 daemon: {name}")
1016
+ print(f" 请在 config.yaml extra_daemons 中加入 {name} 段")
1017
+ sys.exit(1)
1018
+
1019
+ label = d_cfg.get("label", "")
1020
+ plist_src = os.path.expanduser(d_cfg.get("plist_src", ""))
1021
+ log_path = os.path.expanduser(d_cfg.get("log_path", ""))
1022
+ port = d_cfg.get("port")
1023
+ if not label:
1024
+ print(f"{RED}✗{NC} daemon {name} 缺少 label 字段")
1025
+ sys.exit(1)
1026
+
1027
+ full_label = f"system/{label}"
1028
+ plist_dst = f"/Library/LaunchDaemons/{label}.plist"
1029
+
1030
+ subcmd = subcmd or "status"
1031
+ if subcmd == "start":
1032
+ if launchctl_running(full_label, sudo=True):
1033
+ print(f"{name} 已在运行")
1034
+ return
1035
+ if not os.path.isfile(plist_dst):
1036
+ if not os.path.isfile(plist_src):
1037
+ print(f"{RED}✗{NC} plist 源文件不存在: {plist_src}")
1038
+ sys.exit(1)
1039
+ run(["/bin/cp", plist_src, plist_dst], sudo=True)
1040
+ r = run(["launchctl", "bootstrap", "system", plist_dst],
1041
+ sudo=True, capture=True)
1042
+ if r.returncode != 0:
1043
+ print(f"{RED}✗{NC} {name} 启动失败")
1044
+ sys.exit(1)
1045
+ if port:
1046
+ wait_port(int(port), timeout=10)
1047
+ print(f"{GREEN}✓{NC} {name} started (127.0.0.1:{port})")
1048
+ else:
1049
+ print(f"{GREEN}✓{NC} {name} started")
1050
+
1051
+ elif subcmd == "stop":
1052
+ if launchctl_running(full_label, sudo=True):
1053
+ run(["launchctl", "bootout", full_label], sudo=True)
1054
+ print(f"{name} stopped")
1055
+ else:
1056
+ print(f"{name} 未在运行")
1057
+
1058
+ elif subcmd == "restart":
1059
+ run(["launchctl", "kickstart", "-k", full_label], sudo=True)
1060
+ print(f"{name} restarted")
1061
+
1062
+ elif subcmd == "log":
1063
+ if not log_path:
1064
+ print(f"{RED}✗{NC} daemon {name} 未声明 log_path")
1065
+ sys.exit(1)
1066
+ os.execvp("tail", ["tail", "-f", log_path])
1067
+
1068
+ else: # status
1069
+ if launchctl_running(full_label, sudo=True):
1070
+ r = subprocess.run(["sudo", "launchctl", "print", full_label],
1071
+ capture_output=True, text=True)
1072
+ pid = next((l.split()[-1] for l in r.stdout.splitlines()
1073
+ if "pid =" in l), "?")
1074
+ port_str = f", port {port}" if port else ""
1075
+ print(f"{GREEN}✓{NC} {name} running (PID {pid}{port_str})")
1076
+ else:
1077
+ print(f"{RED}✗{NC} {name} not running")
1078
+
1079
+
1080
+ def cmd_dns_lock(config: dict, backend: Backend, *, reload: bool = False):
1081
+ if not IS_MACOS:
1082
+ print(f"{YELLOW}dns-lock 仅支持 macOS(Linux 不劫持系统 DNS){NC}")
1083
+ return
1084
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
1085
+ dns_lock_plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
1086
+ dns_watchdog = os.path.join(HOME, ".local", "bin", "proxyctl-dns-watchdog")
1087
+
1088
+ full_label = f"system/{dns_lock_label}"
1089
+ already_registered = launchctl_running(full_label)
1090
+
1091
+ # 默认行为:如果已 registered 且 plist 已存在,认为已装好
1092
+ # reload=True:强制 bootout + 重写 plist + 重新 bootstrap
1093
+ if already_registered and not reload:
1094
+ print(f"dns-lock daemon 已注册(如需更新 plist,请运行 proxyctl dns-lock --reload)")
1095
+ return
1096
+
1097
+ if not os.access(dns_watchdog, os.X_OK):
1098
+ print(f"错误:看门狗脚本不可执行 {dns_watchdog}")
1099
+ print(f" 请先把 scripts/dns-watchdog 安装到该位置(或重新跑 install.sh)")
1100
+ sys.exit(1)
1101
+
1102
+ # reload 时先 bootout
1103
+ if already_registered:
1104
+ r0 = run(["launchctl", "bootout", full_label], sudo=True, capture=True)
1105
+ if r0.returncode != 0:
1106
+ print(f"{YELLOW}⚠{NC} bootout 失败(继续尝试 bootstrap): {r0.stderr.strip()}")
1107
+
1108
+ rendered = _render_dns_lock_plist(backend, config)
1109
+ # 通过 sudo tee 写入(sudoers 允许 /usr/bin/tee <target.plist>)
1110
+ r = run(["tee", dns_lock_plist], sudo=True, stdin_text=rendered, capture=True)
1111
+ if r.returncode != 0:
1112
+ print(f"{RED}✗{NC} 写入 plist 失败: {r.stderr or '权限不足,请检查 sudoers'}")
1113
+ sys.exit(1)
1114
+
1115
+ r2 = run(["launchctl", "bootstrap", "system", dns_lock_plist],
1116
+ sudo=True, capture=True)
1117
+ if r2.returncode != 0:
1118
+ print(f"{RED}✗{NC} bootstrap 失败: {r2.stderr}")
1119
+ sys.exit(1)
1120
+
1121
+ print(f"{GREEN}dns-lock daemon 已安装并启动{NC}")
1122
+ print(f"label: {dns_lock_label}")
1123
+ print(f"plist: {dns_lock_plist} (内嵌模板渲染,含 config.yaml 注入的 env)")
1124
+ print(f"日志: {os.path.join(DEFAULT_CONFIG_DIR, 'dns-watchdog.log')}")
1125
+
1126
+
1127
+ def cmd_dns_unlock(config: dict):
1128
+ if not IS_MACOS:
1129
+ print(f"{YELLOW}dns-unlock 仅支持 macOS{NC}")
1130
+ return
1131
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
1132
+
1133
+ r = run(["launchctl", "bootout", f"system/{dns_lock_label}"], sudo=True, capture=True)
1134
+ if r.returncode == 0:
1135
+ print(f"{GREEN}dns-lock daemon 已停止{NC}")
1136
+ else:
1137
+ print("dns-lock daemon 未在运行")
1138
+ run(["rm", "-f", dns_lock_plist], sudo=True)
1139
+ print(f"已删除 {dns_lock_plist} (源文件保留在 {DEFAULT_CONFIG_DIR}/launchdaemons/)")
1140
+
1141
+
1142
+ # ── 命令:env ────────────────────────────────────────────────────────────────
1143
+
1144
+ def cmd_env(config: dict, unset: bool = False):
1145
+ """输出设置/清除代理环境变量的 shell 语句。
1146
+
1147
+ 用法:
1148
+ eval $(proxyctl env) # 设置代理
1149
+ eval $(proxyctl env --unset) # 清除代理
1150
+
1151
+ Args:
1152
+ config: 全局配置字典
1153
+ unset: True 则输出 unset 语句
1154
+ """
1155
+ if unset:
1156
+ for var in ("http_proxy", "https_proxy", "all_proxy",
1157
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
1158
+ "no_proxy", "NO_PROXY"):
1159
+ print(f"unset {var};")
1160
+ return
1161
+
1162
+ port = 7890 # mixed-port
1163
+ proxy_http = f"http://127.0.0.1:{port}"
1164
+ proxy_socks = f"socks5://127.0.0.1:{port}"
1165
+ no_proxy = "localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
1166
+
1167
+ for var in ("http_proxy", "HTTP_PROXY"):
1168
+ print(f"export {var}={proxy_http};")
1169
+ for var in ("https_proxy", "HTTPS_PROXY"):
1170
+ print(f"export {var}={proxy_http};")
1171
+ for var in ("all_proxy", "ALL_PROXY"):
1172
+ print(f"export {var}={proxy_socks};")
1173
+ for var in ("no_proxy", "NO_PROXY"):
1174
+ print(f"export {var}={no_proxy};")
1175
+
1176
+
1177
+ # ── 帮助 ──────────────────────────────────────────────────────────────────────
1178
+
1179
+ VERSION = "0.1.0"
1180
+
1181
+ def cmd_help(verbose: bool = False):
1182
+ """打印帮助信息
1183
+
1184
+ Args:
1185
+ verbose: 是否显示详细帮助(包含所有命令的详细说明)
1186
+ """
1187
+ print(f"proxyctl v{VERSION}")
1188
+ print("Proxy configuration lifecycle management\n")
1189
+ print("用法:proxyctl <command> [options]\n")
1190
+
1191
+ print("┌─ 基础操作 ─────────────────────────────────────────────────────┐")
1192
+ print("│ start 启动后端 (Mihomo/Sing-box) │")
1193
+ print("│ stop 停止后端 │")
1194
+ print("│ restart 重启后端 │")
1195
+ print("│ restart-clean 重启并清除缓存 │")
1196
+ print("│ status 系统状态面板 │")
1197
+ print("│ log 查看后端日志 (tail -f) │")
1198
+ print("│ fix 修复 DNS/代理/刷新缓存 │")
1199
+ print("│ recover 切网后软恢复(清 DNS 缓存 + 重测代理组) │")
1200
+ print("└────────────────────────────────────────────────────────────────┘")
1201
+
1202
+ print("\n┌─ 诊断工具 ─────────────────────────────────────────────────────┐")
1203
+ print("│ check 全面健康检查 (4 阶段) │")
1204
+ print("│ bench [groups...] 代理组测速(默认测全部组) │")
1205
+ print("│ trace <domain> 域名链路诊断 │")
1206
+ print("│ audit [days] 扫描日志,找疑似应直连的域名 │")
1207
+ print("└────────────────────────────────────────────────────────────────┘")
1208
+
1209
+ print("\n┌─ 配置管理 ─────────────────────────────────────────────────────┐")
1210
+ print("│ engine [singbox|mihomo] 切换代理引擎 │")
1211
+ print("│ mode [tun|proxy] 切换运行模式 │")
1212
+ print("│ dns-lock 启动 DNS 看门狗 daemon │")
1213
+ print("│ dns-unlock 停止 DNS 看门狗 │")
1214
+ print("│ daemon [name] [subcmd] 管理 extra_daemons (claude-proxy 等) │")
1215
+ print("│ env 输出代理环境变量 │")
1216
+ print("│ env --unset 清除代理环境变量 │")
1217
+ print("│ plugins 显示已加载插件 │")
1218
+ print("└────────────────────────────────────────────────────────────────┘")
1219
+
1220
+ print("\n┌─ 其他 ─────────────────────────────────────────────────────────┐")
1221
+ print("│ --help, -h 显示帮助信息 │")
1222
+ print("│ --version, -v 显示版本号 │")
1223
+ print("└────────────────────────────────────────────────────────────────┘")
1224
+
1225
+ if verbose:
1226
+ print("\n┌─ 命令详解 ───────────────────────────────────────────────────┐")
1227
+ print("│ │")
1228
+ print("│ check 四阶段检查: │")
1229
+ print("│ 1. 基础状态 (daemon/端口) │")
1230
+ print("│ 2. 代理组状态 (节点延迟/存活率) │")
1231
+ print("│ 3. 连通性测试 (Google/GitHub/国内网站) │")
1232
+ print("│ 4. 出口 IP 验证 (分流是否正确) │")
1233
+ print("│ │")
1234
+ print("│ trace 四阶段诊断: │")
1235
+ print("│ 1. DNS 解析 (fakeip/realip) │")
1236
+ print("│ 2. 规则匹配预测 │")
1237
+ print("│ 3. 连通性测试 │")
1238
+ print("│ 4. 实际连接验证 │")
1239
+ print("│ │")
1240
+ print("│ audit 配置审计: │")
1241
+ print("│ 扫描代理日志,找出\"走代理但实际是国内 IP\"的域名 │")
1242
+ print("│ 建议添加到直连规则,可自动应用 │")
1243
+ print("│ │")
1244
+ print("│ recover 切网后软恢复(不重启进程): │")
1245
+ print("│ 1. 热重载配置(清 DNS 缓存) │")
1246
+ print("│ 2. Flush fakeip cache │")
1247
+ print("│ 3. 触发所有代理组 healthcheck │")
1248
+ print("└────────────────────────────────────────────────────────────┘")
1249
+
1250
+ print("\n配置文件:~/.config/proxyctl/config.yaml")
1251
+ print("项目地址:https://github.com/crhan/proxyctl")
1252
+ sys.exit(0)
1253
+
1254
+
1255
+ # ── 主入口 ────────────────────────────────────────────────────────────────────
1256
+
1257
+ def main():
1258
+ # 处理全局标志
1259
+ if len(sys.argv) > 1:
1260
+ if sys.argv[1] in ("--help", "-h", "help"):
1261
+ cmd_help(verbose=True)
1262
+ return
1263
+ elif sys.argv[1] in ("--version", "-v"):
1264
+ print(f"proxyctl v{VERSION}")
1265
+ sys.exit(0)
1266
+
1267
+ config = load_config()
1268
+ backend = get_backend(config)
1269
+ registry = load_plugins(config)
1270
+ api_base = config.get("api_base", DEFAULTS["api_base"])
1271
+ api_secret = config.get("api_secret", "")
1272
+
1273
+ # 检查 api_secret 配置
1274
+ if not api_secret:
1275
+ print(f"{YELLOW}警告:未在配置文件中找到 api_secret{NC}", file=sys.stderr)
1276
+ print(f" 请在 {CONFIG_FILE} 中配置 api_secret: <your-clash-api-secret>",
1277
+ file=sys.stderr)
1278
+ print(file=sys.stderr)
1279
+
1280
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
1281
+
1282
+ if cmd == "start":
1283
+ cmd_start(backend, config)
1284
+ elif cmd == "stop":
1285
+ cmd_stop(backend, config)
1286
+ elif cmd == "restart":
1287
+ cmd_restart(backend, config)
1288
+ elif cmd == "restart-clean":
1289
+ cmd_restart(backend, config, clean=True)
1290
+ elif cmd == "status":
1291
+ from proxyctl.status import cmd_status
1292
+ mode_str = get_mode(backend)
1293
+ cmd_status(backend, api_base, api_secret, config, mode_str, registry=registry)
1294
+ elif cmd == "log":
1295
+ os.execvp("tail", ["tail", "-f", backend.log_file])
1296
+ elif cmd == "check":
1297
+ from proxyctl.check import cmd_check
1298
+ mode_str = get_mode(backend)
1299
+ cmd_check(backend, api_base, api_secret, config, mode_str, registry=registry)
1300
+ elif cmd == "bench":
1301
+ from proxyctl.check import cmd_bench
1302
+ bench_groups = sys.argv[2:] if len(sys.argv) > 2 else None
1303
+ default_groups = registry.collect("check_groups") if registry else None
1304
+ cmd_bench(api_base, api_secret, bench_groups, default_groups=default_groups)
1305
+ elif cmd == "fix":
1306
+ cmd_fix(backend, config)
1307
+ elif cmd == "recover":
1308
+ cmd_recover(backend, config)
1309
+ elif cmd == "dns-lock":
1310
+ reload = "--reload" in sys.argv[2:]
1311
+ cmd_dns_lock(config, backend, reload=reload)
1312
+ elif cmd == "dns-unlock":
1313
+ cmd_dns_unlock(config)
1314
+ elif cmd == "env":
1315
+ unset = "--unset" in sys.argv[2:] or "off" in sys.argv[2:]
1316
+ cmd_env(config, unset=unset)
1317
+ elif cmd == "plugins":
1318
+ cmd_plugins(registry)
1319
+ elif cmd == "engine":
1320
+ target = sys.argv[2] if len(sys.argv) > 2 else ""
1321
+ cmd_engine(backend, target, config)
1322
+ elif cmd == "daemon":
1323
+ name = sys.argv[2] if len(sys.argv) > 2 else ""
1324
+ subcmd = sys.argv[3] if len(sys.argv) > 3 else ""
1325
+ cmd_daemon(name, subcmd, config)
1326
+ elif cmd == "claude-proxy":
1327
+ # sb 时代别名,等价于 `proxyctl daemon claude-proxy <subcmd>`
1328
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else "status"
1329
+ cmd_daemon("claude-proxy", subcmd, config)
1330
+ elif cmd == "audit":
1331
+ arg = sys.argv[2] if len(sys.argv) > 2 else "1"
1332
+ apply_mode = (arg == "apply")
1333
+ days_str = sys.argv[3] if (apply_mode and len(sys.argv) > 3) else (arg if not apply_mode else "1")
1334
+ try:
1335
+ days = int(days_str)
1336
+ except ValueError:
1337
+ days = 1
1338
+ from proxyctl.audit import cmd_audit
1339
+ cmd_audit(days, api_base, api_secret, apply_mode)
1340
+ elif cmd == "mode":
1341
+ target = sys.argv[2] if len(sys.argv) > 2 else ""
1342
+ cmd_mode(backend, target)
1343
+ elif cmd == "trace":
1344
+ if len(sys.argv) < 3:
1345
+ print("用法:proxyctl trace <domain|url>")
1346
+ print(" 诊断域名的完整访问链路:DNS → 规则预测 → 连通性测试 → 实际连接验证")
1347
+ sys.exit(1)
1348
+ from proxyctl.trace import cmd_trace
1349
+ cmd_trace(sys.argv[2], api_base, api_secret, config)
1350
+ else:
1351
+ cmd_help()
1352
+
1353
+
1354
+ if __name__ == "__main__":
1355
+ main()