proxyctl 0.3.2__tar.gz → 0.4.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/PKG-INFO +1 -1
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/man/proxyctl.1 +6 -3
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/pyproject.toml +1 -1
- proxyctl-0.4.0a1/src/proxyctl/__init__.py +8 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/cli.py +243 -74
- proxyctl-0.4.0a1/src/proxyctl/completion.py +381 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/explain.py +183 -20
- proxyctl-0.3.2/src/proxyctl/__init__.py +0 -3
- proxyctl-0.3.2/src/proxyctl/completion.py +0 -223
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/.gitignore +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/LICENSE +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/README.md +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/check.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/status.py +0 -0
- {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/trace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0a1
|
|
4
4
|
Summary: Proxy configuration lifecycle management for macOS and Linux
|
|
5
5
|
Project-URL: Homepage, https://github.com/crhan/proxyctl
|
|
6
6
|
Project-URL: Issues, https://github.com/crhan/proxyctl/issues
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH PROXYCTL 1 "2026-05" "proxyctl 0.
|
|
1
|
+
.TH PROXYCTL 1 "2026-05" "proxyctl 0.4.0a1" "User Commands"
|
|
2
2
|
.SH NAME
|
|
3
3
|
proxyctl \- Proxy configuration lifecycle management for macOS / Linux
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -53,8 +53,10 @@ dns-lock / dns-unlock。
|
|
|
53
53
|
.SH COMMANDS
|
|
54
54
|
.SS 自描述(Agent 入口)
|
|
55
55
|
.TP
|
|
56
|
-
|
|
56
|
+
\fBproxyctl agent-guide\fR [\fB--list-sections\fR | \fB--section\fR \fINAME\fR]
|
|
57
57
|
输出给 LLM 的运行时入门 markdown(能力地图 / 引导路径 / 决策树 / envelope 字段表 / 锁文件位置 / footgun)。
|
|
58
|
+
v0.3.3 新增:\fB--list-sections\fR 列出所有 ASCII slug 形式的 section 名;
|
|
59
|
+
\fB--section <name>\fR 只输出该 section(模糊匹配 + did-you-mean),agent 按需取小块。
|
|
58
60
|
.TP
|
|
59
61
|
\fBproxyctl explain\fR [\fITOPIC\fR]
|
|
60
62
|
无参输出"想改 X 去哪?"速查表;带 topic 输出卡片。
|
|
@@ -74,7 +76,8 @@ supports_dry_run / needs_sudo / interactive / exit_codes / examples。
|
|
|
74
76
|
.B proxyctl doctor [--json]
|
|
75
77
|
极简 5 项健康打分:engine_up / port_listen / dns_ok / system_proxy_ok /
|
|
76
78
|
connectivity_ok。\fB--json\fR 额外含 informational 字段:
|
|
77
|
-
engine / mode / port / config_path / engine_config_path / lock_held / lock_path
|
|
79
|
+
engine / mode / port / config_path / engine_config_path / lock_held / lock_path /
|
|
80
|
+
\fBhealthy\fR (v0.3.3 新增 bool,agent 不必再算 score==max)。
|
|
78
81
|
.TP
|
|
79
82
|
.B proxyctl help [\fICOMMAND\fR]
|
|
80
83
|
顶层帮助 / 单命令完整说明。等价 \fBproxyctl --help\fR / \fBproxyctl <cmd> --help\fR。
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""proxyctl — Proxy configuration lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _v
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _v("proxyctl")
|
|
7
|
+
except PackageNotFoundError: # editable / source tree without metadata
|
|
8
|
+
__version__ = "unknown"
|
|
@@ -1025,10 +1025,12 @@ def cmd_engine(backend: Backend, target: str, config: dict):
|
|
|
1025
1025
|
dns_lock_stop(config)
|
|
1026
1026
|
dns_deactivate(config)
|
|
1027
1027
|
proxy_deactivate()
|
|
1028
|
-
|
|
1029
|
-
|
|
1028
|
+
argvs = _engine_subprocess_argvs(backend, plist_src,
|
|
1029
|
+
new_backend.plist, new_backend.label)
|
|
1030
|
+
run(argvs[0], sudo=True) # launchctl bootout old
|
|
1031
|
+
run(argvs[1], sudo=True) # /bin/rm -f old.plist
|
|
1030
1032
|
|
|
1031
|
-
r = run([
|
|
1033
|
+
r = run(argvs[2], sudo=True, capture=True) # /bin/cp src new.plist
|
|
1032
1034
|
if r.returncode != 0:
|
|
1033
1035
|
_io.fail("部署 plist 失败",
|
|
1034
1036
|
hints=[r.stderr.strip()] if r.stderr else None,
|
|
@@ -1040,8 +1042,7 @@ def cmd_engine(backend: Backend, target: str, config: dict):
|
|
|
1040
1042
|
f.write(target)
|
|
1041
1043
|
|
|
1042
1044
|
print(f"启动 {new_backend.name} ...")
|
|
1043
|
-
r = run([
|
|
1044
|
-
sudo=True, capture=True)
|
|
1045
|
+
r = run(argvs[3], sudo=True, capture=True) # launchctl bootstrap new.plist
|
|
1045
1046
|
if r.returncode != 0:
|
|
1046
1047
|
_io.fail("启动失败",
|
|
1047
1048
|
hints=[r.stderr.strip()] if r.stderr else None,
|
|
@@ -1099,16 +1100,17 @@ def cmd_daemon(name: str, subcmd: str, config: dict):
|
|
|
1099
1100
|
as_json=GLOBAL_FLAGS.get("json", False))
|
|
1100
1101
|
|
|
1101
1102
|
label = d_cfg.get("label", "")
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
port = d_cfg.get("port")
|
|
1103
|
+
log_path = os.path.expanduser(d_cfg.get("log_path", ""))
|
|
1104
|
+
port = d_cfg.get("port")
|
|
1105
1105
|
if not label:
|
|
1106
1106
|
_io.fail(f"daemon {name} 缺少 label 字段",
|
|
1107
1107
|
hint="检查 config.yaml 的 extra_daemons[<name>].label",
|
|
1108
1108
|
doc="extra-daemons", code=_io.CONFIG_ERR, cmd="daemon")
|
|
1109
1109
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1110
|
+
# plist 路径与 launchctl label 由共享 helper 派生,与 _plan_daemon 同源
|
|
1111
|
+
paths = _resolve_daemon_paths(name, config)
|
|
1112
|
+
assert paths is not None # 上面 d_cfg/label 检查已确保
|
|
1113
|
+
plist_src, plist_dst, full_label = paths
|
|
1112
1114
|
|
|
1113
1115
|
subcmd = subcmd or "status"
|
|
1114
1116
|
valid_subcmds = ("start", "stop", "restart", "log", "status")
|
|
@@ -1125,15 +1127,15 @@ def cmd_daemon(name: str, subcmd: str, config: dict):
|
|
|
1125
1127
|
if launchctl_running(full_label, sudo=True):
|
|
1126
1128
|
print(f"{name} 已在运行")
|
|
1127
1129
|
return
|
|
1130
|
+
argvs = _daemon_subprocess_argvs("start", plist_src, plist_dst, full_label)
|
|
1128
1131
|
if not os.path.isfile(plist_dst):
|
|
1129
1132
|
if not os.path.isfile(plist_src):
|
|
1130
1133
|
_io.fail(f"plist 源文件不存在: {plist_src}",
|
|
1131
1134
|
hint="检查 extra_daemons.plist_src 配置",
|
|
1132
1135
|
doc="extra-daemons",
|
|
1133
1136
|
code=_io.NOT_FOUND, cmd="daemon")
|
|
1134
|
-
run([
|
|
1135
|
-
r = run([
|
|
1136
|
-
sudo=True, capture=True)
|
|
1137
|
+
run(argvs[0], sudo=True) # /bin/cp src dst
|
|
1138
|
+
r = run(argvs[1], sudo=True, capture=True) # launchctl bootstrap
|
|
1137
1139
|
if r.returncode != 0:
|
|
1138
1140
|
_io.fail(f"{name} 启动失败",
|
|
1139
1141
|
hints=[r.stderr.strip()] if r.stderr else None,
|
|
@@ -1147,13 +1149,15 @@ def cmd_daemon(name: str, subcmd: str, config: dict):
|
|
|
1147
1149
|
|
|
1148
1150
|
elif subcmd == "stop":
|
|
1149
1151
|
if launchctl_running(full_label, sudo=True):
|
|
1150
|
-
|
|
1152
|
+
argvs = _daemon_subprocess_argvs("stop", plist_src, plist_dst, full_label)
|
|
1153
|
+
run(argvs[0], sudo=True) # launchctl bootout
|
|
1151
1154
|
print(f"{name} stopped")
|
|
1152
1155
|
else:
|
|
1153
1156
|
print(f"{name} 未在运行")
|
|
1154
1157
|
|
|
1155
1158
|
elif subcmd == "restart":
|
|
1156
|
-
|
|
1159
|
+
argvs = _daemon_subprocess_argvs("restart", plist_src, plist_dst, full_label)
|
|
1160
|
+
run(argvs[0], sudo=True) # launchctl kickstart -k
|
|
1157
1161
|
print(f"{name} restarted")
|
|
1158
1162
|
|
|
1159
1163
|
elif subcmd == "log":
|
|
@@ -1198,23 +1202,24 @@ def cmd_dns_lock(config: dict, backend: Backend, *, reload: bool = False):
|
|
|
1198
1202
|
hint="把 scripts/dns-watchdog 安装到该位置(或重新跑 install.sh)",
|
|
1199
1203
|
doc="dns", code=_io.DEPENDENCY_MISSING, cmd="dns-lock")
|
|
1200
1204
|
|
|
1205
|
+
argvs = _dns_lock_subprocess_argvs(dns_lock_plist, full_label)
|
|
1206
|
+
|
|
1201
1207
|
# reload 时先 bootout
|
|
1202
1208
|
if already_registered:
|
|
1203
|
-
r0 = run([
|
|
1209
|
+
r0 = run(argvs[0], sudo=True, capture=True) # launchctl bootout
|
|
1204
1210
|
if r0.returncode != 0:
|
|
1205
1211
|
print(f"{YELLOW}⚠{NC} bootout 失败(继续尝试 bootstrap): {r0.stderr.strip()}")
|
|
1206
1212
|
|
|
1207
1213
|
rendered = _render_dns_lock_plist(backend, config)
|
|
1208
1214
|
# 通过 sudo tee 写入(sudoers 允许 /usr/bin/tee <target.plist>)
|
|
1209
|
-
r = run([
|
|
1215
|
+
r = run(argvs[1], sudo=True, stdin_text=rendered, capture=True) # tee plist
|
|
1210
1216
|
if r.returncode != 0:
|
|
1211
1217
|
_io.fail(
|
|
1212
1218
|
f"写入 plist 失败: {r.stderr or '权限不足,请检查 sudoers'}",
|
|
1213
1219
|
hint="确认 sudoers 允许 /usr/bin/tee 写 LaunchDaemons",
|
|
1214
1220
|
doc="dns", code=_io.PERMISSION, cmd="dns-lock")
|
|
1215
1221
|
|
|
1216
|
-
r2 = run([
|
|
1217
|
-
sudo=True, capture=True)
|
|
1222
|
+
r2 = run(argvs[2], sudo=True, capture=True) # launchctl bootstrap
|
|
1218
1223
|
if r2.returncode != 0:
|
|
1219
1224
|
_io.fail(f"bootstrap 失败: {r2.stderr}",
|
|
1220
1225
|
doc="dns", code=_io.PERMISSION, cmd="dns-lock")
|
|
@@ -1231,13 +1236,15 @@ def cmd_dns_unlock(config: dict):
|
|
|
1231
1236
|
return
|
|
1232
1237
|
dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
|
|
1233
1238
|
dns_lock_plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
|
|
1239
|
+
full_label = f"system/{dns_lock_label}"
|
|
1240
|
+
argvs = _dns_unlock_subprocess_argvs(dns_lock_plist, full_label)
|
|
1234
1241
|
|
|
1235
|
-
r = run([
|
|
1242
|
+
r = run(argvs[0], sudo=True, capture=True) # launchctl bootout
|
|
1236
1243
|
if r.returncode == 0:
|
|
1237
1244
|
print(f"{GREEN}dns-lock daemon 已停止{NC}")
|
|
1238
1245
|
else:
|
|
1239
1246
|
print("dns-lock daemon 未在运行")
|
|
1240
|
-
run([
|
|
1247
|
+
run(argvs[1], sudo=True) # rm -f plist
|
|
1241
1248
|
print(f"已删除 {dns_lock_plist} (源文件保留在 {DEFAULT_CONFIG_DIR}/launchdaemons/)")
|
|
1242
1249
|
|
|
1243
1250
|
|
|
@@ -1571,6 +1578,8 @@ def cmd_version_print() -> None:
|
|
|
1571
1578
|
"agents_md": True,
|
|
1572
1579
|
"commands_schema": True,
|
|
1573
1580
|
"doctor_extended": True,
|
|
1581
|
+
"doctor_healthy_field": True, # 0.3.3
|
|
1582
|
+
"agent_guide_sections": True, # 0.3.3
|
|
1574
1583
|
"log_ndjson_v2": True,
|
|
1575
1584
|
},
|
|
1576
1585
|
}
|
|
@@ -1716,38 +1725,145 @@ def _maybe_dry_run(cmd_name: str, plan_fn) -> None:
|
|
|
1716
1725
|
sys.exit(0)
|
|
1717
1726
|
|
|
1718
1727
|
|
|
1728
|
+
# ── plan/exec 共享 helper(plan 与 cmd 的单一事实来源)─────────────────────────
|
|
1729
|
+
#
|
|
1730
|
+
# 这些 helper 返回**不含 sudo 前缀**的 argv list。plan_fn 把它们 split 成
|
|
1731
|
+
# PlanStep.target 字符串;cmd_* 把它们传给 run(..., sudo=True) 执行。两边共享
|
|
1732
|
+
# 同一份逻辑,CI 层 contract test 进一步断言 plan 与实际 subprocess.run 调用
|
|
1733
|
+
# 完全一致,杜绝 0.3.2 那种 system/system/ 双前缀类漂移。
|
|
1734
|
+
|
|
1735
|
+
def _resolve_daemon_paths(
|
|
1736
|
+
name: str, config: dict
|
|
1737
|
+
) -> tuple[str, str, str] | None:
|
|
1738
|
+
"""从 config.extra_daemons[name] 解析 (plist_src, plist_dst, full_label)。
|
|
1739
|
+
|
|
1740
|
+
用于消除 _h_daemon lambda 中的 <plist_dst> 占位符,并保证 cmd_daemon /
|
|
1741
|
+
_plan_daemon / _h_daemon 三处口径一致。
|
|
1742
|
+
|
|
1743
|
+
name 未声明或 label 缺失时返回 None;调用方决定是 fail 还是降级。
|
|
1744
|
+
"""
|
|
1745
|
+
daemons = (config.get("extra_daemons") or {})
|
|
1746
|
+
d_cfg = daemons.get(name)
|
|
1747
|
+
if not d_cfg:
|
|
1748
|
+
return None
|
|
1749
|
+
label = d_cfg.get("label", "")
|
|
1750
|
+
if not label:
|
|
1751
|
+
return None
|
|
1752
|
+
plist_src = os.path.expanduser(d_cfg.get("plist_src", ""))
|
|
1753
|
+
plist_dst = f"/Library/LaunchDaemons/{label}.plist"
|
|
1754
|
+
full_label = f"system/{label}"
|
|
1755
|
+
return (plist_src, plist_dst, full_label)
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _engine_subprocess_argvs(backend_old: Backend, plist_src: str,
|
|
1759
|
+
new_plist: str, new_label: str) -> list[list[str]]:
|
|
1760
|
+
"""返回 cmd_engine 切换引擎时 4 个 subprocess 的 argv list(不含 sudo)。
|
|
1761
|
+
|
|
1762
|
+
顺序对应 cmd_engine cli.py 中的 launchctl bootout → /bin/rm 旧 plist →
|
|
1763
|
+
/bin/cp 新 plist → launchctl bootstrap。run() 会自动加 sudo 前缀。
|
|
1764
|
+
"""
|
|
1765
|
+
return [
|
|
1766
|
+
["launchctl", "bootout", backend_old.label],
|
|
1767
|
+
["/bin/rm", "-f", backend_old.plist],
|
|
1768
|
+
["/bin/cp", plist_src, new_plist],
|
|
1769
|
+
["launchctl", "bootstrap", "system", new_plist],
|
|
1770
|
+
]
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def _daemon_subprocess_argvs(subcmd: str, plist_src: str,
|
|
1774
|
+
plist_dst: str, full_label: str) -> list[list[str]]:
|
|
1775
|
+
"""返回 cmd_daemon <subcmd> 的 argv list(不含 sudo)。
|
|
1776
|
+
|
|
1777
|
+
start: 始终列 [/bin/cp, launchctl bootstrap];cp 是 conditional
|
|
1778
|
+
(cmd_daemon 在 plist_dst 已存在时跳过),但 plan 作为上界总展示,
|
|
1779
|
+
contract test 走 actual ⊆ plan 方向天然容纳。
|
|
1780
|
+
"""
|
|
1781
|
+
if subcmd == "start":
|
|
1782
|
+
return [
|
|
1783
|
+
["/bin/cp", plist_src, plist_dst],
|
|
1784
|
+
["launchctl", "bootstrap", "system", plist_dst],
|
|
1785
|
+
]
|
|
1786
|
+
if subcmd == "stop":
|
|
1787
|
+
return [["launchctl", "bootout", full_label]]
|
|
1788
|
+
if subcmd == "restart":
|
|
1789
|
+
return [["launchctl", "kickstart", "-k", full_label]]
|
|
1790
|
+
return []
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _dns_lock_subprocess_argvs(plist_path: str,
|
|
1794
|
+
full_label: str) -> list[list[str]]:
|
|
1795
|
+
"""返回 cmd_dns_lock 的 argv list(不含 sudo)。
|
|
1796
|
+
|
|
1797
|
+
plan 始终展示完整 reload 路径:[bootout, tee, bootstrap]。default 模式
|
|
1798
|
+
(already_registered=True 且 reload=False)实际只跑 0 步,但 plan 作为
|
|
1799
|
+
dry-run 上界依然完整展示。contract test 用 actual ⊆ plan 方向。
|
|
1800
|
+
"""
|
|
1801
|
+
return [
|
|
1802
|
+
["launchctl", "bootout", full_label],
|
|
1803
|
+
["tee", plist_path],
|
|
1804
|
+
["launchctl", "bootstrap", "system", plist_path],
|
|
1805
|
+
]
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def _dns_unlock_subprocess_argvs(plist_path: str,
|
|
1809
|
+
full_label: str) -> list[list[str]]:
|
|
1810
|
+
"""返回 cmd_dns_unlock 的 argv list(不含 sudo):[bootout, rm -f plist]。"""
|
|
1811
|
+
return [
|
|
1812
|
+
["launchctl", "bootout", full_label],
|
|
1813
|
+
["rm", "-f", plist_path],
|
|
1814
|
+
]
|
|
1815
|
+
|
|
1816
|
+
|
|
1719
1817
|
def _plan_mode(backend, target: str) -> list[dict]:
|
|
1818
|
+
"""mode tun / mode proxy 的 plan。
|
|
1819
|
+
|
|
1820
|
+
cmd_mode 实际不调 launchctl —— 仅改 config 文件 + 切换系统代理;
|
|
1821
|
+
需手动 restart 引擎才生效(cmd_mode 末尾会提示)。plan 同源展示。
|
|
1822
|
+
"""
|
|
1823
|
+
proxy_action = "开启系统代理 127.0.0.1:7890" if target == "proxy" \
|
|
1824
|
+
else "关闭系统代理"
|
|
1720
1825
|
return [
|
|
1721
1826
|
{"action": "edit_yaml",
|
|
1722
1827
|
"target": backend.config_file,
|
|
1723
1828
|
"summary": f"修改 {backend.config_file}:切换 mode 为 {target}",
|
|
1724
1829
|
"reversible": True,
|
|
1725
1830
|
"side_effects": ["config-write"]},
|
|
1726
|
-
{"action": "
|
|
1727
|
-
"target": f"
|
|
1728
|
-
|
|
1729
|
-
"
|
|
1730
|
-
"
|
|
1831
|
+
{"action": "system_op",
|
|
1832
|
+
"target": f"networksetup -setwebproxy* / -setsecurewebproxy* "
|
|
1833
|
+
f"(per service)",
|
|
1834
|
+
"summary": proxy_action,
|
|
1835
|
+
"reversible": True, "requires_sudo": False,
|
|
1836
|
+
"side_effects": ["system"]},
|
|
1731
1837
|
]
|
|
1732
1838
|
|
|
1733
1839
|
|
|
1734
1840
|
def _plan_engine(backend, target: str) -> list[dict]:
|
|
1735
|
-
from proxyctl.cli import get_backend, DEFAULT_CONFIG_DIR
|
|
1736
1841
|
new_cfg = {"backend": target}
|
|
1737
1842
|
try:
|
|
1738
1843
|
new_backend = get_backend(new_cfg)
|
|
1739
1844
|
new_plist = new_backend.plist
|
|
1740
1845
|
except Exception:
|
|
1741
|
-
|
|
1846
|
+
new_backend = None
|
|
1847
|
+
new_plist = f"/Library/LaunchDaemons/com.{target}.tun.plist"
|
|
1848
|
+
plist_src = os.path.join(DEFAULT_CONFIG_DIR, "launchdaemons",
|
|
1849
|
+
os.path.basename(new_plist))
|
|
1850
|
+
argvs = _engine_subprocess_argvs(backend, plist_src, new_plist,
|
|
1851
|
+
new_backend.label if new_backend
|
|
1852
|
+
else f"system/com.{target}.tun")
|
|
1742
1853
|
return [
|
|
1743
1854
|
{"action": "subprocess",
|
|
1744
|
-
"target":
|
|
1855
|
+
"target": " ".join(argvs[0]),
|
|
1745
1856
|
"summary": f"停止当前引擎 {backend.name}",
|
|
1746
1857
|
"reversible": True, "requires_sudo": True,
|
|
1747
1858
|
"side_effects": ["process"]},
|
|
1748
|
-
{"action": "
|
|
1749
|
-
"target":
|
|
1750
|
-
"summary": f"
|
|
1859
|
+
{"action": "subprocess",
|
|
1860
|
+
"target": " ".join(argvs[1]),
|
|
1861
|
+
"summary": f"删除旧 plist {backend.plist}",
|
|
1862
|
+
"reversible": False, "requires_sudo": True,
|
|
1863
|
+
"side_effects": ["config-write"]},
|
|
1864
|
+
{"action": "subprocess",
|
|
1865
|
+
"target": " ".join(argvs[2]),
|
|
1866
|
+
"summary": f"部署新 plist 到 {new_plist}",
|
|
1751
1867
|
"reversible": True, "requires_sudo": True,
|
|
1752
1868
|
"side_effects": ["config-write"]},
|
|
1753
1869
|
{"action": "fs_write",
|
|
@@ -1756,7 +1872,7 @@ def _plan_engine(backend, target: str) -> list[dict]:
|
|
|
1756
1872
|
"reversible": True,
|
|
1757
1873
|
"side_effects": ["config-write"]},
|
|
1758
1874
|
{"action": "subprocess",
|
|
1759
|
-
"target":
|
|
1875
|
+
"target": " ".join(argvs[3]),
|
|
1760
1876
|
"summary": f"启动新引擎 {target}",
|
|
1761
1877
|
"reversible": True, "requires_sudo": True,
|
|
1762
1878
|
"side_effects": ["process"]},
|
|
@@ -1764,39 +1880,56 @@ def _plan_engine(backend, target: str) -> list[dict]:
|
|
|
1764
1880
|
|
|
1765
1881
|
|
|
1766
1882
|
def _plan_fix(backend, config) -> list[dict]:
|
|
1883
|
+
"""fix 的 plan。
|
|
1884
|
+
|
|
1885
|
+
networksetup / scutil 迭代型操作不可逐条 argv 化(每个网络服务调一次),
|
|
1886
|
+
标 action=system_op + 描述性 target。http_put 是单次精确请求,target
|
|
1887
|
+
为完整 URL。cmd_fix 实际还会调插件 route_hooks + 第二次 curl 清 fakeip,
|
|
1888
|
+
contract test 不做 subprocess 子集校验(plugin 可注入),只校验
|
|
1889
|
+
placeholder 已消除(< / > 字符不出现)。
|
|
1890
|
+
"""
|
|
1891
|
+
api_base = config.get('api_base', 'http://127.0.0.1:9090')
|
|
1767
1892
|
return [
|
|
1768
|
-
{"action": "
|
|
1769
|
-
"target": "networksetup -setdnsservers
|
|
1893
|
+
{"action": "system_op",
|
|
1894
|
+
"target": "networksetup -setdnsservers (per service) 127.0.0.1",
|
|
1770
1895
|
"summary": "重置系统 DNS 指向 127.0.0.1(对抗 DHCP 续租)",
|
|
1771
1896
|
"reversible": True, "requires_sudo": True,
|
|
1772
1897
|
"side_effects": ["system"]},
|
|
1773
|
-
{"action": "
|
|
1774
|
-
"target": "scutil + dscacheutil",
|
|
1898
|
+
{"action": "system_op",
|
|
1899
|
+
"target": "scutil DNS reset + dscacheutil -flushcache",
|
|
1775
1900
|
"summary": "清 macOS DNS 缓存(含 fakeip 表)",
|
|
1776
1901
|
"reversible": True, "requires_sudo": True,
|
|
1777
1902
|
"side_effects": ["cache"]},
|
|
1778
1903
|
{"action": "http_put",
|
|
1779
|
-
"target": f"{
|
|
1904
|
+
"target": f"{api_base}/configs?force=true",
|
|
1780
1905
|
"summary": "向 Clash API 发热重载请求(不重启进程)",
|
|
1781
1906
|
"reversible": True,
|
|
1782
1907
|
"side_effects": ["network-io"]},
|
|
1783
1908
|
]
|
|
1784
1909
|
|
|
1785
1910
|
|
|
1786
|
-
def _plan_audit_apply(days: int) -> list[dict]:
|
|
1911
|
+
def _plan_audit_apply(days: int, backend, config: dict) -> list[dict]:
|
|
1912
|
+
"""audit apply 的 plan。
|
|
1913
|
+
|
|
1914
|
+
scan_log target 等于 audit 模块实际扫描的路径(audit.MH_LOG / SB_LOG),
|
|
1915
|
+
不是 backend.log_file —— sing-box 这两者不同(review Q2)。
|
|
1916
|
+
"""
|
|
1917
|
+
from proxyctl import audit
|
|
1918
|
+
log_path = audit.MH_LOG if backend.name == "mihomo" else audit.SB_LOG
|
|
1919
|
+
api_base = config.get('api_base', 'http://127.0.0.1:9090')
|
|
1787
1920
|
return [
|
|
1788
1921
|
{"action": "scan_log",
|
|
1789
|
-
"target":
|
|
1922
|
+
"target": log_path,
|
|
1790
1923
|
"summary": f"扫描最近 {days} 天后端日志,找疑似应直连的域名",
|
|
1791
1924
|
"reversible": True,
|
|
1792
1925
|
"side_effects": []},
|
|
1793
1926
|
{"action": "edit_yaml",
|
|
1794
|
-
"target": "
|
|
1927
|
+
"target": f"{backend.config_file} [rules:]",
|
|
1795
1928
|
"summary": "把候选域名作为 DOMAIN-SUFFIX,...,DIRECT 加到 rules 段顶部",
|
|
1796
1929
|
"reversible": True,
|
|
1797
1930
|
"side_effects": ["config-write"]},
|
|
1798
1931
|
{"action": "http_put",
|
|
1799
|
-
"target": "
|
|
1932
|
+
"target": f"{api_base}/configs?force=true",
|
|
1800
1933
|
"summary": "热重载配置使新规则生效",
|
|
1801
1934
|
"reversible": True,
|
|
1802
1935
|
"side_effects": ["network-io"]},
|
|
@@ -1818,32 +1951,46 @@ def _plan_config_set(path: str, key: str, value_repr: str) -> list[dict]:
|
|
|
1818
1951
|
]
|
|
1819
1952
|
|
|
1820
1953
|
|
|
1821
|
-
def _plan_daemon(name: str, subcmd: str,
|
|
1954
|
+
def _plan_daemon(name: str, subcmd: str, plist_src: str,
|
|
1955
|
+
plist_dst: str, full_label: str) -> list[dict]:
|
|
1956
|
+
"""daemon <name> start/stop/restart 的 plan。
|
|
1957
|
+
|
|
1958
|
+
所有 subprocess.target 由 _daemon_subprocess_argvs 派生,与 cmd_daemon
|
|
1959
|
+
实际跑的 argv 共享单一事实来源。start 步骤总是展示 cp + bootstrap,
|
|
1960
|
+
cp 是 conditional(plist_dst 已存在时跳过)但 plan 作为 dry-run 上界
|
|
1961
|
+
总是列出,contract test 走 actual ⊆ plan 方向。
|
|
1962
|
+
"""
|
|
1822
1963
|
if subcmd == "start":
|
|
1964
|
+
argvs = _daemon_subprocess_argvs("start", plist_src, plist_dst,
|
|
1965
|
+
full_label)
|
|
1823
1966
|
return [
|
|
1824
|
-
{"action": "
|
|
1825
|
-
"target":
|
|
1826
|
-
"summary": f"
|
|
1967
|
+
{"action": "subprocess",
|
|
1968
|
+
"target": " ".join(argvs[0]),
|
|
1969
|
+
"summary": f"如 {plist_dst} 不存在则从 {plist_src} 拷贝",
|
|
1827
1970
|
"reversible": True, "requires_sudo": True,
|
|
1828
1971
|
"side_effects": ["config-write"]},
|
|
1829
1972
|
{"action": "subprocess",
|
|
1830
|
-
"target":
|
|
1973
|
+
"target": " ".join(argvs[1]),
|
|
1831
1974
|
"summary": f"启动 daemon {name}",
|
|
1832
1975
|
"reversible": True, "requires_sudo": True,
|
|
1833
1976
|
"side_effects": ["process"]},
|
|
1834
1977
|
]
|
|
1835
1978
|
if subcmd == "stop":
|
|
1979
|
+
argvs = _daemon_subprocess_argvs("stop", plist_src, plist_dst,
|
|
1980
|
+
full_label)
|
|
1836
1981
|
return [
|
|
1837
1982
|
{"action": "subprocess",
|
|
1838
|
-
"target":
|
|
1983
|
+
"target": " ".join(argvs[0]),
|
|
1839
1984
|
"summary": f"停止 daemon {name}",
|
|
1840
1985
|
"reversible": True, "requires_sudo": True,
|
|
1841
1986
|
"side_effects": ["process"]},
|
|
1842
1987
|
]
|
|
1843
1988
|
if subcmd == "restart":
|
|
1989
|
+
argvs = _daemon_subprocess_argvs("restart", plist_src, plist_dst,
|
|
1990
|
+
full_label)
|
|
1844
1991
|
return [
|
|
1845
1992
|
{"action": "subprocess",
|
|
1846
|
-
"target":
|
|
1993
|
+
"target": " ".join(argvs[0]),
|
|
1847
1994
|
"summary": f"重启 daemon {name}",
|
|
1848
1995
|
"reversible": True, "requires_sudo": True,
|
|
1849
1996
|
"side_effects": ["process"]},
|
|
@@ -1851,40 +1998,51 @@ def _plan_daemon(name: str, subcmd: str, plist_dst: str) -> list[dict]:
|
|
|
1851
1998
|
return []
|
|
1852
1999
|
|
|
1853
2000
|
|
|
1854
|
-
def _plan_dns_lock(reload: bool) -> list[dict]:
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
2001
|
+
def _plan_dns_lock(config: dict, *, reload: bool = False) -> list[dict]:
|
|
2002
|
+
"""dns-lock 的 plan。
|
|
2003
|
+
|
|
2004
|
+
plan 始终展示完整 reload 路径 [bootout, tee, bootstrap];reload=False
|
|
2005
|
+
且 already_registered=True 时 cmd_dns_lock 实际跑 0 步——这是 dry-run
|
|
2006
|
+
上界,contract test 用 actual ⊆ plan 方向天然容纳。
|
|
2007
|
+
"""
|
|
2008
|
+
dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
|
|
2009
|
+
plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
|
|
2010
|
+
full_label = f"system/{dns_lock_label}"
|
|
2011
|
+
argvs = _dns_lock_subprocess_argvs(plist, full_label)
|
|
2012
|
+
reload_hint = "(reload 时 / 已注册时)" if not reload else ""
|
|
2013
|
+
return [
|
|
2014
|
+
{"action": "subprocess",
|
|
2015
|
+
"target": " ".join(argvs[0]),
|
|
2016
|
+
"summary": f"如已注册,先 bootout 重装{reload_hint}",
|
|
2017
|
+
"reversible": True, "requires_sudo": True,
|
|
2018
|
+
"side_effects": ["process"]},
|
|
2019
|
+
{"action": "subprocess",
|
|
2020
|
+
"target": " ".join(argvs[1]),
|
|
2021
|
+
"summary": f"通过 sudo tee 写入渲染好的 plist 到 {plist}",
|
|
1867
2022
|
"reversible": True, "requires_sudo": True,
|
|
1868
2023
|
"side_effects": ["config-write"]},
|
|
1869
2024
|
{"action": "subprocess",
|
|
1870
|
-
"target": "
|
|
2025
|
+
"target": " ".join(argvs[2]),
|
|
1871
2026
|
"summary": "启动 DNS 看门狗 daemon",
|
|
1872
2027
|
"reversible": True, "requires_sudo": True,
|
|
1873
2028
|
"side_effects": ["process"]},
|
|
1874
2029
|
]
|
|
1875
|
-
return plan
|
|
1876
2030
|
|
|
1877
2031
|
|
|
1878
|
-
def _plan_dns_unlock() -> list[dict]:
|
|
2032
|
+
def _plan_dns_unlock(config: dict) -> list[dict]:
|
|
2033
|
+
dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
|
|
2034
|
+
plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
|
|
2035
|
+
full_label = f"system/{dns_lock_label}"
|
|
2036
|
+
argvs = _dns_unlock_subprocess_argvs(plist, full_label)
|
|
1879
2037
|
return [
|
|
1880
2038
|
{"action": "subprocess",
|
|
1881
|
-
"target": "
|
|
2039
|
+
"target": " ".join(argvs[0]),
|
|
1882
2040
|
"summary": "停止 DNS 看门狗",
|
|
1883
2041
|
"reversible": True, "requires_sudo": True,
|
|
1884
2042
|
"side_effects": ["process"]},
|
|
1885
|
-
{"action": "
|
|
1886
|
-
"target": "
|
|
1887
|
-
"summary": "删除 launchd plist
|
|
2043
|
+
{"action": "subprocess",
|
|
2044
|
+
"target": " ".join(argvs[1]),
|
|
2045
|
+
"summary": f"删除 launchd plist {plist}(源文件保留)",
|
|
1888
2046
|
"reversible": False, "requires_sudo": True,
|
|
1889
2047
|
"side_effects": ["config-write"]},
|
|
1890
2048
|
]
|
|
@@ -1987,7 +2145,7 @@ def _h_restart_clean(ctx):
|
|
|
1987
2145
|
cmd_restart(ctx["backend"], ctx["config"], clean=True, registry=ctx["registry"])
|
|
1988
2146
|
def _h_recover(ctx): cmd_recover(ctx["backend"], ctx["config"])
|
|
1989
2147
|
def _h_dns_unlock(ctx):
|
|
1990
|
-
_maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock())
|
|
2148
|
+
_maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock(ctx["config"]))
|
|
1991
2149
|
_exec_with_lock("daemon", "dns-unlock", cmd_dns_unlock, ctx["config"])
|
|
1992
2150
|
def _h_plugins(ctx): cmd_plugins(ctx["registry"])
|
|
1993
2151
|
|
|
@@ -2020,7 +2178,8 @@ def _h_fix(ctx):
|
|
|
2020
2178
|
|
|
2021
2179
|
def _h_dns_lock(ctx):
|
|
2022
2180
|
reload = "--reload" in ctx["args"]
|
|
2023
|
-
_maybe_dry_run("dns-lock",
|
|
2181
|
+
_maybe_dry_run("dns-lock",
|
|
2182
|
+
lambda: _plan_dns_lock(ctx["config"], reload=reload))
|
|
2024
2183
|
_exec_with_lock("daemon", "dns-lock", cmd_dns_lock,
|
|
2025
2184
|
ctx["config"], ctx["backend"], reload=reload)
|
|
2026
2185
|
|
|
@@ -2041,8 +2200,13 @@ def _h_daemon(ctx):
|
|
|
2041
2200
|
name = ctx["args"][0] if ctx["args"] else ""
|
|
2042
2201
|
subcmd = ctx["args"][1] if len(ctx["args"]) > 1 else ""
|
|
2043
2202
|
if subcmd in ("start", "stop", "restart"):
|
|
2203
|
+
paths = _resolve_daemon_paths(name, ctx["config"])
|
|
2204
|
+
# name 未声明或 label 缺失:plan 用空字符串占位(cmd_daemon 会 fail
|
|
2205
|
+
# 出友好 hint,所以 dry-run 报 plan 也无害)。
|
|
2206
|
+
plist_src, plist_dst, full_label = paths or ("", "", "")
|
|
2044
2207
|
_maybe_dry_run("daemon",
|
|
2045
|
-
lambda: _plan_daemon(name, subcmd,
|
|
2208
|
+
lambda: _plan_daemon(name, subcmd, plist_src,
|
|
2209
|
+
plist_dst, full_label))
|
|
2046
2210
|
_exec_with_lock("daemon", "daemon", cmd_daemon, name, subcmd, ctx["config"])
|
|
2047
2211
|
else:
|
|
2048
2212
|
cmd_daemon(name, subcmd, ctx["config"])
|
|
@@ -2051,8 +2215,11 @@ def _h_claude_proxy(ctx):
|
|
|
2051
2215
|
"""daemon claude-proxy <subcmd> 的别名,向后兼容。"""
|
|
2052
2216
|
subcmd = ctx["args"][0] if ctx["args"] else "status"
|
|
2053
2217
|
if subcmd in ("start", "stop", "restart"):
|
|
2218
|
+
paths = _resolve_daemon_paths("claude-proxy", ctx["config"])
|
|
2219
|
+
plist_src, plist_dst, full_label = paths or ("", "", "")
|
|
2054
2220
|
_maybe_dry_run("claude-proxy",
|
|
2055
|
-
lambda: _plan_daemon("claude-proxy", subcmd,
|
|
2221
|
+
lambda: _plan_daemon("claude-proxy", subcmd, plist_src,
|
|
2222
|
+
plist_dst, full_label))
|
|
2056
2223
|
_exec_with_lock("daemon", "claude-proxy",
|
|
2057
2224
|
cmd_daemon, "claude-proxy", subcmd, ctx["config"])
|
|
2058
2225
|
else:
|
|
@@ -2080,7 +2247,9 @@ def _h_audit(ctx):
|
|
|
2080
2247
|
days = 1
|
|
2081
2248
|
from proxyctl.audit import cmd_audit
|
|
2082
2249
|
if apply_mode:
|
|
2083
|
-
_maybe_dry_run("audit",
|
|
2250
|
+
_maybe_dry_run("audit",
|
|
2251
|
+
lambda: _plan_audit_apply(days, ctx["backend"],
|
|
2252
|
+
ctx["config"]))
|
|
2084
2253
|
_exec_with_lock("config", "audit", cmd_audit,
|
|
2085
2254
|
days, ctx["api_base"], ctx["api_secret"], apply_mode)
|
|
2086
2255
|
else:
|