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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""proxyctl core — 通用骨架(不含任何本机特例逻辑)"""
|
proxyctl/core/plugin.py
ADDED
|
@@ -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']
|
proxyctl/engine/base.py
ADDED
|
@@ -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
|