proxyctl 0.3.3__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.3 → proxyctl-0.4.0a1}/PKG-INFO +1 -1
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/man/proxyctl.1 +1 -1
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/pyproject.toml +1 -1
- proxyctl-0.4.0a1/src/proxyctl/__init__.py +8 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/cli.py +241 -74
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/explain.py +22 -0
- proxyctl-0.3.3/src/proxyctl/__init__.py +0 -3
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/.gitignore +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/LICENSE +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/README.md +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/check.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/status.py +0 -0
- {proxyctl-0.3.3 → 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
|
|
@@ -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
|
|
|
@@ -1718,38 +1725,145 @@ def _maybe_dry_run(cmd_name: str, plan_fn) -> None:
|
|
|
1718
1725
|
sys.exit(0)
|
|
1719
1726
|
|
|
1720
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
|
+
|
|
1721
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 "关闭系统代理"
|
|
1722
1825
|
return [
|
|
1723
1826
|
{"action": "edit_yaml",
|
|
1724
1827
|
"target": backend.config_file,
|
|
1725
1828
|
"summary": f"修改 {backend.config_file}:切换 mode 为 {target}",
|
|
1726
1829
|
"reversible": True,
|
|
1727
1830
|
"side_effects": ["config-write"]},
|
|
1728
|
-
{"action": "
|
|
1729
|
-
"target": f"
|
|
1730
|
-
|
|
1731
|
-
"
|
|
1732
|
-
"
|
|
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"]},
|
|
1733
1837
|
]
|
|
1734
1838
|
|
|
1735
1839
|
|
|
1736
1840
|
def _plan_engine(backend, target: str) -> list[dict]:
|
|
1737
|
-
from proxyctl.cli import get_backend, DEFAULT_CONFIG_DIR
|
|
1738
1841
|
new_cfg = {"backend": target}
|
|
1739
1842
|
try:
|
|
1740
1843
|
new_backend = get_backend(new_cfg)
|
|
1741
1844
|
new_plist = new_backend.plist
|
|
1742
1845
|
except Exception:
|
|
1743
|
-
|
|
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")
|
|
1744
1853
|
return [
|
|
1745
1854
|
{"action": "subprocess",
|
|
1746
|
-
"target":
|
|
1855
|
+
"target": " ".join(argvs[0]),
|
|
1747
1856
|
"summary": f"停止当前引擎 {backend.name}",
|
|
1748
1857
|
"reversible": True, "requires_sudo": True,
|
|
1749
1858
|
"side_effects": ["process"]},
|
|
1750
|
-
{"action": "
|
|
1751
|
-
"target":
|
|
1752
|
-
"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}",
|
|
1753
1867
|
"reversible": True, "requires_sudo": True,
|
|
1754
1868
|
"side_effects": ["config-write"]},
|
|
1755
1869
|
{"action": "fs_write",
|
|
@@ -1758,7 +1872,7 @@ def _plan_engine(backend, target: str) -> list[dict]:
|
|
|
1758
1872
|
"reversible": True,
|
|
1759
1873
|
"side_effects": ["config-write"]},
|
|
1760
1874
|
{"action": "subprocess",
|
|
1761
|
-
"target":
|
|
1875
|
+
"target": " ".join(argvs[3]),
|
|
1762
1876
|
"summary": f"启动新引擎 {target}",
|
|
1763
1877
|
"reversible": True, "requires_sudo": True,
|
|
1764
1878
|
"side_effects": ["process"]},
|
|
@@ -1766,39 +1880,56 @@ def _plan_engine(backend, target: str) -> list[dict]:
|
|
|
1766
1880
|
|
|
1767
1881
|
|
|
1768
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')
|
|
1769
1892
|
return [
|
|
1770
|
-
{"action": "
|
|
1771
|
-
"target": "networksetup -setdnsservers
|
|
1893
|
+
{"action": "system_op",
|
|
1894
|
+
"target": "networksetup -setdnsservers (per service) 127.0.0.1",
|
|
1772
1895
|
"summary": "重置系统 DNS 指向 127.0.0.1(对抗 DHCP 续租)",
|
|
1773
1896
|
"reversible": True, "requires_sudo": True,
|
|
1774
1897
|
"side_effects": ["system"]},
|
|
1775
|
-
{"action": "
|
|
1776
|
-
"target": "scutil + dscacheutil",
|
|
1898
|
+
{"action": "system_op",
|
|
1899
|
+
"target": "scutil DNS reset + dscacheutil -flushcache",
|
|
1777
1900
|
"summary": "清 macOS DNS 缓存(含 fakeip 表)",
|
|
1778
1901
|
"reversible": True, "requires_sudo": True,
|
|
1779
1902
|
"side_effects": ["cache"]},
|
|
1780
1903
|
{"action": "http_put",
|
|
1781
|
-
"target": f"{
|
|
1904
|
+
"target": f"{api_base}/configs?force=true",
|
|
1782
1905
|
"summary": "向 Clash API 发热重载请求(不重启进程)",
|
|
1783
1906
|
"reversible": True,
|
|
1784
1907
|
"side_effects": ["network-io"]},
|
|
1785
1908
|
]
|
|
1786
1909
|
|
|
1787
1910
|
|
|
1788
|
-
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')
|
|
1789
1920
|
return [
|
|
1790
1921
|
{"action": "scan_log",
|
|
1791
|
-
"target":
|
|
1922
|
+
"target": log_path,
|
|
1792
1923
|
"summary": f"扫描最近 {days} 天后端日志,找疑似应直连的域名",
|
|
1793
1924
|
"reversible": True,
|
|
1794
1925
|
"side_effects": []},
|
|
1795
1926
|
{"action": "edit_yaml",
|
|
1796
|
-
"target": "
|
|
1927
|
+
"target": f"{backend.config_file} [rules:]",
|
|
1797
1928
|
"summary": "把候选域名作为 DOMAIN-SUFFIX,...,DIRECT 加到 rules 段顶部",
|
|
1798
1929
|
"reversible": True,
|
|
1799
1930
|
"side_effects": ["config-write"]},
|
|
1800
1931
|
{"action": "http_put",
|
|
1801
|
-
"target": "
|
|
1932
|
+
"target": f"{api_base}/configs?force=true",
|
|
1802
1933
|
"summary": "热重载配置使新规则生效",
|
|
1803
1934
|
"reversible": True,
|
|
1804
1935
|
"side_effects": ["network-io"]},
|
|
@@ -1820,32 +1951,46 @@ def _plan_config_set(path: str, key: str, value_repr: str) -> list[dict]:
|
|
|
1820
1951
|
]
|
|
1821
1952
|
|
|
1822
1953
|
|
|
1823
|
-
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
|
+
"""
|
|
1824
1963
|
if subcmd == "start":
|
|
1964
|
+
argvs = _daemon_subprocess_argvs("start", plist_src, plist_dst,
|
|
1965
|
+
full_label)
|
|
1825
1966
|
return [
|
|
1826
|
-
{"action": "
|
|
1827
|
-
"target":
|
|
1828
|
-
"summary": f"
|
|
1967
|
+
{"action": "subprocess",
|
|
1968
|
+
"target": " ".join(argvs[0]),
|
|
1969
|
+
"summary": f"如 {plist_dst} 不存在则从 {plist_src} 拷贝",
|
|
1829
1970
|
"reversible": True, "requires_sudo": True,
|
|
1830
1971
|
"side_effects": ["config-write"]},
|
|
1831
1972
|
{"action": "subprocess",
|
|
1832
|
-
"target":
|
|
1973
|
+
"target": " ".join(argvs[1]),
|
|
1833
1974
|
"summary": f"启动 daemon {name}",
|
|
1834
1975
|
"reversible": True, "requires_sudo": True,
|
|
1835
1976
|
"side_effects": ["process"]},
|
|
1836
1977
|
]
|
|
1837
1978
|
if subcmd == "stop":
|
|
1979
|
+
argvs = _daemon_subprocess_argvs("stop", plist_src, plist_dst,
|
|
1980
|
+
full_label)
|
|
1838
1981
|
return [
|
|
1839
1982
|
{"action": "subprocess",
|
|
1840
|
-
"target":
|
|
1983
|
+
"target": " ".join(argvs[0]),
|
|
1841
1984
|
"summary": f"停止 daemon {name}",
|
|
1842
1985
|
"reversible": True, "requires_sudo": True,
|
|
1843
1986
|
"side_effects": ["process"]},
|
|
1844
1987
|
]
|
|
1845
1988
|
if subcmd == "restart":
|
|
1989
|
+
argvs = _daemon_subprocess_argvs("restart", plist_src, plist_dst,
|
|
1990
|
+
full_label)
|
|
1846
1991
|
return [
|
|
1847
1992
|
{"action": "subprocess",
|
|
1848
|
-
"target":
|
|
1993
|
+
"target": " ".join(argvs[0]),
|
|
1849
1994
|
"summary": f"重启 daemon {name}",
|
|
1850
1995
|
"reversible": True, "requires_sudo": True,
|
|
1851
1996
|
"side_effects": ["process"]},
|
|
@@ -1853,40 +1998,51 @@ def _plan_daemon(name: str, subcmd: str, plist_dst: str) -> list[dict]:
|
|
|
1853
1998
|
return []
|
|
1854
1999
|
|
|
1855
2000
|
|
|
1856
|
-
def _plan_dns_lock(reload: bool) -> list[dict]:
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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}",
|
|
1869
2022
|
"reversible": True, "requires_sudo": True,
|
|
1870
2023
|
"side_effects": ["config-write"]},
|
|
1871
2024
|
{"action": "subprocess",
|
|
1872
|
-
"target": "
|
|
2025
|
+
"target": " ".join(argvs[2]),
|
|
1873
2026
|
"summary": "启动 DNS 看门狗 daemon",
|
|
1874
2027
|
"reversible": True, "requires_sudo": True,
|
|
1875
2028
|
"side_effects": ["process"]},
|
|
1876
2029
|
]
|
|
1877
|
-
return plan
|
|
1878
2030
|
|
|
1879
2031
|
|
|
1880
|
-
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)
|
|
1881
2037
|
return [
|
|
1882
2038
|
{"action": "subprocess",
|
|
1883
|
-
"target": "
|
|
2039
|
+
"target": " ".join(argvs[0]),
|
|
1884
2040
|
"summary": "停止 DNS 看门狗",
|
|
1885
2041
|
"reversible": True, "requires_sudo": True,
|
|
1886
2042
|
"side_effects": ["process"]},
|
|
1887
|
-
{"action": "
|
|
1888
|
-
"target": "
|
|
1889
|
-
"summary": "删除 launchd plist
|
|
2043
|
+
{"action": "subprocess",
|
|
2044
|
+
"target": " ".join(argvs[1]),
|
|
2045
|
+
"summary": f"删除 launchd plist {plist}(源文件保留)",
|
|
1890
2046
|
"reversible": False, "requires_sudo": True,
|
|
1891
2047
|
"side_effects": ["config-write"]},
|
|
1892
2048
|
]
|
|
@@ -1989,7 +2145,7 @@ def _h_restart_clean(ctx):
|
|
|
1989
2145
|
cmd_restart(ctx["backend"], ctx["config"], clean=True, registry=ctx["registry"])
|
|
1990
2146
|
def _h_recover(ctx): cmd_recover(ctx["backend"], ctx["config"])
|
|
1991
2147
|
def _h_dns_unlock(ctx):
|
|
1992
|
-
_maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock())
|
|
2148
|
+
_maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock(ctx["config"]))
|
|
1993
2149
|
_exec_with_lock("daemon", "dns-unlock", cmd_dns_unlock, ctx["config"])
|
|
1994
2150
|
def _h_plugins(ctx): cmd_plugins(ctx["registry"])
|
|
1995
2151
|
|
|
@@ -2022,7 +2178,8 @@ def _h_fix(ctx):
|
|
|
2022
2178
|
|
|
2023
2179
|
def _h_dns_lock(ctx):
|
|
2024
2180
|
reload = "--reload" in ctx["args"]
|
|
2025
|
-
_maybe_dry_run("dns-lock",
|
|
2181
|
+
_maybe_dry_run("dns-lock",
|
|
2182
|
+
lambda: _plan_dns_lock(ctx["config"], reload=reload))
|
|
2026
2183
|
_exec_with_lock("daemon", "dns-lock", cmd_dns_lock,
|
|
2027
2184
|
ctx["config"], ctx["backend"], reload=reload)
|
|
2028
2185
|
|
|
@@ -2043,8 +2200,13 @@ def _h_daemon(ctx):
|
|
|
2043
2200
|
name = ctx["args"][0] if ctx["args"] else ""
|
|
2044
2201
|
subcmd = ctx["args"][1] if len(ctx["args"]) > 1 else ""
|
|
2045
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 ("", "", "")
|
|
2046
2207
|
_maybe_dry_run("daemon",
|
|
2047
|
-
lambda: _plan_daemon(name, subcmd,
|
|
2208
|
+
lambda: _plan_daemon(name, subcmd, plist_src,
|
|
2209
|
+
plist_dst, full_label))
|
|
2048
2210
|
_exec_with_lock("daemon", "daemon", cmd_daemon, name, subcmd, ctx["config"])
|
|
2049
2211
|
else:
|
|
2050
2212
|
cmd_daemon(name, subcmd, ctx["config"])
|
|
@@ -2053,8 +2215,11 @@ def _h_claude_proxy(ctx):
|
|
|
2053
2215
|
"""daemon claude-proxy <subcmd> 的别名,向后兼容。"""
|
|
2054
2216
|
subcmd = ctx["args"][0] if ctx["args"] else "status"
|
|
2055
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 ("", "", "")
|
|
2056
2220
|
_maybe_dry_run("claude-proxy",
|
|
2057
|
-
lambda: _plan_daemon("claude-proxy", subcmd,
|
|
2221
|
+
lambda: _plan_daemon("claude-proxy", subcmd, plist_src,
|
|
2222
|
+
plist_dst, full_label))
|
|
2058
2223
|
_exec_with_lock("daemon", "claude-proxy",
|
|
2059
2224
|
cmd_daemon, "claude-proxy", subcmd, ctx["config"])
|
|
2060
2225
|
else:
|
|
@@ -2082,7 +2247,9 @@ def _h_audit(ctx):
|
|
|
2082
2247
|
days = 1
|
|
2083
2248
|
from proxyctl.audit import cmd_audit
|
|
2084
2249
|
if apply_mode:
|
|
2085
|
-
_maybe_dry_run("audit",
|
|
2250
|
+
_maybe_dry_run("audit",
|
|
2251
|
+
lambda: _plan_audit_apply(days, ctx["backend"],
|
|
2252
|
+
ctx["config"]))
|
|
2086
2253
|
_exec_with_lock("config", "audit", cmd_audit,
|
|
2087
2254
|
days, ctx["api_base"], ctx["api_secret"], apply_mode)
|
|
2088
2255
|
else:
|
|
@@ -760,6 +760,28 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
|
|
|
760
760
|
NDJSON 流式:`bench --json` 每节点一行 JSON + 末尾 envelope summary;
|
|
761
761
|
`log --json` 每行一个 `{{file, line}}` 对象(非 envelope)。
|
|
762
762
|
|
|
763
|
+
## Plan — `data.plan[].action` 类型枚举(dry-run 输出)
|
|
764
|
+
|
|
765
|
+
写命令的 `--dry-run` 在 `data.plan` 输出 step 列表。从 v0.4.0a1 起,所有
|
|
766
|
+
step.target 字符串都不再含 `<...>` 占位符,agent 可原样使用。
|
|
767
|
+
|
|
768
|
+
| action | target 是什么 | agent 可怎么用 |
|
|
769
|
+
|---|---|---|
|
|
770
|
+
| `subprocess` | 可执行 shell 命令字符串(`target.split()` 即得 argv) | 复读 / 加 sudo 后直接跑 |
|
|
771
|
+
| `system_op` | 迭代型系统操作的描述(如 networksetup 遍历所有 service) | **不可**直接复读;理解副作用 |
|
|
772
|
+
| `fs_write` / `fs_copy` / `fs_write_atomic` | 绝对路径(fs_copy 形如 `src → dst`) | 文件写入意图 |
|
|
773
|
+
| `fs_remove` | 绝对路径 | 文件删除意图 |
|
|
774
|
+
| `edit_yaml` | `path [section:]` | 配置就地编辑意图 |
|
|
775
|
+
| `scan_log` | 日志文件绝对路径 | 日志扫描意图 |
|
|
776
|
+
| `http_put` | 完整 HTTP URL | Clash API 热重载等 |
|
|
777
|
+
|
|
778
|
+
`subprocess` step 的 `target` 是 dry-run 上界 —— cmd 实际执行可能跳过某些
|
|
779
|
+
conditional 步骤(如 daemon-start 的 cp 在 plist 已存在时跳过)。所有 step
|
|
780
|
+
也有 `requires_sudo` / `reversible` / `side_effects` / `summary` 等元信息。
|
|
781
|
+
|
|
782
|
+
CI 层 contract test(`tests/integration/test_plan_exec_contract.py`)保证
|
|
783
|
+
`_plan_<cmd>` 与 `cmd_<cmd>` 的 subprocess argv 永不漂移。
|
|
784
|
+
|
|
763
785
|
## Decision Tree — 故障决策树(给 Agent 自动化)
|
|
764
786
|
|
|
765
787
|
1. `proxyctl doctor --json` ← 最快,5 项布尔 + score(+ engine/mode/lock_path 信息字段)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|