37 Commits
Author SHA1 Message Date
liuzhuohua d2291aeb96 发 v1.0.11:arm64(Debian 13)适配的产物重打
上一版把 arm64 适配的代码推了上去,但没有重打产物 —— Release 上挂的
三件套仍是旧的,不含那次修复。这一版把产物补齐,并首次发布 arm64 的两份。

改的就一件事:版本号 1.0.10 → 1.0.11(packaging/VERSION 是唯一真源,
同步 backend/drouter-web.py 的 VERSION_FALLBACK 与 drouter-update.py 的兜底值)。

⚠️ 全仓 grep '1.0.10' 会命中几十处,绝大多数是「1.0.10 修 / 1.0.10 起」这类
   历史注释,指的是那个版本做过的事,**一个都没动** —— 改了等于篡改历史。

README 更新日志补 v1.0.11 段,写明这次适配的实测机型与分架构产物清单:

  本体      drouter_1.0.11_all.deb            Architecture: all,各 CPU 通用
  离线包    drouter-1.0.11-offline-amd64.tar.gz / -arm64.tar.gz
  镜像      drouter-1.0.11-docker.tar / drouter-1.0.11-arm64-docker.tar

关于「amd64 有没有实质更新」:代码逻辑上没有 ——
  · _read_bios() 新增的设备树分支在 x86 上不触发(有 EFI/DMI 时走原分支,
    结果与改动前逐字节一致,只是多返回 fw_type / is_dt / compat 三个字段);
  · _deb_arch() 在 amd64 上返回 amd64,拼出的离线包名与原写死的完全相同。
但「只重打 arm」这条路走不通,两个硬约束:
  1. deb 是 Architecture: all 的**单包**,不存在「arm 版 deb」;
  2. 离线包里内含本体 deb,本体一变,amd64 那份离线包装出来的就是旧 deb。
所以这次是全量重打(deb 1 + 离线包 2 + 镜像 2)。
2026-10-16 10:58:04 +08:00
liuzhuohua 6df3a94b90 适配 arm64 Debian(Armbian 26.8.1 trixie):去掉架构假设
有用户要在 arm64 的 Debian 上跑 drouter。这一轮只对一个发行版做了适配
—— Armbian 26.8.1 trixie(Debian 13.6)/ aarch64 / 内核 6.18.35-ophub
(Amlogic S912 板),别的 arm64 发行版未验证,验证范围与实测数据全部
写在 docs/07-arm64-适配说明.md。

改掉的四处 x86 假设:

1. helper._read_bios():ARM 板既无 /sys/firmware/efi 也无 /sys/class/dmi,
   原代码把它们一律落到 else 分支,系统概览显示「传统 BIOS」(错的,
   这类机器根本没有 BIOS)。新增设备树分支,判据顺序 EFI → 设备树 → BIOS
   (树莓派 4 这类两可的机器要优先认 EFI);读 /proc/device-tree 与
   /sys/firmware/devicetree 两个候选路径,compatible 按 NUL 切开取第一条;
   没有 DMI 厂商时拿设备树板名顶上,厂商格不再空白。返回结构加 fw_type
   / is_dt / compat,旧字段一个没动。

2. update.py:离线包资源名原先写死 -offline-amd64,arm64 机器点一键更新
   会下载 x86 离线包,传几十 MB 才在装包阶段炸依赖。改为按
   dpkg --print-architecture 拼名(_deb_arch / build_assets),并为带架构
   后缀的附件在下载前先拿官方 SHA256SUMS 核对存在性,没有就明确报错。

3. packaging:离线包说明里两处写死的 amd64 换成 $ARCH(包名早就是变量拼的,
   但包里那份 README 还在教用户核对 amd64);build-docker.sh 新增可选的
   第 2 个参数 [arch],交叉构建时加 --platform 并给标签加 -<arch> 后缀,
   不传第二参数时行为与改动前逐字节一致。

4. web/i18n.js:给新增的「设备树 (Device Tree)」展示串登记英文,
   否则英文界面漏中文。

配套:
- _dev/t-arch.py(31 条判据)+ _dev/t-arch-inject.py(20 例反向验证)。
  反向验证抓出一条假绿:i18n 词条的 n/d 判据用了 300 字符松弛窗口,
  会跨到下一条词条,删掉本词条的 d 之后照样绿 —— 已收紧为本词条花括号内。
- _dev/run-static.py 的 EXCL 补上 t-update-inject —— 它名字也是 t- 前缀,
  之前一直被全量回归自动执行,会在回归过程中反复改写 backend/ 与 web/。
- devtools/verify-arm64.py:只读验收脚本,方便他人在自己的板子上复验。
- README 支持矩阵里 arm64 从「理论可用」改为「已实测 1 个发行版」。

本机全量静态回归 71 个检查器 / 0 失败;目标机上只读验证 12 条全通
(构建 753 396 字节的 deb / depcheck 73 项 / 8 个渲染模块 / 架构感知清单)。
目标机未安装 drouter,未启用任何服务,未改动网络。
2026-10-16 06:16:31 +08:00
liuzhuohua 8303fa3359 fix(update): 更新前自动备份以 drouter 身份跑时写不进备份目录
真机报错:更新功能「备份失败,已中止更新」——
[Errno 13] Permission denied: /opt/drouter/backups/xxx.part。

根因:/opt/drouter/backups 是 root:root drwxr-xr-x,而「更新前备份」这条链
由 drouter-web(drouter 用户)spawn drouter-backupd.py --run-now,backupd 的
call() 用 subprocess 直接跑 helper、没提权 → helper 继承 drouter 身份 →
写备份包 .part 被拒。定时器那条链是 root 跑的,所以平时备份正常,
只有「更新触发的备份」踩雷。

修法:backupd 的 call() 里 helper 调用加 sudo -n 前缀。sudoers 已放行
drouter 免密跑 helper 白名单,root(定时器)/ drouter(更新)两种身份都通。

判据:t-update.py 新增「backupd call() 用 sudo -n 提权」(查命令数组语义),
t-update-inject.py 加对应反向验证注入(34 例 0 异常)。
真机验收:sudo -u drouter backupd --run-now → BK_CREATED files=21 size=23767。
2026-10-10 16:29:19 +08:00
liuzhuohua b7ca9c89e8 feat(i18n/layout): 全站中英双语 + 四轮排版修复收官 + 修 boot_id 重启检测
v1.0.10 两件大事:

1. 界面中英双语(英文界面无中文残留)
   - 新增 web/i18n.js(约 4200 条词条),零依赖零构建
   - 后端下发中文数据走 bt4() 查表翻译;独立模块(upstream/netdetail/
     realtime/update)纳入翻译并注册切语言重绘钩子
   - 后端「状态/聚合」接口只回稳定枚举+原始数值,不下发中文文案
   - 顺带修一个真实 XSS:顶层 const 不挂 window,独立脚本 esc 静默退化成零转义

2. 修掉「文字竖着排/错位」(多分辨率反复暴露)
   - 单元格 min-content 只有 1 字宽 → word-break:keep-all + 表头 nowrap
   - 依赖卡片正文被标签/按钮挤成 27px → .dep-body{flex:1 1 200px} + flex-wrap
   - 裸 flex:1(下限为0)给正文 → 一律改 flex:1 1 <px>
   - .dep-item 横向容器直接塞块级子项 → 退回块级
   - 关键陷阱:auto-fill 布局屏越宽列数越多单卡越窄,窄屏反而测不出来,
     真机审计推进到「逐视图点按钮 + 1920/2560 宽视口 + 中英双语」
   - 守卫 t-layout.py 扩到 35 条,12 处反向验证全红

3. 修真缺陷:_link_since() 重启检测被误改成 if False:(注释还写着「已修」),
   uptime 归零后相减得负数 → 恢复比对 boot_id

附带:新增 25 个检查器(t-*.py)、排版审计工具链(shot-*/ocr-*/live-*)、
版本号兜底值同步到 1.0.10、.gitignore 收纳 i18n/排版排查的一次性脚本。
2026-10-10 04:50:32 +08:00
liuzhuohua d0a72bbb5f fix(release): Release 附件版本号单一真源 + 堵住「注释掉整行代码」型注入残留
发 v1.0.9 前发现两个真问题,都会直接影响用户拿到的附件。

一、docker-compose.yml 里的镜像标签是硬编码的 1.0.8

这两个文件是**直接挂到 GitHub Release 上的附件**,用户照着抄命令。
早先它们硬编码具体版本号,每次发版要人工 sed 一遍 —— 这次就漏了:
附件里写着 `image: drouter:1.0.8`,而镜像 tar 加载后的标签是 `drouter:1.0.9`,
`docker compose up` 直接报「找不到镜像」。DOCKER-GUIDE.md 另有 14 处同样问题。

改成 __VERSION__ 模板,由 build-docker.sh 渲染(版本从 packaging/VERSION 读)。
渲染分两步,顺序不能反:先删 TEMPLATE-NOTE 标记块(那是给维护者看的
「这是模板」提示,留着用户会看到「1.0.9 是占位符」这种自相矛盾的话),
再替换占位符。先替换的话,标记块里的 __VERSION__ 也会被换掉。

retag-docker-image.py 同样问题:NEW_TAGS 硬编码。改完参数化。
这个 bug 更隐蔽 —— 拿 1.0.9 的镜像跑重标记会打上 1.0.8 的标签,
用户 load 完看到的标签与附件说明对不上。

t-packaging.py 补 12 条判据卡住这些(4 条已做反向验证:注入即红、还原即绿)。
判据只抓「紧挨 drouter 标识或产物文件名」的版本号 —— 一刀切拦所有 x.y 会
误伤「1.0.1 起 Web 终端绕开 init」这种历史沿革说明和「GPL-3.0」许可证名。

二、t-worktree-clean.py 抓不到「注释掉整行代码」型注入

跑回归时 t-107 红了 1 项,查出来是 t-107-inject-wg.py 把
`'env': _vpn_env(),` 注释掉了没还原(SIGTERM 打断,finally 没执行)——
和 v1.0.8 那次 `/nonexistent` 残留一模一样的坑,但**形态不同**:
不含任何哨兵字样,原判据的 4 个哨兵全都抓不到。

后果很实:_vpn_status 不返回 env,前端拿不到四态诊断,
VPN 页面的三个修复按钮全废。这正是 v1.0.8 声称修好的那个 bug。

补两条判据:
- 扫「被注释掉的整行代码」:去掉 # 后能 ast.parse 成功,且含调用/赋值/return/dict 项
- 扫 CRLF:Windows 工具写文件会整篇改行尾,sync.sh 预检才拦太晚

判据本身踩了三个坑,都记在注释里:
- dict 项 `'env': _vpn_env(),` 单独 ast.parse 会 SyntaxError(没大括号),
  真正的注入反而被放过 → 补大括号再解析
- dict 项的键**通常带引号**,正则只写 ^[A-Za-z_] 认不出来 → 改成同时认引号键
- 分隔线注释 `# ------ radvd` 里的 ----- 会被解析成负号表达式 → 按前缀排掉

双向验证:4 种注入形态(赋值/dict项/bool/列表)全部精确报到行号,
基线零误报(排掉了分隔线注释和「# module: (service, action, 中文名)」这类
长得像 dict 项的文档注释)。

三、_dev/dl-verify.py(新增)

从目标机往 Windows 拉大产物时发现的坑,记成脚本省得下次重踩:
- `ssh host 'cat f' > out` 在 Windows 上做**文本模式换行转换**,二进制被破坏
  (实测 deb 少 4 字节,sha256 直接对不上)→ 走 base64 再本地解码
- Windows Python 看不到 Git Bash 的 /tmp,必须给 C:/ 绝对路径
- scp 传完一定要判 sha256 —— scp/ssh 都可能「返回 0 却落地 0 字节」
- 落地走 .part + os.replace,避免写一半失败留下半个产物

顺带修了 build-docker.sh 里一处路径笔误
(packaging/docker/packaging/docker/DOCKER-GUIDE.md)和步骤编号 1/3→1/4。
2026-10-04 14:05:33 +08:00
liuzhuohua ed5eb4428b 1.0.9 静态资源传输层优化 + 页脚版本号 + logo 回概述
## 性能:瓶颈在传输,不在代码

先量后改。helper 冷启动 0.44s(编译 22173 行)看着像瓶颈,但
drouter-helpd 是常驻守护,请求走 Unix socket,正常路径根本不 fork;
实测各 API 已是毫秒级。app.js 语法编译 18ms、顶层执行 18ms
(393 个顶层函数 / 13401 行)—— JS 执行不是瓶颈。

真瓶颈是 app.js 690,482 字节每次重传。**决定不拆包**:JS 执行只 18ms,
拆包引入首屏 waterfall 的风险大于收益。改走三条:

1. 缓存策略按「有没有指纹」区分(不是按扩展名)
   - 带 ?v=<mtime> → public, max-age=31536000, immutable
   - 没指纹 → 仍 no-cache(否则部署后用户拿到旧文件)
   - index.html → 永远 no-store(每次都要重新注入)
2. 编码协商 br > gzip > 原文,**逐级降级**
3. 压缩结果按 (path, mtime, size, enc) 缓存

真机实测:首次 690,482 → 200,137 字节,第二次打开 0 字节(304)。

## 逐级降级不是可选项

目标机**没装 python3-brotli**(只有 C 库 libbrotli1)。最初写的
_brotli_cached 在模块缺失时 `return data, None`,而 None 是 encoding,
调用方理解为「压不动,原样输出」—— 于是 583KB 裸传,**比优化前更慢**。
改成 (None, None) 明确表示「这条路不通」,由 _encode_body 继续试 gzip。

同时修:Accept-Encoding 探测必须认 q=0(br;q=0 是「明确不要」,
子串匹配会正好发给解不开的客户端 → 满屏乱码);编码必须进缓存键
(共用键会互相覆盖);brotli 固定 q5(q11 只省 10KB 却要 1.5-3s)。

## 宽 except 把 bug 装成了特性

兜底只接 ImportError 这类**真·预期**的缺失,不接 TypeError/ValueError
(「str 传给要 bytes 的接口」也是 TypeError,接了等于没接)。
早先的 `except Exception` 吞掉了一个 NameError(漏注入 _comp_lock),
表现是压缩功能完好但从不生效,页面照常能开、只看响应头的判据完全发现不了。

## 版本号:建立单一真源 packaging/VERSION

原来版本号在 4 个构建脚本各写死一个默认值 + openapi 里硬编码。
漏改一处就产出「文件名 1.0.9、control 里 1.0.8」的包 —— apt 按
control 认版本,dpkg -l 永远显示旧版且升不上去。

index.html 的 ETag 并入版本号:它发出去的是注入后的内容,
ETag 却按原文件算,只改 VERSION 文件时 ETag 不变,客户端换到 304
继续用旧副本,页面版本号永远停在旧版。

## logo 回概述

用 <a> 而不是 <div onclick>:Tab 能聚焦、Enter 能触发、读屏会念「链接」。
已在 dash 页时不重复 go()(go 会 stopPageTimers + 重拉数据)。
CSS 显式去 text-decoration/color(UA 样式表不声明仍生效),
:focus-visible 焦点环保留(纯键盘用户唯一的路标)。

## 顺带修掉一个已发布版本的真 bug

_vpn_env 里 os.path.join('/lib/modules', ...) 被留成了 '/nonexistent'
(反向验证脚本被 SIGTERM 打断,finally 没执行,注入留在源码里),
于是 mod_file 永远为空、**need_module 整个状态从四态里消失** ——
这正是 v1.0.8 release notes 声称修好的那个 bug(PVE 的 KVM 虚机内核
明明有 wireguard.ko.xz 却报 unsupported,前端配一句写死的
「通常说明跑在精简容器里」把排查方向带偏)。

新增 _dev/t-worktree-clean.py 扫注入哨兵,放在 sync.sh 所有判据之前 ——
基线被污染时,后面每一条红都不可信。

## 判据

新增 t-static-perf.py(42 项静态)、t-encoding-runtime.py(26 项**真跑**,
覆盖 9 种 Accept-Encoding 组合、brotli 缺失降级、两种编码不互相覆盖、
压不动直出、内部错误不被吞)、t-worktree-clean.py。
三个都做了反向验证,注入后确实变红。

反向验证本身也踩了坑:判据里 len(body) 在 body 为 None 时先崩 ——
退出码非 0 但没有 FAIL 行,看着像「跑了」,实际没人知道它验出了什么。
取长度一律过 _sz() 包一层。

全量回归 54 个判据 0 失败;真机验收 22 项全通过;
前端三查 47 视图 0 异常、26 项移动端适配全过。
2026-10-04 10:27:02 +08:00
liuzhuohua 2c92cc9b28 docs(release): v1.0.8 发版准备 —— 三件产物校验完成
产物(全部在目标机 192.168.7.3 上构建,sha256 往返一致):

| 文件 | 体积 | sha256 前 16 位 |
|---|---|---|
| drouter_1.0.8_all.deb | 519 KB(531,688 B) | f6d8c9fdb55dd536 |
| drouter-1.0.8-offline-amd64.tar.gz | 83 MB | 924f38cfe7a1eccc |
| drouter-1.0.8-docker.tar | 169 MB | 9ef999f059a3255d |

校验:
- 离线包内 `sha256sum -c` **238/238 成功,0 失败**(判据用 `grep -c ': 成功'`,
  目标机是中文 locale,输出「成功」不是 OK)
- 包内 deb 与独立产物 sha256 完全一致(f6d8c9fd…)
- retag 后 **6 个 layer 逐个字节一致**(只改 manifest/config,layer 零拷贝)
- 镜像内 helper / ddnsd 与本地源码 **sha256 逐字节一致**
- retag 后标签 `drouter:1.0.8` + `liuzhuohua/drouter:1.0.8`,license=MIT,
  healthcheck 已补(自签证书走 `curl -k`)
- 全量静态回归 **43 个检查器 0 失败**
- markdown 178 个本地引用 0 死链,全仓 0 个 U+FFFD

文档:
- RELEASE_NOTES 补「七、DDNS 真的会更新了」+ 两段踩坑记录
  (部署清单漂移致 203/EXEC、判据测试打真实网络)
- README v1.0.8 段重写:DDNS / 性能优化 / 快照上锁三件事
- 下载表体积全部按**实测字节数**写,不再抄旧数字
2026-10-03 23:33:26 +08:00
liuzhuohua aab35d48a7 feat(ddns): 真实下发 + 「检测间隔」终于有消费方
原状:DDNS 页面「立即更新」只打印一份更新计划,DNS 记录纹丝不动;
「检测间隔(秒)」字段全仓没有任何定时器消费,纯装饰。

本次做两件事:

1) 真实下发
   - 新增 _http_open:区分「HTTP 层失败」与「业务失败」。
     这类接口的共同点是**用 HTTP 200 回一句 error 表达失败**,
     只判 HTTP 层的话页面显示「成功」而记录没动。
   - 接入 7 家:custom / dnspod / aliyun / cloudflare / noip /
     dyndns / dyndns2。各自的成功判据不同(DNSPod 看 status.code,
     阿里云看 Code,Cloudflare 看 success)。
   - huaweicloud / dnspod_tencent **如实报「暂未接入」**并给替代建议,
     不写「看起来对」的实现 —— 未验证过的签名算法不能假装能用。
   - update 分支三态:全成功 / 全失败(DDNS_UPDATE_FAILED) /
     部分成功(DDNS_UPDATE_PARTIAL)。部分成功不显示成全成功。
   - 下发器内部异常被兜住并如实上报,不把动作打断成 500。

2) 检测间隔有消费方
   - 新增 backend/drouter-ddnsd.py:读 interval → 未启用直接跳过 →
     失败退避 → 探测公网地址 → **只在地址变化时才下发**
     (IP 没变不敲 API,避免触发服务商频率限制)。
   - 失败退避 3 倍递增、上限 1 小时。退避期内秒退,不探测外网
     (一次完整探测 5 秒以上,失败时反复探测是自伤)。
   - _write_ddns_timer 由 save / on / off 三处调用,改了间隔立刻
     重写 timer,不会出现「页面显示已保存、实际还是旧周期」。
   - 守护不依赖 init:容器形态下由常驻守护代跑。

真机验收(192.168.7.3,Debian 13):
  - timer generated/enabled/active,OnUnitActiveSec=120s,跟随用户设定
  - 地址未变 → last_result='unchanged',不下发
  - 篡改 last_ip4 制造「地址变了」→ 真下发 → 失败如实记录并退避
  - 退避内第二次执行 0.136s 返回(首次 4.3s),确认没探测外网
  - HTTP 层失败与业务失败文案可区分
  - 验收后已恢复出厂默认(停用),timer 单元与退避文件已清理

顺带修掉 3 个真缺陷:
  - 阿里云更新参数对新建 dict 做 del up['Action'] → KeyError。
    UpdateDomainRecord 少了 Action/Format/Version 会被阿里云拒。
  - 自定义 URL 失败词表漏 badauth:返回 badauth 被判成功。
  - DNSPod 错误文本在 status.message 而非顶层 message,
    只读顶层时用户只看到「失败」没有原因。

判据:
  - 新增 _dev/t-111-ddns-push.py(44 项,12 组),全绿。
    真实验签不靠网络:_http_open 换成假实现按剧本返回不同正文,
    并在启动时掐死 socket.connect —— 判据测试打真实网络既慢又不确定,
    还会污染对方日志,且失败时看起来像「产品有 bug」。
  - t-packaging 补 2 条:deploy.sh 里每个后端模块都要出现。
    本次就踩到 —— build-deb 用 backend/*.py 通配复制(ddnsd 自动进包、
    判据全绿),可 deploy.sh 是**逐个 install 硬名单**,ddnsd 漏在里面,
    表现为「本地全绿、真机没有文件」,timer 起来报 203/EXEC。
    部署才是真正生效的那一步,两份清单都得查。已反向验证会红。

全量静态回归 43 个检查器 0 失败。
2026-10-03 21:45:43 +08:00
liuzhuohua 0b6abc9696 perf(web): DDNS / 打印 / 页面载入三处慢查询并发化 + 落盘缓存,三个接口从十秒级降到亚秒级
现象:用户反馈「DDNS、打印服务、页面载入缓慢」。前端切页是并发的,
页面载入时间约等于最慢的那个接口,实测 /api/pubip 10.2s 就是罪魁。

三处根因:

1. 回显服务串行探测 —— _echo_public_ip 逐个试 5 个 IPv4 + 4 个 IPv6,
   每个 timeout=6,全部失败就是 30 秒起步。改成 ThreadPoolExecutor
   并发、as_completed 谁快用谁;新增 _echo_all_v4 给「多源一致性」用
   (那条证据要看全部服务,不能短路)。traceroute 与 mtr 同理并发,
   原来是「先 traceroute 25s,失败再 mtr 25s」。

2. 进程内缓存在 fork 架构下等于没写 —— helper 每个动作都是 fork 出去的
   一次性进程,_PUBIP_CACHE 每次都是空的。三个缓存全部落盘到 /run/drouter/:
   公网 IP(TTL 300s)、check 分支的两块诊断证据(TTL 1800s)、
   lpinfo 设备列表(TTL 120s)。缓存的是**探测原始结果**不是判定结论,
   结论仍每次现算 —— 缓存只影响快慢,不影响判定逻辑。

3. lpinfo -v 是打印页载入慢的全部原因(实测单项 4.21s,lpstat -p 只要
   0.05s)。它是 cups 的网络发现扫描,慢是固有成本。现在五路查询一起并发。

真机 192.168.7.3 实测:

  /api/pubip   10.2s -> 0.002s(冷缓存 5.5s)
  /api/ddns     7.1s -> 0.003s
  /api/print    4.3s -> 0.08~0.25s(冷缓存 4.3s)

force=1 实测 5.6s,确认「重新判定」按钮没被缓存架空。

真机抓到的 bug:lpinfo 缓存命中时返回 2 元组,调用方按 3 元组解包 →
ValueError 被 except 吞掉 → 设备列表变空 + devices_timed_out=true。
而耗时只有 0.08 秒 ——「快」和「对」是两件事,只看耗时永远发现不了。
判据已改成真的调一次缓存命中分支并断言设备数来自缓存内容。

另外两处:
- act_pubip check 分支三块独立证据一起并发(原来 detect_public_ip 打一轮
  回显、check 分支「多源一致性」又打一轮,是完全重复的第二次探测)
- detect_public_ip 里的三路本地 ip 命令并发(原来连着 sh() 三次)

前端:evidence_cached 如实告知用户复用了缓存,并给重测入口;
载入文案不再宣称「约需 10-30 秒」(已不成立)。

判据 _dev/t-110-perf.py 32 项,含 4 条反向验证(注入不读缓存 / 注入
三块改回串行 / 注入 lpinfo 返回 2 元组 / 替身 env 中 cupsd 必须可见)。
全量静态回归 42 个检查器 0 失败。

顺带修掉 drouter-helper.py 与 README.md 里 3 处既存的 U+FFFD 乱码。
2026-10-03 17:45:42 +08:00
liuzhuohua 309be8b0a5 feat(snapshot): 快照可单独上锁 + 备注事后可改,全部并入紧急救援通道页
用户反馈的四件事一次做完:
1. 给快照加保护(单份上锁,自动清理一律跳过)
2. 说明「手动快照受保护不被清理」,并讲清它与上锁的区别
3. 快照备注事后可改(此前只能在创建时填,存下来就只能看不能改)
4. 列表从「配置快照与回滚」卡片搬进「紧急救援通道」,回滚入口不再弹残缺弹窗

protected 与 keep_manual 是两件事,必须分开:
- keep_manual 是全局策略,按 tag.startswith('auto') 猜来源
- protected 是用户对这一份的显式意图,跨策略生效
判据只能加在删除分支上,混进 auto 算法的话,受保护的自动快照
会开始占 keep_count 配额,把别的自动快照挤掉 —— 用户设「保留 5 份」
却只剩 4 份,且没有任何提示。

上锁不等于删不掉:删受保护的那份时后端回 needs_force,前端弹二次确认
后带 force 重试。审计里标注「受保护,已强制删除」,否则事后只看得到
「删了快照」,看不出删的是用户特意保下来的那份。

meta 用 .tmp + os.replace 原子写 —— meta 是 prune 唯一的判据来源,
写到一半被打断会留下解析不了的残文件,之后那份快照既不会被清理,
也上不了锁(读不到就当没保护)。

备注消毒:控制字符一律换成空格而不是删掉。删掉会把「甲 BEL 乙」
变成「甲乙」,两个词粘成一坨,而用户看到的备注莫名其妙。

真机验收(40 项全绿)当场抓到两个本地回归漏掉的真 bug:
- 备注消毒把制表符删掉而不是换空格:'a\\nb\\tc' 变成 'ab c'
  本地判据只写「不含换行和制表符」,恰好放过 —— 「没有危险字符」
  和「内容没被偷偷改掉」是两件事
- web 层无条件写 'protected': b.get('protected'),
  payload 里这个键永远在(值可能是 None),helper 的
  `if 'protected' in p` 恒成立,「不传就取反」从 HTTP 路上彻底失效
两个都补了反向验证(注入后必须观察到红)。

顺带修掉一个既存缺陷:t-107.py 里的 js_code_only 一直在吃 app.js
的真代码 —— 用 re.sub 块注释优先、再剥行注释,而 app.js 8222 行有一句
讲「怎么防止 CSS 注释被提前闭合」的行注释,注释里写着块注释的起止符
字面量,于是被当成真注释开头一路吃到文件尾。
证据:块注释起始 169 次而结束 170 次,数量不等就是配对错位一格。
HEAD 版同样中招(163/164),所以不是本轮引入。
换成 _dev/jsstrip.py 的词法扫描版(正确处理模板串的插值、正则字面量、
prev 记 token 尾词而不是一个字符),并加 node 侧 V8 交叉验证。
2026-10-03 14:22:14 +08:00
liuzhuohua 890b2b3fa6 test(smoke): 修容器冒烟的 13 条假红 —— 判据吃注释 / 路径拼错 / 结构位置错
冒烟脚本 1.0.7 就在报红,本轮首次真跑才看清:13 个失败里
**没有一条是真产品缺陷**,全是判据自身的问题。三类:

## 一、路径拼错:`/opt/douter`(少个 r)

第 13 节 4 条断言全往这个不存在的路径 grep。grep 会往 stderr 打
"No such file or directory",而 `2>&1` 把它收进了输出,于是:
- 「期望 0 次」那两条→ 错一次 == 期望值,**恒绿**
- 「期望包含 xxx」那两条 → 拿到的是报错不是内容,**恒假**

它们守的3 个 1.0.7 真缺陷于是「看起来早就修好了」。
修法:先加一条「helper 路径拼写正确」的前置断言,再谈内容。

## 二、`ck` 配 `grep -c`:数字里永远不含代码片段

第 12/13/14 节共 7 处。`grep -c` 只吐一个数字(如 `1`),
拿它去 `grep -qE 'def _vpn_env'` 匹配,**恒为假**。
而 `ckc` 只能问「恰好等于几」,「出现过没有」这个中间档没有函数。
新增 `ckg`:问「命中数 ≥1」,且非数字输出(grep 报错)判失败 ——
免得「查错了文件」又被读成「代码不在」。

## 三、判据在注释里命中

修好路径后暴露出来的:
- `sensitive=(d == '/etc/drouter')` 命中在 **11669 行的注释**里
  (那段正是在解释这个 bug 的来历)
- `'raw': d` 一处在 **3975 行注释**里,一处是**打印服务的 raw**
  (`{'cups':..., 'raw': dict(PRINT_DEFAULTS['raw'])}`),与 VPN 无关

改法:反向断言先 `grep -v` 剔注释;raw 那条用 awk 限在
`_vpn_status` 函数体内。这正是本轮在 t-107 里反复抓的同一个根因,
**同一个错在冒烟脚本里又犯了一遍**。

## 四、结构位置错:断言写在 trap 之前

「netavark 表已删除」这条断言原本在函数体末尾,而 cleanup 是
`trap cleanup EXIT` 触发的 —— 打印时容器还活着,表刚被 podman 重建,
**结构性必红**。断言移进 cleanup() 内。

连带发现:trap 在 `exit $fail` **之后**才跑,那时 $fail 已求值传给内核,
在 trap 里 `fail=$((fail+1))` 毫无作用。改用 RC_AT_EXIT 传递 + trap 里自己 exit。

## 顺带修正一条写错的需求

第 12 节原本断言三个守护「有不依赖 init 的拉起路径(Popen)」。
**这三个是oneshot 型 timer 守护**(timer 定时拉起、跑完即退),
压根不需要脱离 init 常驻;实测三个文件里Popen 均为 0。
那条断言每版必红 3 条 —— 于是有人会去改期望值「修绿」,那是把判据
迁就实现。改为验它自己的形态:有 __main__ 入口 + Popen=0。

冒烟:36/13 → **53/0**,SMOKE_EXIT=0,netavark 表确认已删。
2026-10-03 12:35:45 +08:00
liuzhuohua 2f8f09604b release: 1.0.8 — VPN 页把「模块没加载」误报成「内核不支持 WireGuard」
版本号 1.0.7 → 1.0.8(19 个文件 55 处,逐条过;历史陈述不动)。
顺带补上1.0.7 漏掉的更新日志小节(那一版只改了版本号)。

## 本轮修的缺陷

**先说结论:Debian 13 虚拟机上跑 drouter,KVM 不是容器,
内核里一直有 WireGuard —— 是面板自己判错了。**

原 _vpn_installed() 只看两件事:/sys/module/wireguard 在不在(模块当前
已加载)、/usr/bin/wg 在不在(工具已安装)。**从没查过 /lib/modules 下
有没有模块文件** → 「模块文件在但没加载」被压进「内核真没这个模块」。

而 Debian 走 udev 按需加载,/etc/modules 与 /etc/modules-load.d 默认都
不写 wireguard,模块在磁盘上但没进内存。本机实测:内核 6.12.107+deb13-amd64,
wireguard.ko.xz 在,未加载;wireguard-tools 也没装。

前端还配了一句写死的文案把它坐实:「通常说明跑在精简容器里」——
而这台是 PVE 上的 KVM 虚拟机。排查方向被这句话整个带偏。

修法:
- 判据扩到四态(ready / need_module / need_tool / unsupported),
  每态给可执行的下一步。unsupported 明确说装不了且不给一键修复。
- 模块文件名覆盖 .ko / .ko.xz / .ko.zst(Debian 默认压成 .ko.xz,
  只认 .ko 会把正常机器也判成 unsupported)
- 新增「一键修复」:modprobe → 写 /etc/modules-load.d/ 开机自启 →
  缺工具则 apt-get install,确认弹窗写清三步并告知不动网络
- apply 改为先自愈再报错,不再直接弹「内核不支持」把人挡在门外
- 老后端兜底 unknown 而非把旧的 no 猜成 unsupported(猜错=复活本 bug)
- 页面新增「运行环境」行;改掉「不需要装任何第三方软件」这句误导

## 测试基建:这轮抓出 3 条恒绿断言 + 1 处死分支

反向验证(把 bug 注入回去看它变红)逮到三条永远不会红的断言:
把探测路径改成 /nonexistent 仍全绿(docstring 里就写着那句话);
把 'env': _vpn_env() 注释掉仍全绿(我写的是 #注释,正则照样匹配);
把 if (!confirm( 改成 if (false && confirm( 仍全绿(提示文案里有那句话)。

根因:习惯在注释里引用被删掉的旧代码来解释 bug 来历,这些字面量让
「这段代码还在吗」类的判据永远成立。修法:t-107 统一走 py_code_only() /
js_code_only()(先按ast 判语义剥 docstring,再剥行注释),16 个注入全部被抓住。

死分支:用小锚点 Edit 只替换 if (d.installed === 'no') { 这一行时,
旧 body 与它的 } else if (!d.has_systemd) { 原样留在原地,拼出一个
「没有 systemd → 报内核不支持」的假分支——语法合法、静态检查也过。
防线:断言 } else if ( 的数量恰好等于分支数。

另有 4 处判据写错导致假红,共同形态是「脚本假设了一个修好之后就不成立
的前提」——比如「模块必须未加载」这条,fix 一跑完就假红。

## 验证

- t-107:126 → 167 项
- 反向验证 16/16 被抓住
- 新增 devtools/check-vpn-render.js 36 项(挂进 sync.sh 预检链)
- 新增 _dev/live-108-wg.py 真机验收 43 项;真机 _vpn_env 从 need_module
  修到 ready,wg 装上(v1.0.20210914),modules-load.d 已写入
- t-*.py 51 个全绿 / 静态检查器 39 个全绿 / 前端四查全绿 / md 0 死链
- 容器冒烟新增第 14 节:镜像里真跑 _vpn_env() 验四态判定
  (不 grep 字面量——注释里也会写这些词)
- deb 内 helper 与 app.js sha256 与本地逐字节一致
2026-10-03 12:28:21 +08:00
liuzhuohua e1125e3978 release: 1.0.7 — 备份/告警/配额三模块收尾 + 真机再抓 3 个真缺陷
版本号 1.0.6 → 1.0.7(13 个文件 55 处,逐条过;历史陈述不动)。

## 本轮又修的 3 个真缺陷(都是真机 API 验收才暴露的)

静态检查全绿、函数级测试 29/29 全过,接口一打就红:

1. 备份包名校验正则比命名规则窄 → 导出的包自己都认不出
   命名 charset 是 [A-Za-z0-9_-],校验只写 [a-z0-9]+,
   而 Debian 默认主机名 debian-primaryrouter 带连字符
   → 导出了、界面显示「已导出」,但列表里看不到、永远还原不了。
   修完才发现 _bk_list_entries 里还有第二份同样的正则(漏改),
   已让它直接调用 _bk_safe_name()。

2. inspect 把 manifest 的 sha256 弄丢了→ 20 项全部标「已损坏」
   重建 row 时没搬 sha256,下一行又拿 row.get('sha256') 比实际哈希
   → 恒为 None ≠ 哈希。还原也被「内容与清单不符」挡死。

3. 清单重叠 → 同一个文件进包两次
   SNAPSHOT_ETC_FILES 含 rescue.conf、SNAPSHOT_ETC_DIRS 含整个
   /etc/drouter → 同一 arcname 写两遍 tar(后者覆盖,不报错),
   file_count / total_bytes 虚高一倍。真机证据:22 项 → 20 项。

## 测试防线(新增 7 条断言,全部做过反向验证)

把 bug 注入回去看它变红、还原后变绿 —— 没反向验证过的断言
可能因抽取失败而恒绿:

- 校验规则与命名规则同源(charset + 长度)
- 该正则在 helper 里只有一份(列表不再自抄)
- inspect 把 sha256 带进了 row / 比对的是 row 里那个 / 还原侧仍用 it.get
- 打包时按 arcname 跨组去重 + 清单确有「文件 ⊂ 目录」重叠项
- OK_NAMES 补真实主机名形态(原用例只有无连字符的 debian)

t-107:119 → 126 项。

## 真机验收

- 新增 _dev/live-107-api.py(26 项,HTTP 接口层)
- _dev/live-107.py 29 项、live-107-api.py 26 项,全绿
- 基线零变化:网关 192.168.7.2 / nft 0 行 / dnsmasq inactive+disabled
  / rescue inactive / ip_forward=1 / netavark 表已回收
- deb 511,624 B;解包抽查三个新守护在 backend/、helper sha 与本地一致

## 产物校验

- 离线包 sha256sum -c 238/238 通过
- 包内 deb 与独立产物 sha256 一致(c1ebcf17…)
- deb / 离线包 远端↔本地 sha256 逐一对齐

## 顺手

- README 体积数字按 stat 实算改成 500 KB(原文写的是 453 KB)
- smoke-docker-106.sh → smoke-docker-107.sh,新增第 12/13 节
  (容器里三个新守护有不依赖 init 的拉起路径 + 镜像 Python 必须是 3.13)
- wireguard-tools 补进 DEPS 三处(helper / make-offline-deps / deb control)
2026-10-03 02:54:04 +08:00
liuzhuohua 249cc3d8c6 feat(backup/alert/quota/vpn): 新增四个模块 + 深度自查修7 个真缺陷
功能
- 配置备份与还原(backupd):分级导出/预览/还原,敏感内容默认排除
- 告警中心(alertd):WAN掉线/延迟/丢包探测,冷却期+ 免打扰
- 流量配额(quotad):按 IP/服务/小时桶聚合,费用分摊
- VPN 页前端接线 + 前端字段契约补齐

自查发现并修复的缺陷(_dev/t-107.py,119 项)
1. 默认备份静默丢失大半配置(严重)
   _bk_files 把整个 /etc/drouter 标为 sensitive,默认导出时被整目录排除,
   而 BACKUP_SCOPE 恰恰宣称备份含 dcfg.json / kern.conf / vlans.json /
   Docker 栈文件 / 自定义主题。界面全程显示「已导出」,用户换机还原才发现。
   改为逐文件判定(_bk_is_sensitive)+ 敏感子树剪枝 + excluded 收集与展示。
2. VPN status 把全部客户端私钥明文送到浏览器
   _vpn_status 返回 'raw': d(完整配置),前端 4 个渲染函数一个都没读过它。
3. Python 3.13 下保存 VPN 配置静默清空所有 peer(隐蔽)
   'str' in ip_network() 在 3.12 静默 False,3.13 抛 AttributeError,
   被紧邻的 except 吞掉 → 所有客户端无声消失。Debian 13 默认就是 3.13。
4. VPN 私钥在 _vpn_mask 里被连同其他字段一起抹掉
5. 14 处前端属性插值未转义(备份/告警/VPN/配额页)
   type="number" 只过滤输入,不阻止 HTML 属性注入。
6. wireguard 未登记到 DEPS(VPN 有 5 处调 wg,自检页看不到缺失提示)
7. build-deb.sh 声明「后端 N 个」与实际清单不符 + ULOG_ARCHIVE 重复定义

测试基建
- _dev/t-107.py:字段契约双向对照 / op 三段对齐 / 高危路径端到端
  (真跑 _bk_walk、_vpn_norm、配额聚合,不用正则假装测过)
- _dev/run-static.py + scripts/test-static.sh:统一静态回归入口
  排除列表曾写成裸名导致交互脚本没被排除、长会话被 SIGTERM
- t-packaging 改为从 build-deb.sh 抽取模块清单,并反向校验无游离模块
2026-10-03 00:49:07 +08:00
liuzhuohua d2f4caee9c 新增电光蓝/藏青色主题预设,中文化网络异常提示,新增 API 对齐检查器
1. 主题(backend/theme.py)
   - make_palette 新增 deep 参数:深色主题可指定独立底色,
     解决多个 dark 主题共用 #111827 底色、换主色后观感雷同的问题
   - 深色分支的四档状态浅底改用「提亮 + 向各自状态色偏」双通道派生,
     修掉 ok_l / err_l 撞成同色(navy 上四档不可区分)的问题
   - 新增 2 个内置预设:electric-blue(电光蓝,浅色,高饱和青蓝)、
     navy(藏青色,深色,deep=#0d1526,夜间低眩光)。内置主题 6 → 8
   - 对比度实测全部 ≥ WCAG AA:电光蓝最低 4.67,藏青最低 4.70

2. 中文化(web/app.js)
   - 新增 netErrCn():把浏览器原生英文异常(Failed to fetch /
     NetworkError / 证书错误 / 权限拒绝 / 解析失败等)归类为 8 种
     可读中文原因,原始串仅保留在 detail 供排查
   - api() 的网络失败与 JSON 解析失败分支改为中文主提示
   - 替换 7 处 e.message 直出(下载×3、文件读取、加载、初始化、导出)
   - 页面渲染 .catch 统一为中文主提示 + 小字技术细节

3. 测试
   - 新增 _dev/t-api106.py:API 五维对齐检查
     (前端路径→后端路由 / 方法语义 / 查询参数名 / PAGE_MODULES 落点 /
      后端未被调用路由),5 项全通过,前端 88 个静态路径全部可路由
   - t-theme.py:内置主题数断言改为非空(避免加主题误红),
     新增四档状态浅底互不相同 + 两个新预设共 30 项断言,831 项全绿
2026-10-02 22:31:16 +08:00
liuzhuohua b290a85862 docs: deb 体积按实测 452→453 KB(脚本移入 scripts/ 后略增) 2026-10-02 21:59:47 +08:00
liuzhuohua b0ed42b653 test(smoke): 修 1.0.6 容器冒烟 5 个假红并加 ckc 数字断言
首次冒烟 17/5,5 个红全是断言写法问题,不是产品缺陷:

- 四处拿 `grep -c` 的**数字**去 ck 里匹配代码文本 —— 那是问
  「数字里含不含这段代码」,恒为假(实际输出就是 3 / 1 / 2,
  恰恰说明修复代码在镜像里)。改用内容匹配,并新增 ckc()
  专门做「命中 N 次」型断言,反向断言(期望 0 次)不再硬凑正则。
- render_ppp 的 ip-up-script 距 def 行 46 行(docstring + 5 行
  「为什么需要这个钩子」的注释),-A8/-A25 都切在它前面,窗口放宽到 60。
- SNMP 净化的 `re.sub(r'[\r\n]', ...)` 经 ck 的 grep -E 再吃一层
  shell 转义后对不上。改断言净化后的特征串(replace('#', ' ')),
  不硬写字符类字面量。
- 补三条新断言:render_ppp 不再指向 bin/(反向,0 次)、
  sync-isp-dns.sh 有 2 处非零退出、无 systemd 时回退 SIGHUP。

真机结果 24/0 全绿,宿主基线无变化(ip_forward=1、nft 表已回收、
默认路由与 5900 监听不变)。
2026-10-02 21:59:13 +08:00
liuzhuohua 3d028f5188 release: v1.0.6 打包校验发现 sync-isp-dns.sh 装错位置,一并修掉
产物校验时进容器抽查,发现 1.0.6 的 PPPoE 断网修复自己有三个问题:

1. 脚本装错位置。初版由 deploy.sh 用 heredoc 写进 $OPT/bin/,而
   build-deb.sh 与 Dockerfile 都是 install scripts/*.sh → $OPT/scripts/。
   结果装 deb、跑容器的机器上 ip-up-script 指向不存在的文件,DNS 选
   「仅运营商/两者合并」时照样全家断网。改为 scripts/ 下的实体文件,
   三条部署路径共用一份;deploy.sh 顺带迁移存量占位文件里的旧路径
   (只换注释行,不碰已拿到的 nameserver)。

2. 容器里重载不掉。脚本只 systemctl reload dnsmasq,容器形态没有
   systemd,等于什么都不做。改为检测不到 /run/systemd/system 时回退
   kill -HUP $(pidof dnsmasq),与 helper 内部处理方式一致。

3. 写盘失败仍返回 0。pppd 只看 ip-up-script 的退出码来决定要不要记
   日志,原来目录不可写时脚本照样 exit 0,把「DNS 没同步」彻底藏起来。
   现在写临时文件或 mv 失败一律非零退出。

另把 deb 体积 447→452 KB、compose/guide 体积按实测更新到 README 与
RELEASE_NOTES。

真机(192.168.7.3)实测四条路径:拨号成功两条 nameserver 落盘 rc=0;
重复执行内容不变 rc=0 不重载;断线写占位 rc=0;目录不可写 rc=1。
回归 8 模块全绿,安全不变量(53/67 端口 0 占用、服务未启动、
ens18 IP 不变、VNC 5900 完好)全部保持。
2026-10-02 20:57:32 +08:00
liuzhuohua f602e69f1b 全面审计:修 30 处静默失效/安全/资源缺陷,含 4 个 P0
本轮按「每个菜单、每个功能」逐项排查,重点是家庭宽带上网必经的
IP / DHCP / DNS / PPPoE 链路,外加日志、监控、依赖三类基础能力。
所有缺陷的共同特征是「静默」:界面提示成功、配置写盘成功、服务
照常运行,但实际什么都没发生 —— 这类 bug 功能测试抓不到。

P0(安全 / 完全失效)
- readings 遮蔽 6 个模块的 POST 写分支:readings 字典同时匹配 GET 与
  POST 且位于所有写路由之前,/api/{acl,share,docker,ddns,theme,ulog}
  的保存操作永远走不到,还返回 ok:true + 读数据,前端照样提示
  「保存完成」。这六个模块从 1.0.5 之前就一直是废的。
- PPPoE 缺 ip-up 钩子:usepeerdns 只把运营商 DNS 写进
  /etc/ppp/resolv.conf,而 dnsmasq 读的是 resolv-file 指向的
  generated/isp-dns.conf。dns_mode=isp/both 时上游全空,全家断网。
  补 render_ppp 的 ip-up/ip-down-script + 新增 sync-isp-dns.sh。
- 紧急救援通道零凭据:开启时 169.254.0.1/16 挂到每一张物理网卡,
  「救援网段内」等于局域网任意主机,而确认短语是页面源码常量 ——
  等于任何人都能回滚配置并重启全机。改为开启即生成 6 位 token,
  所有写操作强制校验,失败 10 次锁 60 秒;老配置无 token 时拒绝
  放行(不退化成无凭据)。
- SNMP 注入:community 有 [\s#] 校验,sysname/location/contact 只截
  长度。塞一个换行就能凭空插出 rwcommunity,把只读团体名升级成可写。
  三个字段补齐换行与 # 过滤。

P1(静默失效)
- 静态租约写入 host/无 enabled,渲染器读 name/enabled → 整条被
  v_bool 过滤,「转静态」提示成功但 dnsmasq 里一条 dhcp-host 都没有。
- UPnP 网段永远退回 192.168.0.0/16:render_miniupnpd 读
  system.lan_address,但 apply 与 render 两条路径都只注入 ext_iface。
- 防火墙「仅语法检查」会写配置库:live=False 只代表写盘不生效,
  真正不写盘必须传 check_only。前端漏传,后端 merge_cfg 也照写。
- 构建保护模式下 rollback+reload 绕过守卫:会 restart dnsmasq /
  radvd / dhcpcd —— 正是这个模式要防的污染源。
- sys 页改了 access_scope 却提示「点击保存并应用」,而 PAGE_MODULES
  里没有 sys → 保存按钮根本不存在。
- DHCP 页 rebuild() 整体重渲染,新闭包覆盖 stopLeaseTimer,旧 interval
  句柄永久丢失 → 每加 10 条自定义 Option 泄漏一个 10 秒轮询。
- DHCPv6 prefix_len 在 IPv6 页写数字、DHCPv6 页写字符串 → 下拉框全不
  选中,动别的字段还会反向污染。
- 端口转发草稿:编辑只写 S.pfRules,重进从 c.rules 重建 → 切页丢草稿。
- 通用保存/应用成功后从不 pageClean(S.page):20 多个页面的脏标记
  保存后永久残留,之后每次进页面都用旧草稿盖掉库里读回的新值。
- DHCP 池三项校验只在向导路径,直存路径绕过 → 池含本机 LAN 地址时
  表现为「网络时通时不通」且无任何日志。校验提到 render_dnsmasq。
- 多拨 service_name 无白名单,含引号即可闭合引号续写 pppd 指令。
- 救援放行规则只 add 不 delete,改端口 + 关闭后旧 accept 永久残留。
  改独立 chain drouter_rescue,关闭时按 handle 删 jump 再删 chain。

P2(资源 / 性能 / 一致性)
- _ulog_prune 用 f.read().splitlines() + 第二个 kept 列表,keep_rows
  默认 20 万行 ≈ 160MB 峰值,而它每 60 秒跑一次 → 4GB 机器上的
  OOM 杀手。改流式:先只数行,未超上限零重写,超了才滑窗 + os.replace。
- /api/metrics 每 2 秒轮询,而 _route_latency 在 _metric_lock() 内跑
  ping(timeout 10s)→ 目标不可达时整个仪表盘阻塞十几秒。移出锁。
- br0 的 RX/TX 是成员口之和,与成员口一起累加使总流量约翻倍。
- dnsmasq cache-size=0(禁用缓存)被 `or 1000` 吞成 1000。
- radvd pool_enabled / dhcpv6 enabled 用 `is not False`,把字符串
  'false'/'0' 当 True。改 v_bool。
- Number('') === 0,清空的 MTU/MRU/租期/端口被提交成 0。新增 numOr。
- drouter-logd 缺磁盘将满保护(snapshotd 早有),根分区将满时归档
  失败被 except 吞掉,日志静默停更。
- multiupnpd local 域写死 /lan/,不跟随用户域名。
- 多拨 MRU 独立取值并钳 576–1500(原来直接写 mtu,界面填的 MRU
  被静默忽略)。
- rescue.conf 注释说 0600 实际 0640,改注释与实现对齐。

测试
- 新增 _dev/t-apicheck.py:前后端 API 契约 + 分支可达性检查器
  (169 项)。静态比对「路径在文件里出现过」查不出遮蔽,必须检查
  「谁先 return」。
- 新增 _dev/t-audit106.py:83 项,覆盖本轮全部修复。
- 新增 _dev/live-audit106.py:真机验收,重点验 6 个曾失效模块的写
  分支可达、check_only 不写库、静态租约字段、SNMP 净化、多拨白名单。
- t-fefix 的 value="${...}" 断言过严:map 回调形参取自源码字面量
  数组,零风险却一直误报。改为只对含点/调用/拼接的插值强制 esc()。
- 离线回归 35 个测试全绿;真机 192.168.7.3 验收全通过。

真机验证(192.168.7.3)
- 6 个模块 POST 均走到专属分支(用不存在 op 打出模块级 BAD_OP,
  不再是读数据 + ok:true);主题切换 active 读回一致、ulog save_conf
  成功、DDNS 非法 provider 被拒。
- check_only 预检前后配置库逐字节一致。
- 静态绑定写库为 {enabled,mac,ip,name},与渲染器读取字段一致。
- SNMP 库里 sysname 无换行、无 rwcommunity。
- 救援通道:无/错 token 403,正确 token 200,正确 token + 错短语被
  拒不执行,错 10 次 429 锁 60 秒,rescue.conf 0640 root:drouter;
  关闭后 token 清空、nft chain 回收、虚拟地址回收、服务停。
2026-10-02 18:58:23 +08:00
liuzhuohua f93495556c fix(depcheck): 逐项「安装」按钮实际不装包 —— web 层把 keys 丢了
前端 runDepInstall 发 {keys:[...]},drouter-web.py 却只读 b.get('only'),
helper 收到空列表走「只装必需项」分支;必需项全齐时直接返回「无需操作」,
用户点任何可选项的安装都只弹「全部依赖已安装完成」,实际一个包没装(1.0.5 实测)。

- web 层改为 keys = b.get('keys') or b.get('only') or [] 透传
- 顺带修 runDepInstall 里被无条件覆盖的按钮文案(一键安装缺失的必需项→缺失项)
- t-deps.py 新增 keys 透传回归 4 项
2026-10-02 16:33:02 +08:00
liuzhuohua f2c7fc7ae8 fix(shelld): 恢复被覆盖掉的 MAX_SESSIONS 定义(真机 NameError 崩溃)
1.0.5 插入 MAX_BUF 说明块时把 "MAX_SESSIONS = 8" 整行顶掉,
systemd 一拉 shelld 就 NameError 退出、auto-restart 死循环 ——
AST 抽片段的测试注入了自己的常量,抓不到模块顶层顺序问题。

- 恢复 MAX_SESSIONS = 8(_SESS_SEM 之前)
- t-fefix 新增第 12 节:全部守护模块顶层「先用后定义」静态扫描
  (按语句顺序模拟执行,不进入函数体,剔除推导式变量)
2026-10-02 11:35:42 +08:00
liuzhuohua 7b321bfdce fix: 第二轮深度审计 —— 堵提权/注入、收口资源生命周期、低配置性能水位,版本号 1.0.5
安全:
- chpasswd 换行注入可改 root 密码:用户名/密码拒绝换行与控制字符
- 删用户保护从写死两个名字改为 UID<1000 系统账号
- WOL 接口名白名单 + 目标地址 IPv4 校验,去 sh -c 管道
- ACL hosts 改 ipaddress 严格校验;存储挂载点限定 /mnt|/media|/srv
- 文件管理器拒绝清单扩到目录级 + 按文件名拦截(authorized_keys/shadow 等)
- 前端 201 处 value="${...}" 补 esc();href 只放行 http/https;
  主题导出注释同时剥 /* 与 */

丢数据/假功能:
- ACL/文件共享/DDNS 三页切走再回来草稿被静默覆盖:新增 PAGE_DIRTY 脏页登记
- LAN 页网关/域名写进 system 死键,改绑 dnsmasq 同源
- UPnP 映射表按钮不再顺手起服务,走只读接口 read:upnpmap

资源:
- PTY 缓冲 8MB 封顶 + 会话信号量 + 后台空闲回收 + 离开页面主动断开
- Web 工作线程 48 封顶、请求体 16MB 封顶、会话 500 封顶改单调时钟、
  审计 2 万行裁剪、helpd 并发 6 封顶
- helper 日志 32MB 轮转;快照前查磁盘余量;fs 下载 512MB→20MB
  (通道单条响应 32MB,超限只会静默失败)
- 11 处配置写入改原子写;rescue/snapshot 单元补 MemoryMax(三处同步)

性能:
- /api/config 16 次开库 → 1 次 IN 查询
- cleanup 状态扫描 30s 缓存;gzip 缓存加 24MB 字节闸
- 后台标签页心跳暂停、终端轮询降频

杂项:DHCPv6 停用只留注释;租约回收用 dhcp_release(DEPS 可选登记);
VLAN 只删自建接口;公网探测清残留结果文件;快照根白名单;
心跳定时器防重复;keep_days/keep_rows 不吞 0
2026-10-02 10:25:40 +08:00
liuzhuohua cd5ef7411f fix: 清「假输入框 / 隐身字段」这批前后端缺陷,版本号 1.0.4
把全量代码(后端 9 模块 + 前端单文件 SPA,约 4.5 万行)过了一遍,用 AST 抽出
渲染器读取的每个配置键,与页面上输入框绑定的字段做双向差集,专门找两类问题:
前端发了后端从不读(假输入框)、后端读了前端没有入口(隐身字段)。

会写坏系统的:
- render_network 违反 render() 契约返回字典,调用方按二元组解包后把字典的键
  当成路径和内容 —— 迁移 networkd 会生成文件名叫 name、内容写着 content 的垃圾文件
- DMZ 规则方向写反:注释写「排除到本机自身」,条件写成反面,管理口流量也一并 DNAT

五个「填了等于没填」的输入框:
- WAN 的 MTU 与「是否接受上游 DNS」后端零消费,现真正写入 MTUBytes= / UseDNS=
- DHCPv6 的「子网 ID」绑的是不存在的键,真生效字段是 sla_id
- Samba WINS 要求一个从不回填的字段同时为真,勾了永不生效
- 网桥「启用 STP」未绑定事件,勾完保存不进去
- 主题设计器「按主色生成配色」按钮无 handler

其余:
- RA 页补齐 rdnss_life / dnssl_life 输入框(1.0.3 修了错位却没给入口)
- 迁移预览只发 lan,必报「WAN 网卡不能为空」且网桥从不出现 → 补发 wan + bridge
- dnsmasq / miniupnpd / ppp / dhcpcd IPv6 / RA 地址池 / NFS 等插值零校验
- chrony 服务器校验只认域名和 IPv4,IPv6 NTP 服务器被误拒
- v_int 改用 try/except:上标数字能通过 isdigit() 却让 int() 抛异常导致 500
- ensure_cert 删掉硬编码的开发机 IP,改为本机地址 + 默认路由推导;补 openssl 失败检测
- DDNS 面板 XSS(记录名/服务商未转义);主题 CSS 注释注入(前后端各一处)

测试:render 返回值契约断言、CSS 注释注入回归、模板孤儿控件守卫,
_dev/t-*.py 42/42、前端三查 + md 链接检查全绿。
2026-10-02 00:23:30 +08:00
liuzhuohua 79f24aa264 fix: 部署静默回退用户设置 + radvd RDNSS 未校验等 5 处缺陷
1.0.3 为纯修复版本,五个问题全部来自真机(192.168.7.3)深度排查:
翻 journal 与统一日志的 error 记录、全盘扫描文件名控制字符、把现行配置库
里每个模块的渲染结果重新交给对应真守护进程做语法校验。

- render(radvd): RDNSS 字段原先完全不校验(同段代码里 prefix 校验网段、
  DNSSL 校验域名,唯独漏了它),填 IPv4 地址会原样写进 radvd.conf 导致
  radvd 起不来,界面只回一句 radvd.conf:18 error: syntax error。
  现逐项按 IPv6 地址校验并给中文提示;多值支持逗号/空格并统一按空格输出
  (逗号形式 radvd 本就不认);DNSSL 同样支持多域名;修正 DNSSL 生命期
  误读 rdnss_life 的字段错位。

- cleanup: 自动清理的「每天 00:00」根本存不下来——前端 `Number(v) || 4`
  与后端 `int(cfg.get('hour') or 4)` 都把 0 当成「没填」,叠加后保存即变
  04:00 且改不回来。两处改为显式判断 None/''。执行时刻判定由「正好等于」
  放宽为「不早于」,避免计划时刻恰好关机/重启导致当天彻底跳过。

- deploy.sh: 三处无条件覆写改为「只在文件不存在时初始化」——
  active-theme(自定义主题每次部署被打回默认)、generated/isp-dns.conf
  (PPPoE 上游 DNS 被擦、解析变空直到下次拨号)、cleanup enabled=true
  (唯一会真删文件的模块被静默重新打开)。另补兜底:生效主题指向已不
  存在的主题时回退 default 并打印说明。

- drouter-web: socketserver 默认 handle_error 把任何异常连整段 traceback
  打进 journal,浏览器切页/关标签抛的 BrokenPipeError / ConnectionReset
  属「对面先挂了」,三日攒数十条足以淹没真报错。新增 _Server 子类只静默
  这几类,其余照旧抛出。

- devtools: 新增 fix-crlf-names.py 清理 CRLF 环境残留的名字带 \r 脏条目
  (保守:默认只预演、仅删有同名兄弟者、目录需相对文件集合为子集、
  跳过 snapshots/);sync.sh 发布预检增加「文件名不得含控制字符」。
2026-10-01 17:43:28 +08:00
liuzhuohua a49b4d7771 fix(dnsmasq): 合并重复的 option 3/6,并诊断 /dev 节点缺失
DHCP 页有「专属字段」和「自定义 Options 表」两处能写同一个 option code。
两处都填 3/6 时会各自渲染一遍,而 dnsmasq 对列表型 option 是追加语义,
于是客户端收到重复的网关,DNS 列表也被搅乱(公网 DNS 排在本机前面,
本机 dnsmasq 形同虚设)。

改为按 code 合并成一行并顺序去重;带「强制」的 3/6 语义不同,仍单独输出
为 dhcp-option-force;其它 code(43/121…)完全不受影响。新建配置也不再
预置与专属字段重复的 code 3/6 行。

顺带:
- code 6 的占位符原本写上游 DNS,容易误导 —— option 6 是下发给客户端的
  「去哪台机器查 DNS」,应填本机。占位符与说明已改正。
- dnsmasq 预检失败时不再直抛英文原文。像
  「failed to seed the random number generator」其实与配置无关,是宿主机
  /dev 设备节点缺失,现在会分诊成系统层面的中文提示。

版本号 1.0.1 → 1.0.2。注意区分「产物版本」与「功能引入版本」的表述:
容器里无 systemd 时回退子进程拉起守护,是 1.0.1 引入的,文案保持 1.0.1。

新增 _dev/t-render.py 对 3/6 合并的回归断言(含 force、留空、顺序、
其它 code 不受影响、预览路径一致性)。全量回归 42/42 通过。
2026-10-01 16:49:49 +08:00
liuzhuohua 7d646b5f2b fix(helper): dnsmasq 预检失败时诊断出「/dev 节点缺失」
用户在 DHCP 页点「保存并应用」,弹出:
  配置语法检查未通过:dnsmasq: failed to seed the random number
  generator: 没有那个文件或目录

根因不是配置写错,而是目标机的 /dev 标准设备节点被误删过。

/dev 是 devtmpfs,由内核维护,但**已删除的节点内核不会重建**。
2026-09-30 一次 chroot 的 `mount --bind /dev` 变量写错,把宿主机 /dev
挂到了自己身上,随后的清理清空了整个 /dev —— 当时只补回了 /dev/null
一个节点。urandom / random / zero / full / tty / console / kmsg 以及
全部块设备(sda、sda1、sda2、zram0)和 /dev/net/tun 一直缺到今天,
直到 dnsmasq 预检要读随机数才暴露。

内核那句话里既没有 "dev" 也没有「设备」字样,用户只会以为是自己把
地址池填错了。新增 _missing_dev_nodes() 做分诊:命中该错误时返回中文
提示,指明这是系统层面的 /dev 问题、不是配置问题。

- backend/drouter-helper.py:新增 _missing_dev_nodes();_verify 的
  dnsmasq 分支做错误分诊(其它语法错误仍走原路径)
- devtools/fix-dev-nodes.sh:新增。幂等地补齐被删的标准设备节点,
  设备号分别取自内核 devices.txt(字符设备)、/proc/partitions
  (块设备)、/sys/class/input(输入设备);已存在的一律不动
- _dev/t-verify.py:新增 15 项回归,覆盖「节点齐全不误报」「缺谁报谁」
  「随机数错误被翻译」「其它错误不受影响」「合法配置通过」五条路径
- devtools/sync.sh:预检链挂上 t-verify.py

目标机 192.168.7.3 实测:
- fix-dev-nodes.sh 新建 32 个节点、0 失败;二次执行 32 跳过(幂等)
- dnsmasq --test 从「failed to seed the random number generator」
  变为「syntax check OK.」(退出码 0)
- 网络基线零变化:网关仍 192.168.7.2、ip_forward=1、
  5900 仍在听、网卡集合不变、dnsmasq 仍 inactive/disabled、
  nft 表 0 个
2026-10-01 16:01:45 +08:00
liuzhuohua 00fb643d70 docs: 修正「3.8G」歧义并重打 1.0.1 deb
README 特色第三条写「轻到能在 2 核 3.8G 上跑」,读者会把 3.8G
顺读成主频(GHz)。核对真机:2 vCPU、主频 2494 MHz、MemTotal
4015916 kB ≈ 3.83 GiB —— 3.8G 实为内存(free 报的可用总量),
不是主频,但裸写 "G" 确实有歧义。

统一成「N 核 / N GB 内存」写法,与规格表「内存 4 GB」口径一致:

- README 特色三标题 → 「轻到 2 核 4 GB 的小主机就够跑」
- README 正文补明这是**参考环境而非准入门槛**,并给出最低 1 核 1 GB
- README 截图说明 2 核 4G → 2 核 4 GB 内存(统一口径)
- docs/01 资源约束(2C/3.8G)→(2 核 / 4 GB 内存)
- docs/01 Kea 条目「对 3.8G 内存偏重」→「对 4 GB 内存偏重」

devtools/preview-webshell.py 里的 "3.8Gi" 不动 —— 那是模拟真机
free 命令输出的格式,数字本身正确。

docs/*.md 会随 deb 装到 /opt/drouter/docs/,故一并重打
drouter_1.0.1_all.deb(437464 B = 427 KB),包内文档已同步。
纯文案改动,无任何功能变化。
2026-10-01 12:00:30 +08:00
liuzhuohua 147a402eca fix(webshell): 修复 PTY 故障与终端满员自锁,补容器形态守护拉起
Web 终端「任何账号都连不上」的两个真实故障,以及容器形态下终端完全打不开。

- shelld: 根因是 /dev/ptmx 符号链接丢失(/dev/pts/ptmx 还在),
  pty.openpty() 找不到入口,内核却报 "out of pty devices" ——
  把「少一个设备节点」说成了「设备耗尽」,排查方向被带偏。
  新增 ensure_ptmx():启动时与创建失败时都探测,先补 symlink,
  不行再 mknod 兜底;仍失败则回 PTY_FAIL 并给出可照抄的命令

- shelld: 会话锁由 threading.Lock 改为 RLock。原锁不可重入,而
  connect 在持锁状态下调用 _reap_idle() 会二次取同一把锁 ——
  同线程二次 acquire 不报错不超时、直接睡死,锁永不释放,
  第 9 个连接起整个终端服务冻死且日志无痕。
  connect 不再持锁调回收;_reap_idle() 的 close() 移到锁外

- helper: 容器里没有 systemd,拉起终端守护原来只有 systemctl 一条路径,
  导致容器形态 Web 终端永远「终端守护未运行」。新增 _shelld_spawn() 回退:
  直接以子进程启动守护(start_new_session 脱离父进程);
  错误文案改为「容器形态无 systemd,重启容器即可」

- 前端: 终端去掉滚动条(仅隐藏外观,滚动能力保留)、
  高度改为随视口 clamp 自适应;新增「清屏」与「↓ 最新」按钮

- 打包: build-deb.sh 与 Dockerfile 不再整体拷 docs/ ——
  install/COPY 撞上 docs/screenshots/ 目录会报错(install 遇目录返回非 0,
  在 set -e 下直接中断整个打包);截图本就不该进 deb/镜像

- 修正两处不一致: index.html 页脚误写「GPL v3」→ MIT(与 LICENSE 对齐);
  README 中 deb 体积 421 KB → 427 KB(实测 437584 字节)

- 版本号 1.0.0 → 1.0.1(源码、打包脚本、文档、镜像标签、编排文件)

测试: _dev/t-pty.py 扩到 45 项,含「_lock 必须是 RLock」的行首断言
(原先用子串匹配被 self.buf_lock 误伤);t-webshell.py 补 16 项外观契约。
真机 192.168.7.3 实测:第 9 个连接 0.01s 干净返回 BUSY,
断开 8 会话 0.87s 全清、残留 0,PTY/fd 回落基线;
自愈验证(删掉 /dev/ptmx 重启服务可自动补回)。
容器冒烟 12/0:无 systemd 环境下守护成功拉起、
root shell 建会话并跑通命令回显。41 个单测文件全绿。
2026-10-01 03:09:19 +08:00
liuzhuohua 2859705cee fix(webshell): 修复终端满员自锁 + 去掉滚动条并加清屏按钮
一、修 PTY 不可用(Web 终端报「创建终端失败:out of pty devices」)
   根因是目标机上 /dev/ptmx 这个符号链接丢了(devpts 里 /dev/pts/ptmx
   还在,但入口没了),于是 pty.openpty() 抛出误导性的「out of pty devices」。
   - 新增 ensure_ptmx():探测 -> ln -sf pts/ptmx /dev/ptmx -> mknod 兜底
   - 新增 _open_pty():首次失败先自愈再重试一次,仍失败就把真实原因
     (_pty_diagnose() 的结论)包进异常,不再只留一句天书
   - 服务启动时也自愈一次,开机即修,不等用户踩坑
   - 前端识别 PTY_FAIL 并给出可照抄的修复命令

二、修会话满员时的死锁(连到第 9 个会话整个终端服务冻死)
   connect 需要「看是否满员 -> 满了就回收 -> 再看一次」,这个序列必然
   在持锁时调到内部也要拿锁的 _reap_idle()。而 _lock 是普通 Lock,
   同线程二次 acquire 永久阻塞 —— 不报错、不超时,直接锁死;而且锁
   一直不放,其他会话的 read/write 也一起堵住,整个 Web 终端报废。
   - _lock 改为 RLock
   - connect 不再把 _reap_idle() 放在 with _lock 里面
   - _reap_idle() 锁内只做登记摘除,close() 移到锁外:
     close() 要等子进程退出(SIGHUP 后最多轮询 3 秒),持锁做同样会
     把并发请求堵死

三、终端外观(按需求)
   - 去掉右侧滚动条(scrollbar-width:none + ::-webkit-scrollbar),
     但保留滚动能力 —— 直接关掉 overflow 会让人再也翻不了历史
   - 高度改为 clamp(260px,54vh,560px) 随视口自适应;内容多了自动跟随
     最新输出。刻意不做「高度随内容增长」:wsMeasure() 用 box.h 算行数,
     内容撑高容器会反过来改变 PTY 行数,形成反馈环
   - 标题栏加「清屏」小按钮;滚上去离开底部时另出现「↓ 最新」——
     滚动条没了,总得留个回到底部的入口
   - 跟随阈值收敛成唯一的 WS_TAIL_GAP:wsAutoScroll 与 wsSyncTailBtn
     各写一份会出现「按钮没出现、输出也不跟着滚了」的死角

四、顺带清理
   - 删掉 app.css 里整套失效的旧终端样式(.tdot/.tt/.term-in/.tk-*,
     app.js 中 0 引用),它们已被新结构完全覆盖,留着只会让人改错地方
   - 合并 .term-wrap / .term-body 各自重复的第二条声明

测试:
   _dev/t-pty.py 扩到 37 项,含「不得自锁」的结构检查 + _reap_idle
   行为验证(摘除/关闭/返回条数);_dev/t-webshell.py 加 16 项外观契约。
   两者都挂进 devtools/sync.sh 预检链。
   新增 devtools/preview-webshell.py:用真实 app.css + 从 viewWebShell()
   抠出的 DOM 生成离线预览页,不手抄样式以免预览与线上不一致。

真机验收(192.168.7.3):
   8 个会话满员后第 9 个 0.01s 干净返回 BUSY(修复前是永久卡死);
   8 个会话 0.87s 全部断开、残留 0(修复前残留 8 个且后续全卡);
   /dev/pts 与 pty/nr 回落到 0,无 fd 泄漏;root shell 正常执行命令。
2026-10-01 01:29:09 +08:00
liuzhuohua 9b4ffce9a5 feat(devtools): 生成截图图床外链清单(Markdown / BBCode / HTML 三格式)
用户问「能不能当图床、转成论坛可用的格式」。

实测结论:可以。四种直链形式全部 200,且
- Content-Type 正确(image/jpeg)
- Access-Control-Allow-Origin: *(CORS 全开,网页引用无阻)
- 无防盗链:带外部 Referer、无 UA 均正常返回

新增 devtools/gen-image-urls.py,一次性产出三种论坛格式:
  ① Markdown  ![alt](url)     GitHub / 语雀 / Discourse / Obsidian
  ② BBCode    [img]url[/img]  Discuz / phpBB / NGA / 恩山 / Chiphell / v2ex
  ③ HTML      <img src="url"> 博客 / 自建站 / 富文本编辑器

域名默认用 cdn.jsdelivr.net/gh —— 国内可达性明显好于
raw.githubusercontent.com,且缓存 7 天(raw 只有 5 分钟)。
文档里附了换域名对照表(gcore 备用节点 / statically / raw)。

同时支持 @<commit-sha> 冻结写法,把内容锁定在某个提交上,
避免后续提交把已发出去的帖子里的图改掉。

产物同时落在 docs/图床链接清单.md(进仓库)和桌面(方便直接用)。

顺带记录一个失败:想生成「纯格式版」和「带文件名版」两份,
判断逻辑写错导致两份内容相同;改用一次生成、分节呈现,
反而更适合「整段复制」的用法。别为了参数化而增加无意义的重复产物。
2026-09-30 23:14:19 +08:00
liuzhuohua 3f5f31701e feat(devtools): 新增截图覆盖度检查,README 标明待补页面
核对用户打包发来的 78 张截图,与仓库已入库的 78 张 md5 集合完全一致
(互相零差异),确认上一轮提交已是完整状态,本次不需改动任何图片。

本次补充的是「防漂移」能力:

1. 新增 devtools/check-shots.py —— 功能页 ↔ 截图 ↔ README 引用 三方对账
   - 43 个功能页逐个报告有无截图(当前 40 有 / 3 缺)
   - 映射表登记的截图 vs docs/screenshots/ 实际文件,双向查差异
   - README 引用的 vs 实际存在的,双向查差异
   为什么不用中文页名做子串匹配:「防火墙 IPv4」会命中「防火墙 IPv6」的
   规则说明,误判是静默的假绿。宁可手工维护一张映射表。

2. sync.sh 里原来那段内联的截图清单校验,替换为调用 check-shots.py
   (功能被完整覆盖,且现在多了功能页覆盖度这一维)。

3. sync.sh 的 CRLF 检查改为只扫 git 已追踪的文件。
   原来用 glob('**/*.html') 会扫到未追踪的本地临时预览页
   (_preview-shots.html),它既不会进仓库也不会被 deploy.sh 上传,
   为它报错是纯噪音。

4. README 截图章节新增「还没截图的 3 个页面」折叠块:
   新手向导 / 主题之家 / 升级与保护,附补图流程。
   项目结构章节补充两个新工具。

验证:check-md-links 98 个引用 0 死链;check-shots 三方一致;
      t-audit / t-packaging 全部通过。
2026-09-30 23:05:51 +08:00
liuzhuohua 36981ca115 chore: 忽略截图画廊的本地预览页 _preview-shots.html 2026-09-30 22:48:29 +08:00
liuzhuohua a8659dfb98 docs(readme): 用 78 张真实界面截图替换 SVG 占位图
原文里 docs/screenshots/ 放的是 6 张同尺寸 SVG 占位图,只为让 README
在仓库刚建好时能完整渲染。现在换成 78 张真实运行截图(1272x900,
取自一台 2 核 4G 的 Debian 13 虚拟机)。

改动:
- 新增 docs/screenshots/ 78 张 jpg,按 9 个功能分组编号命名
  (01-login / 02-overview… / 09-settings-4),覆盖全部 43 个功能页
- 删除 6 张 SVG 占位图与 devtools/make-placeholder-shots.py
- README「截图」章节重写:首屏放 8 张精选大图,其余 70 张按功能分
  组放进 9 个 <details> 折叠块,每组内用两列表格 + 图注
- README 项目结构补充 docs/screenshots 与 devtools/shots-map.py
- dist/RELEASE_NOTES.md 同步「截图已全部为真图」

为什么文件名必须 ASCII:
  GitHub Releases 资产名已被实测证明会静默剥离非 ASCII 字符
  (DOCKER-使用说明.md → DOCKER-.md);且原始文件名含 &(如
  AC&AP管理中心.jpg、UPnp&NAT-PMP.jpg)在 shell / CI 里都要额外转义。
  devtools/shots-map.py 负责「中文名 → ASCII 名」的映射与复制,
  并做完整性校验(漏拷/多余/重名都会中止)。

新增两项预检,挂进 devtools/sync.sh:
- devtools/check-md-links.py:所有 md 的本地引用逐条查存在性
- 截图清单双向校验:README 引用的 78 张与目录里的 78 个文件必须一一对应

验证:check-md-links 扫 10 个 md / 98 个本地引用,0 死链;
      78 张图与源目录 md5 集合完全一致;
      t-packaging / t-audit / t-deps / t-cachebust 全部通过。
2026-09-30 22:46:43 +08:00
liuzhuohua fe272820f5 chore(docker): 文档改用 ASCII 文件名,避免 GitHub 剥离非 ASCII 字符
GitHub Releases 的资产文件名只接受 ASCII —— 上传 `DOCKER-使用说明.md`
会被静默截断成 `DOCKER-.md`(不只是显示问题,下载下来的文件名就是残缺的)。
改名 `DOCKER-GUIDE.md` 并同步 README / Release 正文 / build-docker.sh 里的引用。
2026-09-30 22:21:54 +08:00
liuzhuohua 5150f4c2e6 feat(docker): 补 compose 编排与使用说明,修正镜像标签与 license
新增
  · packaging/docker/docker-compose.yml   现成编排,含健康检查与命名卷
  · packaging/docker/DOCKER-使用说明.md    完整使用文档(含常见问题)
  · devtools/retag-docker-image.py        镜像重打标签工具

修正三个真实缺陷
1. 镜像标签
   原本是 buildah 留下的 `localhost/drouter:1.0.0`。`localhost/` 是 Podman
   的本地镜像约定,Docker 用户拿到会很困惑(像某个私服地址)。
   改为 `drouter:1.0.0` + 别名 `liuzhuohua/drouter:1.0.0`。

2. license 标签写错
   镜像 LABEL 里 `org.opencontainers.image.licenses` 是 **GPL-3.0**,
   但项目实际是 **MIT**(Dockerfile 与镜像 config 都已改)。

3. 缺 HEALTHCHECK
   没有它,compose 里 `depends_on: condition: service_healthy` 无从等起,
   `docker ps` 也看不出容器是否真的活着。
   补上探活 /api/health —— 注意面板走 HTTPS + 自签证书,curl 必须 -k,
   否则永远 unhealthy。

compose 要点
  · network_mode: host 是硬要求(bridge 下看不到真实网卡、管不了宿主 NAT)
  · cap_add 只给 NET_ADMIN + NET_RAW,不用 privileged
  · host 模式下 ports 会被忽略,端口由 /etc/drouter/web-port 决定,
    故用 expose 表达意图而非 ports
  · 四个命名卷必须挂:配置 / 数据 / 快照 / 日志

重打标签采用流式重写 tar(只替换 manifest.json、repositories 与 config
三个小文件,layer.tar 原样透传),已逐层校验 3 个 layer 的 sha256 与
原包完全一致。
2026-09-30 21:25:28 +08:00
liuzhuohua 7b1ddb59a6 chore: 合并 GitHub 初始 LICENSE 提交 2026-09-30 19:52:30 +08:00
liuzhuohua 64df3cf331 feat: Drouter 1.0.0 —— Debian 13 软路由管理系统
一套跑在 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
2026-09-30 19:26:24 +08:00