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/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