proxyctl 0.4.7__tar.gz → 0.5.1__tar.gz
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-0.4.7 → proxyctl-0.5.1}/PKG-INFO +12 -3
- {proxyctl-0.4.7 → proxyctl-0.5.1}/README.md +11 -2
- {proxyctl-0.4.7 → proxyctl-0.5.1}/pyproject.toml +1 -1
- proxyctl-0.5.1/src/proxyctl/autostart.py +521 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/cli.py +238 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/explain.py +494 -36
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/subscription.py +165 -7
- proxyctl-0.5.1/src/proxyctl/suggest.py +313 -0
- proxyctl-0.5.1/src/proxyctl/suggest_rules.py +423 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/.gitignore +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/LICENSE +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/man/proxyctl.1 +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/check.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/status.py +0 -0
- {proxyctl-0.4.7 → proxyctl-0.5.1}/src/proxyctl/trace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Proxy configuration lifecycle management for macOS and Linux
|
|
5
5
|
Project-URL: Homepage, https://github.com/crhan/proxyctl
|
|
6
6
|
Project-URL: Issues, https://github.com/crhan/proxyctl/issues
|
|
@@ -85,7 +85,9 @@ agent 会:自描述协议 → 读取本机现状 → 用 dry-run 演示要做
|
|
|
85
85
|
4. 健康打底
|
|
86
86
|
- `proxyctl check --json` —— 任一 stage 失败时 envelope.hints
|
|
87
87
|
已聚合真凶摘要(v0.4.3+),不必挖 stages.*.ok
|
|
88
|
-
- `proxyctl doctor --json` —— 5 项快速打分
|
|
88
|
+
- `proxyctl doctor --json` —— 5 项快速打分 + `data.suggestions[]`
|
|
89
|
+
引导建议(v0.5.0+ 含 21 条:订阅过期 / autostart plist 与 PATH 版本不一致 /
|
|
90
|
+
weak API secret / geoip 数据过期 等)
|
|
89
91
|
- `proxyctl audit 7 --json` —— 扫近 7 天访问日志,找走代理但落地国内的域名
|
|
90
92
|
|
|
91
93
|
5. 给我可执行的改进列表
|
|
@@ -110,7 +112,8 @@ proxyctl --version --json # schema_version + supported_features 探
|
|
|
110
112
|
proxyctl commands --json # 全部命令元数据(机读)
|
|
111
113
|
proxyctl commands --schema # 上面 JSON 的 JSON Schema
|
|
112
114
|
proxyctl explain # "我想改 X 去哪?" 速查
|
|
113
|
-
proxyctl doctor --json # 5 项健康打分 +
|
|
115
|
+
proxyctl doctor --json # 5 项健康打分 + data.suggestions[] 引导建议
|
|
116
|
+
proxyctl explain suggestion:<id> # v0.5.0+ 每条 suggestion 都有详情 topic
|
|
114
117
|
PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
|
|
115
118
|
```
|
|
116
119
|
|
|
@@ -127,6 +130,12 @@ PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
|
|
|
127
130
|
- **CI 层 contract test**(`tests/integration/test_plan_exec_contract.py`)
|
|
128
131
|
保证 plan ↔ exec **永不漂移**
|
|
129
132
|
- **错误带可执行 hints + explain topic**:agent 可路由下一步而不需要规则匹配
|
|
133
|
+
- **doctor suggestions(v0.5.0+)**:与 score 解耦的引导维度。21 条规则覆盖
|
|
134
|
+
订阅 / autostart plist 路径与版本一致性 / Clash API 安全 / GeoIP 数据时效。
|
|
135
|
+
每条带稳定 `id` + `fingerprint` + `first_seen`,agent 可跨次去重。
|
|
136
|
+
`severity` 三档(info/advisory/warn)永不影响 exit code,按
|
|
137
|
+
`severity desc, id asc` 固定排序。详见 [AGENTS.md](AGENTS.md)
|
|
138
|
+
的 "Doctor suggestions" 段
|
|
130
139
|
- **`audit/check` 支持 `--plain` TSV**:4 行 stage/ok/detail,agent 友好的备选
|
|
131
140
|
- **`proxyctl help <cmd>` 与 `<cmd> --help` 同源**
|
|
132
141
|
- **非 TTY 自动关色 / 不读 stdin / 不 prompt / 写操作 fcntl.flock 互斥**
|
|
@@ -56,7 +56,9 @@ agent 会:自描述协议 → 读取本机现状 → 用 dry-run 演示要做
|
|
|
56
56
|
4. 健康打底
|
|
57
57
|
- `proxyctl check --json` —— 任一 stage 失败时 envelope.hints
|
|
58
58
|
已聚合真凶摘要(v0.4.3+),不必挖 stages.*.ok
|
|
59
|
-
- `proxyctl doctor --json` —— 5 项快速打分
|
|
59
|
+
- `proxyctl doctor --json` —— 5 项快速打分 + `data.suggestions[]`
|
|
60
|
+
引导建议(v0.5.0+ 含 21 条:订阅过期 / autostart plist 与 PATH 版本不一致 /
|
|
61
|
+
weak API secret / geoip 数据过期 等)
|
|
60
62
|
- `proxyctl audit 7 --json` —— 扫近 7 天访问日志,找走代理但落地国内的域名
|
|
61
63
|
|
|
62
64
|
5. 给我可执行的改进列表
|
|
@@ -81,7 +83,8 @@ proxyctl --version --json # schema_version + supported_features 探
|
|
|
81
83
|
proxyctl commands --json # 全部命令元数据(机读)
|
|
82
84
|
proxyctl commands --schema # 上面 JSON 的 JSON Schema
|
|
83
85
|
proxyctl explain # "我想改 X 去哪?" 速查
|
|
84
|
-
proxyctl doctor --json # 5 项健康打分 +
|
|
86
|
+
proxyctl doctor --json # 5 项健康打分 + data.suggestions[] 引导建议
|
|
87
|
+
proxyctl explain suggestion:<id> # v0.5.0+ 每条 suggestion 都有详情 topic
|
|
85
88
|
PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
|
|
86
89
|
```
|
|
87
90
|
|
|
@@ -98,6 +101,12 @@ PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
|
|
|
98
101
|
- **CI 层 contract test**(`tests/integration/test_plan_exec_contract.py`)
|
|
99
102
|
保证 plan ↔ exec **永不漂移**
|
|
100
103
|
- **错误带可执行 hints + explain topic**:agent 可路由下一步而不需要规则匹配
|
|
104
|
+
- **doctor suggestions(v0.5.0+)**:与 score 解耦的引导维度。21 条规则覆盖
|
|
105
|
+
订阅 / autostart plist 路径与版本一致性 / Clash API 安全 / GeoIP 数据时效。
|
|
106
|
+
每条带稳定 `id` + `fingerprint` + `first_seen`,agent 可跨次去重。
|
|
107
|
+
`severity` 三档(info/advisory/warn)永不影响 exit code,按
|
|
108
|
+
`severity desc, id asc` 固定排序。详见 [AGENTS.md](AGENTS.md)
|
|
109
|
+
的 "Doctor suggestions" 段
|
|
101
110
|
- **`audit/check` 支持 `--plain` TSV**:4 行 stage/ok/detail,agent 友好的备选
|
|
102
111
|
- **`proxyctl help <cmd>` 与 `<cmd> --help` 同源**
|
|
103
112
|
- **非 TTY 自动关色 / 不读 stdin / 不 prompt / 写操作 fcntl.flock 互斥**
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""proxyctl.autostart — 自动启动 unit 解析与诊断(v0.5.0+)。
|
|
2
|
+
|
|
3
|
+
哥的需求:"plist 里跑的 mihomo 路径和版本是不是也应该检测"——
|
|
4
|
+
长期被忽略的 doctor 盲区。一个常见诡异场景:
|
|
5
|
+
PATH 装了 mihomo v1.20,但 plist 还指向 v1.15 的旧 binary
|
|
6
|
+
→ 用户跑 `mihomo -v` 看到新版,实际服务跑老版
|
|
7
|
+
→ 调试 TUIC/QUIC race 时怎么也对不上号
|
|
8
|
+
|
|
9
|
+
本模块两层 API:
|
|
10
|
+
inspect_static(backend) 纯文件读取,无 subprocess,<10ms
|
|
11
|
+
inspect_runtime(...) 额外跑 launchctl print / systemctl / binary -v
|
|
12
|
+
|
|
13
|
+
doctor 调用:先 static 推 6 条规则,再 runtime 补 2 条(disabled / flapping)。
|
|
14
|
+
|
|
15
|
+
接入点:proxyctl.suggest.build_suggestions(autostart=..., path_engine=...)。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import platform as _platform
|
|
22
|
+
import plistlib
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
PLACEHOLDER_RE = re.compile(r"yourname|your[_-]name|/Users/yourname", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _expand(path: str) -> str:
|
|
32
|
+
"""展开 ~ 和 systemd %h 等占位符。"""
|
|
33
|
+
if not path:
|
|
34
|
+
return path
|
|
35
|
+
# systemd %h → $HOME
|
|
36
|
+
if "%h" in path:
|
|
37
|
+
path = path.replace("%h", os.path.expanduser("~"))
|
|
38
|
+
return os.path.expanduser(path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_plist(plist_path: str) -> dict[str, Any]:
|
|
42
|
+
"""解析 macOS LaunchDaemon plist,返回结构化字段。
|
|
43
|
+
|
|
44
|
+
失败时返回 {"errors": [...]},调用方决定怎么降级。
|
|
45
|
+
"""
|
|
46
|
+
out: dict[str, Any] = {"errors": []}
|
|
47
|
+
try:
|
|
48
|
+
with open(plist_path, "rb") as f:
|
|
49
|
+
data = plistlib.load(f)
|
|
50
|
+
except (OSError, plistlib.InvalidFileException) as e:
|
|
51
|
+
out["errors"].append(f"plist 解析失败: {e}")
|
|
52
|
+
return out
|
|
53
|
+
args = data.get("ProgramArguments") or []
|
|
54
|
+
if isinstance(args, list) and args:
|
|
55
|
+
out["binary"] = args[0]
|
|
56
|
+
# 找 -d 参数后的目录(mihomo 用 `-d <dir>` 指定 config 目录)
|
|
57
|
+
for i, a in enumerate(args):
|
|
58
|
+
if a == "-d" and i + 1 < len(args):
|
|
59
|
+
out["config_dir"] = _expand(args[i + 1])
|
|
60
|
+
break
|
|
61
|
+
# sing-box 用 `-c <file>` 指定 config 文件
|
|
62
|
+
if a == "-c" and i + 1 < len(args):
|
|
63
|
+
out["config_dir"] = os.path.dirname(_expand(args[i + 1]))
|
|
64
|
+
break
|
|
65
|
+
out["label"] = data.get("Label")
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_systemd_unit(unit_path: str) -> dict[str, Any]:
|
|
70
|
+
"""解析 systemd user unit,提取 ExecStart 中的 binary 和 -d 后的目录。
|
|
71
|
+
|
|
72
|
+
我们的模板形如:
|
|
73
|
+
ExecStart=/bin/sh -c 'exec %h/.local/bin/mihomo -d %h/.config/mihomo >> ...'
|
|
74
|
+
用正则提取 exec 后的真实命令。
|
|
75
|
+
"""
|
|
76
|
+
out: dict[str, Any] = {"errors": []}
|
|
77
|
+
try:
|
|
78
|
+
with open(unit_path, encoding="utf-8") as f:
|
|
79
|
+
text = f.read()
|
|
80
|
+
except OSError as e:
|
|
81
|
+
out["errors"].append(f"unit 读取失败: {e}")
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
# 提取 ExecStart= 整行
|
|
85
|
+
m = re.search(r"^ExecStart=(.+)$", text, re.M)
|
|
86
|
+
if not m:
|
|
87
|
+
out["errors"].append("ExecStart 字段缺失")
|
|
88
|
+
return out
|
|
89
|
+
exec_line = m.group(1).strip()
|
|
90
|
+
|
|
91
|
+
# 处理 /bin/sh -c 'exec <real-cmd>' 包装
|
|
92
|
+
sh_m = re.search(r"['\"]exec\s+(.+?)['\"]?$", exec_line)
|
|
93
|
+
real_cmd = sh_m.group(1) if sh_m else exec_line
|
|
94
|
+
# 去掉重定向部分
|
|
95
|
+
real_cmd = re.split(r"\s+>>?\s+", real_cmd)[0].strip()
|
|
96
|
+
|
|
97
|
+
tokens = real_cmd.split()
|
|
98
|
+
if not tokens:
|
|
99
|
+
out["errors"].append("ExecStart 解析后为空")
|
|
100
|
+
return out
|
|
101
|
+
out["binary"] = _expand(tokens[0])
|
|
102
|
+
for i, t in enumerate(tokens):
|
|
103
|
+
if t == "-d" and i + 1 < len(tokens):
|
|
104
|
+
out["config_dir"] = _expand(tokens[i + 1])
|
|
105
|
+
break
|
|
106
|
+
if t == "-c" and i + 1 < len(tokens):
|
|
107
|
+
out["config_dir"] = os.path.dirname(_expand(tokens[i + 1]))
|
|
108
|
+
break
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def inspect_static(backend, *, platform: str | None = None) -> dict[str, Any]:
|
|
113
|
+
"""读取 autostart unit 的纯文件状态——无 subprocess,<10ms。
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
backend: cli.Backend 实例(用 backend.plist / backend.unit)
|
|
117
|
+
platform: "darwin" | "linux",默认自动检测;测试可注入
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
dict 见模块 docstring。errors 字段为非空时调用方按需降级,
|
|
121
|
+
但所有规则函数都允许在 inspect 失败时跳过(不抛异常)。
|
|
122
|
+
"""
|
|
123
|
+
plat = platform or _platform.system().lower()
|
|
124
|
+
if plat.startswith("darwin"):
|
|
125
|
+
plat = "darwin"
|
|
126
|
+
elif plat.startswith("linux"):
|
|
127
|
+
plat = "linux"
|
|
128
|
+
|
|
129
|
+
out: dict[str, Any] = {
|
|
130
|
+
"platform": plat,
|
|
131
|
+
"unit_path": None,
|
|
132
|
+
"unit_exists": False,
|
|
133
|
+
"binary": None,
|
|
134
|
+
"binary_exists": False,
|
|
135
|
+
"config_dir": None,
|
|
136
|
+
"placeholder_unrendered": False,
|
|
137
|
+
"raw_snippet": "",
|
|
138
|
+
"errors": [],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if plat == "darwin":
|
|
142
|
+
unit_path = backend.plist
|
|
143
|
+
elif plat == "linux":
|
|
144
|
+
# 模板部署位置:~/.config/systemd/user/<unit>
|
|
145
|
+
unit_path = os.path.join(
|
|
146
|
+
os.path.expanduser("~"), ".config", "systemd", "user", backend.unit)
|
|
147
|
+
else:
|
|
148
|
+
out["errors"].append(f"unsupported platform: {plat}")
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
out["unit_path"] = unit_path
|
|
152
|
+
if not os.path.isfile(unit_path):
|
|
153
|
+
return out # unit_exists=False,调用方据此推 unit_missing 规则
|
|
154
|
+
|
|
155
|
+
out["unit_exists"] = True
|
|
156
|
+
|
|
157
|
+
# 读 raw 内容用于 placeholder 检测和 debug
|
|
158
|
+
try:
|
|
159
|
+
with open(unit_path, encoding="utf-8", errors="replace") as f:
|
|
160
|
+
raw = f.read()
|
|
161
|
+
out["raw_snippet"] = raw[:500]
|
|
162
|
+
if PLACEHOLDER_RE.search(raw):
|
|
163
|
+
out["placeholder_unrendered"] = True
|
|
164
|
+
except OSError as e:
|
|
165
|
+
out["errors"].append(f"unit 读取失败: {e}")
|
|
166
|
+
|
|
167
|
+
# 解析结构化字段
|
|
168
|
+
if plat == "darwin":
|
|
169
|
+
parsed = _parse_plist(unit_path)
|
|
170
|
+
else:
|
|
171
|
+
parsed = _parse_systemd_unit(unit_path)
|
|
172
|
+
|
|
173
|
+
out["errors"].extend(parsed.get("errors", []))
|
|
174
|
+
binary = parsed.get("binary")
|
|
175
|
+
if binary:
|
|
176
|
+
out["binary"] = binary
|
|
177
|
+
out["binary_exists"] = os.path.isfile(binary)
|
|
178
|
+
if parsed.get("config_dir"):
|
|
179
|
+
out["config_dir"] = parsed["config_dir"]
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def inspect_runtime(static_result: dict[str, Any], backend, *,
|
|
184
|
+
platform: str | None = None,
|
|
185
|
+
timeout: float = 1.5) -> dict[str, Any]:
|
|
186
|
+
"""在 inspect_static 基础上跑命令拿 enabled / flapping / autostart_version。
|
|
187
|
+
|
|
188
|
+
所有 subprocess 都 wrap try-except,失败时字段填 None 不报错。
|
|
189
|
+
timeout 1.5s 总预算(autostart 4 个规则不应拖垮 doctor <500ms 承诺)。
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
static_result: inspect_static 返回值
|
|
193
|
+
backend: cli.Backend
|
|
194
|
+
platform: 同 inspect_static
|
|
195
|
+
timeout: 单个子命令超时秒
|
|
196
|
+
"""
|
|
197
|
+
plat = static_result.get("platform") or platform or _platform.system().lower()
|
|
198
|
+
out = dict(static_result)
|
|
199
|
+
out["enabled"] = None
|
|
200
|
+
out["last_exit_status"] = None
|
|
201
|
+
out["is_failed"] = None
|
|
202
|
+
out["autostart_version"] = None
|
|
203
|
+
|
|
204
|
+
if not static_result.get("unit_exists"):
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
# autostart binary -v(拿 autostart 实际跑的版本)
|
|
208
|
+
binary = static_result.get("binary")
|
|
209
|
+
if binary and static_result.get("binary_exists"):
|
|
210
|
+
try:
|
|
211
|
+
r = subprocess.run([binary, "-v"], capture_output=True, text=True,
|
|
212
|
+
timeout=timeout)
|
|
213
|
+
if r.returncode == 0 and r.stdout:
|
|
214
|
+
raw = r.stdout.strip().split("\n", 1)[0]
|
|
215
|
+
out["autostart_version_raw"] = raw
|
|
216
|
+
# 复用 cli.get_engine_version 的解析逻辑(mihomo 输出格式)
|
|
217
|
+
m = re.match(
|
|
218
|
+
r"^\S+\s+\S+\s+(\S+)\s+(\S+\s+\S+)\s+with\s+go(\S+)\s+(\S+)",
|
|
219
|
+
raw)
|
|
220
|
+
if m:
|
|
221
|
+
out["autostart_version"] = m.group(1)
|
|
222
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
if plat == "darwin":
|
|
226
|
+
# launchctl print system/<label> —— 不带 sudo 也能拿到部分信息
|
|
227
|
+
try:
|
|
228
|
+
r = subprocess.run(["launchctl", "print", backend.label],
|
|
229
|
+
capture_output=True, text=True, timeout=timeout)
|
|
230
|
+
if r.returncode == 0:
|
|
231
|
+
out["enabled"] = True
|
|
232
|
+
m = re.search(r"last exit code\s*=\s*(-?\d+)", r.stdout)
|
|
233
|
+
if m:
|
|
234
|
+
out["last_exit_status"] = int(m.group(1))
|
|
235
|
+
else:
|
|
236
|
+
out["enabled"] = False
|
|
237
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
238
|
+
pass
|
|
239
|
+
elif plat == "linux":
|
|
240
|
+
try:
|
|
241
|
+
r = subprocess.run(["systemctl", "--user", "is-enabled", backend.unit],
|
|
242
|
+
capture_output=True, text=True, timeout=timeout)
|
|
243
|
+
out["enabled"] = (r.returncode == 0
|
|
244
|
+
and r.stdout.strip() == "enabled")
|
|
245
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
246
|
+
pass
|
|
247
|
+
try:
|
|
248
|
+
r = subprocess.run(["systemctl", "--user", "is-failed", backend.unit],
|
|
249
|
+
capture_output=True, text=True, timeout=timeout)
|
|
250
|
+
# is-failed 返回 0 表示"是失败状态",1 表示"非失败"
|
|
251
|
+
out["is_failed"] = (r.returncode == 0
|
|
252
|
+
and r.stdout.strip() == "failed")
|
|
253
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def compute_sync(inspect_result: dict[str, Any], *,
|
|
260
|
+
target_binary: str,
|
|
261
|
+
target_config_dir: str) -> dict[str, Any]:
|
|
262
|
+
"""计算 sync 操作的"差异 + 写入内容"。纯函数,无副作用。
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
{
|
|
266
|
+
"needs_update": bool, # 是否需要写
|
|
267
|
+
"changes": list[str], # 人类可读的变更描述
|
|
268
|
+
"new_content_bytes": bytes, # macOS plist 的新字节内容(plistlib.dumps)
|
|
269
|
+
Linux 用 new_content_text
|
|
270
|
+
"new_content_text": str, # Linux unit 的新文本(保留其他段)
|
|
271
|
+
"platform": str,
|
|
272
|
+
"unit_path": str,
|
|
273
|
+
"errors": list[str],
|
|
274
|
+
}
|
|
275
|
+
"""
|
|
276
|
+
out: dict[str, Any] = {
|
|
277
|
+
"needs_update": False,
|
|
278
|
+
"changes": [],
|
|
279
|
+
"new_content_bytes": None,
|
|
280
|
+
"new_content_text": None,
|
|
281
|
+
"platform": inspect_result.get("platform", ""),
|
|
282
|
+
"unit_path": inspect_result.get("unit_path", ""),
|
|
283
|
+
"errors": [],
|
|
284
|
+
}
|
|
285
|
+
if not inspect_result.get("unit_exists"):
|
|
286
|
+
out["errors"].append("unit 文件不存在,无法 sync(先 install)")
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
current_binary = inspect_result.get("binary")
|
|
290
|
+
current_cfg = inspect_result.get("config_dir")
|
|
291
|
+
plat = inspect_result["platform"]
|
|
292
|
+
|
|
293
|
+
if current_binary != target_binary:
|
|
294
|
+
out["changes"].append(f"binary: {current_binary} → {target_binary}")
|
|
295
|
+
if current_cfg and os.path.normpath(current_cfg) != os.path.normpath(target_config_dir):
|
|
296
|
+
out["changes"].append(f"config_dir: {current_cfg} → {target_config_dir}")
|
|
297
|
+
if not out["changes"]:
|
|
298
|
+
return out # no-op
|
|
299
|
+
|
|
300
|
+
out["needs_update"] = True
|
|
301
|
+
unit_path = inspect_result["unit_path"]
|
|
302
|
+
|
|
303
|
+
if plat == "darwin":
|
|
304
|
+
try:
|
|
305
|
+
with open(unit_path, "rb") as f:
|
|
306
|
+
data = plistlib.load(f)
|
|
307
|
+
except (OSError, plistlib.InvalidFileException) as e:
|
|
308
|
+
out["errors"].append(f"plist 解析失败: {e}")
|
|
309
|
+
out["needs_update"] = False
|
|
310
|
+
return out
|
|
311
|
+
# in-place 改 ProgramArguments:保留 binary 之后的所有参数,
|
|
312
|
+
# 仅替换 binary 和 -d/-c 后面的目录
|
|
313
|
+
args = list(data.get("ProgramArguments") or [])
|
|
314
|
+
if args:
|
|
315
|
+
args[0] = target_binary
|
|
316
|
+
for i, a in enumerate(args):
|
|
317
|
+
if a == "-d" and i + 1 < len(args):
|
|
318
|
+
args[i + 1] = target_config_dir
|
|
319
|
+
break
|
|
320
|
+
if a == "-c" and i + 1 < len(args):
|
|
321
|
+
# sing-box 用 -c 指 config 文件——保留文件名,仅换目录
|
|
322
|
+
fname = os.path.basename(args[i + 1])
|
|
323
|
+
args[i + 1] = os.path.join(target_config_dir, fname)
|
|
324
|
+
break
|
|
325
|
+
data["ProgramArguments"] = args
|
|
326
|
+
out["new_content_bytes"] = plistlib.dumps(data)
|
|
327
|
+
elif plat == "linux":
|
|
328
|
+
try:
|
|
329
|
+
with open(unit_path, encoding="utf-8") as f:
|
|
330
|
+
text = f.read()
|
|
331
|
+
except OSError as e:
|
|
332
|
+
out["errors"].append(f"unit 读取失败: {e}")
|
|
333
|
+
out["needs_update"] = False
|
|
334
|
+
return out
|
|
335
|
+
# 保守做法:仅替换 ExecStart= 整行,保留其他段
|
|
336
|
+
# 我们的模板形如:
|
|
337
|
+
# ExecStart=/bin/sh -c 'exec %h/.local/bin/mihomo -d %h/.config/mihomo >> ...'
|
|
338
|
+
# 用户可能改了 sh 包装或日志重定向;为安全起见,**重新生成完整 ExecStart**
|
|
339
|
+
# 用模板:exec <binary> -d <config_dir> >> <config_dir>/<name>.log 2>> <name>.err
|
|
340
|
+
engine_name = os.path.basename(target_binary).replace("-", "_")
|
|
341
|
+
new_exec = (
|
|
342
|
+
f"ExecStart=/bin/sh -c 'exec {target_binary} -d {target_config_dir} "
|
|
343
|
+
f">> {target_config_dir}/{engine_name}.log "
|
|
344
|
+
f"2>> {target_config_dir}/{engine_name}.err'"
|
|
345
|
+
)
|
|
346
|
+
new_text = re.sub(r"^ExecStart=.*$", new_exec, text, flags=re.M)
|
|
347
|
+
if new_text == text:
|
|
348
|
+
# 没匹配到 ExecStart=:可能 unit 被改得面目全非,不动
|
|
349
|
+
out["errors"].append("unit 中未找到 ExecStart= 行,sync 拒绝执行")
|
|
350
|
+
out["needs_update"] = False
|
|
351
|
+
return out
|
|
352
|
+
out["new_content_text"] = new_text
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def to_suggestions(inspect_result: dict[str, Any] | None, *,
|
|
357
|
+
path_binary: str | None = None,
|
|
358
|
+
path_version: str | None = None,
|
|
359
|
+
expected_config_dir: str | None = None,
|
|
360
|
+
) -> list[dict[str, Any]]:
|
|
361
|
+
"""8 条 autostart 规则推导。
|
|
362
|
+
|
|
363
|
+
分层短路:unit 不存在时只报 unit_missing;其他规则跳过。
|
|
364
|
+
所有 evidence 都填实际值,agent 直接消费不必 regex title。
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
inspect_result: inspect_static / inspect_runtime 返回;None 表示跳过整组
|
|
368
|
+
path_binary: shutil.which(backend_name) 结果
|
|
369
|
+
path_version: PATH binary 的版本(来自 cli.get_engine_version)
|
|
370
|
+
expected_config_dir: backend 期望的 config_dir
|
|
371
|
+
(= dirname(backend.config_file))
|
|
372
|
+
"""
|
|
373
|
+
if not inspect_result:
|
|
374
|
+
return []
|
|
375
|
+
|
|
376
|
+
out: list[dict[str, Any]] = []
|
|
377
|
+
unit_path = inspect_result.get("unit_path") or "?"
|
|
378
|
+
plat = inspect_result.get("platform") or "?"
|
|
379
|
+
|
|
380
|
+
# 1. unit_missing —— 优先级最高,触发后短路其他规则
|
|
381
|
+
if not inspect_result.get("unit_exists"):
|
|
382
|
+
out.append({
|
|
383
|
+
"id": "autostart.unit_missing",
|
|
384
|
+
"severity": "warn",
|
|
385
|
+
"actor": "user",
|
|
386
|
+
"title": "自动启动 unit 未安装",
|
|
387
|
+
"evidence": {"unit_path": unit_path, "platform": plat},
|
|
388
|
+
"inspect_command": (f"ls -la {unit_path}" if unit_path != "?" else None),
|
|
389
|
+
"fix_command": (
|
|
390
|
+
"sudo cp ~/.config/proxyctl/launchdaemons/*.plist /Library/LaunchDaemons/"
|
|
391
|
+
if plat == "darwin" else
|
|
392
|
+
"systemctl --user enable --now mihomo.service"),
|
|
393
|
+
"doc": "suggestion:autostart.unit_missing",
|
|
394
|
+
"since": "0.5.0",
|
|
395
|
+
})
|
|
396
|
+
return out # 短路
|
|
397
|
+
|
|
398
|
+
# 2. placeholder_unrendered —— 模板未渲染
|
|
399
|
+
if inspect_result.get("placeholder_unrendered"):
|
|
400
|
+
out.append({
|
|
401
|
+
"id": "autostart.placeholder_unrendered",
|
|
402
|
+
"severity": "warn",
|
|
403
|
+
"actor": "user",
|
|
404
|
+
"title": "autostart 模板未替换占位符(yourname)",
|
|
405
|
+
"evidence": {"unit_path": unit_path},
|
|
406
|
+
"inspect_command": f"grep -n yourname {unit_path}",
|
|
407
|
+
"doc": "suggestion:autostart.placeholder_unrendered",
|
|
408
|
+
"since": "0.5.0",
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
# 3. binary_missing
|
|
412
|
+
binary = inspect_result.get("binary")
|
|
413
|
+
if binary and not inspect_result.get("binary_exists"):
|
|
414
|
+
out.append({
|
|
415
|
+
"id": "autostart.binary_missing",
|
|
416
|
+
"severity": "warn",
|
|
417
|
+
"actor": "user",
|
|
418
|
+
"title": f"autostart 引用的引擎二进制不存在:{binary}",
|
|
419
|
+
"evidence": {"binary": binary, "unit_path": unit_path},
|
|
420
|
+
"inspect_command": f"ls -la {binary}",
|
|
421
|
+
"doc": "suggestion:autostart.binary_missing",
|
|
422
|
+
"since": "0.5.0",
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
# 4. binary_mismatch —— autostart binary 与 PATH binary 不同路径
|
|
426
|
+
if binary and path_binary and binary != path_binary:
|
|
427
|
+
out.append({
|
|
428
|
+
"id": "autostart.binary_mismatch",
|
|
429
|
+
"severity": "advisory",
|
|
430
|
+
"actor": "user",
|
|
431
|
+
"title": "autostart 与 PATH 引用不同的引擎二进制",
|
|
432
|
+
"evidence": {
|
|
433
|
+
"autostart_binary": binary,
|
|
434
|
+
"path_binary": path_binary,
|
|
435
|
+
},
|
|
436
|
+
"inspect_command": f"diff <({binary} -v) <({path_binary} -v)",
|
|
437
|
+
"doc": "suggestion:autostart.binary_mismatch",
|
|
438
|
+
"since": "0.5.0",
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
# 5. version_mismatch —— 优先于 binary_mismatch 之外的独立信号
|
|
442
|
+
autostart_ver = inspect_result.get("autostart_version")
|
|
443
|
+
if autostart_ver and path_version and autostart_ver != path_version:
|
|
444
|
+
out.append({
|
|
445
|
+
"id": "autostart.version_mismatch",
|
|
446
|
+
"severity": "advisory",
|
|
447
|
+
"actor": "user",
|
|
448
|
+
"title": (f"autostart 引擎版本 v{autostart_ver} "
|
|
449
|
+
f"≠ PATH v{path_version}"),
|
|
450
|
+
"evidence": {
|
|
451
|
+
"autostart_version": autostart_ver,
|
|
452
|
+
"path_version": path_version,
|
|
453
|
+
"autostart_binary": binary,
|
|
454
|
+
"path_binary": path_binary,
|
|
455
|
+
},
|
|
456
|
+
"inspect_command": "proxyctl status --json | jq .data.engine",
|
|
457
|
+
"doc": "suggestion:autostart.version_mismatch",
|
|
458
|
+
"since": "0.5.0",
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
# 6. config_dir_mismatch —— autostart 跑的配置目录 ≠ proxyctl 看到的
|
|
462
|
+
autostart_cfg = inspect_result.get("config_dir")
|
|
463
|
+
if (autostart_cfg and expected_config_dir
|
|
464
|
+
and os.path.normpath(autostart_cfg)
|
|
465
|
+
!= os.path.normpath(expected_config_dir)):
|
|
466
|
+
out.append({
|
|
467
|
+
"id": "autostart.config_dir_mismatch",
|
|
468
|
+
"severity": "warn",
|
|
469
|
+
"actor": "user",
|
|
470
|
+
"title": "autostart 配置目录与 proxyctl 不一致",
|
|
471
|
+
"evidence": {
|
|
472
|
+
"autostart_config_dir": autostart_cfg,
|
|
473
|
+
"expected_config_dir": expected_config_dir,
|
|
474
|
+
},
|
|
475
|
+
"inspect_command": (f"diff -r {autostart_cfg} {expected_config_dir} | head"),
|
|
476
|
+
"doc": "suggestion:autostart.config_dir_mismatch",
|
|
477
|
+
"since": "0.5.0",
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
# 7. disabled —— unit 在但未 bootstrap / enable
|
|
481
|
+
enabled = inspect_result.get("enabled")
|
|
482
|
+
if enabled is False:
|
|
483
|
+
out.append({
|
|
484
|
+
"id": "autostart.disabled",
|
|
485
|
+
"severity": "info",
|
|
486
|
+
"actor": "user",
|
|
487
|
+
"title": "自动启动未启用",
|
|
488
|
+
"evidence": {"unit_path": unit_path, "platform": plat},
|
|
489
|
+
"fix_command": ("sudo launchctl bootstrap system " + unit_path
|
|
490
|
+
if plat == "darwin" else
|
|
491
|
+
"systemctl --user enable mihomo.service"),
|
|
492
|
+
"doc": "suggestion:autostart.disabled",
|
|
493
|
+
"since": "0.5.0",
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
# 8. flapping —— 服务最近异常退出
|
|
497
|
+
flapping = False
|
|
498
|
+
flap_evidence: dict[str, Any] = {}
|
|
499
|
+
if plat == "darwin":
|
|
500
|
+
les = inspect_result.get("last_exit_status")
|
|
501
|
+
if les is not None and les != 0:
|
|
502
|
+
flapping = True
|
|
503
|
+
flap_evidence = {"last_exit_status": les}
|
|
504
|
+
elif plat == "linux":
|
|
505
|
+
if inspect_result.get("is_failed") is True:
|
|
506
|
+
flapping = True
|
|
507
|
+
flap_evidence = {"systemd_state": "failed"}
|
|
508
|
+
if flapping:
|
|
509
|
+
out.append({
|
|
510
|
+
"id": "autostart.flapping",
|
|
511
|
+
"severity": "warn",
|
|
512
|
+
"actor": "user",
|
|
513
|
+
"title": "autostart 服务最近异常退出",
|
|
514
|
+
"evidence": flap_evidence,
|
|
515
|
+
"inspect_command": (f"launchctl print {unit_path}" if plat == "darwin"
|
|
516
|
+
else "journalctl --user -u mihomo.service -n 50"),
|
|
517
|
+
"doc": "suggestion:autostart.flapping",
|
|
518
|
+
"since": "0.5.0",
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
return out
|