frpsctl 0.2.3__tar.gz → 0.2.4__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.
- {frpsctl-0.2.3 → frpsctl-0.2.4}/.github/workflows/ci.yml +19 -1
- {frpsctl-0.2.3 → frpsctl-0.2.4}/CHANGELOG.md +61 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/PKG-INFO +18 -11
- {frpsctl-0.2.3 → frpsctl-0.2.4}/README.md +17 -10
- {frpsctl-0.2.3 → frpsctl-0.2.4}/frpsctl-/350/256/276/350/256/241/346/226/271/346/241/210.md +102 -6
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/__init__.py +1 -1
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/cli/__init__.py +13 -20
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/config.py +87 -16
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/transaction.py +62 -9
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/web/api.py +84 -8
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/web/auth.py +19 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/web/server.py +15 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/web/static/index.html +433 -99
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_docs.py +58 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_integration.py +86 -1
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_units.py +120 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_web.py +158 -0
- frpsctl-0.2.4/tests/test_web_frontend.py +137 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/.github/workflows/release.yml +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/.gitignore +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/LICENSE +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/NOTICE +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/install.sh +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/pyproject.toml +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/__main__.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/cli/context.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/cli/ui.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/__init__.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/admin.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/diagnostics.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/doctor.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/health.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/healthcheck.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/instance.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/lifecycle.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/lock.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/logs.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/platform.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/release.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/schema.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/systemd.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/core/version.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/errors.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/plugin/__init__.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/plugin/audit.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/plugin/engine.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/plugin/policy.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/plugin/quota.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/plugin/server.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/plugin/types.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/py.typed +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/src/frpsctl/web/__init__.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/__init__.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/conftest.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/fake_frps.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/faults.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_cli.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_facts.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_faults.py +0 -0
- {frpsctl-0.2.3 → frpsctl-0.2.4}/tests/test_plugin.py +0 -0
|
@@ -112,6 +112,15 @@ jobs:
|
|
|
112
112
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
113
113
|
return json.load(resp)
|
|
114
114
|
|
|
115
|
+
def post(path, body):
|
|
116
|
+
req = urllib.request.Request(
|
|
117
|
+
base + path, method="POST",
|
|
118
|
+
data=json.dumps(body).encode(),
|
|
119
|
+
headers={"Content-Type": "application/json", "Cookie": cookie, "X-CSRF-Token": csrf},
|
|
120
|
+
)
|
|
121
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
122
|
+
return json.load(resp)
|
|
123
|
+
|
|
115
124
|
status = call("/api/status")
|
|
116
125
|
assert status["state"] == "STOPPED", status
|
|
117
126
|
assert call("/api/session")["csrf"] == csrf
|
|
@@ -122,9 +131,18 @@ jobs:
|
|
|
122
131
|
# 端到端冒烟里的 config set 会留下快照:history 必须真的读得到
|
|
123
132
|
# (只断言"是列表"的话,空列表也能骗过这层守卫)。
|
|
124
133
|
assert any(str(e.get("action", "")).startswith("set ") for e in history["entries"]), history
|
|
134
|
+
# 回滚前的"看差异":先经 API 改一个键(实例未运行 → 只写不重启),
|
|
135
|
+
# 再断言差里真的出现它。
|
|
136
|
+
preview = post("/api/config/preview", {"changes": [["maxPortsPerClient", "35"]]})
|
|
137
|
+
applied = post("/api/config/apply", {"preview_id": preview["preview_id"]})
|
|
138
|
+
assert applied["applied"] is True, applied
|
|
139
|
+
diff = call("/api/config/history/1/diff")
|
|
140
|
+
assert "maxPortsPerClient" in diff["diff"], diff
|
|
141
|
+
favicon = urllib.request.urlopen(base + "/favicon.ico", timeout=10)
|
|
142
|
+
assert favicon.status == 204
|
|
125
143
|
html = urllib.request.urlopen(base + "/", timeout=10).read().decode("utf-8")
|
|
126
144
|
assert "frpsctl 管理台" in html
|
|
127
|
-
print("web 冒烟通过:login / session / status / config / history / 静态页")
|
|
145
|
+
print("web 冒烟通过:login / session / status / config / history / diff / favicon / 静态页")
|
|
128
146
|
EOF
|
|
129
147
|
kill "$WEB_PID"
|
|
130
148
|
wait "$WEB_PID" 2>/dev/null || true
|
|
@@ -3,6 +3,67 @@
|
|
|
3
3
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
4
4
|
版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## [0.2.4] - 2026-09-17
|
|
7
|
+
|
|
8
|
+
Web 管理台的体验迭代:把后端已有的能力全部交付到界面,并给单文件前端补上
|
|
9
|
+
自动化守卫。修复 5 条真实缺陷(含一条"白屏而 CI 全绿"的测试盲区)。
|
|
10
|
+
|
|
11
|
+
### 修复
|
|
12
|
+
|
|
13
|
+
- **前端在 CI 里几乎零防护**:所有测试与冒烟都只走 HTTP、不执行 JS——一处
|
|
14
|
+
语法错误会让整站白屏而 CI 全绿。新增 `tests/test_web_frontend.py`:逐
|
|
15
|
+
`<script>` 块 `node --check`(无 node 时 skip)+ 静态纪律守卫(禁 innerHTML
|
|
16
|
+
家族、禁外部资源引用、CSS 变量双向对齐、亮色主题必须覆盖全部颜色变量)。
|
|
17
|
+
- **配置页切换视图会丢失未保存的修改**:每次切回配置页都重新拉取并清空草稿。
|
|
18
|
+
现在已加载过就不重载,"重新加载(丢弃修改)"是显式动作(有未保存修改时
|
|
19
|
+
二次确认)。
|
|
20
|
+
- **日志面板永远自动滚底**:`logScroll` 恒为 true,用户向上翻日志会被 5 秒
|
|
21
|
+
刷新拽回底部。现在按真实滚动位置维护"跟随/暂停"(显示在标题栏,回到底部
|
|
22
|
+
自动恢复)。
|
|
23
|
+
- **清理离线记录后不刷新列表**:用户看到条目还在,以为操作没生效。
|
|
24
|
+
- **认证会话表无上限**:持有口令的调用方反复登录可持续推高内存——与失败
|
|
25
|
+
来源表同类的"输入驱动的表必须有界",现在上限 32(驱逐最早到期者)。
|
|
26
|
+
- 发布前回归 review 追加修复:**`plan_change_many` 把字符串当列表逐字符迭代**
|
|
27
|
+
(`unsets="ab"` 会静默删掉 `a` 与 `b` 两个键;对抗性实测复现,现为用法错误,
|
|
28
|
+
输入归一化单点化在 core 并覆盖 `changes`/`unsets` 两种形状);`snapshot_diff`
|
|
29
|
+
与 `rollback_to` 的 `steps < 1` 曾被 `max(0, steps-1)` 静默归一成"一步"
|
|
30
|
+
(core 入口补防御,与 CLI 的 min=1 同一条纪律);代理柱状图的柱宽 clamp
|
|
31
|
+
(防御"负宽度静默不渲染")。
|
|
32
|
+
|
|
33
|
+
### 新增
|
|
34
|
+
|
|
35
|
+
- **回滚前"查看差异"**:新接口 `GET /api/config/history/{steps}/diff`(打码,
|
|
36
|
+
与 CLI `config diff --steps` 共用 core 的 `snapshot_diff`)——回滚从盲操作
|
|
37
|
+
变为可预览;历史表每行都有"查看差异"按钮。
|
|
38
|
+
- **Web 配置删除键**:配置表单每行"删除"按钮 → 与修改合并成一次事务
|
|
39
|
+
(`plan_change_many` + `apply_sets(unsets=…)`:一份快照、一次重启);
|
|
40
|
+
"删除不存在的键"照旧是配置错误。
|
|
41
|
+
- **Web 配置新增键**:此前只能改已有键,新增必须回到 CLI;现在表单底部可
|
|
42
|
+
添加任意键(预览/校验/危险组合拦截与 CLI 同一套)。
|
|
43
|
+
- **单代理流量曲线**:点击代理行展开该代理的 7 天曲线(数据源与 CLI
|
|
44
|
+
`traffic <name>` 相同)。
|
|
45
|
+
- **操作进行中状态**:启动/重启/停止/回滚/应用期间按钮禁用、状态徽章显示
|
|
46
|
+
"操作中…"(启动最长等 10 秒,此前完全无反馈);轮询与手动刷新不会叠加。
|
|
47
|
+
- **状态面板补齐**:state.json 损坏红色横幅(含处置指引)、0.70.x 版本告警、
|
|
48
|
+
L3 插件告警(`plugin_warning` 此前没有下发)、systemd unit 行、流量超 50
|
|
49
|
+
个代理时"已截断"提示(CLI 有、Web 曾静默)。
|
|
50
|
+
- **双主题**:`prefers-color-scheme` 自动 + 手动切换(本地持久化);图表
|
|
51
|
+
颜色改由 CSS 变量控制,切换即时生效。
|
|
52
|
+
- 界面打磨:diff 语法高亮(+绿/−红)、图表 hover 数值与合计、实时速率文本、
|
|
53
|
+
表格数字右对齐与状态 tag、错误 toast 常驻(手动关闭)、日志"跟随/暂停"、
|
|
54
|
+
内嵌 favicon(`/favicon.ico` 收尾返回 204)、窄屏"菜单"折叠。
|
|
55
|
+
|
|
56
|
+
### 工程
|
|
57
|
+
|
|
58
|
+
- `snapshot_diff` 下沉 core(CLI `config diff` 与 Web 差异接口的唯一实现);
|
|
59
|
+
`plan_change_many` 统一混合变更;快照动作文案 `set many:` → `edit many:`。
|
|
60
|
+
- 设计文档 §18.2 的 API 表补上 `/api/session`、`/api/config/history`、
|
|
61
|
+
`/api/config/history/{steps}/diff`(长期 drift),并把"API 表 ↔ 路由"
|
|
62
|
+
双向核对加入 `tests/test_docs.py`。
|
|
63
|
+
- 新增 35 条测试(510 → 545;非契约 515),覆盖率 85%;前端单文件 650 → 984 行。
|
|
64
|
+
其中前端守卫含 **JS 对 id 的引用与 HTML 定义的双向核对**——`node --check`
|
|
65
|
+
抓不到的"id 拼错 = 白屏"由此变成 CI 断言。
|
|
66
|
+
|
|
6
67
|
## [0.2.3] - 2026-09-17
|
|
7
68
|
|
|
8
69
|
全量通读后的根治性迭代:Web 参数边界、systemd 交叉状态、限速来源模型、分层
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: frpsctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: 把 frp 服务端(frps)包装成命令行工具:配置翻译器 + 进程保镖 + 状态聚合器
|
|
5
5
|
Project-URL: Homepage, https://github.com/ThzxxArt/frpsctl
|
|
6
6
|
Project-URL: Repository, https://github.com/ThzxxArt/frpsctl
|
|
@@ -792,12 +792,15 @@ Web 管理台:http://127.0.0.1:8787/
|
|
|
792
792
|
Ctrl-C 停止。
|
|
793
793
|
```
|
|
794
794
|
|
|
795
|
-
|
|
795
|
+
打开浏览器即可——单文件前端(明暗双主题,跟随系统并可手动切换),零外部资源(不加载任何 CDN):
|
|
796
796
|
|
|
797
797
|
| 页面 | 内容 |
|
|
798
798
|
|------|------|
|
|
799
|
-
| 仪表盘 | 实例状态 / 三层健康 / 客户端与代理列表 / 近 7
|
|
800
|
-
| 配置 |
|
|
799
|
+
| 仪表盘 | 实例状态 / 三层健康 / 客户端与代理列表 / 近 7 天流量柱状图(悬停看数值,点代理行展开单代理曲线)/ 会话内实时流量曲线 / 日志(5 秒自动刷新,向上翻自动暂停跟随)。操作进行中有"操作中…"状态,清理离线记录后列表即时刷新 |
|
|
800
|
+
| 配置 | 逐字段表单(敏感值以打码形式提示"已设置",留空表示不改;可**删除**任意键回落默认值、也可**新增**键)→ 预览打码 diff(+绿/−红)→ 确认应用(一次事务、一次重启)→ 历史回滚(**先看差异再回滚**) |
|
|
801
|
+
|
|
802
|
+
状态异常不会藏在角落:state.json 损坏、0.70.x 缺安全修复、插件不可达(客户端
|
|
803
|
+
将无法登录)、流量超 50 个代理被截断——都会在页面顶部横幅或图表下方明确写出。
|
|
801
804
|
|
|
802
805
|
**安全设计**(比 frp 自带 dashboard 更严——它正是本项目安全决策的来源):
|
|
803
806
|
|
|
@@ -819,9 +822,10 @@ sudo frpsctl web service uninstall
|
|
|
819
822
|
> `web service install` 同样需要 `frpsctl` 位于系统路径(不能被 `ProtectHome`
|
|
820
823
|
> 挡住)——与插件服务的部署要求一致。
|
|
821
824
|
|
|
822
|
-
配置页里还有**历史与回滚**:列出最近 10 份快照(时间 / 动作 /
|
|
823
|
-
|
|
824
|
-
|
|
825
|
+
配置页里还有**历史与回滚**:列出最近 10 份快照(时间 / 动作 / 步数),每行可
|
|
826
|
+
先"查看差异"再决定是否回滚到那一份(差异接口 `GET /api/config/history/{steps}/diff`
|
|
827
|
+
与 `config diff --steps` 共用同一实现,同样打码;列表接口只读快照元数据,
|
|
828
|
+
**不下发配置原文**——快照是含 token 与口令的完整副本)。
|
|
825
829
|
|
|
826
830
|
生成的口令随时可以取回(权限过宽时会告警):
|
|
827
831
|
|
|
@@ -1222,17 +1226,19 @@ FRPSCTL_TRACEBACK=1 frpsctl status
|
|
|
1222
1226
|
|
|
1223
1227
|
```bash
|
|
1224
1228
|
uv venv && uv pip install -e ".[dev]"
|
|
1225
|
-
.venv/bin/pytest # 全部
|
|
1226
|
-
.venv/bin/pytest -m "not contract" # 快速回归(
|
|
1229
|
+
.venv/bin/pytest # 全部 545 条(契约层缺二进制时自动 skip)
|
|
1230
|
+
.venv/bin/pytest -m "not contract" # 快速回归(515 条)
|
|
1227
1231
|
.venv/bin/pytest --cov=frpsctl # 覆盖率(CI 门禁 80%,当前 85%)
|
|
1228
1232
|
.venv/bin/ruff check src/ tests/ # 静态分析
|
|
1229
1233
|
```
|
|
1230
1234
|
|
|
1231
1235
|
CI(Linux,Python 3.11/3.12/3.13/3.14)还包含:ruff、覆盖率门禁、真 frp 0.71.0
|
|
1232
1236
|
契约层、**真 frp 0.70.0 下界契约矩阵**、无二进制降级路径、端到端冒烟与
|
|
1233
|
-
Web
|
|
1237
|
+
Web 管理台冒烟(含配置差异接口)、**前端静态守卫**(逐 `<script>` 块
|
|
1238
|
+
`node --check` + 禁 innerHTML/外部资源)、**文档一致性守卫**(README/设计文档/
|
|
1239
|
+
API 表 vs 代码的交叉核对)。
|
|
1234
1240
|
|
|
1235
|
-
###
|
|
1241
|
+
### 测试分七层
|
|
1236
1242
|
|
|
1237
1243
|
| 层 | 文件 | 目标 |
|
|
1238
1244
|
|----|------|------|
|
|
@@ -1242,6 +1248,7 @@ Web 管理台冒烟、**文档一致性守卫**(README/设计文档 vs 代码
|
|
|
1242
1248
|
| 契约 | `tests/test_facts.py` | **设计文档事实基线的自动化守卫**(需真 frps) |
|
|
1243
1249
|
| 故障注入 | `tests/test_faults.py` | 注入系统调用失败,验证异常路径的五项不变量 |
|
|
1244
1250
|
| 插件 | `tests/test_plugin.py` | 协议报文、裁决、审计、配额;含真 frpc 端到端契约 |
|
|
1251
|
+
| 前端与文档 | `tests/test_web_frontend.py`、`tests/test_docs.py` | 单文件前端的静态守卫(`node --check`、禁 innerHTML/外部资源、CSS 变量对齐)与 README / 设计文档 / API 表的双向一致性 |
|
|
1245
1252
|
|
|
1246
1253
|
让契约层跑起来(需要真实二进制):
|
|
1247
1254
|
|
|
@@ -769,12 +769,15 @@ Web 管理台:http://127.0.0.1:8787/
|
|
|
769
769
|
Ctrl-C 停止。
|
|
770
770
|
```
|
|
771
771
|
|
|
772
|
-
|
|
772
|
+
打开浏览器即可——单文件前端(明暗双主题,跟随系统并可手动切换),零外部资源(不加载任何 CDN):
|
|
773
773
|
|
|
774
774
|
| 页面 | 内容 |
|
|
775
775
|
|------|------|
|
|
776
|
-
| 仪表盘 | 实例状态 / 三层健康 / 客户端与代理列表 / 近 7
|
|
777
|
-
| 配置 |
|
|
776
|
+
| 仪表盘 | 实例状态 / 三层健康 / 客户端与代理列表 / 近 7 天流量柱状图(悬停看数值,点代理行展开单代理曲线)/ 会话内实时流量曲线 / 日志(5 秒自动刷新,向上翻自动暂停跟随)。操作进行中有"操作中…"状态,清理离线记录后列表即时刷新 |
|
|
777
|
+
| 配置 | 逐字段表单(敏感值以打码形式提示"已设置",留空表示不改;可**删除**任意键回落默认值、也可**新增**键)→ 预览打码 diff(+绿/−红)→ 确认应用(一次事务、一次重启)→ 历史回滚(**先看差异再回滚**) |
|
|
778
|
+
|
|
779
|
+
状态异常不会藏在角落:state.json 损坏、0.70.x 缺安全修复、插件不可达(客户端
|
|
780
|
+
将无法登录)、流量超 50 个代理被截断——都会在页面顶部横幅或图表下方明确写出。
|
|
778
781
|
|
|
779
782
|
**安全设计**(比 frp 自带 dashboard 更严——它正是本项目安全决策的来源):
|
|
780
783
|
|
|
@@ -796,9 +799,10 @@ sudo frpsctl web service uninstall
|
|
|
796
799
|
> `web service install` 同样需要 `frpsctl` 位于系统路径(不能被 `ProtectHome`
|
|
797
800
|
> 挡住)——与插件服务的部署要求一致。
|
|
798
801
|
|
|
799
|
-
配置页里还有**历史与回滚**:列出最近 10 份快照(时间 / 动作 /
|
|
800
|
-
|
|
801
|
-
|
|
802
|
+
配置页里还有**历史与回滚**:列出最近 10 份快照(时间 / 动作 / 步数),每行可
|
|
803
|
+
先"查看差异"再决定是否回滚到那一份(差异接口 `GET /api/config/history/{steps}/diff`
|
|
804
|
+
与 `config diff --steps` 共用同一实现,同样打码;列表接口只读快照元数据,
|
|
805
|
+
**不下发配置原文**——快照是含 token 与口令的完整副本)。
|
|
802
806
|
|
|
803
807
|
生成的口令随时可以取回(权限过宽时会告警):
|
|
804
808
|
|
|
@@ -1199,17 +1203,19 @@ FRPSCTL_TRACEBACK=1 frpsctl status
|
|
|
1199
1203
|
|
|
1200
1204
|
```bash
|
|
1201
1205
|
uv venv && uv pip install -e ".[dev]"
|
|
1202
|
-
.venv/bin/pytest # 全部
|
|
1203
|
-
.venv/bin/pytest -m "not contract" # 快速回归(
|
|
1206
|
+
.venv/bin/pytest # 全部 545 条(契约层缺二进制时自动 skip)
|
|
1207
|
+
.venv/bin/pytest -m "not contract" # 快速回归(515 条)
|
|
1204
1208
|
.venv/bin/pytest --cov=frpsctl # 覆盖率(CI 门禁 80%,当前 85%)
|
|
1205
1209
|
.venv/bin/ruff check src/ tests/ # 静态分析
|
|
1206
1210
|
```
|
|
1207
1211
|
|
|
1208
1212
|
CI(Linux,Python 3.11/3.12/3.13/3.14)还包含:ruff、覆盖率门禁、真 frp 0.71.0
|
|
1209
1213
|
契约层、**真 frp 0.70.0 下界契约矩阵**、无二进制降级路径、端到端冒烟与
|
|
1210
|
-
Web
|
|
1214
|
+
Web 管理台冒烟(含配置差异接口)、**前端静态守卫**(逐 `<script>` 块
|
|
1215
|
+
`node --check` + 禁 innerHTML/外部资源)、**文档一致性守卫**(README/设计文档/
|
|
1216
|
+
API 表 vs 代码的交叉核对)。
|
|
1211
1217
|
|
|
1212
|
-
###
|
|
1218
|
+
### 测试分七层
|
|
1213
1219
|
|
|
1214
1220
|
| 层 | 文件 | 目标 |
|
|
1215
1221
|
|----|------|------|
|
|
@@ -1219,6 +1225,7 @@ Web 管理台冒烟、**文档一致性守卫**(README/设计文档 vs 代码
|
|
|
1219
1225
|
| 契约 | `tests/test_facts.py` | **设计文档事实基线的自动化守卫**(需真 frps) |
|
|
1220
1226
|
| 故障注入 | `tests/test_faults.py` | 注入系统调用失败,验证异常路径的五项不变量 |
|
|
1221
1227
|
| 插件 | `tests/test_plugin.py` | 协议报文、裁决、审计、配额;含真 frpc 端到端契约 |
|
|
1228
|
+
| 前端与文档 | `tests/test_web_frontend.py`、`tests/test_docs.py` | 单文件前端的静态守卫(`node --check`、禁 innerHTML/外部资源、CSS 变量对齐)与 README / 设计文档 / API 表的双向一致性 |
|
|
1222
1229
|
|
|
1223
1230
|
让契约层跑起来(需要真实二进制):
|
|
1224
1231
|
|
|
@@ -2476,13 +2476,17 @@ frpsctl web serve(独立进程,默认只绑 127.0.0.1)
|
|
|
2476
2476
|
| 方法 | 路径 | 说明 |
|
|
2477
2477
|
|------|------|------|
|
|
2478
2478
|
| POST | `/api/login` `/api/logout` | 口令登录(下发会话 Cookie + CSRF)/ 登出;**login 是唯一免认证入口** |
|
|
2479
|
-
| GET | `/api/
|
|
2480
|
-
| GET | `/api/
|
|
2479
|
+
| GET | `/api/session` | 会话状态:归还 CSRF(刷新页面后内存丢失 → 用它恢复,否则所有变更都 403) |
|
|
2480
|
+
| GET | `/api/status` | 进程状态 + 三层健康(含 L3 告警文本)+ dashboard 统计(统计不可得为 null) |
|
|
2481
|
+
| GET | `/api/clients` `/api/proxies` `/api/traffic` | v2 数据(自动翻页;traffic 为 7 天日粒度,超 50 个代理时带 `truncated` / `total`) |
|
|
2481
2482
|
| GET | `/api/config` | 配置树(**打码值 + masked 标记**,原文永不下发) |
|
|
2483
|
+
| GET | `/api/config/history` | 快照列表(只读 meta.json,**不读快照里的配置原文**) |
|
|
2484
|
+
| GET | `/api/config/history/{steps}/diff` | 某快照 vs 当前配置的**打码 diff**(回滚前的"看差异";与 `config diff --steps` 同一实现) |
|
|
2482
2485
|
| GET | `/api/logs?lines=` | 日志尾部(≤2000 行,路径解析复用 `core/logs`) |
|
|
2483
|
-
|
|
|
2484
|
-
| POST | `/api/config/
|
|
2485
|
-
| POST | `/api/
|
|
2486
|
+
| GET | `/favicon.ico` | 204(页面内嵌 data URI 图标;这条是给旧工具收尾的) |
|
|
2487
|
+
| POST | `/api/config/preview` | 多键变更(`changes`)+ 删除键(`unsets`)→ 锁内取快照 + 打码 diff,登记 `preview_id`(TTL 10 分钟) |
|
|
2488
|
+
| POST | `/api/config/apply` | 按 `preview_id` 应用(含删除);**CAS**:预览后文件被改 → 400 拒绝而不是覆盖 |
|
|
2489
|
+
| POST | `/api/actions/{start,stop,restart,rollback,prune}` | 与 CLI 同一套 core 入口(数值参数做范围校验,越界/布尔一律 400) |
|
|
2486
2490
|
|
|
2487
2491
|
错误映射:`FrpsctlError.exit_code` → HTTP(用法/配置 400、未运行/冲突 409、
|
|
2488
2492
|
权限 403、dashboard 不可达/健康未过 502)——响应只含 `message` 与 `hint`,
|
|
@@ -2500,9 +2504,11 @@ frp 的教训(user/password 双空 = 完全不鉴权,§3.3)是本项目全
|
|
|
2500
2504
|
| 会话劫持 | 256 位随机 token;Cookie `HttpOnly` + `SameSite=Strict`;TTL 8h(内存态) |
|
|
2501
2505
|
| CSRF | 一切变更请求要求 `X-CSRF-Token`(登录下发,仅存浏览器内存) |
|
|
2502
2506
|
| 口令爆破 | 来源级失败限速(60s/5 次);冷却与错口令**响应完全一致** |
|
|
2507
|
+
| 反代部署下的爆破误伤 | `--trusted-proxy`(默认**关**):开启后按 `X-Forwarded-For` **最后一跳**限速;不开启时该头完全不被读取——伪造它既不能绕开限速、也不能制造新来源 |
|
|
2503
2508
|
| 时序侧信道 | `hmac.compare_digest` |
|
|
2509
|
+
| 内存放大(失败来源 / 会话表) | 两张输入驱动的表都**有上限**:失败来源 1024(驱逐最早失败者)、会话 32(驱逐最早到期者) |
|
|
2504
2510
|
| 配置泄露 | 界面/API 只出打码值;欲看明文用 CLI `--reveal` |
|
|
2505
|
-
| 前端供应链 | 单文件、零外部资源;CSP `default-src 'none'` + `connect-src 'self'` |
|
|
2511
|
+
| 前端供应链 | 单文件、零外部资源;CSP `default-src 'none'` + `connect-src 'self'`;`tests/test_web_frontend.py` 静态守卫(禁 innerHTML 家族、禁外部引用、JS 语法 `node --check`) |
|
|
2506
2512
|
|
|
2507
2513
|
### 18.4 systemd 托管
|
|
2508
2514
|
|
|
@@ -2536,6 +2542,9 @@ Web 端到端测试(真 frps + frpc)第一次调用"下线代理"就暴露
|
|
|
2536
2542
|
| HTTPS | 不做:默认回环明文即可;远程访问建议反向代理终结 TLS(文档已说明) |
|
|
2537
2543
|
| 配置的"全文编辑器" | 不做:表单式逐键编辑 + 预览 diff 更安全(原文不回传浏览器) |
|
|
2538
2544
|
| 强制下线在线代理 | 做不到:frp 没有该 API(§18.6);停掉对端 frpc 是唯一途径 |
|
|
2545
|
+
| 前端行为测试(DOM 级) | 不做**测试框架**:引入 jsdom/构建链会破坏"单文件零依赖"这个安全资产。语法与静态纪律由 `tests/test_web_frontend.py` 守卫(`node --check` + 禁 innerHTML/外部资源 + CSS 变量对齐),行为正确性由 HTTP 层全路由测试 + 真机冒烟覆盖;已知残余风险是"UI 逻辑分支只有人工点得到" |
|
|
2546
|
+
| CSP 去 `'unsafe-inline'`(nonce 化) | 暂不做:单文件内联脚本需要服务端渲染时注入 nonce,收益是纵深防御(当前无任何注入点),成本是前端从"静态文件直发"变成"每请求改写"。已记账,等有真实动机再动 |
|
|
2547
|
+
| 配置表单的结构化数组编辑器 | 暂不做:`allowPorts` 这类数组目前按 JSON 文本编辑(有 `parse_scalar` 兜底与预览 diff 兜底);等实际使用中确认痛点再设计 |
|
|
2539
2548
|
|
|
2540
2549
|
### 18.8 发布前回归 review(第八轮)
|
|
2541
2550
|
|
|
@@ -2669,6 +2678,93 @@ JS 经 `node --check` 语法验证通过。
|
|
|
2669
2678
|
|
|
2670
2679
|
---
|
|
2671
2680
|
|
|
2681
|
+
## 20. 第七轮全量迭代(v0.2.4:Web 体验与前端守卫)
|
|
2682
|
+
|
|
2683
|
+
本轮聚焦 Web 管理台的优化与增强、易用性、可用性、美观性。方法仍是**完整通读
|
|
2684
|
+
全部源码、测试与文档(无截断)后逐条核对**——这一轮暴露出一个此前从未被
|
|
2685
|
+
正视的断层:
|
|
2686
|
+
|
|
2687
|
+
> **后端能力已经齐了,前端没有把它们全部用出来;而前端本身在 CI 里几乎零防护。**
|
|
2688
|
+
|
|
2689
|
+
证据都在代码里(不是推测):`index.html` 的 `logScroll` 恒为 true(日志永远
|
|
2690
|
+
自动滚底)、API 已下发打码值而前端丢弃、`state_corrupted` / `version_hint` /
|
|
2691
|
+
`plugin_warning` 三个后端字段前端从未使用、traffic 超 50 代理静默截断(CLI
|
|
2692
|
+
会告警)、`history` 接口长期不进 §18.2 API 表;而全部测试与冒烟只走 HTTP——
|
|
2693
|
+
**一处 JS 语法错误会让整站白屏而 CI 全绿**。
|
|
2694
|
+
|
|
2695
|
+
### 20.1 修复(5 条,含一条测试盲区)
|
|
2696
|
+
|
|
2697
|
+
| # | 问题 | 根因 | 根治方式 |
|
|
2698
|
+
|---|------|------|---------|
|
|
2699
|
+
| 1 | 前端无任何自动化保护 | 测试与冒烟都只走 HTTP,从不执行 JS | `tests/test_web_frontend.py`:逐 `<script>` 块 `node --check` + 静态纪律守卫(禁 innerHTML 家族 / 禁外部资源 / CSS 变量双向对齐 / 亮色主题覆盖检查) |
|
|
2700
|
+
| 2 | 配置页切换视图丢草稿 | 每次切回都 `loadConfig()` 清空 dirty | `configLoaded` 标志:已加载不重载;"重新加载(丢弃修改)"是显式动作且二次确认 |
|
|
2701
|
+
| 3 | 日志永远自动滚底 | `logScroll` 定义后从未被改写,条件恒真 | 真实滚动监听维护"跟随/暂停"状态并显示在标题栏;滚到底部自动恢复 |
|
|
2702
|
+
| 4 | 清理离线记录后不刷新 | `pruneOffline` 成功路径没有后续刷新 | 成功后 `refreshLists()` |
|
|
2703
|
+
| 5 | 会话表无上限 | 惰性清理只处理过期;持有口令者可反复登录 | `MAX_SESSIONS = 32`,驱逐最早到期者(与失败来源表同一护栏纪律) |
|
|
2704
|
+
|
|
2705
|
+
### 20.2 新增(把后端已有能力交付到界面)
|
|
2706
|
+
|
|
2707
|
+
| 能力 | 落点 |
|
|
2708
|
+
|------|------|
|
|
2709
|
+
| 回滚前"查看差异" | `snapshot_diff` 下沉 core(CLI `config diff` 与 `GET /api/config/history/{steps}/diff` 唯一实现);历史表逐行"查看差异",回滚从盲操作变为可预览 |
|
|
2710
|
+
| Web 删除键 | `plan_change_many` + `apply_sets(unsets=…)`:改与删合成**一次事务**(一份快照、一次重启);同键冲突是用法错误 |
|
|
2711
|
+
| Web 新增键 | 配置表单底部添加任意键(预览/校验/危险组合拦截与 CLI 同一套)——此前新增必须回 CLI |
|
|
2712
|
+
| 单代理流量曲线 | 点击代理行展开该代理 7 天曲线(数据与 CLI `traffic <name>` 同源) |
|
|
2713
|
+
| 操作进行中状态 | start/stop/restart/回滚/应用期间按钮禁用 + 状态徽章"操作中…";in-flight 守卫防轮询叠加(start 最长 10 秒,此前零反馈) |
|
|
2714
|
+
| 状态面板补齐 | 损坏横幅(含处置指引)、版本告警、L3 插件告警(`plugin_warning` 此前未下发)、systemd 行、流量截断提示 |
|
|
2715
|
+
| 双主题 | `prefers-color-scheme` 自动 + 手动切换(localStorage);未手动选择时跟随系统实时变化;图表颜色改由 CSS 变量控制 |
|
|
2716
|
+
| 界面打磨 | diff 语法高亮、图表 hover 数值与合计、实时速率文本、表格数字右对齐、状态 tag、错误 toast 常驻、内嵌 favicon(`/favicon.ico` → 204)、窄屏菜单折叠 |
|
|
2717
|
+
|
|
2718
|
+
### 20.3 工程
|
|
2719
|
+
|
|
2720
|
+
- 设计文档 §18.2 补上 `/api/session`、`/api/config/history`、
|
|
2721
|
+
`/api/config/history/{steps}/diff`(长期 drift),并新增
|
|
2722
|
+
`tests/test_docs.py::TestApiDocConsistency`——**API 表 ↔ 路由双向核对**,
|
|
2723
|
+
与命令表守卫同一条纪律;
|
|
2724
|
+
- 快照动作文案 `set many:` → `edit many:`(混合变更语义更准确,快照记账同步);
|
|
2725
|
+
- §18.7 边界新增三项明确记账:不做 DOM 级前端测试框架(残余风险如实标注)、
|
|
2726
|
+
CSP nonce 化暂缓的理由、结构化数组编辑器暂缓的理由。
|
|
2727
|
+
|
|
2728
|
+
### 20.4 统计与边界(明确记账)
|
|
2729
|
+
|
|
2730
|
+
统计:修复 **7 条**(实施期 5 条 + 发布前 review 2 条)、新增 8 项能力、
|
|
2731
|
+
新增 **35 条**测试(510 → 545;非契约 515),覆盖率 85%,前端单文件
|
|
2732
|
+
650 → 984 行。
|
|
2733
|
+
|
|
2734
|
+
| 项 | 说明 |
|
|
2735
|
+
|----|------|
|
|
2736
|
+
| UI 逻辑分支的人工覆盖 | 语法/纪律已自动化,但"点击某按钮后 DOM 变化"仍只有人工点得到——不引入 jsdom 是刻意的(见 §18.7) |
|
|
2737
|
+
| `allowPorts` 等数组仍按 JSON 文本编辑 | `parse_scalar` 与预览 diff 兜底;结构化编辑器等真实痛点 |
|
|
2738
|
+
| 前端单文件会继续变大(本轮 650 → 984 行) | 拆分需要构建链,与"零外部资源"冲突;在行数带来实际维护痛点前不拆 |
|
|
2739
|
+
|
|
2740
|
+
### 20.5 发布前回归 review
|
|
2741
|
+
|
|
2742
|
+
方法同历次(§17.10 / §17.11 / §19.4):**全部 diff 逐行审查 + 对抗性实测 +
|
|
2743
|
+
测试基建复查**。对抗面集中在两处新代码——core 的混合变更入口与 984 行的
|
|
2744
|
+
单文件前端。
|
|
2745
|
+
|
|
2746
|
+
发现并修复 **2 条真实缺陷**:
|
|
2747
|
+
|
|
2748
|
+
| # | 问题 | 复现 | 修复 |
|
|
2749
|
+
|---|------|------|------|
|
|
2750
|
+
| 1 | `plan_change_many` 把字符串当列表**逐字符迭代**:`unsets="ab"` 静默删掉 `a` 与 `b` 两个键(与 `policy._strict_list` 同型的经典陷阱);`changes="bindPort"` 则在解包处抛裸 `ValueError` | 对抗性实测复现("删掉的键: ('a','b')") | 输入归一化单点化到 `plan_change_many`(`_normalize_pairs` / `_normalize_keys`):字符串 / 字典 / 生成器一律用法错误;`apply_sets` 改为直接透传原始参数——"字符串当列表"没有第二个藏身处 |
|
|
2751
|
+
| 2 | `snapshot_diff` / `rollback_to` 的 `steps < 1` 被 `max(0, steps-1)` **静默归一**成"一步"——参数笔误变成另一个动作(v0.2.3 修过 CLI 侧,core 侧一直敞着) | 代码审查 + 边界实测 | 两个 core 入口都加 `steps >= 1` 防御(与 CLI 的 `min=1` 同一条纪律),各自新增回归用例 |
|
|
2752
|
+
|
|
2753
|
+
另有三条**加固**(非缺陷,但成本极低而静默失败风险真实):
|
|
2754
|
+
|
|
2755
|
+
- **JS id 交叉守卫**进 `test_web_frontend.py`:`$("id")` 引用 ↔ HTML 定义
|
|
2756
|
+
**双向核对**(当前 47/47 完全一致)——`node --check` 抓不到"id 拼错 =
|
|
2757
|
+
运行时 null = 白屏",这条守卫把该盲区关掉;
|
|
2758
|
+
- **柱状图柱宽 clamp**(`Math.max(1.5, …)`):点数异常变多时负宽度会静默不渲染;
|
|
2759
|
+
- **前端脚本真实执行烟测**(一次性,node + 最小 DOM stub):主脚本顶层与
|
|
2760
|
+
boot 异步路径完整执行无异常、`humanBytes` / `humanDuration` 的 7 组取值
|
|
2761
|
+
逐一对齐——覆盖语法检查抓不到的未定义变量 / TDZ 类错误。
|
|
2762
|
+
|
|
2763
|
+
残余风险如实记账:DOM 级行为(点击后的界面变化)仍只能人工验证(§18.7 的
|
|
2764
|
+
不做项),而 id 缺失这类"用户可见的白屏风险"已被自动化覆盖。
|
|
2765
|
+
|
|
2766
|
+
---
|
|
2767
|
+
|
|
2672
2768
|
## 附录 A:frps 配置键速查表
|
|
2673
2769
|
|
|
2674
2770
|
> 全部取自 `v0.71.0` 源码;「默认值」栏是 `Complete()` 之后的**生效值**。合法取值来自校验器。
|
|
@@ -29,7 +29,13 @@ from ..core.instance import list_instances
|
|
|
29
29
|
from ..core.lifecycle import Lifecycle, StartReport, State
|
|
30
30
|
from ..core.lock import instance_lock
|
|
31
31
|
from ..core.systemd import DEFAULT_SERVICE_USER, PluginService, Systemd, WebService
|
|
32
|
-
from ..core.transaction import
|
|
32
|
+
from ..core.transaction import (
|
|
33
|
+
apply_edit,
|
|
34
|
+
apply_set,
|
|
35
|
+
apply_unset,
|
|
36
|
+
rollback_to,
|
|
37
|
+
snapshot_diff,
|
|
38
|
+
)
|
|
33
39
|
from ..core.version import RECKONED_VERSION
|
|
34
40
|
from ..plugin.policy import PluginPolicy
|
|
35
41
|
from ..plugin.server import PluginServer, ServerSettings
|
|
@@ -1290,28 +1296,15 @@ def config_diff(
|
|
|
1290
1296
|
) -> None:
|
|
1291
1297
|
"""当前配置 vs 历史快照(unified diff)。"""
|
|
1292
1298
|
app_ctx = _ctx(ctx).with_json(json_output)
|
|
1293
|
-
|
|
1294
|
-
#
|
|
1295
|
-
|
|
1296
|
-
with instance_lock(inst.lock):
|
|
1297
|
-
entries = inst.history_entries()
|
|
1298
|
-
if not entries:
|
|
1299
|
-
raise ConfigError("没有配置快照", hint="快照在每次 config set / edit 时自动创建")
|
|
1300
|
-
index = max(0, steps - 1)
|
|
1301
|
-
if index >= len(entries):
|
|
1302
|
-
raise ConfigError(f"只找到 {len(entries)} 份快照")
|
|
1303
|
-
snapshot = entries[index] / "frps.toml"
|
|
1304
|
-
if not snapshot.exists():
|
|
1305
|
-
raise ConfigError(f"快照不完整:{snapshot}")
|
|
1306
|
-
snapshot_text = snapshot.read_text("utf-8")
|
|
1307
|
-
current_text = cfg.read_config_text(inst.config)
|
|
1308
|
-
diff = cfg.diff_texts(snapshot_text, current_text, "frps.toml")
|
|
1299
|
+
# 快照选择与读取在 core 的 snapshot_diff 里(锁内完成)——与 Web 的
|
|
1300
|
+
# "查看差异"共用同一份实现与边界错误。
|
|
1301
|
+
result = snapshot_diff(app_ctx.instance, steps=steps)
|
|
1309
1302
|
if app_ctx.json:
|
|
1310
|
-
ui.emit_json({"snapshot": str(snapshot
|
|
1303
|
+
ui.emit_json({"snapshot": str(result.snapshot), "diff": cfg.mask_diff(result.diff)})
|
|
1311
1304
|
else:
|
|
1312
|
-
ui.emit(cfg.mask_diff(diff).rstrip() or "(无差异)")
|
|
1305
|
+
ui.emit(cfg.mask_diff(result.diff).rstrip() or "(无差异)")
|
|
1313
1306
|
ui.emit("")
|
|
1314
|
-
ui.emit(f"# 快照:{snapshot.
|
|
1307
|
+
ui.emit(f"# 快照:{result.snapshot.name}")
|
|
1315
1308
|
|
|
1316
1309
|
|
|
1317
1310
|
@config_app.command("rollback")
|
|
@@ -53,6 +53,7 @@ __all__ = [
|
|
|
53
53
|
"flatten_tree",
|
|
54
54
|
"plan_set",
|
|
55
55
|
"plan_set_many",
|
|
56
|
+
"plan_change_many",
|
|
56
57
|
"plan_unset",
|
|
57
58
|
"parse_scalar",
|
|
58
59
|
"reject_template_syntax",
|
|
@@ -562,8 +563,12 @@ class ChangePlan:
|
|
|
562
563
|
class MultiChangePlan:
|
|
563
564
|
"""多键变更的内存补丁结果(Web 配置表单用)。
|
|
564
565
|
|
|
565
|
-
`
|
|
566
|
-
|
|
566
|
+
`before/after` 是逐键的旧值与新值(`dict`,键为点分路径)——供调用方展示
|
|
567
|
+
"改了哪些键";被删除的键 `after` 为 `None` 且出现在 `deletes` 里。
|
|
568
|
+
`is_noop` 在所有键都未变时为真。
|
|
569
|
+
|
|
570
|
+
⚠️ `dumps` 只调用**一次**:set 与 unset 作用在同一个文档上,因此一次变更
|
|
571
|
+
只产生一份快照、一次重启——把删除做成"先删再 set"两次调用会破坏这个性质。
|
|
567
572
|
"""
|
|
568
573
|
|
|
569
574
|
changes: tuple[tuple[str, str], ...]
|
|
@@ -571,6 +576,8 @@ class MultiChangePlan:
|
|
|
571
576
|
after: dict[str, Any]
|
|
572
577
|
text: str
|
|
573
578
|
diff: str
|
|
579
|
+
#: 被删除的键(`config unset` 语义:回落 frp 默认值)。
|
|
580
|
+
deletes: tuple[str, ...] = ()
|
|
574
581
|
|
|
575
582
|
@property
|
|
576
583
|
def is_noop(self) -> bool:
|
|
@@ -625,13 +632,82 @@ def plan_set(path: Path, dotted: str, raw: str) -> ChangePlan:
|
|
|
625
632
|
def plan_set_many(path: Path, changes: Any) -> MultiChangePlan:
|
|
626
633
|
"""对**多个键**做一次内存补丁(Web 配置表单:一次提交 → 一次重启)。
|
|
627
634
|
|
|
628
|
-
|
|
629
|
-
|
|
635
|
+
是 `plan_change_many` 的纯 set 形态(保留为独立入口,语义更直白)。
|
|
636
|
+
"""
|
|
637
|
+
return plan_change_many(path, changes, ())
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _delete_one(doc: Any, dotted: str) -> Any:
|
|
641
|
+
"""对文档就地删除一个键;返回被删的值(键不存在抛 `ConfigKeyMissing`)。
|
|
642
|
+
|
|
643
|
+
与 `plan_unset` 同一条语义:键不存在是配置错误(3),而不是静默 noop——
|
|
644
|
+
拼错键名的"成功删除"会让人以为清掉了某个设置(ADR-7:不猜测)。
|
|
645
|
+
"""
|
|
646
|
+
parts = _validate_dotted(dotted)
|
|
647
|
+
node: Any = doc
|
|
648
|
+
for part in parts[:-1]:
|
|
649
|
+
if not isinstance(node, dict) or part not in node:
|
|
650
|
+
raise ConfigKeyMissing(dotted)
|
|
651
|
+
node = node[part]
|
|
652
|
+
if not isinstance(node, dict) or parts[-1] not in node:
|
|
653
|
+
raise ConfigKeyMissing(dotted)
|
|
654
|
+
before = node[parts[-1]]
|
|
655
|
+
del node[parts[-1]]
|
|
656
|
+
return before
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _normalize_pairs(raw: Any) -> list[tuple[str, str]]:
|
|
660
|
+
"""把 `changes` 归一化成 `(key, value)` 列表;形状错误抛用法错误。
|
|
661
|
+
|
|
662
|
+
**拒绝字符串**:`for key, value in "ab"` 会解包失败(裸 ValueError),
|
|
663
|
+
而 `for key in "ab"` 这类形态在别处会**逐字符迭代**——"看似能跑"的输入
|
|
664
|
+
必须在边界变成明确报错(与 `plugin/policy.py` 的 `_strict_list` 同一条纪律)。
|
|
630
665
|
"""
|
|
631
|
-
|
|
666
|
+
if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)):
|
|
667
|
+
raise UsageError(f"changes 必须是 (键, 值) 的数组,实际是 {type(raw).__name__}")
|
|
668
|
+
out: list[tuple[str, str]] = []
|
|
669
|
+
for item in raw:
|
|
670
|
+
if isinstance(item, (list, tuple)) and len(item) == 2:
|
|
671
|
+
out.append((str(item[0]), str(item[1])))
|
|
672
|
+
else:
|
|
673
|
+
raise UsageError(f"无法识别的变更条目:{item!r}(应为 (key, value))")
|
|
674
|
+
return out
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _normalize_keys(raw: Any) -> list[str]:
|
|
678
|
+
"""把 `unsets` 归一化成键名列表;**字符串会被逐字符迭代,必须拒绝**。"""
|
|
679
|
+
if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)):
|
|
680
|
+
raise UsageError(f"unsets 必须是键名数组,实际是 {type(raw).__name__}")
|
|
681
|
+
return [str(item) for item in raw]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def plan_change_many(
|
|
685
|
+
path: Path,
|
|
686
|
+
changes: Any,
|
|
687
|
+
unsets: Any = (),
|
|
688
|
+
) -> MultiChangePlan:
|
|
689
|
+
"""对多个键做一次**混合**内存补丁:赋值(set)与删除(unset)在同一个文档上。
|
|
690
|
+
|
|
691
|
+
为什么要混合形态而不是两个入口相加:删除与赋值作用于**同一份文档**,
|
|
692
|
+
一次 `dumps` 就是合并结果——Web 的"改两个键 + 删一个键"因此只产生一份
|
|
693
|
+
快照、一次重启(§9 的事务语义);分成两次调用会重启两次,中间那次还可能
|
|
694
|
+
因为"只删了口令"撞上危险组合检查而被拒绝。
|
|
695
|
+
|
|
696
|
+
同一键同时出现在 set 与 unset 里是用法错误:两边的意图互相矛盾,静默取
|
|
697
|
+
其一都会让人以为变更生效了。
|
|
698
|
+
|
|
699
|
+
输入在**这里**统一归一化与防御(`apply_sets` 直接透传原始参数):
|
|
700
|
+
所有入口共用一套形状检查,"传字符串当列表"这类静默错误没有藏身处。
|
|
701
|
+
"""
|
|
702
|
+
items = _normalize_pairs(changes)
|
|
703
|
+
delete_keys = _normalize_keys(unsets)
|
|
704
|
+
conflict = {key for key, _ in items} & set(delete_keys)
|
|
705
|
+
if conflict:
|
|
706
|
+
raise UsageError(f"同一个键不能同时赋值与删除:{', '.join(sorted(conflict))}")
|
|
632
707
|
original = path.read_text("utf-8")
|
|
633
|
-
if not items:
|
|
708
|
+
if not items and not delete_keys:
|
|
634
709
|
return MultiChangePlan(changes=(), before={}, after={}, text=original, diff="")
|
|
710
|
+
|
|
635
711
|
doc = load_config(path)
|
|
636
712
|
before: dict[str, Any] = {}
|
|
637
713
|
after: dict[str, Any] = {}
|
|
@@ -639,6 +715,9 @@ def plan_set_many(path: Path, changes: Any) -> MultiChangePlan:
|
|
|
639
715
|
old, new = _set_one(doc, dotted, raw)
|
|
640
716
|
before[dotted] = old
|
|
641
717
|
after[dotted] = new
|
|
718
|
+
for dotted in delete_keys:
|
|
719
|
+
before[dotted] = _delete_one(doc, dotted)
|
|
720
|
+
after[dotted] = None
|
|
642
721
|
text = tomlkit.dumps(doc)
|
|
643
722
|
return MultiChangePlan(
|
|
644
723
|
changes=tuple(items),
|
|
@@ -646,6 +725,7 @@ def plan_set_many(path: Path, changes: Any) -> MultiChangePlan:
|
|
|
646
725
|
after=after,
|
|
647
726
|
text=text,
|
|
648
727
|
diff=diff_texts(original, text, path.name),
|
|
728
|
+
deletes=tuple(delete_keys),
|
|
649
729
|
)
|
|
650
730
|
|
|
651
731
|
|
|
@@ -660,16 +740,7 @@ def plan_unset(path: Path, dotted: str) -> ChangePlan:
|
|
|
660
740
|
对不存在的键报错同一条原则(ADR-7:不猜测)。
|
|
661
741
|
"""
|
|
662
742
|
doc = load_config(path)
|
|
663
|
-
|
|
664
|
-
node: Any = doc
|
|
665
|
-
for part in parts[:-1]:
|
|
666
|
-
if not isinstance(node, dict) or part not in node:
|
|
667
|
-
raise ConfigKeyMissing(dotted)
|
|
668
|
-
node = node[part]
|
|
669
|
-
if not isinstance(node, dict) or parts[-1] not in node:
|
|
670
|
-
raise ConfigKeyMissing(dotted)
|
|
671
|
-
before = node[parts[-1]]
|
|
672
|
-
del node[parts[-1]]
|
|
743
|
+
before = _delete_one(doc, dotted)
|
|
673
744
|
text = tomlkit.dumps(doc)
|
|
674
745
|
return ChangePlan(
|
|
675
746
|
dotted=dotted,
|