## 性能:瓶颈在传输,不在代码
先量后改。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 项移动端适配全过。
184 lines
13 KiB
Bash
184 lines
13 KiB
Bash
#!/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
|