Files
drouter/devtools/sync.sh
T
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

184 lines
13 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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; }
# VPN 页四态文案渲染:need_module / need_tool / unsupported / ready 各喂一遍,
# 真跑 vpnRenderTop 看输出的 HTML。check-render 只看「视图崩没崩」,
# 抓不到「某一档文案拼出来的 HTML 是坏的」或「按钮没绑上」。
"$NODEBIN" devtools/check-vpn-render.js || { echo "❌ VPN 文案渲染预检失败"; 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; }
# 工作区残留预检:反向验证被 SIGTERM 打断时 finally 不执行,注入会留在源码里。
# 这不是保险 —— 2026-10-04 就因为它,helper 的 _vpn_env 把 /lib/modules 写成
# /nonexistent,need_module 整个状态从四态里消失,而这份缺陷**已经发到 v1.0.8**。
# 放在所有判据之前:基线被污染时,后面每一条红都不可信。
"$PYBIN" _dev/t-worktree-clean.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; }
# PTY 可用性:/dev/ptmx 丢失时内核只报一句「out of pty devices」,
# 而 kernel.pty.nr 是 0(一个都没用),排查起来极其费时。
# 这里钉住「启动自愈 + 失败重试 + 可定位的诊断信息」三件事。
"$PYBIN" _dev/t-pty.py || { echo "❌ PTY 可用性预检失败"; exit 1; }
# 配置预检分诊:dnsmasq 报「seed the random number generator」时,要翻译成
# 「本机 /dev 节点缺失」这种能直接定位的提示,而不是把内核英文原样抛出
"$PYBIN" _dev/t-verify.py || { echo "❌ 配置预检分诊失败"; 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 喂给不支持的客户端。
# 压缩策略本身(长缓存 / brotli 协商 / 逐级降级)由 t-static-perf.py 静态查、
# t-encoding-runtime.py 真跑一遍验证。
"$PYBIN" _dev/t-gzip.py || { echo "❌ 响应压缩预检失败"; exit 1; }
# 静态资源传输层:指纹长缓存、brotli 协商、版本号单一真源、logo 回概述。
# 这几条都是「不报错但功能悄悄失效」的类型 —— 压缩不生效、版本号停在旧版,
# 面板照常能开,只有看传输量才发现得了。
"$PYBIN" _dev/t-static-perf.py || { echo "❌ 静态资源传输层预检失败"; exit 1; }
"$PYBIN" _dev/t-encoding-runtime.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; }
# 文档链接:README 是项目的门面,一张图挂了就是一个白框,而且这种问题
# 只在 GitHub 网页上才看得出来(本地 markdown 预览器很多会静默忽略)。
# 截图从桌面搬进 docs/screenshots/ 时最容易漏改扩展名或文件名,
# 所以把「文档里写的路径 → 文件是否真的存在」钉成预检项。
"$PYBIN" devtools/check-md-links.py || { echo "❌ 文档链接预检失败"; exit 1; }
# 截图覆盖度:功能页 ↔ 截图 ↔ README 引用 三者必须对得上。
# 加功能页忘截图、补了图忘登记、重命名漏一张,这三类漂移都是静默的,
# 必须靠这条预检抓。缺图只提示不拦截(截图本来就可以慢慢补)。
"$PYBIN" devtools/check-shots.py || { echo "❌ 截图覆盖度预检失败"; exit 1; }
# 换行符检查:Windows 上写出的 CRLF 到了 Linux 会让 bash 报 "$'\r': 未找到命令",
# 整个 deploy.sh 会静默跑成一堆错误。必须在打包前拦住。
# 注意:不能用 grep —— Git Bash 的 grep 在文本模式下会把 LF 读成 CRLF,全是误报。
#
# 只扫 git 已追踪的文件:未追踪的本地临时产物(如 _preview-shots.html)
# 根本不会进仓库、也不会被 deploy.sh 上传,为它们报错是纯噪音。
"$PYBIN" - <<'PY' || { echo "❌ 换行符预检失败"; exit 1; }
import subprocess, sys
out = subprocess.run(['git', 'ls-files'], capture_output=True, text=True).stdout
bad = [f for f in out.splitlines()
if f.endswith(('.sh', '.py', '.js', '.css', '.html'))
and b'\r' in open(f, 'rb').read()]
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)
# 光查内容不够:CRLF 的 shell 脚本在 Linux 上执行时,行尾的 \r 会粘进参数,
# 真的会建出名字带 \r 的目录/文件(本机 /etc 里就留下过 dnsmasq.d\r、
# nftables.d\r、active-theme\r 等 7 个条目,得靠 devtools/fix-crlf-names.py 清)。
# 所以文件名本身也要查一遍,别让这种名字进仓库。
badname = [f for f in out.splitlines()
if any(ord(c) < 32 or ord(c) == 127 for c in f)]
if badname:
print('以下文件的「文件名」含控制字符(多半是 CRLF 环境下编辑产生的):')
for f in badname:
print(' ' + repr(f))
sys.exit(1)
print(' 换行符OK: 已追踪的脚本全部为 LF,且文件名无控制字符')
PY
echo "=== 打包源码 ==="
# docs 也要带上:deploy.sh 会装到 /opt/drouter/docs(systemd 的 Documentation 指向它)
# packaging/VERSION 也必须带上:它是版本号唯一真源,deploy.sh 装到
# /opt/drouter/VERSION,页面底部的版本号与 openapi 的 version 字段都读它。
# 漏了它不会报错,只会让真机一直显示兜底硬编码的旧版本号。
tar czf /tmp/drouter-src.tgz backend web scripts docs packaging/VERSION 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