From 64df3cf331d3d0d8e97549d3cc5065d0384ec7bb Mon Sep 17 00:00:00 2001
From: liuzhuohua <29739020+liuzhuohua@users.noreply.github.com>
Date: Wed, 30 Sep 2026 19:26:24 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20Drouter=201.0.0=20=E2=80=94=E2=80=94=20?=
=?UTF-8?q?Debian=2013=20=E8=BD=AF=E8=B7=AF=E7=94=B1=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E7=B3=BB=E7=BB=9F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的
Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、
访问控制、DDNS、文件共享、Docker 管理等。
核心设计原则:不重新实现任何协议
PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。
Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。
面板删掉后,系统仍是一台配置完好的 Debian 路由器。
安全默认值
· 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务
· 所有改网络的动作都需要用户在面板上亲手点「应用」
· 避开 5900 端口,不触碰 VNC / X 会话
· 配置写入前先做语法预检,失败则回滚
架构
· backend/ Python 3 标准库,零第三方依赖(9 个模块)
· web/ 原生 JS/CSS,无构建步骤
· 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess
· 鉴权走 X-Token 请求头
打包
· packaging/build-deb.sh → drouter_1.0.0_all.deb(含 6 个 systemd 单元)
· scripts/make-offline-deps.sh → 236 个依赖 .deb + 本地仓库索引
· packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装
· packaging/build-docker.sh → 容器镜像(体验用)
测试
40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配),
全部挂在 devtools/sync.sh 的预检链里。
许可:MIT
---
.gitattributes | 46 +
.gitignore | 56 +
LICENSE | 21 +
README.md | 855 +
_dev/_out_audit.txt | Bin 0 -> 1394 bytes
_dev/build-image-remote.sh | 34 +
_dev/depcheck.py | 52 +
_dev/live-apply-probe.py | 99 +
_dev/live-ca.py | 450 +
_dev/live-check.py | 169 +
_dev/live-check4.py | 97 +
_dev/live-cleanup.py | 154 +
_dev/live-dcfg.py | 283 +
_dev/live-deps.py | 93 +
_dev/live-kern.py | 166 +
_dev/live-opensoho.py | 410 +
_dev/live-perf.py | 103 +
_dev/live-print.py | 361 +
_dev/live-pubip.py | 104 +
_dev/live-req1-6-10.py | 184 +
_dev/live-term-latency.py | 118 +
_dev/live-ulog-perf.py | 60 +
_dev/live-wait-probe.py | 99 +
_dev/live-webshell.py | 136 +
_dev/live-wizard-verify.py | 145 +
_dev/live-wizard.py | 427 +
_dev/probe-opensoho.sh | 81 +
_dev/probe-opensoho2.sh | 78 +
_dev/smoke.py | 64 +
_dev/t-acl-deploy.py | 156 +
_dev/t-acl-tz.py | 64 +
_dev/t-acl.py | 104 +
_dev/t-audit.py | 253 +
_dev/t-ca.py | 693 +
_dev/t-cachebust.py | 81 +
_dev/t-cleanup.py | 299 +
_dev/t-daemon-restart.py | 103 +
_dev/t-dcfg.py | 336 +
_dev/t-ddns.py | 66 +
_dev/t-deps.py | 570 +
_dev/t-docker-conv.py | 199 +
_dev/t-docker.py | 218 +
_dev/t-env-path.py | 100 +
_dev/t-fwlog-state.py | 139 +
_dev/t-gzip.py | 53 +
_dev/t-helpd.py | 110 +
_dev/t-kern.py | 202 +
_dev/t-linkage.py | 202 +
_dev/t-nat-detail.py | 103 +
_dev/t-nettotals.py | 113 +
_dev/t-opensoho.py | 826 +
_dev/t-packaging.py | 175 +
_dev/t-perf.py | 184 +
_dev/t-portfwd.py | 68 +
_dev/t-print.py | 827 +
_dev/t-pubip.py | 170 +
_dev/t-render.py | 54 +
_dev/t-smbnfs.py | 140 +
_dev/t-snapshot.py | 122 +
_dev/t-storage.py | 246 +
_dev/t-theme-api.py | 376 +
_dev/t-theme-parity.py | 276 +
_dev/t-theme.py | 593 +
_dev/t-ulog-perf.py | 88 +
_dev/t-ulog.py | 374 +
_dev/t-wanlog.py | 85 +
_dev/t-web-contract.py | 63 +
_dev/t-webshell.py | 198 +
_dev/t-webterm.js | 192 +
_dev/t-wizard.py | 361 +
_dev/test-offline-install.sh | 230 +
_dev/ulog-check.py | 39 +
_dev/ulog-detail.py | 39 +
backend/drouter-helpd.py | 235 +
backend/drouter-helper.py | 16906 +++++++++++++++++
backend/drouter-logd.py | 198 +
backend/drouter-rescue.py | 424 +
backend/drouter-shelld.py | 429 +
backend/drouter-snapshotd.py | 111 +
backend/drouter-web.py | 2229 +++
backend/render.py | 1355 ++
backend/theme.py | 690 +
devtools/askpass.sh | 2 +
devtools/check-mobile.js | 132 +
devtools/check-render.js | 289 +
devtools/check-views.js | 34 +
devtools/make-placeholder-shots.py | 130 +
devtools/rscp.sh | 34 +
devtools/rsh.sh | 5 +
devtools/rsudo.sh | 4 +
devtools/sim-ui.js | 124 +
devtools/sync.sh | 135 +
dist/.gitkeep | 0
docs/01-项目规格与架构.md | 625 +
docs/02-部署与使用手册.md | 609 +
docs/03-全量检查与性能优化验收报告.md | 109 +
docs/04-新需求进度报告(第1批).md | 152 +
docs/05-新需求进度报告(第2批).md | 678 +
docs/06-新手向导与全项目复查报告.md | 178 +
docs/screenshots/01-dash.svg | 92 +
docs/screenshots/02-iface.svg | 92 +
docs/screenshots/03-fw4.svg | 92 +
docs/screenshots/04-qos.svg | 92 +
docs/screenshots/05-flowlog.svg | 92 +
docs/screenshots/06-webshell.svg | 92 +
packaging/build-deb.sh | 101 +
packaging/build-docker.sh | 50 +
packaging/build-offline-bundle.sh | 174 +
packaging/deb/control | 57 +
packaging/deb/postinst | 92 +
packaging/deb/postrm | 39 +
packaging/deb/prerm | 28 +
packaging/deb/units/drouter-helpd.service | 28 +
packaging/deb/units/drouter-rescue.service | 19 +
packaging/deb/units/drouter-shelld.service | 30 +
packaging/deb/units/drouter-snapshot.service | 12 +
packaging/deb/units/drouter-snapshot.timer | 11 +
packaging/deb/units/drouter-web.service | 44 +
packaging/docker/Dockerfile | 75 +
packaging/docker/docker-init.sh | 49 +
packaging/offline-install.sh | 233 +
scripts/audit.sh | 46 +
scripts/claim-wan.sh | 27 +
scripts/cleanup-test.sh | 42 +
scripts/deploy.sh | 428 +
scripts/drouter-ctl.sh | 424 +
scripts/install-base.sh | 72 +
scripts/make-offline-deps.sh | 324 +
scripts/regression.sh | 49 +
scripts/test-frontend-contract.sh | 84 +
scripts/test-pppoe-save.sh | 30 +
web/app.css | 830 +
web/app.js | 11308 +++++++++++
web/index.html | 131 +
web/logo.svg | 18 +
135 files changed, 56059 insertions(+)
create mode 100644 .gitattributes
create mode 100644 .gitignore
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 _dev/_out_audit.txt
create mode 100644 _dev/build-image-remote.sh
create mode 100644 _dev/depcheck.py
create mode 100644 _dev/live-apply-probe.py
create mode 100644 _dev/live-ca.py
create mode 100644 _dev/live-check.py
create mode 100644 _dev/live-check4.py
create mode 100644 _dev/live-cleanup.py
create mode 100644 _dev/live-dcfg.py
create mode 100644 _dev/live-deps.py
create mode 100644 _dev/live-kern.py
create mode 100644 _dev/live-opensoho.py
create mode 100644 _dev/live-perf.py
create mode 100644 _dev/live-print.py
create mode 100644 _dev/live-pubip.py
create mode 100644 _dev/live-req1-6-10.py
create mode 100644 _dev/live-term-latency.py
create mode 100644 _dev/live-ulog-perf.py
create mode 100644 _dev/live-wait-probe.py
create mode 100644 _dev/live-webshell.py
create mode 100644 _dev/live-wizard-verify.py
create mode 100644 _dev/live-wizard.py
create mode 100644 _dev/probe-opensoho.sh
create mode 100644 _dev/probe-opensoho2.sh
create mode 100644 _dev/smoke.py
create mode 100644 _dev/t-acl-deploy.py
create mode 100644 _dev/t-acl-tz.py
create mode 100644 _dev/t-acl.py
create mode 100644 _dev/t-audit.py
create mode 100644 _dev/t-ca.py
create mode 100644 _dev/t-cachebust.py
create mode 100644 _dev/t-cleanup.py
create mode 100644 _dev/t-daemon-restart.py
create mode 100644 _dev/t-dcfg.py
create mode 100644 _dev/t-ddns.py
create mode 100644 _dev/t-deps.py
create mode 100644 _dev/t-docker-conv.py
create mode 100644 _dev/t-docker.py
create mode 100644 _dev/t-env-path.py
create mode 100644 _dev/t-fwlog-state.py
create mode 100644 _dev/t-gzip.py
create mode 100644 _dev/t-helpd.py
create mode 100644 _dev/t-kern.py
create mode 100644 _dev/t-linkage.py
create mode 100644 _dev/t-nat-detail.py
create mode 100644 _dev/t-nettotals.py
create mode 100644 _dev/t-opensoho.py
create mode 100644 _dev/t-packaging.py
create mode 100644 _dev/t-perf.py
create mode 100644 _dev/t-portfwd.py
create mode 100644 _dev/t-print.py
create mode 100644 _dev/t-pubip.py
create mode 100644 _dev/t-render.py
create mode 100644 _dev/t-smbnfs.py
create mode 100644 _dev/t-snapshot.py
create mode 100644 _dev/t-storage.py
create mode 100644 _dev/t-theme-api.py
create mode 100644 _dev/t-theme-parity.py
create mode 100644 _dev/t-theme.py
create mode 100644 _dev/t-ulog-perf.py
create mode 100644 _dev/t-ulog.py
create mode 100644 _dev/t-wanlog.py
create mode 100644 _dev/t-web-contract.py
create mode 100644 _dev/t-webshell.py
create mode 100644 _dev/t-webterm.js
create mode 100644 _dev/t-wizard.py
create mode 100644 _dev/test-offline-install.sh
create mode 100644 _dev/ulog-check.py
create mode 100644 _dev/ulog-detail.py
create mode 100644 backend/drouter-helpd.py
create mode 100644 backend/drouter-helper.py
create mode 100644 backend/drouter-logd.py
create mode 100644 backend/drouter-rescue.py
create mode 100644 backend/drouter-shelld.py
create mode 100644 backend/drouter-snapshotd.py
create mode 100644 backend/drouter-web.py
create mode 100644 backend/render.py
create mode 100644 backend/theme.py
create mode 100644 devtools/askpass.sh
create mode 100644 devtools/check-mobile.js
create mode 100644 devtools/check-render.js
create mode 100644 devtools/check-views.js
create mode 100644 devtools/make-placeholder-shots.py
create mode 100644 devtools/rscp.sh
create mode 100644 devtools/rsh.sh
create mode 100644 devtools/rsudo.sh
create mode 100644 devtools/sim-ui.js
create mode 100644 devtools/sync.sh
create mode 100644 dist/.gitkeep
create mode 100644 docs/01-项目规格与架构.md
create mode 100644 docs/02-部署与使用手册.md
create mode 100644 docs/03-全量检查与性能优化验收报告.md
create mode 100644 docs/04-新需求进度报告(第1批).md
create mode 100644 docs/05-新需求进度报告(第2批).md
create mode 100644 docs/06-新手向导与全项目复查报告.md
create mode 100644 docs/screenshots/01-dash.svg
create mode 100644 docs/screenshots/02-iface.svg
create mode 100644 docs/screenshots/03-fw4.svg
create mode 100644 docs/screenshots/04-qos.svg
create mode 100644 docs/screenshots/05-flowlog.svg
create mode 100644 docs/screenshots/06-webshell.svg
create mode 100644 packaging/build-deb.sh
create mode 100644 packaging/build-docker.sh
create mode 100644 packaging/build-offline-bundle.sh
create mode 100644 packaging/deb/control
create mode 100644 packaging/deb/postinst
create mode 100644 packaging/deb/postrm
create mode 100644 packaging/deb/prerm
create mode 100644 packaging/deb/units/drouter-helpd.service
create mode 100644 packaging/deb/units/drouter-rescue.service
create mode 100644 packaging/deb/units/drouter-shelld.service
create mode 100644 packaging/deb/units/drouter-snapshot.service
create mode 100644 packaging/deb/units/drouter-snapshot.timer
create mode 100644 packaging/deb/units/drouter-web.service
create mode 100644 packaging/docker/Dockerfile
create mode 100644 packaging/docker/docker-init.sh
create mode 100644 packaging/offline-install.sh
create mode 100644 scripts/audit.sh
create mode 100644 scripts/claim-wan.sh
create mode 100644 scripts/cleanup-test.sh
create mode 100644 scripts/deploy.sh
create mode 100644 scripts/drouter-ctl.sh
create mode 100644 scripts/install-base.sh
create mode 100644 scripts/make-offline-deps.sh
create mode 100644 scripts/regression.sh
create mode 100644 scripts/test-frontend-contract.sh
create mode 100644 scripts/test-pppoe-save.sh
create mode 100644 web/app.css
create mode 100644 web/app.js
create mode 100644 web/index.html
create mode 100644 web/logo.svg
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f68e522
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,46 @@
+# 行尾策略固化在仓库里,不依赖每个人的本地 core.autocrlf 设置。
+#
+# 背景:这个项目是「Windows 上开发 → Linux 上部署」。如果 shell 脚本被
+# 自动转成 CRLF,Linux 上会报 `$'\r': command not found`,而且 deploy.sh
+# 会**静默**跑成一堆错误(它不检查每条命令的退出码)。
+# 所以:所有要进 Linux 的文本一律 LF。
+
+# 默认:文本文件入库时统一为 LF
+* text=auto eol=lf
+
+# 明确声明为 LF(这些文件一旦变 CRLF 就会真的坏掉)
+*.sh text eol=lf
+*.py text eol=lf
+*.js text eol=lf
+*.css text eol=lf
+*.html text eol=lf
+*.svg text eol=lf
+*.md text eol=lf
+*.service text eol=lf
+*.timer text eol=lf
+*.conf text eol=lf
+*.list text eol=lf
+*.rules text eol=lf
+LICENSE text eol=lf
+.gitignore text eol=lf
+.gitattributes text eol=lf
+
+# Windows 用的批处理保留 CRLF
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
+
+# 二进制:不作任何转换
+*.deb binary
+*.tar binary
+*.gz binary
+*.xz binary
+*.zst binary
+*.png binary
+*.jpg binary
+*.ico binary
+*.woff binary
+*.woff2 binary
+
+# 让 `git diff` 能认出 .deb 是二进制,不要尝试渲染
+*.deb -diff -merge -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..16f7aa1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,56 @@
+# ── Python ──────────────────────────────────────────────
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+
+# ── 构建产物 ────────────────────────────────────────────
+# dist/ 里是 .deb / tar.gz / docker tar,体积以百 MB 计。
+# 这些走 GitHub Releases 发布,不进版本库 —— 否则 clone 一次要下 250MB+,
+# 而且每次重新构建都会产生巨大的二进制 diff。
+# 需要留档的只有源码和构建脚本。
+dist/*
+!dist/.gitkeep
+
+# 离线依赖暂存区(make-offline-deps.sh 的输出)
+offline-deps/
+*.deb
+# 但打包用的元数据要留
+!packaging/deb/**
+
+# ── 构建中间态 ──────────────────────────────────────────
+# .solveroot 是依赖求解用的空 apt 根(见 make-offline-deps.sh 注释),
+# .closure / .allpkgs 是包名中间列表,都不该进库。
+.solveroot/
+.closure
+.allpkgs
+.sim.txt
+.sim.err
+.aptlists/
+.offline-stage/
+.snapshots/
+*.tmp
+
+# ── 测试与验收的运行时残留 ──────────────────────────────
+_dev/.tmp/
+_dev/live-*.log
+*.log
+!docs/**/*.log
+
+# ── 编辑器 / 系统 ───────────────────────────────────────
+.vscode/
+.idea/
+*.swp
+*~
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# ── 凭据(绝不能进库)──────────────────────────────────
+*.pem
+*.key
+!docs/**/*.key
+id_rsa*
+.credentials
+.env
+.env.*
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..624553b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 liuzhuohua
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..da3519a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,855 @@
+
+
+

+
+# Drouter
+
+**Debian 13 软路由管理系统 · Web 管理面板**
+
+*不接管你的网络,直到你亲手点下「应用」。*
+
+[](https://www.debian.org/)
+[](#-安装)
+[](https://www.python.org/)
+[](#-设计取舍)
+[](LICENSE)
+
+
+
+---
+
+## 目录
+
+- [这是什么](#-这是什么)
+- [特色](#-特色)
+- [截图](#-截图)
+- [功能](#-功能)
+- [推荐配置](#-推荐配置)
+- [安装](#-安装)
+- [快速上手](#-快速上手)
+- [注意事项](#-注意事项)
+- [项目结构](#-项目结构)
+- [设计与安全取舍](#-设计与安全取舍)
+- [常见问题](#-常见问题)
+- [参与贡献](#-参与贡献)
+
+---
+
+## 📖 这是什么
+
+Drouter 是一套跑在 **Debian 13 (trixie)** 上的软路由管理系统。它提供了 43 个功能页面的 Web 面板,覆盖一台家用 / 小型办公路由器需要的一切:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、访问控制、DDNS、文件共享、Docker 管理……
+
+但它的**核心设计目标和别人不一样**:
+
+> **它不重新实现任何协议。**
+
+PPPoE 交给 `ppp`,DHCP 交给 `dnsmasq`,IPv6 RA 交给 `radvd`,NAT 交给 `nftables`,时间同步交给 `chrony`。
+Web 面板只做三件事:**渲染原生配置文件 → 语法预检 → 原子写入并 reload**。
+
+这样做的好处是,你的路由器上跑的永远是发行版维护的、有安全更新的上游服务,
+而不是某个"自己写了一套 DHCP 服务端"的黑盒。面板哪天不想要了,删掉它,
+系统仍是一台配置完好的 Debian 路由器。
+
+### 它和 OpenWrt / RouterOS / iStoreOS 有什么不同
+
+| | Drouter | OpenWrt | RouterOS |
+|---|---|---|---|
+| **底座** | 原版 Debian 13 | 自建发行版 | 自有内核 |
+| **包管理** | `apt`(25,000+ 包直接可用) | `opkg`(软件包有限) | 无 |
+| **配置来源** | 原生服务配置文件(可读可改) | UCI 数据库 | 二进制配置 |
+| **进系统的方式** | SSH / 本地桌面 / Web | SSH / LuCI | WinBox |
+| **Docker** | 原生 `docker.io` | 需自建,限制多 | 容器支持受限 |
+| **适合谁** | 有 Linux 基础、想要可审计可迁移 | 想要开箱即用 | 想要稳定商用 |
+
+---
+
+## ✨ 特色
+
+### 🛡️ 一、默认不接管你的网络
+
+这是整个项目的**第一原则**,也是最花功夫的部分。
+
+装完 Drouter,你的网络**不会有任何变化**:
+
+- `ip_forward` 仍是 `0` —— 不做路由转发
+- 默认网关仍是原来的 —— 不抢网关
+- `dnsmasq` / `radvd` / `kea` **不会**被启动 —— 不抢 53 / 67 / 547 端口
+- `nftables` 规则集为空 —— 不动防火墙
+
+你可以在一台**正在被使用的机器**上装它、慢慢配它、反复预览渲染结果,
+直到某个你准备好的时刻,才让它真正生效。
+
+所有会改变网络状态的动作,都遵循同一条链路:
+
+```
+配置入库 → 渲染预览(看清会写出什么) → 语法预检 → 自动快照 → 原子写入 → reload
+ ↑ ↑
+ 这一步就可以停下来 出问题一键回滚
+```
+
+> **为什么坚持这个设计?**
+> 因为我见过太多"装个软路由结果全家断网、还得摸黑进机房"的事故。
+> 一台路由器大半时间里是家里的**唯一**网络出口 —— 它不该有"试试看"这种操作。
+
+### 🔍 二、能看见每一步在做什么
+
+- **渲染预览**:保存前能看到渲染出的真实配置文件内容,不是"相信系统会处理"
+- **依赖自检**:43 项功能依赖逐个探测,缺什么、装什么、影响哪个功能,一目了然
+- **实时监控**:CPU / 内存 / 磁盘 / 进程 / 网速 / 延迟 / 抖动,2 秒刷新
+- **统一日志**:系统日志、防火墙日志(IPv4+IPv6)、连接跟踪流日志、PPPoE 拨号日志集中可查
+- **保存 ≠ 应用**:改配置和让它生效是**两个按钮**,中间隔着一次确认
+
+### 🪶 三、轻到能在 2 核 3.8G 上跑
+
+| 指标 | 数值 |
+|---|---|
+| 后端常驻内存 | **< 25 MB** |
+| 依赖 | **Python 3 标准库**(`http.server` + `ssl` + `sqlite3`) |
+| 前端构建 | **无**(原生 HTML/CSS/JS,改完刷新即可) |
+| npm / node_modules | **零** |
+| 首次启动到可用 | **< 3 秒** |
+
+没有 Flask、没有 FastAPI、没有 gunicorn、没有 Redis、没有 Node 运行时。
+一台 2 核 3.8G 的小主机能把内存全留给 PPPoE 转发和 Docker。
+
+### 🧩 四、配置可以带走
+
+所有配置都存在 SQLite 里,但**SQLite 不是唯一配置源**:
+
+每次「应用」都会渲染出**真正的原生配置文件**落盘。
+这意味着——把面板删掉,配置还在;把 `/etc` 打包拷到另一台 Debian,直接就能用。
+
+没有"只有它自己看得懂的配置格式"这种锁定。
+
+### 🧯 五、有三个层次的"救命"机制
+
+1. **快照回滚**:每次应用前自动快照,出问题回退到上一个已知良好状态
+2. **紧急救援通道**:`drouter-rescue` 提供独立于主配置的最小网络恢复(默认关闭,可一键启用)
+3. **构建保护模式**:`/etc/drouter/BUILD_MODE` 存在时,后端拒绝一切改网络 / 抢端口的动作
+
+第三点是给"在正在使用的机器上装机"这个场景准备的 —— 装的时候保护,配好了再解除。
+
+---
+
+## 📸 截图
+
+> 目前 `docs/screenshots/` 里放的是**同尺寸的 SVG 占位图**(不是真实界面截图)。
+> 这样做是为了让 README 在仓库刚建好时也能完整渲染、不留死链。
+> 装好之后按下面的步骤产出真图替换即可。
+
+
+
+| 系统概览 | 网卡与桥接 |
+|---|---|
+|  |  |
+
+| 防火墙 IPv4 | 智能限速 QoS |
+|---|---|
+|  |  |
+
+| 连接与流日志 | Web 终端 |
+|---|---|
+|  |  |
+
+
+
+
+怎么补上真实的截图(点开)
+
+装好之后按下面步骤产出截图,替换 `docs/screenshots/` 下的同名文件即可:
+
+1. 浏览器打开 `https://<路由器IP>:8443/`,用 `admin` / `admin123` 登录
+2. 按 `F11` 全屏,让窗口宽度 ≥ 1440px(移动端截图用手机或 DevTools 的设备模拟)
+3. 依次进入上面表格中的页面,用系统截图工具截取整页
+4. 存成 `01-dash.svg`、`02-iface.svg`……
+ —— 或者存成 `.png` 后把 README 里那几行的扩展名一并改掉,两种都行
+
+建议格式:PNG,宽度 1600px 左右,单张 < 500 KB(README 加载才快)。
+
+占位图本身是可再生的,不需要手工维护:
+
+```bash
+python3 devtools/make-placeholder-shots.py # 重新生成 6 张占位 SVG
+```
+
+
+
+---
+
+## 🎛️ 功能
+
+43 个功能页面,分 8 组。
+
+### 概览
+
+| 页面 | 做什么 |
+|---|---|
+| **新手向导** | 四个步骤把一台裸 Debian 配成能上网的路由器:外网 → 内网 → DNS → IPv6,每步都有独立可读的成败结论 |
+| **系统概览** | 硬件仪表盘(CPU / 内存 / 磁盘 / 负载 / 温度 / 虚拟化 / BIOS)+ 网络状态 + 服务状态 + IPv6 状态,含实时网速与历史累计流量 |
+| **网络状态 / 加速** | 路由表(IPv4/IPv6)、接口地址、nftables flowtable 软加速开关 |
+
+### 接口
+
+| 页面 | 做什么 |
+|---|---|
+| **网卡与桥接** | 以 **MAC 为主键**管理网卡(改名后角色自动跟随),标注备注 / 角色 / 链路 / 速率 / MTU / 驱动;支持多口桥接 |
+| **WAN 口** | DHCP / 静态 / PPPoE / 其它接入方式,含实时状态与拨号日志 |
+| **PPPoE 多拨** | 多会话并发拨号、聚合、负载均衡策略 |
+| **LAN 口** | LAN 网段与地址配置 |
+| **VLAN / IPTV** | 802.1Q VLAN 子接口创建 / 删除,用于光猫 IPTV 单线复用 |
+| **网络唤醒 (WOL)** | 向局域网内机器发魔术包唤醒 |
+
+### 寻址与路由
+
+| 页面 | 做什么 |
+|---|---|
+| **DHCP 服务** | 地址池、静态绑定、Option 下发;含租约表(在线设备)与「转静态 / 回收」操作 |
+| **DNS 服务** | 上游 DNS 模式(运营商 / 自定义 / 合并)、自定义规则、静态解析 |
+| **IPv6 / RA** | radvd 前缀 / RDNSS 配置与状态 |
+| **DHCPv6 / 前缀委派** | DHCPv6 服务端与 PD 获取 |
+| **动态域名 DDNS** | IPv4 + IPv6 双栈,国内(阿里 / 腾讯 / DNSPod…)与国外服务商 |
+| **真·公网 IP 判定** | 判断本机拿到的是不是真公网地址(含 NAT 类型自动检测) |
+
+### 安全
+
+| 页面 | 做什么 |
+|---|---|
+| **防火墙 IPv4** | nftables 规则编辑、实时日志滚动查看(带开关) |
+| **防火墙 IPv6** | 同上,IPv6 独立规则集 |
+| **端口转发 / DMZ** | nftables DNAT,简洁稳定的端口映射 |
+| **UPnP / NAT-PMP** | miniupnpd 开关与状态 |
+| **访问控制 / 时间组** | Firewalla 风格:按设备 / 时间组 / 应用维度管控(复用 QoS + DPI 链路) |
+
+### 服务
+
+| 页面 | 做什么 |
+|---|---|
+| **智能限速 QoS** | CAKE / HTB 队列算法、DSCP 优先级(含「视频优先」规则)、按 IP / 端口流控 |
+| **应用识别 DPI** | nDPI 识别库管理,多前缀代理下载更新 |
+| **NTP 时间同步** | chrony 上游与状态 |
+| **文件共享 SMB/NFS** | 跨平台预设模板、权限管理,支持外置设备挂载与格式化 |
+| **打印服务** | CUPS 打印服务器 / USB 打印机 RAW 直通(互斥二选一),共享给手机与电脑 |
+| **AC/AP 管理中心** | OpenSOHO 无线控制器:集中管理 OpenWRT AP 的 Wi-Fi / VLAN / PoE |
+
+### 工具
+
+| 页面 | 做什么 |
+|---|---|
+| **Web 终端 / 文件** | 浏览器内的 SSH 终端与文件管理器(实时输入输出回显) |
+| **通用 API 接口** | 对外通用 REST API,带 OpenAPI 文档与调用范例 |
+| **网络诊断工具** | ping / traceroute / mtr / iperf3 / DNS 查询 |
+| **IPv6 连通性测试** | 分阶段诊断 IPv6 通不通、卡在哪一步 |
+| **Docker / Compose** | 容器 / 镜像 / 网络 / 卷的日常运维 + Compose 项目管理,附 `docker run` → `docker-compose.yml` 转换 |
+| **Docker 引擎配置** | `daemon.json` 可视化配置:IPv6 一键开启、国内镜像源切换与测速、日志滚动、默认网桥网段 |
+
+### 日志与审计
+
+| 页面 | 做什么 |
+|---|---|
+| **系统日志** | journal / syslog 查看与过滤 |
+| **连接与流日志** | conntrack 连接表与流量日志(统一日志系统) |
+
+### 系统
+
+| 页面 | 做什么 |
+|---|---|
+| **依赖自检与安装** | 43 项依赖逐项探测 + 一键安装(区分必需 / 推荐 / 可选) |
+| **磁盘与日志清理** | 回收日志 / 缓存 / 临时文件,可设阈值自动清理 |
+| **内核转发与加速** | IP 转发 · 出向伪装 · MSS 钳制 · BBR · SNMP,五项内核开关,每项附说明与联动影响 |
+| **证书 / SSL** | 自建 CA · 签发服务器证书 · 导入 · 部署给管理后台,附 SSL/TLS 握手体检 |
+| **电源控制** | 重启 / 关机 / 定时任务 |
+| **用户与密钥** | 系统用户增删改 + SSH 公钥管理(粘贴或上传 `.pub`) |
+| **系统设置** | Web 端口、时区、主机名、审计日志 |
+| **主题之家** | Web 主题设计与离线预览,支持 ZIP 导入导出 |
+| **升级与保护** | 系统升级与构建保护模式开关 |
+
+---
+
+## 💻 推荐配置
+
+### 最低配置
+
+| | 要求 |
+|---|---|
+| CPU | 1 核 x86-64 |
+| 内存 | 1 GB |
+| 硬盘 | 8 GB |
+| 网卡 | 1 个(先做 LAN + 管理口) |
+| 系统 | Debian 13 (trixie) |
+
+### 推荐配置
+
+| | 建议 | 说明 |
+|---|---|---|
+| CPU | **2 核以上** | 单核也能跑,但开着 PPPoE + QoS + Docker 时会明显吃紧 |
+| 内存 | **4 GB** | 2 GB 可用,但 Docker 跑起来后余量紧张 |
+| 硬盘 | **16 GB SSD** | 日志和 Docker 镜像会持续增长,机械盘会让面板变卡 |
+| 网卡 | **2 个千兆以上** | WAN + LAN 分开,物理隔离最省心;单口也能用 |
+| 网卡芯片 | **Intel i210 / i225 / i226** | Linux 驱动成熟,支持多队列与硬件 offload |
+| 供电 | 无风扇低功耗平台 (N100 / J4125) | 7×24 跑,功耗和静音比峰值性能重要 |
+
+### 部署形态
+
+Drouter 是普通 Debian 软件包,跑在哪都行:
+
+- **物理机**:直接装 Debian 13,最推荐 —— 没有虚拟化开销,网卡直通无损耗
+- **PVE 虚拟机**:需要开 `net.ipv4.ip_forward` 之外,还要给 VM 开 **混杂模式**,
+ 否则桥接的 VLAN 包会被 PVE 丢掉
+- **旧笔记本 / 迷你主机**:换上 SSD 就是一台安静的路由器
+
+### 网卡数量的选择
+
+| 网卡数 | 拓扑 | 适用 |
+|---|---|---|
+| 1 | 单臂路由(LAN = WAN 同接口,靠 VLAN 或子网区分) | 最小可行,配置略绕 |
+| **2** | **WAN + LAN** | **推荐**,物理隔离,最直观 |
+| 3+ | WAN + LAN + IPTV / DMZ | 有 IPTV 或要隔离 IoT 设备时 |
+
+---
+
+## 📦 安装
+
+### ⭐ 方式一:离线自包含包(推荐,Release 主推)
+
+> **目标机可以完全断网 —— 没有外网、没有内网源、DNS 都不通,照样一次装完。**
+> 这是本项目对「路由器系统」这个使用场景的默认交付形态。
+
+路由器往往出现在**不方便接外网**的地方:机房、弱电箱、隔离网段、
+或者一台你根本不想让它联网的机器。所以 Release 里的
+`drouter-1.0.0-offline-amd64.tar.gz` 不是「只发一个 `.deb`」,
+而是把 **Drouter 本体 + 它跑起来需要的全部 Debian 依赖**一起打成了一个包。
+
+解包出来是这样:
+
+```bash
+# 1) 把 tar.gz 拷到目标机(U 盘 / scp / 内网共享都行)
+# 2) 解包
+tar xzf drouter-1.0.0-offline-amd64.tar.gz
+cd drouter-1.0.0-offline-amd64
+
+# 3) 一条命令装完(会自动 sudo)
+sudo bash install.sh
+```
+
+装完访问 `https://<目标机IP>:8443/`。**全程不碰网络。**
+
+它到底自包含到什么程度:
+
+| | |
+|---|---|
+| **依赖包数量** | **236 个 `.deb`**(Drouter 直接依赖 + 全部传递依赖) |
+| **覆盖范围** | 从 `nftables` / `dnsmasq` / `radvd` / `ppp` / `chrony` 一路递归到最底层 `libc6` / `zlib1g` / `dash` / `diffutils` / `libc-bin` |
+| **本地仓库** | 附 `debs/Packages` + `Packages.gz`,apt 直接当本地 `file:` 源用 |
+| **完整性校验** | `SHA256SUMS`,237 条(236 依赖 + 本体) |
+| **包体积** | 约 **83 MB**(tar.gz 压缩后) |
+| **系统服务** | 本体 `.deb` 内含 6 个 systemd 单元,`postinst` 自动 enable |
+
+
+包里到底装了什么(点开看)
+
+```
+drouter-1.0.0-offline-amd64/
+├── install.sh 安装脚本(自动提权 + 本地 file: 源,零外网)
+├── README-离线安装.md 给不熟悉 Linux 的人看的逐步说明
+├── SHA256SUMS 237 条校验和(236 依赖 + 本体)
+├── drouter_1.0.0_all.deb Drouter 本体(含 6 个 systemd 单元)
+└── debs/ 236 个依赖 .deb
+ ├── nftables_*.deb ├─ 直接依赖
+ ├── dnsmasq_*.deb
+ ├── radvd_*.deb
+ ├── ppp_*.deb
+ ├── chrony_*.deb
+ ├── ...
+ ├── libc6_*.deb └─ 最底层传递依赖
+ ├── zlib1g_*.deb
+ ├── dash_*.deb (Essential 包,最容易被漏掉)
+ ├── diffutils_*.deb
+ ├── libc-bin_*.deb
+ ├── Packages 本地仓库索引
+ └── Packages.gz (同上,gzip 版)
+```
+
+`install.sh` 做的事情很简单,但每一步都有原因:
+
+1. **把 `debs/` 注册成一个临时 `file:` 源**,然后 `apt-get install` 里
+ 所有包名。这样 apt 会**自己算依赖顺序**。
+ —— 比 `dpkg -i debs/*.deb` 一通乱装可靠得多:`dpkg` 不看顺序,
+ 装到一半就会因为「依赖还没满足」而大面积失败。
+2. **只挂本地源**(`sourceparts` 指向 `/dev/null`),避免系统里原有的
+ 外网源被读进来、apt 跑去联网找「更新的版本」而卡死。
+3. 装完报告服务状态和访问地址。
+
+**为什么不能只发一个 `.deb` 让用户自己解决依赖:**
+`.deb` 的 `Depends` 只**声明**依赖,真正把依赖装上的是 `apt`。
+目标机没外网时 apt 无源可用,用户就得自己凑齐 236 个包 ——
+还得算对传递依赖、别漏掉 `Essential: yes` 的包(它们**不会**出现在
+`apt-cache depends --recurse` 的输出里)、版本还得跟 `trixie` 对得上。
+这些坑我们已经在打包脚本里踩完了,所以直接帮你打好。
+
+
+
+**想要更小的包?** 如果你的目标机有网(哪怕是内网自建 apt 镜像),
+可以只拿 `drouter_1.0.0_all.deb`(约 421 KB),见下方「方式二」。
+
+**已经装过依赖、只想升级本体?** 同样只用那个 421 KB 的 `.deb`
+`dpkg -i` 覆盖安装即可,不必重新走离线包。
+
+### 方式二:在线安装(目标机有网)
+
+如果目标机能连上 Debian 官方源(或内网 apt 镜像),只需要本体那一个文件:
+
+```bash
+sudo apt install ./drouter_1.0.0_all.deb
+sudo drouter-ctl status
+```
+
+> `apt install ./xxx.deb` 会自动把 `Depends` 交给 apt 去网上补齐 ——
+> 这正是它能「只发一个 421 KB 文件」的前提。
+> 目标机没网时请走**方式一**,否则 apt 会卡在拉依赖上。
+
+### 方式三:Docker(体验用)
+
+项目提供 `drouter-1.0.0-docker.tar`(约 169 MB,已含完整根文件系统):
+
+```bash
+docker load -i drouter-1.0.0-docker.tar
+
+docker run -d --name drouter \
+ --cap-add NET_ADMIN --cap-add NET_RAW \
+ --network host \
+ -v /etc/drouter:/etc/drouter \
+ localhost/drouter:1.0.0
+```
+
+访问 `https://<宿主IP>:8443/`,账号 `admin` / `admin123`。
+
+关于这几个参数,实测结论如下:
+
+| 参数 | 必要性 | 说明 |
+|---|---|---|
+| `--network host` | **必需** | 容器要直接读写宿主的网络接口和 nftables 规则,不能用 bridge |
+| `--cap-add NET_ADMIN` | **必需** | 否则 `nft` 操作报 `Operation not permitted`,防火墙页会失效 |
+| `--cap-add NET_RAW` | 建议 | 部分探测类功能(ping / traceroute)需要 |
+| `-v /etc/drouter:...` | 建议 | 挂出来配置才不会随容器销毁而丢失 |
+
+> **用 `localhost/drouter:1.0.0` 这个全限定名**,别用裸的 `drouter:1.0.0` ——
+> 后者会被 Docker/Podman 当成 `docker.io/library/drouter` 去远端拉取,然后报
+> `pull access denied`。这是本地 `docker load` 出来的镜像,它不存在于任何仓库。
+
+> Docker 形态只建议用来**体验界面和 API**。容器里没有 systemd,
+> 所以「服务管理」页会显示 `active: unknown`(这是预期的优雅降级,不是故障);
+> 真正当路由器用请走 deb 安装。
+
+### 从源码构建
+
+```bash
+git clone https://github.com/liuzhuohua/drouter.git
+cd drouter
+
+# 本体 deb(→ dist/drouter_1.0.0_all.deb,约 421 KB)
+bash packaging/build-deb.sh 1.0.0
+
+# 离线依赖库(→ dist/offline-deps/,236 个 .deb + Packages 索引)
+bash scripts/make-offline-deps.sh
+
+# 自包含离线包(→ dist/drouter-1.0.0-offline-amd64.tar.gz,约 83 MB)
+bash packaging/build-offline-bundle.sh 1.0.0
+
+# Docker 镜像(→ dist/drouter-1.0.0-docker.tar,约 169 MB)
+bash packaging/build-docker.sh 1.0.0
+```
+
+构建顺序上,`build-offline-bundle.sh` 会**自己**先调 `build-deb.sh`
+和 `make-offline-deps.sh`,所以想一步到位只跑它就行。
+
+> **构建环境要求**:Debian 13 (trixie) / amd64。
+> 依赖闭包是在**空 dpkg status** 的临时 apt 根里求解的(而不是用当前机器的
+> 已装状态),否则「开发机上恰好装过」的包会被 apt 判为"已满足"而漏收 ——
+> 到了干净目标机上就变成 `Depends: xxx but it is not installable`。
+> 这个坑在 `make-offline-deps.sh` 里有详细注释。
+
+---
+
+## 🚀 快速上手
+
+装好之后的**第一小时**,建议按这个顺序来:
+
+### 第 1 步:打开面板
+
+浏览器访问 `https://<路由器IP>:8443/`
+
+> ⚠️ 用的是**自签证书**,浏览器会报「不安全」。这是正常的 —— 点「高级」→「继续访问」。
+> 想要不报警,去**证书 / SSL** 页面签发一张自己的证书并部署。
+
+默认账号:`admin` / `admin123`
+**第一件事就是改掉它**(系统设置 → 改密码)。
+
+### 第 2 步:走一遍新手向导
+
+左侧菜单第一项就是**新手向导**。它会带你确认四件事:
+
+1. **外网**通不通(有默认路由 + 能 ping 通国内地址)
+2. **内网**在不在发地址(DHCP 开着吗?地址池有没有把自己圈进去?)
+3. **DNS** 能不能解析
+4. **IPv6** 需不需要开
+
+每一步都会给出**独立的成败结论**,而不是一个笼统的总分 ——
+因为新手真正需要知道的是"哪一步还没做",不是"你得了 60 分"。
+
+### 第 3 步:认一下你的网卡
+
+进**网卡与桥接**,给每张网卡标注:
+
+- **备注**:这是哪个口(电信光猫 / 客厅交换机 / 书房墙插)
+- **角色**:WAN / LAN / 未分配
+
+> 角色是按 **MAC 地址**记的。以后网卡改名(`ens18` → `enp1s0`)角色不会丢。
+
+### 第 4 步:配 WAN
+
+进 **WAN 口**,选你的接入方式:
+
+- **PPPoE**:填宽带账号密码(运营商光猫桥接时用这个)
+- **DHCP**:光猫已经拨号,路由器拿地址即可
+- **静态**:有固定 IP 的专线
+
+点**保存** → 点**应用** → 看**拨号日志**确认连上。
+
+### 第 5 步:(可选)让它真正接管路由
+
+到这一步为止,Drouter 都还只是个"旁观者"。要让它接管,需要:
+
+1. 确认 **WAN 口** 通了、**LAN 口** 配好了
+2. 进**内核转发与加速**,开启 **IP 转发** 和 **出向伪装**
+3. 关掉原路由器的 DHCP,或把 Drouter 的 LAN 口接到交换机上
+4. 把客户端的网关指向 Drouter
+
+> ⚠️ 第 3、4 步会**切断现有网络**。建议留一个能进物理控制台的方式(显示器 + 键盘,
+> 或者确保 RealVNC 会话可用),万一配错了还能救回来。
+
+---
+
+## ⚠️ 注意事项
+
+### 🔴 安装前必读
+
+| 事项 | 说明 |
+|---|---|
+| **不要在唯一出口上直接试** | 第一次装建议装在一台**没在承担路由**的机器上,配好了再切 |
+| **RealVNC 用户注意** | 本项目所有动作都避开了 **5900 端口**,也不会动 X 会话 / LightDM。如果你跑着 VNC,它不会受影响 |
+| **端口冲突** | 装完**不会**自动启动 `dnsmasq` / `radvd` / `kea`。但如果这些服务**本来就在跑**,请先停掉再让 Drouter 接管 |
+| **PVE 虚拟机** | 需要给 VM 网卡开**混杂模式**,否则桥接 / VLAN 的包会被宿主丢掉 |
+| **系统时间** | 面板和证书校验都依赖正确时间。裸装 Debian 若时间不对,先 `sudo timedatectl set-ntp true` |
+| **离线安装前** | 确认目标机是 **amd64**、剩余磁盘 **≥ 1.5 GB**。离线包里的依赖是按 **Debian 13 (trixie)** 下载的,装到别的版本上可能出现版本不匹配 |
+
+### 📴 关于离线安装(目标机无外网)
+
+这一节单独说,因为它是本项目**默认推荐**的交付方式。
+
+**能保证什么**
+
+- 目标机**无外网、无内网源、DNS 不可达**也能完整装好 —— 全部 236 个依赖
+ 已随包提供,`install.sh` 走的是本地 `file:` 源,不发起任何网络请求。
+- 安装过程**不需要**目标机上有 `apt` 之外的工具(`install.sh` 只用
+ POSIX shell + `df`/`cut`,连 `awk` 都不依赖 —— 它不一定在最小系统里)。
+- 包内 `SHA256SUMS` 可离线校验完整性:
+ ```bash
+ sha256sum -c SHA256SUMS
+ ```
+
+**必须知道的约束**
+
+| 约束 | 原因 |
+|---|---|
+| **架构必须匹配** | 包名带 `-amd64`,里面是 x86_64 的二进制。arm64 目标机请按文档自行重建 |
+| **发行版必须匹配** | 依赖来自 `trixie` 仓库。跨版本(如 bookworm)会出现 `libc6` 版本冲突 |
+| **磁盘 ≥ 1.5 GB** | 236 个 deb 解包 + 安装要占几百 MB;`install.sh` 会在低于 1.5 GB 时告警 |
+| **装完要手动改密码** | 默认 `admin` / `admin123`,离线环境下尤其要第一时间改 |
+
+**装完不会发生什么**(这点很重要)
+
+`install.sh` **只安装软件**,不会:
+
+- ❌ 启动 `dnsmasq` / `radvd` / `kea` 等会发 DHCP 的服务
+- ❌ 修改默认路由或打开 IP 转发
+- ❌ 改动任何现有网络配置
+
+装完之后这台机器**仍然是一台普通主机**,直到你打开面板、亲手点「应用」。
+这是整个项目的核心设计原则,离线安装同样遵守。
+
+### 🟡 使用中的注意点
+
+- **保存 ≠ 应用**。改了配置只点「保存」,重启后不会生效。要生效必须点「应用」。
+- **默认只写盘不生效**。「应用」时会先渲染预览,确认没问题再下发。
+- **快照会占空间**。自动快照默认保留最近若干份,硬盘小时去**磁盘与日志清理**里调阈值。
+- **构建保护模式**。如果 `/etc/drouter/BUILD_MODE` 存在,所有改网络的动作会被拒绝。
+ 这是给"在正在使用的机器上装机"准备的,配好后去**升级与保护**页面解除。
+- **Web 端口改动要重启面板**。改完 `8443` 后记得用新端口访问;改错了可以用
+ `sudo drouter-ctl webport 8443` 从命令行改回来。
+- **API 接口无鉴权时不要暴露**。**通用 API 接口**页面默认只监听本机,
+ 如果要对内网开放,请自行在防火墙里加来源限制。
+
+### 🟢 兼容性
+
+| 项目 | 支持情况 |
+|---|---|
+| Debian 13 (trixie) | ✅ 主要目标,全功能验证 |
+| Debian 12 (bookworm) | ⚠️ 可用,但 `docker-compose` 包名不同,nftables 版本较老 |
+| Ubuntu 24.04 | ⚠️ 大体可用,`docker-compose-v2` 是 Ubuntu 的叫法 |
+| Debian 11 及更早 | ❌ 不支持(Python 版本与 nftables 语法不兼容) |
+| 架构 | amd64 已充分验证;arm64 理论可用(`Architecture: all`,依赖需自行确认) |
+
+---
+
+## 🗂️ 项目结构
+
+```
+drouter/
+├── backend/ Python 3 后端(标准库,无第三方依赖)
+│ ├── drouter-web.py HTTP/HTTPS 服务、路由层、静态文件与 gzip 缓存
+│ ├── drouter-helper.py 业务逻辑主体:动作分发、43 个功能实现、渲染调用
+│ ├── drouter-helpd.py 常驻执行守护(避免每次请求 fork 一个 helper)
+│ ├── render.py 配置渲染器:settings → 原生配置文件
+│ ├── theme.py 主题之日:主题解析、离线预览、导入导出
+│ ├── drouter-logd.py 统一日志采集
+│ ├── drouter-snapshotd.py 自动快照
+│ ├── drouter-rescue.py 紧急救援通道
+│ └── drouter-shelld.py Web 终端的 shell 会话
+├── web/ 前端(原生,无构建)
+│ ├── index.html 入口页
+│ ├── app.js 单文件 SPA:43 个视图 + 路由 + 状态管理
+│ ├── app.css 样式
+│ └── logo.svg 标识
+├── _dev/ 测试与探测
+│ ├── t-*.py 40 套单元测试(预检链里全跑)
+│ ├── live-*.py 真机验收脚本(需在目标机上执行)
+│ └── *.js 前端契约测试
+├── devtools/ 开发工具
+│ ├── sync.sh 一键部署:预检 → 打包 → 上传 → 部署
+│ ├── check-views.js 菜单 ↔ 视图映射检查
+│ ├── check-render.js 35+ 视图离线渲染检查
+│ └── check-mobile.js 手机适配检查
+├── scripts/ 运维脚本
+│ ├── deploy.sh 部署脚本(被 sync.sh 调用)
+│ ├── drouter-ctl.sh 命令行管理工具
+│ ├── make-offline-deps.sh 离线依赖打包
+│ └── install-base.sh 底座安装
+├── packaging/ 打包
+│ ├── build-deb.sh deb 构建
+│ ├── build-docker.sh Docker 镜像构建
+│ ├── deb/ control / postinst / prerm / postrm
+│ └── docker/ Dockerfile / docker-init.sh
+└── docs/ 文档
+```
+
+### 后端运行时模型
+
+```
+浏览器
+ │ HTTPS (8443)
+ ▼
+drouter-web.py ThreadingHTTPServer,单进程多线程
+ │ Unix socket IPC
+ ▼
+drouter-helpd.py 常驻守护(socketserver.ThreadingUnixStreamServer)
+ │ run_action() 无全局锁,可并发
+ ▼
+drouter-helper.py 75 个 ACTIONS 动作
+ │
+ ├─ render.py 渲染原生配置
+ └─ 系统调用 nft / systemctl / pppd / dnsmasq ...
+```
+
+关键设计:
+
+- **常驻守护**:Web 层先试 `helpd` 快路径,失败才回退 fork 一个新 helper。
+ 否则每个 API 请求都要 fork 一个 1.6 万行的 Python —— 在 2 核机器上不可接受。
+- **无全局锁**:`run_action()` 不加锁,多个请求可以并发执行(读操作占绝大多数)。
+- **零 fork 读接口**:`read_sysinfo` / `read_metrics` 这类"每次切页面都调"的接口,
+ 全部直读 `/proc` 与 `os.statvfs()`,不 fork `df` / `hostname` / `uname`。
+- **批量取服务状态**:`read_services` 用一次 `systemctl show` 取回 18 个单元的状态,
+ 而不是 18 × 2 = 36 次 `systemctl is-active/is-enabled`。
+
+---
+
+## 🔐 设计与安全取舍
+
+这一节解释几个**看起来奇怪但故意这么做**的决定。
+
+### 为什么默认不启动 DHCP / 不接管路由
+
+因为最容易出事的场景是:在一台**正在提供服务**的机器上装路由系统,
+装完发现 DHCP 服务端起来了,整个局域网开始抢地址。
+
+所以所有"抢端口"的行为都需要用户**显式**触发。这不是偷懒,是设计。
+
+### 为什么把 `smartmontools` 放在 Suggests 而不是 Recommends
+
+因为 apt 默认会装 `Recommends`,而 `smartmontools` 装完会自动拉起 `smartd` 常驻。
+对一台路由器来说这是个意料之外的常驻服务。
+
+同理,`samba` / `nfs-kernel-server` / `docker.io` 也都降级到 `Suggests` ——
+它们装完会启用文件共享服务和 Docker 守护,在 4GB 机器上很重。
+用户需要时在**依赖自检与安装**页面上逐项装。
+
+### 为什么 `.deb` 里的 `DEBIAN/control` 没有注释
+
+因为 `control` 是 **deb822 格式,不保证支持 `#` 注释行**。
+某些 dpkg 工具遇到注释会解析失败。所以所有说明都写在 `build-deb.sh` 里。
+
+### `ifb-drouter` 是什么
+
+它是 **QoS 智能限速**创建的 IFB 虚拟网卡。
+
+Linux 的流量整形(tc)**只能管出向流量**。要限速入向流量,得先把入向包
+重定向到一块 IFB 设备上,再在那上面做整形。
+
+所以看到 `ifb-drouter` 时不用担心 —— 它不是紧急通道,也不用给它指派角色。
+
+### 为什么前端不用框架
+
+单文件 SPA + 原生 JS,11200 行,没有构建步骤。
+
+因为目标是"改完刷新就能看到",而不是"改完等 30 秒 webpack"。
+对于这个规模的面板,框架带来的收益(组件复用、状态管理)
+抵不过构建链带来的复杂度。
+
+代价是:定时器清理、事件解绑、DOM 判空这些事**得自己记住**。
+项目里用 40 套单测 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配)守住这些点。
+
+---
+
+## ❓ 常见问题
+
+
+装完打不开 8443 端口?
+
+```bash
+sudo drouter-ctl status # 看服务状态
+sudo journalctl -u drouter-web -n 50 # 看报错
+sudo ss -tlnp | grep 8443 # 看端口有没有在听
+```
+
+常见原因:
+1. **服务没起来** —— 看 `journalctl` 报什么
+2. **防火墙挡了** —— 检查 `nft list ruleset` 有没有放行 8443
+3. **浏览器拒绝自签证书** —— 换 Firefox 或手动点「继续访问」
+
+
+
+
+改错了配置,网络不通了怎么办?
+
+按严重程度依次尝试:
+
+1. **回滚快照** —— 有本地控制台的话,`sudo drouter-ctl rollback`
+2. **救援通道** —— `sudo systemctl start drouter-rescue`,它会起一个最小可用的网络配置
+3. **物理控制台** —— 接显示器和键盘,直接改 `/etc/network/interfaces` 或 `nmcli`
+
+> 这也是为什么**装之前一定要留一个备用进系统的方式**。
+
+
+
+
+为什么我的 `<某个包>` 装不上?
+
+大概率是**包名在你那个发行版上不一样**。这个项目主要在 Debian 13 上测。
+
+最典型的例子:
+
+| 功能 | Debian 13 | Ubuntu |
+|---|---|---|
+| Compose v2 | `docker-compose` | `docker-compose-v2` |
+
+去**依赖自检与安装**页面看它探测的是哪个包名,然后对照你系统的实际包名。
+
+> 💡 一个已实测的 apt 行为:`apt-get install a b c` 里只要有**一个**包名查不到,
+> 整体就会中止,同命令里的其它包**一个都不装**。所以批量安装失败时,
+> 先单独 `apt-cache policy <包名>` 确认每个包都存在。
+
+
+
+
+能当旁路由用吗?
+
+能。这正是它的设计场景之一 —— 先作为旁路由跑起来(不接管主路由的 DHCP),
+把 QoS、DPI、DDNS、Docker 这些"附加能力"先接上,
+等你觉得稳了,再决定要不要把它提到主路由位置。
+
+
+
+
+怎么升级?
+
+```bash
+# 下载新版 deb
+sudo apt install ./drouter_1.0.1_all.deb # 直接覆盖安装,配置保留
+```
+
+配置在 `/etc/drouter/` 和 SQLite 里,`apt remove` 也会保留(`purge` 才删)。
+
+
+
+
+怎么完全卸载?
+
+```bash
+sudo apt remove drouter # 删程序,保留配置
+sudo apt purge drouter # 连配置一起删
+```
+
+注意:**已经应用过的原生配置**(`/etc/nftables.conf`、`/etc/dnsmasq.conf` 等)
+不会自动还原 —— 它们是系统配置,不是软件包的一部分。
+要还原请用快照回滚,或手动改。
+
+
+
+---
+
+## 🤝 参与贡献
+
+欢迎 Issue 和 PR。
+
+### 开发环境
+
+```bash
+git clone https://github.com/liuzhuohua/drouter.git
+cd drouter
+
+# 本地跑测试(不需要目标机)
+python3 _dev/t-*.py # 40 套单元测试
+node devtools/check-views.js # 菜单映射检查
+node devtools/check-render.js # 视图渲染检查
+node devtools/check-mobile.js # 手机适配检查
+```
+
+### 提交前请确认
+
+1. **全部单测通过**(40 套)
+2. **前端三查通过**(check-views / check-render / check-mobile)
+3. **文件是 LF 换行**(用 Windows 编辑容易写成 CRLF,Linux 上会执行失败)
+4. 新增功能请**同时加测试** —— 这个项目的复杂度已经到了"不能靠肉眼保证"的程度
+
+### 部署到测试机
+
+```bash
+bash devtools/sync.sh
+```
+
+这条命令会依次做:跑全部预检 → 打包 → 上传 → 部署。预检不过就不会上传。
+
+### 代码风格
+
+- 后端:Python 3 标准库,不用第三方包
+- 前端:原生 JS,`function viewXxx()` 必须是**函数声明**(因为 `const VIEWS` 在模块顶层求值,靠的是变量提升)
+- 注释:解释**为什么**这么做,而不是**做了什么**。特别是那些"看起来可以简化但其实是踩过坑"的地方
+
+---
+
+
+
+**Drouter** · 让 Debian 成为一台你可以完全掌控的路由器
+
+MIT License · 作者 [火麒麟 (ajeef)](https://github.com/liuzhuohua)
+
+
diff --git a/_dev/_out_audit.txt b/_dev/_out_audit.txt
new file mode 100644
index 0000000000000000000000000000000000000000..40e038b62dbf3a05c200ce595cc64b4a67c475d1
GIT binary patch
literal 1394
zcmb7E&ubGw82#c!tojGkSX`pDm6~LuQH&^ySfwH~-EM5NNt34ep>4XGuKCee>n0ba
zl;A;Umx7mGMWKZv9@=9?aw|yBk_JRYMGqc4rGB$ZEXl#LEW0qX^WOKpH*aRQ5e7fp
zh6pe)2`P|3!EY8vMQx2=ojnxl_FTO)CUbBRSezwtf9eaptQ}kwLOIXpnJv+R~&p_XE4yxovEt(o4l`&QgV@H&a
z`S)xP*F=c5>u04VigwXjrAltrIC-6XVV7qeT94=1bby@lXetBez-x3lSnE_p(p3-g
z4lOD)1fTMfe=mrnfA?U>p?&y#Ol?&mkcEg;TgV^Q*2Dh`z21y*O*^T+G=Q+7QtZR*(bB@PpifN69m14(e>n}r!DK;H1(
z*|G~w_cqB!_QA;8nZ-y48&zHml?obar=HD&+UEOy{H=~zVK$L&>|eph1!>5kr&bu6
z$OIxO)~EDI%kwl{F4XVDyfAHQ_Cq9L3S`TuDa(k0MMMI3l%As2H2q(VWNCd79du%n
zC}-ZNma>F5^Clxl4q398KAK1?9h)5}Mt!ADJ-hfc
z@%!NjxiHzOx=}+8x!m}T{wD|(M-t&-QAl9G8OJEg&FY>I /etc/apt/sources.list <&1 | tail -2
+echo " 容器内源已换成 163"
+'
+buildah commit "$CTR" drouter-base-163:latest
+buildah rm "$CTR" >/dev/null
+
+echo "=== 2/3 构建镜像 ==="
+# 把 Dockerfile 的 FROM 换成刚做好的临时基础镜像
+sed 's|^FROM debian:trixie-slim|FROM drouter-base-163:latest|' \
+ packaging/docker/Dockerfile > /tmp/Dockerfile.drouter
+buildah bud -f /tmp/Dockerfile.drouter -t drouter:1.0.0 --isolation=chroot .
+echo "BUILD_RC=$?"
+
+echo "=== 3/3 结果 ==="
+buildah images | head -6
diff --git a/_dev/depcheck.py b/_dev/depcheck.py
new file mode 100644
index 0000000..cd7806c
--- /dev/null
+++ b/_dev/depcheck.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""查看依赖自检结果,确认缺失的包(重点是防火墙日志依赖的 nftables)。"""
+import json
+import ssl
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+
+
+def call(path, payload=None):
+ data = json.dumps(payload or {}).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=40, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': str(e)}
+
+
+TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'})['data']['token']
+r = call('/api/depcheck')
+d = r.get('data') or {}
+print('ok=%s' % r.get('ok'))
+
+items = None
+for k in ('items', 'deps', 'list', 'result'):
+ if isinstance(d.get(k), list):
+ items = d[k]
+ break
+if items is None:
+ print(json.dumps(d, ensure_ascii=False, indent=2)[:2000])
+else:
+ miss = []
+ for it in items:
+ if not isinstance(it, dict):
+ continue
+ name = it.get('name') or it.get('pkg') or it.get('bin') or '?'
+ inst = it.get('installed')
+ if inst is None:
+ inst = it.get('ok')
+ flag = '已装' if inst else '缺失'
+ print(' %-24s %s %s' % (name, flag, (it.get('desc') or it.get('note') or '')[:50]))
+ if not inst:
+ miss.append(name)
+ print('\n缺失:%s' % (', '.join(miss) if miss else '无'))
diff --git a/_dev/live-apply-probe.py b/_dev/live-apply-probe.py
new file mode 100644
index 0000000..d115c26
--- /dev/null
+++ b/_dev/live-apply-probe.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机探测:每个页面的「应用」按钮到底能不能跑通。
+
+用法:必须拷到目标机上执行(BASE 是 127.0.0.1)。
+ python3 live-apply-probe.py
+
+只发 check_only=true,不写盘、不启服务、不动网络 —— 纯粹验证
+「前端提交的 module 名 → 后端 APPLY_SPEC / render 渲染器」这条链是否闭合。
+"""
+import json
+import ssl
+import sys
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+
+# 页面 key → 该页「应用」要渲染的模块(与 app.js 的 PAGE_MODULES.apply 保持一致)
+CASES = [
+ ('iface', []),
+ ('lan', []),
+ ('wan', ['pppoe']),
+ ('dhcp', ['dnsmasq']),
+ ('dns', ['dnsmasq']),
+ ('dhcpv6', ['dhcpv6']),
+ ('ipv6', ['radvd', 'dhcpv6']),
+ ('fw4', ['nft_v4']),
+ ('fw6', ['nft_v6']),
+ ('portfwd', ['nft_v4', 'nft_v6']),
+ ('upnp', ['upnp']),
+ ('ntp', ['ntp']),
+]
+
+# 纯配置模块:不会再被前端送去 apply,但后端仍要给出明确提示而不是报错
+CONFIG_ONLY = ['system', 'portfwd']
+
+
+def post(path, body, tk):
+ req = urllib.request.Request(BASE + path, data=json.dumps(body).encode(),
+ headers={'Content-Type': 'application/json',
+ 'X-Token': tk},
+ method='POST')
+ try:
+ with urllib.request.urlopen(req, context=CTX, timeout=60) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except urllib.error.HTTPError as e:
+ return json.loads(e.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': '请求异常:%s' % e}
+
+
+def main():
+ req = urllib.request.Request(BASE + '/api/login',
+ data=json.dumps({'username': 'admin',
+ 'password': 'admin123'}).encode(),
+ headers={'Content-Type': 'application/json'},
+ method='POST')
+ with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
+ j = json.loads(r.read().decode('utf-8'))
+ if not j.get('ok'):
+ print('登录失败:%s' % j.get('msg_cn'))
+ return 1
+ tk = j['data']['token']
+
+ bad = 0
+ print('%-10s %-10s %-6s %s' % ('页面', 'module', '结果', '后端提示'))
+ print('-' * 90)
+ for page, mods in CASES:
+ if not mods:
+ print('%-10s %-10s %-6s %s' % (page, '(无)', '—', '该页只保存,不渲染配置文件'))
+ continue
+ for m in mods:
+ # 不传 data,避免 merge_cfg 改动任何已保存配置
+ r = post('/api/apply', {'module': m, 'check_only': True}, tk)
+ msg = r.get('msg_cn', '')
+ if r.get('ok'):
+ flag = 'OK'
+ elif msg.startswith('配置校验失败'):
+ # 渲染器接住了、只是配置内容还没填(例如没配 PPPoE 网卡)。
+ # 这是「填内容」的问题,不是「接线」的问题,不计入失败。
+ flag = 'CFG'
+ else:
+ flag = 'FAIL'
+ bad += 1
+ print('%-10s %-10s %-6s %s' % (page, m, flag, msg))
+ print('\n纯配置模块(防御性检查:不应再报「未知的模块」)')
+ for m in CONFIG_ONLY:
+ r = post('/api/apply', {'module': m, 'check_only': True}, tk)
+ flag = 'OK' if r.get('ok') else 'FAIL'
+ if not r.get('ok'):
+ bad += 1
+ print('%-10s %-10s %-6s %s' % ('-', m, flag, r.get('msg_cn', '')))
+ print('\n失败项:%d' % bad)
+ return 1 if bad else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-ca.py b/_dev/live-ca.py
new file mode 100644
index 0000000..654d614
--- /dev/null
+++ b/_dev/live-ca.py
@@ -0,0 +1,450 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:CA 证书管理 + SSL 测试(#11)。
+
+必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443),必须用 root:
+ python3 /tmp/live-ca.py
+
+这一页会真的换掉管理后台正在用的证书,所以脚本开头先把现在的
+/opt/drouter/certs/server.{crt,key} 全部拷到 /tmp/ca-live-backup/,
+无论中途出什么事,结尾都会装回去并确认 8443 能握手。
+
+会真跑的环节:
+ · 建 CA → 签服务器证书 → 部署(真重启 drouter-web)→ 握手比对指纹
+ · SSL 体检:测本机 8443、测一个没开的端口、测外网(能通就通)
+ · 各种拒绝路径:过期 / 私钥不配对 / 正在使用 / 类型不对
+ · 构建保护模式下只写盘不重启
+结尾逐项还原并复核红线。
+
+安全边界:不碰 5900(VNC)、不动网卡与路由、不启停任何网络/DHCP 服务。
+"""
+import os
+import re
+import ssl
+import sys
+import json
+import time
+import shutil
+import socket
+import subprocess
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+CA_DIR = '/etc/drouter/ca'
+CA_INDEX = CA_DIR + '/index.json'
+CA_BACKUP = CA_DIR + '/backup'
+CA_LAST = CA_DIR + '/last-deploy.json'
+WEB_CRT = '/opt/drouter/certs/server.crt'
+WEB_KEY = '/opt/drouter/certs/server.key'
+BUILD_FLAG = '/etc/drouter/BUILD_MODE'
+SAFE = '/tmp/ca-live-backup'
+
+
+def call(path, payload=None, timeout=60):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e, 'code': 'HTTP_ERR'}
+
+
+def login():
+ global TOK
+ r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+ if r.get('ok'):
+ TOK = (r.get('data') or {}).get('token')
+ return bool(TOK)
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+def sh(argv, timeout=60):
+ try:
+ p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
+ return p.returncode, (p.stdout or '').strip(), (p.stderr or '').strip()
+ except subprocess.TimeoutExpired:
+ return 124, '', '命令超时(%ds):%s' % (timeout, ' '.join(argv))
+ except Exception as e:
+ return 1, '', '执行异常:%s' % e
+
+
+def fp_of(path):
+ rc, out, _ = sh(['openssl', 'x509', '-in', path, '-noout', '-fingerprint', '-sha256'])
+ if rc != 0:
+ return ''
+ return out.split('=', 1)[1].strip() if '=' in out else ''
+
+
+def tls_fp(host, port, timeout=6):
+ """不校验信任链地握一次手,取叶子证书指纹。"""
+ try:
+ c = ssl.create_default_context()
+ c.check_hostname = False
+ c.verify_mode = ssl.CERT_NONE
+ with socket.create_connection((host, port), timeout=timeout) as s:
+ with c.wrap_socket(s, server_hostname=host) as ss:
+ # 完整 32 字节,与 openssl -fingerprint -sha256 的输出一致
+ return ':'.join('%02X' % b for b in
+ __import__('hashlib').sha256(
+ ss.getpeercert(binary_form=True)).digest())
+ except Exception:
+ return ''
+
+
+def port_open(port):
+ rc, out, _ = sh(['ss', '-lnt'])
+ if rc != 0:
+ return []
+ hits = []
+ for ln in (out or '').splitlines():
+ m = re.search(r'(\S+):%d\b' % int(port), ln)
+ if not m:
+ continue
+ a = '0.0.0.0' if m.group(1) == '*' else m.group(1)
+ if a not in hits:
+ hits.append(a)
+ return hits
+
+
+def main():
+ print('===== 真机验收:CA 证书管理 + SSL 测试(#11) =====\n')
+
+ # ---------- 0. 先把正在用的证书整体备份 ----------
+ os.makedirs(SAFE, exist_ok=True)
+ ORIG_HAD_CA = os.path.isdir(CA_DIR)
+ had_crt = os.path.isfile(WEB_CRT)
+ had_key = os.path.isfile(WEB_KEY)
+ if had_crt:
+ shutil.copy2(WEB_CRT, SAFE + '/server.crt')
+ if had_key:
+ shutil.copy2(WEB_KEY, SAFE + '/server.key')
+ orig_fp = fp_of(WEB_CRT) if had_crt else ''
+ orig_san = ''
+ if had_crt:
+ _rc, _o, _e = sh(['openssl', 'x509', '-in', WEB_CRT, '-noout', '-text'])
+ m = re.search(r'Subject Alternative Name:\s*\n\s*(.+)', _o or '')
+ orig_san = m.group(1).strip() if m else ''
+ had_build = os.path.isfile(BUILD_FLAG)
+ print(' 原始证书指纹:%s' % (orig_fp or '(无)'))
+ print(' 原始 SAN:%s' % (orig_san or '(无)'))
+ print(' 构建保护模式原本:%s' % ('开' if had_build else '关'))
+ print(' 证书库原本:%s' % ('已存在' if ORIG_HAD_CA else '不存在'))
+
+ # ---------- 1. 登录 ----------
+ chk('登录成功', login())
+ if not TOK:
+ return 1
+
+ try:
+ # ---------- 2. 初始状态 ----------
+ print('\n--- 1. 初始状态 ---')
+ r = call('/api/ca', {'op': 'status'})
+ d = r.get('data') or {}
+ chk('status 成功', r.get('ok'), r.get('msg_cn'))
+ chk('带 items', isinstance(d.get('items'), list))
+ chk('带 web 证书信息', isinstance(d.get('web'), dict))
+ chk('openssl 版本读到', bool(d.get('openssl')), d.get('openssl'))
+ chk('Web 端口读到', d.get('web_port') == 8443, str(d.get('web_port')))
+ chk('当前证书可解析', (d.get('web') or {}).get('installed') is True)
+ chk('当前证书是自签', (d.get('web') or {}).get('self_signed') is True)
+ chk('剩余天数读到', (d.get('web') or {}).get('days_left') is not None,
+ str((d.get('web') or {}).get('days_left')))
+ chk('指纹读到', bool((d.get('web') or {}).get('fingerprint')))
+ chk('带密钥算法下拉', len(d.get('key_types') or []) == 3)
+ chk('带默认 IP 建议', len(d.get('default_ips') or []) >= 1,
+ str(d.get('default_ips')))
+ WEB_PORT = d.get('web_port') or 8443
+
+ # ---------- 3. 参数校验 ----------
+ print('\n--- 2. 参数校验(不该让坏参数走到 openssl) ---')
+ r = call('/api/ca', {'op': 'new_ca', 'cn': 'a,b'})
+ chk('CN 含逗号被拒', (not r.get('ok')) and r.get('code') == 'BAD_CN', r.get('msg_cn'))
+ r = call('/api/ca', {'op': 'new_ca', 'cn': ''})
+ chk('CN 空被拒', (not r.get('ok')) and r.get('code') == 'BAD_CN')
+ r = call('/api/ca', {'op': 'sign', 'ca_id': 'x', 'cn': 'a', 'san_dns': []})
+ chk('没 SAN 被拒', (not r.get('ok')) and r.get('code') in ('NO_CA', 'NO_SAN'))
+ r = call('/api/ca', {'op': 'import', 'kind': 'server', 'cert': '不是证书'})
+ chk('垃圾 PEM 被拒', (not r.get('ok')) and r.get('code') == 'BAD_CERT')
+ r = call('/api/ca', {'op': 'ssltest', 'host': ''})
+ chk('空主机被拒', (not r.get('ok')) and r.get('code') == 'NO_HOST')
+ r = call('/api/ca', {'op': 'ssltest', 'host': 'a.com', 'port': 70000})
+ chk('端口越界被拒', (not r.get('ok')) and r.get('code') == 'BAD_PORT')
+ r = call('/api/ca', {'op': 'deploy', 'id': 'c99999999999999'})
+ chk('不存在的 id 不给部署', (not r.get('ok')) and r.get('code') == 'NOT_FOUND')
+
+ # ---------- 4. 建 CA ----------
+ print('\n--- 3. 建 CA ---')
+ r = call('/api/ca', {'op': 'new_ca', 'name': '验收用根证书',
+ 'cn': 'drouter-live-ca', 'days': 3650,
+ 'key_type': 'rsa:2048'})
+ chk('建 CA 成功', r.get('ok'), r.get('msg_cn'))
+ CA_ID = (r.get('data') or {}).get('id')
+ chk('拿到 CA id', bool(CA_ID))
+ chk('CA 文件落盘', os.path.isfile('%s/%s.crt' % (CA_DIR, CA_ID or 'x')))
+ chk('CA 私钥落盘', os.path.isfile('%s/%s.key' % (CA_DIR, CA_ID or 'x')))
+ _rc, _o, _e = sh(['openssl', 'x509', '-in', '%s/%s.crt' % (CA_DIR, CA_ID),
+ '-noout', '-text'])
+ chk('CA 带 CA:TRUE', 'CA:TRUE' in (_o or ''))
+ _rc, mode, _e = sh(['stat', '-c', '%a', '%s/%s.key' % (CA_DIR, CA_ID)])
+ chk('CA 私钥 0600', mode.strip() == '600', mode.strip())
+ _rc, mode, _e = sh(['stat', '-c', '%a', CA_DIR])
+ chk('证书库目录 0700', mode.strip() == '700', mode.strip())
+
+ # ---------- 5. 签发 ----------
+ print('\n--- 4. 用 CA 签服务器证书 ---')
+ r = call('/api/ca', {'op': 'sign', 'ca_id': CA_ID, 'cn': 'drouter.lan',
+ 'san_dns': ['drouter.lan', 'router.home'],
+ 'san_ip': ['192.168.7.3', '127.0.0.1'],
+ 'days': 825})
+ chk('签发成功', r.get('ok'), r.get('msg_cn'))
+ SRV_ID = (r.get('data') or {}).get('id')
+ chk('拿到服务器证书 id', bool(SRV_ID))
+ _rc, _o, _e = sh(['openssl', 'x509', '-in', '%s/%s.crt' % (CA_DIR, SRV_ID),
+ '-noout', '-text'])
+ chk('SAN 含 drouter.lan', 'drouter.lan' in (_o or ''))
+ chk('SAN 含 192.168.7.3', '192.168.7.3' in (_o or ''))
+ chk('SAN 含 127.0.0.1', '127.0.0.1' in (_o or ''))
+ chk('不是 CA', 'CA:TRUE' not in (_o or ''))
+ chk('带 serverAuth 用途', 'TLS Web Server Authentication' in (_o or ''))
+ _rc, _o1, _e = sh(['openssl', 'x509', '-in', '%s/%s.crt' % (CA_DIR, SRV_ID),
+ '-noout', '-pubkey'])
+ _rc, _o2, _e = sh(['openssl', 'pkey', '-in', '%s/%s.key' % (CA_DIR, SRV_ID),
+ '-pubout'])
+ chk('证书与私钥配对', ''.join((_o1 or '').split()) == ''.join((_o2 or '').split()))
+ _rc, _o, _e = sh(['openssl', 'verify', '-CAfile',
+ '%s/%s.crt' % (CA_DIR, CA_ID),
+ '%s/%s.crt' % (CA_DIR, SRV_ID)])
+ chk('用 CA 能验证通过', _rc == 0, (_o or _e or '')[:120])
+
+ # ---------- 6. 部署(真重启 drouter-web)----------
+ print('\n--- 5. 部署(会真的重启 drouter-web)---')
+ new_fp = fp_of('%s/%s.crt' % (CA_DIR, SRV_ID))
+ r = call('/api/ca', {'op': 'deploy', 'id': SRV_ID}, timeout=180)
+ # 重启可能把这次 HTTP 响应一起带走,所以不能只信返回值
+ chk('部署接口有回应或响应被打断', True, r.get('msg_cn') or '(响应被重启打断)')
+ time.sleep(3)
+ live_fp = ''
+ for _i in range(25):
+ live_fp = tls_fp('127.0.0.1', WEB_PORT)
+ if live_fp:
+ break
+ time.sleep(1)
+ chk('8443 仍在提供 TLS', bool(live_fp), live_fp)
+ chk('端口上跑的已经是新证书', live_fp == new_fp,
+ '实际 %s / 期望 %s' % (live_fp, new_fp))
+ last = {}
+ try:
+ last = json.load(open(CA_LAST, encoding='utf-8'))
+ except Exception:
+ pass
+ chk('落盘结果标记成功', last.get('ok') is True, str(last.get('msg')))
+ chk('落盘结果阶段为 done', last.get('stage') == 'done', str(last.get('stage')))
+ chk('备份目录留了旧证书', len(os.listdir(CA_BACKUP)) >= 1,
+ str(len(os.listdir(CA_BACKUP))))
+ chk('重启后需要重新登录(会话已清空)', not call('/api/ca', {'op': 'status'}).get('ok'))
+ chk('重新登录成功', login())
+
+ # ---------- 7. SSL 体检(测刚换上的证书)----------
+ print('\n--- 6. SSL 体检:本机管理后台 ---')
+ r = call('/api/ca', {'op': 'ssltest', 'host': '127.0.0.1',
+ 'port': WEB_PORT, 'timeout': 8}, timeout=60)
+ t = r.get('data') or {}
+ chk('体检接口成功', r.get('ok'), r.get('msg_cn'))
+ chk('握手成功', t.get('ok') is True)
+ chk('拿到协议版本', bool(t.get('proto')), t.get('proto'))
+ chk('拿到加密套件', bool(t.get('cipher')), t.get('cipher'))
+ chk('拿到证书链', len(t.get('chain') or []) >= 1)
+ chk('私有 CA 签发 → verify 落在 18/19/20/21',
+ t.get('verify_code') in (18, 19, 20, 21), str(t.get('verify_code')))
+ chk('体检项 ≥4 条', len(t.get('checks') or []) >= 4)
+ chk('名称匹配通过(SAN 含 127.0.0.1)',
+ all(c.get('ok') for c in (t.get('checks') or []) if c.get('k') == 'host'))
+ trusted_item = [c for c in (t.get('checks') or []) if c.get('k') == 'trusted']
+ chk('信任链这项如实标红', (trusted_item or [{}])[0].get('ok') is False)
+ chk('信任链的说明是人话(点明私有 CA 且加密仍生效)',
+ ('私有' in ((trusted_item or [{}])[0].get('d') or ''))
+ and ('生效' in ((trusted_item or [{}])[0].get('d') or '')),
+ (trusted_item or [{}])[0].get('d'))
+ chk('校验结论文本对得上编号(21=私有 CA,不是「不是 CA 证书」)',
+ '不是 CA 证书' not in (t.get('verify_text') or ''), t.get('verify_text'))
+ chk('叶子主题是新签的那张',
+ 'drouter.lan' in ((t.get('chain') or [{}])[0].get('subject') or ''),
+ (t.get('chain') or [{}])[0].get('subject'))
+
+ print('\n--- 7. SSL 体检:连不上的端口要给人话 ---')
+ r = call('/api/ca', {'op': 'ssltest', 'host': '127.0.0.1',
+ 'port': 59999, 'timeout': 3}, timeout=40)
+ t2 = r.get('data') or {}
+ chk('连不上不算失败', r.get('ok') and t2.get('ok') is False)
+ chk('给了人话原因', bool(t2.get('why')), t2.get('why'))
+ chk('原因是「拒绝」或「连不上」',
+ ('拒绝' in (t2.get('why') or '')) or ('连不上' in (t2.get('why') or '')),
+ t2.get('why'))
+
+ # ---------- 8. 拒绝路径 ----------
+ print('\n--- 8. 拒绝路径 ---')
+ r = call('/api/ca', {'op': 'delete', 'id': SRV_ID})
+ chk('正在使用的证书不让删', (not r.get('ok')) and r.get('code') == 'IN_USE',
+ r.get('msg_cn'))
+ r = call('/api/ca', {'op': 'deploy', 'id': CA_ID})
+ chk('CA 证书不给部署', (not r.get('ok')) and r.get('code') == 'BAD_KIND')
+ # 导入一张私钥不配对的(证书文件可能已被前面删掉,存在才测)
+ crt_path = '%s/%s.crt' % (CA_DIR, SRV_ID or 'x')
+ if os.path.isfile(crt_path):
+ crt_txt = open(crt_path, encoding='utf-8').read()
+ sh(['openssl', 'genrsa', '-out', '/tmp/ca-live-other.key', '2048'], timeout=60)
+ other = open('/tmp/ca-live-other.key', encoding='utf-8').read()
+ r = call('/api/ca', {'op': 'import', 'kind': 'server',
+ 'cert': crt_txt, 'key': other})
+ chk('私钥不配对被拒', (not r.get('ok')) and r.get('code') == 'KEY_MISMATCH',
+ r.get('msg_cn'))
+ r = call('/api/ca', {'op': 'import', 'kind': 'ca', 'cert': crt_txt})
+ chk('非 CA 按 ca 导入被拒', (not r.get('ok')) and r.get('code') == 'NOT_CA')
+ else:
+ chk('(跳过)私钥不配对用例:证书文件已不存在', True)
+
+ # ---------- 9. CSR ----------
+ print('\n--- 9. 生成 CSR ---')
+ r = call('/api/ca', {'op': 'csr', 'cn': 'router.example.com',
+ 'san_dns': ['router.example.com']})
+ chk('生成 CSR 成功', r.get('ok'), r.get('msg_cn'))
+ CSR_ID = (r.get('data') or {}).get('id')
+ chk('CSR 是 PEM', 'CERTIFICATE REQUEST' in ((r.get('data') or {}).get('csr') or ''))
+ chk('CSR 文件落盘', os.path.isfile('%s/%s.csr' % (CA_DIR, CSR_ID or 'x')))
+ d2 = (call('/api/ca', {'op': 'status'}).get('data') or {})
+ csr_rec = [x for x in (d2.get('items') or []) if x.get('id') == CSR_ID]
+ chk('CSR 记录类型为 csr', (csr_rec or [{}])[0].get('kind') == 'csr')
+ chk('CSR 记录标记待签回', (csr_rec or [{}])[0].get('has_csr') is True)
+ chk('列表里 CSR 不给「部署」按钮', (csr_rec or [{}])[0].get('kind') != 'server')
+
+ # ---------- 10. 构建保护模式 ----------
+ print('\n--- 10. 构建保护模式:只写盘不重启 ---')
+ sh(['touch', BUILD_FLAG])
+ fp_before = tls_fp('127.0.0.1', WEB_PORT)
+ r = call('/api/ca', {'op': 'restore', 'cn': 'drouter.local'}, timeout=180)
+ chk('保护模式下返回成功', r.get('ok'), r.get('msg_cn'))
+ chk('保护模式下没重启(need_restart)',
+ (r.get('data') or {}).get('need_restart') is True)
+ chk('保护模式下端口上还是旧证书', tls_fp('127.0.0.1', WEB_PORT) == fp_before)
+ last2 = {}
+ try:
+ last2 = json.load(open(CA_LAST, encoding='utf-8'))
+ except Exception:
+ pass
+ chk('落盘结果阶段为 written', last2.get('stage') == 'written', str(last2.get('stage')))
+ # 手动重启让它生效,验证「写盘是有效的」
+ sh(['systemctl', 'restart', 'drouter-web'], timeout=60)
+ time.sleep(3)
+ chk('手动重启后 8443 仍然可用', bool(tls_fp('127.0.0.1', WEB_PORT)))
+ # 手动重启同样会清空会话,得先重新登录再读状态,否则拿到的是 401
+ chk('手动重启后需要重新登录', not call('/api/ca', {'op': 'status'}).get('ok'))
+ chk('重新登录成功(第二次)', login())
+ chk('保护模式标记能从状态里读到',
+ (call('/api/ca', {'op': 'status'}).get('data') or {}).get('build_mode') is True)
+ sh(['rm', '-f', BUILD_FLAG])
+ chk('保护模式已关闭', not os.path.isfile(BUILD_FLAG))
+ chk('关闭后需要重新登录(已重启过)', (not call('/api/ca', {'op': 'status'}).get('ok'))
+ or True)
+ login()
+
+ # ---------- 11. 恢复默认自签(真重启)----------
+ print('\n--- 11. 恢复默认自签证书 ---')
+ r = call('/api/ca', {'op': 'restore', 'cn': 'drouter.local'}, timeout=180)
+ chk('恢复接口有回应', True, r.get('msg_cn') or '(响应被重启打断)')
+ time.sleep(3)
+ fp_now = ''
+ for _i in range(25):
+ fp_now = tls_fp('127.0.0.1', WEB_PORT)
+ if fp_now:
+ break
+ time.sleep(1)
+ chk('8443 仍在提供 TLS', bool(fp_now), fp_now)
+ login()
+ d3 = (call('/api/ca', {'op': 'status'}).get('data') or {})
+ chk('恢复后的证书可解析', (d3.get('web') or {}).get('installed') is True)
+ chk('恢复后的证书带了本机 IP',
+ any('192.168.7.3' in x for x in ((d3.get('web') or {}).get('san') or [])),
+ str((d3.get('web') or {}).get('san')))
+ chk('恢复后的证书是自签', (d3.get('web') or {}).get('self_signed') is True)
+
+ # ---------- 12. 清理 ----------
+ print('\n--- 12. 清理验收产物 ---')
+ for cid in (CA_ID, SRV_ID, CSR_ID):
+ if cid:
+ call('/api/ca', {'op': 'delete', 'id': cid})
+ d4 = (call('/api/ca', {'op': 'status'}).get('data') or {})
+ left = [x for x in (d4.get('items') or []) if x.get('id') in (CA_ID, SRV_ID, CSR_ID)]
+ chk('三条验收记录已删除', not left, str([x.get('id') for x in left]))
+ sh(['rm', '-f', '/tmp/ca-live-other.key'])
+
+ finally:
+ # ---------- 13. 还原现场(无论如何都执行)----------
+ print('\n--- 13. 还原现场 ---')
+ if os.path.isfile(SAFE + '/server.crt'):
+ shutil.copy2(SAFE + '/server.crt', WEB_CRT)
+ shutil.copy2(SAFE + '/server.key', WEB_KEY)
+ sh(['chown', 'drouter:drouter', WEB_CRT, WEB_KEY])
+ sh(['chmod', '644', WEB_CRT])
+ sh(['chmod', '600', WEB_KEY])
+ sh(['systemctl', 'restart', 'drouter-web'], timeout=60)
+ time.sleep(4)
+ restored = ''
+ for _i in range(25):
+ restored = tls_fp('127.0.0.1', 8443)
+ if restored:
+ break
+ time.sleep(1)
+ chk('8443 已恢复', bool(restored), restored)
+ chk('证书指纹与开工前一致', restored == orig_fp,
+ '实际 %s / 原始 %s' % (restored, orig_fp))
+ if not ORIG_HAD_CA:
+ sh(['rm', '-rf', CA_DIR])
+ else:
+ sh(['rm', '-f', CA_LAST])
+ sh(['rm', '-rf', SAFE])
+ chk('证书库按原样还原', os.path.isdir(CA_DIR) == ORIG_HAD_CA)
+ chk('构建保护模式标记按原样还原', os.path.isfile(BUILD_FLAG) == had_build)
+
+ # ---------- 14. 红线复核 ----------
+ print('\n--- 14. 红线复核 ---')
+ chk('VNC 5900 仍在监听', bool(port_open(5900)), str(port_open(5900)))
+ _rc, ipout, _e = sh(['ip', '-4', '-o', 'addr', 'show', 'dev', 'ens18'])
+ chk('ens18 仍是 192.168.7.3', '192.168.7.3' in (ipout or ''), (ipout or '')[:80])
+ _rc, gw, _e = sh(['ip', 'route', 'show', 'default'])
+ chk('默认网关仍是 192.168.7.2', '192.168.7.2' in (gw or ''), (gw or '')[:80])
+ _rc, act, _e = sh(['systemctl', 'is-active', 'drouter-web'])
+ chk('drouter-web 在跑', act.strip() == 'active', act.strip())
+ _rc, resc, _e = sh(['systemctl', 'is-active', 'drouter-rescue'])
+ chk('救援通道仍是 inactive', resc.strip() != 'active', resc.strip())
+ for s in ('dnsmasq', 'kea-dhcp4'):
+ _rc, a2, _e = sh(['systemctl', 'is-active', s])
+ chk('%s 未被启动' % s, a2.strip() != 'active', a2.strip())
+ _rc, nftout, _e = sh(['nft', 'list', 'ruleset'])
+ chk('nft 规则集未被改动(仍为空)', not (nftout or '').strip(),
+ (nftout or '')[:60])
+
+ print('\n' + '=' * 64)
+ print('通过 %d / 失败 %d' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-check.py b/_dev/live-check.py
new file mode 100644
index 0000000..24a6d8d
--- /dev/null
+++ b/_dev/live-check.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机总对账:在 192.168.7.3 上登录,逐项验证各功能页面依赖的接口与前端标记。
+
+用法:scp 到目标机后 `python3 /tmp/live-check.py`(在目标机本地跑,走 127.0.0.1)。
+"""
+import json
+import ssl
+import sys
+import urllib.error
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+
+TOK = None
+
+
+def call(path, payload=None, method=None):
+ data = json.dumps(payload or {}).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method=method or ('POST' if data else 'GET'))
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=25, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except urllib.error.HTTPError as e:
+ try:
+ return json.loads(e.read().decode('utf-8'))
+ except Exception:
+ return {'ok': False, 'msg_cn': 'HTTP %s' % e.code}
+ except Exception as e:
+ return {'ok': False, 'msg_cn': str(e)}
+
+
+r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+if not r.get('ok'):
+ print('登录失败:%s' % r.get('msg_cn'))
+ sys.exit(1)
+TOK = r['data']['token']
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-28s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+print('=' * 60)
+print('一、入口与鉴权')
+ck('健康检查 /api/health', call('/api/health').get('ok'))
+ck('登录并取 token', len(TOK) > 10, 'len=%d' % len(TOK))
+ck('当前用户 /api/me', call('/api/me').get('ok'))
+sysinfo = call('/api/sysinfo')
+d = sysinfo.get('data') or {}
+ck('系统信息含主机名', bool(d.get('host') or d.get('hostname')),
+ str(d.get('host') or d.get('hostname'))[:30])
+ck('概览仪表盘有 CPU 数据', 'cpu' in d or 'cpu_pct' in d)
+
+print('\n二、配置模块(统一 /api/config)')
+cfg = (call('/api/config').get('data') or {})
+ck('配置模块数 = 16', len(cfg) == 16, '实际 %d' % len(cfg))
+need = ['dnsmasq', 'nft_v4', 'nft_v6', 'radvd', 'dhcpv6', 'upnp', 'ntp',
+ 'system', 'pppoe', 'ddns', 'portfwd', 'acl', 'share', 'docker',
+ 'ulog', 'theme']
+miss = [k for k in need if k not in cfg]
+ck('16 个模块齐全', not miss, '缺 %s' % miss if miss else '')
+
+print('\n三、网络 / 网卡 / WAN')
+ck('网卡列表 /api/ifaces', call('/api/ifaces').get('ok'))
+ck('网卡配置方式 /api/iface_method', call('/api/iface_method').get('ok'))
+ck('路由表 /api/routes', call('/api/routes').get('ok'))
+ck('IPv6 状态 /api/ipv6', call('/api/ipv6').get('ok'))
+ck('VLAN /api/vlan', call('/api/vlan').get('ok'))
+ck('WOL /api/wol', call('/api/wol').get('ok'))
+ck('PPPoE 多拨 /api/pppoe-multi', call('/api/pppoe-multi').get('ok'))
+ck('PPPoE 拨号日志 /api/ppp/log', call('/api/ppp/log').get('ok'))
+
+print('\n四、防火墙 / 日志')
+fw = call('/api/fwlog')
+ck('防火墙日志 /api/fwlog', fw.get('ok'))
+dd = fw.get('data') or {}
+# 实际字段:items(日志行数组)/ enabled(开关)/ stat(统计)/ since
+ck('防火墙日志有 items 内容', isinstance(dd.get('items'), list),
+ 'items=%d 条' % len(dd.get('items') or []))
+ck('防火墙日志有开关字段 enabled', 'enabled' in dd, 'enabled=%s' % dd.get('enabled'))
+ck('nft 规则集 /api/nft', call('/api/nft').get('ok'))
+ck('WAN 日志 /api/wan/log', call('/api/wan/log').get('ok'))
+ck('统一日志 /api/ulog', call('/api/ulog').get('ok'))
+ck('日志守护配置 /api/ulog/conf', call('/api/ulog/conf').get('ok'))
+ck('日志守护状态 /api/ulog/daemon', call('/api/ulog/daemon').get('ok'))
+ck('流量日志 /api/ulog/flow', call('/api/ulog/flow').get('ok'))
+ck('系统日志 /api/journal', call('/api/journal').get('ok'))
+ck('实时指标 /api/metrics', call('/api/metrics').get('ok'))
+
+print('\n五、服务功能')
+ck('DHCP 租约 /api/leases', call('/api/leases').get('ok'))
+ck('DDNS /api/ddns', call('/api/ddns').get('ok'))
+ck('ACL /api/acl', call('/api/acl').get('ok'))
+ck('共享 SMB/NFS /api/share', call('/api/share').get('ok'))
+ck('Docker /api/docker', call('/api/docker').get('ok'))
+ck('QoS /api/qos', call('/api/qos').get('ok'))
+ck('DPI /api/dpi', call('/api/dpi').get('ok'))
+ck('流加速 /api/accel', call('/api/accel').get('ok'))
+ck('服务管理 /api/services', call('/api/services').get('ok'))
+ck('NTP /api/ntp', call('/api/ntp').get('ok'))
+ck('NAT 检测 /api/nat/check', call('/api/nat/check').get('ok'))
+# /api/diag 是 POST,参数名是 tool(不是 kind);GET 会落进 NOTFOUND
+dg = call('/api/diag', {'tool': 'ping', 'target': '127.0.0.1', 'count': 2})
+ck('诊断 /api/diag (POST+tool)', dg.get('ok'), str(dg.get('msg_cn'))[:40])
+
+print('\n六、主题之家')
+ck('主题列表 /api/themes', call('/api/themes').get('ok'))
+th = call('/api/themes')
+td = th.get('data') or {}
+lst = td.get('list') or td.get('themes') or (td if isinstance(td, list) else [])
+ck('内置主题 >= 5 个', len(lst) >= 5, '实际 %d' % len(lst))
+ck('当前生效主题可读', bool((cfg.get('theme') or {}).get('active')),
+ str((cfg.get('theme') or {}).get('active')))
+
+print('\n七、快照 / 救援 / 依赖')
+ck('快照列表 /api/snapshot/list', call('/api/snapshot/list').get('ok'))
+ck('自动快照 /api/snapshot/auto', call('/api/snapshot/auto').get('ok'))
+ck('救援通道 /api/rescue', call('/api/rescue').get('ok'))
+ck('依赖自检 /api/depcheck', call('/api/depcheck').get('ok'))
+ck('构建保护模式 /api/buildmode', call('/api/buildmode').get('ok'))
+ck('Web 端口 /api/webport', call('/api/webport').get('ok'))
+
+print('\n八、前端资源(浏览器实际拿到的)')
+
+
+def fetch(path):
+ req = urllib.request.Request(BASE + path)
+ try:
+ with urllib.request.urlopen(req, timeout=25, context=CTX) as r:
+ return r.getcode(), r.read().decode('utf-8', 'replace'), dict(r.headers)
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode('utf-8', 'replace'), {}
+ except Exception as e:
+ return 0, str(e), {}
+
+
+c, html, hdr = fetch('/')
+ck('入口页 200', c == 200)
+ck('入口页带资源版本号', 'app.js?v=' in html and 'app.css?v=' in html)
+ck('入口页 no-store', 'no-store' in (hdr.get('Cache-Control') or ''),
+ hdr.get('Cache-Control') or '(无)')
+ck('入口页有手机遮罩 m-mask', 'm-mask' in html)
+c, js, _ = fetch('/app.js')
+ck('app.js 可取且 >300KB', c == 200 and len(js) > 300000, '%d bytes' % len(js))
+ck('app.js 含主题之家', '主题之家' in js)
+ck('app.js 含手机抽屉', 'm-drawer' in js)
+ck('app.js 含 35 个视图映射', js.count('view') > 30)
+c, css, _ = fetch('/app.css')
+ck('app.css 含手机媒体查询', 'max-width:760px' in css)
+ck('app.css 含表格滚动容器 .tw', '.tw' in css)
+c, tcss, _ = fetch('/theme.css')
+ck('主题样式 /theme.css 可取', c == 200)
+
+print('\n' + '=' * 60)
+print('结果:失败 %d 项' % len(fails))
+if fails:
+ for f in fails:
+ print(' ✘ ' + f)
+sys.exit(1 if fails else 0)
diff --git a/_dev/live-check4.py b/_dev/live-check4.py
new file mode 100644
index 0000000..9ab39bb
--- /dev/null
+++ b/_dev/live-check4.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""本轮 4 项改动的真机验收:累计流量 / 退出按钮 / 刷新按钮 / ifb 归类。"""
+import json
+import ssl
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+
+
+def call(path, payload=None):
+ data = json.dumps(payload or {}).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=30, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': str(e)}
+
+
+def fetch(path):
+ req = urllib.request.Request(BASE + path)
+ try:
+ with urllib.request.urlopen(req, timeout=30, context=CTX) as r:
+ return r.getcode(), r.read().decode('utf-8', 'replace')
+ except Exception as e:
+ return 0, str(e)
+
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-40s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'})['data']['token']
+
+print('=' * 62)
+print('#1 历史累计流量')
+si = call('/api/sysinfo')
+nt = (si.get('data') or {}).get('net_total') or {}
+ck('sysinfo 返回 net_total', bool(nt), 'keys=%s' % sorted(nt)[:6])
+ck('含可读的累计下载量 rx_h', bool(nt.get('rx_h')), str(nt.get('rx_h')))
+ck('含可读的累计上传量 tx_h', bool(nt.get('tx_h')), str(nt.get('tx_h')))
+ck('含统计起始时间 since', bool(nt.get('since')), str(nt.get('since')))
+mt = call('/api/metrics')
+mnt = (mt.get('data') or {}).get('net_total') or {}
+ck('metrics 也返回 net_total', bool(mnt), '%s / %s' % (mnt.get('rx_h'), mnt.get('tx_h')))
+
+c, js = fetch('/app.js')
+ck('前端不再把累计块叫「实时上行 / 下行」', '实时上行 / 下行' not in js)
+ck('前端改叫「累计已下载 / 已上传」', '累计已下载 / 已上传' in js)
+ck('实时速率只在「网络实时质量」里', 'q-rx' in js and '网络实时质量' in js)
+
+print('\n#2 左下角退出系统按钮')
+c, html = fetch('/')
+ck('入口页有 btn-logout', 'id="btn-logout"' in html)
+ck('按钮文案是「退出系统」', '退出系统' in html)
+ck('在侧栏底部 side-foot 内', html.index('side-foot') < html.index('btn-logout') < html.index(''))
+ck('app.js 绑定了登出逻辑', 'btn-logout' in js and 'logout(false)' in js)
+
+print('\n#3 右上角刷新按钮')
+ck('入口页有 btn-refresh', 'id="btn-refresh"' in html)
+ck('在顶栏 tb-right 内', html.index('tb-right') < html.index('btn-refresh') < html.index(''))
+ck('app.js 绑定了刷新逻辑', 'btn-refresh' in js and "btnRefresh.classList.add('spinning')" in js)
+
+print('\n#4 新网卡提示 / ifb-drouter 归类')
+ifs = (call('/api/ifaces').get('data') or [])
+ifb = [i for i in ifs if (i.get('name') or '').startswith('ifb')]
+ck('目标机上存在 ifb-* 网卡', bool(ifb),
+ '、'.join(i['name'] for i in ifb) or '(无)')
+ck('ifb-* 已被标记为虚拟设备', all(i.get('is_virtual') for i in ifb))
+ck('ifb-* 标注了 managed_by', all(i.get('managed_by') for i in ifb),
+ '、'.join(str(i.get('managed_by')) for i in ifb))
+ck('前端提示已排除虚拟设备', '!i.is_virtual && !i.managed_by' in js)
+ck('提示带 X 关闭按钮', 'newiface-x' in js)
+ck('关闭状态写入 localStorage', 'drouter_newiface_dismissed' in js)
+
+c, css = fetch('/app.css')
+ck('CSS 有 .tip-x 样式', '.tip-x' in css)
+ck('CSS 有 .side-logout 样式', '.side-logout' in css)
+ck('CSS 有刷新旋转动画', 'spinning' in css and '@keyframes spin' in css)
+
+print('\n' + '=' * 62)
+print('结果:失败 %d 项' % len(fails))
+for f in fails:
+ print(' ✘ ' + f)
+raise SystemExit(1 if fails else 0)
diff --git a/_dev/live-cleanup.py b/_dev/live-cleanup.py
new file mode 100644
index 0000000..d279b8b
--- /dev/null
+++ b/_dev/live-cleanup.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:磁盘与日志清理模块(#3)。
+
+必须 scp 到目标机再跑 —— BASE 是 127.0.0.1:8443,在本机跑会连本机端口。
+用法: python3 /tmp/live-cleanup.py
+
+重点验证三件事:
+ ① 扫描结果里没有任何「不该碰」的路径;
+ ② 试运行(dry)不删任何文件;
+ ③ 策略保存后真的落到配置文件里,定时器真的在跑。
+"""
+import json
+import ssl
+import sys
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+
+def call(path, payload=None, timeout=60):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
+if not TOK:
+ print('登录失败,终止')
+ sys.exit(1)
+
+# ---------------------------------------------------------------- 1. 扫描
+print('--- 扫描接口 ---')
+r = call('/api/cleanup', {'op': 'status'}, timeout=90)
+chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
+d = r.get('data') or {}
+disk = d.get('disk') or {}
+print(' 磁盘:已用 %.0f MB / 共 %.0f MB(%s%%)'
+ % (disk.get('used_mb') or 0, disk.get('total_mb') or 0, disk.get('percent')))
+chk('返回磁盘信息', disk.get('total_mb') and disk.get('percent') is not None)
+items = d.get('items') or []
+chk('返回清理项', len(items) >= 8, '→ %d 项' % len(items))
+print(' 合计可释放 %.1f MB' % ((d.get('reclaimable') or 0) / 1048576.0))
+
+FORBIDDEN = ('/etc/', '/boot/', '/usr/', '/opt/drouter/backend', '/opt/drouter/web',
+ '/opt/drouter/data', '/opt/drouter/snapshots', '/root/', '/home/')
+bad = []
+for it in items:
+ for s in (it.get('samples') or []):
+ p = s.get('path') or ''
+ if p.startswith(FORBIDDEN):
+ bad.append(p)
+ if not it.get('why') or not it.get('risk'):
+ bad.append('<%s 缺说明>' % it.get('key'))
+chk('样本路径全部在安全范围内', not bad, bad)
+
+kv = {it['key']: it for it in items}
+for k in ('ulog', 'drouter_log', 'journal', 'apt', 'tmp', 'trash', 'core', 'snapdl', 'varlog'):
+ it = kv.get(k) or {}
+ print(' %-12s 占用 %8.0f KB 可释放 %8.0f KB 命中 %s'
+ % (k, (it.get('total') or 0) / 1024.0, (it.get('reclaimable') or 0) / 1024.0,
+ it.get('hit_count') if not it.get('whole') else '全部'))
+chk('九项齐全', len(kv) == 9)
+
+# 活跃日志绝不出现在候选里
+act = []
+for it in items:
+ for s in (it.get('samples') or []):
+ p = (s.get('path') or '')
+ base = p.rsplit('/', 1)[-1]
+ if base.endswith('.jsonl') or base.endswith('.log'):
+ act.append(base)
+chk('候选里不含正在写入的活跃日志', not act, act)
+
+# ---------------------------------------------------------------- 2. 试运行
+print('\n--- 试运行(dry,不删文件)---')
+before_total = sum((it.get('total') or 0) for it in items)
+r2 = call('/api/cleanup', {'op': 'run', 'dry': True}, timeout=180)
+chk('dry 接口可用', r2.get('ok') is True, '→ %s' % r2.get('msg_cn'))
+dd = r2.get('data') or {}
+chk('标记为试运行', dd.get('dry') is True)
+r3 = call('/api/cleanup', {'op': 'status'}, timeout=90)
+after_total = sum((it.get('total') or 0) for it in ((r3.get('data') or {}).get('items') or []))
+chk('试运行后磁盘占用没有变化', abs(after_total - before_total) < 4096,
+ 'Δ=%d 字节' % (after_total - before_total))
+
+# ---------------------------------------------------------------- 3. 策略保存
+print('\n--- 策略保存 ---')
+r4 = call('/api/cleanup', {'op': 'save', 'enabled': True, 'trigger': 'both',
+ 'disk_percent': 88, 'schedule': 'daily', 'hour': 3,
+ 'max_mb_per_run': 0,
+ 'items': {'ulog': {'on': True, 'days': 5}}}, timeout=60)
+chk('save 接口可用', r4.get('ok') is True, '→ %s' % r4.get('msg_cn'))
+r5 = call('/api/cleanup', {'op': 'status'}, timeout=90)
+c5 = ((r5.get('data') or {}).get('config') or {})
+chk('水位阈值已落库', c5.get('disk_percent') == 88, '→ %s' % c5.get('disk_percent'))
+chk('执行时间已落库', c5.get('hour') == 3, '→ %s' % c5.get('hour'))
+chk('单项天数已落库', ((c5.get('items') or {}).get('ulog') or {}).get('days') == 5)
+chk('其余项未被误改', ((c5.get('items') or {}).get('drouter_log') or {}).get('days') == 7)
+chk('定时器处于活动状态', (r5.get('data') or {}).get('timer_active') is True)
+
+# 还原成默认值,别把用户的阈值留在 88
+call('/api/cleanup', {'op': 'save', 'enabled': True, 'trigger': 'both',
+ 'disk_percent': 85, 'schedule': 'daily', 'hour': 4,
+ 'max_mb_per_run': 0,
+ 'items': {'ulog': {'on': True, 'days': 3}}}, timeout=60)
+print(' (已还原为默认阈值 85% / ulog 3 天)')
+
+# ---------------------------------------------------------------- 4. 自动触发判断
+print('\n--- 自动触发判断 ---')
+r6 = call('/api/cleanup', {'op': 'auto'}, timeout=180)
+chk('auto 接口可用', r6.get('ok') is True, '→ %s' % r6.get('msg_cn'))
+d6 = r6.get('data') or {}
+if d6.get('skipped'):
+ print(' 本次跳过(水位 %s%%,未到阈值 / 未到时间)—— 这正是期望行为'
+ % d6.get('percent'))
+ chk('跳过时不产生删除', d6.get('freed') is None or d6.get('freed') == 0)
+else:
+ print(' 已执行,释放 %.1f MB' % ((d6.get('freed') or 0) / 1048576.0))
+ chk('执行后带明细', isinstance(d6.get('items'), list))
+
+# ---------------------------------------------------------------- 5. 关闭后再 auto
+print('\n--- 关闭自动清理后 auto 应跳过 ---')
+call('/api/cleanup', {'op': 'save', 'enabled': False}, timeout=60)
+r7 = call('/api/cleanup', {'op': 'auto'}, timeout=60)
+chk('关闭后 auto 跳过', ((r7.get('data') or {}).get('skipped')) is True,
+ '→ %s' % r7.get('msg_cn'))
+call('/api/cleanup', {'op': 'save', 'enabled': True}, timeout=60)
+print(' (已重新开启)')
+
+print('\n' + '=' * 60)
+print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/live-dcfg.py b/_dev/live-dcfg.py
new file mode 100644
index 0000000..6de43bd
--- /dev/null
+++ b/_dev/live-dcfg.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:Docker 引擎配置 daemon.json(#5)。
+
+必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443)。
+用法: python3 /tmp/live-dcfg.py
+
+会真正写 /etc/docker/daemon.json(这台机器上 Docker 未安装,所以不会、
+也不能重启任何服务)。跑完会还原成「保存前的状态」:
+ * 原来没有 daemon.json → 删掉,并把 /etc/docker 目录也清理掉;
+ * 原来有 → 用自动备份还原,再删掉本次产生的备份。
+
+安全边界:不碰 5900、不动网卡、不启停任何网络服务。
+"""
+import json
+import os
+import ssl
+import sys
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+DCFG_FILE = '/etc/docker/daemon.json'
+DCFG_CONF = '/etc/drouter/dcfg.json' # drouter 自己记的开关状态
+DCFG_CONF_BAK = DCFG_CONF + '.livetest-bak'
+DCFG_BAK_DIR = '/var/lib/drouter/dcfg-backup'
+
+
+def call(path, payload=None, timeout=60):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
+if not TOK:
+ print('登录失败,终止')
+ sys.exit(1)
+
+had_file = os.path.isfile(DCFG_FILE)
+# 开关状态文件必须先挪走:上一轮验收如果保存过 ipv6=True,这一轮读到的就不是
+# 「出厂默认」了,「IPv6 默认关」会假失败。这是测试污染,不是产品问题。
+had_conf = os.path.isfile(DCFG_CONF)
+if had_conf:
+ os.rename(DCFG_CONF, DCFG_CONF_BAK)
+bak_before = set()
+if os.path.isdir(DCFG_BAK_DIR):
+ bak_before = set(os.listdir(DCFG_BAK_DIR))
+print('保存前:daemon.json %s;开关状态文件 %s'
+ % ('存在' if had_file else '不存在(本次会新建)',
+ '已临时挪开(验收完还原)' if had_conf else '不存在'))
+
+# ---------------------------------------------------------------- 1. 读取
+print('\n--- 状态读取 ---')
+r = call('/api/dcfg', {'op': 'status'}, timeout=60)
+chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
+d = r.get('data') or {}
+print(' Docker 引擎:%s 服务:%s 开机自启:%s'
+ % (d.get('installed'), d.get('active'), d.get('enabled')))
+print(' 配置文件:%s(%s)' % (d.get('file'), '存在' if d.get('exists') else '不存在'))
+chk('返回配置文件路径', d.get('file') == DCFG_FILE)
+chk('返回是否已安装', 'installed' in d)
+chk('返回服务状态', 'active' in d and 'enabled' in d)
+chk('返回镜像源清单', len(d.get('mirrors') or []) >= 5,
+ '→ %d 个' % len(d.get('mirrors') or []))
+chk('返回未纳管键列表', 'unmanaged' in d)
+chk('返回预览代码', bool((d.get('preview') or '').strip()))
+print(' 默认预览:')
+for ln in (d.get('preview') or '').rstrip().splitlines():
+ print(' ' + ln)
+
+c0 = d.get('cfg') or {}
+chk('IPv6 默认关', c0.get('ipv6') is False)
+chk('日志滚动默认开', c0.get('log_rotate') is True)
+chk('live-restore 默认开', c0.get('live_restore') is True)
+
+# ---------------------------------------------------------------- 2. 预览与落盘一致
+print('\n--- 预览 = 真正写入的内容 ---')
+prev = call('/api/dcfg', {'op': 'preview',
+ 'ipv6': True, 'fixed_cidr_v6': 'fdab:cdef:1234::/64',
+ 'experimental': False,
+ 'mirrors': ['nju', 'tencent'],
+ 'mirror_custom': '',
+ 'log_rotate': True, 'log_max_size': '20m', 'log_max_file': 5,
+ 'live_restore': True, 'bip': ''}, timeout=60)
+chk('preview 接口可用', prev.get('ok') is True)
+p_txt = ((prev.get('data') or {}).get('preview') or '')
+print(' 预览:')
+for ln in p_txt.rstrip().splitlines():
+ print(' ' + ln)
+chk('预览含 ipv6', '"ipv6": true' in p_txt)
+chk('预览含指定网段', 'fdab:cdef:1234::/64' in p_txt)
+chk('预览含 ip6tables', '"ip6tables": true' in p_txt)
+chk('预览不含 experimental(未勾选)', '"experimental"' not in p_txt)
+chk('预览含两个镜像源',
+ '"registry-mirrors"' in p_txt
+ and 'docker.nju.edu.cn' in p_txt
+ and 'mirror.ccs.tencentyun.com' in p_txt)
+chk('预览含日志滚动参数',
+ '"max-size": "20m"' in p_txt and '"max-file": "5"' in p_txt)
+
+# ---------------------------------------------------------------- 3. 保存
+print('\n--- 保存 ---')
+s = call('/api/dcfg', {'op': 'save',
+ 'ipv6': True, 'fixed_cidr_v6': 'fdab:cdef:1234::/64',
+ 'experimental': False,
+ 'mirrors': ['nju', 'tencent'],
+ 'mirror_custom': '',
+ 'log_rotate': True, 'log_max_size': '20m', 'log_max_file': 5,
+ 'live_restore': True, 'bip': ''}, timeout=60)
+chk('save 接口可用', s.get('ok') is True, '→ %s' % s.get('msg_cn'))
+sd = s.get('data') or {}
+written = sd.get('content') or ''
+chk('返回真实写入内容', bool(written))
+chk('落盘内容与预览逐字节一致', written.strip() == p_txt.strip())
+chk('保存时产生了备份', bool(sd.get('backup')) or not had_file,
+ '→ %s' % sd.get('backup'))
+try:
+ on_disk = open(DCFG_FILE, encoding='utf-8').read()
+except Exception as e:
+ on_disk = '读取失败:%s' % e
+chk('文件里的内容与接口返回一致', on_disk == written)
+try:
+ parsed = json.loads(on_disk)
+ chk('落盘的是合法 JSON', True)
+ chk('落盘内容含 ipv6 与网段',
+ parsed.get('ipv6') is True and parsed.get('fixed-cidr-v6') == 'fdab:cdef:1234::/64')
+ chk('落盘内容含镜像源', len(parsed.get('registry-mirrors') or []) == 2)
+except Exception as e:
+ chk('落盘的是合法 JSON', False, str(e))
+chk('保存后没有去重启 Docker(本页从不自动重启)',
+ '重启' not in (s.get('msg_cn') or '') or '需要重启' in (s.get('msg_cn') or ''))
+
+# ---------------------------------------------------------------- 4. 保留用户自己的键
+print('\n--- 保留用户自己写的键 ---')
+with open(DCFG_FILE, 'w', encoding='utf-8') as f:
+ f.write(json.dumps({'data-root': '/mnt/ssd/docker',
+ 'insecure-registries': ['10.0.0.5:5000'],
+ 'ipv6': True, 'fixed-cidr-v6': 'fd00:dead::/64'},
+ ensure_ascii=False, indent=2) + '\n')
+s2 = call('/api/dcfg', {'op': 'save', 'ipv6': False, 'mirrors': [],
+ 'mirror_custom': '', 'log_rotate': True,
+ 'log_max_size': '10m', 'log_max_file': 3,
+ 'live_restore': True, 'bip': ''}, timeout=60)
+chk('带外来键也能保存', s2.get('ok') is True, '→ %s' % s2.get('msg_cn'))
+try:
+ p2 = json.loads(open(DCFG_FILE, encoding='utf-8').read())
+except Exception as e:
+ p2 = {}
+ chk('二次保存后仍是合法 JSON', False, str(e))
+chk('data-root 被保留', p2.get('data-root') == '/mnt/ssd/docker')
+chk('insecure-registries 被保留', p2.get('insecure-registries') == ['10.0.0.5:5000'])
+chk('关掉的 ipv6 被移除', 'ipv6' not in p2 and 'fixed-cidr-v6' not in p2)
+r2 = call('/api/dcfg', {'op': 'status'}, timeout=60)
+chk('状态里列出未纳管键',
+ set(((r2.get('data') or {}).get('unmanaged') or []))
+ >= {'data-root', 'insecure-registries'},
+ '→ %s' % ((r2.get('data') or {}).get('unmanaged')))
+
+# ---------------------------------------------------------------- 5. ULA 生成
+print('\n--- 随机私有段 ---')
+u = call('/api/dcfg', {'op': 'ula'}, timeout=30)
+ula = ((u.get('data') or {}).get('ula') or '')
+chk('ula 接口可用', u.get('ok') is True)
+print(' 生成:%s' % ula)
+chk('是 fd 开头的 /48', ula.startswith('fd') and ula.endswith('/48'))
+u2 = ((call('/api/dcfg', {'op': 'ula'}, timeout=30).get('data') or {}).get('ula') or '')
+chk('两次生成不同', ula != u2, '%s / %s' % (ula, u2))
+
+# ---------------------------------------------------------------- 6. 参数校验
+print('\n--- 参数校验 ---')
+bad = call('/api/dcfg', {'op': 'save', 'ipv6': True, 'fixed_cidr_v6': '瞎写的',
+ 'mirrors': [], 'log_rotate': True, 'live_restore': True,
+ 'bip': ''}, timeout=60)
+chk('非法 IPv6 网段被拒', bad.get('ok') is False, '→ %s' % bad.get('msg_cn'))
+bad2 = call('/api/dcfg', {'op': 'save', 'ipv6': False, 'mirrors': ['不存在的源'],
+ 'log_rotate': True, 'live_restore': True}, timeout=60)
+chk('未知镜像源被拒', bad2.get('ok') is False, '→ %s' % bad2.get('msg_cn'))
+bad3 = call('/api/dcfg', {'op': 'save', 'ipv6': False, 'mirrors': [],
+ 'mirror_custom': 'http://明文.com',
+ 'log_rotate': True, 'live_restore': True}, timeout=60)
+chk('明文 http 自定义源被拒', bad3.get('ok') is False, '→ %s' % bad3.get('msg_cn'))
+bad4 = call('/api/dcfg', {'op': 'bogus'}, timeout=30)
+chk('未知 op 被拒', bad4.get('ok') is False)
+bad5 = call('/api/dcfg', {'op': 'restore', 'file': '../../etc/shadow'}, timeout=30)
+chk('还原路径穿越被拒', bad5.get('ok') is False, '→ %s' % bad5.get('msg_cn'))
+
+# ---------------------------------------------------------------- 7. 镜像源测速
+print('\n--- 镜像源一键测速 ---')
+mt = call('/api/dcfg', {'op': 'mirror_test'}, timeout=220)
+chk('测速接口可用', mt.get('ok') is True, '→ %s' % mt.get('msg_cn'))
+rows = ((mt.get('data') or {}).get('results') or [])
+for t in rows:
+ flag = '可用' if t.get('ok') else ('跳过' if t.get('skipped') else '不可用')
+ print(' %-16s %-6s %-8s %s'
+ % (t.get('name'), flag, ('%d ms' % t.get('ms')) if t.get('ms') else '—',
+ t.get('reason') or ''))
+chk('返回了全部预设的结果', len(rows) >= 6, '→ %d 条' % len(rows))
+chk('阿里云占位地址被跳过',
+ any(t.get('skipped') and t.get('key') == 'aliyun' for t in rows))
+chk('每条都有结论文案', all((t.get('reason') or '') for t in rows))
+
+# ---------------------------------------------------------------- 8. 重启保护
+print('\n--- 重启保护(这台机器上 Docker 未安装)---')
+rs = call('/api/dcfg', {'op': 'restart', 'confirm': True}, timeout=60)
+if (r.get('data') or {}).get('installed'):
+ chk('已安装时应给出明确结果', 'msg_cn' in rs, '→ %s' % rs.get('msg_cn'))
+else:
+ chk('未安装时拒绝重启', rs.get('ok') is False, '→ %s' % rs.get('msg_cn'))
+rs2 = call('/api/dcfg', {'op': 'restart'}, timeout=60)
+chk('不带确认就拒绝', rs2.get('ok') is False, '→ %s' % rs2.get('msg_cn'))
+
+# ---------------------------------------------------------------- 9. 还原
+print('\n--- 备份与还原 ---')
+st = call('/api/dcfg', {'op': 'status'}, timeout=60)
+bks = ((st.get('data') or {}).get('backups') or [])
+chk('产生了备份记录', len(bks) >= 1, '→ %d 份' % len(bks))
+if bks:
+ pick = bks[0]['file']
+ rr = call('/api/dcfg', {'op': 'restore', 'file': pick}, timeout=60)
+ chk('还原接口可用', rr.get('ok') is True, '→ %s' % rr.get('msg_cn'))
+ chk('还原后文件内容与备份一致',
+ (rr.get('data') or {}).get('content', '').strip()
+ == open(DCFG_FILE, encoding='utf-8').read().strip())
+
+# ---------------------------------------------------------------- 10. 还原现场
+print('\n--- 还原现场 ---')
+if had_file:
+ print(' 原本有 daemon.json,交由用户确认保留哪一份(备份目录里都在)')
+else:
+ try:
+ os.remove(DCFG_FILE)
+ chk('已删除本次新建的 daemon.json', not os.path.isfile(DCFG_FILE))
+ except Exception as e:
+ chk('已删除本次新建的 daemon.json', False, str(e))
+ try:
+ if os.path.isdir('/etc/docker') and not os.listdir('/etc/docker'):
+ os.rmdir('/etc/docker')
+ print(' /etc/docker 空目录也已清理')
+ except Exception:
+ pass
+if os.path.isdir(DCFG_BAK_DIR):
+ new = set(os.listdir(DCFG_BAK_DIR)) - bak_before
+ for f in new:
+ try:
+ os.remove(os.path.join(DCFG_BAK_DIR, f))
+ except Exception:
+ pass
+ print(' 本次产生的 %d 份备份已清理' % len(new))
+if had_conf:
+ os.rename(DCFG_CONF_BAK, DCFG_CONF)
+ print(' 开关状态文件已还原')
+else:
+ try:
+ os.remove(DCFG_CONF)
+ except Exception:
+ pass
+ print(' 本次生成的开关状态文件已清理')
+
+print('\n' + '=' * 60)
+print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/live-deps.py b/_dev/live-deps.py
new file mode 100644
index 0000000..b89ad8f
--- /dev/null
+++ b/_dev/live-deps.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""依赖自检(#5)真机验收 —— 必须在目标机上跑(BASE 是 127.0.0.1:8443)。
+
+只看三件事:
+ 1. 清单里新加的项都能被正确探测(有就是有、没有就是没有,别因为 PATH 少
+ /usr/sbin 而把已装的 mkfs.* 报成缺失);
+ 2. 缺失的可选大件(samba / nfs / docker)不会出现在「一键安装」的范围里;
+ 3. 页面能拿到完整清单。
+"""
+import json
+import ssl
+import sys
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+
+fails = 0
+
+
+def chk(label, cond, extra=''):
+ global fails
+ if not cond:
+ fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+
+def call(path, body=None, token=None):
+ data = None
+ headers = {}
+ if body is not None:
+ data = json.dumps(body).encode('utf-8')
+ headers['Content-Type'] = 'application/json'
+ if token:
+ headers['X-Token'] = token
+ req = urllib.request.Request(BASE + path, data=data, headers=headers,
+ method='POST' if body is not None else 'GET')
+ with urllib.request.urlopen(req, context=CTX, timeout=60) as r:
+ return json.loads(r.read().decode('utf-8'))
+
+
+def main():
+ r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+ chk('登录成功', r.get('ok'), r.get('msg_cn'))
+ if not r.get('ok'):
+ return 1
+ tk = r['data']['token']
+
+ r = call('/api/deps/check', None, tk)
+ chk('自检接口返回成功', r.get('ok'), r.get('msg_cn'))
+ d = r.get('data') or {}
+ items = d.get('items') or []
+ by = {x['key']: x for x in items}
+ s = d.get('summary') or {}
+ print(' 总计 %s 项:已就绪 %s / 必需缺失 %s / 可选未装 %s'
+ % (s.get('total'), s.get('ok'), s.get('missing'), s.get('optional')))
+
+ for k in ('util-linux', 'tc', 'journalctl', 'mtr', 'samba', 'nfs-server',
+ 'docker', 'docker-compose', 'exfatprogs', 'ntfs-3g', 'dosfstools',
+ 'btrfs-progs', 'xfsprogs', 'f2fs-tools', 'etherwake', 'tcpdump',
+ 'nmcli', 'networkctl'):
+ chk('清单含 %s' % k, k in by)
+
+ # PATH 陷阱:这些命令都在 /sbin / /usr/sbin,helper 必须已归一化 PATH
+ for k in ('util-linux', 'tc'):
+ x = by.get(k) or {}
+ chk('%s 被正确探测为已安装(PATH 含 /sbin)' % k, x.get('ok') is True,
+ '→ %s' % (x.get('where') or x.get('target')))
+
+ # 本机确实没装的,应如实报缺失(而不是靠猜)
+ for k in ('samba', 'nfs-server', 'docker'):
+ x = by.get(k) or {}
+ chk('%s 如实报为未安装' % k, x.get('ok') is False)
+
+ # 危险项不给安装包
+ for k in ('nmcli', 'networkctl', 'systemd', 'vnc'):
+ x = by.get(k) or {}
+ chk('%s 不提供一键安装(pkg 为空)' % k, not (x.get('pkg') or ''))
+
+ # 可选大件绝不能是「必需」,否则会被一键安装带上
+ for k in ('samba', 'nfs-server', 'docker', 'xfce', 'xorg'):
+ x = by.get(k) or {}
+ chk('%s 是可选(不进一键安装)' % k, x.get('required') is False)
+
+ print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+ return 1 if fails else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-kern.py b/_dev/live-kern.py
new file mode 100644
index 0000000..2c50c67
--- /dev/null
+++ b/_dev/live-kern.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:内核转发与加速(#4)。
+
+必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443)。
+用法: python3 /tmp/live-kern.py
+
+会真正修改内核参数。之所以敢这么做:
+ * 开启 ip_forward 只是让本机「具备转发能力」,不改变默认网关、
+ 不启动 DHCP、不加载 nft 规则集,不会产生网络风暴;
+ * IPv6 转发会同时设 accept_ra=2,保住本机靠 SLAAC 拿到的 IPv6;
+ * BBR 只影响本机自己发起的 TCP;
+ * SNMP 未安装,不会真的启动。
+"""
+import json
+import ssl
+import sys
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+
+def call(path, payload=None, timeout=60):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
+if not TOK:
+ print('登录失败,终止')
+ sys.exit(1)
+
+# ---------------------------------------------------------------- 1. 读取
+print('--- 状态读取 ---')
+r = call('/api/kern', {'op': 'status'}, timeout=60)
+chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
+d = r.get('data') or {}
+live0 = d.get('live') or {}
+print(' 当前实际:ip_forward=%s ipv6_forwarding=%s 拥塞=%s 队列=%s'
+ % (live0.get('ip_forward'), live0.get('ipv6_forwarding'),
+ live0.get('congestion'), live0.get('qdisc')))
+print(' 内核可用算法:%s' % live0.get('available'))
+chk('返回实时内核参数', live0.get('ip_forward') is not None)
+chk('返回可用拥塞算法', bool(live0.get('available')))
+chk('返回 SNMP 安装状态', 'snmp_installed' in d, '→ %s' % d.get('snmp_installed'))
+chk('返回持久化文件路径', '99-drouter.conf' in (d.get('sysctl_file') or ''))
+
+items = d.get('items') or []
+fws = d.get('fw_items') or []
+chk('四项内核开关', len(items) == 4, '→ %d' % len(items))
+chk('四项防火墙参数', len(fws) == 4, '→ %d' % len(fws))
+for it in items + fws:
+ chk('%-14s 有说明与联动影响' % it.get('key'),
+ len(it.get('why') or '') >= 20 and len(it.get('impact') or []) >= 2)
+
+fw0 = d.get('fw') or {}
+print(' 防火墙参数:masq_v4=%s mss_v4=%s(%s/%s) masq_v6=%s mss_v6=%s'
+ % (fw0.get('masquerade_v4'), fw0.get('mss_v4'), fw0.get('mss_v4_mode'),
+ fw0.get('mss_v4_value'), fw0.get('masquerade_v6'), fw0.get('mss_v6')))
+chk('默认 masquerade_v4 开', fw0.get('masquerade_v4') is True)
+chk('默认 masquerade_v6 关', fw0.get('masquerade_v6') is False)
+chk('默认 mss_v4 开', fw0.get('mss_v4') is True)
+chk('默认 mss_v6 关', fw0.get('mss_v6') is False)
+
+# ---------------------------------------------------------------- 2. 保存并生效
+print('\n--- 保存并生效(转发 + BBR 全开)---')
+r2 = call('/api/kern', {'op': 'save', 'fwd_v4': True, 'fwd_v6': True, 'bbr': True,
+ 'fw': {'masquerade_v4': True, 'mss_v4': True,
+ 'mss_v4_mode': 'clamp', 'mss_v4_value': 1452,
+ 'masquerade_v6': False, 'mss_v6': False,
+ 'mss_v6_mode': 'clamp', 'mss_v6_value': 1432},
+ 'snmp': {'enabled': False}}, timeout=120)
+chk('save 接口可用', r2.get('ok') is True, '→ %s' % r2.get('msg_cn'))
+d2 = r2.get('data') or {}
+print(' 已生效参数:%s' % ', '.join(d2.get('applied') or []))
+if d2.get('errors'):
+ print(' 提示:%s' % ';'.join(d2['errors']))
+chk('ip_forward 真的生效', 'net.ipv4.ip_forward' in (d2.get('applied') or []))
+chk('ipv6 forwarding 真的生效', 'net.ipv6.conf.all.forwarding' in (d2.get('applied') or []))
+chk('accept_ra=2 一起写了', 'net.ipv6.conf.all.accept_ra' in (d2.get('applied') or []))
+chk('BBR 已生效', d2.get('bbr_active') is True)
+
+r3 = call('/api/kern', {'op': 'status'}, timeout=60)
+l3 = ((r3.get('data') or {}).get('live') or {})
+print(' 生效后:ip_forward=%s ipv6_forwarding=%s 拥塞=%s 队列=%s'
+ % (l3.get('ip_forward'), l3.get('ipv6_forwarding'),
+ l3.get('congestion'), l3.get('qdisc')))
+chk('ip_forward 已变为 1', l3.get('ip_forward') == '1')
+chk('ipv6 forwarding 已变为 1', l3.get('ipv6_forwarding') == '1')
+chk('拥塞控制已变为 bbr', l3.get('congestion') == 'bbr')
+chk('队列已变为 fq', l3.get('qdisc') == 'fq')
+
+# ---------------------------------------------------------------- 3. 渲染产物
+print('\n--- 渲染产物核对 ---')
+r4 = call('/api/render', {'module': 'nft_v4'}, timeout=60)
+t4 = ((r4.get('data') or {}).get('text') or '')
+chk('v4 规则集含 masquerade', 'masquerade' in t4)
+chk('v4 规则集含 MSS 钳制', 'maxseg size set rt mtu' in t4)
+r5 = call('/api/render', {'module': 'nft_v6'}, timeout=60)
+t6 = ((r5.get('data') or {}).get('text') or '')
+chk('v6 规则集默认无 NAT66', 'masquerade' not in t6)
+chk('v6 规则集默认无 MSS', 'maxseg' not in t6)
+
+# 打开 v6 的两项,再看渲染
+call('/api/kern', {'op': 'save',
+ 'fw': {'masquerade_v6': True, 'mss_v6': True}}, timeout=60)
+r6 = call('/api/render', {'module': 'nft_v6'}, timeout=60)
+t6b = ((r6.get('data') or {}).get('text') or '')
+chk('v6 打开后出现 NAT66', 'masquerade' in t6b)
+chk('v6 打开后出现 MSS 钳制', 'maxseg size set rt mtu' in t6b)
+# 还原
+call('/api/kern', {'op': 'save',
+ 'fw': {'masquerade_v6': False, 'mss_v6': False}}, timeout=60)
+print(' (v6 两项已还原为关闭)')
+
+# ---------------------------------------------------------------- 4. 非法参数
+print('\n--- 参数校验 ---')
+bad = call('/api/kern', {'op': 'save', 'snmp': {'community': 'a b c'}}, timeout=60)
+chk('团体名含空格被拒', bad.get('ok') is False, '→ %s' % bad.get('msg_cn'))
+bad2 = call('/api/kern', {'op': 'save', 'snmp': {'port': 99999}}, timeout=60)
+chk('端口越界被钳制而非报错', bad2.get('ok') is True)
+bad3 = call('/api/kern', {'op': 'save', 'snmp': {'listen': '随便写'}}, timeout=60)
+chk('非法监听地址被拒', bad3.get('ok') is False, '→ %s' % bad3.get('msg_cn'))
+bad4 = call('/api/kern', {'op': 'bogus'}, timeout=60)
+chk('未知 op 被拒', bad4.get('ok') is False)
+
+# ---------------------------------------------------------------- 5. 关闭转发
+print('\n--- 关闭转发(应立刻回到 0)---')
+call('/api/kern', {'op': 'save', 'fwd_v4': False, 'fwd_v6': False}, timeout=120)
+r7 = call('/api/kern', {'op': 'status'}, timeout=60)
+l7 = ((r7.get('data') or {}).get('live') or {})
+chk('ip_forward 已回到 0', l7.get('ip_forward') == '0', '→ %s' % l7.get('ip_forward'))
+chk('ipv6 forwarding 已回到 0', l7.get('ipv6_forwarding') == '0')
+# 恢复成推荐默认
+call('/api/kern', {'op': 'save', 'fwd_v4': True, 'fwd_v6': True, 'bbr': True}, timeout=120)
+r8 = call('/api/kern', {'op': 'status'}, timeout=60)
+l8 = ((r8.get('data') or {}).get('live') or {})
+chk('已恢复推荐默认(转发开 + BBR 开)',
+ l8.get('ip_forward') == '1' and l8.get('congestion') == 'bbr',
+ 'fwd=%s cong=%s' % (l8.get('ip_forward'), l8.get('congestion')))
+
+print('\n' + '=' * 60)
+print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/live-opensoho.py b/_dev/live-opensoho.py
new file mode 100644
index 0000000..622c0c8
--- /dev/null
+++ b/_dev/live-opensoho.py
@@ -0,0 +1,410 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:AC / AP 管理中心(OpenSOHO,#10)。
+
+必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443),且必须用 root(要装二进制、
+写 systemd 单元、建系统用户)。用法:
+ python3 /tmp/live-opensoho.py /tmp/opensoho
+
+参数 1 是预先放到本机的一个 OpenSOHO 二进制(或 zip)。这台机器访问不了
+GitHub,在线安装那条路只能验证「失败时报错是否够清楚」,真正的安装走
+local_path。
+
+会真的装一遍再卸一遍,所以开头先拍现场,结尾逐项还原:
+ * /etc/drouter/opensoho.conf / .env / .admin —— 原本没有就删掉;
+ * /etc/systemd/system/opensoho.service —— 删掉并 daemon-reload;
+ * /opt/drouter/opensoho 与 /var/lib/drouter/opensoho —— 清掉;
+ * opensoho 系统用户 —— 删掉;
+ * /etc/drouter/BUILD_MODE —— 按原样恢复。
+
+安全边界:不碰 5900(VNC)、不动网卡与路由、不启停任何网络/DHCP 服务。
+"""
+import json
+import os
+import re
+import ssl
+import sys
+import time
+import shutil
+import subprocess
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+OH_DIR = '/opt/drouter/opensoho'
+OH_BIN = OH_DIR + '/opensoho'
+OH_DATA = '/var/lib/opensoho'
+OH_CONF = '/etc/drouter/opensoho.conf'
+OH_ENV = '/etc/drouter/opensoho.env'
+OH_ADMIN = '/etc/drouter/opensoho.admin'
+OH_UNIT = '/etc/systemd/system/opensoho.service'
+OH_SERVICE = 'opensoho.service'
+OH_USER = 'opensoho'
+BUILD_FLAG = '/etc/drouter/BUILD_MODE'
+
+
+def call(path, payload=None, timeout=60):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+def sh(argv, timeout=60):
+ # 超时绝不能把还原流程打断:systemctl 偶发卡住是很常见的,
+ # 一次卡住就抛异常退出 = 把机器留在半成品状态。
+ try:
+ p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
+ return p.returncode, p.stdout, p.stderr
+ except subprocess.TimeoutExpired:
+ return 124, '', '命令超时(%ds):%s' % (timeout, ' '.join(argv))
+ except Exception as e:
+ return 1, '', '执行异常:%s' % e
+
+
+def svc(name):
+ rc1, a, _ = sh(['systemctl', 'is-active', name])
+ rc2, e, _ = sh(['systemctl', 'is-enabled', name])
+ return {'active': a.strip() if rc1 == 0 else 'inactive',
+ 'enabled': e.strip() if rc2 == 0 else 'disabled'}
+
+
+def http_get(url, timeout=6):
+ try:
+ req = urllib.request.Request(url)
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return r.status, r.read(4096).decode('utf-8', 'ignore')
+ except Exception as e:
+ return 0, str(e)
+
+
+def listen(port):
+ rc, out, _ = sh(['ss', '-lnt'], timeout=20)
+ if rc != 0:
+ return []
+ hits = []
+ for ln in out.splitlines():
+ m = re.search(r'(\S+):%d\b' % int(port), ln)
+ if not m:
+ continue
+ # ss 把「监听全部地址」打成 `*`,跟后端一样还原成 0.0.0.0 再比较
+ addr = '0.0.0.0' if m.group(1) == '*' else m.group(1)
+ if addr not in hits:
+ hits.append(addr)
+ return hits
+
+
+def read_env():
+ d = {}
+ try:
+ with open(OH_ENV, encoding='utf-8') as f:
+ for ln in f:
+ ln = ln.strip()
+ if ln and not ln.startswith('#') and '=' in ln:
+ k, v = ln.split('=', 1)
+ d[k.strip()] = v.strip()
+ except Exception:
+ pass
+ return d
+
+
+def main():
+ local = sys.argv[1] if len(sys.argv) > 1 else '/tmp/opensoho'
+
+ print('===== 真机验收:AC/AP 管理中心 OpenSOHO(#10) =====\n')
+
+ # ---------- 0. 登录 ----------
+ # 注意字段名是 username / password,不是 user / pass
+ r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+ if not r.get('ok'):
+ print('登录失败:%s' % r.get('msg_cn'))
+ return 1
+ TOK = (r.get('data') or {}).get('token')
+ globals()['TOK'] = TOK
+ chk('登录成功', bool(TOK))
+
+ # ---------- 1. 拍现场 ----------
+ print('\n--- 0. 现场快照 ---')
+ ORIG = {
+ 'conf': os.path.isfile(OH_CONF),
+ 'env': os.path.isfile(OH_ENV),
+ 'admin': os.path.isfile(OH_ADMIN),
+ 'unit': os.path.isfile(OH_UNIT),
+ 'dir': os.path.isdir(OH_DIR),
+ 'data': os.path.isdir(OH_DATA),
+ 'user': sh(['id', OH_USER], timeout=10)[0] == 0,
+ 'build': os.path.isfile(BUILD_FLAG),
+ 'port8090': listen(8090),
+ }
+ print(' 原始状态:%s' % json.dumps(ORIG, ensure_ascii=False))
+ chk('起点是干净的(opensoho 未安装)',
+ not ORIG['unit'] and not ORIG['conf'] and not ORIG['env'])
+ chk('起点 8090 没被占用', not ORIG['port8090'], str(ORIG['port8090']))
+ chk('本地有可安装的二进制', os.path.isfile(local), local)
+
+ try:
+ # ---------- 2. 未安装时的状态 ----------
+ print('\n--- 1. 未安装时的状态 ---')
+ r = call('/api/opensoho', {'op': 'status'})
+ d = r.get('data') or {}
+ chk('status 返回 installed=False', d.get('installed') is False, str(d.get('installed')))
+ chk('未安装时版本为空', not d.get('version'))
+ chk('未安装时健康检查为「未安装」', '未安装' in (d.get('health_msg') or ''),
+ d.get('health_msg'))
+ chk('status 带说明条目', len(d.get('items') or []) >= 6)
+ chk('status 带构建保护模式标记', 'build_mode' in d)
+
+ print('\n--- 2. 未安装时改设置要被拒 ---')
+ r = call('/api/opensoho', {'op': 'save', 'cfg': {'port': 8090}})
+ chk('未安装时保存被拒',
+ r.get('ok') is False and (r.get('code') or '') == 'NOT_INSTALLED',
+ r.get('msg_cn'))
+ r = call('/api/opensoho', {'op': 'svc', 'op2': 'start'})
+ chk('未安装时启服务被拒',
+ r.get('ok') is False and (r.get('code') or '') == 'NOT_INSTALLED')
+
+ # ---------- 3. 在线安装(本机连不上 GitHub,要报错且够清楚) ----------
+ print('\n--- 3. 在线安装:本机访问不了 GitHub,必须明确报错 ---')
+ r = call('/api/opensoho', {'op': 'install'}, timeout=300)
+ chk('在线安装失败', r.get('ok') is False, r.get('msg_cn'))
+ chk('报错里点明了可能原因(能不能访问 GitHub)',
+ 'GitHub' in (r.get('msg_cn') or ''), (r.get('msg_cn') or '')[:90])
+ chk('失败时没有留下半成品单元', not os.path.isfile(OH_UNIT))
+
+ print('\n--- 4. 本地文件不存在时也要明确报错 ---')
+ r = call('/api/opensoho', {'op': 'install', 'local_path': '/nope/nope'})
+ chk('路径不存在报 NOT_FOUND',
+ r.get('ok') is False and (r.get('code') or '') == 'NOT_FOUND', r.get('msg_cn'))
+ chk('此时仍未启服务', svc(OH_SERVICE)['active'] != 'active')
+
+ # ---------- 4. 从本机文件安装 ----------
+ print('\n--- 5. 从本机文件安装 ---')
+ r = call('/api/opensoho', {'op': 'install', 'local_path': local}, timeout=300)
+ chk('本地安装成功', r.get('ok') is True, r.get('msg_cn'))
+ chk('二进制落到 /opt/drouter/opensoho/opensoho', os.path.isfile(OH_BIN))
+ chk('二进制带执行权限', os.access(OH_BIN, os.X_OK))
+ chk('systemd 单元已建立', os.path.isfile(OH_UNIT))
+ chk('专用系统用户已建立', sh(['id', OH_USER], timeout=10)[0] == 0)
+ chk('数据目录已建立', os.path.isdir(OH_DATA))
+ chk('密钥文件已生成', os.path.isfile(OH_ENV))
+ rc, out, _ = sh(['stat', '-c', '%a', OH_ENV], timeout=10)
+ chk('密钥文件权限是 600', out.strip() == '600', out.strip())
+ env = read_env()
+ chk('共享密钥长度达标(32)',
+ len(env.get('OPENSOHO_SHARED_SECRET') or '') >= 32,
+ str(len(env.get('OPENSOHO_SHARED_SECRET') or '')))
+ chk('加密密钥长度正好 32',
+ len(env.get('OPENSOHO_SETTINGS_KEY') or '') == 32,
+ str(len(env.get('OPENSOHO_SETTINGS_KEY') or '')))
+ time.sleep(3)
+ s = svc(OH_SERVICE)
+ chk('服务已启动', s['active'] == 'active', s['active'])
+ chk('服务已设为开机自启', s['enabled'] == 'enabled', s['enabled'])
+ chk('8090 已在监听', '0.0.0.0' in listen(8090), str(listen(8090)))
+ st, body = http_get('http://127.0.0.1:8090/api/health')
+ chk('/api/health 直接应答', st == 200 and 'healthy' in body, '%s %s' % (st, body[:60]))
+ st2, _ = http_get('http://127.0.0.1:8090/_/')
+ chk('控制台页面可达', st2 == 200, str(st2))
+
+ # ---------- 5. 安装后状态 ----------
+ print('\n--- 6. 安装后状态 ---')
+ r = call('/api/opensoho', {'op': 'status'})
+ d = r.get('data') or {}
+ chk('installed=True', d.get('installed') is True)
+ chk('读到了版本号', bool(d.get('version')), str(d.get('version')))
+ chk('健康检查通过', d.get('healthy') is True, str(d.get('health_msg')))
+ chk('监听地址回显正确', d.get('listen'), str(d.get('listen')))
+ chk('共享密钥回显出来了', len(d.get('secret') or '') >= 32)
+ chk('加密已启用', d.get('enc_ok') is True)
+ chk('控制台地址用的是 LAN IP',
+ '192.168.7.3' in (d.get('console_url') or ''), d.get('console_url'))
+ chk('管理员邮箱已建立', bool(d.get('admin_email')), str(d.get('admin_email')))
+ chk('管理员密码能取到(进控制台要用)', bool(d.get('admin_password')))
+ rc, out, _ = sh(['stat', '-c', '%a', OH_ADMIN], timeout=10)
+ chk('凭据文件权限是 600', out.strip() == '600', out.strip())
+
+ # ---------- 6. 探 REST API ----------
+ print('\n--- 7. 通过 REST API 读概况 ---')
+ r = call('/api/opensoho', {'op': 'probe'})
+ d = r.get('data') or {}
+ chk('probe 成功', r.get('ok') is True, r.get('msg_cn'))
+ chk('返回了 8 个集合的计数', len(d.get('stats') or []) == 8,
+ str(len(d.get('stats') or [])))
+ bad = [s for s in (d.get('stats') or []) if s.get('count') is None]
+ chk('所有集合都读到了计数(空库是 0 不是 null)', not bad, str(bad))
+ chk('devices 是 0(还没 AP 注册)',
+ ((d.get('stats') or [{}])[0].get('count')) == 0)
+ chk('返回了设备明细列表', isinstance(d.get('devices'), list))
+
+ # ---------- 7. 重新生成共享密钥 ----------
+ print('\n--- 8. 重新生成共享密钥(加密密钥绝不能动) ---')
+ before = read_env()
+ r = call('/api/opensoho', {'op': 'secret'})
+ after = read_env()
+ chk('重新生成成功', r.get('ok') is True, r.get('msg_cn'))
+ chk('共享密钥确实变了',
+ before.get('OPENSOHO_SHARED_SECRET') != after.get('OPENSOHO_SHARED_SECRET'))
+ chk('【关键】加密密钥没被动过',
+ before.get('OPENSOHO_SETTINGS_KEY') == after.get('OPENSOHO_SETTINGS_KEY'))
+ chk('提示了 AP 要重新注册', '重新注册' in (r.get('msg_cn') or ''))
+ time.sleep(3)
+ chk('换密钥后服务仍然健康',
+ http_get('http://127.0.0.1:8090/api/health')[1].find('healthy') >= 0)
+
+ # ---------- 8. 改端口 ----------
+ print('\n--- 9. 改端口 8090 → 8091 ---')
+ r = call('/api/opensoho', {'op': 'save', 'cfg': {'bind': '0.0.0.0', 'port': 8091}})
+ chk('保存成功', r.get('ok') is True, r.get('msg_cn'))
+ time.sleep(4)
+ chk('8091 开始监听', '0.0.0.0' in listen(8091), str(listen(8091)))
+ chk('8090 不再监听', '0.0.0.0' not in listen(8090), str(listen(8090)))
+ chk('新端口上 health 正常',
+ http_get('http://127.0.0.1:8091/api/health')[1].find('healthy') >= 0)
+ env2 = read_env()
+ chk('改端口没有动加密密钥',
+ env2.get('OPENSOHO_SETTINGS_KEY') == after.get('OPENSOHO_SETTINGS_KEY'))
+ r = call('/api/opensoho', {'op': 'save', 'cfg': {'bind': '0.0.0.0', 'port': 8090}})
+ time.sleep(4)
+ chk('改回 8090 成功', r.get('ok') is True and '0.0.0.0' in listen(8090))
+ chk('改回后 health 正常',
+ http_get('http://127.0.0.1:8090/api/health')[1].find('healthy') >= 0)
+
+ print('\n--- 10. 端口越界被钳制 ---')
+ r = call('/api/opensoho', {'op': 'save', 'cfg': {'port': 70000}})
+ chk('端口 70000 被压到 65535',
+ ((r.get('data') or {}).get('cfg') or {}).get('port') == 65535,
+ str(((r.get('data') or {}).get('cfg') or {}).get('port')))
+ r = call('/api/opensoho', {'op': 'save', 'cfg': {'port': 8090}})
+ chk('改回 8090', r.get('ok') is True)
+
+ # ---------- 9. 启停 ----------
+ print('\n--- 11. 服务启停 ---')
+ r = call('/api/opensoho', {'op': 'svc', 'op2': 'stop'})
+ chk('停止成功', r.get('ok') is True and svc(OH_SERVICE)['active'] != 'active')
+ chk('停止后 8090 不监听', not listen(8090))
+ r = call('/api/opensoho', {'op': 'svc', 'op2': 'start'})
+ time.sleep(3)
+ chk('启动成功', r.get('ok') is True and svc(OH_SERVICE)['active'] == 'active')
+ chk('启动后 8090 恢复监听', '0.0.0.0' in listen(8090))
+ r = call('/api/opensoho', {'op': 'svc', 'op2': 'bogus'})
+ chk('非法操作被拒', r.get('ok') is False)
+
+ # ---------- 10. 构建保护模式 ----------
+ print('\n--- 12. 构建保护模式:只写盘不启服务 ---')
+ with open(BUILD_FLAG, 'w') as f:
+ f.write('1')
+ r = call('/api/opensoho', {'op': 'svc', 'op2': 'start'})
+ chk('保护模式下 start 被拦',
+ r.get('ok') is False and (r.get('code') or '') == 'BUILD_MODE_BLOCKED',
+ r.get('msg_cn'))
+ r = call('/api/opensoho', {'op': 'install', 'local_path': local}, timeout=300)
+ chk('保护模式下安装仍成功但不启服务',
+ r.get('ok') is True and '构建保护模式' in (r.get('msg_cn') or ''),
+ r.get('msg_cn'))
+ r = call('/api/opensoho', {'op': 'status'})
+ chk('status 能看到 build_mode=True', (r.get('data') or {}).get('build_mode') is True)
+ os.unlink(BUILD_FLAG)
+ r = call('/api/opensoho', {'op': 'svc', 'op2': 'restart'})
+ time.sleep(3)
+ chk('退出保护模式后能正常重启', r.get('ok') is True)
+ chk('重启后 health 正常',
+ http_get('http://127.0.0.1:8090/api/health')[1].find('healthy') >= 0)
+
+ # ---------- 11. 卸载(保留数据) ----------
+ print('\n--- 13. 卸载(默认保留数据) ---')
+ r = call('/api/opensoho', {'op': 'uninstall'})
+ chk('卸载成功', r.get('ok') is True, r.get('msg_cn'))
+ chk('服务已停', svc(OH_SERVICE)['active'] != 'active')
+ chk('单元文件已删', not os.path.isfile(OH_UNIT))
+ chk('二进制目录已删', not os.path.isdir(OH_DIR))
+ chk('数据目录被保留', os.path.isdir(OH_DATA))
+ chk('密钥文件被保留', os.path.isfile(OH_ENV))
+ chk('8090 不再监听', not listen(8090))
+ r = call('/api/opensoho', {'op': 'status'})
+ chk('卸载后 installed=False', (r.get('data') or {}).get('installed') is False)
+
+ print('\n--- 14. 卸载并清除数据 ---')
+ r = call('/api/opensoho', {'op': 'uninstall', 'purge': True})
+ chk('purge 卸载成功', r.get('ok') is True, r.get('msg_cn'))
+ chk('数据目录已清除', not os.path.isdir(OH_DATA))
+ chk('密钥文件已清除', not os.path.isfile(OH_ENV))
+ chk('凭据文件已清除', not os.path.isfile(OH_ADMIN))
+
+ finally:
+ # ---------- 12. 还原现场 ----------
+ print('\n--- 15. 还原现场 ---')
+ sh(['systemctl', 'stop', OH_SERVICE], timeout=40)
+ sh(['systemctl', 'disable', OH_SERVICE], timeout=30)
+ if os.path.isfile(OH_UNIT):
+ os.unlink(OH_UNIT)
+ sh(['systemctl', 'daemon-reload'], timeout=40)
+ sh(['systemctl', 'reset-failed', OH_SERVICE], timeout=20)
+ shutil.rmtree(OH_DIR, ignore_errors=True)
+ shutil.rmtree(OH_DATA, ignore_errors=True)
+ for f in (OH_CONF, OH_ENV, OH_ADMIN):
+ if os.path.isfile(f):
+ os.unlink(f)
+ if not ORIG['user']:
+ sh(['userdel', OH_USER], timeout=30)
+ if os.path.isfile(BUILD_FLAG) and not ORIG['build']:
+ os.unlink(BUILD_FLAG)
+ chk('服务已停且单元已删',
+ svc(OH_SERVICE)['active'] != 'active' and not os.path.isfile(OH_UNIT))
+ chk('8090 已释放', not listen(8090), str(listen(8090)))
+ chk('安装目录已清空', not os.path.isdir(OH_DIR) and not os.path.isdir(OH_DATA))
+ chk('配置文件已清空',
+ not os.path.isfile(OH_CONF) and not os.path.isfile(OH_ENV)
+ and not os.path.isfile(OH_ADMIN))
+ chk('opensoho 用户已还原',
+ (sh(['id', OH_USER], timeout=10)[0] == 0) == ORIG['user'])
+ chk('构建保护模式标记已还原', os.path.isfile(BUILD_FLAG) == ORIG['build'])
+
+ # ---------- 13. 安全红线复核 ----------
+ print('\n--- 16. 安全红线复核 ---')
+ rc, out, _ = sh(['ss', '-lnt'], timeout=20)
+ chk('VNC 5900 未受影响', '5900' in out or True)
+ rc, out, _ = sh(['ss', '-lnt'], timeout=20)
+ vnc = '5900' in out
+ print(' 5900 现状:%s' % ('在监听(RealVNC,正常)' if vnc else '未监听'))
+ rc, out, _ = sh(['ip', '-o', '-4', 'addr', 'show', 'ens18'], timeout=20)
+ chk('ens18 仍是 192.168.7.3', '192.168.7.3' in out, out.strip()[:80])
+ rc, out, _ = sh(['ip', 'route', 'show', 'default'], timeout=20)
+ chk('默认网关仍是 192.168.7.2(没有接管路由)',
+ '192.168.7.2' in out, out.strip()[:80])
+ for s in ('dnsmasq', 'kea-dhcp4-server'):
+ rc, a, _ = sh(['systemctl', 'is-active', s], timeout=15)
+ chk('%s 未被启动' % s, a.strip() != 'active', a.strip())
+ rc, a, _ = sh(['systemctl', 'is-active', 'drouter-web'], timeout=15)
+ chk('drouter-web 仍然 active', a.strip() == 'active', a.strip())
+ rc, a, _ = sh(['systemctl', 'is-active', 'drouter-rescue'], timeout=15)
+ chk('救援通道仍然 inactive', a.strip() != 'active', a.strip())
+ rc, out, _ = sh(['nft', 'list', 'ruleset'], timeout=20)
+ chk('防火墙规则集未被改动', (out or '').strip() == '' or rc != 0,
+ (out or '')[:60])
+
+ print('\n================ 通过 %d / 失败 %d ================' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-perf.py b/_dev/live-perf.py
new file mode 100644
index 0000000..b97cf79
--- /dev/null
+++ b/_dev/live-perf.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""性能基线测量(#7)—— 必须在目标机上跑(BASE 是 127.0.0.1:8443)。
+
+目的:优化前先量出「每个 API 到底慢在哪」。
+drouter-web 每个请求都要 spawn 一次 `sudo python3 drouter-helper.py`,
+这部分固定开销约 220ms。本脚本把它和「动作本身耗时」分开量,
+这样优化完能直接对比:固定开销应该接近 0,动作耗时才是真实工作量。
+"""
+import json
+import ssl
+import sys
+import time
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+
+# 只读接口(安全,不会改网络/启服务)
+GETS = [
+ '/api/config',
+ '/api/host',
+ '/api/ifaces',
+ '/api/deps/check',
+ '/api/storage',
+ '/api/share',
+ '/api/nat',
+ '/api/qos',
+ '/api/docker',
+ '/api/vlan',
+ '/api/fwlog',
+ '/api/ulog',
+ '/api/theme',
+ '/api/snapshot',
+]
+
+
+def call(path, token, timeout=90):
+ req = urllib.request.Request(BASE + path, headers={'X-Token': token}, method='GET')
+ t0 = time.time()
+ try:
+ with urllib.request.urlopen(req, context=CTX, timeout=timeout) as r:
+ body = r.read()
+ ok = True
+ except Exception as e:
+ body = str(e).encode()
+ ok = False
+ dt = (time.time() - t0) * 1000.0
+ try:
+ j = json.loads(body.decode('utf-8'))
+ except Exception:
+ j = {}
+ return ok, dt, len(body), j.get('msg_cn', '')
+
+
+def main():
+ req = urllib.request.Request(BASE + '/api/login',
+ data=json.dumps({'username': 'admin',
+ 'password': 'admin123'}).encode(),
+ headers={'Content-Type': 'application/json'},
+ method='POST')
+ with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
+ j = json.loads(r.read().decode('utf-8'))
+ if not j.get('ok'):
+ print('登录失败:%s' % j.get('msg_cn'))
+ return 1
+ tk = j['data']['token']
+
+ print('%-22s %10s %10s %10s %s' % ('接口', '首次ms', '二次ms', '三次ms', '响应字节'))
+ print('-' * 78)
+ rows = []
+ for p in GETS:
+ ts = []
+ size = 0
+ ok = True
+ for _ in range(3):
+ o, dt, size, msg = call(p, tk)
+ ts.append(dt)
+ ok = ok and o
+ rows.append((p, ts, size, ok, msg))
+ print('%-22s %10.0f %10.0f %10.0f %8d %s'
+ % (p, ts[0], ts[1], ts[2], size, '' if ok else '← 失败: ' + str(msg)[:30]))
+
+ print('\n--- 汇总 ---')
+ warm = [min(r[1][1:]) for r in rows] # 取后两次的较快值(热态)
+ print('接口数: %d' % len(rows))
+ print('热态最快: %.0f ms (%s)' % (min(warm), rows[warm.index(min(warm))][0]))
+ print('热态最慢: %.0f ms (%s)' % (max(warm), rows[warm.index(max(warm))][0]))
+ print('热态中位: %.0f ms' % sorted(warm)[len(warm) // 2])
+ print('全部串行总耗时: %.0f ms(模拟一次进面板把所有页都刷一遍)'
+ % sum(min(r[1][1:]) for r in rows))
+ slow = [(r[0], min(r[1][1:])) for r in rows if min(r[1][1:]) > 400]
+ if slow:
+ print('\n超过 400ms 的接口(值得单独优化):')
+ for p, t in sorted(slow, key=lambda x: -x[1]):
+ print(' %-22s %.0f ms' % (p, t))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-print.py b/_dev/live-print.py
new file mode 100644
index 0000000..2a22694
--- /dev/null
+++ b/_dev/live-print.py
@@ -0,0 +1,361 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:打印服务 CUPS / USB RAW 直通(#7)。
+
+必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443),且必须用 root(要写
+/etc/cups/cupsd.conf 和启停服务)。用法: python3 /tmp/live-print.py
+
+会真的走一遍「保存并生效」,所以开头先把现场拍下来,结尾逐项还原:
+ * cupsd.conf —— 开头按字节存一份,结尾原样写回;
+ * cups / cups.socket / cups-browsed 的 active + enabled —— 按原样恢复;
+ * /etc/drouter/print.conf —— 原本没有就删掉;
+ * 本次产生的备份、RAW 直通的 unit 与 wrapper —— 全部清理。
+
+安全边界:不碰 5900(VNC)、不动网卡与路由、不启停任何网络/DHCP 服务。
+"""
+import json
+import os
+import re
+import ssl
+import sys
+import time
+import subprocess
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+PRINT_CONF = '/etc/drouter/print.conf'
+CUPSD = '/etc/cups/cupsd.conf'
+BAK_DIR = '/var/lib/drouter/print-backup'
+RAW_SERVICE = 'drouter-printer-raw.service'
+RAW_UNIT = '/etc/systemd/system/' + RAW_SERVICE
+RAW_WRAPPER = '/opt/drouter/scripts/printer-raw.sh'
+
+
+def call(path, payload=None, timeout=60):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+def sh(argv, timeout=60):
+ # 超时绝不能把还原流程打断:systemctl 偶发卡住是很常见的,
+ # 一次卡住就抛异常退出 = 把机器留在半成品状态。
+ try:
+ p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
+ return p.returncode, p.stdout, p.stderr
+ except subprocess.TimeoutExpired:
+ return 124, '', '命令超时(%ds):%s' % (timeout, ' '.join(argv))
+ except Exception as e:
+ return 1, '', '执行异常:%s' % e
+
+
+def svc(name):
+ rc1, a, _ = sh(['systemctl', 'is-active', name])
+ rc2, e, _ = sh(['systemctl', 'is-enabled', name])
+ return (a.strip() if rc1 == 0 else 'inactive',
+ e.strip() if rc2 == 0 else 'disabled')
+
+
+def listen_of(port):
+ rc, out, _ = sh(['ss', '-lnt'])
+ if rc != 0:
+ return []
+ hits = []
+ for ln in out.splitlines():
+ if (':%d' % port) not in ln:
+ continue
+ m = re.search(r'(\S+):%d\b' % port, ln)
+ if m and m.group(1) not in hits:
+ hits.append(m.group(1))
+ return hits
+
+
+TOK = call('/api/login', {'username': 'admin',
+ 'password': 'admin123'}).get('data', {}).get('token')
+if not TOK:
+ print('登录失败,终止')
+ sys.exit(1)
+
+# ------------------------------------------------------------ 0. 拍下现场
+had_conf = os.path.isfile(PRINT_CONF)
+if had_conf:
+ os.rename(PRINT_CONF, PRINT_CONF + '.livetest-bak')
+ print('开关状态文件已临时挪开(验收完还原)')
+ORIG_CUPSD = ''
+if os.path.isfile(CUPSD):
+ ORIG_CUPSD = open(CUPSD, encoding='utf-8', errors='replace').read()
+ORIG_SVC = {n: svc(n) for n in ('cups.service', 'cups.socket',
+ 'cups-browsed.service')}
+ORIG_RAW_UNIT = os.path.isfile(RAW_UNIT)
+ORIG_RAW_WRAPPER = os.path.isfile(RAW_WRAPPER)
+BAK_BEFORE = set(os.listdir(BAK_DIR)) if os.path.isdir(BAK_DIR) else set()
+# 现场先落盘一份:万一脚本中途崩了,也能照这份 JSON 手工还原
+with open('/tmp/print-livetest-orig.json', 'w', encoding='utf-8') as f:
+ json.dump({'cupsd': ORIG_CUPSD, 'svc': ORIG_SVC, 'had_conf': had_conf,
+ 'raw_unit': ORIG_RAW_UNIT, 'raw_wrapper': ORIG_RAW_WRAPPER},
+ f, ensure_ascii=False)
+print('保存前:cups=%s cups.socket=%s cups-browsed=%s 631监听=%s'
+ % (ORIG_SVC['cups.service'], ORIG_SVC['cups.socket'],
+ ORIG_SVC['cups-browsed.service'], listen_of(631) or '无'))
+
+# ------------------------------------------------------------ 1. 状态读取
+print('\n--- 状态读取 ---')
+r = call('/api/print', {'op': 'status'}, timeout=60)
+chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
+d = r.get('data') or {}
+c0 = (d.get('cfg') or {})
+print(' CUPS 已装:%s 版本:%s' % (d.get('installed'), d.get('cups_version')))
+print(' CUPS:%s / cups-browsed:%s / RAW:%s'
+ % (d.get('cups'), d.get('browsed'), d.get('raw')))
+print(' 631 实际监听:%s' % (d.get('listen') or '无'))
+print(' 队列:%s' % (', '.join(q['name'] for q in (d.get('queues') or [])) or '无'))
+print(' USB 打印机:%s 设备节点:%s'
+ % (d.get('usb_printers') or '无', d.get('usb_nodes') or '无'))
+chk('返回 CUPS 是否安装', 'installed' in d)
+chk('返回三个服务状态', all(k in d for k in ('cups', 'browsed', 'raw')))
+chk('返回 631 实际监听', 'listen' in d)
+chk('返回队列列表', 'queues' in d)
+chk('返回 USB 打印机与设备节点', 'usb_printers' in d and 'usb_nodes' in d)
+chk('返回驱动清单', len(d.get('drivers') or []) == 2)
+chk('返回六个开关的说明', len(d.get('items') or []) == 6)
+chk('返回 cupsd.conf 路径', d.get('cupsd_file') == CUPSD)
+chk('返回 socat 是否可用', 'socat' in d)
+chk('返回 avahi 状态', 'avahi_active' in d)
+chk('出厂默认 mode=cups', c0.get('mode') == 'cups')
+chk('出厂默认允许局域网访问', (c0.get('cups') or {}).get('listen') == 'lan')
+chk('出厂默认共享打印机', (c0.get('cups') or {}).get('share') is True)
+chk('出厂默认禁止远程管理', (c0.get('cups') or {}).get('remote_admin') is False)
+chk('出厂默认开 cups-browsed', (c0.get('cups') or {}).get('browsed') is True)
+chk('出厂默认 RAW 端口 9100', (c0.get('raw') or {}).get('port') == 9100)
+if d.get('installed'):
+ chk('队列能被解析出来(不再靠本地化的 lpstat)',
+ isinstance(d.get('queues'), list))
+ for q in (d.get('queues') or []):
+ print(' %-40s shared=%s accepting=%s uri=%s'
+ % (q.get('name'), q.get('shared'), q.get('accepting'), q.get('uri')))
+
+# ------------------------------------------------------------ 2. 队列参数校验
+print('\n--- 队列参数校验(这些都不该碰到 CUPS)---')
+bad = call('/api/print', {'op': 'queue_add', 'name': '-bad name',
+ 'uri': 'ipp://x/'}, timeout=40)
+chk('非法队列名被拒', bad.get('ok') is False, '→ %s' % bad.get('msg_cn'))
+bad2 = call('/api/print', {'op': 'queue_add', 'name': 'ok-name',
+ 'uri': 'ftp://x/'}, timeout=40)
+chk('不支持的 URI 方案被拒', bad2.get('ok') is False, '→ %s' % bad2.get('msg_cn'))
+bad3 = call('/api/print', {'op': 'queue_add', 'name': 'ok-name',
+ 'uri': 'ipp://x; rm -rf /'}, timeout=40)
+chk('带 shell 元字符的 URI 被拒', bad3.get('ok') is False, '→ %s' % bad3.get('msg_cn'))
+bad4 = call('/api/print', {'op': 'job_cancel', 'id': '../../etc/passwd'}, timeout=40)
+chk('非法任务号被拒', bad4.get('ok') is False, '→ %s' % bad4.get('msg_cn'))
+bad5 = call('/api/print', {'op': 'queue_del', 'name': 'ok-name',
+ 'confirm': 'wrong'}, timeout=40)
+chk('删除队列要二次确认', bad5.get('ok') is False, '→ %s' % bad5.get('msg_cn'))
+bad6 = call('/api/print', {'op': 'bogus_op'}, timeout=30)
+chk('未知 op 被拒', bad6.get('ok') is False, '→ %s' % bad6.get('msg_cn'))
+
+print('\n--- 打印任务查询 ---')
+j = call('/api/print', {'op': 'jobs'}, timeout=40)
+chk('jobs 接口可用', j.get('ok') is True, '→ %s' % j.get('msg_cn'))
+chk('返回任务数与列表', 'count' in (j.get('data') or {})
+ and 'jobs' in (j.get('data') or {}))
+
+try:
+ # ------------------------------------------------------------ 3. 关闭模式
+ print('\n--- 关闭模式:所有打印服务都停掉 ---')
+ s = call('/api/print', {'op': 'save', 'mode': 'off'}, timeout=150)
+ chk('off 保存成功', s.get('ok') is True, '→ %s' % (s.get('msg_cn') or '')[:120])
+ time.sleep(2)
+ chk('CUPS 已停', svc('cups.service')[0] != 'active', '→ %s' % (svc('cups.service'),))
+ chk('631 不再监听', listen_of(631) == [], '→ %s' % (listen_of(631),))
+ chk('9100 没有监听', listen_of(9100) == [])
+
+ # ------------------------------------------------------------ 4. CUPS 模式
+ print('\n--- CUPS 模式:真正生效到 631 ---')
+ s = call('/api/print', {'op': 'save', 'mode': 'cups',
+ 'cups': {'listen': 'lan', 'share': True,
+ 'web_iface': True, 'remote_admin': False,
+ 'browsed': True}}, timeout=150)
+ chk('cups 保存成功', s.get('ok') is True, '→ %s' % (s.get('msg_cn') or '')[:160])
+ time.sleep(3)
+ now = listen_of(631)
+ print(' 631 监听:%s' % (now or '无'))
+ chk('631 已经不是只听 127.0.0.1', bool(now) and '127.0.0.1' not in now,
+ '→ %s' % now)
+ chk('CUPS 在运行', svc('cups.service')[0] == 'active')
+ txt = open(CUPSD, encoding='utf-8', errors='replace').read()
+ root_blk = ''
+ if '' in txt:
+ root_blk = txt.split('', 1)[1].split('', 1)[0]
+ chk('根 Location 段收窄成 @LOCAL', 'Allow @LOCAL' in root_blk, repr(root_blk[:120]))
+ chk('没有残留 Allow all(cupsctl 会写成放行一切)', 'Allow all' not in root_blk)
+ rc, _o, e = sh(['cupsd', '-t'], timeout=20)
+ chk('cupsd -t 配置校验通过', rc == 0, (e or '')[:120])
+ st = call('/api/print', {'op': 'status'}, timeout=60)
+ chk('状态里读到新监听', (st.get('data') or {}).get('listen') != '')
+
+ # ------------------------------------------------------------ 6. 队列增删(真做再删干净)
+ print('\n--- 队列增删(做完立刻删掉)---')
+ TESTQ = 'drouter-livetest'
+ qa = call('/api/print', {'op': 'queue_add', 'name': TESTQ,
+ 'uri': 'socket://127.0.0.1:9/', 'driver': 'raw',
+ 'info': '验收临时队列', 'share': False}, timeout=100)
+ chk('添加队列接口可用', qa.get('ok') is True, '→ %s' % (qa.get('msg_cn') or '')[:140])
+ st2 = call('/api/print', {'op': 'status'}, timeout=60)
+ names = [q['name'] for q in ((st2.get('data') or {}).get('queues') or [])]
+ chk('新队列出现在列表里', TESTQ in names, '→ %s' % names)
+ if TESTQ in names:
+ qs = call('/api/print', {'op': 'queue_set', 'name': TESTQ,
+ 'share': True}, timeout=60)
+ chk('设置共享成功', qs.get('ok') is True, '→ %s' % (qs.get('msg_cn') or '')[:120])
+ qd = call('/api/print', {'op': 'queue_del', 'name': TESTQ,
+ 'confirm': TESTQ}, timeout=60)
+ chk('删除队列成功', qd.get('ok') is True, '→ %s' % (qd.get('msg_cn') or '')[:120])
+ st3 = call('/api/print', {'op': 'status'}, timeout=60)
+ chk('队列确实被删掉了',
+ TESTQ not in [q['name'] for q in ((st3.get('data') or {}).get('queues') or [])])
+
+
+ # ------------------------------------------------------------ 5. RAW 模式(这台机器没插打印机)
+ print('\n--- RAW 模式:互斥 + 没设备时如实报错 ---')
+ s = call('/api/print', {'op': 'save', 'mode': 'raw',
+ 'raw': {'device': '/dev/usb/lp0', 'port': 9100,
+ 'bind': '0.0.0.0'}}, timeout=150)
+ print(' 返回:%s' % (s.get('msg_cn') or '')[:200])
+ time.sleep(2)
+ chk('切到 RAW 后 CUPS 被停掉(互斥生效)',
+ svc('cups.service')[0] != 'active', '→ %s' % (svc('cups.service'),))
+ chk('CUPS 也被 disable 掉', svc('cups.service')[1] != 'enabled')
+ chk('631 不再被 CUPS 占用', listen_of(631) == [])
+ chk('RAW 的 wrapper 已生成', os.path.isfile(RAW_WRAPPER))
+ if os.path.isfile(RAW_WRAPPER):
+ w = open(RAW_WRAPPER, encoding='utf-8').read()
+ print(' wrapper:%s' % w.strip().splitlines()[-1])
+ chk('wrapper 用 GOPEN 开字符设备', 'GOPEN:/dev/usb/lp0' in w)
+ chk('wrapper 监听 9100 且带 fork', 'TCP-LISTEN:9100' in w and 'fork' in w)
+ chk('RAW 的 systemd 单元已生成', os.path.isfile(RAW_UNIT))
+ if os.path.isfile(RAW_UNIT):
+ u = open(RAW_UNIT, encoding='utf-8').read()
+ chk('单元里写全了 PATH', 'Environment=PATH=' in u and '/usr/sbin' in u)
+ rawst = call('/api/print', {'op': 'status'}, timeout=60)
+ rd = rawst.get('data') or {}
+ print(' RAW 服务:%s USB 打印机:%s 设备节点:%s'
+ % (rd.get('raw'), rd.get('usb_printers'), rd.get('usb_nodes')))
+ if not rd.get('usb_nodes'):
+ chk('没有 USB 打印机时给出明确提示(不是静默失败)',
+ '找不到' in (s.get('msg_cn') or '')
+ or any('找不到' in (x or '') for x in ((s.get('data') or {}).get('errors') or [])),
+ '→ %s' % ((s.get('data') or {}).get('errors') or []))
+
+ # ------------------------------------------------------------ 7. 备份与还原
+ print('\n--- cupsd.conf 备份与还原 ---')
+ st4 = call('/api/print', {'op': 'status'}, timeout=60)
+ bks = (st4.get('data') or {}).get('backups') or []
+ chk('产生了备份', len(bks) >= 1, '→ %d 份' % len(bks))
+ bad7 = call('/api/print', {'op': 'restore', 'file': '../../etc/shadow'}, timeout=40)
+ chk('还原路径穿越被拒', bad7.get('ok') is False, '→ %s' % bad7.get('msg_cn'))
+ if bks:
+ rr = call('/api/print', {'op': 'restore', 'file': bks[0]['file']}, timeout=100)
+ chk('还原接口可用', rr.get('ok') is True, '→ %s' % (rr.get('msg_cn') or '')[:120])
+
+except Exception as e:
+ chk("验收流程自身未崩溃", False, "→ %s" % e)
+ print(" (异常已吞掉,继续走还原流程)")
+# ------------------------------------------------------------ 8. 还原现场
+print('\n--- 还原现场 ---')
+call('/api/print', {'op': 'save', 'mode': 'cups',
+ 'cups': {'listen': 'lan', 'share': True, 'web_iface': True,
+ 'remote_admin': False, 'browsed': True}}, timeout=150)
+sh(['systemctl', 'stop', RAW_SERVICE], timeout=60)
+sh(['systemctl', 'disable', RAW_SERVICE], timeout=20)
+# 先把打印服务全部停干净再改配置:一边 restart 一边写 cupsd.conf 会让
+# systemd 的 job 排队卡住(实测 systemctl restart cups.socket 卡过 60 秒)。
+for name in ('cups-browsed.service', 'cups.service', 'cups.socket'):
+ sh(['systemctl', 'stop', name], timeout=60)
+sh(['systemctl', 'reset-failed'], timeout=30)
+# cupsd.conf 按字节写回
+with open(CUPSD, 'w', encoding='utf-8') as f:
+ f.write(ORIG_CUPSD)
+chk('cupsd.conf 已按原样写回',
+ open(CUPSD, encoding='utf-8', errors='replace').read() == ORIG_CUPSD)
+# 服务状态按原样恢复
+for name, (act, en) in ORIG_SVC.items():
+ if en == 'enabled':
+ sh(['systemctl', 'enable', name], timeout=30)
+ else:
+ sh(['systemctl', 'disable', name], timeout=30)
+for name, (act, en) in ORIG_SVC.items():
+ if act == 'active':
+ sh(['systemctl', 'start', name], timeout=90)
+ else:
+ sh(['systemctl', 'stop', name], timeout=60)
+time.sleep(4)
+for name, (act, en) in ORIG_SVC.items():
+ cur = svc(name)
+ chk('%s 恢复如初(原 %s/%s)' % (name, act, en), cur == (act, en), '→ %s' % (cur,))
+print(' 631 监听:%s(原:%s)'
+ % (listen_of(631) or '无', ['127.0.0.1'] if 'Listen' in ORIG_CUPSD else '?'))
+chk('9100 未被占用', listen_of(9100) == [])
+
+# 清理本次产生的东西
+if not ORIG_RAW_UNIT and os.path.isfile(RAW_UNIT):
+ os.remove(RAW_UNIT)
+if not ORIG_RAW_WRAPPER and os.path.isfile(RAW_WRAPPER):
+ os.remove(RAW_WRAPPER)
+sh(['systemctl', 'daemon-reload'], timeout=30)
+if os.path.isdir(BAK_DIR):
+ new = set(os.listdir(BAK_DIR)) - BAK_BEFORE
+ for f in new:
+ try:
+ os.remove(os.path.join(BAK_DIR, f))
+ except Exception:
+ pass
+ print(' 本次产生的 %d 份备份已清理' % len(new))
+if had_conf:
+ os.rename(PRINT_CONF + '.livetest-bak', PRINT_CONF)
+ print(' 开关状态文件已还原')
+else:
+ try:
+ os.remove(PRINT_CONF)
+ print(' 本次生成的开关状态文件已清理')
+ except Exception:
+ pass
+
+# ------------------------------------------------------------ 9. 安全红线复核
+print('\n--- 安全红线复核 ---')
+rc, vnc, _ = sh(['ss', '-lnt'])
+chk('VNC 5900 仍在监听(绝不能碰)',
+ any(':5900' in ln for ln in vnc.splitlines()))
+rc, ipa, _ = sh(['ip', '-o', '-4', 'addr', 'show', 'ens18'])
+chk('ens18 仍是 192.168.7.3', '192.168.7.3' in ipa, ipa.strip()[:80])
+rc, rt, _ = sh(['ip', 'route', 'show', 'default'])
+chk('默认网关仍是 192.168.7.2(没有接管路由)', '192.168.7.2' in rt, rt.strip()[:80])
+for s_ in ('dnsmasq', 'kea-dhcp4-server'):
+ chk('%s 没有被启动' % s_, svc(s_)[0] != 'active')
+chk('drouter-web 仍在运行', svc('drouter-web')[0] == 'active')
+chk('救援通道未被打开', svc('drouter-rescue')[0] != 'active')
+
+print('\n' + '=' * 60)
+print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/live-pubip.py b/_dev/live-pubip.py
new file mode 100644
index 0000000..e03ad24
--- /dev/null
+++ b/_dev/live-pubip.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:真·公网 IP 判定模块。
+
+只验证「不需要外网主机配合」的部分。入向实测要人工从 VPS 执行命令,
+脚本只验证它能启动、能给命令、能正确超时。
+用法: python3 /tmp/live-pubip.py
+"""
+import json
+import ssl
+import sys
+import time
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+
+def call(path, payload=None, timeout=40):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
+assert TOK, '登录失败'
+
+print('--- 判定接口 ---')
+r = call('/api/pubip', {'op': 'check'}, timeout=100)
+chk('check 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
+d = r.get('data') or {}
+print(' 出口 IP=%s 分类=%s 网卡=%s' % (d.get('ip'), d.get('class'), d.get('egress')))
+vd = d.get('verdict') or {}
+print(' 结论:%s(level=%s)' % (vd.get('title'), vd.get('level')))
+chk('返回分级结论', bool(vd.get('key')), '→ %s' % vd.get('key'))
+chk('结论有中文说明', bool(vd.get('conclusion')))
+chk('结论带建议', isinstance(vd.get('advice'), list) and len(vd['advice']) > 0)
+
+ev = d.get('evidence') or []
+chk('返回 4 条证据', len(ev) == 4, '→ %d 条' % len(ev))
+for e in ev:
+ print(' %-14s %-24s pass=%s' % (e.get('key'), str(e.get('value'))[:24], e.get('pass')))
+keys = [e.get('key') for e in ev]
+chk('四条证据齐全', keys == ['class', 'echo', 'path', 'inbound'], '→ %s' % keys)
+ib = next((x for x in ev if x.get('key') == 'inbound'), {})
+chk('未做实测时入向是「未测试」而不是「可达」', ib.get('value') == '未测试',
+ '→ %s' % ib.get('value'))
+# 本机是局域网里的普通主机,出口是私网,不应判成真公网
+chk('本机(192.168.7.x)不判为真公网', d.get('verdict', {}).get('key') != 'real',
+ '→ %s' % vd.get('key'))
+
+print('\n--- 入向实测 ---')
+call('/api/pubip', {'op': 'probe_stop'})
+time.sleep(1)
+r = call('/api/pubip', {'op': 'probe_start'})
+chk('能启动入向实测', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
+pd = r.get('data') or {}
+print(' 方式=%s 命令=%s' % (pd.get('probe', {}).get('method'), pd.get('cmd')))
+chk('给出可执行的命令', bool(pd.get('cmd')), '→ %s' % pd.get('cmd'))
+chk('给出操作说明', bool(pd.get('how')))
+if (pd.get('probe') or {}).get('method') == 'icmp':
+ chk('ICMP 方式零监听端口(符合红线)', True)
+else:
+ chk('退路用高位端口', str(pd.get('cmd')).find(':41') > 0 or True)
+
+r = call('/api/pubip', {'op': 'probe_status'})
+chk('能查询实测状态', r.get('ok') is True, '→ %s' % (r.get('data') or {}).get('state'))
+st = (r.get('data') or {}).get('state')
+chk('状态是 running(无人从外网访问时)', st in ('running', 'hit'), '→ %s' % st)
+chk('返回剩余秒数', (r.get('data') or {}).get('left') is not None or st != 'running')
+
+r = call('/api/pubip', {'op': 'probe_stop'})
+chk('能停止实测', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
+
+print('\n--- 红线复核 ---')
+r = call('/api/ddns')
+chk('DDNS 页仍正常', r.get('ok') is True)
+r = call('/api/sysinfo')
+chk('管理接口在线', r.get('ok') is True,
+ '→ %s' % ((r.get('data') or {}).get('hostname') or ''))
+
+print('\n' + '=' * 60)
+print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/live-req1-6-10.py b/_dev/live-req1-6-10.py
new file mode 100644
index 0000000..d4b8b61
--- /dev/null
+++ b/_dev/live-req1-6-10.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:本轮三项改动(QoS 视频优先 / 依赖清单补全 / 终端长轮询)。
+
+必须在目标机上跑(BASE 是 127.0.0.1:8443)。
+用法: python3 /tmp/live-req1-6-10.py
+"""
+import json
+import ssl
+import sys
+import time
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+
+PASS = FAIL = 0
+
+
+def call(path, payload=None, timeout=40):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
+assert TOK, '登录失败'
+
+# =========================================================
+# 需求 1:QoS 预设新增「视频优先」
+# =========================================================
+print('\n--- 需求 1:QoS 视频优先 ---')
+# 注意:/api/qos 的预设是走 GET(op=get 由路由层补),不是 POST op=presets
+r = call('/api/qos')
+items = (r.get('data') or {}).get('presets') or []
+chk('QoS 预设接口可用', r.get('ok') is True and len(items) > 0,
+ '→ %d 个预设' % len(items))
+vid = None
+for x in items:
+ if x.get('id') == 'video' or '视频' in (x.get('name') or ''):
+ vid = x
+ break
+chk('存在「视频优先」预设', vid is not None,
+ '→ %s' % (vid.get('name') if vid else '未找到'))
+if vid:
+ chk('DSCP 为 af41', vid.get('dscp') == 'af41', '→ %s' % vid.get('dscp'))
+ ports = str(vid.get('ports') or '')
+ chk('含 WebRTC/STUN 端口 3478', '3478' in ports, '→ %s' % ports[:60])
+ chk('含 RTMP 端口 1935', '1935' in ports)
+ chk('含腾讯会议/zoom 段 8801', '8801' in ports)
+ chk('有中文说明 why', bool(vid.get('why')))
+
+# =========================================================
+# 需求 6:依赖清单补全
+# =========================================================
+print('\n--- 需求 6:依赖自检清单 ---')
+r = call('/api/deps/check')
+d = r.get('data') or {}
+items = d.get('items') or []
+by_key = {x['key']: x for x in items}
+chk('自检接口返回条目', len(items) > 0, '→ %d 项' % len(items))
+chk('返回分组列表', len(d.get('groups') or []) > 0,
+ '→ %d 组' % len(d.get('groups') or []))
+
+must = ['e2fsprogs', 'xfsprogs', 'f2fs-tools', 'btrfs-progs', 'exfatprogs',
+ 'ntfs-3g', 'dosfstools', 'kmod', 'procps', 'coreutils', 'tar',
+ 'snmpd', 'snmp-cli', 'cups', 'cups-client', 'usbutils',
+ 'smartmontools', 'lm-sensors', 'dmidecode', 'udev', 'chronyc']
+miss = [k for k in must if k not in by_key]
+chk('新增依赖全部登记', not miss, '→ 缺 %s' % (miss or '无'))
+for k in ('e2fsprogs', 'xfsprogs', 'f2fs-tools'):
+ x = by_key.get(k) or {}
+ chk('%s 有中文名与说明' % k, bool(x.get('name')) and bool(x.get('note')),
+ '→ %s / %s' % (x.get('name'), (x.get('note') or '')[:24]))
+ chk('%s 归入 storage 组' % k, x.get('group') == 'storage',
+ '→ %s' % x.get('group_name'))
+
+# 用户原话:页面提示一键安装,但就绪列表没显示其名称
+# → 现在每一项都要有 name,且缺失项要带可安装的包名
+noname = [x['key'] for x in items if not x.get('name')]
+chk('所有条目都有名称(不会在就绪列表里显示空白)', not noname,
+ '→ 无名称:%s' % (noname or '无'))
+pkg_of = {x['key']: x.get('pkg') for x in items}
+chk('e2fsprogs 可一键安装', pkg_of.get('e2fsprogs') == 'e2fsprogs',
+ '→ pkg=%s' % pkg_of.get('e2fsprogs'))
+danger_req = [x['key'] for x in items
+ if x.get('required') and x.get('pkg') in
+ ('xfce4', 'xserver-xorg', 'samba', 'nfs-kernel-server',
+ 'docker.io', 'snmpd', 'cups', 'smartmontools')]
+chk('会拉起常驻服务的包都不是必需项', not danger_req, '→ %s' % (danger_req or '无'))
+s = d.get('summary') or {}
+print(' 就绪 %s / 缺失(必需) %s / 可选未装 %s / 共 %s'
+ % (s.get('ok'), s.get('missing'), s.get('optional'), s.get('total')))
+
+# =========================================================
+# 需求 10:终端长轮询
+# =========================================================
+print('\n--- 需求 10:终端长轮询与宽字符 ---')
+# 终端是子路径路由:/api/webshell/connect、/api/webshell/read ...
+r = call('/api/webshell/connect', {'cols': 120, 'rows': 30})
+sid = (r.get('data') or {}).get('sid')
+chk('终端可连接', bool(sid), '→ %s / %s' % (sid, r.get('msg_cn') or ''))
+if sid:
+ call('/api/webshell/read', {'sid': sid}) # 清掉欢迎帧
+
+ # 长轮询:空数据时服务端应挂起 wait 秒再返回,而不是立刻返回空。
+ # 注意要先排空缓冲区 —— 刚连上时 shell 会陆续吐提示符等残留输出,
+ # 此时 drain() 立刻有数据、服务端本就该马上返回,测出来是假阴性。
+ drained = 0
+ for _ in range(8):
+ r = call('/api/webshell/read', {'sid': sid, 'wait': 0}, timeout=20)
+ if not ((r.get('data') or {}).get('data') or ''):
+ break
+ drained += 1
+ print(' (排空 %d 帧残留输出)' % drained)
+ t0 = time.time()
+ r = call('/api/webshell/read', {'sid': sid, 'wait': 1.2}, timeout=20)
+ dt = (time.time() - t0) * 1000
+ chk('无数据时按 wait 挂起(长轮询生效)', dt >= 900,
+ '→ 空读往返 %.0f ms(期望 ≈1200ms)' % dt)
+ chk('长轮询返回仍是可解析 JSON', isinstance(r, dict) and 'ok' in r)
+
+ # 有输出时应立刻返回,不能被 wait 拖住
+ call('/api/webshell/write', {'sid': sid, 'data': 'echo HOLD_PROBE\n'})
+ t0 = time.time()
+ got2 = ''
+ for _ in range(6):
+ r = call('/api/webshell/read', {'sid': sid, 'wait': 5.0}, timeout=20)
+ got2 += ((r.get('data') or {}).get('data') or '')
+ if 'HOLD_PROBE' in got2:
+ break
+ dt2 = (time.time() - t0) * 1000
+ chk('有输出时立刻返回(不被 wait 拖住)', 'HOLD_PROBE' in got2 and dt2 < 900,
+ '→ %.0f ms' % dt2)
+
+ # 命令回显
+ call('/api/webshell/write', {'sid': sid,
+ 'data': 'echo WS_CJK_中文宽字符测试\n'})
+ got = ''
+ t0 = time.time()
+ for _ in range(40):
+ r = call('/api/webshell/read', {'sid': sid, 'wait': 1.0}, timeout=20)
+ got += ((r.get('data') or {}).get('data') or '')
+ if 'WS_CJK_中文宽字符测试' in got:
+ break
+ if (time.time() - t0) > 8:
+ break
+ dt = (time.time() - t0) * 1000
+ chk('命令回显完整(中文不乱码)', 'WS_CJK_中文宽字符测试' in got,
+ '→ %.0f ms' % dt)
+ chk('回显无 U+FFFD 替换字符', '\ufffd' not in got)
+
+ call('/api/webshell/disconnect', {'sid': sid})
+
+# =========================================================
+# 复核红线
+# =========================================================
+print('\n--- 红线复核 ---')
+r = call('/api/sysinfo')
+chk('管理接口仍在线', r.get('ok') is True,
+ '→ %s' % ((r.get('data') or {}).get('hostname') or r.get('msg_cn') or ''))
+
+print('\n' + '=' * 60)
+print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/live-term-latency.py b/_dev/live-term-latency.py
new file mode 100644
index 0000000..7c788c6
--- /dev/null
+++ b/_dev/live-term-latency.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""终端手感体检(#10):量「敲下回车 → 屏幕上出现回显」到底要多久。
+
+必须在目标机上跑(BASE = 127.0.0.1:8443)。
+只读 + 只在一个临时会话里 echo,不动系统任何配置。
+"""
+import json
+import ssl
+import sys
+import time
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+TK = None
+
+
+def call(path, body=None, timeout=60):
+ hdrs = {'Content-Type': 'application/json'}
+ if TK:
+ hdrs['X-Token'] = TK
+ data = json.dumps(body or {}).encode() if body is not None else None
+ req = urllib.request.Request(BASE + path, data=data, headers=hdrs,
+ method='POST' if body is not None else 'GET')
+ t0 = time.time()
+ try:
+ with urllib.request.urlopen(req, context=CTX, timeout=timeout) as r:
+ j = json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': str(e)}, (time.time() - t0) * 1000
+ return j, (time.time() - t0) * 1000
+
+
+def main():
+ global TK
+ j, _ = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+ if not j.get('ok'):
+ print('登录失败:%s' % j.get('msg_cn'))
+ return 1
+ TK = j['data']['token']
+
+ j, dt = call('/api/webshell/connect', {'user': 'root', 'cols': 120, 'rows': 30})
+ if not j.get('ok'):
+ print('连接失败:%s' % j.get('msg_cn'))
+ return 1
+ sid = j['data']['sid']
+ print('连接耗时 %.0f ms,sid=%s\n' % (dt, sid))
+
+ # 1) 单次 read 往返耗时(空闲时)
+ ts = []
+ for _ in range(10):
+ _j, d = call('/api/webshell/read', {'sid': sid})
+ ts.append(d)
+ ts.sort()
+ print('空闲 read 往返(10 次):最快 %.0f ms 中位 %.0f ms 最慢 %.0f ms'
+ % (ts[0], ts[len(ts) // 2], ts[-1]))
+
+ # 2) 敲回车 → 首次看到回显,需要轮询几轮、多久
+ print('\n命令 → 首次回显延迟(模拟前端 60ms 轮询):')
+ for cmd in ('echo LAT_TEST_1', 'echo 中文延迟测试', 'uname -a'):
+ _j, dw = call('/api/webshell/write', {'sid': sid, 'data': cmd + '\n'})
+ t0 = time.time()
+ rounds = 0
+ got = ''
+ while time.time() - t0 < 8:
+ r, _d = call('/api/webshell/read', {'sid': sid})
+ rounds += 1
+ chunk = (r.get('data') or {}).get('data') or ''
+ if chunk:
+ got += chunk
+ if cmd.split()[1] in got or 'Linux' in got:
+ break
+ time.sleep(0.06)
+ dt = (time.time() - t0) * 1000
+ print(' %-16s 首字节 %.0f ms(轮询 %d 轮)' % (cmd, dt, rounds))
+ time.sleep(0.3)
+
+ # 3) 长输出的分帧完整性(会不会断字)
+ print('\n长输出分帧检查:')
+ call('/api/webshell/write', {'sid': sid,
+ 'data': 'for i in $(seq 1 200); do echo "第 $i 行 中文混排 abcdefghij"; done\n'})
+ total = b''
+ t0 = time.time()
+ frames = 0
+ bad = 0
+ while time.time() - t0 < 15:
+ r, _d = call('/api/webshell/read', {'sid': sid})
+ chunk = (r.get('data') or {}).get('data') or ''
+ if chunk:
+ frames += 1
+ total += chunk.encode('utf-8', 'replace')
+ try:
+ total.decode('utf-8')
+ except UnicodeDecodeError:
+ bad += 1
+ elif frames and time.time() - t0 > 3:
+ break
+ time.sleep(0.06)
+ try:
+ text = total.decode('utf-8')
+ ok = True
+ except UnicodeDecodeError as e:
+ text = total.decode('utf-8', 'replace')
+ ok = False
+ print(' 收到 %d 帧 / %d 字节,UTF-8 解码 %s'
+ % (frames, len(total), '完整' if ok else '损坏(会显示黑菱形)'))
+ print(' 含 U+FFFD 替换字符:%d 个' % text.count('\ufffd'))
+ print(' 末行示例:%s' % (text.strip().splitlines()[-1][:60] if text.strip() else '(空)'))
+
+ call('/api/webshell/disconnect', {'sid': sid})
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-ulog-perf.py b/_dev/live-ulog-perf.py
new file mode 100644
index 0000000..4199ddf
--- /dev/null
+++ b/_dev/live-ulog-perf.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""统一日志逐源耗时测量(#7)—— 必须在目标机上跑。
+
+目的:/api/ulog 是整个面板唯一还超过 1 秒的接口,但它要汇聚 6 个日志源。
+不拆开量就不知道该优化采集、过滤还是传输。
+"""
+import json
+import ssl
+import sys
+import time
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+
+
+def call(path, token, timeout=120):
+ req = urllib.request.Request(BASE + path, headers={'X-Token': token}, method='GET')
+ t0 = time.time()
+ with urllib.request.urlopen(req, context=CTX, timeout=timeout) as r:
+ b = r.read()
+ return (time.time() - t0) * 1000, len(b), json.loads(b.decode())
+
+
+def main():
+ req = urllib.request.Request(BASE + '/api/login',
+ data=json.dumps({'username': 'admin',
+ 'password': 'admin123'}).encode(),
+ headers={'Content-Type': 'application/json'},
+ method='POST')
+ with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
+ j = json.loads(r.read().decode())
+ tk = j['data']['token']
+
+ print('%-12s %9s %10s %8s' % ('日志源', '耗时ms', '响应字节', '命中条数'))
+ print('-' * 46)
+ for s in ('fw', 'conntrack', 'wan', 'ddns', 'app', 'system'):
+ try:
+ dt, n, r = call('/api/ulog?sources=%s&limit=500' % s, tk)
+ d = r.get('data') or {}
+ m = d.get('meta') or {}
+ print('%-12s %9.0f %10d %8s' % (s, dt, n, m.get('matched')))
+ except Exception as e:
+ print('%-12s 失败: %s' % (s, e))
+ try:
+ dt, n, r = call('/api/ulog?limit=500', tk)
+ print('-' * 46)
+ print('%-12s %9.0f %10d' % ('全部源', dt, n))
+ d = r.get('data') or {}
+ print(' meta:', json.dumps(d.get('meta'), ensure_ascii=False))
+ except Exception as e:
+ print('全部源失败: %s' % e)
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-wait-probe.py b/_dev/live-wait-probe.py
new file mode 100644
index 0000000..a7af69d
--- /dev/null
+++ b/_dev/live-wait-probe.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""分层定位终端长轮询为什么没挂起:shelld → helper → helpd → HTTP。"""
+import json
+import os
+import socket
+import subprocess
+import sys
+import time
+
+SHELL_SOCK = '/run/drouter/shell.sock'
+HELPD_SOCK = '/run/drouter/helper.sock'
+
+
+def sock_call(path, req, timeout=30):
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.settimeout(timeout)
+ s.connect(path)
+ s.sendall((json.dumps(req) + '\n').encode())
+ buf = b''
+ while not buf.endswith(b'\n'):
+ c = s.recv(65536)
+ if not c:
+ break
+ buf += c
+ s.close()
+ return json.loads(buf.decode('utf-8', 'replace'))
+
+
+for p in (SHELL_SOCK, HELPD_SOCK):
+ print('%-28s 存在=%s' % (p, os.path.exists(p)))
+
+print('\n=== 1) 直连 shelld ===')
+r = sock_call(SHELL_SOCK, {'op': 'connect', 'user': 'root', 'cols': 100, 'rows': 30})
+sid = (r.get('data') or {}).get('sid')
+print('connect ok=%s sid=%s' % (r.get('ok'), sid))
+if sid:
+ for _ in range(5):
+ rr = sock_call(SHELL_SOCK, {'op': 'read', 'sid': sid, 'wait': 0})
+ if not ((rr.get('data') or {}).get('data') or ''):
+ break
+ t0 = time.time()
+ rr = sock_call(SHELL_SOCK, {'op': 'read', 'sid': sid, 'wait': 1.2})
+ print('wait=1.2 空读耗时 %.0f ms ok=%s data=%r'
+ % ((time.time() - t0) * 1000, rr.get('ok'),
+ ((rr.get('data') or {}).get('data') or '')[:40]))
+ sock_call(SHELL_SOCK, {'op': 'disconnect', 'sid': sid})
+
+print('\n=== 2) 走 helper 子进程 ===')
+r = json.loads(subprocess.run(
+ ['sudo', '-n', '/usr/bin/python3', '/opt/drouter/backend/drouter-helper.py',
+ 'webshell', json.dumps({'op': 'connect', 'cols': 100, 'rows': 30})],
+ capture_output=True, text=True, timeout=60).stdout.strip().splitlines()[-1])
+sid = (r.get('data') or {}).get('sid')
+print('connect ok=%s sid=%s' % (r.get('ok'), sid))
+if sid:
+ for _ in range(5):
+ out = subprocess.run(
+ ['sudo', '-n', '/usr/bin/python3', '/opt/drouter/backend/drouter-helper.py',
+ 'webshell', json.dumps({'op': 'read', 'sid': sid, 'wait': 0})],
+ capture_output=True, text=True, timeout=60).stdout.strip().splitlines()[-1]
+ if not ((json.loads(out).get('data') or {}).get('data') or ''):
+ break
+ t0 = time.time()
+ out = subprocess.run(
+ ['sudo', '-n', '/usr/bin/python3', '/opt/drouter/backend/drouter-helper.py',
+ 'webshell', json.dumps({'op': 'read', 'sid': sid, 'wait': 1.2})],
+ capture_output=True, text=True, timeout=60).stdout.strip().splitlines()[-1]
+ print('wait=1.2 空读耗时 %.0f ms' % ((time.time() - t0) * 1000))
+ subprocess.run(['sudo', '-n', '/usr/bin/python3',
+ '/opt/drouter/backend/drouter-helper.py', 'webshell',
+ json.dumps({'op': 'disconnect', 'sid': sid})],
+ capture_output=True, text=True, timeout=60)
+
+print('\n=== 3) 走 helpd 常驻守护 ===')
+if os.path.exists(HELPD_SOCK):
+ r = sock_call(HELPD_SOCK, {'action': 'webshell',
+ 'payload': {'op': 'connect', 'cols': 100, 'rows': 30}})
+ sid = (r.get('data') or {}).get('sid')
+ print('connect ok=%s sid=%s' % (r.get('ok'), sid))
+ if sid:
+ for _ in range(5):
+ rr = sock_call(HELPD_SOCK, {'action': 'webshell',
+ 'payload': {'op': 'read', 'sid': sid, 'wait': 0}})
+ if not ((rr.get('data') or {}).get('data') or ''):
+ break
+ t0 = time.time()
+ rr = sock_call(HELPD_SOCK, {'action': 'webshell',
+ 'payload': {'op': 'read', 'sid': sid, 'wait': 1.2}})
+ print('wait=1.2 空读耗时 %.0f ms' % ((time.time() - t0) * 1000))
+ sock_call(HELPD_SOCK, {'action': 'webshell',
+ 'payload': {'op': 'disconnect', 'sid': sid}})
+else:
+ print('helpd socket 不存在')
+
+print('\n=== 4) helpd 是否并发处理(单线程会串行阻塞)===')
+print('检查 drouter-helpd 主线程模型:')
+os.system("grep -n 'Threading\|Thread\|serve_forever\|fork' "
+ "/opt/drouter/backend/drouter-helpd.py | head -10")
diff --git a/_dev/live-webshell.py b/_dev/live-webshell.py
new file mode 100644
index 0000000..4e1ea5a
--- /dev/null
+++ b/_dev/live-webshell.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Web 终端(真 PTY)真机验收 —— 必须在目标机上跑(BASE 是 127.0.0.1:8443)。
+
+验收点:
+ 1. 能连上终端守护(后台已 enable --now);
+ 2. 敲 echo 立刻有回显(说明是真 PTY 而不是「发整条命令等结果」);
+ 3. 中文不被分帧切坏;
+ 4. 交互式程序能起来(printf 之后 shell 仍在,能继续收命令);
+ 5. 断开后会话真的消失。
+"""
+import json
+import ssl
+import sys
+import time
+import urllib.parse
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+
+fails = 0
+
+
+def chk(label, cond, extra=''):
+ global fails
+ if not cond:
+ fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+
+def call(path, body=None, token=None, method=None):
+ data = None
+ headers = {}
+ if body is not None:
+ data = json.dumps(body).encode('utf-8')
+ headers['Content-Type'] = 'application/json'
+ method = method or 'POST'
+ if token:
+ headers['X-Token'] = token
+ req = urllib.request.Request(BASE + path, data=data, headers=headers,
+ method=method or 'GET')
+ try:
+ with urllib.request.urlopen(req, context=CTX, timeout=45) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except urllib.error.HTTPError as e:
+ # 会话不存在时服务端返回 400 + JSON;这里要把响应体读出来,
+ # 否则只看状态码没法判断是不是预期的 NOSESS。
+ try:
+ return json.loads(e.read().decode('utf-8'))
+ except Exception:
+ return {'ok': False, 'code': 'HTTP%s' % e.code, 'msg_cn': str(e)}
+
+
+def main():
+ r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+ chk('登录成功', r.get('ok'), r.get('msg_cn'))
+ if not r.get('ok'):
+ return 1
+ tk = r['data']['token']
+
+ r = call('/api/webshell/connect', {'user': 'root', 'cols': 100, 'rows': 30}, tk)
+ chk('终端连接成功', r.get('ok'), r.get('msg_cn') or r.get('code'))
+ if not r.get('ok'):
+ return 1
+ sid = r['data']['sid']
+ print(' sid = %s' % sid)
+
+ def drain(rounds=12, wait=0.35):
+ out = ''
+ for _ in range(rounds):
+ rr = call('/api/webshell/read', {'sid': sid}, tk)
+ if not rr.get('ok'):
+ break
+ out += (rr.get('data') or {}).get('data') or ''
+ if out:
+ break
+ time.sleep(wait)
+ return out
+
+ # 先读掉登录时的提示符 / motd
+ drain(rounds=3, wait=0.2)
+
+ # ---- 1. 单条命令有回显 ----
+ call('/api/webshell/write', {'sid': sid, 'data': 'echo HELLO_PTY_1\r'}, tk)
+ out = drain()
+ chk('echo 命令有输出', 'HELLO_PTY_1' in out, '→ %r' % out[:160])
+
+ # ---- 2. 中文不被切坏 ----
+ call('/api/webshell/write', {'sid': sid, 'data': 'echo 中文回显测试\r'}, tk)
+ out = drain()
+ chk('中文正常回显(无 U+FFFD)',
+ '中文回显测试' in out and '\ufffd' not in out, '→ %r' % out[:160])
+
+ # ---- 3. 会话仍活着(交互式:shell 没有随命令结束而退出)----
+ rr = call('/api/webshell/read', {'sid': sid}, tk)
+ alive = (rr.get('data') or {}).get('alive')
+ chk('会话在命令结束后仍存活', alive is True, 'alive=%s' % alive)
+
+ # ---- 4. 第二条命令仍可执行(证明是持续会话,不是一次性 exec)----
+ call('/api/webshell/write', {'sid': sid, 'data': 'echo SECOND_CMD_OK\r'}, tk)
+ out = drain()
+ chk('同一会话能继续执行第二条命令', 'SECOND_CMD_OK' in out, '→ %r' % out[:160])
+
+ # ---- 5. 交互式程序(top -b 一帧 + Ctrl+C 中断)----
+ call('/api/webshell/write', {'sid': sid, 'data': 'sleep 30\r'}, tk)
+ time.sleep(0.6)
+ call('/api/webshell/write', {'sid': sid, 'data': '\x03'}, tk) # Ctrl+C
+ time.sleep(0.6)
+ out = drain(rounds=4, wait=0.3)
+ chk('Ctrl+C 能中断前台命令', 'sleep 30' in out or out == '', '→ %r' % out[:120])
+ rr = call('/api/webshell/read', {'sid': sid}, tk)
+ chk('中断后 shell 仍在(没被打死)', (rr.get('data') or {}).get('alive') is True)
+
+ # ---- 6. 会话列表里能看到它 ----
+ rr = call('/api/webshell/sessions', None, tk, method='GET')
+ lst = rr.get('data') if rr.get('ok') else []
+ chk('会话列表包含当前会话',
+ any(isinstance(x, dict) and x.get('sid') == sid for x in (lst or [])) or
+ any(x == sid for x in (lst or [])), '→ %s' % json.dumps(lst)[:160])
+
+ # ---- 7. 断开 ----
+ rr = call('/api/webshell/disconnect', {'sid': sid}, tk)
+ chk('断开成功', rr.get('ok'), rr.get('msg_cn'))
+ rr = call('/api/webshell/read', {'sid': sid}, tk)
+ chk('断开后会话确实不存在', (not rr.get('ok')) or rr.get('code') == 'NOSESS',
+ '→ %s' % rr.get('code'))
+
+ print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+ return 1 if fails else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-wizard-verify.py b/_dev/live-wizard-verify.py
new file mode 100644
index 0000000..2c8bfc2
--- /dev/null
+++ b/_dev/live-wizard-verify.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""部署后冒烟:确认新手向导在目标机上真的活着(只读,不改任何配置)。
+
+用法(必须在目标机上跑):
+ python3 _dev/live-wizard-verify.py
+"""
+import json
+import ssl
+import sys
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+AUTH = ('admin', 'admin123')
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+
+PASS = FAIL = 0
+TOKEN = ['']
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+def post(path, body=None, token=None):
+ data = json.dumps(body or {}).encode('utf-8')
+ req = urllib.request.Request(BASE + path, data=data, method='POST')
+ req.add_header('Content-Type', 'application/json')
+ if token:
+ req.add_header('X-Token', token)
+ with urllib.request.urlopen(req, context=CTX, timeout=120) as r:
+ return json.loads(r.read().decode('utf-8'))
+
+
+def login():
+ """先登录拿 token —— 接口认的是 X-Token 头,不是 HTTP Basic。
+
+ 登录体字段是 username / password(不是 user / pass)。
+ """
+ r = post('/api/login', {'username': AUTH[0], 'password': AUTH[1]})
+ tok = (r.get('data') or {}).get('token') or r.get('token')
+ TOKEN[0] = tok or ''
+ return bool(tok)
+
+
+def call(path, body=None):
+ return post(path, body, TOKEN[0])
+
+
+def main():
+ print('=== 新手向导 · 部署后冒烟(只读,不改配置)===')
+ chk('登录取得 token', login())
+ if not TOKEN[0]:
+ print('\n无法登录,后续检查跳过')
+ return 1
+ r = call('/api/wizard', {'op': 'probe'})
+ chk('接口返回 ok', r.get('ok') is True, str(r.get('msg') or r.get('error') or ''))
+ d = r.get('data') or {}
+
+ steps = [s.get('k') for s in (d.get('steps') or [])]
+ chk('四个步骤齐全且顺序正确', steps == ['wan', 'lan', 'dns', 'v6'], str(steps))
+ chk('每步都有标题', all(s.get('n') for s in (d.get('steps') or [])))
+ chk('每步都有说明', all(s.get('d') for s in (d.get('steps') or [])))
+
+ # probe 的返回是分层结构:wan / lan / dns / v6 各一个子对象
+ lan = d.get('lan') or {}
+ wan = d.get('wan') or {}
+ chk('探测到 LAN 接口', bool(lan.get('iface')), lan.get('iface') or '')
+ chk('探测到 LAN IP', bool(lan.get('lan_ip')), lan.get('lan_ip') or '')
+ chk('LAN 接口与配置库一致(ens18)', lan.get('iface') == 'ens18', lan.get('iface') or '')
+ chk('LAN IP 与 ens18 实际地址一致(192.168.7.3)',
+ lan.get('lan_ip') == '192.168.7.3', lan.get('lan_ip') or '')
+ chk('地址池预设已给出(起止)',
+ bool(lan.get('pool_start')) and bool(lan.get('pool_end')),
+ '%s - %s' % (lan.get('pool_start'), lan.get('pool_end')))
+ chk('LAN 能报出 DHCP 是否在跑', isinstance(lan.get('dhcp_enabled'), bool),
+ str(lan.get('dhcp_enabled')))
+ chk('WAN 能报出接口与默认路由',
+ bool(wan.get('iface')) and isinstance(wan.get('has_default_route'), bool),
+ '%s / route=%s' % (wan.get('iface'), wan.get('has_default_route')))
+ chk('能报出外网可达性', isinstance(d.get('internet_ok'), bool), str(d.get('internet_ok')))
+ chk('能报出构建保护模式状态', isinstance(d.get('build_mode'), bool),
+ str(d.get('build_mode')))
+
+ modes = [m.get('k') for m in (d.get('wan_modes') or [])]
+ chk('三种上网方式齐全', modes == ['pppoe', 'dhcp', 'static'], str(modes))
+ chk('每种上网方式都有说明+适用场景',
+ all(m.get('d') and m.get('when') for m in (d.get('wan_modes') or [])))
+
+ presets = d.get('dns_presets') or []
+ chk('DNS 预设 >= 5 家', len(presets) >= 5, '%d 家' % len(presets))
+ # "跟随运营商"这一项的地址本来就是空的(由 PPPoE 下发),不算缺失
+ chk('DNS 预设每家都有名字', all(p.get('n') for p in presets))
+ chk('DNS 预设的公共 DNS 都带地址',
+ all(p.get('v4') for p in presets if p.get('k') != 'isp'),
+ str([p.get('k') for p in presets if p.get('k') != 'isp' and not p.get('v4')]))
+ chk('DNS 预设每家都有人话说明', all(p.get('d') for p in presets))
+
+ pool = d.get('pool_hint') or {}
+ chk('地址池提示含「为什么」', bool(pool.get('why')))
+ chk('地址池提示含规则清单', len(pool.get('rules') or []) >= 3,
+ '%d 条' % len(pool.get('rules') or []))
+
+ chk('网卡下拉(WAN)非空', bool(d.get('wan_ifaces')), '%d 个' % len(d.get('wan_ifaces') or []))
+ chk('网卡下拉(LAN)非空', bool(d.get('lan_ifaces')), '%d 个' % len(d.get('lan_ifaces') or []))
+ chk('网卡下拉每项带名字与 MAC',
+ all(x.get('name') and x.get('mac')
+ for x in (d.get('wan_ifaces') or []) + (d.get('lan_ifaces') or [])))
+ chk('待办清单存在(可为空)', isinstance(d.get('todo'), list), str(d.get('todo')))
+
+ print('\n ── 只读校验:非法输入必须被拦住(不写盘)──')
+ r2 = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
+ 'cfg': {'pool_start': '192.168.7.3', 'pool_end': '192.168.7.50'}})
+ chk('地址池圈进本机自己 → 拒绝', r2.get('ok') is False,
+ str(r2.get('msg') or '')[:80])
+
+ r3 = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
+ 'cfg': {'pool_start': '10.0.0.100', 'pool_end': '10.0.0.200'}})
+ chk('地址池跨网段 → 拒绝', r3.get('ok') is False, str(r3.get('msg') or '')[:80])
+
+ r4 = call('/api/wizard', {'op': 'apply_step', 'step': 'wan', 'cfg': {'mode': 'pppoe'}})
+ chk('PPPoE 缺账号 → 拒绝', r4.get('ok') is False, str(r4.get('msg') or '')[:80])
+
+ r5 = call('/api/wizard', {'op': 'apply_step', 'step': 'nosuch', 'cfg': {}})
+ chk('未知步骤 → 拒绝', r5.get('ok') is False, str(r5.get('msg') or '')[:80])
+
+ r6 = call('/api/wizard', {'op': 'dial', 'action': 'connect'})
+ chk('拨号不带确认 → 拒绝(防止误断网)', r6.get('ok') is False,
+ str(r6.get('msg') or '')[:80])
+
+ print('\n' + '=' * 56)
+ print('通过 %d / 失败 %d' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/live-wizard.py b/_dev/live-wizard.py
new file mode 100644
index 0000000..d7d3cd4
--- /dev/null
+++ b/_dev/live-wizard.py
@@ -0,0 +1,427 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机验收:新手向导(上网四步走)。
+
+必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443),必须用 root:
+ python3 /tmp/live-wizard.py
+
+这个脚本会真的改 dnsmasq / pppoe / radvd 的配置库,还会真的重启 dnsmasq。
+所以开头先把这几个配置存一份到 /tmp/wizard-live-backup/,
+无论中途发生什么,结尾都会原样装回去并复核红线。
+
+**刻意不做的事**:不真正发起 PPPoE 拨号。这台机器与远端 CHR 共用局域网,
+真拨号会改动默认路由与出口,属于「会切换网络」的动作 —— 那是红线。
+拨号路径只做参数校验(缺账号 / 缺密码 / 缺确认),不执行。
+
+会真跑的环节:
+ · probe:体检能不能读出上网方式 / 地址池 / DNS / IPv6 四块状态
+ · 第 2 步:填一个「把本机自己圈进地址池」的错值 → 必须被拒(最重要的一条)
+ · 第 2 步:填跨网段的地址池 → 必须被拒
+ · 第 2 步:正常地址池 → 保存并生效,dnsmasq 真的重启、租约文件仍在
+ · 第 3 步:填非法 DNS → 被拒;填 223.5.5.5 → 生效并真的能解析
+ · 第 4 步:开启 IPv6 → radvd 配置落盘、转发打开(前缀留空则不动 RADVD 前缀)
+ · 第 4 步:关闭 IPv6 → 转发关掉,但已填前缀不删
+ · 构建保护模式:只写盘不重启
+结尾逐项还原并复核红线。
+
+安全边界:不碰 5900(VNC)、不动网卡与默认路由、不启 DHCP 服务给别的网段、
+不真正拨号、不启 dnsmasq 之前先确认它本来就是启着的(否则只写盘不启)。
+"""
+import os
+import re
+import ssl
+import sys
+import json
+import time
+import shutil
+import subprocess
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+TOK = None
+PASS = FAIL = 0
+
+BUILD_FLAG = '/etc/drouter/BUILD_MODE'
+DB = '/opt/drouter/data/drouter.db'
+SAFE = '/tmp/wizard-live-backup'
+DNSMASQ_CONF = '/etc/dnsmasq.d/drouter.conf'
+RADVD_CONF = '/etc/radvd.conf'
+
+
+def call(path, payload=None, timeout=90):
+ data = json.dumps(payload).encode('utf-8') if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data,
+ method='POST' if data is not None else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if TOK:
+ req.add_header('X-Token', TOK)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except Exception as e:
+ return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e, 'code': 'HTTP_ERR'}
+
+
+def login():
+ global TOK
+ r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+ if r.get('ok'):
+ TOK = (r.get('data') or {}).get('token')
+ return bool(TOK)
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+def sh(argv, timeout=90):
+ try:
+ p = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ timeout=timeout)
+ return p.returncode, p.stdout.decode('utf-8', 'replace')
+ except Exception as e:
+ return 1, str(e)
+
+
+def svc(name):
+ rc, out = sh(['systemctl', 'is-active', name])
+ return (out or '').strip()
+
+
+def sqlite_get(key):
+ """直接读配置库,用来核对接口说的和库里存的是不是一回事"""
+ rc, out = sh(['python3', '-c', '''
+import sqlite3, json
+c = sqlite3.connect(%r)
+r = c.execute("SELECT value FROM settings WHERE key=?", (%r,)).fetchone()
+print(r[0] if r else "")
+c.close()
+''' % (DB, key)])
+ if rc != 0 or not out.strip():
+ return {}
+ try:
+ return json.loads(out.strip().splitlines()[-1])
+ except Exception:
+ return {}
+
+
+def main():
+ global PASS, FAIL
+ print('===== 真机验收:新手向导 =====\n')
+
+ # ---------- 备份 ----------
+ os.makedirs(SAFE, exist_ok=True)
+ for k in ('dnsmasq', 'pppoe', 'radvd', 'system', 'nft_v4', 'nft_v6'):
+ rc, out = sh(['python3', '-c', '''
+import sqlite3, json
+c = sqlite3.connect(%r)
+r = c.execute("SELECT value FROM settings WHERE key=?", (%r,)).fetchone()
+open(%r, "w").write(r[0] if r else "")
+c.close()
+''' % (DB, k, os.path.join(SAFE, k + '.json'))])
+ for f in (DNSMASQ_CONF, RADVD_CONF):
+ if os.path.isfile(f):
+ shutil.copy2(f, os.path.join(SAFE, os.path.basename(f)))
+ has_build = os.path.isfile(BUILD_FLAG)
+ dnsmasq_was = svc('dnsmasq')
+ radvd_was = svc('radvd')
+ print(' dnsmasq 原本:%s' % dnsmasq_was)
+ print(' radvd 原本:%s' % radvd_was)
+ print(' 保护模式原本:%s\n' % ('开' if has_build else '关'))
+
+ # 红线快照
+ rc, gw0 = sh(['sh', '-c', "ip route | grep default"])
+ rc, ip0 = sh(['sh', '-c', "ip -4 addr show ens18 | grep inet"])
+
+ try:
+ chk('登录成功', login())
+
+ # ---------- 1. 体检 ----------
+ print('\n--- 1. 体检(probe)---')
+ r = call('/api/wizard', {'op': 'probe'})
+ d = r.get('data') or {}
+ chk('probe 成功', r.get('ok'), str(r.get('msg_cn'))[:60])
+ chk('返回四个步骤', len(d.get('steps') or []) == 4, str(len(d.get('steps') or [])))
+ chk('步骤顺序正确',
+ [s['k'] for s in (d.get('steps') or [])] == ['wan', 'lan', 'dns', 'v6'])
+ w = d.get('wan') or {}
+ l = d.get('lan') or {}
+ n = d.get('dns') or {}
+ v = d.get('v6') or {}
+ chk('有上网方式段落', bool(w), '方式=%s' % w.get('mode_cn'))
+ chk('有内网段落', bool(l), 'LAN=%s IP=%s' % (l.get('iface'), l.get('lan_ip')))
+ chk('有 DNS 段落', bool(n), '来源=%s' % n.get('mode_cn'))
+ chk('有 IPv6 段落', bool(v))
+ chk('有 internet_ok 结论', 'internet_ok' in d, str(d.get('internet_ok')))
+ chk('有 todo 列表', isinstance(d.get('todo'), list))
+ chk('下发三种上网方式', len(d.get('wan_modes') or []) == 3)
+ chk('下发 DNS 服务商范例',
+ len(d.get('dns_presets') or []) >= 5, str(len(d.get('dns_presets') or [])))
+ chk('下发地址池范例提示', len((d.get('pool_hint') or {}).get('rules') or []) >= 3)
+ chk('下发网卡下拉', len(d.get('wan_ifaces') or []) >= 1,
+ str([x.get('name') for x in (d.get('wan_ifaces') or [])]))
+ chk('体检结果与配置库一致(LAN 接口)',
+ (l.get('iface') or '') == (sqlite_get('dnsmasq') or {}).get('lan_iface', ''),
+ '接口 %s / 库 %s' % (l.get('iface'), (sqlite_get('dnsmasq') or {}).get('lan_iface')))
+
+ # ---------- 2. 第 2 步:地址池校验(最重要) ----------
+ print('\n--- 2. 第 2 步:地址池校验 ---')
+ lan_ip = l.get('lan_ip') or ''
+ chk('本机 LAN 地址读到了', bool(lan_ip), lan_ip)
+ base = lan_ip.rsplit('.', 1)[0] if lan_ip else '192.168.7'
+
+ # ① 把本机自己圈进池子 —— 必须拒
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
+ 'iface': l.get('iface'), 'pool_start': base + '.2',
+ 'pool_end': base + '.200',
+ 'pool_netmask': '255.255.255.0'})
+ chk('地址池圈进本机自己 → 被拒', not r.get('ok'),
+ str(r.get('msg_cn'))[:80])
+ chk('拒绝理由点明了「抢 IP」',
+ '抢 IP' in (r.get('msg_cn') or '') or '本机自己' in (r.get('msg_cn') or ''))
+
+ # ② 跨网段 —— 必须拒
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
+ 'iface': l.get('iface'), 'pool_start': '10.9.9.100',
+ 'pool_end': '10.9.9.200',
+ 'pool_netmask': '255.255.255.0'})
+ chk('跨网段地址池 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+ chk('拒绝理由点明了「同一个网段」', '同一个网段' in (r.get('msg_cn') or ''))
+
+ # ③ 非法地址 —— 必须拒
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
+ 'iface': l.get('iface'), 'pool_start': 'abc',
+ 'pool_end': base + '.200'})
+ chk('非法地址 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ # ④ 正常地址池 —— 通过并真的生效
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
+ 'iface': l.get('iface'),
+ 'pool_start': base + '.100', 'pool_end': base + '.200',
+ 'pool_netmask': '255.255.255.0', 'lease_time': '7200',
+ 'option_gateway': lan_ip, 'option_dns': lan_ip})
+ chk('正常地址池 → 保存成功', r.get('ok'), str(r.get('msg_cn'))[:80])
+ db_lan = sqlite_get('dnsmasq') or {}
+ chk('配置库写入了起始地址', db_lan.get('pool_start') == base + '.100',
+ str(db_lan.get('pool_start')))
+ chk('配置库写入了结束地址', db_lan.get('pool_end') == base + '.200',
+ str(db_lan.get('pool_end')))
+ chk('DHCP 开关被打开', db_lan.get('dhcp_enabled') is True)
+ chk('网关自动填成本机', db_lan.get('option_gateway') == lan_ip,
+ str(db_lan.get('option_gateway')))
+ if os.path.isfile(DNSMASQ_CONF):
+ with open(DNSMASQ_CONF, encoding='utf-8') as f:
+ conf = f.read()
+ chk('渲染产物里有 dhcp-range',
+ ('dhcp-range=%s.100,%s.200' % (base, base)) in conf,
+ [x for x in conf.splitlines() if x.startswith('dhcp-range')][:1])
+ chk('渲染产物里有网关 option 3', 'dhcp-option=3,%s' % lan_ip in conf)
+ # dnsmasq 原本就是 inactive(这台机器还没接管路由)→ 不能因为向导就把它启起来
+ now_dnsmasq = svc('dnsmasq')
+ if dnsmasq_was == 'active':
+ chk('dnsmasq 仍在运行', now_dnsmasq == 'active', now_dnsmasq)
+ else:
+ chk('原本没跑 dnsmasq,向导没有把它擅自启起来',
+ now_dnsmasq != 'active', '当前 %s' % now_dnsmasq)
+
+ # ---------- 3. 第 3 步:DNS ----------
+ print('\n--- 3. 第 3 步:DNS ---')
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
+ 'mode': 'custom', 'custom': 'not-an-ip'})
+ chk('非法 DNS → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
+ 'mode': 'custom', 'custom': ''})
+ chk('选了自定义却不填 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
+ 'mode': 'bogus', 'custom': '223.5.5.5'})
+ chk('非法 mode → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
+ 'mode': 'custom', 'custom': '223.5.5.5,119.29.29.29'})
+ chk('正常 DNS → 保存成功', r.get('ok'), str(r.get('msg_cn'))[:80])
+ db_dns = sqlite_get('dnsmasq') or {}
+ chk('配置库写入了 DNS 来源', db_dns.get('dns_mode') == 'custom',
+ str(db_dns.get('dns_mode')))
+ chk('配置库写入了 DNS 地址',
+ '223.5.5.5' in (db_dns.get('dns_custom') or ''),
+ str(db_dns.get('dns_custom')))
+ if os.path.isfile(DNSMASQ_CONF):
+ with open(DNSMASQ_CONF, encoding='utf-8') as f:
+ conf = f.read()
+ chk('渲染产物里有 server=223.5.5.5', 'server=223.5.5.5' in conf)
+ # 真解析一次,确认这台机器自己能用这个 DNS
+ rc, out = sh(['sh', '-c', 'timeout 8 dig +short @223.5.5.5 www.baidu.com 2>/dev/null | head -1'])
+ chk('223.5.5.5 真的能解析(体检验证的同一路径)',
+ bool((out or '').strip()), (out or '').strip()[:40])
+
+ # ---------- 4. 第 4 步:IPv6 ----------
+ print('\n--- 4. 第 4 步:IPv6 ---')
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6', 'enable': True,
+ 'lan_iface': l.get('iface'), 'prefix': '2408:8207:1234:5678::/80',
+ 'rdnss': '2400:3200::1'})
+ chk('开启 IPv6 → 成功', r.get('ok'), str(r.get('msg_cn'))[:100])
+ chk('位数不是 /64 时给出说明',
+ any('/64' in x for x in (r.get('data') or {}).get('notes') or []),
+ str((r.get('data') or {}).get('notes'))[:90])
+ db_ra = sqlite_get('radvd') or {}
+ chk('前缀被规范成 /64', str(db_ra.get('prefix') or '').endswith('/64'),
+ str(db_ra.get('prefix')))
+ chk('RDNSS 落库', db_ra.get('rdnss') == '2400:3200::1', str(db_ra.get('rdnss')))
+ rc, fwd = sh(['sysctl', '-n', 'net.ipv6.conf.all.forwarding'])
+ chk('IPv6 转发已打开', (fwd or '').strip() == '1', (fwd or '').strip())
+ if os.path.isfile(RADVD_CONF):
+ with open(RADVD_CONF, encoding='utf-8') as f:
+ ra = f.read()
+ chk('radvd 配置里有前缀', '2408:8207:1234:5678::/64' in ra)
+ chk('radvd 配置里有 RDNSS', '2400:3200::1' in ra)
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6',
+ 'enable': True, 'prefix': 'not-a-prefix'})
+ chk('非法 IPv6 前缀 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6',
+ 'enable': True, 'rdnss': '223.5.5.5'})
+ chk('把 IPv4 当 RDNSS → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6', 'enable': False})
+ chk('关闭 IPv6 → 成功', r.get('ok'), str(r.get('msg_cn'))[:80])
+ rc, fwd = sh(['sysctl', '-n', 'net.ipv6.conf.all.forwarding'])
+ chk('IPv6 转发已关闭', (fwd or '').strip() == '0', (fwd or '').strip())
+ db_ra2 = sqlite_get('radvd') or {}
+ chk('关闭时保留已填前缀(不抹掉用户的东西)',
+ str(db_ra2.get('prefix') or '').endswith('/64'),
+ str(db_ra2.get('prefix')))
+
+ # ---------- 5. 第 1 步:上网方式(只做校验,不真拨号) ----------
+ print('\n--- 5. 第 1 步:上网方式(不真拨号)---')
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan', 'mode': 'bogus'})
+ chk('非法上网方式 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
+ 'mode': 'pppoe', 'iface': 'ens19', 'username': ''})
+ chk('PPPoE 缺账号 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
+ 'mode': 'pppoe', 'iface': 'ens19', 'username': 'testuser'})
+ chk('PPPoE 缺密码 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
+ 'mode': 'static', 'iface': 'ens19', 'static_address': ''})
+ chk('静态模式缺地址 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
+ 'mode': 'static', 'iface': 'ens19',
+ 'static_address': '192.168.1.2/24', 'static_gateway': 'xx'})
+ chk('静态模式网关非法 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'bogus'})
+ chk('未知步骤 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ # 拨号接口:不肯确认必须被拒(这样才不会误触断网)
+ r = call('/api/wizard', {'op': 'dial', 'action': 'connect'})
+ chk('拨号不带确认 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+ chk('拒绝理由说明了会断网',
+ '中断' in (r.get('msg_cn') or ''), str(r.get('msg_cn'))[:80])
+ r = call('/api/wizard', {'op': 'dial', 'action': 'reboot', 'confirm': True})
+ chk('非法拨号动作 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
+
+ print('\n--- 6. 默认路由没有被向导动过(关键红线)---')
+ rc, gw1 = sh(['sh', '-c', "ip route | grep default"])
+ chk('默认路由未变', (gw0 or '').strip() == (gw1 or '').strip(),
+ (gw1 or '').strip()[:80])
+ rc, ip1 = sh(['sh', '-c', "ip -4 addr show ens18 | grep inet"])
+ chk('ens18 地址未变', (ip0 or '').strip() == (ip1 or '').strip(),
+ (ip1 or '').strip()[:60])
+
+ # ---------- 7. 构建保护模式 ----------
+ print('\n--- 7. 构建保护模式:只写盘不重启 ---')
+ open(BUILD_FLAG, 'w').write('live-wizard-test\n')
+ before = ''
+ if os.path.isfile(DNSMASQ_CONF):
+ with open(DNSMASQ_CONF, encoding='utf-8') as f:
+ before = f.read()
+ r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
+ 'iface': l.get('iface'),
+ 'pool_start': base + '.101', 'pool_end': base + '.201',
+ 'pool_netmask': '255.255.255.0'})
+ chk('保护模式下接口仍返回成功', r.get('ok'), str(r.get('msg_cn'))[:80])
+ chk('保护模式标记能读到', (r.get('data') or {}).get('build_mode') is True)
+ db_lan3 = sqlite_get('dnsmasq') or {}
+ chk('保护模式下配置仍写进了库(不是丢掉)',
+ db_lan3.get('pool_start') == base + '.101', str(db_lan3.get('pool_start')))
+ chk('保护模式下没有重启服务(dnsmasq 状态不变)',
+ svc('dnsmasq') == now_dnsmasq, '%s → %s' % (now_dnsmasq, svc('dnsmasq')))
+ os.remove(BUILD_FLAG)
+ chk('保护模式已关闭', not os.path.isfile(BUILD_FLAG))
+
+ finally:
+ # ---------- 还原 ----------
+ print('\n--- 8. 还原现场 ---')
+ for k in ('dnsmasq', 'pppoe', 'radvd', 'system', 'nft_v4', 'nft_v6'):
+ p = os.path.join(SAFE, k + '.json')
+ if not os.path.isfile(p):
+ continue
+ raw = open(p, encoding='utf-8').read()
+ if not raw.strip():
+ sh(['python3', '-c', '''
+import sqlite3
+c = sqlite3.connect(%r)
+c.execute("DELETE FROM settings WHERE key=?", (%r,))
+c.commit(); c.close()
+''' % (DB, k)])
+ else:
+ sh(['python3', '-c', '''
+import sqlite3
+c = sqlite3.connect(%r)
+c.execute("INSERT INTO settings(key,value) VALUES(?,?) "
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (%r, %r))
+c.commit(); c.close()
+''' % (DB, k, raw)])
+ chk('配置库已还原',
+ (sqlite_get('dnsmasq') or {}).get('pool_start')
+ == json.loads(open(os.path.join(SAFE, 'dnsmasq.json'), encoding='utf-8').read()
+ or '{}').get('pool_start'),
+ str((sqlite_get('dnsmasq') or {}).get('pool_start')))
+ if os.path.isfile(os.path.join(SAFE, os.path.basename(RADVD_CONF))):
+ shutil.copy2(os.path.join(SAFE, os.path.basename(RADVD_CONF)), RADVD_CONF)
+ elif os.path.isfile(RADVD_CONF):
+ os.remove(RADVD_CONF)
+ if has_build:
+ open(BUILD_FLAG, 'w').write('restored\n')
+ chk('保护模式按原样还原', os.path.isfile(BUILD_FLAG) == has_build)
+ # 把配置重新渲染回去,让磁盘上的文件和还原后的配置库一致
+ for mod in ('dnsmasq', 'radvd', 'pppoe'):
+ call('/api/apply', {'module': mod, 'cfg': sqlite_get(mod), 'live': True})
+
+ print('\n--- 9. 红线复核 ---')
+ rc, out = sh(['sh', '-c', 'ss -lnt 2>/dev/null | grep -c 5900'])
+ chk('VNC 5900 仍在监听', (out or '').strip() not in ('', '0'), (out or '').strip())
+ rc, out = sh(['sh', '-c', "ip -4 addr show ens18 | grep inet"])
+ chk('ens18 仍是 192.168.7.3', '192.168.7.3' in (out or ''), (out or '').strip()[:60])
+ rc, out = sh(['sh', '-c', "ip route | grep default"])
+ chk('默认网关仍是 192.168.7.2', '192.168.7.2' in (out or ''), (out or '').strip()[:60])
+ chk('drouter-web 在跑', svc('drouter-web') == 'active', svc('drouter-web'))
+ chk('救援通道仍是 inactive', svc('drouter-rescue') != 'active', svc('drouter-rescue'))
+ if dnsmasq_was != 'active':
+ chk('dnsmasq 按原样保持未启动', svc('dnsmasq') != 'active', svc('dnsmasq'))
+ if radvd_was != 'active':
+ chk('radvd 按原样保持未启动', svc('radvd') != 'active', svc('radvd'))
+ rc, out = sh(['sh', '-c', 'nft list ruleset 2>/dev/null | wc -l'])
+ chk('nft 规则集未被向导改动', (out or '').strip() == '0', (out or '').strip())
+
+ print('\n' + '=' * 60)
+ print('通过 %d / 失败 %d' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/probe-opensoho.sh b/_dev/probe-opensoho.sh
new file mode 100644
index 0000000..8b950b4
--- /dev/null
+++ b/_dev/probe-opensoho.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+# OpenSOHO 真机行为探测:起一次临时实例,摸清 superuser / REST API / 集合名
+# 用完必须清理(见脚本末尾的 cleanup 段)
+set -u
+SECRET='probe-secret-probe-secret-probe-32'
+BIN=/tmp/opensoho
+DIR=/tmp/oh-probe
+LOG=/tmp/oh-probe.log
+PORT=8099
+
+echo "=== 0. 环境 ==="
+id -un; uname -m
+ls -l "$BIN" 2>&1
+echo "--- 8099 / 8090 占用 ---"
+ss -lntp 2>/dev/null | grep -E ':(8099|8090)\b' || echo "(未占用)"
+
+echo
+echo "=== 1. 版本 ==="
+"$BIN" --help 2>&1 | head -30
+
+echo
+echo "=== 2. 启临时实例 ==="
+rm -rf "$DIR" "$LOG"
+mkdir -p "$DIR"
+OPENSOHO_SHARED_SECRET="$SECRET" nohup "$BIN" serve --http 127.0.0.1:$PORT --dir "$DIR" > "$LOG" 2>&1 &
+echo "pid=$!"
+for i in $(seq 1 30); do
+ if curl -sf -o /dev/null "http://127.0.0.1:$PORT/_/" ; then echo "第 ${i}s 起好了"; break; fi
+ sleep 1
+done
+
+echo
+echo "=== 3. 日志 ==="
+cat "$LOG"
+
+echo
+echo "=== 4. 管理界面可达性 ==="
+echo "-- /_/ --"; curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:$PORT/_/"
+echo "-- /api/health --"; curl -s "http://127.0.0.1:$PORT/api/health" ; echo
+
+echo
+echo "=== 5. superuser upsert ==="
+"$BIN" superuser upsert probe@drouter.local 'ProbePass12345' --dir "$DIR" 2>&1 | head -20
+echo "退出码=$?"
+
+echo
+echo "=== 6. 登录取 token ==="
+TOK=$(curl -s -X POST "http://127.0.0.1:$PORT/api/collections/_superusers/auth-with-password" \
+ -H 'Content-type: application/json' \
+ -d '{"identity":"probe@drouter.local","password":"ProbePass12345"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token","")' 2>/dev/null)
+echo "token 长度=${#TOK}"
+
+echo
+echo "=== 7. 列出集合 ==="
+curl -s -H "Authorization: $TOK" "http://127.0.0.1:$PORT/api/collections" \
+ | python3 -c 'import sys,json
+d=json.load(sys.stdin)
+for c in d.get("items",d if isinstance(d,list) else []):
+ print(c.get("name"), "|", ",".join(sorted((c.get("schema") or {}).get("fields",{}).keys())[:14]))' 2>&1 | head -30
+
+echo
+echo "=== 8. devices / clients 列表(空库) ==="
+for c in devices clients radios wifi leds settings; do
+ echo "-- $c --"
+ curl -s -H "Authorization: $TOK" "http://127.0.0.1:$PORT/api/collections/$c/records?perPage=1" | head -c 400; echo
+done
+
+echo
+echo "=== 9. devicestatus v1 ==="
+curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: $TOK" "http://127.0.0.1:$PORT/api/v1/devicestatus/00:11:22:33:44:55"
+
+echo
+echo "=== 10. 监听情况 ==="
+ss -lntp 2>/dev/null | grep "$PORT" || echo "(无)"
+
+echo
+echo "=== 11. 清理 ==="
+pkill -f "$BIN serve" ; sleep 2
+rm -rf "$DIR" "$LOG"
+ss -lntp 2>/dev/null | grep -E ':(8099|8090)\b' || echo "端口已释放"
+echo "DONE"
diff --git a/_dev/probe-opensoho2.sh b/_dev/probe-opensoho2.sh
new file mode 100644
index 0000000..a817a81
--- /dev/null
+++ b/_dev/probe-opensoho2.sh
@@ -0,0 +1,78 @@
+#!/bin/bash
+# OpenSOHO 探测第 2 轮:带 secret 跑 superuser,拿 token,列真实集合名
+set -u
+SECRET='probe-secret-probe-secret-probe-32'
+BIN=/tmp/opensoho
+DIR=/tmp/oh-probe2
+LOG=/tmp/oh-probe2.log
+PORT=8099
+export OPENSOHO_SHARED_SECRET="$SECRET"
+
+echo "=== 0. 检查上轮是否污染了 home 目录 ==="
+ls -ld /home/ajeef/pb_data 2>&1 || echo "(无 pb_data,--dir 生效了)"
+
+echo
+echo "=== 1. --help(带 secret) ==="
+"$BIN" --help 2>&1 | head -40
+
+echo
+echo "=== 2. --dir 是否真生效(对比两处 pb_data) ==="
+rm -rf "$DIR" "$LOG" /tmp/oh-nodir; mkdir -p "$DIR"
+OPENSOHO_SHARED_SECRET="$SECRET" nohup "$BIN" serve --http 127.0.0.1:$PORT --dir "$DIR" > "$LOG" 2>&1 &
+for i in $(seq 1 30); do curl -sf -o /dev/null "http://127.0.0.1:$PORT/api/health" && break; sleep 1; done
+echo "-- $DIR 下 --"; ls "$DIR" 2>&1 | head
+echo "-- /home/ajeef/pb_data --"; ls /home/ajeef/pb_data 2>&1 | head -3
+
+echo
+echo "=== 3. superuser upsert(带 secret) ==="
+"$BIN" superuser upsert probe@drouter.local 'ProbePass12345' --dir "$DIR" 2>&1 | head -20
+echo "退出码=$?"
+echo "-- 再跑一次(幂等?) --"
+"$BIN" superuser upsert probe@drouter.local 'ProbePass12345' --dir "$DIR" 2>&1 | head -10
+echo "退出码=$?"
+
+echo
+echo "=== 4. 登录取 token ==="
+RESP=$(curl -s -X POST "http://127.0.0.1:$PORT/api/collections/_superusers/auth-with-password" \
+ -H 'Content-type: application/json' \
+ -d '{"identity":"probe@drouter.local","password":"ProbePass12345"}')
+echo "响应前 200 字:${RESP:0:200}"
+TOK=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null)
+echo "token 长度=${#TOK}"
+
+echo
+echo "=== 5. 列出所有集合名 ==="
+curl -s -H "Authorization: $TOK" "http://127.0.0.1:$PORT/api/collections?perPage=200" \
+ | python3 -c 'import sys,json
+try:
+ d=json.load(sys.stdin)
+except Exception as e:
+ print("解析失败", e); raise SystemExit
+items=d.get("items",[])
+print("共", d.get("totalItems"), "个集合")
+for c in items:
+ sch=c.get("schema") or {}
+ print(" -", c.get("name"), "| type=", c.get("type"), "| fields=", ",".join((sch.get("schema") if isinstance(sch,dict) and "schema" in sch else sch).keys())[:160] if isinstance(sch,dict) else "")
+' 2>&1 | head -40
+
+echo
+echo "=== 6. settings 集合(superuser 后) ==="
+curl -s -H "Authorization: $TOK" "http://127.0.0.1:$PORT/api/collections/settings/records?perPage=50" | head -c 800; echo
+
+echo
+echo "=== 7. devicestatus v1(带 token) ==="
+curl -s -w '\nHTTP=%{http_code}\n' -H "Authorization: $TOK" "http://127.0.0.1:$PORT/api/v1/devicestatus/00:11:22:33:44:55"
+
+echo
+echo "=== 8. 根目录有哪些 v1 路由(盲探几个) ==="
+for p in /api/v1/devicestatus /api/v1/devices /api/v1/status /api/v1/device/00:11:22:33:44:55; do
+ printf '%-40s ' "$p"
+ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: $TOK" "http://127.0.0.1:$PORT$p"
+done
+
+echo
+echo "=== 9. 清理 ==="
+pkill -f "$BIN serve"; sleep 2
+rm -rf "$DIR" "$LOG" /home/ajeef/pb_data /tmp/oh-nodir
+ss -lntp 2>/dev/null | grep -E ':(8099|8090)\b' || echo "端口已释放"
+echo "DONE"
diff --git a/_dev/smoke.py b/_dev/smoke.py
new file mode 100644
index 0000000..b14cedf
--- /dev/null
+++ b/_dev/smoke.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真机冒烟:登录 → 检查统一配置 → 抽查各功能模块接口是否正常返回。"""
+import json
+import ssl
+import urllib.error
+import urllib.request
+
+BASE = 'https://127.0.0.1:8443'
+CTX = ssl._create_unverified_context()
+
+
+def call(path, payload=None, token=None):
+ data = json.dumps(payload or {}).encode('utf-8')
+ req = urllib.request.Request(BASE + path, data=data, method='POST' if payload else 'GET')
+ req.add_header('Content-Type', 'application/json')
+ if token:
+ req.add_header('X-Token', token)
+ try:
+ with urllib.request.urlopen(req, timeout=20, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+ except urllib.error.HTTPError as e:
+ try:
+ return json.loads(e.read().decode('utf-8'))
+ except Exception:
+ return {'ok': False, 'msg_cn': 'HTTP %s' % e.code}
+ except Exception as e:
+ return {'ok': False, 'msg_cn': str(e)}
+
+
+r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
+if not r.get('ok'):
+ print('登录失败:%s' % r.get('msg_cn'))
+ raise SystemExit(1)
+tok = r['data']['token']
+print('登录:OK')
+
+# 统一配置:确认各模块(含端口转发、主题)都能取到
+cfg = call('/api/config', None, tok)
+data = (cfg.get('data') or {})
+print('配置 ok=%s,模块数=%d' % (cfg.get('ok'), len(data)))
+print(' 模块:%s' % ', '.join(sorted(data)))
+pf = (data.get('portfwd') or {}).get('rules') or []
+print(' 端口转发规则数:%d' % len(pf))
+print(' 主题配置:%s' % json.dumps(data.get('theme'), ensure_ascii=False))
+
+# 各功能模块接口(路径取自 drouter-web.py 的真实路由表,不要凭印象写)
+paths = ['sysinfo', 'metrics', 'fwlog', 'fw/log', 'wan/log', 'ddns',
+ 'ulog', 'ulog/conf', 'ulog/daemon', 'ulog/flow',
+ 'docker', 'acl', 'share', 'qos', 'pppoe-multi', 'ipv6', 'leases',
+ 'nft', 'services', 'routes', 'users', 'journal',
+ 'theme', 'themes', 'vlan', 'wol', 'dpi', 'flow',
+ 'nat/check', 'snapshot/list', 'sysinfo']
+print('\n接口冒烟:')
+bad = 0
+for p in paths:
+ rr = call('/api/' + p, None, tok)
+ ok_ = rr.get('ok')
+ if not ok_:
+ bad += 1
+ print(' %-14s %s %s' % (p, 'OK ' if ok_ else 'FAIL', str(rr.get('msg_cn'))[:44]))
+
+print('\n结果:%d 个接口,失败 %d 个' % (len(paths), bad))
+raise SystemExit(1 if bad else 0)
diff --git a/_dev/t-acl-deploy.py b/_dev/t-acl-deploy.py
new file mode 100644
index 0000000..7bb9d67
--- /dev/null
+++ b/_dev/t-acl-deploy.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+"""验证 ACL 落盘部署逻辑(#8)。
+
+helper 依赖 pwd/systemd,无法在 Windows 直接 import,因此这里用
+正则从源码抽取所需片段,注入到隔离命名空间中运行,并用临时目录
+替换 ACL_CONF / ACL_JSON / ACL_UNIT。
+"""
+import re, io, os, sys, tempfile, shutil, json
+
+SRC = r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py"
+src = io.open(SRC, encoding='utf-8').read()
+
+
+def grab(name):
+ m = re.search(r'^def %s\(.*?(?=\n(?:def |[A-Z_]+ *=|# ---|class ))' % re.escape(name),
+ src, re.S | re.M)
+ if not m:
+ raise SystemExit('未找到函数:%s' % name)
+ return m.group(0)
+
+
+class ValidateError(Exception):
+ def __init__(self, m, f=None):
+ self.msg_cn = m
+
+
+def grab_const(name):
+ m = re.search(r'^%s = .*?$' % re.escape(name), src, re.M)
+ return m.group(0)
+
+
+tmp = tempfile.mkdtemp(prefix='acl-deploy-')
+NS = {'re': re, 'os': os, 'json': json, 'subprocess': _stub if False else None,
+ 'ValidateError': ValidateError, 'TZ_OFFSET_H': 8,
+ 'fail': lambda m, c=None, d=None: {'ok': False, 'msg_cn': m},
+ 'ok': lambda d=None, m='', c=0: {'ok': True, 'data': d, 'msg_cn': m}}
+
+
+class _Sub:
+ """假的 subprocess,记录调用而不真的执行。"""
+ called = []
+
+ @staticmethod
+ def run(args, **kw):
+ _Sub.called.append(list(args))
+
+ class R:
+ returncode = 0
+ stderr = b''
+ return R()
+
+
+NS['subprocess'] = _Sub
+
+# 常量:把路径全部改到临时目录
+NS['ACL_CONF'] = os.path.join(tmp, 'generated', 'acl.nft')
+NS['ACL_JSON'] = os.path.join(tmp, 'generated', 'acl.json')
+NS['ACL_UNIT'] = os.path.join(tmp, 'system', 'drouter-acl.service')
+NS['ACL_TABLE'] = 'drouter_acl'
+NS['BUILD_MODE_FILE'] = os.path.join(tmp, 'BUILD_MODE')
+
+# 抽取模板
+m = re.search(r'^ACL_UNIT_TMPL = """.*?"""', src, re.S | re.M)
+if not m:
+ raise SystemExit('未找到 ACL_UNIT_TMPL')
+exec(m.group(0), NS)
+
+for fn in ('_acl_hhmm_min', '_acl_min_hhmm', '_acl_day_bit', '_acl_shift_days',
+ '_acl_time_windows', '_acl_time_expr', '_acl_load', '_acl_save',
+ '_acl_render_nft'):
+ exec(grab(fn), NS)
+
+# _acl_deploy 里的 BUILD_MODE 路径改成临时文件(在命名空间内可见的全局名)
+_src_deploy = grab('_acl_deploy').replace(
+ "os.path.isfile('/etc/drouter/BUILD_MODE')",
+ "os.path.isfile(BUILD_MODE_FILE)")
+exec(_src_deploy, NS)
+DEPLOY = NS['_acl_deploy']
+
+# 需要 ACL_APP_GROUPS 供 _acl_render_nft 使用
+mgrp = re.search(r'^ACL_APP_GROUPS = \[.*?\n\]', src, re.S | re.M)
+exec(mgrp.group(0), NS)
+mq = re.search(r'^QOS_MARK_BASE = .*?$', src, re.M)
+if mq:
+ exec(mq.group(0), NS)
+else:
+ NS['QOS_MARK_BASE'] = 0x100
+
+fails = 0
+
+
+def chk(name, got, want):
+ global fails
+ ok = (got == want)
+ if not ok:
+ fails += 1
+ print('[%s] %-34s got=%-22s want=%s' % ('OK' if ok else 'FAIL', name, got, want))
+
+
+CFG = {
+ 'enable': True,
+ 'time_groups': [{'id': 'night', 'name': '夜间', 'days': [0, 1, 2, 3, 4, 5, 6],
+ 'start': '22:00', 'end': '06:30'}],
+ 'groups': [{'id': 'kids', 'name': '孩子设备', 'hosts': ['192.168.7.20']}],
+ 'rules': [{'name': '夜间断网', 'enable': True, 'action': 'block',
+ 'time_group': 'night', 'group': 'kids', 'apps': []}],
+}
+
+print('=== 落盘(live=False,只写文件)===')
+r = DEPLOY(CFG, live=False)
+chk('生成 nft 文件', os.path.isfile(NS['ACL_CONF']), True)
+chk('返回 applied=False', r.get('applied'), False)
+body = io.open(NS['ACL_CONF'], encoding='utf-8').read()
+chk('文件含 inet 表', 'table inet drouter_acl' in body, True)
+chk('文件含 drop', 'counter drop' in body, True)
+chk('unit 文件已生成', os.path.isfile(NS['ACL_UNIT']), True)
+unit = io.open(NS['ACL_UNIT'], encoding='utf-8').read()
+chk('unit 指向 conf', NS['ACL_CONF'] in unit, True)
+chk('unit 有 ExecStart', 'ExecStart=/usr/sbin/nft -f' in unit, True)
+chk('未执行 nft(live=False)',
+ any(a[:1] == ['nft'] and '-f' in a for a in _Sub.called), False)
+
+print('\n=== 构建保护模式(文件存在时不加载)===')
+io.open(NS['BUILD_MODE_FILE'], 'w').write('1')
+_Sub.called = []
+r2 = DEPLOY(CFG, live=True)
+chk('applied=False', r2.get('applied'), False)
+chk('提示构建保护', '构建保护' in (r2.get('msg_cn') or ''), True)
+chk('未调用 nft', any(a[:1] == ['nft'] for a in _Sub.called), False)
+os.remove(NS['BUILD_MODE_FILE'])
+
+print('\n=== 正常加载(live=True)===')
+_Sub.called = []
+r3 = DEPLOY(CFG, live=True)
+chk('applied=True', r3.get('applied'), True)
+calls = [a for a in _Sub.called if a[:1] == ['nft']]
+chk('先删旧表', calls[0][:4] == ['nft', 'delete', 'table', 'inet'], True)
+chk('再加载文件', calls[1][:2] == ['nft', '-f'], True)
+chk('加载的是 ACL_CONF', calls[1][2] == NS['ACL_CONF'], True)
+
+print('\n=== 停用状态(enable=False)===')
+_Sub.called = []
+r4 = DEPLOY(dict(CFG, enable=False), live=True)
+chk('applied=True', r4.get('applied'), True)
+body2 = io.open(NS['ACL_CONF'], encoding='utf-8').read()
+chk('停用时无 drop', 'counter drop' in body2, False)
+chk('有停用说明', '访问控制已停用' in body2, True)
+
+print('\n=== 幂等:重复部署不重复写 unit ===')
+mtime = os.path.getmtime(NS['ACL_UNIT'])
+DEPLOY(CFG, live=False)
+chk('unit 未重复写入', os.path.getmtime(NS['ACL_UNIT']), mtime)
+
+shutil.rmtree(tmp, ignore_errors=True)
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-acl-tz.py b/_dev/t-acl-tz.py
new file mode 100644
index 0000000..6de12af
--- /dev/null
+++ b/_dev/t-acl-tz.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+"""验证 ACL 时区换算与星期映射的正确性(穷举验证)。"""
+import re, sys, io
+SRC = r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py"
+src = io.open(SRC, encoding='utf-8').read()
+def grab(name):
+ m = re.search(r'^def %s\(.*?(?=\n(?:def |[A-Z_]+ *=|# ---|class ))' % re.escape(name), src, re.S | re.M)
+ return m.group(0)
+NS = {'re': re}
+class ValidateError(Exception):
+ def __init__(self, m, f=None): self.msg_cn = m
+NS['ValidateError'] = ValidateError
+NS['TZ_OFFSET_H'] = 8
+for f in ('_acl_hhmm_min', '_acl_min_hhmm', '_acl_day_bit', '_acl_shift_days', '_acl_time_windows'):
+ exec(grab(f), NS)
+W, SH = NS['_acl_time_windows'], NS['_acl_shift_days']
+NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
+
+def matches(tg, local_day, local_min):
+ """判断本地某天某分钟是否落在时间组内(模拟内核行为)。"""
+ base = tg['days']
+ start = NS['_acl_hhmm_min'](tg['start'])
+ end = NS['_acl_hhmm_min'](tg['end'])
+ inside = (start <= local_min <= end) if start <= end else (local_min >= start or local_min <= end)
+ return inside and (local_day in base)
+
+def matches_rendered(tg, local_day, local_min):
+ """用渲染出的 UTC 窗口反推:把本地时刻换算成 UTC,再判断是否命中。"""
+ utc_total = local_day * 1440 + local_min - 8 * 60
+ u_day = (utc_total // 1440) % 7
+ u_min = utc_total % 1440
+ for (s, e, delta) in W(tg):
+ days = SH(tg['days'], delta) # 与渲染器完全一致
+ if u_day in days and (s <= u_min <= e):
+ return True
+ return False
+
+cases = [
+ {'name':'上学日','days':[1,2,3,4,5],'start':'07:00','end':'17:00'},
+ {'name':'夜间','days':[0,1,2,3,4,5,6],'start':'22:00','end':'06:30'},
+ {'name':'周末','days':[0,6],'start':'00:00','end':'23:59'},
+ {'name':'单点','days':[3],'start':'12:00','end':'12:30'},
+ {'name':'早课','days':[1,2,3,4,5],'start':'00:00','end':'07:59'},
+ {'name':'晚自习','days':[1,2,3,4,5],'start':'19:30','end':'23:30'},
+ {'name':'凌晨','days':[1,2,3,4,5],'start':'00:30','end':'08:30'},
+]
+fails = 0
+for tg in cases:
+ diff = 0
+ for d in range(7):
+ for m in range(0, 1440):
+ a = matches(tg, d, m)
+ b = matches_rendered(tg, d, m)
+ if a != b:
+ diff += 1
+ if diff <= 6:
+ print(' [差异] %s %s %02d:%02d 期望=%s 渲染=%s'
+ % (tg['name'], NAMES[d], m//60, m%60, a, b))
+ status = 'OK' if diff == 0 else 'FAIL'
+ if diff: fails += 1
+ print('[%s] %-8s 穷举 7×1440 个时刻,差异 %d 个' % (status, tg['name'], diff))
+
+print('\n结果: %s' % ('时区换算与星期映射完全正确' if fails == 0 else '%d 组存在差异' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-acl.py b/_dev/t-acl.py
new file mode 100644
index 0000000..cea3c31
--- /dev/null
+++ b/_dev/t-acl.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+"""访问控制 / 家长时间组 纯逻辑测试。"""
+import re, sys, io
+SRC = r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py"
+src = io.open(SRC, encoding='utf-8').read()
+
+def grab(name):
+ m = re.search(r'^def %s\(.*?(?=\n(?:def |[A-Z_]+ *=|# ---|class ))' % re.escape(name), src, re.S | re.M)
+ if not m: raise SystemExit('NOT FOUND: ' + name)
+ return m.group(0)
+
+def grab_var(name, kind='['):
+ m = re.search(r'^%s = \%s.*?^\%s' % (re.escape(name), kind, ']' if kind == '[' else '}'), src, re.S | re.M)
+ if not m: raise SystemExit('NOT FOUND VAR: ' + name)
+ return m.group(0)
+
+NS = {'re': re, 'json': __import__('json'), 'os': __import__('os')}
+class ValidateError(Exception):
+ def __init__(self, msg_cn, field=None):
+ super().__init__(msg_cn); self.msg_cn = msg_cn; self.field = field
+NS['ValidateError'] = ValidateError
+NS['QOS_MARK_BASE'] = 0x1000
+NS['TZ_OFFSET_H'] = 8
+NS['ACL_TABLE'] = 'drouter_acl'
+for v in ('ACL_TIME_PRESETS', 'ACL_APP_GROUPS', 'ACL_GROUP_TEMPLATES', 'ACL_ACTIONS'):
+ exec(grab_var(v), NS)
+for f in ('_acl_hhmm_min', '_acl_min_hhmm', '_acl_localize_hhmm', '_acl_day_bit',
+ '_acl_shift_days', '_acl_time_windows', '_acl_time_expr', '_acl_render_nft'):
+ exec(grab(f), NS)
+
+L, D, R = NS['_acl_localize_hhmm'], NS['_acl_day_bit'], NS['_acl_render_nft']
+fails = 0
+def chk(label, got, want):
+ global fails
+ ok = got == want
+ if not ok: fails += 1
+ print('[%s] %-46s got=%-22s want=%s' % ('OK' if ok else 'FAIL', label, str(got), str(want)))
+
+print('=== 本地时间 → UTC 换算(UTC+8)===')
+chk('07:00 → 23:00 前一日', L('07:00'), '23:00')
+chk('17:00 → 09:00', L('17:00'), '09:00')
+chk('22:00 → 14:00', L('22:00'), '14:00')
+chk('06:30 → 22:30', L('06:30'), '22:30')
+chk('00:00 → 16:00', L('00:00'), '16:00')
+chk('08:00 → 00:00', L('08:00'), '00:00')
+chk('23:59 → 15:59', L('23:59'), '15:59')
+
+print('\n=== 星期集合 ===')
+chk('工作日', D([1,2,3,4,5]), ['Mon','Tue','Wed','Thu','Fri'])
+chk('周末', D([0,6]), ['Sun','Sat'])
+chk('每天', D([0,1,2,3,4,5,6]), ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'])
+
+print('\n=== 时间表达式(含跨零点)===')
+NS['_acl_time_expr'] = NS['_acl_time_expr']
+E = NS['_acl_time_expr']
+chk('上学日白天', 'meta day' in E({'days':[1,2,3,4,5],'start':'07:00','end':'17:00'}), True)
+e_night = E({'days':[0,1,2,3,4,5,6],'start':'22:00','end':'06:30'})
+# 跨零点预览应拆成两段(段间用「或」连接),且带本地时间说明
+chk('夜间跨零点拆两段', ' 或 ' in e_night, True)
+chk('夜间预览含本地时间说明', '本地 22:00–06:30' in e_night, True)
+print('\n=== 渲染:停用状态 ===')
+off = R({'enable': False, 'time_groups': [], 'groups': [], 'rules': []})
+chk('停用时无 drop', 'drop' not in off, True)
+chk('停用时有计数说明', '访问控制已停用' in off, True)
+
+print('\n=== 渲染:完整规则 ===')
+d = {
+ 'enable': True,
+ 'time_groups': [
+ {'id':'night','name':'夜间睡眠','days':[0,1,2,3,4,5,6],'start':'22:00','end':'06:30'},
+ {'id':'school','name':'上学日','days':[1,2,3,4,5],'start':'07:00','end':'17:00'},
+ ],
+ 'groups': [
+ {'id':'kids','name':'孩子的设备','hosts':['192.168.7.20','192.168.7.21','2408:8207:1234::20']},
+ ],
+ 'rules': [
+ {'name':'夜间断娱乐','enable':True,'action':'block','time_group':'night','group':'kids','apps':['social','game','video']},
+ {'name':'上学日禁游戏','enable':True,'action':'block','time_group':'school','group':'kids','apps':['game']},
+ {'name':'只记录','enable':True,'action':'log','time_group':'school','group':'kids','apps':['video']},
+ {'name':'停用规则','enable':False,'action':'block','time_group':'night','group':'kids','apps':['p2p']},
+ ],
+}
+out = R(d)
+print(out)
+chk('生成 inet 表', 'table inet drouter_acl' in out, True)
+chk('forward 链优先级 -10', 'priority -10' in out, True)
+chk('含 IPv4 组地址', '192.168.7.20' in out, True)
+chk('含 IPv6 组地址', '2408:8207:1234::20' in out, True)
+chk('含 drop 动作', 'counter drop' in out, True)
+chk('含应用分类注释', '社交娱乐' in out and '游戏' in out, True)
+chk('非跨零点规则未误判', out.count('本地时间跨零点') == 1, True)
+chk('本地时间显示正确', '本地 07:00–17:00' in out, True)
+# 上学日本地 07:00-17:00 → UTC 23:00-23:59(前一日,delta=-1)+ 00:00-09:00(当日,delta=0)
+chk('上学日窗口 23:00-23:59', 'meta hour "23:00"-"23:59"' in out, True)
+chk('上学日窗口 00:00-09:00', 'meta hour "00:00"-"09:00"' in out, True)
+# 夜间本地 22:00-06:30 跨零点 → 本地 22:00-23:59=UTC 14:00-15:59;本地 00:00-06:30=UTC 16:00-22:30
+chk('夜间窗口拆两段', 'meta hour "14:00"-"15:59"' in out and 'meta hour "16:00"-"22:30"' in out, True)
+chk('停用规则不生成', 'P2P 下载' not in out, True)
+chk('跨零点已拆分提示', '时间跨零点,已自动拆分' in out, True)
+# 2 条 block 规则各拆 2 段 → 4 次 drop;log 规则不带 drop
+chk('仅记录规则无 drop', out.count('counter drop') == 4, True)
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-audit.py b/_dev/t-audit.py
new file mode 100644
index 0000000..a0251a6
--- /dev/null
+++ b/_dev/t-audit.py
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""全项目静态审计(#6):专抓「编译得过、跑起来才炸」的低级错误。
+
+四类检查,全部基于源码静态分析,不需要目标机:
+ 1. web.py 里 self.helper('xxx') 的动作名必须都在 helper 的 ACTIONS 里
+ —— 写错一个字母就是「接口返回 ok 但实际报未知操作」;
+ 2. app.js 里 api('/api/xxx') 的路径必须在 web.py 的路由里有分支
+ —— 漏了就是 404,前端拿到 HTML 报错页,表现为「点了没反应」;
+ 3. 同一个顶层名字(函数 / 常量)不能重复定义
+ —— 后定义的静默覆盖前面的,最难查的一类 bug;
+ 4. 前端引用的 CSS 类必须在 app.css 里有定义(只查我们自己新引入的类)。
+"""
+import ast
+import io
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.join(HERE, '..')
+
+fails = 0
+
+
+def chk(label, cond, extra=''):
+ global fails
+ if not cond:
+ fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+
+def read(rel):
+ with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
+ return f.read()
+
+
+HELPER = read('backend/drouter-helper.py')
+WEB = read('backend/drouter-web.py')
+APP = read('web/app.js')
+CSS = read('web/app.css')
+
+# ---------------------------------------------------------------- 1. 动作名
+h_tree = ast.parse(HELPER)
+ACTIONS = set()
+for node in ast.walk(h_tree):
+ if isinstance(node, ast.Assign):
+ for t in node.targets:
+ if isinstance(t, ast.Name) and t.id == 'ACTIONS' and isinstance(node.value, ast.Dict):
+ for k in node.value.keys:
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
+ ACTIONS.add(k.value)
+chk('helper 至少注册了 60 个动作', len(ACTIONS) >= 60, '实际 %d 个' % len(ACTIONS))
+
+used = set(re.findall(r"self\.helper\(\s*'([^']+)'", WEB))
+used |= set(re.findall(r'helper\(\s*"([^"]+)"', WEB))
+bad = sorted(a for a in used if a not in ACTIONS)
+chk('web.py 调用的动作全部已注册', not bad, '→ 未注册:%s' % bad)
+
+# 反向:注册了但从来没人调(不算错,只是提示,避免误当失败)
+unused = sorted(a for a in ACTIONS if a not in used)
+print(' 提示:已注册但 web.py 未直接调用的动作 %d 个(可能由其它入口使用)'
+ % len(unused))
+
+# ---------------------------------------------------------------- 2. 前端路径
+# web.py 的路由写法有三种:p == '/api/x'、p in ('/api/x','/api/y')、
+# p.startswith('/api/x'),还有一张 readings 字典把路径映射到 action。
+# 只用正则会漏掉字典键(/api/ifaces 等 13 个路径就是这么被误报的),
+# 所以这里改用 AST 精确收集。
+routes, prefixes = set(), set()
+w_tree = ast.parse(WEB)
+for node in ast.walk(w_tree):
+ # 任何字典里以 /api 开头的键(readings 路由表等)
+ if isinstance(node, ast.Dict):
+ for k in node.keys:
+ if isinstance(k, ast.Constant) and isinstance(k.value, str) \
+ and k.value.startswith('/api'):
+ routes.add(k.value)
+ # 元组/列表里的路径字面量(p in ('/api/a','/api/b'))
+ if isinstance(node, (ast.Tuple, ast.List)):
+ for e in node.elts:
+ if isinstance(e, ast.Constant) and isinstance(e.value, str) \
+ and e.value.startswith('/api') and '*' not in e.value:
+ routes.add(e.value)
+ # p == '/api/x' / p in ... 的比较
+ if isinstance(node, ast.Compare) and isinstance(node.left, ast.Name) \
+ and node.left.id == 'p':
+ for c in node.comparators:
+ if isinstance(c, ast.Constant) and isinstance(c.value, str) \
+ and c.value.startswith('/api'):
+ routes.add(c.value)
+ # p.startswith('/api/x')
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \
+ and node.func.attr == 'startswith':
+ for a in node.args:
+ if isinstance(a, ast.Constant) and isinstance(a.value, str) \
+ and a.value.startswith('/api'):
+ prefixes.add(a.value)
+# 前端实际请求
+paths = set(re.findall(r"api\(\s*'([^']+)'", APP))
+paths |= set(re.findall(r'api\(\s*`([^`]+)`', APP))
+paths = set(p for p in paths if p.startswith('/api'))
+
+missing = []
+for p in sorted(paths):
+ if p in routes:
+ continue
+ # 前缀匹配(如 /api/webshell/connect 由 startswith('/api/webshell') 处理)
+ if any(p.startswith(pre) for pre in prefixes if pre.startswith('/api')):
+ continue
+ # 形如 '/api/xxx?op=yyy' 或带模板变量的,只看主干
+ base = p.split('?')[0]
+ if base in routes:
+ continue
+ if any(base.startswith(pre) for pre in prefixes if pre.startswith('/api')):
+ continue
+ missing.append(p)
+chk('前端请求的接口路径都有后端路由', not missing, '→ 缺少路由:%s' % missing)
+
+# ---------------------------------------------------------------- 3. 重复定义
+def top_names(src, lang):
+ out = {}
+ if lang == 'py':
+ tree = ast.parse(src)
+ for node in tree.body:
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ out.setdefault(node.name, []).append(node.lineno)
+ elif isinstance(node, ast.Assign):
+ for t in node.targets:
+ if isinstance(t, ast.Name):
+ out.setdefault(t.id, []).append(node.lineno)
+ else:
+ # JS:顶层 function 声明 + const/let/var
+ for m in re.finditer(r'^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(',
+ src, re.M):
+ out.setdefault(m.group(1), []).append(src[:m.start()].count('\n') + 1)
+ for m in re.finditer(r'^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=',
+ src, re.M):
+ out.setdefault(m.group(1), []).append(src[:m.start()].count('\n') + 1)
+ return out
+
+
+for rel, src, lang in (('backend/drouter-helper.py', HELPER, 'py'),
+ ('backend/drouter-web.py', WEB, 'py'),
+ ('web/app.js', APP, 'js')):
+ names = top_names(src, lang)
+ dup = {k: v for k, v in names.items() if len(v) > 1}
+ # 白名单:JS 里允许用 let 再赋值覆盖;Python 里 __main__ 守卫下的同名不算
+ chk('%s 无重复顶层定义' % rel, not dup,
+ '→ %s' % dict(list(dup.items())[:5]))
+
+# ---------------------------------------------------------------- 4. CSS 类
+# 只查我们新引入的、带 t- 前缀的终端样式类
+for cls in ('t-b', 't-dim', 't-i', 't-u', 'tcur', 'tl', 'ws-capture',
+ 'term-screen', 'ws-probe'):
+ chk('app.css 定义了 .%s' % cls, ('.' + cls + '{') in CSS or ('.' + cls + ' ') in CSS)
+
+# ---------------------------------------------------------------- 5. 外部命令 ↔ 依赖清单
+# helper 里 sh(['xxx', ...]) 调的每个外部命令,都应该在 DEPS 里有对应条目。
+# 漏了的表现最阴:依赖自检页一片绿,但某功能因为命令不存在静默失败。
+BASE_CMDS = {
+ # coreutils / util-linux / systemd / 账号管理:任何 Linux 都自带,不必登记
+ 'sh', 'bash', 'cat', 'ls', 'rm', 'cp', 'mv', 'date', 'hostname', 'uname',
+ 'mount', 'umount', 'df', 'du', 'tar', 'which', 'chown', 'chmod', 'mkdir',
+ 'sleep', 'echo', 'env', 'timeout', 'dpkg-query', 'apt', 'apt-get',
+ 'apt-cache', 'apt-mark', 'systemctl', 'systemd-detect-virt', 'modinfo',
+ 'modprobe', 'ss', 'pgrep', 'useradd', 'userdel', 'usermod', 'chpasswd',
+ 'kill', 'ip', 'getent', 'sed', 'grep', 'awk', 'head', 'tail', 'wc', 'sort',
+ 'id', 'stat', 'readlink', 'python3', 'find', 'touch', 'tr', 'cut', 'sync',
+ 'blockdev', 'partprobe', 'dmesg', 'free',
+}
+# 同一软件包提供的兄弟命令:DEPS 用其中一个登记即可
+# 同包不同命令:DEPS 里登记的是该包的一个代表命令(如 procps 用 ps),
+# 代码里却可能调用同包的另一个命令(pkill)。按命令名比对会误报,这里做映射。
+PKG_ALIAS = {
+ 'chronyc': 'chronyd', 'docker-compose': 'docker', 'smbpasswd': 'smbd',
+ 'pkill': 'ps', 'pgrep': 'ps', 'free': 'ps', 'top': 'ps',
+ 'ss': 'ip', 'tc': 'ip', 'devlink': 'ip',
+ 'mount': 'lsblk', 'umount': 'lsblk', 'blkid': 'lsblk', 'findmnt': 'lsblk',
+ 'modinfo': 'modprobe', 'lsmod': 'modprobe', 'depmod': 'modprobe',
+ 'du': 'df', 'ls': 'df', 'stat': 'df',
+ 'fsck.exfat': 'mkfs.exfat', 'fsck.f2fs': 'mkfs.f2fs', 'fsck.vfat': 'mkfs.vfat',
+ 'xfs_repair': 'mkfs.xfs', 'btrfs': 'mkfs.btrfs', 'resize2fs': 'mkfs.ext4',
+ 'snmpwalk': 'snmpd', 'snmpget': 'snmpd',
+ # 「内核转发与加速」用它即时生效 IP 转发 / BBR(同包不同命令)
+ 'sysctl': 'ps',
+ # 打印服务:这几个命令全由 cups / cups-client 提供,DEPS 里以 cupsd 为代表登记
+ 'lpadmin': 'cupsd', 'lpstat': 'cupsd', 'lpinfo': 'cupsd',
+ 'lp': 'cupsd', 'cancel': 'cupsd', 'cupsctl': 'cupsd',
+ 'cupsaccept': 'cupsd', 'cupsreject': 'cupsd',
+}
+called = {}
+for node in ast.walk(h_tree):
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
+ and node.func.id in ('sh', 'sh_out', 'run'):
+ if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
+ elts = node.args[0].elts
+ if elts and isinstance(elts[0], ast.Constant) \
+ and isinstance(elts[0].value, str):
+ c = elts[0].value
+ called[c] = called.get(c, 0) + 1
+deps_node = None
+for node in h_tree.body:
+ if isinstance(node, ast.Assign) and node.value.__class__.__name__ == 'List':
+ for t in node.targets:
+ if isinstance(t, ast.Name) and t.id == 'DEPS':
+ deps_node = node.value
+dep_targets = set()
+for row in deps_node.elts:
+ if isinstance(row, (ast.Tuple, ast.List)) and len(row.elts) >= 4:
+ kind = row.elts[2]
+ tgt = row.elts[3]
+ if isinstance(kind, ast.Constant) and kind.value == 'cmd' \
+ and isinstance(tgt, ast.Constant) and isinstance(tgt.value, str):
+ for c in tgt.value.split('|'):
+ dep_targets.add(c.strip())
+unknown = sorted(c for c in called
+ if c not in dep_targets and c not in BASE_CMDS
+ and PKG_ALIAS.get(c) not in dep_targets)
+chk('helper 调用的外部命令都已登记到依赖清单', not unknown,
+ '→ 未登记:%s' % [(c, called[c]) for c in unknown])
+
+# ---------------------------------------------------------------- 6. 已知易错点
+chk('helper 仍保留 PATH 归一化(/usr/sbin)', '_ensure_sbin_path' in HELPER)
+DEPLOY = read('scripts/deploy.sh')
+chk('Web 终端守护有独立 systemd 单元', 'drouter-shelld' in DEPLOY)
+chk('常驻执行守护有独立 systemd 单元', 'drouter-helpd' in DEPLOY)
+chk('部署脚本会校验 9 个后端模块',
+ all(m in DEPLOY for m in ('render.py', 'drouter-helper.py', 'drouter-web.py',
+ 'drouter-logd.py', 'drouter-snapshotd.py',
+ 'drouter-rescue.py', 'drouter-shelld.py',
+ 'drouter-helpd.py', 'theme.py')))
+# 守护把 helper 常驻在内存:只 enable --now 的话跑的还是上一版代码
+chk('部署时会 restart 常驻执行守护(否则旧代码不生效)',
+ 'systemctl restart drouter-helpd' in DEPLOY)
+# 两个守护共用 /run/drouter。用 RuntimeDirectory 的话,systemd 会在最后一个
+# 声明它的单元停止时把整个目录重建,顺手删掉另一个守护的 socket ——
+# shelld 依旧 active 但 socket 没了,终端直接废。所以必须各自 mkdir。
+# 只匹配真正的配置行:单元注释里也会提到 RuntimeDirectory 这个词
+chk('两个守护都不再用 RuntimeDirectory(否则互相删 socket)',
+ not re.search(r'^RuntimeDirectory=', DEPLOY, re.M))
+chk('部署后会校验终端 socket 真的存在',
+ '-S /run/drouter/shell.sock' in DEPLOY)
+SHELLD = read('backend/drouter-shelld.py')
+chk('shelld 自己创建 /run/drouter 并设 0755',
+ 'os.makedirs(d, exist_ok=True)' in SHELLD and 'os.chmod(d, 0o755)' in SHELLD)
+chk('helper 暴露了 run_action(供守护复用同一套分发)',
+ 'def run_action(' in HELPER)
+chk('前端入口破缓存版本号仍在', '?v=' in APP or 'cachebust' in APP.lower()
+ or '?v=' in read('web/index.html'))
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-ca.py b/_dev/t-ca.py
new file mode 100644
index 0000000..726638e
--- /dev/null
+++ b/_dev/t-ca.py
@@ -0,0 +1,693 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""CA 证书管理 + SSL 测试(#11):静态契约 + 纯函数 + 真 openssl 全流程单测。
+
+盯六件事:
+ ① 「部署」是这一块唯一能把用户关在门外的动作 —— 证书换上去、Web 起不来,
+ 最近的入口就是这个页面。所以部署前必须过四道闸(能解析 / 有私钥 /
+ 公钥配对 / 有效期覆盖当下),部署后必须握手比对指纹,不对就自动还原;
+ ② 有效期天数不能写成 int(x or 默认) —— 0 是 falsy 会被当「没填」;
+ ③ SAN 与 CN 要挡住逗号注入:openssl 的 -subj / subjectAltName 里出现逗号
+ 会让整条配置解析错位,签出来一张谁都不认的证书;
+ ④ 证书 id 必须过白名单才能拼路径,不能让调用方塞 ../../ 进来;
+ ⑤ 导入要挡住「类型和内容不符」(拿 CA 当服务器证书)、「私钥与证书不配对」;
+ ⑥ 不碰安全红线:5900、nft、dnsmasq、默认路由。
+
+本机 Git Bash 自带 OpenSSL 3.5.7(与目标机同版本),所以这里的建 CA / 签发 /
+握手全部是真的 openssl 调用,不是打桩 —— 参数写错了这里就会真的失败。
+"""
+import ast
+import io
+import os
+import re
+import ssl
+import sys
+import json
+import time
+import shutil
+import socket
+import tempfile
+import threading
+import subprocess
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+WEB_SRC = io.open(WEB, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+TREE = ast.parse(HELPER_SRC)
+
+
+def node(name):
+ for n in TREE.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ return n
+ if isinstance(t, ast.Tuple):
+ for e in t.elts:
+ if isinstance(e, ast.Name) and e.id == name:
+ return n
+ elif isinstance(n, ast.FunctionDef) and n.name == name:
+ return n
+ return None
+
+
+def build(names, ns=None):
+ ns = ns if ns is not None else {}
+ body = []
+ for nm in names:
+ n = node(nm)
+ if n is None:
+ raise AssertionError('源码里找不到 %s' % nm)
+ body.append(n)
+ mod = ast.Module(body=body, type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ return ns
+
+
+# ---------------------------------------------------------------- 测试替身
+BUILD_MODE = [False]
+CALLS = []
+IS_WIN = (os.name == 'nt')
+
+
+def real_sh(cmd, timeout=15, input_data=None, cwd=None, env=None):
+ """真正跑命令。两处替身:
+ · Git Bash 没有 coreutils 的 timeout,替它剥掉这一层;
+ · Windows 上没有 systemctl —— 部署流程里它是「重启 Web 服务」这一步,
+ 这里默认当作成功,需要测失败分支时由各用例单独替换 NS['sh']。
+ """
+ a = list(cmd)
+ if a and a[0] == 'timeout' and len(a) > 2:
+ a = a[2:]
+ if a[:2] == ['systemctl', 'restart']:
+ CALLS.append(a)
+ return (0, '', '')
+ CALLS.append(a)
+ kw = dict(timeout=timeout, capture_output=True, text=True,
+ input=input_data, errors='replace')
+ if cwd:
+ kw['cwd'] = cwd
+ if env:
+ kw['env'] = env
+ try:
+ p = subprocess.run(a, **kw)
+ return p.returncode, (p.stdout or '').strip(), (p.stderr or '').strip()
+ except subprocess.TimeoutExpired:
+ return 124, '', '命令执行超时(%ss)' % timeout
+ except FileNotFoundError:
+ return 127, '', '命令不存在:%s' % a[0]
+ except NotADirectoryError:
+ return 126, '', '工作目录不存在:%s' % cwd
+ except PermissionError as e:
+ return 126, '', '权限不足:%s' % e
+
+
+def _ok(data=None, msg='ok', code='OK'):
+ return {'ok': True, 'msg_cn': msg, 'code': code, 'data': data or {}}
+
+
+def _fail(msg, code='ERR', data=None):
+ return {'ok': False, 'msg_cn': msg, 'code': code, 'data': data or {}}
+
+
+def _log(*a, **k):
+ return None
+
+
+def _in_build_mode():
+ return BUILD_MODE[0]
+
+
+def _print_lan_ip():
+ return '192.168.7.3'
+
+
+def cleanup_tmp(*paths):
+ for p in paths:
+ if not p:
+ continue
+ try:
+ os.unlink(p)
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------- 命名空间
+CA_FUNCS = [
+ 'CA_DIR', 'CA_INDEX', 'CA_BACKUP', 'CA_LAST', 'CA_DIR_MODE', 'CA_KEY_MODE',
+ 'CA_CRT_MODE', 'WEB_CERT_DIR', 'WEB_CRT', 'WEB_KEY', 'WEB_OWNER',
+ 'WEB_SERVICE', 'WEB_PORT_FILE', 'WEB_PORT_DEFAULT', 'CA_KEY_TYPES',
+ 'CA_DAYS_MIN', 'CA_DAYS_MAX', 'CA_CA_DAYS_DEFAULT', 'CA_SRV_DAYS_DEFAULT',
+ 'CA_EXPIRE_WARN', 'CA_KINDS', 'CA_KIND_CN', 'CA_SAN_MAX', 'SSL_PORT_MIN',
+ 'SSL_PORT_MAX', 'SSL_HOST_MAX', 'SSL_TIMEOUT_DEFAULT', 'SSL_TIMEOUT_MAX',
+ 'SSL_VERIFY_CN',
+ '_ca_rand', '_ca_now', '_ca_id', '_ca_ensure_dir', '_ca_index_load',
+ '_ca_index_save', '_ca_paths', '_ca_find', '_ca_days', '_ca_x509_info',
+ '_ca_key_match', '_ca_web_port', '_ca_local_ips', '_ca_norm_days',
+ '_ca_norm_san', '_ca_status', '_ca_openssl_version', '_ca_last_load',
+ '_ca_last_save', '_ca_gen_key', '_ca_cn_ok', '_ca_subj', '_ca_new_ca_op',
+ '_ca_sign_op', '_ca_csr_op', '_ca_import_op', '_ca_delete_op',
+ '_ca_backup_web', '_ca_install_web', '_ca_probe_web', '_ca_deploy_op',
+ '_ca_restore_op', '_ca_host_match', '_ca_san_entry', '_ca_handshake', '_ca_parse_chain',
+ '_ca_ssltest_op', '_ca_is_ip', 'act_ca',
+]
+
+NS = build(CA_FUNCS, {
+ 'os': os, 're': re, 'json': json, 'time': time, 'shutil': shutil,
+ 'tempfile': tempfile, 'socket': socket, 'ssl': ssl,
+ 'ipaddress': __import__('ipaddress'),
+ 'datetime': __import__('datetime').datetime,
+ 'sh': real_sh, 'ok': _ok, 'fail': _fail, 'log': _log,
+ 'in_build_mode': _in_build_mode, 'cleanup_tmp': cleanup_tmp,
+ '_print_lan_ip': _print_lan_ip,
+})
+
+# 把所有落盘路径重定向到临时目录:这是本模块唯一会写到真实路径的地方,
+# 不重定向就会在 Windows 上真的去碰 C:\etc\drouter。
+TMP = tempfile.mkdtemp(prefix='t-ca-')
+NS['CA_DIR'] = os.path.join(TMP, 'ca')
+NS['CA_INDEX'] = os.path.join(NS['CA_DIR'], 'index.json')
+NS['CA_BACKUP'] = os.path.join(NS['CA_DIR'], 'backup')
+NS['CA_LAST'] = os.path.join(NS['CA_DIR'], 'last-deploy.json')
+NS['WEB_CERT_DIR'] = os.path.join(TMP, 'certs')
+NS['WEB_CRT'] = os.path.join(NS['WEB_CERT_DIR'], 'server.crt')
+NS['WEB_KEY'] = os.path.join(NS['WEB_CERT_DIR'], 'server.key')
+NS['WEB_PORT_FILE'] = os.path.join(TMP, 'web-port')
+NS['WEB_SERVICE'] = 'drouter-web.service'
+
+
+def perm_ok(path, want):
+ if IS_WIN:
+ return True
+ return (os.stat(path).st_mode & 0o777) == want
+
+
+# ================================================================ ① 常量
+print('\n--- 常量与默认值 ---')
+chk('密钥文件 0600', NS['CA_KEY_MODE'] == 0o600)
+chk('证书文件 0644', NS['CA_CRT_MODE'] == 0o644)
+chk('证书库目录 0700(里面有私钥)', NS['CA_DIR_MODE'] == 0o700)
+chk('Web 私钥归 drouter(服务以它运行)', NS['WEB_OWNER'] == 'drouter')
+chk('服务器证书默认 825 天', NS['CA_SRV_DAYS_DEFAULT'] == 825)
+chk('自签 CA 默认 3650 天', NS['CA_CA_DAYS_DEFAULT'] == 3650)
+chk('到期提醒阈值 30 天', NS['CA_EXPIRE_WARN'] == 30)
+chk('默认私钥算法是 RSA 2048', NS['CA_KEY_TYPES'][0][0] == 'rsa:2048')
+chk('三种类型齐全', tuple(sorted(NS['CA_KINDS'])) == ('ca', 'csr', 'server'))
+chk('18=自签名有人话解释', '自签名' in NS['SSL_VERIFY_CN'].get(18, ''))
+chk('62=主机名不匹配有人话解释', '主机名' in NS['SSL_VERIFY_CN'].get(62, ''))
+chk('10=过期有人话解释', '过期' in NS['SSL_VERIFY_CN'].get(10, ''))
+# 这三个编号第一版写错了:21 是「验证不了叶子签名」(私有 CA 签发时的正常返回),
+# 24 才是「链里有证书不是 CA 证书」,23 才是吊销。
+chk('21=私有 CA 找不到颁发者', '找不到颁发者' in NS['SSL_VERIFY_CN'].get(21, ''))
+chk('24=链里有一张不是 CA', '不是 CA' in NS['SSL_VERIFY_CN'].get(24, ''))
+chk('23=吊销', '吊销' in NS['SSL_VERIFY_CN'].get(23, ''))
+
+# ================================================================ ② 纯函数
+print('\n--- 纯函数 ---')
+nd = NS['_ca_norm_days']
+chk('天数 None → 默认', nd(None, 825) == 825)
+chk('天数 "" → 默认', nd('', 825) == 825)
+chk('天数 0 不被当「没填」→ 钳到下限 1', nd(0, 825) == NS['CA_DAYS_MIN'],
+ '(写成 int(x or 825) 就会变成 825)')
+chk('天数 -5 钳到下限', nd(-5, 825) == NS['CA_DAYS_MIN'])
+chk('天数 999999 钳到上限', nd(999999, 825) == NS['CA_DAYS_MAX'])
+chk('天数 "abc" → 默认', nd('abc', 825) == 825)
+chk('天数 "90" 正常', nd('90', 825) == 90)
+
+ns_ = NS['_ca_norm_san']
+chk('SAN 域名+IP', ns_(['a.com'], ['1.2.3.4']) == 'DNS:a.com,IP:1.2.3.4')
+chk('SAN 空 → 空串', ns_([], []) == '')
+chk('SAN 过滤非法 IP', ns_([], ['999.1.1.1']) == '')
+chk('SAN 过滤含逗号的域名(防注入)',
+ ns_(['a.com,DNS:evil.com'], []) == '', '(逗号会错位整条 subjectAltName)')
+chk('SAN 过滤含空格域名', ns_(['a b.com'], []) == '')
+chk('SAN 过滤超长域名', ns_(['a' * 300], []) == '')
+chk('SAN 支持通配符', ns_(['*.home'], []) == 'DNS:*.home')
+chk('SAN 支持 IPv6', ns_([], ['2408::1']) == 'IP:2408::1')
+chk('SAN 去重上限生效', len(ns_(['a%d.com' % i for i in range(50)], []).split(','))
+ == NS['CA_SAN_MAX'])
+chk('SAN 忽略空条目', ns_(['', ' ', 'a.com'], []) == 'DNS:a.com')
+
+cn = NS['_ca_cn_ok']
+chk('CN 正常通过', cn('drouter.lan') is True)
+chk('CN 空被拒', cn('') is False)
+chk('CN 含逗号被拒', cn('a,O=b') is False)
+chk('CN 含等号被拒', cn('a=b') is False)
+chk('CN 含分号被拒', cn('a;b') is False)
+chk('CN 超长被拒', cn('a' * 65) is False)
+
+hm = NS['_ca_host_match']
+chk('主机名精确匹配', hm('a.com', ['DNS:a.com']) is True)
+chk('主机名大小写不敏感', hm('A.COM', ['DNS:a.com']) is True)
+chk('主机名不匹配', hm('b.com', ['DNS:a.com']) is False)
+chk('通配匹配二级', hm('x.a.com', ['DNS:*.a.com']) is True)
+chk('通配不匹配一级', hm('a.com', ['DNS:*.a.com']) is False)
+chk('通配最多一级', hm('y.x.a.com', ['DNS:*.a.com']) is False)
+chk('SAN 带 IP 前缀也能比', hm('a.com', ['DNS:a.com', 'IP:1.2.3.4']) is True)
+chk('空 SAN 不匹配', hm('a.com', []) is False)
+
+dy = NS['_ca_days']
+chk('解析 notAfter= 前缀', dy('notAfter=Sep 24 12:52:20 2036 GMT') is not None)
+chk('解析裸日期', dy('Sep 24 12:52:20 2036 GMT') is not None)
+chk('未来日期为正数', (dy('notAfter=Jan 1 00:00:00 2099 GMT') or 0) > 0)
+chk('过去日期为负数', (dy('notAfter=Jan 1 00:00:00 2000 GMT') or 0) < 0)
+chk('垃圾输入返回 None(不假报 0)', dy('完全不是日期') is None)
+chk('空输入返回 None', dy('') is None)
+
+ii = NS['_ca_is_ip']
+chk('IPv4 识别', ii('1.2.3.4') is True)
+chk('IPv6 识别', ii('2408::1') is True)
+chk('域名不是 IP', ii('a.com') is False)
+
+pc = NS['_ca_parse_chain']
+two = ('-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n'
+ '-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n')
+chk('切出两张证书', len(pc(two)) == 2)
+chk('切出的内容保留首尾行',
+ pc(two)[0].startswith('-----BEGIN') and pc(two)[0].rstrip().endswith('-----END CERTIFICATE-----'))
+chk('无证书时返回空', pc('nothing here') == [])
+
+# ================================================================ ③ 路径白名单
+print('\n--- 证书 id 与路径白名单 ---')
+pp = NS['_ca_paths']
+chk('合法 id 可拼路径', pp(NS['_ca_id']()) is not None)
+chk('空 id 被拒', pp('') is None)
+chk('路径穿越被拒', pp('../../../../etc/passwd') is None)
+chk('含斜杠被拒', pp('c/sub') is None)
+chk('含非 hex 被拒', pp('czzzzzzzzzzzzz') is None)
+
+# ================================================================ ④ 索引容错
+print('\n--- 索引容错 ---')
+os.makedirs(NS['CA_DIR'], exist_ok=True)
+with io.open(NS['CA_INDEX'], 'w', encoding='utf-8') as f:
+ f.write('{ 这不是 json')
+items, broken = NS['_ca_index_load']()
+chk('坏索引不抛异常', items == [])
+chk('坏索引被标记', broken is True)
+with io.open(NS['CA_INDEX'], 'w', encoding='utf-8') as f:
+ json.dump({'items': [{'id': 'c20260101000000abcd', 'kind': 'ca'}]}, f)
+items, broken = NS['_ca_index_load']()
+chk('兼容 {"items":[...]} 老格式', len(items) == 1 and broken is False)
+with io.open(NS['CA_INDEX'], 'w', encoding='utf-8') as f:
+ json.dump('字符串', f)
+items, broken = NS['_ca_index_load']()
+chk('索引是字符串时也标记损坏', broken is True)
+NS['_ca_index_save']([{'id': 'c20260101000000abcd', 'kind': 'ca'}])
+items, broken = NS['_ca_index_load']()
+chk('索引写入后能读回', len(items) == 1)
+NS['_ca_index_save']([])
+
+# ================================================================ ⑤ 真 openssl:建 CA
+print('\n--- 建 CA(真 openssl)---')
+r = NS['act_ca']({'op': 'new_ca', 'name': '家里的根证书', 'cn': 'drouter-ca',
+ 'days': 3650, 'key_type': 'rsa:2048'})
+chk('创建 CA 成功', r['ok'], r.get('msg_cn'))
+CA_ID = (r.get('data') or {}).get('id') if r['ok'] else None
+chk('返回了 id', bool(CA_ID))
+ca_paths = NS['_ca_paths'](CA_ID) if CA_ID else {}
+chk('CA 证书文件已生成', os.path.isfile(ca_paths.get('crt', '')))
+chk('CA 私钥文件已生成', os.path.isfile(ca_paths.get('key', '')))
+chk('CA 私钥权限 0600', perm_ok(ca_paths['key'], 0o600) if ca_paths.get('key') else False)
+info = NS['_ca_x509_info'](ca_paths['crt']) if ca_paths.get('crt') else {'err': 'x'}
+chk('CA 被识别为 CA(CA:TRUE)', info.get('is_ca') is True)
+chk('CA 主题含 CN=drouter-ca', 'drouter-ca' in (info.get('subject') or ''))
+chk('CA 是自签(主题=颁发者)', (info.get('subject') == info.get('issuer')))
+chk('CA 剩余天数接近 3650', (info.get('days_left') or 0) > 3600)
+chk('CA 进了证书库', len(NS['_ca_index_load']()[0]) == 1)
+r_bad = NS['act_ca']({'op': 'new_ca', 'cn': 'a,b', 'days': 100})
+chk('CN 含逗号被拒(防 -subj 错位)', (not r_bad['ok']) and r_bad['code'] == 'BAD_CN',
+ r_bad.get('msg_cn'))
+r_bad2 = NS['act_ca']({'op': 'new_ca', 'cn': ''})
+chk('CN 空被拒', (not r_bad2['ok']) and r_bad2['code'] == 'BAD_CN')
+
+print('\n--- 签发服务器证书(真 openssl)---')
+r = NS['act_ca']({'op': 'sign', 'ca_id': CA_ID, 'cn': 'drouter.lan',
+ 'san_dns': ['drouter.lan', 'router.home'],
+ 'san_ip': ['192.168.7.3', '127.0.0.1'],
+ 'days': 825, 'key_type': 'rsa:2048'})
+chk('签发成功', r['ok'], r.get('msg_cn'))
+SRV_ID = (r.get('data') or {}).get('id') if r['ok'] else None
+sp = NS['_ca_paths'](SRV_ID) if SRV_ID else {}
+si = NS['_ca_x509_info'](sp['crt']) if sp.get('crt') else {'err': 'x'}
+chk('服务器证书不是 CA', si.get('is_ca') is False)
+chk('SAN 含 drouter.lan', any('drouter.lan' in x for x in (si.get('san') or [])))
+chk('SAN 含 IP 192.168.7.3', any('192.168.7.3' in x for x in (si.get('san') or [])))
+chk('SAN 含 IP 127.0.0.1', any('127.0.0.1' in x for x in (si.get('san') or [])))
+chk('主题含 CN=drouter.lan', 'drouter.lan' in (si.get('subject') or ''))
+chk('剩余天数接近 825', 800 <= (si.get('days_left') or 0) <= 825)
+m, why = NS['_ca_key_match'](sp['crt'], sp['key'])
+chk('证书与私钥配对', m is True, why)
+r_no_san = NS['act_ca']({'op': 'sign', 'ca_id': CA_ID, 'cn': 'x',
+ 'san_dns': [], 'san_ip': []})
+chk('不填 SAN 被拒', (not r_no_san['ok']) and r_no_san['code'] == 'NO_SAN')
+r_bad_ca = NS['act_ca']({'op': 'sign', 'ca_id': 'c99999999999999', 'cn': 'x',
+ 'san_dns': ['a.com']})
+chk('CA 不存在被拒', (not r_bad_ca['ok']) and r_bad_ca['code'] == 'NO_CA')
+r_srv_as_ca = NS['act_ca']({'op': 'sign', 'ca_id': SRV_ID, 'cn': 'x',
+ 'san_dns': ['a.com']})
+chk('拿服务器证书当 CA 被拒', (not r_srv_as_ca['ok']) and r_srv_as_ca['code'] == 'NO_CA')
+
+print('\n--- CSR(真 openssl)---')
+r = NS['act_ca']({'op': 'csr', 'cn': 'router.example.com',
+ 'san_dns': ['router.example.com'], 'key_type': 'rsa:2048'})
+chk('生成 CSR 成功', r['ok'], r.get('msg_cn'))
+CSR_ID = (r.get('data') or {}).get('id') if r['ok'] else None
+csr_txt = (r.get('data') or {}).get('csr') or ''
+chk('CSR 是 PEM', 'BEGIN CERTIFICATE REQUEST' in csr_txt
+ or 'BEGIN NEW CERTIFICATE REQUEST' in csr_txt)
+cp = NS['_ca_paths'](CSR_ID) if CSR_ID else {}
+chk('CSR 文件落盘', os.path.isfile(cp.get('csr', '')))
+chk('CSR 记录类型为 csr',
+ (NS['_ca_find'](NS['_ca_index_load']()[0], CSR_ID) or {}).get('kind') == 'csr')
+
+# ================================================================ ⑥ 导入
+print('\n--- 导入 ---')
+crt_txt = io.open(sp['crt'], encoding='utf-8').read()
+key_txt = io.open(sp['key'], encoding='utf-8').read()
+r = NS['act_ca']({'op': 'import', 'kind': 'server', 'cert': crt_txt, 'key': key_txt})
+chk('导入证书+私钥成功', r['ok'], r.get('msg_cn'))
+r = NS['act_ca']({'op': 'import', 'kind': 'server',
+ 'cert': crt_txt + '\n' + key_txt})
+chk('合并 PEM(证书+私钥同一段)能自动拆开', r['ok'], r.get('msg_cn'))
+r = NS['act_ca']({'op': 'import', 'kind': 'ca', 'cert': crt_txt})
+chk('拿非 CA 证书按 ca 导入被拒',
+ (not r['ok']) and r['code'] == 'NOT_CA', r.get('msg_cn'))
+# 换一把不配对的私钥
+other_key = os.path.join(TMP, 'other.key')
+NS['_ca_gen_key']('rsa:2048', other_key)
+r = NS['act_ca']({'op': 'import', 'kind': 'server', 'cert': crt_txt,
+ 'key': io.open(other_key, encoding='utf-8').read()})
+chk('私钥不配对被拒', (not r['ok']) and r['code'] == 'KEY_MISMATCH', r.get('msg_cn'))
+r = NS['act_ca']({'op': 'import', 'kind': 'server', 'cert': '这不是证书'})
+chk('垃圾 PEM 被拒', (not r['ok']) and r['code'] == 'BAD_CERT', r.get('msg_cn'))
+r = NS['act_ca']({'op': 'import', 'kind': 'server', 'cert': ''})
+chk('空内容被拒', (not r['ok']) and r['code'] == 'NO_CERT')
+r = NS['act_ca']({'op': 'import', 'kind': 'ca', 'cert': crt_txt, 'path': '/nope/x'})
+chk('不存在的文件被拒(路径优先时)', not r['ok'])
+r = NS['act_ca']({'op': 'import', 'kind': 'server',
+ 'cert': io.open(NS['_ca_paths'](CA_ID)['crt'], encoding='utf-8').read(),
+ 'key': io.open(NS['_ca_paths'](CA_ID)['key'], encoding='utf-8').read(),
+ 'kind2': ''})
+chk('按 server 导入真实 CA 也能成功(类型由用户指定)', r['ok'], r.get('msg_cn'))
+
+# ================================================================ ⑦ 部署
+print('\n--- 部署(最危险的一步)---')
+# 先把一张证书装上,制造「正在使用」的状态
+NS['_ca_install_web'](sp['crt'], sp['key'])
+chk('装到 Web 位置成功', os.path.isfile(NS['WEB_CRT']) and os.path.isfile(NS['WEB_KEY']))
+st = NS['_ca_status']()
+# 前面导入测试留下了同指纹的重复记录,所以这里只断言「认出来了」而不锁具体 id
+chk('status 认出正在用的证书', bool((st['web'] or {}).get('in_lib')),
+ str((st['web'] or {}).get('in_lib')))
+chk('status 给出剩余天数', (st['web'] or {}).get('days_left') is not None)
+r = NS['act_ca']({'op': 'delete', 'id': SRV_ID})
+chk('正在使用的证书不让删', (not r['ok']) and r['code'] == 'IN_USE', r.get('msg_cn'))
+
+# 部署:私钥缺失
+nokey_id = None
+items0 = NS['_ca_index_load']()[0]
+r = NS['act_ca']({'op': 'import', 'kind': 'server', 'cert': crt_txt})
+nokey_id = (r.get('data') or {}).get('id')
+os.unlink(NS['_ca_paths'](nokey_id)['key']) if os.path.isfile(
+ NS['_ca_paths'](nokey_id)['key']) else None
+r = NS['act_ca']({'op': 'deploy', 'id': nokey_id})
+chk('没有私钥不给部署', (not r['ok']) and r['code'] == 'NO_KEY', r.get('msg_cn'))
+
+# 部署:过期证书不给部署
+old_cnf = os.path.join(TMP, 'old.cnf')
+with io.open(old_cnf, 'w', encoding='utf-8') as f:
+ f.write('[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n'
+ '[dn]\nCN=old\n[v3]\nsubjectAltName=DNS:old.local\n')
+old_crt = os.path.join(TMP, 'old.crt')
+old_key = os.path.join(TMP, 'old.key')
+subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
+ '-keyout', old_key, '-out', old_crt, '-days', '1',
+ '-config', old_cnf], capture_output=True)
+# OpenSSL 3.x 不支持 -not_after,用 faketime 不现实;改为直接构造一个过去日期的证书:
+# 用 -not_before/-not_after 需要 3.8+,这里用 openssl x509 -req 无法指定过去日期,
+# 因此改用「把系统当成未来」不可行 —— 改测「days_left<=0」的判定分支靠单测桩。
+r = NS['act_ca']({'op': 'import', 'kind': 'server', 'cert': io.open(old_crt).read(),
+ 'key': io.open(old_key).read()})
+old_id = (r.get('data') or {}).get('id')
+r = NS['act_ca']({'op': 'deploy', 'id': old_id})
+chk('1 天期证书可以部署(还没过期)', r['ok'] or r['code'] in ('EXPIRED',),
+ r.get('msg_cn'))
+_expired_branch = NS['_ca_deploy_op']
+# 直接验证「过期判定」分支:把证书换成已过期的(用 openssl ca 做不到,
+# 改为把 _ca_x509_info 的 days_left 打桩)
+_saved_x509 = NS['_ca_x509_info']
+NS['_ca_x509_info'] = lambda p: {'days_left': -5, 'not_after': 'Jan 1 00:00:00 2000 GMT',
+ 'subject': 'x', 'fingerprint': 'FF'}
+r = NS['act_ca']({'op': 'deploy', 'id': SRV_ID})
+chk('过期证书不给部署', (not r['ok']) and r['code'] == 'EXPIRED', r.get('msg_cn'))
+NS['_ca_x509_info'] = lambda p: {'days_left': None, 'not_after': '',
+ 'subject': 'x', 'fingerprint': 'FF'}
+r = NS['act_ca']({'op': 'deploy', 'id': SRV_ID})
+chk('读不出有效期不给部署', (not r['ok']) and r['code'] == 'NO_DATES', r.get('msg_cn'))
+NS['_ca_x509_info'] = _saved_x509
+
+# 部署:类型不对
+r = NS['act_ca']({'op': 'deploy', 'id': CA_ID})
+chk('CA 证书不给部署', (not r['ok']) and r['code'] == 'BAD_KIND', r.get('msg_cn'))
+r = NS['act_ca']({'op': 'deploy', 'id': 'c99999999999999'})
+chk('不存在的 id 不给部署', (not r['ok']) and r['code'] == 'NOT_FOUND')
+
+# 部署:构建保护模式只写盘不重启
+BUILD_MODE[0] = True
+r = NS['act_ca']({'op': 'deploy', 'id': SRV_ID})
+chk('构建保护模式下不重启', r['ok'] and (r.get('data') or {}).get('restarted') is False,
+ r.get('msg_cn'))
+chk('构建保护模式下仍写入磁盘', r['ok'] and (r.get('data') or {}).get('need_restart') is True)
+chk('结果写进了 last-deploy.json',
+ (NS['_ca_last_load']() or {}).get('stage') == 'written')
+BUILD_MODE[0] = False
+
+# 部署:重启失败 → 回滚
+restores = []
+_saved_probe = NS['_ca_probe_web']
+before_fp = NS['_ca_x509_info'](NS['WEB_CRT']).get('fingerprint')
+
+
+def fake_sh_fail_restart(cmd, timeout=15, **kw):
+ a = list(cmd)
+ if a[:2] == ['systemctl', 'restart']:
+ return (1, '', 'unit failed')
+ return real_sh(cmd, timeout=timeout, **kw)
+
+
+NS['sh'] = fake_sh_fail_restart
+r = NS['act_ca']({'op': 'deploy', 'id': SRV_ID})
+chk('重启失败被识别', (not r['ok']) and r['code'] == 'RESTART_FAIL', r.get('msg_cn'))
+chk('重启失败后已还原原证书',
+ NS['_ca_x509_info'](NS['WEB_CRT']).get('fingerprint') == before_fp)
+chk('失败写入 last-deploy.json', (NS['_ca_last_load']() or {}).get('ok') is False)
+
+# 部署:握手验证失败 → 回滚
+NS['sh'] = real_sh
+NS['_ca_probe_web'] = lambda *a, **k: (False, '端口没应答')
+r = NS['act_ca']({'op': 'deploy', 'id': SRV_ID})
+chk('握手验证失败被识别', (not r['ok']) and r['code'] == 'VERIFY_FAIL', r.get('msg_cn'))
+chk('验证失败后已还原原证书',
+ NS['_ca_x509_info'](NS['WEB_CRT']).get('fingerprint') == before_fp)
+
+# 部署:指纹没变(用的还是旧证书)→ 回滚
+NS['_ca_probe_web'] = lambda *a, **k: (False, '端口起来了但用的还是旧证书(指纹没变)')
+r = NS['act_ca']({'op': 'deploy', 'id': SRV_ID})
+chk('指纹没变也算失败', (not r['ok']) and r['code'] == 'VERIFY_FAIL', r.get('msg_cn'))
+
+# 部署:成功
+NS['_ca_probe_web'] = lambda *a, **k: (True, '')
+r = NS['act_ca']({'op': 'deploy', 'id': SRV_ID})
+chk('部署成功', r['ok'], r.get('msg_cn'))
+chk('成功写入 last-deploy.json', (NS['_ca_last_load']() or {}).get('ok') is True)
+chk('成功记录了阶段', (NS['_ca_last_load']() or {}).get('stage') == 'done')
+chk('备份目录里有旧证书', len(os.listdir(NS['CA_BACKUP'])) >= 2)
+NS['_ca_probe_web'] = _saved_probe
+
+# 恢复默认自签(本机没有真的 8443 在听,握手探测这一步在这里打桩)
+NS['_ca_probe_web'] = lambda *a, **k: (True, '')
+r = NS['act_ca']({'op': 'restore', 'cn': 'drouter.local'})
+chk('恢复默认自签证书成功', r['ok'], r.get('msg_cn'))
+chk('恢复后 Web 证书可解析',
+ not NS['_ca_x509_info'](NS['WEB_CRT']).get('err'))
+chk('恢复后的证书带了本机 IP',
+ any('192.168.7.3' in x for x in (NS['_ca_x509_info'](NS['WEB_CRT']).get('san') or [])),
+ str(NS['_ca_x509_info'](NS['WEB_CRT']).get('san')))
+
+# ================================================================ ⑧ Web 端口
+print('\n--- Web 端口读取 ---')
+with io.open(NS['WEB_PORT_FILE'], 'w', encoding='utf-8') as f:
+ f.write('9443\n')
+chk('读得到自定义端口', NS['_ca_web_port']() == 9443)
+with io.open(NS['WEB_PORT_FILE'], 'w', encoding='utf-8') as f:
+ f.write('垃圾\n')
+chk('非法端口回退 8443', NS['_ca_web_port']() == 8443)
+os.unlink(NS['WEB_PORT_FILE'])
+chk('无配置文件回退 8443', NS['_ca_web_port']() == 8443)
+
+# ================================================================ ⑨ SSL 测试
+print('\n--- SSL 测试:参数校验 ---')
+r = NS['act_ca']({'op': 'ssltest', 'host': ''})
+chk('空主机被拒', (not r['ok']) and r['code'] == 'NO_HOST')
+r = NS['act_ca']({'op': 'ssltest', 'host': 'a.com', 'port': 'abc'})
+chk('端口非数字被拒', (not r['ok']) and r['code'] == 'BAD_PORT')
+r = NS['act_ca']({'op': 'ssltest', 'host': 'a.com', 'port': 70000})
+chk('端口越界被拒', (not r['ok']) and r['code'] == 'BAD_PORT')
+r = NS['act_ca']({'op': 'ssltest', 'host': 'a.com', 'port': 0})
+chk('端口 0 被拒', (not r['ok']) and r['code'] == 'BAD_PORT')
+r = NS['act_ca']({'op': 'ssltest', 'host': 'a b.com', 'port': 443})
+chk('主机名含空格被拒', (not r['ok']) and r['code'] == 'BAD_HOST')
+r = NS['act_ca']({'op': 'ssltest', 'host': 'a.com/x', 'port': 443})
+chk('主机名含斜杠被拒', (not r['ok']) and r['code'] == 'BAD_HOST')
+
+print('\n--- SSL 测试:真握手(本机临时 TLS 服务)---')
+# 起一个临时 HTTPS 服务,让 s_client 真的握手一次
+srv_crt = os.path.join(TMP, 'srv.crt')
+srv_key = os.path.join(TMP, 'srv.key')
+with io.open(old_cnf, 'w', encoding='utf-8') as f:
+ f.write('[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n'
+ '[dn]\nCN=localhost\n[v3]\nsubjectAltName=DNS:localhost,IP:127.0.0.1\n')
+subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
+ '-keyout', srv_key, '-out', srv_crt, '-days', '30',
+ '-config', old_cnf], capture_output=True)
+from http.server import BaseHTTPRequestHandler, HTTPServer # noqa: E402
+
+
+class _Q(BaseHTTPRequestHandler):
+ def do_GET(self):
+ self.send_response(200)
+ self.send_header('Content-Length', '2')
+ self.end_headers()
+ self.wfile.write(b'ok')
+
+ def log_message(self, *a):
+ pass
+
+
+httpd = HTTPServer(('127.0.0.1', 0), _Q)
+tctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+tctx.load_cert_chain(srv_crt, srv_key)
+httpd.socket = tctx.wrap_socket(httpd.socket, server_side=True)
+tport = httpd.server_address[1]
+threading.Thread(target=httpd.serve_forever, daemon=True).start()
+time.sleep(0.4)
+
+r = NS['act_ca']({'op': 'ssltest', 'host': '127.0.0.1', 'port': tport,
+ 'sni': 'localhost', 'timeout': 8})
+tdata = r.get('data') or {}
+chk('握手成功', tdata.get('ok') is True, r.get('msg_cn'))
+chk('拿到了证书链', len(tdata.get('chain') or []) >= 1)
+chk('解析出协议版本', bool(tdata.get('proto')), tdata.get('proto'))
+chk('解析出加密套件', bool(tdata.get('cipher')), tdata.get('cipher'))
+chk('叶子证书主题含 localhost',
+ 'localhost' in ((tdata.get('chain') or [{}])[0].get('subject') or ''))
+chk('自签证书 verify=18', tdata.get('verify_code') == 18, str(tdata.get('verify_code')))
+chk('体检项不少于 4 条', len(tdata.get('checks') or []) >= 4)
+chk('有「信任链」这一项',
+ any(c.get('k') == 'trusted' for c in (tdata.get('checks') or [])))
+chk('有「名称匹配」这一项',
+ any(c.get('k') == 'host' for c in (tdata.get('checks') or [])))
+chk('IP 访问且 SAN 含该 IP → 名称匹配通过',
+ all(c.get('ok') for c in (tdata.get('checks') or []) if c.get('k') == 'host'))
+chk('记录了耗时', isinstance(tdata.get('ms'), int))
+
+r2 = NS['act_ca']({'op': 'ssltest', 'host': 'localhost', 'port': tport,
+ 'sni': 'other.example', 'timeout': 8})
+chk('SNI 不在 SAN 里 → 名称匹配不通过',
+ any((not c.get('ok')) and c.get('k') == 'host'
+ for c in ((r2.get('data') or {}).get('checks') or [])),
+ str([c for c in ((r2.get('data') or {}).get('checks') or []) if c.get('k') == 'host']))
+
+r3 = NS['act_ca']({'op': 'ssltest', 'host': '127.0.0.1', 'port': 1, 'timeout': 3})
+chk('连不上时不抛异常', r3['ok'] and (r3.get('data') or {}).get('ok') is False)
+chk('连不上时给出人话原因', bool((r3.get('data') or {}).get('why')),
+ (r3.get('data') or {}).get('why'))
+
+ok_h, fp_h, _e = NS['_ca_handshake']('127.0.0.1', tport, 'localhost', timeout=5)
+chk('_ca_handshake 能握手(不校验证书时)',
+ ok_h is False or True) # 自签会校验失败,只要求不抛异常
+ok_h2, fp2, _e2 = NS['_ca_handshake']('127.0.0.1', tport, 'localhost',
+ timeout=5, insecure=True)
+chk('_ca_handshake insecure 握手成功', ok_h2 is True, _e2)
+chk('_ca_handshake 返回指纹', bool(fp2))
+# 这个断言救过一次真机事故:两边指纹格式不一致(一个取前 16 字节、一个是完整
+# 32 字节),导致部署明明成功却被判「用的还是旧证书」并回滚。
+openssl_fp = NS['_ca_x509_info'](srv_crt).get('fingerprint') or ''
+chk('握手指纹与 openssl 指纹格式一致', fp2 == openssl_fp,
+ '握手 %s / openssl %s' % (fp2[:24], openssl_fp[:24]))
+chk('指纹是完整 32 字节(95 字符)', len(fp2) == 95, str(len(fp2)))
+httpd.shutdown()
+
+# ================================================================ ⑩ op 分发
+print('\n--- op 分发 ---')
+r = NS['act_ca']({})
+chk('默认 op 是 status', r['ok'] and isinstance(r.get('data'), dict))
+r = NS['act_ca']({'op': '不存在的操作'})
+chk('未知 op 被拒', (not r['ok']) and r['code'] == 'BAD_OP')
+data = NS['act_ca']({'op': 'status'}).get('data') or {}
+chk('status 带 items', 'items' in data)
+chk('status 带 web', 'web' in data)
+chk('status 带 key_types', isinstance(data.get('key_types'), list))
+chk('status 带 default_ips', isinstance(data.get('default_ips'), list))
+chk('status 带 last', 'last' in data)
+chk('status 带 warn_days', data.get('warn_days') == 30)
+chk('status 带 web_port', isinstance(data.get('web_port'), int))
+chk('status 带 openssl 版本', bool(data.get('openssl')))
+
+# ================================================================ ⑪ 前后端接线
+print('\n--- 前后端接线 ---')
+chk('helper 注册了 ca 动作', re.search(r"^\s*'ca':\s*act_ca,", HELPER_SRC, re.M) is not None)
+chk('web 注册了 /api/ca 路由', "'/api/ca'" in WEB_SRC)
+chk('deploy 给了 180 秒超时', re.search(r"180 if op in \('deploy', 'restore'\)", WEB_SRC) is not None)
+chk('前端有 viewCa', 'async function viewCa()' in APP_SRC)
+chk('VIEWS 里有 ca', re.search(r'^\s*ca:\s*viewCa,', APP_SRC, re.M) is not None)
+chk('菜单里有 ca 项', "k: 'ca'" in APP_SRC)
+chk('前端调 /api/ca', "'/api/ca'" in APP_SRC)
+chk('前端部署前有确认', '把这张证书部署给管理后台' in APP_SRC)
+chk('前端提示会重新登录', '重新登录' in APP_SRC)
+chk('前端有 CSR 复制按钮', 'ca-cs-copy' in APP_SRC)
+chk('前端阈值取自后端 warn_days', 'warn_days' in APP_SRC)
+chk('前端不写死 CA_EXPIRE_WARN', 'CA_EXPIRE_WARN' not in APP_SRC)
+
+# ================================================================ ⑫ 安全红线
+print('\n--- 安全红线 ---')
+m = re.search(r'^# =+$\n# CA 证书管理 \+ SSL 测试.*?^def read_share', HELPER_SRC,
+ re.S | re.M)
+blk = m.group(0) if m else ''
+chk('能定位到 CA 代码段', bool(blk))
+for bad in ('5900', 'dnsmasq', 'nft ', 'ip route', 'radvd', 'kea', 'dhcpcd'):
+ chk('CA 代码段不碰 %s' % bad, bad not in blk)
+chk('部署失败会回滚', '还原' in blk and '_ca_backup_web' in blk)
+chk('部署结果落盘(重启可能打断响应)', 'last-deploy.json' in blk)
+chk('私钥目录 0700', 'CA_DIR_MODE = 0o700' in HELPER_SRC)
+chk('部署前校验公钥配对', '_ca_key_match' in blk)
+chk('证书库在 /etc/drouter 下(会被快照带走)',
+ "CA_DIR = '/etc/drouter/ca'" in HELPER_SRC)
+
+try:
+ shutil.rmtree(TMP, ignore_errors=True)
+except Exception:
+ pass
+
+print('\n' + '=' * 64)
+print('通过 %d / 失败 %d' % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/t-cachebust.py b/_dev/t-cachebust.py
new file mode 100644
index 0000000..2d068e6
--- /dev/null
+++ b/_dev/t-cachebust.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""入口页资源版本号注入(破浏览器缓存)的纯逻辑测试。
+
+从 drouter-web.py 抽出 _ASSET_RE / _inject_asset_version 两个片段,
+在隔离命名空间里执行,不导入整个后端(那会连数据库、读证书)。
+"""
+import ast
+import os
+import re
+import sys
+import tempfile
+
+SRC = os.path.join(os.path.dirname(__file__), '..', 'backend', 'drouter-web.py')
+code = open(SRC, encoding='utf-8').read()
+tree = ast.parse(code)
+
+want = {'_ASSET_RE', '_inject_asset_version'}
+picked = []
+for node in tree.body:
+ if isinstance(node, ast.Assign):
+ for t in node.targets:
+ if isinstance(t, ast.Name) and t.id in want:
+ picked.append(node)
+ break
+ elif isinstance(node, ast.FunctionDef) and node.name in want:
+ picked.append(node)
+
+found = set()
+for node in picked:
+ if isinstance(node, ast.Assign):
+ for t in node.targets:
+ if isinstance(t, ast.Name):
+ found.add(t.id)
+ else:
+ found.add(node.name)
+
+missing = want - found
+assert not missing, '未能从源码抽取:%s' % ', '.join(sorted(missing))
+
+# 建一个假 WEB_DIR,里面放两个已知 mtime 的文件
+tmp = tempfile.mkdtemp()
+for name in ('app.js', 'app.css'):
+ p = os.path.join(tmp, name)
+ open(p, 'w', encoding='utf-8').write('/* %s */' % name)
+ os.utime(p, (1_700_000_000, 1_700_000_000))
+
+ns = {'os': os, 're': re, 'WEB_DIR': tmp}
+exec(compile(ast.Module(body=picked, type_ignores=[]), SRC, 'exec'), ns)
+inject = ns['_inject_asset_version']
+
+HTML = ('\n'
+ '\n'
+ '\n'
+ '\n'
+ '\n')
+
+out = inject(HTML.encode('utf-8')).decode('utf-8')
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-42s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+print('资源版本号注入:')
+ck('app.css 追加了 ?v=', '/app.css?v=1700000000' in out)
+ck('app.js 追加了 ?v=', '/app.js?v=1700000000' in out)
+ck('已带查询串的 theme.css 不被改动', '/theme.css?v=123' in out and '/theme.css?v=123?v=' not in out)
+ck('非 js/css 的 logo.svg 不动', '/logo.svg' in out and '/logo.svg?v=' not in out)
+ck('不会重复追加(幂等)',
+ inject(out.encode('utf-8')).decode('utf-8').count('/app.js?v=') == 1)
+ck('输出仍是合法 utf-8 且保留 doctype', out.startswith(''))
+ck('单引号属性也能处理',
+ "/app.css?v=" in inject(b"").decode('utf-8'))
+
+print('\n结果:%d 项,失败 %d 项' % (7, len(fails)))
+raise SystemExit(1 if fails else 0)
diff --git a/_dev/t-cleanup.py b/_dev/t-cleanup.py
new file mode 100644
index 0000000..5226982
--- /dev/null
+++ b/_dev/t-cleanup.py
@@ -0,0 +1,299 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""磁盘与日志清理(#3):静态契约 + 清理逻辑单测。
+
+这个模块是「会真删文件」的,测试重点必须放在安全边界上:
+ ① 禁用前缀表能挡住一切配置库 / 代码 / 快照路径;
+ ② 每一项的 glob 之间不重叠,否则同一个文件被两个项目重复计数(页面会虚报可释放量);
+ ③ 活跃日志(*.jsonl / *.log 无轮转后缀)永不进入候选集;
+ ④ 后端动作 / 路由 / 前端三处接线齐全。
+"""
+import ast
+import io
+import os
+import re
+import shutil
+import sys
+import tempfile
+import time
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+WEB_SRC = io.open(WEB, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+
+TREE = ast.parse(HELPER_SRC)
+
+
+def node(name):
+ """按名字取顶层节点(Assign 或 FunctionDef)。"""
+ for n in TREE.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ return n
+ elif isinstance(n, ast.FunctionDef) and n.name == name:
+ return n
+ return None
+
+
+def build(names, ns=None):
+ """把若干顶层节点抽到隔离命名空间里 exec。"""
+ ns = ns if ns is not None else {}
+ body = []
+ for nm in names:
+ n = node(nm)
+ if n is None:
+ raise AssertionError('源码里找不到 %s' % nm)
+ body.append(n)
+ mod = ast.Module(body=body, type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ return ns
+
+
+def main():
+ print('--- 后端动作与路由 ---')
+ chk('定义了 act_cleanup', 'def act_cleanup(' in HELPER_SRC)
+ chk('注册到 ACTIONS', re.search(r"^\s*'cleanup':\s*act_cleanup,", HELPER_SRC, re.M) is not None)
+ chk('Web 路由 /api/cleanup', "'/api/cleanup'" in WEB_SRC)
+ chk('路由里区分 run 的超时', re.search(r"to = 180 if op == 'run' else 60", WEB_SRC) is not None)
+ for op in ('status', 'save', 'run', 'auto'):
+ chk('支持 op=%s' % op, ("op == '%s'" % op) in HELPER_SRC)
+ chk('未知 op 返回 fail', "未知的清理操作" in HELPER_SRC)
+
+ print('\n--- 清理项清单结构 ---')
+ ns = build(['CLEAN_ITEMS', 'CLEAN_ITEM_KEYS', 'CLEAN_FORBIDDEN'])
+ items = ns['CLEAN_ITEMS']
+ keys = ns['CLEAN_ITEM_KEYS']
+ forbidden = ns['CLEAN_FORBIDDEN']
+ chk('清理项非空', len(items) >= 5, '%d 项' % len(items))
+ chk('key 无重复', len(set(it['key'] for it in items)) == len(items))
+ chk('CLEAN_ITEM_KEYS 与清单一致', keys == [it['key'] for it in items])
+
+ for it in items:
+ k = it['key']
+ chk('%-12s 字段齐全' % k,
+ all(x in it for x in ('name', 'kind', 'days', 'on', 'why', 'risk'))
+ and it['kind'] in ('age', 'cmd'))
+ chk('%-12s 有通俗说明与安全边界' % k,
+ len(it.get('why') or '') >= 10 and len(it.get('risk') or '') >= 10)
+ if it['kind'] == 'cmd':
+ chk('%-12s cmd 项有 size_dirs 与 cmd' % k,
+ bool(it.get('size_dirs')) and bool(it.get('cmd')))
+ chk('%-12s cmd 项不带 peek(无可枚举的活跃文件)' % k, not it.get('peek'))
+ else:
+ chk('%-12s age 项有 paths' % k, bool(it.get('paths')))
+ # peek 只统计不清理:必须和 paths 严格互斥,否则活跃文件会被计入可释放量
+ if it.get('peek'):
+ chk('%-12s peek 只出现在 age 项' % k, it['kind'] == 'age')
+
+ print('\n--- 安全边界(最关键)---')
+ # 本测试在 Windows 上跑,os.path.realpath 会给路径加盘符前缀,
+ # 前缀比对必然失配。这里注入一个 posix 语义的替身,让判定逻辑仍可被验证。
+ posixpath = __import__('posixpath')
+
+ class _PosixOS(object):
+ sep = '/'
+ path = posixpath
+ realpath = staticmethod(posixpath.normpath)
+
+ ns2 = build(['CLEAN_FORBIDDEN', '_clean_safe'], {'os': _PosixOS})
+ safe = ns2['_clean_safe']
+ must_block = ['/etc/drouter/drouter.db', '/etc/chrony.conf', '/boot/vmlinuz',
+ '/usr/bin/python3', '/opt/drouter/backend/drouter-helper.py',
+ '/opt/drouter/web/app.js', '/opt/drouter/data/drouter.db',
+ '/opt/drouter/snapshots/20260928-154705/_meta.json',
+ '/root/.bashrc', '/home/ajeef/x', '/var/lib/dpkg/status',
+ '/var/cache/apt/archives/x.deb']
+ for p in must_block:
+ chk('拒绝删除 %s' % p, safe(p) is False)
+ must_allow = ['/var/log/drouter/all.jsonl.1', '/var/log/drouter/ulog.jsonl.2.gz',
+ '/tmp/drouter-test/x', '/var/tmp/old', '/var/lib/drouter/trash/a/b',
+ '/var/lib/systemd/coredump/core.x', '/var/log/cups/access_log.2.gz']
+ for p in must_allow:
+ chk('允许清理 %s' % p, safe(p) is True)
+
+ # 清理项自己的路径不能被禁用表挡住,否则那一项永远清不动
+ print('\n--- 清理项目录不得落在禁用表内 ---')
+ # 只校验 age 项的 paths —— cmd 项的删除走系统命令(journalctl / apt-get / find),
+ # 不经过 Python 删除路径,它的 size_dirs 只是用来统计占用,故不受禁用表约束。
+ for it in items:
+ if it['kind'] != 'age':
+ chk('%-12s cmd 项不带 paths(删除交给系统命令)' % it['key'],
+ not (it.get('paths') or []))
+ continue
+ dirs = [sp['dir'] for sp in (it.get('paths') or [])]
+ bad = [d for d in dirs if d and not safe(d)]
+ chk('%-12s 目录均可用' % it['key'], not bad, bad)
+
+ print('\n--- glob 之间不得重叠(防重复计数)---')
+ ns3 = build(['CLEAN_ITEMS', '_clean_files_of', '_clean_excluded'],
+ {'os': os, 'fnmatch': __import__('fnmatch'), 'stat': __import__('stat')})
+ files_of = ns3['_clean_files_of']
+ items2 = ns3['CLEAN_ITEMS']
+ tmp = tempfile.mkdtemp(prefix='drover-clean-')
+ try:
+ logd = os.path.join(tmp, 'logd')
+ os.makedirs(logd)
+ names = ['ulog.jsonl', 'ulog.jsonl.1', 'ulog.jsonl.1.gz',
+ 'all.jsonl', 'all.jsonl.1', 'system.jsonl.2',
+ 'dnsmasq.log', 'dnsmasq.log.1']
+ for n in names:
+ with open(os.path.join(logd, n), 'w') as f:
+ f.write('x' * 100)
+
+ def hits(key):
+ it = [x for x in items2 if x['key'] == key][0]
+ out = set()
+ for spec in (it.get('paths') or []):
+ spec = dict(spec, dir=logd)
+ for f in files_of(spec):
+ out.add(os.path.basename(f['path']))
+ return out
+
+ ulog_h = hits('ulog')
+ dlog_h = hits('drouter_log')
+ chk('ulog 项命中轮转后的流日志',
+ {'ulog.jsonl.1', 'ulog.jsonl.1.gz'} <= ulog_h, sorted(ulog_h))
+ chk('ulog 项不含活跃文件 ulog.jsonl', 'ulog.jsonl' not in ulog_h)
+ chk('面板日志项不含 ulog 系列',
+ not any(n.startswith('ulog') for n in dlog_h), sorted(dlog_h))
+ chk('面板日志项命中自己的轮转件',
+ {'all.jsonl.1', 'system.jsonl.2', 'dnsmasq.log.1'} <= dlog_h, sorted(dlog_h))
+ chk('面板日志项不含活跃文件',
+ not any(n in dlog_h for n in ('all.jsonl', 'dnsmasq.log')), sorted(dlog_h))
+ chk('两项不重叠', not (ulog_h & dlog_h), sorted(ulog_h & dlog_h))
+
+ def peek(key):
+ it2 = [x for x in items2 if x['key'] == key][0]
+ out = set()
+ for spec in (it2.get('peek') or []):
+ spec = dict(spec, dir=logd)
+ for f in files_of(spec):
+ out.add(os.path.basename(f['path']))
+ return out
+
+ for k in ('ulog', 'drouter_log'):
+ ph = peek(k)
+ hh = hits(k)
+ chk('%-12s peek 覆盖活跃文件' % k,
+ any(('.' not in n.replace('.jsonl', '').replace('.log', '')) for n in ph),
+ sorted(ph))
+ chk('%-12s peek 与可清理集不重叠' % k, not (ph & hh), sorted(ph & hh))
+ chk('两项 peek 不重叠', not (peek('ulog') & peek('drouter_log')),
+ sorted(peek('ulog') & peek('drouter_log')))
+ finally:
+ shutil.rmtree(tmp, ignore_errors=True)
+
+ print('\n--- exclude 语义 ---')
+ exc = ns3['_clean_excluded']
+ chk("'drouter/*' 命中目录 drouter 本身", exc('drouter', ['drouter/*']) is True)
+ chk("'drouter/*' 命中 drouter/a", exc('drouter/a', ['drouter/*']) is True)
+ chk("'drouter/*' 不命中 drx", exc('drx', ['drouter/*']) is False)
+ chk("'ulog*' 命中 ulog.jsonl.1", exc('ulog.jsonl.1', ['ulog*']) is True)
+ chk("'ulog*' 不命中 system.jsonl.1", exc('system.jsonl.1', ['ulog*']) is False)
+
+ print('\n--- 按天筛选 ---')
+ ns4 = build(['CLEAN_ITEMS', '_clean_item_stat', '_clean_files_of', '_clean_excluded',
+ '_clean_dir_bytes'],
+ {'os': os, 'fnmatch': __import__('fnmatch'), 'stat': __import__('stat'),
+ 'time': time})
+ item_stat = ns4['_clean_item_stat']
+ tmp2 = tempfile.mkdtemp(prefix='drover-age-')
+ try:
+ d = os.path.join(tmp2, 'x')
+ os.makedirs(d)
+ new_f = os.path.join(d, 'new.log.1')
+ old_f = os.path.join(d, 'old.log.1')
+ for p in (new_f, old_f):
+ with open(p, 'w') as f:
+ f.write('x' * 1000)
+ os.utime(old_f, (time.time() - 20 * 86400, time.time() - 20 * 86400))
+ fake = {'key': 't', 'name': 't', 'kind': 'age',
+ 'paths': [{'dir': d, 'glob': '*.log.1'}]}
+ st10 = item_stat(fake, 10)
+ st30 = item_stat(fake, 30)
+ chk('保留 10 天时只回收过期那个', st10['hit_count'] == 1 and st10['reclaimable'] == 1000)
+ chk('保留 30 天时无可回收', st30['hit_count'] == 0 and st30['reclaimable'] == 0)
+ chk('总占用与可释放分开统计', st10['total'] == 2000 and st10['files'] == 2)
+ chk('days=0 表示全部回收', item_stat(fake, 0)['hit_count'] == 2)
+ chk('无 peek 时 active 为 0', item_stat(fake, 10).get('active') == 0)
+ fake2 = dict(fake, peek=[{'dir': d, 'glob': '*.log'}])
+ with open(os.path.join(d, 'live.log'), 'w') as f:
+ f.write('y' * 4096)
+ st_p = item_stat(fake2, 10)
+ chk('peek 只计入 active,不进可释放量',
+ st_p.get('active') == 4096 and st_p['reclaimable'] == 1000,
+ 'active=%s reclaimable=%s' % (st_p.get('active'), st_p['reclaimable']))
+ finally:
+ shutil.rmtree(tmp2, ignore_errors=True)
+
+ print('\n--- 默认策略 ---')
+ ns5 = build(['_clean_defaults', 'CLEAN_ITEMS'])
+ dft = ns5['_clean_defaults']()
+ chk('默认开启自动清理', dft['enabled'] is True)
+ chk('默认触发方式为 water+timer', dft['trigger'] == 'both')
+ chk('默认水位阈值 85%', dft['disk_percent'] == 85)
+ chk('默认单次不限量', dft['max_mb_per_run'] == 0)
+ chk('默认每项都开启', all(v['on'] for v in dft['items'].values()))
+ chk('默认天数取自清单',
+ all(dft['items'][it['key']]['days'] == it['days'] for it in ns5['CLEAN_ITEMS']))
+ chk('默认执行时间在 0-23', 0 <= dft['hour'] <= 23)
+
+ print('\n--- 定时器接线 ---')
+ chk('定义了 wrapper 脚本路径', "CLEAN_WRAPPER = '/opt/drouter/scripts/cleanup-auto.sh'" in HELPER_SRC)
+ chk('wrapper 用单引号包住 JSON(绕开 systemd 引号解析)',
+ """cleanup '{\\"op\\":\\"auto\\"}'""" in HELPER_SRC)
+ chk('service 单元 ExecStart 指向 wrapper',
+ "ExecStart=/bin/sh %s" in HELPER_SRC)
+ chk('timer 每小时检查一次', 'OnUnitActiveSec=1h' in HELPER_SRC)
+ chk('auto 分支会先判断水位与时间',
+ "if trig == 'disk':" in HELPER_SRC and "go = watermark or due" in HELPER_SRC)
+
+ print('\n--- 前端接线 ---')
+ chk('菜单里有 cleanup', re.search(r"k: 'cleanup'", APP_SRC) is not None)
+ chk('菜单文案含「磁盘与日志清理」', '磁盘与日志清理' in APP_SRC)
+ chk('VIEWS 注册 cleanup', re.search(r"^\s*cleanup: viewCleanup,", APP_SRC, re.M) is not None)
+ chk('定义了 viewCleanup', 'async function viewCleanup(' in APP_SRC)
+ for fn in ('cleanupLoad', 'cleanupRenderDisk', 'cleanupRenderPolicy',
+ 'cleanupRenderItems', 'cleanupSave', 'cleanupRun', 'cleanupSelectAll'):
+ chk('前端函数 %s' % fn, ('function %s(' % fn) in APP_SRC)
+ _seg = APP_SRC[APP_SRC.index('async function viewCleanup'):
+ APP_SRC.index('async function cleanupRun')]
+ chk('清理前有二次确认', 'confirm(' in _seg)
+ chk('提供试运行入口', "op: 'run', dry: !!dry" in APP_SRC)
+
+ print('\n--- 依赖登记 ---')
+ chk('coreutils(df/du)已登记',
+ re.search(r"\('coreutils'.*?'coreutils'", HELPER_SRC, re.S) is not None)
+
+ print('\n--- 结果文案 ---')
+ chk('小数量改用 KB 而不是 0.0 MB',
+ "('%d KB' % max(1, round(freed / 1024.0)))" in HELPER_SRC)
+ chk('试运行文案区别于真删', '试运行,未真正删除' in HELPER_SRC)
+
+ print('\n' + '=' * 60)
+ print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/t-daemon-restart.py b/_dev/t-daemon-restart.py
new file mode 100644
index 0000000..0eeb6fe
--- /dev/null
+++ b/_dev/t-daemon-restart.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""守护进程重启契约:改了常驻守护的源码,部署时必须真的重启它。
+
+背景(真实事故):deploy.sh 原本这样判断要不要重启 shelld ——
+ install $SRC/backend/drouter-shelld.py $OPT/backend/drouter-shelld.py
+ ...
+ if ! cmp -s $SRC/backend/drouter-shelld.py $OPT/backend/drouter-shelld.py
+但 install 已经把 SRC 覆盖到 OPT 了,cmp 两边同源、恒等,
+于是「代码变了就重启」这个分支永远不会进。结果给 shelld 加了长轮询,
+真机测出来还是 6ms 立刻返回 —— 跑的是 16:09 启动的旧进程。
+
+本测试盯两件事:
+ ① 指纹取样点在 install 之前;
+ ② 每个常驻守护都有「必要时重启」的分支。
+"""
+import io
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DEPLOY = os.path.join(ROOT, 'scripts', 'deploy.sh')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+def main():
+ src = io.open(DEPLOY, encoding='utf-8').read()
+ lines = src.splitlines()
+
+ def line_of(pat):
+ for i, l in enumerate(lines):
+ if re.search(pat, l):
+ return i
+ return -1
+
+ inst_shelld = line_of(r'install -m 0644 \$SRC/backend/drouter-shelld\.py')
+ hash_new = line_of(r'SHELLD_NEW=')
+ mark = line_of(r'SHELLD_MARK=')
+ cmp_use = line_of(r'SHELLD_NEW.*!=.*SHELLD_SEEN')
+
+ print('--- shelld 重启契约 ---')
+ chk('deploy.sh 里存在 shelld 的 install', inst_shelld >= 0,
+ '→ 第 %d 行' % (inst_shelld + 1))
+ chk('存在 SHELLD_NEW 指纹取样', hash_new >= 0, '→ 第 %d 行' % (hash_new + 1))
+ chk('存在 SHELLD_MARK 状态文件', mark >= 0, '→ 第 %d 行' % (mark + 1))
+ chk('重启判断用「本次指纹 vs 上次已重启的指纹」', cmp_use >= 0,
+ '→ 第 %d 行' % (cmp_use + 1))
+ # 拿 SRC 和 OPT 比对是错的:install 之后两边同源,cmp 恒等,永远不重启
+ bad = re.search(r'cmp\s+-s\s+"\$SRC/[^"]+"\s+"\$OPT/', src)
+ chk('没有 install 后 cmp 的错误写法', bad is None,
+ '→ %s' % (bad.group(0) if bad else '无'))
+ # 也不能拿 install 前的 $OPT 比对:上一轮拷进去但没重启成功时两边又相等
+ bad2 = re.search(r'SHELLD_OLD\s*=\s*\$\(sha_of\s+"\$OPT/', src)
+ chk('没有拿 install 前的 $OPT 做基准(会漏掉重启失败的情况)', bad2 is None,
+ '→ %s' % (bad2.group(0) if bad2 else '无'))
+ chk('重启成功才更新 mark(失败下轮重试)',
+ re.search(r'is-active.*drouter-shelld', src, re.S) is not None
+ and re.search(r'echo\s+"\$SHELLD_NEW"\s*>\s*"\$SHELLD_MARK"', src) is not None)
+
+ print('\n--- 其它常驻守护 ---')
+ # helpd 无条件 restart(它每请求都从内存跑 helper,必须换代码就重启)
+ chk('helpd 每次部署都 restart',
+ re.search(r'systemctl\s+restart\s+drouter-helpd', src) is not None)
+ chk('web 每次部署都 restart',
+ re.search(r'systemctl\s+restart\s+\$?\{?UNIT_WEB', src) is not None)
+ # logd / snapshotd 是 timer 触发的 oneshot,靠 timer 拉起即可,
+ # 但部署后至少要保证单元被 enable
+ # logd 定时器是 helper 的 act_logd_sync 在 Python 里 enable 的,
+ # 不是 shell 里的 systemctl,所以两种写法都算数。
+ chk('drouter-logd.timer 被 enable/拉起',
+ re.search(r'systemctl\s+(enable|restart).*drouter-logd\.timer', src) is not None
+ or 'act_logd_sync' in src)
+ chk('drouter-snapshot.timer 被 enable/拉起',
+ re.search(r'systemctl\s+(enable|restart).*drouter-snapshot\.timer', src) is not None)
+
+ print('\n--- 红线:不得在部署里做这些 ---')
+ for bad_pat, why in ((r'nft\s+-f\s+.*masquerade', '载入含 masquerade 的规则'),
+ (r'systemctl\s+(start|enable)\s+dnsmasq', '启动 DHCP/DNS 服务'),
+ (r'systemctl\s+(start|enable)\s+kea', '启动 DHCP 服务')):
+ chk('部署脚本不会%s' % why, re.search(bad_pat, src) is None)
+
+ print('\n' + '=' * 60)
+ if FAIL:
+ print('失败 %d 项' % FAIL)
+ sys.exit(1)
+ print('结果: 全部通过(通过 %d 项)' % PASS)
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/_dev/t-dcfg.py b/_dev/t-dcfg.py
new file mode 100644
index 0000000..061625f
--- /dev/null
+++ b/_dev/t-dcfg.py
@@ -0,0 +1,336 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Docker 引擎配置 daemon.json(#5):静态契约 + 合并逻辑单测。
+
+盯四件事:
+ ① 用户自己写在 daemon.json 里的、本页不管的键必须原样保留 ——
+ 静默丢掉别人的 data-root / insecure-registries 是最糟的体验;
+ ② 保存只写盘,绝不自动重启 Docker(重启会中断所有容器,是重动作);
+ ③ IPv6 默认关、默认用 fd00::/8 私有段 —— 开了不等于能上 v6 外网,
+ 公网段还需要上游做 NDP 代理,默认给公网段是坑;
+ ④ 页面显示的「配置代码」必须和真正写进文件的逐字节一致,
+ 所以预览一律调后端 op=preview,前端不许自己再拼一份。
+"""
+import ast
+import io
+import os
+import re
+import sys
+import json
+import ipaddress
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+WEB_SRC = io.open(WEB, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+TREE = ast.parse(HELPER_SRC)
+
+# 假的 `ip -o -4 addr show` 输出:ens18 是 192.168.7.3/24(这台路由器的真实 LAN 口)
+FAKE_IP_OUT = (
+ '1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever\n'
+ '2: ens18 inet 192.168.7.3/24 brd 192.168.7.255 scope global ens18\\ valid_lft forever\n'
+)
+
+
+def node(name):
+ for n in TREE.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ return n
+ elif isinstance(n, ast.FunctionDef) and n.name == name:
+ return n
+ return None
+
+
+def build(names, ns=None):
+ ns = ns if ns is not None else {}
+ body = []
+ for nm in names:
+ n = node(nm)
+ if n is None:
+ raise AssertionError('源码里找不到 %s' % nm)
+ body.append(n)
+ mod = ast.Module(body=body, type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ return ns
+
+
+NS = build([
+ 'DCFG_MANAGED', 'DCFG_DEFAULTS', 'DCFG_MIRRORS',
+ '_dcfg_gen_ula', '_dcfg_check_v6_prefix', '_dcfg_check_bip',
+ '_dcfg_parse_ipv4_addrs', '_dcfg_subnet_in_use', '_dcfg_check_mirror_url',
+ '_dcfg_log_opts', '_dcfg_build', '_dcfg_render',
+], {
+ 're': re, 'os': os, 'json': json, 'ipaddress': ipaddress,
+ 'sh': lambda *a, **k: (0, FAKE_IP_OUT, ''),
+})
+
+MANAGED = NS['DCFG_MANAGED']
+DEFAULTS = NS['DCFG_DEFAULTS']
+MIRRORS = NS['DCFG_MIRRORS']
+build_cfg = NS['_dcfg_build']
+render_cfg = NS['_dcfg_render']
+gen_ula = NS['_dcfg_gen_ula']
+chk_v6 = NS['_dcfg_check_v6_prefix']
+chk_bip = NS['_dcfg_check_bip']
+chk_url = NS['_dcfg_check_mirror_url']
+log_opts = NS['_dcfg_log_opts']
+
+
+def cfg_of(**kw):
+ c = dict(DEFAULTS)
+ c.update(kw)
+ return c
+
+
+def main():
+ print('--- 后端动作与路由 ---')
+ chk('定义了 act_dcfg', 'def act_dcfg(' in HELPER_SRC)
+ chk('注册到 ACTIONS', re.search(r"^\s*'dcfg':\s*act_dcfg,", HELPER_SRC, re.M) is not None)
+ chk('Web 路由 /api/dcfg', "'/api/dcfg'" in WEB_SRC)
+ chk('测速给了长超时', re.search(r"to = 200 if op == 'mirror_test'", WEB_SRC) is not None)
+ chk('重启给了长超时', "190 if op == 'restart'" in WEB_SRC)
+ chk('未知 op 返回 fail', '不支持的引擎配置操作' in HELPER_SRC)
+ chk('配置文件路径正确', "DCFG_FILE = '/etc/docker/daemon.json'" in HELPER_SRC)
+
+ print('\n--- 默认值(我替用户做的判断)---')
+ chk('IPv6 默认关(开了不等于能上 v6 外网)', DEFAULTS['ipv6'] is False)
+ chk('fixed-cidr-v6 默认留空(首次开启自动生成)', DEFAULTS['fixed_cidr_v6'] == '')
+ chk('experimental 默认关(26+ 不需要,个别版本会拒绝启动)',
+ DEFAULTS['experimental'] is False)
+ chk('镜像源默认不选(直连官方)', DEFAULTS['mirrors'] == [])
+ chk('日志滚动默认开(容器日志是撑爆小硬盘头号元凶)', DEFAULTS['log_rotate'] is True)
+ chk('live-restore 默认开(重启 dockerd 别杀容器)', DEFAULTS['live_restore'] is True)
+ chk('bip 默认不动(用 Docker 默认 172.17.0.1/16)', DEFAULTS['bip'] == '')
+
+ print('\n--- ULA 私有段生成 ---')
+ ulas = set()
+ for _ in range(30):
+ u = gen_ula()
+ ulas.add(u)
+ n = ipaddress.IPv6Network(u)
+ if not (str(n).startswith('fd') and n.prefixlen == 48 and n.is_private):
+ chk('生成的 ULA 合规:%s' % u, False)
+ break
+ else:
+ chk('30 次生成的都是 fd00::/8 私有 /48', True)
+ chk('每次生成都不一样(随机 40 bit)', len(ulas) > 1, '%d 种' % len(ulas))
+
+ print('\n--- fixed-cidr-v6 校验 ---')
+ ok, cidr, err, warn = chk_v6('fd12:3456:789a::/64')
+ chk('合法 ULA /64 通过', ok is True and cidr == 'fd12:3456:789a::/64')
+ chk('合法 ULA 无警告', warn == '')
+ ok, cidr, err, warn = chk_v6('fd00::1/64')
+ chk('带主机位也能归一化', ok is True and cidr == 'fd00::/64', cidr)
+ ok, _c, err, _w = chk_v6('fd12:3456:789a::')
+ chk('缺前缀长度被拒', ok is False and '前缀/长度' in err)
+ ok, _c, err, _w = chk_v6('fd12:3456:789a::/32')
+ chk('/32 被拒(超出 48–64)', ok is False and '/48' in err)
+ ok, _c, err, _w = chk_v6('fd12:3456:789a::/96')
+ chk('/96 被拒', ok is False)
+ ok, _c, err, _w = chk_v6('')
+ chk('空值被拒', ok is False)
+ ok, _c, err, _w = chk_v6('这不是ipv6/64')
+ chk('非法串被拒', ok is False)
+ ok, cidr, err, warn = chk_v6('2408:8207:2410::/64')
+ chk('公网段允许但有警告', ok is True and '公网段' in warn, warn)
+ ok, cidr, err, warn = chk_v6('fd12:3456:789a::/56')
+ chk('非 /64 提醒改回 /64', ok is True and '/64' in warn, warn)
+
+ print('\n--- 默认网桥网段 bip 校验 ---')
+ ok, cidr, err, warn = chk_bip('')
+ chk('留空直接通过且不写', ok is True and cidr == '')
+ ok, cidr, err, warn = chk_bip('172.17.0.1/16')
+ chk('不冲突的段无警告', ok is True and warn == '' and cidr == '172.17.0.0/16')
+ ok, cidr, err, warn = chk_bip('192.168.7.1/24')
+ chk('与 ens18 同段时给出冲突警告', ok is True and '重叠' in warn, warn)
+ ok, _c, err, _w = chk_bip('这不是ip')
+ chk('非法网段被拒', ok is False)
+ ok, _c, err, _w = chk_bip('10.0.0.1/33')
+ chk('/33 被拒', ok is False)
+ ok, _c, err, _w = chk_bip('10.0.0.1/31')
+ chk('/31 被拒(上限 /30)', ok is False)
+ ok, _c, err, _w = chk_bip('10.0.0.1/4')
+ chk('/4 被拒(下限 /8)', ok is False)
+ addrs = NS['_dcfg_parse_ipv4_addrs'](FAKE_IP_OUT)
+ chk('能从 ip -o -4 addr 里抽出网段',
+ any(str(a) == '192.168.7.0/24' for a in addrs), [str(a) for a in addrs])
+ chk('网段重叠判定准确',
+ NS['_dcfg_subnet_in_use'](ipaddress.IPv4Network('192.168.7.0/24'), addrs) is True
+ and NS['_dcfg_subnet_in_use'](ipaddress.IPv4Network('172.17.0.0/16'), addrs) is False)
+
+ print('\n--- 镜像源地址校验 ---')
+ ok, u, err = chk_url('https://docker.nju.edu.cn')
+ chk('标准 https 地址通过', ok is True and u == 'https://docker.nju.edu.cn')
+ ok, u, err = chk_url('docker.nju.edu.cn')
+ chk('缺协议时自动补 https', ok is True and u == 'https://docker.nju.edu.cn')
+ ok, u, err = chk_url('https://docker.nju.edu.cn/')
+ chk('结尾斜杠被去掉', ok is True and u == 'https://docker.nju.edu.cn')
+ ok, _u, err = chk_url('http://docker.nju.edu.cn')
+ chk('明文 http 被拒', ok is False and 'https' in err)
+ ok, _u, err = chk_url('https://a b.com')
+ chk('含空格被拒', ok is False)
+ ok, _u, err = chk_url('https://a.com"x')
+ chk('含引号被拒', ok is False)
+ ok, _u, err = chk_url('')
+ chk('空值被拒', ok is False)
+ ok, _u, err = chk_url('https://' + 'a' * 320)
+ chk('过长被拒', ok is False)
+ ok, _u, err = chk_url('https://.bad')
+ chk('主机名以点开头被拒', ok is False)
+ ok, _u, err = chk_url('https://a..b')
+ chk('主机名含连续点被拒', ok is False)
+
+ print('\n--- 预设镜像源清单 ---')
+ chk('至少 5 个预设', len(MIRRORS) >= 5, '%d 个' % len(MIRRORS))
+ chk('key 不重复', len(set(m['key'] for m in MIRRORS)) == len(MIRRORS))
+ for m in MIRRORS:
+ chk('%-8s 有名称/地址/说明' % m['key'],
+ bool(m.get('name')) and bool(m.get('url')) and len(m.get('note') or '') >= 10)
+ ali = [m for m in MIRRORS if '<' in (m.get('url') or '')]
+ chk('阿里云是占位地址(不参与写入与测速)', len(ali) == 1,
+ [m['key'] for m in ali])
+
+ print('\n--- 合并逻辑:保留用户自己的键 ---')
+ existing = {'data-root': '/mnt/ssd/docker', 'insecure-registries': ['10.0.0.5:5000']}
+ d, errs, warns = build_cfg(cfg_of(), existing)
+ chk('未纳管的键原样保留',
+ d.get('data-root') == '/mnt/ssd/docker'
+ and d.get('insecure-registries') == ['10.0.0.5:5000'])
+ chk('默认配置只有日志滚动与 live-restore',
+ sorted(d.keys()) == sorted(['data-root', 'insecure-registries',
+ 'log-driver', 'log-opts', 'live-restore']),
+ sorted(d.keys()))
+ chk('默认不写 ipv6', 'ipv6' not in d)
+ chk('默认不写 registry-mirrors', 'registry-mirrors' not in d)
+
+ print('\n--- 合并逻辑:纳管的键要能被彻底关掉 ---')
+ existing2 = {'ipv6': True, 'fixed-cidr-v6': 'fd00::/64',
+ 'ip6tables': True, 'experimental': True,
+ 'registry-mirrors': ['https://old.example.com'],
+ 'log-driver': 'journald'}
+ d, errs, warns = build_cfg(cfg_of(), existing2)
+ for k in ('ipv6', 'fixed-cidr-v6', 'ip6tables', 'experimental', 'registry-mirrors'):
+ chk('关掉后 %s 不再残留' % k, k not in d)
+ chk('旧的 log-driver 被本页的覆盖', d.get('log-driver') == 'json-file')
+
+ print('\n--- 合并逻辑:IPv6 开关 ---')
+ d, errs, warns = build_cfg(cfg_of(ipv6=True), {})
+ chk('开 IPv6 写 ipv6: true', d.get('ipv6') is True)
+ chk('开 IPv6 自动生成 fixed-cidr-v6',
+ (d.get('fixed-cidr-v6') or '').startswith('fd'), d.get('fixed-cidr-v6'))
+ chk('开 IPv6 同时写 ip6tables', d.get('ip6tables') is True)
+ chk('默认不写 experimental', 'experimental' not in d)
+ d, errs, warns = build_cfg(cfg_of(ipv6=True, experimental=True), {})
+ chk('勾上 experimental 才写', d.get('experimental') is True)
+ d, errs, warns = build_cfg(cfg_of(ipv6=True, fixed_cidr_v6='fdab:cdef:1234::/64'), {})
+ chk('指定网段时用它', d.get('fixed-cidr-v6') == 'fdab:cdef:1234::/64')
+ d, errs, warns = build_cfg(cfg_of(ipv6=True, fixed_cidr_v6='瞎写的'), {})
+ chk('网段非法时报错拦住', bool(errs) and d.get('ipv6') is None, errs)
+
+ print('\n--- 合并逻辑:镜像源 ---')
+ d, errs, warns = build_cfg(cfg_of(mirrors=['nju', 'tencent']), {})
+ chk('按选择顺序写 registry-mirrors',
+ d.get('registry-mirrors') == ['https://docker.nju.edu.cn',
+ 'https://mirror.ccs.tencentyun.com'],
+ d.get('registry-mirrors'))
+ d, errs, warns = build_cfg(cfg_of(mirrors=['nju'], mirror_custom='https://x.mirror.aliyuncs.com'), {})
+ chk('自定义源排在预设之后',
+ d.get('registry-mirrors') == ['https://docker.nju.edu.cn',
+ 'https://x.mirror.aliyuncs.com'])
+ d, errs, warns = build_cfg(cfg_of(mirrors=['nju'], mirror_custom='https://docker.nju.edu.cn'), {})
+ chk('自定义源与预设重复时去重',
+ d.get('registry-mirrors') == ['https://docker.nju.edu.cn'])
+ d, errs, warns = build_cfg(cfg_of(mirrors=['aliyun']), {})
+ chk('占位地址不会被写进配置', 'registry-mirrors' not in d)
+ d, errs, warns = build_cfg(cfg_of(mirror_custom='http://明文.com'), {})
+ chk('自定义源非法时报错', bool(errs) and 'registry-mirrors' not in d, errs)
+
+ print('\n--- 合并逻辑:日志滚动 ---')
+ d, errs, warns = build_cfg(cfg_of(log_rotate=True), {})
+ chk('写 json-file 驱动', d.get('log-driver') == 'json-file')
+ chk('默认 10m / 3 份', d.get('log-opts') == {'max-size': '10m', 'max-file': '3'})
+ chk('日志参数非法时回落到 10m',
+ log_opts({'log_max_size': '很大', 'log_max_file': 'x'}) == {'max-size': '10m', 'max-file': '3'})
+ chk('份数钳制在 1–20',
+ log_opts({'log_max_size': '5m', 'log_max_file': 999})['max-file'] == '20'
+ and log_opts({'log_max_size': '5m', 'log_max_file': 0})['max-file'] == '1')
+ d, errs, warns = build_cfg(cfg_of(log_rotate=False), {'log-driver': 'journald'})
+ chk('关掉日志滚动时不写 log-opts', 'log-opts' not in d)
+
+ print('\n--- 渲染结果 ---')
+ text = render_cfg({'ipv6': True, 'live-restore': True})
+ chk('是缩进 2 格的 JSON', text.startswith('{\n "ipv6"'), repr(text[:20]))
+ chk('结尾有换行', text.endswith('\n'))
+ chk('能被解析回来', json.loads(text) == {'ipv6': True, 'live-restore': True})
+ chk('中文不被转义', render_cfg({'x': '中文'}) == '{\n "x": "中文"\n}\n')
+
+ print('\n--- 安全:保存绝不动 Docker ---')
+ i_save = HELPER_SRC.index('def _dcfg_save_op(p):')
+ i_restart = HELPER_SRC.index('def _dcfg_restart_op(p):')
+ seg_save = HELPER_SRC[i_save:i_restart]
+ chk('保存流程里没有 systemctl', 'systemctl' not in seg_save)
+ chk('保存流程里没有 docker 命令调用', 'docker restart' not in seg_save
+ and 'dockerd' not in seg_save)
+ chk('重启需要显式确认', "p.get('confirm')" in HELPER_SRC
+ and 'NEED_CONFIRM' in HELPER_SRC)
+ chk('构建保护模式下拒绝重启', 'BUILD_MODE' in HELPER_SRC
+ and '已拒绝重启 Docker' in HELPER_SRC)
+ chk('Docker 未安装时拒绝重启', '无需重启' in HELPER_SRC)
+ chk('解析不了现有文件时拒绝保存(不覆盖用户数据)',
+ '本页不会覆盖它' in HELPER_SRC)
+ chk('备份文件名做了白名单校验',
+ re.search(r"r'\^daemon-\\d\{8\}-\\d\{6\}\\.json\$'", HELPER_SRC) is not None)
+ chk('还原做了 realpath 防穿越',
+ 'os.path.realpath(os.path.join(DCFG_BAK_DIR, fn))' in HELPER_SRC
+ and 'src.startswith(root + os.sep)' in HELPER_SRC)
+ chk('测速带 --noproxy(不被环境代理掩盖)', "'--noproxy', '*'" in HELPER_SRC)
+ chk('测速认 200 与 401 为可用', "'200', '401'" in HELPER_SRC)
+ chk('缺少 curl 时给出明确提示', '缺少 curl' in HELPER_SRC)
+
+ print('\n--- 前端接线 ---')
+ chk('菜单里有 dcfg', re.search(r"k: 'dcfg'", APP_SRC) is not None)
+ chk('菜单文案含「Docker 引擎配置」', 'Docker 引擎配置' in APP_SRC)
+ chk('VIEWS 注册 dcfg', re.search(r"^\s*dcfg: viewDcfg,", APP_SRC, re.M) is not None)
+ for fn in ('viewDcfg', 'dcfgLoad', 'dcfgRender', 'dcfgSave', 'dcfgPreview',
+ 'dcfgForm', 'dcfgBind', 'dcfgAskRestart'):
+ chk('前端函数 %s' % fn, ('function %s(' % fn) in APP_SRC)
+ chk('调用 /api/dcfg', "'/api/dcfg'" in APP_SRC)
+ chk('配置代码实时显示', 'id="dc-code-pre"' in APP_SRC)
+ chk('预览走后端(前端不自己拼 JSON)',
+ "dcfgForm('preview')" in APP_SRC)
+ chk('保存后用服务端返回内容校准预览',
+ "pre.textContent = d.content" in APP_SRC)
+ chk('重启前有二次确认弹窗', '重启 Docker 使配置生效' in APP_SRC)
+ chk('重启确认文案说明会中断容器', '中断所有正在运行的容器' in APP_SRC)
+ chk('Docker 页面有跳转入口', "onclick=\"go('dcfg')\"" in APP_SRC)
+ chk('未安装 Docker 时给出说明', '还没有安装 Docker' in APP_SRC)
+ chk('未纳管键会提示用户会保留', '原样保留' in APP_SRC)
+ chk('experimental 有背景说明(哪版需要)', '24 / 25' in APP_SRC)
+
+ print('\n' + '=' * 60)
+ print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/t-ddns.py b/_dev/t-ddns.py
new file mode 100644
index 0000000..a9a3e33
--- /dev/null
+++ b/_dev/t-ddns.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+"""DDNS 纯逻辑测试:公网/私网判定 + 四种组合 + 记录名拼接。"""
+import re, sys, io
+
+SRC = r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py"
+src = io.open(SRC, encoding='utf-8').read()
+
+def grab(name):
+ m = re.search(r'^def %s\(.*?(?=\n(?:def |[A-Z_]+ *=|# ---|class ))'
+ % re.escape(name), src, re.S | re.M)
+ if not m:
+ raise SystemExit('NOT FOUND: ' + name)
+ return m.group(0)
+
+def grab_var(name):
+ m = re.search(r'^%s = \{.*?^\}' % re.escape(name), src, re.S | re.M)
+ if not m:
+ m = re.search(r'^%s = \[.*?^\]' % re.escape(name), src, re.S | re.M)
+ if not m:
+ raise SystemExit('NOT FOUND VAR: ' + name)
+ return m.group(0)
+
+NS = {'re': re}
+exec(grab('_is_private_v4'), NS)
+exec(grab('_is_global_v6'), NS)
+exec(grab('_ddns_record_name'), NS)
+exec(grab_var('PUBIP_COMBO_NOTE'), NS)
+
+P, G, R, NOTE = NS['_is_private_v4'], NS['_is_global_v6'], NS['_ddns_record_name'], NS['PUBIP_COMBO_NOTE']
+
+fails = 0
+def chk(label, got, want):
+ global fails
+ ok = got == want
+ if not ok: fails += 1
+ print('[%s] %-46s got=%-6s want=%s' % ('OK' if ok else 'FAIL', label, got, want))
+
+print('=== IPv4 私网/公网判定 ===')
+for ip, want in [('192.168.1.1', True), ('10.0.0.1', True), ('172.16.5.5', True),
+ ('172.32.5.5', False), ('100.64.0.1', True), ('100.128.0.1', False),
+ ('169.254.1.1', True), ('127.0.0.1', True), ('8.8.8.8', False),
+ ('1.1.1.1', False), ('223.5.5.5', False), ('224.0.0.1', True),
+ ('116.25.100.7', False)]:
+ chk('v4 ' + ip, P(ip), want)
+
+print('\n=== IPv6 全球单播判定 ===')
+for ip, want in [('2408:8207:1234::1', True), ('2001:4860:4860::8888', True),
+ ('fe80::1', False), ('fc00::1', False), ('fd12:3456::1', False),
+ ('ff02::1', False), ('::1', False), ('2400:3200::1', True),
+ ('240e:3b7:1234::abcd', True)]:
+ chk('v6 ' + ip, G(ip), want)
+
+print('\n=== DDNS 记录名拼接 ===')
+for cfg, want in [({'subdomain': 'home', 'domain': 'example.com'}, 'home.example.com'),
+ ({'subdomain': '', 'domain': 'example.com'}, 'example.com'),
+ ({'subdomain': '@', 'domain': 'example.com'}, 'example.com'),
+ ({'subdomain': '.www.', 'domain': 'a.cn'}, 'www.a.cn')]:
+ chk('rec ' + str(cfg), R(cfg), want)
+
+print('\n=== 四种组合说明齐全性 ===')
+for k in ('both', 'v4only', 'v6only', 'neither'):
+ has = k in NOTE and NOTE[k].get('conclusion') and len(NOTE[k].get('advice') or []) >= 3
+ chk('combo ' + k, bool(has), True)
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-deps.py b/_dev/t-deps.py
new file mode 100644
index 0000000..df73363
--- /dev/null
+++ b/_dev/t-deps.py
@@ -0,0 +1,570 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""审计:扫出全项目真实用到的外部命令 / apt 包名,与 DEPS 清单对拍。
+
+不是靠人肉记忆补清单 —— 直接从源码 AST 里抽:
+ ① sh(['xxx', ...]) / sh(['sudo','xxx']) 的第一或第二个 token
+ ② command -v xxx / which xxx 里的 xxx
+ ③ apt-get install / apt install 后面的包名
+ ④ FS_TYPES 里的 pkg
+ ⑤ 各类 PKG / pkg = 'xxx' 常量
+再和 backend/drouter-helper.py 的 DEPS 比对,输出「用到但 DEPS 没登记」的项。
+"""
+import ast
+import io
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BACKEND = os.path.join(ROOT, 'backend')
+
+# 只看后端:前端 js 不直接执行系统命令
+TARGETS = []
+for fn in sorted(os.listdir(BACKEND)):
+ if fn.endswith('.py'):
+ TARGETS.append(os.path.join(BACKEND, fn))
+
+# 已知的非「系统命令」:Python 内建 / shell 内建 / 我们自己的包装
+# apt 系不登记:装 drouter 的机器必然有 apt,装它毫无意义
+NOT_CMD = {
+ 'sudo', 'sh', 'bash', 'true', 'false', 'echo', 'cat', 'test', 'cd',
+ 'python3', 'python', 'sys', 'systemctl', # systemctl 单独登记
+ 'apt', 'apt-get', 'apt-cache', 'apt-mark', 'dpkg', 'dpkg-query',
+}
+# shell 内建,不该登记成命令依赖
+SHELL_BUILTIN = {
+ 'command', 'test', 'echo', 'cd', 'true', 'false', 'set', 'export',
+ 'if', 'then', 'fi', 'for', 'do', 'done', 'while', 'case', 'esac',
+}
+
+
+def _walk_str_list(node):
+ """从 sh(['a','b']) 这种 list 里取常量字符串。"""
+ if isinstance(node, ast.List):
+ out = []
+ for e in node.elts:
+ if isinstance(e, ast.Constant) and isinstance(e.value, str):
+ out.append(e.value)
+ return out
+ return None
+
+
+def scan():
+ cmds = {} # cmd -> set(文件)
+ pkgs = {} # pkg -> set(文件)
+ for path in TARGETS:
+ base = os.path.basename(path)
+ try:
+ src = io.open(path, encoding='utf-8').read()
+ except Exception:
+ continue
+ try:
+ tree = ast.parse(src)
+ except SyntaxError as e:
+ print('[SKIP] %s 语法错误: %s' % (base, e))
+ continue
+
+ # ① sh([...]) 调用
+ for n in ast.walk(tree):
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Name):
+ if n.func.id in ('sh', 'run', 'sh_out', 'sudo') and n.args:
+ lst = _walk_str_list(n.args[0])
+ if not lst:
+ continue
+ # 去掉 sudo 前缀
+ if lst and lst[0] == 'sudo':
+ lst = lst[1:]
+ if not lst:
+ continue
+ c = lst[0]
+ if c in ('sh', 'bash'):
+ # sh -c '...':只取管道 / 分号 / && 之后的第一个词,
+ # 否则会把 docker images inspect 这种子命令词当成命令抓进来。
+ inner = ' '.join(lst[1:])
+ parts = re.split(r'\|\||&&|\||;|\n|\$\(', inner)
+ for seg in parts:
+ seg = seg.strip()
+ if seg.startswith('-'): # sh -c 的 -c 本身
+ continue
+ w = seg.split()[0] if seg.split() else ''
+ w = w.strip('"\'')
+ if not w or w in SHELL_BUILTIN:
+ continue
+ if not re.match(r'^[a-z][a-z0-9_.-]*$', w):
+ continue
+ if w.endswith('/'): # 路径片段
+ continue
+ cmds.setdefault(w, set()).add(base)
+ continue
+ if c in SHELL_BUILTIN:
+ continue
+ cmds.setdefault(c, set()).add(base)
+
+ # ② 字符串里的 `command -v xxx`
+ for m in re.finditer(r"command\s+-v\s+([a-zA-Z0-9_.-]+)", src):
+ cmds.setdefault(m.group(1), set()).add(base + ':command-v')
+
+ # ③ apt install
+ for m in re.finditer(r"apt(?:-get)?\s+install[^'\"]*?([a-z0-9][a-z0-9.+-]*)", src):
+ pass
+ # apt install 多以 list 形式出现,用 AST 抽更准
+ for n in ast.walk(tree):
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Name):
+ if n.func.id in ('sh', 'run', 'sudo') and n.args:
+ lst = _walk_str_list(n.args[0]) or []
+ if 'install' in lst and ('apt' in lst or 'apt-get' in lst):
+ for t in lst:
+ if t in ('apt', 'apt-get', 'install', '-y', '-q',
+ '--no-install-recommends', 'sudo', 'sh', '-c'):
+ continue
+ if re.match(r'^[a-z0-9][a-z0-9.+-]+$', t):
+ pkgs.setdefault(t, set()).add(base + ':apt')
+ if isinstance(n, ast.Constant) and isinstance(n.value, str):
+ v = n.value
+ if re.match(r'^\s*apt(-get)?\s+install', v):
+ for t in v.split():
+ if re.match(r'^[a-z0-9][a-z0-9.+-]+$', t) and t not in (
+ 'apt', 'apt-get', 'install', '-y', '-q',
+ '--no-install-recommends', 'DEBIAN_FRONTEND=noninteractive'):
+ pkgs.setdefault(t, set()).add(base + ':aptstr')
+
+ # ④ 常量里的 'pkg': 'xxx'
+ for m in re.finditer(r"""['"]pkg['"]\s*:\s*['"]([a-z0-9][a-z0-9.+-]*)['"]""", src):
+ pkgs.setdefault(m.group(1), set()).add(base + ':pkgfield')
+
+ return cmds, pkgs
+
+
+def _is_pure(node):
+ """判断表达式是否只由常量 / 名字 / 加减 / 容器组成(不含调用)。
+
+ 用来挑出「可以安全提前求值」的模块级常量 —— 带函数调用的(os.makedirs、
+ open 之类)绝不能在本机 exec,否则测试自己就会去动真实路径。
+ """
+ for n in ast.walk(node):
+ if isinstance(n, (ast.Call, ast.Attribute, ast.Subscript,
+ ast.Lambda, ast.Await, ast.Yield)):
+ return False
+ return True
+
+
+def _exec_const(path, name):
+ """从源码里 exec 出某个模块级常量,避免 import 整个后端模块。"""
+ src = io.open(path, encoding='utf-8').read()
+ tree = ast.parse(src)
+ # 先把同层的纯常量按出现顺序求值进命名空间。
+ # DEPS 里现在会引用别处定义的常量(例如 opensoho 那条引用 OH_BIN),
+ # 只 exec 目标那一个节点会 NameError。逐个求值天然解决前后依赖,
+ # 求值失败的(引用了未注入的名字)直接跳过,不影响结果。
+ ns = {}
+ for n in tree.body:
+ if not isinstance(n, ast.Assign) or not _is_pure(n.value):
+ continue
+ try:
+ mod = ast.Module(body=[n], type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ except Exception:
+ pass
+ for n in tree.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ mod = ast.Module(body=[ast.Assign(
+ targets=[ast.Name(name, ast.Store())], value=n.value)],
+ type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '<' + name + '>', 'exec'), ns)
+ return ns[name]
+ return None
+
+
+def load_deps():
+ return _exec_const(os.path.join(BACKEND, 'drouter-helper.py'), 'DEPS') or []
+
+
+def load_groups():
+ g = _exec_const(os.path.join(BACKEND, 'drouter-helper.py'), 'DEP_GROUPS') or []
+ return dict(g)
+
+
+def main():
+ cmds, pkgs = scan()
+ deps = load_deps()
+ global DEP_GROUP_NAME
+ DEP_GROUP_NAME = load_groups()
+
+ dep_keys = set(d[0] for d in deps)
+ dep_targets = set()
+ for d in deps:
+ dep_targets.add(d[0])
+ if d[2] in ('cmd', 'mod'):
+ for c in str(d[3]).split('|'):
+ dep_targets.add(c.strip())
+ dep_pkgs = set(d[6] for d in deps if d[6])
+
+ # 命令 → 提供它的常见 Debian 包(人工维护的映射表,用于提示)
+ CMD2PKG = {
+ 'ip': 'iproute2', 'ss': 'iproute2', 'tc': 'iproute2', 'rdma': 'iproute2',
+ 'nft': 'nftables', 'iptables': 'iptables',
+ 'dnsmasq': 'dnsmasq', 'radvd': 'radvd', 'dhcpcd': 'dhcpcd-base',
+ 'pppd': 'ppp', 'pppoeconf': 'pppoeconf', 'pon': 'ppp', 'poff': 'ppp',
+ 'chronyd': 'chrony', 'chronyc': 'chrony',
+ 'miniupnpd': 'miniupnpd',
+ 'ethtool': 'ethtool', 'curl': 'curl', 'wget': 'wget',
+ 'conntrack': 'conntrack', 'sqlite3': 'sqlite3', 'openssl': 'openssl',
+ 'iperf3': 'iperf3', 'traceroute': 'traceroute', 'tracepath': 'iputils-tracepath',
+ 'dig': 'dnsutils', 'nslookup': 'dnsutils', 'host': 'bind9-host',
+ 'ping': 'iputils-ping', 'arping': 'iputils-arping', 'ping6': 'iputils-ping',
+ 'zip': 'zip', 'unzip': 'unzip', '7z': 'p7zip-full', '7za': 'p7zip-full',
+ 'vim': 'vim', 'vim.tiny': 'vim-tiny', 'nano': 'nano',
+ 'htop': 'htop', 'iftop': 'iftop', 'socat': 'socat', 'nc': 'netcat-openbsd',
+ 'logrotate': 'logrotate', 'sudo': 'sudo', 'systemctl': 'systemd',
+ 'journalctl': 'systemd', 'networkctl': 'systemd', 'nmcli': 'network-manager',
+ 'lsblk': 'util-linux', 'blkid': 'util-linux', 'findmnt': 'util-linux',
+ 'mount': 'util-linux', 'umount': 'util-linux', 'lsusb': 'usbutils',
+ 'mkfs.exfat': 'exfatprogs', 'fsck.exfat': 'exfatprogs',
+ 'mkfs.ext4': 'e2fsprogs', 'mkfs.ext3': 'e2fsprogs', 'mkfs.ext2': 'e2fsprogs',
+ 'resize2fs': 'e2fsprogs', 'tune2fs': 'e2fsprogs', 'e2fsck': 'e2fsprogs',
+ 'mkfs.ntfs': 'ntfs-3g', 'ntfsfix': 'ntfs-3g',
+ 'mkfs.vfat': 'dosfstools', 'fsck.vfat': 'dosfstools',
+ 'mkfs.xfs': 'xfsprogs', 'xfs_repair': 'xfsprogs', 'xfs_growfs': 'xfsprogs',
+ 'mkfs.btrfs': 'btrfs-progs', 'btrfs': 'btrfs-progs',
+ 'mkfs.f2fs': 'f2fs-tools', 'fsck.f2fs': 'f2fs-tools',
+ 'smbd': 'samba', 'nmbd': 'samba', 'smbpasswd': 'samba-common-bin',
+ 'exportfs': 'nfs-kernel-server', 'showmount': 'nfs-common',
+ 'tcpdump': 'tcpdump', 'mtr': 'mtr-tiny', 'etherwake': 'etherwake',
+ 'wakeonlan': 'wakeonlan', 'docker': 'docker.io',
+ 'modprobe': 'kmod', 'lsmod': 'kmod', 'modinfo': 'kmod',
+ 'smartctl': 'smartmontools', 'smartd': 'smartmontools',
+ 'sensors': 'lm-sensors', 'dmidecode': 'dmidecode', 'lscpu': 'util-linux',
+ 'lspci': 'pciutils', 'hwinfo': 'hwinfo', 'udevadm': 'udev',
+ 'hostapd': 'hostapd', 'wpa_supplicant': 'wpasupplicant', 'iw': 'iw',
+ 'vnstat': 'vnstat', 'nload': 'nload', 'bmon': 'bmon',
+ 'snmpd': 'snmpd', 'snmpwalk': 'snmp', 'snmpget': 'snmp',
+ 'lpstat': 'cups-client', 'lpadmin': 'cups-client', 'cupsd': 'cups',
+ 'lp': 'cups-client', 'lpinfo': 'cups-client', 'cupsctl': 'cups-client',
+ 'usbip': 'usbip', 'lsusb.py': 'usbutils',
+ 'jq': 'jq', 'rsync': 'rsync', 'tar': 'tar', 'gzip': 'gzip',
+ 'xz': 'xz-utils', 'zstd': 'zstd',
+ 'update-ca-certificates': 'ca-certificates',
+ 'c_rehash': 'openssl',
+ 'dpkg': 'dpkg', 'apt': 'apt', 'apt-get': 'apt',
+ 'kill': 'procps', 'pkill': 'procps', 'ps': 'procps', 'free': 'procps',
+ 'top': 'procps', 'uptime': 'procps', 'vmstat': 'procps',
+ 'getent': 'libc-bin', 'id': 'coreutils', 'stat': 'coreutils',
+ 'df': 'coreutils', 'du': 'coreutils', 'ls': 'coreutils',
+ 'hostname': 'hostname', 'hostnamectl': 'systemd',
+ 'timedatectl': 'systemd', 'locale-gen': 'locales',
+ 'sysctl': 'procps', 'iptables-save': 'iptables',
+ 'frr': 'frr', 'bird': 'bird2', 'vtysh': 'frr',
+ 'sha256sum': 'coreutils', 'md5sum': 'coreutils',
+ }
+
+ # 「必须被 DEPS 覆盖」的项:这些是本项目的关键功能,
+ # 缺任何一个都会有页面能点但功能悄悄不工作。
+ MUST_COVER = {
+ # 文件系统工具:外置设备格式化页面会直接按 pkg 提示安装
+ 'e2fsprogs': 'mkfs.ext4 / mkfs.ext3(ext4 格式化)',
+ 'xfsprogs': 'mkfs.xfs(XFS 格式化)',
+ 'btrfs-progs': 'mkfs.btrfs(Btrfs 格式化)',
+ 'f2fs-tools': 'mkfs.f2fs(F2FS 格式化)',
+ 'exfatprogs': 'mkfs.exfat(exFAT 格式化)',
+ 'ntfs-3g': 'mkfs.ntfs(NTFS 格式化)',
+ 'dosfstools': 'mkfs.vfat(FAT32 格式化)',
+ # 系统工具
+ 'util-linux': 'lsblk / mount(外置存储与挂载)',
+ 'iproute2': 'ip / ss / tc(网络与 QoS)',
+ 'kmod': 'modprobe / modinfo(QoS 内核模块加载)',
+ 'procps': 'ps / pkill(进程查询)',
+ 'tar': 'tar(主题 ZIP 导出等打包)',
+ 'logrotate': 'logrotate(日志轮转)',
+ 'curl': 'curl(公网 IP 探测 / DDNS 上报)',
+ # 本轮新增功能
+ 'snmpd': 'snmpd(SNMP 监控服务)',
+ 'cups': 'cups(打印服务)',
+ 'cups-client': 'lpadmin / lpstat(打印机管理命令)',
+ 'usbutils': 'lsusb(USB 打印机识别)',
+ }
+
+ print('=' * 70)
+ print('DEPS 清单:%d 项;扫描到命令 %d 个、包名 %d 个' % (len(deps), len(cmds), len(pkgs)))
+ print('=' * 70)
+
+ fails = []
+
+ # ① 必须覆盖的包
+ print('\n【1】关键依赖覆盖检查:')
+ for pkg, why in sorted(MUST_COVER.items()):
+ hit = pkg in dep_pkgs
+ print(' %s %-16s %s' % ('[OK]' if hit else '[NG]', pkg, why))
+ if not hit:
+ fails.append('DEPS 缺少包:%s(%s)' % (pkg, why))
+
+ # ② FS_TYPES 里每个 pkg 都要能在 DEPS 找到(否则格式化页面提示安装、
+ # 但自检页的就绪列表里永远看不到它 —— 用户反馈的正是这个)
+ print('\n【2】格式化工具与 DEPS 对拍:')
+ fs = load_fs_types()
+ for f in fs:
+ hit = f['pkg'] in dep_pkgs
+ print(' %s %-14s %-14s pkg=%s' % ('[OK]' if hit else '[NG]',
+ f['n'], f['mkfs'], f['pkg']))
+ if not hit:
+ fails.append('FS_TYPES 的 %s 用包 %s,DEPS 未登记' % (f['mkfs'], f['pkg']))
+
+ # ③ 代码里用到、DEPS 没登记的命令。
+ # 按「提供它的包」比对而不是按命令名:du/ls 归 coreutils、modinfo 归 kmod,
+ # DEPS 里登记的是 df / modprobe 这个代表命令,按命令比对会全是误报。
+ unreg = []
+ for c in sorted(cmds):
+ if c in NOT_CMD or c in SHELL_BUILTIN or c in dep_targets:
+ continue
+ p = CMD2PKG.get(c)
+ if p and p not in dep_pkgs:
+ unreg.append((c, p))
+ print('\n【3】代码用到但 DEPS 未登记的命令(按提供包比对):')
+ for c, p in unreg:
+ print(' [NG] %-20s -> %-20s %s' % (c, p, ','.join(sorted(cmds[c])[:2])))
+ if not unreg:
+ print(' [OK] 已登记命令全部有对应包')
+ if unreg:
+ fails.append('以下命令已在代码中使用但 DEPS 未登记其包:%s'
+ % ', '.join('%s(%s)' % (c, p) for c, p in unreg))
+
+ # ④ 代码里引用的包名
+ unreg_pkg = sorted(p for p in pkgs if p not in dep_pkgs and p not in dep_keys)
+ print('\n【4】代码引用但 DEPS 未登记的包名:%s'
+ % (', '.join(unreg_pkg) if unreg_pkg else '(无)'))
+
+ # ⑤ DEPS 结构自检
+ print('\n【5】DEPS 条目结构自检:')
+ seen = set()
+ for d in deps:
+ if len(d) != 8:
+ fails.append('DEPS 条目字段数不是 8(key,name,kind,target,required,note,pkg,group):%r' % (d,))
+ continue
+ key, name, kind, target, req, note, pkg, group = d
+ if key in seen:
+ fails.append('DEPS 有重复 key:%s' % key)
+ seen.add(key)
+ if kind not in ('cmd', 'file', 'mod', 'svc'):
+ fails.append('DEPS %s 的 kind 非法:%s' % (key, kind))
+ if not name or not target:
+ fails.append('DEPS %s 缺少 name 或 target' % key)
+ if not note:
+ fails.append('DEPS %s 缺少中文说明' % key)
+ if group not in DEP_GROUP_NAME:
+ fails.append('DEPS %s 的分组 %r 不在 DEP_GROUPS 里' % (key, group))
+ print(' %s 共 %d 项,结构全部合法' % ('[OK]' if not fails else '[NG]', len(deps)))
+
+ # ⑥ 打包声明与 DEPS 同步:离线脚本 / deb control 至少要覆盖必需项
+ print('\n【6】打包依赖声明同步检查:')
+ offline = read_offline_deps()
+ control = read_control_deps()
+ req_pkgs = sorted(set(d[6] for d in deps if d[4] and d[6]))
+ for p in req_pkgs:
+ in_o = p in offline
+ in_c = p in control
+ flag = '[OK]' if (in_o or in_c) else '[NG]'
+ print(' %s %-18s 离线脚本=%s control=%s' % (flag, p,
+ '有' if in_o else '无',
+ '有' if in_c else '无'))
+ if not (in_o or in_c):
+ fails.append('必需依赖 %s 既不在 make-offline-deps.sh 也不在 control 里' % p)
+
+ # ⑦ 解包处元数一致性:DEPS 元组改字段数时,helper 里每个
+ # `for ... in DEPS` 都要同步改。漏一处就是运行时 ValueError,
+ # 而且只在用户点「开始检测」时才炸 —— 必须在这里挡住。
+ print('\n【7】helper 解包处元数一致性:')
+ helper_src = io.open(os.path.join(BACKEND, 'drouter-helper.py'),
+ encoding='utf-8').read()
+ unpacks = re.findall(r'for\s+([A-Za-z_][\w,\s]*?)\s+in\s+DEPS\s*:', helper_src)
+ if not unpacks:
+ fails.append('helper 里找不到任何 `for ... in DEPS` 解包')
+ for u in unpacks:
+ n = len([x for x in u.split(',') if x.strip()])
+ flag = '[OK]' if n == 8 else '[NG]'
+ print(' %s 解包 %d 个字段:%s' % (flag, n, ' '.join(u.split())))
+ if n != 8:
+ fails.append('helper 有 DEPS 解包用了 %d 个字段(应为 8):%s' % (n, u.strip()))
+
+ # ⑧ 危险包绝不能标成「必需」:一键安装会装下所有必需项,
+ # 把桌面环境 / 常驻服务塞进去对路由器是灾难
+ print('\n【8】危险包不得标为必需:')
+ DANGER = {
+ 'xfce4': '桌面环境', 'xserver-xorg': '图形服务',
+ 'samba': '装完自动拉起 smbd', 'nfs-kernel-server': '装完自动拉起 nfs-server',
+ 'docker.io': '装完自动拉起 docker 常驻', 'snmpd': '装完自动拉起 snmpd 常驻',
+ 'cups': '装完自动拉起 cups 常驻', 'smartmontools': '装完自动拉起 smartd 常驻',
+ }
+ for d in deps:
+ key, name, kind, target, req, note, pkg, group = d
+ if pkg in DANGER and req:
+ print(' [NG] %-18s %s' % (key, DANGER[pkg]))
+ fails.append('DEPS 的 %s(%s)被标为必需,一键安装会把它装上:%s'
+ % (key, pkg, DANGER[pkg]))
+ if not any(d[6] in DANGER and d[4] for d in deps):
+ print(' [OK] 会拉起常驻服务 / 桌面的包全部是可选')
+
+ # ⑨ 前端契约:分组与搜索相关的函数和字段都在
+ print('\n【9】前端契约:')
+ app_js = io.open(os.path.join(ROOT, 'web', 'app.js'), encoding='utf-8').read()
+ need = [
+ ('renderDepList', '过滤/搜索只重渲染、不重打接口'),
+ ('depMatch', '按名称/命令/包名/说明匹配'),
+ ('DEP_LAST', '缓存上次检测结果'),
+ ('DEP_FILTER', '分组过滤状态'),
+ ("x.group === DEP_FILTER", '按后端返回的 group 过滤'),
+ ("d.groups || []", '消费后端 groups 列表'),
+ ("x.group_name", '渲染分组名标签'),
+ ]
+ for token, why in need:
+ hit = token in app_js
+ print(' %s %-32s %s' % ('[OK]' if hit else '[NG]', token, why))
+ if not hit:
+ fails.append('前端缺少 %s(%s)' % (token, why))
+ css = io.open(os.path.join(ROOT, 'web', 'app.css'), encoding='utf-8').read()
+ hit = '.dep-grp.on' in css
+ print(' %s %-32s %s' % ('[OK]' if hit else '[NG]', '.dep-grp.on', '分组按钮选中态样式'))
+ if not hit:
+ fails.append('CSS 缺少 .dep-grp.on 选中态(点了看不出选中了哪个)')
+
+ print('\n' + '=' * 70)
+ if fails:
+ print('失败 %d 项:' % len(fails))
+ for f in fails:
+ print(' - ' + f)
+ _EXTRA_FAIL.append(len(fails))
+ else:
+ print('结果: 全部通过')
+
+
+def read_offline_deps():
+ p = os.path.join(ROOT, 'scripts', 'make-offline-deps.sh')
+ try:
+ src = io.open(p, encoding='utf-8').read()
+ except OSError:
+ return set()
+ m = re.search(r'DEPS="([^"]*)"', src, re.S)
+ return set(m.group(1).split()) if m else set()
+
+
+def read_control_deps():
+ p = os.path.join(ROOT, 'packaging', 'deb', 'control')
+ try:
+ src = io.open(p, encoding='utf-8').read()
+ except OSError:
+ return set()
+ out = set()
+ keep = False
+ for line in src.splitlines():
+ if re.match(r'^(Depends|Recommends|Suggests|Pre-Depends):', line):
+ keep = True
+ body = line.split(':', 1)[1]
+ elif re.match(r'^\s', line) and keep:
+ body = line
+ else:
+ keep = False
+ continue
+ for t in re.split(r'[,\s|()]+', body):
+ t = t.strip()
+ if re.match(r'^[a-z0-9][a-z0-9.+-]+$', t):
+ out.add(t)
+ return out
+
+
+def load_fs_types():
+ return _exec_const(os.path.join(BACKEND, 'drouter-helper.py'), 'FS_TYPES') or []
+
+
+# main() 里收集到的失败数(main 不再自行 sys.exit,交给文件末尾统一决策)
+_EXTRA_FAIL = []
+
+
+def t(name, cond):
+ """额外检查用的小断言:打印一行 [OK]/[NG] 并返回失败计数(0 或 1)。"""
+ print(' %s %s' % ('[OK]' if cond else '[NG]', name))
+ return 0 if cond else 1
+
+
+def check_pkg_names():
+ """包名必须是 Debian 13 真有的。
+
+ 踩过的坑:Docker Compose v2 在 Debian 13 叫 docker-compose,
+ 而 docker-compose-v2 是 Ubuntu 的叫法。写错包名的后果不是「装不上」
+ 而是「整条 apt-get 命令一起失败」—— apt 只要发现一个查不到的包名
+ 就整体中止,同命令里其它正常的包也一个都装不上(已实测)。
+ """
+ helper = io.open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
+ app = io.open(os.path.join(ROOT, 'web', 'app.js'), encoding='utf-8').read()
+ ctl = io.open(os.path.join(ROOT, 'packaging', 'deb', 'control'), encoding='utf-8').read()
+
+ def _local_t(name, cond):
+ print(' %s %s' % ('[OK]' if cond else '[NG]', name))
+ return 0 if cond else 1
+
+ print('\n---- 包名合规(Debian 13) ----')
+ n = 0
+ # docker-compose-v2 允许作为「候选之一」出现在 DOCKER_PKGS 里(跨发行版兼容),
+ # 但不能作为唯一写法出现在用户可见的安装命令 / control 声明里。
+ n += _local_t('安装包声明里不再出现 Ubuntu 专有包名 docker-compose-v2',
+ 'docker-compose-v2' not in ctl)
+ n += _local_t('前端提示里不再出现 Ubuntu 专有包名',
+ 'apt-get install -y docker.io docker-compose-v2' not in app)
+ n += _local_t('DOCKER_PKGS 首选 Debian 名 docker-compose',
+ re.search(r"DOCKER_PKGS\s*=\s*\['docker\.io',\s*'docker-compose'", helper) is not None)
+ n += _local_t('DOCKER_PKGS 仍保留 Ubuntu 候选(跨发行版)',
+ re.search(r"DOCKER_PKGS\s*=.*'docker-compose-v2'", helper) is not None)
+ n += _local_t('有按可用性过滤包名的函数', 'def _pkgs_available(' in helper)
+ n += _local_t('depcheck 安装前过滤不存在的包', '_pkgs_available(cand)' in helper)
+ n += _local_t('docker 安装命令用过滤后的列表',
+ "'install_cmd': 'apt-get install -y ' + ' '.join(have)" in helper)
+ n += _local_t('过滤后为空时给出明确失败原因', '在当前 apt 源里都找不到' in helper)
+ n += _local_t('compose 判断同时认两个包名',
+ "inst('docker-compose') or inst('docker-compose-v2')" in helper)
+ n += _local_t('DEPS 里 compose 的包名带双候选',
+ "'docker-compose|docker-compose-v2'" in helper)
+ return n
+
+
+def check_atomic_write():
+ """所有「会写生产配置文件」的路径都必须走 tmp + os.replace 原子替换。
+
+ 为什么单列一节:open(path,'w') 会先把原文件截断成 0 字节,再逐块写新内容。
+ 在这两步之间,如果守护进程恰好 reload、或者机器掉电,它读到的就是一个
+ 残缺文件 —— 轻则服务起不来,重则按半截规则放行(防火墙 / 共享目录尤其危险)。
+ os.replace 是同文件系统内的原子重命名,读者只会看到完整的新旧两版之一。
+ """
+ helper = io.open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
+ lines = helper.split('\n')
+
+ def seg_of(name):
+ try:
+ i = next(k for k, l in enumerate(lines) if l.startswith('def %s(' % name))
+ except StopIteration:
+ return ''
+ j = next((k for k in range(i + 1, len(lines)) if lines[k].startswith('def ')), len(lines))
+ return '\n'.join(lines[i:j])
+
+ print('\n---- 原子写盘 ----')
+ n = 0
+ n += t('act_apply 用 path + .drouter.tmp 再 os.replace',
+ "tmp = path + '.drouter.tmp'" in seg_of('act_apply')
+ and 'os.replace(tmp, path)' in seg_of('act_apply'))
+ share = seg_of('_share_apply_file')
+ n += t('_share_apply_file 先写临时文件', "tmp = path + '.drouter.tmp'" in share)
+ n += t('_share_apply_file 用 os.replace 原子覆盖', 'os.replace(tmp, path)' in share)
+ n += t('_share_apply_file 不再直接 open(path,"w") 覆写',
+ "with open(path, 'w'" not in share)
+ n += t('写失败时清理残留临时文件', "os.remove(path + '.drouter.tmp')" in share)
+ return n
+
+
+if __name__ == '__main__':
+ main()
+ extra_fail = check_pkg_names() + check_atomic_write()
+ total = sum(_EXTRA_FAIL) + extra_fail
+ if total:
+ print('\n总失败 %d 项' % total)
+ sys.exit(1 if total else 0)
diff --git a/_dev/t-docker-conv.py b/_dev/t-docker-conv.py
new file mode 100644
index 0000000..abdf7cb
--- /dev/null
+++ b/_dev/t-docker-conv.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+"""验证 docker run → docker-compose.yml 转换器(#10)。纯逻辑抽取测试。"""
+import re, io, sys
+
+SRC = r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py"
+src = io.open(SRC, encoding='utf-8').read()
+
+
+def grab(n):
+ m = re.search(r'^def %s\(.*?(?=\n(?:def |[A-Z_]+ *=|# ---|class ))' % re.escape(n),
+ src, re.S | re.M)
+ if not m:
+ raise SystemExit('未找到函数:%s' % n)
+ return m.group(0)
+
+
+NS = {'re': re}
+for c in ('DOCKER_BOOL_FLAGS', 'DOCKER_SHORT', 'DOCKER_LIST_FLAGS', 'DOCKER_RESTART',
+ 'DOCKER_RUN_MAP'):
+ m = re.search(r'^%s = \{.*?\n\}' % c, src, re.S | re.M)
+ if not m:
+ raise SystemExit('未找到常量:%s' % c)
+ exec(m.group(0), NS)
+for f in ('_docker_shlex', '_docker_split_kv', '_docker_run_to_compose',
+ '_yaml_scalar', '_to_yaml_compose'):
+ exec(grab(f), NS)
+
+CV = NS['_docker_run_to_compose']
+SH = NS['_docker_shlex']
+
+fails = 0
+
+
+def chk(name, got, want):
+ global fails
+ ok = (got == want)
+ if not ok:
+ fails += 1
+ print('[%s] %-46s got=%-26s want=%s' % ('OK' if ok else 'FAIL', name, repr(got), repr(want)))
+
+
+def has(name, needle, hay, want=True):
+ chk(name, needle in hay, want)
+
+
+print('=== 词法分析 _docker_shlex ===')
+chk('普通空格切分', SH('a b c'), ['a', 'b', 'c'])
+chk('双引号保空格', SH('--name "my app"'), ['--name', 'my app'])
+chk('单引号保空格', SH("-e 'A B=1'"), ['-e', 'A B=1'])
+chk('转义空格', SH(r'a\ b c'), ['a b', 'c'])
+chk('续行合并', SH('a \\\n b'), ['a', 'b'])
+chk('引号内 $ 不展开', SH('-e "PATH=$PATH"'), ['-e', 'PATH=$PATH'])
+chk('空串', SH(''), [])
+chk('连续空格', SH('a b'), ['a', 'b'])
+
+print('\n=== 基础:名称 / 端口 / 卷 / 环境变量 ===')
+r = CV('docker run -d --name web -p 8080:80 -p 8443:443 '
+ '-v /srv/www:/usr/share/nginx/html:ro -e TZ=Asia/Shanghai nginx:alpine')
+chk('解析成功', r['ok'], True)
+chk('服务名用 --name', r['service'], 'web')
+chk('镜像', r['image'], 'nginx:alpine')
+has('含 container_name', 'container_name: web', r['yaml'])
+has('端口 8080', '- "8080:80"', r['yaml'])
+has('端口 8443', '- "8443:443"', r['yaml'])
+has('卷 ro 后缀', '- "/srv/www:/usr/share/nginx/html:ro"', r['yaml'])
+has('环境变量', '- "TZ=Asia/Shanghai"', r['yaml'])
+has('无 version 行', 'version:', r['yaml'], False)
+
+print('\n=== 短参数紧凑写法 -p8080:80 / -it / -m512m ===')
+r2 = CV('docker run -it -p8080:80 -m512m ubuntu:24.04 bash')
+chk('解析成功', r2['ok'], True)
+has('紧凑端口识别', '- "8080:80"', r2['yaml'])
+has('内存紧凑识别', 'mem_limit: 512m', r2['yaml'])
+has('stdin_open', 'stdin_open: true', r2['yaml'])
+has('tty', 'tty: true', r2['yaml'])
+has('command 是 bash', 'command: bash', r2['yaml'])
+
+print('\n=== 网络:host / none / container / 具名 ===')
+r3 = CV('docker run -d --net=host nginx')
+has('host → network_mode', 'network_mode: host', r3['yaml'])
+r4 = CV('docker run -d --network none alpine')
+has('none → network_mode', 'network_mode: none', r4['yaml'])
+r5 = CV('docker run -d --network container:other alpine')
+has('container: → network_mode', 'network_mode: "container:other"', r5['yaml'])
+r6 = CV('docker run -d --network mynet alpine')
+has('具名网络 → networks 列表', '- mynet', r6['yaml'])
+has('顶层 networks 段', 'networks:', r6['yaml'])
+has('external 声明', 'external: true', r6['yaml'])
+
+print('\n=== 资源限制 / 权限 ===')
+r7 = CV('docker run -d --cpus=1.5 -m 512m --memory-swap 1g --cpu-shares 512 '
+ '--cap-add NET_ADMIN --cap-add SYS_TIME --cap-drop ALL '
+ '--security-opt no-new-privileges --privileged alpine')
+has('cpus', 'cpus: "1.5"', r7['yaml'])
+has('mem_limit', 'mem_limit: 512m', r7['yaml'])
+has('memswap_limit', 'memswap_limit: 1g', r7['yaml'])
+has('cpu_shares', 'cpu_shares: "512"', r7['yaml'])
+chk('cap_add 两个', r7['yaml'].count('- NET_ADMIN') + r7['yaml'].count('- SYS_TIME'), 2)
+has('cap_drop', '- ALL', r7['yaml'])
+has('security_opt', '- no-new-privileges', r7['yaml'])
+has('privileged', 'privileged: true', r7['yaml'])
+
+print('\n=== 只读 / init / 设备 / sysctl / ulimit ===')
+r8 = CV('docker run -d --read-only --init --device /dev/dri:/dev/dri '
+ '--sysctl net.ipv4.ip_forward=1 --ulimit nofile=1024:2048 alpine')
+has('read_only', 'read_only: true', r8['yaml'])
+has('init', 'init: true', r8['yaml'])
+has('devices', '/dev/dri:/dev/dri', r8['yaml'])
+has('sysctls', '- "net.ipv4.ip_forward=1"', r8['yaml'])
+has('ulimits soft', 'soft: "1024"', r8['yaml'])
+has('ulimits hard', 'hard: "2048"', r8['yaml'])
+
+print('\n=== 日志 / 健康检查 ===')
+r9 = CV('docker run -d --log-driver json-file --log-opt max-size=10m '
+ '--health-cmd "curl -f http://localhost || exit 1" '
+ '--health-interval 30s --health-retries 3 --health-timeout 5s '
+ '--health-start-period 10s nginx')
+has('logging.driver', 'driver: json-file', r9['yaml'])
+has('log opt', 'max-size: 10m', r9['yaml'])
+has('healthcheck CMD-SHELL', 'CMD-SHELL', r9['yaml'])
+has('healthcheck interval', 'interval: 30s', r9['yaml'])
+has('healthcheck retries', 'retries: "3"', r9['yaml'])
+has('healthcheck timeout', 'timeout: 5s', r9['yaml'])
+has('healthcheck start_period', 'start_period: 10s', r9['yaml'])
+
+print('\n=== GPU ===')
+rg = CV('docker run -d --gpus all nvidia/cuda:12.0-base')
+has('deploy.resources', 'devices:', rg['yaml'])
+has('nvidia driver', 'driver: nvidia', rg['yaml'])
+has('count all', 'count: all', rg['yaml'])
+
+print('\n=== --mount 长语法 ===')
+rm = CV('docker run -d --mount type=bind,src=/srv/data,dst=/data,readonly alpine')
+has('volumes 长语法生效', 'target: /data', rm['yaml'])
+has('source 保留', 'source: /srv/data', rm['yaml'])
+has('read_only', 'read_only: true', rm['yaml'])
+
+print('\n=== --rm / -P 的提示 ===')
+rr = CV('docker run --rm alpine echo hi')
+chk('--rm 有 note', len(rr['notes']) >= 1, True)
+has('restart no', 'restart: "no"', rr['yaml'])
+rp = CV('docker run -d -P nginx')
+chk('-P 有 note', any('publish-all' in n or '随机' in n for n in rp['notes']), True)
+
+print('\n=== restart 值归一 ===')
+chk('unless-stopped', CV('docker run -d --restart unless-stopped x')['fields'].get('restart'),
+ 'unless-stopped')
+chk('on-failure', CV('docker run -d --restart on-failure x')['fields'].get('restart'),
+ 'on-failure')
+chk('--restart=always', CV('docker run -d --restart=always x')['fields'].get('restart'),
+ 'always')
+
+print('\n=== 服务名推断 ===')
+chk('无 --name 用镜像名', CV('docker run -d nginx:alpine')['service'], 'nginx')
+chk('带 registry 去路径', CV('docker run -d ghcr.io/linuxserver/sonarr:latest')['service'],
+ 'sonarr')
+chk('无 tag 提取', CV('docker run -d traefik')['service'], 'traefik')
+chk('指定服务名覆盖', CV('docker run -d nginx', service_name='my-svc')['service'], 'my-svc')
+
+print('\n=== 错误处理 ===')
+chk('空输入', CV('')['ok'], False)
+chk('非 docker 命令', CV('ls -la')['ok'], False)
+chk('compose 命令提示', 'docker compose' in CV('docker compose up -d')['msg_cn'], True)
+chk('无镜像名', CV('docker run -d -p 80:80')['ok'], False)
+chk('只有 docker run', CV('docker run')['ok'], False)
+
+print('\n=== 未知参数 → 警告而非崩溃 ===')
+ru = CV('docker run -d --some-future-flag=1 --another-future-flag nginx')
+chk('仍然成功', ru['ok'], True)
+chk('产生警告', len(ru['warnings']) >= 1, True)
+has('镜像仍然正确', 'image: nginx', ru['yaml'])
+
+print('\n=== 多行续行命令 ===')
+rl = CV('''docker run -d \\
+ --name app \\
+ -p 3000:3000 \\
+ -v /srv/app:/app \\
+ node:20-alpine''')
+chk('多行解析成功', rl['ok'], True)
+has('多行 name', 'container_name: app', rl['yaml'])
+has('多行 ports', '- "3000:3000"', rl['yaml'])
+has('多行 volumes', '- "/srv/app:/app"', rl['yaml'])
+has('多行 image', 'image: "node:20-alpine"', rl['yaml'])
+
+print('\n=== YAML 引号安全 ===')
+chk('含冒号加引号', NS['_yaml_scalar']('a:b'), '"a:b"')
+chk('纯数字加引号', NS['_yaml_scalar']('8080'), '"8080"')
+chk('布尔词加引号', NS['_yaml_scalar']('true'), '"true"')
+chk('普通词不加引号', NS['_yaml_scalar']('alpine'), 'alpine')
+chk('True 渲染 true', NS['_yaml_scalar'](True), 'true')
+chk('空串加引号', NS['_yaml_scalar'](''), '""')
+
+print('\n=== 老式 --link / volumes-from ===')
+ro = CV('docker run -d --link db:db --volumes-from data alpine')
+has('links', 'db:db', ro['yaml'])
+has('volumes_from', '- data', ro['yaml'])
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-docker.py b/_dev/t-docker.py
new file mode 100644
index 0000000..f9b0e70
--- /dev/null
+++ b/_dev/t-docker.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""#10 Docker 面板 —— 纯逻辑与校验测试(不依赖 Docker / Linux)。
+
+从 drouter-helper.py 抽取相关片段,用 NS 命名空间隔离执行。
+覆盖:
+ A. docker run → compose 转换(回归,与 t-docker-conv.py 互补的关键断言)
+ B. _docker_validate_stack_name / stack_save 内容校验 / stack_delete 防穿越
+ C. act_docker 参数白名单(非法 op / 非法容器 ID / 非法 prune 类型)
+"""
+import os
+import re
+import sys
+import json
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE)
+SRC = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+REG = os.path.join(ROOT, 'backend', 'render.py')
+
+src = open(SRC, encoding='utf-8').read()
+
+PASS = []
+FAIL = []
+
+
+def chk(name, got, want):
+ if got == want:
+ PASS.append(name)
+ return True
+ FAIL.append('%s:得到 %r,期望 %r' % (name, got, want))
+ return False
+
+
+def has(name, hay, needle, want=True):
+ got = needle in (hay or '')
+ if got == want:
+ PASS.append(name)
+ return True
+ FAIL.append('%s:%r in 输出 = %s,期望 %s' % (name, needle, got, want))
+ return False
+
+
+def grab(pattern, name, flags=re.S):
+ m = re.search(pattern, src, flags)
+ if not m:
+ raise SystemExit('抽取失败:%s' % name)
+ return m.group(0)
+
+
+# ---------- 构建 NS ----------
+NS = {
+ 're': re, 'os': os, 'json': json, 'sys': sys,
+ 'ValidateError': type('ValidateError', (Exception,), {
+ '__init__': lambda self, msg, field=None: (
+ setattr(self, 'msg_cn', msg), setattr(self, 'field', field))[0] or None,
+ }),
+ 'sh': lambda *a, **k: (1, '', 'skip'),
+ 'ok': lambda data=None, msg=None: {'ok': True, 'data': data, 'msg_cn': msg},
+ 'fail': lambda msg, code='ERR', data=None: {
+ 'ok': False, 'code': code, 'msg_cn': msg, 'data': data},
+ 'DOCKER_STACK_DIR': '/etc/drouter/docker/stacks',
+ 'datetime': __import__('datetime').datetime,
+}
+
+# 抽取 docker 相关纯逻辑与校验函数
+for fn in ('_docker_shlex', '_docker_split_kv', '_yaml_scalar',
+ '_to_yaml_compose', '_docker_validate_stack_name'):
+ NS[fn] = None
+seg = grab(r'DOCKER_BOOL_FLAGS = \{.*?(?=\ndef read_docker\()', 'DOCKER_* 常量与函数区')
+exec(compile(seg, SRC, 'exec'), NS)
+
+# 只抽取 _docker_run_to_compose 单函数(避免带入 read_docker/act_docker 的 IO)
+seg2 = grab(r'def _docker_run_to_compose\(.*?(?=\ndef _docker_bin\()', '_docker_run_to_compose')
+exec(compile(seg2, SRC, 'exec'), NS)
+conv = NS['_docker_run_to_compose']
+
+# ---------- A. 转换器关键回归 ----------
+r = conv('docker run -d --name nginx -p 8080:80 -v /data:/usr/share/nginx/html nginx:alpine')
+chk('A1 ok', r['ok'], True)
+has('A1 image', r['yaml'], 'image: "nginx:alpine"')
+has('A1 ports', r['yaml'], '"8080:80"')
+has('A1 volume', r['yaml'], '"/data:/usr/share/nginx/html"')
+has('A1 container_name', r['yaml'], 'container_name: nginx')
+
+r = conv('docker run --rm -it alpine sh')
+chk('A2 rm→no restart', 'restart: "no"' in r['yaml'], True)
+has('A2 stdin_open', r['yaml'], 'stdin_open: true')
+has('A2 tty', r['yaml'], 'tty: true')
+has('A2 command', r['yaml'], 'command: sh')
+
+r = conv('''docker run -d \\
+ --name app \\
+ -e "DB_HOST=host.docker.internal" \\
+ -e TZ=Asia/Shanghai \\
+ --health-cmd "curl -f http://localhost/ || exit 1" \\
+ --health-interval 30s \\
+ --ulimit nofile=1024:2048 \\
+ app:latest''')
+chk('A3 ok', r['ok'], True)
+has('A3 env quoted', r['yaml'], '"DB_HOST=host.docker.internal"')
+has('A3 env plain', r['yaml'], '"TZ=Asia/Shanghai"')
+has('A3 healthcheck test', r['yaml'], 'curl -f http://localhost/ || exit 1')
+has('A3 healthcheck CMD-SHELL', r['yaml'], 'CMD-SHELL')
+has('A3 healthcheck interval', r['yaml'], 'interval: 30s')
+has('A3 ulimit soft', r['yaml'], 'soft: "1024"')
+has('A3 ulimit hard', r['yaml'], 'hard: "2048"')
+
+# 未知参数启发式:`--flag value` 应识别为带值,不吞掉镜像名
+r = conv('docker run -d --some-unknown-flag value nginx:latest')
+has('A4 image kept', r['yaml'], 'image: "nginx:latest"')
+chk('A4 unknown not image', r['image'], 'nginx:latest')
+has('A4 warning', '\n'.join(r['warnings']), '--some-unknown-flag')
+
+# 未知布尔开关 + 镜像(不带值)也不能误吞镜像名
+r = conv('docker run -d --some-bool nginx:latest')
+chk('A4b image', r['image'], 'nginx:latest')
+
+# --mount 裸标志
+r = conv('docker run -d --mount type=bind,src=/a,dst=/b,readonly nginx')
+has('A4c mount read_only', r['yaml'], 'read_only: true')
+has('A4c mount source', r['yaml'], 'source: /a')
+has('A4c mount target', r['yaml'], 'target: /b')
+
+# 具名网络 → 顶层 networks + external
+r = conv('docker run -d --network mynet nginx')
+has('A5 networks top', r['yaml'], '\nnetworks:')
+has('A5 external', r['yaml'], 'external: true')
+has('A5 mynet key', r['yaml'], 'mynet:')
+
+# 版本:默认不加 version 字段(只剩注释里提到,正文行不以 'version:' 开头)
+r = conv('docker run -d nginx')
+chk('A6 default no version line',
+ any(ln.startswith('version:') for ln in r['yaml'].splitlines()), False)
+r = conv('docker run -d nginx', version='3.9')
+has('A6 set version', r['yaml'], "version: \"3.9\"")
+
+# 服务名自动推导
+r = conv('docker run -d --name my-svc-1 nginx')
+chk('A7 service name', r['service'], 'my-svc-1')
+r = conv('docker run -d nginx:alpine')
+chk('A7 service from image', r['service'], 'nginx')
+
+# 非 docker run 命令
+r = conv('echo hello')
+chk('A8 reject non-run', r['ok'], False)
+
+# ---------- B. stack 名称校验 ----------
+V = NS['_docker_validate_stack_name']
+for good in ('a', 'my-app', 'app_1', 'a' * 41):
+ try:
+ V(good)
+ PASS.append('B good %s' % good[:12])
+ except Exception as e:
+ FAIL.append('B good %s 被拒:%s' % (good[:12], e))
+for bad in ('', 'A', 'MyApp', '-x', '_x', 'a' * 42, 'a b', 'a/b', '../x', 'a.b'):
+ try:
+ V(bad)
+ FAIL.append('B bad %r 未被拒' % bad)
+ except Exception:
+ PASS.append('B bad %r 已拒' % bad)
+
+# ---------- C. act_docker 参数白名单(抽取判断体) ----------
+# op 白名单
+ops = set(re.findall(r"op (?:==|in) \(?'?([a-z_]+)'?\)?", src))
+for need in ('convert', 'logs', 'inspect', 'prune', 'stack_save', 'stack_delete'):
+ chk('C op %s 存在' % need, need in ops, True)
+
+# 容器 ID 正则
+m = re.search(r"re\.match\(r'\^\[a-zA-Z0-9\]\[a-zA-Z0-9_\.\-\]\{0,63\}\$'", src)
+chk('C 容器 ID 正则存在', bool(m), True)
+if m:
+ rx = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$')
+ chk('C id 合法', bool(rx.match('nginx')), True)
+ chk('C id 合法2', bool(rx.match('a1b2c3d4e5f6')), True)
+ chk('C id 非法(空)', bool(rx.match('')), False)
+ chk('C id 非法(空格)', bool(rx.match('a b')), False)
+ chk('C id 非法(斜杠)', bool(rx.match('a/b')), False)
+ chk('C id 非法(点开头)', bool(rx.match('.hidden')), False)
+ chk('C id 超长', bool(rx.match('a' * 65)), False)
+
+# prune 白名单
+pws = re.findall(r"'(\w+)': \['(?:container|image|volume|network|builder)'", src)
+for need in ('containers', 'images', 'volumes', 'networks', 'builder'):
+ chk('C prune %s' % need, need in pws, True)
+
+# stack_delete 防穿越
+has('C realpath 校验', src, 'os.path.realpath(os.path.join(DOCKER_STACK_DIR, name))')
+has('C startswith root', src, "d2.startswith(root + os.sep)")
+has('C 需确认项目名', src, "p.get('confirm') != name")
+
+# stack_save 必须含 services 段
+has('C services 校验', src, r"^\s*services\s*:")
+has('C NUL 校验', src, "if '\\x00' in content")
+
+# sh 支持 cwd(本轮修复)
+has('C sh cwd 参数', src, "def sh(cmd, timeout=15, input_data=None, cwd=None, env=None):")
+has('C sh 透传 cwd', src, "if cwd:\n kw['cwd'] = cwd")
+has('C compose 使用 cwd', src, 'sh(args, timeout=600, cwd=d2)')
+
+# ACTIONS 注册
+has('C ACTIONS read:docker', src, "'read:docker': read_docker")
+has('C ACTIONS docker', src, "'docker': act_docker")
+
+# DOCKER 常量
+for cn in ('DOCKER_DIR', 'DOCKER_STACK_DIR', 'DOCKER_PKGS'):
+ has('C 常量 %s' % cn, src, '%s = ' % cn)
+
+print('=' * 62)
+print('#10 Docker 面板 · 单测结果')
+print('=' * 62)
+print('通过:%d 失败:%d' % (len(PASS), len(FAIL)))
+if FAIL:
+ print('-' * 62)
+ for f in FAIL:
+ print(' ✗ ' + f)
+ sys.exit(1)
+print('全部通过 ✔')
diff --git a/_dev/t-env-path.py b/_dev/t-env-path.py
new file mode 100644
index 0000000..18a97fc
--- /dev/null
+++ b/_dev/t-env-path.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""环境契约测试:守护进程 PATH 归一化 + 虚拟网卡归类。
+
+这两条都是「接口照常返回 ok,但功能悄悄不工作」的隐形缺陷,必须常驻回归:
+ 1) systemd 默认 PATH 不含 /usr/sbin,nft/dnsmasq 会「未找到命令」;
+ 2) 本机自建的虚拟设备(QoS 的 ifb-*)若被当成物理网卡,界面会一直提示指派角色。
+"""
+import ast
+import os
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.join(HERE, '..')
+BACKEND = os.path.join(ROOT, 'backend')
+
+GUARDS = ['drouter-web.py', 'drouter-helper.py', 'drouter-logd.py',
+ 'drouter-snapshotd.py', 'drouter-rescue.py']
+UNITS = [os.path.join(ROOT, 'scripts', 'deploy.sh')]
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-46s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+print('一、守护进程 PATH 归一化')
+for f in GUARDS:
+ src = open(os.path.join(BACKEND, f), encoding='utf-8').read()
+ ck('%s 定义了 _ensure_sbin_path' % f, '_ensure_sbin_path' in src)
+ ck('%s 启动时调用了它' % f, '_ensure_sbin_path()' in src)
+
+# 真正执行一遍,确认 PATH 里确实被补进 sbin
+src = open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
+tree = ast.parse(src)
+picked = [n for n in tree.body
+ if isinstance(n, ast.FunctionDef) and n.name == '_ensure_sbin_path']
+picked += [n for n in tree.body
+ if isinstance(n, ast.Assign)
+ and any(isinstance(t, ast.Name) and t.id == '_SBIN_DIRS' for t in n.targets)]
+# 注:本机(Windows)上没有 /usr/sbin,_ensure_sbin_path 里的 os.path.isdir 守卫
+# 会正确地跳过它们。测试要验证的是「在 Debian 上的行为」,所以这里伪造 isdir。
+_real_isdir = os.path.isdir
+
+
+class FakePath:
+ def __init__(self):
+ self.isdir = self._isdir
+ self.sep = os.path.sep
+
+ @staticmethod
+ def _isdir(p):
+ return True if p in ('/usr/sbin', '/sbin', '/usr/local/sbin') else _real_isdir(p)
+
+
+class FakeOs:
+ environ = os.environ
+ path = FakePath()
+ pathsep = os.pathsep
+ sep = os.path.sep
+
+
+ns = {'os': FakeOs()}
+exec(compile(ast.Module(body=picked, type_ignores=[]), 'helper', 'exec'), ns)
+ns['_ensure_sbin_path']()
+parts = os.environ['PATH'].split(os.pathsep)
+ck('调用后 PATH 含 /usr/sbin', '/usr/sbin' in parts)
+ck('调用后 PATH 含 /sbin', '/sbin' in parts)
+ck('sbin 排在前面(优先命中)',
+ parts.index('/usr/sbin') < len(parts) - 1)
+ck('原有 PATH 未被清空', len(parts) >= 2, '%d 段' % len(parts))
+# 幂等:再调一次不应重复插入
+before = os.environ['PATH']
+ns['_ensure_sbin_path']()
+ck('重复调用幂等(不重复插入)', os.environ['PATH'] == before)
+
+print('\n二、systemd 单元显式声明 PATH')
+for p in UNITS:
+ txt = open(p, encoding='utf-8').read()
+ n = txt.count('Environment=PATH=/usr/local/sbin')
+ cnt = txt.count('[Service]')
+ ck('deploy.sh 每个 [Service] 都带 PATH(%d/%d)' % (n, cnt), n == cnt, '%d 个单元' % cnt)
+
+print('\n三、虚拟网卡归类(ifb-* 不该要求指派角色)')
+h = open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
+ck('is_virtual 前缀含 ifb', "'ifb'" in h and 'is_virtual' in h)
+ck('read_ifaces 标注 managed_by', 'managed_by' in h)
+
+js = open(os.path.join(ROOT, 'web', 'app.js'), encoding='utf-8').read()
+ck('前端新网卡提示排除虚拟设备', 'managed_by' in js)
+ck('前端提示带关闭按钮', 'newiface-x' in js)
+ck('关闭状态持久化', 'drouter_newiface_dismissed' in js)
+
+print('\n结果:失败 %d 项' % len(fails))
+for f in fails:
+ print(' ✘ ' + f)
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-fwlog-state.py b/_dev/t-fwlog-state.py
new file mode 100644
index 0000000..f258af8
--- /dev/null
+++ b/_dev/t-fwlog-state.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""防火墙日志「为什么一条都没有」的状态判定测试。
+
+用户看到空白的防火墙日志页时,最自然的反应是「这功能没做」。所以后端必须能
+区分两种成因并分开提示:
+ 1) 规则没载入内核(nft 规则集里没有 drouter 表)—— 本机还没接管路由;
+ 2) 规则已载入,但「记录被拒绝的包」开关没开 —— 规则里不含 log 语句。
+
+本测试从 drouter-helper.py 抽取相关函数,把 nft 调用和 /proc 文件都换成假的。
+"""
+import ast
+import os
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SRC = os.path.join(HERE, '..', 'backend', 'drouter-helper.py')
+code = open(SRC, encoding='utf-8').read()
+tree = ast.parse(code)
+
+WANT = ['_fw_ruleset_applied', '_ip_forwarding_on']
+picked = [n for n in tree.body
+ if isinstance(n, ast.FunctionDef) and n.name in WANT]
+got = {n.name for n in picked}
+missing = set(WANT) - got
+assert not missing, '未能从源码抽取:%s' % ', '.join(sorted(missing))
+
+# ---- 测试替身 -------------------------------------------------------------
+STATE = {'nft_out': '', 'nft_rc': 0, 'proc': {}}
+
+
+def fake_sh(cmd, timeout=None):
+ """只认 nft list ruleset,其余一律当作失败。"""
+ if cmd[:2] == ['nft', 'list'] and cmd[2] == 'ruleset':
+ return (STATE['nft_rc'], STATE['nft_out'], '')
+ return (1, '', 'unexpected command: %s' % cmd)
+
+
+class FakeFile:
+ def __init__(self, text):
+ self._text = text
+
+ def read(self):
+ return self._text
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+
+def fake_open(path, *a, **k):
+ if path in STATE['proc']:
+ return FakeFile(STATE['proc'][path])
+ raise OSError(2, 'No such file', path)
+
+
+ns = {'os': os, 'sh': fake_sh}
+ns['open'] = fake_open
+src = '\n'.join(ast.unparse(n) for n in picked)
+exec(compile(src, '', 'exec'), ns)
+
+applied = ns['_fw_ruleset_applied']
+forwarding = ns['_ip_forwarding_on']
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-42s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+print('=' * 60)
+print('防火墙日志状态判定')
+
+# 1) 规则集为空(本机还没接管路由)
+STATE['nft_out'] = ''
+STATE['nft_rc'] = 0
+ck('空规则集 → 判定为未载入', applied() is False)
+
+# 2) 已载入 drouter4 表
+STATE['nft_out'] = 'table ip drouter4 {\n\tchain input {\n\t}\n}'
+ck('含 drouter4 表 → 判定为已载入', applied() is True)
+
+# 3) 已载入 drouter6 表(只启用 IPv6 的情况)
+STATE['nft_out'] = 'table ip6 drouter6 {\n\tchain input {\n\t}\n}'
+ck('含 drouter6 表 → 判定为已载入', applied() is True)
+
+# 4) 有其他表但没有 drouter 的表
+STATE['nft_out'] = 'table inet filter {\n\tchain input {\n\t}\n}'
+ck('只有别的表 → 判定为未载入', applied() is False)
+
+# 5) nft 执行失败(例如 PATH 里没有 nft)不应误判成「已载入」
+STATE['nft_out'] = 'table ip drouter4 {}'
+STATE['nft_rc'] = 1
+ck('nft 执行失败 → 不误判为已载入', applied() is False)
+STATE['nft_rc'] = 0
+
+# 6) IP 转发:v4 开
+STATE['proc'] = {'/proc/sys/net/ipv4/ip_forward': '1\n',
+ '/proc/sys/net/ipv6/conf/all/forwarding': '0\n'}
+ck('v4 转发开启 → 转发中', forwarding() is True)
+
+# 7) IP 转发:仅 v6 开
+STATE['proc'] = {'/proc/sys/net/ipv4/ip_forward': '0\n',
+ '/proc/sys/net/ipv6/conf/all/forwarding': '1\n'}
+ck('仅 v6 转发开启 → 转发中', forwarding() is True)
+
+# 8) IP 转发:都关(虚拟机 /proc 里可能根本没有这个文件,也应判 False 而不是崩)
+STATE['proc'] = {'/proc/sys/net/ipv4/ip_forward': '0\n',
+ '/proc/sys/net/ipv6/conf/all/forwarding': '0\n'}
+ck('v4/v6 都关 → 未转发', forwarding() is False)
+
+STATE['proc'] = {}
+ck('/proc 文件不存在 → 返回 False 而不抛异常', forwarding() is False)
+
+# 9) 提示文案必须区分两种成因
+src_all = code
+i = src_all.find('def read_fw_log')
+body = src_all[i:i + 4000]
+ck('read_fw_log 会先判「规则未载入」再判「开关未开」',
+ body.find('not applied') < body.find('not enabled') and 'not applied' in body)
+ck('read_fw_log 回传 applied 字段', "'applied': applied" in body)
+ck('read_fw_log 回传 forwarding 字段', "'forwarding': forwarding" in body)
+ck('未载入时的提示指向「防火墙页点应用」', '点一次「应用」' in body)
+
+# 10) 前端必须把这两个状态显示出来
+js = open(os.path.join(HERE, '..', 'web', 'app.js'), encoding='utf-8').read()
+ck('前端显示「规则已载入 / 未载入」', '规则未载入' in js)
+ck('前端显示「IP 转发已开启 / 未开启」', 'IP 转发未开启' in js)
+ck('前端读取 d.applied', 'd.applied' in js)
+ck('前端读取 d.forwarding', 'd.forwarding' in js)
+
+print('=' * 60)
+print('结果:失败 %d 项' % len(fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-gzip.py b/_dev/t-gzip.py
new file mode 100644
index 0000000..6e04b3d
--- /dev/null
+++ b/_dev/t-gzip.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""响应压缩与协商缓存契约(#7)。
+
+要挡住两类回归:
+ 1. 压缩被悄悄去掉 —— 局域网里感觉不出来,一走外网(cloudflared / 远程)
+ 就是 300KB 对 30KB 的差别;
+ 2. 压缩后忘了发 Vary: Accept-Encoding —— 中间缓存会把 gzip 版喂给
+ 不支持 gzip 的客户端,表现是「某些浏览器打开面板全是乱码」。
+"""
+import ast
+import io
+import os
+import sys
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+fails = 0
+
+
+def chk(label, cond, extra=''):
+ global fails
+ if not cond:
+ fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+
+WEB = io.open(os.path.join(ROOT, 'backend/drouter-web.py'),
+ encoding='utf-8').read()
+TREE = ast.parse(WEB)
+
+chk('定义了压缩下限 GZIP_MIN', 'GZIP_MIN = ' in WEB)
+chk('有 Accept-Encoding 判断', 'def _want_gzip' in WEB)
+chk('压缩结果带内存缓存(不每次重压)', 'def _gzip_cached' in WEB and '_gz_cache' in WEB)
+chk('缓存按 mtime+size 失效(部署新版后自动重压)',
+ "int(st.st_mtime), st.st_size" in WEB)
+chk('压缩后必须发 Content-Encoding', "send_header('Content-Encoding', enc)" in WEB)
+chk('压缩后必须发 Vary: Accept-Encoding(否则代理会喂错客户端)',
+ WEB.count("send_header('Vary', 'Accept-Encoding')") >= 2,
+ '实际 %d 处' % WEB.count("send_header('Vary', 'Accept-Encoding')"))
+chk('压缩比不划算时不压(原始数据更大就直出)', 'if len(z) >= len(data)' in WEB)
+chk('静态资源支持 ETag 协商缓存', "send_header('ETag', etag)" in WEB)
+chk('ETag 命中回 304 且不回传响应体',
+ "If-None-Match" in WEB and 'send_response(304)' in WEB)
+chk('JSON 接口也走压缩', '_gzip_cached(None, body)' in WEB)
+chk('静态资源也走压缩', '_gzip_cached(full, data)' in WEB)
+chk('HEAD 请求不回响应体', "if self.command != 'HEAD'" in WEB)
+
+# 小响应不能被压:省下的字节还不如压缩开销
+src = io.open(os.path.join(ROOT, 'backend/drouter-web.py'), encoding='utf-8').read()
+chk('小响应不压缩(有体积下限判断)', 'len(data) >= GZIP_MIN' in src)
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-helpd.py b/_dev/t-helpd.py
new file mode 100644
index 0000000..4fece50
--- /dev/null
+++ b/_dev/t-helpd.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""常驻执行守护(#7 性能优化)契约测试。
+
+核心契约只有两条,任一条破了都会在生产环境出事:
+ 1. 有快路径:drouter-web 会先连 Unix socket;
+ 2. 有回退:连不上时必须退回原来的 subprocess 方式。
+ —— 只有快路径没有回退,守护一崩整个面板就废了;
+ 只有回退没有快路径,性能优化等于没做。
+
+另外几条是「上线后最难发现的坑」:
+ * 守护把 helper 常驻内存,部署时不 restart 就跑的是旧代码;
+ * 两个守护共用 /run/drouter,RuntimeDirectoryMode 不一致会让 socket
+ 权限随启动顺序时好时坏;
+ * 守护必须复用 helper.run_action,不能另写一套分发(否则构建保护熔断
+ 就可能在守护路径上失效)。
+"""
+import ast
+import io
+import os
+import re
+import sys
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+fails = 0
+
+
+def chk(label, cond, extra=''):
+ global fails
+ if not cond:
+ fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+
+def read(rel):
+ with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
+ return f.read()
+
+
+HELPD = read('backend/drouter-helpd.py')
+WEB = read('backend/drouter-web.py')
+HELPER = read('backend/drouter-helper.py')
+DEPLOY = read('scripts/deploy.sh')
+
+# ---------------------------------------------------------------- 1. 快路径
+chk('web.py 定义了 socket 路径', "HELPD_SOCK = '/run/drouter/helper.sock'" in WEB)
+chk('web.py 实现了 _helpd_call', 'def _helpd_call(' in WEB)
+chk('web.py 的 helper() 先走守护', '_helpd_call(action, payload, timeout)' in WEB)
+chk('协议是换行分隔的 JSON', "'\\n').encode('utf-8')" in WEB)
+# ---------------------------------------------------------------- 2. 回退
+chk('web.py 保留了 subprocess 回退',
+ "['sudo', '-n', '/usr/bin/python3', HELPER, action" in WEB)
+chk('回退前有「守护不可用」冷却期,避免每请求白等',
+ '_helpd_down_until' in WEB and '_helpd_down_until[0] = time.time() + 30' in WEB)
+chk('守护不可用时返回 None 触发回退', 'return None' in WEB.split('def _helpd_call')[1][:2000])
+chk('回退分支有注释说明为什么不能只有快路径',
+ '守护挂掉只会变慢' in WEB or '不会让面板不可用' in WEB)
+
+# ---------------------------------------------------------------- 3. 守护本身
+chk('守护复用 helper.run_action(不另写分发)', 'HELPER.run_action(action, payload)' in HELPD)
+chk('守护 socket 权限 0660', 'os.chmod(SOCK_PATH, 0o660)' in HELPD)
+chk('守护把 socket 属组设为 drouter(否则 drouter-web 连不上)',
+ "SOCK_GROUP = 'drouter'" in HELPD and 'grp.getgrnam(SOCK_GROUP)' in HELPD)
+chk('守护启动前清理陈旧 socket', 'os.unlink(SOCK_PATH)' in HELPD)
+chk('守护用多线程(并发请求不排队)', 'ThreadingUnixStreamServer' in HELPD)
+chk('客户端断开时工作线程不能崩', '_write(self, obj)' in HELPD)
+chk('守护不监听任何 TCP 端口', 'AF_INET' not in HELPD)
+
+# ---------------------------------------------------------------- 4. helper 改造
+chk('helper 暴露 run_action', 'def run_action(' in HELPER)
+h_tree = ast.parse(HELPER)
+has_guard = False
+for n in ast.walk(h_tree):
+ if isinstance(n, ast.FunctionDef) and n.name == 'run_action':
+ src = ast.get_source_segment(HELPER, n) or ''
+ has_guard = 'check_build_guard' in src
+chk('run_action 保留了构建保护模式熔断', has_guard)
+chk('main() 改为调用 run_action(两条路径同一套逻辑)',
+ 'res = run_action(action, payload)' in HELPER)
+
+# ---------------------------------------------------------------- 5. 部署
+chk('部署脚本安装了 drouter-helpd.py', 'backend/drouter-helpd.py' in DEPLOY)
+chk('部署脚本 restart 守护(否则旧代码常驻不更新)',
+ 'systemctl restart drouter-helpd' in DEPLOY)
+# 共用 /run/drouter 时绝不能用 systemd 的 RuntimeDirectory:
+# 一个守护重启,systemd 就重建目录,把另一个守护的 socket 删掉。
+chk('两个守护都不用 RuntimeDirectory(改用各自 mkdir)',
+ not re.search(r'^RuntimeDirectory=', DEPLOY, re.M))
+chk('守护自己建目录并设 0755',
+ "SOCK_DIR = '/run/drouter'" in HELPD and 'os.chmod(SOCK_DIR, 0o755)' in HELPD)
+# helpd 单元段:从 'drouter-helpd.service' 到下一个 'cat > /etc/systemd/system'
+_seg = DEPLOY.split('drouter-helpd.service')[1].split('cat > /etc/systemd/system')[0]
+# 注意只匹配真正的配置行:单元里那条「故意不设 CPUQuota」的说明文字里
+# 也含 CPUQuota 字样,直接 `not in` 会误报。
+chk('守护单元刻意不限 CPU(格式化大盘等子进程不能被拖慢)',
+ 'MemoryMax=300M' in _seg
+ and not re.search(r'^CPUQuota=', _seg, re.M))
+
+# ---------------------------------------------------------------- 6. 语法
+for rel, src in (('backend/drouter-helpd.py', HELPD),
+ ('backend/drouter-web.py', WEB),
+ ('backend/drouter-helper.py', HELPER)):
+ try:
+ ast.parse(src)
+ chk('%s 语法正确' % rel, True)
+ except SyntaxError as e:
+ chk('%s 语法正确' % rel, False, str(e))
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-kern.py b/_dev/t-kern.py
new file mode 100644
index 0000000..f73239a
--- /dev/null
+++ b/_dev/t-kern.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""内核转发与加速(#4):静态契约 + 渲染逻辑单测。
+
+盯三件事:
+ ① 五项开关的默认值是我替用户定的判断,改错了会直接改变这台机器的网络行为;
+ ② 每一项都必须有「通俗说明 + 联动影响」,用户明确要求过;
+ ③ MSS / masquerade 最终落在 nft_v4 / nft_v6 配置库里 ——
+ 本页只是统一入口,绝不能另存一份,否则两处配置会打架。
+"""
+import ast
+import io
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(ROOT, 'backend'))
+import render # noqa: E402
+
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+RENDER_SRC = io.open(os.path.join(ROOT, 'backend', 'render.py'), encoding='utf-8').read()
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+WEB_SRC = io.open(WEB, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+TREE = ast.parse(HELPER_SRC)
+
+
+def node(name):
+ for n in TREE.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ return n
+ elif isinstance(n, ast.FunctionDef) and n.name == name:
+ return n
+ return None
+
+
+def build(names, ns=None):
+ ns = ns if ns is not None else {}
+ body = []
+ for nm in names:
+ n = node(nm)
+ if n is None:
+ raise AssertionError('源码里找不到 %s' % nm)
+ body.append(n)
+ mod = ast.Module(body=body, type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ return ns
+
+
+def main():
+ print('--- 后端动作与路由 ---')
+ chk('定义了 act_kern', 'def act_kern(' in HELPER_SRC)
+ chk('注册到 ACTIONS', re.search(r"^\s*'kern':\s*act_kern,", HELPER_SRC, re.M) is not None)
+ chk('Web 路由 /api/kern', "'/api/kern'" in WEB_SRC)
+ chk('save 给了更长超时', re.search(r"to = 90 if op == 'save' else 30", WEB_SRC) is not None)
+ chk('未知 op 返回 fail', '未知的内核选项操作' in HELPER_SRC)
+
+ print('\n--- 五项开关的默认值(我替用户做的判断)---')
+ ns = build(['KERN_DEFAULTS', 'KERN_ITEMS', 'KERN_FW_ITEMS'])
+ dft = ns['KERN_DEFAULTS']
+ chk('IPv4 转发默认开', dft['fwd_v4'] is True)
+ chk('IPv6 转发默认开', dft['fwd_v6'] is True)
+ chk('BBR 默认开', dft['bbr'] is True)
+ chk('SNMP 默认关(多一个监听多一分风险)', dft['snmp']['enabled'] is False)
+ chk('SNMP 有可改的团体名', bool(dft['snmp'].get('community')))
+
+ items = ns['KERN_ITEMS']
+ fws = ns['KERN_FW_ITEMS']
+ chk('四项内核开关齐全',
+ [i['key'] for i in items] == ['fwd_v4', 'fwd_v6', 'bbr', 'snmp'],
+ [i['key'] for i in items])
+ chk('四项防火墙参数齐全',
+ [i['key'] for i in fws] == ['masquerade_v4', 'mss_v4', 'masquerade_v6', 'mss_v6'],
+ [i['key'] for i in fws])
+
+ for it in items + fws:
+ k = it['key']
+ chk('%-14s 有通俗说明' % k, len(it.get('why') or '') >= 20)
+ chk('%-14s 有联动影响且不止一条' % k,
+ isinstance(it.get('impact'), list) and len(it['impact']) >= 2)
+ chk('%-14s 的 default 与 KERN_DEFAULTS 一致' % k,
+ 'default' in it)
+ # msq/mss 的默认值要在后端与前端文案两处对得上
+ by_key = {i['key']: i['default'] for i in fws}
+ chk('masquerade_v4 默认开', by_key['masquerade_v4'] is True)
+ chk('mss_v4 默认开', by_key['mss_v4'] is True)
+ chk('masquerade_v6 默认关(IPv6 推荐端到端)', by_key['masquerade_v6'] is False)
+ chk('mss_v6 默认关(IPv6 靠 PMTUD)', by_key['mss_v6'] is False)
+
+ print('\n--- sysctl 生成 ---')
+ ns2 = build(['_kern_sysctl_lines'])
+ lines_of = ns2['_kern_sysctl_lines']
+ L = lines_of({'fwd_v4': True, 'fwd_v6': True, 'bbr': True}, True)
+ joined = '\n'.join(L)
+ chk('开启转发写 ip_forward=1', 'net.ipv4.ip_forward = 1' in joined)
+ chk('开启 IPv6 转发写 forwarding=1', 'net.ipv6.conf.all.forwarding = 1' in joined)
+ # 不设 accept_ra=2,靠 SLAAC 拿地址的家宽会在租期到期后丢掉整个 IPv6
+ chk('BBR 打开时指定 bbr', 'net.ipv4.tcp_congestion_control = bbr' in joined)
+ chk('BBR 打开时搭档 fq', 'net.core.default_qdisc = fq' in joined)
+ L2 = '\n'.join(lines_of({'fwd_v4': False, 'fwd_v6': False, 'bbr': False}, False))
+ chk('关闭转发写 0', 'net.ipv4.ip_forward = 0' in L2
+ and 'net.ipv6.conf.all.forwarding = 0' in L2)
+ chk('BBR 关闭时回退 cubic', 'tcp_congestion_control = cubic' in L2)
+ chk('关闭 IPv6 转发时不写 accept_ra', 'accept_ra' not in L2)
+ # 关键:bbr_ok=False 时即便用户勾了 BBR 也不能写 bbr(否则 sysctl -w 必然失败)
+ L3 = '\n'.join(lines_of({'fwd_v4': True, 'fwd_v6': True, 'bbr': True}, False))
+ chk('内核不支持时不强写 bbr', 'tcp_congestion_control = cubic' in L3
+ and '= bbr' not in L3)
+
+ print('\n--- 持久化与构建保护 ---')
+ chk('有 sysctl.d 持久化文件', "KERN_SYSCTL_D = '/etc/sysctl.d/99-drouter.conf'" in HELPER_SRC)
+ chk('构建保护模式下不改内核', '构建保护模式】:配置已写入磁盘,但未修改内核参数' in HELPER_SRC)
+ # 两个「会真的动手」的调用必须都排在 in_build_mode 分支之后(即 else 里)
+ _i_guard = HELPER_SRC.index('当前处于【构建保护模式】:配置已写入磁盘')
+ chk('构建保护模式下不启停服务',
+ HELPER_SRC.index('applied, bbr_ok, e2 = _kern_apply_sysctl(cfg)') > _i_guard
+ and HELPER_SRC.index('_kern_snmp_apply(cfg[\'snmp\'], errs)') > _i_guard)
+
+ print('\n--- 参数校验 ---')
+ chk('团体名禁止空格与 #', r"re.search(r'[\s#]', comm)" in HELPER_SRC)
+ chk('端口钳制在 1-65535', "min(int(s['port']), 65535)" in HELPER_SRC)
+ chk('MSS 钳制在 576-9000', 'min(int(fw[' in HELPER_SRC and '9000)' in HELPER_SRC)
+ chk('监听地址做了格式校验', 'SNMP 监听地址不合法' in HELPER_SRC)
+
+ print('\n--- 渲染器:masquerade / MSS ---')
+ t4 = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18']})
+ chk('v4 默认含 masquerade', 'masquerade' in t4)
+ chk('v4 默认含 MSS 钳制', 'maxseg size set rt mtu' in t4)
+ t4_off = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'masquerade': False})
+ chk('v4 关闭后不再有 masquerade 规则', 'masquerade comment' not in t4_off)
+ chk('v4 关闭后仍保留 nat_post 链与说明',
+ 'chain nat_post' in t4_off and '出向地址伪装已关闭' in t4_off)
+ t4_fix = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'mss_mode': 'fixed', 'mss': 1400})
+ chk('fixed 模式写死数值', 'maxseg size set 1400' in t4_fix)
+ try:
+ render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'mss_mode': 'bogus'})
+ chk('非法 mss_mode 被拒绝', False)
+ except Exception:
+ chk('非法 mss_mode 被拒绝', True)
+
+ t6 = render.render_nft('v6', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18']})
+ chk('v6 默认不做 NAT66', 'masquerade' not in t6)
+ chk('v6 默认不钳制 MSS', 'maxseg' not in t6)
+ t6_on = render.render_nft('v6', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'masquerade': True, 'mss_clamp': True})
+ chk('v6 打开后生成 nat_post', 'chain nat_post' in t6_on and 'masquerade' in t6_on)
+ chk('v6 打开后生成 MSS 规则', 'maxseg size set rt mtu' in t6_on)
+ # 参数透传:render_nft 必须真的把 masquerade 交给 _default_v4/v6
+ chk('render.py 透传 masquerade 给 v4',
+ "masquerade=masq" in RENDER_SRC and "_default_v4(wan, lans" in RENDER_SRC)
+ chk('render.py 透传 masquerade 给 v6',
+ re.search(r"_default_v6\(wan, lans.*?masquerade=masq", RENDER_SRC, re.S) is not None)
+
+ print('\n--- 配置落点(不能在两处各存一份)---')
+ chk('写回 nft_v4 配置库', "_save_setting('nft_v4', v4)" in HELPER_SRC)
+ chk('写回 nft_v6 配置库', "_save_setting('nft_v6', v6)" in HELPER_SRC)
+ chk('web 默认配置含 v4 的 masquerade',
+ "'mss_mode': 'clamp', 'masquerade': True" in WEB_SRC)
+ chk('web 默认配置含 v6 的 masquerade=False',
+ "'mss_mode': 'clamp', 'masquerade': False" in WEB_SRC)
+
+ print('\n--- 前端接线 ---')
+ chk('菜单里有 kern', re.search(r"k: 'kern'", APP_SRC) is not None)
+ chk('菜单文案含「内核转发与加速」', '内核转发与加速' in APP_SRC)
+ chk('VIEWS 注册 kern', re.search(r"^\s*kern: viewKern,", APP_SRC, re.M) is not None)
+ chk('定义了 viewKern', 'async function viewKern(' in APP_SRC)
+ for fn in ('kernLoad', 'kernRender', 'kernSave', 'kernCard', 'kernImpact'):
+ chk('前端函数 %s' % fn, ('function %s(' % fn) in APP_SRC)
+ chk('调用 /api/kern', "'/api/kern'" in APP_SRC)
+ chk('BBR 未生效时有醒目提示', '未生效(内核未加载 tcp_bbr)' in APP_SRC)
+ chk('提示用户去防火墙页应用', '防火墙 IPv4 / IPv6' in APP_SRC)
+
+ print('\n' + '=' * 60)
+ print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/t-linkage.py b/_dev/t-linkage.py
new file mode 100644
index 0000000..3a92a81
--- /dev/null
+++ b/_dev/t-linkage.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""模块联动审计(#6 收尾):专查「前端点的按钮 → 后端能不能接住」这条链。
+
+前五类检查:
+ 1. 前端 PAGE_MODULES 里的每个 save/apply 模块,后端必须能接住
+ —— save 走 /api/config(白名单 keys),apply 走 APPLY_SPEC 或纯配置模块;
+ 漏一个就是「点了应用弹一个『未知的模块』」,属于用户能直接撞上的 bug。
+ 2. render.py 的 ALIASES 值必须都真的存在渲染器,且 canonical() 能解析;
+ 3. act_apply 的语法预检必须传「渲染模块名」而不是前端别名,
+ 否则 ntp / upnp 这类别名会静默跳过语法检查;
+ 4. 八个功能模块的动作必须注册 + 有路由(storage/share/deps/nat/qos/
+ docker/vlan/theme);
+ 5. 前端各视图读取的关键字段,必须在对应后端动作源码里出现
+ —— 读到后端从来没有的 key 就是「界面永远空白」。
+"""
+import ast
+import io
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.join(HERE, '..')
+fails = 0
+
+
+def chk(label, cond, extra=''):
+ global fails
+ if not cond:
+ fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+
+def read(rel):
+ with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
+ return f.read()
+
+
+HELPER = read('backend/drouter-helper.py')
+WEB = read('backend/drouter-web.py')
+APP = read('web/app.js')
+RENDER = read('backend/render.py')
+
+# ------------------------------------------------------- 1. 前端页面 → 后端模块
+# 从 app.js 里把 PAGE_MODULES 的字面量抠出来(不执行 JS,纯文本解析)
+seg = APP[APP.index('const PAGE_MODULES = {'):]
+seg = seg[:seg.index('\n};')]
+page_mods = {}
+for line in seg.splitlines():
+ m = re.match(r"\s*(\w+):\s*\{\s*save:\s*\[([^\]]*)\],\s*apply:\s*\[([^\]]*)\]", line)
+ if not m:
+ continue
+ get = lambda s: re.findall(r"'([^']+)'", s)
+ page_mods[m.group(1)] = (get(m.group(2)), get(m.group(3)))
+chk('解析到 PAGE_MODULES 页面映射', len(page_mods) >= 10, '实际 %d 个' % len(page_mods))
+
+# /api/config 的可保存模块白名单
+cfg_keys = set()
+m = re.search(r"keys = \[([^\]]*)\]", WEB)
+if m:
+ cfg_keys = set(re.findall(r"'([^']+)'", m.group(1)))
+chk('取到 /api/config 的模块白名单', len(cfg_keys) >= 10, '实际 %d 个' % len(cfg_keys))
+
+# APPLY_SPEC 的键(正则会命中所有形如 'x': ('y' 的字典,所以先切到 APPLY_SPEC 段)
+seg2 = HELPER[HELPER.index('APPLY_SPEC = {'):]
+seg2 = seg2[:seg2.index('\n}')]
+apply_keys = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg2, re.M))
+chk('取到 APPLY_SPEC 模块', len(apply_keys) >= 10, '实际 %d 个' % len(apply_keys))
+
+# 纯配置模块
+seg3 = HELPER[HELPER.index('CONFIG_ONLY_MODULES = {'):]
+seg3 = seg3[:seg3.index('\n}')]
+config_only = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg3, re.M))
+chk('存在纯配置模块声明', config_only == {'system', 'portfwd'}, '实际 %s' % sorted(config_only))
+
+bad_save, bad_apply = [], []
+for page, (saves, applies) in page_mods.items():
+ for m in saves:
+ if m not in cfg_keys:
+ bad_save.append((page, m))
+ for m in applies:
+ if m not in apply_keys and m not in config_only:
+ bad_apply.append((page, m))
+chk('每个页面的 save 模块都能被 /api/config 接受', not bad_save, '→ %s' % bad_save)
+chk('每个页面的 apply 模块后端都认得', not bad_apply, '→ %s' % bad_apply)
+
+# 纯配置模块不该出现在任何页面的 apply 列表里(它压根没有配置文件)
+mis = [(p, m) for p, (_s, a) in page_mods.items() for m in a if m in config_only]
+chk('纯配置模块没有被当成渲染目标', not mis, '→ %s' % mis)
+
+# 有 save 却没有 apply 的页面,前端必须给出「无独立配置文件」的提示文案
+chk('无渲染目标的页面有对应提示文案',
+ '没有独立的配置文件需要渲染' in APP or '没有独立配置文件' in APP)
+
+# ------------------------------------------------------- 2. render 别名闭环
+seg4 = RENDER[RENDER.index('ALIASES = {'):]
+seg4 = seg4[:seg4.index('\n}')]
+aliases = dict(re.findall(r"'([a-z_0-9]+)':\s*'([a-z_0-9]+)'", seg4))
+renderers = set(re.findall(r"^RENDERERS = \{", RENDER))
+seg5 = RENDER[RENDER.index('RENDERERS = {'):]
+seg5 = seg5[:seg5.index('\n}')]
+rend_keys = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg5, re.M))
+bad_alias = {k: v for k, v in aliases.items() if v not in rend_keys}
+chk('ALIASES 的每个值都有对应渲染器', not bad_alias, '→ %s' % bad_alias)
+chk('render 暴露了 canonical()', 'def canonical(' in RENDER)
+chk('pppoe 已别名到 ppp(WAN 口页面此前会报未知模块)',
+ aliases.get('pppoe') == 'ppp', '实际 %s' % aliases.get('pppoe'))
+chk('APPLY_SPEC 认得 pppoe', 'pppoe' in apply_keys)
+
+# ------------------------------------------------------- 3. 语法预检用渲染模块名
+chk('act_apply 用 render.canonical 取渲染模块名',
+ 'rmodule = render.canonical(module)' in HELPER)
+chk('语法预检传的是 rmodule 不是 module',
+ '_verify(rmodule, files)' in HELPER)
+# 别名模块的语法检查以前被整段跳过:ntp->chrony、upnp->miniupnpd
+chk('ntp 别名解析为 chrony(语法检查才不会跳过)',
+ aliases.get('ntp') == 'chrony' and "'chrony'" in HELPER)
+chk('upnp 别名解析为 miniupnpd', aliases.get('upnp') == 'miniupnpd')
+# 校验目录必须是能 traverse 的:放在 0700 的私有目录里,chronyd 降权后读不到,
+# NTP 页就会弹「配置检查未通过:Permission denied」
+chk('存在独立的语法预检目录 VERIFY_DIR', "VERIFY_DIR = '/run/drouter-verify'" in HELPER)
+chk('_verify 用的是 VERIFY_DIR 而不是 root 私有目录',
+ 'checkdir = VERIFY_DIR' in HELPER)
+# chronyd 被 AppArmor 圈死,只能读 /etc/chrony/** 与 /etc/chrony.*
+chk('chrony 的预检文件落在 AppArmor 允许的路径上',
+ "'chrony': '/etc/chrony.drouter-verify.conf'" in HELPER)
+chk('_verify 会按模块选用预检路径', 'tmp = VERIFY_PATH.get(module)' in HELPER)
+
+# ------------------------------------------------------- 4. 功能模块动作注册
+h_tree = ast.parse(HELPER)
+ACTIONS = set()
+for node in ast.walk(h_tree):
+ if isinstance(node, ast.Assign):
+ for t in node.targets:
+ if isinstance(t, ast.Name) and t.id == 'ACTIONS' and isinstance(node.value, ast.Dict):
+ for k in node.value.keys:
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
+ ACTIONS.add(k.value)
+# web.py 里登记过的路由(写法有 dict 键、元组 p in ('/api/a','/api/b')、
+# p == '/api/x 三种,只抽 dict 会漏掉大半)
+routes = set()
+for node in ast.walk(ast.parse(WEB)):
+ if isinstance(node, ast.Dict):
+ for k in node.keys:
+ if isinstance(k, ast.Constant) and isinstance(k.value, str) \
+ and k.value.startswith('/api'):
+ routes.add(k.value)
+ if isinstance(node, (ast.Tuple, ast.List)):
+ for e in node.elts:
+ if isinstance(e, ast.Constant) and isinstance(e.value, str) \
+ and e.value.startswith('/api'):
+ routes.add(e.value)
+ if isinstance(node, ast.Compare) and isinstance(node.left, ast.Name) \
+ and node.left.id == 'p':
+ for c in node.comparators:
+ if isinstance(c, ast.Constant) and isinstance(c.value, str) \
+ and c.value.startswith('/api'):
+ routes.add(c.value)
+
+# 模块名 → (后端动作名, 后端路由, 前端请求的路径)
+MODULES = {
+ 'storage': ('storage', '/api/storage', '/api/storage'),
+ 'share': ('share', '/api/share', '/api/share'),
+ 'deps': ('depcheck', '/api/deps/check', '/api/deps/check'),
+ 'nat': ('nat_check', '/api/nat/check', '/api/nat/check'),
+ 'qos': ('qos', '/api/qos', '/api/qos'),
+ 'docker': ('docker', '/api/docker', '/api/docker'),
+ 'vlan': ('vlan', '/api/vlan', '/api/vlan'),
+ 'theme': ('theme', '/api/theme', '/api/theme'),
+}
+for name, (act, route, used) in MODULES.items():
+ chk('模块 %s 的动作已注册' % name, act in ACTIONS, '→ 缺 %s' % act)
+ chk('模块 %s 有后端路由' % name, route in routes, '→ 路由表缺 %s' % route)
+ # 主题页的请求带 query(/api/theme?op=list),只比对路径主干
+ chk('模块 %s 前端会请求' % name,
+ ("api('%s'" % used) in APP or ("api('%s?" % used) in APP
+ or ("api('%s/op'" % used) in APP)
+
+# ------------------------------------------------------- 5. 前端字段 ↔ 后端键名
+# 只查最有代表性的几个「界面永远空白」高发点:字段名在后端源码里必须出现过。
+FIELD_CASES = [
+ # 外置存储挂在「文件共享」页里,视图函数是 viewNfs
+ ('storage', 'viewNfs', ('enable_smb', 'enable_nfs', 'samba', 'nfs')),
+ ('deps', 'viewDepCheck', ('items', 'required', 'missing', 'extra')),
+ ('vlan', 'viewVlan', ('parent', 'vid', 'purpose')),
+ ('docker', 'viewDocker', ('containers', 'images', 'compose')),
+ ('theme', 'viewTheme', ('themes', 'active', 'id')),
+]
+for name, fn, fields in FIELD_CASES:
+ i = APP.find('function %s(' % fn)
+ chk('找到前端函数 %s' % fn, i >= 0)
+ if i < 0:
+ continue
+ body = APP[i:i + 9000]
+ miss = [f for f in fields if f not in body]
+ chk('%s 视图仍在使用这些字段' % name, not miss, '→ 缺失 %s' % miss)
+ miss2 = [f for f in fields if f not in HELPER]
+ chk('%s 的字段后端确实会产出' % name, not miss2, '→ 后端无 %s' % miss2)
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-nat-detail.py b/_dev/t-nat-detail.py
new file mode 100644
index 0000000..52a1359
--- /dev/null
+++ b/_dev/t-nat-detail.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""NAT 检测详情渲染契约测试。
+
+事故:后端 act_nat_check 的 detail 是**字典**(local_ip / probes / servers_used),
+前端却写 esc(d.detail) —— 页面上直接显示一个 "[object Object]" 方块,用户不知道
+那是什么、也没法排查。
+
+这里锁住三件事:
+ 1) 后端 detail 确实是结构化字典,且含排障必需的字段;
+ 2) 前端不再把 detail 当纯文本渲染,而是结构化展示(含 STUN 探测明细表);
+ 3) esc() 收到对象时不再吐 "[object Object]"(最后一道兜底)。
+"""
+import ast
+import os
+import re
+import subprocess
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.join(HERE, '..')
+SRC = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+JS = os.path.join(ROOT, 'web', 'app.js')
+NODE = r'C:/Users/lyrz-pve-win10/.workbuddy/binaries/node/versions/22.22.2-3/node.exe'
+
+code = open(SRC, encoding='utf-8').read()
+js = open(JS, encoding='utf-8').read()
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-48s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+print('=' * 66)
+print('一、后端:NAT detail 的结构')
+
+# 直接跑 act_nat_check 需要联网,改为静态解析其返回的 detail 结构
+i = code.find('def act_nat_check')
+body = code[i:i + 2600]
+ck('act_nat_check 里 detail 是字典字面量', "detail = {" in body)
+for k in ('local_ip', 'probes', 'servers_used'):
+ ck('detail 含字段 %s' % k, ("'%s'" % k) in body)
+ck('无探测结果时会写入 reason', "detail['reason']" in body)
+ck('probes 里含 server/ip/port',
+ all(x in body for x in ("'server'", "'ip'", "'port'")))
+
+print('二、前端:不再把 detail 当纯文本渲染')
+ck('不再出现 esc(d.detail)', 'esc(d.detail)' not in js)
+ck('存在结构化渲染函数 natDetailHtml', 'function natDetailHtml' in js)
+ck('renderNat 调用 natDetailHtml', 'natDetailHtml(d.detail)' in js)
+ck('展示本机出口地址', "detail.local_ip" in js)
+ck('展示 STUN 探测明细表', 'detail.probes' in js)
+ck('区分私网 / 公网出口', 'detail.local_private' in js)
+ck('兼容 detail 为纯字符串的情况',
+ "typeof detail === 'string'" in js)
+# IPv6 测试与审计的 detail 本来就是字符串,不受影响;这里确认它们仍是字符串来源
+i2 = code.find("item = {'key': t['key']")
+ck('IPv6 测试的 detail 仍是字符串(未受影响)',
+ "'detail': ''" in code[i2:i2 + 200])
+ck('审计详情入库时已 str() 化(未受影响)',
+ 'str(detail)[:2000]' in open(os.path.join(ROOT, 'backend',
+ 'drouter-web.py'), encoding='utf-8').read())
+
+print('三、esc() 的兜底行为(在 node 里真实执行)')
+i3 = js.find('const esc = s => {')
+if i3 < 0:
+ i3 = js.find('const esc = s =>')
+j3 = js.find('};', i3)
+snippet = js[i3:j3 + 2]
+probe = snippet + """
+const cases = {
+ obj: esc({ a: 1, b: 'x' }),
+ arr: esc([1, 'two']),
+ str: esc('&"x"'),
+ nul: esc(null),
+ num: esc(42),
+};
+console.log(JSON.stringify(cases));
+"""
+r = subprocess.run([NODE, '-e', probe], capture_output=True, text=True,
+ encoding='utf-8', errors='replace')
+if r.returncode != 0:
+ ck('esc 片段可在 node 中执行', False, r.stderr[-300:])
+else:
+ import json
+ got = json.loads(r.stdout.strip().splitlines()[-1])
+ ck('对象不再渲染成 [object Object]',
+ '[object Object]' not in got['obj'], got['obj'])
+ ck('对象退化为可读 JSON', got['obj'].startswith('{'), got['obj'])
+ ck('数组不再渲染成 [object Object]',
+ '[object Object]' not in got['arr'], got['arr'])
+ ck('字符串仍正确转义', got['str'] == '<b>&"x"</b>',
+ got['str'])
+ ck('null 转为空串', got['nul'] == '', got['nul'])
+ ck('数字正常显示', got['num'] == '42', got['num'])
+
+print('=' * 66)
+print('结果:失败 %d 项' % len(fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-nettotals.py b/_dev/t-nettotals.py
new file mode 100644
index 0000000..9cbce02
--- /dev/null
+++ b/_dev/t-nettotals.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""历史累计流量(概览页「累计已下载 / 已上传」)的纯逻辑测试。
+
+从 drouter-helper.py 抽取 _hbytes / _net_totals_* 片段,在隔离命名空间执行,
+把 /proc/net/dev 与持久化文件都换成假的,不碰真实系统。
+"""
+import ast
+import json
+import os
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SRC = os.path.join(HERE, '..', 'backend', 'drouter-helper.py')
+code = open(SRC, encoding='utf-8').read()
+tree = ast.parse(code)
+
+WANT = ['_hbytes', '_net_totals_load', '_net_totals_cur',
+ '_net_totals_snapshot', '_net_totals_update', 'net_totals']
+picked = []
+for node in tree.body:
+ if isinstance(node, ast.Assign):
+ # 故意不抽取 NET_TOTALS_FILE 的赋值:那会把我们注入的临时路径
+ # 覆盖回 /var/lib/drouter/...,测试就会读写到真实位置(Git Bash 下
+ # 还会被映射到 Git 安装目录),造成跨次运行互相污染。
+ for t in node.targets:
+ if isinstance(t, ast.Name) and t.id in WANT:
+ picked.append(node)
+ elif isinstance(node, ast.FunctionDef) and node.name in WANT:
+ picked.append(node)
+
+got = set()
+for n in picked:
+ if isinstance(n, ast.Assign):
+ got.update(t.id for t in n.targets if isinstance(t, ast.Name))
+ else:
+ got.add(n.name)
+missing = set(WANT) - got
+assert not missing, '未能从源码抽取:%s' % ', '.join(sorted(missing))
+
+tmp = tempfile.mkdtemp()
+STATE = {'rx': 0, 'tx': 0, 'cur': (0, 0), 'path': os.path.join(tmp, 'net-totals.json')}
+
+ns = {
+ 'os': os, 'json': json, 'time': __import__('time'),
+ 'NET_TOTALS_FILE': STATE['path'],
+}
+
+
+def fake_counters():
+ rx, tx = STATE['cur']
+ return {'ens18': {'rx_bytes': rx, 'tx_bytes': tx, 'rx_pkts': 0, 'tx_pkts': 0}}
+
+
+ns['_net_counters'] = fake_counters
+exec(compile(ast.Module(body=picked, type_ignores=[]), SRC, 'exec'), ns)
+
+snapshot = ns['_net_totals_snapshot']
+update = ns['_net_totals_update']
+hbytes = ns['_hbytes']
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-44s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+print('字节人性化:')
+ck('0 → 0 B', hbytes(0) == '0 B', hbytes(0))
+ck('1024 → 1.0 KB', hbytes(1024) == '1.0 KB', hbytes(1024))
+ck('1.5 MB', hbytes(1024 ** 2 * 1.5) == '1.5 MB', hbytes(1024 ** 2 * 1.5))
+ck('3.0 GB', hbytes(1024 ** 3 * 3) == '3.0 GB', hbytes(1024 ** 3 * 3))
+ck('2.0 TB', hbytes(1024 ** 4 * 2) == '2.0 TB', hbytes(1024 ** 4 * 2))
+
+print('\n累计逻辑:')
+# 起始:网卡已收 100 / 发 50
+STATE['cur'] = (100, 50)
+s = snapshot()
+ck('无历史文件时从 0 起算', s['rx_bytes'] == 100 and s['tx_bytes'] == 50,
+ '%d/%d' % (s['rx_bytes'], s['tx_bytes']))
+
+update()
+# 再产生 200 / 80 的增量
+STATE['cur'] = (300, 130)
+s = snapshot()
+ck('增量叠加正确', s['rx_bytes'] == 300 and s['tx_bytes'] == 130,
+ '%d/%d' % (s['rx_bytes'], s['tx_bytes']))
+
+update()
+ck('落盘后读取稳定', snapshot()['rx_bytes'] == 300)
+
+# 模拟重启:/proc 计数器清零(比上次记录的 last 更小)
+STATE['cur'] = (10, 5)
+s = snapshot()
+ck('计数器回绕不产生负增量', s['rx_bytes'] >= 300 and s['pending_rx'] == 0,
+ 'rx=%d pending=%d' % (s['rx_bytes'], s['pending_rx']))
+ck('回绕后仍给出可读值', s['rx_h'].endswith('B'), s['rx_h'])
+
+update()
+# 重启后重新增长
+STATE['cur'] = (60, 30)
+s = snapshot()
+ck('重启后继续累计', s['rx_bytes'] == 300 + 50 and s['tx_bytes'] == 130 + 25,
+ '%d/%d' % (s['rx_bytes'], s['tx_bytes']))
+
+ck('since 起始时间已被记录', bool(snapshot().get('since')), snapshot().get('since') or '(空)')
+
+print('\n结果:%d 项,失败 %d 项' % (12, len(fails)))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-opensoho.py b/_dev/t-opensoho.py
new file mode 100644
index 0000000..994940b
--- /dev/null
+++ b/_dev/t-opensoho.py
@@ -0,0 +1,826 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""AC / AP 管理中心 OpenSOHO(#10):静态契约 + 纯函数 + 安装/卸载逻辑单测。
+
+盯五件事:
+ ① 两把密钥的分工不能搞混 —— 共享密钥改了 AP 要重注册(可接受),
+ 加密密钥改了历史配置直接解不开(不可接受)。所以升级、改端口、改开关
+ 都绝不能重新生成加密密钥,只有卸载清除才动它;
+ ② 解包必须挡住 zip slip —— 压缩包是外部下载的,成员名带 ../ 不能落到
+ 目标目录之外;
+ ③ 端口钳制不能把 0 当成「没填」—— int(x or 8090) 的 falsy 陷阱;
+ ④ 构建保护模式下 install / svc / save 只写盘不启服务;
+ ⑤ 不碰安全红线:5900、dnsmasq、nft、默认路由。
+"""
+import ast
+import io
+import os
+import re
+import sys
+import json
+import shutil
+import stat
+import zipfile
+import tempfile
+import urllib
+import urllib.request
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+WEB_SRC = io.open(WEB, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+TREE = ast.parse(HELPER_SRC)
+
+
+def node(name):
+ for n in TREE.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ return n
+ # 元组解包赋值(如 `A, B = 1, 2`):目标名散在 Tuple 里
+ if isinstance(t, ast.Tuple):
+ for e in t.elts:
+ if isinstance(e, ast.Name) and e.id == name:
+ return n
+ elif isinstance(n, ast.FunctionDef) and n.name == name:
+ return n
+ return None
+
+
+def build(names, ns=None):
+ ns = ns if ns is not None else {}
+ body = []
+ for nm in names:
+ n = node(nm)
+ if n is None:
+ raise AssertionError('源码里找不到 %s' % nm)
+ body.append(n)
+ mod = ast.Module(body=body, type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ return ns
+
+
+# ---------------------------------------------------------------- 测试替身
+CALLS = []
+ENVS = []
+CWDS = []
+SH_OUT = {}
+URLS = []
+HTTP_BODY = {}
+BUILD_MODE = [False]
+OPENED = []
+
+
+def fake_sh(argv, timeout=10, input_data=None, cwd=None, env=None):
+ CALLS.append(list(argv))
+ ENVS.append(dict(env or {}))
+ CWDS.append(cwd)
+ a = list(argv)
+ if a[:2] == ['uname', '-m']:
+ return (0, SH_OUT.get('uname', 'x86_64'), '')
+ if a[:2] == ['systemctl', 'is-active']:
+ return (0, SH_OUT.get('active', 'inactive'), '')
+ if a[:2] == ['systemctl', 'is-enabled']:
+ return (0, SH_OUT.get('enabled', 'disabled'), '')
+ if a[0] == 'ip':
+ return (0, SH_OUT.get('ip_out', ''), '')
+ if a[0] == 'ss':
+ return (0, SH_OUT.get('ss_out', ''), '')
+ if a[0] == 'id':
+ return (0, '', '')
+ if len(a) >= 2 and a[1] == '-v':
+ return (0, SH_OUT.get('ver', 'opensoho version 0.15.2'), '')
+ return (0, 'ok', '')
+
+
+def _ok(data=None, msg='ok', code='OK'):
+ return {'ok': True, 'msg_cn': msg, 'code': code, 'data': data or {}}
+
+
+def _fail(msg, code='ERR', data=None):
+ return {'ok': False, 'msg_cn': msg, 'code': code, 'data': data or {}}
+
+
+def _log(*a, **k):
+ return None
+
+
+def _in_build_mode():
+ return BUILD_MODE[0]
+
+
+class _FakeResp(object):
+ def __init__(self, body):
+ self._b = body if isinstance(body, bytes) else body.encode()
+
+ def read(self, n=None):
+ return self._b if n is None else self._b[:n]
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+
+class _FakeRequestMod(object):
+ @staticmethod
+ def Request(url, data=None, headers=None, method=None):
+ return (url, data, headers, method)
+
+ @staticmethod
+ def urlopen(arg, timeout=None, context=None):
+ u = arg[0] if isinstance(arg, tuple) else arg
+ URLS.append(u)
+ body = HTTP_BODY.get(u)
+ if body is None:
+ raise IOError('stub: no body for %s' % u)
+ return _FakeResp(body)
+
+
+class _ShimURLLib(object):
+ request = _FakeRequestMod
+
+
+# zipfile 已经在文件头真实导入过了,这里替换 sys.modules 不会影响它
+_real_urllib = sys.modules.get('urllib')
+sys.modules['urllib'] = _ShimURLLib
+
+
+def _fake_open(path, mode='r', *a, **k):
+ OPENED.append(path)
+ return io.open(path, mode, *a, **k)
+
+
+# Windows 的 os.chmod 只认「只读」那一位,0600 / 0755 都落不下去。
+# 真机是 Linux,所以这里不能把「位不对」当成源码 bug —— 落盘校验只在 POSIX 上做,
+# 本机改为静态校验源码里确实写了正确的 mode。
+IS_WIN = (os.name == 'nt')
+
+
+def perm_ok(path, want):
+ if IS_WIN:
+ return True
+ return (os.stat(path).st_mode & 0o777) == want
+
+
+def mode_str(path):
+ return oct(os.stat(path).st_mode & 0o777)
+
+
+# ---------------------------------------------------------------- 构造命名空间
+TMP = tempfile.mkdtemp(prefix='t-opensoho-')
+
+NS = build([
+ 'OH_DEFAULTS', 'OH_ITEMS', 'OH_COLLECTIONS',
+ 'OH_SECRET_ENV', 'OH_ENC_ENV', 'OH_SECRET_LEN', 'OH_ENC_LEN',
+ 'OH_ADMIN_EMAIL', 'OH_API_LATEST', 'OH_PORT_MIN', 'OH_PORT_MAX',
+ 'OH_BIND_CHOICES', 'OH_UNIT', 'OH_SERVICE', 'OH_USER',
+ 'OH_DIR', 'OH_BIN', 'OH_DATA', 'OH_CONF', 'OH_ENV', 'OH_ADMIN',
+ '_oh_rand', '_oh_norm', '_oh_load', '_oh_save',
+ '_oh_read_env', '_oh_write_env', '_oh_ensure_env', '_oh_env',
+ '_oh_admin_load', '_oh_admin_save',
+ '_oh_installed', '_oh_service', '_oh_version', '_oh_arch',
+ '_oh_cli', '_oh_wait_up',
+ '_oh_unit_body', '_oh_write_unit',
+ '_oh_listen_now', '_oh_lan_ip', '_oh_health',
+ '_oh_api', '_oh_login', '_oh_ensure_admin', '_oh_admin_token',
+ '_oh_local_candidates', '_oh_ensure_user',
+ '_oh_latest_release', '_oh_download', '_oh_extract_bin',
+ '_oh_status', '_oh_install_op', '_oh_uninstall_op', '_oh_svc_op',
+ '_oh_save_op', '_oh_secret_op', '_oh_probe_op', '_oh_pick',
+ 'act_opensoho',
+], {
+ 're': re, 'os': os, 'json': json, 'shutil': shutil, 'stat': stat,
+ 'time': __import__('time'), 'sh': fake_sh,
+ 'ok': _ok, 'fail': _fail, 'log': _log, 'in_build_mode': _in_build_mode,
+ 'tempfile': tempfile,
+})
+
+# 路径常量一律重定向到临时目录 —— 绝不抽源码里的路径常量节点(会覆盖注入值,
+# 让测试读写真实路径造成跨次污染)
+NS['OH_DIR'] = os.path.join(TMP, 'opt', 'opensoho')
+NS['OH_BIN'] = os.path.join(NS['OH_DIR'], 'opensoho')
+NS['OH_DATA'] = os.path.join(TMP, 'var', 'opensoho')
+NS['OH_CONF'] = os.path.join(TMP, 'etc', 'opensoho.conf')
+NS['OH_ENV'] = os.path.join(TMP, 'etc', 'opensoho.env')
+NS['OH_ADMIN'] = os.path.join(TMP, 'etc', 'opensoho.admin')
+NS['OH_UNIT'] = os.path.join(TMP, 'etc', 'opensoho.service')
+for _d in (os.path.dirname(NS['OH_CONF']), NS['OH_DIR'], NS['OH_DATA']):
+ os.makedirs(_d, exist_ok=True)
+
+DEFAULTS = NS['OH_DEFAULTS']
+ITEMS = NS['OH_ITEMS']
+COLLECTIONS = NS['OH_COLLECTIONS']
+SECRET_ENV = NS['OH_SECRET_ENV']
+ENC_ENV = NS['OH_ENC_ENV']
+SECRET_LEN = NS['OH_SECRET_LEN']
+ENC_LEN = NS['OH_ENC_LEN']
+BIND_CHOICES = NS['OH_BIND_CHOICES']
+
+oh_rand = NS['_oh_rand']
+oh_norm = NS['_oh_norm']
+oh_load = NS['_oh_load']
+oh_save = NS['_oh_save']
+read_env = NS['_oh_read_env']
+write_env = NS['_oh_write_env']
+ensure_env = NS['_oh_ensure_env']
+oh_env = NS['_oh_env']
+admin_load = NS['_oh_admin_load']
+admin_save = NS['_oh_admin_save']
+oh_installed = NS['_oh_installed']
+oh_service = NS['_oh_service']
+oh_version = NS['_oh_version']
+oh_arch = NS['_oh_arch']
+unit_body = NS['_oh_unit_body']
+write_unit = NS['_oh_write_unit']
+oh_cli = NS['_oh_cli']
+oh_wait_up = NS['_oh_wait_up']
+listen_now = NS['_oh_listen_now']
+oh_health = NS['_oh_health']
+oh_api = NS['_oh_api']
+oh_login = NS['_oh_login']
+oh_admintoken = NS['_oh_admin_token']
+local_cands = NS['_oh_local_candidates']
+ensure_user = NS['_oh_ensure_user']
+latest_release = NS['_oh_latest_release']
+extract_bin = NS['_oh_extract_bin']
+oh_status = NS['_oh_status']
+install_op = NS['_oh_install_op']
+uninstall_op = NS['_oh_uninstall_op']
+svc_op = NS['_oh_svc_op']
+save_op = NS['_oh_save_op']
+secret_op = NS['_oh_secret_op']
+probe_op = NS['_oh_probe_op']
+oh_pick = NS['_oh_pick']
+act = NS['act_opensoho']
+
+
+def main():
+ print('================ OpenSOHO AC/AP 单测 ================')
+
+ print('\n--- 出厂默认值 ---')
+ chk('默认监听 0.0.0.0(AP 要从局域网注册进来)',
+ DEFAULTS['bind'] == '0.0.0.0', DEFAULTS['bind'])
+ chk('默认端口 8090', DEFAULTS['port'] == 8090, str(DEFAULTS['port']))
+ chk('默认开机自启', DEFAULTS['autostart'] is True)
+ chk('默认自动接管新 AP', DEFAULTS['enable_new_devices'] is True)
+ chk('默认开启配置静态加密', DEFAULTS['encryption'] is True)
+ chk('共享密钥长度要求 32', SECRET_LEN == 32, str(SECRET_LEN))
+ chk('加密密钥长度 32(--encryptionEnv 硬性要求)', ENC_LEN == 32)
+ chk('监听地址只给三个白名单选项',
+ sorted(BIND_CHOICES) == ['0.0.0.0', '127.0.0.1', '::'], str(BIND_CHOICES))
+
+ print('\n--- 说明条目(每项都要讲清为什么与影响) ---')
+ chk('说明条目不少于 6 项', len(ITEMS) >= 6, str(len(ITEMS)))
+ for it in ITEMS:
+ chk('说明 %s 有 why 与 impact' % it['key'],
+ bool(it.get('why')) and len(it.get('impact') or []) >= 2)
+
+ print('\n--- 集合名必须是实测到的真实名字 ---')
+ names = [c[0] for c in COLLECTIONS]
+ for must in ('devices', 'clients', 'radios', 'wifi_ssids', 'wifi_aps'):
+ chk('集合含 %s' % must, must in names)
+ chk('没有用文档里那个不存在的 wifi 集合名',
+ 'wifi' not in names, str(names))
+
+ print('\n--- 端口钳制(int(x or 默认) 的 falsy 陷阱) ---')
+ chk('端口 0 不被悄悄回落成默认', oh_norm({'port': 0})['port'] == 1024,
+ str(oh_norm({'port': 0})['port']))
+ chk('端口 1 被抬到 1024', oh_norm({'port': 1})['port'] == 1024)
+ chk('端口 70000 被压到 65535', oh_norm({'port': 70000})['port'] == 65535)
+ chk('端口 8091 原样保留', oh_norm({'port': 8091})['port'] == 8091)
+ chk('端口 None 用默认 8090', oh_norm({'port': None})['port'] == 8090)
+ chk('端口空串用默认 8090', oh_norm({'port': ''})['port'] == 8090)
+ chk('端口是垃圾字符串时用默认 8090', oh_norm({'port': 'abc'})['port'] == 8090)
+ chk('端口是字符串数字也能用', oh_norm({'port': '9090'})['port'] == 9090)
+
+ print('\n--- 其它字段收敛 ---')
+ chk('非法监听地址回落 0.0.0.0', oh_norm({'bind': 'evil'})['bind'] == '0.0.0.0')
+ chk('合法监听地址保留', oh_norm({'bind': '127.0.0.1'})['bind'] == '127.0.0.1')
+ chk('autostart 被收成 bool', oh_norm({'autostart': 0})['autostart'] is False)
+ chk('缺失字段用默认值补齐',
+ oh_norm({}) == dict(DEFAULTS), str(oh_norm({})))
+
+ print('\n--- 配置读写往返 ---')
+ oh_save({'bind': '127.0.0.1', 'port': 9099, 'autostart': False,
+ 'enable_new_devices': False, 'encryption': False})
+ back = oh_load()
+ chk('保存后能原样读回', back == {'bind': '127.0.0.1', 'port': 9099,
+ 'autostart': False, 'enable_new_devices': False,
+ 'encryption': False}, str(back))
+ chk('配置文件真的落盘了', os.path.isfile(NS['OH_CONF']))
+ os.unlink(NS['OH_CONF'])
+ chk('删掉后读回出厂默认', oh_load() == dict(DEFAULTS))
+ # 坏文件不能让页面白屏
+ with open(NS['OH_CONF'], 'w') as f:
+ f.write('{ this is not json')
+ chk('配置损坏时回落默认值而不是崩溃', oh_load() == dict(DEFAULTS))
+ os.unlink(NS['OH_CONF'])
+
+ print('\n--- systemd 单元 ---')
+ u = unit_body(DEFAULTS)
+ chk('单元指定了专用用户', 'User=opensoho' in u)
+ chk('单元指定了专用组', 'Group=opensoho' in u)
+ chk('密钥走 EnvironmentFile 而不是 Environment=(单元是 0644)',
+ 'EnvironmentFile=' in u and 'Environment=OPENSOHO' not in u)
+ chk('单元显式写了 /usr/sbin 进 PATH',
+ '/usr/sbin' in u)
+ chk('单元带了 --dir(默认值随运行用户变,必须显式给)', '--dir' in u)
+ chk('单元带了数据目录绝对路径', NS['OH_DATA'] in u)
+ # 【实测踩坑】pb_migrations 是解压到「工作目录」而不是 --dir 的,
+ # 工作目录指向 root 所有的二进制目录会让服务用户写不进去、启动秒退。
+ chk('工作目录指向数据目录而不是二进制目录',
+ ('WorkingDirectory=%s' % NS['OH_DATA']) in u
+ and ('WorkingDirectory=%s' % NS['OH_DIR']) not in u,
+ [l for l in u.splitlines() if l.startswith('WorkingDirectory')])
+ # 只看常量赋值本身,别被注释里「为什么不用 /var/lib/drouter/...」的说明误伤
+ _m = re.search(r"^OH_DATA = '([^']+)'", HELPER_SRC, re.M)
+ chk('数据目录常量不在 /var/lib/drouter 下(父目录 0750 会让服务用户穿不进去)',
+ bool(_m) and not _m.group(1).startswith('/var/lib/drouter'),
+ _m.group(1) if _m else '未找到 OH_DATA')
+ chk('默认监听 0.0.0.0:8090', '--http 0.0.0.0:8090' in u)
+ chk('默认开启自动接管', '--enableNewDevices=true' in u)
+ chk('默认带加密密钥环境变量', '--encryptionEnv=OPENSOHO_SETTINGS_KEY' in u)
+ chk('有 Install 段', 'WantedBy=multi-user.target' in u)
+ chk('有失败重启', 'Restart=on-failure' in u)
+ chk('做了最小权限收敛',
+ 'NoNewPrivileges=yes' in u and 'ProtectSystem=full' in u)
+ u2 = unit_body({'bind': '127.0.0.1', 'port': 9999,
+ 'enable_new_devices': False, 'encryption': False})
+ chk('改监听与端口会进 ExecStart', '--http 127.0.0.1:9999' in u2)
+ chk('关闭自动接管会写成 false', '--enableNewDevices=false' in u2)
+ chk('关闭加密就不带 --encryptionEnv', '--encryptionEnv' not in u2)
+ write_unit(DEFAULTS)
+ chk('写单元后文件存在', os.path.isfile(NS['OH_UNIT']))
+ chk('单元文件权限 0644', perm_ok(NS['OH_UNIT'], 0o644), mode_str(NS['OH_UNIT']))
+ chk('源码里显式 chmod 了单元文件', 'os.chmod(OH_UNIT, 0o644)' in HELPER_SRC)
+
+ print('\n--- 两把密钥 ---')
+ s1, e1 = ensure_env()
+ chk('首次生成共享密钥长度达标', len(s1) == SECRET_LEN, str(len(s1)))
+ chk('首次生成加密密钥长度 32', len(e1) == ENC_LEN, str(len(e1)))
+ chk('共享密钥是字母数字(方便往 LuCI 粘贴)', s1.isalnum())
+ s2, e2 = ensure_env()
+ chk('第二次调用共享密钥不变', s2 == s1)
+ chk('【关键】第二次调用加密密钥绝不变——变了历史配置就解不开', e2 == e1)
+ chk('密钥文件权限是 0600(里面有 Wi-Fi 配置的加密密钥)',
+ perm_ok(NS['OH_ENV'], 0o600), mode_str(NS['OH_ENV']))
+ chk('源码里显式 chmod 了密钥文件为 0600',
+ 'os.chmod(OH_ENV, 0o600)' in HELPER_SRC)
+ got = read_env()
+ chk('能读回共享密钥', got[0] == s1)
+ chk('能读回加密密钥', got[1] == e1)
+ # 手工塞一个短密钥,应该被判定为弱并重新生成
+ write_env('short', e1)
+ s3, e3 = ensure_env()
+ chk('共享密钥太短会被重新生成', len(s3) == SECRET_LEN and s3 != 'short')
+ chk('重生成共享密钥时加密密钥依然不动', e3 == e1)
+ write_env(s1, e1)
+ # 解析健壮性:注释、引号、空行都不能让它崩
+ with open(NS['OH_ENV'], 'w') as f:
+ f.write('# comment\n\n%s="%s"\n%s=%s\n' % (SECRET_ENV, s1, ENC_ENV, e1))
+ chk('能解析带注释与引号的环境文件', read_env() == (s1, e1))
+ write_env(s1, e1)
+ envd = oh_env()
+ chk('给子命令的环境里带了共享密钥', envd.get(SECRET_ENV) == s1)
+ chk('给子命令的环境里带了加密密钥', envd.get(ENC_ENV) == e1)
+ # 连 --help 都要求共享密钥已设置,所以绝不能给空串
+ chk('环境里的共享密钥不会是空串(空了连 -v 都跑不了)',
+ bool(envd.get(SECRET_ENV)))
+
+ print('\n--- 控制台管理员凭据 ---')
+ admin_save({'email': 'admin@drouter.local', 'password': 'pw', 'token': 't'})
+ chk('凭据文件权限 0600', perm_ok(NS['OH_ADMIN'], 0o600), mode_str(NS['OH_ADMIN']))
+ chk('源码里显式 chmod 了凭据文件为 0600',
+ 'os.chmod(OH_ADMIN, 0o600)' in HELPER_SRC)
+ chk('能读回凭据', admin_load().get('email') == 'admin@drouter.local')
+ with open(NS['OH_ADMIN'], 'w') as f:
+ f.write('not json')
+ chk('凭据文件损坏时返回空而不是崩溃', admin_load() == {})
+ os.unlink(NS['OH_ADMIN'])
+
+ print('\n--- 健康检查(/api/health 免鉴权) ---')
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/health'] = \
+ '{"message":"API is healthy.","code":200,"data":{}}'
+ okh, msg = oh_health(8090)
+ chk('健康响应被判定为正常', okh is True and msg == 'ok', msg)
+ HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
+ chk('只有 code:200 也算正常', oh_health(8090)[0] is True)
+ HTTP_BODY['http://127.0.0.1:8090/api/health'] = 'garbage'
+ okh2, msg2 = oh_health(8090)
+ chk('异常响应被判定为不健康', okh2 is False, msg2)
+ HTTP_BODY.clear()
+ okh3, msg3 = oh_health(8090)
+ chk('连不上被判定为不健康且不抛异常', okh3 is False and '连不上' in msg3, msg3)
+
+ print('\n--- REST 调用 ---')
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/collections/devices/records?perPage=1'] = \
+ '{"items":[],"totalItems":0}'
+ d, err = oh_api('GET', '/api/collections/devices/records?perPage=1', 8090, 'tok')
+ chk('GET 能拿到解析后的 dict', d is not None and d.get('totalItems') == 0, err)
+ HTTP_BODY.clear()
+ d2, err2 = oh_api('GET', '/api/x', 8090, 'tok')
+ chk('失败时返回 None 与错误串而不是抛异常', d2 is None and bool(err2))
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
+ '{"token":"abc.def.ghi"}'
+ tok, e = oh_login(8090, 'a@b.c', 'pw')
+ chk('登录能取到 token', tok == 'abc.def.ghi', e)
+ HTTP_BODY.clear()
+ tok2, e2 = oh_login(8090, 'a@b.c', 'pw')
+ chk('登录失败时返回空 token 与错误', tok2 == '' and bool(e2))
+
+ print('\n--- 字段取值(不硬猜字段名) ---')
+ chk('取第一个非空候选', oh_pick({'mac': 'aa:bb'}, ('name', 'mac')) == 'aa:bb')
+ chk('候选全空时返回空串', oh_pick({'x': 1}, ('name', 'mac')) == '')
+ chk('空值被跳过继续找下一个',
+ oh_pick({'name': '', 'mac': 'cc'}, ('name', 'mac')) == 'cc')
+ chk('None 入参不崩', oh_pick(None, ('name',)) == '')
+
+ print('\n--- 解包:zip slip 防护 ---')
+ zpath = os.path.join(TMP, 'evil.zip')
+ with zipfile.ZipFile(zpath, 'w') as z:
+ z.writestr('opensoho', b'\x7fELF-fake-binary')
+ z.writestr('../../../../tmp/pwned-by-opensoho', b'evil')
+ z.writestr('some/dir/note.txt', b'hi')
+ outdir = os.path.join(TMP, 'extracted')
+ got = extract_bin(zpath, outdir)
+ chk('解出了 opensoho 二进制', os.path.basename(got) == 'opensoho')
+ chk('二进制带执行权限',
+ True if IS_WIN else (os.stat(got).st_mode & 0o111) != 0)
+ chk('源码里显式 chmod 了解出的二进制为 0755',
+ 'os.chmod(dst, 0o755)' in HELPER_SRC)
+ chk('【关键】带 ../ 的成员没有落到目标目录之外',
+ not os.path.exists('/tmp/pwned-by-opensoho'))
+ chk('只解了那一个文件,没有把整个压缩包铺开',
+ not os.path.exists(os.path.join(outdir, 'some')))
+ badzip = os.path.join(TMP, 'bad.zip')
+ with zipfile.ZipFile(badzip, 'w') as z:
+ z.writestr('readme.txt', b'nothing here')
+ try:
+ extract_bin(badzip, os.path.join(TMP, 'extracted2'))
+ chk('压缩包里没二进制时要报错', False)
+ except Exception as ex:
+ chk('压缩包里没二进制时要报错', '没有 opensoho' in str(ex), str(ex)[:60])
+ raw = os.path.join(TMP, 'opensoho')
+ with open(raw, 'wb') as f:
+ f.write(b'\x7fELF-fake')
+ got2 = extract_bin(raw, os.path.join(TMP, 'extracted3'))
+ chk('裸二进制也能直接装', os.path.isfile(got2))
+
+ print('\n--- 最新版查询 ---')
+ HTTP_BODY.clear()
+ HTTP_BODY[NS['OH_API_LATEST']] = json.dumps({
+ 'tag_name': 'v0.15.2',
+ 'assets': [{'name': 'opensoho_0.15.2_linux_amd64.zip',
+ 'browser_download_url': 'https://x/opensoho.zip'},
+ {'name': 'opensoho_0.15.2_linux_arm64.zip',
+ 'browser_download_url': 'https://x/opensoho-arm.zip'}]})
+ ver, url = latest_release('amd64')
+ chk('能取到版本号且去掉了 v 前缀', ver == '0.15.2', str(ver))
+ chk('按架构挑对了 amd64 包', url == 'https://x/opensoho.zip', str(url))
+ ver2, url2 = latest_release('arm64')
+ chk('按架构挑对了 arm64 包', url2 == 'https://x/opensoho-arm.zip', str(url2))
+ HTTP_BODY.clear()
+ v3, u3 = latest_release('amd64')
+ chk('查不到时返回空版本与中文错误', v3 == '' and 'GitHub' in u3, u3[:50])
+ chk('架构映射 x86_64 → amd64', oh_arch() == 'amd64')
+ SH_OUT['uname'] = 'aarch64'
+ chk('架构映射 aarch64 → arm64', oh_arch() == 'arm64')
+ SH_OUT['uname'] = 'mips'
+ chk('不支持的架构返回空', oh_arch() == '')
+ SH_OUT['uname'] = 'x86_64'
+
+ print('\n--- 子命令执行(cwd 与属主) ---')
+ CALLS[:] = []
+ ENVS[:] = []
+ CWDS[:] = []
+ oh_cli([NS['OH_BIN'], '-v'], timeout=20)
+ chk('子命令把共享密钥带进了环境',
+ any(e.get(SECRET_ENV) for e in ENVS))
+ chk('子命令把 cwd 设成了数据目录(否则 root 会在 / 下留 pb_migrations)',
+ NS['OH_DATA'] in CWDS, str([c for c in CWDS if c]))
+ chk('跑完把属主改回服务用户(不改下次服务起不来)',
+ any(c[:2] == ['chown', '-R'] for c in CALLS),
+ str([c for c in CALLS if c[:1] == ['chown']]))
+ chk('chown 的目标是 opensoho:opensoho',
+ any(c[:2] == ['chown', '-R'] and 'opensoho:opensoho' in c for c in CALLS))
+ chk('chown 的对象是数据目录',
+ any(c[:2] == ['chown', '-R'] and NS['OH_DATA'] in c for c in CALLS))
+
+ print('\n--- 启动确认(systemctl 返回 0 不代表活着) ---')
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
+ SH_OUT['active'] = 'active'
+ up, why = oh_wait_up({'port': 8090}, tries=2)
+ chk('服务在跑且 health 正常 → 判定起来了', up is True, why)
+ SH_OUT['active'] = 'inactive'
+ up2, why2 = oh_wait_up({'port': 8090}, tries=1)
+ chk('服务没起来 → 判定没起来', up2 is False)
+ chk('失败说明里给了排查命令', 'journalctl' in why2, why2[:80])
+ SH_OUT['active'] = 'active'
+ HTTP_BODY['http://127.0.0.1:8090/api/health'] = 'garbage'
+ up3, why3 = oh_wait_up({'port': 8090}, tries=2)
+ chk('服务在跑但端口没应答 → 判定没起来并说清端口',
+ up3 is False and '8090' in why3, why3[:80])
+ SH_OUT['active'] = 'inactive'
+ HTTP_BODY.clear()
+
+ print('\n--- 安装(离线:从本机文件) ---')
+ # 造一个可用的假二进制
+ with open(NS['OH_BIN'], 'wb') as f:
+ f.write(b'\x7fELF-fake-opensoho')
+ os.chmod(NS['OH_BIN'], 0o755)
+ chk('已安装判定为真', oh_installed() is True)
+ CALLS[:] = []
+ SH_OUT['active'] = 'active'
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
+ r = install_op({'local_path': raw})
+ chk('从本机文件安装成功', r['ok'] is True, r.get('msg_cn'))
+ chk('安装时建了 systemd 单元', os.path.isfile(NS['OH_UNIT']))
+ chk('安装时确保了密钥文件', os.path.isfile(NS['OH_ENV']))
+ chk('安装时重新加载了 systemd',
+ any(c[:2] == ['systemctl', 'daemon-reload'] for c in CALLS))
+ chk('安装后启动了服务',
+ any(c[:2] == ['systemctl', 'restart'] for c in CALLS))
+ chk('安装时先建了专用系统用户',
+ any(c[:1] == ['id'] for c in CALLS))
+ # 【实测踩坑】升级时服务正在跑,二进制被内核占用,直接覆盖会报
+ # 「Text file busy」,必须先停再换。
+ i_stop = next((i for i, c in enumerate(CALLS) if c[:2] == ['systemctl', 'stop']), -1)
+ i_restart = next((i for i, c in enumerate(CALLS)
+ if c[:2] == ['systemctl', 'restart']), -1)
+ chk('换二进制前先把服务停了', i_stop >= 0)
+ chk('停服务发生在重启之前', 0 <= i_stop < i_restart,
+ 'stop=%d restart=%d' % (i_stop, i_restart))
+ CALLS[:] = []
+ r2 = install_op({'local_path': '/nope/definitely-not-here'})
+ chk('本地文件不存在时报 NOT_FOUND',
+ r2['ok'] is False and r2.get('code') == 'NOT_FOUND', r2.get('msg_cn'))
+ chk('【关键】文件不存在时不重启服务',
+ not any(c[0] == 'systemctl' and 'restart' in c for c in CALLS))
+ chk('安装失败但原本在跑 → 把服务拉回去,不留停止状态',
+ any(c[:2] == ['systemctl', 'start'] for c in CALLS))
+ r3 = install_op({})
+ chk('不带 local_path 时尝试联网下载(本机连不上 GitHub 会明确报错)',
+ r3['ok'] is False and r3.get('code') in ('NO_RELEASE', 'DL_FAIL'),
+ '%s / %s' % (r3.get('code'), (r3.get('msg_cn') or '')[:60]))
+
+ print('\n--- 构建保护模式:只写盘不启服务 ---')
+ BUILD_MODE[0] = True
+ CALLS[:] = []
+ r4 = install_op({'local_path': raw})
+ chk('保护模式下安装仍然成功(只是不启服务)', r4['ok'] is True, r4.get('msg_cn'))
+ chk('保护模式下不启服务',
+ not any(c[0] == 'systemctl' and 'restart' in c for c in CALLS))
+ chk('保护模式下提示了原因', '构建保护模式' in (r4.get('msg_cn') or ''))
+ CALLS[:] = []
+ r5 = svc_op({'op2': 'start'})
+ chk('保护模式下 start 被拦住',
+ r5['ok'] is False and r5.get('code') == 'BUILD_MODE_BLOCKED', r5.get('msg_cn'))
+ chk('保护模式下拦住时不调 systemctl start',
+ not any(c[:2] == ['systemctl', 'start'] for c in CALLS))
+ CALLS[:] = []
+ save_op({'bind': '127.0.0.1', 'port': 8123})
+ chk('保护模式下保存只写盘不重启',
+ not any(c[0] == 'systemctl' and 'restart' in c for c in CALLS))
+ chk('保护模式下保存仍然落了配置',
+ oh_load()['port'] == 8123, str(oh_load()['port']))
+ BUILD_MODE[0] = False
+ oh_save(dict(DEFAULTS))
+
+ print('\n--- 服务操作 ---')
+ CALLS[:] = []
+ r6 = svc_op({'op2': 'restart'})
+ chk('重启成功', r6['ok'] is True, r6.get('msg_cn'))
+ r7 = svc_op({'op2': 'bogus'})
+ chk('非法服务操作被拒', r7['ok'] is False and r7.get('code') == 'BAD_OP')
+ CALLS[:] = []
+ svc_op({'op2': 'enable'})
+ chk('enable 会把 autostart 落进配置', oh_load()['autostart'] is True)
+ svc_op({'op2': 'disable'})
+ chk('disable 会把 autostart 落进配置', oh_load()['autostart'] is False)
+ oh_save(dict(DEFAULTS))
+
+ print('\n--- 重新生成共享密钥 ---')
+ before_s, before_e = read_env()
+ CALLS[:] = []
+ r8 = secret_op({})
+ after_s, after_e = read_env()
+ chk('共享密钥确实换了', after_s != before_s)
+ chk('【关键】加密密钥绝不能被换掉', after_e == before_e)
+ chk('提示了 AP 要重新注册', '重新注册' in (r8.get('msg_cn') or ''),
+ r8.get('msg_cn'))
+ write_env(before_s, before_e)
+
+ print('\n--- 管理员凭据:首次要能自己建出来 ---')
+ if os.path.isfile(NS['OH_ADMIN']):
+ os.unlink(NS['OH_ADMIN'])
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
+ '{"token":"T1"}'
+ tok, e = oh_admintoken(8090)
+ chk('【关键】没有已存凭据时能现场建出管理员(首次安装后就是这种情况)',
+ tok == 'T1', e)
+ chk('凭据已落盘', os.path.isfile(NS['OH_ADMIN']))
+ chk('邮箱用的是默认管理员账号',
+ admin_load().get('email') == 'admin@drouter.local',
+ str(admin_load().get('email')))
+ chk('密码是随机生成的', len(admin_load().get('password') or '') >= 12)
+ URLS[:] = []
+ HTTP_BODY['http://127.0.0.1:8090/api/collections/devices/records?perPage=1'] = \
+ '{"totalItems":0}'
+ tok2, _e2 = oh_admintoken(8090)
+ chk('已有有效 token 时直接复用', tok2 == 'T1')
+ chk('复用时不重复登录',
+ not any('auth-with-password' in u for u in URLS), str(URLS))
+ # token 失效(401)时要能用留存的密码重新换一个
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
+ '{"token":"T2"}'
+ tok3, _e3 = oh_admintoken(8090)
+ chk('token 失效时用留存密码重新换', tok3 == 'T2', str(tok3))
+
+ print('\n--- 子命令必须带同一个加密参数 ---')
+ # 库是 serve 带 --encryptionEnv 建的,superuser upsert 不带同一个参数就会报
+ # 「missing encryption key」,管理员永远建不出来。
+ CALLS[:] = []
+ oh_save(dict(DEFAULTS)) # encryption=True
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
+ '{"token":"T3"}'
+ if os.path.isfile(NS['OH_ADMIN']):
+ os.unlink(NS['OH_ADMIN'])
+ NS['_oh_ensure_admin'](8090)
+ chk('开启加密时建管理员要带 --encryptionEnv',
+ any('--encryptionEnv=' + ENC_ENV in c for c in CALLS),
+ str([c for c in CALLS if 'superuser' in c])[:140])
+ CALLS[:] = []
+ oh_save(dict(OH_DEFAULTS if False else DEFAULTS, encryption=False))
+ if os.path.isfile(NS['OH_ADMIN']):
+ os.unlink(NS['OH_ADMIN'])
+ NS['_oh_ensure_admin'](8090)
+ chk('关闭加密时建管理员不带 --encryptionEnv',
+ not any('--encryptionEnv' in c for c in CALLS))
+ oh_save(dict(DEFAULTS))
+
+ print('\n--- 加密开关:有数据后不许再切 ---')
+ # 让服务处于停止态,save 就不会走「重启并等 health」那条路
+ # (那段已在别处单独测过,这里只关心加密开关的准入判断)
+ _prev_active = SH_OUT.get('active')
+ SH_OUT['active'] = 'inactive'
+ try:
+ os.makedirs(NS['OH_DATA'], exist_ok=True)
+ with open(os.path.join(NS['OH_DATA'], 'data.db'), 'wb') as f:
+ f.write(b'x')
+ r_enc = save_op({'encryption': False})
+ chk('【关键】已有数据后切加密开关被拒',
+ r_enc['ok'] is False and r_enc.get('code') == 'ENC_LOCKED',
+ r_enc.get('msg_cn'))
+ chk('拒绝时说清了后果与出路',
+ '读不出来' in (r_enc.get('msg_cn') or '') and '卸载' in (r_enc.get('msg_cn') or ''))
+ chk('被拒时配置没有被改坏', oh_load()['encryption'] is True)
+ r_ok = save_op({'port': 8095})
+ chk('不涉及加密的改动仍然能保存', r_ok['ok'] is True, r_ok.get('msg_cn'))
+ os.unlink(os.path.join(NS['OH_DATA'], 'data.db'))
+ r_none = save_op({'encryption': False})
+ chk('没有数据时允许切换(首次安装前的场景)',
+ r_none['ok'] is True, r_none.get('msg_cn'))
+ finally:
+ oh_save(dict(DEFAULTS))
+ SH_OUT['active'] = _prev_active
+
+ print('\n--- 概况读取 ---')
+ HTTP_BODY.clear()
+ HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
+ r9 = probe_op({})
+ chk('服务不健康时明确拒绝读取',
+ r9['ok'] is False, r9.get('msg_cn'))
+ # 管理员凭据为空 → 拿不到 token → 要有明确错误而不是静默成功
+ if os.path.isfile(NS['OH_ADMIN']):
+ os.unlink(NS['OH_ADMIN'])
+ HTTP_BODY['http://127.0.0.1:8090/api/collections/devices/records?perPage=1'] = \
+ '{"items":[],"totalItems":0}'
+ r10 = probe_op({})
+ chk('拿不到凭据时返回明确错误', r10['ok'] is False and bool(r10.get('code')),
+ '%s / %s' % (r10.get('code'), (r10.get('msg_cn') or '')[:60]))
+
+ print('\n--- 卸载 ---')
+ CALLS[:] = []
+ r11 = uninstall_op({})
+ chk('卸载成功', r11['ok'] is True, r11.get('msg_cn'))
+ chk('卸载先停服务',
+ any(c[:2] == ['systemctl', 'stop'] for c in CALLS))
+ chk('卸载移除了单元文件', not os.path.isfile(NS['OH_UNIT']))
+ chk('默认保留数据目录(里面有 AP 清单)',
+ os.path.isdir(NS['OH_DATA']), NS['OH_DATA'])
+ chk('默认保留密钥文件', os.path.isfile(NS['OH_ENV']))
+ r12 = uninstall_op({'purge': True})
+ chk('purge 时清掉数据目录', r12['ok'] is True and not os.path.isdir(NS['OH_DATA']))
+ chk('purge 时清掉密钥文件', not os.path.isfile(NS['OH_ENV']))
+
+ print('\n--- op 分发 ---')
+ r13 = act({'op': 'bogus'})
+ chk('未知 op 返回 BAD_OP', r13['ok'] is False and r13.get('code') == 'BAD_OP')
+ chk('缺省 op 是 status', act({})['ok'] is True)
+ SH_OUT['active'] = 'inactive'
+ st = act({'op': 'status'})
+ chk('status 返回安装状态字段', 'installed' in (st.get('data') or {}))
+ chk('status 返回密钥字段', 'secret' in (st.get('data') or {}))
+ chk('status 返回控制台地址', 'console_url' in (st.get('data') or {}))
+ chk('status 返回说明条目', len((st.get('data') or {}).get('items') or []) >= 6)
+ chk('status 返回构建保护模式状态',
+ 'build_mode' in (st.get('data') or {}))
+
+ print('\n--- 监听与地址解析 ---')
+ SH_OUT['ss_out'] = ('LISTEN 0 4096 0.0.0.0:8090 0.0.0.0:*\n'
+ 'LISTEN 0 4096 [::]:8090 [::]:*\n'
+ 'LISTEN 0 4096 0.0.0.0:631 0.0.0.0:*')
+ got3 = listen_now(8090)
+ chk('能找出 8090 的监听地址', '0.0.0.0' in got3 and '[::]' in got3, str(got3))
+ chk('不会把别的端口算进来', '631' not in ''.join(got3))
+ # ss 把「监听全部地址」打成 `*`,直接显示没人看得懂
+ SH_OUT['ss_out'] = 'LISTEN 0 4096 *:8090 *:*'
+ chk('ss 的通配 * 被还原成 0.0.0.0', listen_now(8090) == ['0.0.0.0'],
+ str(listen_now(8090)))
+ chk('端口非法时返回空列表', listen_now('abc') == [])
+ SH_OUT['ip_out'] = ('2: ens18 inet 192.168.7.3/24 brd\n'
+ '3: ens19 inet 10.0.0.5/24 brd')
+ chk('能取到 LAN 地址', NS['_oh_lan_ip']() == '192.168.7.3', NS['_oh_lan_ip']())
+ SH_OUT['ip_out'] = '1: lo inet 127.0.0.1/8 scope host'
+ chk('只剩回环时返回空', NS['_oh_lan_ip']() == '')
+
+ print('\n--- 后端接线 ---')
+ chk('ACTIONS 注册了 opensoho', "'opensoho': act_opensoho" in HELPER_SRC)
+ chk('drouter-web 有 /api/opensoho 路由', "'/api/opensoho'" in WEB_SRC)
+ chk('install 给了足够长的超时(要下载 13MB)',
+ re.search(r"to = 280 if op == 'install'", WEB_SRC) is not None)
+ chk('路由走的是 helper opensoho',
+ re.search(r"helper\('opensoho', b, timeout=to\)", WEB_SRC) is not None)
+ chk('依赖自检登记了 opensoho',
+ "'opensoho', 'OpenSOHO" in HELPER_SRC)
+ chk('依赖自检里 opensoho 不提供 apt 一键安装(它不是 apt 包)',
+ re.search(r"'AC/AP 管理中心:.*', '', 'ac'\)", HELPER_SRC, re.S) is not None)
+ chk('依赖自检新增了 ac 分组', "('ac', '无线 AC / AP')" in HELPER_SRC)
+
+ print('\n--- 前端接线 ---')
+ chk('菜单里有 AC/AP 管理中心', "k: 'opensoho'" in APP_SRC)
+ chk('VIEWS 映射了 opensoho', 'opensoho: viewOpensoho,' in APP_SRC)
+ chk('有 viewOpensoho 函数', 'async function viewOpensoho()' in APP_SRC)
+ chk('调的是 /api/opensoho', "'/api/opensoho'" in APP_SRC)
+ for fn in ('ohLoad', 'ohRender', 'ohSave', 'ohInstall', 'ohUninstall',
+ 'ohRegenSecret', 'ohProbe', 'ohRenderStats'):
+ chk('前端有 %s' % fn, ('function %s(' % fn) in APP_SRC)
+ for eid in ('oh-bind', 'oh-port', 'oh-newdev', 'oh-enc', 'oh-auto',
+ 'oh-secret-val', 'oh-local', 'oh-purge'):
+ chk('页面有元素 %s' % eid, ('id="%s"' % eid) in APP_SRC)
+ chk('端口输入有 min/max 限制',
+ 'min="1024" max="65535"' in APP_SRC)
+ chk('重新生成密钥前有二次确认',
+ '重新注册一次' in APP_SRC and 'window.confirm' in APP_SRC)
+ chk('卸载区分是否清除数据', 'purge' in APP_SRC)
+ chk('把控制台地址做成可点击链接', 'target="_blank"' in APP_SRC)
+
+ print('\n--- 安全红线:不许碰的东西 ---')
+ seg = HELPER_SRC[HELPER_SRC.index('OH_DIR = '):HELPER_SRC.index('def read_share')]
+ chk('本模块没有动 5900(VNC)', '5900' not in seg)
+ chk('本模块没有启停 DHCP', 'dnsmasq' not in seg and 'kea' not in seg)
+ chk('本模块没有改防火墙', 'nft' not in seg)
+ chk('本模块没有动默认路由', 'ip route' not in seg)
+ chk('本模块没有动 radvd', 'radvd' not in seg)
+
+ sys.modules['urllib'] = _real_urllib
+ shutil.rmtree(TMP, ignore_errors=True)
+ try:
+ os.unlink('/tmp/pwned-by-opensoho')
+ except Exception:
+ pass
+ print('\n================ 通过 %d / 失败 %d ================' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/t-packaging.py b/_dev/t-packaging.py
new file mode 100644
index 0000000..51bda99
--- /dev/null
+++ b/_dev/t-packaging.py
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""打包产物静态自检:deb 元数据 / 维护脚本 / Dockerfile。
+
+真机构建由 packaging/build-deb.sh 负责(需要 dpkg-deb),本脚本负责在任何机器上
+都能跑的「结构正确性」检查,把「打出来的包不能用」挡在构建之前。
+"""
+import os
+import subprocess
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.normpath(os.path.join(HERE, '..'))
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-46s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+def read(rel):
+ with open(os.path.join(ROOT, rel), encoding='utf-8') as f:
+ return f.read()
+
+
+def no_crlf(rel):
+ with open(os.path.join(ROOT, rel), 'rb') as f:
+ return b'\r' not in f.read()
+
+
+def bash_ok(rel):
+ """能用 bash 就做真语法检查;Windows 上没有 bash 就跳过(不算失败)。"""
+ exe = subprocess.run(['bash', '-n', os.path.join(ROOT, rel)],
+ capture_output=True)
+ if exe.returncode == 127 or (b'not found' in exe.stderr.lower()):
+ return None
+ return exe.returncode == 0
+
+
+print('一、deb 元数据(packaging/deb/control)')
+ctl = read('packaging/deb/control')
+ck('含 Package', 'Package: drouter' in ctl)
+ck('Version 是占位符(构建时替换)', '__VERSION__' in ctl)
+ck('Architecture: all', 'Architecture: all' in ctl)
+ck('Depends 非空', 'Depends:' in ctl and len(ctl.split('Depends:')[1].split('\n\n')) > 0)
+for pkg in ('python3', 'nftables', 'dnsmasq', 'radvd', 'ppp', 'systemd', 'sudo'):
+ ck('Depends 含 %s' % pkg, pkg in ctl.split('Recommends:')[0])
+# smartmontools 装完会自动起 smartd,不该出现在 apt 默认安装的 Recommends 里
+rec = ctl.split('Recommends:')[1].split('\n')[0] if 'Recommends:' in ctl else ''
+ck('Recommends 不含 smartmontools(会自启守护)', 'smartmontools' not in rec, rec.strip()[:50])
+ck('smartmontools 放进了 Suggests',
+ 'smartmontools' in (ctl.split('Suggests:')[1] if 'Suggests:' in ctl else ''))
+ck('control 无 CRLF', no_crlf('packaging/deb/control'))
+# DEBIAN/control 是 deb822 格式,官方并不保证支持 "#" 注释 —— 别往里写注释
+ck('control 不含 # 注释行(deb822 不保证支持)',
+ not [l for l in ctl.split('\n') if l.lstrip().startswith('#')])
+# 每一行必须是「空行」或「字段名: 值」或「以空格/制表符开头的续行」
+bad_cont = []
+for l in ctl.split('\n'):
+ if not l.strip():
+ continue
+ if l[0] in (' ', '\t'):
+ continue
+ if ':' not in l:
+ bad_cont.append(l)
+ck('每行都是「字段: 值」或续行', not bad_cont, str(bad_cont[:2]))
+# 依赖策略的理由写在 build-deb.sh 里(control 不能写注释)
+ck('依赖策略说明在 build-deb.sh 里',
+ 'smartmontools' in read('packaging/build-deb.sh'))
+
+print('\n二、维护脚本(postinst / prerm / postrm)')
+for m in ('postinst', 'prerm', 'postrm'):
+ rel = 'packaging/deb/' + m
+ src = read(rel)
+ ck('%s 存在' % m, bool(src))
+ ck('%s 无 CRLF' % m, no_crlf(rel))
+ ck('%s 有 shebang' % m, src.startswith('#!/bin/bash'))
+ ck('%s 处理了所有 dpkg 动作' % m, 'case "$1" in' in src and '*)' in src)
+ ck('%s 以 exit 0 收尾' % m, 'exit 0' in src)
+ r = bash_ok(rel)
+ if r is not None:
+ ck('%s bash 语法' % m, r)
+
+# postinst 的安全红线:绝不能 enable/start DHCP、DNS、UPnP
+pi = read('packaging/deb/postinst')
+for svc in ('dnsmasq', 'radvd', 'miniupnpd'):
+ ck('postinst 不启动 %s' % svc,
+ ('systemctl enable %s' % svc) not in pi and ('systemctl start %s' % svc) not in pi)
+ck('postinst 用构建保护模式', 'BUILD_MODE' in pi)
+ck('postinst 会装 drouter-ctl', 'drouter-ctl' in pi)
+ck('postinst 会跑 deploy.sh', 'deploy.sh' in pi)
+ck('postinst 用 DROUTER_SRC 指向 /opt/drouter', 'DROUTER_SRC=/opt/drouter' in pi)
+
+prm = read('packaging/deb/postrm')
+ck('postrm purge 清 /etc/drouter', 'rm -rf /etc/drouter' in prm)
+ck('postrm purge 清 /var/lib/drouter(累计流量)', '/var/lib/drouter' in prm)
+ck('postrm remove 保留配置', 'remove)' in prm)
+
+print('\n三、Dockerfile')
+df = read('packaging/docker/Dockerfile')
+ck('无 CRLF', no_crlf('packaging/docker/Dockerfile'))
+INSTR = ('FROM', 'LABEL', 'ENV', 'RUN', 'COPY', 'EXPOSE', 'VOLUME',
+ 'HEALTHCHECK', 'ENTRYPOINT', 'CMD', 'WORKDIR', 'ARG', 'USER', 'SHELL')
+lines = df.split('\n')
+bad = []
+for i, l in enumerate(lines):
+ s = l.strip()
+ if not s or s.startswith('#'):
+ continue
+ if s.split()[0].startswith(INSTR):
+ continue
+ # 上一行以 \ 结尾则是续行,合法
+ if i > 0 and lines[i - 1].rstrip().endswith('\\'):
+ continue
+ bad.append((i + 1, s))
+ck('所有指令合法', not bad, str(bad[:2]))
+ck('有 FROM', df.startswith('FROM ') or '\nFROM ' in df)
+ck('有 ENTRYPOINT', 'ENTRYPOINT' in df)
+ck('有 CMD', 'CMD' in df)
+ck('有 HEALTHCHECK', 'HEALTHCHECK' in df)
+
+# COPY 的源必须真实存在,否则 docker build 才失败就太晚了
+missing = []
+for l in lines:
+ if l.strip().startswith('COPY'):
+ parts = l.split()
+ if len(parts) >= 2:
+ src = parts[1]
+ if '*' not in src and not os.path.exists(os.path.join(ROOT, src.rstrip('/'))):
+ missing.append(src)
+ck('COPY 源路径都存在', not missing, str(missing))
+
+# HEALTHCHECK / 端口不应写死,否则用户改端口后永远 unhealthy
+hc = [l for l in lines if 'HEALTHCHECK' in l or 'curl -sk' in l]
+ck('健康检查读取实际端口(不写死 8443)',
+ any('web-port' in x for x in hc), ''.join(hc)[:80])
+
+ini = read('packaging/docker/docker-init.sh')
+ck('docker-init 无 CRLF', no_crlf('packaging/docker/docker-init.sh'))
+ck('docker-init 以 exec 结尾(信号可传递)', 'exec "$@"' in ini)
+ck('docker-init 播种端口后 unset(避免压住界面改端口)',
+ 'unset DROUTER_WEB_PORT' in ini)
+r = bash_ok('packaging/docker/docker-init.sh')
+if r is not None:
+ ck('docker-init bash 语法', r)
+
+print('\n四、构建脚本')
+for rel in ('packaging/build-deb.sh', 'packaging/build-docker.sh',
+ 'scripts/drouter-ctl.sh', 'scripts/install-base.sh'):
+ ck('%s 存在' % rel, os.path.exists(os.path.join(ROOT, rel)))
+ ck('%s 无 CRLF' % rel, no_crlf(rel))
+ r = bash_ok(rel)
+ if r is not None:
+ ck('%s bash 语法' % rel, r)
+
+bd = read('packaging/build-deb.sh')
+ck('build-deb 先校验后端再打包', 'ast.parse' in bd)
+ck('build-deb 生成 md5sums', 'md5sums' in bd)
+# 逐个点名,别再用「数 .py 出现次数」这种会被注释里的文件名骗过的写法。
+# drouter-shelld.py 是 Web 终端的 PTY 守护,漏了它终端就打不开。
+BACKEND_MODULES = ['render.py', 'drouter-helper.py', 'drouter-web.py',
+ 'drouter-logd.py', 'drouter-snapshotd.py',
+ 'drouter-rescue.py', 'drouter-shelld.py',
+ 'drouter-helpd.py', 'theme.py']
+miss_mod = [m for m in BACKEND_MODULES if m not in bd]
+ck('build-deb 逐个校验 %d 个后端模块' % len(BACKEND_MODULES), not miss_mod,
+ '→ 缺 %s' % miss_mod if miss_mod else '')
+
+print('\n结果:失败 %d 项' % len(fails))
+for f in fails:
+ print(' ✘ ' + f)
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-perf.py b/_dev/t-perf.py
new file mode 100644
index 0000000..4e89b33
--- /dev/null
+++ b/_dev/t-perf.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""性能回归(#85):钉死本轮修掉的性能问题,防止改回去。
+
+这个测试专门盯三类「看起来没事、实际一直在浪费」的问题:
+ ① 页面切换后定时器泄漏(切走还在轮询 → 后端被空打);
+ ② 高频接口里的 subprocess 滥用(每次切页面都要 fork 好几次);
+ ③ 死代码分支(写了 quick 参数却两个分支一样)。
+
+放这里而不是散在各模块测试里,是因为它们都是「跨模块的性能约定」,
+改任何一个视图或读接口都可能破坏,需要一处集中守住。
+"""
+import io
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+HELPER_LINES = HELPER_SRC.split('\n')
+APP_LINES = APP_SRC.split('\n')
+
+
+def hseg(name):
+ """取 helper 里某个顶层函数的函数体(按行切片)。"""
+ try:
+ i = next(k for k, l in enumerate(HELPER_LINES) if l.startswith('def %s(' % name))
+ except StopIteration:
+ return ''
+ j = next((k for k in range(i + 1, len(HELPER_LINES))
+ if HELPER_LINES[k].startswith(('def ', 'class ', '@'))), len(HELPER_LINES))
+ return '\n'.join(HELPER_LINES[i:j])
+
+
+def strip_comment_lines(text):
+ """去掉整行注释与行尾注释,只留可执行代码。
+
+ 为什么需要:源码里保留「原来是 sh(['cat', '/proc/loadavg'])」这类**说明性
+ 注释**是好事(后人知道为什么改),但如果断言直接对原始文本做 substring
+ 匹配,就会被自己的注释骗过 —— 本轮 t-perf 已经踩过一次。
+ 这里做一个够用的近似:逐行截断到第一个未出现在字符串字面量里的 '#'。
+ """
+ out = []
+ for line in text.split('\n'):
+ q = None
+ cut = len(line)
+ for k, c in enumerate(line):
+ if q:
+ if c == '\\':
+ continue
+ if c == q:
+ q = None
+ elif c in ('"', "'"):
+ q = c
+ elif c == '#':
+ cut = k
+ break
+ out.append(line[:cut])
+ return '\n'.join(out)
+
+
+def main():
+ print('--- ① 页面切换不得泄漏定时器(前端)---')
+ chk('定义了 stopPageTimers', 'function stopPageTimers(' in APP_SRC)
+ chk('go() 首件事就是停表',
+ re.search(r'function go\(k\) \{\s*\n\s*(//[^\n]*\n\s*)*stopPageTimers\(\);', APP_SRC) is not None)
+ # 所有模块级 interval 定时器变量都必须出现在 stopPageTimers 里
+ for v in ('DASH_TIMER', 'WANLOG_TIMER', 'FWL_TIMER', 'DK_TIMER'):
+ seg = APP_SRC[APP_SRC.index('function stopPageTimers('):]
+ seg = seg[:seg.index('\nfunction go(')]
+ chk('stopPageTimers 里清了 %s' % v, v in seg)
+ chk('stopPageTimers 调用了 pubip 的停止函数', 'pubipProbeStopPoll' in
+ APP_SRC[APP_SRC.index('function stopPageTimers('):APP_SRC.index('\nfunction go(')])
+ # 闭包内定时器通过全局钩子暴露
+ chk('声明了 stopLeaseTimer 钩子', re.search(r'let stopLeaseTimer\s*=', APP_SRC) is not None)
+ chk('声明了 stopV6Timer 钩子', re.search(r'let stopV6Timer\s*=', APP_SRC) is not None)
+ chk('viewDhcp 挂上了 stopLeaseTimer', 'stopLeaseTimer = () =>' in APP_SRC)
+ chk('viewV6Test 挂上了 stopV6Timer', 'stopV6Timer = () =>' in APP_SRC)
+
+ print('\n--- ② 轮询函数必须先判 DOM 再发请求 ---')
+ # liveWanLog:不能出现「先 api(...) 再 if (!box) return」
+ seg = APP_SRC[APP_SRC.index('async function liveWanLog('):]
+ seg = seg[:seg.index('\n}') + 2]
+ i_dom = seg.find("$('#pp-live')")
+ i_api = seg.find('api(')
+ chk('liveWanLog 判 DOM 在发请求之前', 0 <= i_dom < i_api,
+ 'dom@%d api@%d' % (i_dom, i_api))
+ # loadLeases 同理
+ seg2 = APP_SRC[APP_SRC.index('const loadLeases = ()'):]
+ seg2 = seg2[:seg2.index('stopLeaseTimer =')]
+ chk('loadLeases 先判 #dh-leases 再请求',
+ seg2.find("if (!$('#dh-leases'))") < seg2.find('api('))
+
+ print('\n--- ③ 导航代次:旧请求不得覆盖新页面 ---')
+ chk('定义了 NAV_GEN', re.search(r'let NAV_GEN\s*=\s*0', APP_SRC) is not None)
+ chk('go() 里递增代次', re.search(r'function go\(k\) \{.*?NAV_GEN\+\+', APP_SRC, re.S) is not None)
+ chk('有 navStale 判断函数', 'function navStale(' in APP_SRC)
+ chk('渲染链路校验代次', 'if (navStale(gen)) return;' in APP_SRC)
+
+ print('\n--- ④ 高频接口不得滥用 subprocess ---')
+ seg_si = hseg('read_sysinfo')
+ # 断言只看「可执行代码」,先剥掉注释——否则解释性注释里写一句
+ # 「原来是 sh(['cat', '/proc/loadavg'])」就会把测试自己骗过(本轮已踩)。
+ code_si = strip_comment_lines(seg_si)
+ chk('read_sysinfo 不用 sh([\'hostname\'])', "sh(['hostname'])" not in code_si)
+ chk("read_sysinfo 不用 sh(['cat', '/proc/loadavg'])",
+ 'loadavg' not in code_si or "sh(['cat'" not in code_si)
+ chk('read_sysinfo 用 socket.gethostname()', 'socket.gethostname()' in seg_si)
+ chk('read_sysinfo 用 os.uname()', 'os.uname()' in seg_si)
+ chk('read_sysinfo 改用 _read_disks()', '_read_disks()' in seg_si)
+ chk('read_sysinfo 不再直接调 df', "sh(['df'" not in code_si)
+
+ print('\n--- ⑤ _read_disks 实现正确性 ---')
+ seg_d = hseg('_read_disks')
+ chk('_read_disks 读 /proc/mounts', "'/proc/mounts'" in seg_d)
+ chk('_read_disks 用 statvfs', 'os.statvfs(' in seg_d)
+ chk('_read_disks 过滤 tmpfs/devtmpfs',
+ "'tmpfs'" in seg_d and "'devtmpfs'" in seg_d)
+ chk('_read_disks 输出与 df 同结构(含 mount/pct)',
+ "x['mount']" in seg_d and "'pct'" in seg_d)
+ chk('_read_disks 根分区排最前', "x['mount'] != '/'" in seg_d)
+ chk('_read_disks 注册在 read_sysinfo 之前',
+ HELPER_SRC.index('def _read_disks(') < HELPER_SRC.index('def read_sysinfo('))
+
+ print('\n--- ⑥ 死代码分支(quick 参数)---')
+ seg_m = strip_comment_lines(hseg('read_metrics'))
+ # 注意:不能只判 'if not quick else',因为合法写法 `x if quick else y` 也存在。
+ # 真正的「死代码」是**条件与两侧完全颠倒成同一结果**,即 `A if not quick else A`。
+ chk('read_metrics 不再出现「两个分支一样」的写法',
+ re.search(r'(\w[\w\.\(\)\[\]]*)\s+if\s+not\s+quick\s+else\s+\1\b', seg_m) is None)
+ # quick 仍应被读取(保留参数兼容),但不得造成同义分支
+ chk('quick 参数仍被读取(向后兼容)', "quick = bool(" in seg_m)
+
+ print('\n--- ⑦ 批量取服务状态(不再逐条 fork systemctl)---')
+ chk('定义了 _svc_states 批量函数', 'def _svc_states(' in HELPER_SRC)
+ seg_ss = hseg('_svc_states')
+ chk('_svc_states 用一次 systemctl show', "'show'" in seg_ss and "'--value'" in seg_ss)
+ chk('_svc_states 同时请求两个属性',
+ "'-p'" in seg_ss and 'ActiveState' in seg_ss and 'UnitFileState' in seg_ss)
+ chk('_svc_states 按属性个数分段切分',
+ 'len(props)' in seg_ss)
+ seg_rs = strip_comment_lines(hseg('read_services'))
+ chk('read_services 不再循环调 systemctl is-active',
+ "'is-active'" not in seg_rs and "'is-enabled'" not in seg_rs)
+ chk('read_services 改用 _svc_states', '_svc_states(' in seg_rs)
+ # read_share / read_logd / read_docker 三处同样的逐条 fork 也要收口
+ for fn in ('read_share', 'read_logd', 'read_docker'):
+ seg = strip_comment_lines(hseg(fn))
+ chk('%s 不再逐条 systemctl is-active/is-enabled' % fn,
+ "'is-active'" not in seg and "'is-enabled'" not in seg
+ and '_svc_states(' in seg)
+ # which / command -v 也不该在循环里反复 fork
+ chk('read_ulog_conf 用 shutil.which(零 fork)',
+ 'shutil.which(' in strip_comment_lines(hseg('read_ulog_conf')))
+ chk('_dep_installed 用 shutil.which',
+ 'shutil.which(' in strip_comment_lines(hseg('_dep_installed')))
+ seg_dpi = strip_comment_lines(hseg('_dpi_installed'))
+ chk('_dpi_installed 不再循环 fork bash -lc',
+ "bash', '-lc'" not in seg_dpi)
+
+ print('\n' + '=' * 58)
+ print('通过 %d / 失败 %d' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/t-portfwd.py b/_dev/t-portfwd.py
new file mode 100644
index 0000000..bd62e11
--- /dev/null
+++ b/_dev/t-portfwd.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+"""端口转发 / DMZ 渲染测试。"""
+import sys
+sys.path.insert(0, r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend")
+import render
+
+fails = 0
+def chk(label, cond, extra=''):
+ global fails
+ if not cond: fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+cfg4 = {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'], 'mss_clamp': True, 'mss': 1452,
+ 'portfwd': {'enable': True, 'rules': [
+ {'name': '群晖DSM', 'proto': 'tcp', 'ext_port': 5000, 'int_ip': '192.168.7.10', 'int_port': 5000, 'enable': True},
+ {'name': '游戏', 'proto': 'tcp/udp', 'ext_port': 27015, 'int_ip': '192.168.7.20', 'int_port': 27016, 'enable': True},
+ {'name': '禁用', 'proto': 'tcp', 'ext_port': 8080, 'int_ip': '192.168.7.30', 'int_port': 8080, 'enable': False},
+ ], 'dmz': {'enable': False, 'host': ''}}}
+o4 = render.render_nft('v4', cfg4)
+chk('IPv4 DNAT 同端口省略端口号', 'dnat to 192.168.7.10 comment' in o4)
+chk('IPv4 DNAT 异端口带端口号', 'dnat to 192.168.7.20:27016' in o4)
+chk('IPv4 禁用规则不生成', '192.168.7.30' not in o4)
+chk('IPv4 生成 nat_pre/dstnat 链', 'nat_pre' in o4 and 'priority dstnat' in o4)
+chk('IPv4 放通规则在 forward 链', 'tcp dport 5000 ip daddr 192.168.7.10 accept' in o4)
+chk('IPv4 tcp/udp 展开两条', o4.count('dport 27015') >= 2)
+
+cfg6 = {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'portfwd': {'enable': True, 'rules': [
+ {'name': 'NAS', 'proto': 'tcp', 'ext_port': 8443, 'int_ip': '2408:8207:1234::10', 'int_port': 443, 'enable': True}],
+ 'dmz': {'enable': True, 'host': '2408:8207:1234::99'}}}
+o6 = render.render_nft('v6', cfg6)
+chk('IPv6 DNAT 方括号包裹', 'dnat to [2408:8207:1234::10]:443' in o6)
+chk('IPv6 DMZ 全端口映射', 'DMZ 全端口映射' in o6)
+chk('IPv6 DMZ 放通', 'ip6 daddr 2408:8207:1234::99 accept' in o6)
+chk('IPv6 放通用 ip6 关键字', 'tcp dport 443 ip6 daddr 2408:8207:1234::10 accept' in o6)
+
+# 关闭时不应有 nat_pre
+o4off = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'portfwd': {'enable': False, 'rules': [], 'dmz': {'enable': False, 'host': ''}}})
+chk('关闭时无 nat_pre 链', 'nat_pre' not in o4off)
+
+# 校验错误
+for bad, want in [(('999.1.1.1',), 'IPv4'), (('2408::gg',), 'IPv6')]:
+ try:
+ render.render_nft('v4' if want == 'IPv4' else 'v6', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'portfwd': {'enable': True, 'rules': [{'name': 'x', 'proto': 'tcp', 'ext_port': 80,
+ 'int_ip': bad[0], 'int_port': 80}]}})
+ chk('拒绝非法 ' + want, False)
+ except render.ValidateError as e:
+ chk('拒绝非法 ' + want, True, '→ ' + e.msg_cn)
+try:
+ render.render_nft('v6', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'portfwd': {'enable': True, 'rules': [{'name': 'x', 'proto': 'tcp', 'ext_port': 80,
+ 'int_ip': 'fe80::1', 'int_port': 80}]}})
+ chk('拒绝链路本地 IPv6', False)
+except render.ValidateError as e:
+ chk('拒绝链路本地 IPv6', True, '→ ' + e.msg_cn)
+# 非法协议
+try:
+ render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'portfwd': {'enable': True, 'rules': [{'name': 'x', 'proto': 'sctp', 'ext_port': 80,
+ 'int_ip': '192.168.7.1', 'int_port': 80}]}})
+ chk('拒绝非法协议', False)
+except render.ValidateError as e:
+ chk('拒绝非法协议', True, '→ ' + e.msg_cn)
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-print.py b/_dev/t-print.py
new file mode 100644
index 0000000..d487d66
--- /dev/null
+++ b/_dev/t-print.py
@@ -0,0 +1,827 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""打印服务 CUPS / USB RAW 直通(#7):静态契约 + 纯函数 + 互斥逻辑单测。
+
+盯五件事:
+ ① CUPS 与 RAW 互斥 —— 它们抢同一个 USB 设备。切到 RAW 必须先把
+ cups / cups.socket / cups-browsed 停干净,切到 CUPS 必须停掉 RAW 服务;
+ ② 出厂默认「允许远程管理」必须是关的 —— 打印服务器不该让同网段任何人
+ 都能增删队列;
+ ③ cupsctl --remote-any 会写成 `Allow all`(放行一切来源,WAN 侧也能进),
+ 本页必须把它收窄成 `Allow @LOCAL`;
+ ④ 队列名与设备 URI 必须有白名单 —— lpadmin 虽然走列表调用不过 shell,
+ 但这些值会落进 printers.conf,不能让奇怪的字符进去;
+ ⑤ 保存只写配置,构建保护模式下绝不启停服务。
+"""
+import ast
+import io
+import os
+import re
+import sys
+import json
+import shutil
+import tempfile
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+WEB_SRC = io.open(WEB, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+TREE = ast.parse(HELPER_SRC)
+
+
+def node(name):
+ for n in TREE.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ return n
+ elif isinstance(n, ast.FunctionDef) and n.name == name:
+ return n
+ return None
+
+
+def build(names, ns=None):
+ ns = ns if ns is not None else {}
+ body = []
+ for nm in names:
+ n = node(nm)
+ if n is None:
+ raise AssertionError('源码里找不到 %s' % nm)
+ body.append(n)
+ mod = ast.Module(body=body, type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ return ns
+
+
+# ---------------------------------------------------------------- 测试替身
+CALLS = []
+
+
+def fake_sh(argv, timeout=10, input_data=None):
+ CALLS.append(list(argv))
+ # cupsd -t / cupsctl:一律假装成功,避免真去改目标机
+ return (0, 'ok', '')
+
+
+class ValidateError(Exception):
+ def __init__(self, msg, code='BAD_ARG'):
+ Exception.__init__(self, msg)
+ self.msg_cn = msg
+ self.code = code
+
+
+def _ok(data=None, msg='ok'):
+ return {'ok': True, 'msg_cn': msg, 'data': data or {}}
+
+
+def _fail(msg, code='ERR'):
+ return {'ok': False, 'msg_cn': msg, 'code': code}
+
+
+NS = build([
+ 'PRINT_DEFAULTS', 'PRINT_ITEMS', 'PRINT_DRIVERS', 'PRINT_PKGS',
+ 'PRINT_URI_SCHEMES', 'PRINT_NAME_RE', 'PRINT_USB_IFCLASS_PRINTER',
+ 'PRINT_RAW_UNIT_BODY',
+ '_print_parse_lsusb',
+ '_print_parse_printers_conf', '_print_parse_jobs',
+ '_print_parse_lpstat_p', '_print_c_locale_env',
+ '_print_patch_location', '_print_cupsctl_args',
+ '_print_queue_name', '_print_uri',
+ '_print_load', '_print_save',
+ '_print_backup', '_print_prune_backups', '_print_backups',
+], {
+ 're': re, 'os': os, 'json': json, 'shutil': shutil, 'sh': fake_sh,
+ 'ValidateError': ValidateError,
+ # 源码是 `from datetime import datetime`,注入的必须是类不是模块
+ 'datetime': __import__('datetime').datetime,
+ # PRINT_RAW_UNIT_BODY 是拼接出来的,依赖这个值;先占位,build 完再换成临时路径
+ 'PRINT_RAW_WRAPPER': '/opt/drouter/scripts/printer-raw.sh',
+ # _print_usb_ifclass 的默认参数;build 完再换成临时目录
+ 'PRINT_USB_CLASS_DIR': '/sys/bus/usb/devices',
+})
+# 路径常量一律重定向到临时目录 —— 绝不抽源码里的常量节点(会覆盖这里注入的值,
+# 让测试读写真实路径造成跨次污染)
+TMP = tempfile.mkdtemp(prefix='t-print-')
+NS['PRINT_CONF'] = os.path.join(TMP, 'print.conf')
+NS['PRINT_CUPSD'] = os.path.join(TMP, 'cupsd.conf')
+NS['PRINT_BAK_DIR'] = os.path.join(TMP, 'bak')
+NS['PRINT_BAK_KEEP'] = 5
+NS['PRINT_RAW_SERVICE'] = 'drouter-printer-raw.service'
+NS['PRINT_RAW_WRAPPER'] = os.path.join(TMP, 'printer-raw.sh')
+NS['PRINT_USB_CLASS_DIR'] = os.path.join(TMP, 'sysfs')
+
+# ------------------------------------------------- 假 sysfs(给 usb_ifclass 用)
+# 结构:设备名 → {接口目录名: bInterfaceClass}
+FAKE_SYSFS = {
+ '1-1': {'1-1:1.0': '07\n'}, # 打印机
+ '1-2': {'1-2:1.0': '08\n'}, # U 盘(大容量存储)
+ '1-3': {'1-3:1.0': '03\n'}, # 键鼠(HID)
+ '1-4': {}, # 没接口目录(例如 hub)
+ '1-5': {'1-5:1.0': '03\n', '1-5:1.1': '07\n'}, # 多接口,其一命中
+}
+
+
+class _ShimPath(object):
+ """源码走的是 os.path.isdir,所以替身必须挂在 path 上,不能只挂 os.isdir。"""
+ join = staticmethod(os.path.join)
+ basename = staticmethod(os.path.basename)
+
+ @staticmethod
+ def isdir(d):
+ rel = os.path.basename(str(d).rstrip('/\\'))
+ return rel == 'devices' or rel in FAKE_SYSFS
+
+
+class _ShimOS(object):
+ path = _ShimPath
+
+ @staticmethod
+ def listdir(d):
+ rel = os.path.basename(d.rstrip('/\\'))
+ if rel == 'devices':
+ return sorted(FAKE_SYSFS.keys())
+ if rel in FAKE_SYSFS:
+ return sorted(FAKE_SYSFS[rel].keys())
+ raise OSError('no such directory: %s' % d)
+
+ @staticmethod
+ def isdir(d):
+ return _ShimPath.isdir(d)
+
+
+class _ShimFile(object):
+ def __init__(self, path):
+ parts = path.replace('\\', '/').rstrip('/').split('/')
+ self._v = FAKE_SYSFS[parts[-3]][parts[-2]]
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def read(self):
+ return self._v
+
+
+def _shim_open(path, *a, **k):
+ return _ShimFile(path)
+
+
+usb_ifclass = build(['_print_usb_ifclass'], {
+ 'os': _ShimOS, 'open': _shim_open,
+ 'PRINT_USB_IFCLASS_PRINTER': NS['PRINT_USB_IFCLASS_PRINTER'],
+ 'PRINT_USB_CLASS_DIR': '/sys/bus/usb/devices',
+})['_print_usb_ifclass']
+
+DEFAULTS = NS['PRINT_DEFAULTS']
+ITEMS = NS['PRINT_ITEMS']
+DRIVERS = NS['PRINT_DRIVERS']
+PKGS = NS['PRINT_PKGS']
+URI_SCHEMES = NS['PRINT_URI_SCHEMES']
+NAME_RE = NS['PRINT_NAME_RE']
+UNIT_BODY = NS['PRINT_RAW_UNIT_BODY']
+IFCLASS = NS['PRINT_USB_IFCLASS_PRINTER']
+
+parse_lsusb = NS['_print_parse_lsusb']
+parse_conf = NS['_print_parse_printers_conf']
+parse_lpstat_p = NS['_print_parse_lpstat_p']
+c_locale_env = NS['_print_c_locale_env']
+parse_jobs = NS['_print_parse_jobs']
+patch_loc = NS['_print_patch_location']
+cupsctl_args = NS['_print_cupsctl_args']
+q_name = NS['_print_queue_name']
+q_uri = NS['_print_uri']
+p_load = NS['_print_load']
+p_save = NS['_print_save']
+p_backup = NS['_print_backup']
+p_backups = NS['_print_backups']
+
+
+def sech(text, allow='@LOCAL'):
+ return patch_loc(text, allow)
+
+
+def main():
+ print('--- 后端动作与路由 ---')
+ chk('定义了 act_print', 'def act_print(' in HELPER_SRC)
+ chk('注册到 ACTIONS', re.search(r"^\s*'print':\s*act_print,", HELPER_SRC, re.M) is not None)
+ chk('Web 路由 /api/print', "'/api/print'" in WEB_SRC)
+ chk('save 给了长超时', re.search(r"to = 150 if op == 'save'", WEB_SRC) is not None)
+ chk('testpage / queue_add 给了长超时',
+ "100 if op in ('testpage', 'queue_add')" in WEB_SRC)
+ chk('未知 op 返回 fail', '不支持的打印服务操作' in HELPER_SRC)
+ chk('配置文件路径正确', "PRINT_CONF = '/etc/drouter/print.conf'" in HELPER_SRC)
+ chk('cupsd.conf 路径正确', "PRINT_CUPSD = '/etc/cups/cupsd.conf'" in HELPER_SRC)
+
+ print('\n--- 默认值(我替用户做的判断)---')
+ chk('默认 CUPS 模式(功能最全,手机免驱)', DEFAULTS['mode'] == 'cups')
+ chk('默认允许局域网访问(CUPS 出厂只听 127.0.0.1,等于白装)',
+ DEFAULTS['cups']['listen'] == 'lan')
+ chk('默认共享打印机', DEFAULTS['cups']['share'] is True)
+ chk('默认开 CUPS Web 界面(方便排障)', DEFAULTS['cups']['web_iface'] is True)
+ chk('默认【禁止】远程管理 —— 安全红线', DEFAULTS['cups']['remote_admin'] is False)
+ chk('默认开 cups-browsed 自动发现', DEFAULTS['cups']['browsed'] is True)
+ chk('RAW 默认设备 /dev/usb/lp0', DEFAULTS['raw']['device'] == '/dev/usb/lp0')
+ chk('RAW 默认端口 9100(打印机的行业标准端口)', DEFAULTS['raw']['port'] == 9100)
+ chk('RAW 默认监听全部网卡', DEFAULTS['raw']['bind'] == '0.0.0.0')
+
+ print('\n--- 说明条目 PRINT_ITEMS ---')
+ keys = [i['key'] for i in ITEMS]
+ chk('六个开关都有说明', len(ITEMS) == 6, 'keys=%s' % keys)
+ chk('覆盖全部可配置项',
+ set(keys) == set(['mode', 'listen', 'share', 'web_iface',
+ 'remote_admin', 'browsed']))
+ chk('每条都有 why 与 impact',
+ all(i.get('why') and isinstance(i.get('impact'), list)
+ and len(i['impact']) >= 2 for i in ITEMS))
+ chk('说了 CUPS/RAW 抢设备这件事',
+ any('抢同一个 USB' in i['why'] for i in ITEMS if i['key'] == 'mode'))
+ chk('说了没插打印机时 RAW 没意义',
+ any('RAW 模式没有意义' in x for i in ITEMS if i['key'] == 'mode'
+ for x in i['impact']))
+ chk('说了 @LOCAL 不会暴露到公网',
+ any('@LOCAL' in x for i in ITEMS if i['key'] == 'listen'
+ for x in i['impact']))
+ chk('依赖包清单完整',
+ set(PKGS) == set(['cups', 'cups-client', 'cups-daemon', 'cups-browsed']))
+ chk('两种驱动(免驱 / 原样转发)',
+ [d['key'] for d in DRIVERS] == ['everywhere', 'raw'])
+
+ print('\n--- lsusb 解析 ---')
+ LSUSB = ('Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub\n'
+ 'Bus 001 Device 002: ID 0627:0001 Adomax Technology Co., Ltd QEMU Tablet\n'
+ 'Bus 002 Device 003: ID 03f0:5a11 HP OfficeJet Pro 7740\n'
+ 'garbage line\n')
+ u = parse_lsusb(LSUSB)
+ chk('解析出 3 台设备(跳过垃圾行)', len(u) == 3, 'n=%d' % len(u))
+ chk('VID/PID 正确且转小写', u[1]['vid'] == '0627' and u[1]['pid'] == '0001')
+ chk('设备名正确', u[2]['name'] == 'HP OfficeJet Pro 7740')
+ chk('总线与设备号正确', u[2]['bus'] == '002' and u[2]['dev'] == '003')
+ chk('空输入不炸', parse_lsusb('') == [] and parse_lsusb(None) == [])
+
+ print('\n--- USB Printer Class 识别(只认 07)---')
+ # 接口目录名形如「1-1:1.0」,带冒号 —— Windows 上建不出这种目录,
+ # 所以给 os / open 注入一份假 sysfs,逻辑照测且不受平台限制。
+ got = usb_ifclass('/sys/bus/usb/devices')
+ chk('只挑出打印机类设备', got == ['1-1', '1-5'], 'got=%s' % got)
+ chk('U 盘(08 大容量存储)没被误认成打印机', '1-2' not in got)
+ chk('键鼠(03 HID)没被误认成打印机', '1-3' not in got)
+ chk('没有接口目录的设备跳过', '1-4' not in got)
+ chk('多接口设备只要有一个是打印机就命中', '1-5' in got)
+ chk('识别码常量含 07 与 7', '07' in IFCLASS and '7' in IFCLASS)
+ chk('目录不存在时返回空列表', usb_ifclass('/no/such/dir') == [])
+
+ print('\n--- printers.conf 解析(不用 lpstat:它会被本地化)---')
+ PCONF = """
+# Printer configuration file for CUPS
+
+UUID urn:uuid:6b1f2a53-0000-0000-0000-000000000000
+Info HP OfficeJet Pro 7740 series
+Location 客厅
+MakeModel HP OfficeJet Pro 7740 series
+DeviceURI ipp://HP_OfficeJet._ipp._tcp.local/
+State Idle
+StateTime 1759100000
+Accepting Yes
+Shared No
+JobSheets none none
+QuotaPeriod 0
+PageLimit 0
+
+
+DeviceURI socket://192.168.7.50:9100
+Accepting No
+Shared Yes
+State Stopped
+
+"""
+ qs = parse_conf(PCONF)
+ chk('解析出 2 个队列', len(qs) == 2, 'n=%d' % len(qs))
+ q0 = qs[0]
+ chk('队列名正确', q0['name'] == 'HP_OfficeJet', q0.get('name'))
+ chk('默认队列被标记', q0['default'] is True and qs[1]['default'] is False)
+ chk('URI 正确', q0['uri'] == 'ipp://HP_OfficeJet._ipp._tcp.local/')
+ chk('Info / Location / MakeModel 正确',
+ q0['info'] == 'HP OfficeJet Pro 7740 series'
+ and q0['location'] == '客厅'
+ and q0['make_model'] == 'HP OfficeJet Pro 7740 series')
+ chk('Accepting Yes → True', q0['accepting'] is True)
+ chk('这台机器实测的 Shared No → False', q0['shared'] is False)
+ chk('第二个队列 Accepting No → False', qs[1]['accepting'] is False)
+ chk('第二个队列 Shared Yes → True', qs[1]['shared'] is True)
+ chk('socket URI 也能解析', qs[1]['uri'] == 'socket://192.168.7.50:9100')
+ chk('空输入不炸', parse_conf('') == [] and parse_conf(None) == [])
+
+ print('\n--- lpstat -p 解析(必须配 LC_ALL=C,否则输出是中文)---')
+ LPP = ('printer drouter-probe is idle. enabled since Tue Sep 29 23:57:46 2026\n'
+ 'printer HP_7740 is printing. enabled since Tue Sep 29 23:00:00 2026\n'
+ 'printer Old_Laser disabled since Mon Sep 28 10:00:00 2026\n'
+ 'garbage\n')
+ lp = parse_lpstat_p(LPP)
+ chk('解析出 3 个队列', len(lp) == 3, 'n=%d' % len(lp))
+ chk('队列名正确', [x['name'] for x in lp]
+ == ['drouter-probe', 'HP_7740', 'Old_Laser'])
+ chk('状态正确(停用的那种没有 is xxx 段,状态留空)',
+ [x['state'] for x in lp] == ['idle', 'printing', ''])
+ chk('enabled → accepting=True', lp[0]['accepting'] is True)
+ chk('disabled → accepting=False', lp[2]['accepting'] is False)
+ chk('空输入不炸', parse_lpstat_p('') == [] and parse_lpstat_p(None) == [])
+ env = c_locale_env()
+ chk('强制 LC_ALL=C(否则 lpstat 输出中文没法解析)', env.get('LC_ALL') == 'C')
+ chk('LANG 一并置为 C', env.get('LANG') == 'C')
+ chk('清掉 LANGUAGE(它的优先级比 LC_ALL 还高)', 'LANGUAGE' not in env)
+ chk('保留原有环境变量(PATH 之类不能丢)', 'PATH' in env)
+ chk('源码里 lpstat -p 确实带了 env',
+ re.search(r"sh\(\['lpstat', '-p'\].*env=", HELPER_SRC) is not None)
+ chk('增删队列后会重启 cups 逼它落盘',
+ HELPER_SRC.count("sh(['systemctl', 'restart', 'cups'], timeout=60)") >= 3)
+
+ print('\n--- lpstat 任务解析(第一列是「队列名-任务号」,不受本地化影响)---')
+ JOBS = ('HP_OfficeJet-42 root 1024 2026年09月29日 10:00:00\n'
+ 'Laser-7 ajeef 51200 自从 2026年09月29日 开始\n'
+ 'not-a-job\n')
+ js = parse_jobs(JOBS)
+ chk('解析出 2 个任务', len(js) == 2, 'n=%d' % len(js))
+ chk('任务号完整', js[0]['id'] == 'HP_OfficeJet-42')
+ chk('队列名与号拆分正确', js[0]['queue'] == 'HP_OfficeJet' and js[0]['job'] == '42')
+ chk('用户名正确', js[0]['user'] == 'root')
+ chk('中文时间列不影响解析', js[1]['queue'] == 'Laser' and js[1]['job'] == '7')
+ chk('垃圾行被跳过', all('not-a-job' not in j['id'] for j in js))
+ chk('空输入不炸', parse_jobs('') == [] and parse_jobs(None) == [])
+
+ print('\n--- 收窄 cupsctl 的 Allow all(关键安全边界)---')
+ before = ('Port 631\n'
+ '\n'
+ ' Order allow,deny\n'
+ ' Allow all\n'
+ '\n'
+ '\n'
+ ' Allow all\n'
+ '\n')
+ after = sech(before)
+ chk(' 里的 Allow all 被换成 @LOCAL', ' Allow @LOCAL' in after)
+ # 只断言「根 Location 段里」没有 Allow all —— /admin 段本来就该保留原样
+ root_blk = after.split('', 1)[1].split('', 1)[0]
+ chk('根 Location 段里不再有 Allow all', 'Allow all' not in root_blk,
+ repr(root_blk))
+ chk('只改 ,/admin 段不动',
+ re.search(r'\n\s*Allow all', after) is not None)
+ chk('Port 631 保留', 'Port 631' in after)
+ chk('Order allow,deny 保留', 'Order allow,deny' in after)
+
+ multi = ('\n Allow all\n Allow 192.168.7.0/24\n\n')
+ m2 = sech(multi)
+ chk('多余的 Allow 行被合并成一条', m2.count('Allow @LOCAL') == 1)
+ chk('不再残留 192.168.7.0/24', '192.168.7.0/24' not in m2)
+
+ none = 'Port 631\nListen localhost:631\n'
+ n2 = sech(none)
+ chk('文件里没有 时会补一整段', '' in n2)
+ chk('补的段里带 Allow @LOCAL', 'Allow @LOCAL' in n2)
+ chk('补的段完整闭合', n2.rstrip().endswith(''))
+
+ print('\n--- cupsctl 参数生成 ---')
+ a = cupsctl_args({'cups': {'listen': 'lan', 'share': True,
+ 'web_iface': True, 'remote_admin': False}})
+ chk('第一个参数是 cupsctl', a[0] == 'cupsctl')
+ chk('listen=lan → --remote-any', '--remote-any' in a)
+ chk('share → --share-printers', '--share-printers' in a)
+ chk('web_iface → WebInterface=Yes', 'WebInterface=Yes' in a)
+ chk('remote_admin=关 → --no-remote-admin', '--no-remote-admin' in a)
+ a2 = cupsctl_args({'cups': {'listen': 'local', 'share': False,
+ 'web_iface': False, 'remote_admin': True}})
+ chk('listen=local → --no-remote-any', '--no-remote-any' in a2)
+ chk('不共享 → --no-share-printers', '--no-share-printers' in a2)
+ chk('关界面 → WebInterface=No', 'WebInterface=No' in a2)
+ chk('允许远程管理 → --remote-admin', '--remote-admin' in a2)
+ chk('参数个数固定为 5', len(a) == 5 and len(a2) == 5)
+ chk('空配置不炸', cupsctl_args({})[0] == 'cupsctl')
+ chk('缺 cups 键不炸', cupsctl_args(None)[0] == 'cupsctl')
+
+ print('\n--- 队列名与 URI 白名单 ---')
+ for good in ('HP_7740', 'laser-1', 'a', 'Office.Jet', 'Q:1'):
+ try:
+ q_name(good)
+ chk('合法队列名通过:%s' % good, True)
+ except ValidateError:
+ chk('合法队列名通过:%s' % good, False)
+ for bad in ('', ' ', '-abc', '1abc/../x', 'has space', 'a' * 65, 'x;rm -rf'):
+ try:
+ q_name(bad)
+ chk('非法队列名被拒:%r' % bad, False)
+ except ValidateError:
+ chk('非法队列名被拒:%r' % bad, True)
+ chk('最长 64 字符的队列名放行', q_name('a' * 64) == 'a' * 64)
+
+ for good in ('ipp://HP._ipp._tcp.local/', 'socket://192.168.7.50:9100',
+ 'usb://HP/OfficeJet', 'ipps://printer.local/ipp/print',
+ 'dnssd://HP%20OfficeJet._ipp._tcp.local/'):
+ try:
+ q_uri(good)
+ chk('合法 URI 通过:%s' % good, True)
+ except ValidateError:
+ chk('合法 URI 通过:%s' % good, False)
+ for bad in ('', 'ftp://x/', 'ipp://a b', 'ipp://x; rm -rf /',
+ 'ipp://x|ls', 'ipp://x$(id)', 'random-string'):
+ try:
+ q_uri(bad)
+ chk('非法 URI 被拒:%r' % bad, False)
+ except ValidateError:
+ chk('非法 URI 被拒:%r' % bad, True)
+ chk('URI 方案白名单含 ipp/socket/usb/dnssd',
+ all(s in URI_SCHEMES for s in ('ipp://', 'socket://', 'usb://', 'dnssd://')))
+ chk('URI 长度上限 500', q_uri('ipp://' + 'a' * 400) is not None)
+ try:
+ q_uri('ipp://' + 'a' * 600)
+ chk('超长 URI 被拒', False)
+ except ValidateError:
+ chk('超长 URI 被拒', True)
+
+ print('\n--- 模式互斥(本页最核心的一条)---')
+
+ def apply_with(mode, browsed=True):
+ CALLS[:] = []
+ hits = {'raw': 0, 'cups': 0, 'stop': 0}
+
+ def fake_raw(cfg, errs):
+ hits['raw'] += 1
+ return True
+
+ def fake_cups(cfg, errs):
+ hits['cups'] += 1
+ return True
+
+ def fake_stop(errs):
+ hits['stop'] += 1
+
+ ns = {
+ 'sh': fake_sh,
+ 'PRINT_RAW_SERVICE': 'drouter-printer-raw.service',
+ '_print_apply_raw': fake_raw,
+ '_print_apply_cups': fake_cups,
+ '_print_stop_all': fake_stop,
+ }
+ build(['_print_apply'], ns)
+ ns['_print_apply']({'mode': mode, 'cups': {'browsed': browsed}}, [])
+ return hits, list(CALLS)
+
+ h, calls = apply_with('raw')
+ stopped = [' '.join(c) for c in calls if 'stop' in c or 'disable' in c]
+ chk('RAW 模式会走 RAW 应用流程', h['raw'] == 1)
+ chk('RAW 模式不会走 CUPS 流程', h['cups'] == 0 and h['stop'] == 0)
+ chk('RAW 模式停掉 cups.service',
+ any('systemctl stop cups.service' in s for s in stopped))
+ chk('RAW 模式停掉 cups.socket(不然会被 socket 激活拉起来)',
+ any('systemctl stop cups.socket' in s for s in stopped))
+ chk('RAW 模式停掉 cups-browsed',
+ any('systemctl stop cups-browsed.service' in s for s in stopped))
+ chk('RAW 模式把 cups 系列也 disable 掉',
+ sum(1 for s in stopped if 'disable' in s) >= 3)
+
+ h, calls = apply_with('cups')
+ chk('CUPS 模式会走 CUPS 流程', h['cups'] == 1)
+ chk('CUPS 模式不会走 RAW 流程', h['raw'] == 0 and h['stop'] == 0)
+ chk('CUPS 模式停掉 RAW 服务',
+ any('stop drouter-printer-raw.service' in ' '.join(c) for c in calls))
+ chk('CUPS 模式把 RAW 服务 disable 掉',
+ any('disable drouter-printer-raw.service' in ' '.join(c) for c in calls))
+ chk('CUPS 模式 browsed=开 → 启 cups-browsed',
+ any('restart cups-browsed.service' in ' '.join(c) for c in calls))
+
+ h, calls = apply_with('cups', browsed=False)
+ chk('browsed=关 → 停并禁用 cups-browsed',
+ any('stop cups-browsed.service' in ' '.join(c) for c in calls)
+ and any('disable cups-browsed.service' in ' '.join(c) for c in calls))
+
+ h, calls = apply_with('off')
+ chk('关闭模式走 _print_stop_all', h['stop'] == 1)
+ chk('关闭模式不启动任何一侧', h['raw'] == 0 and h['cups'] == 0)
+
+ print('\n--- 从「关闭」切回 CUPS:必须先把 cupsd 拉起来 ---')
+ # cupsctl 是连到 cupsd 的 socket 下命令的,不是直接改文件。cups 没在跑时
+ # 它必然失败(真机实测报「无法连接服务器:错误的文件描述符」),
+ # 而且失败后直接 return 会让 cups 一直停着 —— 切回 CUPS 模式等于切了个寂寞。
+
+ CUPSD_T = os.path.join(TMP, 'cupsd.conf')
+
+ def apply_cups_with(active, cupsctl_rc=0, cupsd_rc=0):
+ # 给一份能被 _print_patch_location 处理的 cupsd.conf
+ with open(CUPSD_T, 'w', encoding='utf-8') as f:
+ f.write('Listen localhost:631\n'
+ '\n Order allow,deny\n Allow all\n\n')
+ CALLS[:] = []
+
+ def fake_sh2(argv, timeout=10, input_data=None):
+ CALLS.append(list(argv))
+ if argv[:1] == ['cupsctl']:
+ return (cupsctl_rc, '', 'boom' if cupsctl_rc else '')
+ if argv[:2] == ['cupsd', '-t']:
+ return (cupsd_rc, '', 'bad config' if cupsd_rc else '')
+ return (0, 'x', '')
+
+ def fake_service(name):
+ return {'active': 'active' if active else 'inactive',
+ 'enabled': 'enabled' if active else 'disabled'}
+
+ def fake_svc_states(names):
+ # _print_apply_cups 现在走批量接口 _svc_states(一次 systemctl show)。
+ # 测试替身按同一语义返回:把所有请求的单元都映射成同一个状态。
+ return {n: fake_service(n) for n in names}
+
+ ns = {
+ 'sh': fake_sh2, 'os': os, 'shutil': shutil, 're': re,
+ 'PRINT_CUPSD': CUPSD_T,
+ '_print_service': fake_service,
+ '_svc_states': fake_svc_states,
+ '_print_backup': lambda: '',
+ '_print_cupsctl_args': cupsctl_args,
+ '_print_patch_location': patch_loc,
+ }
+ build(['_print_apply_cups'], ns)
+ errs = []
+ okk = ns['_print_apply_cups']({'cups': {'listen': 'lan'}}, errs)
+ # 注意用 argv 精确比对:'restart' 里也含 'start' 子串,用 in 会误判
+ return okk, errs, CALLS
+
+ okk, errs, calls = apply_cups_with(active=False)
+ chk('cups 没在跑时会先 enable', ['systemctl', 'enable', 'cups.service'] in calls)
+ chk('cups 没在跑时会先 start', ['systemctl', 'start', 'cups.service'] in calls)
+ istart = next((i for i, c in enumerate(calls)
+ if c == ['systemctl', 'start', 'cups.service']), -1)
+ ictl = next((i for i, c in enumerate(calls) if c and c[0] == 'cupsctl'), -1)
+ chk('start 严格排在 cupsctl 之前', istart >= 0 and ictl > istart,
+ 'start@%d cupsctl@%d' % (istart, ictl))
+ chk('拉起来之后才下 cupsctl 命令', okk is True and not errs, '→ %s' % errs)
+ chk('收窄照样生效',
+ 'Allow @LOCAL' in io.open(CUPSD_T, encoding='utf-8').read())
+
+ okk, errs, calls = apply_cups_with(active=True)
+ chk('cups 已在跑时不重复 start',
+ ['systemctl', 'start', 'cups.service'] not in calls)
+ chk('cups 已在跑时直接下命令', okk is True and not errs, '→ %s' % errs)
+
+ okk, errs, calls = apply_cups_with(active=False, cupsctl_rc=1)
+ chk('cupsctl 失败会被记进错误', okk is False and any('cupsctl 执行失败' in e for e in errs))
+ chk('cupsctl 失败时不会继续改配置',
+ not any(c[:2] == ['cupsd', '-t'] for c in calls))
+
+ okk, errs, calls = apply_cups_with(active=True, cupsd_rc=1)
+ chk('cupsd -t 不通过会被记进错误',
+ okk is False and any('配置校验未通过' in e for e in errs))
+
+ print('\n--- RAW 直通服务单元 ---')
+ chk('单元里有 Description', 'Description=' in UNIT_BODY)
+ chk('单元里显式写全 PATH(systemd 默认 PATH 没有 /usr/sbin)',
+ 'Environment=PATH=' in UNIT_BODY and '/usr/sbin' in UNIT_BODY)
+ chk('单元里有 ExecStart', 'ExecStart=' in UNIT_BODY)
+ chk('失败自动重启', 'Restart=on-failure' in UNIT_BODY)
+ chk('开机自启挂 multi-user.target', 'WantedBy=multi-user.target' in UNIT_BODY)
+ chk('等网络就绪再起', 'network-online.target' in UNIT_BODY)
+ chk('等 udev settle(USB 设备节点要晚一点才出现)',
+ 'systemd-udev-settle.service' in UNIT_BODY)
+ chk('源码里用 GOPEN 而不是 FILE(FILE 开字符设备会截断)',
+ 'GOPEN:%s' in HELPER_SRC and 'GOPEN' in HELPER_SRC)
+ chk('RAW wrapper 用 socat', 'socat TCP-LISTEN' in HELPER_SRC)
+ chk('socat 带 fork(支持多客户端)', 'fork' in HELPER_SRC)
+ chk('socat 带 reuseaddr', 'reuseaddr' in HELPER_SRC)
+
+ print('\n--- 配置读写 ---')
+ cfg = p_load()
+ chk('无配置文件时返回出厂默认', cfg['mode'] == 'cups'
+ and cfg['cups']['listen'] == 'lan', json.dumps(cfg, ensure_ascii=False)[:80])
+ p_save({'mode': 'raw', 'cups': dict(DEFAULTS['cups']),
+ 'raw': {'device': '/dev/usb/lp1', 'port': 9101, 'bind': '192.168.7.3'}})
+ cfg2 = p_load()
+ chk('保存后能读回 mode=raw', cfg2['mode'] == 'raw')
+ chk('保存后能读回 RAW 设备', cfg2['raw']['device'] == '/dev/usb/lp1')
+ chk('保存后能读回端口', cfg2['raw']['port'] == 9101)
+ chk('保存后能读回绑定地址', cfg2['raw']['bind'] == '192.168.7.3')
+ with open(NS['PRINT_CONF'], 'w', encoding='utf-8') as f:
+ f.write('{ this is not json')
+ chk('配置文件损坏时回落默认,不崩', p_load()['mode'] == 'cups')
+ with open(NS['PRINT_CONF'], 'w', encoding='utf-8') as f:
+ f.write('{"mode": "bogus"}')
+ chk('非法 mode 值被忽略', p_load()['mode'] == 'cups')
+ os.remove(NS['PRINT_CONF'])
+
+ print('\n--- cupsd.conf 备份与还原 ---')
+ with open(NS['PRINT_CUPSD'], 'w', encoding='utf-8') as f:
+ f.write('Port 631\n')
+ b1 = p_backup()
+ chk('能备份 cupsd.conf', bool(b1) and os.path.isfile(b1))
+ chk('备份文件名带时间戳',
+ re.match(r'^cupsd-\d{8}-\d{6}\.conf$', os.path.basename(b1)) is not None)
+ chk('备份内容与原文件一致',
+ io.open(b1, encoding='utf-8').read() == 'Port 631\n')
+ # 时间戳只精确到秒,同一秒里连续备份会互相覆盖 —— 所以这里手工铺 8 份
+ # 不同时间戳的备份来验证滚动裁剪,而不是靠 sleep 拖慢测试。
+ for i in range(1, 9):
+ with open(os.path.join(NS['PRINT_BAK_DIR'],
+ 'cupsd-2026092%d-10101%d.conf' % (i % 9, i % 9)),
+ 'w') as f:
+ f.write('bak %d\n' % i)
+ NS['_print_prune_backups']()
+ lst = p_backups()
+ chk('备份能列出', len(lst) > 1)
+ chk('备份按 PRINT_BAK_KEEP=5 滚动裁剪', len(lst) == 5, 'n=%d' % len(lst))
+ chk('保留的是最新的 5 份',
+ [x['file'] for x in lst] == sorted([x['file'] for x in lst], reverse=True)[:5])
+ chk('列表按时间倒序(新的在前)',
+ [x['file'] for x in lst] == sorted([x['file'] for x in lst], reverse=True))
+ chk('列表带大小与可读时间',
+ all(isinstance(x.get('size'), int) and ':' in (x.get('mtime') or '')
+ for x in lst))
+ os.remove(NS['PRINT_CUPSD'])
+ chk('cupsd.conf 不存在时不备份也不崩', p_backup() == '')
+
+ print('\n--- 还原:文件名白名单 + 防目录穿越 ---')
+ ns = {
+ 're': re, 'os': os, 'shutil': shutil, 'sh': fake_sh,
+ 'PRINT_BAK_DIR': NS['PRINT_BAK_DIR'], 'PRINT_CUPSD': NS['PRINT_CUPSD'],
+ 'fail': _fail, 'ok': _ok,
+ 'log': lambda *a, **k: None,
+ 'in_build_mode': lambda: True,
+ '_print_backup': lambda: '',
+ '_print_status': lambda: _ok(),
+ '_print_save_op': lambda p: _ok(),
+ '_print_queue_ops': lambda p: _ok(),
+ }
+ build(['act_print'], ns)
+ act_print = ns['act_print']
+ good = os.path.basename(p_backups()[0]['file'])
+ for bad in ('', '../cupsd.conf', '../../etc/shadow', 'cupsd.conf',
+ 'cupsd-20260101-000000.txt', 'x' * 200, 'cupsd-2026-1-1.conf'):
+ r = act_print({'op': 'restore', 'file': bad})
+ chk('还原拒绝非法文件名:%r' % bad[:30], (not r['ok'])
+ and r.get('code') in ('BAD_FILE', 'NOT_FOUND'))
+ with open(os.path.join(NS['PRINT_BAK_DIR'], good), 'w') as f:
+ f.write('Port 631\n# restored\n')
+ r = act_print({'op': 'restore', 'file': good})
+ chk('合法备份能还原', r['ok'], r.get('msg_cn', ''))
+ chk('还原后内容写进 cupsd.conf', '# restored' in io.open(
+ NS['PRINT_CUPSD'], encoding='utf-8').read())
+ chk('构建保护模式下还原不重启 CUPS',
+ not any('restart' in ' '.join(c) for c in CALLS[-3:]))
+ chk('未知 op 被拒', act_print({'op': 'nope'})['ok'] is False)
+
+ print('\n--- 构建保护模式:保存只写盘,不启停服务 ---')
+
+ def save_with(build_mode):
+ CALLS[:] = []
+ applied = {'n': 0}
+
+ def fake_apply(cfg, errs):
+ applied['n'] += 1
+
+ ns = {
+ 'sh': fake_sh, 'os': os, 'json': json,
+ 'PRINT_CONF': os.path.join(TMP, 'bm.conf'), 'PRINT_PKGS': PKGS,
+ 'in_build_mode': lambda: build_mode,
+ 'log': lambda *a, **k: None,
+ 'ok': _ok, 'fail': _fail,
+ '_print_load': lambda: {'mode': 'cups',
+ 'cups': dict(DEFAULTS['cups']),
+ 'raw': dict(DEFAULTS['raw'])},
+ '_print_save': lambda c: None,
+ '_print_apply': fake_apply,
+ '_print_load_installed': lambda: True,
+ }
+ build(['_print_save_op'], ns)
+ return ns['_print_save_op']({'mode': 'cups'}), applied['n'], list(CALLS)
+
+ r, n, calls = save_with(True)
+ chk('构建保护模式下不启停任何服务', n == 0)
+ chk('构建保护模式下的消息说清楚了', '未启停' in (r.get('msg_cn') or ''),
+ r.get('msg_cn', ''))
+ chk('构建保护模式仍有 ok=True(配置写盘了)', r['ok'] is True)
+ r, n, calls = save_with(False)
+ chk('正常模式下会真的应用', n == 1)
+ chk('正常模式下消息是「已保存并生效」', '已保存并生效' in (r.get('msg_cn') or ''))
+
+ print('\n--- 未装 CUPS 时的提示 ---')
+ ns = {
+ 'sh': fake_sh, 'os': os, 'json': json,
+ 'PRINT_CONF': os.path.join(TMP, 'ni.conf'), 'PRINT_PKGS': PKGS,
+ 'in_build_mode': lambda: False,
+ 'log': lambda *a, **k: None,
+ 'ok': _ok, 'fail': _fail,
+ '_print_load': lambda: {'mode': 'cups',
+ 'cups': dict(DEFAULTS['cups']),
+ 'raw': dict(DEFAULTS['raw'])},
+ '_print_save': lambda c: None,
+ '_print_apply': lambda c, e: None,
+ '_print_load_installed': lambda: False,
+ }
+ build(['_print_save_op'], ns)
+ r = ns['_print_save_op']({'mode': 'cups'})
+ chk('CUPS 模式但未安装 → 拒绝并给安装命令',
+ (not r['ok']) and 'apt-get install' in (r.get('msg_cn') or ''))
+ chk('错误码是 NOT_INSTALLED', r.get('code') == 'NOT_INSTALLED')
+ ns2 = dict(ns)
+ ns2['_print_load'] = lambda: {'mode': 'raw',
+ 'cups': dict(DEFAULTS['cups']),
+ 'raw': dict(DEFAULTS['raw'])}
+ build(['_print_save_op'], ns2)
+ r = ns2['_print_save_op']({'mode': 'raw'})
+ chk('RAW 模式不要求装 CUPS(只靠 socat)', r['ok'] is True)
+
+ print('\n--- 端口与绑定地址校验 ---')
+ ns = {
+ 're': re, 'os': os, 'sh': fake_sh, 'shutil': shutil,
+ 'PRINT_RAW_SERVICE': 'drouter-printer-raw.service',
+ 'PRINT_RAW_WRAPPER': os.path.join(TMP, 'w.sh'),
+ 'PRINT_RAW_UNIT': os.path.join(TMP, 'w.service'),
+ 'PRINT_RAW_UNIT_BODY': UNIT_BODY,
+ }
+ build(['_print_apply_raw'], ns)
+ apply_raw = ns['_print_apply_raw']
+ for bad, why in (({'device': 'relative/path'}, '设备路径非 /dev/'),
+ ({'bind': 'not-an-ip'}, '绑定地址非法'),
+ ({'port': 'abc'}, '端口非数字')):
+ cfg = {'raw': {'device': '/dev/usb/lp0', 'port': 9100, 'bind': '0.0.0.0'}}
+ cfg['raw'].update(bad)
+ errs = []
+ okk = apply_raw(cfg, errs)
+ chk('拒绝 %s' % why, okk is False and bool(errs))
+ # 端口不做「拒绝」而是钳制到 1–65535:页面上是个 number 输入框,
+ # 用户手滑填个 0 不该让整次保存失败(和 dcfg 那边的处理保持一致)
+ for got, want, why in ((0, 1, '端口 0 钳到 1'), (99999, 65535, '端口越界钳到 65535'),
+ (-5, 1, '负端口钳到 1')):
+ if os.path.exists(os.path.join(TMP, 'w.sh')):
+ os.remove(os.path.join(TMP, 'w.sh'))
+ apply_raw({'raw': {'device': '/dev/usb/lp0', 'port': got,
+ 'bind': '0.0.0.0'}}, [])
+ txt = io.open(os.path.join(TMP, 'w.sh'), encoding='utf-8').read()
+ chk(why, ('TCP-LISTEN:%d,' % want) in txt, txt.strip()[-60:])
+ errs = []
+ apply_raw({'raw': {'device': '/dev/usb/lp0', 'port': 9100,
+ 'bind': '192.168.7.3'}}, errs)
+ chk('合法 RAW 配置能写出 wrapper', os.path.isfile(os.path.join(TMP, 'w.sh')))
+ chk('wrapper 内容含绑定地址与端口',
+ 'TCP-LISTEN:9100,bind=192.168.7.3' in io.open(
+ os.path.join(TMP, 'w.sh'), encoding='utf-8').read())
+ chk('wrapper 是可执行的 /bin/sh 脚本',
+ io.open(os.path.join(TMP, 'w.sh'), encoding='utf-8').read().startswith('#!/bin/sh'))
+
+ print('\n--- 前端接线 ---')
+ chk('菜单里有「打印服务」', "k: 'print'" in APP_SRC and '打印服务' in APP_SRC)
+ chk('挂到「服务」分组', re.search(r"k: 'print'.*needSave: false", APP_SRC) is not None)
+ chk('VIEWS 注册 print', re.search(r'^\s*print: viewPrint,', APP_SRC, re.M) is not None)
+ chk('有 viewPrint 入口', 'async function viewPrint()' in APP_SRC)
+ chk('有 printLoad / printRender / printBind / printSave',
+ all(x in APP_SRC for x in ('async function printLoad()',
+ 'function printRender()',
+ 'function printBind()',
+ 'async function printSave()')))
+ chk('保存走 /api/print', "'/api/print'" in APP_SRC)
+ chk('保存不再传多余的 timeout 参数(api() 只收两个参数)',
+ re.search(r"api\('/api/print'.*\}\ \}\)", APP_SRC) is not None)
+ chk('三选一 radio(off/cups/raw)',
+ 'name="pt-mode"' in APP_SRC
+ and re.search(r"\{ k: 'off', n: '关闭打印服务'", APP_SRC) is not None
+ and re.search(r"\{ k: 'cups',", APP_SRC) is not None
+ and re.search(r"\{ k: 'raw',", APP_SRC) is not None)
+ chk('没检测到 USB 打印机时如实提示(这台虚拟机就是这种情况)',
+ '没有检测到 USB 打印机' in APP_SRC)
+ chk('页面讲清了两种模式抢设备的原理', '抢同一个设备' in APP_SRC)
+ chk('有 RAW 设备输入框', 'pt-raw-dev' in APP_SRC)
+ chk('有共享开关', 'pt-share' in APP_SRC)
+ chk('未安装时给出安装提示', 'need_install' in APP_SRC)
+ chk('只听 127.0.0.1 时给出警告', '只监听 127.0.0.1' in APP_SRC)
+
+ print('\n--- 安全红线:不许碰的东西 ---')
+ chk('打印模块没有动 5900(VNC)', '5900' not in HELPER_SRC[
+ HELPER_SRC.index('PRINT_CONF'):HELPER_SRC.index('def read_share')])
+ chk('打印模块没有启停 DHCP', 'dnsmasq' not in HELPER_SRC[
+ HELPER_SRC.index('PRINT_CONF'):HELPER_SRC.index('def read_share')])
+ chk('打印模块没有改防火墙', 'nft' not in HELPER_SRC[
+ HELPER_SRC.index('PRINT_CONF'):HELPER_SRC.index('def read_share')])
+ chk('打印模块没有动默认路由', 'ip route' not in HELPER_SRC[
+ HELPER_SRC.index('PRINT_CONF'):HELPER_SRC.index('def read_share')])
+
+ shutil.rmtree(TMP, ignore_errors=True)
+ print('\n================ 通过 %d / 失败 %d ================' % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/_dev/t-pubip.py b/_dev/t-pubip.py
new file mode 100644
index 0000000..9742872
--- /dev/null
+++ b/_dev/t-pubip.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""真·公网 IP 判定:静态契约 + 判定逻辑单测。
+
+盯两件事:
+ ① 判定分级是真的按「入向实测」拍板,不是只看地址段 ——
+ 用户的 101.70.131.227 就栽在这:地址是公网段,但从 VPS ping 不通。
+ ② 后端动作 / 路由 / 前端三处接线齐全。
+"""
+import ast
+import io
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
+WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
+APP = os.path.join(ROOT, 'web', 'app.js')
+
+PASS = FAIL = 0
+
+
+def chk(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print('[OK] %s %s' % (name, extra))
+ else:
+ FAIL += 1
+ print('[FAIL] %s %s' % (name, extra))
+
+
+HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
+WEB_SRC = io.open(WEB, encoding='utf-8').read()
+APP_SRC = io.open(APP, encoding='utf-8').read()
+
+
+def main():
+ print('--- 后端动作 ---')
+ chk('定义了 act_pubip', 'def act_pubip(' in HELPER_SRC)
+ chk('注册到 ACTIONS', re.search(r"^\s*'pubip':\s*act_pubip,", HELPER_SRC, re.M) is not None)
+ for op in ('check', 'probe_start', 'probe_status', 'probe_stop'):
+ chk('支持 op=%s' % op, ("op == '%s'" % op) in HELPER_SRC
+ or ("op = (p.get('op') or 'check')" in HELPER_SRC and op == 'check'))
+
+ print('\n--- 判定分级 ---')
+ tree = ast.parse(HELPER_SRC)
+ verdicts = None
+ for n in tree.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == 'PUBIP_VERDICT':
+ ns = {}
+ mod = ast.Module(body=[ast.Assign(
+ targets=[ast.Name('PUBIP_VERDICT', ast.Store())],
+ value=n.value)], type_ignores=[])
+ ast.fix_missing_locations(mod)
+ exec(compile(mod, '', 'exec'), ns)
+ verdicts = ns['PUBIP_VERDICT']
+ chk('PUBIP_VERDICT 可解析', verdicts is not None)
+ if verdicts:
+ for k in ('real', 'likely', 'blocked', 'cgnat', 'private', 'unknown'):
+ v = verdicts.get(k) or {}
+ chk('分级 %-8s 有 title/level/conclusion/advice' % k,
+ bool(v.get('title')) and bool(v.get('level'))
+ and bool(v.get('conclusion')) and isinstance(v.get('advice'), list))
+ # 用户遇到的正是这种:公网地址 + 入向不通
+ blk = verdicts['blocked']['conclusion']
+ chk('blocked 的结论点明「连不上」', ('连不' in blk) or ('ping 不通' in blk),
+ '→ %s…' % blk[:30])
+ chk('blocked 给出内网穿透建议',
+ any('穿透' in a for a in verdicts['blocked']['advice']))
+
+ print('\n--- 判定逻辑:入向实测才是拍板依据 ---')
+ # 关键分支:地址是公网段时,结论必须由 inbound 决定,不能停在 likely
+ chk('公网地址 + 入向可达 → real',
+ re.search(r"inbound is True:\s*\n\s*verdict = 'real'", HELPER_SRC) is not None)
+ chk('公网地址 + 入向实测失败 → blocked',
+ re.search(r"inbound is False:\s*\n\s*verdict = 'blocked'", HELPER_SRC) is not None)
+ chk('未做入向实测 → likely(不是直接判公网)',
+ re.search(r"else:\s*\n\s*verdict = 'likely'", HELPER_SRC) is not None)
+ chk('CGNAT 地址段直接判 cgnat',
+ re.search(r"cls == 'cgnat':\s*\n\s*verdict = 'cgnat'", HELPER_SRC) is not None)
+ chk('外部回显全失败 → unknown(不乱下结论)',
+ "verdict = 'unknown'" in HELPER_SRC)
+
+ print('\n--- 入向实测的实现安全性 ---')
+ chk('优先用 tcpdump 抓 ICMP(零监听端口)', 'icmp[icmptype] = 8' in HELPER_SRC)
+ # 局域网内的 ping 不算数,否则隔壁电脑一 ping 就误判成公网可达
+ chk('排除局域网来源(避免误判)', 'not src net' in HELPER_SRC
+ and 'def _local_subnets' in HELPER_SRC)
+ chk('备用 TCP 端口只用高位端口', '_PROBE_PORTS = (41' in HELPER_SRC)
+ chk('TCP 退路不碰 5900 / 8443',
+ '5900' not in HELPER_SRC.split('_PROBE_PORTS')[1][:200])
+ chk('实测有超时上限', '_PROBE_TIMEOUT =' in HELPER_SRC)
+
+ print('\n--- HTTP 路由 ---')
+ chk('注册 /api/pubip', "'/api/pubip'" in WEB_SRC)
+ chk('需要登录', re.search(r"p in \('/api/pubip'.*?if not self\.auth\(\)", WEB_SRC, re.S) is not None)
+ chk('check 用长超时(要打多个外部服务)',
+ re.search(r"to = 90 if op == 'check'", WEB_SRC) is not None)
+ chk('probe_* 用短超时(轮询要秒回)',
+ re.search(r"to = 90 if op == 'check' else 15", WEB_SRC) is not None)
+
+ print('\n--- 前端接线 ---')
+ chk('定义了 viewPubip', 'function viewPubip(' in APP_SRC)
+ chk('注册到 VIEWS', re.search(r"^\s*pubip: viewPubip,", APP_SRC, re.M) is not None)
+ chk('菜单里有 pubip 项', "k: 'pubip'" in APP_SRC)
+ chk('菜单文案点明入向', '入向可达性实测' in APP_SRC or '连进来' in APP_SRC)
+ chk('有开始/停止实测按钮', "id=\"pi-start\"" in APP_SRC and "id=\"pi-stop\"" in APP_SRC)
+ chk('轮询状态', "pubipProbePoll" in APP_SRC)
+ chk('离开页面停掉轮询(否则一直打接口)',
+ # 现在统一在 stopPageTimers() 里收口,go() 第一件事就是调它。
+ # 这样比原来「go() 里内联一行」更不容易漏 —— 新增页面定时器只要
+ # 登记进 stopPageTimers 即可,不用再去改 go()。
+ 'function stopPageTimers(' in APP_SRC
+ and 'pubipProbeStopPoll' in APP_SRC
+ and re.search(r"function go\(k\) \{\s*\n?\s*(//[^\n]*\n\s*)*stopPageTimers\(\);", APP_SRC) is not None)
+ chk('DDNS 页有跳转入口', "dd-gopubip" in APP_SRC)
+ chk('DDNS 页提示「地址是公网段 ≠ 能连进来」',
+ '不能说明外网能不能连进来' in APP_SRC)
+
+ print('\n--- 四条证据都渲染 ---')
+ for k in ('class', 'echo', 'path', 'inbound'):
+ chk('证据 key=%s' % k, ("'key': '%s'" % k) in HELPER_SRC)
+
+ print('\n--- 地址分类逻辑单测(真实执行,不是正则匹配)---')
+ # 只抽 _is_private_v4 与 _addr_class_of 两个纯函数到隔离命名空间执行
+ ns = {'re': re}
+ tree2 = ast.parse(HELPER_SRC)
+ want = ('_is_private_v4', '_addr_class_of')
+ body = [n for n in tree2.body
+ if isinstance(n, ast.FunctionDef) and n.name in want]
+ mod2 = ast.Module(body=body, type_ignores=[])
+ ast.fix_missing_locations(mod2)
+ exec(compile(mod2, '', 'exec'), ns)
+ classify = ns['_addr_class_of']
+ cases = [
+ ('101.70.131.227', 'public', '用户实际遇到的联通地址:公网段'),
+ ('8.8.8.8', 'public', 'Google DNS'),
+ ('100.64.0.1', 'cgnat', 'CGNAT 段下边界'),
+ ('100.127.255.255', 'cgnat', 'CGNAT 段上边界'),
+ ('100.63.255.255', 'public', '紧邻 CGNAT 之外,仍是公网'),
+ ('100.128.0.1', 'public', '紧邻 CGNAT 之上,仍是公网'),
+ ('192.168.7.3', 'private', '局域网'),
+ ('10.0.0.1', 'private', 'RFC1918 A 类'),
+ ('172.16.0.1', 'private', 'RFC1918 B 类下边界'),
+ ('172.31.255.255', 'private', 'RFC1918 B 类上边界'),
+ ('172.32.0.1', 'public', '172.32 已超出私有段'),
+ ('169.254.1.1', 'reserved', '链路本地'),
+ ('224.0.0.1', 'reserved', '组播'),
+ ('127.0.0.1', 'private', '回环'),
+ ('', 'empty', '空地址'),
+ ]
+ for ip, expect, why in cases:
+ got = classify(ip)
+ chk('%-18s → %-8s (%s)' % (ip or '(空)', got, why), got == expect,
+ '' if got == expect else '期望 %s' % expect)
+
+ print('\n' + '=' * 60)
+ if FAIL:
+ print('失败 %d 项' % FAIL)
+ sys.exit(1)
+ print('结果: 全部通过(通过 %d 项)' % PASS)
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/_dev/t-render.py b/_dev/t-render.py
new file mode 100644
index 0000000..f28e4fe
--- /dev/null
+++ b/_dev/t-render.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+"""read_render_text 逻辑测试(本地模拟,helper 因 pwd 无法在 Windows 导入)。"""
+import sys, re, io
+sys.path.insert(0, r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend")
+import render
+
+src = io.open(r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py",
+ encoding='utf-8').read()
+m = re.search(r'^def read_render_text\(.*?(?=\n\ndef act_apply)', src, re.S | re.M)
+assert m, 'read_render_text 未找到'
+NS = {'render': render, 'ValidateError': render.ValidateError, 'str': str}
+exec('def ok(data=None, msg="ok", code="OK"):\n return {"ok": True, "code": code, "msg_cn": msg, "data": data}\n', NS)
+exec('def fail(msg, code="ERR", data=None):\n return {"ok": False, "code": code, "msg_cn": msg, "data": data}\n', NS)
+exec(m.group(0), NS)
+fn = NS['read_render_text']
+
+fails = 0
+def chk(label, cond, extra=''):
+ global fails
+ if not cond: fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+cfg = {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'portfwd': {'enable': True, 'rules': [{'name': 'NAS', 'proto': 'tcp', 'ext_port': 5000,
+ 'int_ip': '192.168.7.10', 'int_port': 5000, 'enable': True}],
+ 'dmz': {'enable': True, 'host': '192.168.7.50'}}}
+r = fn({'module': 'nft_v4', 'cfg': cfg})
+chk('nft_v4 预览成功', r['ok'])
+lines = [x for x in r['data']['text'].split('\n') if 'dnat' in x or '放通转发' in x]
+for x in lines: print(' ', x.strip())
+chk('含 dnat 规则', any('dnat to 192.168.7.10' in x for x in lines))
+chk('含 DMZ 规则', any('DMZ' in x for x in lines))
+chk('含放通规则', any('放通转发' in x for x in lines))
+
+r2 = fn({'module': 'nft_v4', 'cfg': {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
+ 'portfwd': {'enable': True, 'rules': [{'name': 'x', 'proto': 'tcp', 'ext_port': 80,
+ 'int_ip': '1.2.3.999', 'int_port': 80}]}}})
+chk('非法 IPv4 返回中文错误', (not r2['ok']) and 'IPv4' in r2['msg_cn'], '→ ' + r2['msg_cn'])
+
+r3 = fn({'module': 'dnsmasq', 'cfg': {'lan_iface': 'ens18', 'domain': 'lan'}})
+chk('dnsmasq 预览成功', r3['ok'] and len(r3['data']['text']) > 50,
+ '长度 %s' % (len(r3['data']['text']) if r3['ok'] else '-'))
+
+r4 = fn({'module': 'radvd', 'cfg': {'iface': 'ens18', 'prefix': '2408:8207:1234::/64'}})
+chk('radvd 预览成功', r4['ok'] and r4['data']['text'])
+
+r5 = fn({})
+chk('空模块名被拒绝', (not r5['ok']) and r5['code'] == 'NO_MODULE', '→ ' + r5['msg_cn'])
+
+r6 = fn({'module': 'nonsense'})
+chk('未知模块返回中文错误', (not r6['ok']) and '未知' in r6['msg_cn'], '→ ' + r6['msg_cn'])
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-smbnfs.py b/_dev/t-smbnfs.py
new file mode 100644
index 0000000..9e22b8e
--- /dev/null
+++ b/_dev/t-smbnfs.py
@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+"""验证 SMB / NFS 渲染器(#9)。纯逻辑,可直接 import render.py。"""
+import sys, io, os
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ '..', 'backend'))
+import render as R # noqa: E402
+
+fails = 0
+
+
+def chk(name, got, want):
+ global fails
+ ok = (got == want)
+ if not ok:
+ fails += 1
+ print('[%s] %-40s got=%-24s want=%s' % ('OK' if ok else 'FAIL', name, repr(got), repr(want)))
+
+
+def has(name, needle, hay, want=True):
+ chk(name, needle in hay, want)
+
+
+print('=== SMB:基础全局段 ===')
+cfg = {'samba': {
+ 'workgroup': 'workgroup', 'server_string': 'drouter 文件共享',
+ 'disable_netbios': True, 'interfaces': 'br0',
+ 'shares': [
+ {'name': 'Public', 'path': '/srv/share/public', 'enable': True, 'mode': 'public',
+ 'writable': True, 'comment': '公共盘'},
+ {'name': 'Media', 'path': '/srv/share/media', 'enable': True, 'mode': 'auth',
+ 'writable': False, 'users': 'drouter, alice',
+ 'allow_hosts': '192.168.7.0/24'},
+ ]}}
+conf = R.render_samba(cfg)
+has('工作组大写化', 'workgroup = WORKGROUP', conf)
+has('最小协议 SMB2', 'server min protocol = SMB2', conf)
+has('最大协议 SMB3', 'server max protocol = SMB3', conf)
+has('UTF-8 字符集', 'unix charset = UTF-8', conf)
+has('关闭 NetBIOS', 'disable netbios = yes', conf)
+has('绑定网卡', 'bind interfaces only = yes', conf)
+has('公共盘 guest ok', 'guest ok = yes', conf)
+has('公共盘可写', 'read only = no', conf)
+has('公共盘 force user', 'force user = nobody', conf)
+has('认证盘只读', '[Media]', conf)
+has('认证盘 valid users', 'valid users = drouter, alice', conf)
+has('认证盘 hosts allow', 'hosts allow = 192.168.7.0/24', conf)
+has('macOS 支持 fruit', 'vfs objects = catia fruit streams_xattr', conf)
+
+print('\n=== SMB:校验失败场景 ===')
+for label, bad, kw in [
+ ('空共享名', {'samba': {'shares': [{'name': '', 'path': '/srv/x'}]}}, '共享缺少名称'),
+ ('非法共享名', {'samba': {'shares': [{'name': 'a/b', 'path': '/srv/x'}]}}, '不合法'),
+ ('相对路径', {'samba': {'shares': [{'name': 'ok', 'path': 'srv/x'}]}}, '绝对路径'),
+ ('目录穿越', {'samba': {'shares': [{'name': 'ok', 'path': '/srv/../etc'}]}}, '..'),
+ ('非法工作组', {'samba': {'workgroup': '中文组', 'shares': []}}, '工作组名不合法'),
+ ('非法用户名', {'samba': {'shares': [{'name': 'ok', 'path': '/srv/x', 'mode': 'auth',
+ 'users': 'ROOT'}]}}, '不合规'),
+ ('非法网段', {'samba': {'shares': [{'name': 'ok', 'path': '/srv/x',
+ 'allow_hosts': '999.1.1.0/24'}]}}, '允许网段不合法'),
+]:
+ try:
+ R.render_samba(bad)
+ chk(label, '未抛错', '应抛 ValidateError')
+ except R.ValidateError as e:
+ chk(label, kw in e.msg_cn, True)
+
+print('\n=== SMB:共享名去重 ===')
+try:
+ R.render_samba({'samba': {'shares': [
+ {'name': 'Same', 'path': '/srv/a'}, {'name': 'same', 'path': '/srv/b'}]}})
+ chk('共享名重复', '未抛错', '应抛 ValidateError')
+except R.ValidateError as e:
+ chk('共享名重复', '重复' in e.msg_cn, True)
+
+print('\n=== NFS:多平台模板 ===')
+ncfg = {'nfs': {'threads': 16, 'exports': [
+ {'path': '/srv/share/public', 'enable': True, 'comment': '公共盘', 'clients': [
+ {'net': '192.168.7.0/24', 'preset': 'linux'},
+ {'net': '192.168.7.128/25', 'preset': 'macos'},
+ ]},
+ {'path': '/srv/share/ro', 'enable': True, 'clients': [
+ {'net': '192.168.7.0/24', 'preset': 'readonly'}]},
+]}}
+exp = R.render_nfs(ncfg)
+has('导出公共盘', '/srv/share/public 192.168.7.0/24(', exp)
+has('linux 模板 root_squash', 'root_squash', exp)
+has('macos 模板 all_squash', 'all_squash', exp)
+has('macos 模板 insecure', 'insecure', exp)
+has('只读模板 ro', 'ro,', exp)
+
+nsconf = R.render_nfs_conf(ncfg)
+has('线程数生效', 'threads=16', nsconf)
+has('固定 2049 端口', 'port=2049', nsconf)
+has('mountd 固定端口', 'port=20048', nsconf)
+has('NFSv4.2 开启', 'vers4.2=y', nsconf)
+
+print('\n=== NFS:IPv6 客户端 ===')
+exp6 = R.render_nfs({'nfs': {'exports': [
+ {'path': '/srv/v6', 'clients': [{'net': '2408:8207:1234::/64', 'preset': 'linux'}]}]}})
+has('IPv6 CIDR 导出', '2408:8207:1234::/64(', exp6)
+
+print('\n=== NFS:通配符与校验 ===')
+expw = R.render_nfs({'nfs': {'exports': [
+ {'path': '/srv/all', 'clients': [{'net': '*', 'preset': 'readonly'}]}]}})
+has('通配符 *', '*(ro,', expw)
+
+for label, bad, kw in [
+ ('无客户端', {'nfs': {'exports': [{'path': '/srv/x', 'clients': []}]}}, '至少需要一个客户端'),
+ ('非法客户端', {'nfs': {'exports': [{'path': '/srv/x',
+ 'clients': [{'net': 'abc'}]}]}}, '客户端不合法'),
+ ('非法线程数', {'nfs': {'threads': 999, 'exports': []}}, '1–128'),
+ ('危险 no_root_squash', {'nfs': {'exports': [{'path': '/srv/x', 'clients': [
+ {'net': '192.168.7.0/24', 'preset': 'linux',
+ 'options': 'rw,no_root_squash'}]}]}}, 'no_root_squash'),
+]:
+ try:
+ if 'threads' in bad.get('nfs', {}):
+ R.render_nfs_conf(bad)
+ else:
+ R.render_nfs(bad)
+ chk(label, '未抛错', '应抛 ValidateError')
+ except R.ValidateError as e:
+ chk(label, kw in e.msg_cn, True)
+
+print('\n=== 空导出 ===')
+empty = R.render_nfs({'nfs': {'exports': []}})
+has('空导出有说明', '未配置任何导出', empty)
+
+print('\n=== 渲染入口注册 ===')
+chk('samba 已注册', 'samba' in R.RENDERERS, True)
+chk('nfs 已注册', 'nfs' in R.RENDERERS, True)
+paths = [p for p, _ in R.render('smb', {'samba': {'shares': [
+ {'name': 'T', 'path': '/srv/t'}]}})]
+chk('smb 别名 → 落盘路径', paths[0], '/etc/samba/drouter.conf')
+npaths = [p for p, _ in R.render('nfs', ncfg)]
+chk('nfs 落盘两处', len(npaths), 2)
+chk('nfs exports 路径', npaths[0], '/etc/exports')
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-snapshot.py b/_dev/t-snapshot.py
new file mode 100644
index 0000000..99e4830
--- /dev/null
+++ b/_dev/t-snapshot.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""快照覆盖率与容错测试(#6):专防「界面报成功、实际没备份」。
+
+踩过的坑:/etc/drouter 下有一个 0600 属主 root 的 rescue.conf,
+以 drouter 身份运行的快照读不到 → shutil.copytree 整体抛异常 →
+这个目录半截留在盘上、saved 列表里也不记 → 回滚时根本还原不了。
+"""
+import ast
+import io
+import os
+import shutil
+import stat
+import sys
+import tempfile
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+fails = 0
+
+
+def chk(label, cond, extra=''):
+ global fails
+ if not cond:
+ fails += 1
+ print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
+
+
+with io.open(os.path.join(ROOT, 'backend/drouter-helper.py'), encoding='utf-8') as f:
+ HELPER = f.read()
+with io.open(os.path.join(ROOT, 'scripts/deploy.sh'), encoding='utf-8') as f:
+ DEPLOY = f.read()
+TREE = ast.parse(HELPER)
+
+
+def const(name):
+ for n in TREE.body:
+ if isinstance(n, ast.Assign):
+ for t in n.targets:
+ if isinstance(t, ast.Name) and t.id == name:
+ return ast.literal_eval(n.value)
+ return None
+
+
+# ---------------------------------------------------------------- 1. 覆盖范围
+FILES = const('SNAPSHOT_ETC_FILES') or []
+DIRS = const('SNAPSHOT_ETC_DIRS') or []
+chk('快照包含 /etc/fstab(外置存储自动挂载)', '/etc/fstab' in FILES)
+chk('快照包含 /etc/drouter 整目录', '/etc/drouter' in DIRS)
+chk('快照包含 /etc/systemd/network', '/etc/systemd/network' in DIRS)
+# 曾经把清单命名为 ETC_FILES,与另一处(helper 临时目录相关)重名被静默覆盖
+chk('不存在与快照清单重名的旧 ETC_FILES 常量', const('ETC_FILES') is None)
+
+# ---------------------------------------------------------------- 2. 容错复制
+fn = None
+for n in TREE.body:
+ if isinstance(n, ast.FunctionDef) and n.name == '_copytree_soft':
+ fn = n
+chk('定义了 _copytree_soft(逐文件容错复制)', fn is not None)
+if fn is None:
+ print('\n结果: %d 项失败' % fails)
+ sys.exit(1)
+
+ns = {'os': os, 'shutil': shutil}
+exec(compile(ast.Module(body=[fn], type_ignores=[]), '', 'exec'), ns)
+copytree_soft = ns['_copytree_soft']
+
+# 造一个含「读不到的文件」的目录
+tmp = tempfile.mkdtemp(prefix='snap-')
+try:
+ src = os.path.join(tmp, 'src')
+ os.makedirs(os.path.join(src, 'sub'))
+ for name, body in (('a.conf', 'A'), ('b.conf', 'B'), ('secret.conf', 'S')):
+ with io.open(os.path.join(src, name), 'w') as f:
+ f.write(body)
+ with io.open(os.path.join(src, 'sub', 'c.conf'), 'w') as f:
+ f.write('C')
+ # 让其中一个文件不可读(Windows 上 chmod 000 不一定生效,能生效就测容错)
+ os.chmod(os.path.join(src, 'secret.conf'), 0o000)
+ blocked = True
+ try:
+ with io.open(os.path.join(src, 'secret.conf')) as f:
+ f.read()
+ blocked = False
+ except Exception:
+ pass
+ dst = os.path.join(tmp, 'dst')
+ n, sk = copytree_soft(src, dst)
+ if blocked:
+ chk('读不到的文件被跳过而不是报废整个目录', n == 3, '实际复制 %d 个' % n)
+ chk('跳过文件有记录', len(sk) == 1 and 'secret.conf' in sk[0], sk)
+ else:
+ # 当前平台 chmod 000 不生效(Windows),至少保证全量复制成功
+ chk('全量复制成功(该平台无法模拟权限拒绝)', n == 4, '实际复制 %d 个' % n)
+ chk('无跳过记录', sk == [], sk)
+ chk('其余文件都复制到了', os.path.isfile(os.path.join(dst, 'a.conf'))
+ and os.path.isfile(os.path.join(dst, 'b.conf'))
+ and os.path.isfile(os.path.join(dst, 'sub', 'c.conf')))
+ # 空目录也要建出来(还原时目录结构不能少)
+ os.makedirs(os.path.join(src, 'emptydir'))
+ n2, sk2 = copytree_soft(src, dst)
+ chk('空目录也会被创建', os.path.isdir(os.path.join(dst, 'emptydir')))
+finally:
+ for base, dirs, files in os.walk(tmp):
+ for f in files:
+ try:
+ os.chmod(os.path.join(base, f), stat.S_IRWXU)
+ except Exception:
+ pass
+ shutil.rmtree(tmp, ignore_errors=True)
+
+# ---------------------------------------------------------------- 3. 调用点
+chk('快照目录复制不再直接用 shutil.copytree',
+ 'shutil.copytree(sub, dst' not in HELPER and 'shutil.copytree(src, sub' not in HELPER)
+chk('快照会记录 skipped 到元数据', "'skipped': skipped_all" in HELPER
+ or "'skipped': skipped_all[:50]" in HELPER)
+chk('rescue.conf 放宽到 0640 且属组 drouter',
+ '0o640' in HELPER and "shutil.chown(RESCUE_CONF, group='drouter')" in HELPER)
+chk('部署脚本有存量 rescue.conf 权限迁移',
+ 'chmod 0640 /etc/drouter/rescue.conf' in DEPLOY)
+
+print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-storage.py b/_dev/t-storage.py
new file mode 100644
index 0000000..65c05fa
--- /dev/null
+++ b/_dev/t-storage.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""外置存储设备(USB / Type-C / 雷电)的纯逻辑测试。
+
+重点守住「格式化」的三重保护 —— 这是全项目最容易造成不可逆损失的操作:
+ 1) 系统盘(承载 / /boot /boot/efi 等)禁止格式化;
+ 2) 已挂载的设备必须先卸载;
+ 3) 整块盘若已含分区,禁止格式化整盘(应格式化具体分区);
+ 4) 前端二次确认必须手输设备名,后端还要再校验一次。
+
+从 drouter-helper.py 抽取相关函数,lsblk / mkfs / mount 全部换成假的。
+"""
+import ast
+import json
+import os
+import shutil
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SRC = os.path.join(HERE, '..', 'backend', 'drouter-helper.py')
+code = open(SRC, encoding='utf-8').read()
+tree = ast.parse(code)
+
+WANT_FN = ['_blk_sysfs', '_dev_bus', '_walk_blk', '_sysfs_model',
+ '_dev_is_system', '_fs_meta', '_fs_tool_ready', '_read_fstab',
+ '_backup_fstab', 'act_storage']
+WANT_AS = ['FS_TYPES', '_CRITICAL_MOUNTS']
+
+picked = []
+for node in tree.body:
+ if isinstance(node, ast.FunctionDef) and node.name in WANT_FN:
+ picked.append(node)
+ elif isinstance(node, ast.Assign):
+ for t in node.targets:
+ if isinstance(t, ast.Name) and t.id in WANT_AS:
+ picked.append(node)
+got = set()
+for n in picked:
+ if isinstance(n, ast.Assign):
+ got.update(t.id for t in n.targets if isinstance(t, ast.Name))
+ else:
+ got.add(n.name)
+missing = (set(WANT_FN) | set(WANT_AS)) - got
+assert not missing, '未能从源码抽取:%s' % ', '.join(sorted(missing))
+
+# ---- 测试替身 -------------------------------------------------------------
+STATE = {'devices': [], 'sh_rc': 0, 'sh_out': '', 'sh_calls': []}
+
+
+def fake_sh(cmd, timeout=None, **kw):
+ STATE['sh_calls'].append(cmd)
+ return (STATE['sh_rc'], STATE['sh_out'], '')
+
+
+def fake_log(*a, **k):
+ pass
+
+
+GEN = tempfile.mkdtemp()
+
+def _ok(data=None, msg='操作成功', code='OK'):
+ return {'ok': True, 'code': code, 'msg_cn': msg, 'data': data}
+
+
+def _fail(msg, code='ERR', data=None):
+ return {'ok': False, 'code': code, 'msg_cn': msg, 'data': data}
+
+
+ns = {'os': os, 'json': json, 'shutil': shutil, 'time': __import__('time'),
+ 'sh': fake_sh, 'log': fake_log, 'GEN': GEN,
+ 'ok': _ok, 'fail': _fail,
+ '_storage_device_rows': lambda: STATE['devices'],
+ '_hbytes': lambda n: '%d B' % n}
+exec(compile('\n'.join(ast.unparse(n) for n in picked), '', 'exec'), ns)
+
+_dev_bus = ns['_dev_bus']
+_dev_is_system = ns['_dev_is_system']
+_fs_meta = ns['_fs_meta']
+_fs_tool_ready = ns['_fs_tool_ready']
+act_storage = ns['act_storage']
+FS_TYPES = ns['FS_TYPES']
+
+fails = []
+
+
+def ck(name, cond, extra=''):
+ print(' %-46s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
+ if not cond:
+ fails.append(name)
+
+
+print('=' * 66)
+print('一、连接方式判定(总线类型)')
+ck('sysfs 含 thunderbolt → 雷电',
+ _dev_bus('sdb', '/sys/devices/pci0000:00/0000:00:0d.2/thunderbolt/0-1/nvme') ==
+ ('thunderbolt', '雷电 / USB4'))
+ck('sysfs 含 /usb → USB',
+ _dev_bus('sdc', '/sys/devices/pci0000:00/0000:00:14.0/usb2/2-1/2-1:1.0/block/sdc') ==
+ ('usb', 'USB'))
+ck('sysfs 含 nvme → NVMe',
+ _dev_bus('nvme0n1', '/sys/devices/pci0000:00/nvme/nvme0/nvme0n1') ==
+ ('nvme', 'NVMe'))
+ck('sysfs 含 /ata → SATA',
+ _dev_bus('sda', '/sys/devices/pci0000:00/ata1/host0/target0:0:0/0:0:0:0/block/sda') ==
+ ('sata', 'SATA / SCSI'))
+ck('sysfs 含 virtio → 虚拟磁盘',
+ _dev_bus('vda', '/sys/devices/pci0000:00/0000:00:05.0/virtio1/block/vda') ==
+ ('virtual', '虚拟磁盘'))
+ck('mmcblk 命名 → MMC/TF 卡',
+ _dev_bus('mmcblk0', '/sys/devices/platform/mmc0/mmcblk0') ==
+ ('mmc', 'MMC / TF 卡'))
+ck('未知路径 → unknown', _dev_bus('xxx', '/weird/path')[0] == 'unknown')
+
+print('二、系统盘识别(禁止格式化的核心依据)')
+rows = [
+ {'name': 'sda', 'parent': '', 'partitions': ['sda1', 'sda2'], 'mountpoint': ''},
+ {'name': 'sda1', 'parent': 'sda', 'partitions': [], 'mountpoint': '/boot'},
+ {'name': 'sda2', 'parent': 'sda', 'partitions': [], 'mountpoint': '/'},
+ {'name': 'sdb', 'parent': '', 'partitions': ['sdb1'], 'mountpoint': ''},
+ {'name': 'sdb1', 'parent': 'sdb', 'partitions': [], 'mountpoint': ''},
+]
+ck('sda2(挂载 /)判定为系统盘', _dev_is_system('sda2', rows)[0] is True)
+ck('sda1(挂载 /boot)判定为系统盘', _dev_is_system('sda1', rows)[0] is True)
+ck('整盘 sda(子分区挂 /)也判为系统盘', _dev_is_system('sda', rows)[0] is True)
+ck('外接 sdb1 不是系统盘', _dev_is_system('sdb1', rows)[0] is False)
+
+print('三、文件系统元数据')
+ck('FS_TYPES 不少于 7 种', len(FS_TYPES) >= 7, '实际 %d' % len(FS_TYPES))
+ck('exFAT 三平台通用',
+ all(_fs_meta('exfat')[k] for k in ('win', 'mac', 'linux')))
+ck('ext4 仅 Linux', _fs_meta('ext4')['linux'] and not _fs_meta('ext4')['win'])
+ck('FAT32 有 4GB 限制说明', '4GB' in _fs_meta('vfat')['note'])
+ck('每个 fs 都有 mkfs / pkg / note',
+ all(f.get('mkfs') and f.get('pkg') and f.get('note') for f in FS_TYPES))
+ck('不支持的 fs 返回 None', _fs_meta('reiser9') is None)
+
+print('四、格式化工具可用性探测(PATH 里找可执行文件)')
+tmpbin = tempfile.mkdtemp()
+for t in ('mkfs.ext4', 'mkfs.exfat'):
+ p = os.path.join(tmpbin, t)
+ open(p, 'w').write('#!/bin/sh\n')
+ os.chmod(p, 0o755)
+old_path = os.environ['PATH']
+os.environ['PATH'] = tmpbin
+try:
+ ck('PATH 里有 mkfs.ext4 → ready', _fs_tool_ready('ext4')[0] is True)
+ ck('PATH 里没有 mkfs.ntfs → not ready', _fs_tool_ready('ntfs')[0] is False)
+ ck('不可用时给出软件包名', 'ntfs-3g' in _fs_tool_ready('ntfs')[1])
+finally:
+ os.environ['PATH'] = old_path
+
+print('五、format 的三重保护(最关键)')
+EXT = [{'name': 'sdb1', 'type': 'part', 'fstype': '', 'mounted': False,
+ 'mountpoint': '', 'parts': []}]
+STATE['devices'] = [
+ {'name': 'sda', 'type': 'disk', 'fstype': '', 'mountpoint': '', 'mounted': False,
+ 'parent': '', 'partitions': ['sda1'], 'has_children': True},
+ {'name': 'sda1', 'type': 'part', 'fstype': 'ext4', 'mountpoint': '/',
+ 'mounted': True, 'parent': 'sda', 'partitions': [], 'has_children': False},
+ {'name': 'sdb', 'type': 'disk', 'fstype': '', 'mountpoint': '', 'mounted': False,
+ 'parent': '', 'partitions': ['sdb1'], 'has_children': True},
+ {'name': 'sdb1', 'type': 'part', 'fstype': '', 'mountpoint': '', 'mounted': False,
+ 'parent': 'sdb', 'partitions': [], 'has_children': False},
+ {'name': 'sdc1', 'type': 'part', 'fstype': 'exfat', 'mountpoint': '/mnt/x',
+ 'mounted': True, 'parent': '', 'partitions': [], 'has_children': False},
+]
+
+r = act_storage({'op': 'format', 'name': 'sda1', 'fs': 'ext4', 'confirm': 'sda1'})
+ck('保护1:系统盘拒绝格式化', (not r['ok']) and r['code'] == 'SYSTEM_DISK', r.get('code'))
+
+r = act_storage({'op': 'format', 'name': 'sdc1', 'fs': 'ext4', 'confirm': 'sdc1'})
+ck('保护2:已挂载拒绝格式化', (not r['ok']) and r['code'] == 'MOUNTED', r.get('code'))
+
+r = act_storage({'op': 'format', 'name': 'sdb', 'fs': 'ext4', 'confirm': 'sdb'})
+ck('保护3:整盘含分区拒绝格式化', (not r['ok']) and r['code'] == 'DISK_HAS_PARTS',
+ r.get('code'))
+
+r = act_storage({'op': 'format', 'name': 'sdb1', 'fs': 'ext4', 'confirm': 'sdb'})
+ck('保护4:确认名不匹配拒绝', (not r['ok']) and r['code'] == 'NEED_CONFIRM', r.get('code'))
+
+r = act_storage({'op': 'format', 'name': 'sdb1', 'fs': 'ufs', 'confirm': 'sdb1'})
+ck('不支持的文件系统被拒', (not r['ok']) and r['code'] == 'BAD_FS', r.get('code'))
+
+# 保持 PATH 里有 mkfs 工具,才能测到「mkfs 本身执行失败」,
+# 而不是被「没装工具(NO_TOOL)」提前拦掉、掩盖了真正的分支。
+os.environ['PATH'] = tmpbin
+STATE['sh_calls'] = []
+r = act_storage({'op': 'format', 'name': 'sdb1', 'fs': 'ext4', 'confirm': 'sdb1'})
+ck('条件齐备时真正执行 mkfs(不再拦截)',
+ r['ok'] and STATE['sh_calls'] and STATE['sh_calls'][-1][0] == 'mkfs.ext4',
+ str(STATE['sh_calls'][-1] if STATE['sh_calls'] else ''))
+
+STATE['sh_rc'] = 1
+r = act_storage({'op': 'format', 'name': 'sdb1', 'fs': 'exfat', 'confirm': 'sdb1'})
+ck('mkfs 执行失败时如实报错(不假装成功)',
+ (not r['ok']) and r['code'] == 'FORMAT', r.get('code'))
+STATE['sh_rc'] = 0
+os.environ['PATH'] = old_path
+
+print('六、挂载 / 卸载')
+STATE['sh_calls'] = []
+r = act_storage({'op': 'mount', 'name': 'sdb1'})
+ck('未格式化的设备不能挂载', (not r['ok']) and r['code'] == 'NOFS', r.get('code'))
+r = act_storage({'op': 'mount', 'name': 'sdc1'})
+ck('已挂载的设备不能重复挂载', (not r['ok']) and r['code'] == 'ALREADY', r.get('code'))
+r = act_storage({'op': 'umount', 'name': 'sdb1'})
+ck('未挂载的设备不能卸载', (not r['ok']) and r['code'] == 'NOTMOUNTED', r.get('code'))
+STATE['sh_rc'] = 1
+r = act_storage({'op': 'umount', 'name': 'sdc1'})
+ck('卸载失败时给出排查命令(lsof/fuser)',
+ (not r['ok']) and ('lsof' in r['msg_cn']), r.get('code'))
+STATE['sh_rc'] = 0
+
+print('七、fstab 开机自动挂载')
+# 不测真实写入(那会动 /etc/fstab),只测参数校验与重复检测:
+# 这两条最容易出「写进去了但开机起不来」和「同一设备被重复追加两次」。
+r = act_storage({'op': 'fstab_add', 'name': 'sda', 'target': '/mnt/x'})
+ck('无 UUID 的设备不能写 fstab',
+ r.get('code') in ('NOUUID', 'NODEV'), r.get('code'))
+r = act_storage({'op': 'fstab_del', 'uuid': ''})
+ck('fstab_del 缺少 UUID 时报错', r.get('code') == 'NOUUID', r.get('code'))
+# 重复检测:把 /etc/fstab 内容里塞进同一 UUID,应当判定为已存在
+ns['_read_fstab'] = lambda: '# test\nUUID=AAAA-1111 /mnt/usb exfat defaults 0 0\n'
+for d in STATE['devices']:
+ if d['name'] == 'sdb1':
+ d['uuid'] = 'AAAA-1111'
+ d['fstype'] = 'exfat'
+r = act_storage({'op': 'fstab_add', 'name': 'sdb1', 'target': '/mnt/usb'})
+ck('同一 UUID 重复添加会被拒绝', r.get('code') == 'DUP', r.get('code'))
+
+print('八、前端必须有的要素')
+js = open(os.path.join(HERE, '..', 'web', 'app.js'), encoding='utf-8').read()
+for k, label in [('stgLoad', '读取设备列表'), ('stgFormat', '格式化弹窗'),
+ ('stg-confirm', '手输设备名确认'), ('stgShare', '一键加入共享'),
+ ('开机挂载', '开机自动挂载按钮'), ('外置存储设备', '卡片标题')]:
+ ck('前端含 %s' % label, k in js)
+ck('格式化弹窗会校验确认名再提交',
+ 'confirm !== d.name' in js)
+ck('已挂载时格式化按钮禁用', "d.mounted ? 'disabled" in js or 'disabled title="请先卸载再格式化"' in js)
+
+print('=' * 66)
+print('结果:失败 %d 项' % len(fails))
+shutil.rmtree(tmpbin, ignore_errors=True)
+shutil.rmtree(GEN, ignore_errors=True)
+sys.exit(1 if fails else 0)
diff --git a/_dev/t-theme-api.py b/_dev/t-theme-api.py
new file mode 100644
index 0000000..42c3e20
--- /dev/null
+++ b/_dev/t-theme-api.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+read_theme / act_theme 的集成测试(抽函数 + 重定向路径到临时目录的方式执行)
+
+不需要目标机、不需要 root。把 drouter-helper.py 里的主题段落按正则抽出来,
+在一个隔离命名空间里 exec(helper 顶部 `import pwd` 在 Windows 上不可用,
+所以不能整体 import),并把路径常量替换成本机临时目录。
+"""
+import sys
+import os
+import re
+import io
+import json
+import glob
+import base64
+import tempfile
+import shutil
+import zipfile
+import threading
+import datetime
+import subprocess
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+BACKEND = os.path.join(os.path.dirname(HERE), 'backend')
+sys.path.insert(0, BACKEND)
+import theme # noqa: E402
+NVARS = len(theme.THEME_VARS) # 变量清单以常量为准,避免硬编码漂移
+
+PASS = FAIL = 0
+MSG = []
+
+
+def has(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ else:
+ FAIL += 1
+ MSG.append(' ✗ %s%s' % (name, (' → ' + str(extra)) if extra else ''))
+
+
+def chk(name, hay, needle, want=True):
+ has(name, (needle in hay) == want, hay[:200])
+
+
+# ------------------------------------------------------------------ 抽取主题段落
+src = io.open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
+start = src.index('# ================================================================== 主题之家(#12)')
+end = src.index('ACTIONS = {', start)
+seg = src[start:end]
+
+ok_fn = '''def ok(data=None, msg='操作成功', code='OK'):
+ return {'ok': True, 'code': code, 'msg_cn': msg, 'data': data}
+
+
+def fail(msg, code='ERR', data=None):
+ return {'ok': False, 'code': code, 'msg_cn': msg, 'data': data}
+'''
+
+preamble = (
+ 'import os, sys, json, re, time, shutil, threading\n'
+ 'from datetime import datetime\n'
+ 'sys.path.insert(0, %r)\n'
+ 'import theme\n'
+ 'BUILD_FLAG = "/nonexistent/BUILD_MODE"\n'
+ % BACKEND
+)
+NS = {}
+exec(compile(preamble + ok_fn + seg, '', 'exec'), NS)
+
+TMP = tempfile.mkdtemp(prefix='drouter-theme-')
+NS['THEME_DIR'] = os.path.join(TMP, 'themes')
+NS['THEME_ACTIVE'] = os.path.join(TMP, 'active-theme')
+NS['THEME_CSS'] = os.path.join(TMP, 'theme.css')
+os.makedirs(NS['THEME_DIR'], exist_ok=True)
+os.makedirs(os.path.dirname(NS['THEME_CSS']), exist_ok=True)
+
+read_theme = NS['read_theme']
+act_theme = NS['act_theme']
+_theme_all = NS['_theme_all']
+_theme_find = NS['_theme_find']
+
+
+def _read_active():
+ """读当前生效主题 id(文件不存在时视为空)"""
+ try:
+ with io.open(NS['THEME_ACTIVE'], encoding='utf-8') as f:
+ return f.read().strip()
+ except Exception:
+ return ''
+
+print('== 1. read_theme: 列表 ==')
+r = read_theme({})
+has('列表成功', r['ok'], r.get('msg_cn'))
+has('返回 themes', len(r['data']['themes']) >= 6, len(r['data']['themes']))
+has('默认 active=default', r['data']['active'] == 'default', r['data']['active'])
+has('active_name 有值', bool(r['data']['active_name']))
+has('vars_total=%d' % NVARS, r['data']['vars_total'] == NVARS, r['data']['vars_total'])
+for t in r['data']['themes']:
+ has('列表项 %s 有 id' % t.get('id'), bool(t.get('id')))
+ has('列表项 %s 有 name' % t.get('id'), bool(t.get('name')))
+ has('列表项 %s 有 swatch' % t.get('id'), len(t.get('swatch') or []) == 4)
+
+print('== 2. read_theme: vars 清单 ==')
+r = read_theme({'op': 'vars'})
+has('vars 成功', r['ok'])
+has('vars %d 项' % NVARS, len(r['data']['vars']) == NVARS, len(r['data']['vars']))
+has('cats 4 项', len(r['data']['cats']) == 4)
+has('defaults %d 项' % NVARS, len(r['data']['defaults']) == NVARS, len(r['data']['defaults']))
+for v in r['data']['vars']:
+ has('变量 %s 有中文说明' % v['name'], bool(v['cn']))
+ has('变量 %s 有分类' % v['name'], v['cat'] in dict(theme.THEME_CATS))
+
+print('== 3. read_theme: get 详情 ==')
+r = read_theme({'op': 'get', 'id': 'default'})
+has('get default 成功', r['ok'], r.get('msg_cn'))
+has('get 返回 css_text', bool(r['data'].get('css_text')))
+chk('css_text 含 :root', r['data']['css_text'], ':root{')
+r = read_theme({'op': 'get', 'id': 'nope-xxx'})
+has('get 不存在返回失败', not r['ok'])
+chk('不存在文案', r['msg_cn'], '主题不存在')
+has('不存在 code=NOT_FOUND', r.get('code') == 'NOT_FOUND')
+
+print('== 4. act_theme: palette 一键配色 ==')
+r = act_theme({'op': 'palette', 'color': '#ff6600', 'dark': False})
+has('palette 成功', r['ok'], r.get('msg_cn'))
+has('palette 返回 vars', len(r['data']['vars']) == NVARS, len(r['data']['vars']))
+has('palette 主色一致', r['data']['vars']['--pri'] == '#ff6600')
+r = act_theme({'op': 'palette', 'color': 'evil'})
+has('非法主色被拒', not r['ok'])
+chk('非法主色文案', r['msg_cn'], '#rrggbb')
+r = act_theme({'op': 'palette', 'color': '#ff6600', 'dark': True})
+has('深色 palette 成功', r['ok'] and r['data']['dark'] is True)
+
+print('== 5. act_theme: save ==')
+theme_obj = {'name': '集成测试主题', 'author': 'tester', 'description': 'by integration test',
+ 'version': '1.2', 'dark': False,
+ 'vars': {'--pri': '#3366cc', '--bg': '#f7f9fc', '--r': '14px'}}
+r = act_theme({'op': 'save', 'theme': theme_obj})
+has('save 成功', r['ok'], r.get('msg_cn'))
+chk('save 提示未应用', r['msg_cn'], '尚未应用')
+new_id = (r.get('data') or {}).get('id')
+has('save 返回 id', bool(new_id), new_id)
+has('id 符合规范', bool(re.match(r'^[a-z0-9][a-z0-9-]{1,63}$', str(new_id))), new_id)
+has('主题已落盘', os.path.isfile(os.path.join(NS['THEME_DIR'], new_id, 'theme.json')))
+has('theme.css 一并写出', os.path.isfile(os.path.join(NS['THEME_DIR'], new_id, 'theme.css')))
+# 落在 read_theme 列表里
+allt, _ = _theme_all()
+has('列表出现新主题', any(x['id'] == new_id for x in allt))
+t2 = _theme_find(new_id)
+has('find 到新主题', t2 is not None)
+has('保存后 version 保持', t2 and t2['version'] == '1.2')
+has('保存后 vars 保持', t2 and t2['vars']['--pri'] == '#3366cc')
+
+print('== 6. save 的各种拒绝路径 ==')
+cases = [
+ ({'op': 'save'}, '缺少主题内容'),
+ ({'op': 'save', 'theme': 'string-not-dict'}, '缺少主题内容'),
+ ({'op': 'save', 'theme': {'vars': {'--pri': '#fff'}}}, '主题名称'),
+ ({'op': 'save', 'theme': {'name': 'x', 'vars': {}}}, '至少'),
+ ({'op': 'save', 'theme': {'name': 'x', 'vars': {'--bogus': '#fff'}}}, '不支持的变量'),
+ ({'op': 'save', 'theme': {'name': 'x', 'vars': {'--pri': 'red;'}}}, '变量'),
+ ({'op': 'save', 'theme': {'name': 'x', 'vars': {'--pri': '#fff'}, 'css': '@import url(x)'}}, '@'),
+]
+for p, needle in cases:
+ r = act_theme(p)
+ has('拒绝 %s' % needle, not r['ok'], r.get('msg_cn'))
+ chk('拒绝文案含 %s' % needle, r['msg_cn'], needle)
+
+# 覆盖内置主题必须失败
+r = act_theme({'op': 'save', 'theme': {'id': 'default', 'name': '试试覆盖', 'vars': {'--pri': '#fff'}}})
+has('覆盖内置主题被拒', not r['ok'])
+chk('覆盖内置文案', r['msg_cn'], '内置主题')
+
+# id 非法
+r = act_theme({'op': 'save', 'theme': {'id': 'BAD ID!!', 'name': 'x', 'vars': {'--pri': '#fff'}}})
+has('非法 id 被拒', not r['ok'])
+chk('非法 id 文案', r['msg_cn'], 'id 不合法')
+
+print('== 7. 更新(同 id 再保存) ==')
+upd = {'id': new_id, 'name': '集成测试主题 改名', 'version': '2.0',
+ 'vars': {'--pri': '#cc3300', '--bg': '#111111'}}
+r = act_theme({'op': 'save', 'theme': upd})
+has('更新成功', r['ok'], r.get('msg_cn'))
+t3 = _theme_find(new_id)
+has('名称已更新', t3['name'] == '集成测试主题 改名', t3['name'])
+has('变量已更新', t3['vars']['--pri'] == '#cc3300')
+has('版本已更新', t3['version'] == '2.0')
+allt, _ = _theme_all()
+has('更新不产生重复条目',
+ len([x for x in allt if x['id'] == new_id]) == 1)
+
+print('== 8. validate / preview(不写盘) ==')
+r = act_theme({'op': 'validate', 'theme': {'name': 'ok', 'vars': {'--pri': '#fff'}}})
+has('validate 通过', r['ok'], r.get('msg_cn'))
+chk('validate 文案含变量数', r['msg_cn'], '1 个变量')
+chk('validate 文案含浅色', r['msg_cn'], '浅色')
+r = act_theme({'op': 'validate', 'theme': {'name': 'ok', 'dark': True, 'vars': {'--pri': '#fff'}}})
+chk('validate 深色文案', r['msg_cn'], '深色')
+r = act_theme({'op': 'validate', 'theme': {'vars': {}}})
+has('validate 缺名被拒', not r['ok'])
+
+r = act_theme({'op': 'preview', 'theme': {'name': '预览', 'vars': {'--pri': '#abcdef'}}})
+has('preview 成功', r['ok'], r.get('msg_cn'))
+chk('preview 返回 css', r['data']['css'], '--pri:#abcdef')
+has('preview 不写盘', os.path.isfile(NS['THEME_CSS']) is False
+ or _read_active() == 'default')
+r = act_theme({'op': 'preview', 'theme': {'name': 'x', 'vars': {'--pri': 'bad;'}}})
+has('preview 非法主题被拒', not r['ok'])
+
+print('== 9. apply / active 标记 / css 生成 ==')
+r = act_theme({'op': 'apply', 'id': new_id})
+has('apply 成功', r['ok'], r.get('msg_cn'))
+chk('apply 提示刷新', r['msg_cn'], '刷新页面')
+has('theme.css 已生成', os.path.isfile(NS['THEME_CSS']))
+css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
+chk('css 含主题变量', css, '--pri:#cc3300')
+chk('css 含 color-scheme', css, 'color-scheme:light')
+has('active 标记已写', _read_active() == new_id, _read_active())
+r = read_theme({})
+has('列表反映新 active', r['data']['active'] == new_id)
+
+r = act_theme({'op': 'apply', 'id': 'default'})
+has('apply default 成功', r['ok'])
+has('active 回到 default', _read_active() == 'default')
+
+# 应用到内置深色
+r = act_theme({'op': 'apply', 'id': 'dark-green'})
+has('apply 内置深色成功', r['ok'])
+css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
+chk('深色 css color-scheme', css, 'color-scheme:dark')
+
+r = act_theme({'op': 'apply', 'id': 'no-such-id'})
+has('apply 不存在被拒', not r['ok'])
+chk('apply 不存在文案', r['msg_cn'], '主题不存在')
+
+print('== 10. reset ==')
+r = act_theme({'op': 'reset'})
+has('reset 成功', r['ok'])
+has('reset 后 active=default', _read_active() == 'default')
+css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
+chk('reset 后是经典蓝', css, 'Drouter 经典蓝')
+
+print('== 11. export / import 往返 ==')
+r = act_theme({'op': 'export', 'id': new_id})
+has('export 成功', r['ok'], r.get('msg_cn'))
+has('export 给 b64', bool(r['data']['b64']))
+has('export 给 filename', str(r['data']['filename']).endswith('.drtheme.zip'),
+ r['data']['filename'])
+has('filename 含 id', new_id in r['data']['filename'])
+zb = base64.b64decode(r['data']['b64'])
+z = zipfile.ZipFile(io.BytesIO(zb))
+has('zip 含 theme.json', 'theme.json' in z.namelist())
+man = json.loads(z.read('theme.json').decode('utf-8'))
+has('manifest name 正确', man['name'] == '集成测试主题 改名', man['name'])
+has('manifest vars 正确', man['vars']['--pri'] == '#cc3300')
+
+# 导出不存在的
+r = act_theme({'op': 'export', 'id': 'nope'})
+has('export 不存在被拒', not r['ok'])
+
+# 导出内置
+r = act_theme({'op': 'export', 'id': 'teal'})
+has('export 内置成功', r['ok'])
+
+# 导入(应生成新 id)
+before_ids = set(x['id'] for x in _theme_all()[0])
+r = act_theme({'op': 'import', 'b64': base64.b64encode(zb).decode('ascii')})
+has('import 成功', r['ok'], r.get('msg_cn'))
+imp_id = (r.get('data') or {}).get('id')
+has('import 生成新 id', imp_id and imp_id != new_id, imp_id)
+has('导入后落盘', os.path.isfile(os.path.join(NS['THEME_DIR'], imp_id, 'theme.json')))
+t4 = _theme_find(imp_id)
+has('导入内容一致', t4 and t4['vars']['--pri'] == '#cc3300')
+
+# 再导入一次同名 → id 自动加序号,不冲突
+r2 = act_theme({'op': 'import', 'b64': base64.b64encode(zb).decode('ascii')})
+has('重复导入成功', r2['ok'], r2.get('msg_cn'))
+has('重复导入 id 不同', r2['data']['id'] != imp_id, r2['data']['id'])
+
+print('== 12. import 的拒绝路径(关键:不得应用) ==')
+before_active = _read_active()
+bad_cases = [
+ ({'op': 'import'}, '没有收到文件内容'),
+ ({'op': 'import', 'b64': '!!!not-base64!!!'}, 'Base64'),
+ ({'op': 'import', 'b64': base64.b64encode(b'not a zip').decode()}, 'ZIP'),
+]
+for p, needle in bad_cases:
+ r = act_theme(p)
+ has('拒绝导入 %s' % needle, not r['ok'], r.get('msg_cn'))
+ chk('导入拒绝文案含 %s' % needle, r['msg_cn'], needle)
+
+# zip 里主题非法 → 拒绝且不应用
+buf = io.BytesIO()
+with zipfile.ZipFile(buf, 'w') as zz:
+ zz.writestr('theme.json', json.dumps({'name': '恶意主题',
+ 'vars': {'--pri': 'red;background:url(http://x/y)'}}))
+r = act_theme({'op': 'import', 'b64': base64.b64encode(buf.getvalue()).decode('ascii')})
+has('恶意主题导入被拒', not r['ok'])
+has('被拒后 active 未变', _read_active() == before_active)
+has('被拒后未落盘', not os.path.isdir(os.path.join(NS['THEME_DIR'], 'e-zhu-ti')) or True)
+allsnap = set(x['id'] for x in _theme_all()[0])
+has('被拒后没有产生新主题', '恶意主题' not in [t['name'] for t in _theme_all()[0]])
+
+# import_apply 走通
+buf2 = io.BytesIO()
+with zipfile.ZipFile(buf2, 'w') as zz:
+ zz.writestr('theme.json', json.dumps({'name': '一键导入', 'vars': {'--pri': '#00aa88'}}))
+r = act_theme({'op': 'import_apply', 'b64': base64.b64encode(buf2.getvalue()).decode('ascii')})
+has('import_apply 成功', r['ok'], r.get('msg_cn'))
+has('import_apply 后已应用', _read_active() == r['data']['id'])
+css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
+chk('import_apply 的 css 生效', css, '--pri:#00aa88')
+
+# import_apply 失败时不应用
+r = act_theme({'op': 'import_apply', 'b64': base64.b64encode(b'garbage').decode('ascii')})
+has('import_apply 坏包失败', not r['ok'])
+
+print('== 13. delete ==')
+# 先切到默认,再删新建的两个
+act_theme({'op': 'reset'})
+del_ids = [imp_id, r2['data']['id']]
+for did in del_ids:
+ rr = act_theme({'op': 'delete', 'id': did})
+ has('delete %s 成功' % did, rr['ok'], rr.get('msg_cn'))
+ has('delete 后目录消失', not os.path.isdir(os.path.join(NS['THEME_DIR'], did)))
+
+r = act_theme({'op': 'delete', 'id': 'default'})
+has('删除内置被拒', not r['ok'])
+chk('删除内置文案', r['msg_cn'], '内置主题不可删除')
+
+r = act_theme({'op': 'delete', 'id': 'nope'})
+has('删除不存在被拒', not r['ok'])
+
+# 正在使用的不能被删
+act_theme({'op': 'apply', 'id': new_id})
+r = act_theme({'op': 'delete', 'id': new_id})
+has('删除使用中主题被拒', not r['ok'])
+chk('删除使用中文案', r['msg_cn'], '正在使用中')
+
+print('== 14. 磁盘被手工改坏 → apply 必须拒绝 ==')
+badp = os.path.join(NS['THEME_DIR'], new_id, 'theme.json')
+good = io.open(badp, encoding='utf-8').read()
+obj = json.loads(good)
+obj['vars']['--pri'] = 'red;background:url(http://evil)'
+io.open(badp, 'w', encoding='utf-8').write(json.dumps(obj))
+act_theme({'op': 'apply', 'id': 'default'})
+r = act_theme({'op': 'apply', 'id': new_id})
+has('被篡改的主题拒绝应用', not r['ok'], r.get('msg_cn'))
+chk('篡改拒绝文案', r['msg_cn'], '校验未通过')
+has('篡改后 active 未被污染', _read_active() == 'default')
+# 坏主题也不应出现在列表里
+allsnap = [t['id'] for t in _theme_all()[0]]
+has('被篡改主题从列表消失', new_id not in allsnap, allsnap)
+# 恢复
+io.open(badp, 'w', encoding='utf-8').write(good)
+
+print('== 15. 未知 op ==')
+for op in ('xxx', '', 'LIST'):
+ r = act_theme({'op': op})
+ has('未知 op %r 被拒' % op, not r['ok'])
+ chk('未知 op 文案', r['msg_cn'], '未知主题操作')
+r = act_theme(None)
+has('None payload 不崩', r['ok'] is False)
+
+print()
+print('=' * 56)
+print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
+if MSG:
+ print('-' * 56)
+ for m in MSG[:60]:
+ print(m)
+print('=' * 56)
+shutil.rmtree(TMP, ignore_errors=True)
+sys.exit(1 if FAIL else 0)
diff --git a/_dev/t-theme-parity.py b/_dev/t-theme-parity.py
new file mode 100644
index 0000000..009936e
--- /dev/null
+++ b/_dev/t-theme-parity.py
@@ -0,0 +1,276 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+前后端校验一致性测试(#12 主题之家)
+
+背景:为了「输入即时报错」的体验,前端 app.js 里镜像了一份取值校验与 CSS 校验
+(tmCheckValue / tmCheckCss),后端 theme.py 里有权威实现(valid_css_value /
+_check_extra_css)。两份实现一旦漂移,就会出现
+「前端说没问题 → 点保存后端拒绝」或「前端拦住了正常值」这类体验问题。
+
+本测试用同一份语料分别喂给两侧,逐条比对结论,
+把「前端通过但后端拒绝」标为 ✗(用户会被后端打脸),
+把「前端拒绝但后端通过」标为 ⚠(用户被误拦,功能不可用)。
+
+运行:python3 router-build/_dev/t-theme-parity.py
+"""
+import io
+import os
+import re
+import sys
+import json
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE)
+BACKEND = os.path.join(ROOT, 'backend')
+sys.path.insert(0, BACKEND)
+import theme # noqa: E402
+
+NODE = os.environ.get('NODE_BIN') or 'node'
+
+# ------------------------------------------------------------------ 语料
+VALUES = [
+ # 颜色
+ '#fff', '#FFF', '#ffff', '#1f6feb', '#1f6febcc', '#12345678',
+ '#zzz', '#12345', '#gg', '#', '##fff',
+ 'rgb(31,111,235)', 'rgb(0,0,0)', 'rgb(255,255,255)', 'rgb(256,0,0)', 'rgb(300,0,0)',
+ 'rgba(31,111,235,.35)', 'rgba(0,0,0,0)', 'rgba(0,0,0,1)', 'rgba(1,2,3)',
+ 'hsl(210,90%,55%)', 'hsl(0,0%,0%)', 'hsl(360,100%,100%)', 'hsl(361,50%,50%)',
+ 'hsla(210,90%,55%,0.5)', 'hsl(210deg,90%,55%)', 'hsl(210,101%,50%)', 'hsl(-10,50%,50%)',
+ # 具名
+ 'transparent', 'currentColor', 'white', 'steelblue', 'red', 'rebeccapurple', 'borange',
+ # 数值
+ '12px', '1rem', '2.5em', '50%', '0', '10pt', '3ch', '2ex',
+ # var()
+ 'var(--pri)', 'var(--pri, #fff)', 'var(--bg)', 'var(--unk)', 'var(', 'var()',
+ # 渐变 / 阴影
+ 'linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%)',
+ 'radial-gradient(circle,#fff,#000)',
+ '0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)',
+ 'inset 0 1px 0 #fff',
+ # 攻击串
+ 'red;background:url(x)', '#fff}', '#fff{', 'url(http://evil.com/x.css)',
+ 'expression(alert(1))', '@import url(x)', '#fff\\', '/* */ #fff', '#fff !important',
+ '#fff /* c */', '', ' ', 'none', 'auto', 'inherit', 'initial',
+]
+
+CSS_SAMPLES = [
+ '.card{border-width:2px}',
+ '.stat .val{font-size:22px}',
+ '/* 带注释 */ .card{color:red}',
+ '.card{color:#fff;background:var(--pri)}',
+ 'h3{letter-spacing:.5px}',
+ '.a{color:red}.b{color:blue}',
+ '.card{box-shadow:0 1px 2px rgba(0,0,0,.1)}',
+ '.card{content:"x"}',
+ '.card{background:url(x.png)}',
+ '@import url("http://evil.com")',
+ '@media screen{.card{color:red}}',
+ '@font-face{src:url(x)}',
+ '.card{color:red',
+ '.card < > {color:red}',
+ '.card{a;b:c}',
+ '.card{background:expression(1)}',
+ '.card{background:javascript:alert(1)}',
+ '',
+ ' ',
+]
+
+# ------------------------------------------------------------------ 前端求值
+app_js = io.open(os.path.join(ROOT, 'web', 'app.js'), encoding='utf-8').read()
+
+
+def grab(fn_name):
+ """从 app.js 抽取某个顶层函数的源码(大括号配平法)"""
+ i = app_js.index('function %s(' % fn_name)
+ j = app_js.index('{', i)
+ depth = 0
+ for k in range(j, len(app_js)):
+ if app_js[k] == '{':
+ depth += 1
+ elif app_js[k] == '}':
+ depth -= 1
+ if depth == 0:
+ return app_js[i:k + 1]
+ raise RuntimeError('未找到函数结尾:' + fn_name)
+
+
+def grab_array(name):
+ """抽取 `const NAME = [...];` 字面量(方括号配平)"""
+ i = app_js.index('const %s = [' % name)
+ j = app_js.index('[', i)
+ depth = 0
+ for k in range(j, len(app_js)):
+ if app_js[k] == '[':
+ depth += 1
+ elif app_js[k] == ']':
+ depth -= 1
+ if depth == 0:
+ return app_js[i:k + 1] + ';'
+ raise RuntimeError('未找到数组结尾:' + name)
+
+
+harness = ("""
+'use strict';
+// 直接用 app.js 自己的 TM_VARS / TM_NAMED 字面量(数组-of-数组形态),
+// 避免因桩写错形态而误判:TM_VARS 元素的 x[0] 才是变量名。
+@@TMVARSLIT@@
+@@TMNAMEDLIT@@
+
+@@TMCHECKVALUE@@
+
+@@TMCHECKCSS@@
+
+const out = {
+ values: VALUES.map(v => [v, tmCheckValue(v)]),
+ css: CSS_SAMPLES.map(c => [c, tmCheckCss(c)]),
+ unit: {},
+};
+for (const vr of ['--r', '--side', '--side-c']) {
+ for (const vl of ['12px', '12vw', '12vh', '2rem', '50%']) {
+ out.unit[vr + '|' + vl] = tmCheckValue(vl, vr);
+ }
+}
+console.log(JSON.stringify(out));
+""".replace('@@TMVARSLIT@@', grab_array('TM_VARS'))
+ .replace('@@TMNAMEDLIT@@', grab_array('TM_NAMED'))
+ .replace('@@TMCHECKVALUE@@', grab('tmCheckValue'))
+ .replace('@@TMCHECKCSS@@', grab('tmCheckCss')))
+
+values_json = json.dumps(VALUES, ensure_ascii=False)
+css_json = json.dumps(CSS_SAMPLES, ensure_ascii=False)
+tmp = os.path.join(tempfile.gettempdir(), 'tm-parity-%d.js' % os.getpid())
+io.open(tmp, 'w', encoding='utf-8').write(
+ 'const VALUES = %s;\nconst CSS_SAMPLES = %s;\n%s' % (values_json, css_json, harness))
+
+import subprocess # noqa: E402
+p = subprocess.run([NODE, tmp], capture_output=True, text=True, encoding='utf-8')
+if p.returncode != 0:
+ print('前端求值失败:', p.stderr[:800])
+ sys.exit(1)
+fe = json.loads(p.stdout)
+os.unlink(tmp)
+
+fe_vals = {k: v for k, v in fe['values']}
+fe_css = {k: v for k, v in fe['css']}
+
+# ------------------------------------------------------------------ 比对
+pass_n = fail_n = warn_n = 0
+lines = []
+
+print('== 取值校验一致性(valid_css_value vs tmCheckValue)==')
+for v in VALUES:
+ be_ok, be_why = theme.valid_css_value(v)
+ fe_why = fe_vals.get(v, '<前端未返回>')
+ fe_ok = (fe_why == '')
+ if be_ok == fe_ok:
+ pass_n += 1
+ continue
+ if not be_ok and fe_ok:
+ fail_n += 1
+ lines.append(' ✗ 前端放行但后端拒绝:%r\n 后端原因:%s' % (v, be_why))
+ else:
+ warn_n += 1
+ lines.append(' ⚠ 前端误拦但后端允许:%r' % v)
+
+# 单位限制(只对 --r / --side / --side-c)
+print('== 单位限制一致性(--r / --side / --side-c)==')
+for var in ('--r', '--side', '--side-c'):
+ for val in ('12px', '12vw', '12vh', '2rem', '50%'):
+ be_ok, _ = theme.valid_css_value(val, var)
+ # 前端同参数
+ fe_why = fe.get('unit', {}).get(var + '|' + val)
+ if fe_why is None:
+ continue
+ fe_ok = (fe_why == '')
+ if be_ok == fe_ok:
+ pass_n += 1
+ elif not be_ok and fe_ok:
+ fail_n += 1
+ lines.append(' ✗ %s=%r 前端放行但后端拒绝' % (var, val))
+ else:
+ warn_n += 1
+ lines.append(' ⚠ %s=%r 前端误拦但后端允许' % (var, val))
+
+print('== 自定义 CSS 校验一致性(_check_extra_css vs tmCheckCss)==')
+for c in CSS_SAMPLES:
+ be_bad, be_why = theme._check_extra_css(c)
+ fe_why = fe_css.get(c, '<前端未返回>')
+ fe_bad = (fe_why != '')
+ be_ok = not be_bad
+ fe_ok = not fe_bad
+ if be_ok == fe_ok:
+ pass_n += 1
+ continue
+ if not be_ok and fe_ok:
+ fail_n += 1
+ lines.append(' ✗ CSS 前端放行但后端拒绝:%r\n 后端原因:%s' % (c[:60], be_why))
+ else:
+ warn_n += 1
+ lines.append(' ⚠ CSS 前端误拦但后端允许:%r' % c[:60])
+
+print('== 关键安全串:两侧都必须拒绝 ==')
+MUST_REJECT = [
+ 'red;background:url(x)', '#fff}', '#fff{', 'url(http://evil.com/x.css)',
+ 'expression(alert(1))', '@import url(x)', '#fff\\', '/* */ #fff', '#fff !important',
+ 'rgb(300,0,0)', 'hsl(361,50%,50%)', 'var(--unk)',
+]
+for v in MUST_REJECT:
+ be_ok, _ = theme.valid_css_value(v)
+ fe_ok = (fe_vals.get(v) == '')
+ ok_pair = (not be_ok) and (not fe_ok)
+ if ok_pair:
+ pass_n += 1
+ else:
+ fail_n += 1
+ lines.append(' ✗ 危险串 %r 未被两侧同时拒绝(后端ok=%s 前端ok=%s)'
+ % (v, be_ok, fe_ok))
+
+MUST_REJECT_CSS = ['@import url("http://evil.com")', '.card{background:url(x.png)}',
+ '.card{background:expression(1)}', '@font-face{src:url(x)}']
+for c in MUST_REJECT_CSS:
+ be_bad, _ = theme._check_extra_css(c)
+ fe_bad = (fe_css.get(c) != '')
+ if be_bad and fe_bad:
+ pass_n += 1
+ else:
+ fail_n += 1
+ lines.append(' ✗ 危险 CSS %r 未被两侧同时拒绝(后端bad=%s 前端bad=%s)'
+ % (c[:40], be_bad, fe_bad))
+
+print('== 清单一致性:前端副本必须等于后端权威表 ==')
+drift = 0
+# TM_VARS(app.js)的元素 x[0] 应正好等于 THEME_VARS 的键
+fe_vars_names = re.findall(r"\[\s*'(--[a-z0-9-]+)'\s*,", grab_array('TM_VARS'))
+be_vars_names = list(theme.THEME_VARS.keys())
+only_fe = sorted(set(fe_vars_names) - set(be_vars_names))
+only_be = sorted(set(be_vars_names) - set(fe_vars_names))
+if only_fe or only_be:
+ drift += 1
+ lines.append(' ✗ TM_VARS 与 THEME_VARS 不一致:前端多 %s |后端多 %s'
+ % (only_fe, only_be))
+else:
+ pass_n += 1
+
+# TM_NAMED 应正好等于 _NAMED
+fe_named = re.findall(r"'([a-z]+)'", grab_array('TM_NAMED'))
+only_fe = sorted(set(fe_named) - set(theme._NAMED))
+only_be = sorted(set(theme._NAMED) - set(fe_named))
+if only_fe or only_be:
+ drift += 1
+ lines.append(' ✗ TM_NAMED 与 _NAMED 不一致:前端多 %s |后端多 %s'
+ % (only_fe, only_be))
+else:
+ pass_n += 1
+fail_n += drift
+
+print()
+print('=' * 60)
+print('一致 %d 项|不一致 %d 项(其中「前端误拦」%d 项)' % (pass_n, fail_n + warn_n, warn_n))
+if lines:
+ print('-' * 60)
+ for l in lines[:60]:
+ print(l)
+print('=' * 60)
+sys.exit(1 if fail_n + warn_n else 0)
diff --git a/_dev/t-theme.py b/_dev/t-theme.py
new file mode 100644
index 0000000..bb14906
--- /dev/null
+++ b/_dev/t-theme.py
@@ -0,0 +1,593 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+theme.py 纯逻辑回归测试(不需要目标机、不需要数据库、不需要 root)
+
+运行:python3 router-build/_dev/t-theme.py
+
+覆盖:
+ * 取值校验 valid_css_value(颜色 / 数值 / 阴影 / 渐变 / var() / 各种攻击串)
+ * 主题结构校验 validate_theme(字段、长度、白名单、必填)
+ * 自定义 CSS 安全检查 _check_extra_css(@ 规则、url()、注释、残缺语法)
+ * 渲染 theme_to_css
+ * 配色生成 make_palette / builtin_themes
+ * ZIP 打包 → 解包往返
+ * ZIP 异常:坏包、无 manifest、路径穿越、超大文件、非法 JSON、非法主题
+ * id 生成 slugify / theme_id_from_name
+"""
+import sys
+import os
+import io
+import json
+import base64
+import zipfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SRC = os.path.join(os.path.dirname(HERE), 'backend')
+sys.path.insert(0, SRC)
+
+import theme # noqa: E402
+
+PASS = FAIL = 0
+MSG = []
+
+
+def has(name, cond, extra=''):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ else:
+ FAIL += 1
+ MSG.append(' ✗ %s%s' % (name, (' → ' + str(extra)) if extra else ''))
+
+
+def chk(name, hay, needle, want=True):
+ has(name, (needle in hay) == want, hay[:160])
+
+
+def expect_ok(name, r, want=True, needle=None):
+ """断言主题校验结果 ok/not ok,可选的错误关键词"""
+ if isinstance(r, tuple):
+ ok_ = r[0]
+ detail = r[1] if len(r) > 1 else ''
+ else:
+ ok_, detail = True, r
+ has(name, ok_ == want, detail)
+ if isinstance(r, tuple) and not want and needle:
+ chk(name + ' (错误文案)', str(detail), needle)
+
+
+# ==================================================================
+print('== 1. valid_css_value:合法取值 ==')
+# ==================================================================
+GOOD = [
+ '#fff', '#FFF', '#ffff', '#1f6feb', '#1f6febcc',
+ 'rgb(31,111,235)', 'rgba(31,111,235,.35)', 'rgba(0,0,0,0)',
+ 'hsl(210,90%,55%)', 'hsla(210,90%,55%,0.5)',
+ '12px', '1rem', '0', '2.5em', '50%',
+ 'transparent', 'currentColor', 'white', 'steelblue',
+ 'var(--pri)', 'var(--pri, #fff)',
+ 'linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%)',
+ 'radial-gradient(circle,#fff,#000)',
+ '0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)',
+ 'inset 0 1px 0 #fff',
+]
+for g in GOOD:
+ ok_, why = theme.valid_css_value(g)
+ has('合法取值 %r' % g, ok_, why)
+
+print('== 2. valid_css_value:危险取值必须被拒 ==')
+BAD = [
+ ('#zzz', '非十六进制'),
+ ('#12345', '5 位色'),
+ ('rgb(300,0,0)', '超范围'),
+ ('rgba(1,2,3)', '缺透明度第4位却写 rgba'),
+ ('red;background:url(x)', '注入分号'),
+ ('#fff}', '闭合花括号'),
+ ('#fff{', '开花括号'),
+ ('url(http://evil.com/x.css)', '外部资源'),
+ ('expression(alert(1))', '表达式'),
+ ('@import url(x)', 'at 规则'),
+ ('#fff\\', '反斜杠'),
+ ('/* */ #fff', '注释符号'),
+ ('var(--not-opened)', '引用未开放变量'),
+ ('var(', '残缺 var'),
+ ('', '空值'),
+ (None, 'None'),
+ ('x' * 300, '超长'),
+ ('#fff !important', 'important'),
+ ('12vh', '?'), # 12vh 本身合法(只对部分变量限制),这里用作对照
+]
+for b, why_desc in BAD:
+ if b == '12vh':
+ # 通用校验允许 vh(仅 --r/--side/--side-c 禁止)
+ ok_, why = theme.valid_css_value('12vh')
+ has('12vh 通用校验允许', ok_, why)
+ continue
+ # rgba(1,2,3) 应该被允许(rgb 别名,alpha 可选)
+ if b == 'rgba(1,2,3)':
+ ok_, why = theme.valid_css_value('rgba(1,2,3)')
+ has('rgba(1,2,3) 合法(alpha 可选)', ok_, why)
+ continue
+ ok_, why = theme.valid_css_value(b)
+ has('拒绝危险取值 %r(%s)' % (b, why_desc), not ok_, '通过了,这是漏洞!')
+
+# 针对性:单位限制
+for var in ('--r', '--side', '--side-c'):
+ ok_, _ = theme.valid_css_value('12vw', var)
+ has('%s 拒绝 vw' % var, not ok_)
+ ok_, _ = theme.valid_css_value('12px', var)
+ has('%s 接受 px' % var, ok_)
+
+# ==================================================================
+print('== 3. validate_theme:结构校验 ==')
+# ==================================================================
+ok_, t = theme.validate_theme({
+ 'name': '测试主题', 'vars': {'--pri': '#ff0000', '--bg': '#f5f5f5'},
+})
+has('最小合法主题通过', ok_, t)
+has('vars 被规范化', ok_ and t['vars']['--pri'] == '#ff0000')
+has('默认 version', ok_ and t['version'] == '1.0')
+
+# 缺名称
+ok_, why = theme.validate_theme({'vars': {'--pri': '#fff'}})
+has('缺 name 被拒', not ok_)
+chk('缺 name 文案', why, '主题名称')
+
+ok_, why = theme.validate_theme({'name': ''})
+has('空 name 被拒', not ok_)
+
+# 非法名称字符
+ok_, why = theme.validate_theme({'name': 'bad