mihomo-cli 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.
- mihomo_cli/__init__.py +13 -0
- mihomo_cli/__main__.py +6 -0
- mihomo_cli/actions.py +1690 -0
- mihomo_cli/api.py +166 -0
- mihomo_cli/cli.py +754 -0
- mihomo_cli/core_dl.py +202 -0
- mihomo_cli/doctor.py +440 -0
- mihomo_cli/i18n.py +483 -0
- mihomo_cli/menu.py +249 -0
- mihomo_cli/nodes.py +100 -0
- mihomo_cli/platform/__init__.py +21 -0
- mihomo_cli/platform/base.py +141 -0
- mihomo_cli/platform/darwin.py +30 -0
- mihomo_cli/platform/linux.py +480 -0
- mihomo_cli/render.py +188 -0
- mihomo_cli/state.py +249 -0
- mihomo_cli-0.1.0.dist-info/METADATA +115 -0
- mihomo_cli-0.1.0.dist-info/RECORD +21 -0
- mihomo_cli-0.1.0.dist-info/WHEEL +4 -0
- mihomo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mihomo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
mihomo_cli/actions.py
ADDED
|
@@ -0,0 +1,1690 @@
|
|
|
1
|
+
"""命令与菜单共用的全部业务动作——唯一业务入口(方案 5.1.1)。
|
|
2
|
+
|
|
3
|
+
职责边界:
|
|
4
|
+
- 这里决定“改什么、按什么顺序改、失败怎么回滚”;state.py 只管安全读写,render.py 只管生成配置,
|
|
5
|
+
api.py 只管调接口,platform/ 只管系统命令。
|
|
6
|
+
- cli.py / menu.py 只收集参数、展示结果,不得绕过本模块直接改 state 或调接口。
|
|
7
|
+
|
|
8
|
+
并发约定(方案 5.1.2):会改 state 的动作全程持 state.lock(等 130 秒);调用
|
|
9
|
+
systemctl start/restart/stop/enable 之前必须先释放锁,否则 ExecStartPre 拿不到锁。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import getpass
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from . import core_dl, nodes as nodes_mod, render as render_mod
|
|
29
|
+
from .api import Api, ApiError, ApiUnavailable
|
|
30
|
+
from .i18n import t
|
|
31
|
+
from .platform import NotSupported, Platform, PlatformError, ServiceStatus
|
|
32
|
+
from .state import (
|
|
33
|
+
CLI_LOCK_TIMEOUT_SEC,
|
|
34
|
+
PROFILE_NAME_RE,
|
|
35
|
+
SERVICE_LOCK_TIMEOUT_SEC,
|
|
36
|
+
LockTimeout,
|
|
37
|
+
Paths,
|
|
38
|
+
StateError,
|
|
39
|
+
StateLock,
|
|
40
|
+
ensure_home,
|
|
41
|
+
home_mode_ok,
|
|
42
|
+
load_state,
|
|
43
|
+
new_profile,
|
|
44
|
+
save_state,
|
|
45
|
+
write_atomic,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
log = logging.getLogger("mihomo_cli")
|
|
49
|
+
|
|
50
|
+
# 退出码(方案 5.1.6)。
|
|
51
|
+
EXIT_OK = 0
|
|
52
|
+
EXIT_ERROR = 1
|
|
53
|
+
EXIT_USAGE = 2
|
|
54
|
+
EXIT_UNREACHABLE = 3
|
|
55
|
+
EXIT_ROLLED_BACK = 4
|
|
56
|
+
EXIT_SUDO = 5
|
|
57
|
+
EXIT_PLATFORM = 6
|
|
58
|
+
EXIT_MANUAL = 7
|
|
59
|
+
|
|
60
|
+
BACKUP_KEEP = 10
|
|
61
|
+
# _service-reapply:等待就绪上限 60 秒,整体预算 90 秒,下发后等 2 秒回读,最多 3 次(方案 5.1.7)。
|
|
62
|
+
REAPPLY_READY_SEC = 60.0
|
|
63
|
+
REAPPLY_BUDGET_SEC = 90.0
|
|
64
|
+
REAPPLY_SETTLE_SEC = 2.0
|
|
65
|
+
REAPPLY_TRIES = 3
|
|
66
|
+
# _tun-dns-apply 等网卡与 53 端口就绪的上限(方案 5.1.7「启动耗时预算」)。
|
|
67
|
+
DNS_APPLY_WAIT_SEC = 10.0
|
|
68
|
+
EXIT_IP_URL = "https://ipinfo.io/json"
|
|
69
|
+
EXIT_IP_TIMEOUT_SEC = 5.0
|
|
70
|
+
TEST_ENV_DNS_REVERT = "MIHOMO_CLI_TEST_FAIL_DNS_REVERT"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CliError(Exception):
|
|
74
|
+
"""带退出码的业务失败。message 是给用户看的一句话,detail 是底层原因原文(已截断)。"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, code: int, message: str, detail: str = "", hints: Optional[List[str]] = None):
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.code = code
|
|
79
|
+
self.message = message
|
|
80
|
+
self.detail = detail
|
|
81
|
+
self.hints = hints or []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Ctx:
|
|
85
|
+
"""一次 CLI 调用的上下文。messages 收集提示语,由 cli 决定立即打印还是放进 --json 输出。"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, paths: Paths, platform: Platform, python_exe: Optional[str] = None,
|
|
88
|
+
emit: Optional[Callable[[str, str], None]] = None):
|
|
89
|
+
self.paths = paths
|
|
90
|
+
self.platform = platform
|
|
91
|
+
self.python_exe = python_exe or sys.executable
|
|
92
|
+
self.messages: List[Tuple[str, str]] = []
|
|
93
|
+
self._emit = emit
|
|
94
|
+
|
|
95
|
+
def say(self, level: str, text: str) -> None:
|
|
96
|
+
self.messages.append((level, text))
|
|
97
|
+
if self._emit:
|
|
98
|
+
self._emit(level, text)
|
|
99
|
+
|
|
100
|
+
def api(self) -> Api:
|
|
101
|
+
return Api(self.paths.sock)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ====================================================================
|
|
105
|
+
# 公共小工具
|
|
106
|
+
# ====================================================================
|
|
107
|
+
|
|
108
|
+
def check_home(ctx: Ctx) -> None:
|
|
109
|
+
"""主目录权限过宽时拒绝运行:套接字是 0666,只靠目录 0700 隔离其它用户(方案 §7 安全)。"""
|
|
110
|
+
ok = home_mode_ok(ctx.paths)
|
|
111
|
+
if ok is False:
|
|
112
|
+
raise CliError(EXIT_ERROR, t("err.home_mode", home=ctx.paths.home),
|
|
113
|
+
hints=["chmod 700 %s" % ctx.paths.home])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def service_status(ctx: Ctx) -> ServiceStatus:
|
|
117
|
+
try:
|
|
118
|
+
return ctx.platform.service_status()
|
|
119
|
+
except NotSupported:
|
|
120
|
+
raise
|
|
121
|
+
except PlatformError:
|
|
122
|
+
return ServiceStatus()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def kernel_running(ctx: Ctx, st: Optional[ServiceStatus] = None) -> bool:
|
|
126
|
+
"""内核是否运行(方案 5.1.5「内核是否运行的判据」)。
|
|
127
|
+
|
|
128
|
+
正在启动 / 停止时抛退出码 3:ExecStartPre 期间 MainPID 为 0,不能据此判为未运行,
|
|
129
|
+
否则会出现“以为没运行只改文件、内核随后按旧配置起来”的错乱。
|
|
130
|
+
active 且 MainPID 非 0 才算运行,此时套接字连不上同样是退出码 3。
|
|
131
|
+
"""
|
|
132
|
+
st = st or service_status(ctx)
|
|
133
|
+
if st.transitioning:
|
|
134
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_transitioning", state=st.active_state))
|
|
135
|
+
if not st.running:
|
|
136
|
+
return False
|
|
137
|
+
if not os.path.exists(ctx.paths.sock):
|
|
138
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_unreachable"), detail=ctx.paths.sock)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@contextmanager
|
|
143
|
+
def locked(ctx: Ctx, timeout_sec: float = CLI_LOCK_TIMEOUT_SEC) -> Iterator[StateLock]:
|
|
144
|
+
lock = StateLock(ctx.paths, timeout_sec)
|
|
145
|
+
try:
|
|
146
|
+
lock.acquire()
|
|
147
|
+
except LockTimeout:
|
|
148
|
+
raise CliError(EXIT_ERROR, t("err.locked")) from None
|
|
149
|
+
try:
|
|
150
|
+
yield lock
|
|
151
|
+
finally:
|
|
152
|
+
lock.release()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def read_state(ctx: Ctx) -> Dict[str, Any]:
|
|
156
|
+
try:
|
|
157
|
+
return load_state(ctx.paths)
|
|
158
|
+
except StateError as exc:
|
|
159
|
+
raise CliError(EXIT_ERROR, t("err.state_invalid"), detail=str(exc)) from exc
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def require_profile(state: Dict[str, Any], name: str) -> Dict[str, Any]:
|
|
163
|
+
prof = state["profiles"].get(name)
|
|
164
|
+
if prof is None:
|
|
165
|
+
raise CliError(EXIT_ERROR, t("err.no_such_profile", name=name))
|
|
166
|
+
return prof
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def require_active(state: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
170
|
+
name = state.get("active_profile")
|
|
171
|
+
if not name:
|
|
172
|
+
raise CliError(EXIT_ERROR, t("err.no_active_profile"))
|
|
173
|
+
return name, state["profiles"][name]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _api_call(fn: Callable[[], Any]) -> Any:
|
|
177
|
+
try:
|
|
178
|
+
return fn()
|
|
179
|
+
except ApiUnavailable as exc:
|
|
180
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_unreachable"), detail=str(exc)) from exc
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def manual_names(prof: Dict[str, Any]) -> List[Optional[str]]:
|
|
184
|
+
return [m.get("name") for m in prof["manual"]]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def mirror_normalize(mirror: Optional[str]) -> Optional[str]:
|
|
188
|
+
if mirror is None:
|
|
189
|
+
return None
|
|
190
|
+
mirror = mirror.strip().rstrip("/")
|
|
191
|
+
if not mirror:
|
|
192
|
+
return None
|
|
193
|
+
if not (mirror.startswith("http://") or mirror.startswith("https://")):
|
|
194
|
+
raise CliError(EXIT_USAGE, t("err.bad_mirror", mirror=mirror))
|
|
195
|
+
return mirror
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def normalize_version_or_usage(raw: Optional[str]) -> Optional[str]:
|
|
199
|
+
if raw is None:
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
return core_dl.normalize_version(raw)
|
|
203
|
+
except ValueError:
|
|
204
|
+
raise CliError(EXIT_USAGE, t("err.bad_version", version=raw)) from None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _tcp_ok(port: int, timeout: float = 2.0) -> bool:
|
|
208
|
+
try:
|
|
209
|
+
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
|
210
|
+
return True
|
|
211
|
+
except OSError:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def kernel_listens_on(ctx: Ctx, port: int) -> bool:
|
|
216
|
+
"""本实例内核是否自称在听 port(管理接口 /configs 的 mixed-port)。
|
|
217
|
+
|
|
218
|
+
端口检查要靠它排除自己:内核带 AmbientCapabilities 运行,同一用户也读不到它的 fd,
|
|
219
|
+
平台层只能看到“有人在听、属主不可见”。接口不通(服务没跑)时返回 False,按占用处理。
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
api = ctx.api()
|
|
223
|
+
except (ApiError, ApiUnavailable, OSError):
|
|
224
|
+
return False
|
|
225
|
+
try:
|
|
226
|
+
return int(api.get_configs().get("mixed-port") or 0) == int(port)
|
|
227
|
+
except (ApiError, ApiUnavailable, ValueError, TypeError):
|
|
228
|
+
return False
|
|
229
|
+
finally:
|
|
230
|
+
api.close()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def port_conflicts(ctx: Ctx, port: int) -> List[str]:
|
|
234
|
+
"""混合端口占用检查(install 与 doctor D06 共用),本实例内核自己在听不算占用。"""
|
|
235
|
+
conflicts = ctx.platform.listening_conflicts(port)
|
|
236
|
+
if conflicts and kernel_listens_on(ctx, port):
|
|
237
|
+
conflicts = ctx.platform.listening_conflicts(port, own_listener=True)
|
|
238
|
+
return conflicts
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def validate_config(ctx: Ctx, config_path: str) -> None:
|
|
242
|
+
"""`mihomo -t` 校验。-f 必须是绝对路径(相对路径按当前工作目录解析而非 -d,main.go:129-133)。
|
|
243
|
+
|
|
244
|
+
注意 -t 只解析配置、不读 provider 内容,坏节点 / 坏订阅要靠重载后的计数核验拦截。
|
|
245
|
+
"""
|
|
246
|
+
core = ctx.paths.core
|
|
247
|
+
if not os.path.exists(core):
|
|
248
|
+
raise CliError(EXIT_ERROR, t("err.no_core"), hints=["mihomo-cli install"])
|
|
249
|
+
try:
|
|
250
|
+
proc = subprocess.run([core, "-t", "-d", ctx.paths.home, "-f", os.path.abspath(config_path)],
|
|
251
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
252
|
+
timeout=60)
|
|
253
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
254
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.config_invalid"), detail=str(exc)) from exc
|
|
255
|
+
if proc.returncode != 0:
|
|
256
|
+
tail = "\n".join((proc.stdout or "").strip().splitlines()[-5:])
|
|
257
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.config_invalid"), detail=tail[:600])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def write_config(ctx: Ctx, state: Dict[str, Any]) -> None:
|
|
261
|
+
"""不重载、只原子重写 config.yaml(切模式、改镜像、preflight 暂停 TUN 等用),保证重启后与 state 一致。"""
|
|
262
|
+
write_atomic(ctx.paths.config, render_mod.render_text(state, ctx.paths))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ====================================================================
|
|
266
|
+
# 备份与重载事务(方案 5.1.5)
|
|
267
|
+
# ====================================================================
|
|
268
|
+
|
|
269
|
+
def _backup_name(paths: Paths) -> str:
|
|
270
|
+
base = time.strftime("%Y%m%d-%H%M%S")
|
|
271
|
+
name, n = base, 1
|
|
272
|
+
while os.path.exists(os.path.join(paths.backups, name)):
|
|
273
|
+
n += 1
|
|
274
|
+
name = "%s-%d" % (base, n)
|
|
275
|
+
return name
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def make_backup(ctx: Ctx, state: Dict[str, Any]) -> str:
|
|
279
|
+
"""备份 config.yaml、state.yaml、nodes/ 与本地文件类 providers/<名>.yaml;保留最近 10 份。"""
|
|
280
|
+
paths = ctx.paths
|
|
281
|
+
os.makedirs(paths.backups, mode=0o700, exist_ok=True)
|
|
282
|
+
name = _backup_name(paths)
|
|
283
|
+
dest = os.path.join(paths.backups, name)
|
|
284
|
+
os.makedirs(os.path.join(dest, "nodes"))
|
|
285
|
+
os.makedirs(os.path.join(dest, "providers"))
|
|
286
|
+
for src in (paths.config, paths.state):
|
|
287
|
+
if os.path.exists(src):
|
|
288
|
+
shutil.copy2(src, os.path.join(dest, os.path.basename(src)))
|
|
289
|
+
if os.path.isdir(paths.nodes):
|
|
290
|
+
for fname in os.listdir(paths.nodes):
|
|
291
|
+
if fname.endswith(".txt"):
|
|
292
|
+
shutil.copy2(os.path.join(paths.nodes, fname), os.path.join(dest, "nodes", fname))
|
|
293
|
+
for pname, prof in state["profiles"].items():
|
|
294
|
+
src = paths.provider_file(pname)
|
|
295
|
+
if prof["kind"] == "file" and os.path.exists(src):
|
|
296
|
+
shutil.copy2(src, os.path.join(dest, "providers", pname + ".yaml"))
|
|
297
|
+
entries = sorted(d for d in os.listdir(paths.backups) if os.path.isdir(os.path.join(paths.backups, d)))
|
|
298
|
+
for old in entries[:-BACKUP_KEEP]:
|
|
299
|
+
shutil.rmtree(os.path.join(paths.backups, old), ignore_errors=True)
|
|
300
|
+
return name
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _backup_path_for(ctx: Ctx, backup: str, final_path: str) -> Optional[str]:
|
|
304
|
+
rel = os.path.relpath(final_path, ctx.paths.home)
|
|
305
|
+
candidate = os.path.join(ctx.paths.backups, backup, rel)
|
|
306
|
+
return candidate if os.path.exists(candidate) else None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _clear_staging(ctx: Ctx) -> None:
|
|
310
|
+
shutil.rmtree(ctx.paths.staging, ignore_errors=True)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _stage(ctx: Ctx, new_state: Dict[str, Any], files: Dict[str, Optional[bytes]]) -> str:
|
|
314
|
+
"""第 1 步:所有新文件先写到 staging/(不在 provider 监视路径上);返回 staging 里的 config 路径。"""
|
|
315
|
+
_clear_staging(ctx)
|
|
316
|
+
os.makedirs(ctx.paths.staging, mode=0o700)
|
|
317
|
+
write_atomic(os.path.join(ctx.paths.staging, "state.yaml.new"), _dump(new_state))
|
|
318
|
+
staged_config = os.path.join(ctx.paths.staging, "config.yaml.new")
|
|
319
|
+
write_atomic(staged_config, render_mod.render_text(new_state, ctx.paths))
|
|
320
|
+
for final_path, content in files.items():
|
|
321
|
+
if content is None:
|
|
322
|
+
continue
|
|
323
|
+
rel = os.path.relpath(final_path, ctx.paths.home)
|
|
324
|
+
staged = os.path.join(ctx.paths.staging, rel + ".new")
|
|
325
|
+
os.makedirs(os.path.dirname(staged), exist_ok=True)
|
|
326
|
+
with open(staged, "wb") as fh:
|
|
327
|
+
fh.write(content)
|
|
328
|
+
return staged_config
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _dump(state: Dict[str, Any]) -> str:
|
|
332
|
+
from .state import dump_state
|
|
333
|
+
|
|
334
|
+
return dump_state(state)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _install_files(ctx: Ctx, files: Dict[str, Optional[bytes]]) -> None:
|
|
338
|
+
for final_path, content in files.items():
|
|
339
|
+
if content is None:
|
|
340
|
+
try:
|
|
341
|
+
os.unlink(final_path)
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
pass
|
|
344
|
+
continue
|
|
345
|
+
rel = os.path.relpath(final_path, ctx.paths.home)
|
|
346
|
+
staged = os.path.join(ctx.paths.staging, rel + ".new")
|
|
347
|
+
os.makedirs(os.path.dirname(final_path), mode=0o700, exist_ok=True)
|
|
348
|
+
os.replace(staged, final_path)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def manual_file_content(prof: Optional[Dict[str, Any]]) -> Optional[bytes]:
|
|
352
|
+
"""手工节点文件内容;没有手工节点返回 None(删除文件、render 也不写 manual provider)。"""
|
|
353
|
+
if not prof or not prof["manual"]:
|
|
354
|
+
return None
|
|
355
|
+
return nodes_mod.encode_manual_file(m["link"] for m in prof["manual"]).encode("ascii")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def apply_offline(ctx: Ctx, new_state: Dict[str, Any], files: Dict[str, Optional[bytes]]) -> str:
|
|
359
|
+
"""内核未运行:校验后直接落盘,不重载(方案 5.1.5)。新手工条目的 name 保持 None,由下次重载回读。"""
|
|
360
|
+
staged_config = _stage(ctx, new_state, files)
|
|
361
|
+
try:
|
|
362
|
+
validate_config(ctx, staged_config)
|
|
363
|
+
backup = make_backup(ctx, read_state(ctx))
|
|
364
|
+
_install_files(ctx, files)
|
|
365
|
+
write_config(ctx, new_state)
|
|
366
|
+
save_state(ctx.paths, new_state)
|
|
367
|
+
finally:
|
|
368
|
+
_clear_staging(ctx)
|
|
369
|
+
log.info("[reload] offline backup=%s result=ok", backup)
|
|
370
|
+
return backup
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def ensure_selection(ctx: Ctx, api: Api, state: Dict[str, Any]) -> bool:
|
|
374
|
+
"""把 state 的 PROXY / CN 选择下发到内核;记录的节点已不在 PROXY 成员中时补选第一个非 REJECT 成员。
|
|
375
|
+
|
|
376
|
+
返回 state 是否被修改(调用方负责在持锁时写回)。PROXY 只剩 REJECT 时保持阻断并提示。
|
|
377
|
+
选择恢复失败不算回滚条件(方案 5.1.5 第 5 步)。
|
|
378
|
+
"""
|
|
379
|
+
changed = False
|
|
380
|
+
group = api.get_proxy("PROXY")
|
|
381
|
+
members = [m for m in group.get("all", []) if m != "REJECT"]
|
|
382
|
+
prof = render_mod.active_profile(state)
|
|
383
|
+
wanted = prof.get("selected") if prof else None
|
|
384
|
+
if wanted in members:
|
|
385
|
+
try:
|
|
386
|
+
api.select("PROXY", wanted)
|
|
387
|
+
except ApiError as exc:
|
|
388
|
+
ctx.say("warn", t("warn.select_failed", name=wanted, detail=exc.message))
|
|
389
|
+
elif members:
|
|
390
|
+
pick = members[0]
|
|
391
|
+
api.select("PROXY", pick)
|
|
392
|
+
if prof is not None:
|
|
393
|
+
prof["selected"] = pick
|
|
394
|
+
changed = True
|
|
395
|
+
ctx.say("info", t("info.auto_selected", name=pick))
|
|
396
|
+
else:
|
|
397
|
+
if prof is not None and prof.get("selected") is not None:
|
|
398
|
+
prof["selected"] = None
|
|
399
|
+
changed = True
|
|
400
|
+
ctx.say("warn", t("warn.no_nodes_blocked"))
|
|
401
|
+
try:
|
|
402
|
+
api.select("CN", "DIRECT" if state["cn_route"] == "direct" else "PROXY")
|
|
403
|
+
except ApiError as exc:
|
|
404
|
+
ctx.say("warn", t("warn.select_failed", name="CN", detail=exc.message))
|
|
405
|
+
return changed
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _provider_counts(api: Api) -> Dict[str, int]:
|
|
409
|
+
try:
|
|
410
|
+
providers = api.get_providers()
|
|
411
|
+
except ApiError:
|
|
412
|
+
return {}
|
|
413
|
+
return {name: len(p.get("proxies") or []) for name, p in providers.items() if name in ("main", "manual")}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _force_fail(var: str) -> bool:
|
|
417
|
+
return os.environ.get(var) == "1"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def reload(ctx: Ctx, old_state: Dict[str, Any], new_state: Dict[str, Any], action: str,
|
|
421
|
+
files: Optional[Dict[str, Optional[bytes]]] = None,
|
|
422
|
+
involved: Tuple[str, ...] = (),
|
|
423
|
+
hidden_added: Tuple[str, ...] = (), restored: Tuple[str, ...] = (),
|
|
424
|
+
cleanup_after: Tuple[str, ...] = ()) -> str:
|
|
425
|
+
"""重载事务:校验 → 备份 → 替换文件 → PUT /configs → 后置核验 → 提交 / 回滚(方案 5.1.5 第 1–6 步)。
|
|
426
|
+
|
|
427
|
+
调用方必须已持 state.lock。state.yaml 只在核验通过后替换,任何失败时保持旧值。
|
|
428
|
+
files:正式路径 → 新内容(None 表示删除);involved:本次涉及、要精确核验的 provider(main / manual)。
|
|
429
|
+
cleanup_after:提交成功后删除的文件(如改名后的旧名文件)。返回备份名。
|
|
430
|
+
"""
|
|
431
|
+
files = dict(files or {})
|
|
432
|
+
started = time.monotonic()
|
|
433
|
+
new_state = copy.deepcopy(new_state)
|
|
434
|
+
new_prof = render_mod.active_profile(new_state)
|
|
435
|
+
involved_set = set(involved)
|
|
436
|
+
if new_prof is not None and new_prof["manual"]:
|
|
437
|
+
# 当前代理服务有手工条目时 manual 一律精确计数:内核未运行时写入的条目从未核验过,
|
|
438
|
+
# 计数不等时按位置回读会把名字安错(方案 5.1.5 第 5 步)。
|
|
439
|
+
involved_set.add("manual")
|
|
440
|
+
# 选中的若是手工条目,记下它在新列表里的位置,回读改名后按位置迁移。
|
|
441
|
+
sel_index: Optional[int] = None
|
|
442
|
+
if new_prof is not None and new_prof.get("selected"):
|
|
443
|
+
names = manual_names(new_prof)
|
|
444
|
+
if new_prof["selected"] in names:
|
|
445
|
+
sel_index = names.index(new_prof["selected"])
|
|
446
|
+
|
|
447
|
+
# 第 1、2 步:staging + mihomo -t。失败时什么都没动过。
|
|
448
|
+
staged_config = _stage(ctx, new_state, files)
|
|
449
|
+
try:
|
|
450
|
+
validate_config(ctx, staged_config)
|
|
451
|
+
except CliError:
|
|
452
|
+
_clear_staging(ctx)
|
|
453
|
+
log.info("[reload] action=%s result=invalid", action)
|
|
454
|
+
raise
|
|
455
|
+
|
|
456
|
+
api = ctx.api()
|
|
457
|
+
try:
|
|
458
|
+
# 第 3 步:备份 + 记录重载前的 provider 节点数与 PROXY 成员。
|
|
459
|
+
backup = make_backup(ctx, old_state)
|
|
460
|
+
pre_counts = _api_call(lambda: _provider_counts(api))
|
|
461
|
+
pre_members: List[str] = []
|
|
462
|
+
try:
|
|
463
|
+
pre_members = api.get_proxy("PROXY").get("all", [])
|
|
464
|
+
except (ApiError, ApiUnavailable):
|
|
465
|
+
pass
|
|
466
|
+
replaced = list(files.keys()) + [ctx.paths.config]
|
|
467
|
+
try:
|
|
468
|
+
# 第 4 步:provider 文件在这一步之前不得进入正式路径(内核对 file provider 有文件监视,会立即热加载)。
|
|
469
|
+
_install_files(ctx, files)
|
|
470
|
+
os.replace(staged_config, ctx.paths.config)
|
|
471
|
+
api.reload(ctx.paths.config)
|
|
472
|
+
# 第 5 步:后置核验。
|
|
473
|
+
readback = _verify(ctx, api, new_state, pre_counts, pre_members, involved_set,
|
|
474
|
+
hidden_added, restored)
|
|
475
|
+
except (ApiError, ApiUnavailable, CliError, OSError) as exc:
|
|
476
|
+
_rollback(ctx, api, old_state, backup, replaced, exc)
|
|
477
|
+
raise AssertionError("unreachable") from exc # _rollback 总会抛出
|
|
478
|
+
|
|
479
|
+
# 提交:回读手工节点名 → 迁移选择 → 替换 state.yaml → 补选。
|
|
480
|
+
if new_prof is not None and readback is not None:
|
|
481
|
+
for entry, name in zip(new_prof["manual"], readback):
|
|
482
|
+
entry["name"] = name
|
|
483
|
+
if sel_index is not None and sel_index < len(readback):
|
|
484
|
+
new_prof["selected"] = readback[sel_index]
|
|
485
|
+
save_state(ctx.paths, new_state)
|
|
486
|
+
try:
|
|
487
|
+
if ensure_selection(ctx, api, new_state):
|
|
488
|
+
save_state(ctx.paths, new_state)
|
|
489
|
+
except (ApiError, ApiUnavailable) as exc:
|
|
490
|
+
ctx.say("warn", t("warn.select_failed", name="PROXY", detail=str(exc)))
|
|
491
|
+
for path in cleanup_after:
|
|
492
|
+
try:
|
|
493
|
+
os.unlink(path)
|
|
494
|
+
except FileNotFoundError:
|
|
495
|
+
pass
|
|
496
|
+
old_state.clear()
|
|
497
|
+
old_state.update(new_state)
|
|
498
|
+
log.info("[reload] action=%s profile=%s backup=%s elapsed_ms=%d result=ok", action,
|
|
499
|
+
new_state.get("active_profile"), backup, (time.monotonic() - started) * 1000)
|
|
500
|
+
return backup
|
|
501
|
+
finally:
|
|
502
|
+
_clear_staging(ctx)
|
|
503
|
+
api.close()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _verify(ctx: Ctx, api: Api, new_state: Dict[str, Any], pre_counts: Dict[str, int],
|
|
507
|
+
pre_members: List[str], involved: set, hidden_added: Tuple[str, ...],
|
|
508
|
+
restored: Tuple[str, ...]) -> Optional[List[str]]:
|
|
509
|
+
"""第 5 步后置核验;通过时返回 manual 的节点名(按顺序),没有手工节点返回 None。"""
|
|
510
|
+
if _force_fail("MIHOMO_CLI_TEST_FAIL_VERIFY"):
|
|
511
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.verify_failed"), detail="MIHOMO_CLI_TEST_FAIL_VERIFY=1")
|
|
512
|
+
cfg = api.get_configs()
|
|
513
|
+
if str(cfg.get("mode", "")).lower() != new_state["mode"]:
|
|
514
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.verify_failed"), detail="mode=%s" % cfg.get("mode"))
|
|
515
|
+
if int(cfg.get("mixed-port", 0)) != int(new_state["mixed_port"]):
|
|
516
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.verify_failed"), detail="mixed-port=%s" % cfg.get("mixed-port"))
|
|
517
|
+
group = api.get_proxy("PROXY")
|
|
518
|
+
if not _tcp_ok(int(new_state["mixed_port"])):
|
|
519
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.verify_failed"), detail="mixed port not reachable")
|
|
520
|
+
|
|
521
|
+
prof = render_mod.active_profile(new_state)
|
|
522
|
+
written = render_mod.provider_names(new_state)
|
|
523
|
+
readback: Optional[List[str]] = None
|
|
524
|
+
main_names: Optional[List[str]] = None
|
|
525
|
+
for name in written:
|
|
526
|
+
names = api.provider_node_names(name)
|
|
527
|
+
count = len(names)
|
|
528
|
+
if name == "manual":
|
|
529
|
+
expected = len(prof["manual"])
|
|
530
|
+
if count != expected:
|
|
531
|
+
pending = [nodes_mod.link_summary(m["link"]) for m in prof["manual"] if m.get("name") is None]
|
|
532
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.manual_unparsable"),
|
|
533
|
+
detail="manual nodes %d != entries %d; unverified: %s"
|
|
534
|
+
% (count, expected, ", ".join(pending) or "-"))
|
|
535
|
+
readback = names
|
|
536
|
+
elif name == "main":
|
|
537
|
+
main_names = names
|
|
538
|
+
if "main" in involved:
|
|
539
|
+
if count <= 0:
|
|
540
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.main_empty"), detail="main nodes 0")
|
|
541
|
+
elif pre_counts.get("main", 0) > 0 and count <= 0:
|
|
542
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.main_empty"), detail="main nodes dropped to 0")
|
|
543
|
+
members = group.get("all", [])
|
|
544
|
+
for name in hidden_added:
|
|
545
|
+
if name in pre_members and name in members:
|
|
546
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.verify_failed"), detail="still visible: %s" % name)
|
|
547
|
+
for name in restored:
|
|
548
|
+
if main_names is not None and name in main_names and name not in members:
|
|
549
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.verify_failed"), detail="not restored: %s" % name)
|
|
550
|
+
if render_mod.expects_no_nodes(new_state, main_names):
|
|
551
|
+
ctx.say("warn", t("warn.no_nodes_blocked"))
|
|
552
|
+
return readback
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _rollback(ctx: Ctx, api: Api, old_state: Dict[str, Any], backup: str, replaced: List[str],
|
|
556
|
+
cause: BaseException) -> None:
|
|
557
|
+
"""第 6 步:恢复 config.yaml 与本次涉及的文件,再重载;仍失败则 POST /restart(仅 TUN 关闭时)。"""
|
|
558
|
+
detail = getattr(cause, "detail", "") or str(cause)
|
|
559
|
+
for final_path in replaced:
|
|
560
|
+
saved = _backup_path_for(ctx, backup, final_path)
|
|
561
|
+
try:
|
|
562
|
+
if saved:
|
|
563
|
+
# 先写临时文件再原子替换:provider 文件被内核监视,直接覆盖可能被读到半截内容。
|
|
564
|
+
tmp = final_path + ".rollback.tmp"
|
|
565
|
+
shutil.copy2(saved, tmp)
|
|
566
|
+
os.replace(tmp, final_path)
|
|
567
|
+
elif os.path.exists(final_path):
|
|
568
|
+
os.unlink(final_path)
|
|
569
|
+
except OSError as exc:
|
|
570
|
+
log.error("[reload] rollback restore failed path=%s err=%s", final_path, exc)
|
|
571
|
+
_clear_staging(ctx)
|
|
572
|
+
recovered = False
|
|
573
|
+
force_fail = _force_fail("MIHOMO_CLI_TEST_FAIL_ROLLBACK")
|
|
574
|
+
if not force_fail:
|
|
575
|
+
try:
|
|
576
|
+
api.reload(ctx.paths.config)
|
|
577
|
+
recovered = True
|
|
578
|
+
except (ApiError, ApiUnavailable) as exc:
|
|
579
|
+
log.error("[reload] rollback reload failed err=%s", exc)
|
|
580
|
+
tun_on = render_mod.tun_effective_in_config(old_state)
|
|
581
|
+
if not recovered and not force_fail and not tun_on:
|
|
582
|
+
# 进程内重启不会重跑 systemd 的 Pre / Post;TUN 开启时重启期间整机域名解析中断,所以只在 TUN 关闭时用。
|
|
583
|
+
try:
|
|
584
|
+
api.restart()
|
|
585
|
+
recovered = True
|
|
586
|
+
except (ApiError, ApiUnavailable) as exc:
|
|
587
|
+
log.error("[reload] rollback restart failed err=%s", exc)
|
|
588
|
+
if recovered:
|
|
589
|
+
try:
|
|
590
|
+
ensure_selection(ctx, api, copy.deepcopy(old_state))
|
|
591
|
+
except (ApiError, ApiUnavailable):
|
|
592
|
+
pass
|
|
593
|
+
log.warning("[reload] rolled_back backup=%s reason=%s", backup, detail[:300])
|
|
594
|
+
message = getattr(cause, "message", None) or t("err.reload_failed")
|
|
595
|
+
raise CliError(EXIT_ROLLED_BACK, message + " " + t("info.rolled_back", backup=backup), detail=detail)
|
|
596
|
+
log.error("[reload] rollback incomplete backup=%s reason=%s", backup, detail[:300])
|
|
597
|
+
raise CliError(EXIT_MANUAL, t("err.rollback_incomplete"), detail=detail,
|
|
598
|
+
hints=["mihomo-cli backup restore %s" % backup, "sudo systemctl restart mihomo-cli"])
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def commit(ctx: Ctx, old_state: Dict[str, Any], new_state: Dict[str, Any], action: str,
|
|
602
|
+
files: Optional[Dict[str, Optional[bytes]]] = None, **kwargs: Any) -> str:
|
|
603
|
+
"""内核运行时走重载事务,否则只落盘并提示启动服务。调用方持锁。"""
|
|
604
|
+
if kernel_running(ctx):
|
|
605
|
+
return reload(ctx, old_state, new_state, action, files=files, **kwargs)
|
|
606
|
+
backup = apply_offline(ctx, new_state, files or {})
|
|
607
|
+
for path in kwargs.get("cleanup_after", ()):
|
|
608
|
+
try:
|
|
609
|
+
os.unlink(path)
|
|
610
|
+
except FileNotFoundError:
|
|
611
|
+
pass
|
|
612
|
+
ctx.say("info", t("info.saved_not_running"))
|
|
613
|
+
return backup
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ====================================================================
|
|
617
|
+
# 状态概览
|
|
618
|
+
# ====================================================================
|
|
619
|
+
|
|
620
|
+
def exit_ip(port: int) -> Optional[Dict[str, Any]]:
|
|
621
|
+
"""经本实例混合端口访问外网,取出口 IP 与国家;失败返回 None。"""
|
|
622
|
+
proxy_url = "http://127.0.0.1:%d" % port
|
|
623
|
+
try:
|
|
624
|
+
try:
|
|
625
|
+
client = httpx.Client(proxy=proxy_url, timeout=EXIT_IP_TIMEOUT_SEC, trust_env=False)
|
|
626
|
+
except TypeError: # httpx < 0.26
|
|
627
|
+
client = httpx.Client(proxies=proxy_url, timeout=EXIT_IP_TIMEOUT_SEC, trust_env=False)
|
|
628
|
+
with client:
|
|
629
|
+
resp = client.get(EXIT_IP_URL)
|
|
630
|
+
if resp.status_code != 200:
|
|
631
|
+
return None
|
|
632
|
+
data = resp.json()
|
|
633
|
+
return {"ip": data.get("ip"), "country": data.get("country")}
|
|
634
|
+
except (httpx.HTTPError, ValueError):
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def status(ctx: Ctx, with_ip: bool = True) -> Dict[str, Any]:
|
|
639
|
+
state = read_state(ctx)
|
|
640
|
+
st = service_status(ctx)
|
|
641
|
+
prof = render_mod.active_profile(state)
|
|
642
|
+
info: Dict[str, Any] = {
|
|
643
|
+
"home": ctx.paths.home,
|
|
644
|
+
"service": {"installed": st.installed, "active_state": st.active_state, "main_pid": st.main_pid,
|
|
645
|
+
"enabled": st.unit_file_state},
|
|
646
|
+
"kernel_version": None,
|
|
647
|
+
"profile": state.get("active_profile"),
|
|
648
|
+
"profile_kind": prof["kind"] if prof else None,
|
|
649
|
+
"profile_source": nodes_mod.redact_url(prof.get("url")) if prof and prof.get("url") else None,
|
|
650
|
+
"node": prof.get("selected") if prof else None,
|
|
651
|
+
"mode": state["mode"],
|
|
652
|
+
"cn_route": state["cn_route"],
|
|
653
|
+
"tun": state["tun"],
|
|
654
|
+
"tun_suspended": state.get("tun_suspended"),
|
|
655
|
+
"mixed_port": state["mixed_port"],
|
|
656
|
+
"exit": None,
|
|
657
|
+
"running": st.running,
|
|
658
|
+
}
|
|
659
|
+
if st.running and os.path.exists(ctx.paths.sock):
|
|
660
|
+
try:
|
|
661
|
+
with ctx.api() as api:
|
|
662
|
+
info["kernel_version"] = api.version().get("version")
|
|
663
|
+
info["node"] = api.get_proxy("PROXY").get("now")
|
|
664
|
+
info["mode"] = str(api.get_configs().get("mode", state["mode"])).lower()
|
|
665
|
+
except (ApiError, ApiUnavailable):
|
|
666
|
+
pass
|
|
667
|
+
if with_ip:
|
|
668
|
+
info["exit"] = exit_ip(int(state["mixed_port"]))
|
|
669
|
+
return info
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# ====================================================================
|
|
673
|
+
# 安装 / 卸载 / 内核
|
|
674
|
+
# ====================================================================
|
|
675
|
+
|
|
676
|
+
def install(ctx: Ctx, core_version: Optional[str], mirror: Optional[str], port: Optional[int]) -> Dict[str, Any]:
|
|
677
|
+
version = normalize_version_or_usage(core_version)
|
|
678
|
+
mirror_n = mirror_normalize(mirror) if mirror is not None else None
|
|
679
|
+
home = ctx.paths.home
|
|
680
|
+
if any(ch.isspace() for ch in home):
|
|
681
|
+
raise CliError(EXIT_USAGE, t("err.home_space", home=home))
|
|
682
|
+
if port is not None and not (1 <= port <= 65535):
|
|
683
|
+
raise CliError(EXIT_USAGE, t("err.bad_port", port=port))
|
|
684
|
+
ensure_home(ctx.paths)
|
|
685
|
+
before = service_status(ctx)
|
|
686
|
+
result: Dict[str, Any] = {"home": home}
|
|
687
|
+
with locked(ctx):
|
|
688
|
+
state = read_state(ctx)
|
|
689
|
+
if mirror is not None:
|
|
690
|
+
state["mirror"] = mirror_n
|
|
691
|
+
if port is not None:
|
|
692
|
+
state["mixed_port"] = port
|
|
693
|
+
conflicts = port_conflicts(ctx, int(state["mixed_port"]))
|
|
694
|
+
if conflicts:
|
|
695
|
+
raise CliError(EXIT_ERROR, t("err.port_in_use", port=state["mixed_port"]), detail="; ".join(conflicts),
|
|
696
|
+
hints=["mihomo-cli install --port <N>"])
|
|
697
|
+
base = render_mod.site_base(state)
|
|
698
|
+
if version is not None or not os.path.exists(ctx.paths.core):
|
|
699
|
+
target = version or _latest(base)
|
|
700
|
+
ctx.say("info", t("info.downloading_core", version=target))
|
|
701
|
+
warning = _install_core(ctx, base, target)
|
|
702
|
+
if warning:
|
|
703
|
+
ctx.say("warn", t("warn.digest_unavailable"))
|
|
704
|
+
result["core_version"] = target
|
|
705
|
+
missing_geo = [n for n in core_dl.GEODATA_FILES if core_dl.geodata_path(home, n) is None]
|
|
706
|
+
if missing_geo:
|
|
707
|
+
ctx.say("info", t("info.downloading_geodata"))
|
|
708
|
+
try:
|
|
709
|
+
core_dl.install_geodata(home, base)
|
|
710
|
+
except core_dl.DownloadError as exc:
|
|
711
|
+
raise CliError(EXIT_ERROR, t("err.download_failed"), detail=str(exc)) from exc
|
|
712
|
+
save_state(ctx.paths, state)
|
|
713
|
+
write_config(ctx, state)
|
|
714
|
+
# 调 systemctl enable --now 之前必须已释放锁(ExecStartPre 要拿它)。
|
|
715
|
+
ctx.say("info", t("info.sudo_install"))
|
|
716
|
+
user = getpass.getuser()
|
|
717
|
+
try:
|
|
718
|
+
ctx.platform.install_service(home, user, ctx.python_exe)
|
|
719
|
+
if before.running and port is not None:
|
|
720
|
+
ctx.platform.service_action("restart")
|
|
721
|
+
except PlatformError as exc:
|
|
722
|
+
raise CliError(EXIT_SUDO, t("err.service_failed"), detail=str(exc)) from exc
|
|
723
|
+
log.info("[install] home=%s user=%s", home, user)
|
|
724
|
+
result["service"] = service_status(ctx).active_state
|
|
725
|
+
return result
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _latest(base: str) -> str:
|
|
729
|
+
try:
|
|
730
|
+
return core_dl.latest_version(base)
|
|
731
|
+
except core_dl.DownloadError as exc:
|
|
732
|
+
raise CliError(EXIT_ERROR, t("err.download_failed"), detail=str(exc)) from exc
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _install_core(ctx: Ctx, base: str, version: str) -> Optional[str]:
|
|
736
|
+
try:
|
|
737
|
+
asset = ctx.platform.core_asset_name(version)
|
|
738
|
+
return core_dl.install_core(ctx.paths.core, ctx.paths.staging, asset, base, version)
|
|
739
|
+
except core_dl.DownloadError as exc:
|
|
740
|
+
raise CliError(EXIT_ERROR, t("err.download_failed"), detail=str(exc)) from exc
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def uninstall(ctx: Ctx, purge: bool) -> Dict[str, Any]:
|
|
744
|
+
ctx.say("info", t("info.sudo_uninstall"))
|
|
745
|
+
try:
|
|
746
|
+
# 停止时 ExecStopPost 会还原 DNS 接管。
|
|
747
|
+
ctx.platform.uninstall_service()
|
|
748
|
+
except PlatformError as exc:
|
|
749
|
+
raise CliError(EXIT_SUDO, t("err.service_failed"), detail=str(exc)) from exc
|
|
750
|
+
if purge and os.path.isdir(ctx.paths.home):
|
|
751
|
+
shutil.rmtree(ctx.paths.home)
|
|
752
|
+
log.info("[install] uninstall purge=%s", purge)
|
|
753
|
+
return {"purged": purge}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def core_upgrade(ctx: Ctx, version: Optional[str], mirror: Optional[str]) -> Dict[str, Any]:
|
|
757
|
+
version_n = normalize_version_or_usage(version)
|
|
758
|
+
with locked(ctx):
|
|
759
|
+
state = read_state(ctx)
|
|
760
|
+
if mirror is not None:
|
|
761
|
+
state["mirror"] = mirror_normalize(mirror)
|
|
762
|
+
save_state(ctx.paths, state)
|
|
763
|
+
# 镜像变化后与切模式一样原子重写 config.yaml(geox-url 跟着变),使 D04 一致。
|
|
764
|
+
write_config(ctx, state)
|
|
765
|
+
base = render_mod.site_base(state)
|
|
766
|
+
target = version_n or _latest(base)
|
|
767
|
+
old, _ = core_dl.core_version(ctx.paths.core)
|
|
768
|
+
ctx.say("info", t("info.downloading_core", version=target))
|
|
769
|
+
warning = _install_core(ctx, base, target)
|
|
770
|
+
if warning:
|
|
771
|
+
ctx.say("warn", t("warn.digest_unavailable"))
|
|
772
|
+
log.info("[install] core upgrade from=%s to=%s", old, target)
|
|
773
|
+
if service_status(ctx).running:
|
|
774
|
+
ctx.say("info", t("info.restart_to_apply"))
|
|
775
|
+
return {"from": old, "to": target}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# ====================================================================
|
|
779
|
+
# 服务
|
|
780
|
+
# ====================================================================
|
|
781
|
+
|
|
782
|
+
def service_control(ctx: Ctx, action: str) -> Dict[str, Any]:
|
|
783
|
+
st = service_status(ctx)
|
|
784
|
+
if not st.installed:
|
|
785
|
+
raise CliError(EXIT_ERROR, t("err.not_installed"), hints=["mihomo-cli install"])
|
|
786
|
+
try:
|
|
787
|
+
# 本函数不持 state.lock:systemctl start 会同步等 ExecStartPre 拿锁。
|
|
788
|
+
ctx.platform.service_action(action)
|
|
789
|
+
except PlatformError as exc:
|
|
790
|
+
raise CliError(EXIT_SUDO, t("err.service_failed"), detail=str(exc)) from exc
|
|
791
|
+
log.info("[switch] service action=%s", action)
|
|
792
|
+
return {"action": action, "active_state": service_status(ctx).active_state}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ====================================================================
|
|
796
|
+
# 代理服务
|
|
797
|
+
# ====================================================================
|
|
798
|
+
|
|
799
|
+
def _check_profile_name(name: str) -> None:
|
|
800
|
+
if not PROFILE_NAME_RE.match(name or ""):
|
|
801
|
+
raise CliError(EXIT_USAGE, t("err.bad_profile_name", name=name))
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def profile_list(ctx: Ctx) -> List[Dict[str, Any]]:
|
|
805
|
+
state = read_state(ctx)
|
|
806
|
+
out = []
|
|
807
|
+
for name, prof in state["profiles"].items():
|
|
808
|
+
out.append({
|
|
809
|
+
"name": name, "kind": prof["kind"], "active": name == state.get("active_profile"),
|
|
810
|
+
"source": nodes_mod.redact_url(prof.get("url")) if prof.get("url") else prof.get("file"),
|
|
811
|
+
"manual": len(prof["manual"]), "hidden": len(prof["hidden"]), "selected": prof.get("selected"),
|
|
812
|
+
})
|
|
813
|
+
return out
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def profile_show(ctx: Ctx, name: Optional[str], reveal: bool) -> Dict[str, Any]:
|
|
817
|
+
state = read_state(ctx)
|
|
818
|
+
if name is None:
|
|
819
|
+
name, _ = require_active(state)
|
|
820
|
+
prof = copy.deepcopy(require_profile(state, name))
|
|
821
|
+
if prof.get("url") and not reveal:
|
|
822
|
+
prof["url"] = nodes_mod.redact_url(prof["url"])
|
|
823
|
+
if not reveal:
|
|
824
|
+
for m in prof["manual"]:
|
|
825
|
+
m["link"] = nodes_mod.link_summary(m["link"])
|
|
826
|
+
prof["name"] = name
|
|
827
|
+
prof["active"] = name == state.get("active_profile")
|
|
828
|
+
return prof
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def profile_add(ctx: Ctx, name: str, url: Optional[str] = None, file: Optional[str] = None,
|
|
832
|
+
node_links: Tuple[str, ...] = (), interval: Optional[int] = None,
|
|
833
|
+
user_agent: Optional[str] = None) -> Dict[str, Any]:
|
|
834
|
+
_check_profile_name(name)
|
|
835
|
+
if url and file:
|
|
836
|
+
raise CliError(EXIT_USAGE, t("err.url_and_file"))
|
|
837
|
+
if url and not (url.startswith("http://") or url.startswith("https://")):
|
|
838
|
+
raise CliError(EXIT_USAGE, t("err.bad_url"))
|
|
839
|
+
file_bytes: Optional[bytes] = None
|
|
840
|
+
if file:
|
|
841
|
+
try:
|
|
842
|
+
with open(os.path.expanduser(file), "rb") as fh:
|
|
843
|
+
file_bytes = fh.read()
|
|
844
|
+
except OSError as exc:
|
|
845
|
+
raise CliError(EXIT_ERROR, t("err.read_file", path=file), detail=str(exc)) from exc
|
|
846
|
+
links = _validate_links(node_links)
|
|
847
|
+
kind = "subscription" if url else ("file" if file else "nodes")
|
|
848
|
+
ensure_home(ctx.paths)
|
|
849
|
+
check_home(ctx)
|
|
850
|
+
with locked(ctx):
|
|
851
|
+
state = read_state(ctx)
|
|
852
|
+
if name in state["profiles"]:
|
|
853
|
+
raise CliError(EXIT_ERROR, t("err.profile_exists", name=name))
|
|
854
|
+
prof = new_profile(kind, url=url, file=os.path.abspath(os.path.expanduser(file)) if file else None,
|
|
855
|
+
interval=interval or 86400, user_agent=user_agent)
|
|
856
|
+
prof["manual"] = [{"link": link, "name": None} for link in links]
|
|
857
|
+
with_profile = copy.deepcopy(state)
|
|
858
|
+
with_profile["profiles"][name] = prof
|
|
859
|
+
files: Dict[str, Optional[bytes]] = {}
|
|
860
|
+
if file_bytes is not None:
|
|
861
|
+
files[ctx.paths.provider_file(name)] = file_bytes
|
|
862
|
+
if prof["manual"]:
|
|
863
|
+
files[ctx.paths.nodes_file(name)] = manual_file_content(prof)
|
|
864
|
+
if state.get("active_profile"):
|
|
865
|
+
# 非当前代理服务:不进内核,直接落盘。
|
|
866
|
+
_write_files_direct(ctx, files)
|
|
867
|
+
save_state(ctx.paths, with_profile)
|
|
868
|
+
log.info("[switch] profile add name=%s kind=%s", name, kind)
|
|
869
|
+
return {"name": name, "kind": kind, "active": False}
|
|
870
|
+
# 首个代理服务自动启用。
|
|
871
|
+
new_state = copy.deepcopy(with_profile)
|
|
872
|
+
new_state["active_profile"] = name
|
|
873
|
+
try:
|
|
874
|
+
commit(ctx, state, new_state, "profile-add", files=files, involved=("main", "manual"))
|
|
875
|
+
except CliError as exc:
|
|
876
|
+
if exc.code != EXIT_ROLLED_BACK:
|
|
877
|
+
raise
|
|
878
|
+
# 回滚后保留代理服务、不启用,提示修正后再 use(方案 5.1.5 动作表)。
|
|
879
|
+
_write_files_direct(ctx, files)
|
|
880
|
+
save_state(ctx.paths, with_profile)
|
|
881
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.added_not_enabled", name=name), detail=exc.detail,
|
|
882
|
+
hints=["mihomo-cli profile use %s" % name]) from None
|
|
883
|
+
log.info("[switch] profile add name=%s kind=%s active=true", name, kind)
|
|
884
|
+
return {"name": name, "kind": kind, "active": True}
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _write_files_direct(ctx: Ctx, files: Dict[str, Optional[bytes]]) -> None:
|
|
888
|
+
for path, content in files.items():
|
|
889
|
+
if content is None:
|
|
890
|
+
continue
|
|
891
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
892
|
+
with open(path + ".tmp", "wb") as fh:
|
|
893
|
+
fh.write(content)
|
|
894
|
+
os.replace(path + ".tmp", path)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def profile_rm(ctx: Ctx, name: str) -> None:
|
|
898
|
+
with locked(ctx):
|
|
899
|
+
state = read_state(ctx)
|
|
900
|
+
require_profile(state, name)
|
|
901
|
+
if state.get("active_profile") == name:
|
|
902
|
+
raise CliError(EXIT_ERROR, t("err.rm_active", name=name))
|
|
903
|
+
del state["profiles"][name]
|
|
904
|
+
save_state(ctx.paths, state)
|
|
905
|
+
for path in (ctx.paths.provider_file(name), ctx.paths.nodes_file(name)):
|
|
906
|
+
try:
|
|
907
|
+
os.unlink(path)
|
|
908
|
+
except FileNotFoundError:
|
|
909
|
+
pass
|
|
910
|
+
log.info("[switch] profile rm name=%s", name)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def profile_use(ctx: Ctx, name: str) -> Dict[str, Any]:
|
|
914
|
+
check_home(ctx)
|
|
915
|
+
with locked(ctx):
|
|
916
|
+
state = read_state(ctx)
|
|
917
|
+
prof = require_profile(state, name)
|
|
918
|
+
old_name = state.get("active_profile")
|
|
919
|
+
new_state = copy.deepcopy(state)
|
|
920
|
+
new_state["active_profile"] = name
|
|
921
|
+
files = {ctx.paths.nodes_file(name): manual_file_content(prof)}
|
|
922
|
+
commit(ctx, state, new_state, "profile-use", files=files, involved=("main", "manual"))
|
|
923
|
+
log.info("[switch] profile old=%s new=%s", old_name, name)
|
|
924
|
+
return {"profile": name}
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def profile_update(ctx: Ctx, name: Optional[str]) -> Dict[str, Any]:
|
|
928
|
+
state = read_state(ctx)
|
|
929
|
+
active, prof = require_active(state)
|
|
930
|
+
name = name or active
|
|
931
|
+
require_profile(state, name)
|
|
932
|
+
if name != active:
|
|
933
|
+
raise CliError(EXIT_ERROR, t("err.update_inactive", name=name))
|
|
934
|
+
if prof["kind"] == "nodes":
|
|
935
|
+
ctx.say("info", t("info.nothing_to_update"))
|
|
936
|
+
return {"profile": name, "updated": False}
|
|
937
|
+
if not kernel_running(ctx):
|
|
938
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_not_running"), hints=["mihomo-cli service start"])
|
|
939
|
+
with ctx.api() as api:
|
|
940
|
+
try:
|
|
941
|
+
_api_call(lambda: api.update_provider("main"))
|
|
942
|
+
except ApiError as exc:
|
|
943
|
+
# 更新失败时内核保留旧节点、不覆盖缓存(component/resource/fetcher.go:85-91)。
|
|
944
|
+
raise CliError(EXIT_ERROR, t("err.update_failed"), detail=exc.message) from exc
|
|
945
|
+
count = len(api.provider_node_names("main"))
|
|
946
|
+
log.info("[switch] profile update name=%s nodes=%d", name, count)
|
|
947
|
+
return {"profile": name, "updated": True, "nodes": count}
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def profile_rename(ctx: Ctx, old: str, new: str) -> Dict[str, Any]:
|
|
951
|
+
_check_profile_name(new)
|
|
952
|
+
with locked(ctx):
|
|
953
|
+
state = read_state(ctx)
|
|
954
|
+
prof = require_profile(state, old)
|
|
955
|
+
if new in state["profiles"]:
|
|
956
|
+
raise CliError(EXIT_ERROR, t("err.profile_exists", name=new))
|
|
957
|
+
new_state = copy.deepcopy(state)
|
|
958
|
+
new_state["profiles"] = {(new if k == old else k): v for k, v in new_state["profiles"].items()}
|
|
959
|
+
old_files = [ctx.paths.provider_file(old), ctx.paths.nodes_file(old)]
|
|
960
|
+
if state.get("active_profile") != old:
|
|
961
|
+
save_state(ctx.paths, new_state)
|
|
962
|
+
for src in old_files:
|
|
963
|
+
if os.path.exists(src):
|
|
964
|
+
dst = src.replace(os.sep + old + ".", os.sep + new + ".")
|
|
965
|
+
os.replace(src, dst)
|
|
966
|
+
log.info("[switch] profile rename old=%s new=%s", old, new)
|
|
967
|
+
return {"old": old, "new": new}
|
|
968
|
+
# 当前代理服务改名:把旧名的订阅缓存 / 本地文件与节点文件复制成新名一起放进事务,
|
|
969
|
+
# 避免新路径无缓存时内核去远端重拉(方案 5.1.5 动作表)。
|
|
970
|
+
new_state["active_profile"] = new
|
|
971
|
+
files: Dict[str, Optional[bytes]] = {}
|
|
972
|
+
if os.path.exists(ctx.paths.provider_file(old)):
|
|
973
|
+
with open(ctx.paths.provider_file(old), "rb") as fh:
|
|
974
|
+
files[ctx.paths.provider_file(new)] = fh.read()
|
|
975
|
+
if prof["manual"]:
|
|
976
|
+
files[ctx.paths.nodes_file(new)] = manual_file_content(prof)
|
|
977
|
+
commit(ctx, state, new_state, "profile-rename", files=files, cleanup_after=tuple(old_files))
|
|
978
|
+
log.info("[switch] profile rename old=%s new=%s", old, new)
|
|
979
|
+
return {"old": old, "new": new}
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
# ====================================================================
|
|
983
|
+
# 节点
|
|
984
|
+
# ====================================================================
|
|
985
|
+
|
|
986
|
+
def _validate_links(links: Tuple[str, ...]) -> List[str]:
|
|
987
|
+
out = []
|
|
988
|
+
for link in links:
|
|
989
|
+
link = link.strip()
|
|
990
|
+
if not link:
|
|
991
|
+
continue
|
|
992
|
+
if "://" not in link:
|
|
993
|
+
raise CliError(EXIT_USAGE, t("err.bad_link", link=nodes_mod.link_summary(link)))
|
|
994
|
+
out.append(link)
|
|
995
|
+
return out
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def node_list(ctx: Ctx, with_delay: bool = False) -> Dict[str, Any]:
|
|
999
|
+
state = read_state(ctx)
|
|
1000
|
+
name, prof = require_active(state)
|
|
1001
|
+
result: Dict[str, Any] = {"profile": name, "now": prof.get("selected"), "nodes": [], "hidden": prof["hidden"],
|
|
1002
|
+
"pending": [], "running": False}
|
|
1003
|
+
for idx, m in enumerate(prof["manual"], 1):
|
|
1004
|
+
if m.get("name") is None:
|
|
1005
|
+
result["pending"].append({"index": idx, "summary": nodes_mod.link_summary(m["link"])})
|
|
1006
|
+
if not kernel_running(ctx):
|
|
1007
|
+
result["nodes"] = [{"name": m["name"], "source": "manual", "index": i}
|
|
1008
|
+
for i, m in enumerate(prof["manual"], 1) if m.get("name")]
|
|
1009
|
+
return result
|
|
1010
|
+
result["running"] = True
|
|
1011
|
+
with ctx.api() as api:
|
|
1012
|
+
group = _api_call(lambda: api.get_proxy("PROXY"))
|
|
1013
|
+
manual = set(api.provider_node_names("manual")) if prof["manual"] else set()
|
|
1014
|
+
delays: Dict[str, int] = {}
|
|
1015
|
+
if with_delay:
|
|
1016
|
+
try:
|
|
1017
|
+
delays = api.group_delay("PROXY")
|
|
1018
|
+
except ApiError:
|
|
1019
|
+
delays = {}
|
|
1020
|
+
result["now"] = group.get("now")
|
|
1021
|
+
index_of = {m.get("name"): i for i, m in enumerate(prof["manual"], 1)}
|
|
1022
|
+
for member in group.get("all", []):
|
|
1023
|
+
if member == "REJECT":
|
|
1024
|
+
continue
|
|
1025
|
+
item: Dict[str, Any] = {"name": member, "source": "manual" if member in manual else "subscription"}
|
|
1026
|
+
if member in manual:
|
|
1027
|
+
item["index"] = index_of.get(member)
|
|
1028
|
+
if with_delay:
|
|
1029
|
+
item["delay"] = delays.get(member, 0)
|
|
1030
|
+
result["nodes"].append(item)
|
|
1031
|
+
return result
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def node_use(ctx: Ctx, name: str) -> Dict[str, Any]:
|
|
1035
|
+
with locked(ctx):
|
|
1036
|
+
state = read_state(ctx)
|
|
1037
|
+
_, prof = require_active(state)
|
|
1038
|
+
old = prof.get("selected")
|
|
1039
|
+
if name == "REJECT":
|
|
1040
|
+
raise CliError(EXIT_USAGE, t("err.no_such_node", name=name))
|
|
1041
|
+
if kernel_running(ctx):
|
|
1042
|
+
with ctx.api() as api:
|
|
1043
|
+
members = _api_call(lambda: api.get_proxy("PROXY")).get("all", [])
|
|
1044
|
+
if name not in members:
|
|
1045
|
+
raise CliError(EXIT_ERROR, t("err.no_such_node", name=name))
|
|
1046
|
+
try:
|
|
1047
|
+
api.select("PROXY", name)
|
|
1048
|
+
except ApiError as exc:
|
|
1049
|
+
raise CliError(EXIT_ERROR, t("err.select_failed"), detail=exc.message) from exc
|
|
1050
|
+
else:
|
|
1051
|
+
ctx.say("info", t("info.saved_not_running"))
|
|
1052
|
+
prof["selected"] = name
|
|
1053
|
+
save_state(ctx.paths, state)
|
|
1054
|
+
log.info("[switch] node old=%s new=%s", old, name)
|
|
1055
|
+
return {"old": old, "new": name}
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def node_add(ctx: Ctx, links: Tuple[str, ...] = (), file: Optional[str] = None) -> Dict[str, Any]:
|
|
1059
|
+
all_links = list(links)
|
|
1060
|
+
if file:
|
|
1061
|
+
try:
|
|
1062
|
+
with open(os.path.expanduser(file), encoding="utf-8") as fh:
|
|
1063
|
+
all_links += nodes_mod.split_links(fh.read())
|
|
1064
|
+
except OSError as exc:
|
|
1065
|
+
raise CliError(EXIT_ERROR, t("err.read_file", path=file), detail=str(exc)) from exc
|
|
1066
|
+
valid = _validate_links(tuple(all_links))
|
|
1067
|
+
if not valid:
|
|
1068
|
+
raise CliError(EXIT_USAGE, t("err.no_links"))
|
|
1069
|
+
check_home(ctx)
|
|
1070
|
+
with locked(ctx):
|
|
1071
|
+
state = read_state(ctx)
|
|
1072
|
+
name, prof = require_active(state)
|
|
1073
|
+
new_state = copy.deepcopy(state)
|
|
1074
|
+
new_prof = new_state["profiles"][name]
|
|
1075
|
+
new_prof["manual"] += [{"link": link, "name": None} for link in valid]
|
|
1076
|
+
files = {ctx.paths.nodes_file(name): manual_file_content(new_prof)}
|
|
1077
|
+
commit(ctx, state, new_state, "node-add", files=files, involved=("manual",))
|
|
1078
|
+
# 从落盘后的 state 取:在线时是回读后的名字,离线时为 None(待内核启动后回读)。
|
|
1079
|
+
# 不能用传进 commit 的旧 state——离线路径不会更新它。
|
|
1080
|
+
saved = read_state(ctx)["profiles"][name]["manual"]
|
|
1081
|
+
added = [m.get("name") for m in saved[-len(valid):]]
|
|
1082
|
+
log.info("[switch] node add profile=%s count=%d", name, len(valid))
|
|
1083
|
+
return {"profile": name, "added": added}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _find_manual(prof: Dict[str, Any], target: str) -> Optional[int]:
|
|
1087
|
+
for i, m in enumerate(prof["manual"]):
|
|
1088
|
+
if m.get("name") == target:
|
|
1089
|
+
return i
|
|
1090
|
+
if target.isdigit():
|
|
1091
|
+
idx = int(target) - 1
|
|
1092
|
+
if 0 <= idx < len(prof["manual"]):
|
|
1093
|
+
return idx
|
|
1094
|
+
return None
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def node_rm(ctx: Ctx, target: str) -> Dict[str, Any]:
|
|
1098
|
+
"""手工节点直接删除;订阅节点改为隐藏(订阅每次更新都会回来)。target 可为节点名或手工条目序号。"""
|
|
1099
|
+
with locked(ctx):
|
|
1100
|
+
state = read_state(ctx)
|
|
1101
|
+
name, prof = require_active(state)
|
|
1102
|
+
idx = _find_manual(prof, target)
|
|
1103
|
+
new_state = copy.deepcopy(state)
|
|
1104
|
+
new_prof = new_state["profiles"][name]
|
|
1105
|
+
if idx is not None:
|
|
1106
|
+
removed = new_prof["manual"].pop(idx)
|
|
1107
|
+
if removed.get("name") and new_prof.get("selected") == removed["name"]:
|
|
1108
|
+
# 删的正是选中条目:清空,交给 ensure_selection 补选。
|
|
1109
|
+
new_prof["selected"] = None
|
|
1110
|
+
files = {ctx.paths.nodes_file(name): manual_file_content(new_prof)}
|
|
1111
|
+
commit(ctx, state, new_state, "node-rm", files=files, involved=("manual",))
|
|
1112
|
+
log.info("[switch] node rm profile=%s name=%s", name, removed.get("name"))
|
|
1113
|
+
return {"removed": removed.get("name") or nodes_mod.link_summary(removed["link"]), "hidden": False}
|
|
1114
|
+
if prof["kind"] == "nodes":
|
|
1115
|
+
raise CliError(EXIT_ERROR, t("err.no_such_node", name=target))
|
|
1116
|
+
try:
|
|
1117
|
+
nodes_mod.hidden_filter([target])
|
|
1118
|
+
except nodes_mod.NodeNameError:
|
|
1119
|
+
raise CliError(EXIT_USAGE, t("err.cannot_hide", name=target)) from None
|
|
1120
|
+
if target in prof["hidden"]:
|
|
1121
|
+
raise CliError(EXIT_ERROR, t("err.already_hidden", name=target))
|
|
1122
|
+
if kernel_running(ctx):
|
|
1123
|
+
with ctx.api() as api:
|
|
1124
|
+
if target not in api.provider_node_names("main"):
|
|
1125
|
+
raise CliError(EXIT_ERROR, t("err.no_such_node", name=target))
|
|
1126
|
+
new_prof["hidden"].append(target)
|
|
1127
|
+
if new_prof.get("selected") == target:
|
|
1128
|
+
new_prof["selected"] = None
|
|
1129
|
+
commit(ctx, state, new_state, "node-hide", hidden_added=(target,))
|
|
1130
|
+
ctx.say("info", t("info.hidden_instead"))
|
|
1131
|
+
log.info("[switch] node hide profile=%s name=%s", name, target)
|
|
1132
|
+
return {"removed": target, "hidden": True}
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def node_restore(ctx: Ctx, target: str) -> Dict[str, Any]:
|
|
1136
|
+
with locked(ctx):
|
|
1137
|
+
state = read_state(ctx)
|
|
1138
|
+
name, prof = require_active(state)
|
|
1139
|
+
if target not in prof["hidden"]:
|
|
1140
|
+
raise CliError(EXIT_ERROR, t("err.not_hidden", name=target))
|
|
1141
|
+
new_state = copy.deepcopy(state)
|
|
1142
|
+
new_state["profiles"][name]["hidden"].remove(target)
|
|
1143
|
+
commit(ctx, state, new_state, "node-restore", restored=(target,))
|
|
1144
|
+
log.info("[switch] node restore profile=%s name=%s", name, target)
|
|
1145
|
+
return {"restored": target}
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def node_test(ctx: Ctx, name: Optional[str], url: str, timeout_ms: int) -> Dict[str, int]:
|
|
1149
|
+
if not kernel_running(ctx):
|
|
1150
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_not_running"), hints=["mihomo-cli service start"])
|
|
1151
|
+
with ctx.api() as api:
|
|
1152
|
+
if name:
|
|
1153
|
+
try:
|
|
1154
|
+
return {name: _api_call(lambda: api.proxy_delay(name, url, timeout_ms))}
|
|
1155
|
+
except ApiError as exc:
|
|
1156
|
+
if exc.status == 404:
|
|
1157
|
+
raise CliError(EXIT_ERROR, t("err.no_such_node", name=name)) from exc
|
|
1158
|
+
return {name: 0}
|
|
1159
|
+
try:
|
|
1160
|
+
result = _api_call(lambda: api.group_delay("PROXY", url, timeout_ms))
|
|
1161
|
+
except ApiError as exc:
|
|
1162
|
+
raise CliError(EXIT_ERROR, t("err.delay_failed"), detail=exc.message) from exc
|
|
1163
|
+
return {k: v for k, v in result.items() if k != "REJECT"}
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def node_auto(ctx: Ctx, url: str, timeout_ms: int) -> Dict[str, Any]:
|
|
1167
|
+
delays = node_test(ctx, None, url, timeout_ms)
|
|
1168
|
+
alive = {k: v for k, v in delays.items() if v > 0}
|
|
1169
|
+
if not alive:
|
|
1170
|
+
raise CliError(EXIT_ERROR, t("err.no_alive_node"))
|
|
1171
|
+
best = min(alive, key=lambda k: alive[k])
|
|
1172
|
+
node_use(ctx, best)
|
|
1173
|
+
return {"node": best, "delay": alive[best]}
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
# ====================================================================
|
|
1177
|
+
# 模式与国内开关
|
|
1178
|
+
# ====================================================================
|
|
1179
|
+
|
|
1180
|
+
def mode_get(ctx: Ctx) -> Dict[str, Any]:
|
|
1181
|
+
return {"mode": read_state(ctx)["mode"]}
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def mode_set(ctx: Ctx, mode: str) -> Dict[str, Any]:
|
|
1185
|
+
if mode not in ("global", "rule", "direct"):
|
|
1186
|
+
raise CliError(EXIT_USAGE, t("err.bad_mode", mode=mode))
|
|
1187
|
+
with locked(ctx):
|
|
1188
|
+
state = read_state(ctx)
|
|
1189
|
+
old = state["mode"]
|
|
1190
|
+
if kernel_running(ctx):
|
|
1191
|
+
with ctx.api() as api:
|
|
1192
|
+
try:
|
|
1193
|
+
# PATCH 只改运行时,不重启、不断网(hub/route/configs.go:356-357)。
|
|
1194
|
+
_api_call(lambda: api.patch_configs({"mode": mode}))
|
|
1195
|
+
except ApiError as exc:
|
|
1196
|
+
raise CliError(EXIT_ERROR, t("err.mode_failed"), detail=exc.message) from exc
|
|
1197
|
+
else:
|
|
1198
|
+
ctx.say("info", t("info.saved_not_running"))
|
|
1199
|
+
state["mode"] = mode
|
|
1200
|
+
save_state(ctx.paths, state)
|
|
1201
|
+
# 同时重写 config.yaml(不重载),保证重启后仍是新模式。
|
|
1202
|
+
write_config(ctx, state)
|
|
1203
|
+
log.info("[switch] mode old=%s new=%s", old, mode)
|
|
1204
|
+
return {"old": old, "new": mode}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def cn_get(ctx: Ctx) -> Dict[str, Any]:
|
|
1208
|
+
return {"cn_route": read_state(ctx)["cn_route"]}
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def cn_set(ctx: Ctx, route: str) -> Dict[str, Any]:
|
|
1212
|
+
if route not in ("direct", "proxy"):
|
|
1213
|
+
raise CliError(EXIT_USAGE, t("err.bad_cn", value=route))
|
|
1214
|
+
with locked(ctx):
|
|
1215
|
+
state = read_state(ctx)
|
|
1216
|
+
old = state["cn_route"]
|
|
1217
|
+
if kernel_running(ctx):
|
|
1218
|
+
with ctx.api() as api:
|
|
1219
|
+
try:
|
|
1220
|
+
# 国内规则指向 CN 组,切换只改组选择,不改规则、不重启(方案 5.1.4)。
|
|
1221
|
+
_api_call(lambda: api.select("CN", "DIRECT" if route == "direct" else "PROXY"))
|
|
1222
|
+
except ApiError as exc:
|
|
1223
|
+
raise CliError(EXIT_ERROR, t("err.select_failed"), detail=exc.message) from exc
|
|
1224
|
+
else:
|
|
1225
|
+
ctx.say("info", t("info.saved_not_running"))
|
|
1226
|
+
state["cn_route"] = route
|
|
1227
|
+
save_state(ctx.paths, state)
|
|
1228
|
+
log.info("[switch] cn old=%s new=%s", old, route)
|
|
1229
|
+
return {"old": old, "new": route}
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
# ====================================================================
|
|
1233
|
+
# TUN(方案 5.1.8)
|
|
1234
|
+
# ====================================================================
|
|
1235
|
+
|
|
1236
|
+
def _preserve_env() -> List[str]:
|
|
1237
|
+
return [TEST_ENV_DNS_REVERT] if os.environ.get(TEST_ENV_DNS_REVERT) else []
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _dns_apply(ctx: Ctx) -> bool:
|
|
1241
|
+
return ctx.platform.run_self_as_root(ctx.paths.home, ctx.python_exe, "_tun-dns-apply", _preserve_env()) == 0
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _dns_revert(ctx: Ctx) -> bool:
|
|
1245
|
+
return ctx.platform.run_self_as_root(ctx.paths.home, ctx.python_exe, "_tun-dns-revert", _preserve_env()) == 0
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def manual_dns_cleanup_hints() -> List[str]:
|
|
1249
|
+
return ["sudo resolvectl revert <iface>",
|
|
1250
|
+
"sudo rm -f /run/systemd/resolved.conf.d/mihomo-cli.conf /run/mihomo-cli/dns.state",
|
|
1251
|
+
"sudo systemctl restart systemd-resolved"]
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _tun_precheck(ctx: Ctx) -> None:
|
|
1255
|
+
reason = ctx.platform.tun_supported()
|
|
1256
|
+
if reason is None:
|
|
1257
|
+
_, out = core_dl.core_version(ctx.paths.core)
|
|
1258
|
+
# mixed 栈的 UDP 依赖 gvisor;官方发布版带 with_gvisor 标签。
|
|
1259
|
+
if out and "with_gvisor" not in out:
|
|
1260
|
+
reason = "kernel built without gvisor"
|
|
1261
|
+
if reason:
|
|
1262
|
+
raise CliError(EXIT_ERROR, t("err.tun_unsupported"), detail=reason)
|
|
1263
|
+
conflicts = ctx.platform.tun_conflicts()
|
|
1264
|
+
if conflicts:
|
|
1265
|
+
raise CliError(EXIT_ERROR, t("err.tun_conflict"), detail="; ".join(conflicts))
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def tun_on(ctx: Ctx, d13: Optional[Callable[[Ctx], bool]] = None) -> Dict[str, Any]:
|
|
1269
|
+
st = service_status(ctx)
|
|
1270
|
+
running = kernel_running(ctx, st)
|
|
1271
|
+
state = read_state(ctx)
|
|
1272
|
+
if state["tun"] and not state.get("tun_suspended"):
|
|
1273
|
+
ctx.say("info", t("info.tun_already_on"))
|
|
1274
|
+
if running and d13 is not None and not d13(ctx):
|
|
1275
|
+
ctx.say("warn", t("warn.run_doctor_fix"))
|
|
1276
|
+
return {"tun": True, "changed": False}
|
|
1277
|
+
if running and not ctx.platform.sudo_validate():
|
|
1278
|
+
raise CliError(EXIT_SUDO, t("err.sudo_failed"))
|
|
1279
|
+
_tun_precheck(ctx)
|
|
1280
|
+
with locked(ctx):
|
|
1281
|
+
state = read_state(ctx)
|
|
1282
|
+
new_state = copy.deepcopy(state)
|
|
1283
|
+
new_state["tun"] = True
|
|
1284
|
+
new_state["tun_suspended"] = None
|
|
1285
|
+
if not kernel_running(ctx):
|
|
1286
|
+
commit(ctx, state, new_state, "tun-on")
|
|
1287
|
+
log.info("[tun] on offline")
|
|
1288
|
+
return {"tun": True, "changed": True, "dns": "deferred"}
|
|
1289
|
+
reload(ctx, state, new_state, "tun-on")
|
|
1290
|
+
# 事务提交后再接管 DNS:此时内核已在 127.0.0.1:53 监听。
|
|
1291
|
+
if _dns_apply(ctx):
|
|
1292
|
+
log.info("[tun] on dns=applied")
|
|
1293
|
+
return {"tun": True, "changed": True, "dns": "applied"}
|
|
1294
|
+
# 自动撤回:只还原 DNS 并关 TUN,不重新接管(方案 5.1.8 第 4 步)。
|
|
1295
|
+
log.error("[tun] dns apply failed, reverting")
|
|
1296
|
+
reverted = _dns_revert(ctx)
|
|
1297
|
+
try:
|
|
1298
|
+
with locked(ctx):
|
|
1299
|
+
state = read_state(ctx)
|
|
1300
|
+
off_state = copy.deepcopy(state)
|
|
1301
|
+
off_state["tun"] = False
|
|
1302
|
+
off_state["tun_suspended"] = None
|
|
1303
|
+
reload(ctx, state, off_state, "tun-on-revert")
|
|
1304
|
+
except CliError as exc:
|
|
1305
|
+
raise CliError(EXIT_MANUAL, t("err.tun_revert_failed"), detail=exc.detail,
|
|
1306
|
+
hints=manual_dns_cleanup_hints()) from None
|
|
1307
|
+
if not reverted:
|
|
1308
|
+
raise CliError(EXIT_MANUAL, t("err.tun_revert_failed"), hints=manual_dns_cleanup_hints())
|
|
1309
|
+
raise CliError(EXIT_ROLLED_BACK, t("err.dns_apply_failed"))
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def tun_off(ctx: Ctx) -> Dict[str, Any]:
|
|
1313
|
+
st = service_status(ctx)
|
|
1314
|
+
running = kernel_running(ctx, st)
|
|
1315
|
+
state = read_state(ctx)
|
|
1316
|
+
if not state["tun"]:
|
|
1317
|
+
ctx.say("info", t("info.tun_already_off"))
|
|
1318
|
+
return {"tun": False, "changed": False}
|
|
1319
|
+
if not running:
|
|
1320
|
+
with locked(ctx):
|
|
1321
|
+
state = read_state(ctx)
|
|
1322
|
+
new_state = copy.deepcopy(state)
|
|
1323
|
+
new_state["tun"] = False
|
|
1324
|
+
new_state["tun_suspended"] = None
|
|
1325
|
+
commit(ctx, state, new_state, "tun-off")
|
|
1326
|
+
log.info("[tun] off offline")
|
|
1327
|
+
return {"tun": False, "changed": True}
|
|
1328
|
+
if not ctx.platform.sudo_validate():
|
|
1329
|
+
raise CliError(EXIT_SUDO, t("err.sudo_failed"))
|
|
1330
|
+
# 先还原 DNS 再关 TUN:重载去掉 dns 段后 53 端口立即停听;先还原时 TUN 仍开,
|
|
1331
|
+
# 转去原上游的查询仍被 dns-hijack 接住,解析不中断(方案 5.1.8)。
|
|
1332
|
+
if not _dns_revert(ctx):
|
|
1333
|
+
raise CliError(EXIT_ERROR, t("err.dns_revert_failed"), hints=manual_dns_cleanup_hints())
|
|
1334
|
+
try:
|
|
1335
|
+
with locked(ctx):
|
|
1336
|
+
state = read_state(ctx)
|
|
1337
|
+
new_state = copy.deepcopy(state)
|
|
1338
|
+
new_state["tun"] = False
|
|
1339
|
+
new_state["tun_suspended"] = None
|
|
1340
|
+
reload(ctx, state, new_state, "tun-off")
|
|
1341
|
+
except CliError as exc:
|
|
1342
|
+
if exc.code == EXIT_MANUAL:
|
|
1343
|
+
# 回滚也没成功:内核状态未知,53 端口可能已不在监听,重新接管会把 DNS 指向不通的地址。
|
|
1344
|
+
log.error("[tun] tun off rollback incomplete, dns left reverted")
|
|
1345
|
+
raise
|
|
1346
|
+
if not _dns_apply(ctx):
|
|
1347
|
+
log.warning("[tun] re-apply dns after failed tun off also failed")
|
|
1348
|
+
ctx.say("warn", t("warn.dns_reapply_failed"))
|
|
1349
|
+
raise
|
|
1350
|
+
log.info("[tun] off")
|
|
1351
|
+
return {"tun": False, "changed": True}
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def tun_status(ctx: Ctx) -> Dict[str, Any]:
|
|
1355
|
+
state = read_state(ctx)
|
|
1356
|
+
st = service_status(ctx)
|
|
1357
|
+
effective = render_mod.tun_effective_in_config(state) and st.running
|
|
1358
|
+
info = {"tun": state["tun"], "suspended": state.get("tun_suspended"), "effective": effective,
|
|
1359
|
+
"device": ctx.platform.tun_device_exists(render_mod.TUN_DEVICE)}
|
|
1360
|
+
try:
|
|
1361
|
+
info["dns"] = ctx.platform.dns_status()
|
|
1362
|
+
except (PlatformError, NotSupported):
|
|
1363
|
+
info["dns"] = None
|
|
1364
|
+
return info
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
# ====================================================================
|
|
1368
|
+
# 终端代理环境变量
|
|
1369
|
+
# ====================================================================
|
|
1370
|
+
|
|
1371
|
+
PROXY_ENV_VARS = ("http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")
|
|
1372
|
+
NO_PROXY = "localhost,127.0.0.1,::1"
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def proxy_env(ctx: Ctx, on: bool) -> str:
|
|
1376
|
+
"""子进程改不了父 shell 的环境,只输出语句,由用户 eval(方案 5.1.6)。"""
|
|
1377
|
+
if not on:
|
|
1378
|
+
return "unset %s no_proxy NO_PROXY\n" % " ".join(PROXY_ENV_VARS)
|
|
1379
|
+
port = read_state(ctx)["mixed_port"]
|
|
1380
|
+
lines = []
|
|
1381
|
+
for var in PROXY_ENV_VARS:
|
|
1382
|
+
scheme = "socks5h" if var.lower() == "all_proxy" else "http"
|
|
1383
|
+
lines.append("export %s=%s://127.0.0.1:%d" % (var, scheme, port))
|
|
1384
|
+
lines.append("export no_proxy=%s" % NO_PROXY)
|
|
1385
|
+
lines.append("export NO_PROXY=%s" % NO_PROXY)
|
|
1386
|
+
return "\n".join(lines) + "\n"
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
# ====================================================================
|
|
1390
|
+
# 连接与日志
|
|
1391
|
+
# ====================================================================
|
|
1392
|
+
|
|
1393
|
+
def connections(ctx: Ctx) -> List[Dict[str, Any]]:
|
|
1394
|
+
if not kernel_running(ctx):
|
|
1395
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_not_running"), hints=["mihomo-cli service start"])
|
|
1396
|
+
with ctx.api() as api:
|
|
1397
|
+
data = _api_call(api.connections)
|
|
1398
|
+
out = []
|
|
1399
|
+
for c in (data or {}).get("connections") or []:
|
|
1400
|
+
meta = c.get("metadata") or {}
|
|
1401
|
+
host = meta.get("host") or meta.get("destinationIP") or ""
|
|
1402
|
+
out.append({
|
|
1403
|
+
"host": "%s:%s" % (host, meta.get("destinationPort", "")),
|
|
1404
|
+
"network": meta.get("network", ""),
|
|
1405
|
+
"rule": ("%s(%s)" % (c.get("rule", ""), c.get("rulePayload"))) if c.get("rulePayload") else c.get("rule", ""),
|
|
1406
|
+
"chain": " > ".join(reversed(c.get("chains") or [])),
|
|
1407
|
+
"upload": c.get("upload", 0),
|
|
1408
|
+
"download": c.get("download", 0),
|
|
1409
|
+
})
|
|
1410
|
+
return out
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def stream_logs(ctx: Ctx, level: str) -> Iterator[Dict[str, Any]]:
|
|
1414
|
+
if not kernel_running(ctx):
|
|
1415
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_not_running"), hints=["mihomo-cli service start"])
|
|
1416
|
+
api = ctx.api()
|
|
1417
|
+
try:
|
|
1418
|
+
yield from api.stream_logs(level)
|
|
1419
|
+
except ApiUnavailable as exc:
|
|
1420
|
+
raise CliError(EXIT_UNREACHABLE, t("err.kernel_unreachable"), detail=str(exc)) from exc
|
|
1421
|
+
finally:
|
|
1422
|
+
api.close()
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
# ====================================================================
|
|
1426
|
+
# 备份
|
|
1427
|
+
# ====================================================================
|
|
1428
|
+
|
|
1429
|
+
def backup_list(ctx: Ctx) -> List[Dict[str, Any]]:
|
|
1430
|
+
out = []
|
|
1431
|
+
if not os.path.isdir(ctx.paths.backups):
|
|
1432
|
+
return out
|
|
1433
|
+
for name in sorted(os.listdir(ctx.paths.backups), reverse=True):
|
|
1434
|
+
path = os.path.join(ctx.paths.backups, name)
|
|
1435
|
+
if not os.path.isdir(path):
|
|
1436
|
+
continue
|
|
1437
|
+
item: Dict[str, Any] = {"name": name, "profile": None, "profiles": []}
|
|
1438
|
+
try:
|
|
1439
|
+
with open(os.path.join(path, "state.yaml"), encoding="utf-8") as fh:
|
|
1440
|
+
from .state import parse_state
|
|
1441
|
+
|
|
1442
|
+
bstate = parse_state(fh.read())
|
|
1443
|
+
item["profile"] = bstate.get("active_profile")
|
|
1444
|
+
item["profiles"] = list(bstate["profiles"].keys())
|
|
1445
|
+
except (OSError, StateError):
|
|
1446
|
+
pass
|
|
1447
|
+
out.append(item)
|
|
1448
|
+
return out
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def backup_restore(ctx: Ctx, name: str) -> Dict[str, Any]:
|
|
1452
|
+
from .state import parse_state
|
|
1453
|
+
|
|
1454
|
+
path = os.path.join(ctx.paths.backups, name)
|
|
1455
|
+
if os.sep in name or name.startswith(".") or not os.path.isdir(path):
|
|
1456
|
+
raise CliError(EXIT_ERROR, t("err.no_such_backup", name=name))
|
|
1457
|
+
try:
|
|
1458
|
+
with open(os.path.join(path, "state.yaml"), encoding="utf-8") as fh:
|
|
1459
|
+
bstate = parse_state(fh.read())
|
|
1460
|
+
except (OSError, StateError) as exc:
|
|
1461
|
+
raise CliError(EXIT_ERROR, t("err.backup_invalid", name=name), detail=str(exc)) from exc
|
|
1462
|
+
with locked(ctx):
|
|
1463
|
+
state = read_state(ctx)
|
|
1464
|
+
new_state = copy.deepcopy(state)
|
|
1465
|
+
for key in ("profiles", "active_profile", "mode", "cn_route"):
|
|
1466
|
+
new_state[key] = copy.deepcopy(bstate[key])
|
|
1467
|
+
# tun / tun_suspended / mixed_port / mirror 保留当前值:它们各有冲突检查与 sudo 步骤(方案 5.1.5)。
|
|
1468
|
+
files: Dict[str, Optional[bytes]] = {}
|
|
1469
|
+
backup_nodes = os.path.join(path, "nodes")
|
|
1470
|
+
wanted = set()
|
|
1471
|
+
if os.path.isdir(backup_nodes):
|
|
1472
|
+
for fname in os.listdir(backup_nodes):
|
|
1473
|
+
with open(os.path.join(backup_nodes, fname), "rb") as fh:
|
|
1474
|
+
files[os.path.join(ctx.paths.nodes, fname)] = fh.read()
|
|
1475
|
+
wanted.add(fname)
|
|
1476
|
+
if os.path.isdir(ctx.paths.nodes):
|
|
1477
|
+
for fname in os.listdir(ctx.paths.nodes):
|
|
1478
|
+
if fname.endswith(".txt") and fname not in wanted:
|
|
1479
|
+
files[os.path.join(ctx.paths.nodes, fname)] = None
|
|
1480
|
+
backup_providers = os.path.join(path, "providers")
|
|
1481
|
+
if os.path.isdir(backup_providers):
|
|
1482
|
+
for fname in os.listdir(backup_providers):
|
|
1483
|
+
with open(os.path.join(backup_providers, fname), "rb") as fh:
|
|
1484
|
+
files[os.path.join(ctx.paths.providers, fname)] = fh.read()
|
|
1485
|
+
commit(ctx, state, new_state, "backup-restore", files=files, involved=("main", "manual"))
|
|
1486
|
+
log.info("[reload] backup restore name=%s", name)
|
|
1487
|
+
return {"restored": name}
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
# ====================================================================
|
|
1491
|
+
# systemd 调用的隐藏子命令(方案 5.1.7)
|
|
1492
|
+
# ====================================================================
|
|
1493
|
+
|
|
1494
|
+
def service_preflight(ctx: Ctx) -> int:
|
|
1495
|
+
"""ExecStartPre:开机 / 重启时检查 TUN 冲突,冲突则按 TUN 关闭重写配置并记下原因。
|
|
1496
|
+
|
|
1497
|
+
除 state.yaml 不可读外一律返回 0:不阻止内核以端口代理方式启动。
|
|
1498
|
+
"""
|
|
1499
|
+
try:
|
|
1500
|
+
state = load_state(ctx.paths)
|
|
1501
|
+
except StateError as exc:
|
|
1502
|
+
print("[tun] preflight state unreadable: %s" % exc, file=sys.stderr)
|
|
1503
|
+
return EXIT_ERROR
|
|
1504
|
+
conflicts: List[str] = []
|
|
1505
|
+
if state["tun"]:
|
|
1506
|
+
try:
|
|
1507
|
+
conflicts = ctx.platform.tun_conflicts()
|
|
1508
|
+
except (PlatformError, NotSupported) as exc:
|
|
1509
|
+
conflicts = ["probe failed: %s" % exc]
|
|
1510
|
+
lock = StateLock(ctx.paths, SERVICE_LOCK_TIMEOUT_SEC)
|
|
1511
|
+
try:
|
|
1512
|
+
lock.acquire()
|
|
1513
|
+
except LockTimeout:
|
|
1514
|
+
if conflicts:
|
|
1515
|
+
# doctor D13 从 journal 里读这一条并报失败。
|
|
1516
|
+
print("[tun] preflight lock timeout, conflict=%s" % "; ".join(conflicts), file=sys.stderr)
|
|
1517
|
+
return EXIT_OK
|
|
1518
|
+
try:
|
|
1519
|
+
state = load_state(ctx.paths)
|
|
1520
|
+
changed = False
|
|
1521
|
+
if state["tun"] and conflicts:
|
|
1522
|
+
reason = "; ".join(conflicts)
|
|
1523
|
+
if state.get("tun_suspended") != reason:
|
|
1524
|
+
state["tun_suspended"] = reason
|
|
1525
|
+
changed = True
|
|
1526
|
+
print("[tun] suspended reason=%s" % reason, file=sys.stderr)
|
|
1527
|
+
elif state.get("tun_suspended"):
|
|
1528
|
+
state["tun_suspended"] = None
|
|
1529
|
+
changed = True
|
|
1530
|
+
print("[tun] resumed", file=sys.stderr)
|
|
1531
|
+
if changed:
|
|
1532
|
+
save_state(ctx.paths, state)
|
|
1533
|
+
if changed or not os.path.exists(ctx.paths.config):
|
|
1534
|
+
write_config(ctx, state)
|
|
1535
|
+
except (StateError, OSError) as exc:
|
|
1536
|
+
print("[tun] preflight error: %s" % exc, file=sys.stderr)
|
|
1537
|
+
finally:
|
|
1538
|
+
lock.release()
|
|
1539
|
+
return EXIT_OK
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _main_pid_gone(ctx: Ctx) -> bool:
|
|
1543
|
+
"""ExecStartPost 期间内核主进程是否已退出。
|
|
1544
|
+
|
|
1545
|
+
内核因配置错误秒退时,不判断这一点 reapply 会白等满 REAPPLY_READY_SEC,每次失败启动都多卡
|
|
1546
|
+
一分钟,StartLimitBurst 迟迟到不了,期间 CLI 一直报“服务正在启动”。优先用 systemd 传入的
|
|
1547
|
+
$MAINPID;没有时(主进程在 ExecStartPost 启动前就退出等)退而查服务的 MainPID,为 0 即已退出。
|
|
1548
|
+
查询失败一律按仍在运行处理,最坏只是退回原来的等满。
|
|
1549
|
+
"""
|
|
1550
|
+
try:
|
|
1551
|
+
pid = int(os.environ.get("MAINPID", ""))
|
|
1552
|
+
except ValueError:
|
|
1553
|
+
pid = 0
|
|
1554
|
+
if pid <= 0:
|
|
1555
|
+
try:
|
|
1556
|
+
return ctx.platform.service_status().main_pid == 0
|
|
1557
|
+
except Exception: # noqa: BLE001 - 兜底判断不能让 reapply 本身失败
|
|
1558
|
+
return False
|
|
1559
|
+
try:
|
|
1560
|
+
os.kill(pid, 0)
|
|
1561
|
+
except ProcessLookupError:
|
|
1562
|
+
return True
|
|
1563
|
+
except PermissionError:
|
|
1564
|
+
return False
|
|
1565
|
+
return False
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def service_reapply(ctx: Ctx) -> int:
|
|
1569
|
+
"""ExecStartPost:等 provider 加载完后把 state 的 PROXY / CN 选择下发到内核。
|
|
1570
|
+
|
|
1571
|
+
内核加载 provider 后才用 cache.db 恢复选择(hub/executor/executor.go:116、:442-470),切节点接口
|
|
1572
|
+
不经过应用配置的锁,落在两步之间的下发会被覆盖,所以下发后等 2 秒回读,不一致重来(方案 5.1.7)。
|
|
1573
|
+
"""
|
|
1574
|
+
started = time.monotonic()
|
|
1575
|
+
try:
|
|
1576
|
+
state = load_state(ctx.paths)
|
|
1577
|
+
except StateError as exc:
|
|
1578
|
+
print("[reapply] state unreadable: %s" % exc, file=sys.stderr)
|
|
1579
|
+
return EXIT_OK
|
|
1580
|
+
api = ctx.api()
|
|
1581
|
+
try:
|
|
1582
|
+
ready = False
|
|
1583
|
+
while time.monotonic() - started < REAPPLY_READY_SEC:
|
|
1584
|
+
try:
|
|
1585
|
+
members = api.get_proxy("PROXY").get("all", [])
|
|
1586
|
+
if any(m != "REJECT" for m in members):
|
|
1587
|
+
ready = True
|
|
1588
|
+
break
|
|
1589
|
+
main_names = api.provider_node_names("main") if "main" in render_mod.provider_names(state) else None
|
|
1590
|
+
if render_mod.expects_no_nodes(state, main_names):
|
|
1591
|
+
ready = True
|
|
1592
|
+
break
|
|
1593
|
+
except (ApiError, ApiUnavailable):
|
|
1594
|
+
pass
|
|
1595
|
+
if _main_pid_gone(ctx):
|
|
1596
|
+
print("[reapply] kernel exited, skip", file=sys.stderr)
|
|
1597
|
+
return EXIT_OK
|
|
1598
|
+
time.sleep(1)
|
|
1599
|
+
if not ready:
|
|
1600
|
+
print("[reapply] WARN kernel not ready after %ds" % REAPPLY_READY_SEC, file=sys.stderr)
|
|
1601
|
+
return EXIT_OK
|
|
1602
|
+
quiet = Ctx(ctx.paths, ctx.platform, ctx.python_exe)
|
|
1603
|
+
for attempt in range(1, REAPPLY_TRIES + 1):
|
|
1604
|
+
if time.monotonic() - started > REAPPLY_BUDGET_SEC:
|
|
1605
|
+
break
|
|
1606
|
+
try:
|
|
1607
|
+
changed = ensure_selection(quiet, api, state)
|
|
1608
|
+
if changed:
|
|
1609
|
+
_reapply_write_back(ctx, state)
|
|
1610
|
+
time.sleep(REAPPLY_SETTLE_SEC)
|
|
1611
|
+
prof = render_mod.active_profile(state)
|
|
1612
|
+
want_proxy = prof.get("selected") if prof else None
|
|
1613
|
+
want_cn = "DIRECT" if state["cn_route"] == "direct" else "PROXY"
|
|
1614
|
+
now_proxy = api.get_proxy("PROXY").get("now")
|
|
1615
|
+
now_cn = api.get_proxy("CN").get("now")
|
|
1616
|
+
if (want_proxy is None or now_proxy == want_proxy) and now_cn == want_cn:
|
|
1617
|
+
print("[reapply] done attempt=%d proxy=%s cn=%s" % (attempt, now_proxy, now_cn))
|
|
1618
|
+
return EXIT_OK
|
|
1619
|
+
except (ApiError, ApiUnavailable) as exc:
|
|
1620
|
+
print("[reapply] attempt=%d error=%s" % (attempt, exc), file=sys.stderr)
|
|
1621
|
+
print("[reapply] WARN selection not consistent after retries", file=sys.stderr)
|
|
1622
|
+
return EXIT_OK
|
|
1623
|
+
finally:
|
|
1624
|
+
api.close()
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _reapply_write_back(ctx: Ctx, state: Dict[str, Any]) -> None:
|
|
1628
|
+
"""补选结果写回 state.selected:只短暂拿锁(最多 5 秒),拿不到放弃写回并记 WARN。"""
|
|
1629
|
+
lock = StateLock(ctx.paths, SERVICE_LOCK_TIMEOUT_SEC)
|
|
1630
|
+
try:
|
|
1631
|
+
lock.acquire()
|
|
1632
|
+
except LockTimeout:
|
|
1633
|
+
print("[reapply] WARN lock busy, selection not written back", file=sys.stderr)
|
|
1634
|
+
return
|
|
1635
|
+
try:
|
|
1636
|
+
current = load_state(ctx.paths)
|
|
1637
|
+
name = state.get("active_profile")
|
|
1638
|
+
if name and current.get("active_profile") == name:
|
|
1639
|
+
current["profiles"][name]["selected"] = state["profiles"][name]["selected"]
|
|
1640
|
+
save_state(ctx.paths, current)
|
|
1641
|
+
except (StateError, OSError) as exc:
|
|
1642
|
+
print("[reapply] WARN write back failed: %s" % exc, file=sys.stderr)
|
|
1643
|
+
finally:
|
|
1644
|
+
lock.release()
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def tun_dns_apply(ctx: Ctx) -> int:
|
|
1648
|
+
"""root 身份:TUN 生效时接管系统 DNS,否则还原。只读 state、不加锁、不写主目录(方案 5.1.7)。"""
|
|
1649
|
+
from .platform.linux import wait_port
|
|
1650
|
+
|
|
1651
|
+
try:
|
|
1652
|
+
state = load_state(ctx.paths)
|
|
1653
|
+
except StateError as exc:
|
|
1654
|
+
print("[tun] dns-apply state unreadable: %s" % exc, file=sys.stderr)
|
|
1655
|
+
return EXIT_ERROR
|
|
1656
|
+
try:
|
|
1657
|
+
main_pid = ctx.platform.service_status().main_pid
|
|
1658
|
+
effective = render_mod.tun_effective_in_config(state) and main_pid != 0
|
|
1659
|
+
if not effective:
|
|
1660
|
+
# 不会留下指向一个没人监听的 127.0.0.1:53 的全局配置。
|
|
1661
|
+
ctx.platform.dns_revert()
|
|
1662
|
+
print("[tun] dns-apply tun not effective, reverted")
|
|
1663
|
+
return EXIT_OK
|
|
1664
|
+
deadline = time.monotonic() + DNS_APPLY_WAIT_SEC
|
|
1665
|
+
while time.monotonic() < deadline and not ctx.platform.tun_device_exists(render_mod.TUN_DEVICE):
|
|
1666
|
+
time.sleep(0.5)
|
|
1667
|
+
remaining = max(0.5, deadline - time.monotonic())
|
|
1668
|
+
if not ctx.platform.tun_device_exists(render_mod.TUN_DEVICE) or not wait_port("127.0.0.1", 53, remaining):
|
|
1669
|
+
print("[tun] dns-apply ERROR device or 127.0.0.1:53 not ready", file=sys.stderr)
|
|
1670
|
+
return EXIT_ERROR
|
|
1671
|
+
iface = ctx.platform.dns_takeover()
|
|
1672
|
+
print("[tun] dns-apply takeover iface=%s" % iface)
|
|
1673
|
+
return EXIT_OK
|
|
1674
|
+
except (PlatformError, OSError) as exc:
|
|
1675
|
+
print("[tun] dns-apply ERROR %s" % exc, file=sys.stderr)
|
|
1676
|
+
return EXIT_ERROR
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def tun_dns_revert(ctx: Ctx) -> int:
|
|
1680
|
+
"""root 身份:一律还原 DNS 接管,不看 state.tun。"""
|
|
1681
|
+
if os.environ.get(TEST_ENV_DNS_REVERT) == "1":
|
|
1682
|
+
print("[tun] dns-revert forced failure (test)", file=sys.stderr)
|
|
1683
|
+
return EXIT_ERROR
|
|
1684
|
+
try:
|
|
1685
|
+
did = ctx.platform.dns_revert()
|
|
1686
|
+
print("[tun] dns-revert %s" % ("done" if did else "nothing to do"))
|
|
1687
|
+
return EXIT_OK
|
|
1688
|
+
except (PlatformError, OSError) as exc:
|
|
1689
|
+
print("[tun] dns-revert ERROR %s" % exc, file=sys.stderr)
|
|
1690
|
+
return EXIT_ERROR
|