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.
@@ -0,0 +1 @@
1
+ """proxyctl core — 通用骨架(不含任何本机特例逻辑)"""
@@ -0,0 +1,287 @@
1
+ """插件机制 — 注册中心 + Plugin 基类 + Hook 数据类
2
+
3
+ 设计原则:
4
+ - core 不感知任何具体业务(公司、订阅、设备名都走插件)
5
+ - 插件分两种加载源:
6
+ 1. src/proxyctl/builtin_plugins/*.py — 仓库内置(通用增强)
7
+ 2. ~/.config/proxyctl/plugins/*.py — 用户自定义(本机特例)
8
+ - 加载顺序:内置先于用户;用户可通过 config.yaml 关闭某个插件
9
+ - 单插件失败不影响主流程(捕获并打 warning 到 stderr)
10
+
11
+ 每类扩展点对应一个 Hook 数据类,插件通过返回这些数据类的实例集合声明能力。
12
+ core 命令拿到集合后聚合执行,避免到处散落条件分支。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ import importlib.util
19
+ import os
20
+ import pkgutil
21
+ import sys
22
+ import traceback
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Callable, Optional
25
+
26
+
27
+ # ── Hook 数据类 ──────────────────────────────────────────────────────────────
28
+
29
+ @dataclass
30
+ class CheckTarget:
31
+ """连通性检查项(check 命令第 3 阶段)。
32
+
33
+ mode:
34
+ - proxy : 走 socks5h://127.0.0.1:7890
35
+ - direct: 绕过所有代理
36
+ - tcp : 纯 TCP 连接测试(url 形如 'tcp:host:port')
37
+ - dns : dig 测试(url 是 'dns:server:domain')
38
+ """
39
+ name: str
40
+ url: str
41
+ mode: str = "proxy"
42
+ timeout: int = 8
43
+ only_when: Optional[Callable[[dict], bool]] = None # ctx -> bool,控制是否启用
44
+
45
+
46
+ @dataclass
47
+ class OutboundProbe:
48
+ """出口 IP 探测项(check 命令第 4 阶段)。
49
+
50
+ 通过指定不同的 mode + url,验证不同出口的实际 IP 归属。
51
+ 经典三种:proxy(走主代理)/ direct(绕代理)/ 命名组(走特定规则路由)。
52
+
53
+ extract_re:当 url 返回非纯 IP 时,用此正则提取(取第 0 个匹配)。
54
+ 空则取响应整体(strip)。
55
+ """
56
+ name: str
57
+ mode: str = "proxy"
58
+ url: str = "https://api.ipify.org"
59
+ timeout: int = 8
60
+ extract_re: str = ""
61
+
62
+
63
+ @dataclass
64
+ class StatusSection:
65
+ """status 命令的附加段(如企业 VPN、Tailscale、Relay 等)。"""
66
+ name: str
67
+ gather: Callable[[dict], dict] # ctx -> data
68
+ render: Callable[[dict, dict], None] # (ctx, data) -> None,自行 print
69
+
70
+
71
+ @dataclass
72
+ class DnsHook:
73
+ """DNS activate/deactivate 时的额外动作(如劫持 AnyConnect 条目)。"""
74
+ name: str
75
+ activate: Optional[Callable[[dict], None]] = None # ctx -> None
76
+ deactivate: Optional[Callable[[dict], None]] = None
77
+
78
+
79
+ @dataclass
80
+ class RouteHook:
81
+ """start/stop 时的额外路由注入(如 Tailscale 100.64/10 冲突修复)。"""
82
+ name: str
83
+ activate: Optional[Callable[[dict], None]] = None
84
+ deactivate: Optional[Callable[[dict], None]] = None
85
+
86
+
87
+ @dataclass
88
+ class WatchdogLayer:
89
+ """dns-watchdog 的附加检查层(如 TUIC 健康检测)。
90
+
91
+ script_snippet 会被 watchdog 主脚本 source 进来,因此变量命名要避免冲突。
92
+ 用 layer 前缀(layer_xxx_var)规避。
93
+ """
94
+ name: str
95
+ script_snippet: str
96
+
97
+
98
+ # ── Plugin 基类 ──────────────────────────────────────────────────────────────
99
+
100
+ class Plugin:
101
+ """插件基类。子类按需实现 hook 方法,未实现的方法默认返回空集合。
102
+
103
+ name 必填,作为唯一标识用于配置开关 / 日志归属。
104
+ """
105
+ name: str = ""
106
+
107
+ def __init__(self, config: dict | None = None):
108
+ self.config = config or {}
109
+
110
+ # ── 生命周期钩子(接收 ctx,无返回值)─────────────────────────────────
111
+ def on_start(self, ctx: dict) -> None: pass
112
+ def on_stop(self, ctx: dict) -> None: pass
113
+ def on_recover(self, ctx: dict) -> None: pass
114
+
115
+ # ── 检查项扩展 ────────────────────────────────────────────────────────
116
+ def check_groups(self) -> list[str]:
117
+ """返回关注的代理组名(check/status 默认展示这些组)。"""
118
+ return []
119
+
120
+ def check_targets(self, ctx: dict) -> list[CheckTarget]:
121
+ """返回额外的连通性测试项。"""
122
+ return []
123
+
124
+ def check_outbound_probes(self, ctx: dict) -> list[OutboundProbe]:
125
+ """返回额外的出口 IP 探测项。"""
126
+ return []
127
+
128
+ # ── DNS / 路由钩子 ───────────────────────────────────────────────────
129
+ def dns_hooks(self) -> list[DnsHook]:
130
+ return []
131
+
132
+ def route_hooks(self) -> list[RouteHook]:
133
+ return []
134
+
135
+ # ── status 段扩展 ────────────────────────────────────────────────────
136
+ def status_sections(self, ctx: dict) -> list[StatusSection]:
137
+ return []
138
+
139
+ # ── watchdog 层扩展 ──────────────────────────────────────────────────
140
+ def watchdog_layers(self) -> list[WatchdogLayer]:
141
+ return []
142
+
143
+ # ── audit 扩展 ───────────────────────────────────────────────────────
144
+ def audit_skip_hosts(self) -> set[str]:
145
+ return set()
146
+
147
+ def audit_known_proxy_kw(self) -> list[str]:
148
+ return []
149
+
150
+
151
+ # ── Registry ─────────────────────────────────────────────────────────────────
152
+
153
+ class PluginRegistry:
154
+ """插件注册中心。负责发现、加载、调用。"""
155
+
156
+ def __init__(self):
157
+ self.plugins: list[Plugin] = []
158
+ self.errors: list[tuple[str, str]] = [] # (source, error_msg) 加载期错误
159
+
160
+ def register(self, plugin: Plugin) -> None:
161
+ self.plugins.append(plugin)
162
+
163
+ def load_builtin(self, config: dict) -> None:
164
+ """加载 src/proxyctl/builtin_plugins/ 下的所有内置插件。"""
165
+ try:
166
+ from proxyctl import builtin_plugins as _bp
167
+ except ImportError:
168
+ return
169
+ bp_dir = os.path.dirname(_bp.__file__)
170
+ for _, modname, _ in pkgutil.iter_modules([bp_dir]):
171
+ if modname.startswith("_"):
172
+ continue
173
+ try:
174
+ mod = importlib.import_module(f"proxyctl.builtin_plugins.{modname}")
175
+ self._discover_in_module(mod, config, source=f"builtin/{modname}")
176
+ except Exception as e:
177
+ self.errors.append((f"builtin/{modname}", _fmt_err(e)))
178
+
179
+ def load_user(self, plugin_dir: str, config: dict) -> None:
180
+ """加载 ~/.config/proxyctl/plugins/*.py。"""
181
+ if not os.path.isdir(plugin_dir):
182
+ return
183
+ for fname in sorted(os.listdir(plugin_dir)):
184
+ if not fname.endswith(".py") or fname.startswith("_"):
185
+ continue
186
+ path = os.path.join(plugin_dir, fname)
187
+ mod_name = f"proxyctl_user_plugin.{fname[:-3]}"
188
+ try:
189
+ spec = importlib.util.spec_from_file_location(mod_name, path)
190
+ if spec is None or spec.loader is None:
191
+ raise ImportError(f"failed to build spec for {path}")
192
+ mod = importlib.util.module_from_spec(spec)
193
+ sys.modules[mod_name] = mod
194
+ spec.loader.exec_module(mod)
195
+ self._discover_in_module(mod, config, source=f"user/{fname}")
196
+ except Exception as e:
197
+ self.errors.append((f"user/{fname}", _fmt_err(e)))
198
+
199
+ def _discover_in_module(self, mod, config: dict, *, source: str) -> None:
200
+ """在模块里搜 Plugin 子类,实例化并注册。"""
201
+ disabled = set(config.get("plugins_disabled", []) or [])
202
+ for attr_name in dir(mod):
203
+ attr = getattr(mod, attr_name)
204
+ if not isinstance(attr, type):
205
+ continue
206
+ if not issubclass(attr, Plugin) or attr is Plugin:
207
+ continue
208
+ try:
209
+ inst = attr(config)
210
+ if inst.name in disabled:
211
+ continue
212
+ if not inst.name:
213
+ # 没起名字的插件容易和其他插件冲突,警告但仍加载
214
+ sys.stderr.write(
215
+ f"[plugin warning] {source}:{attr.__name__} has empty name\n"
216
+ )
217
+ self.register(inst)
218
+ except Exception as e:
219
+ self.errors.append((f"{source}:{attr.__name__}", _fmt_err(e)))
220
+
221
+ # ── Hook 调用接口 ────────────────────────────────────────────────────
222
+
223
+ def collect(self, hook_name: str, *args, **kwargs) -> list:
224
+ """收集所有插件某 hook 的返回值,拼成单个 list 返回。
225
+
226
+ 用于返回 list 类型的 hook(check_targets / status_sections 等)。
227
+ """
228
+ result = []
229
+ for p in self.plugins:
230
+ fn = getattr(p, hook_name, None)
231
+ if fn is None:
232
+ continue
233
+ try:
234
+ vals = fn(*args, **kwargs)
235
+ if vals:
236
+ result.extend(vals)
237
+ except Exception as e:
238
+ sys.stderr.write(
239
+ f"[plugin warning] {p.name}.{hook_name} failed: {_fmt_err(e)}\n"
240
+ )
241
+ return result
242
+
243
+ def collect_set(self, hook_name: str, *args, **kwargs) -> set:
244
+ """同 collect,但用 set 合并(去重)。"""
245
+ result: set = set()
246
+ for p in self.plugins:
247
+ fn = getattr(p, hook_name, None)
248
+ if fn is None:
249
+ continue
250
+ try:
251
+ vals = fn(*args, **kwargs)
252
+ if vals:
253
+ result.update(vals)
254
+ except Exception as e:
255
+ sys.stderr.write(
256
+ f"[plugin warning] {p.name}.{hook_name} failed: {_fmt_err(e)}\n"
257
+ )
258
+ return result
259
+
260
+ def invoke(self, hook_name: str, *args, **kwargs) -> None:
261
+ """调用所有插件的某 hook(无返回值场景,如生命周期钩子)。"""
262
+ for p in self.plugins:
263
+ fn = getattr(p, hook_name, None)
264
+ if fn is None:
265
+ continue
266
+ try:
267
+ fn(*args, **kwargs)
268
+ except Exception as e:
269
+ sys.stderr.write(
270
+ f"[plugin warning] {p.name}.{hook_name} failed: {_fmt_err(e)}\n"
271
+ )
272
+
273
+
274
+ def _fmt_err(e: BaseException) -> str:
275
+ if os.environ.get("PROXYCTL_DEBUG"):
276
+ return traceback.format_exc()
277
+ return f"{type(e).__name__}: {e}"
278
+
279
+
280
+ # ── 入口工厂 ─────────────────────────────────────────────────────────────────
281
+
282
+ def build_registry(config: dict, user_plugin_dir: str) -> PluginRegistry:
283
+ """加载内置 + 用户插件,返回 registry。"""
284
+ reg = PluginRegistry()
285
+ reg.load_builtin(config)
286
+ reg.load_user(user_plugin_dir, config)
287
+ return reg
@@ -0,0 +1,12 @@
1
+ """proxyctl engine - 后端抽象层
2
+
3
+ 支持的后端:
4
+ - MihomoBackend (首发支持)
5
+ - SingboxBackend (预留)
6
+ """
7
+
8
+ from .base import Backend
9
+ from .mihomo import MihomoBackend
10
+ from .singbox import SingboxBackend
11
+
12
+ __all__ = ['Backend', 'MihomoBackend', 'SingboxBackend']
@@ -0,0 +1,85 @@
1
+ """proxyctl engine.base - 后端抽象接口"""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, Any, Optional
5
+
6
+
7
+ class Backend(ABC):
8
+ """代理后端抽象基类
9
+
10
+ 定义所有后端必须实现的接口,上层工具通过此接口与后端交互,
11
+ 实现与具体后端(Mihomo/Sing-box)的解耦。
12
+ """
13
+
14
+ def __init__(self, name: str, config_dir: str):
15
+ """初始化后端
16
+
17
+ Args:
18
+ name: 后端名称(mihomo/singbox)
19
+ config_dir: 配置目录根路径
20
+ """
21
+ self.name = name
22
+ self.config_dir = config_dir
23
+
24
+ @property
25
+ @abstractmethod
26
+ def label(self) -> str:
27
+ """launchd Label 名称"""
28
+ pass
29
+
30
+ @property
31
+ @abstractmethod
32
+ def plist(self) -> str:
33
+ """launchd plist 文件路径"""
34
+ pass
35
+
36
+ @property
37
+ @abstractmethod
38
+ def config_file(self) -> str:
39
+ """配置文件路径"""
40
+ pass
41
+
42
+ @property
43
+ @abstractmethod
44
+ def cache_file(self) -> str:
45
+ """缓存文件路径"""
46
+ pass
47
+
48
+ @property
49
+ @abstractmethod
50
+ def log_file(self) -> str:
51
+ """日志文件路径"""
52
+ pass
53
+
54
+ @abstractmethod
55
+ def get_mode(self) -> str:
56
+ """获取当前运行模式
57
+
58
+ Returns:
59
+ "tun" - TUN 模式(全局接管)
60
+ "proxy" - 代理模式(仅端口)
61
+ "mixed" - 混合模式
62
+ "unknown" - 未知
63
+ """
64
+ pass
65
+
66
+ @abstractmethod
67
+ def check_config(self) -> bool:
68
+ """验证配置文件语法
69
+
70
+ Returns:
71
+ True if valid, False otherwise
72
+ """
73
+ pass
74
+
75
+ @abstractmethod
76
+ def get_api_url(self) -> str:
77
+ """获取 API 基础 URL
78
+
79
+ Returns:
80
+ API base URL (e.g., http://127.0.0.1:9090)
81
+ """
82
+ pass
83
+
84
+ def __repr__(self) -> str:
85
+ return f"{self.__class__.__name__}(name={self.name!r})"
@@ -0,0 +1,127 @@
1
+ """proxyctl engine.mihomo - Mihomo (Clash Meta) 后端实现"""
2
+
3
+ import os
4
+ import re
5
+ from typing import Dict, Any
6
+
7
+ from .base import Backend
8
+
9
+
10
+ class MihomoBackend(Backend):
11
+ """Mihomo 后端实现
12
+
13
+ Mihomo 是 Clash Meta 内核,提供完整的 Clash API 兼容性。
14
+ 这是 proxyctl 的首发支持后端。
15
+ """
16
+
17
+ def __init__(self, config_dir: str):
18
+ """初始化 Mihomo 后端
19
+
20
+ Args:
21
+ config_dir: 配置目录根路径(通常为 ~/.config)
22
+ """
23
+ super().__init__("mihomo", config_dir)
24
+ self.mihomo_dir = os.path.join(config_dir, "mihomo")
25
+
26
+ @property
27
+ def label(self) -> str:
28
+ return "system/com.mihomo.tun"
29
+
30
+ @property
31
+ def plist(self) -> str:
32
+ return "/Library/LaunchDaemons/com.mihomo.tun.plist"
33
+
34
+ @property
35
+ def config_file(self) -> str:
36
+ return os.path.join(self.mihomo_dir, "config.yaml")
37
+
38
+ @property
39
+ def cache_file(self) -> str:
40
+ return os.path.join(self.mihomo_dir, "cache.db")
41
+
42
+ @property
43
+ def log_file(self) -> str:
44
+ return os.path.join(self.mihomo_dir, "mihomo.log")
45
+
46
+ @property
47
+ def api_url(self) -> str:
48
+ """默认 API URL"""
49
+ return "http://127.0.0.1:9090"
50
+
51
+ def get_mode(self) -> str:
52
+ """从配置文件读取当前模式
53
+
54
+ Returns:
55
+ "tun" - TUN 模式 (auto_route + fakeip)
56
+ "proxy" - 代理模式 (仅端口,redir-host)
57
+ "mixed" - 混合模式
58
+ "unknown" - 无法解析
59
+ """
60
+ try:
61
+ if not os.path.isfile(self.config_file):
62
+ return "unknown"
63
+
64
+ text = open(self.config_file).read()
65
+
66
+ # 检查 TUN 配置
67
+ tun_m = re.search(r'^tun:\s*\n((?:\s+.*\n)*)', text, re.M)
68
+ tun_block = tun_m.group(0) if tun_m else ""
69
+ tun_on = bool(re.search(r'enable:\s*true', tun_block))
70
+ auto_rt = bool(re.search(r'auto-route:\s*true', tun_block))
71
+
72
+ # 检查 DNS 模式
73
+ fakeip = bool(re.search(r'enhanced-mode:\s*fake-ip', text))
74
+
75
+ if tun_on and auto_rt and fakeip:
76
+ return "tun"
77
+ elif not auto_rt and not fakeip:
78
+ return "proxy"
79
+ return "mixed"
80
+ except Exception:
81
+ return "unknown"
82
+
83
+ def check_config(self) -> bool:
84
+ """验证 Mihomo 配置文件语法
85
+
86
+ 使用 mihomo 自带的 -t 参数进行语法检查。
87
+
88
+ Returns:
89
+ True if valid, False otherwise
90
+ """
91
+ import subprocess
92
+ try:
93
+ result = subprocess.run(
94
+ ["mihomo", "-t", "-f", self.config_file],
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=10
98
+ )
99
+ return result.returncode == 0
100
+ except Exception:
101
+ return False
102
+
103
+ def get_api_url(self) -> str:
104
+ """获取 Mihomo API URL
105
+
106
+ 从配置文件中读取 external-controller,如果未配置则返回默认值。
107
+
108
+ Returns:
109
+ API base URL
110
+ """
111
+ try:
112
+ if not os.path.isfile(self.config_file):
113
+ return self.api_url
114
+
115
+ text = open(self.config_file).read()
116
+ m = re.search(r'external-controller:\s*(\S+)', text)
117
+ if m:
118
+ controller = m.group(1)
119
+ # 如果是 :port 格式,补全为 127.0.0.1:port
120
+ if controller.startswith(':'):
121
+ return f"http://127.0.0.1{controller}"
122
+ elif not controller.startswith('http'):
123
+ return f"http://{controller}"
124
+ return controller
125
+ return self.api_url
126
+ except Exception:
127
+ return self.api_url
@@ -0,0 +1,135 @@
1
+ """proxyctl engine.singbox - Sing-box 后端实现(预留)
2
+
3
+ Sing-box 后端支持目前处于预留状态。
4
+ 如需使用 Sing-box,请参考 MihomoBackend 实现相应接口。
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import json
10
+ from typing import Dict, Any
11
+
12
+ from .base import Backend
13
+
14
+
15
+ class SingboxBackend(Backend):
16
+ """Sing-box 后端实现(预留)
17
+
18
+ Sing-box 是新一代代理内核,支持多种协议。
19
+ 此实现目前处于预留状态,完整功能待开发。
20
+ """
21
+
22
+ def __init__(self, config_dir: str):
23
+ """初始化 Sing-box 后端
24
+
25
+ Args:
26
+ config_dir: 配置目录根路径(通常为 ~/.config)
27
+ """
28
+ super().__init__("singbox", config_dir)
29
+ self.singbox_dir = os.path.join(config_dir, "sing-box")
30
+
31
+ @property
32
+ def label(self) -> str:
33
+ return "system/com.singbox.tun"
34
+
35
+ @property
36
+ def plist(self) -> str:
37
+ return "/Library/LaunchDaemons/com.singbox.tun.plist"
38
+
39
+ @property
40
+ def config_file(self) -> str:
41
+ return os.path.join(self.singbox_dir, "config.json")
42
+
43
+ @property
44
+ def cache_file(self) -> str:
45
+ return os.path.join(self.singbox_dir, "cache.db")
46
+
47
+ @property
48
+ def log_file(self) -> str:
49
+ return os.path.join(self.singbox_dir, "sing-box.log")
50
+
51
+ @property
52
+ def api_url(self) -> str:
53
+ """默认 API URL"""
54
+ return "http://127.0.0.1:9090"
55
+
56
+ def get_mode(self) -> str:
57
+ """从配置文件读取当前模式
58
+
59
+ Returns:
60
+ "tun" - TUN 模式 (auto_route + fakeip)
61
+ "proxy" - 代理模式 (仅端口)
62
+ "mixed" - 混合模式
63
+ "unknown" - 无法解析
64
+ """
65
+ try:
66
+ if not os.path.isfile(self.config_file):
67
+ return "unknown"
68
+
69
+ cfg = json.load(open(self.config_file))
70
+
71
+ # 检查 TUN 配置
72
+ ar = True # 默认 auto_route 开启
73
+ for ib in cfg.get("inbounds", []):
74
+ if ib.get("type") == "tun":
75
+ ar = ib.get("auto_route", True)
76
+ break
77
+
78
+ # 检查 fakeip 配置
79
+ fakeip = any(
80
+ r.get("server") == "fakeip-dns"
81
+ for r in cfg.get("dns", {}).get("rules", [])
82
+ )
83
+
84
+ if ar and fakeip:
85
+ return "tun"
86
+ elif not ar and not fakeip:
87
+ return "proxy"
88
+ return "mixed"
89
+ except Exception:
90
+ return "unknown"
91
+
92
+ def check_config(self) -> bool:
93
+ """验证 Sing-box 配置文件语法
94
+
95
+ 使用 sing-box 自带的 check 命令进行语法检查。
96
+
97
+ Returns:
98
+ True if valid, False otherwise
99
+ """
100
+ import subprocess
101
+ try:
102
+ result = subprocess.run(
103
+ ["sing-box", "check", "-c", self.config_file],
104
+ capture_output=True,
105
+ text=True,
106
+ timeout=10
107
+ )
108
+ return result.returncode == 0
109
+ except Exception:
110
+ return False
111
+
112
+ def get_api_url(self) -> str:
113
+ """获取 Sing-box API URL
114
+
115
+ 从配置文件中读取 experimental.clash_api.external_controller,
116
+ 如果未配置则返回默认值。
117
+
118
+ Returns:
119
+ API base URL
120
+ """
121
+ try:
122
+ if not os.path.isfile(self.config_file):
123
+ return self.api_url
124
+
125
+ cfg = json.load(open(self.config_file))
126
+ controller = cfg.get("experimental", {}).get("clash_api", {}).get("external_controller", "")
127
+ if controller:
128
+ if controller.startswith(':'):
129
+ return f"http://127.0.0.1{controller}"
130
+ elif not controller.startswith('http'):
131
+ return f"http://{controller}"
132
+ return controller
133
+ return self.api_url
134
+ except Exception:
135
+ return self.api_url