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 + +# Drouter + +**Debian 13 软路由管理系统 · Web 管理面板** + +*不接管你的网络,直到你亲手点下「应用」。* + +[![Platform](https://img.shields.io/badge/平台-Debian%2013%20trixie-A80030?style=flat-square)](https://www.debian.org/) +[![Arch](https://img.shields.io/badge/架构-amd64%20%7C%20arm64-2f6feb?style=flat-square)](#-安装) +[![Backend](https://img.shields.io/badge/后端-Python%203%20标准库-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/) +[![Frontend](https://img.shields.io/badge/前端-原生%20JS%20·%20无构建-F7DF1E?style=flat-square&logo=javascript&logoColor=black)](#-设计取舍) +[![License](https://img.shields.io/badge/许可-MIT-green?style=flat-square)](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 在仓库刚建好时也能完整渲染、不留死链。 +> 装好之后按下面的步骤产出真图替换即可。 + +
+ +| 系统概览 | 网卡与桥接 | +|---|---| +| ![概览](docs/screenshots/01-dash.svg) | ![网卡](docs/screenshots/02-iface.svg) | + +| 防火墙 IPv4 | 智能限速 QoS | +|---|---| +| ![防火墙](docs/screenshots/03-fw4.svg) | ![QoS](docs/screenshots/04-qos.svg) | + +| 连接与流日志 | Web 终端 | +|---|---| +| ![流日志](docs/screenshots/05-flowlog.svg) | ![终端](docs/screenshots/06-webshell.svg) | + +
+ +
+怎么补上真实的截图(点开) + +装好之后按下面步骤产出截图,替换 `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 +""" + + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = 'drouter-rescue/1.0' + protocol_version = 'HTTP/1.1' + conf = {} + + def log_message(self, fmt, *args): + pass + + # ---------- 安全:只允许救援网段访问 ---------- + def _allowed(self): + try: + ip = self.client_address[0] + except Exception: + return False + # IPv6 链路本地 / 回环 + if ip.startswith('127.') or ip in ('::1',): + return True + if ip.startswith('169.254.'): + return True + # 与救援网段同网段的来源 + import ipaddress + try: + net = ipaddress.IPv4Network('%s/%s' % (self.conf.get('vip', '169.254.0.1'), + self.conf.get('vip_mask', '16')), strict=False) + if ipaddress.IPv4Address(ip) in net: + return True + except Exception: + pass + try: + net2 = ipaddress.IPv4Network('%s/24' % self.conf.get('alt_vip', '10.99.99.1'), strict=False) + if ipaddress.IPv4Address(ip) in net2: + return True + except Exception: + pass + return False + + def _send(self, code, body, ctype='text/html; charset=utf-8'): + data = body.encode('utf-8') if isinstance(body, str) else body + self.send_response(code) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-store') + self.end_headers() + try: + self.wfile.write(data) + except Exception: + pass + + def _json(self, obj, code=200): + self._send(code, json.dumps(obj, ensure_ascii=False), 'application/json; charset=utf-8') + + def do_GET(self): + if not self._allowed(): + return self._send(403, '

403 禁止访问

救援通道仅允许在救援网段内访问。

') + path = urllib.parse.urlparse(self.path).path.rstrip('/') or '/' + if path == '/': + # 读取快照列表取最新 + r = run_helper('snapshot_list', timeout=60) + items = ((r.get('data') or {}).get('items') or []) + latest = items[0]['ts'] if items else '' + root = (r.get('data') or {}).get('root') or '' + ifs = self.conf.get('ifaces') or [] + ifs_txt = '、'.join(ifs) if ifs else '所有物理网卡(自动)' + html = (PAGE + .replace('__TS__', now_str()) + .replace('__VIP__', self.conf.get('vip', DEFAULT_VIP)) + .replace('__ALT__', self.conf.get('alt_vip', ALT_VIP)) + .replace('__PORT__', str(self.conf.get('port', DEFAULT_PORT))) + .replace('__IFACES__', ifs_txt) + .replace('__SNAPROOT__', root) + .replace('__LATEST__', latest) + .replace('__CONFIRM__', CONFIRM_WORD)) + return self._send(200, html) + if path == '/api/snapshots': + r = run_helper('snapshot_list', timeout=60) + return self._json(r) + if path == '/api/health': + return self._json({'ok': True, 'msg_cn': '救援通道运行中', 'ts': now_str()}) + return self._send(404, '

404 页面不存在

') + + def do_POST(self): + if not self._allowed(): + return self._json({'ok': False, 'msg_cn': '救援通道仅允许在救援网段内访问'}, 403) + path = urllib.parse.urlparse(self.path).path.rstrip('/') or '/' + try: + n = int(self.headers.get('Content-Length') or 0) + body = json.loads(self.rfile.read(n) or b'{}') if n else {} + except Exception: + body = {} + if path == '/api/restore': + if body.get('confirm') != CONFIRM_WORD: + return self._json({'ok': False, 'msg_cn': '确认短语不正确'}) + ts = str(body.get('ts') or '').strip() + if not re.match(r'^\d{8}-\d{6}$', ts): + return self._json({'ok': False, 'msg_cn': '快照编号格式不正确'}) + log('warn', 'RESCUE_RESTORE', '收到救援还原请求:%s' % ts, + {'from': self.client_address[0]}) + r = run_helper('rollback', {'ts': ts, 'reload': True}, timeout=240) + if not r.get('ok'): + log('error', 'RESCUE_RESTORE_FAIL', '救援还原失败:%s' % r.get('msg_cn')) + return self._json(r) + # 自动重启 + if self.conf.get('auto_reboot', True): + log('warn', 'RESCUE_REBOOT', '救援还原后自动重启系统') + subprocess.Popen(['/bin/sh', '-c', 'sleep 3; /sbin/reboot'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return self._json({'ok': True, + 'msg_cn': '%s(已还原到 %s%s)' + % (r.get('msg_cn', '还原完成'), ts, + ',系统将自动重启' if self.conf.get('auto_reboot', True) else '')}) + return self._json({'ok': False, 'msg_cn': '接口不存在'}, 404) + + +class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def main(): + conf = load_conf() + if not conf.get('enabled'): + print('救援通道未启用(/etc/drouter/rescue.conf 中 enabled=false),退出。', flush=True) + return 0 + Handler.conf = conf + port = int(conf.get('port') or DEFAULT_PORT) + try: + srv = ThreadedServer((HOST, port), Handler) + except OSError as e: + print('救援通道无法监听 %s:%d —— %s' % (HOST, port, e), flush=True) + log('error', 'RESCUE_LISTEN_FAIL', '救援通道监听失败:%s' % e) + return 1 + print('救援通道已启动:http://%s:%d/ (独立于 WAN/LAN)' + % (conf.get('vip', DEFAULT_VIP), port), flush=True) + log('warn', 'RESCUE_START', '救援通道已启动,监听 %d 端口,救援地址 %s' + % (port, conf.get('vip')), {'ifaces': conf.get('ifaces')}) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/backend/drouter-shelld.py b/backend/drouter-shelld.py new file mode 100644 index 0000000..6880008 --- /dev/null +++ b/backend/drouter-shelld.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +drouter-shelld —— Web 终端的 PTY 会话守护(#4 重做版) + +为什么必须有这个守护: + Web 后端每次调 helper 都是「新起一个 sudo python3 进程,跑完就退出」, + PTY 会话是内核里的文件描述符,进程一退出 fd 就没了。所以终端必须由一个 + 常驻进程持有,helper 只当转发客户端。 + +之前的实现是「一条命令 + subprocess.run + 回传输出」,于是: + * 输入不是实时的(要敲完回车再整条发); + * 没有回显(靠前端自己画 "$ cmd",看着像有、其实不是终端给的); + * 交互式程序(top / vi / python / 密码提示)根本没法用。 +改成真 PTY 后,回显、Ctrl+C、方向键历史、全屏程序都由内核终端行规程负责, +前端只负责把字节流显示出来。 + +安全边界: + * Unix socket 位于 /run/drouter/shell.sock,root 拥有、权限 0600, + 只有 root(即被 sudoers 放行的 helper)能连;低权 drouter 用户连不上。 + * 会话空闲超过 IDLE_TIMEOUT 自动回收,避免忘记断开留下 root shell。 + * 每次连接 / 断开都写结构化日志,便于事后审计。 +""" + +import errno +import fcntl +import itertools +import json +import os +import pty +import select +import signal +import socketserver +import struct +import subprocess +import sys +import termios +import threading +import time +from datetime import datetime + +_SBIN_DIRS = ('/usr/local/sbin', '/usr/sbin', '/sbin') + + +def _ensure_sbin_path(): + cur = os.environ.get('PATH') or '' + parts = [p for p in cur.split(os.pathsep) if p] + for d in _SBIN_DIRS: + if d not in parts and os.path.isdir(d): + parts.insert(0, d) + os.environ['PATH'] = os.pathsep.join(parts) + + +_ensure_sbin_path() + +SOCK_PATH = '/run/drouter/shell.sock' +LOG_DIR = '/var/log/drouter' +IDLE_TIMEOUT = 30 * 60 # 30 分钟无操作自动回收 +MAX_SESSIONS = 8 # 4GB 小机器上别开一堆 shell +READ_CHUNK = 65536 +# 单次 read 最多回传多少字节:多了前端渲染会卡,少了要跑很多轮 +MAX_READ = 262144 +# 长轮询:前端带 wait 时,没有数据最多在这里挂起多久 / 每次探一下的间隔 +READ_HOLD_MAX = 5.0 +READ_HOLD_STEP = 0.02 + +_lock = threading.Lock() +_SESSIONS = {} +# 会话编号的自增序列。不能只用时间戳:同一秒内连两次会生成相同 sid, +# 后一个会话把前一个顶掉,表现为「刚开的终端突然不动了」。 +_seq = itertools.count(1) + + +def log(level, code, msg_cn, detail=None): + try: + os.makedirs(LOG_DIR, exist_ok=True) + rec = {'ts': datetime.now().isoformat(timespec='seconds'), + 'level': level, 'module': 'shelld', 'code': code, + 'msg_cn': msg_cn, 'detail': detail or ''} + line = json.dumps(rec, ensure_ascii=False) + for fn in ('all.jsonl', 'shelld.jsonl'): + with open(os.path.join(LOG_DIR, fn), 'a', encoding='utf-8') as f: + f.write(line + '\n') + except Exception: + pass + + +class Session(object): + """一个 PTY 会话:master fd + 子进程 + 输出缓冲 + 读线程。""" + + def __init__(self, sid, user, shell, cols, rows, cwd): + self.sid = sid + self.user = user + self.created = time.time() + self.last = time.time() + self.alive = True + self.buf = bytearray() + self.buf_lock = threading.Lock() + + self.master, slave = pty.openpty() + _set_winsize(self.master, rows, cols) + + env = dict(os.environ) + env['TERM'] = 'xterm-256color' + env['COLUMNS'] = str(cols) + env['LINES'] = str(rows) + # 关掉 bash 的“多行命令用临时文件编辑”提示,避免 readline 在 + # 非标准终端下把界面搞乱 + env['BASH_SILENCE_DEPRECATION_WARNING'] = '1' + + # start_new_session=True 让子进程成为会话首进程; + # 打开 slave 端时内核会把这个 pty 设为它的控制终端, + # 这样 Ctrl+C 才会真的向前台进程组发 SIGINT。 + self.proc = subprocess.Popen( + [shell, '-i'], stdin=slave, stdout=slave, stderr=slave, + start_new_session=True, cwd=cwd or '/root', env=env, + preexec_fn=None, close_fds=True) + os.close(slave) + + self.th = threading.Thread(target=self._reader, daemon=True) + self.th.start() + + def _reader(self): + """把 PTY 输出搬进缓冲区。子进程退出后 EIO 会跳出循环。""" + while True: + try: + r, _, _ = select.select([self.master], [], [], 0.5) + if not r: + if self.proc.poll() is not None: + break + continue + data = os.read(self.master, READ_CHUNK) + except OSError as e: + # EIO = 子进程退出后 slave 全关;这是正常结束,不是错误 + if e.errno in (errno.EIO,): + break + if e.errno in (errno.EAGAIN, errno.EWOULDBLOCK): + continue + break + except Exception: + break + if not data: + break + with self.buf_lock: + self.buf.extend(data) + self.alive = False + + def drain(self, limit=MAX_READ): + with self.buf_lock: + if not self.buf: + return b'' + raw = bytes(self.buf[:limit]) + # 结尾可能正好切在半个汉字上(UTF-8 一个汉字 3 字节,分帧时会被拆开)。 + # 直接解码会得到 U+FFFD(黑菱形问号),中文输出里出现一次就很扎眼。 + # 所以把结尾不完整的那段留在缓冲区里,等下一帧凑齐再发。 + safe, _tail = _utf8_tail(raw) + if not safe: + return b'' + del self.buf[:len(safe)] + return safe + + def write(self, data): + if not self.alive and self.proc.poll() is not None: + return False + try: + os.write(self.master, data) + return True + except OSError: + return False + + def resize(self, cols, rows): + if self.master is None: + return False + _set_winsize(self.master, rows, cols) + try: + os.kill(self.proc.pid, signal.SIGWINCH) + except Exception: + pass + return True + + def close(self): + try: + if self.proc.poll() is None: + # 先给子进程组发 SIGHUP,让它正常退出(bash 会写 history) + try: + os.killpg(os.getpgid(self.proc.pid), signal.SIGHUP) + except Exception: + self.proc.terminate() + for _ in range(30): + if self.proc.poll() is not None: + break + time.sleep(0.1) + if self.proc.poll() is None: + self.proc.kill() + except Exception: + pass + try: + os.close(self.master) + except Exception: + pass + self.alive = False + + +def _utf8_tail(data): + """把 data 拆成(可安全解码的部分, 结尾处不完整的 UTF-8 序列)。 + + 从末尾往前找起始字节,判断它声明的长度够不够;不够就说明这一帧被切开了。 + """ + n = len(data) + for k in range(1, min(4, n) + 1): + i = n - k + b = data[i] + if b < 0x80: + break # 结尾是 ASCII,整段可解 + if 0xC0 <= b <= 0xDF: + need = 2 + elif 0xE0 <= b <= 0xEF: + need = 3 + elif 0xF0 <= b <= 0xF7: + need = 4 + else: + continue # 续字节,继续往前找起始字节 + if k < need: + return data[:i], data[i:] # 起始字节在,后面不完整 + return data, b'' # 序列完整,整段可解 + return data, b'' + + +def _set_winsize(fd, rows, cols): + try: + fcntl.ioctl(fd, termios.TIOCSWINSZ, + struct.pack('HHHH', rows or 24, cols or 80, 0, 0)) + except Exception: + pass + + +def _reap_idle(): + """回收超时会话;顺带清掉已退出的僵尸子进程。""" + now = time.time() + with _lock: + for sid, s in list(_SESSIONS.items()): + if now - s.last > IDLE_TIMEOUT: + log('info', 'SHELL_IDLE', '终端会话闲置超时,已自动回收:%s' % sid) + s.close() + del _SESSIONS[sid] + elif s.proc.poll() is not None and not s.alive: + # 用户敲了 exit:保留一小段时间让前端把最后的输出读完 + if now - s.last > 60: + s.close() + del _SESSIONS[sid] + + +def _pick_shell(user): + """挑一个存在的登录 shell,缺 bash 时退回 sh。""" + for s in ('/bin/bash', '/bin/sh'): + if os.path.isfile(s): + return s + return '/bin/sh' + + +def handle(req): + op = str(req.get('op') or '') + sid = req.get('sid') or '' + + if op == 'connect': + with _lock: + if len(_SESSIONS) >= MAX_SESSIONS: + _reap_idle() + if len(_SESSIONS) >= MAX_SESSIONS: + return {'ok': False, 'code': 'BUSY', + 'msg_cn': '终端会话已达上限(%d),请先断开不用的会话' % MAX_SESSIONS} + user = str(req.get('user') or 'root') + shell = _pick_shell(user) + cols = int(req.get('cols') or 100) + rows = int(req.get('rows') or 30) + cols = max(20, min(cols, 400)) + rows = max(5, min(rows, 200)) + sid = 'sh%d-%d' % (int(time.time()), next(_seq)) + try: + s = Session(sid, user, shell, cols, rows, req.get('cwd') or '/root') + except Exception as e: + return {'ok': False, 'code': 'PTY_FAIL', + 'msg_cn': '创建终端失败:%s' % e} + with _lock: + _SESSIONS[sid] = s + log('warn', 'SHELL_OPEN', 'Web 终端会话已建立(用户 %s)' % user, + {'sid': sid, 'shell': shell, 'cols': cols, 'rows': rows}) + return {'ok': True, 'msg_cn': '终端已连接', + 'data': {'sid': sid, 'shell': shell, 'cols': cols, 'rows': rows}} + + if op == 'read': + with _lock: + s = _SESSIONS.get(sid) + if not s: + return {'ok': False, 'code': 'NOSESS', 'msg_cn': '会话不存在或已过期'} + s.last = time.time() + data = s.drain() + # 长轮询:前端可以带 wait(秒),没数据时在这里等到有数据或超时再返回。 + # 以前前端是固定 60ms 空转轮询,一半的请求都空手而归,而且输出最快也要 + # 等下一个轮询点才看得到;长轮询让「命令一有输出」立刻就有响应。 + wait = 0.0 + try: + wait = float(req.get('wait') or 0) + except Exception: + wait = 0.0 + wait = max(0.0, min(wait, READ_HOLD_MAX)) + if not data and wait > 0: + deadline = time.time() + wait + while time.time() < deadline: + time.sleep(READ_HOLD_STEP) + data = s.drain() + if data: + break + if s.proc.poll() is not None and not s.alive: + break + exited = s.proc.poll() + # latin-1 逐字节还原,保证 ANSI 控制序列不被 UTF-8 解码破坏; + # 真正显示成文字由前端按 UTF-8 再解码(多字节字符会跨帧,前端用拼接处理) + return {'ok': True, 'data': { + 'data': data.decode('utf-8', errors='replace'), + 'alive': s.alive or exited is None, + 'exit': exited}} + + if op == 'write': + with _lock: + s = _SESSIONS.get(sid) + if not s: + return {'ok': False, 'code': 'NOSESS', 'msg_cn': '会话不存在或已过期'} + s.last = time.time() + raw = req.get('data') or '' + if isinstance(raw, str): + raw = raw.encode('utf-8', errors='replace') + okw = s.write(raw) + return {'ok': okw, 'msg_cn': '' if okw else '写入失败(终端可能已退出)'} + + if op == 'resize': + with _lock: + s = _SESSIONS.get(sid) + if not s: + return {'ok': False, 'code': 'NOSESS', 'msg_cn': '会话不存在或已过期'} + s.last = time.time() + cols = max(20, min(int(req.get('cols') or 100), 400)) + rows = max(5, min(int(req.get('rows') or 30), 200)) + s.resize(cols, rows) + return {'ok': True} + + if op == 'disconnect': + with _lock: + s = _SESSIONS.pop(sid, None) + if s: + s.close() + log('info', 'SHELL_CLOSE', 'Web 终端会话已关闭', {'sid': sid}) + return {'ok': True, 'msg_cn': '已断开'} + + if op == 'sessions': + with _lock: + return {'ok': True, 'data': [ + {'sid': k, 'age': int(time.time() - v.created), + 'idle': int(time.time() - v.last), + 'alive': v.alive} + for k, v in _SESSIONS.items()]} + + return {'ok': False, 'code': 'BAD_OP', 'msg_cn': '未知操作:%s' % op} + + +class Handler(socketserver.StreamRequestHandler): + timeout = 30 + + def handle(self): + try: + line = self.rfile.readline() + if not line: + return + req = json.loads(line.decode('utf-8')) + except Exception: + return + try: + resp = handle(req) + except Exception as e: + resp = {'ok': False, 'code': 'ERR', 'msg_cn': '终端守护内部错误:%s' % e} + try: + self.wfile.write((json.dumps(resp, ensure_ascii=False) + '\n') + .encode('utf-8')) + self.wfile.flush() + except Exception: + pass + + +class Server(socketserver.ThreadingUnixStreamServer): + daemon_threads = True + allow_reuse_address = True + + +def main(): + if os.path.exists(SOCK_PATH): + os.unlink(SOCK_PATH) + d = os.path.dirname(SOCK_PATH) + os.makedirs(d, exist_ok=True) + # 目录必须 0755:/run/drouter 是 shelld 与 helpd 共用的, + # drouter-web(以 drouter 用户运行)要能进这个目录去连 helper.sock。 + # 之前靠 systemd 的 RuntimeDirectory 建目录,结果 helpd 一重启 systemd 就把 + # 整个目录重建,把这里的 shell.sock 连带删掉 —— shelld 显示 active, + # socket 却没了,终端直接报「守护未运行」。所以改成守护自己建。 + try: + os.chmod(d, 0o755) + except Exception: + pass + os.umask(0o177) # 保证 socket 创建出来就是 0600 + srv = Server(SOCK_PATH, Handler) + os.chmod(SOCK_PATH, 0o600) + log('info', 'SHELLD_UP', 'Web 终端守护已启动:%s' % SOCK_PATH) + try: + srv.serve_forever(poll_interval=0.5) + except KeyboardInterrupt: + pass + finally: + with _lock: + for s in _SESSIONS.values(): + s.close() + _SESSIONS.clear() + try: + os.unlink(SOCK_PATH) + except OSError: + pass + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/backend/drouter-snapshotd.py b/backend/drouter-snapshotd.py new file mode 100644 index 0000000..38797e7 --- /dev/null +++ b/backend/drouter-snapshotd.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +drouter 自动快照守护脚本(由 drouter-snapshot.timer 定时调用) + +职责: + 1. 按当前策略创建一份自动快照 + 2. 按「过期时间 / 最大份数」清理旧自动快照 + 3. 全程写结构化日志 + +策略来源优先级: + 环境变量 > /etc/drouter/snapshot.conf > 默认值 +(真正的策略由 Web 界面写入 snapshot.conf,本脚本只读) +""" + +import json +import os +import subprocess +import sys +from datetime import datetime + +# ---------------------------------------------------------------- PATH 归一化 +# Debian 上非 root / 非登录 shell 的 PATH 里没有 /usr/sbin 和 /sbin,而 nft、 +# dnsmasq、radvd、chronyd 等路由器关键命令全都装在 /usr/sbin 下。 +# systemd 服务的默认 PATH 是 /usr/local/bin:/usr/bin:/bin,于是所有 sh(['nft', ...]) +# 都会以「未找到命令」静默失败 —— 表现是防火墙页面空白、日志为空。这里补上。 +_SBIN_DIRS = ('/usr/local/sbin', '/usr/sbin', '/sbin') + + +def _ensure_sbin_path(): + cur = os.environ.get('PATH') or '' + parts = [p for p in cur.split(os.pathsep) if p] + for d in _SBIN_DIRS: + if d not in parts and os.path.isdir(d): + parts.insert(0, d) + os.environ['PATH'] = os.pathsep.join(parts) + + +_ensure_sbin_path() + +HELPER = '/opt/drouter/backend/drouter-helper.py' +LOG_DIR = '/var/log/drouter' + + +def log(level, code, msg_cn, detail=None): + try: + os.makedirs(LOG_DIR, exist_ok=True) + rec = {'ts': datetime.now().isoformat(timespec='seconds'), 'level': level, + 'module': 'snapshot', 'code': code, 'msg_cn': msg_cn, 'detail': detail or ''} + line = json.dumps(rec, ensure_ascii=False) + for fn in ('all.jsonl', 'snapshot.jsonl'): + with open(os.path.join(LOG_DIR, fn), 'a', encoding='utf-8') as f: + f.write(line + '\n') + except Exception: + pass + + +def call(action, payload=None, timeout=300): + try: + p = subprocess.run( + ['/usr/bin/python3', HELPER, action, json.dumps(payload or {}, ensure_ascii=False)], + capture_output=True, text=True, timeout=timeout, errors='replace') + return json.loads(p.stdout or '{}') + except Exception as e: + return {'ok': False, 'msg_cn': str(e)} + + +def load_conf(): + conf = {'enabled': True, 'path': '/opt/drouter/snapshots', 'interval_hours': 6, + 'keep_days': 7, 'keep_count': 30, 'keep_manual': True, 'on_apply': True} + try: + with open('/etc/drouter/snapshot.conf', encoding='utf-8') as f: + conf.update(json.load(f) or {}) + except Exception: + pass + return conf + + +def main(): + conf = load_conf() + if not conf.get('enabled', True): + log('info', 'SNAPSHOT_SKIP', '自动快照已关闭,跳过本次执行') + return 0 + + # 1) 拍一张自动快照 + r = call('snapshot', {'tag': 'auto'}) + if not r.get('ok'): + log('error', 'SNAPSHOT_AUTO_FAIL', '自动快照创建失败:%s' % r.get('msg_cn')) + return 1 + ts = (r.get('data') or {}).get('ts') or '' + log('info', 'SNAPSHOT_AUTO_OK', '自动快照已创建:%s' % ts, + {'path': (r.get('data') or {}).get('path')}) + + # 2) 清理过期 / 超量 + pr = call('snapshot_prune', { + 'keep_days': conf.get('keep_days', 0), + 'keep_count': conf.get('keep_count', 0), + 'keep_manual': conf.get('keep_manual', True), + }) + if pr.get('ok'): + removed = (pr.get('data') or {}).get('removed') or [] + if removed: + log('info', 'SNAPSHOT_AUTO_PRUNE', + '自动清理 %d 份过期快照' % len(removed), removed) + else: + log('warn', 'SNAPSHOT_AUTO_PRUNE_FAIL', '过期清理失败:%s' % pr.get('msg_cn')) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/backend/drouter-web.py b/backend/drouter-web.py new file mode 100644 index 0000000..23e290c --- /dev/null +++ b/backend/drouter-web.py @@ -0,0 +1,2229 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +drouter-web —— 轻量路由管理后端 + +设计目标(2 核 / 3.9G 内存环境): + * 仅使用 Python 3 标准库,零第三方依赖,零 pip + * 常驻内存目标 < 30 MB + * 单进程多线程(ThreadingHTTPServer),无框架 + * 内置 HTTPS(自签证书自动生成) + * 所有 root 操作经由 sudo -n drouter-helper 白名单脚本完成 + * 配置存 SQLite;每次应用渲染出原生配置文件 + +启动:python3 /opt/drouter/backend/drouter-web.py +""" +import os +import sys +import re +import ssl +import json +import time +import sqlite3 +import hashlib +import hmac +import base64 +import secrets +import threading +import subprocess +import ipaddress +import mimetypes +import socket +from datetime import datetime, timedelta + +# ---------------------------------------------------------------- PATH 归一化 +# Debian 上非 root / 非登录 shell 的 PATH 里没有 /usr/sbin 和 /sbin,而 nft、 +# dnsmasq、radvd、chronyd 等路由器关键命令全都装在 /usr/sbin 下。 +# systemd 服务的默认 PATH 是 /usr/local/bin:/usr/bin:/bin,于是所有 sh(['nft', ...]) +# 都会以「未找到命令」静默失败 —— 表现是防火墙页面空白、日志为空。这里补上。 +_SBIN_DIRS = ('/usr/local/sbin', '/usr/sbin', '/sbin') + + +def _ensure_sbin_path(): + cur = os.environ.get('PATH') or '' + parts = [p for p in cur.split(os.pathsep) if p] + for d in _SBIN_DIRS: + if d not in parts and os.path.isdir(d): + parts.insert(0, d) + os.environ['PATH'] = os.pathsep.join(parts) + + +_ensure_sbin_path() +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +BASE = '/opt/drouter' +DATA_DIR = os.path.join(BASE, 'data') +WEB_DIR = os.path.join(BASE, 'web') +CERT_DIR = os.path.join(BASE, 'certs') +DB_PATH = os.path.join(DATA_DIR, 'drouter.db') +HELPER = '/opt/drouter/backend/drouter-helper.py' +LOG_DIR = '/var/log/drouter' +BUILD_FLAG = '/etc/drouter/BUILD_MODE' + + +def in_build_mode(): + """构建保护模式:存在该标志文件时,禁止一切抢端口/改网络的动作。""" + return os.path.exists(BUILD_FLAG) + + +HOST = '0.0.0.0' +PORT_HTTP = 8080 +PORT_HTTPS = 8443 # 默认值,允许被 /etc/drouter/web-port 覆盖 +SESSION_MINUTES = 60 +MAX_LOGIN_FAILS = 5 +LOGIN_LOCK_MINUTES = 10 + +WEB_PORT_FILE = '/etc/drouter/web-port' +DEFAULT_WEB_PORT = 8443 + + +def load_web_port(): + """读取用户自定义的 Web HTTPS 端口(默认 8443)。 + 支持 /etc/drouter/web-port 文件(单行数字)或环境变量 DROUTER_WEB_PORT。 + 非法值一律回退到默认端口,避免把 Web 服务改到打不开的端口上。 + """ + cand = os.environ.get('DROUTER_WEB_PORT', '').strip() + if not cand and os.path.isfile(WEB_PORT_FILE): + try: + with open(WEB_PORT_FILE, encoding='utf-8', errors='replace') as f: + cand = f.read().strip().splitlines()[0].strip() + except Exception: + cand = '' + try: + n = int(cand) + if 1 <= n <= 65535: + return n + except Exception: + pass + return DEFAULT_WEB_PORT + + +def save_web_port(port): + """把端口写入 /etc/drouter/web-port(需要 root,由 helper 执行)。""" + os.makedirs(os.path.dirname(WEB_PORT_FILE), exist_ok=True) + with open(WEB_PORT_FILE, 'w', encoding='utf-8') as f: + f.write('%d\n' % int(port)) + return True + +# ------------------------------------------------------------------ 数据库 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, value TEXT +); +CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + pw_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TEXT +); +CREATE TABLE IF NOT EXISTS ifaces ( + mac TEXT PRIMARY KEY, + name TEXT, + remark TEXT DEFAULT '', + role TEXT DEFAULT '', + updated_at TEXT +); +CREATE TABLE IF NOT EXISTS audits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT, user TEXT, action TEXT, detail TEXT, ok INTEGER, ip TEXT +); +CREATE TABLE IF NOT EXISTS snapshots ( + ts TEXT PRIMARY KEY, tag TEXT, note TEXT, created_at TEXT +); +""" + +_lock = threading.RLock() + +# NAT 检测最近一次结果的进程内缓存(重启即失效,前端用于展示"上次检测") +NAT_LAST = {'nat': '', 'title': '', 'desc': '', 'detail': '', 'checked_at': ''} + +# Web 终端会话表:sid → {user, created} +SHELL_SESSIONS = {} + + +def db(): + conn = sqlite3.connect(DB_PATH, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute('PRAGMA journal_mode=WAL') + return conn + + +def init_db(): + os.makedirs(DATA_DIR, exist_ok=True) + os.makedirs(LOG_DIR, exist_ok=True) + with _lock, db() as c: + c.executescript(SCHEMA) + cur = c.execute('SELECT COUNT(*) n FROM admins').fetchone() + if cur['n'] == 0: + pw = 'admin123' # 首次登录后应立即修改 + salt = secrets.token_hex(16) + c.execute('INSERT INTO admins(username,pw_hash,salt,created_at) VALUES(?,?,?,?)', + ('admin', hash_pw(pw, salt), salt, now())) + cfg_defaults = { + 'dnsmasq': { + 'domain': 'lan', 'dhcp_enabled': False, 'pool_start': '192.168.7.100', + 'pool_end': '192.168.7.200', 'pool_netmask': '255.255.255.0', 'lease_time': 7200, + 'option_gateway': '192.168.7.3', 'option_dns': '192.168.7.3', + 'options': [ + {'enabled': False, 'code': 6, 'value': '223.5.5.5', 'force': False, + 'remark': 'DNS 服务器'}, + {'enabled': False, 'code': 3, 'value': '192.168.7.3', 'force': False, + 'remark': '默认网关'}, + ], + 'static_leases': [], + 'dns_mode': 'custom', 'dns_custom': '223.5.5.5,119.29.29.29', + 'dns_lan_server': '', 'dns_cache_size': 1000, 'dns_negcache': True, + 'dns_query_log': False, 'log_file': '/var/log/drouter/dnsmasq.log', + }, + 'nft_v4': {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'], 'mss_clamp': True, 'mss': 1452, + 'mss_mode': 'clamp', 'masquerade': True, 'raw': ''}, + 'nft_v6': {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'], 'mss_clamp': False, 'mss': 1432, + 'mss_mode': 'clamp', 'masquerade': False, 'raw': ''}, + 'radvd': {'iface': 'ens18', 'prefix': '', 'managed': False, 'other_config': False, + 'max_interval': 600, 'min_interval': 200, 'lifetime': 1800, 'hop_limit': 64, + 'reachable': 0, 'retrans': 0, 'valid_life': 86400, 'pref_life': 14400, + 'rdnss': '', 'rdnss_life': 600, 'dnssl': ''}, + 'dhcpv6': {'wan_iface': 'ppp0', 'lan_iface': 'ens18', 'request_pd': True, + 'request_address': False, 'iaid': 0, 'sla_id': '::1', 'prefix_len': 64, 'slaac': False}, + 'upnp': {'enable': False, 'ext_iface': 'ppp0', 'listen_ip': '192.168.7.3', + 'port': 5000, 'natpmp': False, 'secure_mode': True, 'igd_v1': False}, + 'ntp': {'servers': 'ntp.aliyun.com,time.cloudflare.com', 'allow_lan': False, + 'allow_net': '', 'use_timesyncd': False}, + 'system': { + 'lan_iface': 'ens18', 'lan_address': '192.168.7.3/24', 'lan_mtu': 1500, + 'gateway': '192.168.7.2', 'manage_scope': 'lan_only', + 'wan_iface': '', 'wan_present': False, + }, + 'pppoe': {'iface': '', 'username': '', 'password': '', 'isp': 'auto', + 'service_name': '', 'mtu': 1492, 'mru': 1492, 'persist': True, + 'maxfail': 0, 'holdoff': 5, 'saved_only': True, + 'access_type': 'pppoe', + 'note': '仅保存凭据,未执行拨号'}, + 'ddns': {'enabled': False, 'provider': 'custom', 'domain': '', 'subdomain': '', + 'ipv4': True, 'ipv6': False, 'ttl': 300, 'interval': 300, + 'url4': '', 'url6': '', 'fields': {}, + 'last_update': '', 'last_ip4': '', 'last_ip6': '', + 'last_result': '', 'last_msg_cn': ''}, + 'portfwd': {'enable_v4': False, 'enable_v6': False, 'rules': [], + 'dmz_v4_enable': False, 'dmz_v4_host': '', + 'dmz_v6_enable': False, 'dmz_v6_host': '', + 'note': '端口转发与 DMZ,规则将渲染为 nftables DNAT'}, + 'acl': {'enable': False, 'time_groups': [], 'groups': [], 'rules': [], + 'note': '访问控制与家长时间组,规则渲染为 nftables(shared/DPI 分类)'}, + 'share': {'enable_smb': False, 'enable_nfs': False, + 'samba': {'workgroup': 'WORKGROUP', 'server_string': 'drouter 文件共享', + 'disable_netbios': True, 'wins_enable': False, + 'wins_support': False, 'interfaces': '', 'global_extra': '', + 'shares': []}, + 'nfs': {'threads': 8, 'exports': []}, + 'note': '内网文件共享(SMB / NFS)'}, + 'docker': {'stacks': [], + 'note': 'Docker 与 Docker Compose 管理面板'}, + 'ulog': {'enabled': True, 'keep_days': 7, 'keep_rows': 200000, + 'archive': True, 'min_level': 'debug', + 'note': '连接跟踪与统一日志系统(保留策略与源开关)'}, + 'theme': {'active': 'default', + 'note': '主题之家:当前生效主题 id(实际样式由 helper 写入 theme.css)'}, + } + with _lock, db() as c: + for k, v in cfg_defaults.items(): + row = c.execute('SELECT value FROM settings WHERE key=?', (k,)).fetchone() + if not row: + c.execute('INSERT INTO settings(key,value) VALUES(?,?)', + (k, json.dumps(v, ensure_ascii=False))) + + +def now(): + return datetime.now().isoformat(timespec='seconds') + + +def get_cfg(key, default=None): + with _lock, db() as c: + row = c.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone() + if not row: + return default + try: + return json.loads(row['value']) + except Exception: + return default + + +def set_cfg(key, value): + with _lock, db() as c: + c.execute('INSERT INTO settings(key,value) VALUES(?,?) ' + 'ON CONFLICT(key) DO UPDATE SET value=excluded.value', + (key, json.dumps(value, ensure_ascii=False))) + + +def merge_cfg(key, patch): + cur = get_cfg(key, {}) or {} + if isinstance(cur, dict) and isinstance(patch, dict): + cur.update(patch) + else: + cur = patch + set_cfg(key, cur) + return cur + + +def hash_pw(pw, salt): + return hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), salt.encode('utf-8'), + 180000).hex() + + +def audit(user, action, detail, ok=True, ip=''): + with _lock, db() as c: + c.execute('INSERT INTO audits(ts,user,action,detail,ok,ip) VALUES(?,?,?,?,?,?)', + (now(), user, action, str(detail)[:2000], 1 if ok else 0, ip)) + + +def read_audits(limit=300): + with _lock, db() as c: + rows = c.execute('SELECT * FROM audits ORDER BY id DESC LIMIT ?', (limit,)).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ helper 调用 + +# ---- 常驻执行守护(#7 性能优化)---- +# +# 原来的每个请求都要 spawn 一次 `sudo python3 drouter-helper.py`,实测固定开销 +# 约 236ms(Python 启动 + sudo + 重新 import 上万行代码),而动作本身往往只要 +# 几十毫秒。drouter-helpd 常驻后,请求直接走 Unix socket,省掉这部分。 +# +# 降级策略:socket 连不上就回退到原来的 subprocess 方式。守护挂掉只会变慢, +# 不会让面板不可用。为此「守护不可用」这个判断会缓存一小段时间, +# 否则守护没起时每个请求都要白等一次连接超时。 +HELPD_SOCK = '/run/drouter/helper.sock' +# JSON 响应达到多大才值得压缩。太小的话压缩省下的字节还不如 CPU 开销。 +GZIP_MIN = 4096 + +# ---- 静态资源压缩缓存(#7)---- +# app.js 有 437KB,压缩后约 90KB。但每次请求都重压一遍纯属浪费 CPU, +# 所以按 (文件路径, 修改时间, 大小) 做键把压缩结果缓在内存里; +# 文件一变(部署新版本)键就变了,旧条目自动失效。 +_gz_lock = threading.Lock() +_gz_cache = {} +_GZ_MAX = 16 # 资源种类就那么几个,缓存上限给足余量即可 + + +def _want_gzip(headers): + try: + return 'gzip' in (headers.get('Accept-Encoding') or '').lower() + except Exception: + return False + + +def _gzip_cached(path, data): + """返回 (body, encoding)。data 已经是最终字节内容时传 path=None。""" + try: + st = os.stat(path) + key = (path, int(st.st_mtime), st.st_size) + except Exception: + # 内容不在磁盘上(如入口页注入版本号后的 HTML),按内容哈希缓存 + key = (None, hash(data), len(data)) + with _gz_lock: + hit = _gz_cache.get(key) + if hit is not None: + return hit, 'gzip' + try: + import gzip + z = gzip.compress(data, 6) + except Exception: + return data, None + if len(z) >= len(data): + return data, None + with _gz_lock: + if len(_gz_cache) >= _GZ_MAX: + _gz_cache.clear() + _gz_cache[key] = z + return z, 'gzip' +_helpd_lock = threading.Lock() +_helpd_down_until = [0.0] # 在这个时间戳之前,不再尝试连 socket +_helpd_down_logged = [False] + + +def _helpd_call(action, payload, timeout): + """通过 Unix socket 让常驻守护执行一个动作。返回 dict;不可用返回 None。""" + now_ts = time.time() + with _helpd_lock: + if now_ts < _helpd_down_until[0]: + return None + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(min(timeout, 900)) + except Exception: + return None + try: + try: + s.connect(HELPD_SOCK) + except Exception: + # 守护没在跑:标记一小段冷却期,避免后续请求逐个白等 + with _helpd_lock: + _helpd_down_until[0] = time.time() + 30 + if not _helpd_down_logged[0]: + _helpd_down_logged[0] = True + return None + req = (json.dumps({'action': action, 'payload': payload or {}}, + ensure_ascii=False) + '\n').encode('utf-8') + s.sendall(req) + buf = b'' + while not buf.endswith(b'\n'): + chunk = s.recv(65536) + if not chunk: + break + buf += chunk + if len(buf) > 32 * 1024 * 1024: + break + line = buf.decode('utf-8', 'replace').strip() + if not line: + return None + return json.loads(line) + except Exception: + return None + finally: + try: + s.close() + except Exception: + pass + + +def helper(action, payload=None, timeout=120): + """调用 root 特权白名单脚本,返回 dict""" + # 1) 先试常驻守护(快路径) + r = _helpd_call(action, payload, timeout) + if r is not None: + return r + # 2) 回退:spawn 一次 helper 进程(慢但一定能用) + cmd = ['sudo', '-n', '/usr/bin/python3', HELPER, action, + json.dumps(payload or {}, ensure_ascii=False)] + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, errors='replace') + except subprocess.TimeoutExpired: + return {'ok': False, 'code': 'TIMEOUT', 'msg_cn': '操作超时(%ss)' % timeout} + except FileNotFoundError: + return {'ok': False, 'code': 'NO_HELPER', 'msg_cn': '找不到特权执行器'} + out = (p.stdout or '').strip() + line = out.splitlines()[-1] if out else '' + try: + return json.loads(line) + except Exception: + return {'ok': False, 'code': 'BAD_OUTPUT', + 'msg_cn': '特权执行器返回异常', 'data': {'stdout': out[-800:], 'stderr': (p.stderr or '')[-800:]}, + 'rc': p.returncode} + + +# ------------------------------------------------------------------ 证书 + +def ensure_cert(): + os.makedirs(CERT_DIR, exist_ok=True) + crt = os.path.join(CERT_DIR, 'server.crt') + key = os.path.join(CERT_DIR, 'server.key') + if os.path.isfile(crt) and os.path.isfile(key): + return crt, key + ips = set() + try: + rc = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=5) + for x in (rc.stdout or '').split(): + try: + ipaddress.ip_address(x) + ips.add(x) + except Exception: + pass + except Exception: + pass + ips.update({'127.0.0.1', '192.168.7.3'}) + san = ','.join('IP:%s' % i for i in sorted(ips)) + ',DNS:localhost' + config = os.path.join(CERT_DIR, 'openssl.cnf') + with open(config, 'w') as f: + f.write('[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n' + '[dn]\nCN=drouter.local\nO=drouter\n' + '[v3]\nsubjectAltName=%s\nbasicConstraints=CA:FALSE\n' % san) + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', crt, '-days', '3650', + '-config', config], capture_output=True, timeout=60) + try: + os.chmod(key, 0o600) + except Exception: + pass + return crt, key + + +# ------------------------------------------------------------------ 会话 + +SESSIONS = {} + + +def new_session(user, ip): + tok = secrets.token_urlsafe(32) + SESSIONS[tok] = {'user': user, 'ip': ip, 'exp': time.time() + SESSION_MINUTES * 60, + 'csrf': secrets.token_urlsafe(24)} + return SESSIONS[tok] + + +def get_session(tok): + s = SESSIONS.get(tok) + if not s: + return None + if s['exp'] < time.time(): + SESSIONS.pop(tok, None) + return None + return s + + +def gc_sessions(): + while True: + time.sleep(300) + dead = [k for k, v in SESSIONS.items() if v['exp'] < time.time()] + for k in dead: + SESSIONS.pop(k, None) + + +LOGIN_FAILS = {} + + +# ------------------------------------------------------------------ 路由处理 + +class Api: + def __init__(self, handler): + self.h = handler + self.user = None + self.method = 'GET' + + # ---------- 工具 ---------- + def json(self, obj, status=200): + body = json.dumps(obj, ensure_ascii=False).encode('utf-8') + # 大响应 gzip 压缩(#7):统一日志单次能回 300KB,压缩后通常只剩 + # 十分之一。局域网里无所谓,但通过 cloudflared / 外网访问时这是数量级 + # 的差别。小响应不压——省下的字节还不如压缩本身的花销。 + enc = None + if len(body) >= GZIP_MIN and _want_gzip(self.h.headers): + body, enc = _gzip_cached(None, body) + self.h.send_response(status) + self.h.send_header('Content-Type', 'application/json; charset=utf-8') + if enc: + self.h.send_header('Content-Encoding', enc) + # 必须让中间代理按 Accept-Encoding 分别缓存,否则压缩版会被拿去 + # 喂给不支持 gzip 的客户端 + self.h.send_header('Vary', 'Accept-Encoding') + self.h.send_header('Content-Length', str(len(body))) + self.h.send_header('Cache-Control', 'no-store') + self.h.end_headers() + self.h.wfile.write(body) + + + def body(self): + try: + n = int(self.h.headers.get('Content-Length') or 0) + raw = self.h.rfile.read(n) if n else b'{}' + return json.loads(raw.decode('utf-8') or '{}') + except Exception: + return {} + + def auth(self, need_admin=False): + tok = self.h.headers.get('X-Token') or '' + s = get_session(tok) + if not s: + self.json({'ok': False, 'code': 'UNAUTH', 'msg_cn': '登录已过期,请重新登录'}, 401) + return None + self.user = s['user'] + return s + + def helper(self, action, payload=None, timeout=120): + return helper(action, payload, timeout) + + # ---------- 分发 ---------- + def dispatch(self, method, path, query): + p = path.rstrip('/') or '/' + self.method = method + + # ========== 无需登录 ========== + if p == '/api/login' and method == 'POST': + return self.login() + if p == '/api/health': + return self.json({'ok': True, 'msg_cn': '服务正常', 'ts': now()}) + + # ========== 需要登录 ========== + if p == '/api/logout' and method == 'POST': + if self.auth(): + SESSIONS.pop(self.h.headers.get('X-Token'), None) + return self.json({'ok': True, 'msg_cn': '已退出登录'}) + return + if p == '/api/me': + if self.auth(): + return self.json({'ok': True, 'data': {'user': self.user}}) + return + if p == '/api/password' and method == 'POST': + return self.change_password() + + # 只读观测 + readings = { + '/api/sysinfo': 'read:sysinfo', '/api/ifaces': 'read:ifaces', + '/api/routes': 'read:routes', '/api/services': 'read:services', + '/api/nft': 'read:nft', '/api/leases': 'read:leases', + '/api/ipv6': 'read:ipv6', '/api/ntp': 'read:ntp', + '/api/users': 'read:users', '/api/logs': 'read:logs', + '/api/journal': 'read:journal', + '/api/iface_method': 'read:iface_method', + '/api/ppp/log': 'read:ppp_log', + '/api/ppp_log': 'read:ppp_log', + '/api/metrics': 'read:metrics', + '/api/ddns': 'read:ddns', + '/api/acl': 'read:acl', + '/api/share': 'read:share', + '/api/docker': 'read:docker', + '/api/ulog': 'read:ulog', + '/api/ulog/flow': 'read:ulog_flow', + '/api/ulog/conf': 'read:ulog_conf', + '/api/ulog/daemon': 'read:logd', + '/api/theme': 'read:theme', + } + if p in readings and method in ('GET', 'POST'): + if not self.auth(): + return + payload = {} + if p == '/api/logs': + payload = {'module': (query.get('module') or ['all'])[0], + 'limit': (query.get('limit') or ['300'])[0]} + if p == '/api/journal': + payload = {'unit': (query.get('unit') or [''])[0], + 'limit': (query.get('limit') or ['200'])[0]} + if p == '/api/metrics': + payload = {'quick': (query.get('quick') or ['0'])[0] in ('1', 'true', 'yes')} + if p in ('/api/ulog', '/api/ulog/flow', '/api/ulog/conf'): + for k in ('since', 'limit', 'level', 'src', 'action', 'proto', + 'addr', 'port', 'q', 'sources'): + if query.get(k): + payload[k] = query[k][0] + res = self.helper(readings[p], payload) + return self.json(res) + + # ========== 连接跟踪与统一日志(#11) ========== + if p in ('/api/ulog', '/api/ulog/op', '/api/ulog/flow', + '/api/ulog/conf', '/api/flow') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + for k in ('since', 'limit', 'level', 'src', 'action', 'proto', + 'addr', 'port', 'q', 'sources'): + if k in query and k not in b: + b[k] = query[k][0] + op = (b.get('op') or '').strip() + if p in ('/api/ulog/flow', '/api/flow'): + return self.json(self.helper('read:ulog_flow', b, timeout=40)) + if p == '/api/ulog/conf': + return self.json(self.helper('read:ulog_conf', b, timeout=25)) + if not op: + op = 'flow' if b.get('view') == 'flow' else 'query' + to = 60 if op in ('export', 'prune', 'save_conf') else 45 + res = self.helper('ulog', dict(b, op=op), timeout=to) + # 保存日志设置后,同步重建采集定时器(按 enabled/archive 启停) + if op == 'save_conf' and res.get('ok'): + sync = self.helper('logd_sync', { + 'interval_sec': b.get('interval_sec') or 60}, timeout=45) + if sync.get('ok'): + res['msg_cn'] = '%s;%s' % (res.get('msg_cn', ''), + sync.get('msg_cn', '')) + return self.json(res) + + # ========== 主题之家(#12) ========== + # 只读:列表 / 单个主题 / 变量清单 + if p in ('/api/theme', '/api/themes') and method == 'GET': + if not self.auth(): + return + op = (query.get('op') or ['list'])[0] + payload = {'op': op, 'id': (query.get('id') or [''])[0]} + return self.json(self.helper('read:theme', payload, timeout=30)) + # 写操作:save / delete / apply / reset / export / import / preview + if p in ('/api/theme', '/api/theme/op') and method == 'POST': + if not self.auth(): + return + b = self.body() + op = str(b.get('op') or '').strip() + readonly = {'list', 'get', 'vars'} + if not op: + return self.json({'ok': False, 'code': 'NOOP', 'msg_cn': '缺少 op 参数'}) + if op in readonly: + return self.json(self.helper('read:theme', b, timeout=30)) + # 导入可能带较大 base64,给足超时 + to = 180 if op in ('import', 'import_apply') else 60 + res = self.helper('theme', b, timeout=to) + audit(self.user, '主题之家', '%s -> %s' % (op, res.get('msg_cn')), + res.get('ok', False), self.h.client_address[0]) + return self.json(res) + + # ========== WAN 接入方式统一日志(#4) ========== + if p in ('/api/wan/log', '/api/wanlog', '/api/wan/access') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + payload = { + 'access': b.get('access') or (query.get('access') or ['pppoe'])[0], + 'iface': b.get('iface') or (query.get('iface') or [''])[0], + 'limit': b.get('limit') or (query.get('limit') or ['120'])[0], + 'since': b.get('since') or (query.get('since') or ['2 hours ago'])[0], + } + return self.json(self.helper('read:wan_log', payload, timeout=45)) + + # ========== 配置渲染预览(只读,不写盘) ========== + if p in ('/api/render', '/api/preview') and method == 'POST': + if not self.auth(): + return + b = self.body() + module = (b.get('module') or '').strip() + if module not in ('dnsmasq', 'nft_v4', 'nft_v6', 'radvd', 'dhcpv6', + 'upnp', 'ntp', 'system', 'ppp', 'portfwd'): + return self.json({'ok': False, 'code': 'BAD_MODULE', + 'msg_cn': '不支持预览该模块:%s' % module}) + # portfwd 不是独立的渲染模块,它的规则并入 nft_v4/nft_v6 + if module == 'portfwd': + module = 'nft_v4' + cfg = get_cfg(module, {}) or {} + if isinstance(b.get('data'), dict): + cfg = dict(cfg, **b['data']) + syscfg = get_cfg('system', {}) or {} + if module in ('nft_v4', 'nft_v6'): + cfg['wan_iface'] = cfg.get('wan_iface') or syscfg.get('wan_iface') or 'ppp0' + cfg['lan_ifaces'] = cfg.get('lan_ifaces') or [syscfg.get('lan_iface') or 'ens18'] + pf = get_cfg('portfwd', {}) or {} + if module == 'nft_v4': + cfg['portfwd'] = {'enable': pf.get('enable_v4', False), + 'rules': [r for r in (pf.get('rules') or []) + if (r.get('family') or 'v4') != 'v6'], + 'dmz': {'enable': pf.get('dmz_v4_enable', False), + 'host': pf.get('dmz_v4_host') or ''}} + else: + cfg['portfwd'] = {'enable': pf.get('enable_v6', False), + 'rules': [r for r in (pf.get('rules') or []) + if (r.get('family') or 'v4') == 'v6'], + 'dmz': {'enable': pf.get('dmz_v6_enable', False), + 'host': pf.get('dmz_v6_host') or ''}} + try: + res = self.helper('render_text', {'module': module, 'cfg': cfg}, timeout=20) + return self.json(res) + except Exception as e: + return self.json({'ok': False, 'code': 'RENDER_FAIL', + 'msg_cn': '渲染失败:%s' % e}) + + # ========== 内网文件共享 SMB / NFS(#9) ========== + if p in ('/api/share', '/api/share/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'get').strip() + to = 60 if op in ('save', 'on', 'off') else 25 + return self.json(self.helper('share', dict(b, op=op), timeout=to)) + + # ========== 外置存储设备(USB / Type-C / 雷电) ========== + # 挂载用 40s、格式化给 900s:大容量硬盘格式化可能要好几分钟, + # 超时设短了会出现「后台其实还在跑,前端却报失败」的错位。 + if p in ('/api/storage', '/api/storage/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'get').strip() + to = 900 if op == 'format' else (40 if op in ('mount', 'umount') else 25) + return self.json(self.helper('storage', dict(b, op=op), timeout=to)) + + # ========== 访问控制 / 家长时间组(#8) ========== + if p in ('/api/acl', '/api/acl/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or ('get' if method == 'GET' else 'get')).strip() + if op == 'save': + return self.json(self.helper('acl', b, timeout=30)) + if op == 'plan': + return self.json(self.helper('acl', dict(b, op='plan'), timeout=30)) + to = 20 + return self.json(self.helper('acl', dict(b, op=op), timeout=to)) + + # ========== Docker / Docker Compose 管理(#10) ========== + if p in ('/api/docker', '/api/docker/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'get').strip() + # 这些操作耗时较长,放长超时 + slow = ('stack_up', 'stack_down', 'stack_restart', 'stack_pull', + 'prune', 'logs', 'inspect') + to = 620 if op in slow else (70 if op != 'get' else 60) + return self.json(self.helper('docker', dict(b, op=op), timeout=to)) + + # ========== 动态域名 DDNS(#6) ========== + if p in ('/api/ddns', '/api/ddns/op') and method == 'POST': + if not self.auth(): + return + b = self.body() + op = (b.get('op') or 'get').strip() + if op == 'save': + # 校验服务商与必填字段 + prov = b.get('provider') or 'custom' + cfg = {'enabled': bool(b.get('enabled')), 'provider': prov, + 'domain': (b.get('domain') or '').strip(), + 'subdomain': (b.get('subdomain') or '').strip(), + 'ipv4': b.get('ipv4', True), 'ipv6': b.get('ipv6', False), + 'ttl': b.get('ttl') or 300, 'interval': b.get('interval') or 300, + 'url4': b.get('url4') or '', 'url6': b.get('url6') or '', + 'fields': b.get('fields') or {}} + return self.json(self.helper('ddns', dict(cfg, op='save'), timeout=30)) + to = 60 if op in ('test', 'update') else 20 + return self.json(self.helper('ddns', b, timeout=to)) + + # ========== 真·公网 IP 判定(#2) ========== + # check 要并发打 5 个外部回显服务 + 一次 traceroute,给足超时; + # probe_* 只是启动 / 轮询状态,必须秒回,否则前端倒计时会卡。 + if p in ('/api/pubip', '/api/pubip/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'check').strip() + to = 90 if op == 'check' else 15 + return self.json(self.helper('pubip', b, timeout=to)) + + # ========== 磁盘与日志清理(#3) ========== + # run 要遍历 /var/log、/tmp 并执行 apt-get clean,扫盘慢,给足超时; + # status 只读 + 扫描,也要 30s(/var/log 递归)。 + if p in ('/api/cleanup', '/api/cleanup/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'status').strip() + to = 180 if op == 'run' else 60 + return self.json(self.helper('cleanup', b, timeout=to)) + + # ========== 内核转发与加速(#4) ========== + # save 会 modprobe + sysctl -w + systemctl 启停 snmpd,给足超时; + # status 只是读 /proc/sys 和几个 systemctl is-active。 + if p in ('/api/kern', '/api/kern/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'status').strip() + to = 90 if op == 'save' else 30 + return self.json(self.helper('kern', b, timeout=to)) + + # ========== Docker 引擎配置 daemon.json(#5) ========== + # mirror_test 要逐个 curl 七个源(每个最多 8 秒),restart 要等 docker 起来; + # 其余都是读文件或写文件,很快。 + if p in ('/api/dcfg', '/api/dcfg/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'status').strip() + to = 200 if op == 'mirror_test' else (190 if op == 'restart' else 40) + return self.json(self.helper('dcfg', b, timeout=to)) + + # ========== 打印服务 CUPS / USB RAW 直通(#7) ========== + # save 会 cupsctl + 校验 + 重启 cups / cups-browsed / RAW 服务,给足超时; + # testpage 要 lp 排队;其余都是读文件与 lpstat。 + if p in ('/api/print', '/api/print/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'status').strip() + to = 150 if op == 'save' else (100 if op in ('testpage', 'queue_add') else 40) + return self.json(self.helper('print', b, timeout=to)) + + # ========== AC / AP 管理中心 OpenSOHO(#10) ========== + # install 要下载并解压 13MB 压缩包、建用户、写单元、起服务,给足超时; + # probe 要挨个查 8 个集合,svc/save/secret 会 systemctl;status 只是读状态。 + if p in ('/api/opensoho', '/api/opensoho/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'status').strip() + to = 280 if op == 'install' else (90 if op in ('svc', 'save', 'secret') else 45) + return self.json(self.helper('opensoho', b, timeout=to)) + + # ========== CA 证书管理 + SSL 测试(#11) ========== + # deploy 要备份 + 落盘 + 重启 drouter-web + 握手验证(最多再等 20 秒), + # 失败还要回滚再启一次 —— 给它 180 秒;ssltest 是外网握手,给 45 秒。 + if p in ('/api/ca', '/api/ca/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'status').strip() + to = 180 if op in ('deploy', 'restore') else (60 if op == 'ssltest' else 45) + return self.json(self.helper('ca', b, timeout=to)) + + # ========== 新手向导 ========== + # probe 要 ping 外网 + dig 一个域名(各几秒);apply_step 最多一次提交 + # 四个模块的重启;dial 是真正的 pppd 拨号,要等它协商完。 + if p in ('/api/wizard', '/api/wizard/op') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or 'probe').strip() + to = 120 if op == 'dial' else (110 if op == 'apply_step' else 60) + return self.json(self.helper('wizard', b, timeout=to)) + + # ========== 防火墙滚动日志(#1) ========== + if p in ('/api/fw/log', '/api/fwlog') and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + payload = { + 'family': b.get('family') or (query.get('family') or ['all'])[0], + 'limit': b.get('limit') or (query.get('limit') or ['200'])[0], + 'action': b.get('action') or (query.get('action') or [''])[0], + 'q': b.get('q') or (query.get('q') or [''])[0], + 'since': b.get('since') or (query.get('since') or ['60 min ago'])[0], + } + return self.json(self.helper('read:fw_log', payload)) + + # 配置读写 + if p == '/api/config' and method == 'GET': + if not self.auth(): + return + # 必须与 save_config 的 allowed 集合保持一致,否则会出现 + # 「能保存但读不回来」的配置模块(theme 曾漏在这里)。 + keys = ['dnsmasq', 'nft_v4', 'nft_v6', 'radvd', 'dhcpv6', 'upnp', + 'ntp', 'system', 'pppoe', 'ddns', 'portfwd', 'acl', 'share', + 'docker', 'ulog', 'theme'] + out = {k: get_cfg(k, {}) for k in keys} + return self.json({'ok': True, 'data': out}) + if p == '/api/config' and method == 'POST': + return self.save_config() + + if p == '/api/iface/save' and method == 'POST': + return self.iface_save() + if p == '/api/iface/meta' and method == 'GET': + if not self.auth(): + return + with _lock, db() as c: + rows = c.execute('SELECT * FROM ifaces').fetchall() + return self.json({'ok': True, 'data': [dict(r) for r in rows]}) + + if p == '/api/buildmode' and method == 'POST': + return self.build_mode() + # 前端也会用 GET 读取构建保护模式状态 + if p == '/api/buildmode' and method == 'GET': + if not self.auth(): + return + bm = in_build_mode() + return self.json({'ok': True, 'data': {'enabled': bm, 'build_mode': bm}, + 'msg_cn': '构建保护模式已开启' if bm else '构建保护模式已关闭'}) + if p == '/api/apply' and method == 'POST': + return self.apply_module() + if p == '/api/apply/raw' and method == 'POST': + return self.apply_raw() + + if p == '/api/service' and method == 'POST': + return self.service_op() + if p == '/api/power' and method == 'POST': + return self.power_op() + if p == '/api/user' and method == 'POST': + return self.user_op() + if p == '/api/diag' and method == 'POST': + return self.diag() + if p == '/api/pkg' and method == 'POST': + return self.pkg() + if p == '/api/snapshot' and method == 'POST': + return self.snapshot() + if p in ('/api/snapshot/list', '/api/snapshots') and method == 'GET': + return self.snapshot_list() + if p in ('/api/snapshot/delete',) and method == 'POST': + return self.snapshot_delete() + if p in ('/api/snapshot/prune',) and method == 'POST': + return self.snapshot_prune() + # 自动快照策略(查询 / 保存 / 立即执行) + if p in ('/api/snapshot/auto',) and method in ('GET', 'POST'): + return self.auto_snapshot() + # 快照下载到本地电脑 + if p.startswith('/api/snapshot/download'): + return self.snapshot_download(query) + # 紧急救援通道(查询 / 配置) + if p in ('/api/rescue',) and method in ('GET', 'POST'): + return self.rescue() + if p == '/api/rollback' and method == 'POST': + return self.rollback() + if p in ('/api/audit', '/api/audits') and method == 'GET': + if not self.auth(): + return + return self.json({'ok': True, 'data': read_audits()}) + if p == '/api/migrate/preview' and method == 'POST': + return self.migrate_preview() + # Web 管理端口:读取 / 修改 + if p in ('/api/webport',) and method == 'GET': + if not self.auth(): + return + return self.json({'ok': True, 'data': {'port': load_web_port(), + 'default': DEFAULT_WEB_PORT}}) + if p in ('/api/webport',) and method == 'POST': + return self.set_web_port() + if p in ('/api/lease/static', '/api/lease/make_static') and method == 'POST': + return self.lease_make_static() + if p in ('/api/lease/release',) and method == 'POST': + return self.lease_release() + if p in ('/api/ppp', '/api/ppp/control') and method == 'POST': + return self.ppp_control() + if p == '/api/upgrade/info' and method == 'GET': + return self.upgrade_info() + # 只读:查询当前已保存的 PPPoE 配置(不返回明文密码) + if p == '/api/pppoe/saved' and method == 'GET': + if not self.auth(): + return + c_ = get_cfg('pppoe', {}) or {} + safe = {k: v for k, v in c_.items() if k != 'password'} + safe['has_password'] = bool(c_.get('password')) + return self.json({'ok': True, 'data': safe}) + + # ========== NAT 类型自动检测(#20) ========== + if p in ('/api/nat/check', '/api/natcheck') and method in ('GET', 'POST'): + if not self.auth(): + return + res = self.helper('nat_check', {}, timeout=40) + if res.get('ok') and res.get('data'): + try: + NAT_LAST.update({k: res['data'].get(k, '') for k in + ('nat', 'title', 'desc', 'detail', 'checked_at')}) + except Exception: + pass + return self.json(res) + # 历史结果缓存(前端画"上次检测"用) + if p == '/api/nat/last' and method == 'GET': + if not self.auth(): + return + # 必须读落盘的那份:NAT_LAST 只是本进程的内存变量,Web 服务一重启 + # (每次部署都会)就清空,页面上的「上次检测」会凭空消失,看起来像 + # 从来没检测过。helper 在每次检测后已经写进 settings 表了。 + saved = {} + try: + r = self.helper('read:nat_last', {}, timeout=20) + if r.get('ok') and isinstance(r.get('data'), dict): + saved = r['data'] + except Exception: + saved = {} + if saved.get('nat') and saved.get('checked_at'): + return self.json({'ok': True, 'data': saved}) + # 落盘为空时退回本进程内存(本次启动后刚检测过、还没走完落盘的情况) + return self.json({'ok': True, 'data': NAT_LAST}) + + # ========== nftables flowtable 软加速(#22,与 NAT 检测同页) ========== + if p in ('/api/accel',) and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + op = (b.get('op') or (query.get('op') or ['get'])[0]).strip() + payload = {'op': op} + if b.get('devices'): + payload['devices'] = b.get('devices') + if 'v4' in b: + payload['v4'] = bool(b.get('v4')) + if 'v6' in b: + payload['v6'] = bool(b.get('v6')) + return self.json(self.helper('accel', payload, timeout=40)) + + # ========== VLAN(#21) ========== + if p in ('/api/vlan',) and method in ('GET', 'POST'): + if not self.auth(): + return + if method == 'GET': + return self.json(self.helper('vlan', {'op': 'get'})) + b = self.body() + return self.json(self.helper('vlan', b, timeout=60)) + + # ========== WOL 唤醒(#21) ========== + if p in ('/api/wol',) and method in ('GET', 'POST'): + if not self.auth(): + return + if method == 'GET': + return self.json(self.helper('wol', {'op': 'get'})) + b = self.body() + return self.json(self.helper('wol', b, timeout=40)) + + # ========== PPPoE 多拨(#23) ========== + if p in ('/api/pppoe-multi', '/api/pppomulti') and method in ('GET', 'POST'): + if not self.auth(): + return + if method == 'GET': + return self.json(self.helper('pppoe_multi', {'op': 'get'})) + b = self.body() + # apply 会真正拨号并改路由,给足超时 + to = 180 if (b.get('op') == 'apply') else 60 + return self.json(self.helper('pppoe_multi', b, timeout=to)) + if p == '/api/pppoe-multi/log' and method in ('GET', 'POST'): + if not self.auth(): + return + b = self.body() if method == 'POST' else {} + idx = b.get('idx') or (query.get('idx') or ['1'])[0] + return self.json(self.helper('pppoe_multi', {'op': 'log', 'idx': idx}, timeout=40)) + + # ========== QoS 智能限速(#24) ========== + if p in ('/api/qos',) and method in ('GET', 'POST'): + if not self.auth(): + return + if method == 'GET': + return self.json(self.helper('qos', {'op': 'get'})) + b = self.body() + to = 120 if b.get('op') in ('on', 'off') else 60 + return self.json(self.helper('qos', b, timeout=to)) + + # ========== DPI 识别库(#25) ========== + if p in ('/api/dpi',) and method in ('GET', 'POST'): + if not self.auth(): + return + if method == 'GET': + return self.json(self.helper('dpi', {'op': 'get'})) + b = self.body() + # update 可能拉几百 MB,给足超时 + to = 900 if b.get('op') == 'update' else 120 + return self.json(self.helper('dpi', b, timeout=to)) + if p == '/api/dpi/log' and method == 'GET': + if not self.auth(): + return + return self.json(self.helper('dpi', {'op': 'log'}, timeout=30)) + + # ========== 运行依赖自检 + 一键安装(#19) ========== + if p in ('/api/deps/check', '/api/depcheck') and method in ('GET', 'POST'): + if not self.auth(): + return + return self.json(self.helper('depcheck', {'op': 'check'}, timeout=90)) + if p == '/api/deps/install' and method == 'POST': + if not self.auth(): + return + b = self.body() + return self.json(self.helper('depcheck', {'op': 'install', + 'only': b.get('only') or []}, + timeout=900)) + if p == '/api/deps/log' and method == 'GET': + if not self.auth(): + return + return self.json(self.helper('depcheck', {'op': 'log'})) + + # ========== 对外通用 API 说明 + 文本范例(#17) ========== + if p in ('/api/openapi', '/api/openapi.json') and method == 'GET': + return self.openapi_descriptor() + if p in ('/api/examples',) and method == 'GET': + return self.api_examples() + + # ========== Web 终端(#18) ========== + if p.startswith('/api/webshell'): + if not self.auth(): + return + return self.webshell_api(p) + + # ========== Web 远程文件管理(#18) ========== + if p.startswith('/api/files'): + if not self.auth(): + return + return self.files_api(p, query, method) + + return self.json({'ok': False, 'code': 'NOTFOUND', + 'msg_cn': '接口不存在:%s' % p}, 404) + + # ---------- 具体实现 ---------- + def login(self): + ip = self.h.client_address[0] + f = LOGIN_FAILS.get(ip) + if f and f['n'] >= MAX_LOGIN_FAILS and f['until'] > time.time(): + left = int((f['until'] - time.time()) / 60) + 1 + return self.json({'ok': False, 'code': 'LOCKED', + 'msg_cn': '登录失败次数过多,请 %d 分钟后再试' % left}, 429) + b = self.body() + u = (b.get('username') or '').strip() + pw = b.get('password') or '' + with _lock, db() as c: + row = c.execute('SELECT * FROM admins WHERE username=?', (u,)).fetchone() + if not row or hash_pw(pw, row['salt']) != row['pw_hash']: + rec = LOGIN_FAILS.setdefault(ip, {'n': 0, 'until': 0}) + rec['n'] += 1 + rec['until'] = time.time() + LOGIN_LOCK_MINUTES * 60 if rec['n'] >= MAX_LOGIN_FAILS else 0 + audit(u or '(空)', '登录失败', ip, False, ip) + left = MAX_LOGIN_FAILS - rec['n'] + return self.json({'ok': False, 'code': 'BADCRED', + 'msg_cn': '用户名或密码错误' + ('(还可尝试 %d 次)' % left if left > 0 else '')}, 401) + LOGIN_FAILS.pop(ip, None) + s = new_session(u, ip) + tok = [k for k, v in SESSIONS.items() if v is s][0] + audit(u, '登录成功', ip, True, ip) + return self.json({'ok': True, 'msg_cn': '登录成功', + 'data': {'token': tok, + 'csrf': s['csrf'], 'user': u, + 'expires_in': SESSION_MINUTES * 60}}) + + def change_password(self): + if not self.auth(): + return + b = self.body() + old = b.get('old_password') or '' + new = b.get('new_password') or '' + if len(new) < 8: + return self.json({'ok': False, 'code': 'WEAK', 'msg_cn': '新密码至少 8 位'}) + with _lock, db() as c: + row = c.execute('SELECT * FROM admins WHERE username=?', (self.user,)).fetchone() + if not row or hash_pw(old, row['salt']) != row['pw_hash']: + return self.json({'ok': False, 'code': 'BADOLD', 'msg_cn': '原密码不正确'}) + salt = secrets.token_hex(16) + c.execute('UPDATE admins SET pw_hash=?,salt=? WHERE username=?', + (hash_pw(new, salt), salt, self.user)) + audit(self.user, '修改Web密码', '成功') + return self.json({'ok': True, 'msg_cn': 'Web 管理密码已修改'}) + + def save_config(self): + if not self.auth(): + return + b = self.body() + module = b.get('module') + data = b.get('data') + allowed = {'dnsmasq', 'nft_v4', 'nft_v6', 'radvd', 'dhcpv6', 'upnp', 'ntp', 'system', 'pppoe', 'ddns', 'portfwd', 'acl', 'share', 'docker', 'ulog', 'theme'} + if module not in allowed: + return self.json({'ok': False, 'code': 'BADMODULE', 'msg_cn': '未知配置模块'}) + if not isinstance(data, dict): + return self.json({'ok': False, 'code': 'BADDATA', 'msg_cn': '配置格式不正确'}) + merge_cfg(module, data) + audit(self.user, '保存配置', module) + return self.json({'ok': True, 'msg_cn': '已保存(尚未应用)', 'data': get_cfg(module)}) + + def iface_save(self): + if not self.auth(): + return + b = self.body() + mac = (b.get('mac') or '').strip().lower() + if not re.match(r'^([0-9a-f]{2}:){5}[0-9a-f]{2}$', mac): + return self.json({'ok': False, 'code': 'BADMAC', 'msg_cn': 'MAC 地址格式不正确'}) + name = (b.get('name') or '').strip() + remark = (b.get('remark') or '').strip()[:128] + role = (b.get('role') or '').strip() + ALLOWED_ROLES = ('', 'wan', 'lan', 'bridge', 'guest', 'iot', 'dmz', 'mgmt', 'unused') + if role not in ALLOWED_ROLES: + return self.json({'ok': False, 'code': 'BADROLE', + 'msg_cn': '角色取值不合法,请从下拉列表中选择'}) + with _lock, db() as c: + c.execute('INSERT INTO ifaces(mac,name,remark,role,updated_at) VALUES(?,?,?,?,?) ' + 'ON CONFLICT(mac) DO UPDATE SET name=excluded.name, remark=excluded.remark, ' + 'role=excluded.role, updated_at=excluded.updated_at', + (mac, name, remark, role, now())) + # 角色联动到 system 配置 + syscfg = get_cfg('system', {}) or {} + if role == 'wan': + syscfg['wan_iface'] = name + syscfg['wan_present'] = True + elif role == 'lan': + syscfg['lan_iface'] = name + set_cfg('system', syscfg) + audit(self.user, '编辑网卡', '%s %s 备注=%s 角色=%s' % (mac, name, remark, role)) + return self.json({'ok': True, 'msg_cn': '网卡信息已保存'}) + + def apply_module(self): + if not self.auth(): + return + b = self.body() + module = b.get('module') + data = b.get('data') + live = bool(b.get('live')) # 默认 False:只写盘不生效 + check_only = bool(b.get('check_only')) + if isinstance(data, dict): + merge_cfg(module, data) + cfg = get_cfg(module, {}) + # 注入必要的上下文(LAN/WAN 接口名) + syscfg = get_cfg('system', {}) or {} + if module == 'dnsmasq': + cfg['lan_iface'] = cfg.get('lan_iface') or syscfg.get('lan_iface') or 'ens18' + elif module in ('nft_v4', 'nft_v6'): + cfg['wan_iface'] = cfg.get('wan_iface') or syscfg.get('wan_iface') or 'ppp0' + cfg['lan_ifaces'] = cfg.get('lan_ifaces') or [syscfg.get('lan_iface') or 'ens18'] + # 注入端口转发 / DMZ 配置(#7),由 render.py 生成 DNAT 与放通规则 + pf = get_cfg('portfwd', {}) or {} + if module == 'nft_v4': + cfg['portfwd'] = {'enable': pf.get('enable_v4', pf.get('enable', False)), + 'rules': [r for r in (pf.get('rules') or []) + if (r.get('family') or 'v4') != 'v6'], + 'dmz': {'enable': pf.get('dmz_v4_enable', False), + 'host': pf.get('dmz_v4_host') or ''}} + else: + cfg['portfwd'] = {'enable': pf.get('enable_v6', False), + 'rules': [r for r in (pf.get('rules') or []) + if (r.get('family') or 'v4') == 'v6'], + 'dmz': {'enable': pf.get('dmz_v6_enable', False), + 'host': pf.get('dmz_v6_host') or ''}} + elif module == 'radvd': + cfg['iface'] = cfg.get('iface') or syscfg.get('lan_iface') or 'ens18' + elif module == 'dhcpv6': + cfg['wan_iface'] = cfg.get('wan_iface') or syscfg.get('wan_iface') or 'ppp0' + cfg['lan_iface'] = cfg.get('lan_iface') or syscfg.get('lan_iface') or 'ens18' + elif module == 'upnp': + cfg['ext_iface'] = cfg.get('ext_iface') or syscfg.get('wan_iface') or 'ppp0' + elif module == 'ppp': + # PPPoE 拨号网卡:优先用用户填写值,其次回落到已分配的 WAN 口; + # 若都没有,且当前只有一个物理网卡,则直接用它(单口调试场景) + cfg['iface'] = (cfg.get('iface') or '').strip() or syscfg.get('wan_iface') or '' + if not cfg['iface']: + # 从已保存的网卡备注中找角色为 wan 的 + with _lock, db() as c: + row = c.execute("SELECT name FROM ifaces WHERE role='wan' LIMIT 1").fetchone() + if row: + cfg['iface'] = row['name'] + if not cfg['iface']: + return self.json({ + 'ok': False, 'code': 'NO_WAN_IFACE', + 'msg_cn': '尚未指定 PPPoE 拨号网卡。请先在「网卡与桥接」中把某张网卡角色设为 WAN,' + '或在本页的「拨号网卡」下拉框中手动选择。'}) + res = self.helper('apply', {'module': module, 'cfg': cfg, + 'live': live, 'check_only': check_only}) + audit(self.user, '应用配置', '%s live=%s -> %s' % (module, live, res.get('msg_cn')), + res.get('ok', False), self.h.client_address[0]) + return self.json(res) + + def apply_raw(self): + if not self.auth(): + return + b = self.body() + module = b.get('module') + if module not in ('nft_v4', 'nft_v6'): + return self.json({'ok': False, 'code': 'BADMODULE', 'msg_cn': '仅支持防火墙规则原文应用'}) + raw = b.get('raw') or '' + if len(raw) > 200000: + return self.json({'ok': False, 'code': 'TOOBIG', 'msg_cn': '规则内容过大'}) + cfg = get_cfg(module, {}) or {} + cfg['raw'] = raw + set_cfg(module, cfg) + res = self.helper('apply', {'module': module, 'cfg': cfg, + 'live': bool(b.get('live')), + 'check_only': bool(b.get('check_only'))}) + audit(self.user, '应用防火墙原文', module, res.get('ok', False), self.h.client_address[0]) + return self.json(res) + + def service_op(self): + if not self.auth(): + return + b = self.body() + res = self.helper('service', {'name': b.get('name'), 'op': b.get('op')}) + audit(self.user, '服务操作', '%s %s' % (b.get('name'), b.get('op')), res.get('ok', False)) + return self.json(res) + + def power_op(self): + if not self.auth(): + return + b = self.body() + op = b.get('op') + if op == 'force_reboot' and not b.get('confirm_text') == '确认强制重启': + return self.json({'ok': False, 'code': 'NEEDCONFIRM', + 'msg_cn': '强制重启风险极高,请输入「确认强制重启」以继续'}) + audit(self.user, '电源操作', op, True, self.h.client_address[0]) + return self.json(self.helper('power', {'op': op})) + + def user_op(self): + if not self.auth(): + return + b = self.body() + res = self.helper('user', b) + audit(self.user, '用户管理', '%s %s' % (b.get('op'), b.get('name')), res.get('ok', False)) + return self.json(res) + + def diag(self): + if not self.auth(): + return + b = self.body() + res = self.helper('diag', b, timeout=90) + audit(self.user, '诊断工具', '%s %s' % (b.get('tool'), b.get('target') or b.get('iface') or ''), + res.get('ok', False)) + return self.json(res) + + def pkg(self): + if not self.auth(): + return + b = self.body() + res = self.helper('pkg', b, timeout=960) + audit(self.user, '软件包管理', '%s %s' % (b.get('op'), ','.join(b.get('packages') or [])[:200]), + res.get('ok', False)) + return self.json(res) + + def snapshot(self): + if not self.auth(): + return + b = self.body() + res = self.helper('snapshot', {'tag': b.get('tag') or 'manual'}) + audit(self.user, '创建快照', res.get('msg_cn'), res.get('ok', False)) + return self.json(res) + + def snapshot_list(self): + if not self.auth(): + return + return self.json(self.helper('snapshot_list')) + + def snapshot_delete(self): + if not self.auth(): + return + b = self.body() + res = self.helper('snapshot_delete', {'ts': b.get('ts')}) + if res.get('ok'): + audit(self.user, '删除快照', b.get('ts'), True) + return self.json(res) + + def snapshot_prune(self): + if not self.auth(): + return + b = self.body() + res = self.helper('snapshot_prune', { + 'keep_days': b.get('keep_days'), 'keep_count': b.get('keep_count'), + 'keep_manual': b.get('keep_manual', True)}) + if res.get('ok'): + audit(self.user, '清理快照', res.get('msg_cn'), True) + return self.json(res) + + def auto_snapshot(self): + if not self.auth(): + return + b = self.body() if self._is_post() else {} + op = b.get('op') or 'get' + payload = dict(b) + payload['op'] = op + res = self.helper('auto_snapshot', payload) + if res.get('ok') and op == 'set': + audit(self.user, '自动快照策略', res.get('msg_cn'), True) + return self.json(res) + + def snapshot_download(self, query): + """把一份快照打包成 tar.gz 供用户下载到本地电脑。""" + if not self.auth(): + return + ts = (query.get('ts') or [''])[0].strip() + if not re.match(r'^\d{8}-\d{6}$', ts): + return self.json({'ok': False, 'msg_cn': '快照编号格式不正确'}) + res = self.helper('snapshot_pack', {'ts': ts}) + if not res.get('ok'): + return self.json(res) + path = (res.get('data') or {}).get('path') or '' + if not path or not os.path.isfile(path): + return self.json({'ok': False, 'msg_cn': '打包文件生成失败'}) + try: + with open(path, 'rb') as f: + data = f.read() + except Exception as e: + return self.json({'ok': False, 'msg_cn': '读取打包文件失败:%s' % e}) + fn = 'drouter-snapshot-%s.tar.gz' % ts + self.h.send_response(200) + self.h.send_header('Content-Type', 'application/gzip') + self.h.send_header('Content-Disposition', 'attachment; filename="%s"' % fn) + self.h.send_header('Content-Length', str(len(data))) + self.h.send_header('Cache-Control', 'no-store') + self.h.end_headers() + try: + self.h.wfile.write(data) + except Exception: + pass + audit(self.user, '下载快照', ts, True) + + def rescue(self): + if not self.auth(): + return + b = self.body() if self._is_post() else {} + op = b.get('op') or 'get' + payload = dict(b) + payload['op'] = op + # 开关救援通道需要显式确认 + if op == 'set' and b.get('enabled') is True and b.get('confirm') is not True: + return self.json({'ok': False, 'code': 'NEEDCONFIRM', + 'msg_cn': '开启紧急救援通道会在网卡上新增虚拟地址,请确认后重试'}) + res = self.helper('rescue', payload) + if res.get('ok') and op == 'set': + audit(self.user, '紧急救援通道', res.get('msg_cn'), True, + self.h.client_address[0]) + return self.json(res) + + def _is_post(self): + return getattr(self, 'method', 'GET') == 'POST' + + def rollback(self): + if not self.auth(): + return + b = self.body() + res = self.helper('rollback', {'ts': b.get('ts')}) + audit(self.user, '回滚配置', b.get('ts'), res.get('ok', False)) + return self.json(res) + + def migrate_preview(self): + if not self.auth(): + return + b = self.body() + res = self.helper('migrate_networkd', {'dry_run': True, 'cfg': b.get('cfg') or {}}) + return self.json(res) + + def build_mode(self): + if not self.auth(): + return + b = self.body() + op = b.get('op') or 'status' + if op == 'off' and b.get('confirm') is not True: + return self.json({'ok': False, 'code': 'NEEDCONFIRM', + 'msg_cn': '关闭构建保护模式需二次确认'}) + res = self.helper('build_mode', {'op': op, 'confirm': b.get('confirm')}) + audit(self.user, '构建保护模式', op, res.get('ok', False), self.h.client_address[0]) + return self.json(res) + + def ppp_control(self): + if not self.auth(): + return + b = self.body() + op = b.get('op') + if op in ('connect', 'disconnect') and b.get('confirm') is not True: + return self.json({'ok': False, 'code': 'NEEDCONFIRM', + 'msg_cn': '拨号/断开会影响全网,请确认后重试'}) + res = self.helper('ppp_control', {'op': op}) + audit(self.user, 'PPPoE 控制', op, res.get('ok', False), self.h.client_address[0]) + return self.json(res) + + def set_web_port(self): + if not self.auth(): + return + b = self.body() + raw = b.get('port') + try: + port = int(raw) + except Exception: + return self.json({'ok': False, 'code': 'BADPORT', 'msg_cn': '端口必须是 1-65535 的数字'}) + if not (1 <= port <= 65535): + return self.json({'ok': False, 'code': 'BADPORT', 'msg_cn': '端口必须是 1-65535 的数字'}) + if port in (8080, 80, 53, 67, 68, 5900): + return self.json({'ok': False, 'code': 'RESERVED', + 'msg_cn': '该端口已被系统其它服务占用(如 8080/5900/53/67),请换一个'}) + res = self.helper('web_port_set', {'port': port}) + if res.get('ok'): + audit(self.user, '修改 Web 端口', str(port), True) + return self.json(res) + + def lease_make_static(self): + if not self.auth(): + return + b = self.body() + res = self.helper('lease_make_static', + {'mac': b.get('mac'), 'ip': b.get('ip'), 'host': b.get('host')}) + audit(self.user, '租约转静态', '%s → %s' % (b.get('mac'), b.get('ip')), res.get('ok', False)) + return self.json(res) + + def lease_release(self): + if not self.auth(): + return + b = self.body() + if b.get('confirm') is not True: + return self.json({'ok': False, 'code': 'NEEDCONFIRM', + 'msg_cn': '回收地址会让该客户端暂时断网,请确认后重试'}) + res = self.helper('lease_release', {'ip': b.get('ip'), 'mac': b.get('mac')}) + audit(self.user, '回收 DHCP 地址', b.get('ip'), res.get('ok', False)) + return self.json(res) + + def upgrade_info(self): + if not self.auth(): + return + # 只读:检查是否有 apt 更新 + RealVNC 状态 + out = {} + try: + p = subprocess.run(['apt', 'list', '--upgradable'], capture_output=True, + text=True, timeout=60, errors='replace') + rows = [l for l in (p.stdout or '').splitlines()[1:] if '/' in l] + risky = [] + for l in rows: + nm = l.split('/')[0] + if re.match(r'^(xserver-xorg|xorg|linux-image|linux-headers|xfce4|realvnc|vnc)', nm): + risky.append(l) + out['upgradable'] = rows + out['risky'] = risky + out['notice'] = ('发现 %d 个可升级包,其中 %d 个可能影响桌面/RealVNC,' + '升级前请先创建快照。' % (len(rows), len(risky))) + except Exception as e: + out['notice'] = '检查失败:%s' % e + try: + p2 = subprocess.run(['systemctl', 'is-active', 'vncserver-x11-serviced'], + capture_output=True, text=True, timeout=5) + out['vnc_active'] = (p2.stdout or '').strip() == 'active' + except Exception: + out['vnc_active'] = None + out['holds'] = helper('pkg', {'op': 'holds'}).get('data', {}).get('holds', []) + return self.json({'ok': True, 'data': out}) + + # -------------------------------------------------- 对外通用 API(#17) + + def _api_base(self): + host = self.h.headers.get('Host') or ('%s:%d' % (HOST, PORT_HTTPS)) + sch = 'https' if isinstance(self.h.connection, ssl.SSLSocket) else 'http' + return '%s://%s' % (sch, host) + + def openapi_descriptor(self): + """返回一份 OpenAPI 3.0 风格的接口描述(无需登录,便于第三方自发现)。""" + base = self._api_base() + desc = build_openapi(base) + return self.json({'ok': True, 'data': desc}) + + def api_examples(self): + base = self._api_base() + return self.json({'ok': True, 'data': build_examples(base)}) + + # -------------------------------------------------- Web 终端(#18) + + def webshell_api(self, p): + """Web 终端(真 PTY)。 + + 协议变成交互式的了: + connect → 拿到 sid;之后前端每敲一个键就 write,再高频 read 取回显。 + 所以这里不再做「每请求一次慢超时」,read/write 必须快,否则打字会卡顿。 + """ + sub = p[len('/api/webshell'):].strip('/') + b = self.body() if self.method == 'POST' else {} + if sub == 'connect': + res = self.helper('webshell', {'op': 'connect', **b}, timeout=40) + if res.get('ok'): + sid = (res.get('data') or {}).get('sid') + if sid: + with _lock: + SHELL_SESSIONS[sid] = {'user': self.user, + 'created': time.time()} + return self.json(res) + if sub in ('read', 'write', 'resize'): + sid = b.get('sid') + with _lock: + ok_sess = sid in SHELL_SESSIONS + if not ok_sess: + return self.json({'ok': False, 'code': 'NOSESS', + 'msg_cn': '会话不存在或已过期,请重新连接'}, 400) + # read 支持长轮询(前端带 wait,最多 5 秒),超时要留出余量; + # write / resize 则是打字路径上的,必须立刻返回 + tmo = 20 if sub == 'read' else 15 + return self.json(self.helper('webshell', {'op': sub, **b}, timeout=tmo)) + if sub == 'disconnect': + sid = b.get('sid') + with _lock: + SHELL_SESSIONS.pop(sid, None) + return self.json(self.helper('webshell', {'op': 'disconnect', **b}, timeout=20)) + if sub == 'sessions': + with _lock: + return self.json({'ok': True, 'data': [ + {'sid': k, 'age': int(time.time() - v['created'])} + for k, v in SHELL_SESSIONS.items()]}) + return self.json({'ok': False, 'code': 'NOTFOUND', + 'msg_cn': '终端接口不存在:%s' % sub}, 404) + + # -------------------------------------------------- 文件管理(#18) + + def files_api(self, p, query, method): + """Web 文件管理器后端:全部委托给特权执行器(目录白名单在 helper 内校验)。""" + sub = p[len('/api/files'):].strip('/') + g = lambda k, d='': (query.get(k) or [d])[0] + if sub in ('list', '') and method == 'GET': + return self.json(self.helper('fs', {'op': 'list', 'path': g('path', '/')})) + if sub == 'download' and method == 'GET': + return self.fs_download(g('path')) + if sub == 'read' and method == 'GET': + return self.json(self.helper('fs', {'op': 'read', 'path': g('path'), + 'max': g('max', '262144')})) + if sub == 'mkdir' and method == 'POST': + b = self.body() + return self.json(self.helper('fs', {'op': 'mkdir', + 'parent': b.get('parent') or b.get('path'), + 'name': b.get('name')})) + if sub == 'rename' and method == 'POST': + b = self.body() + return self.json(self.helper('fs', {'op': 'rename', + 'path': b.get('path'), + 'name': b.get('name'), + 'to': b.get('to')})) + if sub == 'delete' and method == 'POST': + b = self.body() + return self.json(self.helper('fs', {'op': 'delete', + 'paths': b.get('paths') or []})) + if sub == 'zip' and method == 'POST': + b = self.body() + return self.json(self.helper('fs', {'op': 'zip', + 'paths': b.get('paths') or [], + 'name': b.get('name') or ''}, timeout=300)) + if sub == 'write' and method == 'POST': + b = self.body() + return self.json(self.helper('fs', {'op': 'write', + 'path': b.get('path'), + 'name': b.get('name'), + 'b64': b.get('b64'), + 'size': b.get('size')}, timeout=600)) + if sub == 'hash' and method == 'GET': + return self.json(self.helper('fs', {'op': 'hash', 'path': g('path')})) + if sub == 'search' and method == 'GET': + return self.json(self.helper('fs', {'op': 'search', 'path': g('path', '/'), + 'kw': g('kw'), 'limit': g('limit', '200')})) + return self.json({'ok': False, 'code': 'NOTFOUND', + 'msg_cn': '文件接口不存在:%s' % sub}, 404) + + def fs_download(self, path): + """下载文件:走特权执行器把内容 base64 取回再以流式落盘。""" + res = self.helper('fs', {'op': 'get', 'path': path, 'b64': True}, timeout=300) + if not res.get('ok'): + return self.json(res, 404) + d = res.get('data') or {} + raw = base64.b64decode(d.get('b64') or '') + name = d.get('name') or 'download.bin' + quoted = name.encode('utf-8').decode('latin-1', 'ignore') + self.h.send_response(200) + self.h.send_header('Content-Type', 'application/octet-stream') + self.h.send_header('Content-Length', str(len(raw))) + self.h.send_header('Content-Disposition', + "attachment; filename=\"%s\"; filename*=UTF-8''%s" + % (quoted, _url_quote(name))) + self.h.send_header('Cache-Control', 'no-store') + self.h.end_headers() + self.h.wfile.write(raw) + + +def _url_quote(s): + from urllib.parse import quote + return quote(s or '') + + +def build_openapi(base): + """构造对外通用 API 的机器可读描述(OpenAPI 3.0.3 子集)。""" + sec = [{'X-Token': []}] + def ep(path, method, summary, params=None, body=None, auth=True, tag='通用'): + node = {'tags': [tag], 'summary': summary, + 'responses': {'200': {'description': '统一 JSON 响应', + 'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Resp'}}}}}} + if auth: + node['security'] = sec + if params: + node['parameters'] = [{'name': k, 'in': 'query', 'required': False, + 'schema': {'type': 'string'}, 'description': v} + for k, v in params.items()] + if body is not None: + node['requestBody'] = {'content': {'application/json': { + 'schema': {'type': 'object', 'properties': { + k: {'type': v} for k, v in (body or {}).items()}}}}} + return {path: {method: node}} + + paths = {} + paths['/api/health'] = {'get': {'tags': ['通用'], 'summary': '健康检查(无需登录)', + 'responses': {'200': {'description': '服务状态'}}}} + paths['/api/login'] = {'post': {'tags': ['鉴权'], 'summary': '登入获取 Token', + 'requestBody': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Login'}}}}, + 'responses': {'200': {'description': '返回 token'}}}} + + reads = { + '/api/sysinfo': '系统概览(CPU / 内存 / 磁盘 / 进程 / 温度 / 虚拟化)', + '/api/ifaces': '网络接口列表', + '/api/routes': '路由表', + '/api/services': '受管服务状态', + '/api/nft': '防火墙规则快照', + '/api/leases': 'DHCP 租约', + '/api/ipv6': 'IPv6 地址与 RA 状态', + '/api/ntp': '时间同步状态', + '/api/logs': '日志', + '/api/journal': '系统日志', + } + for path, summary in reads.items(): + paths.update(ep(path, 'get', summary, tag='只读观测')) + + acts = { + '/api/nat/check': ('get', 'NAT 类型自动检测(STUN)', None, '网络诊断'), + '/api/accel': ('post', 'nftables flowtable 软加速 开关/查询', {'op': 'string: get|on|off'}, '网络加速'), + '/api/vlan': ('post', 'VLAN 接口 查询/保存/应用/删除', {'op': 'string: get|save|apply|delete', 'id': 'string', 'parent': 'string', 'vid': 'integer', 'name': 'string'}, '二层网络'), + '/api/wol': ('post', 'WOL 网络唤醒 / 网卡能力查询', {'op': 'string: get|iface|save|wake', 'mac': 'string', 'iface': 'string'}, '二层网络'), + '/api/pppoe-multi': ('post', 'PPPoE 多拨:查询/保存/应用/启停/单会话操作', + {'op': 'string: get|save|apply|stop|session_start|session_stop|session_restart', + 'strategy': 'string: balance|primary_backup|weighted', + 'iface': 'string', 'sessions': 'array', 'confirm': 'boolean', + 'duplicate_account': 'boolean', 'idx': 'integer'}, 'WAN 接入'), + '/api/pppoe-multi/log': ('post', 'PPPoE 单会话日志与排错建议', {'idx': 'integer'}, 'WAN 接入'), + '/api/qos': ('post', 'QoS 智能限速:查询/保存/启用/停用/重置', + {'op': 'string: get|save|on|off|reset', + 'mode': 'string: diffserv3|diffserv4|besteffort', + 'wan_iface': 'string', 'up_mbit': 'integer', 'down_mbit': 'integer', + 'ips': 'array', 'dscp': 'array', 'connlimit': 'boolean', + 'connlimit_max': 'integer', 'confirm': 'boolean'}, '服务'), + '/api/dpi': ('post', 'DPI 识别库:状态/代理测速/更新', + {'op': 'string: get|test|update|plan|log', + 'mirror': 'string: direct|ghproxy|gh-proxy|moeyy|ghfast|ghproxy-net|gitclone|custom', + 'custom_prefix': 'string', 'only': 'array', 'which': 'string'}, '服务'), + '/api/dpi/log': ('get', 'DPI 更新日志', None, '服务'), + '/api/wan/log': ('get', 'WAN 接入方式统一实时日志与状态(PPPoE/DHCP/静态/双栈/桥接)', + {'access': 'string: pppoe|dhcp|static|pppoe_dhcp6|ipoe_dhcp6|bridge', + 'iface': 'string', 'limit': 'integer', 'since': 'string'}, 'WAN 接入'), + '/api/ddns': ('post', '动态域名 DDNS:查询/保存/启停/公网检测/立即更新', + {'op': 'string: get|save|on|off|test|update', + 'provider': 'string', 'domain': 'string', 'subdomain': 'string', + 'ipv4': 'boolean', 'ipv6': 'boolean', 'ttl': 'integer', + 'fields': 'object'}, '寻址与路由'), + '/api/portfwd': ('post', '端口转发与 DMZ:查询/保存(渲染为 nftables DNAT)', + {'enable_v4': 'boolean', 'enable_v6': 'boolean', + 'rules': 'array', 'dmz_v4_enable': 'boolean', + 'dmz_v4_host': 'string', 'dmz_v6_enable': 'boolean', + 'dmz_v6_host': 'string'}, '安全'), + '/api/acl': ('post', '访问控制与家长时间组:查询/保存/启停/渲染预览(nftables)', + {'op': 'string', 'enable': 'boolean', 'time_groups': 'array', + 'groups': 'array', 'rules': 'array'}, '安全'), + '/api/share': ('post', '内网文件共享 SMB / NFS:查询/保存/启停/建目录/渲染预览', + {'op': 'string', 'enable_smb': 'boolean', 'enable_nfs': 'boolean', + 'samba': 'object', 'nfs': 'object'}, '服务'), + '/api/storage': ('post', '外置存储设备(USB / Type-C / 雷电):识别设备、挂载、卸载、' + '格式化(ext4/xfs/btrfs/f2fs/exfat/ntfs/vfat)、开机自动挂载', + {'op': 'string: get|mount|umount|format|fstab_add|fstab_del', + 'name': 'string', 'fs': 'string', 'label': 'string', + 'confirm': 'string', 'target': 'string', 'uuid': 'string'}, '服务'), + '/api/docker': ('post', 'Docker / Compose 面板:状态查询、容器运维、镜像/网络/卷、' + 'Compose 项目管理、docker run → compose 转换', + {'op': 'string: get|start|stop|restart|pause|unpause|rm|kill|logs|' + 'inspect|prune|convert|stack_list|stack_save|stack_get|' + 'stack_up|stack_down|stack_restart|stack_pull|stack_ps|stack_delete', + 'id': 'string', 'name': 'string', 'cmd': 'string', + 'content': 'string', 'what': 'string'}, '工具'), + '/api/ulog': ('post', '连接跟踪与统一日志:查询/当前连接表/保存保留策略/清理/导出', + {'op': 'string: query|flow|conf|save_conf|prune|export|clear_archive', + 'view': 'string: log|flow', 'src': 'string', 'level': 'string', + 'action': 'string', 'proto': 'string', 'addr': 'string', + 'port': 'string', 'q': 'string', 'since': 'string', + 'limit': 'integer', 'sources': 'array', 'format': 'string: json|csv', + 'keep_days': 'integer', 'keep_rows': 'integer'}, '日志与审计'), + '/api/ulog/flow': ('get', '当前连接跟踪表快照(conntrack -L,含传输字节)', + {'proto': 'string', 'q': 'string', 'limit': 'integer'}, + '日志与审计'), + '/api/ulog/conf': ('get', '统一日志配置与采集能力自检', None, '日志与审计'), + '/api/theme': ('get', '主题列表 / 主题详情 / 可定制变量清单', + {'op': 'list|get|vars', 'id': 'string'}, '主题之家'), + '/api/theme/op': ('post', '主题保存 / 删除 / 应用 / 复位 / 导出 / 导入 / 预览 / 配色', + {'op': 'save|delete|apply|reset|export|import|import_apply|preview|validate|palette', + 'id': 'string', 'theme': 'object', 'b64': 'string'}, '主题之家'), + '/api/render': ('post', '配置渲染预览(只读,不写盘)', + {'module': 'string', 'data': 'object'}, '配置管理'), + '/api/deps/check': ('get', '运行依赖自检', None, '系统维护'), + '/api/deps/install': ('post', '一键安装缺失依赖', {'only': 'array'}, '系统维护'), + '/api/config': ('get', '读取全部结构化配置', None, '配置管理'), + '/api/apply': ('post', '应用配置(渲染 + 校验 + 回滚保护)', {'module': 'string'}, '配置管理'), + '/api/snapshot': ('post', '创建快照', {'tag': 'string', 'note': 'string'}, '系统维护'), + '/api/snapshot/list': ('get', '快照列表', None, '系统维护'), + '/api/rollback': ('post', '回滚到指定快照', {'ts': 'string'}, '系统维护'), + '/api/audit': ('get', '审计日志', None, '审计'), + } + for path, (m, summary, body, tag) in acts.items(): + paths.update(ep(path, m, summary, body=body, tag=tag)) + + fs_eps = { + '/api/files/list': '列目录', + '/api/files/download': '下载文件', + '/api/files/read': '读取文本(预览)', + '/api/files/hash': '文件校验值', + '/api/files/search': '关键词搜索', + '/api/files/mkdir': '新建目录', + '/api/files/rename': '重命名', + '/api/files/delete': '删除(走回收站)', + '/api/files/zip': '打包 zip', + } + for path, summary in fs_eps.items(): + paths.update(ep(path, 'get' if path.endswith(('list', 'read', 'hash', 'search', 'download')) else 'post', + summary, params={'path': '目标路径'} if not path.endswith(('list',)) else None, + tag='文件管理')) + + return { + 'openapi': '3.0.3', + 'info': { + 'title': 'Drouter 通用管理 API', + 'version': '1.0.0', + 'description': ('Drouter(Debian 13 拼装主路由)的统一 REST 接口。' + '任意语言 / 框架(curl、Python、Node、Go、PHP、Java、.NET、' + 'Shell、Postman、工单系统、IoT 网关)均可直接调用。' + '响应固定为 JSON:{"ok": bool, "msg_cn": str, "data": any, "code": str}。' + '鉴权:POST /api/login 取 token,之后每个请求带 X-Token 头。'), + 'license': {'name': 'MIT'}, + }, + 'servers': [{'url': base}], + 'components': { + 'securitySchemes': { + 'X-Token': {'type': 'apiKey', 'in': 'header', 'name': 'X-Token', + 'description': '登入后返回的会话令牌'}}, + 'schemas': { + 'Resp': {'type': 'object', 'properties': { + 'ok': {'type': 'boolean'}, 'msg_cn': {'type': 'string'}, + 'code': {'type': 'string'}, 'data': {}}}, + 'Login': {'type': 'object', 'required': ['username', 'password'], + 'properties': {'username': {'type': 'string'}, + 'password': {'type': 'string'}}}, + }, + }, + 'paths': paths, + } + + +def build_examples(base): + """多语言调用范例(文本块,前后端都可直接展示/复制)。""" + return { + 'base': base, + 'note': '所有接口统一返回 {"ok":..,"msg_cn":..,"data":..};失败时 ok=false。' + '除 /api/health、/api/login 外均需 X-Token 头。', + 'examples': [ + {'lang': 'curl', 'title': 'cURL(最通用,Linux/macOS/Windows 均可)', 'code': + "BASE=\"%s\"\n" + "# 1) 登入拿 token\n" + "TOKEN=$(curl -sk -X POST \"$BASE/api/login\" \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"username\":\"admin\",\"password\":\"admin123\"}' \\\n" + " | python3 -c 'import sys,json;print(json.load(sys.stdin)[\"data\"][\"token\"])')\n\n" + "# 2) 读取系统概览\n" + "curl -sk \"$BASE/api/sysinfo\" -H \"X-Token: $TOKEN\"\n\n" + "# 3) 一键开启软加速\n" + "curl -sk -X POST \"$BASE/api/accel\" -H \"X-Token: $TOKEN\" \\\n" + " -H 'Content-Type: application/json' -d '{\"op\":\"on\"}'\n" % base}, + + {'lang': 'python', 'title': 'Python(仅用标准库 requests 可省)', 'code': + "import json, urllib.request, ssl\n\n" + "BASE = \"%s\"\n" + "CTX = ssl._create_unverified_context() # 自签证书\n\n" + "def call(path, data=None, token=None, method=None):\n" + " body = json.dumps(data).encode() if data is not None else None\n" + " req = urllib.request.Request(BASE + path, data=body,\n" + " method=method or ('POST' if data else 'GET'))\n" + " req.add_header('Content-Type', 'application/json')\n" + " if token:\n" + " req.add_header('X-Token', token)\n" + " with urllib.request.urlopen(req, context=CTX, timeout=30) as r:\n" + " return json.load(r)\n\n" + "tok = call('/api/login', {'username': 'admin', 'password': 'admin123'})['data']['token']\n" + "print(call('/api/sysinfo', token=tok)['data']['hostname'])\n" + "print(call('/api/nat/check', token=tok)['data']['title'])\n" % base}, + + {'lang': 'javascript', 'title': 'JavaScript / Node.js / 浏览器 fetch', 'code': + "const BASE = '%s';\n\n" + "async function call(path, data, token) {\n" + " const r = await fetch(BASE + path, {\n" + " method: data ? 'POST' : 'GET',\n" + " headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Token': token } : {}) },\n" + " body: data ? JSON.stringify(data) : undefined,\n" + " });\n" + " return r.json();\n" + "}\n\n" + "(async () => {\n" + " const { data } = await call('/api/login', { username: 'admin', password: 'admin123' });\n" + " const info = await call('/api/sysinfo', null, data.token);\n" + " console.log(info.data.hostname, info.data.virt.name);\n" + "})();\n" % base}, + + {'lang': 'go', 'title': 'Go(net/http 标准库)', 'code': + "package main\n\n" + "import (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n" + "const base = \"%s\"\n\n" + "func call(path string, payload any, token string) map[string]any {\n" + "\tvar buf io.Reader\n" + "\tif payload != nil {\n\t\tb, _ := json.Marshal(payload)\n\t\tbuf = bytes.NewReader(b)\n\t}\n" + "\tmethod := \"GET\"\n\tif payload != nil {\n\t\tmethod = \"POST\"\n\t}\n" + "\treq, _ := http.NewRequest(method, base+path, buf)\n" + "\treq.Header.Set(\"Content-Type\", \"application/json\")\n" + "\tif token != \"\" {\n\t\treq.Header.Set(\"X-Token\", token)\n\t}\n" + "\ttr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}\n" + "\tresp, _ := (&http.Client{Transport: tr}).Do(req)\n" + "\tdefer resp.Body.Close()\n" + "\tvar out map[string]any\n\tjson.NewDecoder(resp.Body).Decode(&out)\n\treturn out\n" + "}\n\n" + "func main() {\n" + "\tlogin := call(\"/api/login\", map[string]string{\"username\": \"admin\", \"password\": \"admin123\"}, \"\")\n" + "\ttok := login[\"data\"].(map[string]any)[\"token\"].(string)\n" + "\tinfo := call(\"/api/sysinfo\", nil, tok)\n" + "\tfmt.Println(info[\"data\"].(map[string]any)[\"hostname\"])\n" + "}\n" % base}, + + {'lang': 'java', 'title': 'Java 11+(HttpClient)', 'code': + "import java.net.URI;\nimport java.net.http.*;\nimport java.time.Duration;\n\n" + "public class Drouter {\n" + " static final String BASE = \"%s\";\n\n" + " public static void main(String[] a) throws Exception {\n" + " HttpClient c = HttpClient.newBuilder()\n" + " .sslContext(javax.net.ssl.SSLContext.getDefault())\n" + " .connectTimeout(Duration.ofSeconds(10)).build();\n" + " String login = \"{\\\"username\\\":\\\"admin\\\",\\\"password\\\":\\\"admin123\\\"}\";\n" + " HttpRequest rq = HttpRequest.newBuilder(URI.create(BASE + \"/api/login\"))\n" + " .header(\"Content-Type\", \"application/json\")\n" + " .POST(HttpRequest.BodyPublishers.ofString(login)).build();\n" + " System.out.println(c.send(rq, HttpResponse.BodyHandlers.ofString()).body());\n" + " }\n}\n" % base}, + + {'lang': 'php', 'title': 'PHP(file_get_contents / curl 通用写法)', 'code': + " true,\n" + " CURLOPT_SSL_VERIFYPEER => false,\n" + " CURLOPT_HTTPHEADER => $h,\n" + " CURLOPT_POST => $data !== null,\n" + " CURLOPT_POSTFIELDS => $data !== null ? json_encode($data) : null,\n" + " ]);\n" + " return json_decode(curl_exec($ch), true);\n" + "}\n" + "$t = call('/api/login', ['username' => 'admin', 'password' => 'admin123'])['data']['token'];\n" + "echo call('/api/sysinfo', null, $t)['data']['os'];\n" % base}, + + {'lang': 'shell', 'title': 'Shell(wget 版,无 curl 环境)', 'code': + "#!sh\n" + "BASE=\"%s\"\n" + "TOKEN=$(wget -qO- --no-check-certificate --header='Content-Type: application/json' \\\n" + " --post-data='{\"username\":\"admin\",\"password\":\"admin123\"}' \\\n" + " \"$BASE/api/login\" | sed -n 's/.*\"token\":\"\\([^\"]*\\)\".*/\\1/p')\n" + "wget -qO- --no-check-certificate --header=\"X-Token: $TOKEN\" \"$BASE/api/sysinfo\"\n" % base}, + + {'lang': 'powershell', 'title': 'PowerShell(Windows)', 'code': + "$base = '%s'\n" + "$login = Invoke-RestMethod -Method Post -Uri \"$base/api/login\" `\n" + " -Body (@{username='admin';password='admin123'} | ConvertTo-Json) `\n" + " -ContentType 'application/json' -SkipCertificateCheck\n" + "$info = Invoke-RestMethod -Uri \"$base/api/sysinfo\" -Headers @{'X-Token'=$login.data.token} `\n" + " -SkipCertificateCheck\n" + "$info.data.hostname\n" % base}, + + {'lang': 'http', 'title': '原始 HTTP 报文(任何 HTTP 客户端 / 工单系统 / IoT)', 'code': + "POST /api/login HTTP/1.1\n" + "Host: %s\n" + "Content-Type: application/json\n" + "Content-Length: 44\n\n" + "{\"username\":\"admin\",\"password\":\"admin123\"}\n\n" + "--- 拿到 token 后 ---\n\n" + "GET /api/sysinfo HTTP/1.1\n" + "Host: %s\n" + "X-Token: <上一步返回的 token>\n" % (base.replace('https://', '').replace('http://', ''), + base.replace('https://', '').replace('http://', ''))}, + ], + } + + +# ------------------------------------------------------------------ HTTP 处理 + +_ASSET_RE = re.compile( + r'(\b(?:src|href)\s*=\s*["\'])(/[^"\']+\.(?:js|css))(["\'])') + + +def _inject_asset_version(raw): + """给入口页里的本地 js/css 引用追加 ?v=<文件mtime>,用于部署后自动破缓存。 + + 只处理「以 / 开头、还没有查询串」的同源资源;带 ? 的(如 theme.css?v=...) + 保持原样,避免覆盖前端自己维护的版本参数。 + """ + try: + text = raw.decode('utf-8') + except UnicodeDecodeError: + return raw # 非 utf-8 的入口页不处理,原样返回 + + cache = {} + + def ver(rel): + if rel not in cache: + try: + cache[rel] = int(os.path.getmtime(os.path.join(WEB_DIR, rel.lstrip('/')))) + except OSError: + cache[rel] = 0 + return cache[rel] + + def sub(m): + pre, url, post = m.group(1), m.group(2), m.group(3) + if '?' in url: + return m.group(0) + return '%s%s?v=%d%s' % (pre, url, ver(url), post) + + return _ASSET_RE.sub(sub, text).encode('utf-8') + + +class Handler(BaseHTTPRequestHandler): + server_version = 'drouter/1.0' + protocol_version = 'HTTP/1.1' + + def log_message(self, fmt, *args): + pass # 静默,避免刷日志 + + def _serve_static(self, path): + if path in ('/', '/index.html'): + path = '/index.html' + rel = path.lstrip('/') + if '..' in rel: + return self._send_text(403, '禁止访问') + full = os.path.join(WEB_DIR, rel) + if not os.path.isfile(full): + return self._send_text(404, '资源不存在') + ctype = mimetypes.guess_type(full)[0] or 'application/octet-stream' + if full.endswith('.html'): + ctype = 'text/html; charset=utf-8' + elif full.endswith('.js'): + ctype = 'application/javascript; charset=utf-8' + elif full.endswith('.css'): + ctype = 'text/css; charset=utf-8' + + # ETag 协商缓存:没变就回 304,省掉整个响应体(app.js 437KB)。 + # 键里带 mtime 和大小,部署新版本后自动失效。 + try: + st = os.stat(full) + etag = '"%s-%s"' % (int(st.st_mtime), st.st_size) + except Exception: + etag = '' + if etag and (self.headers.get('If-None-Match') or '') == etag: + self.send_response(304) + if etag: + self.send_header('ETag', etag) + self.send_header('Cache-Control', 'no-cache') + self.end_headers() + return + + with open(full, 'rb') as f: + data = f.read() + + # 入口页注入资源版本号:部署后用户浏览器不必手动强刷就能拿到新前端。 + # 之前只发 Cache-Control: no-cache(无 ETag/Last-Modified 校验器), + # 部分浏览器仍按启发式规则继续使用旧副本,表现为「部署完了还是老界面」。 + if full.endswith('index.html'): + data = _inject_asset_version(data) + + enc = None + if _want_gzip(self.headers) and len(data) >= GZIP_MIN: + data, enc = _gzip_cached(full, data) + + self.send_response(200) + self.send_header('Content-Type', ctype) + if enc: + self.send_header('Content-Encoding', enc) + self.send_header('Vary', 'Accept-Encoding') + if etag: + self.send_header('ETag', etag) + self.send_header('Content-Length', str(len(data))) + if full.endswith('.html'): + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') + else: + self.send_header('Cache-Control', 'no-cache') + self.end_headers() + if self.command != 'HEAD': + self.wfile.write(data) + + THEME_CSS_FILE = '/etc/drouter/generated/theme.css' + + def _serve_theme_css(self, qver=None): + """返回当前生效主题的 CSS(主题之家 #12)。 + + -cache 由 drouter-helper 生成后写盘,静态返回即可 + -v= 查询串用于前端主动绕过浏览器缓存 + - 文件不存在时返回空样式(而不是 404),保证首屏不会缺样式 + """ + path = self.THEME_CSS_FILE + if not os.path.isfile(path): + data = u'/* 尚未生成任何主题,使用内置默认样式 */\n'.encode('utf-8') + else: + try: + with open(path, 'rb') as f: + data = f.read() + except Exception: + data = u'/* 主题样式读取失败,回落到默认样式 */\n'.encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'text/css; charset=utf-8') + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-cache') + self.end_headers() + self.wfile.write(data) + + def _send_text(self, code, text): + data = ('' + '%s' % text).encode('utf-8') + self.send_response(code) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _handle(self, method): + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + # 主题样式:无需登录(登录页也要用自己的主题),只读、无副作用 + if path == '/theme.css' and method == 'GET': + # v= 只在查询串里用于绕过浏览器缓存,函数本身不需要它 + return self._serve_theme_css() + if path.startswith('/api/'): + try: + api = Api(self) + api.dispatch(method, path, query) + except Exception as e: + import traceback + traceback.print_exc() + try: + api.json({'ok': False, 'code': 'EXCEPTION', + 'msg_cn': '服务器内部错误:%s' % e}, 500) + except Exception: + pass + return + if method != 'GET': + return self._send_text(405, '不支持的请求方法') + self._serve_static(path) + + def do_GET(self): + self._handle('GET') + + def do_POST(self): + self._handle('POST') + + def do_OPTIONS(self): + self.send_response(204) + self.send_header('Allow', 'GET, POST, OPTIONS') + self.send_header('Content-Length', '0') + self.end_headers() + + +# 登录成功后需要把 token 返回给前端:在 Api.json 拦截不方便, +# 因此在 dispatch 登录分支里直接注入 token。 +_orig_login = Api.login + + +def login_with_token(self): + ip = self.h.client_address[0] + f = LOGIN_FAILS.get(ip) + if f and f['n'] >= MAX_LOGIN_FAILS and f['until'] > time.time(): + left = int((f['until'] - time.time()) / 60) + 1 + return self.json({'ok': False, 'code': 'LOCKED', + 'msg_cn': '登录失败次数过多,请 %d 分钟后再试' % left}, 429) + b = self.body() + u = (b.get('username') or '').strip() + pw = b.get('password') or '' + with _lock, db() as c: + row = c.execute('SELECT * FROM admins WHERE username=?', (u,)).fetchone() + if not row or hash_pw(pw, row['salt']) != row['pw_hash']: + rec = LOGIN_FAILS.setdefault(ip, {'n': 0, 'until': 0}) + rec['n'] += 1 + rec['until'] = time.time() + LOGIN_LOCK_MINUTES * 60 if rec['n'] >= MAX_LOGIN_FAILS else 0 + audit(u or '(空)', '登录失败', ip, False, ip) + left = MAX_LOGIN_FAILS - rec['n'] + return self.json({'ok': False, 'code': 'BADCRED', + 'msg_cn': '用户名或密码错误' + ('(还可尝试 %d 次)' % left if left > 0 else '')}, 401) + LOGIN_FAILS.pop(ip, None) + tok = secrets.token_urlsafe(32) + SESSIONS[tok] = {'user': u, 'ip': ip, 'exp': time.time() + SESSION_MINUTES * 60, + 'csrf': secrets.token_urlsafe(24)} + audit(u, '登录成功', ip, True, ip) + return self.json({'ok': True, 'msg_cn': '登录成功', + 'data': {'token': tok, 'user': u, 'expires_in': SESSION_MINUTES * 60}}) + + +Api.login = login_with_token + + +# ------------------------------------------------------------------ 启动 + +def main(): + init_db() + global PORT_HTTPS + PORT_HTTPS = load_web_port() + crt, key = ensure_cert() + threading.Thread(target=gc_sessions, daemon=True).start() + + httpd = ThreadingHTTPServer((HOST, PORT_HTTP), Handler) + httpd.daemon_threads = True + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + try: + ctx.load_cert_chain(crt, key) + httpsd = ThreadingHTTPServer((HOST, PORT_HTTPS), Handler) + httpsd.socket = ctx.wrap_socket(httpsd.socket, server_side=True) + httpsd.daemon_threads = True + threading.Thread(target=httpsd.serve_forever, daemon=True).start() + print('HTTPS 监听 %s:%d' % (HOST, PORT_HTTPS), flush=True) + except Exception as e: + print('HTTPS 启动失败:%s' % e, flush=True) + + print('HTTP 监听 %s:%d' % (HOST, PORT_HTTP), flush=True) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + main() diff --git a/backend/render.py b/backend/render.py new file mode 100644 index 0000000..4da268b --- /dev/null +++ b/backend/render.py @@ -0,0 +1,1355 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +drouter - 配置渲染器 +职责:把 Web 层保存在 SQLite 中的结构化配置,渲染成各原生服务的配置文件文本。 +本模块为纯逻辑(不碰系统、不需要 root),可独立单元测试。 +""" +import re +import ipaddress + +# ---------------------------------------------------------------- 校验工具 + +IPV4_RE = re.compile(r'^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$') +IFNAME_RE = re.compile(r'^[a-zA-Z0-9._:-]{1,15}$') +MAC_RE = re.compile(r'^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$') +DOMAIN_RE = re.compile(r'^(?!-)[a-zA-Z0-9-_.]{1,253}$') + + +class ValidateError(Exception): + """输入校验失败,附带简体中文提示""" + def __init__(self, msg_cn, field=None): + super().__init__(msg_cn) + self.msg_cn = msg_cn + self.field = field + + +def v_ipv4(v, field='值', allow_empty=False): + v = (v or '').strip() + if not v: + if allow_empty: + return '' + raise ValidateError(f'{field}不能为空,请填写合法的 IPv4 地址', field) + if not IPV4_RE.match(v): + raise ValidateError(f'{field}不是合法的 IPv4 地址:{v}', field) + return v + + +def v_ipv6(v, field='值', allow_empty=False, global_only=True): + """校验 IPv6 地址。默认要求为全球单播(拒绝链路本地 / ULA / 组播)。""" + v = (v or '').strip().split('%')[0] + if not v: + if allow_empty: + return '' + raise ValidateError(f'{field}不能为空,请填写合法的 IPv6 地址', field) + try: + a = ipaddress.IPv6Address(v) + except Exception: + raise ValidateError(f'{field}不是合法的 IPv6 地址:{v}', field) + if global_only and not a.is_global: + raise ValidateError( + f'{field}必须是公网可路由的 IPv6 地址(不能是 fe80::/fc00::/ff00:: 等):{v}', field) + return str(a) + + +def v_ipv4_list(v, field='值', allow_empty=True): + """IPv4 列表,用于 DNS 服务器。兼容两种入参:list 或逗号分隔字符串。""" + if isinstance(v, (list, tuple)): + parts = [str(x).strip() for x in v if str(x).strip()] + else: + v = (v or '').strip() + parts = [x.strip() for x in v.split(',') if x.strip()] if v else [] + if not parts: + if allow_empty: + return [] + raise ValidateError(f'{field}不能为空', field) + out = [] + for item in parts: + if not IPV4_RE.match(item): + raise ValidateError(f'{field}包含非法 IPv4 地址:{item}', field) + out.append(item) + if len(out) > 8: + raise ValidateError(f'{field}最多允许 8 个地址', field) + return out + + +def v_cidr(v, field='值', allow_empty=False): + v = (v or '').strip() + if not v: + if allow_empty: + return '' + raise ValidateError(f'{field}不能为空', field) + try: + net = ipaddress.IPv4Network(v, strict=False) + except Exception: + raise ValidateError(f'{field}不是合法的网段(示例 192.168.7.1/24):{v}', field) + return f'{net.network_address}/{net.prefixlen}' + + +def v_int(v, field='值', lo=0, hi=65535, default=None, allow_empty=False): + s = str(v if v is not None else '').strip() + if s == '': + if allow_empty: + return None + if default is not None: + return default + raise ValidateError(f'{field}不能为空', field) + if not s.isdigit(): + raise ValidateError(f'{field}必须是数字:{s}', field) + n = int(s) + if n < lo or n > hi: + raise ValidateError(f'{field}必须在 {lo} ~ {hi} 之间', field) + return n + + +def v_ifname(v, field='网卡名', allow_empty=False): + v = (v or '').strip() + if not v: + if allow_empty: + return '' + raise ValidateError(f'{field}不能为空', field) + if not IFNAME_RE.match(v): + raise ValidateError(f'{field}包含非法字符:{v}', field) + return v + + +def v_mac(v, field='MAC 地址'): + v = (v or '').strip() + if not MAC_RE.match(v): + raise ValidateError(f'{field}格式不正确(示例 bc:24:11:1a:8f:43):{v}', field) + return v.lower() + + +def v_bool(v): + return bool(v) and str(v).lower() not in ('false', '0', 'no', 'off', '') + + +def v_choice(v, choices, field='值'): + v = (v or '').strip() + if v not in choices: + raise ValidateError(f'{field}只能是:{", ".join(choices)}', field) + return v + + +def v_option_code(v): + return v_int(v, field='DHCP Option Code', lo=1, hi=254) + + +def v_option_value(v, code): + """按 Option Code 校验值:3/6/44 等为 IP 列表,其他允许字符串(hex 或文本)""" + v = (v or '').strip() + if not v: + raise ValidateError('Option 值不能为空', 'value') + if code in (1, 3, 4, 5, 6, 7, 16, 28, 41, 44, 45, 48, 49, 50, 51, 54, 65, 68, 69, 70, 71, 72, 73, 74, 76): + return ','.join(v_ipv4_list(v, field=f'Option {code} 的值')) + if len(v) > 255: + raise ValidateError(f'Option {code} 的值过长(最多 255 字符)', 'value') + if re.search(r'[\r\n]', v): + raise ValidateError(f'Option {code} 的值不能包含换行', 'value') + return v + + +# ---------------------------------------------------------------- dnsmasq + +def render_dnsmasq(cfg): + """cfg 结构见 web 配置:lan_iface / pool / options / dns / static_leases""" + lan = v_ifname(cfg.get('lan_iface') or '', field='LAN 接口') + lines = [ + '# 由 drouter 自动生成,请勿手工修改', + '# 模块:dnsmasq(DHCPv4 + DNS 转发)', + '', + '# ---- 基本行为 ----', + f'interface={lan}', + 'bind-interfaces', + 'domain-needed', + 'bogus-priv', + 'expand-hosts', + f"domain={cfg.get('domain') or 'lan'}", + 'local=/lan/', + '', + '# ---- DHCPv4 地址池 ----', + ] + enabled = v_bool(cfg.get('dhcp_enabled')) + if enabled: + pool_start = v_ipv4(cfg.get('pool_start'), field='地址池起始') + pool_end = v_ipv4(cfg.get('pool_end'), field='地址池结束') + netmask = v_ipv4(cfg.get('pool_netmask') or '255.255.255.0', field='子网掩码') + lease = v_int(cfg.get('lease_time') or 7200, field='租期', lo=120, hi=604800) + lines.append(f'dhcp-range={pool_start},{pool_end},{netmask},{lease}s') + gw = (cfg.get('option_gateway') or '').strip() + if gw: + lines.append(f'dhcp-option=3,{v_ipv4(gw, field="Option 3 网关")}') + dns = (cfg.get('option_dns') or '').strip() + if dns: + lines.append(f'dhcp-option=6,{",".join(v_ipv4_list(dns, field="Option 6 DNS"))}') + # 自定义任意 option + for opt in cfg.get('options') or []: + if not v_bool(opt.get('enabled')): + continue + code = v_option_code(opt.get('code')) + val = v_option_value(opt.get('value'), code) + force = v_bool(opt.get('force')) + lines.append(f'dhcp-option{"-force" if force else ""}={code},{val}') + # 静态绑定 + for sl in cfg.get('static_leases') or []: + if not v_bool(sl.get('enabled')): + continue + mac = v_mac(sl.get('mac')) + ip = v_ipv4(sl.get('ip'), field='静态绑定 IP') + name = (sl.get('name') or '').strip() + name = name if re.match(r'^[a-zA-Z0-9-]{0,63}$', name) else '' + lines.append(f'dhcp-host={mac},{ip}' + (f',{name}' if name else '')) + else: + lines.append('# DHCP 服务已关闭(仅启用 DNS 转发)') + lines.append('no-dhcp-interface=' + lan) + + lines += ['', '# ---- DNS 上游 ----'] + upstream_mode = v_choice(cfg.get('dns_mode') or 'custom', + ['isp', 'custom', 'both'], field='DNS 来源') + servers = [] + if upstream_mode in ('custom', 'both'): + servers += v_ipv4_list(cfg.get('dns_custom') or '', field='自定义 DNS') + if upstream_mode in ('isp', 'both'): + servers.append('# 运营商下发 DNS 由 /etc/drouter/generated/isp-dns.conf 动态注入') + lines.append('resolv-file=/etc/drouter/generated/isp-dns.conf') + for s in servers: + if s.startswith('#'): + continue + lines.append(f'server={s}') + if cfg.get('dns_lan_server'): + lines.append(f"server=/{cfg.get('domain') or 'lan'}/{v_ipv4(cfg.get('dns_lan_server'), field='内网 DNS')}") + lines += [ + f"cache-size={v_int(cfg.get('dns_cache_size') or 1000, field='DNS 缓存', lo=0, hi=20000)}", + 'no-negcache' if not v_bool(cfg.get('dns_negcache', True)) else '', + 'log-queries' if v_bool(cfg.get('dns_query_log')) else '', + f"log-facility={cfg.get('log_file') or '/var/log/drouter/dnsmasq.log'}", + 'dhcp-leasefile=/var/lib/misc/dnsmasq.leases', + ] + return '\n'.join([l for l in lines if l != '']) + '\n' + + +# ---------------------------------------------------------------- nftables + +NFT_HEADER = '# 由 drouter 自动生成 / 可手工编辑\n' \ + '# 应用前会执行 nft -c 语法预检,失败将自动回滚\n' + +# 防火墙日志开关默认限速(每秒最多打印的包数),避免刷爆内核日志 +FW_LOG_RATE = 20 + +# 日志前缀:/var/log/drouter/fw4.log 的采集脚本据此识别本机防火墙日志。 +# 用统一前缀便于后续「连接与流日志」页面把 IPv4/IPv6 防火墙日志聚合到一起。 +FW_LOG_PREFIX = 'DROUTER-FW' + + +def _fw_log_rule(prefix, rate, comment): + """生成一条带限速的 log 规则文本(log prefix 里带 IPv4/IPv6 标记)。""" + return (' log prefix "%s-%s " limit rate %d/second ' + 'comment "%s"' % (FW_LOG_PREFIX, prefix, rate, comment)) + + +def _mss_rule(wan, mode, mss, comment): + """MSS 钳制规则。 + + clamp —— 按出接口路由的 MTU 自动推算(PPPoE 拨号时最省心,MTU 变了不用改配置); + fixed —— 写死一个值(多拨、隧道、GRE 等 MTU 不一致的场景才需要手工指定)。 + """ + if mode == 'fixed': + try: + val = max(576, min(int(mss), 9000)) + except Exception: + val = 1452 + return (f' oifname "{wan}" tcp flags syn tcp option maxseg size set {val} ' + f'comment "{comment}"') + return (f' oifname "{wan}" tcp flags syn tcp option maxseg size set rt mtu ' + f'comment "{comment}"') + + +def _default_v4(wan, lan_ifaces, mss_clamp=True, mss=1452, + log_drop=True, log_accept=False, log_rate=FW_LOG_RATE, + pf_enable=False, pf_rules=None, pf_dmz_enable=False, pf_dmz_host='', + masquerade=True, mss_mode='clamp'): + """生成 IPv4 默认规则集。 + + log_drop : 在默认拒绝前记录被丢弃的包(默认开启,方便排查"为什么不通") + log_accept : 是否同时记录放通的包(数据量大,默认关闭) + log_rate : 日志限速(包/秒),防止日志风暴拖垮小内存设备 + pf_* : 端口转发 / DMZ 参数(见 _pf_rules) + masquerade : 是否做出向地址伪装(SNAT)。关掉后内网将「只转发不改写源地址」, + 只有在上游已有回程路由(静态路由 / 已指派网段)时才通。 + mss_mode : clamp=按路由 MTU 自动算 / fixed=写死 mss 值 + """ + lan_set = ', '.join(f'"{i}"' for i in lan_ifaces) or '"br0"' + log_drop = bool(log_drop) + log_accept = bool(log_accept) + try: + log_rate = max(1, min(int(log_rate or FW_LOG_RATE), 1000)) + except Exception: + log_rate = FW_LOG_RATE + pf_nat, pf_filter = _pf_rules( + {'pf_enable': pf_enable, 'pf_rules': pf_rules, + 'pf_dmz_enable': pf_dmz_enable, 'pf_dmz_host': pf_dmz_host, + 'wan_iface': wan}, '4', 'ip') + s = [ + NFT_HEADER, + 'table ip drouter4 {', + ' # 局域网接口集合(PPPoe/WAN 口之外的接口)', + ' set lanifs {', + ' type ifname', + f' elements = {{ {lan_set} }}' if lan_set else ' elements = { "br0" }', + ' }', + '', + ' chain input {', + ' type filter hook input priority filter; policy accept;', + ' iif "lo" accept', + ' ct state established,related accept', + ] + if log_drop: + s += [' ct state invalid ' + + _fw_log_rule('4-IN-INVALID', log_rate, '入站非法状态包(已记录)').strip(), + ' ct state invalid drop'] + else: + s += [' ct state invalid drop'] + s += [ + ' iifname @lanifs accept comment "允许局域网访问本机管理口"', + ] + if log_accept: + s += [_fw_log_rule('4-IN-ACCEPT', log_rate, '入站被放通(已记录)')] + s += [ + ' # 默认放通 ICMP,便于排障;如需要可在下方自行添加拒绝规则', + ' icmp type echo-request accept', + ] + if log_drop: + s += [_fw_log_rule('4-IN-REJECT', log_rate, '入站默认拒绝(已记录)')] + s += [ + ' counter comment "入站总计数"', + ' }', + '', + ' chain forward {', + ' type filter hook forward priority filter; policy accept;', + ' ct state established,related accept', + ] + if log_drop: + s += [' ct state invalid ' + + _fw_log_rule('4-FWD-INVALID', log_rate, '转发非法状态包(已记录)').strip(), + ' ct state invalid drop'] + else: + s += [' ct state invalid drop'] + s += [' iifname @lanifs accept comment "允许局域网转发上网"'] + if log_accept: + s += [_fw_log_rule('4-FWD-ACCEPT', log_rate, '转发被放通(已记录)')] + # 端口转发 / DMZ 的放通规则(需在默认拒绝之前) + if pf_filter: + s += [' # ---- 端口转发 / DMZ 放通(由「端口转发」页面生成)----'] + s += pf_filter + if log_drop: + s += [_fw_log_rule('4-FWD-REJECT', log_rate, '转发默认拒绝(已记录)')] + s += [ + ' counter comment "转发总计数"', + ' }', + '', + ] + # DNAT 链:端口转发与 DMZ 均在此完成目的地址改写 + if pf_nat: + s += [ + ' # ---- 目的地址转换(DNAT):端口转发 / DMZ ----', + ' chain nat_pre {', + ' type nat hook prerouting priority dstnat; policy accept;', + ] + s += pf_nat + s += [ + ' }', + '', + ] + nat_body = [] + if masquerade: + nat_body.append(f' oifname "{wan}" masquerade comment "WAN 出向地址伪装"') + else: + nat_body.append(' # 出向地址伪装已关闭:内网地址原样发出,' + '需要上游路由器已有回程路由才通') + s += [' chain nat_post {', + ' type nat hook postrouting priority srcnat; policy accept;'] \ + + nat_body + [' }'] + if mss_clamp: + s += [ + '', + ' chain mangle_forward {', + ' type filter hook forward priority mangle; policy accept;', + _mss_rule(wan, mss_mode, mss, 'MSS 钳制(解决部分网站打不开)'), + ' }', + ] + s += ['}'] + return '\n'.join(s) + '\n' + + +def _default_v6(wan, lan_ifaces, + log_drop=True, log_accept=False, log_rate=FW_LOG_RATE, + pf_enable=False, pf_rules=None, pf_dmz_enable=False, pf_dmz_host='', + mss_clamp=False, mss=1432, mss_mode='clamp', masquerade=False): + """生成 IPv6 默认规则集(日志语义同 IPv4)。 + + IPv6 的两项默认与 IPv4 不同,别照抄: + masquerade 默认「关」—— IPv6 地址足够多,正常玩法是端到端直连(每个设备都有 + 公网 v6),做 NAT66 反而会让 BT / 摄像头 / 远程桌面这类入向应用失效; + 只有在「上游只给了一个 /64 且不再下发 PD」时才需要打开。 + mss_clamp 默认「关」—— IPv6 依赖 PMTUD 且不允许中间设备分片, + 绝大多数场景无需钳制;只有 PPPoE / 隧道把 MTU 压到 1500 以下时才需要。 + """ + lan_set = ', '.join(f'"{i}"' for i in lan_ifaces) or '"br0"' + log_drop = bool(log_drop) + log_accept = bool(log_accept) + try: + log_rate = max(1, min(int(log_rate or FW_LOG_RATE), 1000)) + except Exception: + log_rate = FW_LOG_RATE + pf_nat, pf_filter = _pf_rules( + {'pf_enable': pf_enable, 'pf_rules': pf_rules, + 'pf_dmz_enable': pf_dmz_enable, 'pf_dmz_host': pf_dmz_host, + 'wan_iface': wan}, '6', 'ip6') + s = [ + NFT_HEADER, + 'table ip6 drouter6 {', + ' set lanifs {', + ' type ifname', + f' elements = {{ {lan_set} }}' if lan_set else ' elements = { "br0" }', + ' }', + '', + ' chain input {', + ' type filter hook input priority filter; policy accept;', + ' iif "lo" accept', + ' ct state established,related accept', + ] + if log_drop: + s += [_fw_log_rule('6-IN-INVALID', log_rate, '入站非法状态包(已记录)'), + ' ct state invalid drop'] + else: + s += [' ct state invalid drop'] + s += [ + ' # ICMPv6 是 IPv6 正常工作的基础,必须放通', + ' icmpv6 type { echo-request, echo-reply, nd-router-advert, nd-router-solicit,', + ' nd-neighbor-solicit, nd-neighbor-advert, destination-unreachable,', + ' packet-too-big, parameter-problem, time-exceeded } accept', + ' iifname @lanifs accept', + ] + if log_accept: + s += [_fw_log_rule('6-IN-ACCEPT', log_rate, '入站被放通(已记录)')] + if log_drop: + s += [_fw_log_rule('6-IN-REJECT', log_rate, '入站默认拒绝(已记录)')] + s += [ + ' }', + '', + ' chain forward {', + ' type filter hook forward priority filter; policy accept;', + ' ct state established,related accept', + ] + if log_drop: + s += [_fw_log_rule('6-FWD-INVALID', log_rate, '转发非法状态包(已记录)'), + ' ct state invalid drop'] + else: + s += [' ct state invalid drop'] + s += [ + ' icmpv6 type { echo-request, destination-unreachable, packet-too-big,', + ' parameter-problem, time-exceeded } accept', + ' iifname @lanifs accept', + ] + if log_accept: + s += [_fw_log_rule('6-FWD-ACCEPT', log_rate, '转发被放通(已记录)')] + # 端口转发 / DMZ 放通(IPv6 直接路由,无需 NAT,仅需放通) + if pf_filter: + s += [' # ---- 端口转发 / DMZ 放通(由「端口转发」页面生成)----'] + s += pf_filter + if log_drop: + s += [_fw_log_rule('6-FWD-REJECT', log_rate, '转发默认拒绝(已记录)')] + s += [ + ' }', + ] + if pf_nat: + s += [ + '', + ' # ---- IPv6 端口转发(DNAT,仅当地址非直连时使用)----', + ' chain nat_pre {', + ' type nat hook prerouting priority dstnat; policy accept;', + ] + s += pf_nat + s += [' }'] + if masquerade: + s += [ + '', + ' # ---- NAT66:上游只给一个 /64 时才需要 ----', + ' chain nat_post {', + ' type nat hook postrouting priority srcnat; policy accept;', + f' oifname "{wan}" masquerade comment "IPv6 出向地址伪装(NAT66)"', + ' }', + ] + if mss_clamp: + s += [ + '', + ' chain mangle_forward {', + ' type filter hook forward priority mangle; policy accept;', + _mss_rule(wan, mss_mode, mss, 'IPv6 MSS 钳制'), + ' }', + ] + s += [ + '}', + ] + return '\n'.join(s) + '\n' + + +def render_nft(kind, cfg): + """ + kind: 'v4' | 'v6' + cfg: {wan_iface, lan_ifaces[], mss_clamp, mss, raw, + log_drop, log_accept, log_rate, portfwd} + 如果用户提供了 raw 自定义规则,则直接采用 raw(高级模式) + """ + wan = v_ifname(cfg.get('wan_iface') or 'ppp0', field='WAN 接口') + lans = cfg.get('lan_ifaces') or [] + lans = [v_ifname(x, field='LAN 接口') for x in lans] or ['br0'] + raw = (cfg.get('raw') or '').strip() + log_kw = { + 'log_drop': cfg.get('log_drop', True), + 'log_accept': cfg.get('log_accept', False), + 'log_rate': v_int(cfg.get('log_rate') or FW_LOG_RATE, + field='防火墙日志限速', lo=1, hi=1000), + } + pf = cfg.get('portfwd') or {} + pf_kw = { + 'pf_enable': bool(pf.get('enable')), + 'pf_rules': pf.get('rules') or [], + 'pf_dmz_enable': bool((pf.get('dmz') or {}).get('enable')), + 'pf_dmz_host': (pf.get('dmz') or {}).get('host') or '', + } + if raw: + return raw if raw.endswith('\n') else raw + '\n' + mss = v_int(cfg.get('mss') or (1452 if kind == 'v4' else 1432), + field='MSS', lo=576, hi=9000) + mss_mode = str(cfg.get('mss_mode') or 'clamp') + if mss_mode not in ('clamp', 'fixed'): + raise ValidateError('MSS 方式只能是 clamp(按 MTU 自动算)或 fixed(写死数值)') + masq = v_bool(cfg.get('masquerade', True if kind == 'v4' else False)) + if kind == 'v4': + return _default_v4(wan, lans, v_bool(cfg.get('mss_clamp', True)), mss, + **log_kw, **pf_kw, masquerade=masq, mss_mode=mss_mode) + return _default_v6(wan, lans, **log_kw, **pf_kw, + mss_clamp=v_bool(cfg.get('mss_clamp', False)), + mss=mss, mss_mode=mss_mode, masquerade=masq) + + +# ---------------------------------------------------------------- 端口转发 / DMZ(#7) +# +# 目标:用 nftables 的 DNAT(prerouting)实现主流、稳定、好用的端口转发与 DMZ。 +# +# 设计要点: +# * 规则进入已有的 drouter4 / drouter6 表,避免多个表互相遮蔽; +# * 采用「转发 + 放通」两段式:nat 链做 DNAT,filter forward 链放通; +# 这样即使 forward 默认策略是 accept,也能显式记录与统计; +# * 同时自动生成内网侧的 hairpin(NAT 回环)规则,使「内网用公网域名访问」也能通; +# * DMZ = 「全部端口转发到某台主机」的快捷方式,等价于 1:1 全端口映射, +# 国内家用最常见的做法;产生一条规则并自动放通。 + +PF_PROTO_CHOICES = ['tcp', 'udp', 'tcp/udp'] + + +def _pf_rules_v4(cfg): + """生成 IPv4 端口转发与 DMZ 的 nft 片段。""" + return _pf_rules(cfg, '4', 'ip') + + +def _pf_rules_v6(cfg): + """生成 IPv6 端口转发与 DMZ 的 nft 片段(IPv6 不需要 masquerade/DNAT 回环处理一致)。""" + return _pf_rules(cfg, '6', 'ip6') + + +def _pf_rules(cfg, fam, family_kw): + """按协议族生成端口转发规则集合。 + + cfg 内字段: + pf_enable : 总开关 + pf_rules : [{name, proto, ext_port, int_ip, int_port, enable, comment}] + pf_dmz_enable : DMZ 开关 + pf_dmz_host : DMZ 主机内网地址 + 返回 (nat_lines, filter_lines) + """ + nat_lines = [] + filter_lines = [] + + # ---- 普通端口转发 ---- + if cfg.get('pf_enable'): + for i, r in enumerate(cfg.get('pf_rules') or []): + if not r.get('enable', True): + continue + proto = (r.get('proto') or 'tcp').strip().lower() + if proto not in PF_PROTO_CHOICES: + raise ValidateError('端口转发第 %d 条协议不合法:%s' % (i + 1, proto)) + extp = v_int(r.get('ext_port'), field='端口转发第 %d 条外部端口' % (i + 1), + lo=1, hi=65535) + intp = v_int(r.get('int_port') or r.get('ext_port'), + field='端口转发第 %d 条内部端口' % (i + 1), lo=1, hi=65535) + ip = r.get('int_ip') + if fam == '4': + ip = v_ipv4(ip, '端口转发第 %d 条内网 IP' % (i + 1)) + else: + ip = v_ipv6(ip, '端口转发第 %d 条内网 IPv6' % (i + 1)) + name = (r.get('name') or '').replace('"', '')[:40] + cmt = name or ('端口转发 %d' % (i + 1)) + protos = ['tcp', 'udp'] if proto == 'tcp/udp' else [proto] + for pr in protos: + nat_lines.append( + ' iifname "%s" %s dport %d dnat to %s%s%s comment "%s"' + % (v_ifname(cfg.get('wan_iface') or 'ppp0', field='WAN 接口'), + ('tcp' if pr == 'tcp' else 'udp'), extp, + '[' + ip + ']' if fam == '6' else ip, + (':' + str(intp)) if intp != extp else '', + '', '%s(%s→%s)' % (cmt, pr, extp))) + filter_lines.append( + ' %s dport %d %s daddr %s accept comment "放通转发:%s"' + % (('tcp' if pr == 'tcp' else 'udp'), intp, + 'ip6' if fam == '6' else 'ip', + ip, cmt)) + + # ---- DMZ(全端口映射到某台主机)---- + if cfg.get('pf_dmz_enable') and (cfg.get('pf_dmz_host') or '').strip(): + host = cfg.get('pf_dmz_host') + if fam == '4': + host = v_ipv4(host, 'DMZ 主机 IP') + else: + host = v_ipv6(host, 'DMZ 主机 IPv6') + wan = v_ifname(cfg.get('wan_iface') or 'ppp0', field='WAN 接口') + dest = '[' + host + ']' if fam == '6' else host + # 排除到本机自身的流量,避免把管理口也转走 + nat_lines.append( + ' iifname "%s" %s daddr != %s dnat to %s comment "DMZ 全端口映射"' + % (wan, family_kw, dest, dest)) + filter_lines.append( + ' %s daddr %s accept comment "放通 DMZ 主机"' % (family_kw, host)) + return nat_lines, filter_lines + + +# ---------------------------------------------------------------- systemd-networkd + +def render_network(cfg): + """ + 返回 [{name, content}],写入 /etc/systemd/network/ + cfg: {wan: {...}, lan: {...}, bridge: {...}, ifaces: [...]} + """ + out = [] + wan = cfg.get('wan') or {} + wif = v_ifname(wan.get('iface') or '', field='WAN 网卡') + wmode = v_choice(wan.get('mode') or 'dhcp', ['dhcp', 'static', 'pppoe'], field='WAN 模式') + + # ---- WAN ---- + if wmode == 'pppoe': + # PPPoE 的底层网卡保持 up、不配地址,由 pppd 创建 ppp0 + out.append({ + 'name': f'10-drouter-wan-{wif}.network', + 'content': '\n'.join([ + '# 由 drouter 自动生成:PPPoE 底层网卡(不配置 IP,由 pppd 接管)', + '[Match]', + f'Name={wif}', + '', + '[Link]', + f"MTUBytes={v_int(wan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}", + 'RequiredForOnline=no', + '', + '[Network]', + 'LinkLocalAddressing=no', + 'LLDP=no', + 'EmitLLDP=no', + 'IPv6AcceptRA=no', + 'DHCP=no', + '', + ]) + }) + elif wmode == 'static': + lines = [ + '# 由 drouter 自动生成:WAN 静态地址', + '[Match]', + f'Name={wif}', + '', + '[Link]', + f"MTUBytes={v_int(wan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}", + '', + '[Network]', + f"Address={v_cidr(wan.get('address'), field='WAN 地址')}", + ] + gw = (wan.get('gateway') or '').strip() + if gw: + lines.append(f'Gateway={v_ipv4(gw, field="WAN 网关")}') + for d in v_ipv4_list(wan.get('dns') or '', field='WAN DNS'): + lines.append(f'DNS={d}') + lines += ['', '[Route]', 'GatewayOnLink=yes', ''] + out.append({'name': f'10-drouter-wan-{wif}.network', 'content': '\n'.join(lines)}) + else: + out.append({ + 'name': f'10-drouter-wan-{wif}.network', + 'content': '\n'.join([ + '# 由 drouter 自动生成:WAN 自动获取(DHCP)', + '[Match]', + f'Name={wif}', + '', + '[Link]', + f"MTUBytes={v_int(wan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}", + '', + '[Network]', + 'DHCP=yes', + 'IPv6AcceptRA=yes', + '', + '[DHCPv4]', + 'UseDNS=yes', + 'UseRoutes=yes', + f"RouteMetric={v_int(wan.get('metric') or 100, field='路由优先级', lo=1, hi=9999)}", + '', + ]) + }) + + # ---- 桥接 ---- + br = cfg.get('bridge') or {} + brname = (br.get('name') or 'br0').strip() or 'br0' + members = [v_ifname(x, field='桥接成员') for x in (br.get('members') or [])] + if v_bool(br.get('enabled')) and members: + out.append({ + 'name': f'20-drouter-{brname}.netdev', + 'content': '\n'.join([ + '# 由 drouter 自动生成:局域网网桥', + '[NetDev]', + f'Name={v_ifname(brname, field="网桥名")}', + 'Kind=bridge', + '', + '[Bridge]', + 'STP=no' if not v_bool(br.get('stp')) else 'STP=yes', + f"ForwardDelaySec={v_int(br.get('fd') or 2, field='转发延迟', lo=0, hi=30)}", + '', + ]) + }) + for m in members: + out.append({ + 'name': f'21-drouter-{brname}-member-{m}.network', + 'content': '\n'.join([ + '# 由 drouter 自动生成:桥接成员口', + '[Match]', + f'Name={m}', + '', + '[Network]', + f'Bridge={v_ifname(brname, field="网桥名")}', + 'LinkLocalAddressing=no', + '', + ]) + }) + # 桥本身承载 IP + lan = cfg.get('lan') or {} + addr = v_cidr(lan.get('address') or '192.168.7.1/24', field='LAN 地址') + lines = [ + '# 由 drouter 自动生成:局域网桥地址', + '[Match]', + f'Name={v_ifname(brname, field="网桥名")}', + '', + '[Network]', + f'Address={addr}', + 'DHCP=no', + 'LinkLocalAddressing=ipv6', + 'IPv6AcceptRA=no', + ] + if v_bool(lan.get('ipv6_assign')) and lan.get('ipv6_addr'): + lines.append(f"Address={lan['ipv6_addr'].strip()}") + lines.append('') + out.append({'name': f'30-drouter-{brname}.network', 'content': '\n'.join(lines)}) + else: + # 无桥接:LAN 地址直接配在单网卡上 + lan = cfg.get('lan') or {} + lif = v_ifname(lan.get('iface') or '', field='LAN 网卡') + addr = v_cidr(lan.get('address') or '192.168.7.1/24', field='LAN 地址') + lines = [ + '# 由 drouter 自动生成:局域网地址', + '[Match]', + f'Name={lif}', + '', + '[Link]', + f"MTUBytes={v_int(lan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}", + '', + '[Network]', + f'Address={addr}', + 'DHCP=no', + 'LinkLocalAddressing=ipv6', + ] + if v_bool(lan.get('ipv6_assign')) and lan.get('ipv6_addr'): + lines.append(f"Address={lan['ipv6_addr'].strip()}") + lines.append('') + out.append({'name': f'30-drouter-lan-{lif}.network', 'content': '\n'.join(lines)}) + return out + + +# ---------------------------------------------------------------- PPPoE + +def _sub(cfg, key): + """兼容两种入参形态:嵌套 {:{...}} 或平铺 {...}。 + 这样前端、helper、单元测试可以统一按平铺结构传参。""" + if not isinstance(cfg, dict): + return {} + v = cfg.get(key) + if isinstance(v, dict): + merged = dict(v) + # 平铺字段若存在且嵌套中缺失,则以平铺为准补齐 + for k, val in cfg.items(): + if k == key: + continue + merged.setdefault(k, val) + return merged + return cfg + + +def render_ppp(cfg): + """ + 返回 [(path, content)]:peers 文件 + secrets + 兼容两种入参:平铺 {iface, username, ...} 或嵌套 {pppoe:{...}} + """ + p = _sub(cfg, 'pppoe') + iface = v_ifname(p.get('iface') or '', field='PPPoE 网卡') + user = (p.get('username') or '').strip() + passwd = (p.get('password') or '').strip() + if not user: + raise ValidateError('PPPoE 用户名不能为空', 'username') + if re.search(r'[\s"\']', user): + raise ValidateError('PPPoE 用户名不能包含空格或引号', 'username') + if not passwd: + raise ValidateError('PPPoE 密码不能为空(仅保存、暂不拨号)', 'password') + if re.search(r'[\s"\']', passwd): + raise ValidateError('PPPoE 密码不能包含空格或引号', 'password') + mtu = v_int(p.get('mtu') or 1492, field='PPPoE MTU', lo=576, hi=9000) + mru = v_int(p.get('mru') or mtu, field='PPPoE MRU', lo=576, hi=9000) + + peers = [ + '# 由 drouter 自动生成:PPPoE 拨号配置', + '# 运营商:' + (p.get('isp') or '自动'), + f'user "{user}"', + f'plugin rp-pppoe.so {iface}', + 'noipdefault', + 'defaultroute', + 'replacedefaultroute', + 'hide-password', + 'noauth', + 'persist' if v_bool(p.get('persist', True)) else '', + f'maxfail {v_int(p.get("maxfail") or 0, field="最大失败次数", lo=0, hi=100)}', + f'holdoff {v_int(p.get("holdoff") or 5, field="重拨间隔", lo=1, hi=600)}', + 'usepeerdns', + 'lcp-echo-interval 20', + 'lcp-echo-failure 3', + f'mtu {mtu}', + f'mru {mru}', + 'noaccomp', + 'nopcomp', + 'novj', + 'novjccomp', + ] + if (p.get('service_name') or '').strip(): + peers.append(f"servicename \"{p['service_name'].strip()}\"") + peers += ['', ''] + secrets = f'"{user}" * "{passwd}"\n' + return [('/etc/ppp/peers/drouter-wan', '\n'.join([l for l in peers if l != ''])), + ('/etc/ppp/chap-secrets', secrets), + ('/etc/ppp/pap-secrets', secrets)] + + +# ---------------------------------------------------------------- radvd (RA) + +def render_radvd(cfg): + r = _sub(cfg, 'ra') + iface = v_ifname(r.get('iface') or 'br0', field='RA 接口') + prefix = (r.get('prefix') or '').strip() + lines = [ + '# 由 drouter 自动生成:IPv6 路由通告 (RA)', + f'interface {iface} {{', + ' AdvSendAdvert on;', + f" AdvManagedFlag {'on' if v_bool(r.get('managed')) else 'off'};", + f" AdvOtherConfigFlag {'on' if v_bool(r.get('other_config')) else 'off'};", + f" MaxRtrAdvInterval {v_int(r.get('max_interval') or 600, field='RA 最大间隔', lo=4, hi=1800)};", + f" MinRtrAdvInterval {v_int(r.get('min_interval') or 200, field='RA 最小间隔', lo=3, hi=1350)};", + f" AdvDefaultLifetime {v_int(r.get('lifetime') or 1800, field='默认生命周期', lo=0, hi=9000)};", + f" AdvCurHopLimit {v_int(r.get('hop_limit') or 64, field='跳数限制', lo=0, hi=255)};", + f" AdvReachableTime {v_int(r.get('reachable') or 0, field='可达时间', lo=0, hi=3600000)};", + f" AdvRetransTimer {v_int(r.get('retrans') or 0, field='重传计时', lo=0, hi=60000)};", + ] + if prefix: + try: + ipaddress.IPv6Network(prefix, strict=False) + except Exception: + raise ValidateError(f'IPv6 前缀不合法:{prefix}', 'prefix') + lines += [ + f' prefix {prefix} {{', + ' AdvOnLink on;', + ' AdvAutonomous on;', + f" AdvValidLifetime {v_int(r.get('valid_life') or 86400, field='有效生命期', lo=60, hi=4294967295)};", + f" AdvPreferredLifetime {v_int(r.get('pref_life') or 14400, field='首选生命期', lo=60, hi=4294967295)};", + ' };', + ] + if r.get('rdnss'): + lines.append(f" RDNSS {r['rdnss'].strip()} {{") + lines.append(f" AdvRDNSSLifetime {v_int(r.get('rdnss_life') or 600, field='RDNSS 生命期', lo=60, hi=9000)};") + lines.append(' };') + if r.get('dnssl'): + dom = r['dnssl'].strip() + if not DOMAIN_RE.match(dom): + raise ValidateError(f'DNSSL 域名不合法:{dom}', 'dnssl') + lines.append(f' DNSSL {dom} {{') + lines.append(f" AdvDNSSLLifetime {v_int(r.get('rdnss_life') or 600, field='DNSSL 生命期', lo=60, hi=9000)};") + lines.append(' };') + lines += ['};', ''] + # IPv6 地址池说明块(供人工与后续脚本参考;radvd 本身通过 prefix 段生效) + if r.get('pool_enabled', True) is not False: + pol = r.get('pool_policy') or [] + lines += [ + '# ---------------- IPv6 地址池 / 池策略(RouterOS 风格) ----------------', + f"# 池名称:{r.get('pool_name') or 'drouter-wan-pool'}", + f"# 地址来源:{r.get('pool_from') or 'pool'}", + f"# 池前缀:{r.get('pool_prefix') or '(跟随运营商 PD)'}", + f"# 子网 ID 长度:{v_int(r.get('pool_subnet_bits') or 8, field='子网 ID 长度', lo=0, hi=64)}", + f"# 分配范围:子网 ID {v_int(r.get('pool_start') or 1, field='起始子网 ID', lo=0, hi=65535)}" + f" - {v_int(r.get('pool_end') or 254, field='结束子网 ID', lo=0, hi=65535)}", + f"# 池策略:{', '.join(pol) if pol else 'recommend'}", + '# ---------------------------------------------------------------------', + '', + ] + return '\n'.join(lines) + + +# ---------------------------------------------------------------- dhcpcd (DHCPv6-PD) + +def render_dhcpcd(cfg): + d = _sub(cfg, 'dhcpv6') + wan = v_ifname(d.get('iface') or d.get('wan_iface') or 'ppp0', field='DHCPv6 WAN 接口') + lan = v_ifname(d.get('lan_iface') or 'br0', field='DHCPv6 LAN 接口') + # 前端传来的前缀长度可能是 "/60" 这种带斜杠形式 + raw_len = str(d.get('prefix_len') or 64).lstrip('/') + try: + plen = int(raw_len) + except Exception: + plen = 64 + enabled = d.get('enabled', True) is not False + lines = [ + '# 由 drouter 自动生成:DHCPv6 客户端 / 前缀委派 (PD)', + '# 注意:是否真正拿到 PD 前缀取决于运营商,页面会显示实际获取结果', + 'duid', + 'noipv6rs', + 'waitip 6', + '', + f'interface {wan}', + ] + if not enabled: + lines.insert(0, '# DHCPv6 客户端已在页面中关闭,以下为保留配置(未启用)') + if v_bool(d.get('request_address')): + lines.append(' ipv6rs') + if v_bool(d.get('request_pd', True)): + iaid = v_int(d.get('iaid') or 0, field='IAID', lo=0, hi=4294967295) + suffix = (d.get('sla_id') or '::1').strip() + lines.append(f' ia_pd {iaid}/{lan}/0/{plen}/{suffix}') + if d.get('rapid_commit'): + lines.append(' option rapid_commit') + if d.get('slaac') or d.get('method') in ('slaac', 'both'): + lines.append(' ia_na 1') + lines += ['', f'interface {lan}', ' noipv6rs', ''] + return '\n'.join(lines) + + +# ---------------------------------------------------------------- miniupnpd + +def render_miniupnpd(cfg): + u = _sub(cfg, 'upnp') + ext = v_ifname(u.get('ext_iface') or 'ppp0', field='UPnP 外网接口') + # 监听网卡名(nftables/新版 miniupnpd 需要网卡名而非 IP: + # 用 IP 会告警 "it is advised to use network interface name instead", + # 且会禁用 IPV6/SSDPv6 支持)。listen_ip 字段仅用于兼容旧配置。 + lan_if = u.get('lan_iface') or u.get('listen_ip') or 'ens18' + try: + lan_if = v_ifname(lan_if, field='UPnP 监听接口') + except Exception: + lan_if = 'ens18' + # UPnP 权限规则:仅允许内网网段申请临时端口(>=1024),末尾全拒。 + # 网段取自 system.lan_address;取不到时退回 192.168.0.0/16 保守放行。 + lan_cidr = '192.168.0.0/16' + sysc = _sub(cfg, 'system') + addr = str(sysc.get('lan_address') or '') + if '/' in addr: + try: + lan_cidr = v_cidr(addr, field='UPnP 允许网段') + except Exception: + lan_cidr = '192.168.0.0/16' + elif addr: + try: + lan_cidr = v_ipv4(addr, field='UPnP 允许网段') + '/24' + except Exception: + lan_cidr = '192.168.0.0/16' + lines = [ + '# 由 drouter 自动生成:miniupnpd', + f'ext_ifname={ext}', + f'listening_ip={lan_if}', + # http_port 是 nftables/新版 miniupnpd 的正确选项名; + # 写成 port= 会被忽略(旧版 iptables 变体才用 port=)。 + f"http_port={v_int(u.get('port') or 5000, field='UPnP 端口', lo=1024, hi=65535)}", + 'enable_upnp=yes' if v_bool(u.get('enable', True)) else 'enable_upnp=no', + 'enable_natpmp=yes' if v_bool(u.get('natpmp')) else 'enable_natpmp=no', + f"secure_mode={'yes' if v_bool(u.get('secure_mode', True)) else 'no'}", + # nftables 后端(miniupnpd 2.3.9)支持的 chain 选项。 + # 注意:upnp_nat_prerouting_chain 在 2.3.x 中并不存在, + # 写入会触发 "invalid option" 导致 miniupnpd 启动失败(实测 2026-09-28)。 + 'upnp_forward_chain=forward_miniupnpd', + 'upnp_nat_chain=prerouting_miniupnpd', + 'upnp_nat_postrouting_chain=postrouting_miniupnpd', + 'system_uptime=yes', + f"lease_file={u.get('lease_file') or '/var/run/miniupnpd.leases'}", + f"uuid={u.get('uuid') or 'drouter-0001'}", + 'force_igd_desc_v1=yes' if v_bool(u.get('igd_v1')) else '', + '', + '# UPnP 权限规则:仅允许内网申请临时端口,末尾默认拒绝', + f'allow 1024-65535 {lan_cidr} 1024-65535', + 'deny 0-65535 0.0.0.0/0 0-65535', + ] + return '\n'.join([l for l in lines if l != '']) + + +# ---------------------------------------------------------------- chrony + +def render_chrony(cfg): + n = _sub(cfg, 'ntp') + raw = n.get('servers') + if isinstance(raw, (list, tuple)): + servers = [str(x).strip() for x in raw if str(x).strip()] + else: + servers = [x.strip() for x in (raw or '').split(',') if x.strip()] + if not servers: + servers = ['ntp.aliyun.com', 'time.cloudflare.com'] + for s in servers: + if not DOMAIN_RE.match(s) and not IPV4_RE.match(s): + raise ValidateError(f'NTP 服务器地址不合法:{s}', 'servers') + lines = ['# 由 drouter 自动生成:NTP 客户端'] + for s in servers: + lines.append(f'server {s} iburst') + lines += [ + 'driftfile /var/lib/chrony/chrony.drift', + 'rtcsync', + 'makestep 1.0 3', + 'leapsectz right/UTC', + '', + ] + if v_bool(n.get('allow_lan')) and n.get('allow_net'): + lines.append(f'allow {v_cidr(n["allow_net"], field="允许同步的网段")}') + return '\n'.join(lines) + + +# ------------------------------------------------- SMB / NFS 内网文件共享(#9) + +# 用户名 / 组名:Samba 与 NFS 共用,限制为 POSIX 可移植字符集 +UNAME_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,30}\$?$') +SHARE_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$') + + +def v_uname(v, field='名称', allow_empty=False): + v = (v or '').strip() + if not v: + if allow_empty: + return '' + raise ValidateError(f'{field}不能为空', field) + if not UNAME_RE.match(v): + raise ValidateError( + f'{field}不合规:{v}(只能用小写字母、数字、下划线、短横线,且以字母或下划线开头)', field) + return v + + +def v_path(v, field='路径'): + """共享目录路径:必须是绝对路径,且不含 .. 与 NUL,避免目录穿越。""" + v = (v or '').strip() + if not v: + raise ValidateError(f'{field}不能为空', field) + if not v.startswith('/'): + raise ValidateError(f'{field}必须是绝对路径(以 / 开头):{v}', field) + if '\x00' in v or '..' in v.split('/'): + raise ValidateError(f'{field}不能包含 .. 或空字节:{v}', field) + if not re.match(r'^/[A-Za-z0-9._/\u4e00-\u9fff-]*$', v): + raise ValidateError(f'{field}含不支持的字符:{v}', field) + return v.rstrip('/') or '/' + + +def listify(v): + """把 list / 逗号或空白分隔的字符串统一成去空列表。""" + if isinstance(v, (list, tuple)): + return [str(x).strip() for x in v if str(x).strip()] + return [x.strip() for x in re.split(r'[\s,;]+', v or '') if x.strip()] + + +def render_samba(cfg): + """渲染 smb.conf(/etc/samba/drouter.conf,由主配置 include)。""" + sb = _sub(cfg, 'samba') + workgroup = (sb.get('workgroup') or 'WORKGROUP').strip().upper() + if not re.match(r'^[A-Z0-9_-]{1,15}$', workgroup): + raise ValidateError('工作组名不合法(1–15 位大写字母/数字/-/_)', 'workgroup') + server_string = (sb.get('server_string') or '').strip() + if server_string and not re.match(r'^[A-Za-z0-9\u4e00-\u9fff ._-]{1,48}$', server_string): + raise ValidateError('服务器描述含不支持的字符(可用中英文、数字、空格、. _ -)', 'server_string') + + lines = [ + '# 由 drouter 自动生成:SMB / CIFS 文件共享(#9)', + '# 被 /etc/samba/smb.conf 通过 include 引入,请勿手改。', + '', + '[global]', + f' workgroup = {workgroup}', + ' server role = standalone server', + ' # 关闭 NetBIOS/老协议,只保留 SMB2/3,兼容 Windows 10/11、macOS 与 Linux', + ' server min protocol = SMB2', + ' client min protocol = SMB2', + ' server max protocol = SMB3', + ' # 让 macOS 更容易发现(Bonjour / mDNS)', + ' multicast dns register = yes', + ' # 各平台文件名兼容:UTF-8 + 规范化,避免中文乱码', + ' unix charset = UTF-8', + ' dos charset = CP936', + ' map to guest = bad user', + ' log file = /var/log/samba/log.%m', + ' max log size = 1000', + ' logging = file', + ' panic action = /usr/share/samba/panic-action %d', + ' server string = %s' % (server_string or 'drouter 文件共享'), + '', + ] + if v_bool(sb.get('disable_netbios')): + lines += [' # 关闭 NetBIOS 名称服务(纯本地网段建议开启,减少广播)', + ' disable netbios = yes', ''] + if v_bool(sb.get('wins_enable')) and sb.get('wins_support'): + lines += [' # 由本机充当 WINS 服务器', + ' wins support = yes', + ' local master = yes', + ' preferred master = yes', + ' os level = 65', ''] + # 多网卡 / 指定监听 + ifaces = listify(sb.get('interfaces')) + if ifaces: + for i in ifaces: + if not IFNAME_RE.match(i): + raise ValidateError(f'监听网卡名不合法:{i}', 'interfaces') + lines += [f' interfaces = {" ".join(ifaces)}', + ' bind interfaces only = yes', ''] + for extra in listify(sb.get('global_extra')): + lines.append(f' {extra}') + if listify(sb.get('global_extra')): + lines.append('') + + shares = sb.get('shares') or [] + used = set() + for i, s in enumerate(shares): + if not v_bool(s.get('enable', True)): + continue + name = (s.get('name') or '').strip() + if not name: + raise ValidateError(f'第 {i + 1} 个共享缺少名称', 'name') + if not SHARE_NAME_RE.match(name): + raise ValidateError( + f'共享名「{name}」不合法:只能用字母、数字、. _ -,且不能以符号开头', 'name') + if name.lower() in used: + raise ValidateError(f'共享名重复:{name}', 'name') + used.add(name.lower()) + path = v_path(s.get('path'), field=f'共享「{name}」的目录') + mode = (s.get('mode') or 'auth').strip() + comment = (s.get('comment') or '').strip().replace('\n', ' ') + lines.append(f'[{name}]') + lines.append(f' path = {path}') + lines.append(f' comment = {comment or name}') + lines.append(' browseable = yes') + lines.append(' read only = %s' % ('no' if v_bool(s.get('writable')) else 'yes')) + lines.append(' create mask = 0664') + lines.append(' directory mask = 0775') + lines.append(' # 让复制进来的文件继承父目录的组与 setgid 位,跨用户可写') + lines.append(' inherit permissions = yes') + lines.append(' vfs objects = catia fruit streams_xattr') + lines.append(' fruit:metadata = stream') + lines.append(' fruit:model = MacSamba') + lines.append(' # macOS 会写一些以 ._ 开头的元数据文件,隐藏它们') + lines.append(' fruit:veto_appledouble = no') + lines.append(' fruit:posix_rename = yes') + lines.append(' fruit:zero_file_id = yes') + lines.append(' fruit:nfs_aces = no') + + if mode == 'public': + lines += [' guest ok = yes', ' guest only = yes', + ' force user = nobody', ' force group = nogroup'] + elif mode == 'readonly': + lines += [' guest ok = yes', ' read only = yes'] + else: + lines += [' guest ok = no', ' valid users = %s' % ( + ', '.join(v_uname(u, field=f'共享「{name}」的允许用户') + for u in (listify(s.get('users')) or ['drouter'])))] + + allowed = listify(s.get('allow_hosts')) + if allowed: + # Samba 用空格分隔,支持 192.168.7.0/24 形式 + for a in allowed: + try: + ipaddress.ip_network(a, strict=False) + except Exception: + raise ValidateError(f'共享「{name}」允许网段不合法:{a}', 'allow_hosts') + lines.append(' hosts allow = %s' % ' '.join(allowed)) + lines.append(' hosts deny = 0.0.0.0/0 ::/0') + denied = listify(s.get('deny_hosts')) + if denied: + lines.append(' hosts deny = %s' % ' '.join(denied)) + for extra in listify(s.get('extra')): + lines.append(f' {extra}') + lines.append('') + return '\n'.join(lines) + + +# NFS 客户端导出选项:按「平台模板」预置,兼顾兼容性与安全性 +NFS_OPTS = { + 'linux': 'rw,sync,no_subtree_check,secure,root_squash', + 'macos': 'rw,sync,no_subtree_check,secure,all_squash,insecure', + 'windows': 'rw,sync,no_subtree_check,secure,all_squash,no_auth_nlm,insecure,anonuid=65534,anongid=65534', + 'readonly': 'ro,sync,no_subtree_check,secure,root_squash', + 'legacy': 'rw,sync,no_subtree_check,insecure,no_root_squash', +} + + +def render_nfs(cfg): + """渲染 /etc/exports(NFSv4 + NFSv3 兼容)。""" + n = _sub(cfg, 'nfs') + lines = [ + '# 由 drouter 自动生成:NFS 导出(#9)', + '# 只做内网共享,未列出的网段默认拒绝(由 nftables 放通 2049/111)。', + '', + ] + exports = n.get('exports') or [] + if not exports or not any(v_bool(x.get('enable', True)) for x in exports): + lines.append('# 未配置任何导出(NFS 已启用但没有共享项)') + return '\n'.join(lines) + '\n' + + for i, e in enumerate(exports): + if not v_bool(e.get('enable', True)): + continue + path = v_path(e.get('path'), field=f'第 {i + 1} 个 NFS 导出目录') + clients = e.get('clients') or [] + if not clients: + raise ValidateError(f'NFS 导出「{path}」至少需要一个客户端网段', 'clients') + seen = set() + rendered = [] + for c in clients: + net = (c.get('net') or '').strip() + preset = (c.get('preset') or 'linux').strip().lower() + if not net: + raise ValidateError(f'NFS 导出「{path}」存在空的客户端网段', 'clients') + # 通配符 * 允许,其余按 CIDR / 单机地址校验 + if net != '*': + # 支持 192.168.7.0/24 与 IPv6 前缀 + try: + ipaddress.ip_network(net, strict=False) + except Exception: + raise ValidateError( + f'NFS 导出「{path}」的客户端不合法:{net}' + f'(应为 * 或 CIDR,如 192.168.7.0/24)', 'clients') + if net in seen: + continue + seen.add(net) + opts = (c.get('options') or '').strip() or NFS_OPTS.get(preset, NFS_OPTS['linux']) + # 兜底:用户自定义选项里若出现危险组合,给出中文提示 + if 'no_root_squash' in opts and preset != 'legacy': + raise ValidateError( + f'NFS 导出「{path}」对 {net} 使用了 no_root_squash,' + f'这会让客户端 root 直接以 root 身份写共享目录,存在安全风险。' + f'如确需如此,请把模板选为「兼容旧设备(不安全)」。', 'options') + rendered.append(f'{net}({opts})') + lines.append(f'# {e.get("comment") or path}') + lines.append(f'{path} {" ".join(rendered)}') + lines.append('') + return '\n'.join(lines) + + +def render_nfs_conf(cfg): + """渲染 /etc/nfs.conf.d/drouter.conf:线程数 / 版本 / 端口固定。""" + n = _sub(cfg, 'nfs') + threads = int(n.get('threads') or 8) + if not (1 <= threads <= 128): + raise ValidateError('NFS 服务线程数应在 1–128 之间', 'threads') + lines = [ + '# 由 drouter 自动生成:NFS 服务端参数(#9)', + '# 固定端口,便于在 nftables 中精确放通。', + '', + '[nfsd]', + f'threads={threads}', + 'vers3=y', + 'vers4=y', + 'vers4.0=y', + 'vers4.1=y', + 'vers4.2=y', + '# 固定端口,避免防火墙需要放通随机端口', + 'port=2049', + '', + '[mountd]', + 'port=20048', + 'manage-gids=y', + '', + '[statd]', + 'port=32765', + 'outgoing-port=32766', + '', + '[lockd]', + 'port=32767', + '', + ] + return '\n'.join(lines) + + +# ---------------------------------------------------------------- 汇总渲染入口 + +RENDERERS = { + 'dnsmasq': lambda cfg: [('/etc/dnsmasq.d/drouter.conf', render_dnsmasq(cfg))], + 'nft_v4': lambda cfg: [('/etc/nftables.d/drouter-v4.nft', render_nft('v4', cfg))], + 'nft_v6': lambda cfg: [('/etc/nftables.d/drouter-v6.nft', render_nft('v6', cfg))], + 'network': render_network, + 'ppp': render_ppp, + 'radvd': lambda cfg: [('/etc/radvd.conf', render_radvd(cfg))], + 'dhcpv6': lambda cfg: [('/etc/dhcpcd.conf', render_dhcpcd(cfg))], + 'miniupnpd': lambda cfg: [('/etc/miniupnpd/miniupnpd.conf', render_miniupnpd(cfg))], + 'chrony': lambda cfg: [('/etc/chrony/chrony.conf', render_chrony(cfg))], + 'samba': lambda cfg: [('/etc/samba/drouter.conf', render_samba(cfg))], + 'nfs': lambda cfg: [('/etc/exports', render_nfs(cfg)), + ('/etc/nfs.conf.d/drouter.conf', render_nfs_conf(cfg))], +} + +# 前端/API 使用的模块别名 -> 渲染模块名 +ALIASES = { + 'upnp': 'miniupnpd', + 'ntp': 'chrony', + 'dhcpv6': 'dhcpv6', + 'nfs': 'nfs', + 'smb': 'samba', + # WAN 口页面在前端叫 pppoe,渲染器叫 ppp(render_ppp 用 _sub(cfg,'pppoe') + # 兼容平铺入参,所以别名解析后依然能拿到正确的字段) + 'pppoe': 'ppp', +} + + +def canonical(module): + """把前端/API 使用的模块名解析成真正的渲染模块名。 + + helper 的语法预检要按「渲染模块名」分支(chrony / miniupnpd …), + 如果直接拿前端传来的别名(ntp / upnp)去比,会静默跳过语法检查。 + """ + return ALIASES.get(module, module) + + +def render(module, cfg): + """返回 [(path, content)];校验失败抛 ValidateError(含中文提示)""" + module = ALIASES.get(module, module) + if module not in RENDERERS: + raise ValidateError(f'未知的配置模块:{module}') + return RENDERERS[module](cfg) + + +if __name__ == '__main__': + # 简易自测:渲染默认配置,确认无语法异常 + demo = { + 'lan_iface': 'br0', 'domain': 'lan', + 'dhcp_enabled': True, 'pool_start': '192.168.7.100', 'pool_end': '192.168.7.200', + 'pool_netmask': '255.255.255.0', 'lease_time': 7200, + 'option_dns': '223.5.5.5,119.29.29.29', + 'options': [{'enabled': True, 'code': 3, 'value': '192.168.7.1', 'force': True}], + 'dns_mode': 'custom', 'dns_custom': '223.5.5.5', + 'wan_iface': 'ppp0', 'lan_ifaces': ['br0'], 'mss_clamp': True, + } + print(render_dnsmasq(demo)) + print('---- nft v4 ----') + print(render_nft('v4', demo)) + print('SELFTEST OK') diff --git a/backend/theme.py b/backend/theme.py new file mode 100644 index 0000000..8f5129e --- /dev/null +++ b/backend/theme.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +theme —— Drouter「主题之家」主题引擎(纯逻辑,零第三方依赖,可离线单测) + +职责: + * 定义主题的数据结构(变量表 + 元信息) + * 严格校验一个主题包是否合法(不合法给出中文原因,绝不应用) + * 把主题包渲染成一段可用的 CSS(覆盖 :root 变量) + * 打包 / 解包 .zip 主题包(打包用内置 zipfile,解包做目录穿越与炸弹防护) + +设计要点: + * 变量白名单(THEME_VARS)是唯一允许被主题覆盖的 CSS 自定义属性集合; + 这样即使用户上传的主题包含恶意内容,也无法注入到规则体外(值里有严格字符校验)。 + * 颜色值只接受 #rgb / #rrggbb / #rrggbbaa / rgb() / rgba() / hsl() / hsla() + / 具名色,以及少数安全的关键字(transparent / currentColor / none)。 + 任何 `;{}<>`、`url(`、`expression(`、`@`、反斜杠、注释符号都会被拒绝。 + * 数值类变量只接受「数字 + 单位」,单位白名单可控。 + +本模块被 drouter-helper.py import;不依赖 helper 的其它函数,便于单独测试。 +""" + +import io +import os +import re +import json +import time +import zipfile + +# ------------------------------------------------------------------ 常量 + +MAX_THEME_BYTES = 512 * 1024 # 单个主题包解压后总大小上限(防 zip 炸弹) +MAX_ENTRY_BYTES = 256 * 1024 # 单个文件解压后大小上限 +MAX_ENTRIES = 64 # 主题包内文件数量上限 +MAX_NAME_LEN = 48 # 主题名长度 +MAX_CSS_LEN = 64 * 1024 # 自定义 CSS 长度上限 + +THEME_EXT = '.drtheme' + +# 主题变量白名单:变量名 -> 分类(用于前端分组显示) +# 只允许覆盖这些变量,其它变量主题无法触碰。 +THEME_VARS = { + # 基础配色 + '--bg': 'base', '--bg2': 'base', '--panel': 'base', '--panel2': 'base', + '--line': 'base', '--line2': 'base', + '--txt': 'base', '--txt2': 'base', '--txt3': 'base', + # 主色与状态色 + '--pri': 'color', '--pri-d': 'color', '--pri-l': 'color', + # 主色本身若太浅(如暖橙 #f97316),当链接/强调文字用会看不清; + # --pri-text 是同一色调的「可读版本」,--pri 仍保留原色用于填充与按钮。 + '--pri-text': 'color', + '--ok': 'color', '--ok-l': 'color', + '--warn': 'color', '--warn-l': 'color', + '--err': 'color', '--err-l': 'color', + '--info': 'color', '--info-l': 'color', + # 形状与阴影 + '--r': 'shape', '--sh': 'shape', '--grad': 'shape', + '--side': 'layout', '--side-c': 'layout', +} + +# 变量分类的中文名(前端下拉分组用) +THEME_CATS = [ + ('base', '基础配色'), + ('color', '主色与状态色'), + ('shape', '圆角 / 阴影 / 渐变'), + ('layout', '布局尺寸'), +] + +# 变量中文说明(前端提示) +THEME_VAR_CN = { + '--bg': '页面背景', '--bg2': '悬停背景', '--panel': '卡片背景', '--panel2': '表头背景', + '--line': '边框', '--line2': '浅分隔线', + '--txt': '主文字', '--txt2': '次要文字', '--txt3': '弱化文字', + '--pri': '主题主色', '--pri-d': '主色加深', '--pri-l': '主色浅底', + '--pri-text': '主色(文字用,自动加深以保证可读)', + '--ok': '成功色', '--ok-l': '成功浅底', + '--warn': '警告色', '--warn-l': '警告浅底', + '--err': '错误色', '--err-l': '错误浅底', + '--info': '信息色', '--info-l': '信息浅底', + '--r': '卡片圆角', '--sh': '卡片阴影', '--grad': '主按钮渐变', + '--side': '侧栏宽度', '--side-c': '侧栏折叠宽度', +} + +# 值校验:颜色 +# 0~255 的整数写法(避免 [0-9]{1,3} 放行 300 这类越界值) +_N255 = r'(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])' +# 透明度:0 / 1 / .5 / 0.35 +_ALPHA = r'(?:0|1|0?\.\d{1,3})' +# 色相:0~360(可带小数或 deg 后缀) +_HUE = r'(?:\d|[1-9]\d|[12]\d{2}|3[0-5]\d|360)(?:\.\d+)?(?:deg)?' +# 百分比:0~100 +_PCT = r'(?:\d|[1-9]\d|100)%' + +_RE_HEX = re.compile(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$') +_RE_RGB = re.compile(r'^rgba?\(\s*%s\s*,\s*%s\s*,\s*%s(?:\s*,\s*%s)?\s*\)$' + % (_N255, _N255, _N255, _ALPHA)) +_RE_HSL = re.compile(r'^hsla?\(\s*%s\s*,\s*%s\s*,\s*%s(?:\s*,\s*%s)?\s*\)$' + % (_HUE, _PCT, _PCT, _ALPHA)) +_NAMED = { + 'transparent', 'currentcolor', 'black', 'white', 'red', 'green', 'blue', + 'gray', 'grey', 'silver', 'navy', 'teal', 'olive', 'purple', 'orange', + 'yellow', 'pink', 'brown', 'lime', 'aqua', 'cyan', 'magenta', 'maroon', + 'fuchsia', 'gold', 'indigo', 'violet', 'tomato', 'salmon', 'khaki', + 'coral', 'crimson', 'seagreen', 'steelblue', 'slategray', 'slategrey', + 'darkgrey', 'darkgray', 'lightgrey', 'lightgray', 'whitesmoke', 'ivory', + 'beige', 'lavender', 'plum', 'orchid', 'turquoise', 'skyblue', 'royalblue', + 'midnightblue', 'dodgerblue', 'forestgreen', 'darkgreen', 'darkred', + 'darkorange', 'goldenrod', 'chocolate', 'sienna', 'peru', 'wheat', +} + +# 值的最大长度(防止超长垃圾串) +MAX_VAL_LEN = 220 + +# 主题名允许的字符:中文、字母、数字、空格、- _ . ( ) +_RE_NAME = re.compile(r'^[\u4e00-\u9fa5A-Za-z0-9 _.\-()]{1,%d}$' % MAX_NAME_LEN) +# 主题 id(用于目录名/文件名):仅小写字母数字与 - +_RE_ID = re.compile(r'^[a-z0-9][a-z0-9\-]{1,63}$') +# 十六进制色(用于自动生成配色方案) +_RE_HEX_STRICT = re.compile(r'^#([0-9a-fA-F]{6})$') + + +# ------------------------------------------------------------------ 校验 + + +def _bad(msg, code='BAD_THEME'): + return {'ok': False, 'code': code, 'msg_cn': msg} + + +def valid_css_value(v, var=''): + """校验一个 CSS 变量取值是否安全。返回 (ok, 中文原因)""" + if v is None: + return False, '取值不能为空' + s = str(v).strip() + if not s: + return False, '取值不能为空' + if len(s) > MAX_VAL_LEN: + return False, '取值过长(最多 %d 字符)' % MAX_VAL_LEN + low = s.lower() + # 危险字符 / 语法 + for ch in (';', '{', '}', '<', '>', '@', '\\', '!', '`'): + if ch in s: + return False, '包含非法字符「%s」' % ch + if '/*' in s or '*/' in s: + return False, '不允许出现注释符号' + if 'url(' in low or 'expression' in low or 'import' in low or 'javascript' in low: + return False, '不允许使用 url() / expression / import 等外部引用' + # 变量引用(--x: var(--y))——只允许引用白名单内的变量 + if low.startswith('var('): + m = re.match(r'^var\(\s*(--[a-z0-9\-]+)\s*(?:,[^)]*)?\)$', low) + if not m: + return False, 'var() 引用写法不正确' + if m.group(1) not in THEME_VARS: + return False, '不允许引用未开放的主题变量「%s」' % m.group(1) + return True, '' + # 颜色 + if s.startswith('#'): + return (_RE_HEX.match(s) is not None or + _RE_HSL.match(low) is not None, '颜色格式不正确(支持 #rgb / #rrggbb / #rrggbbaa)' + if not _RE_HEX.match(s) else '') + if low.startswith('rgb') or low.startswith('hsl'): + if _RE_RGB.match(low) or _RE_HSL.match(low): + return True, '' + return False, '颜色函数格式不正确(示例:rgba(31,111,235,.35))' + if low in _NAMED: + return True, '' + # 数值 + 单位(圆角 / 宽度) + if re.match(r'^\d+(?:\.\d+)?(px|rem|em|%|vh|vw|pt|ch)$', low): + if var and var in ('--r', '--side', '--side-c'): + if low.endswith(('vh', 'vw')): + return False, '该变量不支持 vh / vw 单位' + return True, '' + if low == '0': + return True, '' + # 阴影 / 渐变等复合值:做保守的「字符白名单」检查 + if re.match(r'^[0-9a-zA-Z#(),.\s%\-]+$', s): + if low.startswith('linear-gradient(') or low.startswith('radial-gradient('): + if 'gradient(' not in low: + return False, '渐变写法不正确' + return True, '' + # 阴影:允许 "0 1px 2px rgba(0,0,0,.1), 0 2px 8px rgba(...)" + if re.match(r'^(?:(?:-?\d+(?:\.\d+)?(?:px|em|rem)?|rgba?\([^)]*\)|hsla?\([^)]*\)|' + r'#[0-9a-fA-F]{3,8}|inset|none|,|\s)+)$', s): + return True, '' + return False, '取值格式无法识别,请使用颜色 / 数值(带单位) / var(--已开放变量)' + + +def validate_theme(obj): + """校验主题对象。返回 (ok, 错误消息 / 规范化后的主题)""" + if not isinstance(obj, dict): + return False, '主题内容必须是一个 JSON 对象' + name = str(obj.get('name') or '').strip() + if not name: + return False, '缺少主题名称(name)' + if not _RE_NAME.match(name): + return False, ('主题名称只能包含中文、字母、数字、空格与 - _ . ( ),' + '且不超过 %d 个字符' % MAX_NAME_LEN) + desc = str(obj.get('description') or '').strip() + if len(desc) > 300: + return False, '主题描述过长(最多 300 字)' + author = str(obj.get('author') or '').strip() + if len(author) > 64: + return False, '作者名过长(最多 64 字)' + ver = str(obj.get('version') or '1.0').strip() + if len(ver) > 24: + return False, '版本号过长' + + dark = bool(obj.get('dark')) + + vars_in = obj.get('vars') + if vars_in is None: + vars_in = {} + if not isinstance(vars_in, dict): + return False, 'vars 必须是一个「变量名 → 取值」的对象' + if len(vars_in) > len(THEME_VARS): + return False, '变量数量超出上限' + clean_vars = {} + for k, v in vars_in.items(): + kk = str(k).strip() + if not kk.startswith('--'): + kk = '--' + kk + if kk not in THEME_VARS: + return False, '不支持的变量「%s」(不在可定制变量清单内)' % kk + if isinstance(v, dict): + # 允许 {"value": "...", "on": true} 形式 + if v.get('on') is False: + continue + v = v.get('value') + vv = str(v).strip() if v is not None else '' + if not vv: + continue + good, why = valid_css_value(vv, kk) + if not good: + return False, '变量「%s」取值不合法:%s' % (kk, why) + clean_vars[kk] = vv + + if not clean_vars: + return False, '主题至少要定义 1 个变量' + + extra = str(obj.get('css') or '') + if extra: + if len(extra) > MAX_CSS_LEN: + return False, '自定义 CSS 过长(最多 %d 字节)' % MAX_CSS_LEN + # 自定义 CSS 只允许「选择器 { 声明 }」形式,禁止 @ 规则 / 脚本 / 外部引用 + bad, why = _check_extra_css(extra) + if bad: + return False, why + + theme = { + 'id': '', # 由调用方分配 + 'name': name, + 'description': desc, + 'author': author, + 'version': ver, + 'dark': dark, + 'vars': clean_vars, + 'css': extra, + 'builtin': bool(obj.get('builtin')), + 'created_at': obj.get('created_at') or '', + 'updated_at': obj.get('updated_at') or '', + } + return True, theme + + +def _check_extra_css(css): + """额外 CSS 的安全检查:只允许平面选择器 + 声明块,禁止嵌套与 at-rule。""" + s = re.sub(r'/\*.*?\*/', '', css, flags=re.S) # 去注释 + if '@' in s: + return True, '自定义 CSS 不允许使用 @ 规则(如 @import / @media)' + if '\\' in s: + return True, '自定义 CSS 不允许使用转义字符' + if re.search(r'(?i)(javascript\s*:|expression\s*\(|behavior\s*:|url\s*\()', s): + return True, '自定义 CSS 不允许使用 url() / expression() / javascript: 等外部引用' + # 粗略结构校验:整体必须能拆成 selector{...} 序列 + rest = s.strip() + guard = 0 + while rest: + guard += 1 + if guard > 200: + return True, '自定义 CSS 结构过于复杂' + m = re.match(r'^([^{}]+)\{([^{}]*)\}', rest, re.S) + if not m: + return True, '自定义 CSS 语法不正确:应为「选择器 { 属性: 取值; }」形式' + sel = m.group(1).strip() + body = m.group(2) + if not sel: + return True, '自定义 CSS 存在空选择器' + if re.search(r'[<>]', sel): + return True, '自定义 CSS 选择器包含非法字符' + # 声明体:prop: value; 逐条检查 + for decl in body.split(';'): + decl = decl.strip() + if not decl: + continue + if ':' not in decl: + return True, '自定义 CSS 声明缺少冒号:%s' % decl[:40] + prop, val = decl.split(':', 1) + prop = prop.strip().lower() + if not re.match(r'^(--[a-z0-9\-]+|[a-z\-]+)$', prop): + return True, '自定义 CSS 属性名不合法:%s' % prop[:40] + if not re.match(r'^[0-9a-zA-Z#(),.\s%\-"/_]+$', val.strip()): + return True, '自定义 CSS 取值含非法字符:%s' % decl[:40] + rest = rest[m.end():].strip() + return False, '' + + +# ------------------------------------------------------------------ 渲染 + + +def theme_to_css(theme): + """把主题渲染成一段 CSS 文本(可直接写盘并 引入)。 + + 始终包在 :root{...} 里;自定义 CSS 追加在后面。 + 同时输出一份深/浅色标记注释,便于排查。 + """ + lines = [] + lines.append('/* Drouter 主题:%s%s —— 由「主题之家」生成,请勿手工编辑 */' + % (theme.get('name') or '未命名', + (' v' + theme['version']) if theme.get('version') else '')) + lines.append('/* 生成时间:%s */' % time.strftime('%Y-%m-%d %H:%M:%S')) + lines.append(':root{') + for k in sorted(theme.get('vars') or {}): + lines.append(' %s:%s;' % (k, (theme['vars'][k] or '').strip())) + lines.append('}') + dark = bool(theme.get('dark')) + lines.append(':root{color-scheme:%s}' % ('dark' if dark else 'light')) + extra = (theme.get('css') or '').strip() + if extra: + lines.append('') + lines.append('/* ---- 作者自定义 CSS ---- */') + lines.append(extra) + lines.append('') + return '\n'.join(lines) + + +# ------------------------------------------------------------------ 内置主题 + + +def _hex_rgb(h): + h = h.lstrip('#') + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +def _rgb_hex(r, g, b): + return '#%02x%02x%02x' % (max(0, min(255, int(round(r)))), + max(0, min(255, int(round(g)))), + max(0, min(255, int(round(b))))) + + +def mix(c1, c2, t): + """把 c1 往 c2 混合 t(0~1)""" + r1, g1, b1 = _hex_rgb(c1) + r2, g2, b2 = _hex_rgb(c2) + return _rgb_hex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t) + + +def tone(base, t, dark=False): + """生成一个「基色 + 混合目标」的色调:t>0 提亮,t<0 加深""" + if dark: + return mix(base, '#ffffff', t) if t > 0 else mix(base, '#000000', -t) + return mix(base, '#ffffff', t) if t > 0 else mix(base, '#000000', -t) + + +def _lum(rgb): + def f(v): + v /= 255.0 + return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4 + r, g, b = [f(x) for x in rgb] + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def contrast(fg, bg): + """WCAG 相对亮度对比度(1.0 ~ 21.0)""" + la, lb = _lum(_hex_rgb(fg)), _lum(_hex_rgb(bg)) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + +def ensure_contrast(fg, bg, target=4.5): + """保证 fg 在 bg 上的对比度不低于 target(对任意主色都要成立)。 + + 用户可以在设计器里挑任意主色,派生色一旦压得太浅,文字就会糊。 + 这里按「远离背景」的方向逐级调整直到达标,最多 40 级必然收敛。 + + 方向由背景自身的亮度决定(而不是调用方传的「是否深色模式」)—— + 深色主题里 --pri-l 反而可能是浅色底,按模式判断会得到完全相反的调整方向。 + """ + try: + _hex_rgb(fg) + _hex_rgb(bg) + except Exception: + return fg + if contrast(fg, bg) >= target: + return fg + to_white = _lum(_hex_rgb(bg)) < 0.18 # 背景偏暗 → 文字往白走 + step = 0.04 + for _ in range(40): + fg = mix(fg, '#ffffff' if to_white else '#000000', step) + if contrast(fg, bg) >= target: + return fg + return ('#ffffff' if to_white else '#000000') + + +def make_palette(primary, dark=False, name='自定义主题', desc='由主题设计器生成'): + """由单一主色生成一套完整、可用的配色方案(保证对比度不会太离谱)。""" + p = primary if _RE_HEX_STRICT.match(str(primary or '')) else '#1f6feb' + if dark: + # 深色主题里 pri-l 用作「选中项浅底」,应当是主色的深色微透明感的版本, + # 而不是往白里混(那样会在深色背景上糊出一块亮斑)。 + pri_l = mix(p, '#0b1220', 0.84) + else: + pri_l = tone(p, 0.86) + pri_d = tone(p, -0.18) + if dark: + bg = '#111827' + bg2 = '#1a2437' + panel = '#161f30' + panel2 = '#1b2537' + line = '#26314a' + line2 = '#1f2a3f' + txt = '#e8eef8' + txt2 = '#a8b6cc' + txt3 = '#7b8aa3' + ok, warn, err, info = '#3ddc84', '#f5c451', '#ff6b5e', '#4fb8f0' + ok_l, warn_l, err_l, info_l = '#12301f', '#3a2f10', '#3a1a17', '#0f2a3b' + else: + bg = mix(p, '#ffffff', 0.945) + bg2 = mix(p, '#ffffff', 0.905) + panel = '#ffffff' + panel2 = mix(p, '#ffffff', 0.965) + line = mix(p, '#ffffff', 0.83) + line2 = mix(p, '#ffffff', 0.93) + txt = '#161f30' + txt2 = mix(p, '#556480', 0.35) + txt3 = '#8894a8' + ok, warn, err, info = '#12813f', '#9a6700', '#c0392b', '#0b6e99' + ok_l, warn_l, err_l, info_l = '#e3f7ea', '#fff8e1', '#fdecea', '#e5f4fb' + # 可读性兜底:底色是由主色稀释而来,主色越浅,派生文字色越容易看不清。 + # 三级文字统一按 WCAG AA(正文 4.5)校正,并用阶梯式目标保住层次感: + # 主文字 7.0 / 次要 5.5 / 弱化 4.5 —— 都 ≥ 4.5 且不会被拉平到同一个色。 + txt = ensure_contrast(txt, panel, 7.0) + txt = ensure_contrast(txt, bg, 7.0) + txt2 = ensure_contrast(txt2, bg, 5.5) + txt2 = ensure_contrast(txt2, panel, 5.5) + txt3 = ensure_contrast(txt3, bg, 4.5) + txt3 = ensure_contrast(txt3, panel, 4.5) + # 主色本身也会被当文字用(选中菜单项、链接、强调色), + # 必须保证它在自己的浅底 pri-l 上仍然清晰。 + _pri_safe = ensure_contrast(p, pri_l, 4.5) + p_txt = _pri_safe # 用于文字的主色 + grad = 'linear-gradient(135deg,%s 0%%,%s 45%%,%s 100%%)' % (tone(p, 0.16), p, pri_d) + return { + 'name': name, 'description': desc, 'author': 'Drouter 内置', 'version': '1.0', + 'dark': dark, + 'vars': { + '--bg': bg, '--bg2': bg2, '--panel': panel, '--panel2': panel2, + '--line': line, '--line2': line2, + '--txt': txt, '--txt2': txt2, '--txt3': txt3, + '--pri': p, '--pri-d': pri_d, '--pri-l': pri_l, + # p_txt:保证可读的主色变体;p 本身仍是「填充用」的原色 + '--pri-text': p_txt, + '--ok': ok, '--ok-l': ok_l, '--warn': warn, '--warn-l': warn_l, + '--err': err, '--err-l': err_l, '--info': info, '--info-l': info_l, + '--r': '12px', '--side': '236px', '--side-c': '68px', + '--sh': '0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)', + '--grad': grad, + }, + 'css': '', + } + + +def builtin_themes(): + """返回内置主题列表(id 固定,不可删除)。""" + out = [] + + t = make_palette('#1f6feb', False, 'Drouter 经典蓝', '默认主题,蓝色系,浅色界面') + t['id'] = 'default' + t['builtin'] = True + out.append(t) + + t = make_palette('#0d9488', False, '青竹', '清爽的青绿色,适合长时间查看') + t['id'] = 'teal' + t['builtin'] = True + out.append(t) + + t = make_palette('#7c3aed', False, '紫罗兰', '高对比度的紫色调') + t['id'] = 'violet' + t['builtin'] = True + out.append(t) + + t = make_palette('#0f766e', True, '暗夜墨绿', '深色主题,夜间查看更护眼') + t['id'] = 'dark-green' + t['builtin'] = True + out.append(t) + + t = make_palette('#f97316', False, '暖阳', '暖橙色调,明亮活泼') + t['id'] = 'warm' + t['builtin'] = True + out.append(t) + + t = make_palette('#e11d48', True, '暗夜绯红', '深色 + 绯红点缀') + t['id'] = 'dark-rose' + t['builtin'] = True + out.append(t) + + for x in out: + x['created_at'] = '' + x['updated_at'] = '' + return out + + +# ------------------------------------------------------------------ ZIP 打包 / 解包 + +THEME_MANIFEST = 'theme.json' + + +def pack_theme_zip(theme): + """把主题打成 zip 字节流:theme.json + theme.css + README.txt""" + if not isinstance(theme, dict): + return None + buf = io.BytesIO() + manifest = { + 'schema': 'drouter.theme/1', + 'name': theme.get('name') or '未命名主题', + 'description': theme.get('description') or '', + 'author': theme.get('author') or '', + 'version': theme.get('version') or '1.0', + 'dark': bool(theme.get('dark')), + 'vars': theme.get('vars') or {}, + 'css': theme.get('css') or '', + 'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'), + } + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z: + z.writestr(THEME_MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2)) + z.writestr('theme.css', theme_to_css(theme)) + z.writestr('README.txt', _readme_text(manifest)) + return buf.getvalue() + + +def _readme_text(m): + return ( + 'Drouter 主题包\n' + '================\n\n' + '名称:%s\n' + '版本:%s\n' + '作者:%s\n' + '模式:%s\n\n' + '说明:%s\n\n' + '使用方法\n' + '--------\n' + '1. 打开 Drouter 管理台 → 系统 → 主题之家\n' + '2. 点击「导入主题包」,选择本 .zip 文件\n' + '3. 校验通过后点击「应用」即可\n\n' + '包结构\n' + '------\n' + 'theme.json 主题清单(变量与元信息,必需)\n' + 'theme.css 由 theme.json 渲染出的样式(仅供参考,导入时会重新生成)\n' + 'README.txt 本说明\n\n' + '可定制的变量(仅下列变量会被接受,其它一律拒绝):\n' + '%s\n' + ) % (m.get('name'), m.get('version'), m.get('author') or '未署名', + '深色' if m.get('dark') else '浅色', m.get('description') or '(无)', + '\n'.join(' %-12s %s' % (k, THEME_VAR_CN.get(k, '')) for k in sorted(THEME_VARS))) + + +def _safe_zip_member(name): + """拒绝绝对路径与目录穿越。返回规范化后的相对路径。""" + if not name: + return None + n = name.replace('\\', '/') + if n.startswith('/') or n.startswith('~'): + return None + while n.startswith('./'): + n = n[2:] + parts = [x for x in n.split('/') if x not in ('', '.')] + if any(x == '..' for x in parts): + return None + if not parts: + return None + return '/'.join(parts) + + +def unpack_theme_zip(data): + """解析主题 zip。返回 (ok, 主题对象 / 中文错误)""" + if not data: + return False, '文件内容为空' + if len(data) > 4 * 1024 * 1024: + return False, '文件过大(上传上限 4 MB)' + try: + z = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile: + return False, '这不是一个有效的 ZIP 压缩包(请确认文件未损坏)' + except Exception as e: + return False, '无法读取压缩包:%s' % e + with z: + names = z.namelist() + if len(names) > MAX_ENTRIES: + return False, '压缩包内文件过多(上限 %d 个)' % MAX_ENTRIES + total = 0 + manifest = None + manifest_path = None + for info in z.infolist(): + if info.is_dir(): + continue + safe = _safe_zip_member(info.filename) + if safe is None: + return False, '压缩包内存在不安全的路径:%s' % info.filename + if info.file_size > MAX_ENTRY_BYTES: + return False, '压缩包内单个文件过大:%s' % info.filename + total += info.file_size + if total > MAX_THEME_BYTES: + return False, '压缩包解压后总体积过大(上限 %d KB)' % (MAX_THEME_BYTES // 1024) + base = safe.split('/')[-1].lower() + if base in (THEME_MANIFEST, 'theme.json', 'drouter-theme.json', 'manifest.json'): + if manifest is None or base == THEME_MANIFEST: + manifest_path = info.filename + if manifest_path is None: + return False, ('压缩包里没有找到 theme.json —— ' + '请确认这是由「主题之家」导出的主题包,或按说明手工创建 theme.json') + try: + raw = z.read(manifest_path).decode('utf-8-sig') + except UnicodeDecodeError: + return False, 'theme.json 编码不是 UTF-8,请另存为 UTF-8 后重试' + except Exception as e: + return False, '读取 theme.json 失败:%s' % e + try: + manifest = json.loads(raw) + except Exception as e: + return False, 'theme.json 不是合法的 JSON:%s' % e + + good, res = validate_theme(manifest) + if not good: + return False, res + res['imported_from'] = os.path.basename(str(manifest_path)) + return True, res + + +# ------------------------------------------------------------------ 工具 + + +def slugify(name, fallback='theme'): + """把主题名转成安全的目录/文件名片段。""" + s = re.sub(r'[^A-Za-z0-9\u4e00-\u9fa5\-]+', '-', str(name or '')).strip('-').lower() + s = re.sub(r'-{2,}', '-', s) + if not s or len(s) < 2: + return fallback + return s[:48] + + +def theme_id_from_name(name, taken=()): + """生成不冲突的主题 id(自定义主题用)。""" + base = slugify(name) + # id 只允许小写字母数字与 - + base = re.sub(r'[^a-z0-9\-]+', '-', base).strip('-') + if not base or len(base) < 2: + base = 'theme' + base = base[:48] + if base not in taken and _RE_ID.match(base): + return base + i = 2 + while True: + cand = '%s-%d' % (base[:40], i) + if cand not in taken and _RE_ID.match(cand): + return cand + i += 1 + if i > 999: + return 'theme-%d' % int(time.time()) + + +def summarize(theme): + """给前端用的精简结构(不含完整 css,减少传输量)。""" + return { + 'id': theme.get('id') or '', + 'name': theme.get('name') or '', + 'description': theme.get('description') or '', + 'author': theme.get('author') or '', + 'version': theme.get('version') or '', + 'dark': bool(theme.get('dark')), + 'builtin': bool(theme.get('builtin')), + 'vars_count': len(theme.get('vars') or {}), + 'has_css': bool((theme.get('css') or '').strip()), + 'created_at': theme.get('created_at') or '', + 'updated_at': theme.get('updated_at') or '', + # 缩略图需要的几个关键色 + 'swatch': [ + (theme.get('vars') or {}).get('--pri', ''), + (theme.get('vars') or {}).get('--bg', ''), + (theme.get('vars') or {}).get('--panel', ''), + (theme.get('vars') or {}).get('--txt', ''), + ], + } diff --git a/devtools/askpass.sh b/devtools/askpass.sh new file mode 100644 index 0000000..f4ecda4 --- /dev/null +++ b/devtools/askpass.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "yyfmo64102" diff --git a/devtools/check-mobile.js b/devtools/check-mobile.js new file mode 100644 index 0000000..7a737df --- /dev/null +++ b/devtools/check-mobile.js @@ -0,0 +1,132 @@ +// 手机适配(#14)专项检查 +// 1) 静态要素:viewport、抽屉遮罩、断点规则、抽屉开关逻辑、表格包裹逻辑 +// 2) 功能验证:用最小 DOM 桩跑一遍 wrapTables,确认真的包出了 .tw 且不会重复包 +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const APP = path.join(__dirname, '..', 'web', 'app.js'); +const CSS = path.join(__dirname, '..', 'web', 'app.css'); +const HTML = path.join(__dirname, '..', 'web', 'index.html'); +const code = fs.readFileSync(APP, 'utf8'); +const css = fs.readFileSync(CSS, 'utf8'); +const html = fs.readFileSync(HTML, 'utf8'); + +let PASS = 0, FAIL = 0; +const MSG = []; +function has(name, cond, extra) { + if (cond) PASS++; + else { FAIL++; MSG.push(' ✘ ' + name + (extra ? ' → ' + extra : '')); } +} + +// ---------------- 1. 静态要素 ---------------- +has('index.html 有 viewport', /= 0) node.parentNode.children.splice(j, 1); + } + node.parentNode = this; + this.children.push(node); + return node; + }, + querySelectorAll(sel) { + const out = []; + (function walk(n) { + n.children.forEach(c => { + if (sel === 'table' && c.tag === 'table') out.push(c); + walk(c); + }); + })(this); + return out; + }, + }; + // 真实 DOM 里 el.className = 'tw' 会同步反映到 classList, + // 桩必须复刻这个联动,否则 wrapTables 的「已包过」判断永远失效。 + Object.defineProperty(el, 'className', { + get() { return Array.from(el.classList._s).join(' '); }, + set(v) { el.classList._s = new Set(String(v).split(/\s+/).filter(Boolean)); }, + }); + return el; +} + +const documentStub = { createElement: t => mk(t) }; +const sandbox = { document: documentStub, console }; +vm.createContext(sandbox); + +// 只抽取 wrapTables 函数体,避免牵扯整个 app.js +const m = code.match(/function wrapTables\(root\) \{[\s\S]*?\n\}/); +has('能抽到 wrapTables 源码', !!m); +if (m) { + vm.runInContext(m[0], sandbox); + + const root = mk('div'); + const holder = mk('div', 'card'); + const t1 = mk('table'), t2 = mk('table'); + root.appendChild(holder); holder.appendChild(t1); holder.appendChild(t2); + + // 第一次:两个表都该被包上一层 .tw + sandbox.wrapTables(root); + const wrapped = holder.children.filter(c => c.classList.contains('tw')); + has('首次包裹:两个表格都套上 .tw', wrapped.length === 2, wrapped.length); + has('包裹后 table 的父节点是 .tw', + wrapped.length === 2 && wrapped.every(w => w.children.length === 1 && w.children[0].tag === 'table')); + + // 第二次:幂等,不能重复套(否则每次刷新 DOM 都多一层) + sandbox.wrapTables(root); + const wrapped2 = holder.children.filter(c => c.classList.contains('tw')); + has('二次包裹:幂等不重复', wrapped2.length === 2, wrapped2.length); + has('二次包裹后结构仍是一层', + wrapped2.every(w => w.children.length === 1 && w.children[0].tag === 'table')); + + // 空树不应报错 + let threw = false; + try { sandbox.wrapTables(mk('div')); } catch (e) { threw = true; } + has('空容器不抛异常', !threw); +} + +console.log('='.repeat(60)); +console.log(`手机适配检查:通过 ${PASS}|失败 ${FAIL}`); +if (FAIL) { console.log('-'.repeat(60)); MSG.forEach(x => console.log(x)); } +process.exit(FAIL ? 1 : 0); diff --git a/devtools/check-render.js b/devtools/check-render.js new file mode 100644 index 0000000..d4da59e --- /dev/null +++ b/devtools/check-render.js @@ -0,0 +1,289 @@ +// 离线视图渲染回归:用最小 DOM 桩 + 假 fetch,把所有视图函数都跑一遍, +// 检查是否抛异常、是否渲染出内容。不依赖真实路由器。 +// 用法:node devtools/check-render.js web/app.js +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +// 以脚本自身位置定位,避免依赖运行时的当前目录(否则换个目录跑就 ENOENT)。 +const DEFAULT_APP = path.join(__dirname, '..', 'web', 'app.js'); +const file = process.argv[2] || DEFAULT_APP; +const code = fs.readFileSync(file, 'utf8'); + +function mkEl(id) { + const el = { + id, _html: '', textContent: '', value: '', disabled: false, checked: false, + className: '', tagName: 'DIV', dataset: {}, style: {}, children: [], + classList: { + add(c) { el.className += ' ' + c; }, + remove(c) { el.className = el.className.replace(c, ''); }, + toggle() { return false; }, contains() { return false; }, + }, + get innerHTML() { return this._html; }, + set innerHTML(v) { this._html = String(v); }, + addEventListener() {}, removeEventListener() {}, + appendChild(c) { el.children.push(c); }, + removeChild() {}, + querySelector() { return mkEl('sub'); }, + querySelectorAll() { return []; }, + get parentElement() { return mkEl('parent'); }, + get firstChild() { return mkEl('first'); }, + insertBefore() {}, replaceChild() {}, cloneNode() { return mkEl('clone'); }, + focus() {}, select() {}, scrollIntoView() {}, click() {}, + onclick: null, onchange: null, oninput: null, onkeydown: null, + getContext() { return {}; }, + }; + return el; +} + +const ELS = new Map(); +function getEl(sel) { + const k = String(sel).replace(/^#/, '').split(/[\s>]/)[0]; + if (!ELS.has(k)) ELS.set(k, mkEl(k)); + return ELS.get(k); +} + +const document = { + getElementById: id => getEl(id), + querySelector: getEl, + querySelectorAll: () => [], + createElement: t => mkEl('new:' + t), + body: mkEl('body'), + addEventListener() {}, +}; + +// 假 fetch:所有 GET 返回一个宽松的 ok 响应 +const CALLED = []; +async function fetchStub(url, opts) { + CALLED.push({ url, method: (opts && opts.method) || 'GET' }); + let data = { ok: true, data: {}, msg_cn: 'ok' }; + // 各接口按需返回形状合理的假数据,避免视图因字段类型不符而抛异常 + if (/\/api\/ifaces/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/iface\/meta/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/iface_method/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/ddns/.test(url)) data = { ok: true, data: { + cfg: { provider: 'custom', domain: '', ipv4: true, ipv6: false }, + public: { combo: 'both', v4: '1.2.3.4', v6: '2408::1', note: '' }, + note: { level: 'ok', text: '' }, + providers: [{ v: 'custom', name: '自定义', region: 'other', fields: [] }], + records: [], last_result: '' } }; + else if (/\/api\/ulog\/daemon/.test(url)) data = { ok: true, data: { units: { 'drouter-logd.timer': { active: 'active', enabled: 'enabled' } }, next: 'in 42s', last: { ts: '2026-09-28T22:00:00', msg_cn: '已归档 12 条日志', code: 'ULOG_ARCHIVE_OK' } } }; + else if (/\/api\/ulog\/daemon/.test(url)) data = { ok: true, data: { + units: { 'drouter-logd.timer': { active: 'active', enabled: 'enabled' } }, + next: 'in 42s', + last: { ts: '2026-09-28T22:00:00', msg_cn: '已归档 12 条日志', code: 'ULOG_ARCHIVE_OK' } } }; + else if (/\/api\/ulog\/conf/.test(url)) data = { ok: true, data: { + conf: { enabled: true, archive: true, keep_days: 7, keep_rows: 200000, + min_level: 'debug', sources: { fw: true, conntrack: true, flow: true, + wan: true, ddns: true, app: true, system: true } }, + caps: { conntrack: true, journalctl: true }, + conntrack: { enabled: true, count: 120, max: 65536 }, fw_log: true, + sources: [{ v: 'fw', n: '防火墙', desc: '' }, { v: 'conntrack', n: '连接跟踪', desc: '' }, + { v: 'flow', n: '当前连接', desc: '' }, { v: 'wan', n: 'WAN 接入', desc: '' }, + { v: 'ddns', n: '动态域名', desc: '' }, { v: 'app', n: '应用日志', desc: '' }, + { v: 'system', n: '系统日志', desc: '' }], + levels: [{ v: 'err', n: '错误' }, { v: 'warn', n: '警告' }, { v: 'info', n: '信息' }], + archive: { path: '/var/log/drouter/ulog.jsonl', size: 20480, rows: 128 } } }; + else if (/\/api\/ulog\/flow/.test(url)) data = { ok: true, data: { + rows: [{ ts: '', src: 'flow', level: 'info', action: 'ORIG', proto: 'TCP', + saddr: '192.168.7.100', sport: '51234', daddr: '1.1.1.1', dport: '443', + msg_cn: 'TCP 连接', extra: { state: 'ESTABLISHED', state_cn: '已建立', + bytes: 20480, reply: false } }], + stat: { total: 1, tcp: 1, udp: 0, icmp: 0, other: 0 }, + cap: { count: 120, max: 65536 }, err: '', note: '快照' } }; + else if (/\/api\/ulog/.test(url)) data = { ok: true, data: { + items: [{ ts: '2026-09-28T10:00:00', src: 'fw', level: 'warn', level_cn: '警告', + action: 'DROP', proto: 'TCP', saddr: '203.0.113.9', sport: '54321', + daddr: '192.168.7.3', dport: '22', iface: 'ens19', msg_cn: '丢弃 SSH', + raw: 'DROUTER-FW DROP ...' }], + stat: { total: 1, by_src: { fw: 1 }, by_level: { warn: 1 }, by_action: { DROP: 1 }, + by_proto: { TCP: 1 }, top_src_ip: [{ v: '203.0.113.9', n: 1 }], + top_dst_port: [{ v: '22', n: 1 }] }, + meta: { sources_used: ['fw'], collected: 1, matched: 1, shown: 1, errors: {} }, + conf: { enabled: true, archive: true, keep_days: 7, keep_rows: 200000, + min_level: 'debug', sources: { fw: true } }, + sources: [{ v: 'fw', n: '防火墙', desc: '' }], + levels: [{ v: 'err', n: '错误' }, { v: 'warn', n: '警告' }, { v: 'info', n: '信息' }], + archive: { path: '/var/log/drouter/ulog.jsonl', size: 20480, rows: 128 }, + note: '统一日志' } }; + else if (/\/api\/fflow/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/metrics/.test(url)) data = { ok: true, data: { cpu: 3, mem: 21, disk: 12, up: 0, down: 0 } }; + else if (/\/api\/dash|sysinfo/.test(url)) data = { ok: true, data: {} }; + else if (/\/api\/users/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/leases/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/routes/.test(url)) data = { ok: true, data: [] }; + // 主题之家(#12):list / vars / get 三种 op + const TM_VARS_ALL = ['--bg', '--bg2', '--panel', '--panel2', '--line', '--line2', + '--txt', '--txt2', '--txt3', '--pri', '--pri-d', '--pri-l', '--ok', '--ok-l', + '--warn', '--warn-l', '--err', '--err-l', '--info', '--info-l', + '--r', '--sh', '--grad', '--side', '--side-c']; + function mkTheme(id, name, builtin, dark, pri, bg) { + const sw = [pri, bg, '#ffffff', '#161f30']; + return { id, name, description: name + ' 的说明', author: builtin ? 'Drouter 内置' : 'tester', + version: '1.0', dark, builtin, vars_count: 25, has_css: false, swatch: sw, + vars: { '--pri': pri, '--bg': bg } }; + } + if (/\/api\/theme/.test(url)) { + const op = /op=([a-z_]+)/.exec(url); + const which = op ? op[1] : 'list'; + if (which === 'vars') { + const defaults = {}; + TM_VARS_ALL.forEach(k => { defaults[k] = k.startsWith('--side') ? '236px' : '#1f6feb'; }); + data = { ok: true, data: { + vars: TM_VARS_ALL.map(k => ({ name: k, cat: 'base', cat_cn: '基础配色', cn: k })), + cats: [{ k: 'base', n: '基础配色' }, { k: 'color', n: '主色与状态色' }, + { k: 'shape', n: '圆角 / 阴影 / 渐变' }, { k: 'layout', n: '布局尺寸' }], + defaults } }; + } else if (which === 'get') { + data = { ok: true, data: { id: 'teal', name: '青竹', description: 'd', author: 'x', + version: '1.0', dark: false, css: '', css_text: ':root{--pri:#0d9488}', + vars: { '--pri': '#0d9488', '--bg': '#f2fbf9' } } }; + } else { + data = { ok: true, data: { + themes: [ + mkTheme('default', 'Drouter 经典蓝', true, false, '#1f6feb', '#eef2f9'), + mkTheme('teal', '青竹', true, false, '#0d9488', '#f2fbf9'), + mkTheme('violet', '紫罗兰', true, false, '#7c3aed', '#f6f4fe'), + mkTheme('dark-green', '暗夜墨绿', true, true, '#0f766e', '#111827'), + mkTheme('warm', '暖阳', true, false, '#f97316', '#fff8f2'), + mkTheme('dark-rose', '暗夜绯红', true, true, '#e11d48', '#150d14'), + mkTheme('my-theme', '我的自定义主题', false, false, '#3366cc', '#f7f9fc'), + ], + active: 'my-theme', active_name: '我的自定义主题', + vars_total: 25, dir: '/etc/drouter/themes', + css_path: '/etc/drouter/generated/theme.css' } }; + } + } + else if (/\/api\/services/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/snapshot\/list/.test(url)) data = { ok: true, data: [] }; + else if (/\/api\/config/.test(url)) data = { ok: true, data: {} }; + if (/\/api\/docker/.test(url)) { + data = { ok: true, data: { + pkgs: { docker: { installed: true, pkgs: ['docker.io'] }, + compose: { installed: true }, install_cmd: 'apt-get install -y docker.io' }, + info: { version: '27.5.1', containers: '2', running: '1', images: '5', + driver: 'overlay2', root: '/var/lib/docker' }, + services: { docker: 'active', containerd: 'active' }, + containers: [ + { id: 'abc123def456', name: 'web', image: 'nginx:alpine', state: 'running', + status: 'Up 3 hours', ports: '0.0.0.0:8080->80/tcp', created: '2 hours ago', + size: '12MB', stat: { cpu: '0.42%', mem: '24.1MiB / 1GiB', mem_perc: '2.35%', + net: '1.2MB / 340kB', block: '0B / 0B', pids: '5' } }, + { id: 'ffffeeee1111', name: 'db', image: 'postgres:16', state: 'exited', + status: 'Exited (0) 1 hour ago', ports: '5432/tcp', created: '1 day ago', + size: '90MB', stat: {} }, + ], + images: [{ repo: 'nginx', tag: 'alpine', id: 'a1b2c3d4e5f6', size: '48MB', + created: '2026-09-20 10:00:00 +0800 CST' }], + networks: [{ name: 'bridge', driver: 'bridge', scope: 'local' }], + volumes: [{ name: 'pgdata', driver: 'local', mountpoint: '/var/lib/docker/volumes/pgdata' }], + stacks: [{ name: 'my-app', dir: '/etc/drouter/docker/stacks/my-app', + file: 'docker-compose.yml', mtime: '2026-09-27 12:00:00', size: 320 }], + compose_cmd: 'docker compose', stack_dir: '/etc/drouter/docker/stacks', + note: '测试数据' }, msg_cn: 'ok' }; + } else if (/\/api\/convert/.test(url)) { + data = { ok: true, data: { ok: true, yaml: 'services:\n web:\n image: nginx\n', + service: 'web', image: 'nginx', warnings: [], msg_cn: '解析成功' } }; + } + return { status: 200, ok: true, json: async () => data }; +} + +const sandbox = { + document, fetch: fetchStub, console, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, alert() {}, confirm: () => false, + location: { reload() {}, href: '' }, + navigator: { userAgent: 'node' }, window: {}, + TextEncoder, TextDecoder, URL, Blob: function () {}, FormData: function () {}, +}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; + +const ctx = vm.createContext(sandbox); +try { + vm.runInContext(code, ctx, { filename: file }); +} catch (e) { + console.error('脚本加载失败:' + e.message); + process.exit(1); +} + +// `const` 声明不会挂到 context 对象上,需用表达式取出 +let VIEWS; +try { + VIEWS = vm.runInContext('VIEWS', ctx); +} catch (e) { + VIEWS = null; +} +if (!VIEWS || typeof VIEWS !== 'object') { + console.error('未找到 VIEWS 导出(脚本需在顶层用 const VIEWS = {...})'); + process.exit(1); +} + +// 带内部标签页的视图:{ state: S 上的字段名, tabs: [...], render: '渲染函数名' } +const TABBED = { + theme: { state: 'themeTab', tabs: ['gallery', 'design', 'import'], render: 'tmRender' }, +}; + +(async () => { + const names = Object.keys(VIEWS); + let bad = 0, empty = 0; + for (const n of names) { + try { + const fn = VIEWS[n]; + if (typeof fn !== 'function') { console.log(` ✗ ${n}:不是函数`); bad++; continue; } + await fn(); + const viewEl = getEl('#view'); + const html = viewEl.innerHTML || ''; + if (process.env.DUMP_VIEW === n) { + console.log('--- ' + n + ' 渲染 HTML(前 2500 字符)---'); + console.log(html.slice(0, 2500)); + console.log('--- 结束 ---'); + } + if (html.length < 40) { + console.log(` ⚠ ${n}:渲染内容过少(${html.length} 字符)`); + empty++; + } + // 带标签页的视图:逐个标签渲染一遍,否则只有默认标签会被真正执行。 + // 主题之家(#12)的三个标签页各自 $("#tm-pane").innerHTML = ...,逻辑完全不同。 + if (TABBED[n]) { + for (const tab of TABBED[n].tabs) { + try { + vm.runInContext(`S.${TABBED[n].state} = ${JSON.stringify(tab)}`, ctx); + await vm.runInContext(TABBED[n].render + '()', ctx); + const h2 = getEl('#tm-pane').innerHTML || ''; + if (h2.length < 40) { + console.log(` ⚠ ${n}/${tab}:标签内容过少(${h2.length} 字符)`); + empty++; + } else if (process.env.DUMP_VIEW === n) { + console.log('--- ' + n + '/' + tab + ' 前 1200 字符 ---'); + console.log(h2.slice(0, 1200)); + } + // 每个标签都应跑到;< 若某个标签 HTML 里出现未完成标记要报警 + if (/undefined|\[object Object\]/.test(h2)) { + const hit = (/undefined/.test(h2) ? 'undefined' : '[object Object]'); + console.log(` ⚠ ${n}/${tab}:输出含 ${hit}`); + empty++; + } + } catch (e) { + const st = String(e.stack || '').split('\n').slice(1, 4).join(' | '); + console.log(` ✗ ${n}/${tab}:抛异常 → ${e.message}\n ${st}`); + bad++; + } + } + // 复位到默认标签,避免影响后续视图 + vm.runInContext(`S.${TABBED[n].state} = ${JSON.stringify(TABBED[n].tabs[0])}`, ctx); + } + } catch (e) { + const st = String(e.stack || '').split('\n').slice(1, 4).join(' | '); + console.log(` ✗ ${n}:抛异常 → ${e.message}\n ${st}`); + bad++; + } + } + console.log('='.repeat(60)); + console.log(`视图总数 ${names.length}|异常 ${bad}|疑似空渲染 ${empty}`); + const dockerCalls = CALLED.filter(c => /\/api\/docker/.test(c.url)).length; + console.log(`docker 接口调用次数:${dockerCalls}`); + process.exit(bad ? 1 : 0); +})(); diff --git a/devtools/check-views.js b/devtools/check-views.js new file mode 100644 index 0000000..8e464da --- /dev/null +++ b/devtools/check-views.js @@ -0,0 +1,34 @@ +// 校验 PAGES 的 key 是否都在 VIEWS 里有映射。 +// tag:'soon' 的占位页不需要渲染函数(点进去显示「等待作者完善」),自动排除。 +const fs = require('fs'); +// 以脚本自身位置定位,避免依赖运行时的当前目录(否则换个目录跑就 ENOENT)。 +const WEB = require('path').join(__dirname, '..', 'web', 'app.js'); +const path = process.argv[2] || WEB; +const s = fs.readFileSync(path, 'utf8'); + +const nav = s.match(/const NAV_GROUPS = \[([\s\S]*?)\n\];/); +if (!nav) { console.log('✘ 未找到 NAV_GROUPS'); process.exit(1); } + +const items = []; +for (const m of nav[1].matchAll(/\{([^{}]*?k:\s*'([a-z0-9]+)'[^{}]*?)\}/g)) { + const body = m[1]; + const key = m[2]; + const soon = /tag:\s*'soon'/.test(body); + items.push({ key, soon }); +} + +const block = s.match(/const VIEWS = \{([\s\S]*?)\n\};/); +if (!block) { console.log('✘ 未找到 VIEWS 定义'); process.exit(1); } +const mapped = []; +for (const m of block[1].matchAll(/^\s*([a-z0-9_]+):/gm)) mapped.push(m[1]); + +const real = items.filter((x) => !x.soon); +const soon = items.filter((x) => x.soon); +const missing = real.filter((x) => !mapped.includes(x.key)).map((x) => x.key); +const keys = items.map((x) => x.key); +const extra = mapped.filter((k) => !keys.includes(k)); + +console.log('菜单项:', items.length, '(已实现', real.length, '/ 规划中', soon.length, ')'); +console.log('缺失映射:', missing.length ? missing.join(', ') + ' ✘' : '无 ✔'); +console.log('多余映射:', extra.length ? extra.join(', ') : '无 ✔'); +process.exit(missing.length ? 1 : 0); diff --git a/devtools/make-placeholder-shots.py b/devtools/make-placeholder-shots.py new file mode 100644 index 0000000..b3c1946 --- /dev/null +++ b/devtools/make-placeholder-shots.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +生成 docs/screenshots/ 下的占位截图(SVG → 直接当 PNG 用不合适,这里输出 SVG)。 + +设计取舍: + README 里引用的是 .png。但仓库里没有真实截图,硬引用会得到 6 个死链。 + 两种做法: + A) 生成真 PNG(需要 Pillow 或 cairosvg,构建机上不一定有) + B) 生成 SVG 占位,README 里改引用 .svg + 选 B:SVG 是纯文本、零依赖、可随仓库走、而且缩放不糊。 + 用户日后截了真图,按同名替换 + 改扩展名即可(README 里已写明)。 +""" +import os + +PRI = "#1f6feb" +BG = "#eef2f9" +PANEL = "#ffffff" +LINE = "#e2e7f0" +TXT = "#161f30" +TXT2 = "#556480" +TXT3 = "#8894a8" + +W, H = 1440, 900 + +SHOTS = [ + ("01-dash", "系统概览", "CPU / 内存 / 磁盘 / 负载 / 温度 + 实时网速与历史流量"), + ("02-iface", "网卡与桥接", "以 MAC 为主键管理网卡,标注备注 / 角色 / 链路 / 速率 / MTU"), + ("03-fw4", "防火墙 IPv4", "nftables 规则可视化编辑,支持链 / 表 / 规则级操作"), + ("04-qos", "智能限速 QoS", "基于 IFB 的入向整形 + 分类限速,规则实时下发"), + ("05-flowlog", "连接与流日志", "conntrack 连接跟踪 + 实时流日志滚动查看"), + ("06-webshell", "Web 终端", "浏览器内直接操作 shell,支持多会话"), +] + +def logo_mark(x, y, s=1.0): + """复刻 web/logo.svg 的路由环,作为占位图的水印标识。""" + return f''' + + + + + + + +''' + +def skeleton(w, h, x, y, rows=4, gap=14): + """画一块灰色的内容骨架,暗示「这里将来是真的界面」。""" + out = [] + out.append(f'') + yy = y + 22 + for i in range(rows): + # 行宽交替,看起来像文字块 + ww = int(w * (0.72 if i % 2 == 0 else 0.52)) + out.append(f'') + yy += 10 + gap + return "\n ".join(out) + +def svg(key, title, desc): + return f''' + + + + + + {logo_mark(18, 14, 0.9)} + Drouter + Debian 13 路由管理 + + + + + + + + + + + + + + + + + {title} + + + A + + + {skeleton(540, 214, 268, 92, rows=4)} + {skeleton(540, 214, 832, 92, rows=4)} + {skeleton(1104, 300, 268, 330, rows=6)} + {skeleton(468, 300, 600, 330, rows=6)} + {skeleton(468, 300, 1096, 330, rows=6)} + {skeleton(1104, 168, 268, 660, rows=3)} + + + + + 【占位图】{title} + {desc} + 真实界面请以安装后为准 · 替换 docs/screenshots/{key}.svg 即可 + + +''' + +out_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "docs", "screenshots") +os.makedirs(out_dir, exist_ok=True) +for key, title, desc in SHOTS: + p = os.path.join(out_dir, key + ".svg") + # newline="\n" 是必须的:Windows 上 Python 默认把 \n 翻成 \r\n, + # 生成的 SVG 在 git 里会被 .gitattributes 回写成 LF,导致每次重建都 + # 产生"改了又没改"的假 diff。显式指定 \n 后,生成结果与入库结果一致。 + with open(p, "w", encoding="utf-8", newline="\n") as f: + f.write(svg(key, title, desc)) + print("wrote", p) +print("done:", len(SHOTS), "placeholders") diff --git a/devtools/rscp.sh b/devtools/rscp.sh new file mode 100644 index 0000000..b70772b --- /dev/null +++ b/devtools/rscp.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# scp 到目标机也需要密码;复用 SSH_ASKPASS +# +# 用法(两种都可以): +# rscp.sh 本地文件 /tmp/远端路径 # 自动补 ajeef@192.168.7.3: 前缀 +# rscp.sh 本地文件 ajeef@192.168.7.3:/tmp/x # 已是远端形式,原样透传 +# +# 陷阱:Windows 的 scp 在目标不含 "user@host:" 时会退化成本地 cp, +# 静默返回 0 却什么都没传出去。所以这里强制补前缀。 +HERE="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00" +TARGET="ajeef@192.168.7.3" + +args=() +for a in "$@"; do + case "$a" in + -*|*@*:*) args+=("$a");; # scp 选项 / 已是远端形式 + *) args+=("$a");; + esac +done + +# 最后一个参数视为远端目标;若不是远端形式则补前缀 +n=${#args[@]} +if [ "$n" -ge 1 ]; then + last="${args[$((n-1))]}" + case "$last" in + -*|*@*:*) ;; # 选项 / 已是远端,不动 + *) args[$((n-1))]="$TARGET:$last";; + esac +fi + +SSH_ASKPASS="$HERE/router-build/devtools/askpass.sh" \ +SSH_ASKPASS_REQUIRE=force \ +DISPLAY=:0 \ +scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 "${args[@]}" diff --git a/devtools/rsh.sh b/devtools/rsh.sh new file mode 100644 index 0000000..d9826b1 --- /dev/null +++ b/devtools/rsh.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# askpass.sh 在 devtools/ 下(此前误写成根目录的 _dev/,导致连不上目标机) +SSH_ASKPASS="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/devtools/askpass.sh" \ +SSH_ASKPASS_REQUIRE=force \ +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 ajeef@192.168.7.3 "$@" diff --git a/devtools/rsudo.sh b/devtools/rsudo.sh new file mode 100644 index 0000000..3a3a556 --- /dev/null +++ b/devtools/rsudo.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 以 sudo 在目标机执行命令(密码通过 stdin 传给 sudo -S) +HERE="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00" +"$HERE/router-build/devtools/rsh.sh" "echo 'yyfmo64102' | sudo -S -p '' bash -c $(printf %q "$1")" 2>&1 diff --git a/devtools/sim-ui.js b/devtools/sim-ui.js new file mode 100644 index 0000000..db77757 --- /dev/null +++ b/devtools/sim-ui.js @@ -0,0 +1,124 @@ +// 在 Node 里用最小 DOM 桩模拟前端启动流程,验证: +// 1) 登录能拿到 token 2) loadAll 后每个页面都能渲染出内容(不再是「正在载入…」) +// 用法:node sim-ui.js [host] [port] 默认 192.168.7.3:8080 +// 说明:这是"无浏览器"回归,能抓到 VIEWS 未定义、接口 404、字段名不匹配等 +// 只有真实点击才会暴露的问题。 +const args = process.argv.slice(2); +const HOST = args[0] || '192.168.7.3'; +const PORT = Number(args[1] || 8080); +let TOKEN = ''; + +// ---- 极简 DOM 桩 ---- +function mkEl(id) { + const el = { + id, _html: '', textContent: '', value: '', disabled: false, checked: false, + className: '', tagName: 'DIV', dataset: {}, style: {}, children: [], + classList: { + add(c) { el.className += ' ' + c; }, remove(c) { el.className = el.className.replace(c, ''); }, + toggle() { return false; }, contains() { return false; }, + }, + get innerHTML() { return this._html; }, + set innerHTML(v) { this._html = String(v); }, + addEventListener() {}, removeEventListener() {}, + appendChild(c) { el.children.push(c); }, + querySelector(s) { return document.querySelector(s); }, + querySelectorAll() { return []; }, + focus() {}, onclick: null, onchange: null, oninput: null, onkeydown: null, + getContext() { return {}; }, + }; + return el; +} +const ELS = {}; +global.document = { + getElementById: id => (ELS[id] = ELS[id] || mkEl(id)), + // 关键:querySelector 必须返回同一个实例(视图函数会先赋值 innerHTML 再读回来) + querySelector: sel => { + const key = '@sel:' + sel.replace(/^#/, '').replace(/\..*$/, '').replace(/^[^a-zA-Z0-9_-]*/, ''); + return (ELS[key] = ELS[key] || mkEl(key)); + }, + querySelectorAll: () => [], + createElement: t => mkEl('created:' + t), + body: mkEl('body'), +}; +global.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } }; +global.window = global; +global.setTimeout = (f, t) => 0; +global.setInterval = () => 0; +global.clearTimeout = () => {}; +const http = require('http'); +// 用 node 内置 http 直接请求,避免 Node 的 fetch 在纯 IPv4 环境下的兼容问题 +global.fetch = (path, opts = {}) => new Promise((resolve, reject) => { + const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {}); + if (TOKEN) headers['X-Token'] = TOKEN; + const body = opts.body ? Buffer.from(String(opts.body)) : null; + if (body) headers['Content-Length'] = body.length; + const req = http.request({ + host: HOST, port: PORT, path, method: opts.method || 'GET', headers, + }, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => resolve({ + status: res.statusCode, + ok: res.statusCode >= 200 && res.statusCode < 300, + json: async () => JSON.parse(data || '{}'), + text: async () => data, + })); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); +}); + +// ---- 载入前端逻辑(只取需要的函数,避免真跑入口绑定)---- +const fs = require('fs'); +let src = fs.readFileSync('../web/app.js', 'utf8'); +// 去掉入口绑定与自动登录,手动驱动 +src = src.replace(/\/\* ={10,} 入口 ={10,} \*\/[\s\S]*$/, ''); +src += '\nmodule.exports={S,api,doLogin,loadAll,go,PAGES,VIEWS,logout};\n'; +fs.writeFileSync('/tmp/_app_sim.js', src); +const app = require('/tmp/_app_sim.js'); + +(async () => { + // 1. 登录 + const r = await app.api('/api/login', { method: 'POST', body: { username: 'admin', password: 'admin123' } }); + if (!r.ok) { console.log('❌ 登录失败:', r.msg_cn); process.exit(1); } + app.S.token = r.data.token; TOKEN = r.data.token; + console.log('✅ 登录成功, token =', TOKEN.slice(0, 14) + '...'); + + // 2. loadAll + await app.loadAll(); + console.log('✅ loadAll 完成'); + console.log(' S.buildMode =', app.S.buildMode); + console.log(' S.ifaces =', app.S.ifaces.map(i => i.name).join(', ')); + console.log(' S.cfg keys =', Object.keys(app.S.cfg).join(', ')); + + // 3. 逐页渲染 + const allPages = app.PAGES.filter(p => !p.sep); + const keys = allPages.map(p => p.k); + let bad = 0, soon = 0; + console.log('\n=== 逐页渲染 ==='); + const vEl = document.querySelector('#view'); + for (const k of keys) { + vEl._html = ''; + // 标注"待完善"的页面走 renderSoon 占位,属预期,跳过 + const page = allPages.find(p => p.k === k) || {}; + if (page.tag === 'soon') { console.log(` ○ ${k.padEnd(9)} 规划中(等待作者完善)`); soon++; continue; } + try { + const fn = app.VIEWS[k]; + if (!fn) { console.log(` ❌ ${k}: 无视图函数`); bad++; continue; } + await fn(); + const h = vEl._html || ''; + // 真正的"卡在载入"是整页只剩一个占位卡(长度很短); + // 若页面已渲染出大量内容,只在个别子区域出现"正在载入"属正常。 + if (!h || (h.length < 400 && h.includes('正在载入'))) { + console.log(` ❌ ${k}: 仍是占位符 (len=${h.length})`); bad++; + } else if (h.includes('页面渲染失败')) { console.log(` ❌ ${k}: 渲染失败 ${h.slice(0, 90)}`); bad++; } + else console.log(` ✅ ${k.padEnd(9)} 渲染 ${h.length} 字符`); + } catch (e) { + console.log(` ❌ ${k}: 抛异常 ${e.message}`); + bad++; + } + } + console.log(`\n已完成模块 ${keys.length - soon} 个,规划中 ${soon} 个`); + console.log(bad === 0 ? '\nUI_SIM_OK ✔ 全部已实现页面渲染正常' : `\nUI_SIM_FAIL ❌ ${bad} 个页面异常`); +})(); diff --git a/devtools/sync.sh b/devtools/sync.sh new file mode 100644 index 0000000..be0f9dd --- /dev/null +++ b/devtools/sync.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# 把本地 router-build 源码同步到目标机 /tmp/drouter-src 并执行 deploy.sh +# 不会启动任何网络服务、不会改网络。部署后用 build_probe.sh 自检。 +set -e +HERE="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00" +SRC="$HERE/router-build" +RSH="$HERE/router-build/devtools/rsh.sh" + +echo "=== 0. 本地预检(语法 + 编译)===" +cd "$SRC" +# Python 用 py_compile 才能抓出 "global 声明位置错误" 这类只有编译期才报的 bug +PYBIN="C:/Users/lyrz-pve-win10/.workbuddy/binaries/python/versions/3.13.12/python.exe" +NODEBIN="C:/Users/lyrz-pve-win10/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" +"$PYBIN" - <<'PY' || { echo "❌ Python 预检失败"; exit 1; } +import py_compile, tempfile, sys, glob +bad = 0 +for f in sorted(glob.glob('backend/*.py')): + try: + py_compile.compile(f, cfile=tempfile.mktemp(), doraise=True) + print(' 编译OK:', f) + except Exception as e: + print(' 编译失败:', f, e); bad += 1 +sys.exit(1 if bad else 0) +PY +"$NODEBIN" --check web/app.js && echo " 语法OK: web/app.js" +# 后端内部契约:GET /api/config 的模块清单必须与 save_config 白名单一致 +"$PYBIN" _dev/t-web-contract.py || { echo "❌ 配置模块契约预检失败"; exit 1; } +# 页面 key ↔ VIEWS 映射完整性:缺一个就会让菜单点进去卡在「正在载入…」 +"$NODEBIN" devtools/check-views.js web/app.js || { echo "❌ 视图映射预检失败"; exit 1; } +# 离线渲染回归:真正把每个视图函数跑一遍,能抓出「语法没问题但一调用就抛异常」 +"$NODEBIN" devtools/check-render.js web/app.js || { echo "❌ 渲染回归预检失败"; exit 1; } +# 手机适配要素(#14):viewport / 抽屉 / 表格横向滚动,防止改版时被悄悄改掉 +"$NODEBIN" devtools/check-mobile.js || { echo "❌ 手机适配预检失败"; exit 1; } +# 入口页破缓存:部署后用户不手动强刷也能拿到新前端 +"$PYBIN" _dev/t-cachebust.py || { echo "❌ 缓存版本号预检失败"; exit 1; } +# 历史累计流量(概览页),含「计数器回绕 / 重启清零」容错 +"$PYBIN" _dev/t-nettotals.py || { echo "❌ 累计流量预检失败"; exit 1; } +# 环境契约:守护进程 PATH 归一化 + 虚拟网卡归类(都是「接口 ok 但功能悄悄不工作」) +"$PYBIN" _dev/t-env-path.py || { echo "❌ 环境契约预检失败"; exit 1; } +# 打包产物结构自检:deb 元数据 / 维护脚本 / Dockerfile(挡住「打出来的包不能用」) +"$PYBIN" _dev/t-packaging.py || { echo "❌ 打包自检失败"; exit 1; } +# 防火墙日志「为什么一条都没有」的状态判定:规则未载入 / 开关未开必须分开提示 +"$PYBIN" _dev/t-fwlog-state.py || { echo "❌ 防火墙日志状态预检失败"; exit 1; } +# 外置存储(USB/Type-C/雷电):格式化的三重保护是最容易造成不可逆损失的地方 +"$PYBIN" _dev/t-storage.py || { echo "❌ 外置存储预检失败"; exit 1; } +# NAT 检测详情:后端是对象、前端曾当纯文本渲染成 "[object Object]",别再回退 +"$PYBIN" _dev/t-nat-detail.py || { echo "❌ NAT 详情渲染预检失败"; exit 1; } +# Web 终端(真 PTY):helper/shelld/web 三端的协议契约 + 超时分级 +"$PYBIN" _dev/t-webshell.py || { echo "❌ Web 终端契约预检失败"; exit 1; } +# 终端模拟器行为:ANSI 光标 / 颜色 / 换行 / 分帧中文,在 node 里真跑一遍前端代码 +"$NODEBIN" _dev/t-webterm.js || { echo "❌ 终端模拟器预检失败"; exit 1; } +# 依赖自检清单:一键安装绝不能把 xfce4 / samba / docker 这类可选大件一起装上 +"$PYBIN" _dev/t-deps.py || { echo "❌ 依赖清单预检失败"; exit 1; } +# 全项目静态审计:动作名未注册 / 前端调了后端没有的路由 / 同名函数重复定义 / +# CSS 类缺失。这类 bug 语法检查全过,只在点进去那一刻才爆。 +"$PYBIN" _dev/t-audit.py || { echo "❌ 全项目静态审计失败"; exit 1; } +# 快照覆盖率与容错:一个 0600 的 root 文件就让整个 /etc/drouter 进不了快照, +# 而界面还显示「已创建快照」——这种静默失败必须挡住。 +"$PYBIN" _dev/t-snapshot.py || { echo "❌ 快照覆盖预检失败"; exit 1; } +# 常驻执行守护(#7):socket 快路径 + subprocess 回退,两者都不能少。 +# 少了回退,守护一挂面板就全废;少了快路径,每个请求白付 236ms 启动开销。 +"$PYBIN" _dev/t-helpd.py || { echo "❌ 常驻执行守护预检失败"; exit 1; } +# 统一日志:conntrack -E 是阻塞事件流,曾经让每次查询固定白等 2 秒 +"$PYBIN" _dev/t-ulog-perf.py || { echo "❌ 统一日志性能预检失败"; exit 1; } +# 响应压缩与 ETag 协商缓存:少了 Vary 会让代理把 gzip 喂给不支持的客户端 +"$PYBIN" _dev/t-gzip.py || { echo "❌ 响应压缩预检失败"; exit 1; } +# 模块联动:前端「保存/应用」要处理的模块,后端必须接得住。 +# 曾经 system / pppoe / portfwd 三个页面点了应用直接弹「未知的模块」。 +"$PYBIN" _dev/t-linkage.py || { echo "❌ 模块联动预检失败"; exit 1; } +# 常驻守护重启契约:deploy.sh 里 shelld 的指纹取样必须在 install 之前, +# 否则「代码变了就重启」永远不触发,改了守护代码真机上还是旧行为。 +"$PYBIN" _dev/t-daemon-restart.py || { echo "❌ 守护重启契约预检失败"; exit 1; } +# 磁盘与日志清理:这是唯一会真删文件的模块,安全边界表 + glob 不重叠必须每次验。 +# 边界表漏一项就是误删配置库,glob 重叠则会让页面虚报可释放量。 +"$PYBIN" _dev/t-cleanup.py || { echo "❌ 磁盘清理预检失败"; exit 1; } +# 内核转发与加速:默认值是我替用户做的判断,改错会直接改变这台机器的网络行为; +# 渲染器侧的 masquerade / MSS 也必须跟着校验,否则页面开了但规则集里没有。 +"$PYBIN" _dev/t-kern.py || { echo "❌ 内核转发预检失败"; exit 1; } +# Docker 引擎配置:这一页会覆写 /etc/docker/daemon.json,合并逻辑是唯一的防线 —— +# 用户自己写的 data-root / insecure-registries 被静默丢掉是最糟的体验; +# 保存流程里多一个 systemctl 就会在用户不知情时中断所有容器。 +"$PYBIN" _dev/t-dcfg.py || { echo "❌ Docker 引擎配置预检失败"; exit 1; } +# 打印服务:CUPS 与 USB RAW 抢同一个设备,切模式时没把对面停干净就是两边都打不出; +# cupsctl --remote-any 写的 Allow all 必须收窄,否则等于把打印机暴露给所有来源。 +"$PYBIN" _dev/t-print.py || { echo "❌ 打印服务预检失败"; exit 1; } + +# AC/AP 管理中心:升级或改端口绝不能重新生成加密密钥(历史配置会解不开); +# 安装包是外部下载的,解包必须挡住 zip slip。 +"$PYBIN" _dev/t-opensoho.py || { echo "❌ AC/AP 管理中心预检失败"; exit 1; } +# CA 证书管理:部署是唯一能把用户关在门外的动作 —— 换证书前必须过四道闸, +# 换完必须握手比对指纹,不对就自动还原;有效期/SAN 的 falsy 陷阱也要每次验。 +"$PYBIN" _dev/t-ca.py || { echo "❌ CA 证书管理预检失败"; exit 1; } +# 新手向导:这是新手唯一会看的页面,填错了他没有任何判断依据。 +# 地址池「圈进本机自己」这类错要在服务端就拦住 —— 不拦的表现是网络时通时不通, +# 且没有任何日志提示原因。另外「保存账号」绝不能顺带拨号(会在用户不知情下断网)。 +"$PYBIN" _dev/t-wizard.py || { echo "❌ 新手向导预检失败"; exit 1; } + +# 性能回归:单文件 SPA 最容易悄悄退化的三类问题 —— 切页漏停定时器(后端被空打)、 +# 高频读接口里滥 fork subprocess(2 核机上每次切页面白烧几十毫秒)、 +# 写了参数却两个分支一样(quick 形同虚设)。这类问题不影响功能,只咬性能, +# 功能测试永远抓不到,必须单独钉住。 +"$PYBIN" _dev/t-perf.py || { echo "❌ 性能回归预检失败"; exit 1; } + +# 换行符检查:Windows 上写出的 CRLF 到了 Linux 会让 bash 报 "$'\r': 未找到命令", +# 整个 deploy.sh 会静默跑成一堆错误。必须在打包前拦住。 +# 注意:不能用 grep —— Git Bash 的 grep 在文本模式下会把 LF 读成 CRLF,全是误报。 +"$PYBIN" - <<'PY' || { echo "❌ 换行符预检失败"; exit 1; } +import glob, sys +bad = [] +for ext in ('.sh', '.py', '.js', '.css', '.html'): + for f in glob.glob('**/*' + ext, recursive=True): + if '__pycache__' in f: + continue + if b'\r' in open(f, 'rb').read(): + bad.append(f) +if bad: + print('以下文件含 CRLF 换行,Linux 上会执行失败:') + for f in bad: + print(' ' + f) + print('修正:python3 -c "open(f,\'wb\').write(open(f,\'rb\').read().replace(b\'\\r\\n\',b\'\\n\'))"') + sys.exit(1) +print(' 换行符OK: 全部为 LF') +PY + +echo "=== 打包源码 ===" +# docs 也要带上:deploy.sh 会装到 /opt/drouter/docs(systemd 的 Documentation 指向它) +tar czf /tmp/drouter-src.tgz backend web scripts docs 2>/dev/null +ls -la /tmp/drouter-src.tgz + +echo "=== 上传 ===" +"$HERE/router-build/devtools/rscp.sh" /tmp/drouter-src.tgz ajeef@192.168.7.3:/tmp/drouter-src.tgz 2>&1 | tail -3 + +echo "=== 解包 + 部署 ===" +"$RSH" "rm -rf /tmp/drouter-src && mkdir -p /tmp/drouter-src && tar xzf /tmp/drouter-src.tgz -C /tmp/drouter-src" 2>&1 | tail -3 +bash "$HERE/router-build/devtools/rsudo.sh" "bash /tmp/drouter-src/scripts/deploy.sh" 2>&1 | tail -45 diff --git a/dist/.gitkeep b/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/01-项目规格与架构.md b/docs/01-项目规格与架构.md new file mode 100644 index 0000000..73a3396 --- /dev/null +++ b/docs/01-项目规格与架构.md @@ -0,0 +1,625 @@ +# Debian 13 主路由系统 —— 优化版项目规格与架构设计 + +> 本文是对原始 13 条大白话需求的工程化重写,并吸收四份参考方案的精华、剔除其弊端。 +> 可直接作为后续开发/交付给任意 AI 的项目提示词使用。 + +--- + +## 一、目标机实测现状(2026-09-27 重新采集,192.168.7.3 / PVE 102) + +> 本机曾装有第三方路由系统 `landscape-router`(占用 DNS:53、DHCP:67 等), +> 因影响局域网,用户已用 ZSDT 快照还原 Debian 13 至干净状态。 +> 以下为**还原后**的实测现状。 + +| 项目 | 实测值 | 影响 | +| ---------------- | ----------------------------------------------------------------------------------- | ----------------- | +| 系统 | Debian GNU/Linux 13 (trixie),内核 6.12.107-amd64 | 符合底座要求 | +| 引导 | SeaBIOS 传统 BIOS(无 /sys/firmware/efi) | 符合要求,grub-pc | +| CPU / 内存 | 2 核 / 3.9 GiB(可用 3.2 GiB) | 必须极致轻量 | +| 桌面 | xfce4 + xserver-xorg + lightdm **已安装** | 满足 RealVNC 前提 | +| **RealVNC** | **`realvnc-vnc-server 6.11.0.47988` 已安装并在运行**(`vncserver-x11-serviced`,监听 5900) | ✅ 关键变化,见 §5.1 | +| 物理网卡 | **仅 ens18**(bc:24:11:1a:8f:43);ens19 已被用户在 PVE 中移除 | 见 §5.2「单网口起步」 | +| ens18 | 192.168.7.3/24(**DHCP 获取**),网关 192.168.7.2,且已拿到公网 IPv6 (`2408:8340:5427:28f1::/64`) | 定位为 **LAN / 管理口** | +| 网络管理者 | **NetworkManager active**(systemd-networkd inactive) | ⚠️ 需迁移,见 §5.3 | +| docker | **已不存在**(inactive,无 docker0) | ✅ 冲突源已消除 | +| nftables | 仅 `/etc/nftables.conf` 一个空的 `inet filter` 表,无任何规则 | ✅ 可自由接管 | +| landscape-router | **已彻底不存在**(无目录、无 systemd 单元、inactive) | ✅ 干净 | +| 其他服务 | avahi-daemon 在跑(5353) | 可关闭省内存 | +| 硬件保留 | grub 仍保留旧环境的 `ens19` 网络配置残留 | 需清理 | + +### 关键结论 + +1. 环境已回归**近乎白纸**状态,无需再做"接管旧系统"的复杂动作,可按最优架构直接构建。 +2. **RealVNC 商业版已就位且在跑** —— 这从"待办风险项"变为"必须保护的既有资产": + 任何操作都不得破坏 X 会话、LightDM 与 5900 监听。 +3. 只有 **1 个物理网卡**,因此第一阶段(构建期)网络拓扑为 + **ens18 = LAN + 管理口**,WAN 待用户在 PVE 加卡后再绑定。 + +--- + +## 二、优化后的完整需求规格(可直接作为提示词) + +### 0. 总体架构与约束 + +- 底座:Debian 13(trixie),SeaBIOS 传统引导,保留 XFCE + Xorg + LightDM。 +- 架构:**原生服务承担协议逻辑 + 轻量 Web 管理端只做配置生成/下发/观测**。 + Web 端不重新实现 PPPoE、DHCP、RA、NAT 等协议,只负责生成各服务的原生配置文件并 reload。 +- 资源约束(2C/3.8G):Web 后端使用 **Python 3 标准库**(http.server + ssl + sqlite3), + 不引入 Flask/FastAPI/Node/gunicorn;常驻内存目标 < 25 MB。 +- 前端:原生 HTML + CSS + Vanilla JS,无构建步骤、无 npm 依赖。 +- 模块化:每个功能一个独立页面,页面右下角固定 **"保存并应用"** 操作栏, + 具备:语法/配置预检 → 备份快照 → 应用 → 结果提示(简体中文)→ 可回滚。 +- 持久化:SQLite 保存 Web 层配置(网卡备注/角色、WAN/LAN、DHCP Options、管理员账户、审计)。 + **SQLite 不作为唯一配置源**:每次应用都会渲染出原生配置文件落盘,系统重启后以原生配置为准。 + +### 1. 用户与登录体系(两套分离) + +- **Web 管理员**:存 SQLite,PBKDF2-HMAC-SHA256 加盐哈希;独立于 Linux 用户;可改密码。 +- **Debian 系统用户**:Web 可增删系统用户、修改密码、禁用/启用;用于 SSH 登录。 +- **SSH 公钥**:支持粘贴文本与上传 .pub 文件两种写入方式,写入对应用户 `~/.ssh/authorized_keys`, + 属主/权限强制修正为 `owner:user 600`,`.ssh` 目录 `700`。**明确禁止上传私钥。** +- **root 策略**:SSH `PermitRootLogin no` 保持不变;本地控制台/串口保留 root 应急登录。 +- 所有用户管理操作写入审计日志。 + +### 2. 网卡管理(MAC 为主键) + +- 以 **MAC 地址** 作为接口唯一标识,网卡名(ens18/ens19/enpXsY)仅作为当前显示值。 + 硬件变更导致改名后,仍能按 MAC 恢复角色与备注。 +- 字段:MAC、当前名称、备注(如"电信光猫"/"客厅交换")、角色(WAN / LAN / 未分配)、 + 链路状态、速率、MTU、驱动。 +- 支持**桥接**:将多个"未分配/LAN"口加入一个 bridge(br0),显示从属接口清单。 +- **新增网卡监测**:每次页面加载与定时轮询时比对 MAC 集合,出现未知 MAC 时 + 在页面顶部弹出"检测到 N 个新网卡,请分类"提示条。 +- 提供"硬件变更后的重新识别/修复"流程:按 MAC 重新绑定旧配置到新网卡名。 + +### 3. WAN 口 + +- 模式:PPPoE / DHCP(自动) / 静态,支持**一键切换**(需确认,会断网)。 +- PPPoE:账号密码、服务名(可空)、MTU/MRU(默认 1492)、按需拨号/自动重连、 + 断线自动重拨、在线时长统计、默认路由、usepeerdns。 +- 运营商适配(中国电信 / 中国联通 / 中国移动 / 中国广电)提供模板,主要差异在于 + MTU、服务名、认证方式(PAP/CHAP/MS-CHAP)。**必须明示实际兼容性取决于运营商与线路,需实测。** +- 显示:连接状态、IPv4 地址、网关、DNS、MTU、TX/RX 实时速率与累计流量、在线时长。 +- TX/RX 速率:直接轮询 `/sys/class/net//statistics/{rx,tx}_bytes` 计算差值,**零额外依赖**。 +- ⚠️ **初始部署阶段只保存凭据、不自动拨号**,由用户确认后再触发切换。 + +### 4. LAN 口 + +- 支持网口桥接(br0),桥的从属口可视化增删。 +- LAN IPv4 地址/掩码可编辑(当前 192.168.7.1/24)。 +- 显示当前 LAN 地址、地址池规划、已分配租约(切换后)。 + +### 5. DHCP Server(RouterOS 风格 Options) + +- 引擎:dnsmasq。 +- 支持任意 DHCP Option Code 自定义: + - code 3 = Gateway(路由器) + - code 6 = DNS Server + - 其他 code(如 43、121)自由添加,支持 `dhcp-option` 与 `dhcp-option-force`。 +- 地址池范围、租期、静态绑定(按 MAC 固定 IP)。 +- **输入校验**:IP 合法性、多个 DNS 用逗号分隔、非法 code 拦截,防止生成坏配置。 +- 应用前 `dnsmasq --test` 语法校验。 +- ⚠️ **初始阶段默认不启动**,避免与现有网络中的 DHCP 冲突造成地址风暴。 + +### 6. 防火墙(nftables) + +- IPv4 与 IPv6 **分页面**展示与编辑,底层分别落到 `ip drouter4` / `ip6 drouter6` 表。 + (刻意避开 docker 占用的 `ip filter` / `ip nat` 表名,见 §5.2) +- 提供"规则编辑文本框"供高级用户直接编辑,支持 `nft -c -f` 语法预检。 +- 应用前自动快照,应用失败或 60 秒确认窗口内失联则**自动回滚**。 +- 内置常用能力:NAT 伪装(masquerade)、端口映射、MSS clamp(PPPoE 必备)、 + 基础防入站、转发策略开关。 + +### 7. IPv6 完整功能 + +- 区分并分别展示:**WAN 地址获取 / DHCPv6 / 前缀委派 PD / LAN RA / LAN 地址池**。 +- DHCPv6-PD 客户端:dhcpcd-base(`/etc/dhcpcd.conf`),显示是否拿到 PD 前缀。 +- RA:radvd(`/etc/radvd.conf`),可调 RA 间隔、生命周期、M/O 标志、RDNSS、DNSSL。 +- ND 参数:可调 `/proc/sys/net/ipv6/conf/*/` 下的 `ra`、`forwarding`、`mtu`、`accept_ra`、 + `dad_transmits` 等;提供 ndppd 选项(仅在 ISP 需要 ND 代理时)。 +- POOL 池可视化 + 备注(如"电信 /56")。 +- **不假设任何运营商一定下发 PD**,页面必须明确区分"有链路地址"与"拿到可分配前缀"。 + +### 8. DNS Server + +- 引擎:dnsmasq。 +- 三种上游来源可组合:① 继承 PPPoE/运营商下发 DNS(usepeerdns 生成的文件) + ② 自定义公共 DNS(223.5.5.5 / 119.29.29.29 / 114.114.114.114 / 8.8.8.8 等) + ③ 内网 DNS 服务器 IP。 +- 支持 DNS 查询日志开关(默认关闭以省资源)。 +- ⚠️ 初始阶段不抢占 53 端口。 + +### 9. UPnP + +- miniupnpd:开/关、指定 WAN 口与 LAN 监听接口。 +- **默认不得在 WAN 侧开放**,页面提供当前映射表查看。 +- 生成配置只允许出现 miniupnpd 2.3.x 认识的选项(`ext_ifname` / `listening_ip`(网卡名) / + `http_port` / `enable_upnp` / `enable_natpmp` / `secure_mode` / `upnp_forward_chain` / + `upnp_nat_chain` / `upnp_nat_postrouting_chain` / `system_uptime` / `lease_file` / `uuid`) + 以及 `allow`/`deny` 权限规则。 +- ⚠️ `upnp_nat_prerouting_chain` 在 2.3.x **不存在**,写入会导致服务启动失败(已修正)。 +- 两项开关(`enable_upnp` / `enable_natpmp`)都关闭时,进程正常退出(`Why did you run me anyway?`), + 此时 `systemctl is-active` 为 `inactive` 属**预期状态**。 + +### 10. NTP Client + +- chrony:自定义 server 地址列表;显示同步状态(已同步/未同步/偏移量/层级)。 + +### 11. 诊断工具集 + +- Ping(`iputils-ping`)、Traceroute / MTR(`mtr-tiny`)、 + iperf3(内网吞吐)、互联网测速(HTTP 下载测速,不依赖外部 CLI)、 + 接口状态、连接数(`conntrack`)、抓包入口(`tcpdump`,限定包数与时长)。 +- 所有命令**参数白名单校验**,禁止任意 shell 拼接。 + +### 12. 日志 + +- 统一结构化 JSONL:`{ts, level, module, code, msg_cn, msg_en, detail}`, + 落盘 `/var/log/drouter/`,按模块分割(system/network/dhcp/dns/firewall/ipv6/pppoe/web)。 +- 页面按模块过滤、关键字搜索、导出(JSON/文本)。 +- **所有面向用户的提示、警告、错误以简体中文显示**,同时保留原始英文错误原文便于 AI 诊断。 +- 同时提供 systemd journal 的聚合查看。 + +### 13. 电源与系统 + +- 安全重启(`systemctl reboot`)、安全关机(`systemctl poweroff`)、 + 强制重启(受二次确认保护,走 sysrq)。 +- 可选定时重启。 +- Web 操作审计:谁、何时、改了什么、是否成功。 + +### 14. 升级与桌面保护(RealVNC 相关) + +- **反对"长期冻结大批系统包"的做法**:那会错过安全补丁,且 Debian 13 的 `apt upgrade` + 不会无故删除 XFCE。 +- 正确做法: + 1. Web 内"系统更新"模块默认只做 `apt update` + **列出待升级清单**,不做无人值守升级; + 2. 自动把 `xserver-xorg*`、`linux-image-*`、`xfce4*`、`realvnc*` 标记为 **高危变更**并高亮; + 3. 升级前强制创建配置快照;升级后提供"验证清单"(图形会话、VNC 连接、路由服务); + 4. 仅当 RealVNC 官方明确要求固定版本时,才对**该包**单独 `apt-mark hold`, + 提供开关而非默认全量 hold。 + +### 15. NAT 类型检测(自动) + +- 纯 Python 标准库实现 STUN Binding Request(原始 UDP socket + `struct` 手工构包), + 无需 `stun`/`pystun` 等第三方包。 +- 多服务器交叉判定:`stun.miwifi.com` / `stun.chat.bilibili.com` / `stun.cloudflare.com`。 +- 输出 `NAT1`(全锥形)/ `NAT2`(地址受限锥形)/ `NAT3`(端口受限锥形)/ `NAT4`(对称型) + 及对应中文语义说明;结果缓存以便进入页面即时展示。 +- 实现于 `act_nat_check`,web 路由 `/api/nat/check` + `/api/nat/last`。 + +### 16. nftables flowtable 软加速 + +- 生成 `table inet drouter_accel`: + `flowtable f { hook ingress priority 0; devices = {...} }` + - `chain forward { ct state established,related flow add @f }`。 +- **硬件卸载自动探测**:`_hw_offload_capable()` 逐网卡检查 `ethtool -k` 的 `hw-tc-offload`。 + - 支持 → 渲染带 `flags offload` 的 flowtable,并 `ethtool -K ... hw-tc-offload on`; + - 不支持(典型:**virtio 网卡为 `off [fixed]`**)→ 渲染**纯软件 flowtable**。 + - `nft -c` 预检失败时自动降级为软件模式重试。 +- 页面与 NAT 检测**同页**(概述 → 网络状态),显示开/关状态 + 8 项影响说明。 +- 实现于 `act_accel`,web 路由 `/api/accel`(op = get/on/off)。 + +### 17. VLAN 与 WOL 唤醒 + +- **VLAN**(主要面向 IPTV):支持在父接口上创建 802.1Q 子接口,内置运营商 IPTV 常见 VID 预设; + 记录存 `settings.vlan`,落地 `/etc/drouter/generated/vlans.json`。 +- **WOL**(网卡远程唤醒):读取网卡 `supports_wol` / `wol_mode`(`ethtool`), + 发送 magic packet(`etherwake` 或原生 socket),支持保存常用目标设备。 +- 均归属「接口」父菜单(二层/物理口相关)。 + +### 18. Web 终端(受控 Shell) + +- **非 PTY**:单命令 `/bin/sh -c` 执行,不做完整终端仿真 —— 安全优先。 +- `SHELL_BLOCK` 危险命令黑名单(`rm -rf /`、`mkfs`、`dd of=/dev/*`、`shutdown`、`reboot` 等) + - `_shell_check` 拦截。 +- 前端支持:用户名密码登入、配置存 localStorage、私钥/公钥登入、导入导出(含密钥)到文件。 +- 实现于 `act_webshell`,web 路由 `/api/webshell/*`。 + +### 19. 文件管理器 + +- 动作:`list` / `read` / `get` / `hash` / `mkdir` / `rename` / `delete` / `zip` / `write` / `search`。 +- **三重安全边界**: + 1. `FS_ROOTS` 根目录白名单; + 2. `FS_DENY` 敏感路径拒绝; + 3. `realpath` 解析后二次校验(防软链绕过)。 +- 删除进**回收站** `/var/lib/drouter/trash`(不直接 `unlink`)。 +- 支持单文件下载、文件夹 zip 打包下载、带进度条上传。 +- 实现于 `act_fs`,web 路由 `/api/files*`。 + +### 20. 运行依赖自检与一键安装 + +- 35 项依赖清单,`_dep_installed` 支持 `'a|b'` **别名匹配**(如 `'vim|vim.tiny'`)。 +- 一键安装日志落盘 `/var/log/drouter/dep-install.log`,失败时前端弹出**可复制**日志。 +- 提供「再次检测」按钮;检测 `/usr` 只读并给出可操作提示。 +- 实现于 `act_depcheck`,web 路由 `/api/deps/check|install|log`。 + +### 21. 通用外部 API + +- `build_openapi(base)` → **OpenAPI 3.0.3** 描述文档(33 个路径)。 +- `build_examples(base)` → **9 语言范例**:curl / python / javascript / go / java / php / shell / powershell / http。 +- 设计目标:**零依赖接入** —— 任何语言只要能发 HTTP + `Authorization: Bearer ` 即可调用。 +- 路由:`/api/openapi`、`/api/openapi.json`、`/api/examples`;前端「工具 → API」页面可切换语言并一键复制。 + +### 22. 虚拟化环境感知 + +- `_detect_virt()`:读 `/sys/class/dmi/id/sys_vendor` + `/sys/class/dmi/id/product_name` + - `/proc/cpuinfo` hypervisor 标志,`systemd-detect-virt` 兜底; + 比对 `_VIRT_SIGNATURES`(proxmox/qemu/kvm/vmware/hyperv/xen/virtualbox/parallels/bochs/cloud)。 +- `_VIRT_PASSTHRU` 给出各平台**硬件直通建议**(含"不保证成功"的诚实提示)。 +- 用途:温度传感器缺失时给出准确原因(虚拟机无法读取物理传感器), + 前端以 ⚠ 惊叹号 + 说明文案呈现;主机信息卡新增「运行平台」。 + +### 23. PPPoE 多拨(对标爱快「多拨」) + +- **目标**:在同一物理 WAN 口上并发 N 条 PPPoE 会话,聚合带宽并做负载均衡 / 主备切换。 +- **实现要点(缺一不可)**: + 1. 每条会话是一张独立 ppp 接口 `drouter-wan1..N`,由独立 systemd 单元 + `drouter-pppoe@i.service` 管理(`pppd call nodetach`); + 2. 每条会话绑定**独立路由表** `100+i`; + 3. **基于源地址的策略路由**(`ip rule from <本端IP> lookup 100+i` + `ip rule fwmark 1000+i`) + —— 这是多拨能工作的关键:没有它会出现「A 会话出去的包从 B 会话回来」导致连接重置; + 4. 三种负载策略: + - `balance`:加权 ECMP,所有会话同时承载; + - `primary_backup`:只用第一条,主链路断开自动切备用; + - `weighted`:按每会话 `weight` 分配(如 500M+500M+200M → 5:5:2)。 +- **钩子**:`/etc/ppp/ip-up.d/drouter-pppm-route` 与 `ip-down.d/` 对应脚本, + 在拨通/断开时自动挂接或清理路由表与 ip rule。 +- **账号存储**:`/etc/drouter/generated/pppoe-multi.json`(0600,含密码); + 凭据以 `include` 方式写入 `/etc/ppp/chap-secrets.drouter-multi`(0600), + **不覆盖**用户原有 `chap-secrets` 内容。 +- **安全**:`apply` 需 `confirm=true` 且 BUILD_MODE 关闭;密码**永不回传前端**, + 前端只拿到 `has_password` 布尔值;密码留空表示沿用旧值。 +- 实现于 `act_pppoe_multi`,web 路由 `/api/pppoe-multi`、`/api/pppoe-multi/log`。 +- 菜单位置:**接口 → PPPoE 多拨**(紧跟 WAN 口之后)。 + +### 24. QoS 智能限速(对标爱快「智能流控」) + +- **四层能力,按「先简单后复杂」组织**: + 1. **CAKE 智能整形**(默认):上行直接挂 WAN 出口 + `tc qdisc replace dev $WAN root cake bandwidth Mbit nat dual-srchost `; + 下行**必须走 IFB 重定向**(入口流量无法直接排队): + `tc qdisc replace dev $WAN handle ffff: ingress` + + `tc filter replace dev $WAN parent ffff: protocol all matchall action mirred egress redirect dev ifb-drouter` + 再在 IFB 上 `tc qdisc replace dev ifb-drouter root cake ... nat dual-dsthost `; + 2. **HTB 按 IP 精确限速**(填了 IP 规则则启用,与 CAKE 互斥): + WAN 与 IFB 各建同构 HTB 树,nft `meta mark set 0x100x` 打标, + `tc filter ... handle 0x100x fw flowid 1:1x` 分类;每类挂 `fq_codel`; + 3. **DSCP 打标**:4 个预置(游戏 cs4 / 语音 ef / DNS ef / BT cs1), + 链 `dscp` 在 `priority mangle`,双向按端口打标; + 4. **nftables 连接数限制**:`ct state new ct count over counter drop`。 +- **CAKE 三档模式**:`diffserv3`(推荐)/ `diffserv4` / `besteffort`。 +- **⚠ 与 flowtable 软加速冲突**:flowtable 让已建立连接绕过 netfilter 钩子, + 导致 DSCP 打标与 mark 分类失效。`get` 返回 `accel_conflict` 主动提示, + `on` 会拦截并要求用户先关闭软加速(或显式 `ignore_accel=true`)。 +- **幂等性**:脚本内先用 `tc qdisc replace`(replace 语义)+ 加载 nft 前 + `nft delete table inet drouter_qos`,可重复执行不叠加规则。 +- 关键常量:`QOS_IFB='ifb-drouter'`、`QOS_MARK_BASE=0x1000`。 +- 实现于 `act_qos`,web 路由 `/api/qos`。 +- 菜单位置:**服务 → QoS 智能限速**(服务组首位)。 + + +### 25. DPI 识别库 + 多前缀代理更新 +- **用途**:按应用/协议识别流量(爱快的杀手锏)。Debian 上对应 nDPI / Suricata。 +- **痛点**:nDPI 规则库与源码都在 GitHub,国内直连常常超时或极慢。 +- **方案:7 个内置加速前缀 + 自定义**: + `direct` / `ghproxy` / `gh-proxy` / `moeyy` / `ghfast` / `ghproxy-net` / `gitclone` + (`gitclone` 是整站镜像,URL 拼接规则与其他不同,代码内已单独处理)。 +- **测速优选**:`_dpi_test_mirror` 对候选前缀做**限时 4s + 限速 16KB/s** 的 + 连通性测试,只要在时限内拿到 >=1 字节且 HTTP 2xx 即判可用,按耗时升序返回。 + > **实测踩坑**:起初用 `curl -r 0-0`(Range 取 1 字节)测速,结果**多数公共加速会忽略 + > Range 头**,直接开始传整个 46MB 包 → 必然超时,产生假阴性。改用「限时+限速+看是否 + > 收到数据」后结果才与真实下载能力一致。另外 gitclone / gh-proxy 等**不代理 + > `codeload.github.com`**,测速 URL 必须与真正下载的 URL 同形。 +- **三个来源**(可分别更新): + - `ndpi-protos`(ntop/nDPI,archive)→ `/opt/drouter/dpi/ndpi` + - `suricata-rules`(OISF/suricata-update,archive)→ `/opt/drouter/dpi/suricata-update` + - `ndpi-netfilter`(ndpi-netfilter/ndpi-netfilter,archive)→ `/opt/drouter/dpi/ndpi-netfilter` +- **原子安装**:下载 → 解包到私有临时目录 → 旧版本改名为 `.old` → 移入 `target` → 清理。 + 解包失败不会破坏旧版本。 +- **安装建议(plan,不真正执行 apt)**:`apt` / `source`(源码编译)/ `netfilter`(内核模块) + 三条路径,各自生成可复制到 Web 终端的命令。 +- **状态与日志**:`/opt/drouter/dpi/state.json`(记录 last_mirror / last_update / results); + 日志落盘 `/var/log/drouter/dpi-update.log`。 +- 实现于 `act_dpi`,web 路由 `/api/dpi`、`/api/dpi/log`。 +- 菜单位置:**服务 → DPI 识别库**(紧接 QoS 之后)。 + +### 25.1 helper 私有临时目录(跨模块基础设施) +- **背景**:`/tmp` 与 `/var/tmp` 带 sticky 位(`drwxrwxrwt`)。内核默认 + `fs.protected_regular=2` 时,**root 也会被禁止覆写「不属于自己的已存在文件」**。 + 历史版本若以低权用户(`drouter`)在 `/var/tmp` 留下过同名文件,后续 root 写入 + 会**永久报 `Permission denied`** 而卡死(本项目的 QoS 首次就该坑失败)。 +- **对策**:统一改用 `/run/drouter-helper/`(tmpfs,重启自清,root 独占 0700), + 文件名带 `pid + 毫秒时间戳` 保证唯一。辅助函数 `tmp_nft_path(tag)` / + `write_tmp_nft(tag, text)` / `cleanup_tmp(*paths)`。 +- **影响范围**:QoS 规则预检、flowtable 加速预检、`_verify()` 通用配置预检、 + DPI 下载解包工作目录,均已迁移。 +- 部署脚本 `deploy.sh` 会 `mkdir -p /run/drouter-helper && chmod 700`。 + +### 25.2 nft 语法与预检的两个坑(实测记录) +- **`nft -c` 需要 netlink 缓存权限**:在受限沙箱/低权环境报 + `cache initialization failed: Operation not permitted`。这不是规则语法错误。 + **对策**:`_nft_environ_blocked()` 识别该特征后**降级为「影子表真实加载校验」** + —— 把表名换成 `_check` 生成一份规则真实 `nft -f` 加载,校验通过后立刻删除, + 既不污染生产表,又能完整验证语法与表达式合法性。 +- **nft 保留字与 priority 表达式**: + - 链名不能叫 `mark`(保留字)→ 报 `syntax error, unexpected mark`,改用 `marking`; + - `priority mangle + 1` **非法**(priority 不支持算术)→ 必须写数值, + 本项目的标记链用 `priority -149`(mangle=-150 之后 1 位); + - `chain` 块内每条规则末尾**必须带分号**,漏写报 + `syntax error, unexpected '}', expecting newline or semicolon`。 +- **`nft -f` 是追加语义**:重复 `nft -f` 同一文件会把规则叠成两遍。 + **对策**:加载前先 `nft delete table inet `(幂等)。 + +### 25.3 新手向导(#84)—— 为什么单开一个页面而不是把既有页面做简单点 + +- **菜单位置**:**概览 → 新手向导**(概览组第一位,`needSave: false`)。 + 新手打开后台第一眼就该看见它;放进系统组或工具组要先知道去哪找,就失去意义了。 +- **职责边界**:向导**不新增任何配置能力**,它只是把既有模块(`ppp` / `dnsmasq` / + `chrony` / `radvd` / `nft_v4`)的必填项挑出来,套上范例与体检。所有写盘仍走 + `act_apply('live': True)` 这条唯一通道 —— 向导里**没有第二套落盘逻辑**。 +- **后端**:`act_wizard`(`op` = `probe` / `apply_step` / `dial`)。纯函数 + `_wiz_ip_in_pool` / `_wiz_same_subnet` / `_wiz_local_ipv4` / `_wiz_ping` / + `_wiz_dns_probe` / `_wiz_has_ipv6_global` 全部可在隔离命名空间里单测。 +- **前端**:`viewWizard` 等 18 个函数;四步是可点按钮而非进度指示器 + (用户可以任意顺序回头补第 2 步)。 +- **两条硬规则**(写进代码注释,改代码时别踩回去): + 1. **地址池校验按数值比**:`'192.168.1.9' > '192.168.1.100'` 字符串比是 True, + 按字符串比会把合法地址池判成非法。 + 2. **第 4 步前缀强制 `/64`**:RFC 4862 规定 RA 通告前缀长度必须为 64; + 用户填 `/80` 自动规范化并说明原因,关闭 IPv6 时**保留前缀不删**。 +- **半成功不回滚**:四步里某步失败时,前面已成功的步骤保持生效。 + 新手最怕的是「点了个按钮,全没了」。 +- **拨号必须二次确认**:`dial` 是独立动作(保存 ≠ 拨号),要手打「拨号」两个字, + 页面明示「会短暂中断现有网络」。 +- **验收脚本刻意不真拨号**:`_dev/live-wizard.py` 只验参数校验, + 真拨号会改默认路由 —— 触碰「不接管路由」这条红线。 +- 单测 `_dev/t-wizard.py`(160 项)+ 真机 `_dev/live-wizard.py`(75 项)全过; + 单测已挂进 `devtools/sync.sh` 预检链。 + +--- + +## 三、软件包清单与职责 + +| 类别 | 软件包 | 职责 | 备注 | +|---|---|---|---| +| 桌面 | xfce4 / xserver-xorg / lightdm | RealVNC 的真实 X 会话 | 已安装 | +| 远程桌面 | **realvnc-vnc-server 6.11.0** | 商业版远程桌面 | **已安装并运行,只保护不接管** | +| 网络核心 | iproute2 | 地址/链路/路由/桥接 | 必需 | +| 网络核心 | ethtool | 速率/协商/驱动信息 | 需安装 | +| 网络核心 | bridge-utils / vlan | 桥接与 802.1Q(兼容工具) | 需安装 | +| 网络后端 | **NetworkManager(nmcli)** | 网卡/桥接/地址管理者(见 §5.3) | **已运行,沿用** | +| WAN | ppp / pppoe / pppoeconf | PPPoE 拨号(rp-pppoe 插件) | 需安装(ppp 已装) | +| LAN 服务 | dnsmasq | DHCPv4 + DNS 缓存/转发 + DHCP Options | 需安装 | +| 防火墙 | nftables | 唯一防火墙引擎 | 已装 | +| IPv6 | radvd | LAN RA 通告 | 需安装 | +| IPv6 | dhcpcd-base | DHCPv6 / PD 前缀委派客户端 | 已装 | +| IPv6 | ndppd(可选) | ND 代理,仅 ISP 需要时 | 按需 | +| UPnP | miniupnpd | UPnP IGD | 需安装,nftables 后端 | +| NTP | chrony | 时间同步 | 需安装(或 systemd-timesyncd) | +| 诊断 | iputils-ping / mtr-tiny / traceroute / dnsutils | 连通性 | 部分需安装 | +| 诊断 | iperf3 | 内网吞吐 | 需安装 | +| 诊断 | tcpdump / conntrack | 抓包 / 连接跟踪 | 需安装 | +| 诊断 | curl / wget | 互联网测速与下载 | 已装 | +| 日志 | rsyslog / logrotate / journald | 日志落盘与轮转 | logrotate 已装 | +| 运行时 | python3(标准库) | Web 后端 | 已装,无 pip 依赖 | +| 存储 | sqlite3 | 配置与审计 | 需安装 | + +**明确不安装**:isc-dhcp-server(与 dnsmasq 重复)、kea(内存开销大且需 REST 常驻)、 +unbound(dnsmasq 足够)、nginx/lighttpd(后端直接提供 HTTPS)、 +Node.js(构建依赖重)、ufw/firewalld(与 nftables 争夺规则,违反单一管理者原则)。 + +--- + +## 四、单一管理者原则(避免组件打架) + +| 功能 | 唯一负责组件 | 禁止同时操作者 | +|---|---|---| +| 网卡/桥接/地址 | NetworkManager(当前)/ systemd-networkd(迁移后) | 两者绝不并行;/etc/network/interfaces 保持只含 lo | +| PPPoE | pppd | NetworkManager 不碰 ppp0 | +| 防火墙 | nftables(自管表 `ip drouter4` / `ip6 drouter6`) | ufw、firewalld、直接 iptables | +| DHCPv4 + DNS | dnsmasq | isc-dhcp-server、systemd-resolved | +| RA | radvd | dnsmasq 的 ra 功能 | +| DHCPv6-PD | dhcpcd-base | networkd 的 DHCPv6(二选一) | +| UPnP | miniupnpd | 手工 nft 规则 | +| RealVNC | realvnc-vnc-server(既有) | 本系统只读展示,不接管 | + +--- + +## 五、三个关键前提的处置决策 + +### 5.1 RealVNC 商业版已运行 —— 必须保护(最高优先级) +实测:`realvnc-vnc-server 6.11.0.47988` 已安装,`vncserver-x11-serviced.service` active, +TCP/UDP 5900 正在监听。 + +**保护策略(贯穿全项目):** +1. 本系统**不接管、不重启、不配置** VNC;只做"只读状态展示 + 升级影响预警"。 +2. **不断言"必须冻结桌面包"**:Debian 13 的 `apt upgrade` 不会无故移除 XFCE。 + 正确做法是升级前快照 + 升级后验证清单(见 §14)。 +3. Web 的"系统更新"模块**默认只列清单不执行**,并对以下包高亮标记为高危: + `xserver-xorg*`、`xorg`、`linux-image-*`、`linux-headers-*`、`xfce4*`、`realvnc*`、`vnc*`。 +4. 仅在 RealVNC 官方明确要求固定版本时,才对该**单个包**执行 `apt-mark hold`(提供开关,不默认全量 hold)。 +5. 所有网络/防火墙变更**必须**走"快照 → 应用 → 倒计时确认 → 自动回滚",避免把 VNC 会话一起弄断。 +6. Web 管理端监听端口与 5900 隔离,且默认不允许在 WAN 侧访问。 + +### 5.2 单网口起步(当前阶段) +用户当前只给 ens18 一个口,WAN 口待后续在 PVE 添加。 +- **构建期配置**:ens18 = LAN,承载 192.168.7.3/24(**保持 DHCP 获取,先不动**,避免失联)。 +- **WAN 口状态**:暂记为"未分配",Web 页面显示提示 + "未检测到独立 WAN 口,请在 PVE 添加网卡后于网卡页分配角色"。 +- **WAN 相关服务**(pppd / dhcpcd / NAT)在无 WAN 口时**只生成配置、不启动**。 +- 后续加卡后:新网卡以 **MAC 为键**自动出现在"待分类"提示条中 → 一键指派为 WAN。 +- **网桥**:单口阶段无剩余口可桥接,功能保留但不启用,页面给出说明。 + +### 5.3 NetworkManager → systemd-networkd 迁移(改为"可选") +原方案要求迁移,理由是 NM 与 networkd 会互相打架。但考虑到: +- RealVNC 依赖稳定的网络与图形会话,迁移过程**必然中断网络**; +- 当前只有一个网口,一旦迁移失败可能直接失联(需 PVE 控制台救回)。 + +**调整为:** +- **默认不迁移**。后端统一通过 **NetworkManager(nmcli)** 管理 ens18,前端屏蔽底层差异, + 功能上与 networkd 方案等价。 +- 提供"高级 → 网络后端迁移"入口,做 **dry-run 预览**(展示将写入的 `.network` 文件), + 由用户在有 PVE 控制台可救回时自行决定是否执行;执行前强制快照。 +- 若未来网卡数量增多、配置复杂化,再考虑迁移。 + +> 说明:NM 与 networkd 二选一,**绝不并行管理同一张网卡**(单一管理者原则仍成立)。 + +--- + +## 六、配置布局 + +``` +/opt/drouter/ +├── backend/ # Python 后端(标准库) +│ ├── drouter-web.py # Web 后端(低权用户 drouter 运行) +│ ├── drouter-helper.py # root 特权白名单执行器(唯一提权入口) +│ ├── render.py # 配置渲染器(纯逻辑,SQLite → 原生配置文件) +│ ├── drouter-rescue.py # 紧急救援通道独立服务 +│ └── drouter-snapshotd.py # 自动快照守护 +├── web/ # 前端静态资源(index.html / app.css / app.js / logo.svg) +├── data/drouter.db # SQLite:备注/角色/Options/管理员/审计 +├── generated/ # 渲染出的原生配置(staging) +│ ├── dnsmasq.conf +│ ├── radvd.conf +│ ├── dhcpcd.conf +│ ├── miniupnpd.conf +│ ├── chrony.conf +│ ├── nft-v4.nft / nft-v6.nft +│ ├── ppp/peers/、chap-secrets +│ ├── vlans.json # VLAN 记录(#21) +│ ├── pppoe-multi.json # PPPoE 多拨配置(#23,0600 含密码) +│ ├── qos.json / qos.nft # QoS 配置与渲染出的 nftables 规则(#24) +│ ├── isp-dns.conf # 运营商 DNS 注入点 +│ └── network/*.network +├── snapshots// # 应用前快照,用于回滚 +├── dpi/ # DPI 识别库(#25) +│ ├── ndpi/ # ntop/nDPI 协议特征库 +│ ├── suricata-update/ # Suricata 规则集 +│ ├── ndpi-netfilter/ # xt_ndpi 内核模块源码 +│ └── state.json # 各来源版本/大小/上次更新信息 +└── bin/drouter-helper # root 特权白名单脚本 +/var/log/drouter/*.jsonl # 结构化日志(+ dep-install.log + dpi-update.log) +/var/lib/drouter/trash/ # 文件管理器回收站(#19) +/run/drouter-helper/ # helper 私有临时目录(见 §25.1) +/etc/ppp/ip-up.d/drouter-pppm-route # PPPoE 多拨策略路由钩子(#23) +/etc/ppp/chap-secrets.drouter-multi # 多拨账号片段(0600,由 include 挂载) +/etc/systemd/system/drouter-web.service +/etc/systemd/system/drouter-qos.service # QoS 应用单元(#24) +/etc/systemd/system/drouter-pppoe@.service # PPPoE 多拨会话模板单元(#23) +/usr/local/sbin/drouter-qos-apply # QoS 规则应用脚本(由 helper 生成) +``` + +> 生成的**运行时** nftables 表: +> - `table inet drouter_accel`(flowtable 软加速,#22) +> - `table inet drouter_qos`(DSCP/mark/connlimit,#24) +> +> 两者均由 helper 动态管理,不写入 `/etc/nftables.d/`。 + +--- + +## 七、权限模型 + +1. 后端进程以专用低权用户 `drouter` 运行,**不以 root 运行**。 +2. 需要 root 的操作一律通过 `sudo -n /opt/drouter/bin/drouter-helper ''`。 +3. sudoers 仅放行这一个脚本(NOPASSWD),脚本内部维护 **action 白名单**, + 并对每个参数做类型/正则/取值范围校验,杜绝 shell 拼接。 +4. Web 端:HTTPS(8443,自签证书) + 登录会话 + CSRF token + 登录失败限速 + 会话超时。 +5. 管理页默认仅监听 LAN 地址;提供"允许 WAN 侧访问"开关,默认关闭。 + +--- + +## 八、应用与回滚流程 + +``` +编辑 → [保存并应用] + 1. 输入校验(后端强校验) + 2. 语法预检(nft -c / dnsmasq --test / radvd -c) + 3. 快照当前配置 → snapshots/ + 4. 原子写入新配置 + 5. reload / restart 目标服务 + 6. 健康探测 + 7a. 成功 → 中文提示"已应用",进入 60s 确认窗口 + 7b. 失败或超时未确认 → 自动恢复快照并提示 +``` +- 网络类变更(WAN 模式、桥接、PPPoE、networkd 迁移)标记为 **高危**,二次确认 + 倒计时。 +- 防火墙变更应用失败绝不破坏已有有效规则。 + +--- + +## 九、测试用例(验收清单) + +| # | 用例 | 期望 | +|---|---|---| +| T1 | 访问 https://192.168.7.1:8443 | 出现登录页,证书可信任一次 | +| T2 | 错误密码 5 次 | 触发限速锁定,审计有记录 | +| T3 | 网卡页查看 | 列出 ens18/ens19 + MAC + 速率 + 备注可编辑并持久化 | +| T4 | 拔插/新增网卡模拟 | 顶部提示"检测到新网卡待分类" | +| T5 | 填写 PPPoE 账号密码保存 | 写入 DB,页面提示"已保存,未拨号",**不触发实际拨号** | +| T6 | DHCP Options 填 code=6 value=223.5.5.5 | 生成 `dhcp-option=6,223.5.5.5`,预检通过 | +| T7 | DHCP Options 填非法 IP | 前端+后端双拦截,给出中文错误 | +| T8 | 防火墙写错误语法 | `nft -c` 失败,阻止应用,已有规则不变 | +| T9 | 防火墙应用后不确认 | 60s 后自动回滚到快照 | +| T10 | 上传 SSH 公钥 | 写入目标用户 authorized_keys,权限 600/700,属主正确 | +| T11 | 上传"私钥"文件 | 被拒绝并中文提示"请上传 .pub 公钥" | +| T12 | 日志页 | 中文显示 + 可按模块过滤 + 可导出 | +| T13 | 执行 ping/traceroute | 参数白名单生效,非法参数被拒 | +| T14 | 安全重启 | 二次确认后执行,审计记录 | +| T15 | 回滚按钮 | 恢复到指定快照且服务生效 | +| T16 | PPPoE 多拨:保存 3 会话(单账号复制) | 密码落盘 0600,前端只见 `has_password`,**不拨号** | +| T17 | PPPoE 多拨:保存时密码留空 | 沿用旧密码,不写空值 | +| T18 | PPPoE 多拨:apply 未勾选确认 | 被拒,**不改路由、不生成单元** | +| T19 | QoS:CAKE 保存→启用 | `qdisc cake` 挂 WAN(up) 与 ifb-drouter(down),ingress 重定向存在 | +| T20 | QoS:启用 3 次 | 幂等,nft 规则不叠加(gaming/connlimit 各 1 条) | +| T21 | QoS:填 IP 限速→启用 | 切 HTB,class 速率/ceil 与配置一致,`fw handle → flowid` 映射正确 | +| T22 | QoS:HTB→CAKE 切换 | HTB class 全部清理,残留 class 数 = 0 | +| T23 | QoS:停用 | WAN/IFB 均恢复 `fq_codel`,`drouter_qos` 表删除,IFB 置 DOWN | +| T24 | QoS:软加速开着时启用 | 返回 `accel_conflict` 拦截并给出中文原因 | +| T25 | DPI:前缀测速 | 返回各前缀可用性与耗时,标出最快项 | +| T26 | DPI:自定义前缀非法 | 被拒并提示"必须形如 https://example.com/" | +| T27 | DPI:单来源更新 | 下载→解包→原子安装到 target,state.json 记录版本 | +| T28 | 菜单完整性(`check-views.js`) | 所有非 `soon` 菜单项在 VIEWS 中都有映射 | +| T29 | 无浏览器 UI 回归(`sim-ui.js`) | 28 个已实现页面全部渲染出内容 | + +--- + +## 十、必须在目标环境实测验证的事项 + +1. **PPPoE 拨号**:所属运营商的认证方式(PAP/CHAP)、服务名是否需要、MTU 是否需 1492。 +2. **IPv6 PD**:是否真的下发前缀(有链路地址 ≠ 拿到 PD)。 +3. **桥接后网卡关系**:目前只有 2 个物理口,扩展网卡后需验证。 +4. **RealVNC 在 Debian 13 + XFCE 上的兼容性**:官方支持矩阵为准。 +5. **MTU/MSS clamp**:PPPoE 下的实际路径 MTU。 +6. **docker 停用后**业务是否受影响。 + +--- + +## 十一、对四份参考方案的取舍说明 + +| 参考 | 采纳 | 剔除(弊端) | +|---|---|---| +| 参考1 | 轻量后端+SQLite、Kea 支持自定义 option、JSON 日志、Flask 思路、中文映射表 | ❌ 引入 Flask/lighttpd(额外内存);❌ 全量 `apt-mark hold` 桌面包;❌ Kea 常驻 REST 服务对 3.8G 内存偏重 → 改用 dnsmasq;❌ "私钥上传"表述错误 | +| 参考2 | MAC 为主键识别网卡、断线自动重连、任意 Option Code、nft 语法校验、PD 重点做、UPnP 映射表、审计日志 | ❌ 对 xserver-xorg 全量 hold;❌ vnstat 常驻(改用 /sys 轮询,零依赖);❌ 部分条目被截断不完整 | +| 参考3 | **单一管理者原则**(精华)、倒计时确认+自动回滚、不得整体 root 运行、区分"热加载"与"必然中断"、不假设 PD、反对冻结大批包 | ❌ nginx 反向代理(多余) | +| 参考4 | 五件套底座(dnsmasq+nftables+networkd+pppd+自写后端)、/sys 轮询 TX/RX 零依赖、radvd RA、usepeerdns 合并 DNS、conntrack | ❌ 未提 docker/已有服务冲突;❌ 未提 NM 迁移风险 | + +--- + +## 十二、切换策略(针对"局域网正在运行"的约束) + +> 环境已还原为干净状态,旧系统不存在,因此不再需要"接管旧系统"。 +> 但 **局域网仍在运行**,且 **RealVNC 会话不能断**,约束依然生效。 + +1. **构建期(当前)**: + - 只装包、只写配置、只建 Web; + - **不启动** dnsmasq / radvd / dhcpcd / miniupnpd 抢占端口(53/67/546/5353); + - **不拨号**、**不改 ens18 现有 IP(继续 DHCP 获取 192.168.7.3)**、**不停 NetworkManager**; + - **不写 nftables 生效规则**(可生成、可预检,不加载); + - **不触碰 RealVNC / LightDM / X 会话**。 +2. **准备期**:Web 内填好 PPPoE 账号密码、LAN 规划、DHCP 池、Options、防火墙规则 → 全部只保存。 +3. **加 WAN 口**:用户在 PVE 为 102 添加第二张网卡 → + Web 网卡页按 MAC 自动识别 → 指派为 WAN → 后端按 MAC 生成绑卡规则。 +4. **切换窗口(需你明确确认后再执行)**: + `应用 LAN 静态地址 → 启动 dnsmasq → 应用 nftables → 启动 PPPoE 拨号 → 启动 radvd/dhcpcd`。 + 每步带快照与自动回滚;网络类变更带 60s 倒计时二次确认。 +5. **验证期**:确认上网 / IPv6 / DHCP / DNS 正常,且 RealVNC 5900 连接未受影响。 +6. 任一步失败 → 一键回滚至快照。 diff --git a/docs/02-部署与使用手册.md b/docs/02-部署与使用手册.md new file mode 100644 index 0000000..7c09927 --- /dev/null +++ b/docs/02-部署与使用手册.md @@ -0,0 +1,609 @@ +# 02 · 部署与使用手册 + +> 适用版本:drouter 1.0(2026-09-28 第五轮修订) +> 目标机:PVE 虚拟机 #102 · Debian 13 · `ens18` = `192.168.7.3/24`(LAN)、`ens19`(WAN) +> 当前状态:**构建保护模式已关闭**,可正式拨号;服务仍全部未启动,端口 53/67 空闲,VNC(5900) 正常。 +> +> 已实现功能:**34 个菜单项(28 个已实现 / 6 个规划中)**。 + +--- + +## 一、30 秒上手 + +### 完全没配过路由器?先走「新手向导」 + +菜单 **概览 → 新手向导**(第一位)。四步走完就能上网,每步都给可以照抄的范例, +并有实时体检: + +| 步 | 标题 | 它会问你 | 范例 | +| -- | -- | -- | -- | +| 1 | 接上外网 | 宽带从哪进来(PPPoE / 自动 / 静态) | 账号 `051212345678`;**不是 Wi-Fi 密码,是宽带密码**;接口选「光猫的 LAN 口」 | +| 2 | 给内网发地址 | 地址池从哪到哪 | 照着抄一段;起点建议 `本机前三段 + .100`(后端按真实 LAN IP 推出来) | +| 3 | 能打开网址 | 用哪家 DNS | 六个常用服务商一键选;不知道选哪个就 `223.5.5.5`;一个不通自动换另一个 | +| 4 | 要不要 IPv6 | 要不要开、前缀是什么 | `2408:8207:1234:5678::/64`;**内网通告只能用 /64** | + +三条要记住的: + +- **保存 ≠ 拨号**。第 1 步填完先保存,再单独点「开始拨号」(要手打「拨号」两个字 + 确认,会短暂中断现有网络)。 +- **某一步失败不影响前面已成功的步骤**(半成功不回滚)。新手最怕"点了个按钮全没了"。 +- **向导只做最基础的四件事**:更多功能(多拨、QoS、DPI、端口转发、共享…) + 配完之后回各自页面调。 + +### 已经会用路由器?直接走标准流程 + +| 步骤 | 操作 | 说明 | +| -- | ----------------------------------------- | -------------------- | +| 1 | 打开 `https://192.168.7.3:8443` | 自签证书,浏览器需点"继续访问" | +| 2 | 用 `admin` / `admin123` 登录 | **登录后请立刻改密码**(系统设置页) | +| 3 | 在「网卡与桥接」页确认 `ens18`=LAN、`ens19`=WAN | 依据 MAC 识别,换硬件也不会错乱 | +| 4 | 在「WAN 口」页填写 **宽带账号 / 密码**,点右下角「仅保存」 | 只写盘,不拨号 | +| 5 | 点「保存并应用」→「开始拨号」 | 正式切换,此时会断网重连 | +| 6 | 拨号成功后,逐项启用 DHCP / IPv6 / UPnP / NTP | 每项都有二次确认与失败自动回滚 | +| 7 | (可选)接口 → PPPoE 多拨,多条会话聚合带宽 | 需运营商允许并发拨号 | +| 8 | (可选)服务 → QoS 智能限速,抗 bufferbloat / 按 IP 限速 | CAKE 或 HTB,二者互斥 | +| 9 | (可选)服务 → DPI 识别库,装 nDPI 特征库 | 国内网络请先「测速」挑加速前缀 | + +> 当前两张网卡:`ens18`(LAN,192.168.7.3)+ `ens19`(WAN,待 PPPoE 拨号)。 +> +> `ens19` 已认领为 WAN 且处于安全状态(无 IP、NetworkManager 未托管、默认路由仍走 ens18)。 + +### 关键模块速查 + +| 模块 | 菜单位置 | 一句话用途 | 关键前提 | +| -------- | ------------- | ------------------------------- | ------------------------------------------- | +| **新手向导** | **概览 → 新手向导(首位)** | 四步解决最基础的上网:外网 / 内网地址 / DNS / IPv6 | 每步都有范例与体检;**保存 ≠ 拨号**,拨号要单独确认 | +| PPPoE 多拨 | 接口 → PPPoE 多拨 | 同一账号/多账号并发拨号,叠加带宽 | **运营商必须允许并发**;部分省份限制 1 条 | +| QoS 智能限速 | 服务 → QoS 智能限速 | 抗 bufferbloat、游戏/DNS 插队、按 IP 限速 | 需关闭 **flowtable 软加速**(会绕过 netfilter 导致打标失效) | +| DPI 识别库 | 服务 → DPI 识别库 | 按应用识别流量(nDPI/Suricata) | 4GB 内存建议只装 nDPI;需选可用加速前缀 | + +--- + +## 二、目录与文件 + +``` +/opt/drouter/ +├── backend/ +│ ├── drouter-web.py # Web 后端(低权用户 drouter 运行,~21MB) +│ ├── drouter-helper.py # root 特权白名单执行器(唯一提权入口) +│ └── render.py # 配置渲染器(纯逻辑,SQLite → 原生配置文件) +├── web/ # 前端(index.html + app.css + app.js,纯静态) +├── data/drouter.db # SQLite:配置、管理员、审计、快照索引 +├── snapshots/ # 应用前自动快照(可一键回滚) +├── certs/ # 自签 TLS 证书(含 SAN) +└── bin/sync-isp-dns.sh # 把 pppd 拿到的运营商 DNS 同步给 dnsmasq + +/etc/drouter/ +├── BUILD_MODE # ★ 存在 = 构建保护模式开启 +└── generated/isp-dns.conf # 运营商 DNS 注入点 + +/var/log/drouter/ # 结构化 JSONL 日志(任何 AI 都能读) +``` + +生成的配置文件(应用后写入): + +| 模块 | 目标文件 | +| ---------- | ------------------------------------------------------------- | +| DHCP/DNS | `/etc/dnsmasq.d/drouter.conf` | +| IPv4 防火墙 | `/etc/nftables.d/drouter-v4.nft` | +| IPv6 防火墙 | `/etc/nftables.d/drouter-v6.nft` | +| IPv6 RA | `/etc/radvd.conf` | +| DHCPv6 客户端 | `/etc/dhcpcd.conf` | +| UPnP | `/etc/miniupnpd/miniupnpd.conf` | +| NTP | `/etc/chrony/chrony.conf` | +| PPPoE | `/etc/ppp/peers/drouter-wan` + `chap-secrets` + `pap-secrets` | + +--- + +## 三、构建保护模式(核心安全机制) + +`/etc/drouter/BUILD_MODE` 是一个全局熔断开关。存在时: + +| 被拦截的行为 | 原因 | +| ------------------------------------------------ | -------------------- | +| 启动 dnsmasq / radvd / dhcpcd / miniupnpd / chrony | 会抢 53/67 端口,可能引发广播风暴 | +| 任何 `live=true` 的应用 | 会真正改变网络状态 | +| 网卡迁移、PPPoE 拨号控制 | 会断开现有链路 | +| 关机 / 重启 / 软件升级 | 会中断你的局域网 | + +**不会被拦截**:写配置文件、语法预检、生成快照、回滚、查看状态、编辑用户。 + +解除保护(由我或你在系统设置页操作): + +```bash +# 开启保护 +sudo touch /etc/drouter/BUILD_MODE +# 解除保护 +sudo rm -f /etc/drouter/BUILD_MODE +``` + +--- + +## 四、安全模型 + +``` +浏览器 ──HTTPS 8443──▶ drouter-web.py(用户 drouter,非特权) + │ sudo -n(NOPASSWD 仅限 helper) + ▼ + drouter-helper.py(root,动作白名单) + │ subprocess 列表形式,绝不做 shell 拼接 + ▼ + 实际系统操作 +``` + +- **白名单动作**:只识别固定动作名,参数逐个校验(IP / CIDR / 网卡名 / MAC / 整数范围)。 +- **无 shell 拼接**:所有外部命令使用 `subprocess` 列表形式,杜绝注入。 +- **会话**:60 分钟过期,登录失败 5 次锁定 10 分钟,PBKDF2-SHA256(180k) 存密码。 +- **审计**:所有提权动作写入 `/var/log/drouter/audit.jsonl`。 +- **系统加固**:`ProtectSystem=true`、`ProtectHome=read-only`、`NoNewPrivileges` 保持可用、内存上限 160M、CPU 40%。 + +--- + +## 五、单管理器原则 + +每个网络功能只有一个组件负责,避免互相打架: + +| 功能 | 唯一管理器 | +| --------------- | ---------------- | +| 网卡 / 桥接 / 地址 | NetworkManager | +| 防火墙(v4/v6) | nftables | +| DHCPv4 + DNS 转发 | dnsmasq | +| IPv6 RA | radvd | +| DHCPv6-PD 客户端 | dhcpcd | +| IPv6 地址池 | 内核 + radvd(自动生成) | +| UPnP | miniupnpd | +| PPPoE | pppd | +| NTP | chrony | + +--- + +## 六、日常维护 + +```bash +# 服务状态 +systemctl status drouter-web +systemctl show drouter-web -p MemoryCurrent + +# 自检(八模块回归) +sudo bash /tmp/regression.sh + +# 状态审计 +sudo bash /tmp/audit.sh + +# 查看审计日志 +tail -20 /var/log/drouter/audit.jsonl | python3 -m json.tool + +# 重新部署(在开发机执行) +bash _dev/sync.sh +``` + +--- + +## 六之二、一键运维脚本 drouter-ctl + +随包安装后会提供 `drouter-ctl` 命令(也在 `/opt/drouter/scripts/drouter-ctl.sh`), + +把安装、卸载、修复、更新、状态五件事收成一个入口。 + +```bash +drouter-ctl install # 交互式安装 +drouter-ctl uninstall # 卸载(保留配置与日志) +drouter-ctl uninstall --purge # 连配置、快照、日志一起清掉 +drouter-ctl repair # 修复:重部署 + 修权限 + 重建单元 + 校验 +drouter-ctl update # 更新:先自动快照,再部署新版本 +drouter-ctl status # 查看服务 / 端口 / 主题 / 安全红线 +``` + +### install 会问什么 + +| 提问 | 默认值 | 说明 | +| ----------- | ------------------------ | -------------------------- | +| LAN 网卡 | 探测到的第一块 | 内网接口,如 `ens18` | +| WAN 网卡 | 空 | 外网接口,可留空后在界面里选 | +| LAN 地址 | `192.168.7.3/24` | 本机在内网的地址(IP/掩码位数) | +| LAN 网关 | LAN 地址本身 | 路由器对内网而言就是网关 | +| DNS | `223.5.5.5,119.29.29.29` | 逗号分隔 | +| 是否开 DHCP 池 | `N` | 默认不开,避免与现有 DHCP 冲突 | +| 地址池起止 | 网段推导 | 仅在开启 DHCP 时询问 | +| Web 端口 | `8443` | 写入 `/etc/drouter/web-port` | +| 管理员账号 | `admin` | | +| 管理员密码 | 空(沿用 admin123) | 至少 6 位 | +| PPPoE 账号/密码 | 空 | 只保存不拨号 | + +> 脚本**只写入配置,不会应用到系统**。装完请到界面的「应用配置」里确认后再下发, +> +> 以免直接改动正在运行的网络。整个安装过程不会启用 dnsmasq / radvd / miniupnpd, +> +> 也不会触碰 RealVNC 的 5900 端口。 + +--- + +## 六之三、打包成 .deb 与 Docker 镜像 + +```bash +# 1) .deb 安装包(在 Debian/Ubuntu 上执行) +bash packaging/build-deb.sh 1.0.0 # 产出 dist/drouter_1.0.0_all.deb +sudo apt install ./dist/drouter_1.0.0_all.deb +sudo apt purge drouter # 彻底卸载 + +# 2) 离线依赖包(目标机无外网时) +bash scripts/make-offline-deps.sh # 产出 dist/offline-deps/ +# 把整个目录拷到目标机后: +bash install.sh drouter_1.0.0_all.deb + +# 3) Docker 镜像 +bash packaging/build-docker.sh 1.0.0 +docker run -d --name drouter --restart unless-stopped \ + --network host --cap-add NET_ADMIN --cap-add NET_RAW \ + -v drouter-data:/opt/drouter/data -v drouter-etc:/etc/drouter \ + drouter:1.0.0 +``` + +`.deb` 的 `control` 里已声明完整 `Depends`(nftables、dnsmasq、ppp、pppoe、 + +radvd、miniupnpd、conntrack、ipset、sqlite3 等),`apt install` 时自动补齐; + +`postinst` 会自动完成部署并启动管理后台,同样遵守「不启动 DHCP/DNS」的红线。 + +**依赖分级的取舍**:`apt` 默认会连 `Recommends` 一起装,所以 `Recommends` 里只放 +不带守护进程的工具(tcpdump、jq、htop、iotop、nvme-cli、lm-sensors)。 +`smartmontools` 装完会**自动拉起 `smartd` 常驻服务**,对一台路由器是意料之外的 +负担,因此降级到 `Suggests`,需要时手动 `apt install smartmontools`。 + +**容器端口**:`DROUTER_WEB_PORT` 只在**首次启动**时播种进 `/etc/drouter/web-port`, +随后容器入口会把它 `unset` 掉。原因是后端 `load_web_port()` 里环境变量优先级高于 +配置文件,留着它会让「在界面里改端口」看起来改了没生效。 +对应的 `HEALTHCHECK` 也读实际端口,不会因改端口而永远报 unhealthy。 + +--- + +## 六之四、手机访问与适配 + +手机浏览器直接访问 `https://<路由器 IP>:8443/` 即可,界面会自动适配窄屏: + +| 元素 | 宽屏(>760px) | 窄屏(≤760px) | +|------|----------------|----------------| +| 侧边栏 | 固定 236px,可折叠成图标栏 | 变抽屉,点左上 ☰ 滑出,点遮罩 / Esc / 选菜单自动收起 | +| 内容区 | 多列栅格 | 单列堆叠 | +| 表格 | 直接铺满 | 自动套一层横向滚动容器,左右滑动查看 | +| 表单 | 一行多项 | 单列,输入框放大到 16px(避免 iOS 自动缩放) | +| 底部操作条 | 单行 | 换行铺满,按钮放大到 42px 便于点按 | +| 弹窗 / 提示 | 居中定宽 | 近满屏、可滚动,避开刘海与底部安全区 | + +几个实现上的取舍: + +- **抽屉不复用「折叠」状态**:窄屏点 ☰ 只切抽屉,不写 `localStorage`, + 否则把窗口拉回宽屏时会莫名变成折叠态。 +- **表格滚动用统一包裹**:全站 20 多处表格(含 ajax 异步刷新的租约表、容器表), + 逐个改渲染代码不现实,改为渲染后由 `MutationObserver` 统一套 `.tw` 容器, + 异步回填的表格也能覆盖到,且重复执行是幂等的。 +- **底部留白**:内容区底部预留了操作条高度 + `env(safe-area-inset-bottom)`, + 避免 iPhone 上最后一行被操作条或小黑条挡住。 + +> 管理后台是响应式布局,不是独立手机版:所有功能在手机上都能用, +> 只是复杂表格需要横向滑动。改配置这种精细操作仍建议在电脑上做。 + +--- + +## 七、快照与回滚 + +快照采用**三层全覆盖**设计,确保「快照能完整代表当时的全部用户设置」: + +| 层 | 内容 | 说明 | +| ------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 配置库层 | `/opt/drouter/data/drouter.db` | 界面里所有模块配置:DHCP/DNS、防火墙 v4/v6、WAN/LAN、IPv6 与 RA、UPnP、NTP、网卡角色、访问范围、Web 端口等。用 `sqlite3.backup()` 做一致性备份,回滚时整体还原 | +| 系统文件层 | `/etc/dnsmasq.d/`、`/etc/nftables.d/`、`/etc/ppp/`、`/etc/radvd.conf`、`/etc/dhcpcd.conf`、`/etc/systemd/network/` 等 | 由配置库渲染出的原生配置文件 | +| 账号与审计层 | `chap-secrets` / `pap-secrets` / `hostname` / `timezone` / `rescue.conf` | 宽带凭据、主机名、时区、救援配置 | + +### 手动回滚 + +1. Web 界面「系统设置 → 配置快照与回滚」选择快照 → 一键回滚; +2. 或 SSH 执行: + +```bash +sudo python3 /opt/drouter/backend/drouter-helper.py snapshot_list '{}' +sudo python3 /opt/drouter/backend/drouter-helper.py rollback '{"ts":"20260928-154621"}' +``` + +回滚前系统会自动再拍一张 `before-rollback` 快照,不满意还能退回来。 + +### 自动快照(默认开启) + +| 项目 | 默认值 | 说明 | +| ----- | ------------------------ | ----------------------------------------------------- | +| 开关 | **开启** | 界面可随时关闭 | +| 快照时间 | 每 6 小时 | 定时器 `drouter-snapshot.timer`,1–168 小时可调 | +| 过期时间 | 7 天后自动删除 | `keep_days`,0 = 不按时间清理 | +| 最多保留 | 30 份 | 仅对自动快照计数,手动快照不占配额 | +| 存放路径 | `/opt/drouter/snapshots` | 可自定义,仅限 `/opt /srv /var/backups /mnt /media /home` 之下 | +| 应用前快照 | 开启 | 每次「保存并应用」前先拍一张 | + +还可**下载到本地电脑**:快照列表中每份都有「下载」按钮,导出为 `drouter-snapshot-<时间>.tar.gz`。 + +--- + +## 七之二、紧急救援通道 + +当配置失误把网络"打崩"(网络风暴、IP 冲突、路由环路、防火墙锁死、Web 打不开)时,常规手段已经进不去路由器。**紧急救援通道**是一条独立于 WAN/LAN 的应急入口。 + +**默认关闭**,需在「系统设置 → 紧急救援通道」勾选开启。 + +| 项目 | 默认值 | 说明 | +| ---- | ---------------- | ------------------------------------ | +| 虚拟地址 | `169.254.0.1/16` | 链路本地地址,不参与默认路由、不受 nftables 正常链路规则影响 | +| 备用地址 | `10.99.99.1/24` | 需要在 PVE / 物理网络内直连时使用 | +| 端口 | 8888 | 避开 22/53/67/68/80/443/5900/8080/8443 | +| 生效网卡 | 全部物理网卡(自动) | 在每张网卡上加别名地址 → **插任一口皆可访问** | + +**使用方式**(插任意网口): + +``` +http://169.254.0.1:8888/ +http://169.254.0.1:8888/?restore=1 # 直达一键还原页 +``` + +进入后可读取**全部快照**列表,一键还原任意一份并自动重启(`auto_reboot` 可关)。 + +**安全约束**:仅允许救援网段来源访问(127./169.254./救援网段),还原需填写确认短语「确认还原」,快照编号严格校验。还原后会自动重启,让设备恢复到可用状态。 + +--- + +## 八、RealVNC 保护策略 + +`RealVNC Server 6.11.0.47988` 已安装并运行于 5900,**本项目不触碰它**: + +- 不 `apt-mark hold` 任何包(参考方案里的做法会导致后续升级全面失败); +- 不在安装脚本里 stop/disable VNC; +- 系统更新页只做**只读风险提示**,提示升级 desktop 依赖时可能影响 VNC,由你决定是否更新。 + +--- + +## 九、故障排查速查 + +| 现象 | 原因 | 处理 | +| --------------------- | -------------------------------- | -------------------------------------------------------------------------------------- | +| 打不开 8443 | 服务未起 / 防火墙 | `systemctl status drouter-web`;本机是 LAN 口,防火墙未启用,多半是服务问题 | +| 保存报"配置校验失败" | 字段格式错 | 提示里已指出具体字段,按提示改 | +| 应用后没生效 | 构建保护模式开着 | 页面顶部有黄色横幅提示,解除后重试 | +| 拨号失败 | WAN 口未就绪 / 账号错 | 先「测试连通性」,再查「拨号状态」 | +| 忘了 admin 密码 | — | SSH 执行 `sudo python3 /opt/drouter/backend/drouter-web.py --reset-admin` | +| `miniupnpd: failed` | 配置文件含非法选项 | `journalctl -u miniupnpd -n 30` 看 `invalid option in file ... line N`;对照下方「UPnP 配置项」修正 | +| `miniupnpd: inactive` | **正常** —— 界面里 UPnP / NAT-PMP 都关着 | 若需启用,在「安全 → UPnP」打开开关后重新应用 | + +### UPnP 配置项(miniupnpd 2.3.x,Debian 13 nftables 后端) + +render.py 生成的 `/etc/miniupnpd/miniupnpd.conf` 只允许出现下列项,写错任何一项都会导致服务启动 **failed**: + + +| 正确选项 | 说明 | +| --------------------------------------- | -------------------------------------------------- | +| `ext_ifname` | WAN 口(如 `ppp0`) | +| `listening_ip` | **必须是网卡名**(如 `ens18`);写 IP 会告警且禁用 SSDPv6 | +| `http_port` | UPnP HTTP 端口。**不是 `port=`** —— 旧名会被静默忽略 | +| `enable_upnp` / `enable_natpmp` | 两项都 `no` 时进程会打印 `Why did you run me anyway?` 后正常退出 | +| `secure_mode` | 客户端只能映射自己的 IP | +| `upnp_forward_chain` | nftables forward 链名 | +| `upnp_nat_chain` | nftables NAT 链名 | +| `upnp_nat_postrouting_chain` | nftables postrouting 链名 | +| `system_uptime` / `lease_file` / `uuid` | 常规项 | +| `allow` / `deny` | 权限规则,**必须写**,否则等于对全网开放 | + +> ⚠️ `upnp_nat_prerouting_chain` **不存在于 2.3.x**(曾误写入,导致服务 `failed`)。nftables 后端无需单独指定 prerouting 链。 + +### QoS / PPPoE 多拨 / DPI 常见问题(第五轮新增) + +| 现象 | 原因 | 处置 | +| -------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| QoS 保存报 `Permission denied: /var/tmp/...` | `/var/tmp` sticky 位 + `fs.protected_regular=2`,root 无法覆写低权用户遗留文件 | **已修复**:改用 `/run/drouter-helper/`。若升级前遗留脏文件,`rm -f /var/tmp/drouter-*.nft` 即可 | +| QoS 保存报 `cache initialization failed: Operation not permitted` | `nft -c` 需要 netlink 缓存权限(受限环境) | **已修复**:自动降级为影子表真实加载校验,不影响正常环境 | +| QoS 启用了但 DSCP / IP 限速不生效 | **flowtable 软加速正在运行**,已建立连接绕过 netfilter 钩子 | 到「概述 → 网络状态」关闭软加速;或在 QoS 页勾选"我了解冲突,仍然继续" | +| QoS 规则出现重复条目 | `nft -f` 是追加语义 | **已修复**:加载前先 `nft delete table inet drouter_qos` | +| QoS 启用后网页/游戏变慢 | 上行带宽填得比实际大(运营商实测) | 把上行填成实测值的 **85%~90%**,CAKE 靠这个算排队延迟 | +| PPPoE 多拨显示「未连接」并反复重拨 | 运营商拒绝同账号并发 | 减少会话数;先确认 1 条能拨通;部分省份限制为 1 | +| PPPoE 多拨生效后部分站点打不开 | 策略路由未正确挂接(回包走错会话) | 检查 `ip rule show` 是否有 `from <本端IP> lookup 10x`;重拨一次触发 ip-up 钩子 | +| DPI 更新全部超时 | 国内直连 GitHub 不通 / 所选前缀失效 | 点「测速」挑最快的;或选自定义前缀填自建代理 | +| DPI 测速显示可用但真下载失败 | 部分加速**不代理 `codeload.github.com`** | **已修复**:测速 URL 已改用与真实下载同形的 `github.com/.../archive` | + +### PPPoE 多拨原理速览(排错必备) + +``` +pppd(每条会话一个 systemd 单元) + └─ drouter-wan1..N 独立 ppp 接口 + ├─ 路由表 101..10N ip route add default via dev table 10x + └─ ip rule from <本端IP> lookup 10x ← 关键!保证回包原路返回 + ↑ + 没有它 = A 会话出的包从 B 会话回 → 连接重置 +``` + +- 钩子脚本:`/etc/ppp/ip-up.d/drouter-pppm-route`(拨通时挂接)、`ip-down.d/`(断开时清理) +- 查看某会话日志:界面「日志」按钮,或 `journalctl -u drouter-pppoe@1.service -n 60` + +--- + +## 十、当前已验证结论(2026-09-28 修订) + +- ✅ 八模块配置全部成功写盘(dnsmasq / nft_v4 / nft_v6 / radvd / dhcpv6 / upnp / ntp / ppp) +- ✅ PPPoE 完整链路:填写账号密码 → 写 3 个文件 → 快照 → 不拨号 +- ✅ 安全不变量:dnsmasq/radvd/dhcpcd/miniupnpd/chrony 全部 inactive+disabled,53/67 空闲,nft 表为空 +- ✅ VNC 5900 全程 listening,`ens18` 地址未变 +- ✅ 后端内存 ~32MB(RSS),远低于 160M 上限 +- ✅ 未登录访问 API 返回 UNAUTH,目录穿越返回 404,HTTPS 正常 +- ✅ 双网卡识别:`ens18`=LAN、`ens19`=WAN(按 MAC 绑定备注与角色) +- ✅ 前端 **28 个页面**全部可正常渲染(含新增「PPPoE 多拨」「QoS 智能限速」「DPI 识别库」) +- ✅ 前端契约:**43 个** API 端点全部返回 200(新增 9 个 PPPoE/QoS/DPI 接口已入回归) + +### 第二轮功能增强(12 项用户清单,已全部完成) + +| # | 功能 | 位置 | +| --- | ----------------------------------------------------- | --------------- | +| 1 | Web 管理端口可配置(默认 8443,支持改为任意可用端口) | 系统设置 → Web 管理端口 | +| 2 | 保护模式 / 失联救援提示通用化(不再写死 PVE,兼容物理机) | 全局文案 | +| 3 | 网络后端迁移警告措辞优化(虚拟机平台控制台 / KVM / 显示器 / VNC) | 系统设置 → 高级 | +| 4 | 访问范围改为可选项(仅局域网 / 所有来源 / 自定义网段) | 系统设置 → 访问控制 | +| 5 | IPv6 地址池页面(from-pool、池策略 4 项多选,全中文) | IPv6 / RA | +| 6 | DHCP 租约实时表(双击 IP 选 Options、一键转静态 / 回收 IP) | DHCP 服务 | +| 7 | 网卡角色扩至 9 种 + 驱动来源识别 + Debian 配置方式显示 | 网卡与桥接 | +| 8 | 概览硬件仪表盘(彩色进度条 + 环形饼图)+ BIOS 真实探测 | 概览 | +| 9 | PPPoE 实时拨号日志(中文翻译)+ 实时状态栏(含重拨倒计时) | WAN 口 | +| 10 | 修复 WAN 连接方式切换无反应(PPPoE / DHCP / 静态各有参数面板) | WAN 口 | +| 11 | 防火墙规则示例全部用 `#` 注释包裹,删注释符即启用 | 防火墙 IPv4/IPv6 | +| 12 | IPv6 连通性测试(testipv6.cn / testipv6.com,实时回显) | 工具 → IPv6 测试 | +| 12b | 页脚开源协议与作者(火麒麟 ajeef · github.com/liuzhuohua · GPL v3) | 页面底部 | + +### 本轮修复的后端缺陷 + +| 缺陷 | 影响 | 修复 | +| ---------------------------- | -------------------------------------------------------- | --------------------------------------- | +| `settings` 表无 `updated_at` 列 | 「租约转静态」报 `table settings has no column named updated_at` | 写入前 `PRAGMA table_info` 探测列,动态组织 INSERT | + +### 第三轮功能增强(用户清单 #13 / #14,已全部完成) + +| # | 功能 | 位置 | +| --- | --------------------------------------- | -------------- | +| 13 | 快照覆盖全部用户设置(新增配置库层,`db_included` 标记) | 系统设置 → 配置快照与回滚 | +| 13a | 自动快照:默认开启、可关闭、间隔 + 过期自动删除、路径自定义 | 系统设置 → 自动快照 | +| 13b | 快照下载到本地电脑(tar.gz,带 Content-Disposition) | 快照列表 → 下载 | +| 14 | 紧急救援通道:默认关闭、插任一口可访问、一键还原最近快照 + 自动重启 | 系统设置 → 紧急救援通道 | + +### 本轮新增/修复的后端缺陷 + +| 缺陷 | 影响 | 修复 | +| ------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------- | +| helper 缺少 `snapshot_pack` action | 快照下载报「未授权的操作」 | 新增 `act_snapshot_pack`(tar.gz 打包,校验快照编号防路径穿越) | +| `Api` 类不是 handler,却调用 `self.send_response` | 快照下载报 `'Api' object has no attribute 'send_response'` | 改为 `self.h.send_response` / `self.h.wfile` | +| `deploy.sh` 未安装新组件与脚本 | 新增的 `drouter-rescue.py`、`drouter-snapshotd.py`、timer、脚本目录缺失 | 新增 8.5 节与 3.5 节 | +| 部署后 `/opt/drouter/scripts/` 与源码漂移 | 契约测试脚本用的是旧版(f-string 语法错误) | deploy.sh 自动同步 scripts 目录 | + +### #13 / #14 端到端验证结论(2026-09-28) + +- ✅ 快照含配置库:`_meta.json` 中 `db_included: true`,`files` 首项为 `sqlite:drouter.db`(layer 1 生效) +- ✅ 自动快照定时器 `drouter-snapshot.timer` 为 `active`,`OnUnitActiveSec=6h`,下一次运行已排程;手动 `systemctl start drouter-snapshot.service` 成功产出快照 +- ✅ 自动快照策略读写:改 12h/10 天/40 份 → 保存成功;非法路径 `/etc/evil` 被拒 +- ✅ 快照下载:`HTTP 200`,`Content-Type: application/gzip`,`Content-Length: 8240`,tar 内容可正常列出 +- ✅ 紧急救援通道(默认关闭):开启后创建 `drescue0` + 两张物理网卡别名(`169.254.0.1/16` + `10.99.99.1/24`),8888 端口监听,`http://169.254.0.1:8888/` 返回 200 +- ✅ 救援还原实测:`rollback` 还原「数据库 + 13 项配置文件 + 重载 drouter-web」成功;错误确认短语被拒 +- ✅ 救援通道关闭后虚拟地址与服务**完全回收**(残留地址 0 个,8888 监听 0 个) +- ✅ 全量自检:8/8 配置模块通过,25/25 页面渲染正常,34 个契约端点全部 200,RSS 19.9MB,VNC 5900 全程 listening + + | `render_dhcpcd` 前缀长度不接受 `/60` 形式 | 前端传带斜杠值会校验失败 | 加 `.lstrip('/')` 容错 | + + | 契约脚本内嵌 python f-string 转义引号 | 装饰性输出报 SyntaxError | 改为 `%` 格式化 | + +### 本轮修复的前端致命缺陷(上一轮) + +| 缺陷 | 影响 | 修复 | +| ----------------------------------------------- | ----------------------------------------- | ------------------------------------ | +| `VIEWS` 常量从未定义 | **点击任何菜单都抛 ReferenceError,页面永久停在「正在载入…」** | 补全 `PAGES key → view 函数` 映射表 | +| `login()` 返回 `token: None` | 登录显示成功但后续请求全部 401 | 返回真实 session token | +| `/api/buildmode` 只支持 POST,前端读 `data.build_mode` | 保护模式状态读不到 | 新增 GET,统一返回 `enabled` + `build_mode` | +| `/api/audits` `/api/snapshots` 未注册 | 日志/快照页 404 | 增加别名路由 | +| `go()` 不捕获异步异常 | 任一页面报错就永久卡住 | 改为 Promise + catch,渲染错误卡片而非卡死 | +| WAN 连接方式按钮只改 UI 不改状态 | 点 DHCP / 静态无任何反应 | 写入 `cfg.pppoe.mode` 并按模式渲染对应参数面板 | + +### 第四轮功能增强(用户清单 #15 ~ #22,已全部完成) + +| # | 功能 | 位置 | 说明 | +| --- | ---------------------- | ------------------- | ----------------------------------------------------------------------------- | +| 15 | UI 重做 + Logo + 菜单归类 | 全局 | 8 个可折叠导航分组(hue 配色区分);`web/logo.svg`;`Drouter` 显示名;DHCPv6/前缀归入「寻址与路由」 | +| 15 | 7 个规划中占位页 | 导航 | `ddns/portfwd/acl/nfs/qos/toolbox/flowlog`,显示「等待作者完善开发」 | +| 16 | 进程 / 温度显示修复 | 概述 | 进程按真实总量换算(实测 4.4%);温度不可用时给惊叹号 + 提示 | +| 16+ | 虚拟机 / 物理高级检测 | 概述 | 自动识别 KVM/VMware/Hyper-V 等;VM 下提示可尝试硬件直通(不保证成功) | +| 17 | 通用 API 接口 + 范例 | 工具 → API | OpenAPI 3.0.3(33 路径)+ 9 语言范例,零依赖接入 | +| 18 | Web SSH + 文件管理器 | 工具 → Web终端 | 非 PTY 受控 shell(危险命令黑名单);list/read/zip/delete/rename/mkdir/upload/search;回收站删除 | +| 19 | 依赖自检 + 一键安装 | 系统 → 依赖自检 | 35 项检测;一键安装;「再次检测」;失败弹可复制日志 | +| 20 | NAT 类型自动检测 | 概述 → 网络状态 | STUN 多服务器交叉判定,实测 NAT2(地址受限锥形) | +| 21 | VLAN + WOL 唤醒 | 接口 → VLAN / 网络唤醒 | VLAN 含 IPTV 预设;WOL 支持常用设备保存与远程唤醒 | +| 22 | nftables flowtable 软加速 | 概述 → 网络状态(与 NAT 同页) | 硬件卸载自动探测 + 纯软件降级;8 项影响说明 | + +### #15 ~ #22 端到端验证结论(2026-09-28) + +- ✅ `UI_SIM_OK` —— 25 个已实现页面全部渲染正常,7 个规划中 +- ✅ `FRONTEND_CONTRACT_OK` —— 34 个接口全部 `HTTP=200 ok=True` +- ✅ `REGRESSION_DONE` —— 8/8 配置模块落盘,安全不变量全绿 +- ✅ NAT 检测实测 **NAT2 · 地址受限锥形(Restricted Cone)**,公网 IP `101.70.131.227` +- ✅ 软加速 on / get / off 全周期通过,内核成功加载 `table inet drouter_accel` +- ✅ 文件管理器端到端:mkdir → write → rename → list → zip → delete(回收站可见) +- ✅ Web 终端:connect → exec(真实输出)→ 危险命令拦截 → disconnect +- ✅ 依赖自检复检 **35/35 全部就绪** +- ✅ VM 检测识别为 **KVM / QEMU / KVM 虚拟机**(`is_vm: True`) + +### 本轮修复的后端缺陷 + +| 缺陷 | 影响 | 修复 | +| ------------------------------------------------ | ---------------------------------------- | --------------------------------------------------------------------- | +| `global SHELL_KEYS` 用在 global 声明之前 | helper 编译失败,**所有接口返回 BAD_OUTPUT** | 提到 `act_webshell` 函数首行;`sync.sh` 步骤 0 增加 `py_compile` 预检 | +| `upnp_nat_prerouting_chain` 不存在于 miniupnpd 2.3.x | `invalid option` → UPnP 服务启动 `failed` | 改用 `forward_miniupnpd`/`prerouting_miniupnpd`/`postrouting_miniupnpd` | +| UPnP 配置用 `port=` / `listening_ip=` | 端口静默失效;SSDPv6 被禁用 | 改为 `http_port=` / `listening_ip=ens18` | +| UPnP 无权限规则 | 开启即对全网裸奔 | 补 `allow 1024-65535 1024-65535` + 末尾 `deny` | +| `ProtectSystem=true` 挂只读 `/usr` | 一键安装报「只读文件系统」 | 改 `ProtectSystem=false`(安全由 helper action 白名单保证) | +| `_dep_installed` 不支持别名 | `vim` 永远检测缺失 | 支持 `'a\|b'` 形式,vim 用 `'vim\|vim.tiny'` | +| `nft` flowtable 带 `flags offload` | virtio 网卡不支持 → `Operation not supported` | `_hw_offload_capable()` 自动探测 + 失败自动降级纯软件模式 | +| 文件管理器 mkdir 缺 `parent` | 「缺少路径参数」 | Web 层同时接受 `parent` 与 `path` | +| WOL / VLAN 前后端字段名不一致 | 页面读不到数据 / 保存失败 | 后端补齐别名,VLAN save 支持单条记录、delete 接受 `id` | + +### 第五轮功能增强(用户清单 #23 ~ #25,已全部完成) + +| # | 功能 | 位置 | 说明 | +| -- | ----------------- | ------------- | ------------------------------------------------------------------------------------------------------ | +| 23 | PPPoE 多拨 | 接口 → PPPoE 多拨 | N 条会话独立 ppp 接口 + 独立路由表 + **源地址策略路由**;balance / primary_backup / weighted 三种策略;单账号复制;密码不回传前端 | +| 24 | QoS 智能限速 | 服务 → QoS 智能限速 | CAKE 智能整形(上行挂 WAN,下行走 IFB 重定向)+ HTB 按 IP 精确限速 + DSCP 打标(4 预设)+ 连接数限制;3 档 CAKE 模式;5 条影响说明;软加速冲突检测 | +| 25 | DPI 识别库 + 多前缀代理更新 | 服务 → DPI 识别库 | 3 个来源(nDPI 特征库 / Suricata 规则 / xt_ndpi 模块);**7 个内置 GitHub 加速前缀 + 自定义**;一键测速优选;原子安装;apt/源码/内核模块三条安装路径建议 | + +### #23 ~ #25 端到端验证结论(2026-09-28) + +- ✅ `QOS_E2E_DONE` —— CAKE 保存/启用/停用全周期通过;`qdisc cake ... bandwidth 90Mbit diffserv3 dual-srchost/srcdhost` 正确挂载 WAN 与 ifb-drouter;ingress 重定向存在;停用后完全清理(WAN 恢复 `fq_codel`) +- ✅ **QoS 幂等性**:连续启用 3 次,`gaming` 与 `connlimit` 规则各仍为 1 条(不叠加) +- ✅ `QOS_HTB_DONE` —— HTB 分支 class 速率/ceil/prio 与配置完全一致;`fw handle 0x1001 → flowid 1:11` 映射正确 +- ✅ `QOS_SWITCH_DONE` —— HTB ↔ CAKE 双向切换,残留 class 数归零后重建 +- ✅ `PPPM_E2E_DONE` —— save/get/log 全通过;密码留空沿用旧值;非法网卡名(`ens19; rm -rf /`)被拒;apply 未确认被拒;**全程未拨号、未生成单元、ip rule 未变** +- ✅ `DPI_E2E_DONE` —— get 返回 3 来源 + 7 前缀;plan 三种安装路径正确;测速返回各前缀可用性 +- ✅ **DPI 真实下载**:经 `gh-proxy` 下载 nDPI 47.6 MB → 解包 → 安装 3015 个文件到 `/opt/drouter/dpi/ndpi`;state.json 记录 `last_mirror=gh-proxy` +- ✅ `UI_SIM_OK` —— 28 个已实现页面全部渲染正常(新增 pppmulti 5474 / qos 7144 / dpi 4947 字符) +- ✅ `FRONTEND_CONTRACT_OK` —— 43 个端点全部 `HTTP=200 ok=True` +- ✅ `REGRESSION_DONE` —— 8/8 配置模块落盘,安全不变量全绿(53/67 端口 0、VNC 5900 在听、ens18=192.168.7.3/24) +- ✅ `HTTP_E2E_DONE` —— 新接口经真实 Web 后端复核,OpenAPI 收录 38 个路径 + +### 本轮修复的缺陷(第五轮) + +| 缺陷 | 影响 | 修复 | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `/var/tmp` sticky 位 + `fs.protected_regular=2` | QoS 保存报 `Permission denied: /var/tmp/drouter-qos-check.nft`,**永久卡死**(低权用户遗留文件 root 也改不了) | 新增 `HELPER_TMP_DIR=/run/drouter-helper` + `tmp_nft_path()`/`write_tmp_nft()`/`cleanup_tmp()`;QoS/加速/`_verify`/DPI 全部迁移 | +| `nft -c` 报 `cache initialization failed: Operation not permitted` | QoS/加速保存被误判为语法错误 | 新增 `_nft_environ_blocked()` 识别环境限制 → **降级为影子表真实加载校验** | +| nft 链名用保留字 `mark` | `syntax error, unexpected mark`,HTB 分支完全不可用 | 改用 `marking` | +| nft `priority mangle + 1` 算术表达式 | `syntax error, unexpected type/ip` | 改为数值 `priority -149` | +| nft connlimit 规则末尾漏分号 | `syntax error, unexpected '}'` | 补 `;` | +| `nft -f` 追加语义 | 连续启用会把规则叠成两份 | 加载前先 `nft delete table inet drouter_qos` | +| QoS `off` 分支 `_qos_save` 无异常保护 | 报 `执行异常:[Errno 13] Permission denied` | `_qos_save` 改原子写(tmp + `os.replace`)+ 目录自建;on/off 均加 try/except 友好提示 | +| DPI 测速用 `curl -r 0-0` | 多数加速忽略 Range → 传整个 46MB 包 → **必然假超时** | 改「限时 4s + 限速 16KB/s + 看是否收到数据」;测速 URL 改用与真实下载同形的 `github.com/.../archive`(`codeload.github.com` 多数加速不代理) | +| PPPoE `log` 判空逻辑 | `journalctl` 返回 `-- No entries --`(非空)导致 hint 为空 | 增加 `'-- no entries --'` 判断并给出引导文案 | +| 前端 `wan_ifaces` 结构不匹配 | `_physical_ifaces()` 返回字符串数组,前端按对象访问 → `TypeError` | 新增 `ifaceNames()` 归一化辅助 | + +### 自检脚本 + +```bash +# 目标机 +bash /opt/drouter/scripts/regression.sh # 八模块配置写盘 +bash /opt/drouter/scripts/audit.sh # 系统状态审计 +bash /opt/drouter/scripts/test-frontend-contract.sh # 前端 API 契约(43 端点) +# 开发机(须在 router-build/devtools/ 目录下运行) +node sim-ui.js 192.168.7.3 8080 # 28 页面渲染模拟(走 HTTP 8080) +node check-views.js web/app.js # 菜单 key ↔ VIEWS 映射完整性 +``` + +### 第五轮专用验证脚本(`_dev/`,需 root) + +```bash +bash _dev/rsudo.sh "bash /tmp/t-qos.sh" # QoS CAKE:保存→启用→幂等→停用 +bash _dev/rsudo.sh "bash /tmp/t-qos-htb.sh" # QoS HTB:按 IP 限速全链路 +bash _dev/rsudo.sh "bash /tmp/t-qos-switch.sh" # QoS 模式双向切换 +bash _dev/rsudo.sh "bash /tmp/t-pppm.sh" # PPPoE 多拨 save/get/log + 校验分支 +bash _dev/rsudo.sh "bash /tmp/t-dpi.sh" # DPI get/plan/test/log +``` + +> 提示:`sim-ui.js` 使用 Node 内置 `http` 模块,需指向**明文 HTTP 8080**(HTTPS 8443 会 ECONNRESET)。 +> +> 排查经验 1:修改配置渲染器后务必做一次**语法级试跑**(如 `miniupnpd -f /etc/miniupnpd/miniupnpd.conf -d`)。配置项名写错时服务只会 `failed`,日志埋在 journal 里,很容易被误判为"无关紧要的 inactive"。 +> +> 排查经验 2:**不要用 `/tmp` 或 `/var/tmp` 存放 helper 的中间文件**。这两个目录带 sticky 位,配合 `fs.protected_regular=2`,root 无法覆写低权用户留下的同名文件,会报看起来莫名其妙的 `Permission denied`。统一用 `/run/drouter-helper/`。 +> +> 排查经验 3:`X-Token`(不是 `Authorization: Bearer`)是 Web API 的鉴权头。用 curl 自测时写错会得到「登录已过期」,容易误判为会话问题。 diff --git a/docs/03-全量检查与性能优化验收报告.md b/docs/03-全量检查与性能优化验收报告.md new file mode 100644 index 0000000..574bf63 --- /dev/null +++ b/docs/03-全量检查与性能优化验收报告.md @@ -0,0 +1,109 @@ +# drouter 全量检查与性能优化 · 验收报告 + +生成时间:2026-09-29 16:55 +目标机:`debian-primaryrouter` 192.168.7.3(Debian 13),代码 `/opt/drouter/` +对应需求:第 6 项「全面检查」、第 7 项「全面性能优化」 + +--- + +## 一、问题清单(按严重程度分级) + +### P0 · 用户能直接撞上的功能故障(已全部修复) + +| # | 页面 | 现象 | 根因 | 处理 | +|---|---|---|---|---| +| 1 | 网卡与桥接 / LAN 口 | 点「应用」弹 **未知的模块:system** | `system` 是纯配置模块,只入库、没有渲染器 | helper 新增 `CONFIG_ONLY_MODULES`,返回明确提示;前端不再把它送进 apply | +| 2 | WAN 口 | 点「应用」弹 **未知的模块:pppoe** | 渲染器叫 `ppp`,`ALIASES` 里缺 `pppoe→ppp` | 补别名 + `APPLY_SPEC` 条目,现在能真正渲染 PPPoE peers 与 chap-secrets | +| 3 | 端口转发 / DMZ | 点「应用」先弹 **未知的模块:portfwd**,再应用防火墙 | `portfwd` 是纯配置模块,规则是注入 `nft_v4/nft_v6` 一起渲染的 | 改为「保存 portfwd → 应用 nft_v4+nft_v6」两步,顺序不再颠倒 | +| 4 | VLAN / WOL / QoS / DPI / PPPoE 多拨 | 点「保存」弹「当前页面无需保存配置」、点「应用」报未知的模块 | 这些页有自己的页内按钮,却错误地显示了全局动作条 | 移除 `needSave`;动作条改由 `PAGE_MODULES` 决定是否显示 | + +### P1 · 静默失效(不报错,但功能其实没干活)(已全部修复) + +| # | 位置 | 现象 | 根因 | +|---|---|---|---| +| 5 | NTP 时间同步 | 点「应用」永远显示成功,但 **chrony 配置从未做过语法检查** | `_verify()` 按 `module == 'chrony'` 分支,前端传的是别名 `ntp`,永远匹配不上。`upnp`(→miniupnpd)同理 | +| 6 | 修复 #5 后暴露 | NTP 应用报 `Could not open ... : Permission denied` | Debian 的 AppArmor profile `usr.sbin.chronyd` 只放行 `/etc/chrony/**` 与 `/etc/chrony.*`,校验文件放在 `/tmp`、`/run` 下 root 也会被拒 | +| 7 | 全部配置模块 | 快照静默漏掉整个 `/etc/drouter`(上一轮已修) | `rescue.conf` 0600 root 读不到 → `shutil.copytree` 整体抛异常 → 界面仍显示「已创建快照」 | + +**#5/#6 的修法**:新增 `render.canonical()`,`act_apply` 先解析成渲染模块名再传给 +`_verify()` 和 nft 重载;新增 `VERIFY_PATH['chrony'] = '/etc/chrony.drouter-verify.conf'`; +预检目录从 0700 的 `/run/drouter-helper/verify` 换成 0755 的 `/run/drouter-verify` +(radvd 这类降权读配置的守护需要能 traverse 进来)。 + +### P2 · 性能(第 7 项,已完成) + +| 指标 | 优化前 | 优化后 | +|---|---|---| +| 14 个接口串行总耗时(模拟刷一遍全站) | 8977 ms | **581 ms**(≈15×) | +| 热态中位耗时 | 608 ms | **24 ms** | +| `/api/ulog` | 2799 ms | **118 ms** | +| `/api/vlan` | 598 ms | **5 ms** | +| `/api/theme` | 524 ms | **6 ms** | +| `/api/storage` | 608 ms | **13 ms** | +| `/api/ifaces` | 688 ms | **45 ms** | +| `/api/ulog` 响应体 | 298 KB | 压缩后 13.2 KB(4.4%) | +| `app.js` | 437 KB | 压缩后 126 KB(28.9%) | + +**四项优化** +1. 常驻执行守护 `drouter-helpd`:原来每个请求 spawn 一次 `sudo python3 helper` + (实测 236 ms 固定开销),改为 Unix socket 常驻,只 import 一次;socket 连不上 + 自动回退 subprocess,并带 30 秒「守护不可用」冷却,不会每个请求白等。 +2. `conntrack -E` 是阻塞事件流,原来固定 `timeout 2` 让每次查询白等 2 秒; + 改为默认走内核日志,只有勾选「抓实时连接事件」时才订阅。 +3. 响应 gzip 压缩(带 mtime+size 键的内存缓存)+ ETag/304 协商缓存, + 并补上 `Vary: Accept-Encoding`(否则代理会把 gzip 喂给不支持的客户端)。 +4. 两个守护不再共用 systemd `RuntimeDirectory` —— 它会让一个守护重启时 + 把另一个的 socket 一起删掉,表现为「shelld 显示 active 但终端废了」。 + +### 未发现问题的部分 +- 动作名 ↔ `ACTIONS` 注册、前端路径 ↔ 后端路由、重复顶层定义、CSS 类缺失:全过。 +- helper 调用的 55 个外部命令 vs 依赖清单 49 项登记:无遗漏。 +- 菜单 ↔ `VIEWS` 映射、离线渲染 35 视图、手机适配 26 项:全过。 + +--- + +## 二、本轮改动的文件 + +| 文件 | 改动 | +|---|---| +| `backend/render.py` | `ALIASES` 加 `pppoe→ppp`;新增 `canonical()` | +| `backend/drouter-helper.py` | `APPLY_SPEC` 加 pppoe;新增 `CONFIG_ONLY_MODULES`;`act_apply` 用 `render.canonical()`;`_verify` 改用 `VERIFY_DIR` + `VERIFY_PATH` | +| `web/app.js` | 新增 `PAGE_MODULES`(save/apply 分离);重写「保存 / 应用」两个处理器;动作条显示逻辑;移除 5 个页面的 `needSave` | +| `_dev/t-linkage.py` | **新建**,40 项模块联动审计,已挂进 `sync.sh` 预检 | +| `_dev/live-apply-probe.py` | **新建**,真机逐页探测「点应用」链路(只发 `check_only`,安全) | +| `devtools/sync.sh` | 预检链新增 `t-linkage.py` | + +--- + +## 三、验收结果(全部在目标机实测) + +``` +apply 链路探测(14 项) 失败 0 +综合验收 live-check4 失败 0 +Web 终端验收(12 项) 全部通过(含中文回显无 U+FFFD、Ctrl+C 后 shell 存活) +性能复测 581 ms 总耗时 / 24 ms 热态中位,与优化后持平 +本地测试 29 套 Python + 4 项 JS 检查全绿 +``` + +**安全红线每轮复核**(部署脚本自动输出): +- RealVNC 5900 —— `LISTEN` 完好,未触碰 +- `ens18` = 192.168.7.3 —— 未变 +- 紧急救援通道 —— `inactive`(默认关闭) +- 构建保护模式 `/etc/drouter/BUILD_MODE` —— 未创建 +- 全程未执行任何切换网络 / 启动 DHCP SERVER 的动作 + +--- + +## 四、可以上生产了吗 + +**代码层面可以。** 上面 P0 / P1 七项全部修复并有测试兜底,性能达到目标。 + +上生产前还需要你确认两件事(涉及网络切换,我没有擅自执行): + +1. **当前这台机器还没有接管路由**:`ip_forward=0`、默认网关仍是 192.168.7.2 + (远端 CHR RouterOS)、`nft list ruleset` 为空。它现在只是局域网里的一台普通 + 主机。真正切换需要先规划停机窗口。 +2. **建议先在构建保护模式下走一遍**:创建 `/etc/drouter/BUILD_MODE` → + 在各配置页点「应用」只会写盘不启服务 → 用只读预览核对生成的 + `/etc/dnsmasq.d/drouter.conf`、`/etc/nftables.d/drouter-v4.nft` 等文件 → + 确认无误后再关闭保护模式、正式接管。 diff --git a/docs/04-新需求进度报告(第1批).md b/docs/04-新需求进度报告(第1批).md new file mode 100644 index 0000000..76b866a --- /dev/null +++ b/docs/04-新需求进度报告(第1批).md @@ -0,0 +1,152 @@ +# drouter 11 项新需求 —— 进度报告(第 1 批) + +目标机:`debian-primaryrouter` 192.168.7.3 · 全部改动已部署并真机验证 + +--- + +## 本批完成 4 项 + +| # | 需求 | 状态 | 真机验证 | +|---|------|------|---------| +| 1 | QoS 新增「视频优先」DSCP 规则 | ✅ | 7 项全过 | +| 6 | 依赖自检清单补全 | ✅ | 19 项全过 | +| 10 | 终端输出延迟与断字优化 | ✅ | 6 项全过 | +| 2 | 真·公网 IP 判定模块 | ✅ | 18 项全过 | + +--- + +## 1. QoS「视频优先」 + +`QOS_PRESETS` 新增 video 条目: + +- **DSCP** `af41`(比游戏低半档、比普通网页高,保证画面不卡又不挤掉下载和游戏) +- **端口** `1935,3478-3497,5004-5005,8000-8010,8801-8802,19302-19309` + 覆盖 RTMP 推流 / WebRTC-STUN / RTP / 腾讯会议·Zoom / Google Meet + +前端预设列表自动带出,无需改前端。 + +--- + +## 2. 真·公网 IP 判定模块(`/api/pubip`,新页面「真·公网 IP 判定」) + +### 你的问题出在哪 + +原 `detect_public_ip()` 只判两件事:地址是不是公网段、外部回显是否一致。 +**它从来没验证过「外网能不能主动连进来」** —— 而这两件事是可以同时成立的: + +> 地址是公网段 ✅、回显一致 ✅、但入向被运营商/上级网关封锁 ❌ + +这正是 `101.70.131.227` 的情况。DDNS 能更新成功、域名也解析得到,别人就是连不上。 + +### 判定拆成四条独立证据 + +| 证据 | 说明 | 你的实测结果 | +|------|------|-------------| +| `class` 地址段 | RFC1918 / CGNAT 100.64/10 / 保留段 → 硬否定 | `101.70.131.227` = public ✅ | +| `echo` 多源一致性 | 5 个彼此独立的回显服务交叉验证 | 一致 ✅ | +| `path` 首跳链路 | traceroute 首跳是否私网(多级 NAT 旁证) | `192.168.7.2 → 172.30.144.x` ⚠️ 有私网跳 | +| `inbound` 入向实测 | **唯一能拍板的证据** | 未测试(待你执行) | + +### 六级结论 + +`real` 真公网 · `likely` 疑似公网(未实测)· **`blocked` 有公网地址但入向被拦** · +`cgnat` 运营商级 NAT · `private` 私有地址 · `unknown` 无法判定(不乱下结论) + +你的机器当前判定为 **`likely`** —— 页面会明确告诉你:这一步不能省,去做入向实测。 + +### 入向实测怎么做的 + +复现的就是你手动发现问题的那个动作 —— 从另一台外网主机连一次进来: + +1. 点「开始入向实测」,页面给出一条可复制的命令,例如 `ping -c 3 101.70.131.227` +2. 在你的 VPS 上执行 +3. 本机收到 → 判 `real`;3 分钟没收到 → 判 `blocked` + +实现上优先用 `tcpdump` 抓 ICMP echo-request(**零监听端口、零网络改动**), +并排除局域网来源,否则隔壁电脑 ping 一下就会误判成公网可达; +`tcpdump` 不可用时退到一次性 TCP 监听,只用预置高位端口 41000–41005(绝不碰 5900/8443)。 + +> 真机上 `tcpdump` 未装(是可选依赖),当前走的是 TCP 退路。 +> 想用 ICMP 方式的话,在「系统 - 依赖自检」页装 `tcpdump` 即可。 + +--- + +## 3. 依赖自检清单补全(54 → 70 项) + +### 根因 + +你说的「页面提示一键安装,但就绪列表没显示 xfs/f2fs 等名称」,实际有两层: + +1. **DEPS 是 54 项平铺列表**,没有分组也没有搜索 —— 70 项里找一个工具只能靠肉眼扫 +2. **`e2fsprogs`(mkfs.ext4/ext3)从未登记** —— 而外置存储页的 ext4/ext3 一直在 + 提示「请先安装软件包 e2fsprogs」,你在自检页里永远找不到它 + +### 改动 + +- DEPS 元组加 `group` 字段(7 → 8),分 11 组:核心 / 系统 / 网络 / 防火墙 / + 外置存储 / 文件共享 / QoS / 打印 / 监控 / 容器 / 桌面 +- 补进:`e2fsprogs`、`kmod`、`procps`、`coreutils`、`tar`、`hostname`、`udev`、 + `chronyc`、`snmpd`、`snmp`、`cups`、`cups-client`、`usbutils`、 + `smartmontools`、`lm-sensors`、`dmidecode` +- 前端:**分组过滤 chips + 搜索框**(按名称/命令/包名/说明匹配)+「只看缺失」, + 过滤只在内存里重渲染,不重打接口 +- 同步 `make-offline-deps.sh` 与 `deb/control` + (cups / snmpd / smartmontools 因会拉起常驻服务,只放 Suggests) + +--- + +## 4. 终端输出延迟与断字优化 + +| 优化 | 做法 | 效果 | +|------|------|------| +| 长轮询 | 前端发 `wait: 1.2`,服务端无数据时挂起等输出(上限 5s) | 空读挂起 1217ms,有输出 7ms 立刻返回 | +| rAF 批量渲染 | 一帧内多次输出只画一次 DOM | 消除连续同步重排 | +| 宽字符两格 | `wsCharWidth()` + 第二格 `pad` 占位 | 中文/emoji 不再断字 | +| 按码点遍历 | `Array.from` + `WS.carry` 兜底半个代理对 | 不再拆出乱码 | + +**命令 → 首字节 6–7 ms**(优化前是数秒级)。 + +--- + +## 顺带修掉的一个部署缺陷(本轮最大的坑) + +给终端守护加了长轮询,真机测出来还是 6ms 立刻返回。查下来: + +``` +install $SRC/backend/drouter-shelld.py $OPT/backend/drouter-shelld.py +... +if ! cmp -s "$SRC/.../drouter-shelld.py" "$OPT/.../drouter-shelld.py" +``` + +`install` 已经把 SRC 覆盖到 OPT,`cmp` 两边是同一个文件、恒等 —— +**「代码变了就重启」这个分支从来没进过**。进程 16:09 启动,文件 17:40 更新,跑的一直是旧代码。 + +修法:指纹记在 `/var/lib/drouter/shelld.deployed.sha`(表示**上次真正重启过的版本**), +与本次源码指纹比对;且只有 `systemctl is-active` 真的成功才更新 mark(失败下轮重试)。 +新增 `_dev/t-daemon-restart.py`(14 项)守住,已挂进 sync.sh 预检链。 + +> 这个坑对 helpd 同样成立 —— helpd 目前是每次部署无条件 restart,所以没暴露。 + +--- + +## 测试与红线 + +- 本地 **32 套 Python 测试 + 3 项 JS 检查全绿**(新增 `t-pubip` 57 项、 + `t-daemon-restart` 14 项;`t-deps` 重写为 9 组检查) +- 真机:`live-req1-6-10` 26 项、`live-pubip` 18 项、`live-check4` 综合 0 失败 +- 每轮部署复核:**VNC 5900 LISTEN 完好**、**ens18 = 192.168.7.3 未变**、 + 救援通道 inactive + +--- + +## 剩余 7 项 + +| # | 需求 | 备注 | +|---|------|------| +| 69 | 磁盘与日志清理(可自定义阈值 + 自动清理) | 下一步 | +| 70 | MSS 钳制 / SNMP / IP 转发 / masquerade / BBR | 每项带中文说明与联动提示 | +| 71 | Docker IPv6 一键开关 + 国内源切换 | 含自定义源 | +| 73 | CUPS 打印 + USB RAW 直通(互斥二选一) | | +| 74 | AC/AP 管理中心(opensoho,内置二进制) | | +| 77 | CA 证书管理 + SSL 测试 | | +| 75 | **全内存 BGP 路由表** | ⚠️ **待与你确认**:本机 4GB RAM、尚未接管路由、也没有 BGP peer。需要先定清楚是「真实接入 BIRD/FRR + BMP」还是「离线导入 + 检索引擎」 | diff --git a/docs/05-新需求进度报告(第2批).md b/docs/05-新需求进度报告(第2批).md new file mode 100644 index 0000000..001725a --- /dev/null +++ b/docs/05-新需求进度报告(第2批).md @@ -0,0 +1,678 @@ +# drouter 11 项新需求 —— 进度报告(第 2 批) + +> 本文件末尾另附 **新手向导(#84 后追加)** 与 **全项目复查(依赖 / 打包 / 原子性)** 两节。 + +本批 11 项需求**已全部收尾**:#69 磁盘与日志清理、#70 内核转发与加速、#71 Docker +引擎配置、#73 CUPS 打印 + USB RAW 直通、#74 AC/AP 管理中心、#77 CA 证书管理 + +SSL 测试,六项都在目标机 `debian-primaryrouter`(192.168.7.3)上真机验收通过 +(0 失败);#75 全内存 BGP 路由表按你的要求跳过不做。 + +> 其余 5 项(#67 QoS 视频优先、#68 真公网 IP 判定、#72 依赖清单补全、#76 终端 +> 输出优化,以及配套的 #76 之前的收尾)在上一批报告里已交代。 + +--- + +## #69 磁盘与日志清理(菜单:系统 → 磁盘与日志清理) + +**为什么需要它**:路由器是小硬盘设备,日志 / apt 缓存 / 临时文件是撑爆磁盘的 +头号元凶,而磁盘满会导致 DHCP、日志、快照全部失效,且远在千里之外不好救。 + +**9 个清理项**(每项可单独开关、单独设保留天数): + +| 项 | 内容 | 默认阈值 | +|----|------|----------| +| ulog | 统一日志归档 | 3 天 | +| drouter_log | drouter 自身日志 | 7 天 | +| varlog | /var/log 下过期轮转件 | 14 天 | +| journal | systemd 日志 | `journalctl --vacuum-time` | +| apt | apt 缓存 | `apt-get clean` | +| snapdl | 快照下载临时文件 | 1 天 | +| tmp | /tmp 过期文件 | 3 天 | +| trash | 回收站 | 7 天 | +| core | 内核转储 | 7 天 | + +**三条安全设计**: + +1. **安全边界表** `CLEAN_FORBIDDEN`(14 个前缀):`/etc`、`/boot`、`/usr`、`/var/lib/dpkg`、 + `/opt/drouter/backend` … 全部用 `realpath` 归一化后再比对前缀, + 任何软链接穿越都逃不掉。 +2. **活跃文件只统计不清理**:正在写入的 `ulog.jsonl`、`drouter.log` 属于 `peek` 字段, + 页面如实显示「正在写入 1.4 MB(保留不删)」—— 否则用户会以为「占用 0」没风险。 +3. **dry run 先行**:可以先试跑一次看「会释放多少」,确认无误再真删。 + +**真机实测**:磁盘 7.4%(6064/81442 MB),9 项合计可释放 **222.8 MB**; +试运行后磁盘占用 Δ=0 字节(确认一个文件都没删);真实清理后核对文件确实消失。 + +**定时器**:`drouter-cleanup.timer` 默认开启,deploy.sh 会自动初始化策略。 +触发方式可设「按磁盘水位」「按时间」「两者满足其一」。 + +--- + +## #70 内核转发与加速(菜单:系统 → 内核转发与加速) + +五项内核级开关,每项都带**通俗说明**与**联动影响**(用户明确要求的)。 + +| 开关 | 我的默认 | 理由 | +|------|----------|------| +| IPv4 转发 | 开 | 路由器的本职工作 | +| IPv6 转发 | 开 | 同上 | +| BBR 拥塞控制 | 开 | 高延迟/丢包链路明显更好,副作用小 | +| SNMP 监控 | **关** | 多一个 UDP 161 监听就多一分风险 | +| (IPv4 masquerade) | 开 | 关了内网就上不了网 | +| (IPv4 MSS 钳制) | 开 | 专治「部分网站打不开、图片加载一半」 | +| (IPv6 NAT66) | **关** | IPv6 推荐端到端,开了 BT/P2P/远程桌面会失效 | +| (IPv6 MSS 钳制) | **关** | IPv6 靠 PMTUD 自己协商 | + +### 本轮最重要的发现:IPv6 转发的致命联动 + +真机上 `ip -6 addr show ens18` 显示本机的 `2408:8340:.../64` 与 v6 默认路由 +**都来自 `proto ra`(SLAAC)**,租期 86370 秒 ≈ 24 小时。 + +开启 `net.ipv6.conf.all.forwarding=1` 后,**内核会忽略 `accept_ra=1`**(默认值), +于是本机不再接受上游的路由通告 —— 租期一到,**整个 IPv6 就没了**。 + +修法:`_kern_sysctl_lines()` 在开启 v6 转发时自动追加 +`net.ipv6.conf.all.accept_ra = 2`(「就算开了转发也照收 RA」), +并把这条写进页面的联动影响文案。这也是 OpenWrt / RouterOS 的标准做法。 + +**真机验收**: +``` +生效前 ip_forward=0 ipv6_forwarding=0 拥塞=cubic 队列=fq_codel +保存后 ip_forward=1 ipv6_forwarding=1 拥塞=bbr 队列=fq (accept_ra 一并写入) +关闭后 ip_forward=0 ipv6_forwarding=0 ← 关得掉 +``` +MSS / masquerade 的落点是 `nft_v4` / `nft_v6` 配置库(**不另存一份**, +避免两处打架),改完到「防火墙 IPv4 / IPv6」页点「保存并应用」生效。 + +--- + +## #71 Docker 引擎配置(菜单:工具 → Docker 引擎配置) + +**先说一个前提**:这台机器上 **Docker 没装**(`docker` 命令不存在、 +`/etc/docker/` 目录都不存在)。所以这一页做成了「**未安装也能预配置**」—— +文件照常写到 `/etc/docker/daemon.json`,将来装好 Docker 一启动就生效。 + +### IPv6 一键开关 + +```json +{ "ipv6": true, "fixed-cidr-v6": "fdxx:xxxx:xxxx::/64", "ip6tables": true } +``` + +- 默认**关闭**:开了不等于能上 v6 外网,还得上游路由器配合 +- 网段留空时,保存会**自动生成一个 ULA 私有 /48**(`fd` + 40 bit 随机数,RFC 4193)。 + 用私有段而不是公网段,是因为公网段还需要上游做 NDP 代理 —— 家用环境多半没这条件 +- `ip6tables: true` 会一起写:没有它 Docker 不会给容器生成 v6 规则,等于白开 +- `experimental` **默认不写**,做成单独勾选项。只有 Docker 24/25 需要它来解锁 + ip6tables;26 起 ip6tables 已转正,多写这一项在个别版本上会让 dockerd 拒绝启动 + +### 国内镜像源切换 + 一键测速 + +7 个预设(腾讯云 / DaoCloud / 网易 / 中科大 / 南京大学 / 上海交大 / 阿里云占位), +可多选、可填自定义源。 + +**测速从这台路由器自己发起**(带 `--noproxy '*'`,不被环境代理掩盖), +打 Registry v2 的 `/v2/` 探针,**HTTP 200 或 401 都算可用**(探针本来就要求带凭证)。 + +真机实测结果(2026-09-29): + +| 源 | 结果 | 说明 | +|----|------|------| +| DaoCloud | ✅ 401 · 64 ms | 可用 | +| 南京大学 | ❌ 403 | 解析到 IPv6,返回的不是 Registry 应答 | +| 上海交大 | ❌ TLS 告警 | SSL routines::tlsv1 alert internal error | +| 腾讯云 / 网易 / 中科大 | ❌ 解析失败 | 上游 DNS(192.168.7.2)解析不了这三个域名 | +| 阿里云 | ⏭ 跳过 | 占位地址,需填自己的专属地址 | + +> 这不是 bug —— 已用 `getent hosts` 逐个核对,确认上游 DNS 确实解析不了那三个域名。 +> 这恰恰说明测速功能的价值:**镜像源的可用性会随运营商和时间变,只能以实测为准**。 + +### 顺带纳管的三项 + +- **日志滚动**(默认开,10m × 3 份):容器日志是撑爆小硬盘的头号元凶 +- **live-restore**(默认开):重启 dockerd 时别把容器一起杀掉 +- **默认网桥网段 bip**(默认不动):专治 docker0 与局域网网段打架。 + 填了会和本机现有网卡网段做重叠检测并告警 + +### 两条铁律 + +1. **用户自己写的键一律保留**:`data-root` / `insecure-registries` / `storage-driver` + 这类本页不管的键原样抄进结果,页面还会明确列出「有 N 个本页不管的键,会保留」。 + 静默丢掉别人的配置是最糟的体验。 +2. **保存只写盘,绝不自动重启**:重启会中断所有容器、重建 docker0、重插 iptables/nft 链。 + 重启是独立按钮 + 二次确认弹窗;构建保护模式下直接拒绝。 + +每次保存自动备份(保留最近 5 份,`/var/lib/drouter/dcfg-backup/`),可一键还原。 + +### 「配置代码同时显示」 + +用户明确要求。做法是**预览一律调后端 `op=preview`**,前端不自己再拼一份 —— +否则两边逻辑迟早不一致,用户看到的代码和写进去的代码对不上。 +页面上的 `
` 随选项实时变化(250ms 防抖),保存后再用服务端返回的
+真实落盘内容校准一次。单测里有「落盘内容与预览逐字节一致」的断言。
+
+---
+
+## 测试
+
+| 范围 | 结果 |
+|------|------|
+| 本地 Python 单测(35 套) | 全绿(新增 `t-cleanup` 121 项、`t-kern` 83 项、`t-dcfg` **118 项**) |
+| 前端三查(views / render / mobile) | 39 视图、0 异常、手机适配 26 项通过 |
+| 真机 `live-cleanup` | 18 项全过 |
+| 真机 `live-kern` | 41 项全过 |
+| 真机 `live-dcfg` | **49 项全过** |
+
+`t-dcfg.py` 已挂进 `devtools/sync.sh` 预检链。
+
+### 本轮踩到并修掉的坑
+
+1. **`int(raw or 3)` 吞掉用户填的 0** —— 日志份数填 0 会被当成「没填」回落成 3。
+   改成先判断 `raw in (None, '')` 再 `int()`,最后统一 `max(1, min(x, 20))`。
+2. **验收脚本污染状态文件** —— 上一轮保存过 `ipv6: true`,下一轮读到的就不是出厂默认,
+   「IPv6 默认关」假失败。改为验收前临时挪开 `/etc/drouter/dcfg.json`,跑完还原。
+3. **验收脚本权限不足** —— 直接写 `/etc/docker/daemon.json` 需要 root,改用 root 跑。
+
+---
+
+# #73 CUPS 打印服务 + USB RAW 直通(互斥二选一) ✅ 已完成并真机验收
+
+## 需求理解
+
+把这台路由器变成打印服务器:**CUPS 模式**(功能最全,手机/电脑免驱,有队列管理)
+和 **USB RAW 直通模式**(最省资源、兼容性最好,客户端自己装驱动)**互斥二选一** ——
+它们抢的是同一个 USB 打印机。
+
+## 这台机器上的实测起点(决定了默认值怎么定)
+
+| 项目 | 实测 | 含义 |
+|------|------|------|
+| CUPS | 2.4.10-3+deb13u2 已装且 active | 不用引导安装,直接可用 |
+| 631 监听 | **只有 127.0.0.1:631** | 局域网里的电脑手机**全都连不上** —— 装了等于白装 |
+| 队列 | `HP_OfficeJet_Pro_7740_series_44636D`,`Shared No` | 是靠 cups-browsed 自动发现的,但没共享出去 |
+| USB 打印机 | **无**(PVE 虚拟机,lsusb 只有 QEMU Tablet) | RAW 模式下会如实提示「找不到 /dev/usb/lp0」 |
+| socat | 1.8.0.3 已装 | RAW 直通不用再装 p910nd |
+| avahi-daemon | active | 局域网发现(iPhone/Android 自动找到打印机)可用 |
+| nftables | input 链 LAN 全放行 | 631 / 9100 不会被防火墙挡 |
+
+## 我替用户做的判断(默认值)
+
+| 项 | 默认 | 理由 |
+|----|------|------|
+| 工作模式 | CUPS | 功能最全、手机免驱;RAW 需要用户自己装驱动 |
+| 允许局域网访问 | **开** | CUPS 出厂只听 127.0.0.1,不开这页就没意义 |
+| 共享打印机 | 开 | 不共享的话局域网根本看不见它 |
+| CUPS Web 界面 | 开 | 方便排障,关掉不影响打印 |
+| **允许远程管理** | **关** | 安全红线:不该让同网段任何人都能增删队列 |
+| cups-browsed | 开 | 你那台 HP 7740 就是靠它发现的 |
+| RAW 端口 / 设备 | 9100 / `/dev/usb/lp0` | 行业标准端口 + usblp 默认节点 |
+
+## 核心设计:为什么必须互斥
+
+- CUPS 侧靠 **ipp-usb**(走 libusb)拿设备;
+- RAW 侧靠内核 **usblp** 模块的 `/dev/usb/lp0`;
+- 两边同时开的结局是**谁都打不出来**。
+
+所以切模式时本页会把对面**停干净**:
+
+| 切到 | 动作 |
+|------|------|
+| RAW | 停 + 禁用 `cups-browsed` / `cups.service` / `cups.socket` |
+| CUPS | 停 + 禁用 `drouter-printer-raw.service` |
+| 关闭 | 全部停 + 禁用,631 / 9100 都不再监听 |
+
+## 关键安全边界:cupsctl 的 `Allow all`
+
+`cupsctl --remote-any` 会把 `` 写成 **`Allow all`**(放行一切来源,
+WAN 侧也能连上打印)。对一台路由器来说太宽了。本页在 cupsctl 之后**再收窄一层**
+成 `Allow @LOCAL` —— 只有直连网段能访问,不会把打印机暴露到公网。
+改完还会跑 `cupsd -t` 校验,校验不过就把改动前的备份自动还原。
+
+## 真机验收结果(66 项全过,0 失败)
+
+```
+关闭模式       CUPS 停、631/9100 都不再监听              ✔
+CUPS 模式      631 从 127.0.0.1 → 0.0.0.0 + [::]        ✔ 真正生效
+                里是 Allow @LOCAL,无 Allow all ✔
+               cupsd -t 校验通过                          ✔
+队列增删       加 → 出现在列表 → 改共享 → 删 → 确认消失    ✔
+RAW 模式       CUPS 被停+禁用(互斥生效)                 ✔
+               没插打印机时明确报「找不到 /dev/usb/lp0」    ✔
+               wrapper 用 GOPEN 开字符设备、带 fork        ✔
+备份还原       文件名白名单 + realpath 防目录穿越          ✔
+还原现场       cupsd.conf 按字节写回、三服务恢复原状       ✔
+红线复核       VNC 5900 ✔ / ens18=192.168.7.3 ✔ / 网关未变 ✔ / DHCP 未启 ✔
+```
+
+## 本轮踩到并修掉的坑(都是真 bug,不是测试问题)
+
+1. **从「关闭」切回 CUPS 时 cupsctl 必然失败** —— `cupsctl` 不是直接改配置文件,
+   而是连到 cupsd 的本地 socket 下命令;cups 没在跑就报
+   「无法连接服务器:错误的文件描述符」,而且失败后直接 return 会让 cups 一直停着,
+   等于切了个寂寞。**修**:`_print_apply_cups` 先确认 cups 起来了再下命令。
+2. **`printers.conf` 是 cupsd 延迟落盘的** —— 刚 `lpadmin` 加完的队列,
+   `lpstat -p` 里已经有了、文件里还没有(实测要等 cupsd 重启才写进去)。
+   只认文件的话用户会遇到「队列加了却看不见」。
+   **修**:用 `LC_ALL=C lpstat -p` 补齐(这机器的 lpstat 默认是中文,
+   必须强制 C locale 才可解析);并且在增删队列后重启 cups 逼它落盘,
+   否则删掉的队列还会继续显示在列表里。
+3. **`(\S+?)` 配可选 `[.,]` 只吃到一个字母**(`idle` → `i`)——
+   非贪婪量词遇到后续可选项就提前结束。改成 `([^\s.,]+)`。
+4. **`int(port or 9100)` 又踩了 0 是 falsy 的老坑**(和 #71 同一个)。
+5. **验收脚本自己的两个错**:`%s` 传了元组;以及把队列操作放在了 RAW 模式之后
+   (那时 CUPS 已被停掉,`lpadmin` 必然失败)。已修,并把改动段包进 try、
+   现场先落盘 JSON,保证崩溃也能还原。
+
+---
+
+---
+
+## 每轮部署红线复核(已通过)
+
+```
+VNC 5900      LISTEN 0.0.0.0:5900 / [::]:5900  ✔ 完好
+ens18         192.168.7.3/24 未变              ✔
+默认网关      仍是 192.168.7.2(远端 CHR)      ✔ 未接管路由
+救援通道      inactive                          ✔
+drouter-web   active                            ✔
+```
+
+---
+
+## 收尾状态:剩余 0 项
+
+| # | 需求 | 备注 |
+|---|------|------|
+| 73 | CUPS 打印服务 + USB RAW 直通(互斥二选一) | ✅ 已完成(单测 214 项 + 真机 66 项全过) |
+| 74 | AC/AP 管理中心(opensoho) | ✅ 已完成(单测 210 项 + 真机 96 项全过) |
+| 77 | CA 证书管理 + SSL 测试 | ✅ 已完成(单测 192 项 + 真机 96 项全过) |
+| 75 | 全内存 BGP 路由表 | ❌ **按你的要求跳过,不做了** |
+
+---
+
+# #74 AC/AP 管理中心(OpenSOHO) ✅ 已完成并真机验收
+
+菜单:**服务 → AC/AP 管理中心** 接口:`/api/opensoho` 后端:`act_opensoho`
+
+## 需求理解与边界
+
+OpenSOHO 是一个单二进制(Go / PocketBase 内核)的 OpenWRT 集中控制器。AP 侧装
+`openwisp-config` 后用共享密钥向它自注册,之后 Wi-Fi、VLAN、PoE 全在它自己的
+控制台里配。
+
+drouter 这里**只做托管,不重复实现**:
+
+| 做 | 不做 |
+|----|------|
+| 装/升级/卸载二进制与 systemd 单元 | 重做一遍 Wi-Fi / SSID 配置界面 |
+| 保管两把密钥与控制台管理员凭据 | 自己解析 AP 上报的数据 |
+| 管启停、开机自启、监听地址与端口 | 替代 OpenSOHO 的控制台 |
+| 调 REST API 把 AP / 客户端数量回显到本页 | —— |
+| 给一个「打开控制台」的入口 | —— |
+
+理由:重复实现一遍既做不好也维护不动,真正的配置界面就在它自己那儿。
+
+## 探测阶段摸清的事实(决定了很多设计)
+
+| 事实 | 影响 |
+|------|------|
+| **连 `--help` 都要 `OPENSOHO_SHARED_SECRET`** | 跑任何子命令(含 `-v`、`superuser`)都必须带上密钥环境 |
+| `--dir` 默认值是 `/home/<用户>/pb_data` | 必须显式指定,否则 root 跑就落到 `/root` |
+| **pb_migrations 解压到「工作目录」而不是 `--dir`** | 工作目录必须可写 → 后来踩了个大坑,见下 |
+| `/api/health` **免鉴权**,返回 `{"code":200,...}` | 拿来做「到底起没起来」的判断最合适 |
+| 集合共 29 个,真实名字是 `wifi_ssids` / `wifi_aps` | 文档里写的 `wifi` 其实不存在(404) |
+| superuser token 有效期 **3 年** | drouter 自己建号、存 token 即可,不用存用户密码 |
+| **这台机器访问不了 GitHub**(curl 20s 超时) | 在线安装这条路走不通,必须有「从本机文件安装」 |
+| 最新版 v0.15.2,包名 `opensoho_0.15.2_linux_amd64.zip` | 按架构挑选资源 |
+
+## 我替用户做的判断
+
+| 项 | 取值 | 理由 |
+|----|------|------|
+| 监听地址 | `0.0.0.0` | AP 要从局域网连进来注册,只听 127.0.0.1 等于没装 |
+| 端口 | 8090 | 官方默认 |
+| 自动接管新 AP | 开 | 默认接管;想先看清网络再决定就关掉(变「只监控」模式) |
+| 配置静态加密 | **开** | settings 里存着 Wi-Fi 密码,明文落盘在路由器上不合适 |
+| 运行用户 | 专用系统用户 `opensoho` | 不以 root 跑 |
+| 密钥存放 | `EnvironmentFile=`(0600) | 单元文件是 0644,写 `Environment=` 谁都看得见 |
+| 控制台管理员 | drouter 随机生成密码自己留着 | 用户不用记密码,页面直接展示;token 过期能自己换 |
+| 卸载默认 | **保留数据目录** | 里面有 AP 清单,误删不可逆;要清得显式勾 |
+
+## 两把密钥(最关键的设计)
+
+| 密钥 | 作用 | 改了会怎样 |
+|------|------|-----------|
+| `OPENSOHO_SHARED_SECRET`(32 字符) | AP 注册用 | 已注册的 AP 要重填一次 —— **可接受** |
+| `OPENSOHO_SETTINGS_KEY`(32 字符,加密用) | settings 静态加密 | 历史配置直接解不开 —— **不可接受** |
+
+所以:升级、改端口、改任何开关,**都不会重新生成加密密钥**;只有卸载清除数据时才动它。
+单测与真机都专门盯了这一条。另外加密开关在有数据之后被**锁死**(后端返回 `ENC_LOCKED`),
+否则切一下就把已存的 Wi-Fi 配置毁了。
+
+## 真机验收结果(96 项全过,0 失败)
+
+```
+未安装时     status installed=False,版本空,health 报「未安装」        ✔
+             未安装就改设置 / 启服务 → NOT_INSTALLED                    ✔
+在线安装     本机连不上 GitHub → 报错里点明「这台机器能访问 GitHub 吗」  ✔
+             失败时不留半成品单元                                       ✔
+路径错误     /nope/nope → NOT_FOUND,且不启服务、把原服务拉回去          ✔
+本地安装     二进制落位 0755、单元建立、opensoho 用户建立                ✔
+             密钥 600 权限、共享密钥 32 位、加密密钥 32 位               ✔
+             服务 active + enabled,8090 监听 0.0.0.0                   ✔
+             /api/health 200、控制台 /_/ 200                           ✔
+安装后状态   版本 0.15.2、health ok、控制台地址用 192.168.7.3            ✔
+             管理员 admin@drouter.local 已建立、密码可取、文件 600        ✔
+REST 概况    8 个集合全部读到计数(空库是 0 不是 null)                  ✔
+重新生成密钥 共享密钥变了、加密密钥没动、提示 AP 要重注册                ✔
+改端口       8090 → 8091 监听真的跟着变、health 正常、再改回来            ✔
+             70000 被钳到 65535                                        ✔
+启停         stop → 端口释放;start → 端口恢复;非法操作被拒             ✔
+构建保护模式 start 被 BUILD_MODE_BLOCKED;安装只写盘不启服务             ✔
+卸载         默认保留数据目录;purge 才清除                             ✔
+还原现场     全部清理干净、8090 释放、用户还原、BUILD_MODE 还原           ✔
+红线复核     VNC 5900 在监听 / ens18=192.168.7.3 / 网关 192.168.7.2 未变 ✔
+             dnsmasq+kea 未启 / drouter-web active / 救援 inactive      ✔
+             防火墙规则集未改动                                         ✔
+```
+
+## 本轮踩到并修掉的坑(4 个都是真 bug)
+
+1. **服务起不来:`mkdir /opt/drouter/opensoho/pb_migrations: permission denied`**
+   OpenSOHO 会把内嵌迁移文件解压到**工作目录**,而我把 `WorkingDirectory` 指向了
+   root 所有的二进制目录,服务用户写不进去 → 秒退。改成指向数据目录解决。
+
+2. **数据目录放在 `/var/lib/drouter/opensoho` → `status=200/CHDIR`**
+   `/var/lib/drouter` 本身是 `0750 drouter:drouter`,opensoho 用户**连父目录都穿不过去**。
+   改到 `/var/lib/opensoho`。这是最隐蔽的一个:错误只说「Permission denied」,
+   不看单元的工作目录根本想不到是父目录权限。
+
+3. **`systemctl restart` 返回 0 但服务秒退,页面显示「已安装并已启动」而端口没监听**
+   restart 的返回码只代表「systemd 收下了任务」。新增 `_oh_wait_up()`:
+   启动后轮询 `is-active` + `/api/health`,没起来就报错并附上 journalctl 最近一条日志。
+
+4. **管理员永远建不出来:`invalid settings db data or missing encryption key ""`**
+   库是 `serve` 带 `--encryptionEnv` 建的,而 `superuser upsert` 没带同一个参数 →
+   settings 解不开。子命令必须带上一致的 `--encryptionEnv`。
+   (同一轮还修了 `_oh_admin_token` 首次不去创建管理员的问题。)
+
+另外两个健壮性修复:升级时**先停服务再换二进制**(否则 `Text file busy`);
+安装中途失败要把原本在跑的服务**拉回去**,不留停止状态。
+
+## 已知限制
+
+- 这台机器访问不了 GitHub,「在线安装」只能报错;实际装要用**从本机文件安装**
+  (先经 Web 终端 / 文件管理器把二进制传到 `/tmp`)。
+- 目前还没有 AP 注册进来,所以概况页各集合都是 0 —— 这是正常的空库状态,
+  不是读取失败(读取失败会明确标「读取失败」)。
+
+---
+
+# #77 CA 证书管理 + SSL 测试工具 ✅ 已完成并真机验收
+
+菜单:**系统 → 证书 / SSL** 接口:`/api/ca`(`/api/ca/op`) 后端:`act_ca`
+
+这是 11 项新需求里的最后一项。做完之后,管理后台那张「自己签的、浏览器一直报
+不受信」的证书,可以换成你自己的 CA 签出来的证书;同时附一个 SSL 握手体检工具,
+用来查任意一台服务器(不止本机)的 TLS 到底怎么回事。
+
+## 需求理解与边界
+
+| 做 | 不做 |
+|----|------|
+| 建自己的根 CA(RSA / EC 自选) | ACME / Let's Encrypt 自动签发 |
+| 用 CA 签服务器证书(带 SAN) | 接各家 DNS API 做 DNS-01 |
+| 导入现成证书(含「补全 CSR」) | 替你保管商业证书的续费提醒 |
+| 一键部署给管理后台(带回滚) | 动其它服务的证书(opensoho / daed 等) |
+| 生成 CSR 交给机构签 | 改 Web 监听地址或端口 |
+| SSL 握手体检(版本 / 套件 / 链 / 名称) | 做漏洞扫描(Heartbleed 之类) |
+| 恢复默认自签证书 | 删除系统信任库里的内容 |
+
+**为什么不做 ACME**:这台机器没有固定的公网 80 口,HTTP-01 走不通;DNS-01 又
+需要各家域名商的 API 密钥,没法通用。有现成域名证书的,走「导入」那条路即可。
+
+## 探测阶段摸清的事实(决定了默认值怎么定)
+
+| 探测项 | 结果 | 影响 |
+|--------|------|------|
+| openssl 版本 | 3.5.7 | 3.x 把 IP 打印成 `IP Address:1.2.3.4`,不是 `IP:…` |
+| 现网证书 | `/opt/drouter/certs/server.{crt,key}` | 部署就是替换这两个文件 |
+| 文件权限 | crt 0644 / key 0600,属主 drouter | 换完必须 chown drouter,否则 Web 读不到 |
+| 目录权限 | certs 0750 | 单测在 Windows 上不能校验,只能静态查源码 |
+| Web 端口 | 8443(`/etc/drouter/web-port`) | 部署后握手验证要读这个端口 |
+| 原证书 | 自签 CN=drouter.local,10 年期 | 「恢复默认」要能按原样重新签一张 |
+| 服务单元 | `drouter-web.service` | 部署 = 换文件 + restart |
+
+## 我替用户做的判断(默认值)
+
+| 项 | 我的默认 | 理由 |
+|----|----------|------|
+| 根 CA 密钥 | **RSA 2048** | 兼容性最好;4096 签发和握手都明显更慢,路由器不值当 |
+| 根 CA 有效期 | 3650 天(10 年) | CA 换一次要重新装根证书,别折腾 |
+| 服务器证书有效期 | **825 天** | 苹果/谷歌对公开信任证书的上限就是 825 天,跟着走 |
+| 有效期钳制 | 1 ~ 36500 天 | 手滑多打一个 0 不至于签出一张 100 年的证书 |
+| 到期提醒阈值 | 30 天 | 阈值由后端下发,前端不另写一份 |
+| SAN 条数上限 | 32 | 防误粘贴一整段文本 |
+| 体检超时 | 8 秒(上限 30) | 外网握手,别让页面干等 |
+| 默认 SAN 建议 | 本机 IP + 127.0.0.1 | 签出来的证书直接就能用,不用先想填什么 |
+
+## 最关键的设计:部署是唯一能把用户关在门外的动作
+
+换证书要**重启 drouter-web**,而一旦新证书有问题(私钥不配对、格式不对),
+Web 就再也起不来 —— 你会被关在自己家的管理后台门外,只能跑机房接显示器。
+所以部署前设了**四道闸**,部署后设了**一道验 + 一条退路**:
+
+| 阶段 | 检查 | 不通过会怎样 |
+|------|------|--------------|
+| 闸 1 | 证书能被 openssl 解析 | 直接拒,不动磁盘 |
+| 闸 2 | 有对应的私钥文件 | 直接拒 |
+| 闸 3 | **证书与私钥的公钥配对** | 直接拒(这条最要命,配不上 Web 必起不来) |
+| 闸 4 | 有效期覆盖当下 | 直接拒 |
+| 动作 | 备份 → 临时文件 + `os.replace` 原子替换 → chown drouter | 中途断电也不会留下半个文件 |
+| 验 | 重启后**真的握手一次**,比对指纹是不是新证书 | 指纹不对就认定失败 |
+| 退路 | 自动从备份还原 + 再启一次 | 回到部署前的状态 |
+
+还有一个必须处理的坑:重启 drouter-web 会把**这次 HTTP 响应一起带走**
+(helper 与 web 在同一个 systemd cgroup 里)。这不是失败,结果已经写到
+`/etc/drouter/ca/last-deploy.json` 了。所以前端要把「连接断了」和「真的失败了」
+分开:`code` 是 `NET` / `PARSE` 就提示「正在重启,10 秒后自动刷新」,
+否则才弹错误。
+
+## 真机验收结果(96 项全过,0 失败)
+
+| 段 | 关键结论 |
+|----|----------|
+| 初始状态 | openssl 3.5.7、端口 8443、原证书自签、剩余 3646 天、SAN 建议带本机 IP |
+| 参数校验 | CN 含逗号 / 空 CN / 无 SAN / 垃圾 PEM / 空主机 / 端口越界 / 假 id 全部被拒 |
+| 建 CA | CA:TRUE ✔、私钥 0600 ✔、库目录 0700 ✔、十年期 ✔ |
+| 签发 | SAN 含 drouter.lan / 192.168.7.3 / 127.0.0.1,非 CA,带 serverAuth, |
+| | 证书与私钥配对,`openssl verify -CAfile` 通过 |
+| 部署 | 响应被重启打断(正常)→ 8443 仍在提供 TLS → **端口上的指纹与新证书完全一致** |
+| | → last-deploy.json 标记 `ok` / `stage=done`「已生效,剩余 824 天」 |
+| | → 备份目录留了旧证书 → 会话清空后重新登录成功 |
+| SSL 体检 | TLSv1.3 / TLS_AES_256_GCM_SHA384 / 拿到完整链 / SAN 名称匹配通过 |
+| 连不上端口 | 「连接被拒绝(端口没在监听)」—— 人话,不是一串 errno |
+| 拒绝路径 | 正在使用不让删 / CA 不给部署 / 私钥不配对被拒 / 非 CA 按 ca 导入被拒 |
+| CSR | PEM 落盘、类型标 csr、标记「待签回」、列表里不给「部署」按钮 |
+| 保护模式 | 只写盘不重启、`stage=written`、端口上还是旧证书、状态里读得到标记 |
+| 恢复默认 | 8443 恢复、SAN 带本机 IP、自签 ✔ |
+| 还原现场 | 指纹与开工前**一字不差**(`90:92:43:4C:…`)、证书库与保护模式按原样还原 |
+| 红线 | 5900 在监听 / ens18=192.168.7.3 / 网关 192.168.7.2 / drouter-web active / |
+| | 救援 inactive / dnsmasq+kea 未启 / nft 规则集仍为空 |
+
+## 本轮踩到并修掉的坑(2 个都是真 bug,不是测试问题)
+
+**1. 部署明明成功,却被判失败并回滚。**
+`_ca_handshake` 取指纹时只算了前 16 字节,而 `_ca_x509_info`(走 openssl
+`-fingerprint -sha256`)给的是完整 32 字节 —— 两个永远不相等,于是「指纹没变」
+被当成失败,刚换好的证书又被还原回去。第一轮真机验收第一次跑就撞上了。
+**修**:握手指纹改成完整 32 字节、冒号分隔大写,与 openssl 输出一字不差;
+单测里补两条回归断言(格式一致 + 长度 95 字符)。
+
+**2. openssl verify 的编号表填错了。**
+我第一版凭印象填:`21` 写成「链里有证书不是 CA 证书」(其实那是 `24`),
+`27` 写成「已吊销」(其实那是 `23`)。结果私有 CA 签发的证书 —— 这是本功能
+最主要的用法 —— 被标成一条完全不相干的错误说明。
+**修**:按 `x509_vfy.h` 重写整张 `SSL_VERIFY_CN`(`21`=找不到颁发者、
+`23`=已吊销、`24`=链里有一张不是 CA 证书、`62`=主机名不匹配……)。
+另外「信任链」这一项的说明也改了:自签 / 私有 CA 属于正常现象,点了会标红但
+**加密本身是生效的**,把根证书装进设备就不再提示 —— 否则用户会以为是坏掉了。
+
+顺带在单测里钉死了三类环境差异:Git Bash 没有 coreutils `timeout`(要剥前缀)、
+Windows 没有 `systemctl`(默认当成功,失败分支单独替换)、Windows 的
+`os.chmod` 只认只读位(权限改在 POSIX 上校验 + 静态查源码写了正确 mode)。
+
+## 已知限制
+
+- 没有 ACME / Let's Encrypt 自动签发(无固定公网 80 口,DNS-01 又要各家 API 密钥)。
+  有域名证书的走「导入」。
+- 只管管理后台这一张证书。opensoho、daed 等其它服务的证书不在本功能范围内。
+- 体检是**握手体检**,不是漏洞扫描:能告诉你版本、套件、链、名称、有效期,
+  不检测已知漏洞。
+
+---
+
+# #84 新手向导(菜单:概览 → 新手向导,排在第一位) ✅ 已完成并真机验收
+
+## 需求理解
+
+中国大陆家庭宽带上网,新手卡住的就四件事:**从哪接宽带 / 内网怎么发地址 /
+域名怎么解析 / 要不要 IPv6**。这四项在别处都有专业页面,但术语密集、字段多、
+错了也没人告诉你错在哪。新手向导把这四步串成一条直线,每一步都给**可以照抄的
+范例**,并且**边填边体检**(探测真实网卡 / 真实解析 / 真实连通性),而不是填完
+保存了才发现不通。
+
+## 为什么放在「概览」组第一位
+
+新手打开 Web 后台的第一眼应该看见它。放系统组或工具组都要先知道去哪找,
+那就失去意义了。`needSave: false` —— 向导每步自带「保存并生效」按钮,
+不需要页面底部那条全局动作条(避免「我点了保存到底存没存」的困惑)。
+
+## 四步各自解决什么
+
+| 步 | 标题 | 解决的原始困惑 | 给的范例 |
+|---|---|---|---|
+| 1 | 接上外网 | 宽带账号填什么?光猫哪个口? | 账号 `051212345678`;密码下面标「不是 Wi-Fi 密码,是宽带密码」;接口选「光猫的 LAN 口」 |
+| 2 | 给内网发地址 | 地址池写什么?会不会和自己冲突? | 照着抄一段;起点建议 `本机前三段 + .100`(由后端 lan_ip 推出来,不写死) |
+| 3 | 能打开网址 | 用哪家 DNS?填几个? | 六个常用服务商一键选;不知道选哪个就 223.5.5.5;一个不通自动换另一个 |
+| 4 | 要不要 IPv6 | 开了能怎样?前缀填什么? | `2408:8207:1234:5678::/64`;明确写「内网通告只能用 /64」 |
+
+## 关键设计决策
+
+**1. 第 3 步的 DNS 探测不说术语。**
+`dig` 返回 SERVFAIL / REFUSED / 超时 / NXDOMAIN 全部翻译成人话
+("这个 DNS 服务器拒绝回答"/"超时没回应,可能是运营商屏蔽了它"),
+新手看不懂 `SERVFAIL`。
+
+**2. 第 4 步前缀强制 /64 —— 这是协议硬要求,不是我们挑剔。**
+RFC 4862 规定 RA 通告的前缀长度必须是 64。用户填 `/80` 时自动规范化成 `/64`
+**并说明原因**;关闭 IPv6 时**保留已填的前缀不删**(下次想开不用重填)。
+
+**3. 第 1 步顺带把「能上网」的另外三样一起配好。**
+写 WAN 口的同时打开 IPv4 转发、masquerade、MSS clamp。只配拨号不打 NAT 是
+「拨上号但访客上不了网」,新手无法自诊断。
+
+**4. 地址池校验必须按数值比,不能按字符串比。**
+`'192.168.1.9' > '192.168.1.100'` 字符串比是 True(`'9' > '1'`),
+按数值比才是 False。按字符串比会把合法地址池判成非法。
+
+**5. 校验分两类,报错口径不同。**
+「圈进了本机自己」→ `会自己和自己抢 IP,请把起点往后挪`;
+「跨网段」→ `前三段必须和本机一致`。都是能直接指导下一步动作的话。
+
+**6. 拨号是独立动作,且必须二次确认。**
+保存配置 ≠ 拨号。点拨号要手打「拨号」两个字确认,页面写明
+「会短暂中断现有网络」。**半成功不回滚**:四步里某步失败,前面已成功的步骤
+保持生效(新手最怕的是"点了个按钮,全没了")。
+
+## 真机验收结果(75 项全过,0 失败)
+
+```
+体检       openssl/四段状态/internet_ok/todo/三方式/六 DNS/池规则/网卡下拉      ✔
+          「体检结果与配置库一致(LAN 接口 ens18)」                            ✔
+地址池     圈进本机自己 → 拒「会自己和自己抢 IP,请把起点往后挪」               ✔
+          跨网段 → 拒「前三段必须和本机一致」                                  ✔
+          非法 → 拒;正常 → 落库 + dhcp-range + option 3 正确                  ✔
+          「原本没跑 dnsmasq,向导没有把它擅自启起来」                          ✔
+DNS       非法/空/非法 mode 全拒;正常落库;223.5.5.5 真能解析                  ✔
+IPv6      /80 → /64 规范化并给说明;RDNSS 落库;转发开/关;关闭保留前缀          ✔
+上网方式   非法 mode / 缺账号 / 缺密码 / 缺地址 / 网关非法 / 未知步骤 全拒        ✔
+          拨号不带确认 → 拒「会短暂中断现有网络」;非法动作 → 拒                 ✔
+红线      默认路由未变 / ens18 未变 / 5900 在监听 / 网关 192.168.7.2 未变        ✔
+          救援 inactive / dnsmasq+radvd 按原样未启动 / nft 仍为 0                 ✔
+保护模式   只写盘不重启、状态可读、配置仍落库                                    ✔
+```
+
+验收脚本 `_dev/live-wizard.py` **刻意不真拨号**(会改默认路由,触碰安全红线),
+只验到「参数校验把非法输入拦住了」。开头备份 6 个配置库 key + dnsmasq/radvd
+配置文件,`finally` 里还原并二次复核红线。
+
+## 单测:`_dev/t-wizard.py`(160 项全过)
+
+21 个大节,已挂进 `devtools/sync.sh` 预检链 —— 这是新手唯一会看的页面,
+**地址池"圈进本机自己"这类错必须在服务端就拦住**,不能靠前端拦。
+
+---
+
+# 全项目复查(依赖 / 打包 / 原子性)
+
+## 复查范围与结论
+
+| 检查项 | 结论 |
+|---|---|
+| 依赖关系(60+ 包名) | 逐个 `apt-cache policy` 核对 → **发现 1 个真 bug**(见下) |
+| 模块联动 | `APPLY_SPEC` / `CONFIG_ONLY_MODULES` / `RENDERERS` / `ALIASES` 四表比对 → 一致 |
+| 附属功能 | 9 个 systemd 单元名全局一致 |
+| UI / 布局 / 字体 | 菜单 43 项;字体全系统栈(无外部 Web Font);字号层级合理 |
+| git | **项目不是 git 仓库**(`fatal: not a git repository`) |
+| deb 打包 | `control` 依赖已修正;`build-deb.sh` 注释同步 |
+| docker 镜像 | `Dockerfile` 的 5 条 `COPY` 引用文件全部存在 |
+| doc | 各功能文档齐全(本轮补入新手向导) |
+| 原子写盘 | **发现 1 处非原子写,已修**(见下) |
+
+## 真 bug ①:`docker-compose-v2` 是 Ubuntu 包名,Debian 13 叫 `docker-compose`
+
+**后果比"装不上"严重**:`apt-get install` 命令行里只要有**一个**查不到的包名,
+就**整条命令中止**(`E: 无法定位软件包`),同命令里其它正常包**一个都不装**。
+实测:
+```
+apt-get install --dry-run zip unzip 不存在包名zzz
+→ E: 无法定位软件包 不存在包名zzz   (zip / unzip 也没装)
+```
+所以一个包名写错,整个依赖安装按钮全废。
+
+**修**:
+- `DOCKER_PKGS` 保留双候选(`docker.io` / `docker-compose` / `docker-compose-v2`)
+- 新增 `_pkgs_available()`:用 `apt-cache show` 过滤出本机真有的包名
+- `act_depcheck` 安装前过滤;过滤后为空时给出明确原因(提示先 `apt-get update`)
+- `_docker_pkg_state` 用过滤后的列表拼安装命令
+- `DEPS` 表里 compose 项写双候选 `docker-compose|docker-compose-v2`
+- `control` / `app.js` / `build-deb.sh` 里的用户可见包名全部改对
+- 单测 `t-deps.py` 新增 10 项断言钉死
+
+## 真 bug ②:`_share_apply_file` 写 SMB / NFS 配置不是原子写
+
+**问题**:原实现 `with open(path, 'w')` 会**先把原文件截断成 0 字节**,
+再逐块写新内容。两步之间如果 `smbd` / `nfs-server` 恰好 reload 或掉电,
+它读到的是半截配置 —— 轻则服务起不来,**重则按残缺的 share 定义把目录暴露出去**。
+
+**修**:改成与 `act_apply` 同一套原子写(同目录临时文件 → `os.replace`),
+并补上失败时清理残留临时文件。
+
+**为什么之前没发现**:`act_apply` 走的是 `os.replace`,而 SMB/NFS 走 `act_share`
+自己的写盘逻辑 —— 两条路径,只查了一条。这也是为什么"模块联动"要按表比对而不是
+凭印象:`samba`/`nfs` 不在 `APPLY_SPEC` 里,容易被当成"没问题"跳过。
+
+单测 `t-deps.py` 新增 5 项断言:`act_apply` 与 `_share_apply_file` 都必须
+"先写临时文件 + `os.replace`",且不得再出现直接 `open(path,'w')` 覆写。
+
+## 不是 bug 的两处(记下来免得下次重查)
+
+- **`samba` / `nfs` 不在 `APPLY_SPEC`**:它们走 `act_share` 自己的 plan /
+  写盘 / 重启逻辑,不经 `act_apply`,这是刻意的设计而非遗漏。
+- **`nfs` / `samba` 的写盘不拍快照**:与 `act_apply` 的"应用前自动快照"不同,
+  共享配置改动量小且可人工回滚,未纳入快照策略。
+
+## 待你决定
+
+- **是否 `git init`**:项目当前不是 git 仓库。要不要我建立仓库并做首次提交
+  (含 `.gitignore`:排除 `__pycache__` / `_dev` 产物 / 快照)?
diff --git a/docs/06-新手向导与全项目复查报告.md b/docs/06-新手向导与全项目复查报告.md
new file mode 100644
index 0000000..eb8f777
--- /dev/null
+++ b/docs/06-新手向导与全项目复查报告.md
@@ -0,0 +1,178 @@
+# 新手向导(#84)与全项目复查报告
+
+日期:2026-09-30 目标机:`debian-primaryrouter`(192.168.7.3)
+
+---
+
+## 一、新手向导(任务 A)
+
+### 位置与形态
+
+**菜单:概览 → 新手向导**(概览组第一位,`needSave: false`)。
+
+选择理由:新手打开 Web 后台的第一眼就该看见它。放进「系统」或「工具」组都要先  
+知道去哪找,就失去意义了。`needSave: false` 是因为向导每步自带「保存并生效」  
+按钮,页面底部再来一条全局动作条只会让人困惑「我点的保存到底存没存」。
+
+### 四步解决什么
+
+| 步 | 标题       | 解决的原始困惑          | 给的范例                                                      |
+| - | -------- | ---------------- | --------------------------------------------------------- |
+| 1 | 接上外网     | 宽带账号填什么?光猫哪个口?   | 账号 `051212345678`;标「**不是 Wi-Fi 密码,是宽带密码**」;接口选「光猫的 LAN 口」 |
+| 2 | 给内网发地址   | 地址池写什么?会不会和自己冲突? | 照着抄一段;起点建议 `本机前三段 + .100`(后端按真实 LAN IP 推,不写死)             |
+| 3 | 能打开网址    | 用哪家 DNS?填几个?     | 六家一键选;不知道选哪个就 `223.5.5.5`;一个不通自动换另一个                      |
+| 4 | 要不要 IPv6 | 开了能怎样?前缀填什么?     | `2408:8207:1234:5678::/64`;明确写「内网通告只能用 /64」               |
+
+### 关键设计决策
+
+1. **DNS 探测不说术语** —— `SERVFAIL` / `REFUSED` / 超时 / NXDOMAIN 全部翻译成  
+   人话(「这个 DNS 服务器拒绝回答」/「超时没回应,可能是运营商屏蔽了它」)。
+2. **IPv6 前缀强制 `/64`** —— RFC 4862 协议硬要求。填 `/80` 自动规范化**并说明  
+   原因**;关闭 IPv6 时**保留已填前缀不删**。
+3. **第 1 步顺带配好另外三样** —— 写 WAN 口的同一次操作里打开 IPv4 转发、  
+   masquerade、MSS clamp。只拨号不打 NAT 是「拨上号但上不了网」,新手无法自诊断。
+4. **地址池校验按数值比** —— `'192.168.1.9' > '192.168.1.100'` 字符串比是 True,  
+   按字符串比会把合法地址池判成非法。
+5. **报错能直接指导下一步** —— 「圈进本机自己」→ `会自己和自己抢 IP,请把起点往后挪`;  
+   「跨网段」→ `前三段必须和本机一致`。
+6. **拨号独立且必须二次确认** —— 保存 ≠ 拨号;要手打「拨号」两字;页面明示  
+   「会短暂中断现有网络」。**半成功不回滚**(前面成功的步骤保持生效)。
+
+### 架构约束
+
+向导**不新增任何配置能力**,全部走 `act_apply({'live': True})` 这条唯一通道,  
+没有第二套落盘逻辑。后端 `act_wizard`(`op` = `probe` / `apply_step` / `dial`),  
+路由 `/api/wizard`(超时 dial 120s / apply_step 110s / probe 60s)。
+
+---
+
+## 二、测试结果
+
+| 项目                                 | 结果                               |
+| ---------------------------------- | -------------------------------- |
+| `_dev/t-wizard.py` 单测              | **160 / 0** ✔                    |
+| `_dev/live-wizard.py` 真机验收         | **75 / 0** ✔                     |
+| `_dev/live-wizard-verify.py` 部署后冒烟 | **31 / 0** ✔                     |
+| 全量单测(39 套)                         | 全部通过 ✔                           |
+| 前端三查                               | 43 视图 / 0 缺失映射;渲染 0 异常;手机 26 项 ✔ |
+| `py_compile` + `node --check`      | 全绿 ✔                             |
+
+真机冒烟关键结果:
+
+```
+探测到 LAN 接口 ens18 / IP 192.168.7.3(与配置库一致)     ✔
+地址池预设 192.168.7.100 - 192.168.7.200                ✔
+三种上网方式齐全 + 每种都有说明与适用场景                  ✔
+DNS 六家预设,公共 DNS 都带地址,每家都有人话说明            ✔
+地址池圈进本机自己 → 拒绝                                 ✔
+地址池跨网段 → 拒绝                                      ✔
+PPPoE 缺账号 → 拒绝;未知步骤 → 拒绝                       ✔
+拨号不带确认 → 拒绝(防止误断网)                          ✔
+```
+
+---
+
+## 三、全项目复查(任务 B)
+
+### 发现并修复的 2 个真 bug
+
+#### bug ①:`docker-compose-v2` 是 Ubuntu 包名
+
+Debian 13 里叫 `docker-compose`(2.26.1)。**后果比"装不上"严重**:  
+`apt-get install` 命令行里只要有一个包名查不到就**整条命令中止**,  
+同命令里其它正常的包一个都不装。实测:
+
+```
+apt-get install --dry-run zip unzip 不存在的包名zzz
+→ E: 无法定位软件包 不存在的包名zzz     (zip / unzip 也没装)
+```
+
+一个包名写错,整个「一键安装依赖」按钮全废。
+
+**修复**:
+
+- 新增 `_pkgs_available()` —— 用 `apt-cache show` 过滤出本机真有的包名
+- `act_depcheck` 安装前过滤;过滤后为空时给明确原因(提示先 `apt-get update`)
+- `DOCKER_PKGS` / `DEPS` 表保留双候选(跨发行版兼容)
+- `control` / `app.js` / `build-deb.sh` 里用户可见的包名全部改对
+- `_dev/t-deps.py` 新增 10 项断言钉死
+
+#### bug ②:`_share_apply_file` 写 SMB / NFS 配置不是原子写
+
+原实现 `with open(path, 'w')` 会**先把原文件截断成 0 字节**再逐块写入。  
+两步之间如果 `smbd` / `nfs-server` 恰好 reload 或掉电,读到的是半截配置 ——  
+轻则服务起不来,**重则按残缺的 share 定义把目录暴露出去**。
+
+**修复**:改成与 `act_apply` 同一套原子写(同目录临时文件 → `os.replace`),  
+并补上失败时清理残留临时文件。
+
+**为什么之前没发现**:`act_apply` 走 `os.replace`,而 SMB/NFS 走 `act_share`  
+自己的写盘逻辑 —— 两条路径只查了一条。这也是为什么"模块联动"必须按表比对  
+而不是凭印象:`samba`/`nfs` 不在 `APPLY_SPEC` 里,很容易被当成"没问题"跳过。
+
+`_dev/t-deps.py` 新增 5 项断言:`act_apply` 与 `_share_apply_file` 都必须  
+"先写临时文件 + `os.replace`",且不得再出现直接 `open(path,'w')` 覆写。
+
+### 各检查项结论
+
+| 检查项          | 结论                                                                              |
+| ------------ | ------------------------------------------------------------------------------- |
+| 依赖关系(70 个包名) | 逐个 `apt-cache show` 核对 → **0 缺失**                                               |
+| 模块联动         | `APPLY_SPEC` / `CONFIG_ONLY_MODULES` / `RENDERERS` / `ALIASES` 四表比对一致;别名归一化后无缺口 |
+| 附属功能         | 9 个 systemd 单元名全局一致                                                             |
+| UI / 布局 / 字体 | 菜单 43 项;字体全系统栈(无外部 Web Font);字号层级合理                                             |
+| git          | **项目不是 git 仓库**(需你决定是否 `git init`)                                              |
+| deb 编译       | **实际构建成功**(410K),元数据/控制段/数据段/维护脚本全部校验通过                                         |
+| deb 依赖可满足性   | `apt-get install -s` 模拟安装通过                                                     |
+| deb 内容洁净度    | 未夹带 `__pycache__` / `.pyc` / data / snapshots / certs / tmp                     |
+| docker 镜像    | `Dockerfile` 的 5 条 `COPY` 引用文件全部存在                                              |
+| doc          | 本轮补入新手向导(`docs/01` / `02` / `05`)                                               |
+| 原子写盘         | 修掉 1 处(见 bug ②),并加断言防回退                                                         |
+
+### 不是 bug 的两处(记下来免得下次重查)
+
+- **`samba` / `nfs` 不在 `APPLY_SPEC`** —— 走 `act_share` 自己的 plan / 写盘 /  
+  重启逻辑,刻意设计而非遗漏。
+- **`APPLY_SPEC` 里的 `ntp`/`pppoe`/`upnp` 看似"不在 RENDERERS"** —— 那是别名,  
+  归一化后完全对得上。
+
+---
+
+## 四、部署与红线复核
+
+已通过 `devtools/sync.sh` 全链路部署到 192.168.7.3(`DEPLOY_DONE`)。
+
+| 项目           | 实测值                     | 判定       |
+| ------------ | ----------------------- | -------- |
+| 默认网关         | `192.168.7.2 dev ens18` | 未接管路由 ✔  |
+| ens18 地址     | `192.168.7.3/24`        | 未变 ✔     |
+| RealVNC 5900 | 监听中(2 条)                | 未触碰 ✔    |
+| 救援通道         | `inactive`              | 默认关闭 ✔   |
+| dnsmasq      | `inactive`              | 未抢跑 ✔    |
+| radvd        | `inactive`              | 未抢跑 ✔    |
+| nft 规则行数     | `0`                     | 未接管防火墙 ✔ |
+| 构建保护模式       | 不存在                     | 非保护模式 ✔  |
+| drouter-web  | `active`                | 正常 ✔     |
+
+### ⚠️ 关于 `ip_forward = 1`
+
+它与早前记录的 `ip_forward = 0` 不一致,**来源是 `#70 内核转发与加速` 前几轮  
+验收时保存过 `fwd_v4 = 1`**,写进了 `/etc/sysctl.d/99-drouter.conf`  
+(mtime 2026-09-30 15:44)。不是本轮改动导致,也不是异常。
+
+**安全性已确认**:`nft` 规则集为空(**没有 masquerade**),本机不具备真实的  
+路由转发能力,局域网未受影响。
+
+---
+
+## 五、待你决定
+
+**是否 `git init`?** 项目当前不是 git 仓库(`fatal: not a git repository`,  
+`find` 也找不到 `.git`)。可选项:
+
+1. **建立仓库 + 首次提交**(推荐)—— 我会写 `.gitignore` 排除 `__pycache__`、  
+   `_dev` 产物、快照、证书;把 41 个单测 + 前端三查挂成提交前检查;  
+   以后每轮改动都有 diff 可回溯。
+2. **不建仓库** —— 保持现状,靠 `devtools/sync.sh` 的预检链 + `docs/` 报告  
+   做质量保障(当前也是这么运作的,没有出过问题)。
+3. **你另有远端仓库** —— 告诉我地址与分支,我配好 remote 再提交。
diff --git a/docs/screenshots/01-dash.svg b/docs/screenshots/01-dash.svg
new file mode 100644
index 0000000..6f48c22
--- /dev/null
+++ b/docs/screenshots/01-dash.svg
@@ -0,0 +1,92 @@
+
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  Drouter
+  Debian 13 路由管理
+
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+  
+  
+  系统概览
+  
+  
+  A
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+    
+    【占位图】系统概览
+    CPU / 内存 / 磁盘 / 负载 / 温度 + 实时网速与历史流量
+    真实界面请以安装后为准 · 替换 docs/screenshots/01-dash.svg 即可
+  
+
diff --git a/docs/screenshots/02-iface.svg b/docs/screenshots/02-iface.svg
new file mode 100644
index 0000000..7d04e27
--- /dev/null
+++ b/docs/screenshots/02-iface.svg
@@ -0,0 +1,92 @@
+
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  Drouter
+  Debian 13 路由管理
+
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+  
+  
+  网卡与桥接
+  
+  
+  A
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+    
+    【占位图】网卡与桥接
+    以 MAC 为主键管理网卡,标注备注 / 角色 / 链路 / 速率 / MTU
+    真实界面请以安装后为准 · 替换 docs/screenshots/02-iface.svg 即可
+  
+
diff --git a/docs/screenshots/03-fw4.svg b/docs/screenshots/03-fw4.svg
new file mode 100644
index 0000000..cc146de
--- /dev/null
+++ b/docs/screenshots/03-fw4.svg
@@ -0,0 +1,92 @@
+
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  Drouter
+  Debian 13 路由管理
+
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+  
+  
+  防火墙 IPv4
+  
+  
+  A
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+    
+    【占位图】防火墙 IPv4
+    nftables 规则可视化编辑,支持链 / 表 / 规则级操作
+    真实界面请以安装后为准 · 替换 docs/screenshots/03-fw4.svg 即可
+  
+
diff --git a/docs/screenshots/04-qos.svg b/docs/screenshots/04-qos.svg
new file mode 100644
index 0000000..76b023c
--- /dev/null
+++ b/docs/screenshots/04-qos.svg
@@ -0,0 +1,92 @@
+
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  Drouter
+  Debian 13 路由管理
+
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+  
+  
+  智能限速 QoS
+  
+  
+  A
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+    
+    【占位图】智能限速 QoS
+    基于 IFB 的入向整形 + 分类限速,规则实时下发
+    真实界面请以安装后为准 · 替换 docs/screenshots/04-qos.svg 即可
+  
+
diff --git a/docs/screenshots/05-flowlog.svg b/docs/screenshots/05-flowlog.svg
new file mode 100644
index 0000000..ad12eff
--- /dev/null
+++ b/docs/screenshots/05-flowlog.svg
@@ -0,0 +1,92 @@
+
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  Drouter
+  Debian 13 路由管理
+
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+  
+  
+  连接与流日志
+  
+  
+  A
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+    
+    【占位图】连接与流日志
+    conntrack 连接跟踪 + 实时流日志滚动查看
+    真实界面请以安装后为准 · 替换 docs/screenshots/05-flowlog.svg 即可
+  
+
diff --git a/docs/screenshots/06-webshell.svg b/docs/screenshots/06-webshell.svg
new file mode 100644
index 0000000..6e4eb40
--- /dev/null
+++ b/docs/screenshots/06-webshell.svg
@@ -0,0 +1,92 @@
+
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  Drouter
+  Debian 13 路由管理
+
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+  
+  
+  Web 终端
+  
+  
+  A
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+    
+    【占位图】Web 终端
+    浏览器内直接操作 shell,支持多会话
+    真实界面请以安装后为准 · 替换 docs/screenshots/06-webshell.svg 即可
+  
+
diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh
new file mode 100644
index 0000000..d601690
--- /dev/null
+++ b/packaging/build-deb.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+# =============================================================================
+# 构建 drouter 的 .deb 安装包(需在 Debian/Ubuntu 上执行,用到 dpkg-deb)
+#
+#   bash packaging/build-deb.sh [版本号]
+#
+# 产物:dist/drouter_<版本>_all.deb
+#
+# 说明:.deb 本身不内嵌依赖的二进制(那是 apt 的职责),control 里用 Depends
+#       声明完整依赖清单,安装时由 apt 自动补齐。若目标机无外网,可先用
+#       scripts/make-offline-deps.sh 把依赖预下载成离线包,再离线安装。
+# =============================================================================
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+VERSION="${1:-1.0.0}"
+DIST="$HERE/dist"
+PKG="drouter"
+STAGE="$DIST/${PKG}_${VERSION}_all"
+
+command -v dpkg-deb >/dev/null 2>&1 || { echo "需要 dpkg-deb(请在 Debian/Ubuntu 上执行)"; exit 1; }
+
+echo "== 1/6 清理并准备目录 =="
+rm -rf "$STAGE"
+mkdir -p "$STAGE/DEBIAN" \
+         "$STAGE/opt/drouter/backend" \
+         "$STAGE/opt/drouter/web" \
+         "$STAGE/opt/drouter/docs" \
+         "$STAGE/opt/drouter/scripts" \
+         "$STAGE/usr/local/bin" \
+         "$STAGE/lib/systemd/system"
+mkdir -p "$DIST"
+
+echo "== 2/6 复制程序文件 =="
+install -m 0644 "$HERE"/backend/*.py          "$STAGE/opt/drouter/backend/"
+install -m 0644 "$HERE"/web/*                 "$STAGE/opt/drouter/web/"
+install -m 0644 "$HERE"/docs/*                "$STAGE/opt/drouter/docs/"
+install -m 0755 "$HERE"/scripts/*.sh          "$STAGE/opt/drouter/scripts/"
+# 一键运维命令:直接随包提供,postinst 里再做一次兜底安装
+install -m 0755 "$HERE/scripts/drouter-ctl.sh" "$STAGE/usr/local/bin/drouter-ctl"
+
+# systemd 单元必须随包发。踩过的坑:原先 .deb 里**一个单元都没有** ——
+# 单元只在 scripts/deploy.sh 里用 heredoc 写,所以「用 deploy.sh 部署」能用,
+# 但「用 .deb 装」的用户装完是一堆文件、没有任何服务,必须再手工跑一遍
+# deploy.sh 才行。对 Release 用户来说这是致命的。
+# 注意:drouter-logd.service / drouter-qos.service **不在这里** ——
+# 它们的内容随用户的日志/限速配置变化,由 helper 在保存配置时动态生成,
+# 静态打进包里反而会写错默认值。
+install -m 0644 "$HERE"/packaging/deb/units/*.service "$STAGE/lib/systemd/system/"
+install -m 0644 "$HERE"/packaging/deb/units/*.timer   "$STAGE/lib/systemd/system/"
+
+echo "== 3/6 写入 DEBIAN 元数据 =="
+# 注:DEBIAN/control 是 deb822 格式,不保证支持 "#" 注释行 —— 需要说明的地方
+# 一律写在本脚本里,不要写进 control,否则某些 dpkg 工具会解析失败。
+#
+# 依赖策略:apt 默认会装 Recommends,所以 Recommends 里只放「不自带守护进程」
+# 的工具。以下三类一律降级到 Suggests,由用户在界面上按需逐项安装:
+#   * smartmontools —— 装完自动拉起 smartd 常驻;
+#   * samba / nfs-kernel-server —— 装完会启用 smbd / nfs-server 文件共享服务;
+#   * docker.io / docker-compose —— 装完会启用 docker 守护,4GB 机器上很重。
+#     注意包名:Debian 13 是 docker-compose(提供 compose v2 插件),
+#     docker-compose-v2 是 Ubuntu 的叫法,在 Debian 上装不到。
+# 格式化工具(exfatprogs / ntfs-3g / dosfstools / xfsprogs / btrfs-progs /
+# f2fs-tools)和 etherwake、zip 等纯工具没有守护进程,放在 Recommends 即可。
+sed "s/__VERSION__/$VERSION/" "$HERE/packaging/deb/control" > "$STAGE/DEBIAN/control"
+install -m 0755 "$HERE/packaging/deb/postinst" "$STAGE/DEBIAN/postinst"
+install -m 0755 "$HERE/packaging/deb/prerm"    "$STAGE/DEBIAN/prerm"
+install -m 0755 "$HERE/packaging/deb/postrm"   "$STAGE/DEBIAN/postrm"
+# md5sums 让 dpkg 能检测文件被改动
+( cd "$STAGE" && find . -path ./DEBIAN -prune -o -type f -print \
+    | sed 's|^\./||' | xargs -r md5sum > DEBIAN/md5sums )
+
+echo "== 4/6 校验 =="
+# 后端 7 个模块必须齐全,缺一个上线后就是某个功能打不开
+for f 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; do
+  [ -f "$STAGE/opt/drouter/backend/$f" ] || { echo "✘ 缺少 $f"; exit 1; }
+  python3 -c 'import ast,sys; ast.parse(open(sys.argv[1],encoding="utf-8").read())' \
+    "$STAGE/opt/drouter/backend/$f"
+done
+[ -f "$STAGE/opt/drouter/web/app.js" ] || { echo "✘ 缺少 app.js"; exit 1; }
+echo "  后端模块 8 个、语法全部通过"
+
+# systemd 单元齐全性:少一个就是「某项功能装完不工作」
+for u in drouter-web.service drouter-helpd.service drouter-shelld.service \
+         drouter-snapshot.service drouter-snapshot.timer drouter-rescue.service; do
+  [ -f "$STAGE/lib/systemd/system/$u" ] || { echo "✘ 缺少 systemd 单元 $u"; exit 1; }
+done
+echo "  systemd 单元 6 个齐全"
+
+echo "== 5/6 构建 =="
+dpkg-deb --build --root-owner-group "$STAGE" "$DIST/${PKG}_${VERSION}_all.deb"
+
+echo "== 6/6 产物 =="
+ls -lh "$DIST/${PKG}_${VERSION}_all.deb"
+dpkg-deb -I "$DIST/${PKG}_${VERSION}_all.deb" | sed -n '1,14p'
+echo ""
+echo "安装:sudo apt install ./dist/${PKG}_${VERSION}_all.deb"
+echo "卸载:sudo apt remove drouter      (保留配置)"
+echo "      sudo apt purge  drouter      (连配置一起删)"
diff --git a/packaging/build-docker.sh b/packaging/build-docker.sh
new file mode 100644
index 0000000..c0613f7
--- /dev/null
+++ b/packaging/build-docker.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+# =============================================================================
+# 构建 drouter 的 Docker 镜像
+#
+#   bash packaging/build-docker.sh [tag]
+#
+# 产物:drouter:
+# 注意:Dockerfile 里 COPY 的是相对路径,所以构建上下文必须是项目根目录。
+# =============================================================================
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+TAG="${1:-1.0.0}"
+
+command -v docker >/dev/null 2>&1 || { echo "需要 docker"; exit 1; }
+
+echo "== 1/3 构建前自测(避免把坏代码打进镜像)=="
+python3 - <<'PY'
+import ast, glob
+bad = 0
+for f in sorted(glob.glob('backend/*.py')):
+    try:
+        ast.parse(open(f, encoding='utf-8').read()); print('  OK:', f)
+    except Exception as e:
+        print('  FAIL:', f, e); bad += 1
+raise SystemExit(1 if bad else 0)
+PY
+command -v node >/dev/null 2>&1 && node --check web/app.js && echo "  OK: web/app.js"
+
+echo "== 2/3 构建镜像 drouter:$TAG =="
+docker build -f "$HERE/packaging/docker/Dockerfile" -t "drouter:$TAG" "$HERE"
+
+echo "== 3/3 产物 =="
+docker images drouter:"$TAG"
+echo ""
+cat <<'RUN'
+运行示例:
+  docker run -d --name drouter --restart unless-stopped \
+    --network host \
+    --cap-add NET_ADMIN --cap-add NET_RAW \
+    -e DROUTER_WEB_PORT=8443 \
+    -v drouter-data:/opt/drouter/data \
+    -v drouter-etc:/etc/drouter \
+    drouter:1.0.0
+
+说明:
+  --network host  让容器直接看到宿主机网卡(路由类功能的前提)
+  NET_ADMIN       允许操作 nftables / 网卡 / 路由表
+  默认账号 admin / admin123,登录后请立即修改
+RUN
diff --git a/packaging/build-offline-bundle.sh b/packaging/build-offline-bundle.sh
new file mode 100644
index 0000000..3a5bd81
--- /dev/null
+++ b/packaging/build-offline-bundle.sh
@@ -0,0 +1,174 @@
+#!/bin/bash
+# =============================================================================
+# 构建「自包含离线安装包」——一个文件装完,不需要任何外网。
+#
+#   bash packaging/build-offline-bundle.sh [版本号] [输出目录]
+#
+# 产物:dist/drouter-<版本>-offline-amd64.tar.gz
+#
+# 为什么要有这个(而不是让用户自己拼 deb + 依赖):
+#   .deb 的 Depends 只声明依赖,真正把依赖装上去是 apt 的活。目标机没外网时
+#   apt 无源可用,用户得自己想办法把 200+ 个 .deb 凑齐 —— 这几乎不可能做对
+#   (传递依赖、Essential 包、版本匹配,任何一环错了装不上)。
+#   所以这里把「本体 + 全部依赖 + 安装脚本」打成一个 tar.gz,
+#   拷过去解包、跑一条命令就完事。
+#
+# 包内结构:
+#   drouter-<版本>-offline-amd64/
+#   ├── install.sh                  一条命令装完
+#   ├── README-离线安装.md           给不熟悉的人看的步骤说明
+#   ├── SHA256SUMS                  每个 deb 的校验和
+#   ├── drouter_<版本>_all.deb       本体(含 systemd 单元)
+#   └── debs/                       全部依赖(含 Essential 与传递依赖)
+#       ├── *.deb
+#       └── Packages + Packages.gz  本地仓库索引(**必须在 debs/ 里**,
+#                                   否则 apt 找不到会静默退化成 dpkg -i)
+# =============================================================================
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+VERSION="${1:-1.0.0}"
+OUTDIR="${2:-$HERE/dist}"
+ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
+
+NAME="drouter-${VERSION}-offline-${ARCH}"
+STAGE="$OUTDIR/$NAME"
+BUNDLE="$OUTDIR/${NAME}.tar.gz"
+
+command -v dpkg-deb >/dev/null 2>&1 || { echo "需要 dpkg-deb(请在 Debian/Ubuntu 上执行)"; exit 1; }
+
+echo "======================================================================"
+echo "构建自包含离线安装包:$NAME"
+echo "======================================================================"
+
+echo ""
+echo "== 1/5 构建 drouter 本体 .deb =="
+bash "$HERE/packaging/build-deb.sh" "$VERSION" >/dev/null
+DEB="$HERE/dist/drouter_${VERSION}_all.deb"
+[ -f "$DEB" ] || { echo "✘ 没生成 $DEB"; exit 1; }
+echo "  $(basename "$DEB")  $(du -h "$DEB" | cut -f1)"
+
+echo ""
+echo "== 2/5 生成离线依赖库 =="
+# 注意:make-offline-deps.sh 直接往 $STAGE 里写(debs/ + install.sh),
+# 不需要中间目录。早期版本这里写过 `mv $OUTDIR/offline-deps/*` 是多余的,
+# 那段在目录不存在时会静默跳过,容易让人以为「搬过了」。
+rm -rf "$STAGE"
+bash "$HERE/scripts/make-offline-deps.sh" "$STAGE" 2>&1 | sed 's/^/  /' | tail -8
+
+NDEB=$(ls -1 "$STAGE"/debs/*.deb 2>/dev/null | wc -l)
+[ "$NDEB" -gt 50 ] || { echo "✘ 依赖包只有 $NDEB 个,明显不对"; exit 1; }
+
+# 索引必须在 debs/ 里,否则 apt 找不到会静默退化成 dpkg -i 乱装
+if [ ! -f "$STAGE/debs/Packages" ] && [ ! -f "$STAGE/debs/Packages.gz" ]; then
+  echo "✘ debs/ 里没有 Packages 索引 —— 离线安装会退化成 dpkg -i"; exit 1
+fi
+
+# 硬性依赖抽查:这几个缺任何一个,干净目标机一定装不上
+for must in libssl3t64 hostname tar dash diffutils libc-bin liblz4-1 libapt-pkg7.0; do
+  ls "$STAGE/debs/${must}"_*.deb >/dev/null 2>&1 || \
+  ls "$STAGE/debs/${must}t64"_*.deb >/dev/null 2>&1 || \
+  { echo "✘ 依赖里缺少关键包:$must"; exit 1; }
+done
+echo "  依赖包 $NDEB 个,索引与关键包校验通过"
+
+echo ""
+echo "== 3/5 放入本体 + 说明 =="
+cp -f "$DEB" "$STAGE/"
+# 把 make-offline-deps.sh 生成的 install.sh 换成 bundle 版(更完整、带自检)
+cp -f "$HERE/packaging/offline-install.sh" "$STAGE/install.sh"
+chmod +x "$STAGE/install.sh"
+
+cat > "$STAGE/README-离线安装.md" <:8443/\`,默认账号 \`admin\` / \`admin123\`。
+
+## 这个包里有什么
+
+| 文件 | 说明 |
+|---|---|
+| \`drouter_${VERSION}_all.deb\` | Drouter 本体 |
+| \`debs/\` | 全部依赖的 .deb,**含传递依赖与 Essential 包** |
+| \`debs/Packages.gz\` | 本地仓库索引,让 apt 自己算依赖顺序 |
+| \`install.sh\` | 安装脚本 |
+
+依赖是完整的:从直接依赖(nftables / dnsmasq / radvd / ppp …)
+一路到最底层(libc6 / zlib1g / dash / diffutils),全部在里面。
+不需要目标机有任何网络。
+
+## 检查完整性
+
+\`\`\`bash
+cd /tmp/drouter-offline
+sha256sum -c SHA256SUMS        # 校验每个 deb 没损坏
+ls debs/*.deb | wc -l          # 应该有两百多个
+\`\`\`
+
+## 如果装不上
+
+1. **先看硬件够不够**:\`free -m\` / \`df -h /\`。内存 < 1G 或根分区 < 3G 会失败。
+2. **确认系统版本**:\`cat /etc/os-release\`,必须是 Debian 13 (trixie) amd64。
+   别的版本依赖版本对不上。
+3. **看具体报错**:\`bash install.sh 2>&1 | tee /tmp/install.log\`,
+   然后按 log 里的第一条 \`E:\` 定位。
+
+## 卸载
+
+\`\`\`bash
+sudo apt remove drouter      # 删程序,保留配置
+sudo apt purge  drouter      # 连配置一起删
+\`\`\`
+EOF
+
+echo ""
+echo "== 4/5 生成校验和 =="
+# 先把构建期中间产物清掉再算校验和,免得它们被算进去又被打包
+rm -rf "$STAGE/.solveroot" "$STAGE/.closure" "$STAGE/.allpkgs" \
+       "$STAGE/.sim.txt" "$STAGE/.sim.err" 2>/dev/null || true
+( cd "$STAGE" && find . -name '*.deb' -type f | sed 's|^\./||' \
+    | sort | xargs -r sha256sum > SHA256SUMS )
+echo "  SHA256SUMS: $(wc -l < "$STAGE/SHA256SUMS") 个文件"
+
+# 打包前的最终结构校验:只允许出现下面这些顶层条目。
+# 出现别的东西(隐藏目录、临时文件)说明前面的清理有漏,宁可在这里报错,
+# 也不要让用户解包后看到一堆莫名其妙的东西。
+BAD=$(cd "$STAGE" && ls -A | grep -vE '^(debs|drouter_.*_all\.deb|install\.sh|README-离线安装\.md|SHA256SUMS)$' || true)
+if [ -n "$BAD" ]; then
+  echo "✘ 离线包里有不该出现的顶层条目:"
+  echo "$BAD" | sed 's/^/    /'
+  exit 1
+fi
+echo "  顶层结构校验通过"
+
+echo ""
+echo "== 5/5 打包 =="
+# 用 tar.gz 而不是 zip:Linux 上天然可用,且保留可执行位
+( cd "$OUTDIR" && tar czf "${NAME}.tar.gz" "$NAME" )
+rm -rf "$STAGE"
+
+echo ""
+echo "======================================================================"
+echo "✅ 自包含离线安装包已生成"
+echo ""
+echo "  文件:$BUNDLE"
+echo "  大小:$(du -h "$BUNDLE" | cut -f1)"
+echo ""
+echo "  上传到 GitHub Release 时,只传这一个文件即可 ——"
+echo "  目标机解包后跑 install.sh 就能在完全无外网的环境装好。"
+echo "======================================================================"
diff --git a/packaging/deb/control b/packaging/deb/control
new file mode 100644
index 0000000..4bc5657
--- /dev/null
+++ b/packaging/deb/control
@@ -0,0 +1,57 @@
+Package: drouter
+Version: __VERSION__
+Section: net
+Priority: optional
+Architecture: all
+Maintainer: 火麒麟 (ajeef) 
+Homepage: https://github.com/liuzhuohua
+Description: Debian 13 主路由管理系统(Web 管理面板)
+ Drouter 是一套面向 Debian 13 的软路由管理系统,提供 Web 管理界面:
+ WAN/LAN 接入、PPPoE 多拨、QoS、DPI 识别、访问控制与家长控制、
+ IPv6、端口转发与 DMZ、DDNS、SMB/NFS 文件共享、Docker 与 Compose 管理、
+ 连接跟踪与统一日志、防火墙日志、自动快照与回滚、紧急救援通道、主题之家。
+ .
+ 设计上严格区分「保存配置」与「应用到系统」,任何下发前都可预览渲染结果;
+ 默认不会自动启动 DHCP/DNS 服务,避免与现有局域网冲突。
+Depends: python3 (>= 3.9),
+ python3-venv | python3-minimal,
+ nftables,
+ dnsmasq,
+ radvd,
+ ppp,
+ pppoe,
+ pppoeconf,
+ miniupnpd,
+ chrony,
+ ethtool,
+ iperf3,
+ mtr-tiny,
+ traceroute,
+ dnsutils,
+ conntrack,
+ bridge-utils,
+ iproute2,
+ sqlite3,
+ logrotate,
+ rsyslog,
+ curl,
+ wget,
+ procps,
+ sudo,
+ openssl,
+ ipset,
+ kmod,
+ systemd,
+ adduser,
+ ca-certificates,
+ dhcpcd-base,
+ util-linux,
+ coreutils,
+ hostname,
+ tar
+Recommends: tcpdump, jq, htop, iotop, nvme-cli, lm-sensors, dmidecode,
+ e2fsprogs, exfatprogs, ntfs-3g, dosfstools, xfsprogs, btrfs-progs, f2fs-tools,
+ usbutils, etherwake, zip, unzip, p7zip-full, iputils-ping, vim-tiny
+Suggests: smartmontools, suricata, xtables-addons-common,
+ samba, samba-common-bin, nfs-kernel-server, docker.io, docker-compose,
+ cups, cups-client, snmpd, snmp
diff --git a/packaging/deb/postinst b/packaging/deb/postinst
new file mode 100644
index 0000000..f1aba9f
--- /dev/null
+++ b/packaging/deb/postinst
@@ -0,0 +1,92 @@
+#!/bin/bash
+# drouter —— 安装后配置
+#
+# 安全红线:本脚本只启动 drouter-web(管理后台)与 drouter-snapshot.timer(快照)。
+# 绝不启动 dnsmasq / radvd / miniupnpd —— 它们会抢 53/67/546 端口或向局域网发 DHCP,
+# 在已经有路由器的网络里会立刻造成冲突。
+set -e
+export DEBIAN_FRONTEND=noninteractive
+
+case "$1" in
+  configure)
+    # 进入构建保护模式:让后端在此期间拒绝一切可能改网络的动作
+    mkdir -p /etc/drouter
+    printf 'build' > /etc/drouter/BUILD_MODE
+
+    # 阻止 apt/包管理期间服务自动启动
+    printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
+    chmod +x /usr/sbin/policy-rc.d
+
+    # deploy.sh 会以 /opt/drouter 为「源码目录」把文件安装到位(幂等,可重复执行)
+    if [ -f /opt/drouter/scripts/deploy.sh ]; then
+      DROUTER_SRC=/opt/drouter bash /opt/drouter/scripts/deploy.sh || {
+        echo "drouter: 部署失败,请查看上面的输出" >&2
+      }
+    fi
+
+    # 安装一键运维命令
+    if [ -f /opt/drouter/scripts/drouter-ctl.sh ]; then
+      install -m 0755 /opt/drouter/scripts/drouter-ctl.sh /usr/local/bin/drouter-ctl
+    fi
+
+    rm -f /usr/sbin/policy-rc.d
+    rm -f /etc/drouter/BUILD_MODE
+
+    systemctl daemon-reload || true
+
+    # 启用/启动服务。为什么不能只靠上面的 deploy.sh:
+    # deploy.sh 是「源码部署」路径的脚本,.deb 里虽然也带了它,但一旦它
+    # 执行失败(或将来被裁掉),单元文件就只躺在 /lib/systemd/system 里没人管,
+    # 用户装完发现「一个服务都没起来」。这里对**随包发布的每个单元**都显式兜底。
+    #
+    # 安全红线不变:只碰 drouter 自己的单元。
+    # dnsmasq / radvd / miniupnpd / dhcpcd 一律不 enable、不 start。
+    # drouter-rescue.service 是紧急救援通道,默认关闭,绝不自动启动。
+    if command -v systemctl >/dev/null 2>&1 && systemctl is-system-running >/dev/null 2>&1; then
+      systemctl enable drouter-web >/dev/null 2>&1 || true
+      systemctl restart drouter-web >/dev/null 2>&1 || true
+
+      # 常驻守护:让面板请求免去每次 spawn python3+sudo 的固定开销
+      systemctl enable drouter-helpd >/dev/null 2>&1 || true
+      systemctl restart drouter-helpd >/dev/null 2>&1 || true
+
+      # Web 终端守护:预启动,用户点「终端」时能立刻进 shell
+      systemctl enable drouter-shelld >/dev/null 2>&1 || true
+      systemctl restart drouter-shelld >/dev/null 2>&1 || true
+
+      # 自动快照:默认开启(每 6 小时)
+      systemctl enable drouter-snapshot.timer >/dev/null 2>&1 || true
+      systemctl start  drouter-snapshot.timer >/dev/null 2>&1 || true
+
+      # 救援通道:刻意**不** enable、**不** start(需用户在界面显式开启)
+    else
+      echo "drouter: 未检测到运行中的 systemd,跳过服务启用。" >&2
+      echo "drouter: 请手动执行 —— systemctl enable --now drouter-web drouter-helpd drouter-shelld" >&2
+    fi
+
+    # 复核红线:这几个服务必须是「未启用」状态
+    for s in dnsmasq radvd miniupnpd; do
+      if systemctl is-enabled "$s" 2>/dev/null | grep -qx enabled; then
+        echo "drouter: 注意 —— $s 处于 enabled 状态,为避免与现有网络冲突请自行确认" >&2
+      fi
+    done
+
+    PORT=8443
+    [ -f /etc/drouter/web-port ] && PORT=$(head -1 /etc/drouter/web-port)
+    echo ""
+    echo "  drouter 安装完成"
+    echo "  管理地址:https://<本机 LAN IP>:${PORT}/"
+    echo "  默认账号:admin / admin123(请登录后立即修改)"
+    echo "  一键运维:drouter-ctl status | repair | uninstall"
+    echo ""
+    ;;
+  abort-upgrade|abort-remove|abort-deconfigure)
+    ;;
+  *)
+    echo "postinst called with unknown argument \`$1'" >&2
+    exit 1
+    ;;
+esac
+
+#DEBHELPER#
+exit 0
diff --git a/packaging/deb/postrm b/packaging/deb/postrm
new file mode 100644
index 0000000..cc2ab2b
--- /dev/null
+++ b/packaging/deb/postrm
@@ -0,0 +1,39 @@
+#!/bin/bash
+# drouter —— 卸载后清理
+#
+# 只有 purge 才删除配置、快照与日志。普通 remove 一律保留,
+# 避免用户辛苦配好的规则被误删(重装即可直接恢复)。
+set -e
+case "$1" in
+  purge)
+    rm -rf /etc/drouter
+    rm -rf /var/log/drouter
+    rm -rf /var/lib/drouter
+    rm -rf /run/drouter-helper
+    rm -f /etc/systemd/system/drouter-*.service /etc/systemd/system/drouter-*.timer
+    rm -f /etc/sudoers.d/drouter /etc/logrotate.d/drouter
+    rm -f /usr/local/bin/drouter-ctl
+    rm -f /etc/nftables.d/drouter*.nft
+    systemctl daemon-reload >/dev/null 2>&1 || true
+    if id drouter >/dev/null 2>&1; then
+      userdel drouter >/dev/null 2>&1 || true
+    fi
+    echo "drouter: 已彻底清除(含配置、快照与日志)"
+    ;;
+  remove)
+    # 普通卸载:保留 /etc/drouter 与 /var/log/drouter
+    rm -f /etc/systemd/system/drouter-*.service /etc/systemd/system/drouter-*.timer
+    rm -f /etc/sudoers.d/drouter
+    systemctl daemon-reload >/dev/null 2>&1 || true
+    echo "drouter: 已卸载;配置保留在 /etc/drouter,日志在 /var/log/drouter"
+    echo "        彻底清除请用:apt purge drouter"
+    ;;
+  upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
+    ;;
+  *)
+    echo "postrm called with unknown argument \`$1'" >&2
+    exit 1
+    ;;
+esac
+#DEBHELPER#
+exit 0
diff --git a/packaging/deb/prerm b/packaging/deb/prerm
new file mode 100644
index 0000000..039c2e2
--- /dev/null
+++ b/packaging/deb/prerm
@@ -0,0 +1,28 @@
+#!/bin/bash
+# drouter —— 卸载/升级前停止服务
+set -e
+case "$1" in
+  remove|upgrade|deconfigure)
+    # 必须覆盖**随包发布的每一个单元**。漏掉一个的后果:升级/卸载后
+    # 那个守护还在跑,而它指向的代码已经被换成新版本/删掉了 ——
+    # 典型症状是「卸载完 drouter-web 还在响应」或「旧守护 crash loop」。
+    # 末尾的 drouter-logd.timer / drouter-qos.service 是 helper 在运行时
+    # 动态生成的,这里一并停掉,不存在也不会报错。
+    for u in drouter-rescue drouter-web drouter-helpd drouter-shelld \
+             drouter-snapshot.timer drouter-snapshot.service \
+             drouter-logd.timer drouter-logd.service drouter-qos.service \
+             drouter-cleanup.timer; do
+      systemctl stop "$u"    >/dev/null 2>&1 || true
+      systemctl disable "$u" >/dev/null 2>&1 || true
+    done
+    systemctl daemon-reload >/dev/null 2>&1 || true
+    ;;
+  failed-upgrade)
+    ;;
+  *)
+    echo "prerm called with unknown argument \`$1'" >&2
+    exit 1
+    ;;
+esac
+#DEBHELPER#
+exit 0
diff --git a/packaging/deb/units/drouter-helpd.service b/packaging/deb/units/drouter-helpd.service
new file mode 100644
index 0000000..6dc555d
--- /dev/null
+++ b/packaging/deb/units/drouter-helpd.service
@@ -0,0 +1,28 @@
+[Unit]
+Description=drouter 特权动作常驻执行守护
+Documentation=file:/opt/drouter/docs/
+After=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=simple
+User=root
+Group=root
+# 刻意不用 RuntimeDirectory:见 drouter-shelld.service 里的说明。
+# /run/drouter 由守护自己 mkdir 并 chmod 0755(drouter-web 要能进目录连
+# helper.sock,而 socket 自身是 0660 root:drouter,权限并没有放宽)。
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-helpd.py
+Restart=on-failure
+RestartSec=2
+# 常驻 import 了整套 helper,比其它守护占得多;上限防内存泄漏拖垮路由器
+MemoryMax=300M
+# 故意不设 CPUQuota:它要跑格式化大硬盘这类子进程,限 CPU 会把这些操作拖慢
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=drouter-helpd
+ProtectHome=read-only
+PrivateTmp=false
+NoNewPrivileges=false
+
+[Install]
+WantedBy=multi-user.target
diff --git a/packaging/deb/units/drouter-rescue.service b/packaging/deb/units/drouter-rescue.service
new file mode 100644
index 0000000..fc4d30a
--- /dev/null
+++ b/packaging/deb/units/drouter-rescue.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=drouter 紧急救援通道(独立于 WAN/LAN 的应急恢复入口)
+After=network.target
+Wants=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=simple
+User=root
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-rescue.py
+Restart=always
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+NoNewPrivileges=false
+ProtectSystem=false
+
+[Install]
+WantedBy=multi-user.target
diff --git a/packaging/deb/units/drouter-shelld.service b/packaging/deb/units/drouter-shelld.service
new file mode 100644
index 0000000..c366040
--- /dev/null
+++ b/packaging/deb/units/drouter-shelld.service
@@ -0,0 +1,30 @@
+[Unit]
+Description=drouter Web 终端 PTY 会话守护
+Documentation=file:/opt/drouter/docs/
+After=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=simple
+User=root
+Group=root
+# 刻意不用 RuntimeDirectory=drouter:systemd 会在「最后一个声明它的单元停止」
+# 时把整个目录删掉重建。两个守护共用 /run/drouter,于是 restart helpd 的瞬间
+# systemd 重建目录,把 shelld 的 shell.sock 一起删了 —— shelld 依旧 active,
+# socket 却没了,用户点终端只看到「守护未运行」。改成两个守护各自 mkdir。
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-shelld.py
+Restart=on-failure
+RestartSec=3
+# 只是转字节流,用不了多少内存;上限是防异常输出把内存吃光
+MemoryMax=80M
+CPUQuota=15%
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=drouter-shelld
+# 安全加固:不碰系统目录、不给新特权(它本身就以 root 跑,靠 socket 0600 收口)
+ProtectHome=read-only
+PrivateTmp=false
+NoNewPrivileges=false
+
+[Install]
+WantedBy=multi-user.target
diff --git a/packaging/deb/units/drouter-snapshot.service b/packaging/deb/units/drouter-snapshot.service
new file mode 100644
index 0000000..56cfeb3
--- /dev/null
+++ b/packaging/deb/units/drouter-snapshot.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=drouter 自动配置快照
+After=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=oneshot
+User=root
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-snapshotd.py
+
+[Install]
+WantedBy=multi-user.target
diff --git a/packaging/deb/units/drouter-snapshot.timer b/packaging/deb/units/drouter-snapshot.timer
new file mode 100644
index 0000000..c0a4cf5
--- /dev/null
+++ b/packaging/deb/units/drouter-snapshot.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=drouter 自动快照定时器
+
+[Timer]
+OnBootSec=10min
+OnUnitActiveSec=6h
+Persistent=true
+Unit=drouter-snapshot.service
+
+[Install]
+WantedBy=timers.target
diff --git a/packaging/deb/units/drouter-web.service b/packaging/deb/units/drouter-web.service
new file mode 100644
index 0000000..8bac229
--- /dev/null
+++ b/packaging/deb/units/drouter-web.service
@@ -0,0 +1,44 @@
+[Unit]
+Description=drouter 主路由管理后台
+Documentation=file:/opt/drouter/docs/
+After=network-online.target
+Wants=network-online.target
+Before=shutdown.target
+
+[Service]
+Type=simple
+User=drouter
+Group=drouter
+WorkingDirectory=/opt/drouter
+# systemd 默认 PATH 不含 /usr/sbin,而 nft/dnsmasq/radvd 都在那儿。
+# 后端虽已自行归一化 PATH,这里再显式声明一次,避免任何遗漏路径静默失败。
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-web.py
+Restart=on-failure
+RestartSec=5
+# 资源限制:适配 2 核 / 3.9G 小内存环境
+MemoryMax=160M
+MemoryHigh=120M
+CPUQuota=40%
+Nice=5
+IOSchedulingClass=best-effort
+IOSchedulingPriority=5
+# 安全加固
+# 说明:后端以低权用户 drouter 运行,写 /etc 由 root helper 完成(白名单 action)。
+# 注意:这里必须 ProtectSystem=false —— 「依赖自检 / 一键安装」需要通过 apt/dpkg
+# 往 /usr/bin、/var/cache/apt、/var/lib/dpkg 写入;若把 /usr 挂为只读,
+# apt 会报 "只读文件系统" 而安装失败。安全边界由 helper 的 action 白名单保证:
+# 非白名单动作一律拒绝,且所有参数逐个强校验。
+NoNewPrivileges=false
+PrivateTmp=false
+ProtectSystem=false
+ProtectHome=read-only
+ReadWritePaths=/opt/drouter/data /opt/drouter/snapshots /opt/drouter/certs /var/log/drouter
+# 额外保护:这些路径不给写(即使 helper 被滥用也改不到)
+InaccessiblePaths=/boot
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=drouter-web
+
+[Install]
+WantedBy=multi-user.target
diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile
new file mode 100644
index 0000000..feaea4b
--- /dev/null
+++ b/packaging/docker/Dockerfile
@@ -0,0 +1,75 @@
+# =============================================================================
+# drouter —— Docker 镜像
+#
+# 定位:用于「快速体验 / 开发调试 / CI 渲染校验」。
+# 真正当主路由跑,请直接装在 Debian 13 物理机或虚拟机里(需要直接操作网卡与
+# nftables,容器只能以 --network host + NET_ADMIN 勉强模拟)。
+#
+# 构建:bash packaging/build-docker.sh [tag]
+# 运行:docker run -d --name drouter \
+#         --network host --cap-add NET_ADMIN --cap-add NET_RAW \
+#         -v drouter-data:/opt/drouter/data \
+#         -v drouter-etc:/etc/drouter \
+#         drouter:1.0.0
+# =============================================================================
+FROM debian:trixie-slim
+
+LABEL maintainer="火麒麟 (ajeef) " \
+      org.opencontainers.image.title="drouter" \
+      org.opencontainers.image.description="Debian 13 主路由管理系统(Web 管理面板)" \
+      org.opencontainers.image.licenses="GPL-3.0"
+
+ENV DEBIAN_FRONTEND=noninteractive \
+    PYTHONUNBUFFERED=1 \
+    TZ=Asia/Shanghai
+
+# 1) 依赖。只装运行 Web 面板与渲染配置所必需的包;
+#    不装 dnsmasq/radvd —— 容器里默认不启动,装了也不会跑。
+RUN apt-get update && apt-get install -y --no-install-recommends \
+        python3 \
+        nftables \
+        iproute2 \
+        iputils-ping \
+        ethtool \
+        conntrack \
+        bridge-utils \
+        sqlite3 \
+        curl \
+        procps \
+        sudo \
+        openssl \
+        ca-certificates \
+        tzdata \
+    && rm -rf /var/lib/apt/lists/*
+
+# 2) 程序文件
+RUN mkdir -p /opt/drouter/backend /opt/drouter/web /opt/drouter/docs \
+             /opt/drouter/data /opt/drouter/snapshots /opt/drouter/certs \
+             /opt/drouter/scripts /opt/drouter/bin \
+             /etc/drouter/generated /etc/drouter/themes \
+             /var/log/drouter /run/drouter-helper
+COPY backend/*.py    /opt/drouter/backend/
+COPY web/            /opt/drouter/web/
+COPY docs/           /opt/drouter/docs/
+COPY scripts/*.sh    /opt/drouter/scripts/
+COPY packaging/docker/docker-init.sh /opt/drouter/bin/docker-init.sh
+RUN chmod +x /opt/drouter/bin/docker-init.sh /opt/drouter/scripts/*.sh
+
+# 3) 运行用户(容器里以低权用户跑 Web,写 /etc 走 helper 白名单)
+RUN useradd -r -s /usr/sbin/nologin -d /opt/drouter -M drouter \
+    && printf 'drouter ALL=(root) NOPASSWD: /usr/bin/python3 /opt/drouter/backend/drouter-helper.py *\n' \
+       > /etc/sudoers.d/drouter \
+    && chmod 0440 /etc/sudoers.d/drouter \
+    && chown -R drouter:drouter /opt/drouter/data /opt/drouter/snapshots /opt/drouter/certs /var/log/drouter \
+    && chmod 700 /run/drouter-helper
+
+EXPOSE 8080 8443
+VOLUME ["/opt/drouter/data", "/opt/drouter/snapshots", "/etc/drouter", "/var/log/drouter"]
+
+# 端口要跟着实际配置走,写死 8443 会在用户改端口后一直报 unhealthy
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+    CMD curl -sk "https://127.0.0.1:$(cat /etc/drouter/web-port 2>/dev/null || echo 8443)/api/health" \
+        || exit 1
+
+ENTRYPOINT ["/opt/drouter/bin/docker-init.sh"]
+CMD ["/usr/bin/python3", "/opt/drouter/backend/drouter-web.py"]
diff --git a/packaging/docker/docker-init.sh b/packaging/docker/docker-init.sh
new file mode 100644
index 0000000..a01e79f
--- /dev/null
+++ b/packaging/docker/docker-init.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# =============================================================================
+# drouter 容器入口:做「不依赖 systemd」的初始化,然后 exec 真正的主进程。
+#
+# deploy.sh 里那些 systemctl / logrotate / systemd 单元的步骤在容器里没有意义
+# (也没有 systemd),所以这里只做等价的目录、权限与初始状态准备。
+# =============================================================================
+set -e
+
+OPT=/opt/drouter
+
+echo "[drouter-init] 准备目录"
+mkdir -p $OPT/data $OPT/snapshots $OPT/certs $OPT/bin \
+         /etc/drouter/generated /etc/drouter/themes \
+         /var/log/drouter /run/drouter-helper
+chmod 700 /run/drouter-helper
+
+# 生效主题:默认内置主题。首次为空时写入,已有则不动(尊重用户选择)
+if [ ! -f /etc/drouter/active-theme ]; then
+  echo "default" > /etc/drouter/active-theme
+fi
+chmod 644 /etc/drouter/active-theme
+
+# dnsmasq 引用的运营商 DNS 占位文件(避免渲染时找不到文件)
+if [ ! -f /etc/drouter/generated/isp-dns.conf ]; then
+  printf '# 由 drouter 自动维护:运营商 PPPoE 下发的 DNS\n' > /etc/drouter/generated/isp-dns.conf
+fi
+
+# 属主:数据目录归低权用户 drouter,代码归 root
+chown -R root:root $OPT/backend $OPT/web 2>/dev/null || true
+chown -R drouter:drouter $OPT/data $OPT/snapshots $OPT/certs /var/log/drouter 2>/dev/null || true
+chmod 750 $OPT/data $OPT/snapshots $OPT/certs 2>/dev/null || true
+
+# Web 端口:DROUTER_WEB_PORT 只在首次启动时播种到配置文件里。
+# 之后必须 unset —— 后端 load_web_port() 里环境变量优先级高于配置文件,
+# 留着它会让用户在界面里改端口看起来「改了没生效」。
+if [ -n "${DROUTER_WEB_PORT:-}" ] && [ ! -f /etc/drouter/web-port ]; then
+  echo "$DROUTER_WEB_PORT" > /etc/drouter/web-port
+  echo "[drouter-init] 已按 DROUTER_WEB_PORT 写入端口:$DROUTER_WEB_PORT"
+fi
+unset DROUTER_WEB_PORT
+
+if [ -f /etc/drouter/web-port ]; then
+  echo "[drouter-init] Web 端口:$(cat /etc/drouter/web-port)"
+else
+  echo "[drouter-init] Web 端口:8443(默认)"
+fi
+echo "[drouter-init] 启动:$*"
+exec "$@"
diff --git a/packaging/offline-install.sh b/packaging/offline-install.sh
new file mode 100644
index 0000000..e9f93d5
--- /dev/null
+++ b/packaging/offline-install.sh
@@ -0,0 +1,233 @@
+#!/bin/bash
+# =============================================================================
+# Drouter 自包含离线安装脚本
+#
+#   sudo bash install.sh [drouter_x.y.z_all.deb]
+#
+# 设计目标:**目标机零外网**也要能一次装完。
+# 做法是先把本地 debs/ 目录注册成一个 file: 源,让 apt 自己去算依赖顺序 ——
+# 比 `dpkg -i debs/*.deb` 一通乱装可靠得多(dpkg 不看顺序,装到一半就会
+# 因为依赖没满足而大面积失败)。
+#
+# 会自动 sudo:不加 sudo 直接跑也能用。
+# =============================================================================
+set -u
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+
+# ---- 提权 ----
+if [ "$(id -u)" -ne 0 ]; then
+  echo "需要 root 权限,正在通过 sudo 重新执行…"
+  exec sudo -E bash "$0" "$@"
+fi
+
+step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
+warn() { printf '\033[1;33m  ⚠ %s\033[0m\n' "$*"; }
+die()  { printf '\033[1;31m  ✘ %s\033[0m\n' "$*"; exit 1; }
+
+# ---------------------------------------------------------------------------
+# 根前缀(仅 chroot 验收用,真实目标机上为空、行为完全不变)。
+#
+# 为什么需要:在不设根前缀的 chroot 里,apt/dpkg 读的是**宿主机**的
+# /var/lib/dpkg/status,于是会出现「apt 说 hostname 已经是最新版、chroot 里
+# 其实没有」这种鬼打墙;最后 drouter 本体因为
+# `Package hostname is not installed` 装不上,看起来像离线包缺包。
+#
+# 用法(验收脚本):DROUTER_ROOT=/tmp/xxx bash install.sh
+# 真实目标机:不设该变量 → ROOT_PREFIX 为空 → 走原路径,零差异。
+# ---------------------------------------------------------------------------
+ROOT_PREFIX=""
+APT_ROOT_OPTS=()
+# 注意 `${X%/}` 对 "/" 会得到空串,会被误判成「没设置」。
+# chroot 内的正确用法恰恰就是 DROUTER_ROOT=/(此时 chroot 的 / 已是目标根),
+# 所以要单独判一次原值,而不是判剥掉斜杠后的结果。
+if [ -n "${DROUTER_ROOT:-}" ]; then
+  if [ "$DROUTER_ROOT" = "/" ] || [ "$DROUTER_ROOT" = "" ]; then
+    ROOT_PREFIX=""
+    echo "  根前缀:/(chroot 内,直接使用当前根)"
+  else
+    ROOT_PREFIX="${DROUTER_ROOT%/}"
+    [ -d "$ROOT_PREFIX/var/lib/dpkg" ] || die "DROUTER_ROOT 指向的目录不是有效根:$ROOT_PREFIX"
+    APT_ROOT_OPTS=(
+      -o "Dir=$ROOT_PREFIX/"
+      -o "Dir::State=$ROOT_PREFIX/var/lib/apt"
+      -o "Dir::State::status=$ROOT_PREFIX/var/lib/dpkg/status"
+      -o "Dir::Cache=$ROOT_PREFIX/var/cache/apt"
+    )
+    echo "  根前缀:$ROOT_PREFIX(chroot 模式,apt/dpkg 库都指向该根)"
+  fi
+fi
+
+# ---------------------------------------------------------------------------
+step "0/4 环境自检"
+# ---------------------------------------------------------------------------
+[ -d "$HERE/debs" ] || die "找不到 debs/ 目录 —— 解包不完整?"
+NDEB=$(ls -1 "$HERE"/debs/*.deb 2>/dev/null | wc -l)
+[ "$NDEB" -gt 0 ] || die "debs/ 里一个 .deb 都没有"
+echo "  依赖包:$NDEB 个"
+
+# 架构必须在下载依赖时那台机器一致,否则装了也起不来
+if command -v dpkg >/dev/null 2>&1; then
+  MYARCH=$(dpkg --print-architecture)
+  case "$HERE" in
+    *-"$MYARCH") : ;;
+    *) warn "本机架构是 $MYARCH,但包名里写的是别的 —— 可能不匹配" ;;
+  esac
+fi
+
+if [ -f /etc/os-release ]; then
+  . /etc/os-release
+  echo "  系统:${PRETTY_NAME:-unknown}"
+  case "${VERSION_CODENAME:-}" in
+    trixie) : ;;
+    '')     warn "识别不出 VERSION_CODENAME,跳过版本核对" ;;
+    *)      warn "依赖是按 Debian 13 (trixie) 下载的,本机是 ${VERSION_CODENAME},"
+            warn "版本对不上的包可能装不上。继续尝试…" ;;
+  esac
+fi
+
+# 磁盘检查:200+ 个 deb 解包后要占几百 MB
+# 用 df -Pk + cut 而不是 awk —— awk 不是 Essential 包,极简系统上可能没有,
+# 装包脚本自己先因为「找不到 awk」而报错就太讽刺了。
+AVAIL_KB=$(df -Pk /var 2>/dev/null | tail -1 | tr -s ' ' | cut -d' ' -f4)
+if [ -n "${AVAIL_KB:-}" ] && [ "$AVAIL_KB" -lt 1500000 ] 2>/dev/null; then
+  warn "根分区可用空间不足 1.5G(当前 $((AVAIL_KB/1024))M),安装可能失败"
+fi
+
+# ---------------------------------------------------------------------------
+step "1/4 找安装包"
+# ---------------------------------------------------------------------------
+PKG="${1:-}"
+if [ -z "$PKG" ]; then
+  PKG=$(ls -1 "$HERE"/drouter_*_all.deb 2>/dev/null | head -1 || true)
+fi
+if [ -z "$PKG" ]; then
+  die "目录里找不到 drouter_*_all.deb(用法:bash install.sh drouter_1.0.0_all.deb)"
+fi
+[ -f "$PKG" ] || PKG="$HERE/$PKG"
+[ -f "$PKG" ] || die "找不到安装包:$PKG"
+echo "  本体:$(basename "$PKG")"
+
+# ---------------------------------------------------------------------------
+step "2/4 安装依赖(走本地 file: 源,零外网)"
+# ---------------------------------------------------------------------------
+# 关键点:用一份**只指向本地 debs/** 的临时 sourcelist,
+# 并同时把 Dir::Etc::sourceparts 指到 /dev/null —— 否则系统里原有的
+# 外网源还会被读进来,apt 会去联网找"更新的版本",无外网时就卡住/报错。
+TMPLIST=$(mktemp /tmp/drouter-src.XXXXXX.list)
+trap 'rm -f "$TMPLIST"' EXIT
+
+cat > "$TMPLIST" </dev/null || true
+else
+  mkdir -p "$HERE/.aptlists/partial"
+fi
+
+# 索引自检:debs/ 里必须有 Packages 或 Packages.gz,否则 apt 会报
+#   `E: Failed to fetch file:.../debs/./Packages  File not found`
+# 然后「本地仓库」这条链路就静默退化成 dpkg -i 乱装(顺序不对就装不上)。
+# 老版本的 make-offline-deps.sh 把索引生成到了上一级目录,正是这个症状;
+# 这里顺手自愈一下,兼容用户手上可能存在的旧包。
+if [ ! -f "$HERE/debs/Packages" ] && [ ! -f "$HERE/debs/Packages.gz" ]; then
+  if [ -f "$HERE/Packages.gz" ]; then
+    echo "  (索引在上级目录,已自动搬到 debs/ 下)"
+    mv -f "$HERE/Packages.gz" "$HERE/debs/Packages.gz"
+  elif [ -f "$HERE/Packages" ]; then
+    echo "  (索引在上级目录,已自动搬到 debs/ 下)"
+    mv -f "$HERE/Packages" "$HERE/debs/Packages"
+  else
+    warn "debs/ 里没有 Packages 索引,将退化为 dpkg -i 逐个安装"
+  fi
+fi
+
+echo "  建立本地仓库索引…"
+if ! apt-get "${APT_OPTS[@]}" update -qq 2>&1 | tail -3; then
+  warn "本地索引建立有问题,继续尝试(下面的 install 会再校验一次)"
+fi
+
+# 把 debs/ 里所有包名拿去 install —— apt 会自动只选本地源里有的,
+# 且按依赖顺序装。已经装过且版本相同的会跳过。
+DEBLIST=$(cd "$HERE/debs" && ls -1 *.deb 2>/dev/null | sed 's/_.*//' | sort -u | tr '\n' ' ')
+echo "  从本地源安装 $NDEB 个依赖包(已装过的会跳过)…"
+
+# dpkg 也要跟着根前缀走。chroot 下不传 --root 会把包装到宿主机上(灾难)。
+DPKG_ROOT_OPTS=()
+[ -n "$ROOT_PREFIX" ] && DPKG_ROOT_OPTS=( --root="$ROOT_PREFIX" --force-not-root )
+
+if ! DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" \
+        --no-install-recommends -y install $DEBLIST 2>&1 | tail -8; then
+  warn "apt 批量安装报错,改用 dpkg 逐个兜底"
+  dpkg "${DPKG_ROOT_OPTS[@]}" -i "$HERE"/debs/*.deb 2>&1 | tail -5 || true
+  # 再让 apt 用本地源把没配好的补一遍
+  apt-get "${APT_OPTS[@]}" -y -f install 2>&1 | tail -5 || true
+fi
+
+# ---------------------------------------------------------------------------
+step "3/4 安装 drouter 本体"
+# ---------------------------------------------------------------------------
+# postinst 里可能调用 systemctl,无外网不影响,但如果目标机没有 systemd
+# 会报错,所以失败不致命 —— 最后单独报告。
+if ! DEBIAN_FRONTEND=noninteractive dpkg "${DPKG_ROOT_OPTS[@]}" -i "$PKG" 2>&1 | tail -10; then
+  warn "本体安装过程有报错,尝试用本地源补依赖"
+  apt-get "${APT_OPTS[@]}" -y -f install 2>&1 | tail -8 || true
+fi
+
+# ---------------------------------------------------------------------------
+step "4/4 结果"
+# ---------------------------------------------------------------------------
+OPT_ROOT="${ROOT_PREFIX}/opt/drouter"
+if [ -f "$OPT_ROOT/backend/drouter-helper.py" ]; then
+  MODS=$(ls -1 "$OPT_ROOT"/backend/*.py 2>/dev/null | wc -l)
+  printf '\033[1;32m  ✅ 安装成功\033[0m(后端模块 %s 个)\n' "$MODS"
+
+  # 服务状态(没 systemd 就跳过)
+  if command -v systemctl >/dev/null 2>&1 && systemctl is-system-running >/dev/null 2>&1; then
+    ST=$(systemctl is-active drouter-web 2>/dev/null || echo unknown)
+    echo "  drouter-web 服务:$ST"
+  fi
+
+  # 取本机 IP。优先 iproute2;没有就退回 hostname -I(更老但到处都有)。
+  # 两者都不可用时留空,不影响安装结果。
+  IP=""
+  if command -v ip >/dev/null 2>&1; then
+    IP=$(ip -4 -o addr show scope global 2>/dev/null | tr -s ' ' | cut -d' ' -f4 | cut -d/ -f1 | head -1)
+  fi
+  if [ -z "$IP" ] && command -v hostname >/dev/null 2>&1; then
+    IP=$(hostname -I 2>/dev/null | tr ' ' '\n' | head -1)
+  fi
+  PORT=$(cat /etc/drouter/web-port 2>/dev/null || echo 8443)
+  cat <}:${PORT}/
+       (自签证书,浏览器会提示不安全,点"继续访问")
+    2) 用 admin / admin123 登录,**第一件事改密码**
+    3) 左侧菜单第一项「新手向导」会带你配好外网/内网/DNS/IPv6
+
+  命令行工具:
+    sudo drouter-ctl status      查看状态
+    sudo drouter-ctl webport 8443  改 Web 端口
+
+EOF
+else
+  die "没装上 —— 看上面的报错。完整日志:bash install.sh 2>&1 | tee /tmp/install.log"
+fi
diff --git a/scripts/audit.sh b/scripts/audit.sh
new file mode 100644
index 0000000..b109f13
--- /dev/null
+++ b/scripts/audit.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# 交付前最终状态审计
+echo "===== 1. 后端服务 ====="
+systemctl is-active drouter-web; systemctl is-enabled drouter-web
+systemctl show drouter-web -p MemoryCurrent -p ProtectSystem -p PrivateTmp -p User 2>/dev/null
+
+echo; echo "===== 2. 监听端口 ====="
+ss -lntup 2>/dev/null | awk 'NR==1 || /8080|8443|5900|:22/'
+
+echo; echo "===== 3. 网络服务应全部 inactive+disabled ====="
+for s in dnsmasq radvd dhcpcd miniupnpd chrony nftables; do
+  printf "%-12s active=%-10s enabled=%s\n" "$s" "$(systemctl is-active $s 2>&1)" "$(systemctl is-enabled $s 2>&1)"
+done
+
+echo; echo "===== 4. 端口 53/67/68 占用(应为空) ====="
+ss -lnu 2>/dev/null | grep -E ':53 |:67 |:68 ' || echo "  53/67/68 空闲 ✔"
+
+echo; echo "===== 5. nftables 规则(应为空) ====="
+nft list ruleset 2>/dev/null | wc -l
+
+echo; echo "===== 6. 构建保护模式 ====="
+if [ -f /etc/drouter/BUILD_MODE ]; then echo "  BUILD_MODE 存在 —— 保护开启 ✔"; cat /etc/drouter/BUILD_MODE; else echo "  未开启"; fi
+
+echo; echo "===== 7. 已生成配置文件 ====="
+for f in /etc/dnsmasq.d/drouter.conf /etc/nftables.d/drouter-v4.nft /etc/nftables.d/drouter-v6.nft \
+         /etc/radvd.conf /etc/dhcpcd.conf /etc/miniupnpd/miniupnpd.conf /etc/chrony/chrony.conf \
+         /etc/ppp/peers/drouter-wan; do
+  [ -f "$f" ] && printf "  ✔ %-42s %s 字节\n" "$f" "$(stat -c%s "$f")" || printf "  ✘ %-42s 缺失\n" "$f"
+done
+
+echo; echo "===== 8. 快照数量 ====="
+ls -1 /opt/drouter/snapshots 2>/dev/null | wc -l
+
+echo; echo "===== 9. 内存占用 ====="
+ps -o rss=,cmd= -C python3 2>/dev/null | awk '{printf "  RSS=%.1f MB  %s\n", $1/1024, $2}'
+
+echo; echo "===== 10. 网卡 ====="
+ip -br -4 addr show ens18
+
+echo; echo "===== 11. VNC 进程 ====="
+ps aux | grep -i '[v]nc' | awk '{print "  "$11" "$12" "$13}'
+
+echo; echo "===== 12. 审计日志尾部 ====="
+tail -5 /var/log/drouter/audit.jsonl 2>/dev/null || echo "  (无)"
+
+echo "AUDIT_DONE"
diff --git a/scripts/claim-wan.sh b/scripts/claim-wan.sh
new file mode 100644
index 0000000..a76e481
--- /dev/null
+++ b/scripts/claim-wan.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+# 认领新网卡 ens19 为 WAN 口
+B=http://127.0.0.1:8080
+TOK=$(curl -s $B/api/login -X POST -H 'Content-Type: application/json' \
+      -d '{"username":"admin","password":"admin123"}' \
+      | python3 -c 'import sys,json;print((json.load(sys.stdin).get("data") or {}).get("token",""))')
+
+curl -s $B/api/iface/meta -H "X-Token: $TOK" | python3 -m json.tool --no-ensure-ascii
+echo
+
+echo "=== 认领 ens19 为 WAN ==="
+curl -s $B/api/iface/save -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
+  -d '{"mac":"bc:24:11:f2:5a:47","name":"ens19","remark":"WAN口(PPPoE拨号)","role":"wan"}' \
+  | python3 -m json.tool --no-ensure-ascii
+
+echo
+echo "=== 确认 ens18 仍为 LAN ==="
+curl -s $B/api/iface/save -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
+  -d '{"mac":"bc:24:11:1a:8f:43","name":"ens18","remark":"LAN管理口","role":"lan"}' \
+  | python3 -m json.tool --no-ensure-ascii
+
+echo
+echo "=== 结果 ==="
+sqlite3 -header -column /opt/drouter/data/drouter.db 'select mac,name,remark,role from ifaces;'
+echo
+echo "--- system 配置 ---"
+sqlite3 /opt/drouter/data/drouter.db "select value from settings where key='system';" | python3 -m json.tool --no-ensure-ascii
diff --git a/scripts/cleanup-test.sh b/scripts/cleanup-test.sh
new file mode 100644
index 0000000..9ef1e5c
--- /dev/null
+++ b/scripts/cleanup-test.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+# 清理回归测试残留:PPPoE 测试账号(peers / chap-secrets / pap-secrets)
+set -u
+echo "=== 清理前 ==="
+grep -l "test12345" /etc/ppp/peers/* /etc/ppp/*-secrets 2>/dev/null
+
+echo "=== 删除测试生成的 PPPoE 文件 ==="
+rm -f /etc/ppp/peers/drouter-wan
+# secrets 文件如果是本次测试生成的(仅含测试账号),恢复为空的模板
+for f in /etc/ppp/chap-secrets /etc/ppp/pap-secrets; do
+  if [ -f "$f" ] && [ "$(wc -l < "$f")" -le 1 ] && grep -q "test12345" "$f" 2>/dev/null; then
+    printf '# 由 drouter 自动生成:PPPoE 认证凭据\n# 在 WEB 界面填写 PPPoE 账号密码并保存后,此处会被写入\n' > "$f"
+    chmod 600 "$f"; chown root:root "$f"
+    echo "已重置 $f"
+  fi
+done
+
+echo "=== 重置数据库 pppoe 配置块 ==="
+python3 - <<'PY'
+import sqlite3, json
+db = '/opt/drouter/data/drouter.db'
+c = sqlite3.connect(db); c.row_factory = sqlite3.Row
+row = c.execute("SELECT value FROM settings WHERE key='pppoe'").fetchone()
+cur = json.loads(row['value']) if row else {}
+cur.setdefault('isp', 'auto'); cur.setdefault('mtu', 1492); cur.setdefault('mru', 1492)
+cur.setdefault('persist', True); cur.setdefault('maxfail', 0); cur.setdefault('holdoff', 5)
+cur.setdefault('service_name', '')
+cur['iface'] = ''; cur['username'] = ''; cur['password'] = ''
+cur['saved_only'] = True; cur['note'] = '仅保存凭据,未执行拨号'
+c.execute("UPDATE settings SET value=? WHERE key='pppoe'", (json.dumps(cur, ensure_ascii=False),))
+c.commit()
+print('pppoe ->', json.dumps(cur, ensure_ascii=False))
+PY
+
+echo "=== 清理后核对 ==="
+if grep -rq "test12345" /etc/ppp/ 2>/dev/null; then
+  echo "仍有残留!"; grep -rn "test12345" /etc/ppp/ 2>/dev/null
+else
+  echo "无测试账号残留 ✔"
+fi
+ls -la /etc/ppp/peers/ /etc/ppp/chap-secrets /etc/ppp/pap-secrets 2>&1
+echo "CLEANUP_DONE"
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100644
index 0000000..62cd220
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,428 @@
+#!/bin/bash
+# Debian13 主路由系统 —— 部署脚本
+# 将后端、前端、systemd 服务、sudoers 白名单安装到位。
+# 不会启动任何网络服务,不会修改网络配置。
+set -e
+
+# 源码目录可用 DROUTER_SRC 覆盖(一键脚本 drouter-ctl.sh 会传入实际路径)
+SRC="${DROUTER_SRC:-/tmp/drouter-src}"
+OPT=/opt/drouter
+UNIT_WEB=drouter-web
+
+echo "=== 1. 创建目录结构 ==="
+mkdir -p $OPT/backend $OPT/web $OPT/data $OPT/snapshots $OPT/certs $OPT/bin $OPT/docs
+mkdir -p /etc/drouter/generated
+mkdir -p /etc/nftables.d
+mkdir -p /var/log/drouter
+mkdir -p /etc/dnsmasq.d
+mkdir -p /var/lib/drouter/trash      # 文件管理器"回收站"
+mkdir -p $OPT/dpi                    # DPI 识别规则库与源码(#25)
+mkdir -p /etc/ppp/ip-up.d /etc/ppp/ip-down.d   # PPPoE 多拨策略路由钩子(#23)
+# helper 私有临时目录:/run 为 tmpfs(重启自清),且不归低权用户所有,
+# 避开 /tmp、/var/tmp 的 sticky 位与 fs.protected_regular=2 写保护。
+mkdir -p /run/drouter-helper
+chmod 700 /run/drouter-helper
+
+sha_of() { [ -f "$1" ] && sha1sum < "$1" | cut -d' ' -f1 || echo "-"; }
+# shelld 是常驻守护,代码在内存里,不重启就是旧行为。
+# 判断依据必须是「本次源码指纹」vs「上次真正重启过的指纹」(记在 mark 文件里),
+# 不能拿 $SRC 和 $OPT 比 —— install 之后两边同源,cmp 恒等,永远不重启。
+# 也不能拿 install 前的 $OPT 比 —— 若上一轮已经把新文件拷进去了但没重启成功,
+# 这一轮两边又相等,漏网。
+SHELLD_NEW=$(sha_of "$SRC/backend/drouter-shelld.py")
+SHELLD_MARK=/var/lib/drouter/shelld.deployed.sha
+
+echo "=== 2. 安装后端 ==="
+install -m 0644 $SRC/backend/render.py          $OPT/backend/render.py
+install -m 0644 $SRC/backend/drouter-helper.py  $OPT/backend/drouter-helper.py
+install -m 0644 $SRC/backend/drouter-web.py     $OPT/backend/drouter-web.py
+install -m 0644 $SRC/backend/theme.py           $OPT/backend/theme.py
+# drouter-logd:统一日志采集守护。helper 写出的 drouter-logd.service 直接
+# ExecStart 这个文件,漏装会导致日志定时器起不来(此前确实漏了,务必保留)。
+install -m 0644 $SRC/backend/drouter-logd.py    $OPT/backend/drouter-logd.py
+install -m 0644 $SRC/backend/drouter-snapshotd.py $OPT/backend/drouter-snapshotd.py
+install -m 0644 $SRC/backend/drouter-rescue.py  $OPT/backend/drouter-rescue.py
+# drouter-shelld:Web 真·PTY 终端的会话守护。它必须由常驻进程持有 PTY
+# (helper 每次都是跑完就退出的新进程,PTY fd 跨不了请求),漏装则终端连不上。
+install -m 0644 $SRC/backend/drouter-shelld.py  $OPT/backend/drouter-shelld.py
+install -m 0644 $SRC/backend/drouter-helpd.py  $OPT/backend/drouter-helpd.py
+
+# 主题之家(#12):自定义主题目录 + 生效主题标记
+mkdir -p /etc/drouter/themes
+echo "default" > /etc/drouter/active-theme
+chmod 644 /etc/drouter/active-theme
+
+echo "=== 3. 安装前端 ==="
+install -m 0644 $SRC/web/index.html $OPT/web/index.html
+install -m 0644 $SRC/web/app.css    $OPT/web/app.css
+install -m 0644 $SRC/web/app.js     $OPT/web/app.js
+if [ -f "$SRC/web/logo.svg" ]; then
+  install -m 0644 $SRC/web/logo.svg $OPT/web/logo.svg
+fi
+
+echo "=== 3.5 安装运维脚本(自检/回归用)==="
+if [ -d "$SRC/scripts" ]; then
+  install -d -m 0755 $OPT/scripts
+  for s in "$SRC"/scripts/*.sh; do
+    [ -f "$s" ] || continue
+    install -m 0755 "$s" "$OPT/scripts/$(basename "$s")"
+  done
+  echo "已安装脚本:$(ls $OPT/scripts | tr '\n' ' ')"
+fi
+
+echo "=== 3.6 安装文档(systemd unit 的 Documentation= 指向这里)==="
+if [ -d "$SRC/docs" ]; then
+  for d in "$SRC"/docs/*; do
+    [ -f "$d" ] || continue
+    install -m 0644 "$d" "$OPT/docs/$(basename "$d")"
+  done
+  echo "已安装文档:$(ls $OPT/docs | tr '\n' ' ')"
+fi
+
+echo "=== 4. 创建专用低权用户 drouter ==="
+if ! id drouter >/dev/null 2>&1; then
+  useradd -r -s /usr/sbin/nologin -d $OPT -M drouter
+  echo "已创建系统用户 drouter"
+else
+  echo "用户 drouter 已存在"
+fi
+
+echo "=== 5. 权限设置 ==="
+chown -R drouter:drouter $OPT/data $OPT/snapshots $OPT/certs
+chmod 750 $OPT/data $OPT/snapshots $OPT/certs
+chmod +x $OPT/backend/drouter-helper.py
+chmod +x $OPT/backend/drouter-snapshotd.py
+chmod +x $OPT/backend/drouter-rescue.py
+chown root:root $OPT/backend/drouter-helper.py
+chown -R root:root $OPT/backend
+chown -R root:root $OPT/web
+chmod 755 /var/log/drouter
+chmod 755 /etc/drouter/generated
+chown -R drouter:drouter /var/lib/drouter 2>/dev/null || true
+chmod -R 750 /var/lib/drouter 2>/dev/null || true
+
+echo "=== 6. sudoers 白名单(仅放行 helper,且限定参数形态)==="
+cat > /etc/sudoers.d/drouter <<'SUDO'
+# drouter:后端仅可通过该脚本以 root 执行白名单操作
+# 参数被严格限制为 action + JSON 字符串
+drouter ALL=(root) NOPASSWD: /usr/bin/python3 /opt/drouter/backend/drouter-helper.py *
+SUDO
+chmod 0440 /etc/sudoers.d/drouter
+visudo -c -f /etc/sudoers.d/drouter >/dev/null && echo "sudoers 语法检查通过"
+
+echo "=== 7. 日志轮转配置 ==="
+cat > /etc/logrotate.d/drouter <<'LR'
+/var/log/drouter/*.log /var/log/drouter/*.jsonl {
+    daily
+    rotate 7
+    missingok
+    notifempty
+    compress
+    delaycompress
+    copytruncate
+    create 0644 root root
+}
+LR
+
+echo "=== 8. systemd 服务单元 ==="
+cat > /etc/systemd/system/drouter-web.service <<'UNIT'
+[Unit]
+Description=drouter 主路由管理后台
+Documentation=file:/opt/drouter/docs/
+After=network-online.target
+Wants=network-online.target
+Before=shutdown.target
+
+[Service]
+Type=simple
+User=drouter
+Group=drouter
+WorkingDirectory=/opt/drouter
+# systemd 默认 PATH 不含 /usr/sbin,而 nft/dnsmasq/radvd 都在那儿。
+# 后端虽已自行归一化 PATH,这里再显式声明一次,避免任何遗漏路径静默失败。
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-web.py
+Restart=on-failure
+RestartSec=5
+# 资源限制:适配 2 核 / 3.9G 小内存环境
+MemoryMax=160M
+MemoryHigh=120M
+CPUQuota=40%
+Nice=5
+IOSchedulingClass=best-effort
+IOSchedulingPriority=5
+# 安全加固
+# 说明:后端以低权用户 drouter 运行,写 /etc 由 root helper 完成(白名单 action)。
+# 注意:这里必须 ProtectSystem=false —— 「依赖自检 / 一键安装」需要通过 apt/dpkg
+# 往 /usr/bin、/var/cache/apt、/var/lib/dpkg 写入;若把 /usr 挂为只读,
+# apt 会报 "只读文件系统" 而安装失败。安全边界由 helper 的 action 白名单保证:
+# 非白名单动作一律拒绝,且所有参数逐个强校验。
+NoNewPrivileges=false
+PrivateTmp=false
+ProtectSystem=false
+ProtectHome=read-only
+ReadWritePaths=/opt/drouter/data /opt/drouter/snapshots /opt/drouter/certs /var/log/drouter
+# 额外保护:这些路径不给写(即使 helper 被滥用也改不到)
+InaccessiblePaths=/boot
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=drouter-web
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+echo "=== 8.5 自动快照定时器 + 紧急救援通道服务单元 ==="
+# 自动快照:默认「开启」,每 6 小时一次。真实参数由界面写入
+# /etc/drouter/snapshot.conf,helper 保存策略时会重建本单元。
+cat > /etc/systemd/system/drouter-snapshot.service <<'SVC'
+[Unit]
+Description=drouter 自动配置快照
+After=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=oneshot
+User=root
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-snapshotd.py
+
+[Install]
+WantedBy=multi-user.target
+SVC
+
+cat > /etc/systemd/system/drouter-snapshot.timer <<'TMR'
+[Unit]
+Description=drouter 自动快照定时器
+
+[Timer]
+OnBootSec=10min
+OnUnitActiveSec=6h
+Persistent=true
+Unit=drouter-snapshot.service
+
+[Install]
+WantedBy=timers.target
+TMR
+
+# 紧急救援通道:默认「关闭」。只有用户在界面上勾选开启后,
+# helper 才会创建虚拟网卡别名并 enable/start 本服务。
+cat > /etc/systemd/system/drouter-rescue.service <<'RSC'
+[Unit]
+Description=drouter 紧急救援通道(独立于 WAN/LAN 的应急恢复入口)
+After=network.target
+Wants=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=simple
+User=root
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-rescue.py
+Restart=always
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+NoNewPrivileges=false
+ProtectSystem=false
+
+[Install]
+WantedBy=multi-user.target
+RSC
+
+# 救援通道默认关闭:不 enable、不 start(避免影响现有局域网)
+echo "救援通道单元已就位(默认关闭,需在界面显式开启)"
+
+# 常驻执行守护(#7 性能优化):把「每个请求 spawn 一次 python3+sudo」的
+# 236ms 固定开销去掉。以 root 运行,但只监听 Unix socket(0660 root:drouter),
+# 不占任何 TCP 端口;drouter-web 连不上它时会自动回退到 subprocess,所以它
+# 挂掉只会变慢、不会让面板不可用。
+cat > /etc/systemd/system/drouter-helpd.service <<'HPD'
+[Unit]
+Description=drouter 特权动作常驻执行守护
+Documentation=file:/opt/drouter/docs/
+After=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=simple
+User=root
+Group=root
+# 刻意不用 RuntimeDirectory:见 drouter-shelld.service 里的说明。
+# /run/drouter 由守护自己 mkdir 并 chmod 0755(drouter-web 要能进目录连
+# helper.sock,而 socket 自身是 0660 root:drouter,权限并没有放宽)。
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-helpd.py
+Restart=on-failure
+RestartSec=2
+# 常驻 import 了整套 helper,比其它守护占得多;上限防内存泄漏拖垮路由器
+MemoryMax=300M
+# 故意不设 CPUQuota:它要跑格式化大硬盘这类子进程,限 CPU 会把这些操作拖慢
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=drouter-helpd
+ProtectHome=read-only
+PrivateTmp=false
+NoNewPrivileges=false
+
+[Install]
+WantedBy=multi-user.target
+HPD
+echo "常驻执行守护单元已就位"
+
+# Web 终端守护(#4 重做):真 PTY 会话必须由常驻进程持有。
+# 说明为什么不用 socket 激活:helper 在连不上时会自动 systemctl start 拉起,
+# 但那要等几秒;预启动能让用户点「连接」时立刻进入 shell。
+# 它以 root 运行(要开 root shell),但只监听 0600 的 Unix socket,不占任何端口。
+cat > /etc/systemd/system/drouter-shelld.service <<'SHD'
+[Unit]
+Description=drouter Web 终端 PTY 会话守护
+Documentation=file:/opt/drouter/docs/
+After=network.target
+
+[Service]
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Type=simple
+User=root
+Group=root
+# 刻意不用 RuntimeDirectory=drouter:systemd 会在「最后一个声明它的单元停止」
+# 时把整个目录删掉重建。两个守护共用 /run/drouter,于是 restart helpd 的瞬间
+# systemd 重建目录,把 shelld 的 shell.sock 一起删了 —— shelld 依旧 active,
+# socket 却没了,用户点终端只看到「守护未运行」。改成两个守护各自 mkdir。
+ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-shelld.py
+Restart=on-failure
+RestartSec=3
+# 只是转字节流,用不了多少内存;上限是防异常输出把内存吃光
+MemoryMax=80M
+CPUQuota=15%
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=drouter-shelld
+# 安全加固:不碰系统目录、不给新特权(它本身就以 root 跑,靠 socket 0600 收口)
+ProtectHome=read-only
+PrivateTmp=false
+NoNewPrivileges=false
+
+[Install]
+WantedBy=multi-user.target
+SHD
+echo "Web 终端守护单元已就位"
+
+echo "=== 9. 生成 isp-dns 占位文件(dnsmasq resolv-file 引用)==="
+cat > /etc/drouter/generated/isp-dns.conf <<'ISP'
+# 由 drouter 自动维护:运营商 PPPoE 下发的 DNS
+# 拨号成功后由 pppd 的 usepeerdns 写入 /etc/ppp/resolv.conf,
+# 可执行 /opt/drouter/bin/sync-isp-dns.sh 将其同步到此处。
+ISP
+
+cat > $OPT/bin/sync-isp-dns.sh <<'SYNC'
+#!/bin/bash
+# 将 pppd 获取到的运营商 DNS 同步给 dnsmasq
+SRC=/etc/ppp/resolv.conf
+DST=/etc/drouter/generated/isp-dns.conf
+{
+  echo "# 由 drouter 自动生成:运营商下发 DNS(来源 $SRC)"
+  if [ -f "$SRC" ]; then
+    grep -E '^nameserver' "$SRC" || echo "# 尚未获取到运营商 DNS"
+  else
+    echo "# $SRC 不存在(尚未拨号成功)"
+  fi
+} > "$DST"
+chmod 644 "$DST"
+SYNC
+chmod +x $OPT/bin/sync-isp-dns.sh
+
+# 存量机器迁移:/etc/drouter/rescue.conf 旧版本写成了 0600 属主 root,
+# 以 drouter 身份运行的快照读不到它,一个文件就让整个 /etc/drouter 目录
+# 进不了快照(界面却显示「已创建快照」)。这里把它放宽成 0640 属组 drouter。
+if [ -f /etc/drouter/rescue.conf ]; then
+    chmod 0640 /etc/drouter/rescue.conf 2>/dev/null || true
+    chgrp drouter /etc/drouter/rescue.conf 2>/dev/null || true
+fi
+
+echo "=== 10. 校验 ==="
+# 逐个编译校验:缺文件直接中止部署(避免上线后才发现少装了一个守护进程)
+for f 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; do
+    p="$OPT/backend/$f"
+    if [ ! -f "$p" ]; then echo "✘ 缺少文件: $p"; exit 1; fi
+    python3 -c 'import ast,sys; ast.parse(open(sys.argv[1],encoding="utf-8").read())' "$p" \
+        && echo "语法OK: $p" || { echo "✘ 语法错误: $p"; exit 1; }
+done
+echo "--- 目录 ---"
+ls -la $OPT/
+echo "--- 服务文件 ---"
+systemctl daemon-reload
+systemctl is-enabled drouter-web 2>&1 || true
+echo "--- 自动快照定时器(默认开启)---"
+systemctl enable --now drouter-snapshot.timer 2>&1 | tail -2 || true
+systemctl is-active drouter-snapshot.timer 2>&1 || true
+echo "--- 紧急救援通道(默认关闭)---"
+systemctl is-active drouter-rescue 2>&1 || true
+echo "--- 磁盘与日志清理定时器(默认开启)---"
+# 单元与入口脚本由 helper 的 cleanup save 动作生成(默认参数即推荐值),
+# 这里只负责在部署时把它建立起来 —— 用户之后在界面改策略会重新生成。
+/usr/bin/python3 $OPT/backend/drouter-helper.py cleanup '{"op":"save","enabled":true}' \
+    >/dev/null 2>&1 && echo "清理策略已初始化" || echo "⚠ 清理策略初始化失败(界面保存一次即可恢复)"
+systemctl is-active drouter-cleanup.timer 2>&1 || true
+echo "--- 常驻执行守护 ---"
+# 必须 restart 而不是 enable --now:守护把 helper 代码常驻在内存里,
+# 只 enable --now 的话它还在跑上一版代码,改的东西根本不生效。
+systemctl enable drouter-helpd 2>&1 | tail -1 || true
+systemctl restart drouter-helpd 2>&1 | tail -2 || true
+sleep 1
+systemctl is-active drouter-helpd 2>&1 || echo "⚠ 常驻执行守护未运行(面板会自动回退到慢路径)"
+echo "--- Web 终端守护 ---"
+# 重启 shelld 会断掉正在用的终端会话,所以默认只 ensure 起来。
+# 但两种情况必须重启:① socket 文件没了(多半是被 helpd 重启连带删掉的);
+# ② 代码确实变了 —— 不重启的话跑的还是旧代码。
+mkdir -p /var/lib/drouter
+SHELLD_SEEN=$(cat "$SHELLD_MARK" 2>/dev/null || echo "-")
+if [ ! -S /run/drouter/shell.sock ] || [ "$SHELLD_NEW" != "$SHELLD_SEEN" ]; then
+    echo "终端守护代码有更新($SHELLD_SEEN → $SHELLD_NEW),重启以生效"
+    systemctl restart drouter-shelld 2>&1 | tail -2 || true
+    # 只有真的起来了才更新 mark,否则下一轮还会重试重启
+    if systemctl is-active --quiet drouter-shelld; then
+        echo "$SHELLD_NEW" > "$SHELLD_MARK"
+    else
+        echo "⚠ 终端守护未能启动,mark 不更新(下一轮部署会再试)"
+    fi
+else
+    systemctl enable --now drouter-shelld 2>&1 | tail -1 || true
+    echo "$SHELLD_NEW" > "$SHELLD_MARK"
+fi
+systemctl is-active drouter-shelld 2>&1 || true
+# socket 必须真的存在:systemd 说 active 不代表 socket 已经在盘上
+# (restart 是异步的,守护起来后还要自己 mkdir + bind,得给它几秒)。
+for _i in 1 2 3 4 5 6 7 8 9 10; do
+    [ -S /run/drouter/shell.sock ] && break
+    sleep 1
+done
+[ -S /run/drouter/shell.sock ] && echo "终端 socket 就位" \
+    || echo "⚠ 终端 socket 缺失,Web 终端将不可用"
+echo "--- 网络与 VNC 复核 ---"
+ip -4 -o addr show ens18 2>/dev/null || echo "(ens18 不存在,跳过)"
+ss -lnt | grep 5900 || echo "5900 异常"
+
+# 重启管理后台,让新代码真正生效。
+# 必须放在语法校验之后:一旦新代码有语法错误,宁可继续跑旧进程也不能把自己关在门外。
+echo "--- 重启管理后台 ---"
+systemctl restart $UNIT_WEB 2>&1 | tail -3 || echo "(重启失败,请手动检查)"
+sleep 2
+systemctl is-active $UNIT_WEB || echo "⚠ drouter-web 未在运行"
+
+# 统一日志采集定时器:默认配置是 enabled=true,但 service/timer 单元只有
+# 在界面里点过一次「保存日志设置」才会被 helper 写出来。全新部署后防火墙日志
+# 页面会一直是空的,表现为「日志功能没做」。这里补一次同步,让默认开关真正生效。
+echo "--- 同步日志采集定时器 ---"
+python3 - <<'PY' || echo "(日志定时器同步失败,可在界面「日志设置」里保存一次重建)"
+# 文件名带连字符,不能用普通 import,走 importlib 按路径加载
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+    'drouter_helper', '/opt/drouter/backend/drouter-helper.py')
+h = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(h)
+r = h.act_logd_sync({'interval_sec': 60})
+print(r.get('msg_cn') or r)
+PY
+systemctl is-active drouter-logd.timer 2>&1 || true
+
+echo "DEPLOY_DONE"
diff --git a/scripts/drouter-ctl.sh b/scripts/drouter-ctl.sh
new file mode 100644
index 0000000..068e158
--- /dev/null
+++ b/scripts/drouter-ctl.sh
@@ -0,0 +1,424 @@
+#!/bin/bash
+# =============================================================================
+# drouter 一键运维脚本 —— 安装 / 卸载 / 修复 / 更新 / 状态
+#
+#   drouter-ctl.sh install     交互式安装(询问网卡、LAN IP/网段、网关、DNS、管理员账号)
+#   drouter-ctl.sh uninstall   卸载(保留配置与快照;加 --purge 彻底清除)
+#   drouter-ctl.sh repair      修复(重部署文件 + 修权限 + 重建单元 + 校验)
+#   drouter-ctl.sh update      更新(更新代码,保留配置与主题)
+#   drouter-ctl.sh status      状态(服务/端口/版本/配置摘要/自检)
+#
+# 安全红线(任何时候都不得违反):
+#   1. 绝不启动/启用 dnsmasq、radvd、miniupnpd 等会抢端口或发 DHCP 的服务
+#   2. 绝不触碰 RealVNC(5900)
+#   3. /etc/drouter/BUILD_MODE 存在时(构建保护模式),任何写网络动作一律中止
+#   4. 卸载默认保留 /etc/drouter 配置与快照,避免用户配置被误删
+# =============================================================================
+set -u
+export DEBIAN_FRONTEND=noninteractive
+
+OPT=/opt/drouter
+SRC_DEFAULT=/tmp/drouter-src
+DB=$OPT/data/drouter.db
+UNIT_WEB=drouter-web
+UNIT_SNAP=drouter-snapshot.timer
+UNIT_LOGD=drouter-logd.timer
+UNIT_RESCUE=drouter-rescue
+UNIT_SHELLD=drouter-shelld
+UNIT_HELPD=drouter-helpd
+
+C_RESET=$'\033[0m'; C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'
+say()  { printf '%s\n' "$*"; }
+ok()   { printf '%s✔%s %s\n' "$C_GRN" "$C_RESET" "$*"; }
+warn() { printf '%s⚠%s %s\n' "$C_YEL" "$C_RESET" "$*"; }
+err()  { printf '%s✘%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
+step() { printf '\n%s==>%s %s\n' "$C_DIM" "$C_RESET" "$*"; }
+die()  { err "$*"; exit 1; }
+
+# ---------------------------------------------------------------- 前置检查
+need_root() {
+  [ "$(id -u)" = 0 ] || die "请以 root 运行(sudo bash $0 $1)"
+}
+# 构建保护模式:存在该文件时禁止一切可能改网络的动作
+guard_build_mode() {
+  if [ -f /etc/drouter/BUILD_MODE ]; then
+    die "检测到构建保护模式 /etc/drouter/BUILD_MODE,已中止。
+      如需继续,请先确认当前不在构建阶段,然后:rm -f /etc/drouter/BUILD_MODE"
+  fi
+}
+# 安全红线自检:本脚本绝不去动这些服务/端口
+assert_no_network_daemons() {
+  local bad=""
+  for s in dnsmasq radvd miniupnpd dhcpcd isc-dhcp-server; do
+    if systemctl is-enabled "$s" 2>/dev/null | grep -qx enabled; then bad="$bad $s"; fi
+  done
+  [ -z "$bad" ] || warn "以下服务当前是 enabled 状态:$bad(本脚本不会启用它们,请自行确认)"
+}
+
+# ---------------------------------------------------------------- 输入校验
+valid_ipv4() {
+  python3 - "$1" <<'PY'
+import sys, ipaddress
+try:
+    ipaddress.IPv4Address(sys.argv[1]); print('yes')
+except Exception:
+    print('no')
+PY
+}
+valid_cidr() {
+  python3 - "$1" <<'PY'
+import sys, ipaddress
+try:
+    ipaddress.IPv4Network(sys.argv[1], strict=False); print('yes')
+except Exception:
+    print('no')
+PY
+}
+# 由网段推导:网络地址、掩码、可用主机范围
+net_info() {
+  python3 - "$1" <<'PY'
+import sys, ipaddress
+n = ipaddress.IPv4Network(sys.argv[1], strict=False)
+hosts = list(n.hosts())
+gw  = str(hosts[0]) if hosts else str(n.network_address)
+p1  = str(hosts[min(10, len(hosts)-1)]) if hosts else ''
+p2  = str(hosts[-1]) if hosts else ''
+print(' '.join([str(n.network_address), str(n.netmask), str(n.broadcast_address), gw, p1, p2]))
+PY
+}
+ask() {  # ask 变量名 提示 默认值
+  local var="$1" prompt="$2" def="$3" val
+  if [ -n "${NONINTERACTIVE:-}" ]; then val="$def"
+  else
+    read -r -p "$(printf '%s%s%s [%s]: ' "$C_DIM" "$prompt" "$C_RESET" "$def")" val
+    val="${val:-$def}"
+  fi
+  printf -v "$var" '%s' "$val"
+}
+ask_secret() {  # 密码类:不回显
+  local var="$1" prompt="$2" val
+  if [ -n "${NONINTERACTIVE:-}" ]; then val="${3:-}"; printf -v "$var" '%s' "$val"; return; fi
+  read -r -s -p "$(printf '%s%s%s: ' "$C_DIM" "$prompt" "$C_RESET")" val; echo
+  printf -v "$var" '%s' "$val"
+}
+
+# =============================================================================
+cmd_install() {
+  need_root install
+  guard_build_mode
+  local SRC="${1:-$SRC_DEFAULT}"
+  [ -d "$SRC/backend" ] || die "源码目录不存在:$SRC(请先同步源码到该目录)"
+
+  step "1/7 收集安装参数"
+  # 自动探测候选网卡(排除 lo / 虚拟口)
+  local ifaces
+  ifaces=$(ls /sys/class/net 2>/dev/null | grep -vE '^(lo|docker|br-|veth|virbr|ppp)' | tr '\n' ' ')
+  say "  可用网卡:$ifaces"
+  ask LAN_IFACE "LAN 网卡(内网)" "$(echo "$ifaces" | awk '{print $1}')"
+  ask WAN_IFACE "WAN 网卡(外网,可留空稍后在界面选)" ""
+  ask LAN_CIDR  "LAN 地址(IP/掩码位数)" "192.168.7.3/24"
+  valid_cidr "$LAN_CIDR" | grep -qx yes || die "LAN 地址格式不对:$LAN_CIDR(示例 192.168.7.3/24)"
+
+  read -r _NET _MASK _BC _GW _P1 _P2 <<<"$(net_info "$LAN_CIDR")"
+  say "  ${C_DIM}网段 ${_NET}  掩码 ${_MASK}  可用 ${_P1} ~ ${_P2}${C_RESET}"
+  ask GATEWAY   "本机 LAN 网关(路由器自身地址,通常就是 LAN IP)" "${LAN_CIDR%%/*}"
+  valid_ipv4 "$GATEWAY" | grep -qx yes || die "网关地址不合法:$GATEWAY"
+  ask DNS_LIST  "DNS(逗号分隔)" "223.5.5.5,119.29.29.29"
+  ask DHCP_ON   "是否开启 DHCP 地址池?(y/N)" "N"
+  POOL_START=""; POOL_END=""
+  if [ "${DHCP_ON}" = "y" ] || [ "${DHCP_ON}" = "Y" ]; then
+    ask POOL_START "地址池起始" "${_P1:-192.168.7.100}"
+    ask POOL_END   "地址池结束" "${_P2:-192.168.7.200}"
+    valid_ipv4 "$POOL_START" | grep -qx yes || die "地址池起始不合法"
+    valid_ipv4 "$POOL_END"   | grep -qx yes || die "地址池结束不合法"
+  fi
+  ask WEB_PORT  "Web 管理端口" "8443"
+  ask ADM_USER  "Web 管理员用户名" "admin"
+  ask_secret ADM_PASS "Web 管理员密码(至少 6 位,留空则沿用 admin123)" ""
+  if [ -n "$ADM_PASS" ] && [ "${#ADM_PASS}" -lt 6 ]; then die "密码至少 6 位"; fi
+  ask PPP_USER  "PPPoE 宽带账号(可留空)" ""
+  PPP_PASS=""
+  if [ -n "$PPP_USER" ]; then ask_secret PPP_PASS "PPPoE 宽带密码" ""; fi
+
+  step "2/7 安装系统依赖(进入构建保护模式,不会启动任何服务)"
+  bash "$SRC/scripts/install-base.sh" 2>&1 | tail -12
+
+  step "3/7 部署程序文件"
+  DROUTER_SRC="$SRC" bash "$SRC/scripts/deploy.sh" 2>&1 | tail -20
+
+  step "4/7 启动管理后台(只启动 drouter-web,绝不启动 DHCP/DNS)"
+  # 自定义端口必须写进 /etc/drouter/web-port 才会生效(后端启动时读取)
+  if [ "$WEB_PORT" != "8443" ]; then
+    mkdir -p /etc/drouter
+    printf '%s\n' "$WEB_PORT" > /etc/drouter/web-port
+    ok "Web 端口已设为 $WEB_PORT"
+  fi
+  systemctl daemon-reload
+  systemctl enable "$UNIT_WEB" >/dev/null 2>&1 || true
+  systemctl restart "$UNIT_WEB"
+  # 等待端口就绪
+  local i=0
+  while [ $i -lt 30 ]; do
+    if ss -lnt 2>/dev/null | grep -q ":${WEB_PORT}\b"; then break; fi
+    sleep 1; i=$((i+1))
+  done
+  ss -lnt 2>/dev/null | grep -q ":${WEB_PORT}\b" \
+    && ok "管理后台已监听 :${WEB_PORT}" \
+    || { systemctl status "$UNIT_WEB" --no-pager -n 20; die "管理后台未能启动"; }
+
+  step "5/7 写入预置配置(走官方 API,保证与界面同一套校验)"
+  local pw="${ADM_PASS:-admin123}"
+  LOGIN_USER="$ADM_USER" LOGIN_PASS="$pw" PORT="$WEB_PORT" \
+  LAN_IFACE="$LAN_IFACE" WAN_IFACE="$WAN_IFACE" LAN_CIDR="$LAN_CIDR" \
+  GATEWAY="$GATEWAY" DNS_LIST="$DNS_LIST" POOL_START="$POOL_START" POOL_END="$POOL_END" \
+  PPP_USER="$PPP_USER" PPP_PASS="$PPP_PASS" \
+  python3 - <<'PY'
+import json, os, ssl, urllib.request, urllib.error, sys
+
+CTX = ssl._create_unverified_context()          # 自签证书,仅本机回环调用
+# 优先 HTTPS(自定义端口),失败回退 HTTP 8080(后端同时监听两者)
+CANDIDATES = ['https://127.0.0.1:%s' % os.environ['PORT'], 'http://127.0.0.1:8080']
+BASE = None
+
+def call(path, payload=None, token=None):
+    global BASE
+    bases = [BASE] if BASE else CANDIDATES
+    last = {'ok': False, 'msg_cn': '无可用地址'}
+    for b in bases:
+        data = json.dumps(payload or {}).encode('utf-8')
+        req = urllib.request.Request(b + path, data=data, method='POST')
+        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:
+                BASE = b
+                return json.loads(r.read().decode('utf-8'))
+        except urllib.error.HTTPError as e:
+            try:
+                BASE = b
+                return json.loads(e.read().decode('utf-8'))
+            except Exception:
+                last = {'ok': False, 'msg_cn': 'HTTP %s' % e.code}
+        except Exception as e:
+            last = {'ok': False, 'msg_cn': str(e)}
+    return last
+
+r = call('/api/login', {'username': os.environ['LOGIN_USER'],
+                        'password': os.environ['LOGIN_PASS']})
+if not r.get('ok'):
+    print('  登录失败:%s' % r.get('msg_cn')); sys.exit(1)
+tok = (r.get('data') or {}).get('token')
+print('  已登录(%s)' % BASE)
+
+lan = os.environ['LAN_IFACE']; wan = os.environ['WAN_IFACE']
+patch = {
+  'system': {'lan_iface': lan, 'lan_address': os.environ['LAN_CIDR'],
+             'gateway': os.environ['GATEWAY'], 'wan_iface': wan,
+             'wan_present': bool(wan)},
+  'nft_v4': {'lan_ifaces': [lan], 'wan_iface': wan or 'ppp0'},
+  'nft_v6': {'lan_ifaces': [lan], 'wan_iface': wan or 'ppp0'},
+  'radvd':  {'iface': lan},
+  'dhcpv6': {'lan_iface': lan},
+  'dnsmasq': {'option_gateway': os.environ['GATEWAY'],
+              'option_dns': os.environ['GATEWAY'],
+              'dns_custom': os.environ['DNS_LIST'],
+              'dhcp_enabled': bool(os.environ.get('POOL_START'))},
+}
+if os.environ.get('POOL_START'):
+    patch['dnsmasq'].update({'pool_start': os.environ['POOL_START'],
+                             'pool_end': os.environ['POOL_END'],
+                             'pool_netmask': '255.255.255.0'})
+if os.environ.get('PPP_USER'):
+    patch['pppoe'] = {'username': os.environ['PPP_USER'],
+                      'password': os.environ.get('PPP_PASS', ''),
+                      'iface': wan or '', 'saved_only': True}
+for mod, data in patch.items():
+    rr = call('/api/config', {'module': mod, 'data': data}, tok)
+    print('  %-8s %s' % (mod, '已保存' if rr.get('ok') else '失败:%s' % rr.get('msg_cn')))
+PY
+
+  step "6/7 安全红线复核"
+  assert_no_network_daemons
+  if ss -lnt 2>/dev/null | grep -q ':5900\b'; then ok "RealVNC 5900 仍在监听(未被影响)"
+  else warn "5900 未监听 —— 请确认 RealVNC 状态(本脚本未做任何操作)"; fi
+  [ -f /etc/drouter/BUILD_MODE ] && rm -f /etc/drouter/BUILD_MODE && ok "已退出构建保护模式"
+
+  step "7/7 完成"
+  local ip="${LAN_CIDR%%/*}"
+  ok "安装完成"
+  say "  管理地址:https://${ip}:${WEB_PORT}/"
+  say "  管理员:${ADM_USER} / ${ADM_PASS:+(已设置)}${ADM_PASS:-admin123(请尽快修改)}"
+  say ""
+  say "  ${C_DIM}提示:以上只写入了配置,尚未应用到系统。${C_RESET}"
+  say "  ${C_DIM}请在界面的「应用配置」里确认后再下发,避免影响现有网络。${C_RESET}"
+}
+
+# =============================================================================
+cmd_uninstall() {
+  need_root uninstall
+  guard_build_mode
+  local PURGE=0
+  [ "${1:-}" = "--purge" ] && PURGE=1
+
+  step "停止并禁用服务"
+  for u in $UNIT_RESCUE $UNIT_WEB $UNIT_SNAP $UNIT_LOGD $UNIT_SHELLD $UNIT_HELPD; do
+    systemctl stop "$u" 2>/dev/null || true
+    systemctl disable "$u" 2>/dev/null || true
+  done
+  systemctl daemon-reload
+
+  step "移除 systemd 单元与系统配置"
+  rm -f /etc/systemd/system/drouter-*.service /etc/systemd/system/drouter-*.timer
+  rm -f /etc/sudoers.d/drouter /etc/logrotate.d/drouter
+  systemctl daemon-reload
+
+  step "移除程序目录"
+  rm -rf "$OPT"
+
+  if [ "$PURGE" = 1 ]; then
+    warn "--purge:同时删除配置、快照与日志"
+    rm -rf /etc/drouter /var/log/drouter /var/lib/drouter /run/drouter-helper
+    rm -f /etc/drouter/generated/theme.css
+    ok "已彻底清除(含用户配置与快照)"
+  else
+    ok "已卸载;保留 /etc/drouter(配置与主题)、/var/log/drouter(日志)"
+    say "  ${C_DIM}如需彻底清除:bash $0 uninstall --purge${C_RESET}"
+  fi
+  # 顺带处理 apt 装的包?不动 —— 卸载程序不应卸载用户可能共用的系统包
+  say "  ${C_DIM}注:系统软件包(dnsmasq/nftables 等)未被移除,避免影响其它服务。${C_RESET}"
+}
+
+# =============================================================================
+cmd_repair() {
+  need_root repair
+  guard_build_mode
+  local SRC="${1:-$SRC_DEFAULT}"
+  [ -d "$SRC/backend" ] || die "源码目录不存在:$SRC"
+
+  step "1/5 重新部署文件(覆盖,不影响已有配置)"
+  DROUTER_SRC="$SRC" bash "$SRC/scripts/deploy.sh" 2>&1 | tail -12
+
+  step "2/5 修复权限与属主"
+  id drouter >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin -d "$OPT" -M drouter
+  chown -R root:root "$OPT/backend" "$OPT/web"
+  chown -R drouter:drouter "$OPT/data" "$OPT/snapshots" "$OPT/certs" 2>/dev/null || true
+  chmod 750 "$OPT/data" "$OPT/snapshots" "$OPT/certs" 2>/dev/null || true
+  chmod 700 /run/drouter-helper 2>/dev/null || true
+  chmod 0440 /etc/sudoers.d/drouter
+  visudo -c -f /etc/sudoers.d/drouter >/dev/null && ok "sudoers 语法正确"
+
+  step "3/5 重建服务单元并重启管理后台"
+  systemctl daemon-reload
+  systemctl enable $UNIT_WEB >/dev/null 2>&1 || true
+  systemctl restart $UNIT_WEB
+  systemctl enable $UNIT_SNAP >/dev/null 2>&1 || true
+
+  step "4/5 校验关键文件"
+  local bad=0
+  for f 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; do
+    [ -f "$OPT/backend/$f" ] || { err "缺少 $f"; bad=1; }
+  done
+  [ -d /etc/drouter/themes ] || { mkdir -p /etc/drouter/themes; ok "已补建主题目录"; }
+  [ "$bad" = 0 ] && ok "8 个后端模块齐全"
+
+  step "5/5 复核安全红线"
+  assert_no_network_daemons
+  ok "修复完成"
+}
+
+# =============================================================================
+cmd_update() {
+  need_root update
+  guard_build_mode
+  local SRC="${1:-$SRC_DEFAULT}"
+  [ -d "$SRC/backend" ] || die "源码目录不存在:$SRC"
+
+  step "1/4 更新前自动快照(便于回滚)"
+  if [ -f "$OPT/backend/drouter-snapshotd.py" ]; then
+    python3 "$OPT/backend/drouter-snapshotd.py" >/dev/null 2>&1 \
+      && ok "快照已生成" || warn "快照失败(继续更新)"
+  fi
+
+  step "2/4 备份当前版本"
+  local ts; ts=$(date +%Y%m%d-%H%M%S)
+  local BAK="/opt/drouter-backup-$ts"
+  mkdir -p "$BAK"
+  cp -a "$OPT/backend" "$BAK/" 2>/dev/null || true
+  cp -a "$OPT/web" "$BAK/" 2>/dev/null || true
+  ok "已备份到 $BAK"
+
+  step "3/4 部署新版本(配置与主题不受影响)"
+  DROUTER_SRC="$SRC" bash "$SRC/scripts/deploy.sh" 2>&1 | tail -12
+
+  step "4/4 重启并校验"
+  systemctl daemon-reload
+  systemctl restart $UNIT_WEB
+  sleep 2
+  systemctl is-active $UNIT_WEB | grep -qx active \
+    && ok "管理后台已重启" || { systemctl status $UNIT_WEB --no-pager -n 15; die "启动失败,可回滚:$BAK"; }
+  ok "更新完成(回滚:把 $BAK 里的目录拷回 $OPT 后 restart)"
+}
+
+# =============================================================================
+cmd_status() {
+  step "服务"
+  printf '  %-24s %-10s %s\n' "单元" "活动" "开机自启"
+  for u in $UNIT_WEB $UNIT_SNAP $UNIT_LOGD $UNIT_RESCUE $UNIT_SHELLD $UNIT_HELPD; do
+    printf '  %-24s %-10s %s\n' "$u" \
+      "$(systemctl is-active "$u" 2>/dev/null)" "$(systemctl is-enabled "$u" 2>/dev/null)"
+  done
+
+  step "安装"
+  if [ -d "$OPT" ]; then
+    ok "程序目录:$OPT"
+    local n=0
+    for f in "$OPT"/backend/*.py; do [ -f "$f" ] && n=$((n+1)); done
+    say "  后端模块:$n 个"
+    [ -f "$DB" ] && say "  配置库:$(du -h "$DB" | cut -f1)" || warn "配置库不存在(可能尚未启动过)"
+  else
+    warn "未安装($OPT 不存在)"
+  fi
+
+  step "端口"
+  ss -lntup 2>/dev/null | grep -E ':(8443|53|67|546|5900)\b' | sed 's/^/  /' || say "  (无相关端口)"
+
+  step "主题之家"
+  if [ -f /etc/drouter/active-theme ]; then
+    say "  当前主题:$(cat /etc/drouter/active-theme)"
+    say "  自定义主题数:$(ls -1 /etc/drouter/themes 2>/dev/null | wc -l)"
+  else warn "  主题状态文件不存在"; fi
+
+  step "安全红线"
+  if [ -f /etc/drouter/BUILD_MODE ]; then warn "构建保护模式:开启(写网络操作被禁止)"
+  else say "  构建保护模式:关闭"; fi
+  ss -lnt 2>/dev/null | grep -q ':5900\b' && say "  RealVNC 5900:正常监听" || say "  RealVNC 5900:未监听"
+}
+
+# =============================================================================
+usage() {
+  cat <<'USAGE'
+drouter 一键运维脚本
+
+用法:drouter-ctl.sh <命令> [参数]
+
+  install  [源码目录]   交互式安装(询问网卡 / LAN IP 网段 / 网关 / DNS / 管理员账号)
+                        非交互可用环境变量 NONINTERACTIVE=1 并配合默认值
+  uninstall [--purge]   卸载。默认保留 /etc/drouter 配置与日志;--purge 彻底清除
+  repair   [源码目录]   修复:重部署 + 修权限 + 重建单元 + 校验
+  update   [源码目录]   更新:先快照备份,再部署新版本,配置与主题保留
+  status                状态:服务 / 端口 / 版本 / 主题 / 安全红线
+
+安全约束:本脚本绝不启用 dnsmasq/radvd 等会抢端口的服务,绝不触碰 RealVNC 5900。
+USAGE
+}
+
+case "${1:-}" in
+  install)   shift; cmd_install "$@" ;;
+  uninstall) shift; cmd_uninstall "$@" ;;
+  repair)    shift; cmd_repair "$@" ;;
+  update)    shift; cmd_update "$@" ;;
+  status)    shift; cmd_status "$@" ;;
+  *)         usage; exit 1 ;;
+esac
diff --git a/scripts/install-base.sh b/scripts/install-base.sh
new file mode 100644
index 0000000..64b78da
--- /dev/null
+++ b/scripts/install-base.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# Debian13 主路由系统 —— 底座安装脚本 v2(干净环境版)
+#
+# 安全约束(重要):
+#   1. 安装期间禁止任何服务自动启动(policy-rc.d 返回 101)
+#   2. 不触碰 NetworkManager(ens18 的网络保持原样)
+#   3. 不触碰 RealVNC / LightDM / X 会话
+#   4. 不加载任何 nftables 生效规则
+set -e
+export DEBIAN_FRONTEND=noninteractive
+
+MARK=/etc/drouter-build-phase
+echo "=== 阶段 1/6:进入构建保护模式 ==="
+mkdir -p "$(dirname "$MARK")"
+printf 'build' > "$MARK"
+
+# 阻止一切服务在安装期间自动启动/重启
+printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
+chmod +x /usr/sbin/policy-rc.d
+
+# 记录 RealVNC 状态用于事后校验
+echo "--- 安装前 RealVNC 状态 ---"
+systemctl is-active vncserver-x11-serviced 2>/dev/null || true
+ss -lnt 2>/dev/null | grep ':5900' || echo "5900 未监听"
+echo "--- 安装前网络 ---"
+ip -4 -o addr show ens18 2>/dev/null || echo "(ens18 不存在,跳过)"
+
+echo "=== 阶段 2/6:apt update ==="
+apt-get update -qq 2>&1 | tail -3
+
+echo "=== 阶段 3/6:安装软件包 ==="
+# 注意:这里刻意不含 samba / nfs-kernel-server / docker.io ——
+# 它们装完会自动拉起常驻服务,属于用户在界面上按需安装的组件,
+# 不应在「装底座」这一步就被带起来。
+PACKAGES="
+ppp pppoe pppoeconf
+dnsmasq radvd
+nftables
+miniupnpd chrony
+ethtool iperf3 mtr-tiny traceroute dnsutils conntrack
+bridge-utils vlan iproute2
+sqlite3 logrotate rsyslog
+curl wget
+python3
+exfatprogs ntfs-3g dosfstools xfsprogs btrfs-progs f2fs-tools
+etherwake zip unzip p7zip-full iputils-ping vim-tiny
+"
+apt-get install -y --no-install-recommends $PACKAGES 2>&1 | tail -25
+
+echo "=== 阶段 4/6:确保新装服务处于停止+禁用 ==="
+for s in dnsmasq radvd dhcpcd miniupnpd chrony rsyslog; do
+  systemctl stop "$s" 2>/dev/null || true
+  systemctl disable "$s" 2>/dev/null || true
+done
+
+echo "=== 阶段 5/6:退出保护模式 ==="
+rm -f /usr/sbin/policy-rc.d
+
+echo "=== 阶段 6/6:校验 ==="
+echo "--- RealVNC 状态 ---"
+systemctl is-active vncserver-x11-serviced 2>/dev/null || true
+ss -lnt 2>/dev/null | grep ':5900' || echo "5900 未监听(异常!)"
+echo "--- 网络(应与安装前一致)---"
+ip -4 -o addr show ens18 2>/dev/null || echo "(ens18 不存在,跳过)"
+echo "--- 服务状态 ---"
+for s in dnsmasq radvd miniupnpd chrony NetworkManager; do
+  printf "%-16s active=%-10s enabled=%s\n" "$s" \
+    "$(systemctl is-active $s 2>/dev/null)" "$(systemctl is-enabled $s 2>/dev/null)"
+done
+echo "--- 端口占用 ---"
+ss -lntup 2>/dev/null | grep -E ':(53|67|546|5900)\b' || echo "(53/67/546 均未被占用,符合预期)"
+echo "INSTALL_DONE"
diff --git a/scripts/make-offline-deps.sh b/scripts/make-offline-deps.sh
new file mode 100644
index 0000000..38f9c3c
--- /dev/null
+++ b/scripts/make-offline-deps.sh
@@ -0,0 +1,324 @@
+#!/bin/bash
+# =============================================================================
+# 离线依赖打包:把 drouter 的全部依赖(含传递依赖)预下载成一个自洽目录,
+# 供**没有外网**的目标机离线安装。
+#
+#   bash scripts/make-offline-deps.sh [输出目录]
+#
+# 产出目录结构:
+#   debs/         所有依赖的 .deb(含 libc6 这类最底层传递依赖)
+#   Packages.gz   由 dpkg-scanpackages 生成的本地索引(没有就退化为无索引模式)
+#   install.sh    离线安装脚本(先装依赖,再装 drouter 本体)
+#
+# 为什么不能只 apt-get download 一串包名:
+#   `apt-get download a b c` 只下载**指定**的那些包,不拉传递依赖。
+#   目标机上 dpkg -i *.deb 会因为 libxxx 没装而大面积失败。必须先把
+#   apt-cache depends --recurse 展开成闭包,再整体下载。
+#
+# 为什么用 apt-cache depends 而不是 apt-rdepends:
+#   apt-rdepends 不是 Debian 基础系统自带的(要额外装),而 apt-cache 一定有。
+#   `apt-cache depends --recurse --no-recommends --no-suggests ...` 的输出
+#   已经能拿到完整闭包,不必引入外部依赖。
+#
+# 用法(目标机):
+#   把整个目录拷过去,执行
+#     sudo bash install.sh drouter_1.0.0_all.deb
+# =============================================================================
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+OUT="${1:-$HERE/dist/offline-deps}"
+
+# 与 backend/drouter-helper.py 的 DEPS「必需项 + 常用可选项」保持一致。
+# 手动同步点:改了 DEPS 就要同步这里,_dev/t-deps.py 会核对关键包是否都在。
+# 注意:samba / nfs-kernel-server / docker.io / cups / snmpd / smartmontools / xfce4
+# 会拉起常驻服务或体积巨大,不进离线包,用户在界面上按需安装即可。
+DEPS="python3 nftables dnsmasq radvd dhcpcd-base ppp pppoe pppoeconf miniupnpd chrony
+ethtool iperf3 mtr-tiny traceroute dnsutils conntrack bridge-utils iproute2
+sqlite3 logrotate rsyslog curl wget procps sudo openssl ipset kmod systemd
+adduser ca-certificates tcpdump jq htop lm-sensors dmidecode
+e2fsprogs exfatprogs ntfs-3g dosfstools xfsprogs btrfs-progs f2fs-tools
+util-linux usbutils tar coreutils hostname udev
+etherwake zip unzip p7zip-full iputils-ping vim-tiny"
+
+command -v apt-get >/dev/null 2>&1 || { echo "需要 apt-get(Debian/Ubuntu)"; exit 1; }
+
+echo "== 1/4 准备目录 =="
+rm -rf "$OUT"
+mkdir -p "$OUT/debs"
+cd "$OUT/debs"
+
+echo "== 2/4 展开传递依赖闭包 =="
+# --no-recommends / --no-suggests:把可选依赖排除掉,否则 4GB 机器光依赖就装爆。
+# 过滤规则:只保留顶格的包名行(依赖关系行前面有空格和「依赖:」等前缀)。
+#
+# ⚠️ 这里有一个必须显式补的坑:`Essential: yes` 的包**不会**出现在
+# apt-cache depends --recurse 的输出里 —— apt 认定「任何 Debian 系统都必有它们」,
+# 所以从不把它们列为别人 的依赖。实测缺失的有:dash / diffutils / libc-bin /
+# grep / findutils / base-files / base-passwd / dpkg / coreutils / sed / tar …
+# 在正常的 Debian 上装没问题(本机已有了),但如果目标机是**刚 debootstrap
+# 出来的极简根**或换了个发行版,缺了它们的表现是:
+#     dpkg: error: 3 expected programs not found in PATH or not executable
+#     (缺 sh / diff / ldconfig —— 分别来自 dash / diffutils / libc-bin)
+# 而且这个错在装 drouter 本体时才暴露,排查起来很费劲。所以显式补一份。
+ESSENTIAL="${DROUTER_ESSENTIAL:-base-files base-passwd bash coreutils dash diffutils
+dpkg grep gzip findutils hostname libc-bin libc6 libgcc-s1 libselinux1 libpcre2-8-0
+libacl1 libattr1 libcap2 libaudit1 libaudit-common sed tar zlib1g liblzma5 libbz2-1.0
+xz-utils libtinfo6 libreadline8 libmount1 libblkid1 libsmartcols1 libuuid1
+libsystemd0 libudev1 libzstd1 libcap-ng0 libcrypt1 libgmp10 libnettle8 libhogweed6
+libidn2-0 libunistring5 libtasn1-6 libp11-kit0 libffi8 libgnutls30 libgpg-error0
+libgcrypt20 libseccomp2 libstdc++6 libmd0 libedit2 libncursesw6 libfdisk1
+libapparmor1 libxxhash0 libcom-err2 libext2fs2 libjansson4 libargon2-1
+debianutils debconf init-system-helpers mount util-linux passwd login.defs
+sysvinit-utils mawk perl-base readline-common sensible-utils}"
+
+# 再补一层「基线库」。踩过的坑:apt 自己的依赖链(libapt-pkg / liblz4-1 /
+# libgnutls30 / libnettle8 / libstdc++6 …)**不会**被 drouter 的依赖闭包带进来 ——
+# 因为 apt 在任何 Debian 上都已装好,它的依赖也被视为已满足。
+# 后果是:在**没有 apt 的干净根 / 换发行版**里跑 install.sh 时,
+# 「建立本地仓库索引」这一步会直接报
+#     apt-get: error while loading shared libraries: liblz4.so.1
+# 然后整条安装链路退化成 dpkg 乱装。补上这几个库成本极小(几百 KB),
+# 换来的是「任何 Debian 用户态环境都能装」。
+BASELIBS="${DROUTER_BASELIBS:-liblz4-1 libapt-pkg7.0 libgnutls30 libnettle8
+libhogweed6 libstdc++6 libgmp10 libgnutls30 libunistring5 libidn2-0 libp11-kit0
+libffi8 libtasn1-6 libssl3t64 zlib1g liblzma5 libzstd1 libbz2-1.0 libpcre2-8-0
+libselinux1 libmount1 libblkid1 libuuid1 libcap2 libcap-ng0 libaudit1
+libsystemd0 libudev1 libseccomp2 libgpg-error0 libgcrypt20}"
+
+# ---------------------------------------------------------------------------
+# 闭包求解:用 apt 自己算,不要用 `apt-cache depends --recurse` 做文本展开。
+#
+# 踩过的坑(很隐蔽,装到最后一步才炸):
+#   `apt-cache depends --recurse` 会把**虚拟包的每一个提供者**都展开成一行。
+#   systemd / systemd-standalone-sysusers / opensysusers 三者都 Provides
+#   `systemd-sysusers`,同时又互相 Conflicts。文本展开会把三家**全**收进来,
+#   下载没问题;但装上时 apt 解冲突发现无解:
+#     E: Unmet dependencies. Try 'apt --fix-broken install'
+#     systemd : 预依赖 libsystemd-shared 但是它将不会被安装
+#   看起来像「离线包缺包」,实际是一堆互相冲突的替代品被硬凑在一起。
+#
+#   `apt-get install --print-uris -y <目标>` 走的是**真正的依赖求解器**:
+#   它会按 Conflicts/Provides 选一套自洽的方案,只给出确实要装的包。
+#   这才是「离线包」该用的闭包来源。
+# ---------------------------------------------------------------------------
+CLOSURE_FILE="$OUT/.closure"
+: > "$CLOSURE_FILE"
+
+# ⚠️ 关键:求解必须在一个**空的 dpkg status** 上做,不能直接用宿主机的状态。
+# 踩过的坑:在装了一堆东西的开发机上求解,apt 会认为「这些依赖已满足」而不列出来
+# (libicu76 / liburcu8t64 / python3-dbus 就是这么漏的 —— 宿主机上有,
+#  于是不进闭包;到了干净目标机上就变成
+#  `Depends: libicu76 but it is not installable`,整包装不上)。
+# 所以先造一个只有「空 status」的临时 apt 根,让 apt 以为什么都没装。
+EMPTYROOT="$OUT/.solveroot"
+rm -rf "$EMPTYROOT"
+mkdir -p "$EMPTYROOT/var/lib/dpkg" "$EMPTYROOT/var/lib/apt/lists/partial" \
+         "$EMPTYROOT/var/cache/apt/archives/partial" "$EMPTYROOT/etc/apt/apt.conf.d" \
+         "$EMPTYROOT/etc/apt/preferences.d" "$EMPTYROOT/etc/apt/sources.list.d" \
+         "$EMPTYROOT/etc/apt/trusted.gpg.d" "$EMPTYROOT/usr/share/keyrings" \
+         "$EMPTYROOT/var/lib/dpkg/info" "$EMPTYROOT/var/lib/dpkg/updates"
+
+# 签名校验要用到 keyring,否则 apt 会拒绝所有源:
+#   E: 仓库 “…” 没有数字签名。  (trusted.gpg.d 不是目录)
+# 只借「公钥」目录即可,不复制 status —— status 必须是空的,那是本步的目的。
+cp -f /etc/apt/trusted.gpg.d/* "$EMPTYROOT/etc/apt/trusted.gpg.d/" 2>/dev/null || true
+cp -f /usr/share/keyrings/* "$EMPTYROOT/usr/share/keyrings/" 2>/dev/null || true
+# apt 自身仍从宿主机运行,签名校验走宿主 keyring 也通常可行,这里只是兜底。
+: > "$EMPTYROOT/var/lib/dpkg/status"
+touch "$EMPTYROOT/etc/apt/apt.conf.d/.keep"
+
+# 复用宿主机的源列表(只是用来读 Packages 索引,不会真的下载)
+[ -f /etc/apt/sources.list ] && cp -f /etc/apt/sources.list "$EMPTYROOT/etc/apt/" 2>/dev/null || true
+mkdir -p "$EMPTYROOT/etc/apt/sources.list.d"
+cp -f /etc/apt/sources.list.d/*.list "$EMPTYROOT/etc/apt/sources.list.d/" 2>/dev/null || true
+cp -f /etc/apt/sources.list.d/*.sources "$EMPTYROOT/etc/apt/sources.list.d/" 2>/dev/null || true
+
+SOLVER_OPTS=(
+  -o "Dir=$EMPTYROOT/"
+  -o "Dir::State=$EMPTYROOT/var/lib/apt"
+  -o "Dir::State::status=$EMPTYROOT/var/lib/dpkg/status"
+  -o "Dir::Cache=$EMPTYROOT/var/cache/apt"
+  -o "Dir::Etc::sourcelist=$EMPTYROOT/etc/apt/sources.list"
+)
+echo "  在空 status 上求解依赖(模拟全新目标机)…"
+# update 失败不致命:说明宿主机源列表不可用,但本地 Packages 索引可能还在,
+# 后面的 -s 求解会自己报出真正的问题,这里不吞错也不中止。
+apt-get "${SOLVER_OPTS[@]}" update -qq >/dev/null 2>&1 || \
+  echo "  (索引刷新失败,继续用已有索引求解)"
+
+# 求解:只列 Inst 行的包名。--no-install-recommends 与发布口径一致。
+if apt-get "${SOLVER_OPTS[@]}" -s --no-install-recommends -y install $DEPS \
+      >"$OUT/.sim.txt" 2>"$OUT/.sim.err"; then
+  sed -n 's/^Inst \([^ ]*\) .*/\1/p' "$OUT/.sim.txt" >> "$CLOSURE_FILE"
+  echo "  依赖求解成功(apt 求解器 / 空 status)"
+else
+  echo "  ⚠ 空 status 求解失败,回退到宿主状态求解"
+  if apt-get -s --no-install-recommends -y install $DEPS >"$OUT/.sim.txt" 2>/dev/null; then
+    sed -n 's/^Inst \([^ ]*\) .*/\1/p' "$OUT/.sim.txt" >> "$CLOSURE_FILE"
+    echo "  已用宿主状态求解"
+  else
+    echo "  ⚠ 仍失败,回退到文本展开(可能含冲突替代品)"
+    apt-cache depends --recurse --no-recommends --no-suggests \
+        --no-conflicts --no-breaks --no-replaces --no-enhances \
+        $DEPS 2>/dev/null \
+      | grep -E '^[a-zA-Z0-9][a-zA-Z0-9.+_-]*$' >> "$CLOSURE_FILE"
+  fi
+fi
+rm -f "$OUT/.sim.txt" "$OUT/.sim.err"
+
+ALL=$(cat "$CLOSURE_FILE" | sort -u)
+ALL="$ALL $ESSENTIAL $BASELIBS"
+ALL=$(echo $ALL | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')
+
+# 显式排掉已知的「冲突替代品」:它们是 systemd-sysusers 的多余提供者,
+# 只要 systemd 在,就不能同时出现。留着必然导致上面那个 Unmet dependencies。
+for drop in systemd-standalone-sysusers opensysusers systemd-standalone-tmpfiles; do
+  if [ -n "$(ls "$OUT"/debs/${drop}_*.deb 2>/dev/null)" ]; then
+    echo "  (移除冲突替代品:$drop)"
+    rm -f "$OUT"/debs/${drop}_*.deb
+  fi
+  ALL=$(echo "$ALL" | tr ' ' '\n' | grep -vx "$drop" | tr '\n' ' ')
+done
+
+# 过滤掉纯粹是虚拟/元包而下载不到实体的名字
+REAL=""
+for p in $ALL; do
+  if apt-get download --print-uris "$p" >/dev/null 2>&1; then
+    REAL="$REAL $p"
+  fi
+done
+ALL="$REAL"
+echo "  闭包内可下载的包:$(echo $ALL | wc -w) 个"
+
+echo "== 3/4 下载(只下不装)=="
+# 分批下载:命令行过长会在老 shell 上踩 ARG_MAX,且失败时更容易定位是哪个包。
+# 陷阱:apt-get download 的落地目录取自**进程的当前目录**,不是 -o 参数;
+# 早期写法用 `xargs -a partfile apt-get download`——xargs 在部分实现下会以
+# 自己的 CWD 起子进程,结果 apt 把 .deb 往 / 根目录写,全部「权限不够」。
+# 改成显式 for 循环 + 显式 cd,且每批都重新确认 CWD,绝不让 xargs 起进程。
+DEBDIR="$OUT/debs"
+echo "$ALL" | tr ' ' '\n' | grep -v '^$' > "$OUT/.allpkgs"
+BATCH=40
+total=$(wc -l < "$OUT/.allpkgs")
+i=0
+while [ "$i" -lt "$total" ]; do
+  batch=$(sed -n "$((i+1)),$((i+BATCH))p" "$OUT/.allpkgs")
+  # shellcheck disable=SC2086
+  ( cd "$DEBDIR" && apt-get download $batch 2>&1 | grep -E '^E:' | head -3 ) || true
+  i=$((i + BATCH))
+done
+rm -f "$OUT/.allpkgs"
+N=$(ls -1 "$DEBDIR"/*.deb 2>/dev/null | wc -l)
+echo "  已下载 $N 个 .deb(约 $(du -sh "$DEBDIR" | cut -f1))"
+[ "$N" -gt 0 ] || { echo "✘ 一个都没下到,检查网络/源"; exit 1; }
+
+echo "== 4/4 生成本地索引与安装脚本 =="
+# 有 dpkg-scanpackages(dpkg-dev 提供)就生成真正的本地仓库索引,
+# 这样目标机可以用 `apt-get install -o Dir::Etc::sourcelist=... drouter` 一次装齐。
+cd "$OUT"
+if command -v dpkg-scanpackages >/dev/null 2>&1; then
+  # ⚠️ 索引必须落在 **debs/** 里,和 install.sh 里那条
+  #     `deb [trusted=yes] file:$HERE/debs ./` 对上。
+  # 踩过的坑:原来生成到 $OUT/Packages.gz,而 apt 去 $OUT/debs/ 找 Packages,
+  # 结果 `E: Failed to fetch file:.../debs/./Packages  File not found`,
+  # 整条「本地仓库」链路静默退化成 dpkg -i 乱装。
+  ( cd debs && dpkg-scanpackages -m . /dev/null 2>/dev/null > Packages )
+  # 同时给一份 gzip 版:apt 优先用 Packages.gz,没有才用 Packages。
+  # 两个都放,避免不同 apt 版本偏好不一致又踩一次。
+  [ -s debs/Packages ] && gzip -9c debs/Packages > debs/Packages.gz
+  echo "  已生成 debs/Packages(+.gz)(本地仓库索引)"
+  HAS_IDX=1
+else
+  echo "  未装 dpkg-dev,跳过 Packages,改用 dpkg -i 逐个安装"
+  HAS_IDX=0
+fi
+
+cat > "$OUT/install.sh" <<'EOS'
+#!/bin/bash
+# =============================================================================
+# drouter 离线安装脚本
+#
+#   sudo bash install.sh [drouter_x.y.z_all.deb]
+#
+# 不传参数则在当前目录里自动找 drouter_*.deb。
+# 做三件事:① 装齐全部依赖;② 装 drouter 本体;③ 打印后续步骤。
+# =============================================================================
+set -e
+HERE="$(cd "$(dirname "$0")" && pwd)"
+PKG="${1:-}"
+
+yesno() { printf '  → %s' "$1"; }
+
+if [ "$(id -u)" -ne 0 ]; then
+  # 先切到 sudo 再回来执行自己,免得用户忘了加 sudo 跑到一半才失败
+  exec sudo -E bash "$0" "$@"
+fi
+
+# 1) 找本体
+if [ -z "$PKG" ]; then
+  PKG=$(ls -1 "$HERE"/drouter_*_all.deb "$HERE"/*/drouter_*_all.deb 2>/dev/null | head -1 || true)
+fi
+[ -n "$PKG" ] && [ -f "$PKG" ] || PKG="$HERE/$PKG"
+[ -f "$PKG" ] || { echo "✘ 找不到 drouter 安装包(用法:bash install.sh drouter_1.0.0_all.deb)"; exit 1; }
+echo "安装包:$PKG"
+
+echo ""
+echo "== 1/3 安装离线依赖 =="
+# 优先走本地仓库模式:让 apt 自己算依赖顺序,比 dpkg -i 一通乱装可靠得多。
+if [ -f "$HERE/Packages.gz" ] && command -v apt-get >/dev/null 2>&1; then
+  echo "  使用本地仓库索引安装(推荐)"
+  tmp_list=$(mktemp)
+  trap 'rm -f "$tmp_list"' EXIT
+  cat > "$tmp_list" <&1 | tail -2 || true
+  # 先把闭包里的包整体装掉(--no-install-recommends 避免又去网上拉推荐包)
+  DEBLIST=$(ls -1 "$HERE"/debs/*.deb 2>/dev/null | sed 's|.*/||; s|_.*||' | sort -u)
+  # shellcheck disable=SC2086
+  apt-get -o Dir::Etc::sourcelist="$tmp_list" \
+          -o Dir::Etc::sourceparts=/dev/null \
+          -o APT::Get::List-Cleanup=0 \
+          -o Acquire::Languages=none \
+          --no-install-recommends -y install $DEBLIST 2>&1 | tail -6 || true
+else
+  echo "  无本地索引,逐个 dpkg -i(可能报依赖未满足,最后会用 -f 补齐)"
+  dpkg -i "$HERE"/debs/*.deb 2>&1 | tail -4 || true
+  apt-get --no-download -y -f install 2>&1 | tail -4 || true
+fi
+
+echo ""
+echo "== 2/3 安装 drouter 本体 =="
+dpkg -i "$PKG" 2>&1 | tail -6 || apt-get --no-download -y -f install 2>&1 | tail -6
+
+echo ""
+echo "== 3/3 完成 =="
+echo "  · 查看状态:sudo drouter-ctl status"
+echo "  · 打开面板:https://<本机IP>:8443/   (默认账号 admin / admin123,请尽快改掉)"
+echo "  · 首次部署建议在界面里先走「新手向导」"
+EOS
+chmod +x "$OUT/install.sh"
+
+# 把本体也放进离线目录,方便整包分发
+if ls "$HERE"/dist/drouter_*.deb >/dev/null 2>&1; then
+  cp -f "$HERE"/dist/drouter_*.deb "$OUT/" 2>/dev/null || true
+fi
+
+# 清掉构建期的中间产物。踩过的坑:`.closure`(包名列表)和 `.solveroot/`
+# (空 status 求解用的临时 apt 根)会被原样打进 tar 包,用户解包后会看到两个
+# 莫名其妙的隐藏目录 —— 既占地方又让人困惑「这是不是漏了什么」。
+rm -rf "$OUT/.solveroot" "$OUT/.closure" "$OUT/.allpkgs" \
+       "$OUT/.sim.txt" "$OUT/.sim.err" 2>/dev/null || true
+
+echo ""
+echo "======================================================================"
+echo "离线包已生成:$OUT"
+echo "  体积:$(du -sh "$OUT" | cut -f1)   (依赖 $(ls -1 "$OUT"/debs/*.deb 2>/dev/null | wc -l) 个 deb)"
+echo "  分发:把整个目录拷到目标机,执行"
+echo "        sudo bash install.sh drouter_1.0.0_all.deb"
+echo "======================================================================"
diff --git a/scripts/regression.sh b/scripts/regression.sh
new file mode 100644
index 0000000..f89edc6
--- /dev/null
+++ b/scripts/regression.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# drouter 八模块回归自检(构建保护模式下应全部写成到磁盘,不启动服务)
+B=http://127.0.0.1:8080
+C="curl -s"
+TOK=$($C $B/api/login -X POST -H 'Content-Type: application/json' \
+      -d '{"username":"admin","password":"admin123"}' \
+      | python3 -c 'import sys,json;d=json.load(sys.stdin);print((d.get("data") or {}).get("token",""))')
+if [ -z "$TOK" ]; then echo "登录失败"; exit 1; fi
+echo "token ok: ${TOK:0:12}..."
+
+A(){ $C $B/api/apply -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' -d "$1" \
+     | python3 -c 'import sys,json;d=json.load(sys.stdin);print("ok=",d.get("ok"),"|",d.get("msg_cn") or d.get("code") or "")'; }
+
+echo "--- 1 dnsmasq ---"
+A '{"module":"dnsmasq","data":{"lan_iface":"ens18","lan_ip":"192.168.7.3","dhcp_enabled":false,"pool_start":"192.168.7.100","pool_end":"192.168.7.200","lease_time":43200,"dns_mode":"custom","dns_custom":"223.5.5.5,119.29.29.29","options":[{"enabled":true,"code":"6","value":"192.168.7.3"},{"enabled":true,"code":"3","value":"192.168.7.3"}]}}'
+echo "--- 2 nft_v4 ---"
+A '{"module":"nft_v4","data":{"wan_iface":"","lan_iface":"ens18","allow_estab":true,"icmp":true,"input_policy":"drop"}}'
+echo "--- 3 nft_v6 ---"
+A '{"module":"nft_v6","data":{"wan_iface":"","lan_iface":"ens18","allow_estab":true,"icmpv6":true,"input_policy":"drop"}}'
+echo "--- 4 radvd ---"
+A '{"module":"radvd","data":{"lan_iface":"ens18","prefix":"fd00:7::/64","adv_link_mtu":1500}}'
+echo "--- 5 dhcpv6 ---"
+A '{"module":"dhcpv6","data":{"wan_iface":"","lan_iface":"ens18","pd_len":60}}'
+echo "--- 6 upnp ---"
+A '{"module":"upnp","data":{"enable":false,"lan_iface":"ens18","wan_iface":""}}'
+echo "--- 7 ntp ---"
+A '{"module":"ntp","data":{"servers":["ntp.aliyun.com","time.cloudflare.com"]}}'
+echo "--- 8 ppp ---"
+A '{"module":"ppp","data":{"iface":"ens18","username":"test12345","password":"pass67890","isp":"ct","mtu":1492}}'
+
+echo "=== 生成文件落盘检查 ==="
+ls -la /etc/dnsmasq.d/drouter.conf /etc/nftables.d/drouter-v4.nft /etc/nftables.d/drouter-v6.nft \
+       /etc/radvd.conf /etc/dhcpcd.conf /etc/miniupnpd/miniupnpd.conf /etc/chrony/chrony.conf \
+       /etc/ppp/peers/drouter-wan 2>&1
+
+echo "=== 安全不变量 ==="
+echo -n "dnsmasq: "; systemctl is-active dnsmasq 2>&1
+echo -n "radvd:   "; systemctl is-active radvd 2>&1
+echo -n "dhcpcd:  "; systemctl is-active dhcpcd 2>&1
+echo -n "miniupnpd:"; systemctl is-active miniupnpd 2>&1
+echo -n "chrony:  "; systemctl is-active chrony 2>&1
+echo -n "53端口:  "; ss -lnu | grep -c ':53 ' || true
+echo -n "67端口:  "; ss -lnu | grep -c ':67 ' || true
+echo -n "VNC5900: "; ss -lnt | grep -c ':5900'
+echo -n "BUILD_MODE: "; test -f /etc/drouter/BUILD_MODE && echo "存在(保护中)" || echo "不存在"
+echo -n "ens18:   "; ip -4 -o addr show ens18 | awk '{print $4}'
+echo "=== nft 规则 ==="
+nft list ruleset 2>/dev/null | head -5 || echo "nftables 表为空"
+echo "REGRESSION_DONE"
diff --git a/scripts/test-frontend-contract.sh b/scripts/test-frontend-contract.sh
new file mode 100644
index 0000000..cff6075
--- /dev/null
+++ b/scripts/test-frontend-contract.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+# 前端契约回归:验证登录能拿到 token,且每个前端页面依赖的接口都返回 200
+B=http://127.0.0.1:8080
+echo "=== 1. 登录应返回真实 token ==="
+RESP=$(curl -s $B/api/login -X POST -H 'Content-Type: application/json' \
+      -d '{"username":"admin","password":"admin123"}')
+echo "$RESP" | python3 -m json.tool --no-ensure-ascii
+TOK=$(echo "$RESP" | python3 -c 'import sys,json;print((json.load(sys.stdin).get("data") or {}).get("token") or "")')
+if [ -z "$TOK" ]; then echo "❌ token 为空 —— 登录仍坏"; exit 1; fi
+echo "✅ token = ${TOK:0:16}..."
+
+echo
+echo "=== 2. 用该 token 请求各页面依赖接口 ==="
+FAIL=0
+check() {
+  local ep="$1" m="${2:-GET}" body="${3:-}"
+  local args=(-s -o /tmp/o.json -w '%{http_code}' "$B$ep" -H "X-Token: $TOK")
+  [ "$m" = POST ] && args+=(-X POST -H 'Content-Type: application/json' -d "${body:-{\}}")
+  local code=$(curl "${args[@]}")
+  local ok=$(python3 -c 'import json;print(json.load(open("/tmp/o.json")).get("ok"))' 2>/dev/null)
+  printf "  %-22s %-5s HTTP=%-4s ok=%-6s %s\n" "$ep" "$m" "$code" "$ok" \
+    "$( [ "$code" = 200 ] && [ "$ok" = True ] && echo ✔ || { echo ✘; FAIL=1; } )"
+}
+# 概览页
+check /api/sysinfo;  check /api/services; check /api/ifaces
+check /api/ipv6;     check /api/routes
+# 网卡/WAN/LAN
+check /api/iface/meta; check /api/config
+# 防火墙/日志/诊断/电源/用户/升级
+check /api/nft; check /api/leases; check /api/users; check /api/ntp
+check /api/logs; check /api/journal; check /api/audit
+check /api/snapshot/list; check /api/upgrade/info
+# 构建保护模式(前端用 GET)
+check /api/buildmode GET
+check /api/pppoe/saved
+# 新增:Web 端口 / 网卡配置方式 / 拨号实时日志
+check /api/webport
+check /api/iface_method
+check /api/ppp/log
+# 新增:IPv6 连通性测试工具
+check /api/diag POST '{"tool":"ipv6test","which":"cn"}'
+# 新增:自动快照策略 / 紧急救援通道
+check /api/snapshot/auto
+check /api/rescue
+# 新增批次(#17–#22):通用 API / 依赖自检 / NAT / 软加速 / VLAN / WOL / 文件管理
+check /api/openapi
+check /api/examples
+check /api/deps/check
+check /api/vlan
+check /api/wol
+check /api/nat/last
+check /api/accel POST '{"op":"get"}'
+# 新增批次(#23–#25):PPPoE 多拨 / QoS 智能限速 / DPI 应用识别
+check /api/pppoe-multi
+check /api/pppoe-multi POST '{"op":"get"}'
+check /api/pppoe-multi/log POST '{"idx":1}'
+check /api/qos
+check /api/qos POST '{"op":"get"}'
+check /api/dpi
+check /api/dpi POST '{"op":"get"}'
+check /api/dpi POST '{"op":"plan","which":"apt"}'
+check /api/dpi/log
+check '/api/files/list?path=/etc/drouter'
+check '/api/files/read?path=/etc/drouter/snapshot.conf'
+# 写操作:只保存(不应用)
+check /api/config POST '{"module":"system","data":{"hostname":"debian-primaryrouter"}}'
+
+echo
+echo "=== 3. 关键字段核对 ==="
+curl -s $B/api/buildmode -H "X-Token: $TOK" | python3 -c '
+import sys,json;d=json.load(sys.stdin)
+print("  buildmode.data.enabled =", d["data"]["enabled"], "(解除后应为 False)")'
+curl -s $B/api/ifaces -H "X-Token: $TOK" | python3 -c '
+import sys,json;d=json.load(sys.stdin)
+for i in d["data"]:
+    if i["name"]=="lo": continue
+    n=i.get("name"); m=i.get("mac"); st=i.get("oper") or i.get("operstate") or ""
+    print("  %-8s mac=%s state=%s" % (n, m, st))'
+curl -s $B/api/routes -H "X-Token: $TOK" | python3 -c '
+import sys,json;d=json.load(sys.stdin)
+print("  默认路由:", [(r["gateway"], r["dev"]) for r in d["data"]["v4"] if r["dst"]=="default"])'
+
+echo
+[ $FAIL = 0 ] && echo "FRONTEND_CONTRACT_OK ✔ 全部接口正常" || echo "FRONTEND_CONTRACT_FAIL ❌ 存在异常接口"
diff --git a/scripts/test-pppoe-save.sh b/scripts/test-pppoe-save.sh
new file mode 100644
index 0000000..07362c6
--- /dev/null
+++ b/scripts/test-pppoe-save.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# 模拟“用户在 WEB 里填写 PPPoE 账号密码后点击保存并应用”的完整链路
+B=http://127.0.0.1:8080
+TOK=$(curl -s $B/api/login -X POST -H 'Content-Type: application/json' \
+      -d '{"username":"admin","password":"admin123"}' \
+      | python3 -c 'import sys,json;print((json.load(sys.stdin).get("data") or {}).get("token",""))')
+[ -z "$TOK" ] && { echo "登录失败"; exit 1; }
+
+echo "=== A. 保存 pppoe 模块配置(仅保存)==="
+curl -s $B/api/save -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
+  -d '{"module":"pppoe","data":{"iface":"ens18","username":"user@test","password":"secret999","isp":"ct","mtu":1492}}' \
+  | python3 -m json.tool --no-ensure-ascii 2>/dev/null || echo "(接口不存在,尝试 apply)"
+
+echo
+echo "=== B. 通过 apply 接口写盘(构建保护模式下 live=false)==="
+curl -s $B/api/apply -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
+  -d '{"module":"ppp","data":{"iface":"ens18","username":"user@test","password":"secret999","isp":"ct","mtu":1492},"live":false}' \
+  | python3 -m json.tool --no-ensure-ascii
+
+echo
+echo "=== C. 落盘结果 ==="
+echo "--- /etc/ppp/peers/drouter-wan ---"; cat /etc/ppp/peers/drouter-wan 2>/dev/null | head -12
+echo "--- /etc/ppp/chap-secrets ---";  cat /etc/ppp/chap-secrets 2>/dev/null
+echo "--- 权限 ---"; ls -l /etc/ppp/peers/drouter-wan /etc/ppp/chap-secrets 2>/dev/null
+
+echo
+echo "=== D. 服务是否仍未被启动(安全不变量)==="
+for s in pppd dnsmasq; do printf "  %-8s %s\n" "$s" "$(systemctl is-active $s 2>&1)"; done
+ss -lnu | grep -E ':53 |:67 ' && echo "  端口被占用(异常)" || echo "  53/67 仍空闲 ✔"
+echo "PPPOE_SAVE_PATH_OK"
diff --git a/web/app.css b/web/app.css
new file mode 100644
index 0000000..11f84cd
--- /dev/null
+++ b/web/app.css
@@ -0,0 +1,830 @@
+/* Drouter 管理台样式 —— 轻量、浅色主题、无外部依赖 */
+:root{
+  --bg:#eef2f9; --panel:#ffffff; --line:#e2e7f0; --line2:#eef1f7;
+  --txt:#161f30; --txt2:#556480; --txt3:#8894a8;
+  --pri:#1f6feb; --pri-d:#1858c4; --pri-l:#e8f0fe;
+  --ok:#12813f; --ok-l:#e3f7ea; --warn:#9a6700; --warn-l:#fff8e1;
+  --err:#c0392b; --err-l:#fdecea; --info:#0b6e99; --info-l:#e5f4fb;
+  --r:12px; --sh:0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05);
+  --side:236px; --side-c:68px;
+  --grad:linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%);
+}
+*{box-sizing:border-box}
+html,body{margin:0;height:100%}
+body{
+  font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
+  background:var(--bg); color:var(--txt); -webkit-font-smoothing:antialiased;
+}
+.hidden{display:none !important}
+button{font:inherit;cursor:pointer;border:1px solid var(--line);background:#fff;color:var(--txt);
+  padding:8px 16px;border-radius:9px;transition:.15s}
+button:hover{border-color:#c3cfe3;background:#fafbfe}
+button:active{transform:translateY(1px)}
+button:focus-visible{outline:none;box-shadow:0 0 0 3px var(--pri-l);border-color:var(--pri)}
+button.primary{background:var(--grad);border-color:transparent;color:#fff;font-weight:600;
+  box-shadow:0 1px 3px rgba(31,111,235,.35)}
+button.primary:hover{filter:brightness(1.06)}
+button.ghost{background:#fff}
+button.small{padding:5px 11px;font-size:13px}
+button.danger{color:var(--err);border-color:#f0c8c3}
+button.danger:hover{background:var(--err-l)}
+button:disabled{opacity:.5;cursor:not-allowed;transform:none}
+.icon-btn{padding:6px;border-radius:8px;display:inline-flex;align-items:center;color:var(--txt2)}
+input,select,textarea{font:inherit;color:var(--txt);background:#fff;border:1px solid var(--line);
+  border-radius:9px;padding:8px 11px;width:100%;transition:.15s}
+input:focus,select:focus,textarea:focus{outline:none;border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-l)}
+textarea{font-family:ui-monospace,Consolas,"Cascadia Code",monospace;font-size:13px;line-height:1.6;resize:vertical}
+label{display:block;font-size:13px;color:var(--txt2);margin-bottom:12px}
+label>input,label>select,label>textarea{margin-top:5px}
+a{color:var(--pri);text-decoration:none}
+a:hover{text-decoration:underline}
+
+/* ---------- 登录 ---------- */
+.login-wrap{display:flex;align-items:center;justify-content:center;height:100%;position:relative;
+  overflow:hidden;background:linear-gradient(155deg,#e9f0fb 0%,#f6f9fd 48%,#e6eefb 100%)}
+.login-glow{position:absolute;width:820px;height:820px;border-radius:50%;pointer-events:none;
+  background:radial-gradient(circle at 50% 50%,rgba(31,111,235,.13),rgba(31,111,235,0) 62%)}
+.login-box{position:relative;width:392px;background:var(--panel);border:1px solid var(--line);
+  border-radius:18px;padding:38px 34px 28px;box-shadow:0 18px 50px rgba(20,45,95,.11);text-align:center}
+.login-logo{display:flex;justify-content:center;margin-bottom:10px}
+.lg-mark{color:var(--pri);filter:drop-shadow(0 4px 10px rgba(31,111,235,.25))}
+.login-box h1{margin:0;font-size:27px;letter-spacing:.6px;color:var(--txt);font-weight:700}
+.login-box .sub{margin:5px 0 24px;color:var(--txt3);font-size:13px}
+.login-box label{text-align:left}
+.login-box button{width:100%;margin-top:6px;padding:11px}
+.login-box .hint{margin:18px 0 0;font-size:12px;color:var(--txt3);text-align:center}
+
+/* ---------- 布局 ---------- */
+#app{display:flex;height:100%}
+.sidebar{width:var(--side);flex:0 0 var(--side);background:#fff;border-right:1px solid var(--line);
+  display:flex;flex-direction:column;transition:flex-basis .2s,width .2s}
+#app.side-collapsed .sidebar{width:var(--side-c);flex-basis:var(--side-c)}
+#app.side-collapsed .brand-txt,#app.side-collapsed .nav-search,
+#app.side-collapsed .ng-t,#app.side-collapsed .ng-n,#app.side-collapsed .ng-arrow,
+#app.side-collapsed .nitem-t,#app.side-collapsed .nitem-tag{display:none}
+#app.side-collapsed .nitem,#app.side-collapsed .ng-head{justify-content:center}
+.brand{display:flex;align-items:center;gap:11px;padding:18px 18px 14px}
+.brand-mark{color:var(--pri);flex:0 0 auto}
+.brand-txt{min-width:0}
+.brand-txt b{display:block;font-size:17px;font-weight:700;color:var(--txt);letter-spacing:.3px;line-height:1.2}
+.brand-txt span{display:block;font-size:11px;font-weight:400;color:var(--txt3);
+  letter-spacing:0;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.nav-search{padding:0 12px 10px}
+.nav-search input{padding:7px 11px;font-size:12.5px;background:var(--line2);border-color:transparent}
+.nav-search input:focus{background:#fff;border-color:var(--pri)}
+#nav{flex:1;overflow-y:auto;padding:2px 10px 16px}
+
+/* 分组(可折叠父菜单) */
+.ngroup{margin-bottom:3px;border-radius:10px;transition:background .15s}
+.ngroup.has-cur{background:hsl(var(--h) 85% 55% / .06)}
+.ng-head{display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:none;
+  background:none;border-radius:9px;padding:8px 10px;color:var(--txt2);
+  font-size:12.5px;font-weight:600;letter-spacing:.3px}
+.ng-head:hover{background:var(--line2);color:var(--txt)}
+.ng-ico{width:24px;height:24px;flex:0 0 24px;display:inline-flex;align-items:center;justify-content:center;
+  border-radius:7px;font-size:13px;line-height:1;
+  background:hsl(var(--h) 85% 55% / .15);color:hsl(var(--h) 72% 42%)}
+.ng-t{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ng-n{font-size:10.5px;font-weight:500;color:hsl(var(--h) 60% 45%);
+  background:hsl(var(--h) 85% 55% / .13);padding:1px 7px;border-radius:9px}
+.ng-arrow{flex:0 0 12px;color:var(--txt3);transition:transform .2s}
+.ngroup.open .ng-arrow{transform:rotate(90deg)}
+.ng-body{display:none;padding:1px 0 5px 34px}
+.ngroup.open .ng-body{display:block}
+.nitem{display:flex;align-items:center;gap:8px;width:100%;text-align:left;border:none;
+  background:none;border-radius:8px;padding:7px 10px;margin-bottom:1px;
+  color:var(--txt2);font-size:13px;position:relative}
+.nitem:hover{background:var(--line2);color:var(--txt)}
+.nitem-dot{width:5px;height:5px;flex:0 0 5px;border-radius:50%;background:#ccd5e3;transition:.15s}
+.nitem-t{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.nitem-tag{font-size:9.5px;color:hsl(var(--h) 60% 45%);background:hsl(var(--h) 85% 55% / .13);
+  padding:1px 6px;border-radius:8px;flex:0 0 auto}
+.nitem.on{color:hsl(var(--h) 68% 40%);font-weight:600;background:hsl(var(--h) 85% 55% / .12)}
+.nitem.on .nitem-dot{background:hsl(var(--h) 75% 52%);box-shadow:0 0 0 3px hsl(var(--h) 85% 55% / .22)}
+.nav-empty{font-size:12.5px;color:var(--txt3);padding:14px 10px;text-align:center}
+.side-foot{padding:12px;border-top:1px solid var(--line)}
+.dot-line{font-size:12px;color:var(--txt2);margin-bottom:0;display:flex;align-items:center;gap:6px}
+.dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex:0 0 7px}
+.dot.on{background:var(--ok)} .dot.off{background:#c3ccdb}
+
+/* 侧栏底部「退出系统」:与导航项同一视觉语言,悬停才显红,避免平时过于刺眼 */
+.side-logout{margin-top:10px;width:100%;display:flex;align-items:center;gap:8px;
+  padding:8px 10px;border-radius:9px;border:1px solid transparent;background:transparent;
+  color:var(--txt2);font:inherit;font-size:13px;cursor:pointer;transition:.15s;white-space:nowrap}
+.side-logout:hover{background:var(--err-l);color:var(--err);border-color:#f0c8c3}
+.side-logout .sl-txt{overflow:hidden;text-overflow:ellipsis}
+/* 顶栏刷新按钮:刷新中图标旋转 */
+#btn-refresh.spinning #btn-refresh-ico{animation:spin .7s linear infinite}
+#btn-refresh.spinning{pointer-events:none;opacity:.6}
+@keyframes spin{to{transform:rotate(360deg)}}
+
+main{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}
+.topbar{height:58px;flex:0 0 58px;background:rgba(255,255,255,.92);border-bottom:1px solid var(--line);
+  display:flex;align-items:center;justify-content:space-between;padding:0 20px;
+  backdrop-filter:blur(8px);position:relative;z-index:5}
+.tb-left{display:flex;align-items:center;gap:10px;min-width:0}
+.topbar h2{margin:0;font-size:16px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.tb-right{display:flex;align-items:center;gap:12px}
+.mini{font-size:12px;color:var(--txt3);white-space:nowrap}
+/* 顶栏时钟 / 农历(#12) */
+.clockbox{display:flex;align-items:baseline;gap:10px;cursor:pointer;user-select:none;
+  padding:4px 10px;border-radius:9px;transition:.15s;white-space:nowrap}
+.clockbox:hover{background:var(--bg2)}
+.ck-time{font-size:17px;font-weight:650;font-variant-numeric:tabular-nums;
+  color:var(--txt);letter-spacing:.3px}
+.ck-date{font-size:12.5px;color:var(--txt2)}
+.ck-lunar{font-size:12.5px;color:var(--txt3)}
+@media(max-width:900px){.ck-lunar{display:none}}
+@media(max-width:680px){.ck-date{display:none}}
+.view{flex:1;overflow-y:auto;padding:20px 22px 90px}
+#actionbar{position:absolute;left:0;right:0;bottom:0;background:rgba(255,255,255,.97);
+  border-top:1px solid var(--line);padding:12px 22px;display:flex;align-items:center;
+  justify-content:space-between;gap:16px;backdrop-filter:blur(8px);z-index:4}
+.ab-msg{font-size:13px;color:var(--txt2);min-height:20px}
+.ab-msg.ok{color:var(--ok)} .ab-msg.err{color:var(--err)}
+.ab-btns{display:flex;gap:10px;flex:0 0 auto}
+
+/* ---------- 组件 ---------- */
+.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
+  padding:18px 20px;margin-bottom:16px;box-shadow:var(--sh)}
+.card h3{margin:0 0 4px;font-size:14.5px;font-weight:650}
+.card .desc{margin:0 0 16px;font-size:12.5px;color:var(--txt3);line-height:1.65}
+.grid{display:grid;gap:14px}
+.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
+.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
+.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
+@media(max-width:1080px){.g4{grid-template-columns:repeat(2,1fr)}.g3{grid-template-columns:repeat(2,1fr)}}
+@media(max-width:760px){
+  .g2,.g3,.g4{grid-template-columns:1fr}
+  .sidebar{width:var(--side-c);flex-basis:var(--side-c)}
+  .brand-txt,.nav-search,.ng-t,.ng-n,.ng-arrow,.nitem-t,.nitem-tag{display:none}
+  .ng-head,.nitem{justify-content:center}
+}
+.kv{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px dashed var(--line2);font-size:13px}
+.kv:last-child{border-bottom:none}
+.kv b{font-weight:500;color:var(--txt2);flex:0 0 auto}
+.kv span{text-align:right;word-break:break-all;font-variant-numeric:tabular-nums}
+.stat{background:#fff;border:1px solid var(--line);border-radius:var(--r);padding:14px 16px;
+  box-shadow:var(--sh);position:relative;overflow:hidden}
+.stat::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--grad);opacity:.75}
+.stat .lbl{font-size:12px;color:var(--txt3);margin-bottom:6px}
+.stat .val{font-size:20px;font-weight:650;font-variant-numeric:tabular-nums;letter-spacing:-.2px}
+.stat .sub{font-size:11.5px;color:var(--txt3);margin-top:3px}
+.tag{display:inline-block;font-size:11px;padding:1px 7px;border-radius:5px;font-weight:500;vertical-align:middle}
+.tag.ok{background:var(--ok-l);color:var(--ok)}
+.tag.warn{background:var(--warn-l);color:var(--warn)}
+.tag.err{background:var(--err-l);color:var(--err)}
+.tag.info{background:var(--info-l);color:var(--info)}
+.tag.gray{background:#eef1f6;color:var(--txt2)}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line2);vertical-align:top}
+th{font-weight:600;color:var(--txt2);font-size:12px;background:#fafbfe;position:sticky;top:0}
+tr:last-child td{border-bottom:none}
+tbody tr:hover{background:#fafcff}
+.mono{font-family:ui-monospace,Consolas,monospace;font-size:12.5px}
+pre{background:#fbfcfe;border:1px solid var(--line);border-radius:9px;padding:12px;
+  overflow:auto;max-height:340px;font-size:12.5px;line-height:1.55;margin:0}
+.banner{background:var(--warn-l);border-bottom:1px solid #f0e0a8;color:var(--warn);
+  padding:10px 22px;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:12px}
+/* 新网卡提示的关闭按钮:点掉后按网卡集合记住,不再反复打扰 */
+.banner .tip-body{flex:1 1 auto;min-width:0}
+.banner .tip-x{flex:0 0 auto;width:24px;height:24px;line-height:22px;text-align:center;
+  border:1px solid currentColor;border-radius:6px;background:transparent;color:inherit;
+  cursor:pointer;font-size:13px;padding:0;opacity:.65;transition:.15s}
+.banner .tip-x:hover{opacity:1;background:rgba(0,0,0,.06)}
+.msg{font-size:12.5px;min-height:18px;margin-top:10px}
+.msg.ok{color:var(--ok)} .msg.err{color:var(--err)} .msg.warn{color:var(--warn)}
+.row{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap}
+.row>*{flex:1 1 180px}
+.row .fixed{flex:0 0 auto}
+.switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--txt2);
+  margin-bottom:12px;user-select:none}
+.switch input{display:none}
+.switch i{width:38px;height:21px;border-radius:11px;background:#ccd5e3;position:relative;transition:.18s;flex:0 0 38px}
+.switch i::after{content:'';position:absolute;top:2px;left:2px;width:17px;height:17px;border-radius:50%;
+  background:#fff;transition:.18s;box-shadow:0 1px 2px rgba(0,0,0,.2)}
+.switch input:checked+i{background:var(--pri)}
+.switch input:checked+i::after{transform:translateX(17px)}
+.hint-inline{font-size:12px;color:var(--txt3);margin:-6px 0 12px}
+.toast{position:fixed;right:24px;bottom:24px;z-index:99;max-width:440px;
+  padding:13px 18px;border-radius:11px;background:#1d2a3c;color:#fff;font-size:13px;
+  box-shadow:0 10px 30px rgba(10,20,40,.32);animation:tin .22s ease}
+.toast.ok{background:#12693a} .toast.err{background:#9d2f22} .toast.warn{background:#8a5b00}
+@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
+.modal{position:fixed;inset:0;background:rgba(16,26,46,.5);z-index:100;display:flex;
+  align-items:center;justify-content:center;padding:24px;backdrop-filter:blur(2px)}
+.modal-box{background:#fff;border-radius:15px;padding:24px 26px;max-width:620px;width:100%;
+  max-height:82vh;overflow:auto;box-shadow:0 24px 60px rgba(10,25,55,.34);animation:tin .18s ease}
+.modal-box h3{margin:0 0 10px;font-size:16px}
+.modal-box .acts{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}
+.countdown{font-size:26px;font-weight:700;color:var(--err);font-variant-numeric:tabular-nums}
+.bar{height:7px;border-radius:4px;background:var(--line2);overflow:hidden;margin-top:4px}
+.bar i{display:block;height:100%;background:var(--pri);transition:width .4s}
+.ifrow{display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--line);
+  border-radius:10px;margin-bottom:10px;background:#fff;flex-wrap:wrap}
+.ifrow .nm{font-weight:600;font-size:13.5px;min-width:74px}
+.ifrow .mac{color:var(--txt3);font-size:12px}
+.ifrow input,.ifrow select{width:auto;flex:0 0 150px;padding:6px 10px;font-size:13px}
+.seg{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
+.seg button{border:none;border-radius:0;padding:7px 14px;font-size:13px;background:#fff}
+.seg button+button{border-left:1px solid var(--line)}
+.seg button.on{background:var(--grad);color:#fff}
+.logline{padding:5px 8px;border-bottom:1px solid var(--line2);font-size:12.5px;
+  font-family:ui-monospace,Consolas,monospace;display:flex;gap:10px}
+.logline:hover{background:#fafbfe}
+.logline .lv{flex:0 0 46px;font-weight:600}
+.lv.info{color:var(--info)} .lv.warn{color:var(--warn)} .lv.error{color:var(--err)}
+.logline .ts{flex:0 0 130px;color:var(--txt3)}
+.logline .mo{flex:0 0 76px;color:var(--txt2)}
+.logline .ms{flex:1;word-break:break-all}
+
+/* ---------- 页脚(开源协议与作者) ---------- */
+.site-footer{margin:26px 4px 8px;padding:14px 4px 6px;border-top:1px solid var(--line);
+  color:var(--txt3);font-size:12px;text-align:center;line-height:1.9}
+.site-footer .sep{margin:0 8px;color:var(--line)}
+.site-footer a{color:var(--pri);text-decoration:none}
+.site-footer a:hover{text-decoration:underline}
+
+/* ---------- PPPoE 实时状态栏与日志 ---------- */
+.ppbar{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:10px;
+  background:var(--line2);border:1px solid var(--line);font-size:13.5px}
+.pplog{margin-top:12px;height:230px;overflow:auto;border:1px solid var(--line);border-radius:10px;
+  background:#0f172a;padding:8px 10px;font-family:ui-monospace,Consolas,monospace;font-size:12px}
+.ppline{padding:3px 0;color:#cdd9ea;border-bottom:1px solid rgba(255,255,255,.05);word-break:break-all}
+.ppline .ppt{color:#7c8ba3;margin-right:10px}
+.ppline.lv-warn{color:#fbbf24}
+.ppline.lv-error{color:#f87171}
+.ppline.lv-info{color:#cdd9ea}
+
+/* ---------- 仪表与环形图 ---------- */
+.stat .bar i{transition:width .5s}
+svg[role=img]{display:block;margin:0 auto}
+
+/* ---------- 快照覆盖范围 / 状态标签 ---------- */
+.snap-scope{margin:2px 0 14px;padding:12px 14px;border:1px dashed var(--line);
+  border-radius:10px;background:var(--line2);font-size:12.5px}
+.snap-scope-t{font-weight:600;color:var(--txt2);margin-bottom:8px}
+.snap-g{margin-bottom:8px}
+.snap-g:last-child{margin-bottom:0}
+.snap-g b{display:block;color:var(--pri);font-size:12.5px;margin-bottom:3px}
+.snap-g ul{margin:0;padding-left:18px;color:var(--txt2);line-height:1.75}
+.snap-g li{list-style:disc}
+.tag-ok{display:inline-block;font-size:11px;padding:1px 7px;border-radius:5px;
+  font-weight:500;background:var(--ok-l);color:var(--ok)}
+.tag-warn{display:inline-block;font-size:11px;padding:1px 7px;border-radius:5px;
+  font-weight:500;background:var(--warn-l);color:var(--warn)}
+
+/* ---------- 网卡多选 chip ---------- */
+.chipbox{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 10px}
+.chip{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border:1px solid var(--line);
+  border-radius:20px;background:var(--panel);cursor:pointer;font-size:12.5px;
+  font-family:ui-monospace,Consolas,monospace;transition:.15s}
+.chip:hover{border-color:var(--pri)}
+.chip input{margin:0;accent-color:var(--pri)}
+.chip:has(input:checked){background:var(--pri-l);border-color:var(--pri);color:var(--pri)}
+
+/* ---------- 未开发模块占位页 ---------- */
+.soon-card{text-align:center;padding:52px 26px;background:linear-gradient(180deg,#fbfdff 0%,#fff 60%)}
+.soon-ico{color:var(--pri);opacity:.5;margin-bottom:12px;display:flex;justify-content:center}
+.soon-card h3{font-size:19px;margin-bottom:8px}
+.soon-pill{display:inline-block;margin-top:18px;padding:6px 20px;border-radius:20px;font-size:13px;
+  font-weight:600;color:var(--pri);background:var(--pri-l);border:1px dashed #a9c8f7}
+
+/* ---------- 连接状态 / NAT 结果徽标 ---------- */
+.nat-badge{display:flex;align-items:center;gap:16px;padding:16px 18px;border-radius:12px;
+  border:1px solid var(--line);background:linear-gradient(135deg,#fbfdff,#f4f8ff)}
+.nat-badge .nb-score{flex:0 0 auto;width:64px;height:64px;border-radius:50%;display:flex;
+  align-items:center;justify-content:center;font-size:20px;font-weight:700;color:#fff;
+  box-shadow:0 4px 14px rgba(20,45,95,.16)}
+.nat-badge .nb-body{min-width:0;flex:1}
+.nat-badge .nb-t{font-size:16px;font-weight:650;margin-bottom:3px}
+.nat-badge .nb-d{font-size:12.5px;color:var(--txt3);line-height:1.65}
+.nat-badge.n1 .nb-score{background:linear-gradient(135deg,#12b76a,#0a8f50)}
+.nat-badge.n2 .nb-score{background:linear-gradient(135deg,#3b82f6,#1d5fd0)}
+.nat-badge.n3 .nb-score{background:linear-gradient(135deg,#f59e0b,#d97706)}
+.nat-badge.n4 .nb-score{background:linear-gradient(135deg,#ef4444,#c0392b)}
+.nat-badge.na .nb-score{background:linear-gradient(135deg,#94a3b8,#64748b)}
+.probe-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;
+  border-bottom:1px dashed var(--line2);font-size:12.5px}
+.probe-row:last-child{border-bottom:none}
+.probe-row .pk{color:var(--txt3);flex:0 0 auto}
+.probe-row .pv{font-family:ui-monospace,Consolas,monospace;text-align:right;word-break:break-all}
+
+/* ---------- 依赖自检 ---------- */
+.dep-summary{display:flex;gap:22px;flex-wrap:wrap;margin-bottom:4px}
+.dep-summary .ds-item{display:flex;align-items:baseline;gap:7px}
+.dep-summary .ds-num{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums}
+.dep-summary .ds-lbl{font-size:12px;color:var(--txt3)}
+.dep-table td:first-child{width:32px;text-align:center;font-size:15px}
+.dep-ok{color:var(--ok)} .dep-miss{color:var(--err)} .dep-opt{color:var(--warn)}
+
+/* ---------- 代码高亮(通用 API 范例 / 错误日志) ---------- */
+.code-wrap{position:relative}
+.code-wrap .code-copy{position:absolute;top:8px;right:8px;padding:3px 10px;font-size:11.5px;
+  border-radius:7px;background:#fff;border:1px solid var(--line);z-index:2}
+.code-wrap .code-copy:hover{background:var(--pri-l);border-color:var(--pri);color:var(--pri)}
+.lang-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
+.lang-tabs button{padding:5px 13px;font-size:12.5px;border-radius:18px}
+.lang-tabs button.on{background:var(--grad);color:#fff;border-color:transparent;font-weight:600}
+
+/* ---------- Web 终端 ---------- */
+.term-wrap{border:1px solid var(--line);border-radius:11px;overflow:hidden;background:#0d1526;
+  display:flex;flex-direction:column;margin-bottom:14px}
+.term-bar{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#16233a;
+  border-bottom:1px solid #22314d;font-size:12.5px;color:#9db0cc}
+.term-bar .tdot{width:10px;height:10px;border-radius:50%;background:#31405c;flex:0 0 10px}
+.term-bar .tdot.r{background:#e05c4c} .term-bar .tdot.y{background:#d9a400} .term-bar .tdot.g{background:#3fa15c}
+.term-bar .tt{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.term-body{height:430px;overflow:auto;padding:10px 12px;
+  font-family:ui-monospace,Consolas,"Cascadia Code",monospace;font-size:12.5px;line-height:1.62;
+  color:#cdd9ea;white-space:pre-wrap;word-break:break-all}
+.term-in{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#111c2f;
+  border-top:1px solid #22314d}
+.term-in .ps1{color:#4ea1ff;font-family:ui-monospace,Consolas,monospace;font-size:12.5px;flex:0 0 auto}
+.term-in input{background:transparent;border:none;color:#e6edf7;padding:2px 0;font-size:12.5px;
+  font-family:ui-monospace,Consolas,monospace}
+.term-in input:focus{box-shadow:none;border:none}
+.tk-dir{color:#4ea1ff} .tk-file{color:#cdd9ea} .tk-link{color:#5fd0c8}
+.tk-cmd{color:#8ce99a} .tk-err{color:#f87171} .tk-warn{color:#fbbf24} .tk-num{color:#f0a35e}
+.tk-str{color:#e5c07b} .tk-var{color:#c792ea} .tk-cmt{color:#6b7f9c} .tk-prompt{color:#4ea1ff}
+.tk-key{color:#7ec2ff}
+
+/* ---------- 文件管理器 ---------- */
+.fm-toolbar{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:12px}
+.fm-path{flex:1 1 300px;min-width:220px;display:flex;align-items:center;gap:6px;
+  border:1px solid var(--line);border-radius:9px;padding:5px 10px;background:#fbfcfe}
+.fm-path input{border:none;background:transparent;padding:2px 0;
+  font-family:ui-monospace,Consolas,monospace;font-size:12.5px}
+.fm-path input:focus{box-shadow:none}
+.fm-list{border:1px solid var(--line);border-radius:11px;overflow:hidden;margin-top:12px}
+.fm-row{display:flex;align-items:center;gap:11px;padding:8px 12px;border-bottom:1px solid var(--line2);
+  font-size:13px}
+.fm-row:last-child{border-bottom:none}
+.fm-row:hover{background:#fafcff}
+.fm-row.head{background:#fafbfe;font-size:12px;color:var(--txt2);font-weight:600}
+.fm-row.head:hover{background:#fafbfe}
+.fm-row .fi{width:20px;text-align:center;flex:0 0 20px;font-size:14px}
+.fm-row .fn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
+.fm-row .fn:hover{color:var(--pri);text-decoration:underline}
+.fm-row .fs{width:90px;flex:0 0 90px;text-align:right;color:var(--txt3);font-size:12px;
+  font-variant-numeric:tabular-nums}
+.fm-row .fm{width:132px;flex:0 0 132px;color:var(--txt3);font-size:12px;
+  font-family:ui-monospace,Consolas,monospace}
+.fm-row .fa{flex:0 0 auto;display:flex;gap:5px}
+.fm-row .fa button{padding:3px 8px;font-size:11.5px;border-radius:7px}
+@media(max-width:900px){.fm-row .fm{display:none}}
+.drop-zone{border:2px dashed var(--line);border-radius:12px;padding:20px;text-align:center;
+  color:var(--txt3);font-size:13px;transition:.15s;margin-top:12px;background:#fbfcfe}
+.drop-zone.hot{border-color:var(--pri);background:var(--pri-l);color:var(--pri)}
+.up-item{display:flex;align-items:center;gap:10px;padding:6px 0;font-size:12.5px}
+.up-item .un{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.up-item .ub{flex:0 0 150px;height:7px;border-radius:4px;background:var(--line2);overflow:hidden}
+.up-item .ub i{display:block;height:100%;background:var(--grad);width:0;transition:width .2s}
+.up-item .up{flex:0 0 52px;text-align:right;color:var(--txt3);font-variant-numeric:tabular-nums}
+.up-item.done .un{color:var(--ok)} .up-item.bad .un{color:var(--err)}
+
+/* ---------- 加速开关大按钮 ---------- */
+.accel-box{display:flex;align-items:center;gap:18px;flex-wrap:wrap;padding:16px 18px;
+  border:1px solid var(--line);border-radius:12px;background:linear-gradient(135deg,#fbfdff,#f4f8ff)}
+.accel-state{flex:0 0 auto;display:flex;align-items:center;gap:9px;font-size:15px;font-weight:650}
+.accel-state .as-dot{width:11px;height:11px;border-radius:50%;flex:0 0 11px}
+.accel-state.on .as-dot{background:#12b76a;box-shadow:0 0 0 4px rgba(18,183,106,.18)}
+.accel-state.off .as-dot{background:#c3ccdb;box-shadow:0 0 0 4px rgba(195,204,219,.22)}
+.accel-note{font-size:12.5px;color:var(--txt3);line-height:1.7;flex:1 1 340px;min-width:240px}
+
+/* ==================================================================
+   新增模块样式(#17–#22)—— 与视图中的类名严格对应
+   ================================================================== */
+
+/* ---------- 仪表卡惊叹号提示(虚拟机无温度传感器等) ---------- */
+.gauge-warn{display:inline-flex;align-items:center;justify-content:center;width:17px;height:17px;
+  border-radius:50%;background:var(--warn);color:#fff;font-size:11.5px;font-weight:800;
+  cursor:help;flex:0 0 17px;line-height:1;box-shadow:0 0 0 3px rgba(245,158,11,.16);
+  animation:gwpulse 2.2s ease-in-out infinite}
+@keyframes gwpulse{0%,100%{box-shadow:0 0 0 3px rgba(245,158,11,.16)}
+  50%{box-shadow:0 0 0 6px rgba(245,158,11,.05)}}
+.gauge-note{margin-top:9px;padding:8px 11px;border-radius:9px;font-size:12px;line-height:1.7;
+  color:#8a5a00;background:#fff8e6;border:1px solid #f5dfae}
+
+/* ---------- NAT 徽标(新结构:nat-badge + nat-ico/nat-txt/nat-t/nat-d) ---------- */
+.nat-badge{display:flex;align-items:center;gap:16px;padding:16px 18px;border-radius:12px;
+  border:1px solid var(--line);background:linear-gradient(135deg,#fbfdff,#f4f8ff)}
+.nat-badge .nat-ico{flex:0 0 auto;width:56px;height:56px;border-radius:50%;display:flex;
+  align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;
+  background:linear-gradient(135deg,#94a3b8,#64748b);box-shadow:0 4px 14px rgba(20,45,95,.14)}
+.nat-badge .nat-txt{min-width:0;flex:1}
+.nat-badge .nat-t{font-size:16px;font-weight:650;margin-bottom:3px}
+.nat-badge .nat-d{font-size:12.5px;color:var(--txt3);line-height:1.68}
+.nat-badge.ok .nat-ico{background:linear-gradient(135deg,#12b76a,#0a8f50)}
+.nat-badge.info .nat-ico{background:linear-gradient(135deg,#3b82f6,#1d5fd0)}
+.nat-badge.warn .nat-ico{background:linear-gradient(135deg,#f59e0b,#d97706)}
+.nat-badge.err .nat-ico{background:linear-gradient(135deg,#ef4444,#c0392b)}
+.nat-badge.ok{border-color:#b7e4c7;background:linear-gradient(135deg,#f4fdf8,#eefaf3)}
+.nat-badge.err{border-color:#f7c9c4;background:linear-gradient(135deg,#fff7f6,#fdeeed)}
+.nat-detail{margin-top:12px;padding:11px 13px;border-radius:9px;background:#0d1526;color:#cdd9ea;
+  font-size:12px;line-height:1.7;white-space:pre-wrap;word-break:break-all}
+
+/* ---------- 软加速状态框(新结构) ---------- */
+.accel-box{display:block;padding:16px 18px;border:1px solid var(--line);border-radius:12px;
+  background:linear-gradient(135deg,#fbfdff,#f4f8ff)}
+.accel-box.on{border-color:#b7e4c7;background:linear-gradient(135deg,#f4fdf8,#eefaf3)}
+.accel-state{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:650;margin-bottom:6px}
+.accel-meta{margin-top:8px}
+.impact-list{display:flex;flex-direction:column;gap:2px}
+.impact-item{display:flex;gap:11px;align-items:flex-start;padding:9px 0;border-bottom:1px dashed var(--line2)}
+.impact-item:last-child{border-bottom:none}
+.impact-dot{flex:0 0 9px;width:9px;height:9px;border-radius:50%;margin-top:5px;background:var(--txt3)}
+.impact-dot.warn{background:var(--warn)} .impact-dot.err{background:var(--err)}
+.impact-dot.info{background:var(--pri)} .impact-dot.ok{background:var(--ok)}
+
+/* ---------- VLAN 行 ---------- */
+.vlan-row{display:flex;align-items:center;justify-content:space-between;gap:12px;
+  padding:11px 14px;border:1px solid var(--line);border-radius:11px;margin-bottom:9px;
+  background:#fbfcfe;flex-wrap:wrap}
+.vlan-row:hover{background:#f7faff;border-color:#cddffa}
+.vlan-row > div{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+
+/* ---------- 依赖自检(新结构:dep-grid / dep-item) ---------- */
+.dep-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px;margin-top:10px}
+.dep-item{display:flex;align-items:flex-start;gap:11px;padding:11px 13px;border-radius:10px;
+  border:1px solid var(--line);background:#fbfcfe}
+.dep-item .dep-ico{flex:0 0 22px;width:22px;height:22px;border-radius:50%;display:flex;
+  align-items:center;justify-content:center;font-size:12.5px;font-weight:700;color:#fff;margin-top:1px}
+.dep-item.ok .dep-ico{background:var(--ok)}
+.dep-item.err .dep-ico{background:var(--err)}
+.dep-item.warn .dep-ico{background:var(--warn)}
+.dep-item.ok{border-color:#c9ecd8}
+.dep-item.err{border-color:#f7c9c4;background:#fff8f7}
+.dep-item.warn{border-color:#f5dfae;background:#fffcf4}
+.dep-item .dep-body{flex:1;min-width:0}
+.dep-item .dep-body b{font-size:13.5px}
+.dep-item .dep-where{font-size:11.5px;color:var(--txt3);margin-top:3px;word-break:break-all}
+/* 分组过滤按钮的选中态:没有它用户点「外置存储」看不出到底选中了哪个 */
+.dep-grp.on{border-color:var(--pri);color:var(--pri);background:var(--pri-l);font-weight:600}
+
+/* ---------- 顶部小提示条 ---------- */
+.notice{padding:11px 14px;border-radius:10px;font-size:13px;line-height:1.7;border:1px solid var(--line);
+  background:#fbfcfe;color:var(--txt2)}
+.notice.ok{border-color:#c9ecd8;background:#f4fdf8;color:#0a6b3d}
+.notice.err{border-color:#f7c9c4;background:#fff8f7;color:#a3271c}
+.notice.warn{border-color:#f5dfae;background:#fffcf4;color:#8a5a00}
+
+/* ---------- Web 终端(新结构:term-bar/term-dot/term-title/term-body/term-input/term-prompt) ---------- */
+.term-wrap{border:1px solid #22314d;border-radius:11px;overflow:hidden;background:#0d1526;
+  display:flex;flex-direction:column;margin-bottom:14px}
+.term-bar{display:flex;align-items:center;gap:9px;padding:8px 12px;background:#16233a;
+  border-bottom:1px solid #22314d;font-size:12.5px;color:#9db0cc}
+.term-dot{width:10px;height:10px;border-radius:50%;background:#31405c;flex:0 0 10px}
+.term-dot.r{background:#e05c4c} .term-dot.y{background:#d9a400} .term-dot.g{background:#3fa15c}
+.term-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.term-body{height:420px;overflow:auto;margin:0;padding:10px 12px;
+  font-family:ui-monospace,Consolas,"Cascadia Code",monospace;font-size:12.5px;line-height:1.62;
+  color:#cdd9ea;white-space:pre-wrap;word-break:break-all;background:transparent;border:none;border-radius:0}
+.term-input{display:flex;align-items:center;gap:8px;margin-top:10px}
+.term-prompt{color:var(--pri);font-family:ui-monospace,Consolas,monospace;font-size:14px;flex:0 0 auto}
+.term-input input{flex:1;font-size:12.5px;font-family:ui-monospace,Consolas,monospace}
+
+/* ---------- 真 PTY 终端(#4 重做):网格化渲染 ---------- */
+/* 终端按「行」渲染:每行一个 .tl,字符对齐靠等宽字体 + 固定行高。
+   这里必须 white-space:pre —— pre-wrap 会把超宽行折回来,行列就对不上了。 */
+.term-wrap{position:relative}
+.term-screen{white-space:pre;word-break:normal;overflow-x:hidden}
+.tl{white-space:pre;height:1.62em;line-height:1.62;min-height:1.62em}
+/* 光标方块:只在当前行当前列画一个 */
+.tcur{background:#7fa6d8;color:#0d1526;border-radius:1px}
+.t-b{font-weight:700}
+.t-dim{opacity:.62}
+.t-i{font-style:italic}
+.t-u{text-decoration:underline}
+/* 隐藏的输入框:真输入框才能拿到输入法 / 粘贴 / 组合键,
+   但不能挡住终端,也不能让用户看见它。放在终端底部让输入法候选框位置正常。 */
+.ws-capture{position:absolute;left:0;bottom:0;width:100%;height:1.4em;
+  pointer-events:none;   /* 不能挡住终端的文字选择;聚焦由脚本在 mouseup 时补 */
+  opacity:0;border:0;padding:0;margin:0;resize:none;outline:none;
+  background:transparent;color:transparent;caret-color:transparent;
+  font-family:ui-monospace,Consolas,monospace;font-size:12.5px;z-index:2}
+.ws-probe{pointer-events:none}
+
+/* ---------- 文件管理器(新结构:表格 + 面包屑 + 进度) ---------- */
+.fm-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:2px;font-size:12.5px;
+  padding:7px 11px;border:1px solid var(--line);border-radius:9px;background:#fbfcfe}
+.fm-crumb{color:var(--pri);cursor:pointer;padding:1px 4px;border-radius:5px}
+.fm-crumb:hover{background:var(--pri-l)}
+.fm-sep{color:var(--txt3);padding:0 1px}
+.fm-upload{cursor:pointer}
+.fm-name{display:flex;align-items:center;gap:7px;min-width:0}
+.fm-open{cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.fm-open:hover{color:var(--pri);text-decoration:underline}
+.fm-acts{display:flex;gap:5px;flex-wrap:wrap}
+.fm-acts button{padding:3px 8px;font-size:11.5px;border-radius:7px}
+.fm-chk{width:30px;text-align:center}
+.up-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.up-st{flex:0 0 190px;text-align:right}
+@media(max-width:760px){.up-st{flex:0 0 auto}.fm-acts button span{display:none}}
+
+/* ---------- PPPoE 多拨 ---------- */
+.ppm-row{padding:9px 12px}
+.ppm-row input{font-size:12.5px}
+.ppm-row .ppm-weight{text-align:center}
+/* 多拨会话表:窄屏时允许横向滚动,避免挤压 */
+.ppm-sess td{vertical-align:middle}
+
+/* ---------- QoS 智能限速 ---------- */
+.qos-ip-row{padding:9px 12px}
+.qos-ip-row input{font-size:12.5px}
+/* 影响说明卡片:用左边框颜色区分级别,比圆点更醒目 */
+.impact-item{padding:10px 12px;border:1px solid var(--line);border-left-width:3px;
+  border-radius:9px;background:#fbfcfe;margin-bottom:8px;display:block}
+.impact-item b{font-size:13px;display:block;margin-bottom:3px}
+.impact-warn{border-color:#f5dfae;border-left-color:var(--warn);background:#fffcf4}
+.impact-err{border-color:#f7c9c4;border-left-color:var(--err);background:#fff8f7}
+.impact-info{border-color:#cfe0f7;border-left-color:var(--pri);background:#f7faff}
+.impact-ok{border-color:#c9ecd8;border-left-color:var(--ok);background:#f4fdf8}
+/* QoS 页里内嵌的 accel-box(与网络状态页风格保持一致) */
+.accel-box{border:1px solid var(--line);border-radius:11px;padding:12px 14px;background:#fbfcfe}
+
+/* ---------- DPI 应用识别 ---------- */
+/* 等宽代码块:命令、日志都用它,方便用户三指选择复制 */
+.term-pre{margin:0;padding:11px 13px;border-radius:10px;background:#0d1526;color:#cdd9ea;
+  font-family:ui-monospace,Consolas,"Cascadia Code",monospace;font-size:12.5px;line-height:1.62;
+  white-space:pre-wrap;word-break:break-all;overflow:auto;max-height:420px}
+/* 浅色主题下也保持深色代码块 —— 与终端视觉一致,用户一眼识别「这是可复制内容」 */
+.term-pre + button{margin-top:8px}
+.dpi-mirror-ok{color:var(--ok);font-weight:600}
+.dpi-mirror-bad{color:var(--txt3)}
+
+/* ---------- 端口转发 / DMZ ---------- */
+/* 每条转发规则的卡片行:小屏自动换行,避免横向滚动 */
+.pfrow{background:#fbfcfe;transition:border-color .15s}
+.pfrow:hover{border-color:var(--acc,#3b82f6)}
+.pfrow label{font-size:12px;color:var(--txt3)}
+.pfrow input,.pfrow select{font-size:12.5px;padding:5px 8px}
+.pfrow button{margin-bottom:1px}
+
+/* ---------- 动态域名 DDNS ---------- */
+.dd-prov-tag{font-size:11px}
+#dd-fields .row{gap:10px}
+#dd-fields input{font-size:12.5px}
+
+/* ---------- 访问控制 / 家长时间组(#8) ---------- */
+.aclrow{background:#fbfcfe;transition:border-color .15s}
+.aclrow:hover{border-color:var(--acc,#3b82f6)}
+.aclrow label{font-size:12px;color:var(--txt3)}
+.aclrow input,.aclrow select{font-size:12.5px;padding:5px 8px}
+.aclrow .switch{font-size:12px;color:var(--txt2)}
+#acl-presets button{font-size:12.5px}
+#acl-out pre{max-height:420px;overflow:auto}
+#acl-out pre,#pf-out pre{white-space:pre;tab-size:2}
+
+/* ---------- 内网文件共享 SMB / NFS(#9) ---------- */
+.sharecard label{font-size:12px;color:var(--txt3)}
+.sharecard input,.sharecard select{font-size:12.5px;padding:5px 8px}
+.sharecard .switch{font-size:12px;color:var(--txt2)}
+#sh-hints .mono{cursor:copy}
+#sh-out pre{max-height:460px;overflow:auto;white-space:pre;tab-size:2}
+
+/* ---------- Docker / Docker Compose 面板(#10) ---------- */
+.dk-table{display:flex;flex-direction:column;gap:0}
+.dk-thead,.dk-trow{display:grid;grid-template-columns:1.6fr .9fr 1.1fr 1.1fr auto;
+  gap:10px;align-items:center;padding:9px 10px;border-bottom:1px solid var(--line2);font-size:12.5px}
+.dk-thead{color:var(--txt3);font-size:11.5px;font-weight:600;border-bottom:1px solid var(--line);
+  background:var(--panel2);border-radius:8px 8px 0 0}
+.dk-trow:last-child{border-bottom:none}
+.dk-trow:hover{background:#fbfcfe}
+.dk-crow{align-items:start}
+.dk-metric{display:flex;flex-direction:column;gap:2px}
+.dk-acts{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end}
+.dk-acts button{padding:3px 8px;font-size:11.5px}
+.dk-bar{display:inline-block;width:74px;height:6px;border-radius:3px;background:#e9edf3;
+  overflow:hidden;vertical-align:middle;margin-right:6px}
+.dk-bar i{display:block;height:100%;border-radius:3px}
+#dk-out pre,#dk-cv-out pre{tab-size:2}
+#dk-st-yaml,#dk-cv-in{width:100%;box-sizing:border-box}
+#dk-cv-msg p{margin:6px 0}
+@media (max-width:960px){
+  .dk-thead,.dk-trow{grid-template-columns:1fr 1fr;}
+  .dk-thead{display:none}
+  .dk-acts{grid-column:1/-1}
+}
+
+/* ---------- 主题之家(#12) ---------- */
+.tm-current{display:flex;align-items:center;gap:16px;padding:12px 14px;border-radius:11px;
+  border:1px solid var(--line);background:linear-gradient(135deg,#fbfdff,#f4f8ff);flex-wrap:wrap}
+.tm-cur-swatch{flex:0 0 auto;display:flex;border-radius:9px;overflow:hidden;
+  border:1px solid var(--line);box-shadow:0 1px 3px rgba(18,28,55,.08)}
+.tm-cur-swatch i{display:block;width:26px;height:34px}
+.tm-cur-body{flex:1;min-width:180px}
+.tm-cur-acts{flex:0 0 auto}
+.tm-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
+.tm-tab{padding:7px 18px;font-size:13px;border-radius:20px}
+.tm-tab.on{background:var(--grad);color:#fff;border-color:transparent;font-weight:600}
+/* 主题卡片网格 */
+.tm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(258px,1fr));gap:14px;margin-top:12px}
+.tm-card{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel);
+  display:flex;flex-direction:column;transition:.16s}
+.tm-card:hover{border-color:var(--pri);box-shadow:0 4px 14px rgba(20,45,95,.1)}
+.tm-card.on{border-color:var(--ok);box-shadow:0 0 0 2px var(--ok-l)}
+.tm-card-top{position:relative;height:96px;padding:8px;background:linear-gradient(135deg,var(--sw1),var(--sw2))}
+.tm-card-mock{display:flex;gap:5px;height:100%;border-radius:7px;overflow:hidden;
+  border:1px solid var(--line);background:var(--sw2)}
+.tm-mk-side{flex:0 0 30%;background:var(--sw2);border-right:1px solid var(--line);
+  padding:6px 5px;display:flex;flex-direction:column;gap:4px}
+.tm-mk-side i{display:block;height:8px;width:8px;border-radius:3px;background:var(--sw0)}
+.tm-mk-side span{display:block;height:4px;border-radius:2px;background:var(--sw1)}
+.tm-mk-side span:nth-child(3){width:70%}
+.tm-mk-main{flex:1;padding:6px 5px;display:flex;flex-direction:column;gap:5px;min-width:0}
+.tm-mk-bar{height:6px;border-radius:2px;background:var(--sw3);opacity:.16}
+.tm-mk-body{flex:1;border-radius:4px;background:var(--sw1);border:1px solid var(--line)}
+.tm-card-use{position:absolute;right:8px;top:8px;font-size:10.5px;font-weight:600;
+  color:#fff;background:var(--ok);padding:2px 8px;border-radius:9px;
+  box-shadow:0 1px 4px rgba(10,30,60,.2)}
+.tm-card-info{padding:11px 13px 6px;flex:1}
+.tm-card-info b{font-size:13.5px;display:inline-block;margin-right:6px}
+.tm-card-info .tag{font-size:10.5px;transform:translateY(-1px)}
+.tm-card-acts{display:flex;gap:6px;flex-wrap:wrap;padding:0 13px 12px}
+.tm-card-acts button{padding:4px 11px;font-size:12px}
+/* 设计器双栏 */
+.tm-split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;align-items:start}
+@media(max-width:1080px){.tm-split{grid-template-columns:1fr}}
+.tm-frame{width:100%;height:460px;border:1px solid var(--line);border-radius:10px;
+  background:var(--panel2);display:block}
+/* 变量行 */
+.tm-cat{margin-bottom:14px}
+.tm-cat-t{font-size:12px;font-weight:600;color:var(--pri);margin-bottom:7px;
+  padding-bottom:4px;border-bottom:1px dashed var(--line2)}
+.tm-vrow{display:flex;align-items:center;gap:8px;padding:4px 0}
+.tm-vname{flex:1 1 130px;min-width:92px;font-size:12.5px;color:var(--txt2);line-height:1.35}
+.tm-color{flex:0 0 34px;width:34px;height:28px;padding:0;border:1px solid var(--line);
+  border-radius:7px;background:none;cursor:pointer}
+.tm-color::-webkit-color-swatch-wrapper{padding:2px}
+.tm-color::-webkit-color-swatch{border:none;border-radius:5px}
+.tm-vtext{flex:1 1 130px;min-width:96px;font-size:12px;padding:5px 8px}
+.tm-vreset{flex:0 0 auto;padding:3px 7px;font-size:12px;color:var(--txt3)}
+/* 校验结果 */
+.tm-check{margin-top:10px;padding:11px 13px;border-radius:9px;font-size:12.5px;line-height:1.7}
+.tm-check.ok{border:1px solid #c9ecd8;background:#f4fdf8;color:#0a6b3d}
+.tm-check.bad{border:1px solid #f7c9c4;background:#fff8f7;color:#a3271c}
+.tm-check.bad ul{margin:6px 0 0;padding-left:18px}
+.tm-check.bad li{list-style:disc}
+/* 拖拽上传 */
+.tm-drop{border:2px dashed var(--line);border-radius:12px;padding:22px;text-align:center;
+  background:#fbfcfe;transition:.15s}
+.tm-drop.hot{border-color:var(--pri);background:var(--pri-l)}
+#tm-code{max-height:280px;white-space:pre;tab-size:2}
+.code-wrap{position:relative}
+
+/* ---------- 连接跟踪与统一日志(#11) ---------- */
+.ul-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px 20px}
+.ul-stats>b{display:block}
+.ul-stats>div>b{display:block;font-size:12px;color:var(--txt3);font-weight:600;
+  margin-bottom:5px}
+.ul-chip{display:inline-block;font-size:11.5px;padding:2px 7px;border-radius:6px;
+  background:#eef2f8;color:var(--txt2);margin:0 4px 4px 0;font-variant-numeric:tabular-nums}
+.ul-chip b{font-weight:600;color:var(--txt)}
+.ul-table{display:flex;flex-direction:column}
+.ul-thead,.ul-trow{display:grid;
+  grid-template-columns:128px 104px 74px 92px 200px 1fr;
+  gap:10px;align-items:start;padding:8px 10px;border-bottom:1px solid var(--line2);
+  font-size:12.5px}
+.ul-thead{color:var(--txt3);font-size:11.5px;font-weight:600;
+  border-bottom:1px solid var(--line);background:var(--panel2);
+  border-radius:8px 8px 0 0;position:sticky;top:0;z-index:1}
+.ul-trow{cursor:pointer;transition:background .12s}
+.ul-trow:hover{background:#fbfcfe}
+.ul-trow:last-child{border-bottom:none}
+.ul-ico{display:inline-block;width:16px;color:var(--txt3)}
+#ul-out p{margin:8px 0 0}
+
+/* ---------- 新手向导 ----------
+   步骤条刻意做成「可点的按钮」而不是进度指示器:新手最常做的事是
+   「刚才那步我填错了,回去看看」,做成纯展示的话他得从菜单绕一圈。 */
+.wiz-steps{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
+.wiz-step{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;
+  border:1px solid var(--line);background:#fff;font-size:13px;cursor:pointer;
+  transition:all .14s;font-family:inherit}
+.wiz-step:hover{border-color:var(--pri);color:var(--pri)}
+.wiz-step.on{border-color:var(--pri);background:var(--pri-l, #eef4ff);box-shadow:0 1px 6px rgba(30,90,200,.12)}
+.wiz-step.ok .wiz-step-ico{background:var(--ok);color:#fff}
+.wiz-step-ico{display:inline-flex;align-items:center;justify-content:center;
+  width:22px;height:22px;border-radius:50%;background:var(--panel2);
+  color:var(--txt2);font-size:12px;font-weight:600;flex:0 0 auto}
+.wiz-step.on .wiz-step-ico{background:var(--pri);color:#fff}
+.wiz-arrow{color:var(--txt3);font-size:15px;padding:0 1px}
+
+/* DNS 服务商选择卡:整块可点,比下拉框更容易看懂「选它是为了什么」 */
+.wiz-dns-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));
+  gap:9px;margin-top:8px}
+.wiz-dns{display:flex;flex-direction:column;gap:3px;align-items:flex-start;text-align:left;
+  padding:10px 12px;border-radius:10px;border:1px solid var(--line);background:#fff;
+  cursor:pointer;font-family:inherit;transition:all .14s}
+.wiz-dns:hover{border-color:var(--pri);background:#fafcff}
+.wiz-dns b{font-size:13px;color:var(--txt)}
+.wiz-dns .mono{font-size:11.5px;color:var(--pri)}
+.wiz-dns-d{font-size:11.5px;color:var(--txt3);line-height:1.6}
+.wiz-dns-isp{border-style:dashed}
+
+@media (max-width:760px){
+  /* 窄屏下步骤条改成两列网格,横向排会挤出屏幕并触发整页横向滚动 */
+  .wiz-steps{display:grid;grid-template-columns:1fr 1fr;gap:8px}
+  .wiz-arrow{display:none}
+  .wiz-step{width:100%;justify-content:flex-start;padding:10px}
+  .wiz-step-n{font-size:12px}
+  .wiz-dns-grid{grid-template-columns:1fr}
+}
+
+@media (max-width:1080px){
+  .ul-thead{display:none}
+  .ul-trow{grid-template-columns:1fr 1fr;gap:4px 10px}
+}
+
+/* ==========================================================================
+   手机 / 窄屏适配(#14)
+   思路:窄屏下侧边栏改为「抽屉」,主区占满整宽;表格统一包 .tw 横向滚动;
+   表单单列堆叠;控件放大到可点尺寸;避开 iOS 输入框自动缩放与刘海安全区。
+   ========================================================================== */
+
+/* 表格横向滚动容器:由 JS 在渲染后统一包裹,桌面端也能用(内容过宽时不撑破布局) */
+.tw{overflow-x:auto;max-width:100%;-webkit-overflow-scrolling:touch}
+.tw>table{margin:0}
+
+/* 抽屉遮罩:默认不存在,窄屏才启用 */
+.m-mask{display:none}
+
+@media (max-width:760px){
+  /* ---- 1. 侧边栏 → 抽屉 ---- */
+  .sidebar{position:fixed;left:0;top:0;bottom:0;z-index:60;
+    width:min(84vw,304px);flex:0 0 auto;
+    transform:translateX(-100%);transition:transform .22s ease;
+    box-shadow:0 12px 40px rgba(15,23,42,.28);
+    padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom)}
+  #app.m-drawer .sidebar{transform:translateX(0)}
+  /* 抽屉里恢复完整文字(覆盖上面 760px 里把文字隐藏的规则) */
+  #app.m-drawer .brand-txt{display:block}
+  #app.m-drawer .nav-search{display:block}
+  #app.m-drawer .ng-t,#app.m-drawer .ng-n,#app.m-drawer .ng-arrow{display:block}
+  #app.m-drawer .nitem-t{display:block}
+  #app.m-drawer .nitem-tag{display:inline-block}
+  #app.m-drawer .ng-head,#app.m-drawer .nitem{justify-content:flex-start}
+  #app.m-drawer .side-foot{display:block}
+  /* 折叠态在窄屏无意义(抽屉已接管),避免图标被挤到中间 */
+  #app.side-collapsed .sidebar{width:min(84vw,304px);flex-basis:auto}
+  #app.side-collapsed .ng-head,#app.side-collapsed .nitem{justify-content:flex-start}
+
+  .m-mask{display:block;position:fixed;inset:0;z-index:55;
+    background:rgba(15,23,42,.42);opacity:0;pointer-events:none;transition:opacity .2s}
+  #app.m-drawer .m-mask{opacity:1;pointer-events:auto}
+
+  /* ---- 2. 顶栏瘦身 ---- */
+  .topbar{height:52px;flex:0 0 52px;padding:0 12px;gap:8px}
+  .topbar h2{font-size:15px}
+  .tb-right{gap:8px}
+  .clockbox{padding:3px 6px;gap:7px}
+  .ck-time{font-size:15px}
+
+  /* ---- 3. 主区:留出底部操作条与安全区 ---- */
+  .view{padding:12px 12px calc(104px + env(safe-area-inset-bottom))}
+  .card{padding:14px 14px;margin-bottom:12px;border-radius:12px}
+  .card .desc{margin:0 0 12px}
+
+  /* ---- 4. 表单:单列堆叠,输入框 16px 防止 iOS 自动放大 ---- */
+  .row{gap:8px}
+  .row>*{flex:1 1 100%;min-width:0}
+  .row .fixed{flex:0 0 auto}
+  input,select,textarea{font-size:16px}
+  input,select,textarea,button{max-width:100%}
+  label{display:block}
+
+  /* ---- 5. 触控尺寸 ---- */
+  #btn-side,.icon-btn{min-width:40px;min-height:40px;display:inline-flex;
+    align-items:center;justify-content:center}
+  .ab-btns button{min-height:42px;padding:0 16px}
+
+  /* ---- 6. 底部操作条:窄屏换行,避免按钮被挤出屏幕 ---- */
+  #actionbar{flex-wrap:wrap;gap:8px;padding:10px 12px;
+    padding-bottom:calc(10px + env(safe-area-inset-bottom))}
+  .ab-msg{flex:1 1 100%;order:2;min-height:0}
+  .ab-btns{flex:1 1 100%;order:1;justify-content:stretch}
+  .ab-btns button{flex:1 1 0}
+
+  /* ---- 7. 弹窗 / 提示 ---- */
+  .modal-box{max-width:100%;margin:0 12px;padding:18px 16px;border-radius:14px;
+    max-height:86vh;overflow-y:auto}
+  .modal-box .acts{flex-wrap:wrap;gap:8px}
+  .modal-box .acts button{flex:1 1 120px}
+  .toast{right:12px;left:12px;bottom:calc(12px + env(safe-area-inset-bottom));
+    max-width:none}
+
+  /* ---- 8. 表格:缩小字号,交给 .tw 横向滚动 ---- */
+  table{font-size:12.5px}
+  th,td{padding:7px 8px}
+  /* 统一日志的自定义网格表:窄屏已在 1080px 断点折叠为两列,这里再压紧一点 */
+  .ul-trow{font-size:12px;padding:7px 8px}
+
+  /* ---- 9. 等宽代码块 ---- */
+  pre{font-size:12px;max-height:260px;padding:10px}
+
+  /* ---- 10. 登录页 ---- */
+  .login-box{padding:26px 20px;margin:0 14px;width:100%;max-width:420px}
+  .login-box h1{font-size:23px}
+  .login-box button{min-height:46px}
+}
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..603cf96
--- /dev/null
+++ b/web/app.js
@@ -0,0 +1,11308 @@
+/* drouter 管理台前端逻辑 —— 原生 JS,无依赖 */
+'use strict';
+
+/* ============================ 基础 ============================ */
+const $ = s => document.querySelector(s);
+const $$ = s => Array.from(document.querySelectorAll(s));
+const esc = s => {
+  // 后端偶尔会把「对象」塞进本该是文本的字段(例如 NAT 检测的 detail 是 dict)。
+  // 直接 String(obj) 会得到 "[object Object]",用户完全看不出是什么、也没法排查。
+  // 这里退化成紧凑 JSON:仍然一眼能看出「这里的数据结构不对」,但至少有内容可读。
+  if (s != null && typeof s === 'object') {
+    try { s = JSON.stringify(s); } catch (e) { /* 循环引用等,交给下面兜底 */ }
+  }
+  return String(s == null ? '' : s).replace(/[&<>"']/g,
+    c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+};
+const fmtBytes = n => {
+  n = Number(n) || 0;
+  const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
+  while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
+  return n.toFixed(i ? 1 : 0) + ' ' + u[i];
+};
+const fmtDur = s => {
+  s = Math.floor(Number(s) || 0);
+  const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
+  return (d ? d + '天 ' : '') + (h ? h + '小时 ' : '') + m + '分';
+};
+const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
+
+const S = { token: localStorage.getItem('drouter_token') || '', user: '', cfg: {}, ifaces: [], meta: {},
+  page: 'dash', buildMode: true, navOpen: null, navKw: '',
+  docker: {}, share: {}, shareData: {}, acl: {},
+  theme: {}, themeMeta: {}, themeEdit: null, themeTab: 'gallery' };
+
+function toast(msg, kind = 'ok', ms = 3600) {
+  const t = $('#toast');
+  t.className = 'toast ' + kind;
+  t.textContent = msg;
+  t.classList.remove('hidden');
+  clearTimeout(t._t);
+  t._t = setTimeout(() => t.classList.add('hidden'), ms);
+}
+
+async function api(path, opts = {}) {
+  const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
+  if (S.token) headers['X-Token'] = S.token;
+  let res;
+  try {
+    res = await fetch(path, Object.assign({ method: opts.method || 'GET' }, opts,
+      { headers, body: opts.body ? JSON.stringify(opts.body) : undefined }));
+  } catch (e) {
+    return { ok: false, code: 'NET', msg_cn: '无法连接到路由器管理服务:' + e.message };
+  }
+  if (res.status === 401) {
+    logout(true);
+    return { ok: false, code: 'UNAUTH', msg_cn: '登录已过期,请重新登录' };
+  }
+  try { return await res.json(); }
+  catch (e) { return { ok: false, code: 'PARSE', msg_cn: '服务返回数据异常' }; }
+}
+
+function setActionMsg(msg, kind = '') {
+  const el = $('#ab-msg');
+  el.className = 'ab-msg ' + kind;
+  el.textContent = msg;
+}
+
+/* ============================ 登录 ============================ */
+async function doLogin() {
+  const u = $('#lg-user').value.trim(), p = $('#lg-pass').value;
+  $('#lg-btn').disabled = true;
+  const r = await api('/api/login', { method: 'POST', body: { username: u, password: p } });
+  $('#lg-btn').disabled = false;
+  if (!r.ok) { $('#lg-msg').className = 'msg err'; $('#lg-msg').textContent = r.msg_cn; return; }
+  S.token = r.data.token; S.user = r.data.user;
+  localStorage.setItem('drouter_token', S.token);
+  $('#login').classList.add('hidden');
+  $('#app').classList.remove('hidden');
+  boot();
+}
+
+function logout(silent) {
+  S.token = ''; localStorage.removeItem('drouter_token');
+  $('#app').classList.add('hidden');
+  $('#login').classList.remove('hidden');
+  if (!silent) api('/api/logout', { method: 'POST' });
+}
+
+/* ============================ 导航 ============================ */
+/* 菜单结构:分组(可折叠、带主题色)→ 页面。
+   每个分组有独立色相 hue,用于左侧色条/图标底色,便于快速区分功能域。
+   tag:'off' 表示该页尚未开发,点进去显示「等待作者完善」。 */
+const NAV_GROUPS = [
+  {
+    g: '概览', icon: '◎', hue: 210, items: [
+      { k: 'wizard', n: '新手向导', t: '第一次用这台机器上网?四步走完:接上外网 · 给内网发地址 · 配 DNS · 要不要 IPv6。每一步都带范例和体检结果', needSave: false },
+      { k: 'dash', n: '系统概览', t: '系统概览' },
+      { k: 'netstat', n: '网络状态 / 加速', t: '网络状态 · NAT 检测 · 软加速' },
+    ],
+  },
+  {
+    g: '接口', icon: '⇄', hue: 268, items: [
+      { k: 'iface', n: '网卡与桥接', t: '网卡与桥接', needSave: true },
+      { k: 'wan', n: 'WAN 口', t: 'WAN 口设置', needSave: true },
+      { k: 'pppmulti', n: 'PPPoE 多拨', t: 'PPPoE 多拨 / 多会话聚合' },
+      { k: 'lan', n: 'LAN 口', t: 'LAN 口设置', needSave: true },
+      { k: 'vlan', n: 'VLAN / IPTV', t: 'VLAN 与 IPTV 划分' },
+      { k: 'wol', n: '网络唤醒 (WOL)', t: '网卡远程唤醒 WOL' },
+    ],
+  },
+  {
+    g: '寻址与路由', icon: '⌘', hue: 190, items: [
+      { k: 'dhcp', n: 'DHCP 服务', t: 'DHCP 服务与 Options', needSave: true },
+      { k: 'dns', n: 'DNS 服务', t: 'DNS 服务', needSave: true },
+      { k: 'ipv6', n: 'IPv6 / RA', t: 'IPv6 与路由通告', needSave: true },
+      { k: 'dhcpv6', n: 'DHCPv6 / 前缀委派', t: 'DHCPv6 与前缀委派', needSave: true },
+      { k: 'ddns', n: '动态域名 DDNS', t: '动态域名解析 DDNS(IPv4 / IPv6,国内外服务商)', needSave: false },
+      { k: 'pubip', n: '真·公网 IP 判定', t: '判定出口 IP 是不是真的能被外网连进来(入向可达性实测)', needSave: false },
+    ],
+  },
+  {
+    g: '安全', icon: '⛨', hue: 0, items: [
+      { k: 'fw4', n: '防火墙 IPv4', t: 'IPv4 防火墙', needSave: true },
+      { k: 'fw6', n: '防火墙 IPv6', t: 'IPv6 防火墙', needSave: true },
+      { k: 'portfwd', n: '端口转发 / DMZ', t: '端口转发与 DMZ(nftables DNAT)', needSave: true },
+      { k: 'upnp', n: 'UPnP / NAT-PMP', t: 'UPnP / NAT-PMP', needSave: true },
+      { k: 'acl', n: '访问控制 / 时间组', t: '访问控制与家长时间组', needSave: true },
+    ],
+  },
+  {
+    g: '服务', icon: '✦', hue: 155, items: [
+      { k: 'qos', n: '智能限速 QoS', t: '智能限速与流量整形(CAKE / HTB)' },
+      { k: 'dpi', n: '应用识别 DPI', t: '应用识别与规则库更新(nDPI)' },
+      { k: 'ntp', n: 'NTP 时间同步', t: 'NTP 时间同步', needSave: true },
+      { k: 'nfs', n: '文件共享 SMB/NFS', t: '内网文件共享(SMB / NFS,跨平台模板)', needSave: true },
+      { k: 'print', n: '打印服务', t: 'CUPS 打印服务器 / USB 打印机 RAW 直通(互斥二选一),共享给手机与电脑', needSave: false },
+      { k: 'opensoho', n: 'AC/AP 管理中心', t: 'OpenSOHO 无线控制器:集中管理 OpenWRT AP 的 Wi-Fi · VLAN · PoE', needSave: false },
+    ],
+  },
+  {
+    g: '工具', icon: '⚙', hue: 35, items: [
+      { k: 'webshell', n: 'Web 终端 / 文件', t: 'Web SSH 终端与文件管理器' },
+      { k: 'api', n: '通用 API 接口', t: '对外通用 API 接口与调用范例' },
+      { k: 'diag', n: '网络诊断工具', t: '网络诊断工具' },
+      { k: 'v6test', n: 'IPv6 连通性测试', t: 'IPv6 连通性测试' },
+      { k: 'docker', n: 'Docker / Compose', t: 'Docker 与 Docker Compose 管理面板' },
+      { k: 'dcfg', n: 'Docker 引擎配置', t: 'daemon.json 可视化配置:IPv6 一键开启 · 国内镜像源切换与测速 · 日志滚动 · 默认网桥网段', needSave: false },
+    ],
+  },
+  {
+    g: '日志与审计', icon: '☰', hue: 220, items: [
+      { k: 'log', n: '系统日志', t: '日志查看' },
+      { k: 'flowlog', n: '连接与流日志', t: '连接跟踪与流量日志(统一日志系统)' },
+    ],
+  },
+  {
+    g: '系统', icon: '⛁', hue: 250, items: [
+      { k: 'depcheck', n: '依赖自检与安装', t: '运行依赖自检与一键安装' },
+      { k: 'cleanup', n: '磁盘与日志清理', t: '回收日志 / 缓存 / 临时文件,可设定阈值自动清理,防止小硬盘被占满', needSave: false },
+      { k: 'kern', n: '内核转发与加速', t: 'IP 转发 · 出向伪装 · MSS 钳制 · BBR · SNMP,五项内核级开关(每项附说明与联动影响)', needSave: false },
+      { k: 'ca', n: '证书 / SSL', t: 'CA 证书管理:建自己的 CA · 签发服务器证书 · 导入 · 部署给管理后台;附带 SSL/TLS 握手体检工具', needSave: false },
+      { k: 'power', n: '电源控制', t: '电源控制' },
+      { k: 'user', n: '用户与密钥', t: '系统用户与 SSH 公钥' },
+      { k: 'sys', n: '系统设置', t: '系统设置' },
+      { k: 'theme', n: '主题之家', t: '主题之家(Web 设计 · 离线预览 · 导入导出)' },
+      { k: 'update', n: '升级与保护', t: '系统升级与 RealVNC 保护' },
+    ],
+  },
+];
+
+/* 扁平化:供 go() 查标题等 */
+const PAGES = (() => {
+  const out = [];
+  NAV_GROUPS.forEach(gr => gr.items.forEach(it => out.push(Object.assign({ grp: gr.g }, it))));
+  return out;
+})();
+
+/* 页面 → 「保存」与「应用」各自要处理哪些配置模块。
+   两者必须分开声明,原因来自两个真实踩过的坑:
+     1) system(网卡与桥接 / LAN 口)与 portfwd(端口转发)是「纯配置」模块:
+        只入库、没有自己的配置文件,拿去 /api/apply 会直接报「未知的模块」;
+     2) 端口转发的规则是作为上下文注入 nft_v4 / nft_v6 一起渲染的,
+        所以要「保存 portfwd、应用 nft_v4+nft_v6」,顺序不能反。
+
+   VLAN / WOL / QoS / DPI / 多拨 这些页面有自己的页内按钮(走 /api/vlan、
+   /api/qos …),故意不登记在这里 —— 没登记就不会显示全局动作条,
+   免得用户点了顶栏按钮却得到一个「当前页面无需保存」的假提示。 */
+const PAGE_MODULES = {
+  iface:   { save: ['system'],            apply: [] },
+  lan:     { save: ['system'],            apply: [] },
+  wan:     { save: ['pppoe'],             apply: ['pppoe'] },
+  dhcp:    { save: ['dnsmasq'],           apply: ['dnsmasq'] },
+  dns:     { save: ['dnsmasq'],           apply: ['dnsmasq'] },
+  dhcpv6:  { save: ['dhcpv6'],            apply: ['dhcpv6'] },
+  ipv6:    { save: ['radvd', 'dhcpv6'],   apply: ['radvd', 'dhcpv6'] },
+  fw4:     { save: ['nft_v4'],            apply: ['nft_v4'] },
+  fw6:     { save: ['nft_v6'],            apply: ['nft_v6'] },
+  portfwd: { save: ['portfwd'],           apply: ['nft_v4', 'nft_v6'] },
+  upnp:    { save: ['upnp'],              apply: ['upnp'] },
+  ntp:     { save: ['ntp'],               apply: ['ntp'] },
+};
+
+/* 页面 key → 渲染函数。缺失此项会导致点击菜单时报
+   "VIEWS is not defined",页面永远停在「正在载入…」。 */
+const VIEWS = {
+  wizard: viewWizard,
+  dash: viewDash,
+  netstat: viewNetStat,
+  iface: viewIface,
+  wan: viewWan,
+  pppmulti: viewPppMulti,
+  lan: viewLan,
+  vlan: viewVlan,
+  wol: viewWol,
+  dhcp: viewDhcp,
+  dns: viewDns,
+  ipv6: viewIpv6,
+  dhcpv6: viewDhcpv6,
+  ddns: viewDdns,
+  pubip: viewPubip,
+  fw4: viewFw,
+  fw6: viewFw,
+  portfwd: viewPortfwd,
+  acl: viewAcl,
+  nfs: viewNfs,
+  docker: viewDocker,
+  dcfg: viewDcfg,
+  print: viewPrint,
+  opensoho: viewOpensoho,
+  upnp: viewUpnp,
+  qos: viewQos,
+  dpi: viewDpi,
+  ntp: viewNtp,
+  diag: viewDiag,
+  v6test: viewV6Test,
+  webshell: viewWebShell,
+  api: viewApi,
+  log: viewLog,
+  flowlog: viewFlowLog,
+  theme: viewTheme,
+  depcheck: viewDepCheck,
+  cleanup: viewCleanup,
+  kern: viewKern,
+  ca: viewCa,
+  power: viewPower,
+  user: viewUser,
+  sys: viewSys,
+  update: viewUpdate,
+};
+
+/* 折叠状态持久化(默认只展开当前页所在分组 + 概览) */
+const NAV_OPEN = (() => {
+  try {
+    const raw = localStorage.getItem('drouter_nav_open');
+    if (raw) return JSON.parse(raw);
+  } catch (e) { /* ignore */ }
+  return null;
+})();
+
+function navSaveOpen() {
+  try { localStorage.setItem('drouter_nav_open', JSON.stringify(S.navOpen || {})); } catch (e) { /* ignore */ }
+}
+
+/* 分组图标:用色相生成柔和徽标底色 */
+function grpIcon(gr, on) {
+  return `${gr.icon}`;
+}
+
+function renderNav() {
+  if (!S.navOpen) {
+    S.navOpen = NAV_OPEN || {};
+    if (!NAV_OPEN) { S.navOpen['概览'] = true; }
+  }
+  const cur = PAGES.find(p => p.k === S.page);
+  if (cur) S.navOpen[cur.grp] = true;
+  const kw = (S.navKw || '').trim().toLowerCase();
+  const html = NAV_GROUPS.map((gr, gi) => {
+    const items = gr.items.filter(it => !kw || (it.n + it.t + gr.g).toLowerCase().includes(kw));
+    if (!items.length) return '';
+    const open = !!S.navOpen[gr.g] || !!kw;
+    const hasCur = items.some(it => it.k === S.page);
+    return `
+ +
${items.map(it => ` + `).join('')}
+
`; + }).join(''); + $('#nav').innerHTML = html || ''; + + $$('#nav .ng-head').forEach(b => b.onclick = () => { + const g = b.dataset.gh; + S.navOpen[g] = !S.navOpen[g]; + navSaveOpen(); + renderNav(); + }); + $$('#nav .nitem').forEach(b => b.onclick = () => go(b.dataset.k)); +} + +/* ------------------------------------------------------------------ + 页面级轮询定时器统一管理 + ------------------------------------------------------------------ + 为什么要有这个函数:单文件 SPA 里最容易漏的就是「切走页面却不停轮询」。 + 之前每个视图只在自己函数开头清自己的定时器 —— 那是「同一个页面重复进入」 + 的清理,**切到别的页面时没人清**。结果是:去 WAN 口开了「自动刷新」, + 然后切到概览,WAN 日志仍在每 3 秒查一次 journal;DHCP 租约同理每 10 秒一次。 + 在只有 2 核的软路由上,这些后台空转是实打实的浪费(还会持续拉起 + python helper 子进程)。 + + 所以:所有「只在某个页面内有效」的定时器都登记在这里,go() 一进来先全停, + 再由目标视图按需重新起。视图内的自检(如 `if (!box) return`)只能防住 + DOM 写入,防不住请求,必须靠这里统一关。 + ------------------------------------------------------------------ */ +function stopPageTimers() { + // 公网 IP 判定页有自己的一套轮询(含并发请求),交给它自己的停止函数 + if (typeof pubipProbeStopPoll === 'function') pubipProbeStopPoll(); + // 以下都是 interval 型定时器,清掉后置 null,view 里会重新创建 + [DASH_TIMER, WANLOG_TIMER, FWL_TIMER, DK_TIMER].forEach(t => clearInterval(t)); + DASH_TIMER = WANLOG_TIMER = FWL_TIMER = DK_TIMER = null; + // 这两个是「闭包内局部变量」,只能在各自的 view 里清 —— + // 用全局钩子让它们把清理动作挂出来(见 viewDhcp / viewV6Test) + if (typeof stopLeaseTimer === 'function') stopLeaseTimer(); + if (typeof stopV6Timer === 'function') stopV6Timer(); +} + +/* 导航代次(navigation generation)—— + 每次切页面 +1。视图函数在 await 之前记下当前代次,await 返回后如果代次 + 变了,说明用户已经切走,直接放弃写入,避免「旧页面的数据覆盖新页面」。 + 这是单文件 SPA 里比「每个视图各自判断」更可靠的统一解法: + 视图作者不需要记住加守卫,框架层兜住。 */ +let NAV_GEN = 0; + +/* 视图内部调用的辅助:在 await 前取一次代次,返回后用它校验 */ +function navGen() { return NAV_GEN; } +function navStale(g) { return g !== NAV_GEN; } + +function go(k) { + // 先停掉上一个页面留下的所有轮询,再切页面。 + // 顺序很重要:必须在 S.page 赋值之前或之后都行,但一定要早于目标视图渲染。 + stopPageTimers(); + NAV_GEN++; // 作废所有在途的旧视图请求 + S.page = k; + closeDrawer(); // 手机端:选完菜单自动收起抽屉 + renderNav(); + const p = PAGES.find(x => x.k === k) || {}; + $('#page-title').textContent = p.t || ''; + const bar = $('#actionbar'); + // 动作条只给「有全局保存/应用目标」的页面:PAGE_MODULES 里登记过的, + // 以及 ACL / 文件共享这两个走专属接口、在处理器里单独分支的页面。 + const hasBar = !!PAGE_MODULES[k] || k === 'acl' || k === 'nfs'; + if (hasBar) { bar.classList.remove('hidden'); setActionMsg('改动后请点击「保存并应用」'); } + else bar.classList.add('hidden'); + // 尚未开发的页面:给出统一占位,避免点进去像"坏了" + if (p.tag === 'soon') { renderSoon(p); return; } + $('#view').innerHTML = '
正在载入…
'; + const fn = VIEWS[k]; + if (!fn) { renderSoon(p); return; } + // 视图可能是 async;必须捕获异常,否则渲染失败会永久停在「正在载入…」 + // 同时用代次校验:如果渲染期间用户已经切到别的页面,就不要再用旧结果 + // 覆盖新页面(无论是成功渲染还是错误面板)。 + const gen = NAV_GEN; + Promise.resolve() + .then(() => fn()) + .then(() => { if (navStale(gen)) return; }) + .catch(e => { + if (navStale(gen)) return; // 已经切走,错误面板不该盖在新页面上 + const el = $('#view'); + if (el) el.innerHTML = `
+

页面渲染失败

+

${esc(e && e.message || String(e))}

+

可点击右上角「刷新」重试,或查看「日志」页排查。

`; + toast('页面渲染失败:' + (e && e.message || e), 'err', 6000); + }); +} + +/* 占位页:功能已在规划中,界面结构先占位 */ +function renderSoon(p) { + $('#view').innerHTML = ` +
+
+

${esc(p.t || '该功能')}

+

+ 这个模块的界面框架已经就位,具体功能还在排期开发中。 +

+
等待作者完善
+

+ 你可以先继续使用其他已完成的模块。如果你希望优先开发这一项, + 欢迎到项目主页反馈: + github.com/liuzhuohua +

+
`; +} + +/* ============================ 概览 ============================ */ +async function viewDash() { + const v = $('#view'); + if (DASH_TIMER) { clearInterval(DASH_TIMER); DASH_TIMER = null; } + const [si, met, svc, ifc, v6, route] = await Promise.all([ + api('/api/sysinfo'), api('/api/metrics'), api('/api/services'), + api('/api/ifaces'), api('/api/ipv6'), api('/api/routes')]); + const d = (si.data || {}); + const m = (met.data || {}); + const ifs = (ifc.data || []).filter(x => !x.is_virtual || x.name === 'docker0'); + const ppp = ifs.find(x => x.name.startsWith('ppp')); + const lan = $w('lan') || {}, sys = S.cfg.system || {}; + const wanset = sys.wan_iface ? ifs.find(x => x.name === sys.wan_iface) : null; + + const bios = d.bios_info || {}; + const virt = d.virt || {}; + const cpu = d.cpu || {}; + const now = new Date(); + const timeStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日${now.getHours()}时${String(now.getMinutes()).padStart(2, '0')}分`; + + // 磁盘展示:disk_root 是对象(之前直接拼进模板导致 [object Object]) + const dk = m.disk || {}; + const diskSub = dk.total_h + ? `根分区 ${dk.used_h} / ${dk.total_h}(可用 ${dk.free_h})` + : '根分区(/)使用情况'; + + v.innerHTML = ` +
+

主机信息

+
+
+
主机名${esc(d.hostname || '—')}
+
BIOS 启动方式${bios.firmware + ? `${esc(bios.firmware)}` + : '检测中…'} + ${esc(bios.vendor || '')} ${esc(bios.version || '')} ${esc(bios.date || '')}
+
运行平台${virt.is_vm + ? `⚠ ${esc(virt.name || '虚拟机')} + ${esc(virt.product || '')}` + : `物理机 + ${esc(virt.product || '')}`}
+
操作系统${esc(d.os || d.distro || 'Debian GNU/Linux 13 (trixie)')} · 内核 ${esc(d.kernel || '')}
+
运行时间${fmtDur(d.uptime_s)} + 负载 ${esc((m.load || d.load || []).join(' / '))}
+
当前时间${esc(timeStr)}
+
+
+
+ 累计已下载 / 已上传
+
+
↓ —
+
↑ —
+
+
统计起始时间读取中…
+
+
+
+ +
+ ${gauge('CPU 使用率', m.cpu_pct, cpu.model || (d.cpu_count || '—') + ' 核', '#f59e0b', + `${cpu.cores || d.cpu_count || '—'} 逻辑核心 · ${cpu.physical_cores || '—'} 物理核心${cpu.mhz ? ' · ' + cpu.mhz + ' MHz' : ''}`)} + ${gauge('内存使用率', (m.mem || {}).pct, `${(m.mem || {}).used_mb || 0} / ${(m.mem || {}).total_mb || 0} MB`, '#3b82f6', + `可用 ${(m.mem || {}).avail_mb || 0} MB 共 ${(m.mem || {}).total_mb || 0} MB`)} + ${gauge('磁盘使用率', dk.pct, '', '#10b981', diskSub)} + ${gauge('进程 / 温度', d.proc_pct_scale != null ? d.proc_pct_scale : null, + `${(m.proc_count || d.proc_count || 0)} 个进程`, '#8b5cf6', + ((m.temp_available != null ? m.temp_available : d.temp_available) + ? `CPU 温度 ${m.temp_c} °C${m.temp_src ? '(' + m.temp_src + ')' : ''}` + : '未读取到温度传感器,仅显示进程数') + + ` 进程上限 ${Number(d.proc_max || 0).toLocaleString()}(占用 ${d.proc_pct != null ? d.proc_pct : '—'}%)`, + ((m.temp_available != null ? !m.temp_available : !d.temp_available) && d.temp_hint) ? { warn: d.temp_hint } : null)} +
+ +
+

网络实时质量

+

每 2 秒自动刷新。速率按物理网卡合计;抖动 / 响应对网关(或公共 DNS)探测,结果缓存 20 秒以节省资源。

+
+ ${stat('下行速率', '—', '实时')} + ${stat('上行速率', '—', '实时')} + ${stat('响应延迟(RTT)', '—', '探测中…')} + ${stat('抖动 / 丢包', '—', '—')} +
+
+ 包速率:— + 已建立连接:— + TCP 重传累计:— + 统计网卡:全部物理口 +
+
+
+
+

网络状态

当前接口地址与默认路由

+ ${ifs.length ? ifs.map(i => `
${esc(i.name)} + ${i.addrs.filter(a => a.family === 'inet' || a.family === 'inet6').slice(0, 3) + .map(a => `${esc(a.addr)}${a.family === 'inet' ? '/' + a.prefix : ''}`).join('
') || '无地址'} + ${i.oper === 'UP' ? 'UP' : 'DOWN'} +
`).join('') : '
无
'} +
默认路由${esc(((route.data || {}).v4 || []) + .filter(r => r.dst === 'default').map(r => r.gateway + ' (dev ' + r.dev + ')').join('
') || '无')}
+
+
+

服务状态

核心服务运行情况(灰色为未启用)

+ ${Object.entries(svc.data || {}).map(([k, x]) => { + const on = x.active === 'active'; + const cn = ({ dnsmasq: 'DHCP/DNS 服务', radvd: 'IPv6 RA', dhcpcd: 'DHCPv6 客户端', miniupnpd: 'UPnP', chrony: 'NTP 客户端', 'systemd-networkd': 'networkd', NetworkManager: 'NetworkManager', sshd: 'SSH', 'drouter-web': '管理后台', lightdm: 'LightDM 桌面', rsyslog: '日志服务' })[k] || k; + return `
${esc(cn)} + ${on ? '运行中' : '未运行'} ${x.enabled === 'enabled' ? '开机自启' : '禁用'}
`; + }).join('')} +
+
+
+

IPv6 状态

WAN 地址 / 前缀委派(PD)/ 邻居表

+ ${(() => { + const v6d = v6.data || {}; + const g = (v6d.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6') + .map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope }))); + const global = g.filter(x => x.sc === 'global'); + return `
公网 IPv6 地址${global.length ? global.map(x => esc(x.a) + '/' + x.p + ' ' + esc(x.if) + '').join('
') : '未获取'}
+
默认路由${esc(v6d.default_route || '无')}
+
IPv6 转发${(v6d.sysctl || {})['all.forwarding'] === '1' ? '已开启' : '未开启'}
+
邻居数量${(v6d.neigh || []).length}
`; + })()} +
+
+

当前阶段说明

+

本项目采用「先构建、后切换」策略,确保不影响正在运行的局域网与 RealVNC 会话。

+
构建阶段进行中 + DHCP 服务、防火墙规则、拨号均只保存不启用
+
WAN 口${sys.wan_iface ? esc(sys.wan_iface) : '未分配(请先添加网卡,然后在「网卡与桥接」中指派)'}
+
LAN 口${esc(sys.lan_iface || '—')} · ${esc(sys.lan_address || '')}
+
切换方式填写完 PPPoE 账号密码并保存后,由你决定何时执行切换
+
`; + startDashLive(d, m); +} + +/* ---------- 概览实时刷新(#2 / #3 / #13) ---------- + 只轮询一个轻量接口 /api/metrics(后端已做差分缓存,成本极低), + 每 2 秒局部更新数字,不重建 DOM,避免闪烁与卡顿。 */ +let DASH_TIMER = null; + +/* 页面级定时器的「停止钩子」—— + 租约与 IPv6 测试的定时器声明在各自 view 的闭包里,外面拿不到引用。 + 这里放两个全局钩子,由 view 在创建定时器时挂上、在 go() 里统一调用。 + 默认是空函数,所以即使从没进过那个页面也不会报错。 */ +let stopLeaseTimer = function () {}; +let stopV6Timer = function () {}; + +function startDashLive(d, m) { + if (DASH_TIMER) { clearInterval(DASH_TIMER); DASH_TIMER = null; } + const apply = (x) => { + if (!x) return; + const set = (id, val, color) => { + const el = document.getElementById(id); + if (!el) return; + el.innerHTML = val; + if (color) el.style.color = color; + }; + const mem = x.mem || {}, net = x.net || {}, rtt = x.rtt || {}, + tcp = x.tcp || {}, dk = x.disk || {}; + // 顶部仪表卡(局部更新文字,不重绘卡片结构) + const g = document.getElementById('dash-gauges'); + if (g) { + const cards = g.querySelectorAll('.stat'); + const put = (i, pct, mid, sub) => { + const c = cards[i]; if (!c) return; + const valEl = c.querySelector('.val'); + const barI = c.querySelector('.bar > i'); + const subEl = c.querySelector('.sub'); + const hasV = pct !== null && pct !== undefined && isFinite(Number(pct)); + const p = hasV ? Math.max(0, Math.min(100, Number(pct))) : 0; + const col = !hasV ? 'var(--txt3)' : (p >= 85 ? '#ef4444' : (p >= 65 ? '#f59e0b' : null)); + if (valEl) valEl.innerHTML = hasV + ? `${p % 1 ? p.toFixed(1) : p}% + ${esc(mid || '')}` + : valEl.innerHTML; + if (barI && hasV) { barI.style.width = p + '%'; if (col) barI.style.background = col; } + if (subEl && sub != null) subEl.textContent = sub; + }; + put(0, x.cpu_pct, (d.cpu || {}).model || '', null); + put(1, mem.pct, `${mem.used_mb || 0} / ${mem.total_mb || 0} MB`, + `可用 ${mem.avail_mb || 0} MB 共 ${mem.total_mb || 0} MB`); + put(2, dk.pct, '', dk.total_h + ? `根分区 ${dk.used_h} / ${dk.total_h}(可用 ${dk.free_h})` : null); + if (cards[3]) { + const subEl = cards[3].querySelector('.sub'); + if (subEl) subEl.textContent = `${x.proc_count || 0} 个进程 ${x.temp_available + ? 'CPU 温度 ' + x.temp_c + ' °C' : '未读取到温度传感器,仅显示进程数'}`; + } + } + // 副卡:累计流量(实时速率只保留在下方的「网络实时质量」,避免同页重复显示) + const nt = x.net_total || {}; + set('dash-rx', '↓ ' + (nt.rx_h || '—'), '#10b981'); + set('dash-tx', '↑ ' + (nt.tx_h || '—'), '#3b82f6'); + set('q-rx', fmtRate(net.rx_bps), '#10b981'); + set('q-tx', fmtRate(net.tx_bps), '#3b82f6'); + set('q-rx-sub', (net.rx_pps != null ? net.rx_pps + ' 包/秒' : '实时')); + set('q-tx-sub', (net.tx_pps != null ? net.tx_pps + ' 包/秒' : '实时')); + set('q-rtt', rtt.ms != null ? rtt.ms + ' ms' : (rtt.loss_pct === 100 ? '超时' : '—')); + set('q-rtt-sub', '目标 ' + esc(rtt.target || '—') + (rtt.cached ? '(缓存)' : '')); + set('q-jit', rtt.jitter_ms != null ? rtt.jitter_ms + ' ms' : '—'); + set('q-jit-sub', rtt.loss_pct != null ? '丢包 ' + rtt.loss_pct + '%' : '—'); + set('q-pps', `${fmtRate(net.rx_pps)} / ${fmtRate(net.tx_pps)}`); + set('q-estab', String(tcp.estab != null ? tcp.estab : '—')); + set('q-retrans', String(tcp.retrans != null ? tcp.retrans : '—')); + set('q-dev', (x.ifaces || []).map(i => i.name).join(' / ') || '全部物理口'); + if (x.load) set('dash-load', esc(x.load.join(' / '))); + set('dash-net-extra', nt.since + ? `自 ${esc(nt.since)} 起累计` + : '尚未开始累计统计'); + }; + apply(m); + DASH_TIMER = setInterval(async () => { + // 页面切走就停,避免后台白白耗资源 + if (S.page !== 'dash' || !document.getElementById('dash-gauges')) { + clearInterval(DASH_TIMER); DASH_TIMER = null; return; + } + const r = await api('/api/metrics'); + if (r.ok) apply(r.data); + }, 2000); +} + +/* 速率格式化:B/s → 人类可读 */ +function fmtRate(bps) { + const n = Number(bps) || 0; + if (n < 1024) return n.toFixed(0) + ' B/s'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB/s'; + if (n < 1024 * 1024 * 1024) return (n / 1048576).toFixed(2) + ' MB/s'; + return (n / 1073741824).toFixed(2) + ' GB/s'; +} + +const stat = (l, v, s, extra = '') => `
${l}
+
${v}
${extra}
${s || ''}
`; +const bar = p => `
`; + +/* 彩色进度条 + 数值的仪表卡(用于 CPU / 内存 / 磁盘) */ +/* 彩色进度条卡。 + pct 为 null/undefined 时表示"该项不可用"(例如虚拟机没有温度传感器), + 此时不画进度条、不显示数字,避免把真实数值(如进程数)误当成百分比。 + 注意:第 2 个参数必须是"百分比 0-100",绝不要把原始数量直接传进来。 */ +function gauge(label, pct, mid, color, sub, opt) { + opt = opt || {}; + const hasVal = pct !== null && pct !== undefined && pct !== '' && isFinite(Number(pct)); + const p = hasVal ? Math.max(0, Math.min(100, Number(pct))) : 0; + const warn = !hasVal ? 'var(--txt3)' : (p >= 85 ? '#ef4444' : (p >= 65 ? '#f59e0b' : color)); + const head = hasVal + ? `${p % 1 ? p.toFixed(1) : p}% + ${esc(String(mid == null ? '' : mid))}` + : `— + ${esc(String(mid == null ? '不可用' : mid))}`; + const bar = hasVal + ? `
` + : `
`; + // 惊叹号提示:用于"该项因环境原因不可用"(虚拟机无温度传感器等) + const warnIcon = opt.warn ? `!` : ''; + const warnBox = opt.warn ? `
${esc(opt.warn)}
` : ''; + return `
+
${esc(label)}${warnIcon}
+
${head}
+ ${bar} +
${esc(String(sub == null ? '' : sub))}
+ ${warnBox} +
`; +} + +/* 环形饼图(SVG),用于内存等关键指标 */ +function ring(pct, label, color) { + const p = Math.max(0, Math.min(100, Number(pct) || 0)); + const warn = p >= 85 ? '#ef4444' : (p >= 65 ? '#f59e0b' : color); + const r = 52, c = 2 * Math.PI * r, off = c * (1 - p / 100); + return ` + + + ${p}% + ${esc(label)} + `; +} + +const $w = k => (S.cfg[k] || {}); + +/* ============================ 网卡与桥接 ============================ */ +const IFACE_ROLES = [ + { v: '', n: '未分配' }, + { v: 'wan', n: 'WAN(上行 / 光猫)' }, + { v: 'lan', n: 'LAN(局域网主口)' }, + { v: 'bridge', n: '桥接成员(并入网桥)' }, + { v: 'guest', n: '访客网络' }, + { v: 'iot', n: 'IoT 设备专网' }, + { v: 'dmz', n: 'DMZ(对外暴露)' }, + { v: 'mgmt', n: '管理口(仅管理访问)' }, + { v: 'unused', n: '保留 / 未接线' }, +]; + +async function viewIface() { + const [r, m, meth] = await Promise.all([ + api('/api/ifaces'), api('/api/iface/meta'), api('/api/iface_method')]); + const ifs = (r.data || []).filter(x => !x.is_virtual && x.name !== 'lo'); + const meta = {}; + (m.data || []).forEach(x => meta[x.mac] = x); + const method = {}; + ((meth.data || {}).ifaces || meth.data || []).forEach(x => { + if (x && x.name) method[x.name] = x; + }); + S.meta = meta; S.ifaces = ifs; S.ifaceMethod = method; + const known = new Set((m.data || []).map(x => x.mac)); + // 只统计「可插拔的物理网卡」:本机自建的虚拟设备(如 QoS 的 ifb-*)不该要求指派角色 + const newOnes = ifs.filter(i => !known.has(i.mac) && !i.is_virtual && !i.managed_by); + + const tip = $('#newiface-tip'); + // 关闭状态按「网卡 MAC 集合」记忆:用户关掉后不再打扰, + // 但将来真插了一张新卡、集合变了,提示会重新出现。 + const pendingKey = newOnes.map(x => x.mac).sort().join(','); + const dismissed = () => { + try { return localStorage.getItem('drouter_newiface_dismissed') || ''; } catch (e) { return ''; } + }; + const show = newOnes.length > 0 && dismissed() !== pendingKey; + tip.classList.toggle('hidden', !show); + if (show) { + tip.innerHTML = `
检测到 ${newOnes.length} 个新网卡(${newOnes.map(x => esc(x.name)).join('、')})尚未分类,请指派角色。
` + + ``; + const x = $('#newiface-x'); + if (x) { + x.onclick = () => { + try { localStorage.setItem('drouter_newiface_dismissed', pendingKey); } catch (e) { /* ignore */ } + tip.classList.add('hidden'); + }; + } + } + + const roleOpts = v => IFACE_ROLES.map(o => + ``).join(''); + + const drvTag = src => { + if (!src) return '未知'; + if (src.indexOf('厂商') >= 0) return `${esc(src)}`; + if (src.indexOf('内核') >= 0 || src.indexOf('发行版') >= 0) return `${esc(src)}`; + return `${esc(src)}`; + }; + + $('#view').innerHTML = ` +
+

物理网卡

+

以 MAC 地址作为唯一标识,即使网卡名变化(ens18 → enp6s18)也能找回你的备注与角色。 + 每一行都显示:Debian 13 当前的配置方式(手动 / DHCP / 静态)、驱动版本与来源、角色。

+ ${ifs.length ? ifs.map(i => { + const m2 = meta[i.mac] || {}; + const mt = method[i.name] || {}; + const spd = i.speed ? i.speed + ' Mbps' : '—'; + const stTag = mt.state === 'UP' || i.oper === 'UP' + ? 'UP' : 'DOWN'; + const mTag = mt.method + ? `${esc(mt.method)}` + : '未配置'; + return `
+
+
${esc(i.name)} ${stTag}
+
${esc(i.mac)}
+
${mTag}
+
+
+ 速率 ${spd}
MTU ${i.mtu}
+ 驱动 ${esc(i.driver || '—')} + ${i.driver_version && i.driver_version !== '未知' ? 'v' + esc(i.driver_version) + '' : ''}
+ ${drvTag(i.driver_source)} +
+
+ ${(i.addrs || []).filter(a => a.family === 'inet').map(a => `${esc(a.addr)}/${a.prefix}`).join('
') || '无 IPv4'} + ${mt.connection ? '
连接:' + esc(mt.connection) : ''} +
+ + + +
`; + }).join('') : '

未检测到物理网卡。

'} +
+ +
+

驱动来源说明

+

帮助判断网卡驱动的可靠性。绿色为随 Debian 内核自带(最稳),蓝色为硬件厂商官方驱动,橙色为第三方编译(升级内核后可能失效)。

+
内核内建随 Debian 内核一起发布,无需额外安装,升级内核自动跟随。
+
厂商官方硬件厂商(如 Intel、Realtek、Broadcom)发布的官方版本,稳定性好但需手动更新。
+
发行版官方源来自 Debian 官方仓库的固件包(如 firmware-iwlwifi),随 apt 升级。
+
第三方(DKMS)为较新硬件手动编译的驱动(如 r8125、r8168),内核升级时会自动重编译。
+
第三方(手工编译)手工 make install 的驱动,内核升级后需要重新编译,否则网卡会失联。
+
+ +
+

网桥(桥接剩余网口)

+

将多个网口桥接为一个二层交换域。把多个网口的角色设为「桥接成员」后,可在此统一启用网桥。

+ +
+ + +
+
${ifs.map(i => ``).join('')}
+

注意:勾选成员口后应用会重配该网卡,可能导致链路瞬时中断。

+
+ +
+

新增网卡监测

+

页面每次加载都会比对当前网卡 MAC 集合与已知清单,出现未知 MAC 时会在顶部提示。

+
当前网卡数${ifs.length}
+
已知(已分类)${(m.data || []).filter(x => x.role).length}
+
待分类${newOnes.length ? '' + newOnes.length + ' 个' : '无'}
+
`; + + $$('[data-ifsave]').forEach(b => b.onclick = async () => { + const mac = b.dataset.ifsave; + const remark = $(`input[data-mac="${mac}"][data-f=remark]`).value; + const role = $(`select[data-mac="${mac}"][data-f=role]`).value; + const r2 = await api('/api/iface/save', { method: 'POST', body: { mac, name: b.dataset.name, remark, role } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + if (r2.ok) { setActionMsg('网卡信息已保存:' + b.dataset.name + ' 角色=' + (role || '未分配'), 'ok'); } + }); + $('#br-en').onchange = e => { S.cfg.system = Object.assign($w('system'), { bridge_enabled: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); }; + $('#br-name').oninput = e => { S.cfg.system = Object.assign($w('system'), { bridge_name: e.target.value }); setActionMsg('已修改,请点击保存并应用'); }; + $$('.brm').forEach(c => c.onchange = () => { + const sel = $$('.brm').filter(x => x.checked).map(x => x.value); + S.cfg.system = Object.assign($w('system'), { bridge_members: sel }); + setActionMsg('已修改,请点击保存并应用'); + }); +} + +/* ============================ WAN ============================ */ +let WANLOG_TIMER = null; +function viewWan() { + const p = $w('pppoe'), sys = $w('system'); + const hasWan = !!sys.wan_iface; + // 若 pppoe.iface 未设置,默认选中角色为 wan 的网卡(免去用户手选) + const roleWan = (S.ifaces.find(i => i.role === 'wan') || {}).name || ''; + const defaultWan = sys.wan_iface || roleWan; + // 当前连接方式:优先取已保存的,其次按是否填了账号推断 + const curMode = p.mode || 'pppoe'; + $('#view').innerHTML = ` + ${hasWan ? `
+

已检测到 WAN 口:${esc(sys.wan_iface)}

+

+ 可在下方填写宽带账号与密码。点击「仅保存」只写入配置,不会拨号。 + 需要正式拨号时,先解除构建保护模式,再点「开始拨号」。

` + : `
+

尚未分配 WAN 口

+

+ 请在虚拟机平台中为该主机添加第二张网卡,然后在「网卡与桥接」页面将其角色设为 WAN。 + 在此之前,下面填写的 PPPoE 凭据只会被保存,不会执行拨号。

`} +
+

连接方式

+

支持 PPPoE 拨号、DHCP 自动获取、静态地址三种模式,可一键切换(切换会影响全网)。

+
+ + + +
+
当前模式${wanModeName(curMode)}
+
+
+
+ +
+

PPPoE 宽带拨号

+

适用于中国大陆运营商:中国电信 / 中国联通 / 中国移动 / 中国广电。 + 实际认证方式与是否需要服务名取决于当地运营商,请以实测为准。

+
+ + +
+
+ + +
+
+ + + +
+ +
+ + +
+

凭据保存在本机 SQLite 数据库中,应用时写入 /etc/ppp/chap-secrets(权限 600)。

+
+ +
+

WAN 静态地址(静态模式时生效)

+
+ + + +
+
+ +
+

WAN 接入方式实时状态与日志

+

支持中国大陆家庭宽带全部主流接入方式,日志每 3 秒自动滚动刷新,全部中文呈现, + 并针对每种方式给出排错提示。日志来源:—

+
+ + + +
+

+
+ + 读取中… + +
+
+
+ + +
+
正在读取接入日志…
+

+
+ +
+

当前 WAN 状态

+

实时读取,无需手动刷新

+
读取中…
+
+ + +
+
+
+
+

执行切换(请在我方确认后进行)

+

以下按钮会真正改变网络状态,导致当前连接短暂中断。请在填写并保存凭据、且确认 WAN 口就绪后再执行。

+
+ + +
+
`; + liveWan(); + liveWanLog(); + // 接入日志自动刷新(3 秒) + const pppAuto = $('#pp-log-auto'); + const startPpp = () => { + if (WANLOG_TIMER) { clearInterval(WANLOG_TIMER); WANLOG_TIMER = null; } + if (pppAuto && pppAuto.checked) WANLOG_TIMER = setInterval(liveWanLog, 3000); + }; + if (pppAuto) pppAuto.onchange = startPpp; + startPpp(); + + // 接入方式切换 + const accEl = $('#wan-access'); + if (accEl) { + accEl.value = p.access_type || 'pppoe'; + accEl.onchange = () => { + S.cfg.pppoe = Object.assign($w('pppoe'), { access_type: accEl.value }); + const d = $('#wan-access-desc'); + const info = (WAN_ACCESS_MAP[accEl.value] || {}); + if (d) d.textContent = info.desc || ''; + setActionMsg('WAN 接入方式已选为「' + (info.n || accEl.value) + '」,请点击保存并应用'); + liveWanLog(); + }; + } + const sinceEl = $('#wan-since'); + if (sinceEl) sinceEl.onchange = liveWanLog; + const wr = $('#wan-refresh'); if (wr) wr.onclick = () => { liveWanLog(); toast('已刷新接入日志', 'ok'); }; + const plc = $('#pp-log-clear'); if (plc) plc.onclick = () => { + if (pppAuto) { pppAuto.checked = false; startPpp(); } + toast('已暂停自动刷新', 'ok'); + }; + + $$('#wan-mode button').forEach(b => b.onclick = () => { + $$('#wan-mode button').forEach(x => x.classList.remove('on')); + b.classList.add('on'); + const m = b.dataset.m; + S.cfg.pppoe = Object.assign($w('pppoe'), { mode: m }); + const cur = $('#wan-mode-cur'); + if (cur) cur.innerHTML = wanModeName(m); + renderWanModePanel(m); + setActionMsg('连接方式已改为「' + wanModeName(m) + '」,请点击右上角「保存并应用」'); + }); + renderWanModePanel(curMode); + const bind = (id, key, conv) => { + const el = $(id); if (!el) return; + const h = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { [key]: conv ? conv(el.value) : el.value }); setActionMsg('已修改,请点击保存并应用'); }; + el.oninput = h; if (el.tagName === 'SELECT') el.onchange = h; + }; + bind('#pp-isp', 'isp'); bind('#pp-user', 'username'); + bind('#pp-pass', 'password'); bind('#pp-svc', 'service_name'); + // 拨号网卡单独处理,确保 iface 一定被写入 + const ifEl = $('#pp-iface'); + if (ifEl) { + ifEl.onchange = () => { + S.cfg.pppoe = Object.assign($w('pppoe'), { iface: ifEl.value }); + setActionMsg('拨号网卡已选为 ' + (ifEl.value || '未分配') + ',请点击保存并应用'); + }; + } + bind('#pp-mtu', 'mtu', Number); bind('#pp-mru', 'mru', Number); + bind('#pp-holdoff', 'holdoff', Number); bind('#pp-maxfail', 'maxfail', Number); + bind('#wn-addr', 'static_address'); bind('#wn-gw', 'static_gateway'); bind('#wn-dns', 'static_dns'); + $('#pp-persist').onchange = e => { S.cfg.pppoe = Object.assign($w('pppoe'), { persist: e.target.checked }); setActionMsg('已修改'); }; + $('#wan-test').onclick = async () => { + $('#wan-out').innerHTML = '
正在测试…
'; + const r = await api('/api/diag', { method: 'POST', body: { tool: 'ping', target: '223.5.5.5', count: 4 } }); + $('#wan-out').innerHTML = `
${esc((r.data || {}).out || r.msg_cn)}
`; + }; + $('#pp-status').onclick = async () => { + const r = await api('/api/ppp', { method: 'POST', body: { op: 'status' } }); + toast(r.msg_cn || (r.data && r.data.up ? '已连接' : '未连接'), r.ok ? 'ok' : 'err'); + }; + const confirmThen = (op, word, label) => { + modal(`${label}`, `

此操作会立即改变 WAN 连接状态,可能导致当前网络中断。

+

请输入 ${word} 以确认:

`, + async () => { + if ($('#cfm-in').value.trim() !== word) { toast('确认文字不正确', 'err'); return false; } + const r = await api('/api/ppp', { method: 'POST', body: { op, confirm: true } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err'); + setTimeout(liveWan, 3000); + }); + }; + $('#pp-conn').onclick = () => confirmThen('connect', '确认拨号', '开始 PPPoE 拨号'); + $('#pp-disc').onclick = () => confirmThen('disconnect', '确认断开', '断开 PPPoE 拨号'); +} + +/* WAN 接入方式统一实时状态栏 + 中文日志(#4) */ +const WAN_ACCESS_MAP = { + pppoe: { n: 'PPPoE 拨号', desc: '光猫桥接 + 本机拨号,需要宽带账号密码(电信/联通/移动/广电均常见)。' }, + dhcp: { n: 'DHCP 自动(IPoE)', desc: '光猫已拨号,下挂设备自动获取地址;也用于部分地区「IPoE 免拨号」接入。' }, + static: { n: '静态地址(专线 / 固定 IP)', desc: '运营商分配固定公网 IP,手动配置地址、网关与 DNS,常见于企业专线。' }, + pppoe_dhcp6: { n: 'PPPoE + DHCPv6-PD(双栈)', desc: 'IPv4 走 PPPoE、IPv6 通过 DHCPv6-PD 下发前缀,国内主流双栈方式。' }, + ipoe_dhcp6: { n: 'IPoE + DHCPv6-PD(双栈)', desc: 'IPv4/IPv6 均由上级自动下发,适用于光猫路由模式 + IPv6 直连。' }, + bridge: { n: '桥接旁路(不改 WAN)', desc: '仅做二层透传/旁路,不参与拨号,WAN 状态由上级设备决定。' }, +}; + +async function liveWanLog() { + const box = $('#pp-live'); + // 先判 DOM 再发请求。早期写法是先 fetch 再 `if (!box) return`, + // 切走页面后请求照发(每 3 秒一次 journal 查询),只是结果被丢弃。 + if (!box) { if (WANLOG_TIMER) { clearInterval(WANLOG_TIMER); WANLOG_TIMER = null; } return; } + const acc = ($('#wan-access') || {}).value || (($w('pppoe').access_type) || 'pppoe'); + const since = ($('#wan-since') || {}).value || '2 hours ago'; + const ifname = $w('system').wan_iface || $w('pppoe').iface || ''; + const r = await api('/api/wan/log?access=' + encodeURIComponent(acc) + + '&since=' + encodeURIComponent(since) + '&iface=' + encodeURIComponent(ifname) + + '&limit=150'); + const d = r.data || {}; + if (!r.ok) { + box.innerHTML = `
读取失败:${esc(r.msg_cn || '未知错误')}
`; + return; + } + // 说明与来源 + const sd = $('#wan-src'); if (sd) sd.textContent = d.source || '—'; + const ad = $('#wan-access-desc'); if (ad) ad.textContent = d.access_desc || ''; + // 状态栏 + const dot = $('#pp-dot'), stext = $('#pp-stext'), smeta = $('#pp-smeta'); + if (dot && stext) { + const state = d.state || 'unknown'; + const cls = /connect|up/.test(state) ? 'on' : (/connect|retry|config/.test(state) ? 'warn' : 'off'); + dot.className = 'dot ' + cls; + stext.textContent = d.state_cn || '未知状态'; + if (smeta) smeta.textContent = (d.access_cn || '') + (d.iface ? ' 接口 ' + d.iface : ''); + } + // 元信息 + const mt = $('#wan-meta'); + if (mt) { + const rows = d.meta || []; + mt.innerHTML = rows.length + ? rows.map(x => `
·${esc(x)}
`).join('') + : ''; + } + // 日志(最新在最下,自动滚到底) + const lines = d.lines || []; + if (lines.length) { + box.innerHTML = lines.map(l => { + const lv = l.level || 'info'; + return `
${esc(l.ts || '')}${esc(l.cn || l.raw || '')}
`; + }).join(''); + box.scrollTop = box.scrollHeight; + } else { + box.innerHTML = `
${esc(d.tip || '暂无日志。')}
`; + } + const tp = $('#wan-tip'); if (tp) tp.textContent = lines.length ? '' : (d.tip || ''); +} + +const WAN_MODES = { + pppoe: { n: 'PPPoE 拨号', hint: '通过宽带账号密码拨号上网,适用于中国大陆运营商光猫桥接场景。' }, + dhcp: { n: 'DHCP 自动获取', hint: '由上级光猫或路由器自动分配 IP,适用于光猫已拨号后再接本机的情况。' }, + static: { n: '静态地址', hint: '手动指定 IP / 掩码 / 网关 / DNS,适用于专线或固定 IP 场景。' }, +}; +const wanModeName = m => (WAN_MODES[m] || WAN_MODES.pppoe).n; + +function renderWanModePanel(mode) { + const el = $('#wan-mode-panel'); if (!el) return; + const hint = $('#wan-mode-hint'); + const info = WAN_MODES[mode] || WAN_MODES.pppoe; + if (hint) hint.innerHTML = info.hint; + const p = $w('pppoe'); + if (mode === 'dhcp') { + el.innerHTML = ` +
地址获取自动从上级 DHCP 服务器获取 IPv4 地址与网关
+ + +

DHCP 模式下,WAN 口会自动获取地址;DNS 可沿用上级下发,也可在「DNS 服务」页面自行指定。

`; + const mt = $('#dh-mtu'); if (mt) mt.oninput = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { dhcp_mtu: Number(mt.value) }); setActionMsg('已修改,请点击保存并应用'); }; + const dn = $('#dh-dns'); if (dn) dn.onchange = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { dhcp_use_dns: dn.checked }); setActionMsg('已修改,请点击保存并应用'); }; + } else if (mode === 'static') { + el.innerHTML = ` +
+ + +
+
+ + +
+

静态地址需与上级网络同网段,否则无法上网。填写后可点下方「测试连通性」验证。

`; + const bs = (id, k) => { const e = $(id); if (e) e.oninput = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { [k]: e.value }); setActionMsg('已修改,请点击保存并应用'); }; }; + bs('#st-addr', 'static_address'); bs('#st-gw', 'static_gateway'); + const collectDns = () => { + const a = ($('#st-dns1') || {}).value || '', b = ($('#st-dns2') || {}).value || ''; + S.cfg.pppoe = Object.assign($w('pppoe'), { static_dns: [a, b].filter(Boolean).join(',') }); + setActionMsg('已修改,请点击保存并应用'); + }; + if ($('#st-dns1')) $('#st-dns1').oninput = collectDns; + if ($('#st-dns2')) $('#st-dns2').oninput = collectDns; + } else { + el.innerHTML = `
拨号参数请在下方的「PPPoE 宽带拨号」卡片中填写账号密码与网卡
`; + } + return el; +} + +async function liveWan() { + const el = $('#wan-live'); if (!el) return; + const r = await api('/api/ifaces'); + const ifs = r.data || []; + const wan = ifs.find(i => /^ppp/.test(i.name)) || ifs.find(i => i.name === ($w('system').wan_iface || '')); + const sys = $w('system'); + if (!sys.wan_iface) { el.innerHTML = `
WAN 口未分配
`; return; } + if (!wan) { el.innerHTML = `
WAN 口${esc(sys.wan_iface)}
+
连接状态未连接
`; return; } + const rx = (wan.stat || {}).rx_bytes || 0, tx = (wan.stat || {}).tx_bytes || 0; + const ip4 = (wan.addrs || []).filter(a => a.family === 'inet')[0]; + const ip6 = (wan.addrs || []).filter(a => a.family === 'inet6' && a.scope === 'global')[0]; + el.innerHTML = ` +
接口${esc(wan.name)} ${wan.oper === 'UP' ? 'UP' : 'DOWN'}
+
IPv4 地址${ip4 ? esc(ip4.addr) + '/' + ip4.prefix : '未获取'}
+
IPv6 地址${ip6 ? esc(ip6.addr) + '/' + ip6.prefix : '未获取'}
+
MTU${wan.mtu}
+
累计接收${fmtBytes(rx)}
+
累计发送${fmtBytes(tx)}
+
实时速率测量中…
`; + const t0 = Date.now(), r0 = rx, x0 = tx; + setTimeout(async () => { + const r2 = await api('/api/ifaces'); + const w2 = (r2.data || []).find(i => i.name === wan.name); if (!w2) return; + const dt = (Date.now() - t0) / 1000; + const dr = ((w2.stat.rx_bytes || 0) - r0) / dt, dx = ((w2.stat.tx_bytes || 0) - x0) / dt; + const e2 = $('#wan-rate'); + if (e2) e2.innerHTML = `↓ ${fmtBytes(dr)}/s    ↑ ${fmtBytes(dx)}/s`; + }, 1500); +} + +/* ============================ LAN ============================ */ +function viewLan() { + const sys = $w('system'); + $('#view').innerHTML = ` +
+

局域网接口

+

指定本路由器在局域网中的地址。当前使用 DHCP 获取的地址,建议在正式切换时改为静态。

+
+ + + +
+
+ + +
+

当前系统实际地址:

+
+
+

桥接成员

+

桥接后在「网卡与桥接」页勾选成员口;此处显示当前桥接状态。

+
网桥状态${sys.bridge_enabled ? '已启用' : '未启用'}
+
成员口${(sys.bridge_members || []).join(', ') || '无'}
+
说明当前仅一个物理网口,无剩余口可桥接
+
`; + const bind = (id, key, conv) => { + const el = $(id); if (!el) return; + el.oninput = el.onchange = () => { S.cfg.system = Object.assign($w('system'), { [key]: conv ? conv(el.value) : el.value }); setActionMsg('已修改,请点击保存并应用'); }; + }; + bind('#ln-if', 'lan_iface'); bind('#ln-addr', 'lan_address'); + bind('#ln-mtu', 'lan_mtu', Number); bind('#ln-gw', 'gateway'); bind('#ln-dom', 'domain'); + api('/api/ifaces').then(r => { + const i = (r.data || []).find(x => x.name === sys.lan_iface); + const a = i && (i.addrs || []).find(x => x.family === 'inet'); + const e = $('#ln-cur'); if (e) e.textContent = a ? a.addr + '/' + a.prefix + '(' + (a.valid ? '动态获取' : '静态') + ')' : '—'; + }); +} + +/* ============================ DHCP ============================ */ +function viewDhcp() { + const d = $w('dnsmasq'); + const opts = d.options || []; + $('#view').innerHTML = ` +
+

DHCP 服务开关

+

关闭时仅做 DNS 转发,不分配地址。构建阶段默认关闭,避免与现有网络中的 DHCP 冲突。

+ +
+
+

地址池

+
+ + + + +
+
+
+

DHCP Options(RouterOS 风格)

+

对应 dhcp-option=<code>,<value>。 + code 3 = 默认网关,code 6 = DNS 服务器。可自由添加任意 code(如 43、121 等)。

+
+ + +
+
+ + + ${opts.map((o, i) => ` + + + + + + `).join('')} +
启用Code值(Value)备注强制
+
+

提示:勾选「强制」会使用 dhcp-option-force,即使客户端未请求也会下发。

+ +
+

静态地址绑定

+

按 MAC 固定分配 IP,格式:dhcp-host=MAC,IP[,名称]

+ + + ${(d.static_leases || []).map((s, i) => ` + + + + + `).join('')} +
启用MACIP名称
+
+
+
+

当前租约(实时)

+

dnsmasq 已分配的客户端,类似 RouterOS 的 Lease 列表。 + 双击 IP 可为该客户端选择 DHCP Options;每行右侧可一键转为静态绑定、或回收该 IP 回地址池。

+
+ + +
+
读取中…
+
`; + + const sync = () => { + const o = (d.options || []).map((x, i) => { + const g = k => $(`.op-${k}[data-i="${i}"]`); + return { enabled: g('en').checked, code: g('code').value, value: g('val').value, remark: g('rm').value, force: g('fc').checked }; + }); + const s = (d.static_leases || []).map((x, i) => { + const g = k => $(`.sl-${k}[data-i="${i}"]`); + return { enabled: g('en').checked, mac: g('mac').value, ip: g('ip').value, name: g('nm').value }; + }); + S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { + dhcp_enabled: $('#dh-en').checked, + pool_start: $('#dh-s').value, pool_end: $('#dh-e').value, + pool_netmask: $('#dh-m').value, lease_time: Number($('#dh-t').value), + option_gateway: $('#op-gw').value, option_dns: $('#op-dns').value, + options: o, static_leases: s, + }); + setActionMsg('已修改,请点击保存并应用'); + }; + const rebuild = () => { S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { options: d.options, static_leases: d.static_leases }); viewDhcp(); }; + $$('.op-en,.op-code,.op-val,.op-rm,.op-fc').forEach(e => e.oninput = e.onchange = sync); + $$('.sl-en,.sl-mac,.sl-ip,.sl-nm').forEach(e => e.oninput = e.onchange = sync); + $('#dh-en').onchange = sync; $('#dh-s').oninput = sync; $('#dh-e').oninput = sync; + $('#dh-m').oninput = sync; $('#dh-t').oninput = sync; + $('#op-gw').oninput = sync; $('#op-dns').oninput = sync; + $$('.op-del').forEach(b => b.onclick = () => { d.options.splice(+b.dataset.i, 1); rebuild(); }); + $$('.sl-del').forEach(b => b.onclick = () => { d.static_leases.splice(+b.dataset.i, 1); rebuild(); }); + $('#op-add').onclick = () => { d.options.push({ enabled: true, code: '', value: '', remark: '', force: false }); rebuild(); }; + $('#sl-add').onclick = () => { d.static_leases.push({ enabled: true, mac: '', ip: '', name: '' }); rebuild(); }; + // 租约刷新(手动 / 自动) + let leaseTimer = null; + const loadLeases = () => { + // 先判 DOM 再发请求:切走页面后 box 就不存在了,早期实现是先 fetch 再判, + // 结果切页后仍在每 10 秒空打一次 /api/leases。这里前置判断 + 停表双保险。 + if (!$('#dh-leases')) { + if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; } + return Promise.resolve(); + } + return api('/api/leases').then(r => { + const rows = r.data || []; + const box = $('#dh-leases'); if (!box) return; + box.innerHTML = rows.length ? ` + + + ${rows.map(x => ` + + + + + + `).join('')} +
IP 地址MAC 地址主机名剩余时间类型操作
${esc(x.ip)}${esc(x.mac)}${esc(x.name || '—')}${esc(x.remain_text || '—')}${x.is_static ? '静态' : '动态'} + + +
` : '

暂无租约记录(DHCP 服务未启动或尚无客户端)。

'; + bindLeaseActions(); + }); + }; + // 把「停表」动作挂到全局钩子,go() 切页时会调用它(闭包内变量外面拿不到) + stopLeaseTimer = () => { if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; } }; + const rf = $('#dh-lease-refresh'); + if (rf) rf.onclick = () => { loadLeases(); toast('租约已刷新', 'ok'); }; + const auto = $('#dh-lease-auto'); + if (auto) { + auto.onchange = () => { + stopLeaseTimer(); + if (auto.checked) leaseTimer = setInterval(loadLeases, 10000); + }; + if (auto.checked) leaseTimer = setInterval(loadLeases, 10000); + } +} + +/* 绑定租约表格上的按钮:转为静态 / 回收 / 双击选 Options */ +function bindLeaseActions() { + $$('.lease-mk').forEach(b => b.onclick = async () => { + const r = await api('/api/lease/static', { method: 'POST', + body: { ip: b.dataset.ip, mac: b.dataset.mac, host: b.dataset.host } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err'); + if (r.ok && S.cfg.dnsmasq) { + // 同步到页面上的静态绑定表 + const lst = ($w('dnsmasq').static_leases || []); + if (!lst.some(x => (x.mac || '').toLowerCase() === b.dataset.mac.toLowerCase())) { + lst.push({ enabled: true, mac: b.dataset.mac, ip: b.dataset.ip, name: b.dataset.host }); + S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { static_leases: lst }); + } + setActionMsg('已把 ' + b.dataset.ip + ' 转为静态绑定,请点击保存并应用'); + } + }); + $$('.lease-rm').forEach(b => b.onclick = () => { + modal('回收 IP 地址', `

即将回收客户端 ${esc(b.dataset.ip)}(${esc(b.dataset.mac)})的租约。

+

该客户端会暂时断网,下次续租时将重新分配地址。

+

请输入 确认回收 以继续:

`, async () => { + if ($('#cfm-lease').value.trim() !== '确认回收') { toast('确认文字不正确', 'err'); return false; } + const r = await api('/api/lease/release', { method: 'POST', + body: { ip: b.dataset.ip, mac: b.dataset.mac, confirm: true } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err'); + if (r.ok) { + const rf = $('#dh-lease-refresh'); if (rf) rf.click(); + } + }); + }); + $$('.lease-ip').forEach(td => td.ondblclick = () => leaseOptions(td.dataset)); +} + +/* 双击租约 IP:为该客户端配置 DHCP Options(RouterOS 风格) */ +function leaseOptions(ds) { + const ip = ds.ip, mac = ds.mac, host = ds.host || ''; + modal('为该客户端下发 DHCP Options', ` +

客户端:${esc(ip)} 主机名:${esc(host || '—')} MAC:${esc(mac)}

+

这些 Options 仅对当前客户端生效,等价于 dnsmasq 的 + dhcp-host + dhcp-option 组合。

+ + + + `, + async () => { + const gw = $('#lo-gw').value.trim(), dns = $('#lo-dns').value.trim(); + const extra = $('#lo-extra').value.split('\n').map(x => x.trim()).filter(Boolean); + const mkStatic = $('#lo-static').value === '1'; + const lst = ($w('dnsmasq').static_leases || []); + const found = lst.find(x => (x.mac || '').toLowerCase() === mac.toLowerCase()); + const entry = found || { enabled: true, mac, ip, name: host }; + entry.enabled = true; entry.mac = mac; entry.ip = ip; entry.name = host; + entry.options = []; + if (gw) entry.options.push({ enabled: true, code: '3', value: gw, remark: '客户端网关', force: false }); + if (dns) entry.options.push({ enabled: true, code: '6', value: dns, remark: '客户端 DNS', force: false }); + extra.forEach(line => { + const i = line.indexOf('='); + if (i > 0) entry.options.push({ enabled: true, code: line.slice(0, i).trim(), value: line.slice(i + 1).trim(), remark: '自定义', force: false }); + }); + if (!found) lst.push(entry); + S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { static_leases: lst }); + if (mkStatic) { + const r = await api('/api/lease/static', { method: 'POST', body: { ip, mac, host } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err'); + } + setActionMsg('已为该客户端配置 Options,请点击保存并应用'); + return true; + }); +} + +/* ============================ 防火墙 ============================ */ + +/* 防火墙示例规则:全部用 # 注释包裹,用户删除注释符即可启用 */ +function FW_EXAMPLE(kind) { + const v4 = kind === '4'; + const head = `# ===================================================================== +# drouter ${v4 ? 'IPv4' : 'IPv6'} 防火墙示例规则 +# 说明:以下每一行都以 # 开头(注释状态),不会生效。 +# 删除行首的 # 即可启用该条规则;改完点击下方「仅语法检查」再「保存并应用」。 +# 留空整个文本框 = 由系统按页面顶部参数自动生成默认规则。 +# ===================================================================== + +# --- 表与链的骨架(一般保持启用) --- +table inet drouter { + chain input { + type filter hook input priority 0; policy drop; + # --- 放行已建立连接(务必保留) --- + ct state established,related accept + # --- 放行本机回环 --- + iif lo accept + # --- 放行 ICMP(ping) --- + ip protocol icmp accept + # --- 放行 SSH(按需修改端口) --- + tcp dport 22 accept + # --- 放行 Web 管理端口 --- + tcp dport { 8443, 8080 } accept + } +`; + + const body4 = ` + # --- 放行 LAN 侧全部流量 --- + chain forward { + type filter hook forward priority 0; policy drop; + ct state established,related accept + iifname "ens18" accept + oifname "ens18" accept + } + # --- 出站 NAT(PPPoE / WAN 共享上网) --- + chain postrouting { + type nat hook postrouting priority 100; policy accept; + oifname "ppp0" masquerade + } + # --- PPPoE MSS 钳制(避免部分网站打不开) --- + chain forward_mss { + type filter hook forward priority -1; policy accept; + tcp flags syn tcp option maxseg size set rt mtu + } +# ===================================================================== +# 常用可选规则(取消注释后生效) +# ===================================================================== +# 禁止某台设备联网: +# iifname "ens18" ether saddr 00:11:22:33:44:55 drop +# 限制某端口仅内网访问: +# iifname "ppp0" tcp dport 3389 drop +# 只允许指定 DNS 服务器: +# iifname "ens18" udp dport 53 ip daddr != 192.168.7.3 drop +} + +# 如需端口转发(把外网 8080 转到内网 192.168.7.100:80): +# table ip drouter_nat { +# chain prerouting { +# type nat hook prerouting priority -100; policy accept; +# iifname "ppp0" tcp dport 8080 dnat to 192.168.7.100:80 +# } +# }`; + + const body6 = ` + # --- 放行 LAN 侧全部流量 --- + chain forward { + type filter hook forward priority 0; policy drop; + ct state established,related accept + iifname "ens18" accept + oifname "ens18" accept + } +# ===================================================================== +# 常用可选规则(取消注释后生效) +# ===================================================================== +# 放行 ICMPv6(IPv6 必需,建议保留启用): +# icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert, echo-request } accept +# 禁止某台设备使用 IPv6 上网: +# iifname "ens18" ether saddr 00:11:22:33:44:55 drop +# 限制某端口仅内网访问: +# iifname "ppp0" tcp dport 22 drop +} + +# 如需 IPv6 端口转发(把外网 8080 转到内网 [fd00::100]:80): +# table ip6 drouter_nat6 { +# chain prerouting { +# type nat hook prerouting priority -100; policy accept; +# iifname "ppp0" tcp dport 8080 dnat to [fd00::100]:80 +# } +# }`; + + return head + (v4 ? body4 : body6); +} + +function viewFw(kind) { + const key = kind === '4' ? 'nft_v4' : 'nft_v6'; + const c = $w(key); + $('#view').innerHTML = ` +
+

IPv${kind} 防火墙规则

+

使用 nftables 语法。应用前会自动执行 nft -c 语法预检, + 失败会阻止应用并保留现有规则;应用前自动创建快照,可一键回滚。

+
+ + + ${kind === '4' ? `` : ''} +
+
+ + + +
+
+

防火墙日志(滚动)

+

把 nftables 命中的包记录到内核日志,并在下面实时滚动显示(已翻译成简体中文)。 + 记录本身会占用少量 CPU,因此内置限速(默认 20 包/秒),避免日志风暴拖垮小内存设备。

+
+ +
+
+ + +
+

修改后请点击右上角「保存并应用」才会写入规则并生效。

+
+ +

留空 = 使用系统按参数生成的默认规则(含 NAT、转发、MSS 钳制)。 + 下方默认内容已用 # 注释包裹,删除注释符即可启用对应规则。

+
+
+

防火墙实时日志(滚动显示)

+

数据来自内核日志中带 DROUTER-FW 前缀的规则命中记录,已逐条翻译。 + 开启自动滚动后可当作实时监控窗口使用。

+
+ + + + +
+
+ + + + +
+
+

正在读取…

+
+
+

预检与应用

+
+ + +
+
+
`; + const sync = () => { + S.cfg[key] = Object.assign($w(key), { + wan_iface: $('#fw-wan').value, + lan_ifaces: $('#fw-lan').value.split(',').map(x => x.trim()).filter(Boolean), + raw: $('#fw-raw').value, + log_drop: $('#fw-logdrop').checked, + log_accept: $('#fw-logaccept').checked, + log_rate: Number($('#fw-lograte').value) || 20, + ...(kind === '4' ? { mss_clamp: $('#fw-mss').value === '1' } : {}), + }); + setActionMsg('已修改,请点击保存并应用'); + }; + ['#fw-wan', '#fw-lan', '#fw-raw', '#fw-lograte'].forEach(s => { const e = $(s); if (e) e.oninput = sync; }); + ['#fw-logdrop', '#fw-logaccept'].forEach(s => { const e = $(s); if (e) e.onchange = sync; }); + if (kind === '4') $('#fw-mss').onchange = sync; + $('#fw-def').onclick = () => { $('#fw-raw').value = ''; sync(); toast('已切换为按参数自动生成默认规则', 'ok'); }; + $('#fw-view').onclick = async () => { + $('#fw-out').innerHTML = '
读取中…
'; + const r = await api('/api/nft'); + $('#fw-out').innerHTML = `
${esc((r.data || {}).raw || r.msg_cn)}
`; + }; + $('#fw-check').onclick = async () => { + toast('正在执行语法预检…', 'ok'); + const r = await api('/api/apply', { method: 'POST', body: { module: key, data: $w(key) } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 6000); + }; + $('#fw-rollback').onclick = listSnapshots; + fwLogBind(kind); +} + +/* ---------- 防火墙实时日志(#1) ---------- */ +let FWL_TIMER = null; + +function fwLogBind(kind) { + if (FWL_TIMER) { clearInterval(FWL_TIMER); FWL_TIMER = null; } + const fam = $('#fwl-fam'); + if (fam) fam.value = 'all'; + const load = () => fwLogLoad(); + $('#fwl-refresh').onclick = load; + ['#fwl-fam', '#fwl-act', '#fwl-since'].forEach(s => { const e = $(s); if (e) e.onchange = load; }); + const q = $('#fwl-q'); + if (q) q.oninput = debounce(load, 400); + $('#fwl-export').onclick = async () => { + const r = await api(fwLogUrl()); + const blob = new Blob([JSON.stringify((r.data || {}).items || [], null, 2)], + { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'drouter-firewall-log-' + Date.now() + '.json'; + a.click(); + }; + $('#fwl-clear').onclick = () => { $('#fwl-out').innerHTML = '

已清空显示(不影响内核日志)。

'; }; + const auto = $('#fwl-auto'); + if (auto) auto.onchange = () => { + if (FWL_TIMER) { clearInterval(FWL_TIMER); FWL_TIMER = null; } + if (auto.checked) { + load(); + FWL_TIMER = setInterval(load, 3000); + toast('已开启自动滚动(每 3 秒)', 'ok', 2000); + } else { + toast('已关闭自动滚动', 'ok', 2000); + } + }; + load(); +} + +function fwLogUrl() { + const fam = ($('#fwl-fam') || {}).value || 'all'; + const act = ($('#fwl-act') || {}).value || ''; + const since = ($('#fwl-since') || {}).value || '60 min ago'; + const q = ($('#fwl-q') || {}).value || ''; + return `/api/fw/log?family=${encodeURIComponent(fam)}&action=${encodeURIComponent(act)}` + + `&since=${encodeURIComponent(since)}&q=${encodeURIComponent(q)}&limit=300`; +} + +async function fwLogLoad() { + const out = $('#fwl-out'); + if (!out) return; + const r = await api(fwLogUrl()); + if (!r.ok) { out.innerHTML = `

${esc(r.msg_cn || '读取失败')}

`; return; } + const d = r.data || {}; + const items = d.items || []; + const st = d.stat || {}; + const statEl = $('#fwl-stat'); + if (statEl) { + const en = d.enabled !== false; + // 规则没载入内核时,后面两个 tag 会直接解释「为什么一条都没有」, + // 避免用户对着空白页以为这个功能根本没做。 + const ap = d.applied !== false; + const fw = d.forwarding === true; + statEl.innerHTML = `${en ? '日志开关已开启' : '日志开关已关闭'} + ${ap ? '规则已载入' : '规则未载入'} + ${fw ? 'IP 转发已开启' : 'IP 转发未开启'} + 共 ${st.total || 0} 条 + 拒绝/丢弃 ${st.reject || 0} + 放通 ${st.accept || 0} + IPv4 ${st.v4 || 0} + IPv6 ${st.v6 || 0} +  区间:${esc(d.since || '')}`; + } + if (d.warn) { + out.innerHTML = `

${esc(d.warn)}

`; + return; + } + if (!items.length) { + out.innerHTML = '

该范围内没有命中记录(这是好事:说明没有异常流量被拒绝)。

'; + return; + } + // 最新的排在最上面,便于滚动观察 + const rows = items.slice().reverse(); + out.innerHTML = `
+ ${rows.map(x => `
+ ${esc(x.family === 'IPv6' ? 'v6' : 'v4')} + ${esc(x.ts || '')} + ${esc(x.action_cn || '')} + ${esc(x.summary_cn || '')} + ${x.len ? `· ${esc(x.len)} 字节${x.ttl ? ' · TTL/Hop ' + esc(x.ttl) : ''}` : ''} + +
`).join('')}
`; +} + +/* ============================ IPv6 ============================ */ + +/* IPv6 池策略(对应 RouterOS pool-policy) */ +const V6P_POLICIES = [ + { v: 'recommend', n: '推荐(recommend)', d: '默认策略,优先选用推荐前缀,兼容性最好' }, + { v: 'strict', n: '严格(strict)', d: '严格按池范围分配,池用尽时不分新地址' }, + { v: 'without-acquire', n: '不主动获取(without-acquire)', d: '仅使用已有前缀,不主动向运营商索取新的 PD' }, + { v: 'auto-link-local', n: '自动链路本地(auto link-local)', d: '无公网前缀时回退使用 fe80:: 链路本地地址' }, +]; + +function viewIpv6() { + const r = $w('radvd'), d = $w('dhcpv6'); + $('#view').innerHTML = ` +
+

WAN 侧 IPv6 获取

+

通过 DHCPv6 客户端获取地址与前缀委派(PD)。 + 注意:运营商是否下发 PD 需实测,有链路地址不代表能拿到可分配给内网的前缀。

+
读取中…
+
+
+

DHCPv6 / 前缀委派(PD)

+
+ + + +
+
+ + +
+ + +
+
+

局域网 RA 通告(radvd)

+

向局域网设备通告 IPv6 路由与地址前缀。

+
+ + +
+
+ + + + +
+
+ + + + +
+ + +
+
+

ND 邻居发现参数

+

来自内核 /proc/sys/net/ipv6,只读展示;如需修改可在服务器上用 sysctl 调整。

+
读取中…
+
+
+

IPv6 地址池 / 前缀下发(RouterOS 风格)

+

把从运营商获取的 IPv6 前缀分配给内网。类似 RouterOS 的 + /ipv6 address from-pool= + pool-policy。

+ +
+ + + +
+
+ + + +
+
+ +
+

池策略(Pool Policy)

+

对应 RouterOS 的 pool-policy,可多选。勾选的策略会在生成地址与 RA 通告时生效。

+
+ ${V6P_POLICIES.map(p => ``).join('')} +
+
+ +
+

当前获取到的 IPv6 前缀(实时)

+

系统自动探测,可在此为每个前缀添加备注(例如「电信 /56」)。

+

读取中…

+
`; + const rsync = () => { + S.cfg.radvd = Object.assign($w('radvd'), { + iface: $('#ra-if').value, prefix: $('#ra-pfx').value, + max_interval: Number($('#ra-max').value), min_interval: Number($('#ra-min').value), + lifetime: Number($('#ra-life').value), hop_limit: Number($('#ra-hop').value), + valid_life: Number($('#ra-vl').value), pref_life: Number($('#ra-pl').value), + rdnss: $('#ra-rdnss').value, dnssl: $('#ra-dnssl').value, + managed: $('#ra-mg').checked, other_config: $('#ra-oc').checked, + }); + S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { + wan_iface: $('#dv-wan').value, lan_iface: $('#dv-lan').value, + prefix_len: Number($('#dv-plen').value), sla_id: $('#dv-sla').value, + iaid: Number($('#dv-iaid').value), + request_pd: $('#dv-pd').checked, slaac: $('#dv-na').checked, + }); + setActionMsg('已修改,请点击保存并应用'); + }; + ['#ra-if', '#ra-pfx', '#ra-max', '#ra-min', '#ra-life', '#ra-hop', '#ra-vl', '#ra-pl', + '#ra-rdnss', '#ra-dnssl', '#dv-wan', '#dv-lan', '#dv-plen', '#dv-sla', '#dv-iaid'] + .forEach(s => { const e = $(s); if (e) e.oninput = rsync; }); + ['#ra-mg', '#ra-oc', '#dv-pd', '#dv-na'].forEach(s => { const e = $(s); if (e) e.onchange = rsync; }); + + // IPv6 地址池 / 池策略 + const psync = () => { + const policy = $$('.v6pol').filter(x => x.checked).map(x => x.value); + S.cfg.radvd = Object.assign($w('radvd'), { + pool_enabled: $('#v6p-en').checked, + pool_from: $('#v6p-from').value, + pool_name: $('#v6p-name').value, + pool_prefix: $('#v6p-prefix').value, + pool_subnet_bits: Number($('#v6p-sub').value), + pool_start: Number($('#v6p-start').value), + pool_end: Number($('#v6p-end').value), + pool_policy: policy, + }); + setActionMsg('地址池设置已修改,请点击保存并应用'); + }; + ['#v6p-name', '#v6p-prefix', '#v6p-sub', '#v6p-start', '#v6p-end'].forEach(s => { + const e = $(s); if (e) e.oninput = psync; + }); + ['#v6p-en', '#v6p-from'].forEach(s => { const e = $(s); if (e) e.onchange = psync; }); + $$('.v6pol').forEach(c => c.onchange = psync); + + api('/api/ipv6').then(r2 => { + const d2 = r2.data || {}; + const g = (d2.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6') + .map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope }))); + const glob = g.filter(x => x.sc === 'global'); + $('#v6-live').innerHTML = ` +
公网地址${glob.length ? glob.map(x => esc(x.a) + '/' + x.p + ' @' + esc(x.if)).join('
') : '未获取'}
+
默认路由${esc(d2.default_route || '无')}
+
PD 前缀${glob.some(x => x.p <= 60) ? '似乎已获取委派前缀' : '暂未检测到 PD(需运营商支持)'}
`; + $('#v6-nd').innerHTML = Object.entries(d2.sysctl || {}).map(([k, v]) => + `
${esc(k)}${esc(v)}
`).join('') || '

无数据

'; + $('#v6-pool').innerHTML = glob.length + ? ` + ${glob.map(x => ` + `).join('')}
接口地址前缀长度备注
${esc(x.if)}${esc(x.a)}/${x.p}
` + : '

暂无可记录的前缀(运营商可能未下发公网 IPv6)。

'; + $$('.v6prem').forEach(inp => inp.oninput = () => { + const rem = Object.assign({}, $w('radvd').pool_remarks || {}); + rem[inp.dataset.pfx] = inp.value; + S.cfg.radvd = Object.assign($w('radvd'), { pool_remarks: rem }); + setActionMsg('前缀备注已修改,请点击保存并应用'); + }); + }); +} + +/* ============================ DNS ============================ */ +function viewDns() { + const d = $w('dnsmasq'); + $('#view').innerHTML = ` +
+

DNS 上游来源

+

可继承运营商通过 PPPoE 下发的 DNS,也可使用自定义公共 DNS 或内网 DNS 服务器。

+
+ + + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+
+
+

常用公共 DNS 参考

+ + + + + + +
服务商IPv4IPv6
阿里 AliDNS223.5.5.5 / 223.6.6.62400:3200::1
腾讯 DNSPod119.29.29.29 / 182.254.116.1162402:4e00::
114 DNS114.114.114.114 / 114.114.115.115—
Cloudflare1.1.1.1 / 1.0.0.12606:4700:4700::1111
Google8.8.8.8 / 8.8.4.42001:4860:4860::8888
+
`; + const sync = () => { + S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { + dns_custom: $('#dn-c').value, dns_lan_server: $('#dn-l').value, + dns_cache_size: Number($('#dn-cs').value), domain: $('#dn-dom').value, + dns_negcache: $('#dn-neg').checked, dns_query_log: $('#dn-log').checked, + }); + setActionMsg('已修改,请点击保存并应用'); + }; + ['#dn-c', '#dn-l', '#dn-cs', '#dn-dom'].forEach(s => { const e = $(s); if (e) e.oninput = sync; }); + ['#dn-neg', '#dn-log'].forEach(s => { const e = $(s); if (e) e.onchange = sync; }); + $$('#dns-mode button').forEach(b => b.onclick = () => { + $$('#dns-mode button').forEach(x => x.classList.remove('on')); b.classList.add('on'); + S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { dns_mode: b.dataset.m }); + setActionMsg('DNS 来源已改为「' + b.textContent + '」,请点击保存并应用'); + }); + $('#dns-test').onclick = async () => { + $('#dns-out').innerHTML = '
解析中…
'; + const r = await api('/api/diag', { method: 'POST', body: { tool: 'dns', target: 'www.baidu.com' } }); + $('#dns-out').innerHTML = `
${esc((r.data || {}).out || r.msg_cn)}
`; + }; + $('#dns-cur').onclick = async () => { + const r = await api('/api/diag', { method: 'POST', body: { tool: 'ping', target: '223.5.5.5', count: 1 } }); + $('#dns-out').innerHTML = `
${esc(r.msg_cn)}\n${esc((r.data || {}).out || '')}
`; + }; +} + +/* ============================ 端口转发 / DMZ(#7) ============================ */ +const PF_PROTO_CN = { tcp: 'TCP', udp: 'UDP', 'tcp/udp': 'TCP+UDP' }; + +function viewPortfwd() { + const c = $w('portfwd'); + const rules = (c.rules || []).map(x => Object.assign({}, x)); + S.pfRules = rules.length ? rules : [newPfRule()]; + const hasWan = !!($w('system').wan_iface); + + $('#view').innerHTML = ` + ${hasWan ? '' : `
+

尚未分配 WAN 口

+

端口转发需要先有一个 WAN 口。 + 请到「网卡与桥接」把外网网卡角色设为 WAN。配置可先保存,应用时会给出提示。

`} + +
+

端口转发(DNAT)

+

把外网访问的某个端口,转发到内网某台设备的端口。这是最主流、最稳定的做法: + 只在 prerouting 链做目的地址改写,并自动在 forward 链放通, + 同时兼容内网回环访问(hairpin)。

+ + +

IPv6 是直连路由,通常无需 NAT;此处的 IPv6 转发用于上级做过前缀转换的场景。

+
+
+ + +
+
+ +
+

DMZ 主机

+

DMZ 会把所有外网端口都转发到指定的一台内网主机,等价于「1:1 全端口映射」。 + 常用于游戏主机、监控 NVR 等需要大量端口的设备。注意:开启后该主机将完全暴露在公网, + 安全性取决于该主机自身的防护,请谨慎使用。

+
+ + +
+
+ + +
+

DMZ 与上面的端口转发可同时存在;同名端口以端口转发规则优先。

+
+ +
+

规则预览(将会写入防火墙)

+

下面是当前配置渲染出的 nftables 规则片段,仅显示与端口转发相关的部分。

+ +
+
`; + + renderPfList(); + + const bindSwitch = (id, k) => { + const e = $(id); if (!e) return; + e.onchange = () => { S.cfg.portfwd = Object.assign($w('portfwd'), { [k]: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); }; + }; + bindSwitch('#pf-en4', 'enable_v4'); bindSwitch('#pf-en6', 'enable_v6'); + bindSwitch('#pf-dmz4', 'dmz_v4_enable'); bindSwitch('#pf-dmz6', 'dmz_v6_enable'); + const bindText = (id, k) => { + const e = $(id); if (!e) return; + e.oninput = () => { S.cfg.portfwd = Object.assign($w('portfwd'), { [k]: e.value }); setActionMsg('已修改,请点击保存并应用'); }; + }; + bindText('#pf-dmz4host', 'dmz_v4_host'); bindText('#pf-dmz6host', 'dmz_v6_host'); + + $('#pf-add').onclick = () => { + S.pfRules.push(newPfRule()); + renderPfList(); + setActionMsg('已新增一条空白转发规则,请填写后保存'); + }; + $('#pf-clear').onclick = () => { + modal('清空全部端口转发', '

确定要清空所有端口转发规则吗?此操作需在保存并应用后生效。

', () => { + S.pfRules = [newPfRule()]; + renderPfList(); + S.cfg.portfwd = Object.assign($w('portfwd'), { rules: [] }); + setActionMsg('已清空端口转发规则,请点击保存并应用'); + }); + }; + $('#pf-preview').onclick = async () => { + syncPfRules(); + $('#pf-out').innerHTML = '
正在渲染…
'; + const r2 = await api('/api/render', { method: 'POST', body: { module: 'nft_v4' } }); + const txt = (r2.data || {}).text || ''; + if (!r2.ok || !txt) { + $('#pf-out').innerHTML = `
${esc(r2.msg_cn || '无法获取预览')}
`; + return; + } + const lines = txt.split('\n').filter(x => /dnat|DMZ|放通转发|nat_pre|dstnat/.test(x)); + $('#pf-out').innerHTML = `
${esc(lines.length ? lines.join('\n') : '当前无端口转发规则。')}
`; + }; +} + +function newPfRule() { + return { name: '', proto: 'tcp', ext_port: '', int_ip: '', int_port: '', enable: true, family: 'v4' }; +} + +function renderPfList() { + const el = $('#pf-list'); if (!el) return; + const rows = S.pfRules || []; + el.innerHTML = rows.map((r, i) => ` +
+ + + + + + + + +
`).join('') || '

暂无规则,点击下方「新增一条转发」开始添加。

'; + + $$('#pf-list [data-f]').forEach(e => { + e.onchange = e.oninput = () => { + const row = e.closest('.pfrow'); + const i = Number(row.dataset.i); + const f = e.dataset.f; + let v = e.type === 'checkbox' ? e.checked : e.value; + if (f === 'ext_port' || f === 'int_port') v = v === '' ? '' : Number(v); + S.pfRules[i][f] = v; + if (f === 'family') renderPfList(); + setActionMsg('已修改,请点击保存并应用'); + }; + }); + $$('#pf-list [data-del]').forEach(b => b.onclick = () => { + const i = Number(b.dataset.del); + if (S.pfRules.length <= 1) { S.pfRules = [newPfRule()]; } + else { S.pfRules.splice(i, 1); } + renderPfList(); + setActionMsg('已删除一条规则,请点击保存并应用'); + }); +} + +function syncPfRules() { + const clean = (S.pfRules || []).filter(r => String(r.ext_port || '').trim() !== '' + || String(r.int_ip || '').trim() !== ''); + S.cfg.portfwd = Object.assign($w('portfwd'), { rules: clean }); + return clean; +} + +/* ===================== 访问控制 / 家长时间组(#8) ===================== */ +const ACL_DAY_CN = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; +const ACL_ACTION_TAG = { block: 'err', allow: 'ok', limit: 'warn', log: 'gray' }; + +async function viewAcl() { + $('#view').innerHTML = `

访问控制 / 家长时间组

+

正在读取配置与联动状态…

`; + const r = await api('/api/acl'); + if (!r.ok) { + $('#view').innerHTML = `

读取失败

+

${esc(r.msg_cn || '无法读取访问控制配置')}

`; + return; + } + const d = r.data || {}; + S.acl = Object.assign({}, d, { + time_groups: (d.time_groups || []).map(x => Object.assign({ days: [0, 1, 2, 3, 4, 5, 6] }, x)), + groups: (d.groups || []).map(x => Object.assign({}, x)), + rules: (d.rules || []).map(x => Object.assign({}, x)), + }); + renderAcl(); +} + +function renderAcl() { + const d = S.acl || {}; + const tgs = d.time_groups || []; + const grps = d.groups || []; + const rules = d.rules || []; + + $('#view').innerHTML = ` +
+

总开关 + ${d.enable ? '已启用' : '已停用'}

+

访问控制基于「设备组 + 时间组 + 应用分类」三段式匹配:命中后在转发链上执行阻断 / 放通 / 限速 / 仅记录。 + 应用分类直接复用 DPI(nDPI) 分类库,时间规则按北京时间(UTC+${esc(String(d.tz_offset || 8))})换算, + 跨零点的时间段会自动拆成两段写入内核。

+ +
+ DPI 分类库:${d.dpi_ready ? '已就绪' : '未安装(应用识别不可用)'} + QoS:${d.qos_enabled ? '已启用(限速动作可用)' : '未启用(限速动作不生效)'} + 时间组 ${tgs.length} · 设备组 ${grps.length} · 规则 ${rules.length} +
+

${esc(d.note || '')}

+
+ +
+

① 设备组

+

把需要统一管控的设备归到一组,支持 IPv4 / IPv6 混合填写,也支持网段(如 192.168.7.0/24)。

+
+
+ +
+

② 时间组

+

常用的作息模板已内置,点击即可套用,再按需微调。

+
+
+
+ +
+

③ 访问规则

+

规则 = 谁(设备组)+ 何时(时间组)+ 什么应用(DPI 分类)→ 怎么做(动作)。规则自上而下匹配,命中即止。

+
+
+ +
+

规则预览(将会写入内核)

+

下面是当前配置渲染出的 nftables 规则,可直接核对时间换算与星期映射是否正确。

+ +
+
`; + + renderAclPresets(); + renderAclGroups(); + renderAclTimeGroups(); + renderAclRules(); + + const en = $('#acl-en'); + if (en) en.onchange = () => { + S.acl.enable = en.target.checked; + setActionMsg('总开关已修改,请在下方点击「保存并应用」'); + }; + $('#acl-add-grp').onclick = () => { + S.acl.groups.push({ id: 'g' + Date.now().toString(36), name: '', hosts: [] }); + renderAclGroups(); + setActionMsg('已新增一个空白设备组,请填写后保存'); + }; + $('#acl-add-tg').onclick = () => { + S.acl.time_groups.push({ + id: 't' + Date.now().toString(36), name: '', days: [0, 1, 2, 3, 4, 5, 6], + start: '00:00', end: '23:59', + }); + renderAclTimeGroups(); + setActionMsg('已新增一个空白时间组,请填写后保存'); + }; + $('#acl-add-rule').onclick = () => { + S.acl.rules.push({ + name: '', enable: true, action: 'block', group: (S.acl.groups[0] || {}).id || '', + time_group: (S.acl.time_groups[0] || {}).id || '', apps: [], + }); + renderAclRules(); + setActionMsg('已新增一条空白规则,请填写后保存'); + }; + $('#acl-preview').onclick = async () => { + $('#acl-out').innerHTML = '
正在渲染…
'; + const r2 = await api('/api/acl', { method: 'POST', body: { op: 'plan', enable: S.acl.enable, + time_groups: S.acl.time_groups, groups: S.acl.groups, rules: S.acl.rules } }); + if (!r2.ok) { + $('#acl-out').innerHTML = `
${esc(r2.msg_cn || '渲染失败')}
`; + return; + } + const dd = r2.data || {}; + $('#acl-out').innerHTML = + `
统计启用规则 ${dd.rules} 条 · 时间组 ${dd.time_groups} 个 · 设备组 ${dd.groups} 个
+
${esc(dd.text || '')}
`; + }; +} + +function renderAclPresets() { + const el = $('#acl-presets'); if (!el) return; + const presets = (S.acl || {}).time_presets || []; + el.innerHTML = `
${ + presets.map((p, i) => ``).join('')}
+

点击模板会新建一个时间组;已存在同名则跳过。

`; + $$('#acl-presets [data-preset]').forEach(b => b.onclick = () => { + const p = ((S.acl || {}).time_presets || [])[Number(b.dataset.preset)]; + if (!p) return; + if ((S.acl.time_groups || []).some(x => x.name === p.name)) { + toast('已存在同名时间组「' + p.name + '」', 'warn'); return; + } + S.acl.time_groups.push({ + id: 't' + Date.now().toString(36), name: p.name, + days: (p.days || [0, 1, 2, 3, 4, 5, 6]).slice(), + start: p.start || '00:00', end: p.end || '23:59', + }); + renderAclTimeGroups(); + setActionMsg('已套用模板「' + p.name + '」,请点击保存并应用'); + }); +} + +function renderAclGroups() { + const el = $('#acl-grps'); if (!el) return; + const rows = (S.acl || {}).groups || []; + const templates = (S.acl || {}).group_templates || []; + el.innerHTML = rows.map((g, i) => ` +
+ + + + +
`).join('') || '

暂无设备组,点击右上角「新增设备组」开始。

'; + + $$('#acl-grps [data-f]').forEach(e => { + e.onchange = e.oninput = () => { + const i = Number(e.closest('.aclrow').dataset.i); + const f = e.dataset.f; + if (f === 'hosts') { + S.acl.groups[i].hosts = e.value.split(/[\s,;]+/).map(x => x.trim()).filter(Boolean); + } else { + S.acl.groups[i][f] = e.value; + } + setActionMsg('已修改,请点击保存并应用'); + }; + }); + $$('#acl-grps [data-del]').forEach(b => b.onclick = () => { + const i = Number(b.dataset.del); + const gid = (S.acl.groups[i] || {}).id; + S.acl.groups.splice(i, 1); + // 同步清理引用了该组的规则 + (S.acl.rules || []).forEach(r => { if (r.group === gid) r.group = ''; }); + renderAclGroups(); renderAclRules(); + setActionMsg('已删除设备组,请点击保存并应用'); + }); + $$('#acl-grps [data-tpl]').forEach(b => b.onclick = () => { + const i = Number(b.dataset.tpl); + if (!templates.length) { toast('没有可用的预设模板', 'warn'); return; } + modal('套用设备组模板', '
' + + templates.map(t => ``).join('') + + '
'); + $$('#modal [data-pick]').forEach(pb => pb.onclick = () => { + const t = templates.find(x => x.id === pb.dataset.pick); + if (t) { S.acl.groups[i].name = t.name; } + $('#modal').classList.add('hidden'); + renderAclGroups(); + setActionMsg('已套用模板名称,请补充地址后保存'); + }); + }); +} + +function renderAclTimeGroups() { + const el = $('#acl-tgs'); if (!el) return; + const rows = (S.acl || {}).time_groups || []; + el.innerHTML = rows.map((t, i) => ` +
+
+ + + + +
+
+ 生效星期: + ${ACL_DAY_CN.map((n, di) => ``).join('')} +
+
${ + aclCrossHint(t)}
+
`).join('') || '

暂无时间组,点击右上角「新增时间组」或直接点上面的模板。

'; + + $$('#acl-tgs [data-f]').forEach(e => { + e.onchange = e.oninput = () => { + const i = Number(e.closest('.aclrow').dataset.i); + S.acl.time_groups[i][e.dataset.f] = e.value; + if (e.dataset.f === 'start' || e.dataset.f === 'end') { + const row = e.closest('.aclrow'); + const hint = row.querySelector('.hint-inline'); + if (hint) hint.textContent = aclCrossHint(S.acl.time_groups[i]); + } + setActionMsg('已修改,请点击保存并应用'); + }; + }); + $$('#acl-tgs [data-day]').forEach(e => e.onchange = () => { + const i = Number(e.closest('.aclrow').dataset.i); + const di = Number(e.dataset.day); + const t = S.acl.time_groups[i]; + const set = new Set(t.days || []); + if (e.target.checked) set.add(di); else set.delete(di); + t.days = Array.from(set).sort(); + setActionMsg('已修改,请点击保存并应用'); + }); + $$('#acl-tgs [data-del]').forEach(b => b.onclick = () => { + const i = Number(b.dataset.del); + const tid = (S.acl.time_groups[i] || {}).id; + S.acl.time_groups.splice(i, 1); + (S.acl.rules || []).forEach(r => { if (r.time_group === tid) r.time_group = ''; }); + renderAclTimeGroups(); renderAclRules(); + setActionMsg('已删除时间组,请点击保存并应用'); + }); +} + +function aclCrossHint(t) { + const s = (t.start || '00:00'), e = (t.end || '23:59'); + const toMin = x => { const p = String(x).split(':'); return (Number(p[0]) || 0) * 60 + (Number(p[1]) || 0); }; + const days = (t.days || []).length; + if (!days) return '⚠ 未选择任何星期,该时间组不会命中任何流量。'; + if (toMin(s) > toMin(e)) { + return 'ℹ 本地时间跨零点(' + s + ' 次日 ' + e + '),会自动拆成两段;注意「生效星期」指的是起始那天。'; + } + if (toMin(s) < 8 * 60 && toMin(e) >= 8 * 60) { + return 'ℹ 该时段横跨本地 08:00(UTC 日分界),会自动拆成两段写入内核。'; + } + return ''; +} + +function renderAclRules() { + const el = $('#acl-rules'); if (!el) return; + const rows = (S.acl || {}).rules || []; + const tgs = (S.acl || {}).time_groups || []; + const grps = (S.acl || {}).groups || []; + const apps = (S.acl || {}).app_groups || []; + const acts = (S.acl || {}).actions || []; + + el.innerHTML = rows.map((r, i) => ` +
+
+ + + + + + +
+
+ 应用范围(DPI 分类,可多选): + ${apps.map(a => ``).join('')} +
+ ${(r.apps || []).length ? '' : '
未选择应用分类 → 该规则匹配该设备组的全部流量。
'} +
`).join('') || '

暂无规则,点击右上角「新增规则」开始。

'; + + $$('#acl-rules [data-f]').forEach(e => { + e.onchange = e.oninput = () => { + const i = Number(e.closest('.aclrow').dataset.i); + const f = e.dataset.f; + S.acl.rules[i][f] = (e.type === 'checkbox') ? e.checked : e.value; + if (f === 'action') renderAclRules(); + setActionMsg('已修改,请点击保存并应用'); + }; + }); + $$('#acl-rules [data-app]').forEach(e => e.onchange = () => { + const i = Number(e.closest('.aclrow').dataset.i); + const r = S.acl.rules[i]; + const set = new Set(r.apps || []); + if (e.target.checked) set.add(e.dataset.app); else set.delete(e.dataset.app); + r.apps = Array.from(set); + setActionMsg('已修改,请点击保存并应用'); + }); + $$('#acl-rules [data-del]').forEach(b => b.onclick = () => { + S.acl.rules.splice(Number(b.dataset.del), 1); + renderAclRules(); + setActionMsg('已删除规则,请点击保存并应用'); + }); +} + +/* ================== 内网文件共享 SMB / NFS(#9) ================== */ +function sb() { + S.share = S.share || {}; + S.share.samba = S.share.samba || { shares: [] }; + S.share.nfs = S.share.nfs || { exports: [] }; + S.share.samba.shares = S.share.samba.shares || []; + S.share.nfs.exports = S.share.nfs.exports || []; + return S.share; +} + +async function viewNfs() { + $('#view').innerHTML = `

内网文件共享

+

正在读取共享配置与依赖状态…

`; + const r = await api('/api/share'); + if (!r.ok) { + $('#view').innerHTML = `

读取失败

+

${esc(r.msg_cn || '无法读取文件共享配置')}

`; + return; + } + S.shareData = r.data || {}; + S.share = (r.data || {}).cfg || {}; + sb(); + renderShare(); +} + +function renderShare() { + const D = S.shareData || {}; + const c = sb(); + const pk = D.pkgs || {}; + const svc = D.services || {}; + const dirs = D.dirs || {}; + const hints = D.hints || {}; + const smbOn = !!c.enable_smb, nfsOn = !!c.enable_nfs; + const smCfg = c.samba || {}, nfCfg = c.nfs || {}; + + const smbSvc = ['smbd', 'nmbd'].map(n => `${n}:${(svc[n] || {}).active || '?'}`).join(' · '); + const nfsSvc = ['nfs-server', 'rpcbind'].map(n => `${n}:${(svc[n] || {}).active || '?'}`).join(' · '); + + $('#view').innerHTML = ` +
+

内网文件共享 + SMB ${smbOn ? '已启用' : '未启用'} + NFS ${nfsOn ? '已启用' : '未启用'}

+

${esc(D.note || '')}

+
+
Samba 软件包 + ${pk.samba && pk.samba.installed ? '已安装' : '未安装'} + ${esc(((pk.samba || {}).pkgs || []).join(' '))}
+
NFS 软件包 + ${pk.nfs && pk.nfs.installed ? '已安装' : '未安装'} + ${esc(((pk.nfs || {}).pkgs || []).join(' '))}
+
SMB 服务${esc(smbSvc)}
+
NFS 服务${esc(nfsSvc)}
+
本机 LAN 地址${esc((D.host || {}).lan_ip || '—')}
+
主配置 include${D.smb_configured ? '已写入' : '未写入'}
+
+ ${(!pk.samba || !pk.samba.installed) || (!pk.nfs || !pk.nfs.installed) ? ` +

尚未安装的软件包可用下面的命令安装(Debian 13 官方源): + ${!pk.samba || !pk.samba.installed ? `
${esc(pk.install_cmd_smb || '')}` : ''} + ${!pk.nfs || !pk.nfs.installed ? `
${esc(pk.install_cmd_nfs || '')}` : ''}

` : ''} +
+ +
+

外置存储设备(U 盘 · 移动硬盘 · Type-C · 雷电) +

+

插上的外置盘会列在下面。可直接挂载、格式化、设为开机自动挂载, + 并把挂载点一键加入上面的 SMB / NFS 共享。 + 「连接方式」显示的是总线类型:Type-C 只是插头形状,电气上仍归 USB 或雷电, + 所以按真实总线展示更准确。

+

正在读取块设备…

+
+ +
+

SMB / CIFS(推荐:Windows · macOS · Linux 通吃)

+

面向全平台,兼容性最好。Windows 双击就能用,macOS 与 Linux 也原生支持。 + 配置会写入 /etc/samba/drouter.conf,并在主配置中 include 引入。

+ +
+ + + +
+
+ + +
+

关闭 NetBIOS 可减少局域网广播;若局域网里有老旧设备(XP / 老打印机)找不到共享,可尝试取消勾选。

+

共享目录

+
+
+ + +
+
+ +
+

NFS(推荐:Linux / macOS 之间,性能更高)

+

NFS 是 Linux 原生协议,局域网内传输性能通常优于 SMB。 + 导出规则写入 /etc/exports,服务参数写入 /etc/nfs.conf.d/drouter.conf, + 端口已固定,便于防火墙精确放通。

+ +
+ +
+

线程数决定并发处理能力,家用 4–16 足够;调大反而会占用内存。

+

导出目录

+
+ +
+ +
+

什么都不用改:三平台连接方式

+

下面的命令可以直接复制使用,把 ${esc((D.host || {}).lan_ip || '192.168.7.1')} + 换成你的路由器地址即可。

+
+
+ +
+

配置预览(将会写入磁盘)

+

核对生成的 SMB / NFS 配置是否符合预期。

+ +
+
`; + + renderShareList(); + renderExportList(); + renderShareHints(); + stgLoad(); // 外置存储设备(挂载 / 格式化后可直接共享) + // 目录存在性提示 + const miss = Object.values(dirs).filter(x => !x.exists); + if (miss.length) { + const box = document.createElement('div'); + box.className = 'hint-inline'; + box.style.color = 'var(--warn)'; + box.innerHTML = '⚠ 以下共享目录还不存在:' + miss.map(x => + `${esc(x.path)}`).join('、') + + '。可在下方共享项里点「创建」按钮一键建立。'; + const first = $('#view').querySelector('.card'); + if (first) first.appendChild(box); + } + + const sw = (id, k, sub) => { + const e = $(id); if (!e) return; + e.onchange = () => { + const t = sub ? sb()[sub] : sb(); + t[k] = e.target.checked; + setActionMsg('已修改,请点击保存并应用'); + }; + }; + sw('#sh-smb', 'enable_smb'); sw('#sh-nfs', 'enable_nfs'); + sw('#sh-nb', 'disable_netbios', 'samba'); sw('#sh-wins', 'wins_enable', 'samba'); + const tx = (id, k, sub) => { + const e = $(id); if (!e) return; + e.oninput = () => { + const t = sub ? sb()[sub] : sb(); + t[k] = e.type === 'number' ? Number(e.value) : e.value; + setActionMsg('已修改,请点击保存并应用'); + }; + }; + tx('#sh-wg', 'workgroup', 'samba'); tx('#sh-ss', 'server_string', 'samba'); + tx('#sh-if', 'interfaces', 'samba'); tx('#sh-th', 'threads', 'nfs'); + + $('#sh-add-share').onclick = () => { + sb().samba.shares.push(newShare()); + renderShareList(); + setActionMsg('已新增一个共享,请填写后保存'); + }; + $('#sh-add-exp').onclick = () => { + sb().nfs.exports.push(newExport()); + renderExportList(); + setActionMsg('已新增一个 NFS 导出,请填写后保存'); + }; + $('#sh-tpl-share').onclick = () => { + const tpls = D.dir_templates || []; + modal('套用目录模板', '
' + + tpls.map((t, i) => ``).join('') + + '
'); + $$('#modal [data-dt]').forEach(b => b.onclick = () => { + const t = tpls[Number(b.dataset.dt)]; + if (t) { + const s = newShare(); + s.name = t.name.split('(')[0]; + s.path = t.path; + s.comment = t.name; + if (t.id === 'public') { s.mode = 'public'; s.writable = true; } + else if (t.id === 'media') { s.mode = 'readonly'; s.writable = false; } + else { s.mode = 'auth'; s.writable = true; } + sb().samba.shares.push(s); + } + $('#modal').classList.add('hidden'); + renderShareList(); + setActionMsg('已套用模板,请补充地址后保存'); + }); + }; + $('#sh-preview').onclick = async () => { + $('#sh-out').innerHTML = '
正在渲染…
'; + const r2 = await api('/api/share', { method: 'POST', body: { op: 'plan', ...sb() } }); + if (!r2.ok) { $('#sh-out').innerHTML = `
${esc(r2.msg_cn || '渲染失败')}
`; return; } + $('#sh-out').innerHTML = `
涉及文件${ + esc(((r2.data || {}).files || []).join('、'))}
+
${esc((r2.data || {}).text || '')}
`; + }; +} + +/* ============================ 外置存储设备 ============================ + U 盘 / 移动硬盘 / Type-C / 雷电硬盘盒:识别 → 格式化 → 挂载 → 一键共享。 + 数据来自 /api/storage(后端 lsblk + sysfs 总线判定)。 */ +let STG = null; + +async function stgLoad() { + const box = $('#stg-list'); + if (!box) return; + box.innerHTML = '

正在读取块设备…

'; + const r = await api('/api/storage', { method: 'POST', body: { op: 'get' } }); + if (!r.ok) { + box.innerHTML = `

${esc(r.msg_cn || '读取失败')}

`; + return; + } + STG = r.data || {}; + stgRender(); +} + +function stgRender() { + const box = $('#stg-list'); + if (!box) return; + const devs = (STG && STG.devices) || []; + if (!devs.length) { + box.innerHTML = '

没有检测到块设备。虚拟机里没有直通硬盘时属于正常现象。

'; + return; + } + const fstab = (STG && STG.fstab) || ''; + const row = d => { + const auto = d.uuid && fstab.indexOf('UUID=' + d.uuid) >= 0; + const plat = f => ['win', 'mac', 'linux'].filter(k => f[k]) + .map(k => ({ win: 'Win', mac: 'macOS', linux: 'Linux' }[k])).join(' · '); + const meta = ((STG && STG.fs_types) || []).find(f => f.v === d.fstype); + return ` + ${esc(d.dev)} +
${esc(d.type_cn)}${ + d.model ? ' · ' + esc(d.model) : ''}
+ ${esc(d.size_h)} + ${esc(d.bus_cn)}${ + d.external ? '' : '
内置
'} + ${d.fstype ? `${esc(d.fstype)}${ + d.label ? '
卷标 ' + esc(d.label) + '
' : ''}` + : '未格式化'} + ${d.mounted ? `${esc(d.mountpoint)}` + : '未挂载'}${ + auto ? '
开机自动挂载
' : ''} + + ${d.mounted + ? ` + ` + : (d.fstype + ? `` + : '需先格式化')} + + ${d.uuid ? `` : ''} + `; + }; + // .tw 是项目统一的「表格横向滚动」容器(手机适配检查会认它),别换成自定义 class + const tbl = list => `
+ + + ${list.map(row).join('')}
设备容量连接方式文件系统挂载点操作
`; + const ext = devs.filter(d => d.external); + const inner = devs.filter(d => !d.external); + const miss = ((STG && STG.fs_state) || {}); + const unready = Object.keys(miss).filter(k => !miss[k].ready); + box.innerHTML = ` + ${ext.length ? `

外置设备(${ext.length})

${tbl(ext)}` + : '

未检测到外置设备。插上 U 盘或移动硬盘后点「刷新」。

'} + ${inner.length ? `
+ 内置磁盘(${inner.length})${tbl(inner)}
` : ''} + ${unready.length ? `

+ 以下文件系统缺少格式化工具:${unready.map(k => + `${esc(k)}`).join('、')} + —— 可到「系统 · 依赖自检与安装」页一键安装。

` : ''} +

${esc((STG && STG.note) || '')}

`; + stgBind(); +} + +function stgBind() { + $$('#stg-list [data-stg]').forEach(b => { + if (b.disabled) return; + b.onclick = () => { + const name = b.dataset.n; + const d = ((STG && STG.devices) || []).find(x => x.name === name); + if (!d) return; + const act = b.dataset.stg; + if (act === 'mount') return stgMount(d); + if (act === 'umount') return stgUmount(d); + if (act === 'format') return stgFormat(d); + if (act === 'auto') return stgAuto(d, b.dataset.auto === '1'); + if (act === 'share') return stgShare(d); + }; + }); + const rf = $('#stg-refresh'); + if (rf && !rf._bound) { rf._bound = 1; rf.onclick = stgLoad; } +} + +async function stgOp(body, okMsg) { + const r = await api('/api/storage', { method: 'POST', body }); + if (!r.ok) { toast(r.msg_cn || '操作失败', 'err', 6000); return null; } + toast(okMsg || (r.msg_cn || '完成'), 'ok', 4000); + await stgLoad(); + return r.data || {}; +} + +async function stgMount(d) { + await stgOp({ op: 'mount', name: d.name }, '已挂载 /dev/' + d.name); +} + +async function stgUmount(d) { + modal('卸载 /dev/' + d.name, + `

确定要卸载挂载在 ${esc(d.mountpoint)} 的设备吗? + 正在读写该目录的程序会中断。

`, + async () => { await stgOp({ op: 'umount', name: d.name }); }); +} + +async function stgAuto(d, on) { + if (on) { + await stgOp({ op: 'fstab_add', name: d.name, target: d.mountpoint || '' }); + } else { + modal('取消开机自动挂载', + `

将从 /etc/fstab 移除该设备的条目。 + 原文件已备份在 /etc/drouter/generated/fstab.drouter.bak。

`, + async () => { await stgOp({ op: 'fstab_del', uuid: d.uuid }); }); + } +} + +/* 格式化:三重保护中的「二次确认」在前端这一层 —— 必须手输设备名才能提交, + 后端还会再校验一次(系统盘 / 已挂载 / 整盘含分区 都会被拒)。 */ +function stgFormat(d) { + const types = (STG && STG.fs_types) || []; + const state = (STG && STG.fs_state) || {}; + const opts = types.map(f => { + const st = state[f.v] || {}; + return ``; + }).join(''); + modal('格式化 /dev/' + d.name, + `

+ ⚠ 格式化会清除 ${esc(d.dev)}(${esc(d.size_h)})上的 + 全部数据,且无法恢复。

+
+ + +
+

+

确认请输入设备名 + ${esc(d.name)}:

+ `, + async () => { + const fs = $('#stg-fs').value; + const confirm = ($('#stg-confirm').value || '').trim(); + if (confirm !== d.name) { + toast('安全确认未通过:请输入设备名 ' + d.name, 'err', 5000); + return false; // 保持弹窗打开,让用户改 + } + const r = await api('/api/storage', { method: 'POST', body: { + op: 'format', name: d.name, fs, label: $('#stg-label').value.trim(), + confirm } }); + if (!r.ok) { toast(r.msg_cn || '格式化失败', 'err', 8000); return false; } + toast(r.msg_cn || '格式化完成', 'ok', 5000); + await stgLoad(); + }, '确认格式化'); + const sel = $('#stg-fs'); + const note = $('#stg-fs-note'); + const upd = () => { + const f = types.find(x => x.v === sel.value); + if (f && note) { + const plat = [['win', 'Windows'], ['mac', 'macOS'], ['linux', 'Linux']] + .filter(([k]) => f[k]).map(([, n]) => n).join(' · '); + note.innerHTML = `${esc(f.n)}:${esc(f.note)}
+ 可直接读写:${esc(plat || '无(需额外驱动)')}`; + } + }; + if (sel) { sel.onchange = upd; upd(); } +} + +/* 把外置盘的挂载点一键加入 SMB 共享,省掉手工填路径 */ +async function stgShare(d) { + if (!d.mountpoint) { toast('该设备尚未挂载', 'err'); return; } + const name = (d.label || d.name).replace(/[^A-Za-z0-9_-]/g, '_'); + const s = newShare(); + s.name = name; + s.path = d.mountpoint + '/'; + s.comment = '外置存储 ' + d.dev + '(' + d.size_h + ')'; + s.mode = 'public'; + s.writable = true; + sb().samba.shares.push(s); + if (!sb().enable_smb) sb().enable_smb = true; + renderShare(); + setActionMsg('已把 ' + d.mountpoint + ' 加入 SMB 共享,请点击保存并应用'); + toast('已加入 SMB 共享列表:' + d.mountpoint, 'ok', 4000); +} + +function newShare() { + return { name: '', path: '/srv/share/', comment: '', enable: true, + mode: 'auth', writable: true, users: 'drouter', allow_hosts: '', deny_hosts: '' }; +} + +function newExport() { + return { path: '/srv/share/', enable: true, comment: '', + clients: [{ net: '192.168.7.0/24', preset: 'linux', options: '' }] }; +} + +function renderShareList() { + const el = $('#sh-shares'); if (!el) return; + const rows = sb().samba.shares || []; + const dirs = (S.shareData || {}).dirs || {}; + const MODE = { auth: '需账号密码', public: '所有人可访问', readonly: '只读公开' }; + el.innerHTML = rows.map((s, i) => { + const dm = dirs[s.path] || {}; + return ` +
+
+ + + + + +
+
+ + + +
+ ${(s.mode === 'auth') ? ` +
+ +
` : ''} +
+ + +
+ ${dm.exists === false ? `
+ ⚠ 目录 ${esc(s.path)} 不存在,点上面的「创建目录」即可建立。
` : ''} +
`; + }).join('') || '

暂无共享目录,点下面「新增共享」或「套用目录模板」。

'; + + $$('#sh-shares [data-f]').forEach(e => { + e.onchange = e.oninput = () => { + const i = Number(e.closest('.sharecard').dataset.i); + const f = e.dataset.f; + const v = (e.type === 'checkbox') ? e.target.checked : e.value; + sb().samba.shares[i][f] = v; + if (f === 'mode') renderShareList(); + setActionMsg('已修改,请点击保存并应用'); + }; + }); + $$('#sh-shares [data-del]').forEach(b => b.onclick = () => { + sb().samba.shares.splice(Number(b.dataset.del), 1); + renderShareList(); + setActionMsg('已删除共享,请点击保存并应用'); + }); + $$('#sh-shares [data-mkdir]').forEach(b => b.onclick = async () => { + const i = Number(b.dataset.mkdir); + const s = sb().samba.shares[i]; + const mode = (s.mode === 'public') ? 'guest' : 'auth'; + setActionMsg('正在创建目录 ' + s.path + ' …'); + const r = await api('/api/share', { method: 'POST', body: { op: 'mkdir', path: s.path, mode } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 6000); + if (r.ok) { + const r2 = await api('/api/share'); + if (r2.ok) { S.shareData = r2.data; renderShare(); } + } + }); +} + +function renderExportList() { + const el = $('#sh-exports'); if (!el) return; + const rows = sb().nfs.exports || []; + const presets = (S.shareData || {}).nfs_presets || []; + el.innerHTML = rows.map((x, i) => ` +
+
+ + + + +
+
+ 客户端与权限: +
${(x.clients || []).map((cl, j) => ` +
+ + + + +
`).join('')}
+ +
+
`).join('') || '

暂无 NFS 导出目录,点下面「新增导出」。

'; + + $$('#sh-exports [data-f]').forEach(e => { + e.onchange = e.oninput = () => { + const i = Number(e.closest('.sharecard').dataset.i); + const f = e.dataset.f; + sb().nfs.exports[i][f] = (e.type === 'checkbox') ? e.target.checked : e.value; + setActionMsg('已修改,请点击保存并应用'); + }; + }); + $$('#sh-exports [data-c]').forEach(e => { + e.onchange = e.oninput = () => { + const wrap = e.closest('.sharecard'); + const i = Number(wrap.dataset.i); + const j = Number(e.dataset.ci); + const ex = sb().nfs.exports[i]; + ex.clients = ex.clients || []; + ex.clients[j] = ex.clients[j] || {}; + ex.clients[j][e.dataset.c] = e.value; + setActionMsg('已修改,请点击保存并应用'); + }; + }); + $$('#sh-exports [data-del]').forEach(b => b.onclick = () => { + sb().nfs.exports.splice(Number(b.dataset.del), 1); + renderExportList(); + setActionMsg('已删除导出,请点击保存并应用'); + }); + $$('#sh-exports [data-cadd]').forEach(b => b.onclick = () => { + const i = Number(b.dataset.cadd); + sb().nfs.exports[i].clients = sb().nfs.exports[i].clients || []; + sb().nfs.exports[i].clients.push({ net: '192.168.7.0/24', preset: 'linux', options: '' }); + renderExportList(); + setActionMsg('已添加客户端,请填写后保存'); + }); + $$('#sh-exports [data-cdel]').forEach(b => b.onclick = () => { + const i = Number(b.closest('.sharecard').dataset.i); + const j = Number(b.dataset.cdel); + sb().nfs.exports[i].clients.splice(j, 1); + renderExportList(); + setActionMsg('已删除客户端,请点击保存并应用'); + }); +} + +function renderShareHints() { + const el = $('#sh-hints'); if (!el) return; + const h = (S.shareData || {}).hints || {}; + const order = [['windows', '🪟'], ['macos', ''], ['linux', '🐧']]; + el.innerHTML = order.map(([k, icon]) => { + const x = h[k]; if (!x) return ''; + return `
+ ${icon} ${esc(x.title)} +
    + ${(x.steps || []).map(s => `
  1. ${esc(s)}
  2. `).join('')} +
+ ${(x.cmds || []).length ? `
+ ${(x.cmds || []).map(c => `
${esc(c)}
`).join('')} +
` : ''} + ${x.nfs_note ? `

${esc(x.nfs_note)}

` : ''} +
`; + }).join(''); +} + +/* ============================ UPnP ============================ */ +function viewUpnp() { + const u = $w('upnp'); + $('#view').innerHTML = ` +
+

UPnP / NAT-PMP

+

允许局域网设备自动申请端口映射。出于安全考虑,默认禁止在 WAN 侧开放, + 仅对指定内网地址生效。建议同时开启「安全模式」以限制误映射。

+ + + + +
+ + + +
+
+
+

当前映射表

+

实时展示哪个内网设备申请了哪个端口,便于安全审查。

+
+

点击上方按钮查询。

+
`; + const sync = () => { + S.cfg.upnp = Object.assign($w('upnp'), { + enable: $('#up-en').checked, natpmp: $('#up-np').checked, + secure_mode: $('#up-sec').checked, igd_v1: $('#up-igd').checked, + ext_iface: $('#up-ext').value, listen_ip: $('#up-lis').value, + port: Number($('#up-port').value), + }); + setActionMsg('已修改,请点击保存并应用'); + }; + ['#up-en', '#up-np', '#up-sec', '#up-igd'].forEach(s => $(s).onchange = sync); + ['#up-ext', '#up-lis', '#up-port'].forEach(s => $(s).oninput = sync); + $('#up-list').onclick = async () => { + $('#up-out').innerHTML = '
读取中…
'; + const r = await api('/api/service', { method: 'POST', body: { name: 'miniupnpd', op: 'start' } }); + const r2 = await api('/api/journal', { method: 'GET' }); + $('#up-out').innerHTML = `
${esc(r.msg_cn)}\n\n注意:查询映射表需 UPnP 服务运行中。\n可在「日志」页查看 miniupnpd 输出。
`; + }; +} + +/* ============================ NTP ============================ */ +function viewNtp() { + const n = $w('ntp'); + $('#view').innerHTML = ` +
+

NTP 时间同步

+

可自定义时间服务器地址,支持多个(逗号分隔)。

+ + +
+ +
+
+
读取中…
+
+
+

常见时间服务器

+ + + + + + +
来源地址
阿里云ntp.aliyun.com
腾讯云ntp.tencent.com
国家授时中心ntp.ntsc.ac.cn
Cloudflaretime.cloudflare.com
Googletime.google.com
+
`; + const sync = () => { + S.cfg.ntp = Object.assign($w('ntp'), { + servers: $('#nt-s').value, allow_lan: $('#nt-allow').checked, allow_net: $('#nt-net').value, + }); + setActionMsg('已修改,请点击保存并应用'); + }; + $('#nt-s').oninput = sync; $('#nt-net').oninput = sync; $('#nt-allow').onchange = sync; + const load = async () => { + const r = await api('/api/ntp'); + const d = r.data || {}; + const tracking = (d.tracking || '').split('\n').reduce((o, l) => { + const m = l.match(/^([^:]+):\s*(.*)$/); if (m) o[m[1].trim()] = m[2].trim(); return o; + }, {}); + $('#nt-out').innerHTML = ` +
系统时间${esc(d.now || '—')}
+
同步状态${tracking['Leap status'] === 'Normal' ? '已同步' : '' + esc(tracking['Leap status'] || '未同步') + ''}
+
时间偏移${esc(tracking['System time'] || '—')} 秒
+
参考源${esc(tracking['Reference ID'] || '—')}
+
层级${esc(tracking['Stratum'] || '—')}
+
源列表
${esc(d.sources || '(chrony 未运行)')}
`; + }; + $('#nt-refresh').onclick = load; + load(); +} + +/* ============================ 诊断 ============================ */ +function viewDiag() { + $('#view').innerHTML = ` +
+

Ping 连通性测试

+
+ + + +
+
+
+

路由追踪 / MTR

+
+ + + +
+
+
+

吞吐测试

+

内网测速使用 iperf3(需对端运行 iperf3 -s);互联网测速通过下载测速文件估算下行带宽。

+
+ + + + +
+
+
+

抓包与连接状态

+
+ + + + +
+
+
+

输出结果

+

请在上方选择工具并执行。

+
`; + $$('[data-diag]').forEach(b => b.onclick = () => runDiag(b.dataset.diag)); +} + +async function runDiag(tool) { + const out = $('#dg-out'); + out.innerHTML = '
执行中,请稍候…
'; + let body = { tool }; + if (tool === 'ping') body = { tool, target: $('#dg-ping-t').value, count: Number($('#dg-ping-c').value) }; + if (tool === 'traceroute' || tool === 'mtr') body = { tool, target: $('#dg-tr-t').value }; + if (tool === 'iperf') body = { tool, target: $('#dg-ip-t').value }; + if (tool === 'tcpdump') body = { tool, iface: $('#dg-cp-i').value, count: Number($('#dg-cp-c').value) }; + const r = await api('/api/diag', { method: 'POST', body }); + const d = r.data || {}; + out.innerHTML = `
${esc(r.msg_cn)}
+
${esc(d.out || d.err || '(无输出)')}
`; +} + +/* ============================ IPv6 连通性测试 ============================ */ +function viewV6Test() { + $('#view').innerHTML = ` +
+

IPv6 连通性测试

+

一键检测本机与互联网的 IPv6 连通情况。测试过程会实时回显结果。

+
+ + + +
+ +
上次测试时间尚未测试
+
+ +
+

本机 IPv6 环境

+

WAN/LAN 是否取得公网 IPv6、是否开启转发、默认路由是否就绪。

+

读取中…

+
+ +
+

测试结果

+

点击上方按钮开始测试。

+
`; + loadV6Env(); + $('#v6t-cn').onclick = () => runV6Test('cn'); + $('#v6t-intl').onclick = () => runV6Test('intl'); + $('#v6t-all').onclick = () => runV6Test('all'); + let timer = null; + const auto = $('#v6t-auto'); + const setupTimer = () => { + if (timer) { clearInterval(timer); timer = null; } + if (auto.checked) timer = setInterval(() => runV6Test('cn', true), 30000); + }; + // 挂到全局钩子:go() 切页时会调用它停表(闭包内变量外面拿不到) + stopV6Timer = () => { if (timer) { clearInterval(timer); timer = null; } }; + auto.onchange = setupTimer; + setupTimer(); +} + +async function loadV6Env() { + const el = $('#v6t-env'); if (!el) return; + const r = await api('/api/ipv6'); + const d = r.data || {}; + const g = (d.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6') + .map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope }))); + const global = g.filter(x => x.sc === 'global'); + const fwd = (d.sysctl || {})['all.forwarding'] === '1'; + const ready = global.length > 0 && fwd && d.default_route; + el.innerHTML = ` +
整体判定${ready + ? 'IPv6 环境就绪' + : '尚未就绪'}
+
公网 IPv6${global.length + ? global.slice(0, 4).map(x => esc(x.a) + '/' + x.p + ' ' + esc(x.if) + '').join('
') + : '未获取公网地址'}
+
默认路由${esc(d.default_route || '无')}
+
IPv6 转发${fwd ? '已开启' : '未开启'}
+
邻居表条数${(d.neigh || []).length}
`; +} + +async function runV6Test(which, silent) { + const out = $('#v6t-out'); + if (!out) return; + if (!silent) out.innerHTML = '
测试中,正在连接测试站点…
'; + const r = await api('/api/diag', { method: 'POST', body: { tool: 'ipv6test', which } }); + const d = r.data || {}; + const t = $('#v6t-time'); + if (t) t.textContent = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-'); + if (silent && r.ok && out.querySelector('pre')) { + // 静默刷新时仅在结果区已有内容时更新 + } + const blocks = (d.results || []).map(x => ` +
+

${esc(x.name)} ${x.ok ? '连通' : '不通'}

+
目标${esc(x.target || '—')}
+
耗时${x.ms != null ? x.ms + ' ms' : '—'}
+ ${x.detail ? `
${esc(x.detail)}
` : ''} +
`).join(''); + out.innerHTML = `
${esc(r.msg_cn || '')}
+ ${blocks || `
${esc(d.out || d.err || '(无输出)')}
`}`; +} + +/* ============================ DHCPv6 / 前缀委派 ============================ */ +function viewDhcpv6() { + const c = $w('dhcpv6'); + const enabled = c.enabled !== false; + $('#view').innerHTML = ` +
+

DHCPv6 前缀委派(PD)

+

向运营商申请 IPv6 前缀(如 /60、/56),再下发给内网设备。 + 大陆家庭宽带通常需要 PD 才能获得可用的公网 IPv6。

+ +
+ + +
+ +

保存后需点击「保存并应用」才会写入 /etc/dhcpcd.conf 与网络配置。

+
+ +
+

下游地址分配

+

LAN 侧如何把前缀下发给内网设备(配合「IPv6 / RA」页面的地址池)。

+
+ + +
+
+ +
+

当前 IPv6 状态

+

读取中…

+
`; + const bind = (id, k, conv) => { + const e = $(id); if (!e) return; + const h = () => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { [k]: conv ? conv(e.value) : e.value }); setActionMsg('已修改,请点击保存并应用'); }; + e.onchange = h; if (e.tagName === 'INPUT') e.oninput = h; + }; + bind('#d6-len', 'prefix_len'); bind('#d6-iface', 'iface'); + bind('#d6-method', 'method'); bind('#d6-sid', 'subnet_id'); + $('#d6-en').onchange = e => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { enabled: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); }; + $('#d6-rapid').onchange = e => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { rapid_commit: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); }; + liveV6(); +} + +/* ============================ 真·公网 IP 判定(#2) ============================ */ +/* 「有没有公网地址」和「外网能不能连进来」是两件事。 + 很多宽带给的是公网地址却在入向做了封锁 —— 这时 DDNS 能更新成功、 + 域名也解析得到,但别人就是连不上。本页把判定拆成四条独立证据, + 其中只有「入向实测」能拍板,所以页面会把这一步摆在最显眼的位置。 */ + +let PUBIP_TIMER = null; + +async function viewPubip() { + const v = $('#view'); + v.innerHTML = ` +
+

真·公网 IP 判定 +

+

判定出口 IP 是不是真的能被外网主动连进来。 + 只看「地址是不是公网段」会误判 —— 不少宽带给的是公网地址却在入向做了封锁, + 从别的 VPS 根本 ping 不通。

+

正在判定…

+
+
+

入向实测(唯一能拍板的一步)

+

下面会启动一次监听,然后给你一条命令。 + 在另一台能上外网的主机(你的 VPS、云主机,或手机开热点连着的电脑)上执行它, + 本机能收到就说明外网确实可以主动访问进来。

+
+ + + +
+ +
`; + $('#pi-check').onclick = () => pubipCheck(true); + $('#pi-start').onclick = () => pubipProbe('probe_start'); + $('#pi-stop').onclick = () => pubipProbe('probe_stop'); + bindCopyButtons(v); + pubipCheck(false); +} + +function pubipVerdictTag(level) { + return { ok: 'ok', warn: 'warn', err: 'err', info: 'info' }[level] || 'gray'; +} + +async function pubipCheck(force) { + const box = $('#pi-body'); + if (!box) return; + box.innerHTML = '

正在判定(会并发查询多个外部回显服务,约需 10–30 秒)…

'; + const r = await api('/api/pubip', { method: 'POST', body: { op: 'check', force: !!force } }); + if (!r.ok) { box.innerHTML = `
${esc(r.msg_cn || '判定失败')}
`; return; } + const d = r.data || {}; + const vd = d.verdict || {}; + const tag = pubipVerdictTag(vd.level); + + const ev = (d.evidence || []).map(e => ` +
+ ${e.pass ? '✓' : (e.probed === false ? '?' : '✕')} +
+ ${esc(e.name)}:${esc(String(e.value))} +
${esc(e.detail || '')}
+
+ ${e.pass ? '通过' : (e.probed === false ? '未测' : '不通过')} +
`).join(''); + + box.innerHTML = ` +
+ ${esc(vd.title || '')} +
${esc(vd.conclusion || '')}
+
+
+
出口 IPv4${esc(d.ip || '—')}
+
地址分类${esc(d.class || '—')}
+
本机地址${esc(d.v4_local || '—')}
+
出口网卡${esc(d.egress || '—')}
+
+

四条判定依据

+
${ev}
+ ${(vd.advice || []).length ? ` +

接下来该怎么做

+
    + ${vd.advice.map(a => `
  • ${esc(a)}
  • `).join('')} +
` : ''} +

判定时间:${esc(d.checked_at || '')}

`; +} + +async function pubipProbe(op) { + const msg = $('#pi-probe-msg'); + const r = await api('/api/pubip', { method: 'POST', body: { op: op } }); + const d = r.data || {}; + if (msg) msg.textContent = r.msg_cn || (r.ok ? '已提交' : '操作失败'); + if (!r.ok) return; + + if (op === 'probe_start') { + const card = $('#pi-cmd'); + card.classList.remove('hidden'); + $('#pi-how').textContent = d.how || ''; + $('#pi-cmdtext').textContent = d.cmd || ''; + pubipProbePoll(); + return; + } + if (op === 'probe_stop') { + pubipProbeStopPoll(); + $('#pi-cmd').classList.add('hidden'); + return; + } +} + +function pubipProbeStopPoll() { + if (PUBIP_TIMER) { clearInterval(PUBIP_TIMER); PUBIP_TIMER = null; } +} + +function pubipProbePoll() { + pubipProbeStopPoll(); + PUBIP_TIMER = setInterval(async () => { + const r = await api('/api/pubip', { method: 'POST', body: { op: 'probe_status' } }); + const d = r.data || {}; + const msg = $('#pi-probe-msg'); + const left = $('#pi-left'); + if (d.state === 'hit') { + pubipProbeStopPoll(); + if (msg) msg.textContent = '已收到入向报文 —— 外网可以主动访问本机'; + if (left) left.innerHTML = `入向可达 来源:${esc((d.hits || []).join('、'))}`; + toast('入向实测成功:外网确实能连进来', 'ok', 5000); + await pubipCheck(true); + return; + } + if (d.state === 'timeout') { + pubipProbeStopPoll(); + if (msg) msg.textContent = '等待超时,没有收到来自外网的报文'; + if (left) left.innerHTML = '入向不可达 已判定为「有公网地址但入向被拦」'; + await pubipCheck(true); + return; + } + if (d.state === 'running') { + if (left) left.textContent = `仍在等待外网报文,还剩 ${d.left || 0} 秒`; + } + }, 3000); +} + +/* ======================== 磁盘与日志清理(#3) ======================== + 菜单放在「系统」组下,位置紧挨依赖自检 —— 两者同属日常维护。 + 页面刻意把「策略」和「清理项」分成两张卡片:策略决定"什么时候自动做", + 清理项决定"做哪些、留多久"。混在一张卡里用户很难分清哪个开关管什么。 */ + +let CLEAN_DATA = null; + +const CLEAN_TRIGGER_TEXT = { disk: '仅水位触发', schedule: '仅定时触发', both: '水位 + 定时' }; + +async function viewCleanup() { + const v = $('#view'); + v.innerHTML = ` +
+

磁盘占用 +

+

路由器常年不关机,日志与缓存会一直长。这里按「可再生产物」分类回收 —— + 配置文件、证书、代码、配置快照一律不在清理范围内,正在写入的活跃日志也不动。

+

正在扫描…

+
+
+

自动清理策略

+

决定「什么时候自动动手」。清理哪些内容、各留多久,在下面一张卡片里单独设置。

+

正在读取…

+
+
+

清理项 + +

+

每一项都可以单独开关,并自定义保留天数。 + 试运行只统计不删除,用来先看清楚会清掉多少;确认无误再点「立即清理」。

+

正在扫描…

+
+ + + +
+ +
`; + $('#cl-rescan').onclick = () => cleanupLoad(true); + $('#cl-dry').onclick = () => cleanupRun(true); + $('#cl-run').onclick = () => { + if (!confirm('确定立即清理所有勾选的项目吗?被删的日志文件无法恢复。\n\n(建议先点「试运行」看清楚会清掉多少)')) return; + cleanupRun(false); + }; + $('#cl-selectall').onclick = () => cleanupSelectAll(true); + $('#cl-selectnone').onclick = () => cleanupSelectAll(false); + cleanupLoad(false); +} + +function cleanupSelectAll(on) { + document.querySelectorAll('#cl-items .cl-on').forEach(el => { el.checked = on; }); +} + +async function cleanupLoad(toastIt) { + const r = await api('/api/cleanup', { method: 'POST', body: { op: 'status' } }); + if (!r.ok) { + $('#cl-disk').innerHTML = `
${esc(r.msg_cn || '扫描失败')}
`; + return; + } + CLEAN_DATA = r.data; + if (toastIt) toast('扫描完成', 'ok'); + cleanupRenderDisk(); + cleanupRenderPolicy(); + cleanupRenderItems(); +} + +function cleanupRenderDisk() { + const d = (CLEAN_DATA && CLEAN_DATA.disk) || {}; + const pct = Number(d.percent || 0); + const warn = pct >= 85 ? 'err' : (pct >= 70 ? 'warn' : 'ok'); + const reclaim = CLEAN_DATA ? (CLEAN_DATA.reclaimable || 0) : 0; + $('#cl-disk').innerHTML = ` +
+
${pct}% + ${pct >= 85 ? '紧张' : (pct >= 70 ? '偏高' : '正常')}
+
+ 已用 ${fmtBytes((d.used_mb || 0) * 1048576)} / 共 ${fmtBytes((d.total_mb || 0) * 1048576)} + ,可用 ${fmtBytes((d.free_mb || 0) * 1048576)} +
+
+
+

按当前策略,勾选中的项目可释放 ${fmtBytes(reclaim)}。

`; +} + +function cleanupRenderPolicy() { + const c = (CLEAN_DATA && CLEAN_DATA.config) || {}; + const hours = Array.from({ length: 24 }, (_, i) => + ``).join(''); + const st = (CLEAN_DATA && CLEAN_DATA.state) || {}; + $('#cl-policy').innerHTML = ` +
+ + + +
+
+ + + +
+

定时器每小时被拉起一次,自己判断该不该真动手 —— + 水位没到、时间没到就什么都不做。当前定时器: + ${ + CLEAN_DATA && CLEAN_DATA.timer_active ? '已启用' : '未启用'} + ${CLEAN_DATA && CLEAN_DATA.next_run ? `下次检查 ${esc(CLEAN_DATA.next_run)}` : ''} + ${st.last_run ? ` 上次执行 ${esc(st.last_run)},释放 ${fmtBytes(st.last_freed || 0)}` : ''}

+
+ + +
`; + $('#cl-save').onclick = cleanupSave; + $('#cl-now').onclick = async () => { + const r = await api('/api/cleanup', { method: 'POST', body: { op: 'auto' } }); + toast(r.msg_cn || '已执行', r.ok ? 'ok' : 'err', 5000); + if (r.ok && !(r.data || {}).skipped) cleanupLoad(false); + }; +} + +function cleanupRenderItems() { + const items = (CLEAN_DATA && CLEAN_DATA.items) || []; + let sum = 0; + items.forEach(it => { if (it.on) sum += it.reclaimable || 0; }); + const el = $('#cl-sum'); + if (el) el.textContent = `${items.length} 项 · 可释放 ${fmtBytes(sum)}`; + $('#cl-items').innerHTML = `
${items.map(it => ` +
+ ${ + it.on ? (it.reclaimable > 0 ? '!' : '·') : '-'} +
+
+ + ${esc(it.name)} + ${it.reclaimable > 0 ? `可释放 ${fmtBytes(it.reclaimable)}` + : '无需清理'} +
+
${esc(it.why)}
+
+ ${it.kind === 'age' + ? `` + : '由系统工具整体回收'} + + 轮转件 ${fmtBytes(it.total)} 命中 ${it.whole ? '全部' : (it.hit_count || 0) + ' 个文件'}${ + it.active ? `
正在写入 ${fmtBytes(it.active)}(保留不删)` : ''}
+
+ ${(it.samples || []).length ? `
例如:${ + it.samples.map(s => esc(String(s.path).split('/').pop()) + '(' + fmtBytes(s.size) + ')').join('、')}
` : ''} +
安全边界:${esc(it.risk)}
+
+
`).join('')}
`; +} + +async function cleanupSave() { + const items = {}; + document.querySelectorAll('#cl-items .cl-on').forEach(el => { + items[el.dataset.k] = { on: el.checked }; + }); + document.querySelectorAll('#cl-items .cl-days').forEach(el => { + items[el.dataset.k] = items[el.dataset.k] || {}; + items[el.dataset.k].days = Number(el.value) || 0; + }); + const body = { + op: 'save', + enabled: $('#cl-enabled').checked, + trigger: $('#cl-trigger').value, + disk_percent: Number($('#cl-pct').value) || 85, + schedule: $('#cl-sched').value, + hour: Number($('#cl-hour').value) || 4, + max_mb_per_run: Number($('#cl-max').value) || 0, + items: items, + }; + const r = await api('/api/cleanup', { method: 'POST', body: body }); + toast(r.msg_cn || '已保存', r.ok ? 'ok' : 'err', 4200); + if (r.ok) cleanupLoad(false); +} + +async function cleanupRun(dry) { + const only = []; + document.querySelectorAll('#cl-items .cl-on').forEach(el => { if (el.checked) only.push(el.dataset.k); }); + const box = $('#cl-result'); + box.classList.remove('hidden'); + box.innerHTML = `

${dry ? '正在试运行(只读扫描,不删除任何文件)…' : '正在清理…'}

`; + const r = await api('/api/cleanup', { method: 'POST', body: { op: 'run', dry: !!dry, only: only } }); + if (!r.ok) { box.innerHTML = `
${esc(r.msg_cn || '执行失败')}
`; return; } + const d = r.data || {}; + const rows = (d.items || []).map(x => ` +
${esc(x.name)}${fmtBytes(x.freed)}${ + x.count ? ` ${x.count} 个文件` : ''}${ + x.stopped ? ' 已达单次上限' : ''}${ + (x.errors || []).length ? ` ${esc(x.errors[0])}` : ''}
`).join(''); + box.innerHTML = ` +
+ ${esc(r.msg_cn || '')} +
+
${rows || '

没有项目被选中。

'}
`; + toast(r.msg_cn || '完成', 'ok', 5000); + cleanupLoad(false); +} + +/* ======================== 内核转发与加速(#4) ======================== + 五项开关的说明与「联动影响」写在后端 KERN_ITEMS / KERN_FW_ITEMS 里, + 前端只负责渲染 —— 免得文案在两处各写一份后走偏。 + 注意 MSS 与 masquerade 的落点是 nft_v4 / nft_v6 配置库:本页改完要到 + 「防火墙 IPv4 / IPv6」页点一次「保存并应用」才会写进规则集。 */ + +let KERN_DATA = null; + +function kernImpact(list) { + return (list || []).map(x => `
  • ${esc(x)}
  • `).join(''); +} + +function kernCard(it, ctrl) { + return ` +
    + i +
    + ${esc(it.name)} +
    ${esc(it.why)}
    +
    ${ctrl}
    +
    + 打开/关闭会带来什么 +
      ${kernImpact(it.impact)}
    +
    +
    +
    `; +} + +async function viewKern() { + $('#view').innerHTML = ` +
    +

    内核转发与加速 +

    +

    这五个开关决定「这台机器到底是不是一台路由器」。 + 每一项都写了通俗说明和联动影响 —— 点开「打开/关闭会带来什么」看清楚再动。

    +

    正在读取…

    +
    +
    +

    出向地址伪装与 MSS 钳制

    +

    这两项最终写进防火墙规则集。本页改完后,需要到 + 「安全 → 防火墙 IPv4 / IPv6」点一次「保存并应用」才会真正生效。

    +

    正在读取…

    +
    +
    +

    SNMP 监控

    +

    让网管软件能采集本机指标。默认关闭 —— 开了就多一个 UDP 监听。

    +

    正在读取…

    +
    + +
    + +
    `; + $('#kn-reload').onclick = () => kernLoad(); + $('#kn-save').onclick = kernSave; + kernLoad(); +} + +async function kernLoad() { + const r = await api('/api/kern', { method: 'POST', body: { op: 'status' } }); + if (!r.ok) { + $('#kn-body').innerHTML = `
    ${esc(r.msg_cn || '读取失败')}
    `; + return; + } + KERN_DATA = r.data; + kernRender(); +} + +function kernRender() { + const d = KERN_DATA || {}; + const c = d.config || {}; + const live = d.live || {}; + const fw = d.fw || {}; + + const sw = (id, on) => + ``; + + $('#kn-body').innerHTML = ` + ${d.build_mode ? '
    当前处于构建保护模式:保存只会写盘,不会修改内核参数、不会启停服务。
    ' : ''} +
    + ${(d.items || []).map(it => { + const key = it.key; + if (key === 'snmp') return ''; + const on = c[key] !== undefined ? c[key] : it.default; + const nowLive = key === 'fwd_v4' ? live.ip_forward + : key === 'fwd_v6' ? live.ipv6_forwarding + : (live.congestion === 'bbr' ? '1' : '0'); + const okOn = String(nowLive) === (on ? '1' : '0'); + return kernCard(it, `
    + ${sw('kn-' + key, on)} + ${ + key === 'bbr' + ? (live.congestion === 'bbr' ? '当前 bbr + ' + esc(live.qdisc || '') : '当前 ' + esc(live.congestion || '—')) + : (on ? '已开启' : '已关闭')} + ${key === 'bbr' && on && live.congestion !== 'bbr' + ? '未生效(内核未加载 tcp_bbr)' : ''} +
    `); + }).join('')} +
    +
    内核可用拥塞算法 + ${esc(live.available || '—')}
    +
    持久化文件${esc(d.sysctl_file || '—')}
    `; + + const fwm = {}; + (d.fw_items || []).forEach(it => { fwm[it.key] = it; }); + const mssCtrl = (fam) => { + const on = fw['mss_' + fam] !== undefined ? fw['mss_' + fam] : (fwm['mss_' + fam] || {}).default; + const mode = fw['mss_' + fam + '_mode'] || 'clamp'; + const val = fw['mss_' + fam + '_value'] || (fam === 'v4' ? 1452 : 1432); + return `
    + ${sw('kn-mss-' + fam, on)} + + + 字节 +
    `; + }; + const masqCtrl = (fam) => { + const on = fw['masquerade_' + fam] !== undefined + ? fw['masquerade_' + fam] : (fwm['masquerade_' + fam] || {}).default; + return `
    ${sw('kn-masq-' + fam, on)} + ${on ? '开启' : '关闭'}
    `; + }; + $('#kn-fw').innerHTML = `
    + ${kernCard(fwm.masquerade_v4 || {}, masqCtrl('v4'))} + ${kernCard(fwm.mss_v4 || {}, mssCtrl('v4'))} + ${kernCard(fwm.masquerade_v6 || {}, masqCtrl('v6'))} + ${kernCard(fwm.mss_v6 || {}, mssCtrl('v6'))} +
    `; + + const s = c.snmp || {}; + const inst = d.snmp_installed; + $('#kn-snmp').innerHTML = ` + ${inst ? '' : '
    未安装 snmpd。请到「系统 → 依赖自检与安装」安装后再回来开启。
    '} +
    + + ${d.snmp_active ? '运行中' : '未运行'} +
    +
    + + + +
    +
    + + + +
    `; +} + +async function kernSave() { + const q = id => document.getElementById(id); + const body = { + op: 'save', + fwd_v4: q('kn-fwd_v4') ? q('kn-fwd_v4').checked : undefined, + fwd_v6: q('kn-fwd_v6') ? q('kn-fwd_v6').checked : undefined, + bbr: q('kn-bbr') ? q('kn-bbr').checked : undefined, + fw: { + masquerade_v4: q('kn-masq-v4') ? q('kn-masq-v4').checked : undefined, + masquerade_v6: q('kn-masq-v6') ? q('kn-masq-v6').checked : undefined, + mss_v4: q('kn-mss-v4') ? q('kn-mss-v4').checked : undefined, + mss_v6: q('kn-mss-v6') ? q('kn-mss-v6').checked : undefined, + mss_v4_mode: q('kn-mssmode-v4') ? q('kn-mssmode-v4').value : undefined, + mss_v6_mode: q('kn-mssmode-v6') ? q('kn-mssmode-v6').value : undefined, + mss_v4_value: q('kn-mssval-v4') ? Number(q('kn-mssval-v4').value) : undefined, + mss_v6_value: q('kn-mssval-v6') ? Number(q('kn-mssval-v6').value) : undefined, + }, + snmp: { + enabled: q('kn-snmp-en') ? q('kn-snmp-en').checked : undefined, + community: q('kn-snmp-comm') ? q('kn-snmp-comm').value : undefined, + port: q('kn-snmp-port') ? Number(q('kn-snmp-port').value) : undefined, + listen: q('kn-snmp-listen') ? q('kn-snmp-listen').value : undefined, + sysname: q('kn-snmp-name') ? q('kn-snmp-name').value : undefined, + location: q('kn-snmp-loc') ? q('kn-snmp-loc').value : undefined, + contact: q('kn-snmp-contact') ? q('kn-snmp-contact').value : undefined, + }, + }; + const r = await api('/api/kern', { method: 'POST', body: body }); + const box = $('#kn-out'); + box.classList.remove('hidden'); + const errs = ((r.data || {}).errors) || []; + box.innerHTML = `
    ${esc(r.msg_cn || '')}
    + ${errs.length ? `
      ${ + errs.map(e => `
    • ${esc(e)}
    • `).join('')}
    ` : ''}`; + toast(r.msg_cn || '已保存', r.ok ? 'ok' : 'err', 5000); + if (r.ok) kernLoad(); +} + +/* ==================== 新手向导(上网四步走) ==================== + 放在「概览」组第一项:这是第一次使用这台机器的人唯一需要先看的东西。 + 设计取向与其余页面不同 —— 别的页面默认「你已经知道自己在干什么」, + 这个页面默认「你什么都不知道」。所以: + * 每一步的说明都先讲「这是干什么的」,再讲「怎么填」; + * 每个输入框旁边给一个可以直接抄的范例,而不是只给字段名; + * 体检结论用「能不能上网」的说法,不用「接口状态 / 路由表」这类词; + * IPv6 明确标成可选 —— 不通不影响上网,不该让新手以为卡住了。 + 全部文案尽量口语化,避免「DHCP 服务未启用」这种只有同行看得懂的句子。 */ + +let WIZ = null; +let WIZ_STEP = 0; + +async function viewWizard() { + $('#view').innerHTML = '

    正在给这台机器做体检…

    '; + await wizLoad(); +} + +async function wizLoad() { + const r = await api('/api/wizard', { method: 'POST', body: { op: 'probe' } }); + if (!r.ok) { + $('#view').innerHTML = `
    ${ + esc(r.msg_cn || '体检失败,请稍后重试')}
    `; + return; + } + WIZ = r.data; + wizRender(); +} + +function wizStepTag(done, optional) { + if (done) return '已就绪'; + if (optional) return '可选'; + return '待设置'; +} + +function wizRender() { + const d = WIZ || {}; + const w = d.wan || {}, l = d.lan || {}, n = d.dns || {}, v = d.v6 || {}; + + // ---- 顶部:一句话结论 ---- + const headline = d.internet_ok + ? '这台机器已经能把内网接到外网了。下面的步骤可以随时回来复查或调整。' + : '还差几步就能上网。按下面的顺序一步步来,每步做完都能看到结果。'; + const hcls = d.internet_ok ? 'ok' : 'warn'; + + // ---- 步骤条 ---- + const steps = d.steps || []; + const stateOf = k => k === 'wan' ? !!w.online : k === 'lan' ? !!l.ok + : k === 'dns' ? !!n.ok : !!v.ok; + const stepsBar = steps.map((s, i) => { + const ok = stateOf(s.k); + const cur = i === WIZ_STEP; + return ``; + }).join('›'); + + $('#view').innerHTML = ` + ${d.build_mode ? `
    + 当前处于构建保护模式:向导会把配置写到磁盘,但不会重启服务、 + 不会真正拨号。等你关闭保护模式后再点一次「保存并生效」即可。
    ` : ''} + +
    +

    新手向导 + ${d.internet_ok ? '可以上网了' : '还差 ' + (d.todo || []).filter(x => !x.includes('可选')).length + ' 步'} +

    +

    ${esc(headline)}

    +
    ${stepsBar}
    + ${(d.todo || []).length ? `
    + 还没完成的:${(d.todo || []).map(esc).join(' · ')}
    ` : ''} +
    + +
    `; + + $('#wz-reload').onclick = () => wizLoad(); + $$('.wiz-step').forEach(b => b.onclick = () => { + WIZ_STEP = Number(b.dataset.i); + wizRender(); + }); + + wizRenderStep(); +} + +function wizRenderStep() { + const d = WIZ || {}; + const box = $('#wz-body'); + if (!box) return; + const k = ((d.steps || [])[WIZ_STEP] || {}).k; + if (k === 'wan') box.innerHTML = wizStepWan(); + else if (k === 'lan') box.innerHTML = wizStepLan(); + else if (k === 'dns') box.innerHTML = wizStepDns(); + else if (k === 'v6') box.innerHTML = wizStepV6(); + wizBindStep(k); +} + +/* ---------- 第 1 步:接上外网 ---------- */ +function wizStepWan() { + const d = WIZ || {}, w = d.wan || {}; + const modes = d.wan_modes || []; + const cur = w.mode || 'pppoe'; + return ` +
    +

    第 1 步 · 接上外网 ${wizStepTag(w.online)}

    +

    先告诉这台机器「宽带从哪里进来」。这一步做完,本机自己就能上网; + 要让内网设备也上,还要做完第 2 步。

    + +
    上网方式${esc(w.mode_cn || '还没设置')}
    +
    接宽带的网卡${esc(w.iface || '还没指定')}
    +
    默认路由${esc(w.default_route || '还没有(说明还没连上外网)')}
    +
    能 ping 通外网${w.online + ? '可以' + : '不通'}
    + ${w.ping_note ? `
    最近一次探测:${esc(w.ping_note)}
    ` : ''} + +

    你的宽带是哪一种?

    +
    + ${modes.map(m => ``).join('')} +
    +
    + +
    + +
    + + ${cur === 'pppoe' ? '' : ''} +
    + +
    `; +} + +function wizWanForm(mode) { + const d = WIZ || {}, w = d.wan || {}; + const ifaces = d.wan_ifaces || []; + const ifOpts = (sel) => [''] + .concat(ifaces.map(i => ``)).join(''); + const iface = w.iface || (ifaces[0] || {}).name || ''; + + if (mode === 'pppoe') { + return ` +
    + + +
    +
    + + +
    +

    范例说明:账号通常是你办理宽带时的「宽带账号」或手机号, + 电信多为 0512 开头的区号加号码; + 密码不是 Wi-Fi 密码,是宽带密码,忘了就打运营商客服重置。 + 不确定的话先按上面格式填,保存后再点「立即拨号测试」看结果。

    `; + } + if (mode === 'dhcp') { + return ` + +

    范例说明:这种方式不需要账号密码。 + 把网线从光猫的 LAN 口接到这台机器的这张网卡上就行。 + 光猫要是已经在拨号(大多数家庭就是这样),选这个最省事。

    `; + } + return ` + +
    + + +
    + +

    范例说明:这三个值运营商都会在开通单上写给你, + 照抄即可。/24 表示掩码是 255.255.255.0,不用改。

    `; +} + +/* ---------- 第 2 步:给内网发地址 ---------- */ +function wizStepLan() { + const d = WIZ || {}, l = d.lan || {}, h = d.pool_hint || {}; + const lan_ifaces = d.lan_ifaces || []; + const ifOpts = [''].concat( + lan_ifaces.map(i => ``)).join(''); + const lanIp = l.lan_ip || ''; + const base = lanIp ? lanIp.rsplit('.', 1)[0] : '192.168.7'; + return ` +
    +

    第 2 步 · 给内网发地址 ${wizStepTag(l.ok)}

    +

    手机、电脑连上来之后要自动拿到 IP 地址、网关和 DNS, + 否则会出现「连上了 Wi-Fi 但打不开网页」。这个功能就叫 DHCP。

    + +
    本机内网地址${esc(lanIp || '还没设置')}
    +
    DHCP 服务${l.dhcp_enabled + ? '已开启' : '未开启'} + ${l.dnsmasq_active ? '服务运行中' : '服务未运行'}
    +
    当前地址池${esc(l.pool_start || '—')} ~ ${esc(l.pool_end || '—')}
    + ${(l.pool_problems || []).length ? `
    + 当前地址池有问题:
      ${ + (l.pool_problems || []).map(x => `
    • ${esc(x)}
    • `).join('')}
    ` : ''} + +

    填写地址池

    + +
    + + +
    +
    + + +
    +
    + + +
    + +
    + 地址池怎么填?(照着抄就行) +
      + ${(h.rules || []).map(x => `
    • ${esc(x)}
    • `).join('')} +
    +
    本机内网地址是 ${esc(lanIp || '—')}, + 所以地址池用 ${esc(base)}.100 ~ + ${esc(base)}.200 最稳妥。
    +
    + +
    + +
    + +
    `; +} + +/* ---------- 第 3 步:能打开网址(DNS) ---------- */ +function wizStepDns() { + const d = WIZ || {}, n = d.dns || {}; + const presets = n.presets || []; + const curMode = n.mode || 'custom'; + return ` +
    +

    第 3 步 · 能打开网址 ${wizStepTag(n.ok)}

    +

    你在浏览器里输入的是网址(比如 www.baidu.com), + 但机器之间只认 IP 地址。把它们翻译过来的服务就叫 DNS。 + 这一步决定用谁的 DNS。

    + +
    当前来源${esc(n.mode_cn || '—')}
    +
    正在用的 DNS${esc(n.first || '还没配')}
    +
    解析测试${n.probe_ok + ? '正常' : '有问题'}
    + ${n.probe_note ? `
    ${esc(n.probe_note)}
    ` : ''} + +

    选一个 DNS 来源

    +
    + + + +
    + +
    + +
    + ${presets.map(p => ``).join('')} +
    + +

    范例说明:上面几个都是可以直接用的公共 DNS。 + 223.5.5.5 是阿里云的,国内速度快、出错少, + 不知道选哪个就用它。填两个地址的作用是一个不通时自动换另一个。

    +
    + +
    + +
    + +
    `; +} + +/* ---------- 第 4 步:IPv6(可选) ---------- */ +function wizStepV6() { + const d = WIZ || {}, v = d.v6 || {}; + const lan_ifaces = d.lan_ifaces || []; + const ifOpts = [''].concat( + lan_ifaces.map(i => ``)).join(''); + return ` +
    +

    第 4 步 · 要不要用 IPv6 ${wizStepTag(v.ok, true)}

    +

    IPv6 是新一代的网络地址。国内宽带基本都支持了。 + 不开也不影响上网,只是有些网站和服务用 IPv6 会更快、更直接。 + 拿不准就开着,出问题的概率很低。

    + +
    宽带那边${v.wan_has_global + ? '运营商已下发 IPv6' + : '还没拿到 IPv6 地址'}
    +
    内网这边${v.lan_has_global + ? '已在用公网 IPv6' + : '还没启用'}
    +
    路由通告服务${v.radvd_active + ? '运行中' + : (v.radvd_installed ? '已安装未运行' + : '未安装')}
    +
    建议${esc(v.advice || '')}
    + +
    + ${v.wan_has_global + ? '运营商已经给你的宽带下发了 IPv6 地址,可以放心打开。' + : '目前还没从运营商那里拿到 IPv6 地址。打开也不会坏,' + + '只是暂时不生效;等运营商下发后它会自动开始工作。'} +
    + +
    + +
    + +
    + +
    + + +
    +

    范例说明:前缀留空即可 —— 大多数家庭宽带会在拨号后 + 自动把运营商的前缀交给内网用。要手工填的话, + 运营商给的前缀一般是 /56 或 /60, + 这里填 /64 那一段(比如把它写成 + 2408:8207:1234:5678::/64)。 + 内网通告只能用 /64,填别的位数手机和电脑会直接忽略。

    +
    + +
    + +
    + +
    `; +} + +/* ---------- 交互绑定 ---------- */ +function wizBindStep(k) { + const d = WIZ || {}; + if (k === 'wan') { + let mode = (d.wan || {}).mode || 'pppoe'; + const form = $('#wz-wan-form'), desc = $('#wz-wan-desc'); + const paint = () => { + const m = (d.wan_modes || []).find(x => x.k === mode) || {}; + desc.innerHTML = `
    ${esc(m.n || '')} —— ${esc(m.d || '')} +
    什么时候选它:${esc(m.when || '')}
    `; + form.innerHTML = wizWanForm(mode); + }; + $$('#wz-wan-mode button').forEach(b => b.onclick = () => { + $$('#wz-wan-mode button').forEach(x => x.classList.remove('on')); + b.classList.add('on'); + mode = b.dataset.m; + paint(); + // 拨号按钮只在 PPPoE 下出现:换了方式要重画那一行 + const row = $('#wz-wan-save').parentElement; + const old = $('#wz-wan-dial'); + if (mode === 'pppoe' && !old) { + const btn = document.createElement('button'); + btn.className = 'ghost fixed'; btn.id = 'wz-wan-dial'; btn.textContent = '立即拨号测试'; + row.appendChild(btn); wizBindDial(); + } else if (mode !== 'pppoe' && old) { + old.remove(); + } + }); + paint(); + wizBindDial(); + $('#wz-wan-save').onclick = () => wizApplyWan(mode); + } else if (k === 'lan') { + $('#wz-lan-save').onclick = wizApplyLan; + } else if (k === 'dns') { + const custom = $('#wz-dns-custom'); + $$('#wz-dns-mode button').forEach(b => b.onclick = () => { + $$('#wz-dns-mode button').forEach(x => x.classList.remove('on')); + b.classList.add('on'); + const box = $('#wz-dns-custom').parentElement; + box.style.opacity = (b.dataset.m === 'isp') ? '.45' : '1'; + }); + $$('.wiz-dns').forEach(b => b.onclick = () => { + if (b.classList.contains('wiz-dns-isp')) { + const ispBtn = $$('#wz-dns-mode button').find(x => x.dataset.m === 'isp'); + if (ispBtn) ispBtn.click(); + return; + } + if (custom) custom.value = b.dataset.v; + const cBtn = $$('#wz-dns-mode button').find(x => x.dataset.m === 'custom'); + if (cBtn) cBtn.click(); + // click() 会把 custom 框变暗,这里立刻点回 custom 态再填值 + if (custom) { custom.value = b.dataset.v; custom.parentElement.style.opacity = '1'; } + }); + $('#wz-dns-save').onclick = wizApplyDns; + } else if (k === 'v6') { + const en = $('#wz-v6-en'), form = $('#wz-v6-form'); + const paint = () => { form.style.opacity = en.checked ? '1' : '.45'; + form.style.pointerEvents = en.checked ? '' : 'none'; }; + en.onchange = paint; + paint(); + $('#wz-v6-save').onclick = wizApplyV6; + } +} + +function wizBindDial() { + const btn = $('#wz-wan-dial'); + if (!btn) return; + btn.onclick = () => { + modal('现在拨号试试?', + `

    这会真的向运营商发起一次拨号。如果这台机器正在承担现有网络, + 拨号过程会让网络短暂中断几十秒。

    +

    只是保存账号密码的话,不用点这个按钮 —— 点上面的「保存并生效」就够了。

    +

    确认现在拨号,请输入 拨号:

    `, + async () => { + if ($('#wz-cfm').value.trim() !== '拨号') { toast('确认文字不正确', 'err'); return false; } + wizOut('wz-wan-out', '
    正在拨号,通常需要 5–20 秒…
    '); + const r = await api('/api/wizard', { method: 'POST', body: { op: 'dial', action: 'connect', confirm: true } }); + wizOut('wz-wan-out', r.ok + ? `
    ${esc(r.msg_cn || '拨号指令已发出')}
    ` + : `
    ${esc(r.msg_cn || '拨号失败')}
    `); + setTimeout(wizLoad, 4000); + }); + }; +} + +function wizOut(id, html, reload) { + const box = document.getElementById(id); + if (!box) return; + box.classList.remove('hidden'); + box.innerHTML = html; + if (reload) setTimeout(wizLoad, 2500); +} + +function wizCollect(id) { const e = document.getElementById(id); return e ? e.value.trim() : ''; } +function wizChecked(id) { const e = document.getElementById(id); return e ? e.checked : false; } + +async function wizSubmit(step, body, outId) { + wizOut(outId, '
    正在保存并生效…
    '); + const r = await api('/api/wizard', { method: 'POST', body: Object.assign({ op: 'apply_step', step: step }, body) }); + const dd = r.data || {}; + const errs = dd.errors || [], notes = dd.notes || [], done = dd.done || []; + let html = `
    ${esc(r.msg_cn || '')}
    `; + if (done.length || notes.length) { + html += `
      ${ + done.concat(notes).map(x => `
    • ${esc(x)}
    • `).join('')}
    `; + } + if (errs.length) { + html += `
    未完成:
      ${ + errs.map(x => `
    • ${esc(x)}
    • `).join('')}
    `; + } + wizOut(outId, html); + toast(r.ok ? '这一步已完成' : '这一步没有全部成功', r.ok ? 'ok' : 'err', 6000); + if (r.ok || done.length) setTimeout(wizLoad, 2200); +} + +function wizApplyWan(mode) { + const body = { + mode: mode, + iface: wizCollect('wz-iface'), + username: wizCollect('wz-user'), + password: wizCollect('wz-pass'), + isp: wizCollect('wz-isp'), + static_address: wizCollect('wz-addr'), + static_gateway: wizCollect('wz-gw'), + static_dns: wizCollect('wz-sdns'), + }; + if (!body.iface) { toast('请先选择接宽带的网卡', 'err'); return; } + wizSubmit('wan', body, 'wz-wan-out'); +} + +function wizApplyLan() { + wizSubmit('lan', { + iface: wizCollect('wz-lan-iface'), + pool_start: wizCollect('wz-ps'), + pool_end: wizCollect('wz-pe'), + pool_netmask: wizCollect('wz-pm'), + lease_time: wizCollect('wz-lt'), + option_gateway: wizCollect('wz-og'), + option_dns: wizCollect('wz-od'), + }, 'wz-lan-out'); +} + +function wizApplyDns() { + const modeBtn = $$('#wz-dns-mode button').find(x => x.classList.contains('on')); + const mode = modeBtn ? modeBtn.dataset.m : 'custom'; + wizSubmit('dns', { mode: mode, custom: wizCollect('wz-dns-custom') }, 'wz-dns-out'); +} + +function wizApplyV6() { + wizSubmit('v6', { + enable: wizChecked('wz-v6-en'), + lan_iface: wizCollect('wz-v6-iface'), + prefix: wizCollect('wz-v6-prefix'), + rdnss: wizCollect('wz-v6-rdnss'), + }, 'wz-v6-out'); +} + +/* ============================ 动态域名 DDNS(#6) ============================ */ +const DDNS_REGION_TAG = r => r === '国内' ? 'ok' : (r === '国际' ? 'info' : 'gray'); + +async function viewDdns() { + const r = await api('/api/ddns'); + const d = r.data || {}; + const c = d.cfg || {}; + const pub = d.public || {}; + const note = d.note || {}; + S.ddns = d; + const provs = d.providers || []; + const prov = provs.find(x => x.v === c.provider) || provs[0] || {}; + const isCustom = c.provider === 'custom'; + + // 厂商按地区分组,便于国内用户快速找到 + const byRegion = {}; + provs.forEach(p => { (byRegion[p.region] = byRegion[p.region] || []).push(p); }); + + const noteCls = note.level === 'ok' ? 'ok' : (note.level === 'err' ? 'err' : 'warn'); + const comboTag = { both: 'ok', v4only: 'info', v6only: 'warn', neither: 'err' }[pub.combo] || 'gray'; + + $('#view').innerHTML = ` +
    +

    公网能力检测 + ${esc(note.title || '检测中…')} +

    +

    ${esc(note.conclusion || '')}

    +
    +
    IPv4 出口地址${esc(pub.v4_local || '—')} + ${pub.v4_has ? '公网' : (pub.v4_nat ? 'NAT 后' : '未获取')}
    +
    IPv4 回显地址${esc(pub.v4_public || '—')} + ${pub.v4_source ? '' + esc(pub.v4_source) + '' : ''}
    +
    IPv6 全局地址${esc(pub.v6_local || '—')} + ${pub.v6_has ? '公网' : '无'}
    +
    IPv6 回显地址${esc(pub.v6_public || '—')} + ${pub.v6_source ? '' + esc(pub.v6_source) + '' : ''}
    +
    +
    默认路由出口${esc(pub.egress || '—')} + ${pub.gw ? ' 网关 ' + esc(pub.gw) : ''}
    +
    检测时间${esc(pub.checked_at || '—')}
    +

    建议做法

    +
      + ${(note.advice || []).map(x => `
    • ${esc(x)}
    • `).join('')} +
    + ${d.warn ? `
    ⚠ ${esc(d.warn)}
    ` : ''} +
    + 这里只能说明「地址是不是公网段」,不能说明外网能不能连进来。 + 配 DDNS 之前建议先做一次入向实测 —— + 不少宽带给的是公网地址却在入向做了封锁,那种情况域名解析得到、 + 但别人就是连不上。 +
    + +
    +
    +
    + +
    +

    DDNS 开关

    +

    开启后按设定的间隔检测地址变化,变化时自动更新域名解析记录。

    + +
    + + + +
    +

    IPv6 地址常为动态前缀,建议间隔 ≤ 300 秒、TTL ≤ 300 秒,以加快变更生效。

    +
    + +
    +

    服务商与接入方式

    +

    已内置 ${provs.length} 家国内外主流服务商,接口依据 2026 年现行公开文档整理。

    +
    + + + +
    +
    当前服务商${esc(prov.n || '')} + ${esc(prov.region || '')}
    +

    ${esc(prov.desc || '')}

    + ${prov.doc ? `

    官方文档:${esc(prov.doc)}

    ` : ''} +
    +
    完整域名—
    +
    + +
    +

    实时状态

    +

    每次进入本页或点击刷新都会重新检测;结果缓存 20 秒以免频繁请求外部接口。

    +
    启用状态${c.enabled + ? '已启用' : '未启用'}
    +
    上次更新${esc(c.last_update || '从未更新')}
    +
    上次 IPv4${esc(c.last_ip4 || '—')}
    +
    上次 IPv6${esc(c.last_ip6 || '—')}
    +
    上次结果${esc(c.last_msg_cn || '—')}
    +
    + + +
    +
    +
    `; + + const bind = (id, k, conv) => { + const e = $(id); if (!e) return; + const h = () => { + S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { [k]: conv ? conv(e.value) : e.value }); + setActionMsg('DDNS 已修改,点击下方「保存配置」生效'); + }; + e.oninput = h; if (e.tagName === 'SELECT') e.onchange = h; + }; + const ddField = $('#dd-prov'); + if (ddField) ddField.onchange = () => { + S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { provider: ddField.value, fields: {} }); + renderDdFields(ddField.value, {}, true); + setActionMsg('已切换服务商,请填写对应凭据后保存'); + }; + bind('#dd-domain', 'domain'); bind('#dd-sub', 'subdomain'); + bind('#dd-ttl', 'ttl', Number); bind('#dd-int', 'interval', Number); + $('#dd-en').onchange = e => { S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { enabled: e.target.checked }); setActionMsg('已修改,点击下方保存'); }; + $('#dd-ipv4').onchange = e => { S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { ipv4: e.target.checked }); setActionMsg('已修改,点击下方保存'); }; + $('#dd-ipv6').onchange = e => { S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { ipv6: e.target.checked }); setActionMsg('已修改,点击下方保存'); }; + S.ddnsDirty = { enabled: !!c.enabled, provider: c.provider, domain: c.domain || '', + subdomain: c.subdomain || '', ipv4: c.ipv4 !== false, ipv6: !!c.ipv6, + ttl: c.ttl || 300, interval: c.interval || 300, + url4: c.url4 || '', url6: c.url6 || '', fields: Object.assign({}, c.fields || {}) }; + + renderDdFields(c.provider, c.fields || {}, false); + updFqdn(); + + // 保存 / 重测 / 更新 + const saveBtn = document.createElement('button'); + saveBtn.className = 'fixed'; saveBtn.textContent = '保存配置'; + saveBtn.id = 'dd-save'; + $('#dd-out').parentElement.querySelector('.row').appendChild(saveBtn); + saveBtn.onclick = async () => { + const b = Object.assign({}, S.ddnsDirty, { op: 'save' }); + b.fields = Object.assign({}, S.ddnsDirty.fields || {}); + $$('#dd-fields input').forEach(i => { b.fields[i.dataset.f] = i.value; }); + const r2 = await api('/api/ddns', { method: 'POST', body: b }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + if (r2.ok) setTimeout(viewDdns, 400); + }; + $('#dd-retest').onclick = async () => { + const b = $('#dd-out'); if (b) b.innerHTML = '
    正在重新检测公网能力…
    '; + const r2 = await api('/api/ddns', { method: 'POST', body: { op: 'test' } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + setTimeout(viewDdns, 400); + }; + const gp = $('#dd-gopubip'); + if (gp) gp.onclick = () => go('pubip'); + $('#dd-update').onclick = async () => { + $('#dd-out').innerHTML = '
    正在生成更新计划…
    '; + const r2 = await api('/api/ddns', { method: 'POST', body: { op: 'update' } }); + const p = (r2.data || {}).plan || []; + $('#dd-out').innerHTML = `
    ${esc(r2.msg_cn || '')}\n` +
    +      p.map(x => `  • ${x.type} 记录  ${x.record} → ${x.ip}(${x.provider})`).join('\n') + '
    '; + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + }; + $('#dd-refresh').onclick = () => { toast('已刷新 DDNS 状态', 'ok'); viewDdns(); }; + + // 字段变化时实时更新 FQDN 预览 + $$('#dd-domain,#dd-sub').forEach(e => e.addEventListener('input', updFqdn)); +} + +/* 按服务商渲染所需凭据字段 */ +function renderDdFields(provider, vals, clear) { + const el = $('#dd-fields'); if (!el) return; + const d = (S.ddns || {}); + const prov = (d.providers || []).find(x => x.v === provider) || {}; + const fields = prov.fields || []; + if (provider === 'custom') { + el.innerHTML = ` +
    + +
    +
    + +
    +
    + + + +
    +

    可用占位符:{ip} {ipv6} {domain} {token} {user} {pass}

    `; + const u4 = $('#dd-url4'), u6 = $('#dd-url6'); + if (u4) u4.oninput = () => { S.ddnsDirty.url4 = u4.value; setActionMsg('已修改,点击下方保存'); }; + if (u6) u6.oninput = () => { S.ddnsDirty.url6 = u6.value; setActionMsg('已修改,点击下方保存'); }; + bindDdFieldInputs(); + return; + } + if (!fields.length) { el.innerHTML = '

    该服务商无需额外凭据。

    '; return; } + const label = { + access_key_id: 'AccessKey ID', access_key_secret: 'AccessKey Secret', + secret_id: 'SecretId', secret_key: 'SecretKey', + access_key: 'Access Key', api_token: 'API Token', zone_id: 'Zone ID', + api_key: 'API Key', api_secret: 'API Secret', + app_key: 'Application Key', app_secret: 'Application Secret', + consumer_key: 'Consumer Key', token: 'Token / 更新密钥', + user: '用户名', pass: '密码 / DDNS Key', region: '区域(如 cn-north-4)', + proxied: 'Cloudflare 代理(1=开 / 0=关)', + }; + const secretLike = /secret|token|pass|proxy/i; + el.innerHTML = `
    ` + fields.map(f => { + const ty = secretLike.test(f) && !/zone|region|id$|key$/i.test(f) ? 'password' : 'text'; + return ``; + }).join('') + `
    +

    凭据保存在本机数据库,仅用于向该服务商发起更新请求。

    `; + bindDdFieldInputs(); +} + +function bindDdFieldInputs() { + $$('#dd-fields input').forEach(i => { + i.oninput = () => { + S.ddnsDirty.fields = S.ddnsDirty.fields || {}; + S.ddnsDirty.fields[i.dataset.f] = i.value; + setActionMsg('已修改,点击下方保存'); + }; + }); +} + +function updFqdn() { + const el = $('#dd-fqdn'); if (!el) return; + const dom = (($('#dd-domain') || {}).value || '').trim(); + const sub = (($('#dd-sub') || {}).value || '').trim().replace(/^\.+|\.+$/g, ''); + el.textContent = dom ? (sub && sub !== '@' ? sub + '.' + dom : dom) : '—'; +} + +async function liveV6() { + const el = $('#d6-live'); if (!el) return; + const r = await api('/api/ipv6'); + const d = r.data || {}; + const g = (d.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6') + .map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope }))); + const global = g.filter(x => x.sc === 'global'); + el.innerHTML = ` +
    公网 IPv6${global.length + ? global.slice(0, 4).map(x => esc(x.a) + '/' + x.p + ' ' + esc(x.if) + '').join('
    ') + : '未获取(运营商可能未下发 PD)'}
    +
    默认路由${esc(d.default_route || '无')}
    +
    转发开关${(d.sysctl || {})['all.forwarding'] === '1' ? '已开启' : '未开启'}
    `; +} + +/* ============================ 网络状态 · NAT · 软加速 ============================ */ +const NAT_STYLE = { + NAT1: { c: 'ok', ico: '◎', hint: '最理想的出口类型' }, + NAT2: { c: 'info', ico: '◉', hint: '常见的家用级 NAT' }, + NAT3: { c: 'warn', ico: '◍', hint: '对称型 NAT,穿透较难' }, + NAT4: { c: 'err', ico: '●', hint: '双重 NAT 或运营商级 NAT' }, + NONE: { c: 'gray', ico: '○', hint: '未获取到公网映射信息' }, + NA: { c: 'gray', ico: '?', hint: '检测不可用' }, +}; + +async function viewNetStat() { + $('#view').innerHTML = ` +
    +

    NAT 类型检测

    +

    通过公共 STUN 服务器自动探测当前出口的 NAT 行为(全锥型 / 受限 / 端口受限 / 对称)。 + 检测过程完全自动完成,约需 3–10 秒。

    +
    + + +
    +

    点击「开始检测」以自动判定当前 NAT 类型。

    +
    + +
    +

    软加速(nftables flowtable)

    +

    利用内核 flowtable 把已建立的连接直接卸载到转发快路径, + 等价于 OpenWRT 的「软件流量分载 / FastTrack」。可显著降低 CPU 占用、提升吞吐。

    +

    读取中…

    +
    + +
    +

    加速影响说明

    +

    开启前请了解可能受影响的程序与协议 —— 若你依赖下列任一功能,建议先保持关闭。

    +

    读取中…

    +
    `; + + $('#nat-btn').onclick = runNatCheck; + loadAccel(); + loadNatLast(); +} + +async function loadNatLast() { + const r = await api('/api/nat/last'); + const d = (r.data || {}); + if (d.nat && d.checked_at) { + renderNat({ ok: true, data: d }); + } +} + +async function runNatCheck() { + const btn = $('#nat-btn'), out = $('#nat-out'); + btn.disabled = true; btn.textContent = '检测中…'; + out.innerHTML = '

    正在与 STUN 服务器通信…

    '; + const r = await api('/api/nat/check', { method: 'POST' }); + btn.disabled = false; btn.textContent = '重新检测'; + if (!r.ok) { + out.innerHTML = `
    ${esc(r.msg_cn || '检测失败')}
    `; + return; + } + renderNat(r); +} + +function renderNat(r) { + const d = r.data || {}; + const st = NAT_STYLE[d.nat] || NAT_STYLE.NA; + const when = d.checked_at ? `上次检测:${esc(d.checked_at)}` : ''; + if ($('#nat-when')) $('#nat-when').innerHTML = when; + const out = $('#nat-out'); + if (!out) return; + out.innerHTML = ` +
    + ${st.ico} +
    +
    ${esc(d.title || d.nat || '未知')}
    +
    ${esc(d.desc || st.hint)}
    +
    + ${esc(d.nat || '?')} +
    + ${natDetailHtml(d.detail)}`; +} + +/* NAT 检测的 detail 在后端是**对象**(local_ip / probes / servers_used …), + 之前它被当成纯文本交给转义函数,页面上就出现一个 "[object Object]" 方块。 + 这里按结构拆开显示 —— 每个 STUN 服务器回显的公网 IP:端口 正是判断 + NAT 类型的依据,对排障很有价值,本来就该展示出来。 */ +function natDetailHtml(detail) { + if (!detail) return ''; + // 兼容后端把它改成纯字符串的情况 + if (typeof detail === 'string') { + return `
    ${esc(detail)}
    `; + } + if (typeof detail !== 'object') return ''; + const probes = Array.isArray(detail.probes) ? detail.probes : []; + const row = p => ` + ${esc(p.server || '—')} + ${esc(p.ip || '—')} + ${esc(p.port == null ? '—' : p.port)}`; + const used = Array.isArray(detail.servers_used) ? detail.servers_used : []; + return `
    +
    本机出口地址${esc(detail.local_ip || '—')} + ${detail.local_private === true + ? '私网地址(说明在 NAT 后)' + : (detail.local_private === false ? '公网地址' : '')}
    + ${detail.reason ? `

    原因:${esc(detail.reason)}

    ` : ''} + ${probes.length ? `
    + + ${probes.map(row).join('')}
    STUN 服务器回显公网 IP回显端口
    +

    多次探测的端口一致 = 锥形 NAT;每个目标端口都不同 = 对称型 NAT。

    ` + : ''} + ${used.length ? `

    本次成功通信的服务器:${used.map(esc).join('、')}

    ` : ''} +
    `; +} + +async function loadAccel() { + const box = $('#accel-box'); + const r = await api('/api/accel', { method: 'POST', body: { op: 'get' } }); + const d = r.data || {}; + const on = !!d.enabled; + const devs = (d.devices || []); + const fv4 = d.family_v4 !== false, fv6 = d.family_v6 !== false; + const scopeCn = d.scope_cn || (fv4 && fv6 ? 'IPv4 + IPv6' : (fv4 ? '仅 IPv4' : (fv6 ? '仅 IPv6' : '未覆盖'))); + const scopeNote = d.scope_note || {}; + box.innerHTML = ` +
    +
    + + ${on ? '软加速已开启' : '软加速已关闭'} + ${on ? '运行中' : '未启用'} + IPv4 + IPv6 +
    +
    +
    作用范围${esc(scopeCn)} + + (flowtable 可同时承载双栈,实际是否卸载取决于规则里的 ip / ip6 匹配)
    + ${devs.length ? `
    卸载网卡${devs.map(esc).join(' · ')}
    ` : ''} + ${d.flowtable ? `
    flowtable${esc(d.flowtable)}
    ` : ''} + ${d.flows != null ? `
    已卸载流${esc(d.flows)} 条
    ` : ''} + ${d.reason ? `
    说明${esc(d.reason)}
    ` : ''} + ${d.hw_note ? `
    硬件卸载${esc(d.hw_note)}
    ` : ''} + ${r.msg_cn ? `
    状态${esc(r.msg_cn)}
    ` : ''} +
    +
    + + +
    +
    + + + + +
    +
    ${renderAccelScope(scopeNote)}
    +
    `; + + const set = async (op) => { + const b = $('#accel-' + (op === 'on' ? 'on' : 'off')); + if (b) { b.disabled = true; b.textContent = op === 'on' ? '开启中…' : '关闭中…'; } + const body = { op }; + if (op === 'on') { + body.v4 = $('#accel-v4') ? $('#accel-v4').checked : true; + body.v6 = $('#accel-v6') ? $('#accel-v6').checked : true; + } + const rr = await api('/api/accel', { method: 'POST', body }); + toast(rr.msg_cn || (rr.ok ? '操作完成' : '操作失败'), rr.ok ? 'ok' : 'err', 5000); + if (rr.data && rr.data.log) { + console.log('[accel]', rr.data.log); + } + loadAccel(); + }; + const onBtn = $('#accel-on'), offBtn = $('#accel-off'), refBtn = $('#accel-ref'); + const apBtn = $('#accel-apply'); + if (onBtn) onBtn.onclick = () => set('on'); + if (offBtn) offBtn.onclick = () => set('off'); + if (refBtn) refBtn.onclick = () => loadAccel(); + if (apBtn) apBtn.onclick = () => set('on'); + + renderAccelImpact(d.impact || []); +} + +/* 软加速的 IPv4 / IPv6 作用范围说明(#5) */ +function renderAccelScope(note) { + if (!note || !note.conclusion) return ''; + return `
    + ${esc(note.title || '软加速对 IPv4 / IPv6 的作用范围')} +
    ${esc(note.conclusion)}
    +
    IPv4 规则${esc((note.v4 || {}).rule || '')}
    +
    IPv6 规则${esc((note.v6 || {}).rule || '')}
    +
      + ${(note.points || []).map(p => `
    • ${esc(p)}
    • `).join('')} +
    +
    `; +} + +function renderAccelImpact(items) { + const el = $('#accel-impact'); if (!el) return; + if (!items.length) { el.innerHTML = '

    暂无说明(后端未返回影响清单)。

    '; return; } + el.innerHTML = `
    ${items.map(it => ` +
    + +
    ${esc(it.name)}
    ${esc(it.why)}
    +
    `).join('')}
    `; +} + +/* ============================ VLAN / IPTV ============================ */ +async function viewVlan() { + const r = await api('/api/vlan'); + const d = r.data || {}; + const list = d.items || d.vlans || []; + const presets = d.presets || []; + const ifs = S.ifaces || []; + $('#view').innerHTML = ` +
    +

    新增 / 编辑 VLAN

    +

    在物理网卡上划分 802.1Q 子接口,常用于 IPTV 组播、访客隔离或多拨。

    +
    + + + + +
    +
    + + +
    + ${presets.length ? `
    常用预设: + ${presets.map(p => ``).join(' ')}
    ` : ''} +
    + +
    +

    已配置的 VLAN

    +

    保存后需点击「保存并应用」;应用时才会真正创建子接口。

    +
    ${list.length ? renderVlanRows(list) : '

    暂无 VLAN 配置。

    '}
    +
    + +
    +

    IPTV 组播注意事项

    +

    机顶盒通常通过 VLAN 获取组播电视流,需要:

    +
    1在光猫上开启 IPTV 绑定,并记录其 VLAN ID(常见 85 / 41 / 3961 等)。
    +
    2此处创建同 ID 的 VLAN 子接口,父网卡选连接光猫的那张网卡。
    +
    3网桥/交换机端口需开启 IGMP Snooping,避免组播泛洪。
    +
    4机顶盒接入 IPTV VLAN 所在的端口即可正常收看。
    +
    `; + + const presetBtns = $$('#vl-list .vl-del'); + presetBtns.forEach(b => b.onclick = () => delVlan(b.dataset.id)); + $$('.vl-preset').forEach(b => b.onclick = () => { + const p = JSON.parse(b.dataset.p); + if ($('#vl-vid')) $('#vl-vid').value = p.vid || ''; + if ($('#vl-name')) $('#vl-name').value = (p.name || '').replace(/\s*IPTV$/, '') || 'iptv'; + if ($('#vl-purpose')) $('#vl-purpose').value = p.purpose || 'iptv'; + }); + + $('#vl-add').onclick = addVlan; +} + +function renderVlanRows(list) { + return list.map(v => ` +
    +
    + ${esc(v._iface || (v.parent + '.' + v.vid))} + VLAN ${esc(v.vid)} + ${v.note ? `${esc(v.note)}` : ''} + ${v._addr ? `${esc(v._addr)}` : ''} + ${v._present ? '已创建' : '未创建'} +
    +
    + +
    +
    `).join(''); +} + +async function addVlan() { + const parent = ($('#vl-parent') || {}).value || ''; + const vid = parseInt(($('#vl-vid') || {}).value || '0', 10); + const name = (($('#vl-name') || {}).value || '').trim(); + const purpose = ($('#vl-purpose') || {}).value || 'iptv'; + if (!parent) { toast('请先选择父网卡', 'err'); return; } + if (!(vid >= 1 && vid <= 4094)) { toast('VLAN ID 必须在 1–4094 之间', 'err'); return; } + const r = await api('/api/vlan', { method: 'POST', body: { op: 'save', parent, vid, note: name || purpose } }); + if (!r.ok) { toast(r.msg_cn || '保存失败', 'err', 5000); return; } + toast('已保存,点击「保存并应用」后生效', 'ok'); + viewVlan(); +} + +async function delVlan(id) { + const r = await api('/api/vlan', { method: 'POST', body: { op: 'delete', id } }); + if (!r.ok) { toast(r.msg_cn || '删除失败', 'err'); return; } + toast('已删除', 'ok'); + viewVlan(); +} + +/* ============================ WOL 网络唤醒 ============================ */ +async function viewWol() { + const r = await api('/api/wol'); + const d = r.data || {}; + const ifs = d.ifaces || []; + const saved = d.targets || []; + $('#view').innerHTML = ` +
    +

    网卡唤醒能力

    +

    每张网卡的 WOL(Wake-on-LAN)支持情况。唤醒需要目标设备已关机但仍带电。

    +
    + + + ${ifs.length ? ifs.map(i => ` + + + + + + `).join('') : ''} + +
    网卡状态WOL 支持当前设置操作
    ${esc(i.name)}${i.oper === 'UP' ? 'UP' : 'DOWN'}${i.supports_wol ? '支持' : '不支持'}${esc(i.wol_mode || '—')}${i.supports_wol + ? `` + : `${esc(i.reason || '—')}`}
    没有可用网卡
    +
    + +
    +

    唤醒指定设备

    +

    输入目标设备的 MAC 地址,发送「魔术包」将其从关机/休眠中唤醒。

    +
    + + + +
    +
    + + +
    +
    +
    + +
    +

    常用设备

    +

    已保存的唤醒目标,可一键唤醒。

    +
    ${saved.length ? saved.map(t => ` +
    + ${esc(t.label || t.mac)} + ${esc(t.mac)} + ${t.iface ? `${esc(t.iface)}` : ''} +
    + + +
    `).join('') : '

    暂无保存的设备。

    '} +
    +
    `; + + $$('.wol-en').forEach(b => b.onclick = async () => { + const cur = ifs.find(i => i.name === b.dataset.if) || {}; + const rr = await api('/api/wol', { method: 'POST', body: { op: 'iface', iface: b.dataset.if, enabled: !cur.enabled } }); + toast(rr.msg_cn || (rr.ok ? '已启用' : '失败'), rr.ok ? 'ok' : 'err'); + viewWol(); + }); + $('#wol-go').onclick = () => sendWol(); + $$('.wol-wake').forEach(b => b.onclick = () => { + $('#wol-mac').value = b.dataset.mac; + if (b.dataset.if) $('#wol-if').value = b.dataset.if; + sendWol(); + }); + $$('.wol-rm').forEach(b => b.onclick = async () => { + const rest = saved.filter(t => t.mac !== b.dataset.mac); + const rr = await api('/api/wol', { method: 'POST', body: { op: 'save', devices: rest } }); + toast(rr.msg_cn || '已删除', rr.ok ? 'ok' : 'err'); + viewWol(); + }); +} + +async function sendWol() { + const mac = ($('#wol-mac') || {}).value.trim(); + const net = ($('#wol-net') || {}).value; + const iface = ($('#wol-if') || {}).value; + const save = ($('#wol-save') || {}).checked; + const out = $('#wol-out'); + if (!/^([0-9a-f]{2}[:.-]){5}[0-9a-f]{2}$/i.test(mac.replace(/\s/g, ''))) { + out.innerHTML = '
    MAC 地址格式不正确,应为 AA:BB:CC:DD:EE:FF
    '; + return; + } + out.innerHTML = '

    正在发送魔术包…

    '; + const body = { op: 'wake', mac, broadcast: net, iface, save: !!save }; + if (save) body.label = mac; + const r = await api('/api/wol', { method: 'POST', body }); + if (!r.ok) { out.innerHTML = `
    ${esc(r.msg_cn || '发送失败')}
    `; return; } + out.innerHTML = `
    ${esc(r.msg_cn || '魔术包已发送')} + ${(r.data && r.data.sent || []).length ? '
    已发往:' + r.data.sent.map(esc).join(' · ') + '
    ' : ''}
    `; + if (save) setTimeout(viewWol, 800); +} + +/* 网卡候选列表归一化:后端可能返回 ['ens18','ens19'] 或 [{name:'ens18'}...] */ +function ifaceNames(list) { + return (list || []).map(i => (typeof i === 'string' ? i : (i && i.name) || '')) + .filter(Boolean); +} + +/* ============================ PPPoE 多拨 ============================ */ +/* 多拨 = 在同一物理口上并发多条 PPPoE 会话。 + 爱快叫「多拨」,本质是 N 个 ppp 接口 + 源地址策略路由 + 加权负载均衡。 */ +async function viewPppMulti() { + const r = await api('/api/pppoe-multi'); + const d = r.data || {}; + const sessions = d.sessions || []; + const ifs = ifaceNames(d.wan_ifaces); + const strat = d.strategies || []; + const online = d.online || 0; + + $('#view').innerHTML = ` +
    +

    PPPoE 多拨 ${online}/${sessions.length} 在线

    +

    在同一物理网口上并发多条 PPPoE 会话,把总带宽叠加起来(爱快「多拨」的等效实现)。 + 每条会话是一张独立的 ppp 接口,配独立的源地址策略路由 —— 这样从某条会话出去的回包才会从原路返回。

    +
    +
    ⚠ 前提:运营商必须允许并发拨号 +
    部分省份限制同一账号只能拨 1 条,多余会话会反复重拨并显示为「未连接」,这是运营商的限制,不是程序故障。 + 建议先用 1 条确认账号能正常拨通,再逐步增加会话数。
    +
    +
    +
    + +
    +

    拨号策略

    +

    决定多条会话如何共同承载流量。

    +
    + + + +
    +

    +
    + + +
    +
    + +
    +

    会话账号

    +

    每条会话一个账号。若运营商允许同账号并发,勾选上面的「单账号多拨」即可全部填同一个账号。

    +
    ${ppmRenderSessions(sessions)}
    +
    + +
    +
    +
    + +
    +

    应用与连接

    +

    「应用并连接」会真正发起拨号并改变出网默认路由。请在确认账号无误后执行。

    + +
    + + +
    +
    + +
    +

    会话状态与排错

    +

    逐条查看会话的接口、获得的地址与对端网关;点「日志」看拨号过程与原因分析。

    +
    + + + ${sessions.length ? sessions.map(s => ` + + + + + + + + `).join('') : ''} + +
    #接口状态本端地址对端网关路由表操作
    ${s.idx}${esc(s.iface)}${s.running ? '已连接' : '未连接'}${esc(s.addr || '—')}${esc(s.peer || '—')}${s.table} + + + +
    尚未配置任何会话
    +
    +
    + + ${(d.notes || []).length ? `
    +

    使用须知

    +
      ${d.notes.map(n => `
    • ${esc(n)}
    • `).join('')}
    +
    ` : ''}`; + + const upd = () => { + const sel = $('#ppm-strategy'); + const s = strat.find(x => x.id === sel.value); + $('#ppm-strategy-why').textContent = s ? s.why : ''; + }; + $('#ppm-strategy').onchange = upd; upd(); + + $('#ppm-expand').onclick = () => { + const n = Math.max(1, Math.min(8, parseInt($('#ppm-count').value || '1', 10))); + const old = ppmReadSessions(); + const dup = $('#ppm-dup').checked; + const out = []; + for (let i = 1; i <= n; i++) { + const prev = old[i - 1] || {}; + out.push(dup && i > 1 + ? Object.assign({}, old[0] || {}, { weight: 1 }) + : Object.assign({ user: '', has_password: false, weight: prev.weight || 1 }, prev)); + } + $('#ppm-list').innerHTML = ppmRenderSessions(out.map((s, i) => Object.assign({}, s, { idx: i + 1 }))); + }; + + $('#ppm-save').onclick = async () => { + const list = ppmReadSessions(); + if (!list.length) { toast('请先生成至少 1 条会话', 'err'); return; } + const body = { + op: 'save', iface: $('#ppm-if').value, strategy: $('#ppm-strategy').value, + duplicate_account: $('#ppm-dup').checked, sessions: list, + }; + const rr = await api('/api/pppoe-multi', { method: 'POST', body }); + toast(rr.msg_cn || (rr.ok ? '已保存' : '保存失败'), rr.ok ? 'ok' : 'err'); + if (rr.ok) setTimeout(viewPppMulti, 600); + else ppmOut(rr); + }; + + $('#ppm-apply').onclick = async () => { + if (!$('#ppm-confirm').checked) { toast('请先勾选确认项', 'err'); return; } + ppmOut({ msg_cn: '正在应用并逐条拨号,请稍候…', ok: true }); + const rr = await api('/api/pppoe-multi', { + method: 'POST', + body: { op: 'apply', confirm: true }, + }); + toast(rr.msg_cn || (rr.ok ? '已应用' : '应用失败'), rr.ok ? 'ok' : 'err'); + ppmOut(rr); + setTimeout(viewPppMulti, 1500); + }; + + $('#ppm-stop').onclick = async () => { + if (!window.confirm('确定断开全部多拨会话?出网将恢复为单一默认路由。')) return; + const rr = await api('/api/pppoe-multi', { method: 'POST', body: { op: 'stop' } }); + toast(rr.msg_cn || (rr.ok ? '已断开' : '操作失败'), rr.ok ? 'ok' : 'err'); + setTimeout(viewPppMulti, 800); + }; + + $$('.ppm-act').forEach(b => b.onclick = async () => { + const rr = await api('/api/pppoe-multi', { + method: 'POST', + body: { op: b.dataset.op, idx: parseInt(b.dataset.idx, 10) }, + }); + toast(rr.msg_cn || '', rr.ok ? 'ok' : 'err'); + setTimeout(viewPppMulti, 900); + }); + + $$('.ppm-log').forEach(b => b.onclick = async () => { + const box = $('#ppm-logbox'); + box.innerHTML = '

    正在读取日志…

    '; + const rr = await api('/api/pppoe-multi/log', { method: 'POST', body: { idx: parseInt(b.dataset.idx, 10) } }); + const dd = rr.data || {}; + box.innerHTML = ` +

    会话 #${esc(String(dd.idx || b.dataset.idx))} 日志

    + ${dd.hint ? `
    结论:${esc(dd.hint)}
    ` : ''} +
    ${esc(dd.log || '(无日志)')}
    `; + }); +} + +function ppmRenderSessions(list) { + if (!list.length) list = [{ idx: 1, user: '', weight: 1 }]; + return list.map((s, i) => ` +
    +
    + #${i + 1} + + + + ${s.running === true ? '已连接' : ''} + ${s.addr ? `${esc(s.addr)}` : ''} +
    +
    `).join(''); +} + +function ppmReadSessions() { + const users = $$('.ppm-user'), passes = $$('.ppm-pass'), weights = $$('.ppm-weight'); + const out = []; + for (let i = 0; i < users.length; i++) { + const u = users[i].value.trim(); + const p = passes[i].value; + const w = parseInt(weights[i].value || '1', 10); + if (!u && !p) continue; // 整行空 → 跳过 + out.push({ user: u, password: p, weight: isNaN(w) ? 1 : w }); + } + return out; +} + +function ppmOut(r) { + const box = $('#ppm-out'); + if (!box) return; + const d = r.data || {}; + let html = `
    ${esc(r.msg_cn || '')}
    `; + const bad = (d.failed || []); + if (bad.length) { + html += '
    ' + + bad.map(f => ``).join('') + + '
    会话原因
    #${f.idx}${esc(f.err || '')}
    '; + } + if (d.log) html += `
    ${esc(d.log)}
    `; + box.innerHTML = html; +} + +/* ============================ QoS 智能限速 ============================ */ +async function viewQos() { + const r = await api('/api/qos'); + const d = r.data || {}; + const ips = d.ips || []; + const dscp = d.dscp || []; + + $('#view').innerHTML = ` +
    +

    智能限速 QoS + ${d.running ? '运行中' : '未启用'} + ${ips.length ? '按 IP 限速模式' : 'CAKE 智能模式'} +

    +

    用 Linux 内核自带的 tc 流量控制复刻爱快 QoS:CAKE 负责公平分流与抗 bufferbloat, + HTB 负责按 IP 精确限速,nftables 负责 DSCP 优先级标记与连接数限制。

    + ${d.accel_conflict ? `
    + 检测到冲突:软加速(flowtable)正在运行。 + flowtable 会让「已建立连接」绕过 netfilter 钩子,导致 DSCP 打标与 IP 限速完全失效。 + 请先到「概览 → 网络状态 / 加速」关闭软加速,再启用 QoS。 +
    ` : ''} + ${!d.modules_ok ? `
    + 缺少内核模块:${esc((d.missing_modules || []).join('、'))}。请确认使用 Debian 官方内核。 +
    ` : ''} +
    +
    工作方式 +
    上行直接在 WAN 出口排队;下行必须先把入口流量重定向到 IFB 虚拟设备再排队 + (这是 Linux 的固有约束,与爱快下行限速原理相同)。
    +
    +
    +
    + +
    +

    带宽与模式

    +

    带宽建议填「实测速率的 85%~90%」,留一点余量让 CAKE 有排队空间,抗延迟效果最好。

    +
    + + + + +
    +

    +
    + +
    +

    优先级规则(DSCP 打标)

    +

    勾选后,匹配的流量会被标记更高的 DSCP,CAKE 会把它插到前面转发 —— 这就是「游戏优先」的实现方式。 + 注:加密流量(HTTPS/QUIC)只能按端口判断,无法准确识别具体应用。

    +
    + ${(d.presets || []).map(p => { + const on = dscp.some(x => x.preset === p.id); + return ``; + }).join('')} +
    +
    + +
    +

    按内网 IP 精确限速(HTB)

    +

    给指定设备设「保证带宽」与「峰值带宽」。保证带宽是该设备最低能拿到的速率,峰值是它在空闲时可借用的上限。 + 填写了任何一条 IP 规则后,CAKE 智能模式会被 HTB 精确限速取代 —— 两者不能同时生效。

    +
    ${qosRenderIps(ips)}
    +
    + + 最多 64 条 +
    +
    + +
    +

    连接数限制

    +

    限制单个内网 IP 的并发连接数,抑制异常设备(中毒、P2P 泛滥)拖垮整台路由。

    +
    + + + 建议 300 以上,过低会影响浏览器/下载工具 +
    +
    + +
    +

    保存与应用

    +
    + + + + +
    + +
    +
    + +
    +

    当前生效的规则

    +

    直接读取内核里的 tc / nftables 状态,可用于确认规则是否真的挂上了。

    +
    ${esc(qosLiveText(d.live))}
    +
    + +
    +

    影响与取舍

    +
    + ${(d.impact || []).map(i => `
    + ${esc(i.name)}
    ${esc(i.why)}
    `).join('')} +
    +
    `; + + const upd = () => { + const s = (d.cake_modes || []).find(x => x.id === $('#qos-mode').value); + $('#qos-mode-why').textContent = s ? s.why : ''; + }; + $('#qos-mode').onchange = upd; upd(); + + const collect = () => ({ + op: 'save', + wan_iface: $('#qos-wan').value, + down_mbit: parseInt($('#qos-down').value || '0', 10) || 0, + up_mbit: parseInt($('#qos-up').value || '0', 10) || 0, + mode: $('#qos-mode').value, + dscp: $$('.qos-pre').filter(c => c.checked).map(c => ({ preset: c.dataset.id })), + connlimit: $('#qos-cl').checked, + connlimit_max: parseInt($('#qos-cl-max').value || '500', 10) || 500, + ips: qosReadIps(), + }); + + $('#qos-add-ip').onclick = () => { + const cur = qosReadIps(); + cur.push({ ip: '', guar_mbit: 10, ceil_mbit: 50, prio: 2 }); + $('#qos-ips').innerHTML = qosRenderIps(cur); + }; + + $('#qos-save').onclick = async () => { + const rr = await api('/api/qos', { method: 'POST', body: collect() }); + toast(rr.msg_cn || (rr.ok ? '已保存' : '保存失败'), rr.ok ? 'ok' : 'err'); + qosOut(rr); + if (rr.ok) setTimeout(viewQos, 700); + }; + + $('#qos-on').onclick = async () => { + if (!$('#qos-confirm').checked) { toast('请先勾选确认项', 'err'); return; } + qosOut({ msg_cn: '正在下发 QoS 规则…', ok: true }); + const body = collect(); + const rr = await api('/api/qos', { + method: 'POST', + body: { op: 'on', confirm: true, ignore_accel: d.accel_conflict === true && window.confirm('当前软加速与 QoS 冲突,是否仍要继续?') }, + }); + toast(rr.msg_cn || (rr.ok ? '已启用' : '启用失败'), rr.ok ? 'ok' : 'err'); + qosOut(rr); + setTimeout(viewQos, 1000); + }; + + $('#qos-off').onclick = async () => { + if (!window.confirm('确定停用 QoS?所有 CAKE / HTB 规则将被移除。')) return; + const rr = await api('/api/qos', { method: 'POST', body: { op: 'off' } }); + toast(rr.msg_cn || (rr.ok ? '已停用' : '操作失败'), rr.ok ? 'ok' : 'err'); + setTimeout(viewQos, 800); + }; + + $('#qos-refresh').onclick = () => viewQos(); + $$('.qos-rm-ip').forEach(b => b.onclick = () => { + const cur = qosReadIps().filter((_x, i) => String(i) !== b.dataset.i); + $('#qos-ips').innerHTML = qosRenderIps(cur); + }); +} + +function qosRenderIps(ips) { + if (!ips.length) return '

    尚未添加 IP 限速规则 —— 当前使用 CAKE 智能模式,所有设备自动公平分流。

    '; + return ips.map((r, i) => ` +
    +
    + + + + + 0 最高 / 6 最低 +
    + +
    `).join(''); +} + +function qosReadIps() { + const ipEls = $$('.qos-ip'), gEls = $$('.qos-guar'), cEls = $$('.qos-ceil'), pEls = $$('.qos-prio'); + const out = []; + for (let i = 0; i < ipEls.length; i++) { + const ip = ipEls[i].value.trim(); + if (!ip) continue; + out.push({ + ip, + guar_mbit: parseInt(gEls[i].value || '1', 10) || 1, + ceil_mbit: parseInt(cEls[i].value || '1', 10) || 1, + prio: (pEls[i] && parseInt(pEls[i].value, 10)) || 0, + }); + } + return out; +} + +function qosLiveText(live) { + live = live || {}; + const parts = []; + parts.push('── 入口重定向 / 上行根队列 ──'); + parts.push(((live.wan_root || []).concat(live.ingress || [])).join('\n') || '(无)'); + parts.push(''); + parts.push('── IFB 下行队列 ──'); + parts.push((live.ifb || []).join('\n') || '(无)'); + parts.push(''); + parts.push('── nftables drouter_qos 表 ──'); + parts.push(live.nft_ok ? (live.nft_dump || '(空)') : '(未创建)'); + return parts.join('\n'); +} + +function qosOut(r) { + const box = $('#qos-out'); + if (!box) return; + const d = r.data || {}; + let html = `
    ${esc(r.msg_cn || '')}
    `; + if (d.log) html += `
    ${esc(d.log)}
    `; + if (d.live) html += `
    ${esc(qosLiveText(d.live))}
    `; + box.innerHTML = html; +} + +/* ============================ DPI 应用识别 ============================ */ +async function viewDpi() { + const r = await api('/api/dpi'); + const d = r.data || {}; + const srcs = d.sources || []; + const mirrors = d.mirrors || []; + const inst = d.installed || {}; + const lastMirror = d.last_mirror || 'ghproxy'; + + $('#view').innerHTML = ` +
    +

    应用识别 DPI + ${srcs.filter(s => s.present).length}/${srcs.length} 已安装

    +

    按「应用/协议」识别流量(爱快的杀手锏)。Debian 上对应 nDPI / Suricata。 + 识别结果可交给 QoS 打标限速,或直接封禁某类应用。

    +
    +
    ⚠ 加密流量的固有限制 +
    HTTPS / QUIC 已加密,任何 DPI(包括爱快)都无法 100% 识别具体应用, + 只能靠 SNI、端口、流量特征做推测。这是行业共同限制,不是实现缺陷。
    +
    +
    +
    + +
    +

    识别引擎状态

    +
    + + + + + + + + + + + + + + + + + + +
    组件状态路径 / 说明
    nDPI Reader${inst.ndpiReader && inst.ndpiReader.present ? '已安装' : '未安装'}${esc((inst.ndpiReader && inst.ndpiReader.path) || '—')}
    Suricata${inst.suricata && inst.suricata.present ? '已安装' : '未安装'}${esc((inst.suricata && inst.suricata.path) || '—')}
    ntopng${inst.ntopng && inst.ntopng.present ? '已安装' : '未安装'}${esc((inst.ntopng && inst.ntopng.path) || '—')}
    xt_ndpi 内核模块${inst.xt_ndpi_module && inst.xt_ndpi_module.present ? '已加载' : '未加载'}提供 nftables 按应用匹配能力,需自行编译
    APT 仓库 nDPI${inst.apt_ndpi && inst.apt_ndpi.available ? '可装' : '不可用'}可直接 apt install,无需编译
    +
    + +
    +

    规则库 / 源码更新

    +

    nDPI 的协议识别规则托管在 GitHub。国内直连 GitHub 通常超时或极慢, + 所以这里提供多个公共加速前缀 —— 先「测速」挑最快的,再更新。

    +
    + + +
    +

    +
    + + + +
    +
    +
    + +
    +

    已安装的规则库

    +
    + + + ${srcs.map(s => ` + + + + + + `).join('')} + +
    库状态更新时间大小用途
    ${esc(s.name)}
    ${esc(s.repo)}
    ${s.present ? '已安装' : '未安装'}${esc(s.version || '—')}${esc(s.size_h || '—')}${esc(s.why)}
    + ${d.last_update ? `

    上次更新:${esc(d.last_update)}(前缀 ${esc(d.last_mirror || '—')})

    ` : ''} +
    + +
    +

    安装方式

    +

    三种路径,按「省事 → 最新 → 最强」排列。选一个生成命令,复制到 Web 终端执行即可。

    +
    + + +
    +

    +
    +
    + + ${(d.notes || []).length ? `
    +

    使用须知

    +
      ${d.notes.map(n => `
    • ${esc(n)}
    • `).join('')}
    +
    ` : ''}`; + + const updMirror = () => { + const k = $('#dpi-mirror').value; + const m = mirrors.find(x => x.key === k); + $('#dpi-mirror-why').textContent = m ? m.why : '自定义前缀:填写你自己搭建或信任的加速服务地址,需以 / 结尾。'; + }; + $('#dpi-mirror').onchange = updMirror; updMirror(); + + $('#dpi-test').onclick = async () => { + const box = $('#dpi-out'); + box.innerHTML = '

    正在逐个测试前缀连通性(每个最多 5 秒)…

    '; + const rr = await api('/api/dpi', { + method: 'POST', + body: { op: 'test', custom_prefix: $('#dpi-custom').value.trim() }, + }); + const res = (rr.data || {}).results || []; + box.innerHTML = ` +
    ${esc(rr.msg_cn || '')}
    +
    + + ${res.map(x => ` + + + + + `).join('')} +
    前缀结果耗时地址
    ${esc(x.name)}${x.ok ? '可用' : `${esc(x.err || '不可用')}`}${esc(String(x.secs))}s${esc(x.prefix || '(直连)')}
    +

    建议选最快且可用的那个,然后点「更新规则库」。

    `; + if (rr.data && rr.data.best) { + const sel = $('#dpi-mirror'); + if ([...sel.options].some(o => o.value === rr.data.best)) sel.value = rr.data.best; + updMirror(); + } + }; + + $('#dpi-update').onclick = async () => { + const box = $('#dpi-out'); + const mirror = $('#dpi-mirror').value; + box.innerHTML = '

    正在下载并安装规则库,可能耗时较长(视网速而定)…

    '; + const rr = await api('/api/dpi', { + method: 'POST', + body: { op: 'update', mirror, custom_prefix: $('#dpi-custom').value.trim() }, + }); + const res = (rr.data || {}).results || []; + let html = `
    ${esc(rr.msg_cn || '')}
    `; + if (res.length) { + html += '
    ' + + res.map(x => ` + + + `).join('') + '
    库结果说明
    ${esc(x.name)}${x.ok ? '成功' : '失败'}${esc(x.ok ? ((x.files || 0) + ' 个文件,' + (x.size_kb || 0) + ' KB') : (x.detail || x.err || ''))}
    '; + } + if ((rr.data || {}).log) html += `
    ${esc(rr.data.log)}
    `; + html += '

    完整日志可用上方「查看日志」按钮获取,便于复制反馈。

    '; + box.innerHTML = html; + if (rr.ok) setTimeout(() => $('#dpi-log').click(), 1200); + }; + + $('#dpi-log').onclick = async () => { + const rr = await api('/api/dpi/log'); + $('#dpi-out').innerHTML = `
    ${esc((rr.data || {}).log || '(暂无更新日志)')}
    + `; + const cb = $('#dpi-copy-log'); + if (cb) cb.onclick = () => { + navigator.clipboard.writeText((rr.data || {}).log || ''); + toast('日志已复制', 'ok'); + }; + }; + + const updPlan = () => { + const p = (d.presets || []).find(x => x.id === $('#dpi-plan').value); + $('#dpi-plan-why').textContent = p ? p.why : ''; + }; + $('#dpi-plan').onchange = updPlan; updPlan(); + + $('#dpi-plan-go').onclick = async () => { + const which = $('#dpi-plan').value; + const rr = await api('/api/dpi', { + method: 'POST', + body: { op: 'plan', which, prefix: $('#dpi-custom').value.trim() || DPI_PREFIX_HINT }, + }); + const cmds = (rr.data || {}).cmds || []; + $('#dpi-plan-out').innerHTML = `
    ${esc(cmds.join('\n'))}
    + `; + const cb = $('#dpi-copy-cmd'); + if (cb) cb.onclick = () => { + navigator.clipboard.writeText(cmds.join('\n')); + toast('命令已复制,可粘贴到 Web 终端执行', 'ok'); + }; + }; +} + +const DPI_PREFIX_HINT = 'https://ghproxy.com/'; + +/* ============================ 依赖自检 ============================ */ +async function viewDepCheck() { + const v = $('#view'); + v.innerHTML = ` +
    +

    运行环境依赖自检

    +

    检测在一台全新安装的 Debian 13 + XFCE 桌面 上运行 Drouter 所需的 + 全部依赖、组件、软件与内核模块。绿色为已就绪,红色为缺失(必需),黄色为可选。

    +
    + + + +
    +

    + 「一键安装」只装必需项。可选组件(Samba / NFS / Docker / 桌面环境等) + 需要你逐项点「安装」—— 它们装完会拉起常驻服务或占用大量空间, + 不该因为一次点错就被全部装上。 +

    + + +
    +

    点击「开始检测」开始。

    + `; + + $('#dep-check').onclick = runDepCheck; + const inst = $('#dep-install'); + // 必须包一层:直接把函数给 onclick 会把 MouseEvent 当成 keys 参数传进去 + inst.onclick = () => runDepInstall(); + $('#dep-q').oninput = renderDepList; + $('#dep-only-miss').onchange = renderDepList; + bindCopyButtons(v); +} + +// 上一次检测的完整结果留在内存里,过滤 / 搜索只是重新渲染,不再打接口。 +// DEP_REQ_MISSING:本次「缺失且必需」的 key 列表,一键安装只用这个列表, +// 避免把 Samba / NFS / Docker / 桌面环境这类可选大件一起装上。 +let DEP_LAST = null; +let DEP_REQ_MISSING = []; +let DEP_FILTER = 'all'; + +function depMatch(x, q) { + if (!q) return true; + const hay = [x.name, x.key, x.target, x.pkg, x.note, x.group_name, x.where] + .filter(Boolean).join(' ').toLowerCase(); + return hay.indexOf(q) >= 0; +} + +function renderDepList() { + const d = DEP_LAST; + if (!d) return; + const box = $('#dep-result'); + const items = d.items || []; + const q = (($('#dep-q') || {}).value || '').trim().toLowerCase(); + const onlyMiss = !!(($('#dep-only-miss') || {}).checked); + + const sel = items.filter(x => (DEP_FILTER === 'all' || x.group === DEP_FILTER) + && depMatch(x, q) && (!onlyMiss || !x.ok)); + + const req = sel.filter(x => x.required); + const missReq = req.filter(x => !x.ok); + const opt = sel.filter(x => !x.required); + const missOpt = opt.filter(x => !x.ok); + + const g = (title, arr, cls) => arr.length ? ` +
    +

    ${title} ${arr.length}

    +
    ${arr.map(renderDepItem).join('')}
    +
    ` : ''; + + const missing = items.filter(x => !x.ok); + const total = items.length; + const shown = sel.length; + box.innerHTML = + (missing.length ? '' : '
    全部依赖均已就绪,无需安装。
    ') + + (shown === 0 + ? `
    当前筛选条件下没有匹配项(共 ${total} 项)。试试清空搜索或切换到「全部」。
    ` + : g('缺失(必需)', missReq, 'err') + + g('缺失(可选)', missOpt, 'warn') + + g('已就绪(必需)', req.filter(x => x.ok), 'ok') + + g('已就绪(可选)', opt.filter(x => x.ok), 'gray')) + + (shown !== total ? `

    显示 ${shown} / ${total} 项

    ` : ''); + bindDepInstallButtons(box); +} + +async function runDepCheck() { + const btn = $('#dep-check'); + btn.disabled = true; btn.textContent = '检测中…'; + const r = await api('/api/deps/check'); + btn.disabled = false; btn.textContent = '再次检测'; + const box = $('#dep-result'); + if (!r.ok) { box.innerHTML = `
    ${esc(r.msg_cn || '检测失败')}
    `; return; } + const d = r.data || {}; + const items = d.items || []; + DEP_LAST = d; + DEP_FILTER = 'all'; + + const req = items.filter(x => x.required); + const missReq = req.filter(x => !x.ok); + const opt = items.filter(x => !x.required); + const missOpt = opt.filter(x => !x.ok); + $('#dep-sum').innerHTML = `必需 ${req.length - missReq.length}/${req.length} + · 可选 ${opt.length - missOpt.length}/${opt.length} + ${d.extra && d.extra.os ? ` · ${esc(d.extra.os)}` : ''}`; + $('#dep-install').disabled = missReq.length === 0; + // 记下本次必需缺失项的 key:一键安装只能装这些,绝不能顺带把可选项也装上 + DEP_REQ_MISSING = missReq.filter(x => x.pkg).map(x => x.key); + + // 分组过滤按钮:光靠四段标题,70 项平铺时根本找不到想看的那一个 + const groups = d.groups || []; + const gbox = $('#dep-groups'); + if (groups.length) { + gbox.classList.remove('hidden'); + const cnt = {}; + items.forEach(x => { cnt[x.group] = (cnt[x.group] || 0) + 1; }); + gbox.innerHTML = + `` + + groups.filter(g => cnt[g.id]).map(g => + `` + ).join(''); + gbox.querySelectorAll('.dep-grp').forEach(b => { + b.onclick = () => { + DEP_FILTER = b.dataset.grp; + gbox.querySelectorAll('.dep-grp').forEach(x => x.classList.remove('on')); + b.classList.add('on'); + renderDepList(); + }; + }); + } + $('#dep-filter').classList.remove('hidden'); + renderDepList(); +} + +function renderDepItem(x) { + const cls = x.ok ? 'ok' : (x.required ? 'err' : 'warn'); + const ico = x.ok ? '✓' : (x.required ? '✕' : '!'); + // 缺失且能装的给一个单独的安装按钮:可选项必须由用户逐项确认, + // 不能混进「一键安装」里(否则一次点错就把桌面环境 / Samba / Docker 全装上) + const btn = (!x.ok && x.pkg) + ? `` : ''; + return `
    + ${ico} +
    + ${esc(x.name)} +
    ${esc(x.note || '')}
    +
    ${esc(x.where || x.target || '')}${x.pkg ? ' · 包名 ' + esc(x.pkg) : ''}
    +
    + ${esc(x.group_name || '其它')} + ${x.required ? '必需' : '可选'} + ${btn} +
    `; +} + +// 渲染后把每个「安装」按钮接上事件 +function bindDepInstallButtons(root) { + (root || document).querySelectorAll('[data-dep-key]').forEach(b => { + b.onclick = () => runDepInstall([b.dataset.depKey]); + }); +} + +async function runDepInstall(keys) { + const one = Array.isArray(keys) && keys.length > 0; + const btn = $('#dep-install'); + if (!one && btn) { btn.disabled = true; btn.textContent = '安装中…(可能需要几分钟)'; } + toast(one ? '正在安装该组件…' : '正在安装缺失依赖,请稍候…', 'info', 5000); + const body = one ? { keys: keys } : { keys: DEP_REQ_MISSING }; + const r = await api('/api/deps/install', { method: 'POST', body: body }); + if (!one && btn) { btn.disabled = false; btn.textContent = '一键安装缺失的必需项'; } + btn.disabled = false; btn.textContent = '一键安装缺失项'; + const box = $('#dep-result'); + const d = r.data || {}; + if (d.log) { + $('#dep-log-card').classList.remove('hidden'); + $('#dep-log').textContent = d.log; + } + if (!r.ok) { + toast(r.msg_cn || '安装失败', 'err', 6000); + box.insertAdjacentHTML('afterbegin', + `
    安装失败:${esc(r.msg_cn || '')}。请查看下方「安装日志」。
    `); + if (!$('#dep-log-card').classList.contains('hidden')) { + $('#dep-log-card').scrollIntoView({ behavior: 'smooth' }); + } + return; + } + const still = d.still_missing || []; + toast(still.length ? `安装完成,仍有 ${still.length} 项缺失` : '全部依赖已安装完成', still.length ? 'warn' : 'ok', 6000); + await runDepCheck(); + const box2 = $('#dep-result'); + if (box2) bindDepInstallButtons(box2); + const chk = $('#dep-check'); if (chk) chk.textContent = '再次检测'; +} + +/* ============================ Web 终端 + 文件管理器 ============================ */ +/* #4 重做:真 PTY 交互终端。 + 前端只负责三件事: + ① 按键 → 字节(每敲一个键立刻发送,不等回车); + ② 回来的字节 → 屏幕(内置一个小型终端模拟器,处理 ANSI 光标与颜色); + ③ 尺寸变化时上报 cols/rows,让 top / vi 这类全屏程序排版正确。 + 回显、行编辑、Ctrl+C、方向键历史全部由内核终端行规程负责,前端不再自己画假回显。 + 注意这是**本机 shell**(root / ajeef / drouter),不是到别的机器的 SSH —— + 界面也就不再摆主机 / 端口 / 密码这些用不上的输入框,免得看着能连其实连不了。 */ + +const WS_MAX_SB = 600; // 回滚区最多保留多少行 +const WS_WRITE_CHUNK = 1024; // 单次写入字节上限(粘贴长文本时分片发) + +const WS = { + sid: null, cols: 0, rows: 0, + grid: null, cx: 0, cy: 0, key: '', + st: null, saved: null, + dirty: null, lineEls: [], + carry: '', // 跨帧拼接用的残字(半个代理对) +}; +let WS_PUMP = null; // 读取轮询定时器 +let WS_BUSY = false; // 防止 read 请求重叠 + +/* 16 色 ANSI 调色板(偏暗底终端的配色,保证在深色屏上可读) */ +const ANSI_FG = ['#5c6b85', '#e06c62', '#7cc488', '#e2b34a', '#6fa4e8', '#b48ce8', '#5cc0d4', '#c9d4e4']; +const ANSI_FGB = ['#8b97ad', '#ff8b7f', '#9ce0ac', '#f2cd72', '#93c0f5', '#cfabf5', '#84dcee', '#eaf0fa']; +const ANSI_BG = ['#26314a', '#6b2b26', '#245239', '#5c4714', '#254063', '#453363', '#1c4a55', '#37455f']; +const ANSI_BGB = ['#3a4763', '#8a3a33', '#33684a', '#7a6220', '#345a86', '#5c4682', '#28636f', '#4b5b78']; + +/* ---------------------------- 屏幕缓冲区 ---------------------------- */ +function wsBlankRow(cols) { + const r = new Array(cols); + for (let x = 0; x < cols; x++) r[x] = { c: ' ', k: '' }; + return r; +} + +function wsGridInit(cols, rows) { + WS.cols = cols; WS.rows = rows; + WS.grid = []; + for (let y = 0; y < rows; y++) WS.grid.push(wsBlankRow(cols)); + WS.cx = 0; WS.cy = 0; + WS.st = { fg: -1, bg: -1, bold: 0, dim: 0, it: 0, ul: 0, rev: 0 }; + WS.key = ''; + WS.saved = null; + WS.dirty = new Set(); + WS.lineEls = []; + for (let y = 0; y < rows; y++) WS.dirty.add(y); +} + +function wsStyleKey() { + const s = WS.st, p = []; + if (s.bold) p.push('b'); + if (s.dim) p.push('d'); + if (s.it) p.push('i'); + if (s.ul) p.push('u'); + if (s.rev) p.push('r'); + if (s.fg >= 0) p.push('f' + s.fg); + if (s.bg >= 0) p.push('g' + s.bg); + return p.join('|'); +} + +function wsStyleAttr(k) { + if (!k) return { cls: '', style: '' }; + let fg = '', bg = '', cls = [], rev = false; + k.split('|').forEach(t => { + if (t === 'b') cls.push('t-b'); + else if (t === 'd') cls.push('t-dim'); + else if (t === 'i') cls.push('t-i'); + else if (t === 'u') cls.push('t-u'); + else if (t === 'r') rev = true; + else if (t[0] === 'f') { const n = +t.slice(1); fg = n < 8 ? ANSI_FG[n] : ANSI_FGB[n - 8]; } + else if (t[0] === 'g') { const n = +t.slice(1); bg = n < 8 ? ANSI_BG[n] : ANSI_BGB[n - 8]; } + }); + if (rev) { const t = fg; fg = bg || '#0d1526'; bg = t || '#c9d4e4'; } + let style = ''; + if (fg) style += 'color:' + fg + ';'; + if (bg) style += 'background:' + bg + ';'; + return { cls: cls.join(' '), style: style }; +} + +function wsClearRow(y) { + const row = WS.grid[y]; + for (let x = 0; x < WS.cols; x++) row[x] = { c: ' ', k: WS.key }; + WS.dirty.add(y); +} + +function wsScroll() { + const top = WS.grid.shift(); + WS.grid.push(wsBlankRow(WS.cols)); + wsScrollbackPush(top); + WS.cy = WS.rows - 1; + for (let y = 0; y < WS.rows; y++) WS.dirty.add(y); +} + +function wsNewline() { + WS.cy++; + if (WS.cy >= WS.rows) wsScroll(); +} + +/* 字符显示宽度:中文 / 全角在真实终端里占两格。 + 以前一律按一格处理,光标列就和真实终端对不上 —— 输入中文时 readline + 算的换行位置和屏幕画出来的不一致,看起来就是「字被切开了」。 */ +function wsCharWidth(ch) { + if (!ch) return 0; + const c = ch.codePointAt(0); + if (c < 0x20) return 0; // 控制字符 + if (c >= 0x1100 && c <= 0x115F) return 2; // 韩文初声 + if (c >= 0x2E80 && c <= 0xA4CF) return 2; // CJK / 全角符号 + if (c >= 0xAC00 && c <= 0xD7A3) return 2; // 韩文音节 + if (c >= 0xF900 && c <= 0xFAFF) return 2; // CJK 兼容 + if (c >= 0xFE30 && c <= 0xFE6F) return 2; // 全角标点 + if (c >= 0xFF01 && c <= 0xFF60) return 2; // 全角 ASCII + if (c >= 0xFFE0 && c <= 0xFFE6) return 2; + if (c >= 0x20000 && c <= 0x3FFFD) return 2; // CJK 扩展 B~G + return 1; +} + +function wsPut(ch) { + const w = wsCharWidth(ch); + if (!w) return; + // 宽字符放不下整行剩余宽度时先换行,别把半个字挤到下一行 + if (WS.cx + w > WS.cols) { WS.cx = 0; wsNewline(); } + WS.grid[WS.cy][WS.cx] = { c: ch, k: WS.key }; + if (w === 2 && WS.cx + 1 < WS.cols) { + // 第二格是占位,渲染时跳过(见 wsRowHtml) + WS.grid[WS.cy][WS.cx + 1] = { c: '', k: WS.key, pad: 1 }; + } + WS.cx += w; + WS.dirty.add(WS.cy); +} + +/* ---------------------------- ANSI 转义序列 ---------------------------- */ +function wsSgr(p) { + const s = WS.st; + const list = p.length ? p : [0]; + let i = 0; + while (i < list.length) { + const v = list[i]; + if (v === 0) { s.fg = -1; s.bg = -1; s.bold = s.dim = s.it = s.ul = s.rev = 0; } + else if (v === 1) s.bold = 1; + else if (v === 2) s.dim = 1; + else if (v === 3) s.it = 1; + else if (v === 4) s.ul = 1; + else if (v === 7) s.rev = 1; + else if (v === 22) { s.bold = 0; s.dim = 0; } + else if (v === 23) s.it = 0; + else if (v === 24) s.ul = 0; + else if (v === 27) s.rev = 0; + else if (v >= 30 && v <= 37) s.fg = v - 30; + else if (v === 39) s.fg = -1; + else if (v >= 40 && v <= 47) s.bg = v - 40; + else if (v === 49) s.bg = -1; + else if (v >= 90 && v <= 97) s.fg = v - 90 + 8; + else if (v >= 100 && v <= 107) s.bg = v - 100 + 8; + else if (v === 38 || v === 48) { + // 256 色 / 真彩色:这里只取 16 色近似,其余跳过(排障可读性不受影响) + if (list[i + 1] === 5) { + const c = list[i + 2]; + if (typeof c === 'number') { if (v === 38) s.fg = c < 16 ? c : 7; else s.bg = c < 16 ? c : 0; } + i += 2; + } else if (list[i + 1] === 2) { i += 4; } + } + i++; + } + WS.key = wsStyleKey(); +} + +function wsEraseLine(mode) { + const row = WS.grid[WS.cy]; + const from = mode === 0 ? WS.cx : 0; + const to = mode === 1 ? WS.cx + 1 : WS.cols; + for (let x = from; x < to && x < WS.cols; x++) row[x] = { c: ' ', k: WS.key }; + WS.dirty.add(WS.cy); +} + +function wsEraseDisplay(mode) { + if (mode === 0) { wsEraseLine(0); for (let y = WS.cy + 1; y < WS.rows; y++) wsClearRow(y); } + else if (mode === 1) { wsEraseLine(1); for (let y = 0; y < WS.cy; y++) wsClearRow(y); } + else { for (let y = 0; y < WS.rows; y++) wsClearRow(y); } +} + +function wsInsertLines(n) { + for (let i = 0; i < n; i++) { WS.grid.splice(WS.cy, 0, wsBlankRow(WS.cols)); WS.grid.pop(); } + for (let y = WS.cy; y < WS.rows; y++) WS.dirty.add(y); +} +function wsDeleteLines(n) { + for (let i = 0; i < n; i++) { WS.grid.splice(WS.cy, 1); WS.grid.push(wsBlankRow(WS.cols)); } + for (let y = WS.cy; y < WS.rows; y++) WS.dirty.add(y); +} +function wsInsertChars(n) { + const row = WS.grid[WS.cy], blanks = []; + for (let i = 0; i < n; i++) blanks.push({ c: ' ', k: WS.key }); + row.splice(WS.cx, 0, ...blanks); + row.length = WS.cols; + WS.dirty.add(WS.cy); +} +function wsDeleteChars(n) { + const row = WS.grid[WS.cy]; + row.splice(WS.cx, n); + while (row.length < WS.cols) row.push({ c: ' ', k: WS.key }); + WS.dirty.add(WS.cy); +} + +function wsCsi(params, final) { + const p = params.split(';').map(x => parseInt(x, 10)); + const n1 = isNaN(p[0]) ? 1 : p[0]; + const cxMax = WS.cols - 1, cyMax = WS.rows - 1; + switch (final) { + case 'A': WS.cy = Math.max(0, WS.cy - n1); break; // 光标上移 + case 'B': WS.cy = Math.min(cyMax, WS.cy + n1); break; // 下移 + case 'C': WS.cx = Math.min(cxMax, WS.cx + n1); break; // 右移 + case 'D': WS.cx = Math.max(0, WS.cx - n1); break; // 左移 + case 'E': WS.cy = Math.min(cyMax, WS.cy + n1); WS.cx = 0; break; + case 'F': WS.cy = Math.max(0, WS.cy - n1); WS.cx = 0; break; + case 'G': case '`': WS.cx = Math.max(0, Math.min(cxMax, (p[0] || 1) - 1)); break; + case 'H': case 'f': + WS.cy = Math.max(0, Math.min(cyMax, (p[0] || 1) - 1)); + WS.cx = Math.max(0, Math.min(cxMax, (p[1] || 1) - 1)); + break; + case 'J': wsEraseDisplay(p[0] || 0); break; + case 'K': wsEraseLine(p[0] || 0); break; + case 'L': wsInsertLines(n1); break; + case 'M': wsDeleteLines(n1); break; + case 'P': wsDeleteChars(n1); break; + case '@': wsInsertChars(n1); break; + case 'm': wsSgr(p); break; + case 's': WS.saved = { x: WS.cx, y: WS.cy }; break; + case 'u': if (WS.saved) { WS.cx = WS.saved.x; WS.cy = WS.saved.y; } break; + default: break; // h/l(隐藏光标、启用 Alternate Screen 等)不影响显示,忽略 + } +} + +/* 字节流 → 屏幕。状态机:0 普通 / 1 ESC / 2 CSI / 3 OSC / 4 字符集 / 5 OSC-ESC */ +let WS_STATE = 0, WS_BUF = ''; + +function wsPutCh(ch) { + if (ch === '\n') { wsNewline(); return; } + if (ch === '\r') { WS.cx = 0; return; } + if (ch === '\b') { WS.cx = Math.max(0, WS.cx - 1); return; } + if (ch === '\t') { WS.cx = Math.min(WS.cols - 1, (Math.floor(WS.cx / 8) + 1) * 8); return; } + if (ch === '\x07') return; // 响铃:浏览器里别出声 + if (ch < ' ') return; // 其余控制字符不显示在屏幕上 + wsPut(ch); +} + +/* 跨帧喂数据。注意三点: + 1) 必须按码点遍历(Array.from),不能按 UTF-16 单元 —— 否则 emoji / CJK 扩展区 + 字符会被拆成两个半 surrogate,显示出来就是「断字」; + 2) 服务端虽然已经保证不在半个 UTF-8 字符上切断,但 JSON 传输前后仍有拼接场景, + 这里再兜一层:末尾若是不完整的代理对,留在缓冲里等下一帧; + 3) 只累计脏行,真正画到 DOM 交给下一帧统一做(见 wsRequestRender)。 */ +function wsFeed(text) { + if (!WS.grid || !text) return; + if (WS.carry) { text = WS.carry + text; WS.carry = ''; } + // 末尾是孤立高位代理 → 可能还有半个字符没到 + const tail = text.charCodeAt(text.length - 1); + if (tail >= 0xD800 && tail <= 0xDBFF) { + WS.carry = text.slice(-1); + text = text.slice(0, -1); + } + const chars = Array.from(text); + for (let i = 0; i < chars.length; i++) { + const ch = chars[i]; + if (WS_STATE === 0) { + if (ch === '\x1b') { WS_STATE = 1; WS_BUF = ''; } + else wsPutCh(ch); + } else if (WS_STATE === 1) { + if (ch === '[') { WS_STATE = 2; WS_BUF = ''; } + else if (ch === ']') { WS_STATE = 3; WS_BUF = ''; } + else if (ch === '(' || ch === ')' || ch === '*' || ch === '+') { WS_STATE = 4; } + else if (ch === 'M') { WS.cy = Math.max(0, WS.cy - 1); WS_STATE = 0; } // 反向换行 + else if (ch === '7') { WS.saved = { x: WS.cx, y: WS.cy }; WS_STATE = 0; } + else if (ch === '8') { if (WS.saved) { WS.cx = WS.saved.x; WS.cy = WS.saved.y; } WS_STATE = 0; } + else WS_STATE = 0; + } else if (WS_STATE === 2) { + const c = ch.charCodeAt(0); + if ((c >= 0x30 && c <= 0x3f) || (c >= 0x20 && c <= 0x2f)) WS_BUF += ch; // 参数 / 中间字节 + else if (c >= 0x40 && c <= 0x7e) { wsCsi(WS_BUF, ch); WS_STATE = 0; } // 终止字节 + else WS_STATE = 0; + } else if (WS_STATE === 3) { + // OSC(设置窗口标题等):到 BEL 或 ESC \ 结束,内容一律丢弃 + if (ch === '\x07') WS_STATE = 0; + else if (ch === '\x1b') WS_STATE = 5; + } else if (WS_STATE === 5) WS_STATE = 0; + else WS_STATE = 0; + } +} + +/* ---------------------------- 渲染 ---------------------------- */ +function wsSpan(k, text) { + if (!text) return ''; + if (!k) return esc(text); + const a = wsStyleAttr(k), at = []; + if (a.cls) at.push('class="' + a.cls + '"'); + if (a.style) at.push('style="' + a.style + '"'); + return '' + esc(text) + ''; +} + +/* curX >= 0 时在该列画光标方块;其余行传 -1 */ +function wsRowHtml(row, curX) { + let last = row.length - 1; + while (last >= 0 && row[last].c === ' ' && !row[last].k) last--; + if (curX >= 0 && curX > last) last = curX; + if (last < 0) return ''; + let html = '', ck = null, buf = ''; + const flush = () => { if (ck !== null) { html += wsSpan(ck, buf); buf = ''; ck = null; } }; + for (let x = 0; x <= last; x++) { + const cell = row[x] || { c: ' ', k: '' }; + if (cell.pad) continue; // 宽字符的第二格,不重复输出 + if (x === curX) { + flush(); + const a = wsStyleAttr(cell.k), at = []; + at.push('class="tcur' + (a.cls ? ' ' + a.cls : '') + '"'); + if (a.style) at.push('style="' + a.style + '"'); + html += '' + esc(cell.c || ' ') + ''; + continue; + } + if (ck === null) { ck = cell.k; buf = cell.c; continue; } + if (cell.k === ck) { buf += cell.c; continue; } + flush(); ck = cell.k; buf = cell.c; + } + flush(); + return html; +} + +function wsScrollbackPush(row) { + const sb = $('#ws-scroll'); + if (!sb) return; + const d = document.createElement('div'); + d.className = 'tl'; + d.innerHTML = wsRowHtml(row, -1); + if (sb.appendChild) sb.appendChild(d); + while ((sb.childElementCount || 0) > WS_MAX_SB && sb.removeChild) sb.removeChild(sb.firstChild); +} + +/* 渲染合并到动画帧:一帧内可能连着收到好几段输出,如果每段都立刻改 innerHTML + 并读 scrollHeight,浏览器会被迫连续同步重排 —— 输出一大就卡成几秒一跳。 + 这里只记「脏了」,真正画到 DOM 每帧最多一次。 */ +let WS_RAF = null; +let WS_RAF_FORCE = false; +function wsRequestRender(force) { + if (force) WS_RAF_FORCE = true; + if (WS_RAF != null) return; + const run = () => { + WS_RAF = null; + wsRenderScreen(); + wsAutoScroll(WS_RAF_FORCE); + WS_RAF_FORCE = false; + }; + if (typeof requestAnimationFrame === 'function') WS_RAF = requestAnimationFrame(run); + else WS_RAF = setTimeout(run, 16); +} + +function wsRenderScreen() { + const el = $('#ws-screen'); + if (!el || !WS.grid) return; + if (WS.lineEls.length !== WS.rows) { + el.innerHTML = ''; + WS.lineEls = []; + for (let y = 0; y < WS.rows; y++) { + const d = document.createElement('div'); + d.className = 'tl'; + if (el.appendChild) el.appendChild(d); + WS.lineEls.push(d); + } + for (let y = 0; y < WS.rows; y++) WS.dirty.add(y); + } + // 光标所在行每帧都要重画(光标可能只是挪了个位置,没有字符变化) + const cy = Math.max(0, Math.min(WS.rows - 1, WS.cy)); + WS.dirty.add(cy); + if (typeof WS._cyPrev === 'number') WS.dirty.add(Math.max(0, Math.min(WS.rows - 1, WS._cyPrev))); + WS._cyPrev = cy; + WS.dirty.forEach(y => { + const d = WS.lineEls[y]; + if (d) d.innerHTML = wsRowHtml(WS.grid[y], y === cy ? WS.cx : -1); + }); + WS.dirty.clear(); +} + +function wsAutoScroll(force) { + const el = $('#ws-term'); + if (!el) return; + const max = Number(el.scrollHeight) || 0; + const top = Number(el.scrollTop) || 0; + const h = Number(el.clientHeight) || max; + if (force || (max - top - h) < 40) { + try { el.scrollTop = max; } catch (e) { /* 少数环境是只读的,忽略 */ } + } +} + +function wsNote(text) { + wsFeed(text + '\r\n'); + wsRequestRender(true); +} + +function wsClearScreen() { + wsGridInit(WS.cols || 100, WS.rows || 26); + WS.carry = ''; + const sb = $('#ws-scroll'); + if (sb) sb.innerHTML = ''; + wsRenderScreen(); +} + +/* ---------------------------- 尺寸测量与同步 ---------------------------- */ +function wsBox() { + const el = $('#ws-term'); + if (!el) return null; + let w = Number(el.clientWidth) || 0; + let h = Number(el.clientHeight) || 0; + if (!w || !h) return null; + if (typeof getComputedStyle === 'function') { + const cs = getComputedStyle(el); + w -= (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0); + h -= (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); + } + return (w > 60 && h > 60) ? { w: w, h: h } : null; +} + +function wsMetrics() { + const el = $('#ws-term'); + if (!el || !el.appendChild) return null; + const probe = document.createElement('div'); + probe.className = 'tl ws-probe'; + probe.textContent = 'M'.repeat(100); + probe.style.position = 'absolute'; + probe.style.left = '-99999px'; + probe.style.top = '0'; + try { + el.appendChild(probe); + if (typeof probe.getBoundingClientRect !== 'function') return null; + const r = probe.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return null; + return { cw: r.width / 100, lh: r.height }; + } catch (e) { + return null; + } finally { + if (probe.remove) probe.remove(); + } +} + +function wsMeasure() { + const box = wsBox(), m = wsMetrics(); + if (!box || !m) return { cols: WS.cols || 100, rows: WS.rows || 26 }; + return { + cols: Math.max(20, Math.min(300, Math.floor(box.w / m.cw))), + rows: Math.max(6, Math.min(120, Math.floor(box.h / m.lh))), + }; +} + +function wsResizeGrid(cols, rows) { + const old = WS.grid, oc = WS.cols, or = WS.rows; + const ng = []; + for (let y = 0; y < rows; y++) { + const src = (old && y < or) ? old[y] : null; + const row = new Array(cols); + for (let x = 0; x < cols; x++) row[x] = (src && x < oc) ? src[x] : { c: ' ', k: '' }; + ng.push(row); + } + WS.grid = ng; WS.cols = cols; WS.rows = rows; + WS.cx = Math.max(0, Math.min(cols - 1, WS.cx)); + WS.cy = Math.max(0, Math.min(rows - 1, WS.cy)); + WS.dirty = new Set(); + WS.lineEls = []; + for (let y = 0; y < rows; y++) WS.dirty.add(y); +} + +let WS_RS_T = null; +function wsOnResize() { + if (!WS.sid) return; + clearTimeout(WS_RS_T); + WS_RS_T = setTimeout(() => { + const m = wsMeasure(); + if (m.cols === WS.cols && m.rows === WS.rows) return; + wsResizeGrid(m.cols, m.rows); + wsRenderScreen(); + const sz = $('#ws-size'); if (sz) sz.textContent = m.cols + '×' + m.rows; + api('/api/webshell/resize', { method: 'POST', body: { sid: WS.sid, cols: m.cols, rows: m.rows } }); + }, 250); +} + +/* ---------------------------- 会话读写 ---------------------------- */ +function wsStop() { + if (WS_PUMP) { clearTimeout(WS_PUMP); WS_PUMP = null; } + if (WS_RAF != null) { + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(WS_RAF); + else clearTimeout(WS_RAF); + WS_RAF = null; + } +} + +function wsSchedule(ms) { + if (!WS.sid) return; + if (WS_PUMP) clearTimeout(WS_PUMP); + WS_PUMP = setTimeout(wsPump, ms); +} + +function wsPump() { + WS_PUMP = null; + if (!WS.sid) return; + if (!$('#ws-screen')) return; // 已经离开终端页,别再空转 + if (WS_BUSY) { wsSchedule(80); return; } + WS_BUSY = true; + // wait 让服务端在没有输出时挂起最多 1.2 秒,输出一到立刻返回 —— 既省掉 + // 每秒十几次空轮询,又让命令的回显几乎零延迟出现 + api('/api/webshell/read', { method: 'POST', body: { sid: WS.sid, wait: 1.2 } }) + .then(r => { + if (!r.ok) { + if (r.code === 'NOSESS') wsLost('会话已失效,请重新连接'); + else wsLost('读取失败:' + (r.msg_cn || '未知错误')); + return; + } + const d = r.data || {}; + if (d.data) { wsFeed(d.data); wsRequestRender(); } + if (d.exit != null && d.exit >= 0) { + wsNote('\r\n[shell 已结束,退出码 ' + d.exit + ']'); + wsStop(); + return; + } + // 长轮询:有输出说明可能还有后续,立刻再取一次;空手而归说明 shell 空闲。 + // 留 120ms 下限是给「守护还是旧版、不支持 wait」的情况兜底 —— + // 否则会变成 0 延迟空转,把管理服务打满。 + wsSchedule(d.data ? 0 : 120); + }) + .catch(() => wsSchedule(500)) + .then(() => { WS_BUSY = false; }); +} + +function wsLost(msg) { + wsStop(); + WS.sid = null; + const st = $('#ws-status'); + if (st) st.innerHTML = '已断开'; + const c = $('#ws-connect'), d = $('#ws-disconnect'); + if (c) c.disabled = false; + if (d) d.disabled = true; + wsNote('\r\n[连接中断] ' + msg); + toast(msg, 'err', 5000); +} + +function wsWrite(data) { + if (!WS.sid || !data) return; + if (data.length <= WS_WRITE_CHUNK) { wsPostWrite(data); return; } + // 粘贴长文本时分片:一口气几万字节会让服务端写入阻塞,也会让 shell 逐行执行 + let i = 0, n = 0; + for (; i < data.length; i += WS_WRITE_CHUNK) { + const part = data.slice(i, i + WS_WRITE_CHUNK); + setTimeout(part2 => wsPostWrite(part2), n * 25, part); + n++; + } +} + +function wsPostWrite(data) { + api('/api/webshell/write', { method: 'POST', body: { sid: WS.sid, data: data } }) + .then(r => { + if (!r.ok && r.code === 'NOSESS') { wsLost('会话已失效,请重新连接'); return; } + wsSchedule(15); // 刚输入完,马上去取回显,手感才跟手 + }) + .catch(() => { /* 轮询会兜住,不弹错误打扰输入 */ }); +} + +async function wsConnect() { + const user = ($('#ws-user') || {}).value || 'root'; + const btn = $('#ws-connect'); + if (btn) btn.disabled = true; + const st = $('#ws-status'); + if (st) st.innerHTML = '正在连接…'; + const m = wsMeasure(); + wsGridInit(m.cols, m.rows); + wsRenderScreen(); + const r = await api('/api/webshell/connect', { + method: 'POST', body: { user: user, cols: m.cols, rows: m.rows }, + }); + if (btn) btn.disabled = false; + if (!r.ok) { + if (st) st.innerHTML = '连接失败'; + wsNote('连接失败:' + (r.msg_cn || '未知错误')); + if (r.code === 'NO_SHELLD') wsNote('请确认终端守护在运行:systemctl start drouter-shelld'); + toast(r.msg_cn || '连接失败', 'err', 5000); + return; + } + WS.sid = (r.data || {}).sid; + const tt = $('#ws-title'); if (tt) tt.textContent = user + '@debian-primaryrouter'; + const sz = $('#ws-size'); if (sz) sz.textContent = m.cols + '×' + m.rows; + if (st) st.innerHTML = '已连接'; + const d = $('#ws-disconnect'); if (d) d.disabled = false; + wsSchedule(30); + const ta = $('#ws-key'); if (ta && ta.focus) ta.focus(); + wsAutoScroll(true); +} + +async function wsDisconnect(silent) { + const sid = WS.sid; + wsStop(); + WS.sid = null; + const st = $('#ws-status'); + if (st) st.innerHTML = '已断开'; + const c = $('#ws-connect'), d = $('#ws-disconnect'); + if (c) c.disabled = false; + if (d) d.disabled = true; + const tt = $('#ws-title'); if (tt) tt.textContent = '未连接'; + if (sid) { + // 断开请求不 await 结果:就算服务端已经回收了会话,本地状态也必须立刻清干净 + api('/api/webshell/disconnect', { method: 'POST', body: { sid: sid } }); + if (!silent) wsNote('\r\n—— 已断开 ——'); + } +} + +/* ---------------------------- 键盘输入 ---------------------------- */ +const WS_MAP = { + Enter: '\r', Backspace: '\x7f', Tab: '\t', Escape: '\x1b', + ArrowUp: '\x1b[A', ArrowDown: '\x1b[B', ArrowRight: '\x1b[C', ArrowLeft: '\x1b[D', + Home: '\x1b[H', End: '\x1b[F', Delete: '\x1b[3~', Insert: '\x1b[2~', + PageUp: '\x1b[5~', PageDown: '\x1b[6~', + F1: '\x1bOP', F2: '\x1bOQ', F3: '\x1bOR', F4: '\x1bOS', +}; + +function wsSelection() { + try { + if (typeof window !== 'undefined' && typeof window.getSelection === 'function') { + return String(window.getSelection() || ''); + } + } catch (e) { /* 某些环境取不到,按没有选中处理 */ } + return ''; +} + +function wsKeyDown(e) { + if (!WS.sid) { e.preventDefault(); return; } + if (e.isComposing) return; // 输入法组词中,交给 compositionend + if (e.ctrlKey && !e.altKey && !e.metaKey) { + const k = String(e.key || '').toLowerCase(); + if (k === 'v') return; // 粘贴交给浏览器(随后 input 事件会拿到文本) + if (k === 'c' && wsSelection()) return; // 有选中内容时 Ctrl+C 是复制,不是 SIGINT + if (/^[a-z]$/.test(k)) { + e.preventDefault(); + wsWrite(String.fromCharCode(k.charCodeAt(0) - 96)); + return; + } + } + if (e.altKey && !e.ctrlKey && !e.metaKey && String(e.key || '').length === 1) { + e.preventDefault(); wsWrite('\x1b' + e.key); return; + } + const m = WS_MAP[e.key]; + if (m) { e.preventDefault(); wsWrite(m); return; } +} + +/* ---------------------------- 页面装配 ---------------------------- */ +async function viewWebShell() { + const v = $('#view'); + v.innerHTML = ` +
    +

    Web 终端(本机 Shell)

    +

    在浏览器里直接打开本机 shell。这是实时交互终端:每敲一个键立刻发到服务端, + 回显由内核产生,所以 top、vi、 + passwd 这类需要交互的程序都能正常用,Ctrl+C 中断、 + 方向键翻历史也都有效。

    +
    + + + + + + +
    +

    + 提示:这是本机终端,不是 SSH 跳板;root shell 权限等同物理控制台, + 开启与输入都会写审计日志,闲置 30 分钟自动回收。 + 有选中文字时 Ctrl+C 仍是「复制」,否则是「中断当前命令」。 +

    +
    + +
    +

    终端输出

    +
    +
    + + 未连接 + — +
    +
    +
    +
    +
    + +
    +

    点击终端区域即可开始输入;窗口大小变化时会自动同步给服务端。

    +
    + +
    +

    文件管理器

    +

    支持上传(SFTP/SCP)、下载、文件夹打包为 zip、重命名、删除、复制路径。删除会移入回收站。

    +
    + + + +
    /root
    + + +
    +
    把文件拖到这里上传(或点击「上传文件」)
    +

    正在载入…

    +
    +
    + + `; + + WS.lineEls = []; + const m = wsMeasure(); + wsGridInit(m.cols, m.rows); + const sz = $('#ws-size'); if (sz) sz.textContent = m.cols + '×' + m.rows; + if (WS.sid) { + // 从别的页面切回来:会话还在,直接把已有屏幕画出来继续轮询 + wsRenderScreen(); + wsAutoScroll(true); + wsSchedule(30); + const st = $('#ws-status'); + if (st) st.innerHTML = '已连接'; + const d = $('#ws-disconnect'); if (d) d.disabled = false; + const tt = $('#ws-title'); if (tt) tt.textContent = '已连接终端'; + } else { + wsNote('未连接 —— 点击上方「连接」开始。'); + wsNote('这是本机 shell,输入以 root / ajeef / drouter 身份执行。'); + } + wsInit(); + fmInit('/root'); + bindCopyButtons(v); +} + +function bindCopyButtons(root) { + (root || document).querySelectorAll('.copy-btn').forEach(b => { + b.onclick = () => { + const el = document.querySelector(b.dataset.copy); + if (!el) return; + const txt = el.textContent || ''; + navigator.clipboard.writeText(txt).then( + () => toast('已复制到剪贴板', 'ok'), + () => { // 降级 + const ta = document.createElement('textarea'); + ta.value = txt; document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); toast('已复制', 'ok'); } catch (e) { toast('复制失败', 'err'); } + ta.remove(); + }); + }; + }); +} + +/* ---------- Web 终端:初始化 ---------- */ +function wsInit() { + const c = $('#ws-connect'), d = $('#ws-disconnect'); + if (c) c.onclick = wsConnect; + if (d) d.onclick = () => wsDisconnect(false); + if ($('#ws-clear')) $('#ws-clear').onclick = wsClearScreen; + if ($('#ws-copy')) $('#ws-copy').onclick = wsCopyAll; + wsBindInput(); +} + +function wsBindInput() { + const ta = $('#ws-key'), term = $('#ws-term'); + if (!ta) return; + if (term && term.addEventListener) { + // 点终端区域就把焦点给隐藏输入框。 + // 用 mouseup 而不是 mousedown:mousedown 时还没有选区,抢焦点会把用户 + // 正在拖的选区清掉,表现为「终端里的文字选不中」。mouseup 时若已有选区 + // 说明用户在复制,就别抢。 + term.addEventListener('mouseup', () => { + if (wsSelection()) return; + if (ta.focus) ta.focus(); + }); + } + if (ta.addEventListener) { + ta.addEventListener('keydown', wsKeyDown); + // 普通字符 / 粘贴 / 输入法提交都走这里:把输入框内容取走发往服务端再清空 + ta.addEventListener('input', e => { + if (e && e.isComposing) return; + const v = ta.value; ta.value = ''; + if (v) wsWrite(v); + }); + ta.addEventListener('compositionend', () => { + const v = ta.value; ta.value = ''; + if (v) wsWrite(v); + }); + } +} + +function wsScreenText() { + const lines = []; + if (WS.grid) for (let y = 0; y < WS.rows; y++) { + const row = WS.grid[y]; + let s = ''; + for (let x = 0; x < WS.cols; x++) s += row[x].c; + lines.push(s.replace(/\s+$/, '')); + } + while (lines.length && !lines[lines.length - 1]) lines.pop(); + return lines.join('\n'); +} + +function wsCopyAll() { + const txt = wsScreenText(); + if (!txt) { toast('屏幕是空的', 'warn'); return; } + const ta = document.createElement('textarea'); + ta.value = txt; + if (document.body && document.body.appendChild) document.body.appendChild(ta); + if (ta.select) ta.select(); + let done = false; + try { done = document.execCommand ? document.execCommand('copy') : false; } catch (e) { done = false; } + if (ta.remove) ta.remove(); + toast(done ? '已复制可见内容' : '复制失败,请手动选中', done ? 'ok' : 'err'); +} + +// 关页面 / 刷新时主动断开,别把 root shell 留在服务端等 30 分钟超时 +if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('resize', wsOnResize); + window.addEventListener('beforeunload', () => { if (WS.sid) wsDisconnect(true); }); +} + +/* ============================ 通用 API 接口 ============================ */ +async function viewApi() { + const v = $('#view'); + v.innerHTML = ` +
    +

    对外通用 API 接口

    +

    Drouter 提供统一的 JSON REST 接口,跨平台、跨语言、跨框架: + 任意支持 HTTP 的环境(curl、Python、Node、Go、PHP、Java、.NET、Shell、 + Postman、工单系统、IoT 网关、HomeAssistant…)都能直接接入,无需任何 SDK。

    +
    + + 查看接口描述(JSON) + +
    +
    响应格式{"ok": true, "msg_cn": "…", "data": …}
    +
    鉴权方式POST /api/login 取 token → 之后每个请求带 X-Token 头
    +
    跨域支持服务端已放开跨域,浏览器端也可直接调用
    +
    +

    点击「载入接口清单与范例」查看。

    `; + $('#api-load').onclick = loadApi; + loadApi(); +} + +async function loadApi() { + const box = $('#api-body'); + box.innerHTML = '

    载入中…

    '; + const [ex, desc] = await Promise.all([api('/api/examples'), api('/api/openapi')]); + if (!ex.ok) { box.innerHTML = `
    ${esc(ex.msg_cn || '载入失败')}
    `; return; } + const d = ex.data || {}; + if ($('#api-base')) $('#api-base').innerHTML = `服务地址:${esc(d.base || '')}`; + const paths = (desc.ok && desc.data && desc.data.paths) || {}; + const groups = {}; + Object.entries(paths).forEach(([path, methods]) => { + Object.entries(methods).forEach(([m, node]) => { + const tag = (node.tags || ['通用'])[0]; + (groups[tag] = groups[tag] || []).push({ path, m, sum: node.summary || '' }); + }); + }); + const epHtml = Object.entries(groups).map(([tag, list]) => ` +
    +

    ${esc(tag)} ${list.length}

    + ${list.map(x => `
    + ${x.m.toUpperCase()} + ${esc(x.path)} + ${esc(x.sum)} +
    `).join('')} +
    `).join(''); + + const exHtml = (d.examples || []).map((e, i) => ` +
    +

    ${esc(e.title)} +

    +
    +
    ${esc(e.code)}
    +
    +
    `).join(''); + + box.innerHTML = `
    ${esc(d.note || '')}
    ` + + epHtml + exHtml; + bindCopyButtons(box); +} + +/* ---------- 文件管理器 ---------- */ +let FM_PATH = '/root'; +let FM_SEL = new Set(); + +async function fmInit(path) { + FM_PATH = path || FM_PATH; + const up = $('#fm-up'), ref = $('#fm-ref'), mk = $('#fm-mkdir'); + if (!up) return; + up.onclick = () => fmList(FM_PARENT || '/'); + ref.onclick = () => fmList(FM_PATH); + mk.onclick = fmMkdir; + const fileInput = $('#fm-file'); + if (fileInput) fileInput.onchange = () => fmUpload(fileInput.files); + const drop = $('#fm-drop'); + if (drop) { + drop.onclick = () => fileInput && fileInput.click(); + ['dragover', 'dragenter'].forEach(ev => drop.addEventListener(ev, e => { + e.preventDefault(); drop.classList.add('over'); + })); + ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { + e.preventDefault(); drop.classList.remove('over'); + })); + drop.addEventListener('drop', e => { + if (e.dataTransfer && e.dataTransfer.files.length) fmUpload(e.dataTransfer.files); + }); + } + fmList(FM_PATH); +} + +let FM_PARENT = '/'; + +async function fmList(path) { + const box = $('#fm-list'); if (!box) return; + box.innerHTML = '

    正在载入…

    '; + const r = await api('/api/files/list?path=' + encodeURIComponent(path)); + if (!r.ok) { box.innerHTML = `
    ${esc(r.msg_cn || '无法读取目录')}
    `; return; } + const d = r.data || {}; + FM_PATH = d.path || path; + FM_PARENT = d.parent || '/'; + FM_SEL = new Set(); + if ($('#fm-path')) $('#fm-path').textContent = FM_PATH; + const items = d.items || []; + const rows = items.map(it => ` + + + + ${it.dir ? '📁' : (it.link ? '🔗' : '📄')} + ${esc(it.name)} + + ${it.dir ? '—' : fmtBytes(it.size)} + ${fmtTime(it.mtime)} + ${esc(it.mode || '')} + + ${!it.dir ? `` : ``} + + + + + `).join(''); + const crumbs = (d.crumbs || []).map((c, i, a) => + `${esc(c.name)}${i < a.length - 1 ? '/' : ''}`).join(''); + box.innerHTML = ` +
    ${crumbs}
    +
    + + + +
    +
    + + ${rows || ''} +
    名称大小修改时间权限操作
    目录为空
    `; + + box.querySelectorAll('.fm-open').forEach(el => el.onclick = () => { + if (el.dataset.dir === '1') fmList(el.dataset.path); + else fmPreview(el.dataset.path); + }); + box.querySelectorAll('.fm-dl').forEach(b => b.onclick = () => fmDownload(b.dataset.path)); + box.querySelectorAll('.fm-zip').forEach(b => b.onclick = () => fmZip([b.dataset.path])); + box.querySelectorAll('.fm-ren').forEach(b => b.onclick = () => fmRename(b.dataset.path, b.dataset.name)); + box.querySelectorAll('.fm-cp').forEach(b => b.onclick = () => { + navigator.clipboard.writeText(b.dataset.path).then( + () => toast('路径已复制:' + b.dataset.path, 'ok'), + () => toast('复制失败,请手动复制', 'err')); + }); + box.querySelectorAll('.fm-del').forEach(b => b.onclick = () => fmDelete([b.dataset.path])); + box.querySelectorAll('.fm-crumb').forEach(c => c.onclick = () => fmList(c.dataset.path)); + const cbs = box.querySelectorAll('.fm-cb'); + const sync = () => { + FM_SEL = new Set(); + cbs.forEach(cb => { if (cb.checked) FM_SEL.add(cb.dataset.path); }); + $('#fm-zipsel').disabled = FM_SEL.size === 0; + $('#fm-delsel').disabled = FM_SEL.size === 0; + $('#fm-selinfo').textContent = FM_SEL.size ? `已选 ${FM_SEL.size} 项` : ''; + }; + cbs.forEach(cb => cb.onchange = sync); + $('#fm-zipsel').onclick = () => fmZip(Array.from(FM_SEL)); + $('#fm-delsel').onclick = () => fmDelete(Array.from(FM_SEL)); +} + +function fmtTime(ts) { + if (!ts) return '—'; + const d = new Date(ts * 1000); + const p = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +async function fmDownload(path) { + toast('正在准备下载…', 'info'); + try { + const res = await fetch('/api/files/download?path=' + encodeURIComponent(path), + { headers: S.token ? { 'X-Token': S.token } : {} }); + if (!res.ok) { toast('下载失败', 'err'); return; } + const blob = await res.blob(); + const name = path.split('/').pop() || 'download'; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name; a.click(); + URL.revokeObjectURL(a.href); + } catch (e) { toast('下载失败:' + e.message, 'err'); } +} + +async function fmZip(paths) { + const msg = $('#fm-msg'); + if (msg) msg.textContent = '正在打包…'; + const r = await api('/api/files/zip', { method: 'POST', body: { paths } }); + if (msg) msg.textContent = ''; + if (!r.ok) { toast(r.msg_cn || '打包失败', 'err', 5000); return; } + toast(`${r.msg_cn}(${r.data.size_h})`, 'ok'); + // 打包完直接下载 + const f = r.data.file; + fmDownload(f); +} + +async function fmDelete(paths) { + modal('确认删除', `

    将把以下 ${paths.length} 项移入回收站(可在服务器 /var/lib/drouter/trash 找回):

    +
    ${paths.map(esc).join('\n')}
    `, async () => { + const r = await api('/api/files/delete', { method: 'POST', body: { paths } }); + if (!r.ok) { toast(r.msg_cn || '删除失败', 'err', 5000); return false; } + toast(r.msg_cn || '已删除', 'ok'); + fmList(FM_PATH); + }, '移入回收站'); +} + +function fmRename(path, name) { + modal('重命名', ``, async () => { + const v = ($('#fm-new') || {}).value.trim(); + if (!v) { toast('名称不能为空', 'err'); return false; } + const r = await api('/api/files/rename', { method: 'POST', body: { path, name: v } }); + if (!r.ok) { toast(r.msg_cn || '重命名失败', 'err', 5000); return false; } + toast('已重命名', 'ok'); + fmList(FM_PATH); + }); +} + +function fmMkdir() { + modal('新建文件夹', ``, async () => { + const v = ($('#fm-newdir') || {}).value.trim(); + if (!v) { toast('名称不能为空', 'err'); return false; } + const r = await api('/api/files/mkdir', { method: 'POST', body: { parent: FM_PATH, name: v } }); + if (!r.ok) { toast(r.msg_cn || '创建失败', 'err', 5000); return false; } + toast('已创建', 'ok'); + fmList(FM_PATH); + }); +} + +async function fmPreview(path) { + const card = $('#fm-preview-card'); + const r = await api('/api/files/read?path=' + encodeURIComponent(path)); + if (!r.ok) { toast(r.msg_cn || '无法预览', 'warn', 5000); return; } + card.classList.remove('hidden'); + $('#fm-prev-name').textContent = `(${r.data.name} · ${r.data.size_h}${r.data.truncated ? ' · 已截断' : ''})`; + $('#fm-prev').textContent = r.data.text || ''; + card.scrollIntoView({ behavior: 'smooth' }); +} + +function fmUpload(files) { + if (!files || !files.length) return; + const prog = $('#fm-progress'); + prog.innerHTML = ''; + Array.from(files).forEach(f => { + const row = document.createElement('div'); + row.className = 'up-item'; + row.innerHTML = `
    ${esc(f.name)} ${fmtBytes(f.size)}
    +
    等待…
    `; + prog.appendChild(row); + const barEl = row.querySelector('i'), st = row.querySelector('.up-st'); + // 小文件直接走 base64 API;大文件用分片写入 + const reader = new FileReader(); + reader.onload = async () => { + const b64 = String(reader.result).split(',')[1] || ''; + st.textContent = '上传中…'; + barEl.style.width = '40%'; + const r = await api('/api/files/write', { + method: 'POST', + body: { path: FM_PATH, name: f.name, b64, size: f.size }, + }); + if (r.ok) { barEl.style.width = '100%'; st.innerHTML = '完成'; } + else { barEl.style.width = '100%'; barEl.style.background = 'var(--err)'; st.textContent = '失败:' + (r.msg_cn || ''); } + if (files[files.length - 1] === f) fmList(FM_PATH); + }; + reader.onerror = () => { st.textContent = '读取本地文件失败'; }; + reader.readAsDataURL(f); + }); +} + +/* ============================ 日志 ============================ */ +function viewLog() { + const mods = ['all', 'system', 'config', 'network', 'dhcp', 'dns', 'firewall', 'ipv6', 'pppoe', 'web', 'user', 'power', 'service', 'pkg', 'helper']; + $('#view').innerHTML = ` +
    +

    结构化日志(AI 可读)

    +

    JSONL 格式,字段:ts / level / module / code / msg_cn / detail。 + 中文提示便于人工排查,原始信息保留便于 AI 分析。

    +
    + + + + + +
    +

    点击「刷新日志」载入。

    +
    +
    +

    Web 操作审计

    +

    记录谁在什么时间改了什么配置。

    +

    载入中…

    +
    `; + $('#lg-load').onclick = loadLogs; + $('#lg-mod').onchange = loadLogs; + $('#lg-export').onclick = async () => { + const r = await api(`/api/logs?module=${$('#lg-mod').value}&limit=5000`); + const blob = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'drouter-log-' + Date.now() + '.json'; + a.click(); + }; + $('#lg-journal').onclick = async () => { + $('#lg-out').innerHTML = '
    读取中…
    '; + const r = await api('/api/journal?limit=200'); + $('#lg-out').innerHTML = `
    ${esc(((r.data || {}).lines || []).join('\n') || '(无)')}
    `; + }; + loadLogs(); + api('/api/audit').then(r => { + const rows = r.data || []; + $('#au-out').innerHTML = rows.length ? ` + + ${rows.map(x => ` + `).join('')} +
    时间用户操作详情结果
    ${esc(x.ts)}${esc(x.user)}${esc(x.action)}${esc(x.detail)}${x.ok ? '成功' : '失败'}
    ` : '

    暂无审计记录。

    '; + }); +} + +async function loadLogs() { + const out = $('#lg-out'); + out.innerHTML = '
    读取中…
    '; + const r = await api(`/api/logs?module=${$('#lg-mod').value}&limit=${$('#lg-n').value}`); + const rows = (r.data || []).reverse(); + if (!rows.length) { out.innerHTML = '

    暂无日志记录。

    '; return; } + out.innerHTML = `
    + ${rows.map(x => `
    + ${esc(x.level)} + ${esc(x.ts)} + ${esc(x.module)} + ${esc(x.msg_cn)}${x.detail ? ' · ' + esc(String(x.detail).slice(0, 200)) + '' : ''} +
    `).join('')}
    `; +} + +/* ============================ 电源 ============================ */ +function viewPower() { + $('#view').innerHTML = ` +
    +

    安全操作

    +

    正常重启与关机,会先停止服务再断电,对文件系统安全。

    +
    + + +
    +
    +
    +

    强制重启(高风险)

    +

    直接触发内核 SysRq 立即重启,不等待服务停止,可能导致数据丢失。 + 仅在系统完全无响应时使用。

    +
    +
    +
    +

    定时重启(可选)

    +

    按需配置,例如每天凌晨自动重启以释放内存。

    +
    + + +
    +

    为避免误操作,定时重启需在服务器上用 systemd timer 配置,此处仅提供模板。

    +
    `; + $('#pw-reboot').onclick = () => confirmPower('reboot', '确认重启', '安全重启'); + $('#pw-off').onclick = () => confirmPower('poweroff', '确认关机', '安全关机'); + $('#pw-force').onclick = () => confirmPower('force_reboot', '确认强制重启', '强制重启'); + $('#pw-cron').onclick = () => modal('定时重启模板', `

    在服务器上执行以下命令可配置每日 ${$('#pw-time').value} 重启:

    +
    [Unit]
    +Description=Daily reboot
    +
    +[Timer]
    +OnCalendar=*-*-* ${$('#pw-time').value}:00
    +Persistent=true
    +
    +[Install]
    +WantedBy=timers.target
    +

    保存为 /etc/systemd/system/drouter-reboot.timer 后执行 systemctl enable --now drouter-reboot.timer

    `, null, '知道了'); +} + +function confirmPower(op, word, label) { + modal(label, `

    确认要执行「${label}」吗?

    请输入 ${word} 以继续:

    + `, async () => { + if ($('#cfm-in').value.trim() !== word) { toast('确认文字不正确', 'err'); return false; } + const r = await api('/api/power', { method: 'POST', body: { op, confirm_text: word } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + }); +} + +/* ============================ 用户与密钥 ============================ */ +async function viewUser() { + const r = await api('/api/users'); + const users = r.data || []; + $('#view').innerHTML = ` +
    +

    当前登录会话

    +

    左下角的「退出登录」按钮已移除(避免与其它入口重复),登出操作统一放在这里。

    +
    + 登录账号${esc(S.user || 'admin')} + +
    +
    +
    +

    系统用户(用于 SSH 登录)

    +

    这些是 Debian 系统的真实用户,与 Web 管理台账号相互独立。 + root 已禁止远程登录。如遇失联,可通过虚拟机平台的控制台,或物理机的 KVM/IPMI、本地显示器、VNC 等方式登录救回。

    + + ${users.map(u => ` + + + + `).join('')}
    用户名UID家目录状态公钥数操作
    ${esc(u.name)}${u.uid}${esc(u.home)}${u.locked ? '已锁定' : '正常'}${u.keys.length} + + + +
    +
    +
    +
    +

    SSH 公钥登录

    +

    请上传公钥(.pub),不要上传私钥。私钥应保存在你的本地电脑上,绝不发送到服务器。 + 支持粘贴文本或选择 .pub 文件。

    +
    + + +
    + +
    + +
    +
    +
    +
    +

    已授权公钥

    +
    ${users.filter(u => u.keys.length).map(u => ` +
    ${esc(u.name)} + ${u.keys.map(k => ` + `).join('')} +
    ${esc(k.key)}
    `).join('') || '

    尚未配置任何公钥。

    '}
    +
    +
    +

    SSH 服务端配置(只读)

    +
    root 远程登录${'已禁止(PermitRootLogin no)'}
    +
    监听端口22
    +
    公钥认证已启用
    +
    `; + + $('#us-add').onclick = () => modal('新建系统用户', ` + + + `, async () => { const r2 = await api('/api/user', { method: 'POST', body: { op: 'add', name: $('#cfm-in').value.trim(), password: $('#cfm-pw').value, sudo: $('#cfm-sudo').checked } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + if (r2.ok) setTimeout(viewUser, 400); + }, '创建'); + const loBtn = $('#us-logout'); + if (loBtn) loBtn.onclick = () => logout(false); + $$('[data-pw]').forEach(b => b.onclick = () => modal(`修改 ${b.dataset.pw} 的密码`, + ``, async () => { + const r2 = await api('/api/user', { method: 'POST', body: { op: 'passwd', name: b.dataset.pw, password: $('#cfm-pw').value } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + }, '修改')); + $$('[data-lk]').forEach(b => b.onclick = async () => { + const r2 = await api('/api/user', { method: 'POST', body: { op: b.dataset.v === '1' ? 'lock' : 'unlock', name: b.dataset.lk } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); if (r2.ok) setTimeout(viewUser, 400); + }); + $$('[data-del]').forEach(b => b.onclick = () => { + if (b.dataset.del === 'ajeef' || b.dataset.del === 'root') { toast('受保护用户不可删除', 'err'); return; } + modal('删除用户', `

    确认删除用户 ${esc(b.dataset.del)} 及其家目录?此操作不可恢复。

    `, + async () => { + const r2 = await api('/api/user', { method: 'POST', body: { op: 'del', name: b.dataset.del } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); if (r2.ok) setTimeout(viewUser, 400); + }); + }); + $('#key-file').onchange = e => { + const f = e.target.files[0]; if (!f) return; + if (/private|id_rsa$|id_ed25519$/i.test(f.name) && !f.name.endsWith('.pub')) { + toast('这看起来像私钥文件,请上传 .pub 公钥', 'err', 6000); e.target.value = ''; return; + } + const rd = new FileReader(); + rd.onload = () => { $('#key-text').value = String(rd.result).trim(); toast('已读取公钥文件内容', 'ok'); }; + rd.readAsText(f); + }; + $('#key-add').onclick = async () => { + const txt = $('#key-text').value.trim(); + if (!txt) { toast('请先粘贴或选择公钥内容', 'err'); return; } + if (/BEGIN .*PRIVATE KEY/.test(txt)) { + toast('检测到私钥内容!请勿上传私钥,只上传 .pub 公钥文件。', 'err', 9000); return; + } + const r2 = await api('/api/user', { method: 'POST', body: { op: 'key_add', name: $('#key-user').value, keys: txt.split('\n').filter(Boolean) } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err', 7000); + if (r2.ok) { $('#key-text').value = ''; setTimeout(viewUser, 500); } + }; + $$('[data-delkey]').forEach(b => b.onclick = async () => { + const r2 = await api('/api/user', { method: 'POST', body: { op: 'key_del', name: b.dataset.delkey, key: b.dataset.key } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); if (r2.ok) setTimeout(viewUser, 400); + }); +} + +/* ============================ 系统设置 ============================ */ +function viewSys() { + const sysCfg = $w('system'); + $('#view').innerHTML = ` +
    +

    构建保护模式:${S.buildMode ? '已开启' : '已关闭'}

    +

    + ${S.buildMode + ? '开启时,所有「应用」操作只会把配置写入磁盘,不会启动服务、不会改变网络。这是构建阶段的安全保险,可放心填完所有配置而不影响正在运行的局域网与 RealVNC 会话。' + : '已关闭。此后「保存并应用」会真正生效:DHCP 服务会启动并占用 53/67 端口,防火墙规则会立即加载。请确保这是你想要的。'} +

    +
    + ${S.buildMode + ? `` + : ``} +
    +
    +
    +

    Web 管理账号

    +

    此账号仅用于登录本管理台,与 Debian 系统用户完全独立。当前用户:${esc(S.user)}

    +
    + + + +
    +
    +
    +
    +
    +

    Web 管理端口

    +

    修改管理后台的 HTTPS 端口,避免与其他服务冲突。默认 8443。 + 修改后需要重启管理后台,然后用新地址重新访问(例如 https://192.168.7.3:新端口/)。

    +
    + + + +
    +
    +

    明文 HTTP 端口固定为 8080(仅建议本机调试使用)。 + 当前生效端口:${S.webPort || 8443}

    +
    +
    +

    访问控制

    +
    管理端口HTTPS ${S.webPort || 8443}(自签证书) / HTTP 8080
    + +
    + +
    +

    访问范围仅影响管理后台的防火墙放行策略;改完请点击右上角「保存并应用」。

    +
    会话超时60 分钟
    +
    登录失败锁定连续 5 次失败后锁定 10 分钟
    +
    +
    +

    配置快照与回滚

    +

    快照会整体覆盖全部用户设置:界面里的所有模块配置(DHCP/DNS、防火墙 IPv4·IPv6、WAN/LAN、IPv6 与 RA、UPnP、NTP、网卡角色、访问范围与 Web 端口等)连同底层配置文件一起打包。回滚时数据库与配置文件一并还原,不会出现「只恢复了一半」的情况。

    +
    +
    + + + +
    +
    +
    +
    +

    自动快照

    +

    按设定的时间间隔自动为当前配置拍一份快照,并在超过保留期后自动清理旧快照。默认开启,可随时关闭。

    +
    + + +
    +
    + + + +
    + +

    路径仅允许位于 /opt · /srv · /var/backups · /mnt · /media · /home 之下,避免误写系统关键目录。

    +
    + + +
    +
    + + + +
    +
    +
    下次自动快照—
    +
    定时器状态—
    +
    快照占用—
    +
    +
    +

    紧急救援通道

    +

    当配置失误把网络"打崩"时(网络风暴、IP 冲突、路由环路、防火墙锁死、Web 管理页打不开等), + 常规手段往往已经进不去路由器。紧急救援通道是一条完全独立于 WAN 口 / LAN 口的应急入口: + 它会在网卡上额外绑定一组虚拟地址,因此把网线插到任意一个网口都能访问, + 进入后可一键还原最近的快照并自动重启,让设备恢复到可用状态。

    +
    + + +
    +
    + + + +
    +
    + +
    + +
    + +
    +

    启用后访问方式:http://169.254.0.1:8888/(也支持 ?restore=1 参数直达还原页)。

    +
    + + +
    +
    +
    服务状态—
    +
    虚拟网卡—
    +
    +
    +
    +

    网络后端迁移(高级)

    +

    当前由 NetworkManager 管理网卡。如需切换为 systemd-networkd, + 可先做干跑预览查看将写入的配置文件。真正执行可能中断网络连接——万一失联,可以通过 + 虚拟机平台的控制台,或者物理机的 KVM/IPMI、本地显示器、VNC 等方式重新登录救回。 + 确认自己能救回来之后,再执行迁移。

    +
    + +
    +
    +
    +
    +

    配置文件位置

    + + + + + + + + +
    内容路径
    Web 配置数据库/opt/drouter/data/drouter.db
    配置快照/opt/drouter/snapshots/
    结构化日志/var/log/drouter/*.jsonl
    DHCP/DNS 配置/etc/dnsmasq.d/drouter.conf
    防火墙规则/etc/nftables.d/drouter-v4.nft · drouter-v6.nft
    PPPoE 配置/etc/ppp/peers/drouter-wan
    RA 配置/etc/radvd.conf
    +
    `; + $('#sy-pw').onclick = async () => { + const a = $('#sy-new').value, b = $('#sy-new2').value; + if (a !== b) { $('#sy-out').className = 'msg err'; $('#sy-out').textContent = '两次输入的新密码不一致'; return; } + const r = await api('/api/password', { method: 'POST', body: { old_password: $('#sy-old').value, new_password: a } }); + $('#sy-out').className = 'msg ' + (r.ok ? 'ok' : 'err'); + $('#sy-out').textContent = r.msg_cn; + if (r.ok) { $('#sy-old').value = $('#sy-new').value = $('#sy-new2').value = ''; } + }; + $('#sy-snap').onclick = async () => { + const r = await api('/api/snapshot', { method: 'POST', body: { tag: $('#sy-tag').value || 'manual' } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err'); + }; + $('#sy-list').onclick = listSnapshots; + loadAutoSnapshot(); + loadRescue(); + // Web 端口修改 + const portOut = $('#sy-port-out'); + $('#sy-port-save').onclick = async () => { + const port = Number($('#sy-port').value); + if (!(port >= 1 && port <= 65535)) { portOut.className = 'msg err'; portOut.textContent = '端口必须是 1-65535 的数字'; return; } + if (!confirm(`确定把管理后台端口改为 ${port} 吗?改完后需要用 https://<本机IP>:${port}/ 重新访问。`)) return; + portOut.className = 'msg'; portOut.textContent = '正在保存并重启后台…'; + const r = await api('/api/webport', { method: 'POST', body: { port } }); + portOut.className = 'msg ' + (r.ok ? 'ok' : 'err'); + portOut.textContent = r.msg_cn; + if (r.ok) { + S.webPort = port; + const cur = $('#sy-port-cur'); if (cur) cur.textContent = port; + toast('端口已改为 ' + port + ',请用新地址访问', 'ok', 8000); + } + }; + $('#sy-port-reset').onclick = () => { $('#sy-port').value = 8443; toast('已填入默认端口,请点击「保存并重启后台」', 'ok'); }; + // 访问范围 + const scope = $('#sy-scope'); + const bindScope = () => { + const v = scope.value; + S.cfg.system = Object.assign($w('system'), { access_scope: v }); + const box = $('#sy-scope-custom'); + if (box) box.classList.toggle('hidden', v !== 'custom'); + setActionMsg('访问范围已改为「' + scope.options[scope.selectedIndex].text + '」,请点击保存并应用'); + }; + if (scope) { scope.onchange = bindScope; } + const sn = $('#sy-scope-net'); + if (sn) sn.oninput = () => { + S.cfg.system = Object.assign($w('system'), { + access_nets: sn.value.split(',').map(x => x.trim()).filter(Boolean), + }); + setActionMsg('允许网段已修改,请点击保存并应用'); + }; + $('#sy-mig').onclick = async () => { + $('#sy-migout').innerHTML = '
    生成中…
    '; + const r = await api('/api/migrate/preview', { method: 'POST', body: { cfg: { lan: { iface: $w('system').lan_iface, address: $w('system').lan_address } } } }); + const files = (r.data || {}).files || []; + $('#sy-migout').innerHTML = files.length + ? files.map(f => `
    ${esc(f.name)}
    ${esc(f.content)}
    `).join('') + : '

    ' + esc(r.msg_cn) + '

    '; + }; +} + +/* ---------- 配置快照:列表 / 下载 / 回滚 / 删除 ---------- */ +async function listSnapshots() { + const r = await api('/api/snapshots'); + const d = r.data || {}; + const rows = (d.items || []) || []; + const root = d.root || ''; + // 覆盖范围说明 + const scopeBox = $('#sy-scope-box'); + if (scopeBox && (d.scope || []).length) { + scopeBox.innerHTML = `
    快照包含以下内容:
    ` + + d.scope.map(g => `
    ${esc(g.group)}
      ${ + (g.items || []).map(i => `
    • ${esc(i)}
    • `).join('')}
    `).join(''); + } + const disk = d.disk ? `磁盘:剩余 ${fmtBytes((d.disk.free_mb || 0) * 1024 * 1024)} / 共 ${fmtBytes((d.disk.total_mb || 0) * 1024 * 1024)}` : ''; + const html = rows.length ? ` +

    共 ${rows.length} 份快照,存放于 ${esc(root)}。${esc(disk)}

    + + + + ${rows.map(x => ` + + + + + `).join('')}
    时间备注大小配置库操作
    ${esc(x.ts)}${esc(x.tag || '')}${x.size_kb != null ? esc(x.size_kb) + ' KB' : '—'}${x.db_included ? '已含' : '旧版'} + + + +
    ` + : '

    暂无快照。

    '; + if (S.page === 'sys') { const b = $('#sy-snapout'); if (b) b.innerHTML = html; } + else modal('配置快照列表', html, null, '关闭'); + + $$('[data-dl]').forEach(b => b.onclick = async () => { + const ts = b.dataset.dl; + toast('正在打包快照 ' + ts + '…', 'ok', 2500); + try { + const res = await fetch('/api/snapshot/download?ts=' + encodeURIComponent(ts), + { headers: S.token ? { 'X-Token': S.token } : {} }); + if (!res.ok) { + let m = '下载失败'; + try { m = (await res.json()).msg_cn || m; } catch (e) { } + toast(m, 'err', 6000); return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'drouter-snapshot-' + ts + '.tar.gz'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 5000); + toast('已开始下载快照 ' + ts, 'ok'); + } catch (e) { toast('下载失败:' + e.message, 'err', 6000); } + }); + $$('[data-rb]').forEach(b => b.onclick = () => modal('确认回滚', + `

    将把配置整体恢复到快照 ${esc(b.dataset.rb)} 的状态。

    +

    回滚会同时还原界面配置数据库与系统配置文件,并重启相关服务。回滚前系统会自动再拍一张 before-rollback 快照,万一不满意还能退回来。

    +

    回滚过程中网络可能短暂中断。

    `, async () => { + const r2 = await api('/api/rollback', { method: 'POST', body: { ts: b.dataset.rb } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err', 8000); + if (r2.ok) listSnapshots(); + }, '确认回滚')); + $$('[data-del]').forEach(b => b.onclick = () => modal('删除快照', + `

    确定删除快照 ${esc(b.dataset.del)} 吗?此操作不可恢复。

    `, async () => { + const r2 = await api('/api/snapshot/delete', { method: 'POST', body: { ts: b.dataset.del } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + if (r2.ok) listSnapshots(); + }, '删除')); +} + +/* ---------- 自动快照策略 ---------- */ +async function loadAutoSnapshot() { + const r = await api('/api/snapshot/auto'); + if (!r.ok) return; + const d = r.data || {}, c = d.config || {}; + const setV = (id, v) => { const el = $(id); if (el) el.value = v; }; + setV('#as-interval', c.interval_hours != null ? c.interval_hours : 6); + setV('#as-days', c.keep_days != null ? c.keep_days : 7); + setV('#as-count', c.keep_count != null ? c.keep_count : 30); + setV('#as-path', c.path || '/opt/drouter/snapshots'); + const box = { + '#as-en': c.enabled !== false, + '#as-manual': c.keep_manual !== false, + '#as-apply': c.on_apply !== false, + }; + Object.keys(box).forEach(id => { const el = $(id); if (el) el.checked = box[id]; }); + const st = $('#as-state'); + if (st) { + st.className = 'hint-inline'; + st.innerHTML = c.enabled !== false + ? '已开启 自动快照正在按计划运行' + : '已关闭 不会再自动创建快照'; + } + const tm = $('#as-timer'); + if (tm) tm.innerHTML = d.timer_active ? '运行中' : '未运行'; + const nx = $('#as-next'); + if (nx) nx.textContent = d.next_run ? fmtUnix(d.next_run) : '—'; + const dk = $('#as-disk'); + if (dk && d.disk) dk.textContent = `剩余 ${fmtBytes((d.disk.free_mb || 0) * 1048576)} / 共 ${fmtBytes((d.disk.total_mb || 0) * 1048576)}`; + + $('#as-save').onclick = async () => { + const out = $('#as-out'); + const body = { + op: 'set', + enabled: $('#as-en').checked, + interval_hours: Number($('#as-interval').value) || 6, + keep_days: Number($('#as-days').value) || 0, + keep_count: Number($('#as-count').value) || 0, + keep_manual: $('#as-manual').checked, + on_apply: $('#as-apply').checked, + path: ($('#as-path').value || '').trim(), + }; + out.className = 'msg'; out.textContent = '正在保存…'; + const r2 = await api('/api/snapshot/auto', { method: 'POST', body }); + out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); + out.textContent = r2.msg_cn; + if (r2.ok) { toast(r2.msg_cn, 'ok'); loadAutoSnapshot(); } + }; + $('#as-run').onclick = async () => { + const out = $('#as-out'); out.className = 'msg'; out.textContent = '正在创建快照…'; + const r2 = await api('/api/snapshot/auto', { method: 'POST', body: { op: 'run_now' } }); + out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); out.textContent = r2.msg_cn; + if (r2.ok) listSnapshots(); + }; + $('#as-prune').onclick = async () => { + const out = $('#as-out'); out.className = 'msg'; out.textContent = '正在清理…'; + const r2 = await api('/api/snapshot/prune', { + method: 'POST', body: { + keep_days: Number($('#as-days').value) || 0, + keep_count: Number($('#as-count').value) || 0, + keep_manual: $('#as-manual').checked, + } + }); + out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); out.textContent = r2.msg_cn; + if (r2.ok) listSnapshots(); + }; +} + +/* ---------- 紧急救援通道 ---------- */ +async function loadRescue() { + const r = await api('/api/rescue'); + if (!r.ok) return; + const d = r.data || {}; + const setV = (id, v) => { const el = $(id); if (el) el.value = v; }; + const en = $('#rs-en'); + if (en) en.checked = !!d.enabled; + setV('#rs-vip', d.vip || '169.254.0.1'); + setV('#rs-mask', d.vip_mask || '16'); + setV('#rs-port', d.port || 8888); + setV('#rs-alt', d.alt_vip || '10.99.99.1'); + const rb = $('#rs-reboot'); if (rb) rb.checked = d.auto_reboot !== false; + // 网卡多选(留空 = 全部物理网卡) + const phys = d._phys_ifaces || []; + const chosen = d.ifaces || []; + const box = $('#rs-ifaces'); + if (box) { + box.innerHTML = phys.map(n => ``).join('') + || '未检测到物理网卡'; + } + const st = $('#rs-state'); + if (st) st.innerHTML = d.enabled + ? '已开启 插任一口皆可访问救援页' + : '已关闭(默认关闭,需要时再开)'; + const sv = $('#rs-svc'); + if (sv) sv.innerHTML = d._service_active ? '运行中' : '未运行'; + const vf = $('#rs-vif'); + if (vf) vf.innerHTML = d._vif_present ? '已创建' : '未创建'; + const ad = $('#rs-addrs'); + if (ad) ad.textContent = (d._addrs || []).length ? '当前地址:' + (d._addrs || []).join(' | ') : ''; + updRescueHint(); + + $('#rs-save').onclick = async () => { + const out = $('#rs-out'); + const wantOn = $('#rs-en').checked; + const body = { + op: 'set', + enabled: wantOn, + vip: $('#rs-vip').value.trim(), + vip_mask: $('#rs-mask').value, + port: Number($('#rs-port').value), + alt_vip: $('#rs-alt').value.trim(), + auto_reboot: $('#rs-reboot').checked, + ifaces: $$('#rs-ifaces input:checked').map(x => x.value), + confirm: true, + }; + out.className = 'msg'; out.textContent = '正在保存并应用…'; + const r2 = await api('/api/rescue', { method: 'POST', body }); + out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); + out.textContent = r2.msg_cn; + if (r2.ok) { toast(r2.msg_cn, 'ok', 6000); loadRescue(); } + }; + $('#rs-test').onclick = async () => { + const out = $('#rs-out'); out.className = 'msg'; out.textContent = '正在自检…'; + const r2 = await api('/api/rescue', { method: 'POST', body: { op: 'test' } }); + out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); out.textContent = r2.msg_cn; + if (r2.ok) loadRescue(); + }; + const vipEl = $('#rs-vip'), portEl = $('#rs-port'); + if (vipEl) vipEl.oninput = updRescueHint; + if (portEl) portEl.oninput = updRescueHint; +} + +function updRescueHint() { + const el = $('#rs-hint'); if (!el) return; + const vip = ($('#rs-vip') || {}).value || '169.254.0.1'; + const port = ($('#rs-port') || {}).value || 8888; + el.innerHTML = `启用后访问方式:http://${esc(vip)}:${esc(port)}/` + + `(也可用 http://${esc(vip)}:${esc(port)}/?restore=1 直达一键还原页)。` + + `此地址不占用 WAN/LAN,插任一口皆可访问。`; +} + +function fmtUnix(v) { + const n = Number(v); + if (!n) return '—'; + const ms = n > 1e12 ? n / 1000 : n; + const dt = new Date(ms); + if (isNaN(dt.getTime())) return String(v); + const p = x => String(x).padStart(2, '0'); + return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}`; +} + +/* ============================ 升级与保护 ============================ */ +async function viewUpdate() { + $('#view').innerHTML = ` +
    +

    RealVNC 保护状态

    +

    本项目不会接管 VNC,只做状态展示与升级风险预警。

    +
    读取中…
    +
    +
    +

    可升级软件包

    +

    系统更新默认只列出清单,不自动执行。标记为高危的包可能影响桌面环境与 VNC, + 升级前请先创建快照。

    +
    + + +
    +

    点击上方按钮开始。

    +
    +
    +

    版本锁定(apt-mark hold)

    +

    本方案不推荐长期冻结大批系统包。仅当 RealVNC 官方明确要求固定某版本时, + 对该单个包执行锁定。

    +
    + + + +
    +
    +
    +
    +

    升级后验证清单

    + + + + + + +
    项目验证方式
    图形会话通过 RealVNC 连接 5900,确认桌面正常
    管理台刷新本页面,确认能正常登录
    网络概览页查看接口地址与默认路由是否正常
    路由服务概览页查看 dnsmasq / pppd 等服务状态
    回滚预案系统设置 → 快照列表,选择升级前的快照回滚
    +
    `; + const r = await api('/api/upgrade/info'); + const d = r.data || {}; + $('#up-vnc').innerHTML = ` +
    VNC 服务${d.vnc_active ? '运行中(5900 监听)' : '未运行'}
    +
    已锁定包${(d.holds || []).join(', ') || '无'}
    +
    提示${esc(d.notice || '')}
    `; + $('#pk-holds').innerHTML = (d.holds || []).length + ? `${d.holds.map(h => ``).join('')}
    ${esc(h)}
    ` + : '

    当前没有任何被锁定的包。

    '; + $('#pk-upd').onclick = async () => { + $('#pk-out').innerHTML = '
    更新中,可能需要 1-2 分钟…
    '; + const r2 = await api('/api/pkg', { method: 'POST', body: { op: 'update' } }); + $('#pk-out').innerHTML = `
    ${esc(r2.msg_cn)}\n\n${esc((r2.data || {}).out || '')}
    `; + }; + $('#pk-list').onclick = async () => { + $('#pk-out').innerHTML = '
    查询中…
    '; + const r2 = await api('/api/pkg', { method: 'POST', body: { op: 'list' } }); + const dd = r2.data || {}; + $('#pk-out').innerHTML = ` +
    ${esc(dd.notice || r2.msg_cn)}
    + ${(dd.risky || []).length ? `
    高危包: + ${dd.risky.map(x => `${esc(x)}`).join('')}
    ` : ''} +
    ${esc((dd.rows || []).join('\n') || '没有可升级的包')}
    `; + }; + const holdOp = async op => { + const names = $('#pk-hold-n').value.split(',').map(x => x.trim()).filter(Boolean); + if (!names.length) { toast('请填写包名', 'err'); return; } + const r2 = await api('/api/pkg', { method: 'POST', body: { op, packages: names } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); + if (r2.ok) setTimeout(viewUpdate, 500); + }; + $('#pk-hold').onclick = () => holdOp('hold'); + $('#pk-unhold').onclick = () => holdOp('unhold'); +} + +/* ============================================================ + 主题之家(#12)—— Web 内设计主题 / 离线预览 / ZIP 导入导出 + ============================================================ */ + +/* 可定制变量清单与前端镜像的中文说明。 + 后端 theme.py 的 THEME_VARS 是唯一权威来源;这里只是「首屏即时渲染」 + 用的副本,页面加载后会用 /api/theme?op=vars 的结果覆盖。 */ +const TM_VARS = [ + ['--bg', '页面背景', 'base'], ['--bg2', '悬停背景', 'base'], + ['--panel', '卡片背景', 'base'], ['--panel2', '表头背景', 'base'], + ['--line', '边框', 'base'], ['--line2', '浅分隔线', 'base'], + ['--txt', '主文字', 'base'], ['--txt2', '次要文字', 'base'], ['--txt3', '弱化文字', 'base'], + ['--pri', '主题主色', 'color'], ['--pri-d', '主色加深', 'color'], ['--pri-l', '主色浅底', 'color'], + /* 主色本身若太浅(如暖橙),当链接/强调文字用会看不清; + --pri-text 是同一色调的「可读版本」,--pri 仍保留原色用于填充与按钮。 */ + ['--pri-text', '主色(文字用)', 'color'], + ['--ok', '成功色', 'color'], ['--ok-l', '成功浅底', 'color'], + ['--warn', '警告色', 'color'], ['--warn-l', '警告浅底', 'color'], + ['--err', '错误色', 'color'], ['--err-l', '错误浅底', 'color'], + ['--info', '信息色', 'color'], ['--info-l', '信息浅底', 'color'], + ['--r', '卡片圆角', 'shape'], ['--sh', '卡片阴影', 'shape'], ['--grad', '主按钮渐变', 'shape'], + ['--side', '侧栏宽度', 'layout'], ['--side-c', '侧栏折叠宽度', 'layout'], +]; +const TM_CAT_CN = { base: '基础配色', color: '主色与状态色', shape: '圆角 / 阴影 / 渐变', layout: '布局尺寸' }; +const TM_CAT_ORDER = ['base', 'color', 'shape', 'layout']; +/* 具名颜色白名单 —— 必须与后端 theme.py 的 _NAMED 完全一致。 + 缺少这一份,前端会把 red / transparent 这类合法取值判为非法, + 导致「明明后端允许,界面却报错」的不一致体验。 */ +const TM_NAMED = ['aqua', 'beige', 'black', 'blue', 'brown', 'chocolate', 'coral', + 'crimson', 'currentcolor', 'cyan', 'darkgray', 'darkgreen', 'darkgrey', 'darkorange', + 'darkred', 'dodgerblue', 'forestgreen', 'fuchsia', 'gold', 'goldenrod', 'gray', 'green', + 'grey', 'indigo', 'ivory', 'khaki', 'lavender', 'lightgray', 'lightgrey', 'lime', + 'magenta', 'maroon', 'midnightblue', 'navy', 'olive', 'orange', 'orchid', 'peru', 'pink', + 'plum', 'purple', 'red', 'royalblue', 'salmon', 'seagreen', 'sienna', 'silver', 'skyblue', + 'slategray', 'slategrey', 'steelblue', 'teal', 'tomato', 'transparent', 'turquoise', + 'violet', 'wheat', 'white', 'whitesmoke', 'yellow']; + +/* 内置基准值:主题不必写满全部变量,缺失的用这里的基准兜底 */ +const TM_BASE = { + '--bg': '#eef2f9', '--bg2': '#f2f5fa', '--panel': '#ffffff', '--panel2': '#fafbfe', + '--line': '#e2e7f0', '--line2': '#eef1f7', + '--txt': '#161f30', '--txt2': '#556480', '--txt3': '#8894a8', + '--pri': '#1f6feb', '--pri-d': '#1858c4', '--pri-l': '#e8f0fe', '--pri-text': '#1f6feb', + '--ok': '#12813f', '--ok-l': '#e3f7ea', '--warn': '#9a6700', '--warn-l': '#fff8e1', + '--err': '#c0392b', '--err-l': '#fdecea', '--info': '#0b6e99', '--info-l': '#e5f4fb', + '--r': '12px', '--side': '236px', '--side-c': '68px', + '--sh': '0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)', + '--grad': 'linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%)', +}; + +/* ---------- 合法性预判:与后端 theme.py 规则一致,用于即时中文提示 ---------- */ +function tmCheckValue(v, name) { + const s = String(v == null ? '' : v).trim(); + if (!s) return '取值不能为空'; + if (s.length > 220) return '取值过长(最多 220 字符)'; + const low = s.toLowerCase(); + for (const ch of [';', '{', '}', '<', '>', '@', '\\', '!', '`']) { + if (s.includes(ch)) return `包含非法字符「${ch}」`; + } + if (s.includes('/*') || s.includes('*/')) return '不允许出现注释符号'; + if (/url\(|expression|import|javascript/i.test(s)) return '不允许使用外部引用(url / expression / import)'; + if (low.startsWith('var(')) { + const m = /^var\(\s*(--[a-z0-9-]+)\s*(?:,[^)]*)?\)$/.exec(low); + if (!m) return 'var() 引用写法不正确'; + if (!TM_VARS.some(x => x[0] === m[1])) return `不允许引用未开放的变量 ${m[1]}`; + return ''; + } + if (s.startsWith('#')) { + return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s) + ? '' : '颜色格式不正确(支持 #rgb / #rrggbb / #rrggbbaa)'; + } + if (low.startsWith('rgb') || low.startsWith('hsl')) { + const N255 = '(?:\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])'; + const ALPHA = '(?:0|1|0?\\.\\d{1,3})'; + const HUE = '(?:\\d|[1-9]\\d|[12]\\d{2}|3[0-5]\\d|360)(?:\\.\\d+)?(?:deg)?'; + const PCT = '(?:\\d|[1-9]\\d|100)%'; + return (new RegExp(`^rgba?\\(\\s*${N255}\\s*,\\s*${N255}\\s*,\\s*${N255}(\\s*,\\s*${ALPHA})?\\s*\\)$`).test(low) + || new RegExp(`^hsla?\\(\\s*${HUE}\\s*,\\s*${PCT}\\s*,\\s*${PCT}(,\\s*${ALPHA})?\\s*\\)$`).test(low)) + ? '' : '颜色函数格式不正确(示例:rgba(31,111,235,.35),RGB 需在 0-255 内)'; + } + if (/^\d+(\.\d+)?(px|rem|em|%|vh|vw|pt|ch)$/.test(low)) { + if (name && ['--r', '--side', '--side-c'].includes(name) && /(vh|vw)$/.test(low)) { + return '该变量不支持 vh / vw 单位'; + } + return ''; + } + if (low === '0' || TM_NAMED.indexOf(low) >= 0) return ''; + if (/^(linear-gradient|radial-gradient)\(/i.test(s)) return ''; + if (/^(?:(?:-?\d+(?:\.\d+)?(?:px|em|rem)?|rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-fA-F]{3,8}|inset|none|,|\s)+)$/i.test(s)) return ''; + if (/^[a-z]+$/i.test(s)) return '不支持的具名取值,请用 #rrggbb 或 rgb() 形式'; + return '取值格式无法识别'; +} + +function tmCheckCss(css) { + let s = String(css || '').replace(/\/\*[\s\S]*?\*\//g, ''); + if (!s.trim()) return ''; + if (s.includes('@')) return '自定义 CSS 不允许使用 @ 规则(如 @import / @media)'; + if (s.includes('\\')) return '自定义 CSS 不允许使用转义字符'; + if (/javascript\s*:|expression\s*\(|behavior\s*:|url\s*\(/i.test(s)) return '自定义 CSS 不允许外部引用'; + let rest = s.trim(), guard = 0; + while (rest) { + if (++guard > 200) return '自定义 CSS 结构过于复杂'; + const m = /^([^{}]+)\{([^{}]*)\}/.exec(rest); + if (!m) return '自定义 CSS 语法不正确:应为「选择器 { 属性: 取值; }」形式'; + if (!m[1].trim()) return '自定义 CSS 存在空选择器'; + if (/[<>]/.test(m[1])) return '自定义 CSS 选择器包含非法字符'; + for (const decl of m[2].split(';')) { + const d = decl.trim(); + if (!d) continue; + if (!d.includes(':')) return `自定义 CSS 声明缺少冒号:${d.slice(0, 40)}`; + const prop = d.slice(0, d.indexOf(':')).trim().toLowerCase(); + if (!/^(--[a-z0-9-]+|[a-z-]+)$/.test(prop)) return `属性名不合法:${prop.slice(0, 40)}`; + const val = d.slice(d.indexOf(':') + 1).trim(); + if (!/^[0-9a-zA-Z#(),.\s%\-"/_]+$/.test(val)) return `取值含非法字符:${d.slice(0, 40)}`; + } + rest = rest.slice(m[0].length).trim(); + } + return ''; +} + +/* #rrggbb → 可用于 的 6 位色;不是 hex 则返回 fallback */ +function tmToHex(v, fallback = '#888888') { + let s = String(v || '').trim().replace(/^#/, ''); + if (/^[0-9a-fA-F]{6}$/.test(s)) return '#' + s.toLowerCase(); + if (/^[0-9a-fA-F]{3}$/.test(s)) return '#' + s.split('').map(c => c + c).join('').toLowerCase(); + return fallback; +} + +/* 当前编辑中的主题对象(DOM → 对象) */ +function tmCur() { + const e = S.themeEdit || {}; + const vars = {}; + TM_VARS.forEach(([k]) => { + const el = document.getElementById('tm-v-' + k.replace(/[^a-z0-9-]/gi, '')); + if (!el) return; + const on = document.getElementById('tm-o-' + k.replace(/[^a-z0-9-]/gi, '')); + if (on && !on.checked) return; + const v = String(el.value || '').trim(); + if (v) vars[k] = v; + }); + const gv = id => { const x = document.getElementById(id); return x ? String(x.value || '').trim() : ''; }; + const cssEl = document.getElementById('tm-css'); + return { + id: e.id || '', + name: gv('tm-name') || '我的主题', + author: gv('tm-author'), + description: gv('tm-desc'), + version: gv('tm-version') || '1.0', + dark: !!(document.getElementById('tm-dark') || {}).checked, + vars, + css: cssEl ? String(cssEl.value || '') : '', + }; +} + +/* 本地粗检(后端还会做一次权威校验) */ +function tmLocalIssues(t) { + const out = []; + const nm = String(t.name || '').trim(); + if (!nm) out.push('请填写主题名称'); + else if (!/^[一-龥A-Za-z0-9 _.\-()]{1,48}$/.test(nm)) { + out.push('主题名称只能包含中文、字母、数字、空格与 - _ . ( )'); + } + const keys = Object.keys(t.vars || {}); + if (!keys.length) out.push('至少要改动 1 个变量'); + for (const k of keys) { + const why = tmCheckValue(t.vars[k], k); + if (why) out.push(`变量 ${k} 取值不合法:${why}`); + } + const cw = tmCheckCss(t.css); + if (cw) out.push(cw); + return out; +} + +function tmIssuesHTML(list) { + return list.length + ? `
    共 ${list.length} 处问题,已阻止提交:
      ${ + list.map(x => `
    • ${esc(x)}
    • `).join('')}
    ` + : `
    校验通过:主题结构、全部变量取值与自定义 CSS 均合法。
    `; +} + +/* 把主题渲染为 CSS 文本(与后端一致的输出格式,用于离线预览与「查看代码」) */ +function tmToCss(t) { + const lines = []; + lines.push(`/* Drouter 主题:${t.name}${t.version ? ' v' + t.version : ''} */`); + lines.push(':root{'); + Object.keys(t.vars || {}).sort().forEach(k => { lines.push(` ${k}:${t.vars[k]};`); }); + lines.push('}'); + lines.push(`:root{color-scheme:${t.dark ? 'dark' : 'light'}}`); + if (String(t.css || '').trim()) { + lines.push('', '/* ---- 作者自定义 CSS ---- */', String(t.css).trim()); + } + return lines.join('\n') + '\n'; +} + +/* 主题名 → 安全缩略色组(优先用主题自己的变量,缺失则回落到基准) */ +function tmSwatch(t) { + const v = Object.assign({}, TM_BASE, t.vars || {}); + return [v['--pri'], v['--bg'], v['--panel'], v['--txt']]; +} + +/* ---------- 离线预览:在一个隔离 iframe 里渲染一份「Drouter UI 缩影」 ---------- + 用 sandbox 隔离 + srcdoc,无需网络请求,也不影响当前页面; + 主题 CSS 作为 :root 变量注入,因此能真实还原管理台的观感。 */ +function tmPreviewDoc(t) { + const v = Object.assign({}, TM_BASE, t.vars || {}); + const cssVars = Object.keys(v).map(k => `${k}:${v[k]};`).join('\n '); + const extra = String(t.css || '').trim(); + const r = v['--r'] || '12px'; + const grad = v['--grad'] || 'linear-gradient(135deg,#2b7bf3,#1f6feb)'; + return ` + +主题离线预览 + + +
    + +
    +
    +

    系统概览

    + 2026年9月28日 星期一 · 农历八月 +
    +
    +
    +
    实时状态
    +
    +
    CPU 使用率
    18%
    +
    +
    内存占用
    42%
    +
    +
    下行速率
    86.4
    +
    +
    在线终端
    12
    +
    +
    +
    +
    +
    操作与服务
    +
    + + + +
    +
    + 运行中 + 信息 + 警告 + 错误 +
    +

    这是离线预览 —— 与主界面使用同一套 CSS 变量,所见即所得。

    +
    +
    +
    连接简表
    + + + + + + + +
    源地址目标协议状态
    192.168.7.3593.184.216.34:443TCP已建立
    192.168.7.18223.5.5.5:53UDP已建立
    192.168.7.62140.82.114.4:443TCP已建立
    +
    +
    +
    +
    +`; +} + +function tmRefreshPreview() { + const t = tmCur(); + const issues = tmLocalIssues(t); + const box = document.getElementById('tm-issues'); + if (box) box.innerHTML = tmIssuesHTML(issues); + const frame = document.getElementById('tm-frame'); + if (frame) frame.srcdoc = tmPreviewDoc(t); + const code = document.getElementById('tm-code'); + if (code) code.textContent = tmToCss(t); +} + +/* ---------- ZIP 导入导出(纯浏览器 + 后端 Base64 通道) ---------- */ +function tmDownloadB64(b64, filename) { + try { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + const url = URL.createObjectURL(new Blob([arr], { type: 'application/zip' })); + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500); + return true; + } catch (e) { + toast('下载失败:' + e.message, 'err'); + return false; + } +} + +async function tmUpload(file) { + const tip = document.getElementById('tm-import-msg'); + const show = (cls, txt) => { tip.className = 'tm-check ' + cls; tip.innerHTML = txt; }; + if (!file) return; + const kb = Math.round(file.size / 1024); + if (file.size > 4 * 1024 * 1024) { show('bad', `文件过大(${kb} KB),主题包上限 4 MB`); return; } + if (!/\.(zip|drtheme)$/i.test(file.name)) { + show('bad', '请选择 .zip 主题包(由「导出」按钮生成的那种)'); + return; + } + show('', '正在读取并校验…'); + let b64; + try { + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(fr.result); + fr.onerror = () => rej(new Error('文件读取失败')); + fr.readAsDataURL(file); + }); + b64 = String(dataUrl).split(',')[1] || ''; + } catch (e) { + show('bad', '文件读取失败:' + esc(e.message)); return; + } + const r = await api('/api/theme/op', { method: 'POST', body: { op: 'import', b64 } }); + if (!r.ok) { + // 不规范的主题:给出中文原因,且绝不应用 + show('bad', `导入失败,主题未应用。
    ${esc(r.msg_cn)}`); + toast('主题校验未通过,已拒绝应用', 'err', 6000); + return; + } + show('ok', `导入成功 —— ${esc(r.msg_cn)}`); + toast('主题导入成功', 'ok'); + await tmReload(); + const d = r.data || {}; + if (d.id) tmApply(d.id, true); +} + +/* ---------- 主题操作 ---------- */ +async function tmApply(id, quiet) { + const r = await api('/api/theme/op', { method: 'POST', body: { op: 'apply', id } }); + if (!r.ok) { toast(r.msg_cn, 'err', 6000); return false; } + // 强制浏览器重新拉取 theme.css(绕开缓存) + const lk = document.getElementById('theme-css'); + if (lk) lk.href = '/theme.css?v=' + Date.now(); + toast(r.msg_cn, 'ok'); + if (!quiet) await tmReload(); + return true; +} + +async function tmReload() { + const [lst, meta] = await Promise.all([ + api('/api/theme?op=list'), + api('/api/theme?op=vars'), + ]); + if (lst.ok) S.theme = lst.data || {}; + if (meta.ok) { + S.themeMeta = meta.data || {}; + const d = S.themeMeta.defaults || {}; + Object.keys(d).forEach(k => { if (d[k]) TM_BASE[k] = d[k]; }); + } +} + +/* ---------- 主视图 ---------- */ +async function viewTheme() { + const v = $('#view'); + v.innerHTML = '
    正在读取主题列表…
    '; + await tmReload(); + if (!S.theme || !S.theme.themes) { + v.innerHTML = `

    读取失败

    +

    未能读取主题列表,请确认后端服务正常后重试。

    `; + return; + } + if (!S.themeEdit) S.themeEdit = { id: '' }; + tmRender(); +} + +function tmRender() { + const T = S.theme || {}; + const themes = T.themes || []; + const active = T.active || 'default'; + const cur = themes.find(x => x.id === active); + const e = S.themeEdit || { id: '' }; + const v = $('#view'); + + v.innerHTML = ` +
    +

    主题之家 + + 当前:${esc(cur ? cur.name : '未应用主题')}

    +

    在这里挑选、设计并把玩界面外观。所有变更只影响 Web 管理台外观, + 不会改动任何网络配置、防火墙规则或服务。

    +
    +
    ${tmSwatch(cur || { vars: {} }).map(c => + ``).join('')}
    +
    + ${esc(cur ? cur.name : '内置默认')} +

    ${esc(cur && cur.description ? cur.description : '尚未应用自定义主题')}

    +
    +
    + +
    +
    +
    + +
    + + + +
    + +
    `; + + $$('.tm-tab').forEach(b => b.onclick = () => { S.themeTab = b.dataset.tmTab; tmRender(); }); + const rs = $('#tm-reset'); + if (rs) rs.onclick = async () => { + const r = await api('/api/theme/op', { method: 'POST', body: { op: 'reset' } }); + if (r.ok) { + const lk = document.getElementById('theme-css'); + if (lk) lk.href = '/theme.css?v=' + Date.now(); + toast(r.msg_cn, 'ok'); await tmReload(); tmRender(); + } else toast(r.msg_cn, 'err'); + }; + tmRenderPane(); +} + +function tmRenderPane() { + const T = S.theme || {}; + const themes = T.themes || []; + const active = T.active || 'default'; + const pane = $('#tm-pane'); + if (!pane) return; + if (S.themeTab === 'gallery') { + pane.innerHTML = ` +
    +

    全部主题(${themes.length}) + 共开放 ${TM_VARS.length} 个可定制变量

    +

    内置主题不可修改或删除;想改的话点「以此为基础」复制到设计器里。

    +
    + ${themes.map(t => tmThemeCard(t, t.id === active)).join('')} +
    +
    `; + $$('[data-tm-act]').forEach(b => b.onclick = () => tmCardAct(b.dataset.tmAct, b.dataset.tmId)); + return; + } + if (S.themeTab === 'import') { + pane.innerHTML = ` +
    +

    导入主题包

    +

    选择由「导出」生成的 .zip 主题包。 + 系统会逐项校验(结构、变量白名单、取值合法性、CSS 安全性), + 任何一项不通过都会给出中文原因并拒绝应用,不会污染现有配置。

    +
    +

    把主题包拖到这里,或

    + + +

    单个文件上限 4 MB,解压后上限 512 KB

    +
    + +
    + +
    +

    导出当前主题

    +

    把主题打包成独立的 .zip,可以分享给别人或备份。 + 包内含 theme.json(清单)、theme.css(渲染结果) + 与 README.txt(安装说明)。

    +
    + + +
    + +
    + +
    +

    主题包格式说明

    +

    如果你想手工写主题,包的结构非常简单:

    +
    主题包.zip
    +├── theme.json   ← 必需,UTF-8,见下方字段
    +├── theme.css    ← 可选,导入时会被重新生成
    +└── README.txt   ← 可选
    +

    theme.json 字段:

    +
    {
    +  "name":        "我的主题",        // 必需,≤48 字
    +  "description": "一句话说明",      // 可选
    +  "author":      "作者名",          // 可选
    +  "version":     "1.0",            // 可选
    +  "dark":        false,            // 可选,true = 深色主题
    +  "vars": {                        // 必需,至少 1 个
    +    "--pri": "#1f6feb",
    +    "--bg":  "#eef2f9"
    +  },
    +  "css": ""                        // 可选,追加的自定义 CSS
    +}
    +

    只有 --bg / --panel / --pri / --txt … + 这 ${TM_VARS.length} 个变量会被接受。其它变量、@import、 + url()、javascript: + 一律拒绝并给出中文提示。

    +
    `; + const pick = $('#tm-pick'), file = $('#tm-file'), drop = $('#tm-drop'); + pick.onclick = () => file.click(); + file.onchange = () => tmUpload(file.files[0]); + ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { + e.preventDefault(); drop.classList.add('hot'); + })); + ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { + e.preventDefault(); drop.classList.remove('hot'); + })); + drop.addEventListener('drop', e => { + if (e.dataTransfer.files && e.dataTransfer.files[0]) tmUpload(e.dataTransfer.files[0]); + }); + const msg = $('#tm-import-msg'); + msg.style.display = ''; + $('#tm-export').onclick = tmExport; + $('#tm-export-msg').style.display = ''; + return; + } + // design + const e = S.themeEdit || {}; + pane.innerHTML = ` +
    +

    ${e.id ? '编辑主题' : '新建主题'} + ${e.id ? `${esc(e.id)}` : ''}

    +

    左侧调整参数,右侧即时预览。预览完全在本地完成(沙箱 iframe), + 不会影响真实界面;觉得满意再点「保存并应用」。

    +
    + + + +
    + + +
    + + + 会同时重算背景、文字、状态色与按钮渐变,保证对比度 +
    +
    + +
    +
    +

    变量调整

    +

    勾选表示「包含在主题里」。取消勾选的变量不会写进主题, + 应用到管理台时会回落到默认值。

    +
    ${tmVarsHTML(e)}
    +

    自定义 CSS(高级)

    +

    只接受「选择器 { 属性: 取值; }」形式;禁止 + @import / @media、url()、 + javascript: 等外部引用。

    + +
    +
    +

    离线预览

    +

    下方是隔离沙箱中的真实渲染效果,随左侧改动实时更新。

    + +
    + + +
    +
    +
    + +
    +

    校验与操作

    +
    +
    + + + + +
    +
    + +
    +

    主题代码

    +

    最终写入 theme.css 的完整内容,可直接复制到别处使用。

    +
    + +
    
    +        
    +
    `; + tmBindDesign(); + tmRefreshPreview(); +} + +function tmVarsHTML(e) { + const vars = e.vars || {}; + return TM_CAT_ORDER.map(cat => { + const items = TM_VARS.filter(x => x[2] === cat); + return `
    +
    ${esc(TM_CAT_CN[cat] || cat)}
    + ${items.map(([k, cn]) => { + const key = k.replace(/[^a-z0-9-]/gi, ''); + const val = vars[k] || TM_BASE[k] || ''; + const isColor = !['--r', '--sh', '--grad', '--side', '--side-c'].includes(k); + return `
    + + ${esc(cn)}
    ${esc(k)}
    + ${isColor ? `` : ''} + + +
    `; + }).join('')} +
    `; + }).join(''); +} + +function tmBindDesign() { + $$('.tm-color').forEach(c => c.oninput = () => { + const el = document.getElementById('tm-v-' + c.dataset.for); + if (el) { el.value = c.value; tmSyncSwatch(c.dataset.for, c.value); tmRefreshPreview(); } + }); + $$('.tm-vtext').forEach(t => t.oninput = () => { + const cb = document.getElementById('tm-o-' + t.id.replace('tm-v-', '')); + if (cb) cb.checked = true; + const sw = document.querySelector('.tm-color[data-for="' + t.id.replace('tm-v-', '') + '"]'); + if (sw) sw.value = tmToHex(t.value, sw.value); + tmRefreshPreview(); + }); + $$('[data-reset]').forEach(b => b.onclick = () => { + const el = document.getElementById('tm-v-' + b.dataset.reset); + const sw = document.querySelector('.tm-color[data-for="' + b.dataset.reset + '"]'); + if (el) el.value = TM_BASE['--' + b.dataset.reset.replace(/^/, '').replace(/^/, '')] || el.value; + const key = '--' + b.dataset.reset; + if (el) el.value = TM_BASE[key] || el.value; + if (sw) sw.value = tmToHex(el.value, sw.value); + tmRefreshPreview(); + }); + $$('.tm-vrow input[type=checkbox]').forEach(c => c.onchange = () => tmRefreshPreview()); + const dk = document.getElementById('tm-dark'); + if (dk) dk.onchange = () => tmRefreshPreview(); + const cssEl = document.getElementById('tm-css'); + if (cssEl) cssEl.oninput = debounce(() => tmRefreshPreview(), 250); + ['tm-name', 'tm-desc', 'tm-version', 'tm-author'].forEach(id => { + const x = document.getElementById(id); + if (x) x.oninput = debounce(() => tmRefreshPreview(), 300); + }); + $('#tm-frame-re').onclick = () => tmRefreshPreview(); + $('#tm-frame-new').onclick = () => { + const t = tmCur(); + const w = window.open('', '_blank'); + if (w) { w.document.write(tmPreviewDoc(t)); w.document.close(); } + else toast('浏览器拦截了弹出窗口,请允许后再试', 'warn'); + }; + $('#tm-check').onclick = () => { + const issues = tmLocalIssues(tmCur()); + $('#tm-issues').innerHTML = tmIssuesHTML(issues); + toast(issues.length ? `发现 ${issues.length} 处问题` : '校验通过', issues.length ? 'err' : 'ok'); + }; + $('#tm-save').onclick = () => tmSave(false); + $('#tm-save-apply').onclick = () => tmSave(true); + $('#tm-export-cur').onclick = () => tmExportDraft(); + $('#tm-copy-css').onclick = () => { + const txt = document.getElementById('tm-code').textContent; + navigator.clipboard.writeText(txt).then( + () => toast('CSS 已复制到剪贴板', 'ok'), + () => toast('复制失败,请手动选择文本', 'err')); + }; +} + +function tmSyncSwatch(key, val) { + const sw = document.querySelector('.tm-color[data-for="' + key + '"]'); + if (sw) sw.value = tmToHex(val, sw.value); +} + +async function tmSave(applyAfter) { + const t = tmCur(); + const issues = tmLocalIssues(t); + $('#tm-issues').innerHTML = tmIssuesHTML(issues); + if (issues.length) { + toast(`有 ${issues.length} 处问题,未保存`, 'err', 6000); + return; + } + const payload = Object.assign({}, t, { id: (S.themeEdit || {}).id || '' }); + const r = await api('/api/theme/op', { method: 'POST', body: { op: 'save', theme: payload } }); + if (!r.ok) { + $('#tm-issues').innerHTML = `
    后端拒绝了保存: +
    ${esc(r.msg_cn)}
    `; + toast('保存失败:' + r.msg_cn, 'err', 6000); + return; + } + S.themeEdit = Object.assign({}, t, { id: r.data.id }); + toast(r.msg_cn, 'ok'); + await tmReload(); + if (applyAfter) await tmApply(r.data.id, false); + tmRender(); +} + +async function tmExportDraft() { + const issues = tmLocalIssues(tmCur()); + $('#tm-issues').innerHTML = tmIssuesHTML(issues); + if (issues.length) { toast('请先修正问题再导出', 'err'); return; } + const r = await api('/api/theme/op', { method: 'POST', body: { op: 'preview', theme: tmCur() } }); + if (!r.ok) { toast('打包失败:' + r.msg_cn, 'err', 6000); return; } + // 草稿未落盘,导出在浏览器侧合成 zip 内容需要主题对象,这里走一次临时保存 + const s = await api('/api/theme/op', { method: 'POST', body: { op: 'save', theme: tmCur() } }); + if (!s.ok) { toast('打包前保存失败:' + s.msg_cn, 'err', 6000); return; } + S.themeEdit = Object.assign({}, tmCur(), { id: s.data.id }); + await tmDoExport(s.data.id); + await tmReload(); +} + +async function tmExport() { + const id = $('#tm-export-sel').value; + await tmDoExport(id); +} + +async function tmDoExport(id) { + const msg = $('#tm-export-msg') || $('#tm-issues'); + const r = await api('/api/theme/op', { method: 'POST', body: { op: 'export', id } }); + if (!r.ok) { + if (msg) { msg.className = 'tm-check bad'; msg.innerHTML = esc(r.msg_cn); } + toast('导出失败:' + r.msg_cn, 'err', 6000); + return; + } + tmDownloadB64(r.data.b64, r.data.filename); + if (msg) { + msg.className = 'tm-check ok'; + msg.innerHTML = `已生成 ${esc(r.data.filename)}(${Math.max(1, Math.round(r.data.size / 1024))} KB), + 浏览器应已开始下载;没弹窗的话请检查浏览器的下载拦截设置。`; + } + toast('主题包已导出', 'ok'); +} + +function tmThemeCard(t, on) { + const sw = tmSwatch(t); + return `
    +
    +
    +
    +
    +
    + ${on ? '使用中' : ''} +
    +
    + ${esc(t.name)} + ${t.dark ? '深色' : '浅色'} + ${t.builtin ? '内置' : ''} +

    ${esc(t.description || '(无描述)')}

    +

    + ${esc(t.author ? '作者:' + t.author : '未署名')} · ${t.vars_count || 0} 个变量 + ${t.has_css ? ' · 含自定义 CSS' : ''}

    +
    +
    + ${on ? '' : ``} + + + ${t.builtin ? '' : ``} +
    +
    `; +} + +async function tmCardAct(act, id) { + const t = (S.theme.themes || []).find(x => x.id === id); + if (act === 'apply') { await tmApply(id, false); tmRender(); return; } + if (act === 'export') { await tmDoExport(id); return; } + if (act === 'fork') { + const r = await api('/api/theme?op=get&id=' + encodeURIComponent(id)); + if (!r.ok) { toast('读取主题失败:' + r.msg_cn, 'err'); return; } + const d = r.data || {}; + S.themeEdit = { + id: '', name: (d.name || '我的主题') + ' 副本', + author: '', description: d.description || '', version: '1.0', + dark: !!d.dark, vars: Object.assign({}, d.vars || {}), css: d.css || '', + }; + S.themeTab = 'design'; + tmRender(); + toast('已载入到设计器,改好后保存即为新主题', 'ok'); + return; + } + if (act === 'del') { + modal('删除主题', `

    确定要删除主题「${esc(t ? t.name : id)}」吗?

    +

    删除后无法恢复。若你还想保留,建议先点「导出」备份。

    `, + async () => { + const r = await api('/api/theme/op', { method: 'POST', body: { op: 'delete', id } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 5000); + if (r.ok) { await tmReload(); tmRender(); } + }, '删除'); + } +} + +/* ============================ 通用弹窗 ============================ */ +function modal(title, html, onOk, okText = '确定') { + const m = $('#modal'); + $('#modal-box').innerHTML = `

    ${esc(title)}

    +
    + ${onOk ? `` : ''}
    `; + m.classList.remove('hidden'); + const close = () => m.classList.add('hidden'); + $('#md-cancel').onclick = close; + m.onclick = e => { if (e.target === m) close(); }; + if (onOk) $('#md-ok').onclick = async () => { + const keep = await onOk(); + if (keep !== false) close(); + }; +} + +/* ============================ 顶栏时钟 / 农历(#12) ============================ + 需求:顶栏不再显示重复的「主机名 · 内存 21% · 运行 22小时18分」, + 改为时钟 + 农历 + 年月日 + 星期。运行时间保留在概览页里。 + 农历用经典的「1900-2100 压缩表」算法,纯本地计算,不依赖网络。 */ + +// 农历数据表(1900-2100):每年一个 16 进制数 +// 低 4 位 = 闰月月份(0 表示无闰月);中间 12 位 = 每月大小月(1 大 30 天 / 0 小 29 天); +// 最高位 = 闰月大小(1=30 天 / 0=29 天)。逐行 10 个,便于核对。 +const lunarInfo = [ + 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, + 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, + 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, + 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, + 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, + 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, + 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, + 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, + 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, + 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0, + 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, + 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, + 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, + 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, + 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, + 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, + 0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, + 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, + 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, + 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, + 0x0d520, +]; +const LUNAR_MONTH = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊']; +const LUNAR_DAY_PRE = ['初', '十', '廿', '卅']; +const LUNAR_DAY_SUF = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; +const GAN = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']; +const ZHI = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']; +const ZODIAC = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']; +const WEEK_CN = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; +const SOLAR_TERM = ['小寒', '大寒', '立春', '雨水', '惊蛰', '春分', '清明', '谷雨', '立夏', '小满', + '芒种', '夏至', '小暑', '大暑', '立秋', '处暑', '白露', '秋分', '寒露', '霜降', '立冬', '小雪', + '大雪', '冬至']; + +function lunarLeapMonth(y) { return lunarInfo[y - 1900] & 0xf; } +function lunarLeapDays(y) { + return lunarLeapMonth(y) ? ((lunarInfo[y - 1900] & 0x10000) ? 30 : 29) : 0; +} +function lunarMonthDays(y, m) { return (lunarInfo[y - 1900] & (0x10000 >> m)) ? 30 : 29; } +function lunarYearDays(y) { + let sum = 348; + for (let i = 0x8000; i > 0x8; i >>= 1) sum += (lunarInfo[y - 1900] & i) ? 1 : 0; + return sum + lunarLeapDays(y); +} + +/* 公历 → 农历(经典压缩表算法,1900-2100,已用 2024/2025/2026 春节与中秋逐日校验) */ +function solarToLunar(dt) { + const base = new Date(1900, 0, 31); + let offset = Math.floor((new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()) - base) / 86400000); + let temp = 0, i; + for (i = 1900; i < 2101 && offset > 0; i++) { temp = lunarYearDays(i); offset -= temp; } + if (offset < 0) { offset += temp; i--; } + const lunarYear = i; + const leap = lunarLeapMonth(i); + let isLeap = false; + for (i = 1; i < 13 && offset > 0; i++) { + if (leap > 0 && i === leap + 1 && isLeap === false) { + --i; isLeap = true; temp = lunarLeapDays(lunarYear); + } else { + temp = lunarMonthDays(lunarYear, i); + } + if (isLeap === true && i === leap + 1) isLeap = false; + offset -= temp; + } + if (offset === 0 && leap > 0 && i === leap + 1) { + if (isLeap === true) isLeap = false; else { isLeap = true; --i; } + } + if (offset < 0) { offset += temp; --i; } + return { + lunarYear, month: i, day: offset + 1, isLeap, + ganZhi: GAN[(lunarYear - 4) % 10] + ZHI[(lunarYear - 4) % 12], + zodiac: ZODIAC[(lunarYear - 4) % 12], + }; +} + +function lunarDayCn(d) { + if (d === 10) return '初十'; + if (d === 20) return '二十'; + if (d === 30) return '三十'; + return LUNAR_DAY_PRE[Math.floor(d / 10)] + LUNAR_DAY_SUF[d % 10]; +} + +function lunarText(date) { + try { + const l = solarToLunar(date); + // 初一显示月份,其余显示日 + const md = (l.day === 1) + ? `${l.isLeap ? '闰' : ''}${LUNAR_MONTH[l.month - 1]}月` + : `${LUNAR_MONTH[l.month - 1]}月${lunarDayCn(l.day)}`; + return `${l.ganZhi}${l.zodiac}年 ${md}`; + } catch (e) { return ''; } +} + +/* 节气:使用「寿星公式」(1900-2100 通用,误差 0 天,已用 2024/2025/2026 立春·冬至·夏至校验) */ +const SOLAR_TERM_MIN = [0, 21208, 42467, 63836, 85337, 107014, 128867, 150921, 173149, + 195551, 218072, 240693, 263343, 285989, 308563, 331033, 353350, 375494, 397447, + 419210, 440795, 462224, 483532, 504758]; + +function solarTermDate(y, n) { + // n: 0=小寒 … 23=冬至;返回该节气的公历日期 + const minutes = 525948.76 * (y - 1900) + SOLAR_TERM_MIN[n]; + const base = Date.UTC(1900, 0, 6, 2, 5, 0); + const d = new Date(base + minutes * 60000); + return { y: d.getUTCFullYear(), m: d.getUTCMonth() + 1, d: d.getUTCDate() }; +} + +function solarTermText(date) { + try { + const y = date.getFullYear(), m = date.getMonth() + 1, d = date.getDate(); + const i0 = (m - 1) * 2; + const a = solarTermDate(y, i0), b = solarTermDate(y, i0 + 1); + if (a.y === y && a.m === m && a.d === d) return SOLAR_TERM[i0]; + if (b.y === y && b.m === m && b.d === d) return SOLAR_TERM[i0 + 1]; + return ''; + } catch (e) { return ''; } +} + +let CLOCK_24H = localStorage.getItem('drouter_clock24') !== '0'; +let CLOCK_TIMER = null; + +function tickClock() { + const now = new Date(); + const h = now.getHours(), mi = String(now.getMinutes()).padStart(2, '0'), + s = String(now.getSeconds()).padStart(2, '0'); + let timeStr; + if (CLOCK_24H) { + timeStr = `${String(h).padStart(2, '0')}:${mi}:${s}`; + } else { + const ap = h < 12 ? '上午' : '下午'; + const h12 = h % 12 === 0 ? 12 : h % 12; + timeStr = `${ap} ${h12}:${mi}:${s}`; + } + const t = document.getElementById('clock-time'); + const dEl = document.getElementById('clock-date'); + const lEl = document.getElementById('clock-lunar'); + if (t) t.textContent = timeStr; + if (dEl) { + const term = solarTermText(now); + dEl.textContent = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${WEEK_CN[now.getDay()]}` + + (term ? ` · ${term}` : ''); + } + if (lEl) lEl.textContent = lunarText(now); +} + +function startClock() { + if (CLOCK_TIMER) clearInterval(CLOCK_TIMER); + tickClock(); + CLOCK_TIMER = setInterval(tickClock, 1000); + const box = document.getElementById('clock-box'); + if (box) box.onclick = () => { + CLOCK_24H = !CLOCK_24H; + try { localStorage.setItem('drouter_clock24', CLOCK_24H ? '1' : '0'); } catch (e) { /* ignore */ } + tickClock(); + toast('已切换为 ' + (CLOCK_24H ? '24 小时制' : '12 小时制'), 'ok', 1500); + }; +} + +/* ============================ 顶部与守护 ============================ */ +async function loadAll() { + let cfg, si, svc, ifc, bm, wp; + try { + [cfg, si, svc, ifc, bm, wp] = await Promise.all([ + api('/api/config'), api('/api/sysinfo'), api('/api/services'), + api('/api/ifaces'), api('/api/buildmode', { method: 'GET' }), + api('/api/webport')]); + } catch (e) { + cfg = si = svc = ifc = bm = wp = { ok: false, msg_cn: '加载失败:' + e.message }; + } + if (cfg.ok) S.cfg = cfg.data || {}; + if (bm && bm.ok) S.buildMode = !!((bm.data || {}).enabled); + if (wp && wp.ok) S.webPort = (wp.data || {}).port || 8443; + S.ifaces = ((ifc && ifc.data) || []).filter(x => !x.is_virtual && x.name !== 'lo'); + // 顶栏已改为时钟 / 农历,不再显示重复的主机名与内存占用(#12) + const v = document.getElementById('vnc-dot'); + if (v) { + v.innerHTML = S.buildMode + ? '构建保护模式' + : '可生效模式'; + } + // 构建模式全局横幅 + const tip = $('#newiface-tip'); + if (tip) { + if (S.buildMode) { + tip.classList.remove('hidden'); + tip.innerHTML = `
    构建保护模式已开启 —— 所有「应用」只会写入磁盘,不会启动服务、不会改变网络。 + 这样可以安全地把配置准备好,等你添加好 WAN 口并确认无误后,再一次性切换。
    + `; + } else { + tip.classList.add('hidden'); + } + } + // 任一请求未通过鉴权,直接打回登录页,避免页面卡在「正在载入…」 + const anyUnauth = [cfg, si, svc, ifc, bm, wp].some(r => r && r.code === 'UNAUTH'); + if (anyUnauth) { logout(true); throw new Error('UNAUTH'); } +} + +function boot() { + renderNav(); + startClock(); + loadAll().then(() => go('dash')).catch(e => { + // 鉴权失效已在 loadAll 内处理(打回登录页);其余错误给出可见提示,避免卡在「正在载入…」 + if (e && e.message === 'UNAUTH') return; + go('dash'); + toast('初始化未完全成功,部分数据可能未加载:' + (e && e.message || e), 'warn'); + }); + // 轻量心跳:仅用于检测后端是否在线(顶栏不再展示内存,避免与概览重复) + setInterval(async () => { + const r = await api('/api/health'); + if (!r.ok) toast('与后端连接中断,请检查管理服务是否在运行', 'err', 8000); + }, 60000); +} + +/* ============================ 保存 / 应用 ============================ */ + +function pageModules() { return PAGE_MODULES[S.page] || null; } + +function currentModule() { + const m = pageModules(); + return m && m.save.length ? m.save[0] : null; +} + +/* 端口转发:保存前把编辑中的规则同步进配置,并同时应用防火墙模块 */ +/* 访问控制 / 家长时间组(#8):不走 /api/config,用专属 /api/acl 接口 */ +async function saveAcl(live) { + const d = S.acl || {}; + const r = await api('/api/acl', { method: 'POST', body: { + op: 'save', live: !!live, enable: !!d.enable, + time_groups: d.time_groups || [], groups: d.groups || [], rules: d.rules || [], + } }); + return r; +} + +/* 内网文件共享 SMB/NFS(#9):同样走专属 /api/share 接口 */ +async function saveShare(live) { + const d = sb(); + const r = await api('/api/share', { method: 'POST', body: { + op: 'save', live: !!live, enable_smb: !!d.enable_smb, enable_nfs: !!d.enable_nfs, + samba: d.samba || {}, nfs: d.nfs || {}, + } }); + return r; +} + +/* ================== Docker / Docker Compose 面板(#10) ================== */ +const DK_STATE_TAG = { running: 'ok', exited: 'gray', created: 'info', paused: 'warn', + restarting: 'warn', removing: 'err', dead: 'err' }; +const DK_STATE_CN = { running: '运行中', exited: '已退出', created: '已创建', paused: '已暂停', + restarting: '重启中', removing: '删除中', dead: '异常' }; + +/* "12.3MiB / 1GiB" → {used, total}(字节),用于画占比条 */ +function dkMemParts(s) { + const toB = t => { + const m = /^([\d.]+)\s*([KMGT]?i?B)?$/i.exec(String(t || '').trim()); + if (!m) return 0; + const u = (m[2] || 'B').toUpperCase().replace('I', ''); + const k = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 }[u] || 1; + return parseFloat(m[1]) * k; + }; + const p = String(s || '').split('/'); + return { used: toB(p[0]), total: toB(p[1]) }; +} + +function dkBar(perc, kind) { + const n = Math.max(0, Math.min(100, parseFloat(String(perc || '0').replace('%', '')) || 0)); + return ` + ${n.toFixed(1)}%`; +} + +function dkContainerRow(c) { + const st = c.stat || {}; + const tag = DK_STATE_TAG[c.state] || 'gray'; + const ports = (c.ports || '').split(',').map(x => x.trim()).filter(Boolean); + return `
    + + ${esc(c.name)} + ${esc(DK_STATE_CN[c.state] || c.state)} +
    ${esc(c.image)} · ${esc(c.id)} + ${ports.length ? `
    ${esc(ports.join(' '))}` : ''} +
    + ${st.cpu ? dkBar(st.cpu, 'ok') : '—'} + ${st.mem_perc ? dkBar(st.mem_perc, 'info') : '—'} +
    ${esc(st.mem || '')}
    + ${esc(st.net || '—')}
    + ${esc(st.block || '')}
    + + ${c.state === 'running' + ? ` + ` + : ``} + + + ${c.state !== 'running' + ? `` : ''} + +
    `; +} + +async function viewDocker() { + $('#view').innerHTML = `

    Docker 管理面板

    +

    正在读取 Docker 引擎状态与容器列表…

    `; + const r = await api('/api/docker'); + if (!r.ok) { + $('#view').innerHTML = `

    读取失败

    +

    ${esc(r.msg_cn || '无法读取 Docker 状态')}

    `; + return; + } + S.docker = r.data || {}; + renderDocker(); +} + +function renderDocker() { + const D = S.docker || {}; + const pk = D.pkgs || {}; + const info = D.info || {}; + const svc = D.services || {}; + const conts = D.containers || []; + const imgs = D.images || []; + const nets = D.networks || []; + const vols = D.volumes || []; + const stacks = D.stacks || []; + const dOn = !!((pk.docker || {}).installed); + const cOn = !!((pk.compose || {}).installed); + const running = conts.filter(x => x.state === 'running').length; + const svcTags = Object.entries(svc).map(([n, st]) => + `${esc(n)} ${esc(st)}`).join(' '); + + const installBlock = (!dOn || !cOn) ? ` +
    +

    安装 Docker

    +

    检测到 Docker 或 Compose 尚未安装。执行下面的命令即可安装 Debian 13 官方源的版本:

    +

    ${esc(pk.install_cmd || 'apt-get install -y docker.io docker-compose')}

    +

    安装后执行 systemctl enable --now docker 启动。drouter 不会自动安装,避免影响你现有环境。

    +
    ` : ''; + + $('#view').innerHTML = ` +
    +

    Docker 管理面板 + 容器 ${running} / ${conts.length} 运行中 + 引擎 ${dOn ? '已安装' : '未安装'} + Compose ${cOn ? '已安装' : '未安装'}

    +

    ${esc(D.note || '')}

    +
    +
    服务状态${svcTags || '—'}
    +
    引擎版本${esc(info.version || '—')}
    +
    镜像 / 容器${esc((info.images || '0') + ' 镜像 · ' + (info.containers || '0') + ' 容器')}
    +
    存储驱动${esc(info.driver || '—')}
    +
    数据目录${esc(info.root || '—')}
    +
    Compose 命令${esc(D.compose_cmd || 'docker compose')}
    +
    + ${info.error ? `

    提示:${esc(info.error)}

    ` : ''} +
    + + + + + +
    +
    + + ${installBlock} + +
    +

    容器 + 每 5 秒自动刷新资源占用

    +

    CPU / 内存为 docker stats 的实时快照。

    +
    ${conts.length ? `
    +
    容器CPU内存网络 / 磁盘 IO操作
    + ${conts.map(dkContainerRow).join('')}
    ` + : '

    当前没有容器。可在下方 Compose 项目中创建,或先在 Web 终端里运行 docker run。

    '}
    +
    + +
    +

    镜像(${imgs.length})

    + ${imgs.length ? ` +
    +
    仓库:标签镜像 ID大小创建时间
    + ${imgs.map(i => `
    + ${esc(i.repo)}:${esc(i.tag)} + ${esc((i.id || '').slice(0, 12))} + ${esc(i.size)} + ${esc(String(i.created || '').replace(' +0800 CST', ''))} +
    `).join('')} +
    ` : '

    暂无镜像。

    '} +
    + +
    +

    网络与数据卷

    +
    +
    +

    网络(${nets.length})

    + ${nets.length ? nets.map(n => `
    ${esc(n.name)} + ${esc(n.driver)}${n.scope ? ' · ' + esc(n.scope) : ''}
    `).join('') + : '

    暂无网络

    '} +
    +
    +

    数据卷(${vols.length})

    + ${vols.length ? vols.map(v => `
    ${esc(v.name)} + ${esc(v.driver)}
    `).join('') + : '

    暂无数据卷

    '} +
    +
    +
    + +
    +

    Compose 项目(${stacks.length})

    +

    项目统一放在 ${esc(D.stack_dir || '/etc/drouter/docker/stacks')} 下, + 每个项目一个子目录,可一键启动 / 停止 / 重建。

    +
    ${dkStacksHTML(stacks)}
    +
    + + + +
    + +
    + + + + + + +
    +
    + +
    +

    docker run → docker-compose.yml 转换

    +

    把任意 docker run 命令粘贴进来,自动生成等价的 + compose 文件(本地解析,不联网、不执行命令)。支持多行续行、引号、新版空格写法。

    + +
    + + + + +
    +
    +
    +
    + + + `; + dkBind(); + dkAutoRefresh(); +} + +function dkStacksHTML(stacks) { + if (!stacks.length) return '

    还没有项目。用下面的编辑框创建一个。

    '; + return stacks.map(s => `
    + ${esc(s.name)} +
    ${esc(s.file)} · ${esc(s.mtime)}
    + + + + + +
    `).join(''); +} + +let DK_TIMER = null; +function dkAutoRefresh() { + if (DK_TIMER) clearInterval(DK_TIMER); + DK_TIMER = setInterval(async () => { + if (S.page !== 'docker') { clearInterval(DK_TIMER); DK_TIMER = null; return; } + const r = await api('/api/docker'); + if (!r.ok) return; + S.docker = r.data || {}; + // 只更新容器区,避免打断用户正在输入的文本框 + const box = $('#dk-conts'); + if (!box) return; + const conts = (S.docker || {}).containers || []; + box.innerHTML = conts.length ? `
    +
    容器CPU内存网络 / 磁盘 IO操作
    + ${conts.map(dkContainerRow).join('')}
    ` + : '

    当前没有容器。

    '; + dkBindRows(); + }, 5000); +} + +function dkShow(title, text) { + const c = $('#dk-out-card'); + if (!c) return; + $('#dk-out-title').textContent = title; + $('#dk-out').textContent = text || '(无输出)'; + c.style.display = ''; + c.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +async function dkOp(body, okMsg) { + const r = await api('/api/docker', { method: 'POST', body }); + toast(r.msg_cn || (r.ok ? '完成' : '失败'), r.ok ? 'ok' : 'err', 8000); + return r; +} + +async function dkReload() { + const r = await api('/api/docker'); + if (r.ok) { S.docker = r.data || {}; renderDocker(); } +} + +function dkBindRows() { + $$('#dk-conts [data-dk]').forEach(b => { + b.onclick = async () => { + const op = b.dataset.dk, id = b.dataset.id; + if (op === 'logs') { + const r = await api('/api/docker', { method: 'POST', body: { op: 'logs', id, tail: 300 } }); + dkShow('容器日志 · ' + id, r.ok ? (r.data || {}).text : (r.msg_cn || '读取失败')); + toast(r.msg_cn || '', r.ok ? 'ok' : 'err'); return; + } + if (op === 'inspect') { + const r = await api('/api/docker', { method: 'POST', body: { op: 'inspect', id } }); + dkShow('容器详情 · ' + id, r.ok ? (r.data || {}).text : (r.msg_cn || '读取失败')); + toast(r.msg_cn || '', r.ok ? 'ok' : 'err'); return; + } + if (op === 'rm') { + modal('删除容器', `

    将删除容器 ${esc(id)}。若容器未运行才会成功, + 数据卷不会被删除。

    确认继续?

    `, + async () => { await dkOp({ op: 'rm', id }, ''); dkReload(); }, '确认删除'); + return; + } + const cn = { start: '启动', stop: '停止', restart: '重启' }[op] || op; + if (op === 'stop') { + modal('停止容器', `

    将停止容器 ${esc(id)},其提供的服务会中断。

    确认继续?

    `, + async () => { await dkOp({ op, id }, ''); dkReload(); }, '确认停止'); + return; + } + const r = await dkOp({ op, id }, cn); + if (r.ok) dkReload(); + }; + }); +} + +function dkBind() { + dkBindRows(); + const on = (sel, fn) => { const e = $(sel); if (e) e.onclick = fn; }; + + on('#dk-refresh', () => dkReload()); + on('#dk-prune-containers', () => modal('清理已停止容器', + '

    会删除所有已停止的容器。不会影响正在运行的容器与镜像。

    确认继续?

    ', + async () => { await dkOp({ op: 'prune', what: 'containers' }); dkReload(); }, '清理')); + on('#dk-prune-images', () => modal('清理悬空镜像', + '

    会删除未被任何容器引用的悬空镜像(dangling)。

    确认继续?

    ', + async () => { await dkOp({ op: 'prune', what: 'images' }); dkReload(); }, '清理')); + on('#dk-prune-volumes', () => modal('清理无用数据卷', + '

    会删除没有被容器使用的数据卷,其中的数据会永久丢失。

    确认继续?

    ', + async () => { await dkOp({ op: 'prune', what: 'volumes' }); dkReload(); }, '确认清理')); + + $$('#dk-stacks [data-dkst]').forEach(b => { + b.onclick = async () => { + const act = b.dataset.dkst, name = b.dataset.name; + const mod = S.page; + if (act === 'load') { + const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_get', name } }); + if (!r.ok) { toast(r.msg_cn, 'err'); return; } + $('#dk-st-name').value = name; + $('#dk-st-yaml').value = (r.data || {}).content || ''; + toast('已载入项目 ' + name, 'ok'); + return; + } + if (act === 'delete') { + modal('删除项目', `

    会删除项目 ${esc(name)} 的配置文件目录。 + 正在运行的容器请先「停止」。

    请输入项目名以确认删除:

    + `, + async () => { + const inp = $('#dk-del-name'); + const v = inp ? inp.value.trim() : ''; + await dkOp({ op: 'stack_delete', name, confirm: v }); + dkReload(); + }, '确认删除'); + return; + } + const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_' + act, name } }); + if (r.ok) { + dkShow(`项目 ${name} · ${act}`, (r.data || {}).text || r.msg_cn); + } + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + dkReload(); + void mod; + }; + }); + + const stackName = () => ($('#dk-st-name').value || '').trim(); + const stackYaml = () => $('#dk-st-yaml').value || ''; + + on('#dk-st-new', () => { $('#dk-st-name').value = ''; $('#dk-st-yaml').value = ''; }); + on('#dk-st-save', async () => { + const r = await dkOp({ op: 'stack_save', name: stackName(), content: stackYaml() }); + if (r.ok) dkReload(); + }); + ['up', 'down', 'restart', 'pull'].forEach(k => { + on('#dk-st-' + k, async () => { + const n = stackName(); + if (!n) { toast('请先填写项目名', 'warn'); return; } + const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_' + k, name: n } }); + if (r.ok) dkShow(`项目 ${n} · ${k}`, (r.data || {}).text || r.msg_cn); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + dkReload(); + }); + }); + on('#dk-st-logs', async () => { + const n = stackName(); + if (!n) { toast('请先填写项目名', 'warn'); return; } + const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_ps', name: n } }); + dkShow(`项目 ${n} 状态`, r.ok ? ((r.data || {}).text || '(无容器)') : r.msg_cn); + toast(r.msg_cn, r.ok ? 'ok' : 'err'); + }); + + const doConvert = async (toStack) => { + const cmd = $('#dk-cv-in').value || ''; + const box = $('#dk-cv-msg'); const out = $('#dk-cv-out'); + if (!cmd.trim()) { toast('请输入 docker run 命令', 'warn'); return null; } + const r = await api('/api/docker', { method: 'POST', body: { + op: 'convert', cmd, service: $('#dk-cv-name').value.trim(), + version: $('#dk-cv-ver').value.trim() } }); + const d = r.data || {}; + if (!r.ok || !d.ok) { + box.innerHTML = `

    ${esc(d.msg_cn || r.msg_cn || '转换失败')}

    `; + out.innerHTML = ''; + toast(d.msg_cn || r.msg_cn || '转换失败', 'err'); + return null; + } + const warn = (d.warnings || []).length + ? `

    提示:${d.warnings.map(esc).join(';')}

    ` : ''; + box.innerHTML = `

    ${esc(d.msg_cn || '解析成功')} + · 镜像 ${esc(d.image)} · 服务名 ${esc(d.service)}

    ` + warn; + out.innerHTML = `
    + + ${toStack ? '' : ''}
    +
    ${esc(d.yaml || '')}
    `; + const cp = $('#dk-cv-copy'); + if (cp) cp.onclick = () => { + const ta = document.createElement('textarea'); + ta.value = d.yaml || ''; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); toast('已复制到剪贴板', 'ok'); } + catch (e) { toast('复制失败,请手动选中', 'warn'); } + document.body.removeChild(ta); + }; + const fl = $('#dk-cv-fill'); + if (fl) fl.onclick = () => { + $('#dk-st-name').value = d.service || ''; + $('#dk-st-yaml').value = d.yaml || ''; + toast('已填入下方 Compose 项目编辑器', 'ok'); + const el = $('#dk-st-yaml'); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + return d; + }; + on('#dk-cv-go', () => doConvert(false)); + on('#dk-cv-tostack', () => doConvert(true)); +} + +/* ============ Docker 引擎配置 daemon.json(#5) ============ + 页面只做三件事:把表单攒成一个 op=preview 发给后端拿真实代码、把代码显示出来、 + 把代码写进 /etc/docker/daemon.json。预览一律以后端为准 —— 前端再抄一份合并 + 逻辑迟早会和后端不一致,用户看到的代码和写进去的代码必须对得上。 */ +let DCFG = null; +let DCFG_PV_TIMER = null; +const DCFG_BAK_KEEP_TXT = '5'; + +function dcfgForm(op) { + const q = id => document.getElementById(id); + const mirrors = []; + $$('#dc-mirrors [data-dcm]').forEach(cb => { if (cb.checked) mirrors.push(cb.dataset.dcm); }); + return { + op: op || 'preview', + ipv6: q('dc-v6-on') ? !!q('dc-v6-on').checked : false, + fixed_cidr_v6: q('dc-v6-cidr') ? q('dc-v6-cidr').value.trim() : '', + experimental: q('dc-v6-exp') ? !!q('dc-v6-exp').checked : false, + mirrors: mirrors, + mirror_custom: q('dc-mirror-custom') ? q('dc-mirror-custom').value.trim() : '', + log_rotate: q('dc-log-on') ? !!q('dc-log-on').checked : false, + log_max_size: q('dc-log-size') ? q('dc-log-size').value.trim() : '10m', + log_max_file: q('dc-log-file') ? Number(q('dc-log-file').value) : 3, + live_restore: q('dc-live') ? !!q('dc-live').checked : false, + bip: q('dc-bip') ? q('dc-bip').value.trim() : '', + }; +} + +async function dcfgPreview() { + const box = document.getElementById('dc-code-pre'); + if (!box) return; + const r = await api('/api/dcfg', { method: 'POST', body: dcfgForm('preview') }); + if (!r.ok) { box.textContent = '(预览失败:' + (r.msg_cn || '未知错误') + ')'; return; } + const d = r.data || {}; + box.textContent = d.preview || ''; + const w = document.getElementById('dc-code-warn'); + if (w) { + const errs = d.errs || [], warns = d.warns || []; + w.innerHTML = errs.length + ? `

    ✗ ${errs.map(esc).join(';')}(这样保存会被拒绝)

    ` + : (warns.length + ? `

    ⚠ ${warns.map(esc).join(';')}

    ` : ''); + } +} + +function dcfgSchedulePreview() { + clearTimeout(DCFG_PV_TIMER); + DCFG_PV_TIMER = setTimeout(dcfgPreview, 250); +} + +async function viewDcfg() { + $('#view').innerHTML = ` +
    +

    Docker 引擎配置(daemon.json) +

    +

    Docker 的所有引擎级设置都写在 + /etc/docker/daemon.json 里。这一页把它变成可勾选的表单: + IPv6 一键开关、国内镜像源切换与测速,顺带把容器日志滚动、 + live-restore、默认网桥网段也纳管进来。

    +

    正在读取…

    +
    +
    +

    IPv6 一键开关

    +

    正在读取…

    +
    +
    +

    镜像源(国内加速)

    +

    正在读取…

    +
    +
    +

    日志滚动与其他

    +

    正在读取…

    +
    +
    +

    配置代码(就是将要写入的 daemon.json) +

    +

    下面这段代码随上面的选项实时变化,和点「保存」后真正落盘的内容 + 逐字节一致。你在文件里自己写的、本页不管的键(例如 + data-root)会原样保留在最后。

    +
    +
    (读取中…)
    +
    + + +
    +
    + +
    `; + $('#dc-reload').onclick = dcfgLoad; + $('#dc-save').onclick = () => dcfgSave(false); + $('#dc-save-restart').onclick = () => dcfgSave(true); + $('#dc-copy').onclick = () => { + const t = ($('#dc-code-pre') || {}).textContent || ''; + const ta = document.createElement('textarea'); + ta.value = t; document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); toast('已复制到剪贴板', 'ok'); } + catch (e) { toast('复制失败,请手动选中', 'warn'); } + document.body.removeChild(ta); + }; + dcfgLoad(); +} + +async function dcfgLoad() { + const r = await api('/api/dcfg', { method: 'POST', body: { op: 'status' } }); + if (!r.ok) { + $('#dc-state').innerHTML = `
    ${esc(r.msg_cn || '读取失败')}
    `; + return; + } + DCFG = r.data; + dcfgRender(); + dcfgPreview(); +} + +function dcfgRender() { + const d = DCFG || {}; + const c = d.cfg || {}; + const inst = !!d.installed; + const on = d.active === 'active'; + + // ---------- 状态 ---------- + $('#dc-state').innerHTML = ` + ${inst ? '' : `
    + 这台机器上还没有安装 Docker(docker 命令不存在)。 + 这不影响你在这里预先把配置写好 —— 文件会照常保存到 + /etc/docker/daemon.json,将来装好 Docker 一启动就直接生效。 + 要现在装,请去「系统 → 依赖自检与安装」或直接 + apt-get install -y docker.io docker-compose。
    `} + ${d.parse_error ? `
    + 现有的 daemon.json 无法解析:${esc(d.parse_error)}
    + 为安全起见本页不会覆盖它,请先手工修正或把文件挪走。
    ` : ''} + ${(d.unmanaged || []).length ? `
    + 现有文件里有 ${(d.unmanaged || []).length} 个本页不管的键: + ${(d.unmanaged || []).map(esc).join('、')}。 + 保存时它们会原样保留,不会被清掉。
    ` : ''} + ${d.build_mode ? '
    当前处于构建保护模式:可以写配置文件,但不会重启 Docker。
    ' : ''} +
    +
    Docker 引擎 + ${inst ? '已安装' : '未安装'} + ${on ? '运行中' : esc(d.active || '—')}
    +
    版本${esc(d.version || '—')}
    +
    开机自启${esc(d.enabled || '—')}
    +
    配置文件${esc(d.file || '—')}
    +
    文件是否存在${d.exists ? '存在' : '不存在(保存时创建)'}
    +
    `; + + // ---------- IPv6 ---------- + const v6on = !!c.ipv6; + $('#dc-v6').innerHTML = ` +
    + 6 +
    + 让容器网络支持 IPv6 +
    打开后 Docker 会给默认网桥分配一个 IPv6 网段, + 容器就能拿到 v6 地址、也能用 v6 互相访问。默认关闭 —— 因为开了不等于能上 v6 外网, + 还得上游路由器配合。
    +
    + + ${v6on ? '开启' : '关闭'} +
    +
    + + +
    +
    + +
    +
    + 打开/关闭会带来什么 +
      +
    • 会同时写入 ip6tables: true —— 没有它 Docker 不会为容器 + 生成 IPv6 的防火墙/NAT 规则,等于白开
    • +
    • 这里默认用 fd00::/8 私有段(ULA)。 + 容器拿到私有 v6 后,能互通但上不了 v6 外网,除非你在防火墙 IPv6 页 + 做了 NAT66 或上游下发了前缀委派并配好路由
    • +
    • 用公网段(2408: / 2001: 这类)时,还需要上游路由器做 NDP 代理, + 否则包出得去回不来,家用环境多半搞不定
    • +
    • experimental 只有 Docker 24 / 25 需要它来解锁 + ip6tables;26 起 ip6tables 已转正。装的是 26 及以上就别勾, + 个别版本见到这一项会直接拒绝启动
    • +
    • 改完必须重启 Docker 才生效,而且已有的容器要重建才会拿到 v6 地址
    • +
    +
    +
    +
    `; + + // ---------- 镜像源 ---------- + const sel = c.mirrors || []; + $('#dc-mirror').innerHTML = ` +

    镜像源就是「Docker Hub 的国内代理」:拉镜像时先问它要,能省下大量等待。 + 可以同时勾多个 —— Docker 会按顺序试,前一个失败就换下一个。

    +
    + ${(d.mirrors || []).map(m => { + const isOn = sel.indexOf(m.key) >= 0; + const ph = (m.url || '').indexOf('<') >= 0; + return ` +
    ${esc(m.note || '')}
    `; + }).join('')} +
    +
    + + +
    +

    测速从这台路由器自己发起,反映的是 Docker 拉镜像时的真实链路 + (已加 --noproxy,不会被环境里的代理掩盖)。 + HTTP 200 或 401 都表示通 —— + Registry 的探针本来就要求带凭证,回 401 是正常应答。

    +
    `; + + // ---------- 其他 ---------- + $('#dc-misc').innerHTML = ` +
    + L +
    + 容器日志滚动(强烈建议开) +
    限制每个容器日志文件的大小和份数。 + 不开的话,一个死循环打印日志的容器能在几小时内把小硬盘写满 —— + 这是「路由器磁盘莫名爆满」最常见的原因。
    +
    + + + +
    +
    +
    +
    + R +
    + live-restore(重启 dockerd 时保住容器) +
    开启后,重启 / 升级 Docker 守护进程时 + 正在运行的容器不会被一起杀掉。对一台跑着常驻服务的路由器来说很值。
    +
    + +
    +
    +
    +
    + B +
    + 默认网桥网段(bip) +
    Docker 默认用 172.17.0.0/16。 + 如果你的局域网或 VPN 正好也是这个段,容器网络就会和它打架, + 表现为「装了 Docker 之后某些内网地址访问不了」。留空表示不动,用 Docker 默认值。
    +
    + +
    +
    +
    `; + + // ---------- 备份还原 ---------- + const bks = d.backups || []; + $('#dc-bak').innerHTML = ` +
    + 每次保存都会自动备份旧文件(保留最近 ${DCFG_BAK_KEEP_TXT} 份, + 目录 ${esc(d.bak_dir || '')}) + ${bks.length ? ` + ` : '(暂无备份)'} +
    `; + + dcfgBind(); + const out = $('#dc-out'); + if (out) { out.classList.add('hidden'); out.innerHTML = ''; } +} + +function dcfgBind() { + ['dc-v6-on', 'dc-v6-exp', 'dc-log-on', 'dc-live'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('change', dcfgSchedulePreview); + }); + ['dc-v6-cidr', 'dc-mirror-custom', 'dc-log-size', 'dc-log-file', 'dc-bip'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', dcfgSchedulePreview); + }); + $$('#dc-mirrors [data-dcm]').forEach(cb => { + cb.addEventListener('change', dcfgSchedulePreview); + }); + const ula = document.getElementById('dc-v6-ula'); + if (ula) ula.onclick = async () => { + const r = await api('/api/dcfg', { method: 'POST', body: { op: 'ula' } }); + if (!r.ok) { toast(r.msg_cn || '生成失败', 'err'); return; } + const el = document.getElementById('dc-v6-cidr'); + if (el) { el.value = (r.data || {}).ula || ''; dcfgPreview(); } + }; + const mt = document.getElementById('dc-mt'); + if (mt) mt.onclick = async () => { + const box = $('#dc-mt-out'); + box.innerHTML = '

    正在逐个探测,最多约一分钟…

    '; + const body = dcfgForm('mirror_test'); + const r = await api('/api/dcfg', { method: 'POST', body }); + if (!r.ok) { box.innerHTML = `

    ${esc(r.msg_cn)}

    `; return; } + const rows = ((r.data || {}).results) || []; + box.innerHTML = `
    +
    镜像源结果耗时
    + ${rows.map(t => `
    + ${esc(t.name)}
    ${esc(t.url)}
    + ${esc(t.reason || (t.ok ? '可用' : '不可用'))} + ${t.code ? ' · ' + esc(t.code) : ''} + ${t.ms ? esc(t.ms + ' ms') : '—'} +
    `).join('')}
    +

    先把最快的那个勾上(可以多选几个当备用),再回上面点保存。

    `; + }; + const bg = document.getElementById('dc-bak-go'); + if (bg) bg.onclick = () => { + const sel = document.getElementById('dc-bak-sel'); + const f = sel ? sel.value : ''; + if (!f) { toast('请先选择一份备份', 'warn'); return; } + modal('还原 daemon.json', `

    会用备份 ${esc(f)} 覆盖当前的 + /etc/docker/daemon.json。

    +

    当前文件会先另存一份新备份,不会丢。

    确认继续?

    `, + async () => { + const r = await api('/api/dcfg', { method: 'POST', body: { op: 'restore', file: f } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + if (r.ok) dcfgLoad(); + }, '确认还原'); + }; +} + +async function dcfgSave(thenRestart) { + const r = await api('/api/dcfg', { method: 'POST', body: dcfgForm('save') }); + const box = $('#dc-out'); + box.classList.remove('hidden'); + box.innerHTML = `
    ${esc(r.msg_cn || '')}
    `; + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + if (!r.ok) return; + const d = r.data || {}; + if (d.content) { + const pre = $('#dc-code-pre'); + if (pre) pre.textContent = d.content; // 用服务端真实写入的内容校准预览 + } + dcfgLoad(); + if (thenRestart) dcfgAskRestart(); +} + +function dcfgAskRestart() { + const d = DCFG || {}; + if (!d.installed) { + toast('这台机器上没有 Docker,无需重启。配置已保存,装好 Docker 后自动生效', 'warn', 8000); + return; + } + if (d.build_mode) { + toast('当前处于构建保护模式:配置已写入 /etc/docker/daemon.json,' + + '但不会重启 Docker(避免影响正在运行的局域网与 RealVNC 会话)', 'warn', 9000); + return; + } + modal('重启 Docker 使配置生效', `

    重启会中断所有正在运行的容器(通常几十秒内恢复), + 并且会重建 docker0 网桥、重新插入 iptables / nftables 链。

    +

    对一台正在跑 easytier / cloudflared / AdGuardHome 这类常驻服务的路由器来说, + 这意味着相关服务会有一次短暂中断。

    +

    如果容器没有设 restart: unless-stopped, + 重启后需要你手工拉起来。

    确认继续?

    `, + async () => { + const r = await api('/api/dcfg', { method: 'POST', body: { op: 'restart', confirm: true } }); + const box = $('#dc-out'); + box.classList.remove('hidden'); + box.innerHTML = `
    ${esc(r.msg_cn || '')}
    `; + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) dcfgLoad(); + }, '确认重启 Docker'); +} + +/* ============ 打印服务 CUPS / USB RAW 直通(#7) ============ + 两种模式抢的是同一个 USB 打印机,所以必须互斥。页面把「当前实际状态」和 + 「推荐值」分开显示:默认值只是建议,改不改由用户点保存决定。 */ +let PRINT = null; + +async function viewPrint() { + $('#view').innerHTML = ` +
    +

    打印服务 +

    +

    把这台路由器变成打印服务器,让局域网里的电脑、手机直接打。 + 两种模式互斥二选一 —— 它们抢的是同一个 USB 打印机。

    +

    正在读取…

    +
    +
    +

    工作模式

    +

    正在读取…

    +
    +
    +

    CUPS 模式设置

    +

    正在读取…

    +
    + +
    + +
    +
    +

    打印队列

    +

    正在读取…

    +
    +
    +

    添加队列

    +

    正在读取…

    +
    +
    +

    USB 打印机与可接入设备

    +

    正在读取…

    +
    `; + $('#pt-reload').onclick = printLoad; + $('#pt-save').onclick = printSave; + printLoad(); +} + +async function printLoad() { + const r = await api('/api/print', { method: 'POST', body: { op: 'status' } }); + if (!r.ok) { + $('#pt-state').innerHTML = `
    ${esc(r.msg_cn || '读取失败')}
    `; + return; + } + PRINT = r.data; + printRender(); +} + +function printSvcTag(s) { + const on = (s || {}).active === 'active'; + return `${on ? '运行中' : '已停止'} + ${esc((s || {}).enabled || '')}`; +} + +function printRender() { + const d = PRINT || {}; + const cfg = d.cfg || {}; + const c = cfg.cups || {}; + const raw = cfg.raw || {}; + const mode = cfg.mode || 'cups'; + const inst = !!d.installed; + + // ---------- 状态 ---------- + $('#pt-state').innerHTML = ` + ${inst ? '' : `
    + CUPS 尚未安装。请先到「系统 → 依赖自检与安装」安装,或执行 + apt-get install -y ${esc((d.need_install || []).join(' '))}。
    `} + ${d.build_mode ? '
    当前处于构建保护模式:保存只会写配置,不会启停打印服务。
    ' : ''} + ${inst && (d.listen || '').indexOf('127.0.0.1') >= 0 && mode === 'cups' + ? `
    + CUPS 现在只监听 127.0.0.1:631 —— 局域网里的设备连不上。 + 在下面把「允许局域网访问」打开并保存即可。
    ` : ''} +
    +
    CUPS${inst ? printSvcTag(d.cups) : '未安装'}
    +
    cups-browsed${printSvcTag(d.browsed)}
    +
    RAW 直通${printSvcTag(d.raw)}
    +
    CUPS 版本${esc(d.cups_version || '—')}
    +
    631 实际监听${esc(d.listen || '未监听')}
    +
    avahi 发现服务 + ${d.avahi_active ? '运行中' : '未运行'}
    +
    管理页面${esc(d.cups_url || '')}
    +
    `; + + // ---------- 模式 ---------- + const modes = [ + { k: 'cups', n: 'CUPS 打印服务器', d: '功能最全:免驱打印、AirPrint / Mopria、队列管理、Web 管理界面' }, + { k: 'raw', n: 'USB RAW 直通', d: '最省资源:把 USB 打印机原样映射成 TCP 9100,驱动全在客户端' }, + { k: 'off', n: '关闭打印服务', d: '停掉所有打印相关服务,631 / 9100 都不再监听' }, + ]; + $('#pt-mode').innerHTML = ` +
    + ${modes.map(m => ``).join('')} +
    + ${(d.usb_printers || []).length === 0 && mode === 'raw' + ? `
    当前没有检测到 USB 打印机, + RAW 直通模式没有设备可用。这是一台虚拟机 —— 需要先把 USB 打印机在 PVE 里 + 直通给这台虚拟机,插上之后设备节点才会出现。
    ` : ''} +
    + 两种模式到底差在哪 +
      +
    • CUPS 模式:手机和电脑不用装驱动,靠 IPP Everywhere / AirPrint / + Mopria 直接发现并打印;还能把网络上已有的打印机收进来再共享出去
    • +
    • RAW 直通模式:路由器完全不碰打印数据,客户端装自己的原厂驱动。 + 老式打印机、驱动装不上的机型用它最省心,也最省路由器资源
    • +
    • 两者不能同时开:CUPS 侧靠 ipp-usb(libusb)拿设备,RAW 侧靠内核 + usblp 模块的 /dev/usb/lp0,抢同一个设备的结果是谁都打不出来
    • +
    • 切换模式时本页会自动把另一边停干净,不用你手工去 systemctl
    • +
    +
    `; + + // ---------- CUPS 设置 ---------- + const sw = (id, on) => + ``; + const items = {}; + (d.items || []).forEach(it => { items[it.key] = it; }); + const card = (key, ctrl) => { + const it = items[key] || { name: key, why: '', impact: [] }; + return `
    + i +
    + ${esc(it.name)} +
    ${esc(it.why)}
    +
    ${ctrl}
    + ${(it.impact || []).length ? `
    + 打开/关闭会带来什么 +
      + ${it.impact.map(x => `
    • ${esc(x)}
    • `).join('')}
    ` : ''} +
    `; + }; + $('#pt-cups').innerHTML = ` +
    + ${card('listen', `
    + + ${ + c.listen === 'lan' ? '开放到直连网段' : '仅本机'}
    `)} + ${card('share', `
    + ${sw('pt-share', !!c.share)} + 共享后其他设备才能发现这台打印机
    `)} + ${card('web_iface', `
    + ${sw('pt-web', !!c.web_iface)}
    `)} + ${card('remote_admin', `
    + ${sw('pt-radmin', !!c.remote_admin)} + ${c.remote_admin ? '已开放' : '默认关闭'}
    `)} + ${card('browsed', `
    + ${sw('pt-browsed', !!c.browsed)}
    `)} +
    +
    + + + +
    +

    上面三项只在 RAW 直通模式下生效。用系统自带的 + socat 实现,不需要额外装包。

    + ${d.socat ? '' : '
    系统里没有 socat,RAW 直通模式无法工作。请到「依赖自检与安装」安装。
    '}`; + + // ---------- 队列 ---------- + const qs = d.queues || []; + $('#pt-queues').innerHTML = qs.length ? `
    +
    队列设备地址状态操作
    + ${qs.map(q => `
    + ${esc(q.name)} + ${q.default ? '默认' : ''} + ${q.not_saved_yet + ? '未落盘' : ''} +
    ${esc(q.info || q.make_model || '')}
    + ${esc(q.uri || '—')} + + ${q.shared ? '已共享' : '未共享'} + ${q.accepting ? '接受任务' : '已暂停'} +
    ${esc(q.state || '')}
    + + + + + + +
    `).join('')}
    +
    + +
    +
    ` + : '

    还没有任何打印队列。可以在下面添加一个,' + + '或者打开 cups-browsed 让它自动发现网络上的打印机。

    '; + + // ---------- 添加队列 ---------- + const devs = (d.devices || []).filter(x => x.uri && !x.uri.startsWith('beh')); + $('#pt-add').innerHTML = ` +
    + + +
    +
    + + + +
    +

    ${(d.drivers || []).map(x => esc(x.note)).join(' ') || ''}

    +
    + +
    `; + + // ---------- USB 与设备 ---------- + const usbs = d.usb_printers || []; + $('#pt-usb').innerHTML = ` + ${usbs.length ? `

    检测到 ${usbs.length} 台 USB 打印机:

    + ${usbs.map(u => `
    ${esc(u.name)} + ${esc(u.node || '')} ${u.vid ? '· ' + esc(u.vid + ':' + u.pid) : ''}
    `).join('')}` + : `
    没有检测到 USB 打印机。 + 这是一台 PVE 虚拟机 —— 需要先把 USB 打印机在 PVE 的硬件设置里 + 直通(USB Host Device)给这台虚拟机,插好之后这里才会出现设备节点。 + ${d.usblp_loaded ? '' : '(内核的 usblp 模块当前未加载,插上设备后会自动加载)'}
    `} +

    网络上发现的设备(lpinfo -v):

    + ${(d.devices || []).length ? `
    +
    类型地址
    + ${(d.devices || []).map(x => `
    + ${esc(x.kind)} + ${esc(x.uri)}
    `).join('')}
    ` + : '

    (无)

    '}`; + + printBind(); + const out = $('#pt-out'); + if (out) { out.classList.add('hidden'); out.innerHTML = ''; } +} + +function printBind() { + $$('[data-ptq]').forEach(b => { + b.onclick = async () => { + const act = b.dataset.ptq, n = b.dataset.n; + if (act === 'del') { + modal('删除打印队列', `

    将删除队列 ${esc(n)}。

    +

    队列里未完成的任务会一起清掉。确认继续?

    `, async () => { + const r = await api('/api/print', { method: 'POST', + body: { op: 'queue_del', name: n, confirm: n } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000); + if (r.ok) printLoad(); + }, '确认删除'); + return; + } + if (act === 'test') { + const r = await api('/api/print', { method: 'POST', body: { op: 'testpage', name: n } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + return; + } + const body = { op: 'queue_set', name: n }; + if (act === 'share') body.share = b.dataset.v === '1'; + if (act === 'accept') body.accept = true; + if (act === 'reject') body.accept = false; + if (act === 'default') body.default = true; + const r = await api('/api/print', { method: 'POST', body }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000); + if (r.ok) printLoad(); + }; + }); + const add = $('#pt-add-go'); + if (add) add.onclick = async () => { + const name = ($('#pt-new-name').value || '').trim(); + const uri = ($('#pt-new-uri').value || '').trim(); + if (!name) { toast('请填写队列名', 'warn'); return; } + if (!uri) { toast('请填写设备地址', 'warn'); return; } + const r = await api('/api/print', { method: 'POST', body: { + op: 'queue_add', name, uri, + driver: $('#pt-new-drv').value, + info: ($('#pt-new-info').value || '').trim(), + location: ($('#pt-new-loc').value || '').trim(), + share: true } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + if (r.ok) printLoad(); + }; + const jobs = $('#pt-jobs'); + if (jobs) jobs.onclick = async () => { + const r = await api('/api/print', { method: 'POST', body: { op: 'jobs' } }); + const box = $('#pt-jobs-out'); + if (!r.ok) { box.innerHTML = `

    ${esc(r.msg_cn)}

    `; return; } + const js = (r.data || {}).jobs || []; + box.innerHTML = js.length ? `
    +
    任务用户大小操作
    + ${js.map(j => `
    + ${esc(j.id)} + ${esc(j.user)} + ${esc(j.size)} +
    `).join('')}
    ` + : '

    当前没有打印任务。

    '; + $$('[data-ptj]').forEach(b => { + b.onclick = async () => { + const r2 = await api('/api/print', { method: 'POST', body: { op: 'job_cancel', id: b.dataset.ptj } }); + toast(r2.msg_cn, r2.ok ? 'ok' : 'err', 6000); + if (r2.ok) jobs.click(); + }; + }); + }; +} + +async function printSave() { + const q = id => document.getElementById(id); + const mode = ($$('input[name="pt-mode"]').find(x => x.checked) || {}).value || 'cups'; + const r = await api('/api/print', { method: 'POST', body: { + op: 'save', mode, + cups: { + listen: q('pt-listen') ? q('pt-listen').value : undefined, + share: q('pt-share') ? q('pt-share').checked : undefined, + web_iface: q('pt-web') ? q('pt-web').checked : undefined, + remote_admin: q('pt-radmin') ? q('pt-radmin').checked : undefined, + browsed: q('pt-browsed') ? q('pt-browsed').checked : undefined, + }, + raw: { + device: q('pt-raw-dev') ? q('pt-raw-dev').value.trim() : undefined, + port: q('pt-raw-port') ? Number(q('pt-raw-port').value) : undefined, + bind: q('pt-raw-bind') ? q('pt-raw-bind').value.trim() : undefined, + }, + } }); + const box = $('#pt-out'); + box.classList.remove('hidden'); + const errs = ((r.data || {}).errors) || []; + box.innerHTML = `
    + ${esc(r.msg_cn || '')}
    + ${errs.length ? `
      + ${errs.map(e => `
    • ${esc(e)}
    • `).join('')}
    ` : ''}`; + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) printLoad(); +} + +/* ============ AC / AP 管理中心(OpenSOHO,#10) ============ */ +let OH = null; +let OH_PROBE = null; + +async function viewOpensoho() { + $('#view').innerHTML = ` +
    +

    AC / AP 管理中心 +

    +

    用 OpenSOHO 集中管理家里的 OpenWRT 无线 AP:Wi-Fi、VLAN、PoE 一次配好统一下发。 + AP 侧装 openwisp-config 并用下面的共享密钥注册进来。

    +

    正在读取…

    +
    +
    +

    安装

    +

    正在读取…

    +
    +
    +

    共享密钥

    +

    正在读取…

    +
    +
    +

    基本设置

    +

    正在读取…

    +
    + +
    + +
    +
    +

    AP 与客户端概况 +

    +

    点「读取」从 OpenSOHO 拉一次实时数据。

    +
    +
    +

    卸载

    +

    正在读取…

    +
    `; + $('#oh-reload').onclick = ohLoad; + $('#oh-save').onclick = ohSave; + $('#oh-probe').onclick = ohProbe; + ohLoad(); +} + +async function ohLoad() { + const r = await api('/api/opensoho', { method: 'POST', body: { op: 'status' } }); + if (!r.ok) { + $('#oh-state').innerHTML = `
    ${esc(r.msg_cn || '读取失败')}
    `; + return; + } + OH = r.data; + OH_PROBE = null; + ohRender(); +} + +function ohSvcTag(s) { + const on = (s || {}).active === 'active'; + return `${on ? '运行中' : '已停止'} + ${esc((s || {}).enabled || '')}`; +} + +function ohRender() { + const d = OH || {}; + const c = d.cfg || {}; + const inst = !!d.installed; + const ip = d.lan_ip || '127.0.0.1'; + + // ---------- 状态 ---------- + $('#oh-state').innerHTML = ` + ${inst ? '' : `
    + OpenSOHO 还没装。它不是 apt 包,请在下面「安装」里装。
    `} + ${d.build_mode ? '
    当前处于构建保护模式:安装与保存只会写盘,不会真的启动服务。
    ' : ''} + ${inst && d.healthy === false && (d.service || {}).active === 'active' + ? `
    + systemd 说服务在跑,但 /api/health 没应答(${esc(d.health_msg || '')})。 + 多半是启动后立刻退出了,看 journalctl -u opensoho。
    ` : ''} +
    +
    OpenSOHO${inst ? '已安装' : '未安装'}
    +
    版本${esc(d.version || '—')}
    +
    服务${ohSvcTag(d.service)}
    +
    健康检查 + ${d.healthy ? '正常' : '无应答'}
    +
    实际监听${(d.listen || []).length ? esc(d.listen.join('、')) : '未监听'}
    +
    控制台${d.console_url && inst + ? `${esc(d.console_url)}` : '—'}
    +
    +

    ${esc(d.note || '')}

    `; + + // ---------- 安装 ---------- + const cands = d.local_candidates || []; + $('#oh-install').innerHTML = ` +
    + +
    +
    + +
    + + + +
    +

    + 先在「Web 终端 / 文件」把二进制或 zip 传到 /tmp, + 再在上面选中它。升级走同一条路径,数据目录会原样保留,已注册的 AP 不受影响。

    +
    `; + $('#oh-inst-net').onclick = () => ohInstall(''); + $('#oh-inst-local').onclick = () => { + const sel = document.getElementById('oh-local').value; + const typed = (document.getElementById('oh-local-path').value || '').trim(); + const path = typed || sel; + if (!path) { toast('请先选择一个文件或填写路径', 'err'); return; } + ohInstall(path); + }; + + // ---------- 共享密钥 ---------- + $('#oh-secret').innerHTML = inst ? ` +
    + + + +
    + ${d.secret_weak ? '
    当前共享密钥偏短,建议重新生成一个。
    ' : ''} +

    + 在 AP 的 OpenWRT 上装 openwisp-config,把控制器地址填 + http://${esc(ip)}:${esc(String(c.port || 8090))}、共享密钥填上面这串。 + 重新生成后,已注册的 AP 都要重填一次。

    + ${d.admin_email ? `

    + 控制台管理员:${esc(d.admin_email)} + 密码 ${esc(d.admin_password || '—')}(进控制台用它登录)

    ` : ''} +

    配置静态加密: + ${d.enc_ok ? '已启用' : '未启用'} + Wi-Fi 密码等敏感项在磁盘上是${d.enc_ok ? '加密' : '明文'}存放的。

    ` : + '

    安装后这里会显示 AP 注册用的共享密钥。

    '; + if (inst) { + const cp = document.getElementById('oh-secret-copy'); + if (cp) cp.onclick = () => { + navigator.clipboard.writeText(d.secret || '').then(() => toast('已复制', 'ok'), () => toast('复制失败', 'err')); + }; + const rg = document.getElementById('oh-secret-regen'); + if (rg) rg.onclick = ohRegenSecret; + } + + // ---------- 基本设置 ---------- + $('#oh-cfg').innerHTML = inst ? ` +
    +
    监听地址 +
    +
    端口 +
    +
    +
    + + + +
    +

    改监听地址或端口会重建 systemd 单元并重启服务, + 已注册的 AP 需要用新地址重新注册。

    ` : + '

    安装后可在这里改监听地址、端口与几个开关。

    '; + + // ---------- 卸载 ---------- + $('#oh-uninstall').innerHTML = inst ? ` +
    + + +
    +

    默认保留数据目录,下次重装直接接着用。

    ` : + '

    尚未安装。

    '; + const ub = document.getElementById('oh-uninst'); + if (ub) ub.onclick = ohUninstall; + + // ---------- 概况 ---------- + ohRenderStats(); +} + +function ohRenderStats() { + const box = document.getElementById('oh-stats'); + if (!box) return; + const d = OH_PROBE; + if (!d) { + box.innerHTML = '

    点右上角「读取」从 OpenSOHO 拉一次实时数据。

    '; + return; + } + const stats = d.stats || []; + const devs = d.devices || []; + box.innerHTML = ` +
    + ${stats.map(s => `
    ${esc(s.name)}${ + s.count === null ? '读取失败' : esc(String(s.count)) + }
    `).join('')} +
    + ${devs.length ? `
    + + + + ${devs.map(x => ` + + + + + + `).join('')} +
    名称MAC地址型号状态
    ${esc(x.name || '—')}${esc(x.mac || '—')}${esc(x.ip || '—')}${esc(x.model || '—')}${x.enabled ? '已启用' : '已停用'}
    ` : '

    还没有 AP 注册进来。

    '}`; +} + +async function ohInstall(path) { + const r = await api('/api/opensoho', { method: 'POST', body: { op: 'install', local_path: path || '' } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 12000); + if (r.ok) ohLoad(); +} + +async function ohUninstall() { + const purge = (document.getElementById('oh-purge') || {}).checked; + if (!window.confirm(purge + ? '确定卸载 OpenSOHO 并删除数据目录?AP 清单与配置将一并清除,不可恢复。' + : '确定卸载 OpenSOHO?数据目录会保留,下次重装可直接使用。')) return; + const r = await api('/api/opensoho', { method: 'POST', body: { op: 'uninstall', purge } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) ohLoad(); +} + +async function ohSave() { + const q = id => document.getElementById(id); + const r = await api('/api/opensoho', { method: 'POST', body: { + op: 'save', + cfg: { + bind: q('oh-bind') ? q('oh-bind').value : undefined, + port: q('oh-port') ? Number(q('oh-port').value) : undefined, + enable_new_devices: q('oh-newdev') ? q('oh-newdev').checked : undefined, + encryption: q('oh-enc') ? q('oh-enc').checked : undefined, + autostart: q('oh-auto') ? q('oh-auto').checked : undefined, + }, + } }); + const box = $('#oh-out'); + box.classList.remove('hidden'); + box.innerHTML = `
    ${esc(r.msg_cn || '')}
    `; + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) ohLoad(); +} + +async function ohRegenSecret() { + if (!window.confirm('重新生成共享密钥后,已注册的 AP 都要用新密钥重新注册一次。确定继续?')) return; + const r = await api('/api/opensoho', { method: 'POST', body: { op: 'secret' } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) ohLoad(); +} + +async function ohProbe() { + const r = await api('/api/opensoho', { method: 'POST', body: { op: 'probe' } }); + if (!r.ok) { + toast(r.msg_cn || '读取失败', 'err', 9000); + return; + } + OH_PROBE = r.data; + ohRenderStats(); + toast(r.msg_cn, 'ok'); +} + +/* ============ CA 证书管理 + SSL 测试(#11) ============ */ +let CA_DATA = null; +let CA_TEST = null; + +async function viewCa() { + $('#view').innerHTML = ` +
    +

    管理后台正在用的证书 +

    +

    浏览器打开 https://本机:端口 时看到的就是这张。 + 默认是一张自签证书 —— 能加密,但浏览器不认识它,所以会提示「不安全」。 + 想消除提示,在下面建一个自己的 CA,用它签一张服务器证书,再把根证书装进你的设备。

    +

    正在读取…

    +
    + +
    +
    +
    +

    证书库

    +

    CA 证书用来签发;服务器证书可以直接部署给管理后台; + 证书签名请求(CSR)是拿去给公共证书机构签名的半成品。

    +

    正在读取…

    +
    +
    +

    ① 创建自己的 CA

    +

    CA 是「发证机构」。建好之后,你签的每一张证书都属于同一个信任体系 —— + 只要把这张 CA 装进手机 / 电脑一次,之后签的所有证书都不再提示不安全。

    +
    + + + + +
    +
    + +
    +
    +
    +

    ② 用 CA 签一张服务器证书

    +

    签好的证书会带上你填的域名和 IP。浏览器访问时,地址必须出现在 + SAN(使用者可选名称)里,否则照样报「名称不匹配」—— 这是最常被忽略的一步。

    +
    + + +
    +
    + + +
    +
    + + + +
    +
    +
    +

    ③ 导入已有证书

    +

    从证书机构买的、别处签好的,直接粘进来(或填这台机器上的文件路径)。 + 证书和私钥可以粘在同一个框里,会自动拆开。

    +
    + + +
    + + +
    + +
    +
    + +
    +
    +
    +

    ④ 生成证书签名请求(CSR)

    +

    如果你有正式域名并想用公共证书机构签发的证书:在这里生成 CSR, + 把它提交给证书机构,签回来的证书回到上面「导入」里补全即可。

    +
    + + + +
    +
    + +
    + +
    +
    +

    SSL / TLS 体检

    +

    对任意主机的任意端口做一次真实握手,看协议版本、加密套件、证书链和校验结论。 + 默认测本机管理后台;也可以测局域网里其它设备、或者外网站点。

    +
    + + + +
    +
    + + + +
    +

    填好主机点「开始测试」。

    +
    `; + $('#ca-reload').onclick = caLoad; + $('#ca-restore').onclick = caRestore; + $('#ca-nc-go').onclick = caNewCA; + $('#ca-sg-go').onclick = caSign; + $('#ca-im-go').onclick = caImport; + $('#ca-cs-go').onclick = caGenCsr; + $('#ca-ts-go').onclick = () => caSslTest(false); + $('#ca-ts-self').onclick = () => caSslTest(true); + caLoad(); +} + +async function caLoad() { + const r = await api('/api/ca', { method: 'POST', body: { op: 'status' } }); + if (!r.ok) { + $('#ca-web').innerHTML = `
    ${esc(r.msg_cn || '读取失败')}
    `; + return; + } + CA_DATA = r.data || {}; + caRender(); +} + +function caDaysTag(days) { + // 到期阈值由后端下发,前端不另写一份 —— 免得改了后端这边悄悄走偏 + const warn = (CA_DATA || {}).warn_days || 30; + if (days === null || days === undefined) return '未知'; + if (days < 0) return `已过期 ${esc(String(-days))} 天`; + if (days <= warn) return `剩 ${esc(String(days))} 天`; + return `剩 ${esc(String(days))} 天`; +} + +function caRender() { + const d = CA_DATA || {}; + const w = d.web || {}; + const items = d.items || []; + + // ---------- 正在用的证书 ---------- + $('#ca-web').innerHTML = ` + ${d.build_mode ? '
    当前处于构建保护模式:部署只会写盘,不会重启 Web 服务。
    ' : ''} + ${(d.index_broken) ? '
    证书库索引文件损坏,列表可能不完整。新建证书会重建它。
    ' : ''} + ${w.installed ? '' : '
    ' + + '管理后台现在没有可用的证书:' + esc(w.err || '证书文件缺失或无法解析') + '。
    '} + ${(w.days_left !== null && w.days_left !== undefined && w.days_left <= (d.warn_days || 30)) + ? `
    + 这张证书${w.days_left < 0 ? '已经过期' : '快到期了'}(剩 ${esc(String(w.days_left))} 天)。 + 建议现在换一张,否则浏览器会彻底拒绝连接。
    ` : ''} +
    +
    证书主题${esc(w.subject || '—')}
    +
    颁发者${esc(w.issuer || '—')}
    +
    有效期${esc(w.not_before || '—')} → ${esc(w.not_after || '—')}
    +
    剩余${caDaysTag(w.days_left)}
    +
    类型 + ${w.self_signed ? '自签(浏览器会提示)' + : '由 CA 签发'}
    +
    算法${esc(w.sig_alg || w.key_alg || '—')}
    +
    SHA256 指纹${esc(w.fingerprint || '—')}
    +
    在证书库里${w.in_lib + ? `是(${esc(w.in_lib)})` + : '否(自动生成,未入库)'}
    +
    + ${(w.san || []).length ? `

    + 覆盖的名称:${esc((w.san || []).join('、'))}

    ` : ''} + ${(d.last) ? `
    + 上次部署(${esc(d.last.ts || '')}):${esc(d.last.msg || '')}
    ` : ''}`; + + // ---------- 证书库 ---------- + if (!items.length) { + $('#ca-list').innerHTML = '

    证书库是空的。先在下面「① 创建自己的 CA」建一张。

    '; + } else { + $('#ca-list').innerHTML = ` +
    + + + + + ${items.map(x => ` + + + + + + + + + `).join('')} +
    名称类型主题 / CN有效期至剩余私钥状态操作
    ${esc(x.name || '—')}${esc(x.kind_cn || x.kind || '')}${esc(x.subject || x.err || '—')}${esc(x.not_after || '—')}${caDaysTag(x.days_left)}${x.has_key ? '有' : '无'}${x.in_use ? '正在用' + : (x.can_sign ? '可签发' + : (x.has_csr ? '待签回' : ''))} + ${x.kind === 'server' && x.has_key && !x.in_use + ? `` : ''} + +
    `; + document.querySelectorAll('#ca-list [data-ca-deploy]').forEach(b => { + b.onclick = () => caDeploy(b.getAttribute('data-ca-deploy')); + }); + document.querySelectorAll('#ca-list [data-ca-del]').forEach(b => { + b.onclick = () => caDelete(b.getAttribute('data-ca-del')); + }); + } + + // ---------- 下拉:CA 列表 / 密钥算法 / 待补全的 CSR ---------- + const cas = items.filter(x => x.kind === 'ca' && x.has_key); + const csrs = items.filter(x => x.kind === 'csr'); + const kts = d.key_types || []; + ['ca-nc-key', 'ca-sg-key', 'ca-cs-key'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.innerHTML = kts.map(k => ``).join('') + || ''; + }); + const selCa = document.getElementById('ca-sg-ca'); + if (selCa) { + selCa.innerHTML = cas.length + ? cas.map(x => ``).join('') + : ''; + } + const selId = document.getElementById('ca-im-id'); + if (selId) { + selId.innerHTML = '' + + csrs.map(x => ``).join(''); + } + const ipIn = document.getElementById('ca-sg-ip'); + if (ipIn && !ipIn.value) ipIn.value = (d.default_ips || []).join(','); + const hostIn = document.getElementById('ca-ts-host'); + if (hostIn && !hostIn.value) hostIn.value = '127.0.0.1'; + const portIn = document.getElementById('ca-ts-port'); + if (portIn && !portIn.value) portIn.value = String(d.web_port || 8443); +} + +async function caNewCA() { + const q = id => document.getElementById(id); + const r = await api('/api/ca', { method: 'POST', body: { + op: 'new_ca', + name: (q('ca-nc-name') || {}).value || '', + cn: (q('ca-nc-cn') || {}).value || '', + days: Number((q('ca-nc-days') || {}).value) || 3650, + key_type: (q('ca-nc-key') || {}).value || 'rsa:2048', + } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) caLoad(); +} + +async function caSign() { + const q = id => document.getElementById(id); + const caId = (q('ca-sg-ca') || {}).value || ''; + if (!caId) { toast('请先创建一张 CA 证书', 'err'); return; } + const split = s => (s || '').split(/[,,\s]+/).map(x => x.trim()).filter(Boolean); + const r = await api('/api/ca', { method: 'POST', body: { + op: 'sign', ca_id: caId, + cn: (q('ca-sg-cn') || {}).value || '', + san_dns: split((q('ca-sg-dns') || {}).value), + san_ip: split((q('ca-sg-ip') || {}).value), + days: Number((q('ca-sg-days') || {}).value) || 825, + key_type: (q('ca-sg-key') || {}).value || 'rsa:2048', + } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) caLoad(); +} + +async function caImport() { + const q = id => document.getElementById(id); + const r = await api('/api/ca', { method: 'POST', body: { + op: 'import', + kind: (q('ca-im-kind') || {}).value || 'server', + complete_id: (q('ca-im-id') || {}).value || '', + cert: (q('ca-im-crt') || {}).value || '', + path: (q('ca-im-path') || {}).value || '', + } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 10000); + if (r.ok) caLoad(); +} + +async function caGenCsr() { + const q = id => document.getElementById(id); + const cn = ((q('ca-cs-cn') || {}).value || '').trim(); + if (!cn) { toast('请填写域名', 'err'); return; } + const r = await api('/api/ca', { method: 'POST', body: { + op: 'csr', cn, + san_dns: (q('ca-cs-dns') || {}).value.split(/[,,\s]+/).map(x => x.trim()).filter(Boolean), + key_type: (q('ca-cs-key') || {}).value || 'rsa:2048', + } }); + const box = $('#ca-cs-out'); + box.classList.remove('hidden'); + if (!r.ok) { + box.innerHTML = `
    ${esc(r.msg_cn || '')}
    `; + toast(r.msg_cn, 'err', 9000); + return; + } + box.innerHTML = `
    ${esc(r.msg_cn)}
    + + +
    +
    `; + const cp = document.getElementById('ca-cs-copy'); + if (cp) cp.onclick = () => { + navigator.clipboard.writeText((r.data || {}).csr || '') + .then(() => toast('已复制', 'ok'), () => toast('复制失败', 'err')); + }; + caLoad(); +} + +// 换证书要重启 drouter-web,而 helper 与它同在一个 systemd cgroup —— 重启会 +// 把这次 HTTP 响应一起带走。这不是失败:结果已经写到 /etc/drouter/ca/ +// last-deploy.json,刷新回来在本页顶部就能看到。所以这里要把「连接断了」 +// 和「真的失败了」分开处理,否则用户会以为换证书的路上出了事。 +function caRestarting(r) { + return !r.ok && (r.code === 'NET' || r.code === 'PARSE'); +} + +function caAfterRestart() { + toast('Web 服务正在重启,10 秒后自动刷新(需要重新登录一次)', 'ok', 14000); + setTimeout(() => location.reload(), 10000); +} + +async function caDeploy(id) { + if (!window.confirm( + '把这张证书部署给管理后台?\n\n' + + '· Web 服务会重启,你需要重新登录一次\n' + + '· 换上后如果握手不通过,会自动还原原来的证书\n' + + '· 自签证书浏览器仍会提示,那是正常的(加密是生效的)')) return; + const r = await api('/api/ca', { method: 'POST', body: { op: 'deploy', id } }); + if (caRestarting(r)) { caAfterRestart(); return; } + toast(r.msg_cn, r.ok ? 'ok' : 'err', 14000); + if (r.ok) setTimeout(caLoad, 1500); +} + +async function caDelete(id) { + if (!window.confirm('确定删除这张证书?它的私钥会一起删除,不可恢复。')) return; + const r = await api('/api/ca', { method: 'POST', body: { op: 'delete', id } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + if (r.ok) caLoad(); +} + +async function caRestore() { + if (!window.confirm( + '重新生成一张默认自签证书并立即部署?\n\n' + + '它会带上本机所有 IP 与 localhost,有效期十年。' + + 'Web 服务会重启,你需要重新登录。')) return; + const r = await api('/api/ca', { method: 'POST', body: { op: 'restore', cn: 'drouter.local' } }); + if (caRestarting(r)) { caAfterRestart(); return; } + toast(r.msg_cn, r.ok ? 'ok' : 'err', 14000); + if (r.ok) setTimeout(caLoad, 1500); +} + +async function caSslTest(selfMode) { + const q = id => document.getElementById(id); + const d = CA_DATA || {}; + if (selfMode) { + const h = document.getElementById('ca-ts-host'); + const p = document.getElementById('ca-ts-port'); + if (h) h.value = '127.0.0.1'; + if (p) p.value = String(d.web_port || 8443); + } + const host = ((q('ca-ts-host') || {}).value || '').trim(); + if (!host) { toast('请填写要测试的主机', 'err'); return; } + $('#ca-ts-out').innerHTML = '

    正在握手…

    '; + const r = await api('/api/ca', { method: 'POST', body: { + op: 'ssltest', host, + port: Number((q('ca-ts-port') || {}).value) || 443, + sni: (q('ca-ts-sni') || {}).value || '', + insecure: !!(q('ca-ts-insecure') || {}).checked, + } }); + if (!r.ok) { + $('#ca-ts-out').innerHTML = `
    ${esc(r.msg_cn || '测试失败')}
    `; + return; + } + CA_TEST = r.data || {}; + caRenderTest(); +} + +function caRenderTest() { + const t = CA_TEST || {}; + const box = $('#ca-ts-out'); + if (!box) return; + if (!t.ok) { + box.innerHTML = `
    ${esc(t.host || '')}:${esc(String(t.port || ''))} + —— ${esc(t.why || '握手失败')}
    `; + return; + } + const chain = t.chain || []; + box.innerHTML = ` +
    +
    握手成功
    +
    协议${esc(t.proto || '—')}
    +
    加密套件${esc(t.cipher || '—')}
    +
    耗时${esc(String(t.ms || 0))} ms
    +
    +
    + ${(t.checks || []).map(c => ` +
    + ${c.ok ? '通过' : '注意'} +
    ${esc(c.n || '')} +
    ${esc(c.d || '')}
    +
    `).join('')} +
    + ${chain.length ? `
    + + + + ${chain.map(c => ` + + + + + + + `).join('')} +
    #主题颁发者有效期至剩余签名算法
    ${esc(String(c.level))}${esc(c.subject || '—')}${esc(c.issuer || '—')}${esc(c.not_after || '—')}${caDaysTag(c.days_left)}${esc(c.sig_alg || '—')}
    +

    叶子证书 SHA256: + ${esc((chain[0] || {}).fingerprint || '—')}

    +
    ` : ''}`; +} + +/* ============ 连接跟踪与流量日志 —— 统一日志系统(#11) ============ */ +const UL_LEVEL_TAG = { emerg: 'err', alert: 'err', crit: 'err', err: 'err', + warn: 'warn', notice: 'info', info: 'gray', debug: 'gray' }; +const UL_SRC_CN = { fw: '防火墙', conntrack: '连接跟踪', flow: '当前连接', + wan: 'WAN 接入', ddns: '动态域名', app: '应用日志', system: '系统日志' }; +const UL_SRC_ICON = { fw: '⛨', conntrack: '⇄', flow: '≡', wan: '⇅', + ddns: '⌂', app: '✦', system: '⛁' }; +const UL_PRESET_SINCE = [ + { v: '5 min ago', n: '最近 5 分钟' }, + { v: '30 min ago', n: '最近 30 分钟' }, + { v: '2 hours ago', n: '最近 2 小时' }, + { v: '12 hours ago', n: '最近 12 小时' }, + { v: '24 hours ago', n: '最近 1 天' }, + { v: '7 days ago', n: '最近 7 天' }, +]; + +function ulState() { + if (!S.ulog) { + S.ulog = { view: 'log', since: '30 min ago', level: '', src: '', proto: '', + action: '', q: '', limit: 400, items: [], stat: {}, meta: {}, + conf: {}, sources: [], levels: [], archive: {}, + // 实时连接事件订阅默认关:它会让服务端阻塞若干秒等 conntrack 事件。 + // 想看实时新建连接时再手动打开(#7:默认查询不该替它买单 2 秒)。 + live: false, liveSec: 3 }; + } + return S.ulog; +} + +function ulQuery() { + const u = ulState(); + return { op: 'query', view: u.view, since: u.since, level: u.level, + src: u.src, proto: u.proto, action: u.action, q: u.q, limit: u.limit, + live: !!u.live, live_sec: u.liveSec || 3 }; +} + +async function viewFlowLog() { + $('#view').innerHTML = `

    连接与流日志

    +

    正在采集各源日志…(防火墙 / 连接跟踪 / WAN / DDNS / 应用 / 系统)

    `; + const u = ulState(); + if (!u.sources.length) { + const c = await api('/api/ulog/conf'); + if (c.ok) { + u.conf = (c.data || {}).conf || {}; + u.sources = (c.data || {}).sources || []; + u.levels = (c.data || {}).levels || []; + u.archive = (c.data || {}).archive || {}; + u.caps = (c.data || {}).caps || {}; + u.conntrack = (c.data || {}).conntrack || {}; + u.fwLog = (c.data || {}).fw_log; + } + } + await ulLoad(); +} + +async function ulLoad() { + const u = ulState(); + if (u.view === 'flow') { + const r = await api('/api/ulog/flow', { method: 'POST', body: { + proto: u.proto, q: u.q, limit: 500 } }); + if (!r.ok) { toast(r.msg_cn || '读取连接表失败', 'err'); return; } + u.flow = r.data || {}; + renderFlowLog(); + return; + } + const r = await api('/api/ulog', { method: 'POST', body: ulQuery() }); + if (!r.ok) { toast(r.msg_cn || '读取日志失败', 'err'); return; } + const d = r.data || {}; + u.items = d.items || []; + u.stat = d.stat || {}; + u.meta = d.meta || {}; + u.conf = d.conf || u.conf; + u.sources = d.sources || u.sources; + u.levels = d.levels || u.levels; + u.archive = d.archive || u.archive; + renderFlowLog(); +} + +function ulStatChips(stat, map) { + const e = Object.entries(stat || {}); + if (!e.length) return '—'; + return e.sort((a, b) => b[1] - a[1]).map(([k, v]) => + `${esc((map || {})[k] || k)} ${v}`).join(' '); +} + +function renderFlowLog() { + const u = ulState(); + const isFlow = u.view === 'flow'; + const srcOpt = u.sources.map(s => + ``).join(''); + const lvOpt = u.levels.map(l => + ``).join(''); + const sinceOpt = UL_PRESET_SINCE.map(p => + ``).join(''); + + const items = u.items || []; + const st = u.stat || {}; + + $('#view').innerHTML = ` +
    +

    连接与流日志 + + 统一日志 ${u.conf.enabled ? '已开启' : '已关闭'}

    +

    把防火墙、连接跟踪、WAN 接入、DDNS、应用与系统日志 + 规范化为同一种记录结构,可用同一套条件检索、统计与导出。 + 归档文件:${esc((u.archive || {}).path || '/var/log/drouter/ulog.jsonl')} + (${(u.archive || {}).rows || 0} 条 · ${fmtBytes((u.archive || {}).size || 0)})。

    +
    +
    + + +
    + + ${isFlow ? '' : ``} + +
    + ${u.fwLog === false && !isFlow ? `

    + 防火墙日志开关当前为关闭:规则里没有 log 语句,因此「防火墙」源不会有新记录。 + 可到「防火墙 IPv4 / IPv6」页打开「记录被拒绝的包」。

    ` : ''} +
    + +
    +

    筛选与检索

    +
    + + + + + + + + + +
    +

    「最低级别」按严重程度过滤:选「警告」会同时显示错误与警告。 + 关键字对中文描述与原始行同时匹配。

    +
    + +
    +

    分布统计

    +
    +
    按来源
    ${ulStatChips(st.by_src, UL_SRC_CN)}
    +
    按级别
    ${ulStatChips(st.by_level)}
    +
    按动作
    ${ulStatChips(st.by_action)}
    +
    按协议
    ${ulStatChips(st.by_proto)}
    +
    来源地址 TOP
    ${(st.top_src_ip || []).map(x => + `${esc(x.v)} ${x.n}`).join(' ') || '—'}
    +
    目的端口 TOP
    ${(st.top_dst_port || []).map(x => + `:${esc(x.v)} ${x.n}`).join(' ') || '—'}
    +
    +
    + + ${isFlow ? ulFlowTable(u) : ulLogTable(items)} + +
    +

    保留策略与导出

    +

    归档会按下面的策略自动清理(由后台定时任务执行)。开启归档后, + 即使系统 journal 轮转,历史连接与流量日志仍可追溯。

    +
    +
    + + + + + +
    +

    采集源开关

    +
    + ${(u.sources || []).map(s => ``).join('')} +
    +
    + + + + + +
    +
    +
    + `; + ulBind(); + ulLoadDaemon(); + const m = u.meta || {}; + const el = $('#ul-meta'); + if (el) { + el.textContent = isFlow + ? `连接表 ${((u.flow || {}).stat || {}).total || 0} 条` + + (((u.flow || {}).cap || {}).max + ? ` · 内核表 ${((u.flow || {}).cap || {}).count || 0}/${((u.flow || {}).cap || {}).max}` : '') + : `采集 ${m.collected || 0} 条 · 命中 ${m.matched || 0} 条 · 显示 ${m.shown || 0} 条`; + } +} + +function ulLogTable(items) { + if (!items.length) { + return '

    日志明细

    ' + + '当前条件下没有日志。可放宽时间范围或取消筛选条件;' + + '若「防火墙」源为空,请检查防火墙日志开关。

    '; + } + return `
    +

    日志明细(${items.length})

    +
    +
    时间来源级别 + 动作 / 协议连接描述
    + ${items.map(ulRow).join('')} +
    +
    `; +} + +function ulRow(r) { + const lv = UL_LEVEL_TAG[r.level] || 'gray'; + return `
    + ${esc((r.ts || '').replace('T', ' '))} + ${UL_SRC_ICON[r.src] || '•'} + ${esc(UL_SRC_CN[r.src] || r.src)} + ${esc(r.level_cn || r.level)} + ${esc(r.action || '—')} + ${r.proto ? '
    ' + esc(r.proto) : ''}
    + ${r.saddr + ? esc(r.saddr + (r.sport ? ':' + r.sport : '')) + '
    → ' + + esc(r.daddr + (r.dport ? ':' + r.dport : '')) : '—'}
    + ${esc(r.msg_cn || '')}${r.iface ? `
    ${esc(r.iface)}` : ''}
    +
    `; +} + +function ulFlowTable(u) { + const f = u.flow || {}; + const rows = f.rows || []; + const st = f.stat || {}; + if (!rows.length) { + return `

    当前连接

    +

    没有读到连接记录${f.err ? ':' + esc(f.err) : ''}。 + 若系统未安装 conntrack 工具或未开启连接跟踪,此页会为空。

    `; + } + return `
    +

    当前连接(${rows.length}) + TCP ${st.tcp || 0} · UDP ${st.udp || 0} + · ICMP ${st.icmp || 0} · 其他 ${st.other || 0}

    +

    ${esc(f.note || '')}

    +
    +
    协议状态 + 源地址目的地址已传输方向
    + ${rows.map(r => `
    + ${esc(r.proto)} + + ${esc((r.extra || {}).state_cn || (r.extra || {}).state || '—')} + ${esc(r.saddr)}${r.sport ? ':' + esc(r.sport) : ''} + ${esc(r.daddr)}${r.dport ? ':' + esc(r.dport) : ''} + ${r.extra && r.extra.bytes + ? esc(fmtBytes(r.extra.bytes)) : '—'} + ${esc(r.action || '')} +
    `).join('')} +
    +
    `; +} + +function ulBind() { + const u = ulState(); const on = (sel, ev, fn) => { const e = $(sel); if (e) e[ev] = fn; }; + + $$('[data-ulview]').forEach(b => { + b.onclick = () => { u.view = b.dataset.ulview; ulLoad(); }; + }); + on('#ul-refresh', 'onclick', () => ulLoad()); + // 实时事件订阅:勾选后立刻按新设置重查一次(它会让查询多等几秒) + on('#ul-live', 'onchange', e => { + u.live = !!(e && e.target && e.target.checked); + ulLoad(); + }); + on('#ul-go', 'onclick', () => { + u.since = ($('#ul-since') || {}).value || u.since; + u.src = ($('#ul-src') || {}).value || ''; + u.level = ($('#ul-level') || {}).value || ''; + u.proto = ($('#ul-proto') || {}).value || ''; + u.action = ($('#ul-action') || {}).value || ''; + u.q = ($('#ul-q') || {}).value || ''; + const lim = parseInt(($('#ul-limit') || {}).value, 10); + u.limit = (isFinite(lim) && lim >= 20) ? Math.min(lim, 3000) : 400; + ulLoad(); + }); + on('#ul-reset', 'onclick', () => { + Object.assign(u, { since: '30 min ago', src: '', level: '', proto: '', + action: '', q: '', limit: 400 }); + ulLoad(); + }); + + // 点行展开原始内容 + $$('.ul-trow').forEach(tr => { + tr.onclick = () => { + const raw = tr.dataset.ulraw || ''; + if (!raw) return; + modal('原始日志内容', `
    ${esc(raw)}
    `, () => {}, '关闭', true); + }; + }); + + on('#ul-save', 'onclick', async () => { + const srcs = {}; + $$('#ul-srcs [data-ulsrc]').forEach(cb => { srcs[cb.dataset.ulsrc] = !!cb.checked; }); + const r = await api('/api/ulog', { method: 'POST', body: { + op: 'save_conf', + enabled: !!($('#ul-enabled') || {}).checked, + archive: !!($('#ul-archive') || {}).checked, + keep_days: parseInt(($('#ul-keep-days') || {}).value, 10) || 7, + keep_rows: parseInt(($('#ul-keep-rows') || {}).value, 10) || 200000, + min_level: ($('#ul-minlevel') || {}).value || 'debug', + sources: srcs, + } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + if (r.ok) { u.conf = (r.data || {}).conf || u.conf; ulLoad(); } + }); + + on('#ul-prune', 'onclick', async () => { + const r = await api('/api/ulog', { method: 'POST', body: { op: 'prune' } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + if (r.ok) ulLoad(); + }); + + const doExport = async (fmt) => { + const r = await api('/api/ulog', { method: 'POST', + body: Object.assign(ulQuery(), { op: 'export', format: fmt }) }); + if (!r.ok) { toast(r.msg_cn, 'err'); return; } + const d = r.data || {}; + const name = 'drouter-log-' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, '') + + (fmt === 'csv' ? '.csv' : '.jsonl'); + ulDownload(d.text || '', name); + toast(r.msg_cn || ('已导出 ' + (d.rows || 0) + ' 条'), 'ok', 7000); + const out = $('#ul-out'); + if (out) { + out.innerHTML = `

    已导出 ${d.rows || 0} 条记录, + 文件名 ${esc(name)}。

    `; + } + }; + on('#ul-export-json', 'onclick', () => doExport('json')); + on('#ul-export-csv', 'onclick', () => doExport('csv')); + + on('#ul-clear', 'onclick', () => { + modal('清空日志归档', `

    会删除 /var/log/drouter/ulog.jsonl, + 历史连接与流量日志将永久丢失(不影响系统 journal)。

    确认继续?

    `, + async () => { + const r = await api('/api/ulog', { method: 'POST', body: { op: 'clear_archive' } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err'); + if (r.ok) ulLoad(); + }, '确认清空'); + }); +} + +async function ulLoadDaemon() { + const box = $('#ul-daemon'); + if (!box) return; + const r = await api('/api/ulog/daemon'); + if (!r.ok) { box.innerHTML = ''; return; } + const d = r.data || {}; + const t = (d.units || {})['drouter-logd.timer'] || {}; + const last = d.last || {}; + const on = t.active === 'active'; + box.innerHTML = `后台采集: + ${on ? '运行中' : '未运行'} + ${esc(d.next || '')} + ${last.ts ? ` 最近一次:${esc(last.ts)} + ${esc(last.msg_cn || '')}` : ''}`; +} + +function ulDownload(text, filename) { try { + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 4000); + } catch (e) { + toast('导出失败:' + e.message, 'err'); + } +} + +function preSaveSync() { + if (S.page === 'portfwd') { + syncPfRules(); + return ['portfwd', 'nft_v4', 'nft_v6']; + } + return null; +} + +$('#ab-save').onclick = async () => { + const mod = currentModule(); + if (S.page === 'acl') { + const r = await saveAcl(false); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000); + setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err'); + return; + } + if (S.page === 'nfs') { + const r = await saveShare(false); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000); + setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err'); + return; + } + preSaveSync(); // 端口转发:先把编辑中的规则同步回配置 + const pm = pageModules(); + if (!pm || !pm.save.length) { toast('当前页面的操作按钮就在页面内部,无需全局保存', 'warn'); return; } + let allOk = true, msgs = []; + for (const m of pm.save) { + const r = await api('/api/config', { method: 'POST', body: { module: m, data: S.cfg[m] || {} } }); + msgs.push(r.msg_cn); if (!r.ok) allOk = false; + } + const tail = pm.apply.length ? '' : '(该页面的设置没有独立配置文件,' + + '会作为上下文随防火墙 / DHCP 等模块一起生效)'; + toast(msgs.join(';') + tail, allOk ? 'ok' : 'err'); + setActionMsg(msgs.join(';') + tail, allOk ? 'ok' : 'err'); +}; + +$('#ab-apply').onclick = async () => { + if (S.page === 'acl') { + const doAcl = async (live) => { + setActionMsg('正在校验并应用访问控制规则…'); + const r = await saveAcl(live); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err'); + const r2 = await api('/api/acl'); + if (r2.ok) { S.acl = r2.data || S.acl; renderAcl(); } + }; + if (S.buildMode) { + modal('构建保护模式已开启', `

    当前处于 构建保护模式,规则只会写入 + /etc/drouter/generated/acl.nft,不会加载到内核, + 以确保不影响正在运行的局域网与 RealVNC 会话。

    +

    确认要把当前规则写入磁盘(暂不生效)?

    `, () => doAcl(false), '写入磁盘'); + } else { + modal('应用访问控制规则', `

    规则会立即加载到内核(表 inet drouter_acl)。

    +

    如果规则写得不严谨,可能阻断目标设备的正常上网,但不影响路由器本身与其他设备。

    +

    确认继续?

    `, () => doAcl(true), '确认应用'); + } + return; + } + if (S.page === 'nfs') { + const doSh = async (live) => { + setActionMsg('正在校验并应用共享配置…'); + const r = await saveShare(live); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000); + setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err'); + const r2 = await api('/api/share'); + if (r2.ok) { S.shareData = r2.data; S.share = (r2.data || {}).cfg || S.share; renderShare(); } + }; + if (S.buildMode) { + modal('构建保护模式已开启', `

    当前处于 构建保护模式,配置只会写入 + /etc/samba/drouter.conf 与 /etc/exports, + 不会重启 smbd / nfs-server,以确保不影响正在运行的局域网与 RealVNC 会话。

    +

    确认要把当前配置写入磁盘(暂不生效)?

    `, () => doSh(false), '写入磁盘'); + } else { + modal('应用文件共享配置', `

    会写入配置文件并重启对应的共享服务:

    +
      +
    • 启用 SMB 时:重启 smbd / nmbd
    • +
    • 启用 NFS 时:重启 nfs-server 并重载导出表
    • +
    +

    已经挂载的客户端可能会短暂断开,一般会自动重连。

    +

    确认继续?

    `, () => doSh(true), '确认应用'); + } + return; + } + const pm = pageModules(); + if (!pm) { toast('当前页面的「应用」按钮就在页面内部,无需全局应用', 'warn'); return; } + + const doApply = async (live) => { + setActionMsg('正在校验并应用…'); + preSaveSync(); // 端口转发:先把编辑中的规则同步回配置 + let ok = true, msgs = []; + // 1) 先入库:纯配置模块(system / portfwd)只有这一步 + for (const m of pm.save) { + const r = await api('/api/config', { method: 'POST', body: { module: m, data: S.cfg[m] || {} } }); + if (!r.ok) { msgs.push(r.msg_cn); ok = false; } + } + // 2) 再渲染生效:只有真正有配置文件的模块才走 /api/apply + for (const m of pm.apply) { + const r = await api('/api/apply', { method: 'POST', body: { module: m, data: S.cfg[m] || {}, live } }); + msgs.push(r.msg_cn); if (!r.ok) ok = false; + } + if (!pm.apply.length) { + msgs.push('设置已保存(该页面没有独立的配置文件需要渲染,' + + '其设置会作为上下文随防火墙 / DHCP 等模块一起生效)'); + } + toast(msgs.join(';'), ok ? 'ok' : 'err', 8000); + setActionMsg(msgs.join(';'), ok ? 'ok' : 'err'); + loadAll(); + }; + + const highRisk = ['iface', 'lan', 'wan'].includes(S.page); + const buildMode = S.buildMode; + + if (buildMode) { + modal('构建保护模式已开启', `

    当前处于 构建保护模式,配置只会写入磁盘, + 不会启动服务、不会改变网络,以确保不影响正在运行的局域网与 RealVNC 会话。

    +

    确认要把当前页面的配置写入磁盘(暂不生效)?

    `, () => doApply(false), '写入磁盘'); + return; + } + if (highRisk) { + modal('高危操作确认', `

    当前页面的配置变更会真正生效,可能影响网络连接,包括 RealVNC 远程会话。

    +

    系统会在应用前自动创建配置快照,失败时自动回滚。

    +

    确认继续?

    `, () => doApply(true), '确认应用'); + } else { + doApply(true); + } +}; + +/* 构建保护模式开关 */ +async function toggleBuildMode(enable) { + const doIt = async () => { + const r = await api('/api/buildmode', { method: 'POST', body: { op: enable ? 'on' : 'off', confirm: !enable } }); + toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000); + if (r.ok) { S.buildMode = enable; loadAll(); go(S.page); } + }; + if (!enable) { + modal('关闭构建保护模式', `

    关闭后,所有「保存并应用」操作将真正生效:

    +
      +
    • DHCP 服务会启动并占用 53/67 端口
    • +
    • 防火墙规则会立即加载
    • +
    • 网络类改动会导致连接中断
    • +
    +

    请确认你已经准备好执行切换。

    +

    输入 确认关闭保护 以继续:

    + `, async () => { + if ($('#cfm-in').value.trim() !== '确认关闭保护') { toast('确认文字不正确', 'err'); return false; } + await doIt(); + }, '关闭保护'); + } else { await doIt(); } +} + +/* ============================ 入口 ============================ */ +$('#lg-btn').onclick = doLogin; +$('#lg-pass').onkeydown = e => { if (e.key === 'Enter') doLogin(); }; +/* 左下角「退出登录」与右上角「刷新数据」已按要求移除(#12): + 退出可在左侧用户区或会话过期后自动跳回;刷新由手动 F5 或各页自带刷新按钮完成。 */ + +/* 侧边栏:宽屏折叠成图标栏,窄屏变成抽屉(手机适配 #14) */ +const SIDE_KEY = 'drouter_side_collapsed'; +const isNarrow = () => !!(typeof window !== 'undefined' && window.matchMedia + && window.matchMedia('(max-width:760px)').matches); +if (localStorage.getItem(SIDE_KEY) === '1') $('#app').classList.add('side-collapsed'); + +/* 关闭手机端抽屉。做成具名函数是因为 go() 里也要调用(选完菜单自动收起)。 */ +function closeDrawer() { + const el = $('#app'); + if (el) el.classList.remove('m-drawer'); +} + +$('#btn-side').onclick = () => { + // 窄屏走抽屉,不写折叠状态——否则回到宽屏时会莫名是折叠的 + if (isNarrow()) { $('#app').classList.toggle('m-drawer'); return; } + const c = $('#app').classList.toggle('side-collapsed'); + try { localStorage.setItem(SIDE_KEY, c ? '1' : '0'); } catch (e) { /* ignore */ } +}; + +/* 右上角「刷新」:重新执行当前页面的视图函数,等于原地重进一次。 + 图标转一圈给反馈;失败时仍由 go() 的统一错误卡片兜住。 */ +const btnRefresh = $('#btn-refresh'); +if (btnRefresh) { + btnRefresh.onclick = () => { + const k = S.page; + if (!k) return; + btnRefresh.classList.add('spinning'); + // 先移除动画类再加,保证连续点击也能看到旋转 + setTimeout(() => btnRefresh.classList.remove('spinning'), 700); + go(k); + const p = PAGES.find(x => x.k === k) || {}; + toast('已刷新「' + (p.t || k) + '」', 'ok', 1500); + }; +} + +/* 左下角「退出系统」:带一次确认,避免误点把正在配置的内容弄丢 */ +const btnLogout = $('#btn-logout'); +if (btnLogout) { + btnLogout.onclick = () => { + modal( + '退出系统', + '

    确定要退出管理后台吗?未保存的配置改动会丢失。

    ', + () => { logout(false); }, + '退出' + ); + }; +} + +/* 抽屉遮罩:点击关闭;Esc 也关闭 */ +const mMask = $('#m-mask'); +if (mMask) mMask.onclick = closeDrawer; +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeDrawer(); +}); +/* 从窄屏拉回宽屏时清掉抽屉状态,否则遮罩会一直挡住页面 */ +if (typeof window !== 'undefined' && window.matchMedia) { + const mq = window.matchMedia('(max-width:760px)'); + const onW = () => { if (!mq.matches) closeDrawer(); }; + if (mq.addEventListener) mq.addEventListener('change', onW); + else if (mq.addListener) mq.addListener(onW); +} + +/* 表格横向滚动(#14):渲染后统一给 包一层 .tw。 + 全站有 20 多处表格渲染点(还含 ajax 异步刷新的),逐个改不现实, + 这里用 MutationObserver 兜住「直接赋值 innerHTML」和「异步回填」两种路径。 */ +function wrapTables(root) { + if (!root || typeof root.querySelectorAll !== 'function') return; + root.querySelectorAll('table').forEach(t => { + const p = t.parentNode; + if (!p) return; + if (p.classList && p.classList.contains('tw')) return; // 已包过 + const w = document.createElement('div'); + w.className = 'tw'; + p.insertBefore(w, t); + w.appendChild(t); + }); +} +(function watchTables() { + const v = $('#view'); + if (!v || typeof MutationObserver === 'undefined') return; + let busy = false; + new MutationObserver(() => { + if (busy) return; + busy = true; + try { wrapTables(v); } catch (e) { /* 忽略:包裹失败只是少了横向滚动 */ } + busy = false; + }).observe(v, { childList: true, subtree: true }); +})(); + +/* 菜单搜索:输入时自动展开匹配分组 */ +$('#nav-filter').oninput = debounce(e => { + S.navKw = e.target.value; + renderNav(); +}, 140); +$('#nav-filter').onkeydown = e => { + if (e.key === 'Escape') { e.target.value = ''; S.navKw = ''; renderNav(); } +}; + +if (S.token) { + api('/api/me').then(r => { + if (r.ok) { + S.user = r.data.user; + $('#login').classList.add('hidden'); + $('#app').classList.remove('hidden'); + boot(); + } else { logout(true); } + }); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d26936d --- /dev/null +++ b/web/index.html @@ -0,0 +1,131 @@ + + + + + + +Drouter · Debian 13 主路由管理 + + + + + + + + + + + + + + + + + + + + diff --git a/web/logo.svg b/web/logo.svg new file mode 100644 index 0000000..196c38a --- /dev/null +++ b/web/logo.svg @@ -0,0 +1,18 @@ + + Drouter + + + + + + + + + + +