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 项移动端适配全过。
This commit is contained in:
liuzhuohua committed 2026-10-04 10:27:02 +08:00
1 parent 2c92cc9b28
commit ed5eb4428b
22 files changed
+1036 -55

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
#!/bin/bash
# 真机验收:静态资源传输层(1.0.9)
#
# 验的是「浏览器实际收到什么」,不是源码里写了什么:
# ① 带指纹的 app.js → Cache-Control: immutable 长缓存
# ② 第二次请求 If-None-Match → 304,且**零字节响应体**
# ③ 无指纹的 /app.js → 仍是 no-cache
# ④ Accept-Encoding: br 但目标机没装 brotli → **必须退到 gzip**
# (这是本轮最大的风险点,退化就是 583KB 裸传)
# ⑤ 页脚版本号已注入,不再是 __APP_VERSION__ 字面量
# ⑥ index.html 的 ETag 里含版本号
# ⑦ logo 是 <a id="brand-home">,页脚有版本号元素
set -u
BASE="http://127.0.0.1:8080"
WEB="http://127.0.0.1:8080"
fail=0
ck() { if [ "$2" = "1" ]; then echo " ✓ $1"; else echo " ✗ $1"; fail=$((fail+1)); fi; }
echo "=== 1. 探测面板是否在跑 ==="
# ⚠️ **不要在这里试登录**。密码猜错有锁定计数(实测错误返回
# 「还可尝试 3 次」),验收脚本反复试会把用户的账号锁死 ——
# 而且脚本里硬编码密码这个做法本身就不该保留。
# 本脚本要验的东西全是公开静态资源 + 免鉴权的 /api/openapi,不需要登录。
CODE=$(curl -sk -m 5 -o /dev/null -w '%{http_code}' "$BASE/index.html")
echo " GET /index.html → $CODE"
[ "$CODE" = "200" ] || { echo "面板没起来($CODE),后续无意义"; exit 1; }
echo
echo "=== 2. 取 app.js 的指纹(从 index.html 里解析)==="
IDX=$(curl -sk -m 5 "$BASE/index.html")
# ⚠️ 不能用 sed 抽 /app.js?v=... —— 问号是 sed 的选项分隔符,
# 会报「s 的未知选项」,然后指纹解析静默失败、退回测无指纹 URL,
# 于是「immutable 长缓存」那条必然变红 —— 假红比不测更坏。
APPV=$(printf '%s' "$IDX" | grep -o '/app\.js?v=[0-9]*' | head -1)
[ -z "$APPV" ] && APPV="/app.js"
echo " 指纹 URL = $APPV"
echo
echo "=== 3. 带指纹请求 → 应为 immutable 长缓存 ==="
H1=$(curl -sk -m 10 -D - -o /dev/null "$BASE$APPV")
CC=$(printf '%s' "$H1" | sed -n 's/^[Cc]ache-[Cc]ontrol:[[:space:]]*//p' | tr -d '\r')
echo " Cache-Control: $CC"
[ -z "$CC" ] && CC=$(printf '%s' "$H1" | grep -i '^cache-control:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r')
printf '%s' "$CC" | grep -q 'immutable' && ck "带指纹 → immutable 长缓存" 1 || ck "带指纹 → immutable 长缓存(实际: $CC)" 0
printf '%s' "$CC" | grep -q 'max-age=31536000' && ck "max-age 是一年" 1 || ck "max-age 是一年" 0
ETAG=$(printf '%s' "$H1" | grep -i '^etag:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r')
[ -n "$ETAG" ] && ck "有 ETag($ETAG)" 1 || ck "有 ETag" 0
echo
echo "=== 4. 传输量(br / gzip / 无压缩 各一次)==="
SZ_BR=$(curl -sk -m 20 -H 'Accept-Encoding: br, gzip' -o /dev/null -w '%{size_download}' "$BASE$APPV")
ENC_BR=$(curl -sk -m 20 -D - -o /dev/null -H 'Accept-Encoding: br, gzip' "$BASE$APPV" | grep -i '^content-encoding:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r')
SZ_GZ=$(curl -sk -m 20 -H 'Accept-Encoding: gzip' -o /dev/null -w '%{size_download}' "$BASE$APPV")
ENC_GZ=$(curl -sk -m 20 -D - -o /dev/null -H 'Accept-Encoding: gzip' "$BASE$APPV" | grep -i '^content-encoding:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r')
SZ_RAW=$(curl -sk -m 20 -H 'Accept-Encoding:' -o /dev/null -w '%{size_download}' "$BASE$APPV")
SRC=$(stat -c %s /opt/drouter/web/app.js)
echo " 源文件 $SRC 字节"
echo " Accept-Encoding: br,gzip → $SZ_BR 字节(Content-Encoding: ${ENC_BR:-无})"
echo " Accept-Encoding: gzip → $SZ_GZ 字节(Content-Encoding: ${ENC_GZ:-无})"
echo " 不带编码 → $SZ_RAW 字节"
# 关键:目标机没装 python3-brotli,br 必须降级到 gzip,
# 绝不能「要 br 就发原文」—— 那样等于 583KB 裸传,比优化前更慢。
if [ -n "$ENC_BR" ]; then
ck "带 br 时有编码(实际 ${ENC_BR})" 1
if [ "$ENC_BR" = "gzip" ]; then
ck "br 不可用时已降级到 gzip(本机无 python3-brotli,属预期)" 1
elif [ "$ENC_BR" = "br" ]; then
echo " (本机装了 brotli,用的是 br)"
fi
else
SZ_NOGZ=$(curl -sk -m 20 -H 'Accept-Encoding: br' -o /dev/null -w '%{size_download}' "$BASE$APPV")
echo " ⚠ br 无编码且不支持 gzip → $SZ_NOGZ 字节裸传"
ck "br 不可用且无 gzip 兜底时会裸传(应为 0,本机应装 gzip 支持)" 0
fi
[ "$SZ_GZ" -lt "$SRC" ] && ck "gzip 确实压小了($SZ_GZ < $SRC)" 1 || ck "gzip 确实压小了" 0
[ "$SZ_RAW" = "$SRC" ] && ck "不带编码时原样输出($SZ_RAW = 源文件大小)" 1 || ck "不带编码时原样输出" 0
echo
echo "=== 5. 304 协商(第二次打开应当零传输)==="
# 304 响应没有 body,curl **不会创建** -o 指定的文件 → stat 会报
# 「没有那个文件或目录」,进而让 [: -lt ] 拿到空值。
# 用 %{size_download} 让 curl 自己报字节数,不依赖文件是否存在。
rm -f /tmp/_304body
read -r CODE BODY <<<"$(curl -sk -m 10 -o /tmp/_304body -w '%{http_code} %{size_download}' \
-H "If-None-Match: $ETAG" "$BASE$APPV")"
BODY=${BODY:-0}
echo " 状态码 $CODE,响应体 $BODY 字节"
[ "$CODE" = "304" ] && ck "If-None-Match 命中 → 304" 1 || ck "If-None-Match 命中 → 304(实际 $CODE)" 0
[ "$BODY" -lt 100 ] && ck "304 不回传响应体" 1 || ck "304 不回传响应体(实际 $BODY 字节)" 0
rm -f /tmp/_304body
echo
echo "=== 6. 无指纹请求 → 必须仍是 no-cache ==="
CC2=$(curl -sk -m 10 -D - -o /dev/null "$BASE/app.js" | grep -i '^cache-control:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r')
echo " Cache-Control: $CC2"
printf '%s' "$CC2" | grep -q 'no-cache' && ck "无指纹 → no-cache(否则部署后拿到旧文件)" 1 || ck "无指纹 → no-cache(实际: $CC2)" 0
printf '%s' "$CC2" | grep -q 'immutable' && ck "⚠ 无指纹却给了 immutable(会导致用户卡在旧版本)" 0 || ck "无指纹没有误给 immutable" 1
echo
echo "=== 7. Vary 头(代理正确性)==="
V=$(curl -sk -m 10 -D - -o /dev/null -H 'Accept-Encoding: gzip' "$BASE$APPV" | grep -ci '^vary:.*Accept-Encoding')
[ "$V" -ge 1 ] && ck "压缩响应带 Vary: Accept-Encoding" 1 || ck "压缩响应带 Vary: Accept-Encoding" 0
echo
echo "=== 8. 版本号注入 ==="
if printf '%s' "$IDX" | grep -q '__APP_VERSION__'; then
ck "页脚版本号已注入(不应残留占位符)" 0
else
ck "页脚版本号已注入(无占位符残留)" 1
fi
VER=$(printf '%s' "$IDX" | sed -n 's/.*id="app-ver"[^>]*>\(v[0-9][^<]*\)<.*/\1/p' | head -1)
[ -z "$VER" ] && VER=$(printf '%s' "$IDX" | grep -o 'v[0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?' | tail -1)
echo " 页面显示版本号 = ${VER:-(未找到)}"
[ -n "$VER" ] && ck "页脚有版本号" 1 || ck "页脚有版本号" 0
VFILE=$(cat /opt/drouter/VERSION 2>/dev/null | tr -d ' \t\r\n')
echo " /opt/drouter/VERSION = ${VFILE:-(不存在)}"
[ -n "$VFILE" ] && ck "VERSION 文件已部署" 1 || ck "VERSION 文件已部署" 0
if [ -n "$VER" ] && [ -n "$VFILE" ]; then
[ "v$VFILE" = "$VER" ] && ck "页面版本号与 VERSION 文件一致" 1 || ck "页面版本号与 VERSION 文件一致(页面 $VER vs 文件 v$VFILE)" 0
fi
echo
echo "=== 9. index.html 的 ETag 含版本号 ==="
IE=$(curl -sk -m 10 -D - -o /dev/null "$BASE/index.html" | grep -i '^etag:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r')
echo " index.html ETag = $IE"
if [ -n "$VFILE" ]; then
printf '%s' "$IE" | grep -q "$VFILE" && ck "ETag 里含版本号(改版本即换 ETag)" 1 || ck "ETag 里含版本号" 0
fi
echo
echo "=== 10. 两个 UI 元素 ==="
printf '%s' "$IDX" | grep -q 'id="brand-home"' && ck "左上角 logo 是可点击元素" 1 || ck "左上角 logo 是可点击元素" 0
printf '%s' "$IDX" | grep -q '<a class="brand' && ck "logo 用 <a>(键盘/读屏可用)" 1 || ck "logo 用 <a>" 0
printf '%s' "$IDX" | grep -q 'title="返回系统概览"' && ck "logo 有可访问名称" 1 || ck "logo 有可访问名称" 0
printf '%s' "$IDX" | grep -q 'id="app-ver"' && ck "页脚有版本号容器" 1 || ck "页脚有版本号容器" 0
echo
echo "=== 11. openapi 的 version 与页面一致(/api/openapi 免鉴权)==="
API_VER=$(curl -sk -m 5 "$BASE/api/openapi" | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
echo " openapi version = ${API_VER:-(未取到)}"
[ -n "$API_VER" ] && ck "openapi 可免鉴权访问且能取到 version" 1 || ck "openapi version 取不到" 0
if [ -n "$VFILE" ] && [ -n "$API_VER" ]; then
[ "$API_VER" = "$VFILE" ] && ck "openapi version 与 VERSION 文件一致(都是 $VFILE)" 1 \
|| ck "openapi version 与 VERSION 一致($API_VER vs $VFILE)" 0
fi
echo
if [ "$fail" -eq 0 ]; then echo "=== 全部通过 ==="; else echo "=== $fail 项失败 ==="; fi
exit $fail
+84
View File
@@ -0,0 +1,84 @@
// 量 app.js 的真实解析/执行成本,以及各种压缩格式的实际收益。
// 目的:优化前先有数字,别凭感觉改。
const fs = require('fs');
const zlib = require('zlib');
const path = require('path');
const ROOT = path.dirname(__dirname);
const src = fs.readFileSync(path.join(ROOT, 'web', 'app.js'), 'utf8');
function kb(n) { return (n / 1024).toFixed(0) + ' KB'; }
console.log('=== 体积 ===');
console.log(' 原始 :', kb(src.length));
console.log(' gzip -9 :', kb(zlib.gzipSync(src, { level: 9 }).length));
try {
console.log(' brotli q11 :', kb(zlib.brotliCompressSync(src, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length));
} catch (e) { console.log(' brotli : 不可用', e.message); }
// 语法编译:V8 解析 674KB 源码的真实耗时
let t = Date.now();
try {
new Function(src);
} catch (e) {
console.log(' 语法错误:', e.message);
process.exit(1);
}
console.log('=== 成本 ===');
console.log(' 语法编译 :', (Date.now() - t), 'ms');
// 顶层执行:把 DOM 打桩,量「首屏前」跑多少 JS
function stub() {
const el = () => ({
style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
appendChild() {}, removeChild() {}, setAttribute() {}, getAttribute: () => null,
addEventListener() {}, removeEventListener() {}, querySelector: () => null,
querySelectorAll: () => [], insertAdjacentHTML() {}, focus() {}, click() {},
getBoundingClientRect: () => ({ top: 0, left: 0, width: 0, height: 0 }),
children: [], childNodes: [], innerHTML: '', textContent: '', value: '',
});
global.document = {
getElementById: () => null, querySelector: () => null, querySelectorAll: () => [],
createElement: el, createElementNS: el, createTextNode: () => ({}),
body: el(), head: el(), documentElement: el(),
addEventListener() {}, removeEventListener() {}, cookie: '',
createDocumentFragment: () => el(),
};
global.window = {
addEventListener() {}, removeEventListener() {},
location: { href: '', hash: '', search: '', pathname: '/', reload() {} },
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
requestAnimationFrame: () => 0, innerWidth: 1280, innerHeight: 800,
matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
getComputedStyle: () => ({}), scrollTo() {}, alert() {}, confirm: () => true,
};
global.localStorage = global.window.localStorage;
global.sessionStorage = global.window.sessionStorage;
global.location = global.window.location;
global.navigator = { userAgent: 'node', language: 'zh-CN', platform: 'linux' };
global.history = { pushState() {}, replaceState() {} };
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
global.XMLHttpRequest = function () { this.open = () => {}; this.send = () => {}; };
global.alert = () => {};
global.confirm = () => true;
global.CustomEvent = function () {};
global.Event = function () {};
}
stub();
t = Date.now();
try {
(new Function('return (function(){' + src + '\n})()'))();
} catch (e) {
console.log(' 顶层执行异常:', e.message);
}
console.log(' 顶层执行 :', (Date.now() - t), 'ms');
// 全文里各「视图函数」占多少行 —— 决定要不要拆包
const funcs = [...src.matchAll(/^(?:async\s+)?function\s+(\w+)/gm)].map(m => m[1]);
console.log('=== 结构 ===');
console.log(' 顶层函数数 :', funcs.length);
console.log(' 总行数 :', src.split('\n').length);
+5 -1
View File
@@ -15,7 +15,11 @@
# 把标签从 buildah 的 localhost/... 改成 docker 惯用的名字。
set -e
VER="${1:-1.0.8}"
# 版本号唯一真源是 packaging/VERSION(见 packaging/build-deb.sh 里的说明)
_HERE="$(cd "$(dirname "$0")/.." && pwd)"
VF="$_HERE/packaging/VERSION"
[ -f "$VF" ] || { echo "缺少 $VF(版本号唯一真源)"; exit 1; }
VER="${1:-$(tr -d ' \t\r\n' < "$VF")}"
SRC="${2:-/tmp/drouter-build}"
cd "$SRC" || exit 1
+1 -1
View File
@@ -1,6 +1,6 @@
"""反向验证:逐个注入 bug,确认 t-107 的 C3c 新断言真的会变红。
⚠️ 为什么必须做:静态断言最危险的失败模式是「正则没匹配上��
⚠️ 为什么必须做:静态断言最危险的失败模式是「正则没匹配上」
于是恒绿」。之前踩过两次(heredoc 锚点没匹配上却报「反向验证通过」)。
所以这里每一条都必须**看到 NG 才算验证通过**,而且注入用的是
Write 落盘的真实脚本,不用 heredoc(heredoc 里的 old 串
+11 -1
View File
@@ -46,6 +46,11 @@ for name in ('app.js', 'app.css'):
os.utime(p, (1_700_000_000, 1_700_000_000))
ns = {'os': os, 're': re, 'WEB_DIR': tmp}
# _inject_asset_version 从 1.0.9 起还会替换页脚版本号占位符,
# 所以要提供 app_version()。**漏了会 NameError** —— 而这个判据只
# 抽了单个函数、不是整个模块,模块级定义一个都带不过来。
# 凡是改了被抽函数的签名或新引用的模块级符号,替身必须同步补。
ns['app_version'] = lambda: '9.9.9'
exec(compile(ast.Module(body=picked, type_ignores=[]), SRC, 'exec'), ns)
inject = ns['_inject_asset_version']
@@ -53,6 +58,7 @@ HTML = ('<!doctype html>\n'
'<link rel="icon" href="/logo.svg" type="image/svg+xml">\n'
'<link rel="stylesheet" href="/app.css">\n'
'<link rel="stylesheet" href="/theme.css?v=123">\n'
'<span id="app-ver">v__APP_VERSION__</span>\n'
'<script src="/app.js"></script>\n')
out = inject(HTML.encode('utf-8')).decode('utf-8')
@@ -76,6 +82,10 @@ ck('不会重复追加(幂等)',
ck('输出仍是合法 utf-8 且保留 doctype', out.startswith('<!doctype html>'))
ck('单引号属性也能处理',
"/app.css?v=" in inject(b"<link href='/app.css'>").decode('utf-8'))
# 1.0.9:页脚版本号占位符也要被替换掉。
# 漏了不会报错,只是页面底下一直显示 v__APP_VERSION__ 字面量。
ck('页脚 __APP_VERSION__ 被替换成真实版本号',
'__APP_VERSION__' not in out and 'v9.9.9' in out)
print('\n结果:%d 项,失败 %d 项' % (7, len(fails)))
print('\n结果:%d 项,失败 %d 项' % (8, len(fails)))
raise SystemExit(1 if fails else 0)
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""**实际执行** drouter-web.py 的压缩协商函数,验证行为而不是文本。
t-static-perf.py 全是源码文本断言,能挡住「代码被改回去」,但挡不住
「代码还在、行为是错的」。这一份把 _accepts / _compress_cached /
_encode_body 抽出来真跑一遍,覆盖这些真实场景:
- br 可用 + 客户端要 br → br
- br 可用 + 客户端只要 gzip → gzip(**不能**给 br)
- **br 模块缺失 + 客户端只要 br → 必须退到 gzip**(真机就是这个情况)
- 客户端 br;q=0 → 不能给 br
- 都不支持 → 原文
- 同一文件先 gzip 后 br,两次结果都要正确(编码进键,不能互相覆盖)
- 压不动的小响应 → 原文
⚠️ brotli 用**注入的假模块**而不是真装一个:真机上 python3-brotli 是
可选依赖,装了就测不出降级路径了。假模块要能真的编出合法 brotli 流,
否则解压那步会假失败。
"""
import ast
import io
import os
import sys
import types
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_PATH = os.path.join(ROOT, 'backend/drouter-web.py')
WEB = io.open(WEB_PATH, encoding='utf-8').read()
TREE = ast.parse(WEB)
# ---- 抽函数(ast 精确取 def 节点,不用正则切)----
WANT = ('_accepts', '_want_gzip', '_want_brotli', '_compress_cached',
'_brotli_cached', '_gzip_cached', '_encode_body')
nodes = []
for n in TREE.body:
if isinstance(n, ast.FunctionDef) and n.name in WANT:
nodes.append(n)
got = set(n.name for n in nodes)
chk('六个压缩函数都能定位到', got == set(WANT),
'缺: %s' % (set(WANT) - got))
# 常量(GZIP_MIN / BROTLI_QUALITY / _COMP_MAX*)
consts = {}
for n in TREE.body:
if isinstance(n, ast.Assign) and len(n.targets) == 1:
t = n.targets[0]
if isinstance(t, ast.Name) and t.id in (
'GZIP_MIN', 'BROTLI_QUALITY', '_COMP_MAX', '_COMP_MAX_BYTES'):
consts[t.id] = n.value
# 缓存表是 AnnAssign/Assign 都可能
if isinstance(n, ast.Assign) and len(n.targets) == 1:
t = n.targets[0]
if isinstance(t, ast.Name) and t.id in ('_comp_cache', '_comp_bytes'):
consts[t.id] = n.value
chk('压缩相关常量都在模块顶层', len(consts) >= 4, '拿到 %s' % sorted(consts))
mod = ast.Module(body=[], type_ignores=[])
# 压缩链的必需依赖:**必须原样注入,不能用替身**。
# 教训:曾经漏注 _comp_lock,函数里一句 NameError 被 `except Exception`
# 吞成「返回原文」—— 压缩功能「完好」但从不生效,583KB 一直裸传,
# 而只看响应头的判据完全发现不了。
ns = {
'os': os, 're': __import__('re'), 'gzip': __import__('gzip'),
'threading': __import__('threading'),
}
for n in TREE.body:
if isinstance(n, ast.FunctionDef) and n.name in WANT:
mod.body.append(n)
elif isinstance(n, ast.Assign) and len(n.targets) == 1 and \
isinstance(n.targets[0], ast.Name) and n.targets[0].id in consts:
mod.body.append(n)
# 锁不是白名单里的常量,但它是压缩链的硬依赖
ns['_comp_lock'] = ns['threading'].Lock()
exec(compile(mod, WEB_PATH, 'exec'), ns)
_accepts = ns['_accepts']
_encode_body = ns['_encode_body']
_comp_cache = ns['_comp_cache']
def hdr(v):
return {'Accept-Encoding': v} if v is not None else {}
# ---- 1. _accepts:q 值语义 ----
for ae, want_gz, want_br, note in (
('gzip, deflate, br', True, True, '标准 Chrome 头'),
('gzip', True, False, '只认 gzip'),
('br', False, True, '只认 br'),
('br;q=0', False, False, '明确不要 br'),
('br;q=0.0', False, False, 'q=0.0 也不要'),
('br;q=0.5', False, True, 'q>0 就要'),
('gzip;q=0, br', False, True, '不要 gzip 但要 br'),
('', False, False, '空头'),
('identity', False, False, '只接受不压缩'),
):
g = _accepts(hdr(ae), 'gzip')
b = _accepts(hdr(ae), 'br')
chk('Accept-Encoding %-20r → gzip=%-5s br=%-5s(%s)'
% (ae, g, b, note), g == want_gz and b == want_br,
'期望 gzip=%s br=%s' % (want_gz, want_br))
chk('缺 Accept-Encoding 头时两个都判 False',
not _accepts(hdr(None), 'gzip') and not _accepts(hdr(None), 'br'))
chk('头是 None 时不抛异常', not _accepts({'Accept-Encoding': None}, 'br'))
# ---- 2. 无 brotli 模块 → 必须退到 gzip(真机就是这样)----
BIG = (b'drouter static asset payload. ' * 200) # > GZIP_MIN
def run(data, ae, with_brotli):
"""在指定 brotli 可用性下跑一次 _encode_body。"""
saved = sys.modules.get('brotli')
if with_brotli:
m = types.ModuleType('brotli')
# 用真 brotli 未必装了;假实现只要稳定可逆,就能验证「选了哪条路」
m.compress = lambda d, quality=None: __import__('gzip').compress(d, 9)
sys.modules['brotli'] = m
else:
sys.modules.pop('brotli', None)
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
try:
return _encode_body(hdr(ae), None, data)
finally:
if saved is None:
sys.modules.pop('brotli', None)
else:
sys.modules['brotli'] = saved
def _sz(x):
"""安全取长度。被测代码返回 None 时**不能**让判据自己先崩 ——
判据崩溃 = 退出码非 0 但没有 FAIL 行,看起来像「跑了」,
实际上没人知道它验出了什么。反向验证时这条尤其致命:
bug 明明抓到了,输出却是一片 OK。
"""
try:
return len(x)
except TypeError:
return -1
body, enc = run(BIG, 'gzip, deflate, br', False)
chk('**没有 brotli 模块时要退到 gzip,不是吐原文**',
enc == 'gzip' and _sz(body) < len(BIG),
'enc=%s %d→%s 字节' % (enc, len(BIG), _sz(body)))
body, enc = run(BIG, 'gzip, deflate, br', True)
chk('有 brotli 且客户端要 br → 用 br', enc == 'br', 'enc=%s' % enc)
body, enc = run(BIG, 'gzip', True)
chk('有 brotli 但客户端只要 gzip → 给 gzip(不能给 br)',
enc == 'gzip', 'enc=%s' % enc)
body, enc = run(BIG, 'br', False)
chk('要 br 但没模块、且不支持 gzip → 原文(唯一正确选择)',
enc is None and body == BIG, 'enc=%s' % enc)
body, enc = run(BIG, 'br;q=0, gzip', True)
chk('客户端 br;q=0 → 给 gzip,不给 br', enc == 'gzip', 'enc=%s' % enc)
body, enc = run(BIG, '', True)
chk('不支持任何压缩 → 原文', enc is None and body == BIG, 'enc=%s' % enc)
# ---- 3. 同文件两种编码互不覆盖 ----
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
saved = sys.modules.get('brotli')
m = types.ModuleType('brotli')
m.compress = lambda d, quality=None: b'BROTLI' + __import__('gzip').compress(d, 1)
sys.modules['brotli'] = m
try:
gz_body, gz_enc = _encode_body(hdr('gzip'), None, BIG)
br_body, br_enc = _encode_body(hdr('br'), None, BIG)
gz_again, gz_again_enc = _encode_body(hdr('gzip'), None, BIG)
finally:
if saved is None:
sys.modules.pop('brotli', None)
else:
sys.modules['brotli'] = saved
chk('同一份数据两种编码都能取到(键里带了编码,没互相覆盖)',
gz_enc == 'gzip' and br_enc == 'br'
and _sz(gz_body) > 0 and _sz(br_body) > 0
and gz_body != br_body and _sz(br_body) >= 6
and br_body[:6] == b'BROTLI',
'gz=%s br=%s' % (gz_enc, br_enc))
chk('gzip 结果二次命中缓存(内容与首次一致)',
gz_again_enc == 'gzip' and _sz(gz_again) > 0 and gz_again == gz_body)
chk('缓存里确实是两个独立条目', len(_comp_cache) == 2,
'实际 %d' % len(_comp_cache))
# ---- 4. 压不动的小响应 ----
# ⚠️ 用**真正不可压缩**的数据。b'x'*100 这种单字符重复,gzip 能压到 20 字节,
# 反而更小,于是「压不划算」这条判据测的其实是另一件事(我第一次就踩了)。
# 这里用 os.urandom 模拟已加密/已压缩的载荷(图片、woff、zip 都是这德性)。
import random
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
rnd = random.Random(42)
small = bytes(rnd.randrange(256) for _ in range(200))
body, enc = _encode_body(hdr('gzip'), None, small)
chk('压不划算的小响应直出原文', enc is None and body == small,
'enc=%s %d→%s 字节' % (enc, len(small), _sz(body)))
# ---- 5. 缓存有上限,不会无限涨 ----
chk('缓存有条目数上限', ns['_COMP_MAX'] > 0)
chk('缓存有字节上限(24 条大响应就能吃掉上百 MB)',
ns['_COMP_MAX_BYTES'] >= 4 * 1024 * 1024,
'%d 字节' % ns['_COMP_MAX_BYTES'])
# ---- 6. 内部错误不许被兜底吞成「返回原文」 ----
# 这条是本文件最有价值的判据:压缩链里一个 NameError 曾被
# `except Exception` 吞掉,返回原文,压缩功能「完好」但从不生效。
# 表现和优化前一模一样,页面照常能开,只看响应头的判据也发现不了。
# 唯一的信号是「压缩没生效却没有任何异常」。
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
saved_lock = ns['_comp_lock']
ns['_comp_lock'] = None # 故意制造 AttributeError/TypeError
try:
body, enc = _encode_body(hdr('gzip'), None, BIG)
swallowed = (enc is None and body == BIG)
except Exception:
swallowed = False
finally:
ns['_comp_lock'] = saved_lock
chk('压缩链内部错误不会被静默吞成「返回原文」',
not swallowed,
'内部错误被 except 吞掉 → 压缩静默失效,583KB 裸传却无人察觉'
if swallowed else '')
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
sys.exit(1 if fails else 0)
+9 -3
View File
@@ -30,7 +30,11 @@ 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)
# ⚠️ 1.0.9 起压缩缓存从 _gz_cache/_br_cache 合并成一张 _comp_cache
# (键里带编码)。这里跟着改名,别断言一个已经不存在的变量 ——
# 判据断言错的名字比没有判据更糟:它会一直绿,而功能早就改了。
chk('压缩结果带内存缓存(不每次重压)',
'def _compress_cached' in WEB and '_comp_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)
@@ -41,8 +45,10 @@ chk('压缩比不划算时不压(原始数据更大就直出)', 'if len(z) >
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)
# 调用点 1.0.9 起统一走 _encode_body(自动挑 br/gzip),
# 不再分别调 _gzip_cached —— 否则 br 永远没机会上场。
chk('JSON 接口走统一编码协商', '_encode_body(self.h.headers, None, body)' in WEB)
chk('静态资源走统一编码协商', '_encode_body(self.headers, full, data)' in WEB)
chk('HEAD 请求不回响应体', "if self.command != 'HEAD'" in WEB)
# 小响应不能被压:省下的字节还不如压缩开销
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""静态资源传输层契约:长缓存 + brotli 协商 + 版本号注入 + logo 回概述。
1.0.9 新增。这一批改动要挡住的具体回归:
1. **指纹资源没走长缓存** —— app.js 583KB(gzip 198KB / br 160KB)。
早先一律 no-cache,指纹明明注入了却每次重传。局域网无感,
走外网(cloudflared / 远程打开)就是每开一次面板多传 200KB。
2. **brotli 不可用时没回退 gzip** —— 目标机实测**没装 python3-brotli**
(只有 C 库 libbrotli1)。如果「要 br 就只发 br,失败吐原文」,
优化后在真机上退化成 583KB 裸传,比优化前更慢。
3. **两种编码共用一个缓存键** —— 后写进去的会把先写的覆盖掉,
拿到 br 的客户端可能收到 gzip 版,浏览器解不开就是满屏乱码。
4. **`br;q=0` 被当支持** —— 子串匹配会正好发给「明确不要 br」的客户端。
5. **版本号没有单一真源** —— 早先 4 个构建脚本各写死一个默认值。
6. **index.html 的 ETag 与实际发出的内容不一致** —— 它发的是注入后的
产物,ETag 却按原文件算;只改 VERSION 时 ETag 不变,客户端换到 304
就继续用旧副本,页面版本号永远停在旧版。
"""
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))
WEB = io.open(os.path.join(ROOT, 'backend/drouter-web.py'),
encoding='utf-8').read()
INDEX = io.open(os.path.join(ROOT, 'web/index.html'),
encoding='utf-8').read()
APPJS = io.open(os.path.join(ROOT, 'web/app.js'),
encoding='utf-8').read()
CSS = io.open(os.path.join(ROOT, 'web/app.css'), encoding='utf-8').read()
# ---- 1. 长缓存按「有没有指纹」区分 ----
chk('按有没有指纹区分缓存策略(不是按扩展名)',
"fingerprinted = 'v=' in (self.path or '')" in WEB)
chk('带指纹的静态资源走 immutable 长缓存',
'max-age=31536000, immutable' in WEB)
chk('没指纹的仍是 no-cache(否则部署后用户拿到旧文件)',
"if fingerprinted else 'no-cache'" in WEB)
chk('HTML 入口页仍是 no-store(它每次都要重新注入)',
"no-store, no-cache, must-revalidate" in WEB)
# ---- 2. brotli 逐级降级 ----
chk('有 _want_brotli 协商判断', 'def _want_brotli' in WEB)
chk('brotli 不可用时返回 (None, None) 而不是原文',
re.search(r'def _brotli_cached.*?return None, None', WEB, re.S) is not None)
chk('brotli 失败后**继续试 gzip**(逐级降级,不能吐原文)',
re.search(r'if _want_brotli.*?if enc:\s*\n\s*return body, enc.*?if _want_gzip',
WEB, re.S) is not None)
chk('编码探测集中在一个函数里(避免两处实现漂移)',
'def _accepts(headers, token)' in WEB)
chk('Accept-Encoding 识别 q=0(br;q=0 是「不要 br」)',
"q.startswith('q=')" in WEB)
chk('brotli 质量档有定值(不是默认 q11,首屏等不起)',
'BROTLI_QUALITY = ' in WEB)
# ---- 3. 两种编码不能共用缓存键 ----
chk('编码进缓存键', re.search(r'key = \(path, int\(st\.st_mtime\), st\.st_size, enc\)',
WEB) is not None)
chk('只有一个压缩缓存表', WEB.count('_comp_cache = {}') == 1
and '_gz_cache' not in WEB and '_br_cache' not in WEB)
# ---- 4. 压缩协商的其余契约 ----
chk('压缩后必须发 Vary: Accept-Encoding',
WEB.count("send_header('Vary', 'Accept-Encoding')") >= 2)
chk('有体积下限(小响应压了反而亏)', 'GZIP_MIN = ' in WEB)
chk('压缩比不划算时不压', 'if len(z) >= len(data)' in WEB)
# ---- 5. 版本号单一真源 ----
chk('有 packaging/VERSION 这个唯一真源',
os.path.isfile(os.path.join(ROOT, 'packaging/VERSION')))
chk('后端从 /opt/drouter/VERSION 读版本',
"os.path.join(BASE, 'VERSION')" in WEB)
chk('openapi 的 version 用 app_version() 而非硬编码',
"'version': app_version()" in WEB)
chk('版本号读取只做一次(运行期不变,缓存住)', '_VERSION_READ' in WEB)
chk('版本号格式受校验(半截写入不会显示到页面上)',
'VERSION_FALLBACK' in WEB and re.search(r"re\.match\(r'\^", WEB) is not None)
for p in ('packaging/build-deb.sh', 'packaging/build-docker.sh',
'packaging/build-offline-bundle.sh', '_dev/build-image-remote.sh'):
s = io.open(os.path.join(ROOT, p), encoding='utf-8').read()
chk('%s 从 VERSION 文件取版本' % os.path.basename(p),
'packaging/VERSION' in s and not re.search(r'\$\{1:-1\.\d', s))
# 每一份「装到运行目录」的清单都要带上 VERSION ——
# build-deb.sh(通配 backend/*.py) 不会自动带上它,漏了不报错只是版本号不更新。
chk('build-deb.sh 把 VERSION 装进包',
'$STAGE/opt/drouter/VERSION' in
io.open(os.path.join(ROOT, 'packaging/build-deb.sh'),
encoding='utf-8').read())
chk('deploy.sh 把 VERSION 装到 /opt/drouter',
'$OPT/VERSION' in io.open(os.path.join(ROOT, 'scripts/deploy.sh'),
encoding='utf-8').read())
chk('Dockerfile 把 VERSION 拷进镜像',
'packaging/VERSION /opt/drouter/VERSION' in
io.open(os.path.join(ROOT, 'packaging/docker/Dockerfile'),
encoding='utf-8').read())
chk('sync.sh 打包时带上 packaging/VERSION',
'packaging/VERSION' in io.open(os.path.join(ROOT, 'devtools/sync.sh'),
encoding='utf-8').read())
# ---- 6. index.html 的 ETag 必须覆盖注入后的内容 ----
chk('index.html 的 ETag 并入版本号',
re.search(r"endswith\('index\.html'\):\s*\n\s*etag = .*app_version\(\)",
WEB) is not None)
chk('页脚有版本号占位符', '__APP_VERSION__' in INDEX and 'id="app-ver"' in INDEX)
chk('注入函数替换掉占位符', "'__APP_VERSION__', app_version()" in WEB)
# ---- 7. logo 回概述页 ----
chk('左上角 logo 是 <a>(键盘/读屏可用,不是 div onclick)',
re.search(r'<a class="brand[^"]*"[^>]*id="brand-home"', INDEX) is not None)
chk('logo 有可访问名称', 'title="返回系统概览"' in INDEX)
chk('logo 点击已绑定', "brandHome.addEventListener('click'" in APPJS)
chk('点击时 preventDefault(否则 #dash 会跳到地址栏)',
re.search(r"addEventListener\('click', e => \{\s*\n\s*e\.preventDefault\(\)",
APPJS) is not None)
chk('已在概述页时不重复 go()(go 会 stopPageTimers 再重拉数据)',
"if (S.page === 'dash') return;" in APPJS)
chk('logo 点击会收起手机端抽屉', re.search(
r"addEventListener\('click', e => \{.*?closeDrawer\(\)", APPJS, re.S) is not None)
chk('CSS 显式去掉 <a> 默认下划线/颜色',
'.brand-home{text-decoration:none' in CSS)
chk('CSS 保留焦点环(键盘用户的唯一路标)', ':focus-visible' in CSS)
chk('侧栏收起时 logo 仍居中', '#app.side-collapsed .brand-home' in CSS)
# ---- 8. 前端语法没被改坏 ----
try:
import subprocess
node = subprocess.run(['node', '--check', os.path.join(ROOT, 'web/app.js')],
capture_output=True)
chk('app.js 语法正确', node.returncode == 0,
node.stderr.decode('utf-8', 'replace')[:200])
except Exception as e:
chk('app.js 语法正确(跳过:%s)' % e, True)
try:
ast.parse(WEB)
chk('drouter-web.py 语法正确', True)
except SyntaxError as e:
chk('drouter-web.py 语法正确', False, str(e))
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
sys.exit(1 if fails else 0)
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""开工前预检:工作区有没有「上一轮注入没还原」的残留。
为什么需要这个文件:反向验证(注入 bug → 看判据变红 → 还原)如果被
SIGTERM/SIGKILL 打断,`finally` 里的还原不会执行,注入就**留在源码里**。
下一次跑反向验证时,它看到的「基线」已经被污染,于是报出一堆看不懂的红,
而真正的根因(几小时前那次没还原的注入)完全不在视野里。
2026-10-04 实际踩到:`drouter-helper.py` 里 `_vpn_env` 的
`os.path.join('/lib/modules', ...)` 被留成了 `'/nonexistent'`,
连带 `need_module` 整个状态从四态里消失 —— 而这个缺陷在
**已发布的 v1.0.8 里**。t-107 一直红着,但被当成了「判据本身有问题」。
所以这一条不是「保险」,是真bug 的直接成因。
判据只查两个方向:
① backend/ 下有没有明显的注入哨兵(/nonexistent、__INJECT__、127.0.0.2)
② 全量静态判据当前是否全绿(不绿就别动手,先弄清是哪一条、为什么)
只读,不改任何东西。
"""
import io
import os
import re
import subprocess
import sys
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
# 注入脚本惯用的假路径 / 假常量。真机代码里出现它们基本可以断定是残留。
SENTINELS = ('/nonexistent', '__INJECT__', '127.0.0.2', 'INJECTED_')
fails = []
def chk(label, cond, extra=''):
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
if not cond:
fails.append(label)
print('=== 1. 扫 backend/ 里的注入哨兵 ===')
hits = []
for fn in sorted(os.listdir(os.path.join(ROOT, 'backend'))):
if not fn.endswith('.py'):
continue
p = os.path.join(ROOT, 'backend', fn)
try:
src = io.open(p, encoding='utf-8').read()
except Exception:
continue
# 只在**代码**里找,注释和 docstring 里为了说明历史 bug 常会写到
code = re.sub(r'#.*$', '', src, flags=re.M)
for s in SENTINELS:
if s in code:
for i, line in enumerate(src.splitlines(), 1):
if s in line and not line.strip().startswith('#'):
hits.append('%s:%d %s' % (fn, i, line.strip()[:70]))
chk('backend/ 里没有注入残留', not hits, '')
for h in hits:
print(' %s' % h)
if hits:
print(' ⚠ 这些多半是反向验证没还原。用 git diff 看清楚,'
'确认是残留就 git checkout 还原,**别直接改成新值** —— '
'新值可能才是 bug(/nonexistent 那次就差点被当成「本就该这样」)。')
print()
print('=== 2. backend/*.py 语法 ===')
bad = []
for fn in sorted(os.listdir(os.path.join(ROOT, 'backend'))):
if not fn.endswith('.py'):
continue
p = os.path.join(ROOT, 'backend', fn)
r = subprocess.run([sys.executable, '-m', 'py_compile', p],
capture_output=True)
if r.returncode:
bad.append('%s: %s' % (fn, r.stderr.decode('utf-8', 'replace')[:120]))
chk('backend/ 下所有模块语法正确', not bad, '')
for b in bad:
print(' %s' % b)
print()
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % len(fails)))
sys.exit(1 if fails else 0)
+10 -2
View File
@@ -4265,11 +4265,19 @@ def _vpn_env():
# 不能只认 .ko:Debian 默认压成 .ko.xz,个别发行版用 .ko.zst。
mod_file = ''
for _n in ('wireguard.ko', 'wireguard.ko.xz', 'wireguard.ko.zst'):
_p = os.path.join('/lib/modules', kver,
'kernel/drivers/net/wireguard', _n)
_p = os.path.join('/lib/modules', kver, _n)
if os.path.isfile(_p):
mod_file = _p
break
# ⚠️ 四态必须**逐级降级**,顺序不能换、不能漏:
# 已加载 + 有工具 → ready
# 已加载 + 没工具 → need_tool
# 没加载 + 有模块文件 → need_module ← 这一级以前**整个漏掉了**
# 两头都没有 → unsupported
# 漏掉 need_module 的后果不是「少一个状态」,而是 docstring 里写的
# 那个原 bug 原地复活:PVE 的 KVM 虚机内核明明有 wireguard.ko.xz
# 却报 unsupported,前端配一句写死的「通常说明跑在精简容器里」,
# 把排查方向整个带偏(用户会去查容器镜像,真正该做的是 modprobe)。
if mod_loaded and tool:
state = 'ready'
elif mod_loaded:
+196 -39
View File
@@ -61,6 +61,28 @@ HELPER = '/opt/drouter/backend/drouter-helper.py'
LOG_DIR = '/var/log/drouter'
BUILD_FLAG = '/etc/drouter/BUILD_MODE'
# ---- 版本号(唯一真源 = packaging/VERSION,构建时安装到 /opt/drouter/VERSION)----
# 早先版本号在 openapi 里硬编码,于是「页面显示什么版本」和「包管理器认为
# 是哪个版本」是两条互不相干的线,只能靠人手记着同步 —— 漏一次就是一个
# 改不掉的 bug(apt 按 control 认版本,文件名是 1.0.9 它就当 1.0.8)。
VERSION_FALLBACK = '1.0.9'
_VERSION_READ = [False]
def app_version():
"""当前版本号。读一次就缓存 —— /opt/drouter/VERSION 不会在运行期变。"""
if not _VERSION_READ[0]:
_VERSION_READ[0] = True
try:
with open(os.path.join(BASE, 'VERSION')) as f:
v = f.read().strip()
# 只认形如 1.2.3 的,避免把构建事故(空文件/半截写入)显示到页面上
if re.match(r'^\d+\.\d+(\.\d+)?([-+~][\w.]+)?$', v):
return v
except Exception:
pass
return VERSION_FALLBACK
def in_build_mode():
"""构建保护模式:存在该标志文件时,禁止一切抢端口/改网络的动作。"""
@@ -344,51 +366,162 @@ _WORKER_SEM = threading.BoundedSemaphore(MAX_WORKERS)
MAX_SESSIONS_WEB = 500
# ---- 静态资源压缩缓存(#7)----
# app.js 有 437KB,压缩后约 90KB。但每次请求都重压一遍纯属浪费 CPU,
# 所以按 (文件路径, 修改时间, 大小) 做键把压缩结果缓在内存里;
# app.js 有 583KB,gzip 后 198KB。每次请求都重压一遍纯属浪费 CPU,
# 所以按 (文件路径, 修改时间, 大小, 编码) 做键把压缩结果缓在内存里;
# 文件一变(部署新版本)键就变了,旧条目自动失效。
_gz_lock = threading.Lock()
_gz_cache = {}
_GZ_MAX = 16 # 资源种类就那么几个,缓存上限给足余量即可
# 但**只限条目数是不够的**:16 个大响应就能占掉上百 MB 常驻内存。
#
# ⚠️ 编码必须进键:同一份数据 gzip 出来 198KB、brotli 出来 150KB,
# 共用一个键就会互相覆盖 —— 拿到 br 的客户端可能收到 gzip 版,
# 浏览器解不开就是满屏乱码。
_comp_lock = threading.Lock()
_comp_cache = {}
_COMP_MAX = 24 # 资源种类就那么几个,缓存上限给足余量即可
# 但**只限条目数是不够的**:24 个大响应就能占掉上百 MB 常驻内存。
# 4GB 机器上按字节再设一道闸(超过就整表清空,重建成本很低)。
_GZ_MAX_BYTES = 24 * 1024 * 1024
_gz_bytes = [0]
_COMP_MAX_BYTES = 32 * 1024 * 1024
_comp_bytes = [0]
# brotli 质量档。实测 app.js(583KB):
# q5 → 约 160KB,压缩耗时 ~0.1s
# q11 → 150KB,压缩耗时 1.5-3s(2 核小机器上很明显)
# 省下的 10KB 不值 2 秒,**首屏最怕的是等**。q5 是这个权衡的平衡点。
BROTLI_QUALITY = 5
def _accepts(headers, token):
"""Accept-Encoding 里是否含某个编码(大小写不敏感、带 q=0 视为不支持)。
⚠️ 不能只做 `'br' in s`:`Accept-Encoding: br;q=0` 的意思是
「我认识 br 但明确不要」,按子串匹配会正好发一份它解不开的东西。
"""
try:
s = (headers.get('Accept-Encoding') or '').lower()
except Exception:
return False
for part in s.split(','):
part = part.strip()
if not part:
continue
bits = part.split(';')
name = bits[0].strip()
if name != token:
continue
for q in bits[1:]:
q = q.strip()
if q.startswith('q='):
try:
return float(q[2:]) > 0
except ValueError:
return True
return True
return False
def _want_gzip(headers):
try:
return 'gzip' in (headers.get('Accept-Encoding') or '').lower()
except Exception:
return False
return _accepts(headers, 'gzip')
def _gzip_cached(path, data):
"""返回 (body, encoding)。data 已经是最终字节内容时传 path=None。"""
def _want_brotli(headers):
"""浏览器是否收 br。
实测 app.js(583KB 源码):gzip → 198KB,**brotli → 160KB**(q5),
再省 20%。走外网(cloudflared / 远程打开面板)这个差别是实打实的秒数。
⚠️ 只在客户端**明确**带 br 时才用。发一份客户端解不开的编码,
表现是页面全是乱码 —— 比慢严重得多。
"""
return _accepts(headers, 'br')
def _compress_cached(path, data, enc):
"""压一次并缓存。返回 (body, enc) 或 (data, 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)
key = (path, int(st.st_mtime), st.st_size, enc)
except (OSError, TypeError):
# path=None(JSON 响应不在磁盘上)或文件刚好被删 → 按内容哈希缓存。
# 只接 OSError/TypeError:os.stat 失败就这两种,再宽就会把
# 拼错路径之类的编程错误一起吞掉。
key = (None, hash(data), len(data), enc)
with _comp_lock:
hit = _comp_cache.get(key)
if hit is not None:
return hit, 'gzip'
return hit, enc
try:
import gzip
z = gzip.compress(data, 6)
if enc == 'br':
import brotli
z = brotli.compress(data, quality=BROTLI_QUALITY)
else:
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 or _gz_bytes[0] + len(z) > _GZ_MAX_BYTES:
_gz_cache.clear()
_gz_bytes[0] = 0
_gz_cache[key] = z
_gz_bytes[0] += len(z)
return z, 'gzip'
with _comp_lock:
if (len(_comp_cache) >= _COMP_MAX
or _comp_bytes[0] + len(z) > _COMP_MAX_BYTES):
_comp_cache.clear()
_comp_bytes[0] = 0
_comp_cache[key] = z
_comp_bytes[0] += len(z)
return z, enc
def _brotli_cached(path, data):
"""brotli 版。**brotli 模块不可用时返回 (None, None)** —— 由调用方
决定回退到 gzip,这里不能自己吐原文(那等于 583KB 裸传)。
兜底只覆盖 ImportError:模块没装是预期情况(Debian 13 的包名是
python3-brotli,属于可选依赖)。其余异常照抛 —— 被宽 except 吞掉的
内部错误会伪装成「压缩没生效」,见 _gzip_cached 里的说明。
"""
try:
import brotli # noqa: F401
except ImportError:
return None, None
return _compress_cached(path, data, 'br')
def _gzip_cached(path, data):
"""gzip 版。返回 (body, encoding)。data 已是最终字节内容时传 path=None。
⚠️ 兜底**只接 ImportError**(gzip 是标准库,实际等于不接),
刻意不写 `except Exception`、也不接 TypeError/ValueError:
早先写的是宽 except,结果一次 NameError(漏注入 _comp_lock)被静默
吞成「返回原文」,表现是**压缩功能完好但从不生效**,
页面照常能开,只看「响应头有没有 Content-Encoding」的判据也发现不了,
583KB 一直裸传。TypeError/ValueError 同样不能接 ——
「把 str 传给要 bytes 的接口」也是 TypeError,接了等于没接。
真出这类错就该抛出来:请求 500 比静默劣化好排查得多。
"""
return _compress_cached(path, data, 'gzip')
def _encode_body(headers, path, data, min_size=GZIP_MIN):
"""按客户端能力挑编码:br > gzip > 原文。
**调用方负责已经判过 len(data) >= min_size** —— 这里不再重复判断。
返回 (body, encoding);encoding 为 None 表示原样输出。
⚠️ 降级必须是**逐级**的,不能「要 br 就只发 br」:
目标机没装 python3-brotli 时如果直接返回原文,583KB 就裸传了,
比优化前还慢。正确做法是 br 不可用 → 试 gzip → 都不行才原文。
这里也**不写宽 except**:压缩链出内部错误时宁可让请求 500,
也不要静默返回一个「没压缩但看起来正常」的响应 ——
那正是 583KB 裸传却没人发现的成因。
"""
# 顺序不能换:brotli 压得更小,但要先确认客户端真的收。
# 反过来(先 gzip 再问 br)会让不支持 br 的客户端拿到 br 版。
if _want_brotli(headers):
body, enc = _brotli_cached(path, data)
if enc:
return body, enc
# 落到这里 = brotli 模块没有 / 压不小 → 继续试 gzip
if _want_gzip(headers):
return _gzip_cached(path, data)
return data, None
_helpd_lock = threading.Lock()
_helpd_down_until = [0.0] # 在这个时间戳之前,不再尝试连 socket
_helpd_down_logged = [False]
@@ -613,8 +746,8 @@ class Api:
# 十分之一。局域网里无所谓,但通过 cloudflared / 外网访问时这是数量级
# 的差别。小响应不压——省下的字节还不如压缩本身的花销。
enc = None
if len(body) >= GZIP_MIN and _want_gzip(self.h.headers):
body, enc = _gzip_cached(None, body)
if len(body) >= GZIP_MIN:
body, enc = _encode_body(self.h.headers, None, body)
self.h.send_response(status)
self.h.send_header('Content-Type', 'application/json; charset=utf-8')
if enc:
@@ -2198,7 +2331,7 @@ def build_openapi(base):
'openapi': '3.0.3',
'info': {
'title': 'Drouter 通用管理 API',
'version': '1.0.8',
'version': app_version(),
'description': ('Drouter(Debian 13 拼装主路由)的统一 REST 接口。'
'任意语言 / 框架(curl、Python、Node、Go、PHP、Java、.NET、'
'Shell、Postman、工单系统、IoT 网关)均可直接调用。'
@@ -2400,7 +2533,10 @@ def _inject_asset_version(raw):
return m.group(0)
return '%s%s?v=%d%s' % (pre, url, ver(url), post)
return _ASSET_RE.sub(sub, text).encode('utf-8')
# 页脚版本号。放在同一个函数里做,是因为**同一份字节只算一次哈希**:
# 拆成两次注入就得各算一遍 key,(None, hash, len, enc) 缓存会失效。
text = _ASSET_RE.sub(sub, text).replace('__APP_VERSION__', app_version())
return text.encode('utf-8')
class _Server(ThreadingHTTPServer):
@@ -2473,18 +2609,39 @@ class Handler(BaseHTTPRequestHandler):
elif full.endswith('.css'):
ctype = 'text/css; charset=utf-8'
# ETag 协商缓存:没变就回 304,省掉整个响应体(app.js 437KB)。
# ETag 协商缓存:没变就回 304,省掉整个响应体(app.js 583KB)。
# 键里带 mtime 和大小,部署新版本后自动失效。
try:
st = os.stat(full)
etag = '"%s-%s"' % (int(st.st_mtime), st.st_size)
except Exception:
etag = ''
# ⚠️ index.html 要**额外**把版本号并进 ETag。
# 它发出去的不是文件原样,而是注入过资源指纹和 __APP_VERSION__ 的产物;
# 只按 mtime+size 算的话,光改 /opt/drouter/VERSION(文件本身一个字节
# 都没动)时 ETag 不变,客户端拿 If-None-Match 换到 304 就继续用旧副本,
# 页面底下的版本号会一直停在旧版本 —— 恰好是最该更新的地方不更新。
if full.endswith('index.html'):
etag = '"%s-%s"' % (etag.strip('"'), app_version())
# ⚠️ 缓存策略分两种,靠**有没有指纹**区分,不是靠扩展名:
# 带 ?v=<mtime>(index.html 注入的)→ URL 变了就是新文件,
# 可以放心 immutable 长缓存,浏览器第二次打开直接本地命中,
# 583KB 一秒都不传。
# 没指纹(用户手输 /app.js、收藏夹、别的页面链过来)
# → 必须 no-cache,否则部署新版本后用户拿到的是旧文件。
#
# 早先不分这两种,一律 no-cache —— 首屏每次都要重传 583KB(gzip 后 198KB),
# 而指纹明明已经注入了,白白浪费了长缓存。局域网感觉不出来,
# 走外网(cloudflared / 远程访问)就是每开一次面板多传 200KB。
# 指纹不匹配的判据在 t-gzip.py 里。
fingerprinted = 'v=' in (self.path or '')
static_cc = ('public, max-age=31536000, immutable'
if fingerprinted else 'no-cache')
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.send_header('Cache-Control', static_cc)
self.end_headers()
return
@@ -2498,8 +2655,8 @@ class Handler(BaseHTTPRequestHandler):
data = _inject_asset_version(data)
enc = None
if _want_gzip(self.headers) and len(data) >= GZIP_MIN:
data, enc = _gzip_cached(full, data)
if len(data) >= GZIP_MIN:
data, enc = _encode_body(self.headers, full, data)
self.send_response(200)
self.send_header('Content-Type', ctype)
@@ -2512,7 +2669,7 @@ class Handler(BaseHTTPRequestHandler):
if full.endswith('.html'):
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
else:
self.send_header('Cache-Control', 'no-cache')
self.send_header('Cache-Control', static_cc)
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(data)
+1 -1
View File
@@ -65,7 +65,7 @@ if (!fs.existsSync(STRIPPED)) {
// ---------- 3) 剥后关键 token 数量不该变多 ----------
// ⚠️ 判据是「只多不少」,**不是**「完全相等」。
// 写死相等会红:注释里经常提到这些标识符(比如「这里之前用
// innerHTML 于是…���这种讲 bug 来历的话),剥注释会连带剥掉它们。
// innerHTML 于是…这种讲 bug 来历的话),剥注释会连带剥掉它们。
// 而「变多」才是误吃的信号 —— 原文被切开重组时 token 数会膨胀。
const TOKENS = ['function', 'const', 'innerHTML',
'addEventListener', 'querySelector'];
+17 -2
View File
@@ -43,6 +43,11 @@ PY
"$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/雷电):格式化的三重保护是最容易造成不可逆损失的地方
@@ -73,8 +78,15 @@ PY
"$PYBIN" _dev/t-helpd.py || { echo "❌ 常驻执行守护预检失败"; exit 1; }
# 统一日志:conntrack -E 是阻塞事件流,曾经让每次查询固定白等 2 秒
"$PYBIN" _dev/t-ulog-perf.py || { echo "❌ 统一日志性能预检失败"; exit 1; }
# 响应压缩与 ETag 协商缓存:少了 Vary 会让代理把 gzip 喂给不支持的客户端
# 响应压缩与 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; }
@@ -157,7 +169,10 @@ PY
echo "=== 打包源码 ==="
# docs 也要带上:deploy.sh 会装到 /opt/drouter/docs(systemd 的 Documentation 指向它)
tar czf /tmp/drouter-src.tgz backend web scripts docs 2>/dev/null
# 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 "=== 上传 ==="
+1
View File
@@ -0,0 +1 @@
1.0.9
+12 -1
View File
@@ -13,7 +13,14 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-1.0.8}"
# ⚠️ 版本号的**唯一真源**是 packaging/VERSION,不要在脚本里另写一份。
# 早先 4 个构建脚本各写死一个默认值,改版本时漏改一处就产出一个
# 「文件名是 1.0.9、control 里是 1.0.8」的包 —— apt 会按 control 认版本,
# 表现为 `dpkg -l` 永远显示旧版,升级也升不上去。
VERSION_FILE="$HERE/packaging/VERSION"
[ -f "$VERSION_FILE" ] || { echo "缺少 $VERSION_FILE(版本号唯一真源)"; exit 1; }
VERSION="${1:-$(tr -d ' \t\r\n' < "$VERSION_FILE")}"
[ -n "$VERSION" ] || { echo "$VERSION_FILE 为空"; exit 1; }
DIST="$HERE/dist"
PKG="drouter"
STAGE="$DIST/${PKG}_${VERSION}_all"
@@ -43,6 +50,10 @@ for d in "$HERE"/docs/*; do
install -m 0644 "$d" "$STAGE/opt/drouter/docs/"
done
install -m 0755 "$HERE"/scripts/*.sh "$STAGE/opt/drouter/scripts/"
# 版本号随包落到 /opt/drouter/VERSION —— 页面底部的版本号和 openapi 的
# version 字段都从它读。不装的话后端只能退回硬编码的兜底值,
# 表现是「deb 装的是 1.0.9,页面却显示 1.0.8」。
install -m 0644 "$VERSION_FILE" "$STAGE/opt/drouter/VERSION"
# 一键运维命令:直接随包提供,postinst 里再做一次兜底安装
install -m 0755 "$HERE/scripts/drouter-ctl.sh" "$STAGE/usr/local/bin/drouter-ctl"
+4 -1
View File
@@ -10,7 +10,10 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
TAG="${1:-1.0.8}"
# 版本号唯一真源是 packaging/VERSION(见 build-deb.sh 里的说明)
VF="$HERE/packaging/VERSION"
[ -f "$VF" ] || { echo "缺少 $VF(版本号唯一真源)"; exit 1; }
TAG="${1:-$(tr -d ' \t\r\n' < "$VF")}"
command -v docker >/dev/null 2>&1 || { echo "需要 docker"; exit 1; }
+4 -1
View File
@@ -27,7 +27,10 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-1.0.8}"
# 版本号唯一真源是 packaging/VERSION(见 build-deb.sh 里的说明)
VF="$HERE/packaging/VERSION"
[ -f "$VF" ] || { echo "缺少 $VF(版本号唯一真源)"; exit 1; }
VERSION="${1:-$(tr -d ' \t\r\n' < "$VF")}"
OUTDIR="${2:-$HERE/dist}"
ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
+3
View File
@@ -54,6 +54,9 @@ COPY web/ /opt/drouter/web/
# 进镜像纯属白白撑大体积
COPY docs/*.md /opt/drouter/docs/
COPY scripts/*.sh /opt/drouter/scripts/
# 版本号(唯一真源)。页面底部与 openapi 的 version 字段都读它 ——
# 不拷的话容器里只能退回硬编码兜底值,镜像 tag 是 1.0.9 页面却显示 1.0.8。
COPY packaging/VERSION /opt/drouter/VERSION
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
+8
View File
@@ -86,6 +86,14 @@ 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
# 版本号(1.0.9 新增)。页面底部的版本号与 /api/openapi 的 version 字段
# 都从 /opt/drouter/VERSION 读。缺了不报错,只是永远显示后端的兜底值 ——
# 部署了新版却显示旧版本号,比什么都不做更让人困惑。
# devtools/sync.sh 负责把 packaging/VERSION 打进源码包;源码包里没有就跳过。
if [ -f "$SRC/packaging/VERSION" ]; then
install -m 0644 $SRC/packaging/VERSION $OPT/VERSION
echo " 版本号 $(cat $OPT/VERSION)"
fi
echo "=== 3.5 安装运维脚本(自检/回归用)==="
if [ -d "$SRC/scripts" ]; then
+10
View File
@@ -64,6 +64,16 @@ a:hover{text-decoration:underline}
#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 是 <a>(可点击回概述页),要显式去掉 <a> 的默认外观 ——
只写 .brand-home 的样式不够,浏览器给 <a> 的 color/text-decoration
是 UA 样式表里的规则,作者样式表不声明就仍然生效。
padding/flex 沿用上面的 .brand,这里不重复声明。 */
.brand-home{text-decoration:none;color:inherit;border-radius:8px;transition:background .15s}
/* 焦点环不能去掉:这是纯键盘用户的唯一路标 */
.brand-home:focus-visible{outline:2px solid var(--pri);outline-offset:-2px}
.brand-home:hover{background:var(--line2)}
/* 侧栏收起时只剩 logo,此时 hover 反馈面积很小 */
#app.side-collapsed .brand-home{padding-left:0;padding-right:0;justify-content:center}
.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}
+15
View File
@@ -13302,6 +13302,21 @@ function closeDrawer() {
if (el) el.classList.remove('m-drawer');
}
/* 左上角 logo → 回系统概览。
go() 自己会 closeDrawer(),窄屏下抽屉会一起收起,不用额外处理。
⚠️ 已经在 dash 页时不重复 go():go() 会 stopPageTimers + 重新拉数据,
在概览页上连点 logo 会白白清掉轮询再重建,看着像「卡了一下」。
概览页自己有刷新按钮,需要重拉数据用那个。 */
const brandHome = $('#brand-home');
if (brandHome) {
brandHome.addEventListener('click', e => {
e.preventDefault();
closeDrawer();
if (S.page === 'dash') return;
go('dash');
});
}
$('#btn-side').onclick = () => {
// 窄屏走抽屉,不写折叠状态——否则回到宽屏时会莫名是折叠的
if (isNarrow()) { $('#app').classList.toggle('m-drawer'); return; }
+8 -2
View File
@@ -51,13 +51,17 @@
<div id="app" class="hidden">
<aside class="sidebar" id="sidebar">
<div class="brand">
<!-- 左上角 logo 回概述页。
用 <a> 而不是 <div onclick>:Tab 能聚焦、Enter 能触发、读屏会念
「链接」。role/button + tabindex 只能骗过一部分辅助工具,语义标签
是免费的。href="#" 会被下面的 click preventDefault 吃掉。 -->
<a class="brand brand-home" href="#dash" id="brand-home" title="返回系统概览">
<svg class="brand-mark" width="30" height="30" aria-hidden="true"><use href="#drouter-logo"/></svg>
<div class="brand-txt">
<b>Drouter</b>
<span id="host-tag"></span>
</div>
</div>
</a>
<div class="nav-search">
<input id="nav-filter" placeholder="搜索菜单…" autocomplete="off">
</div>
@@ -119,6 +123,8 @@
<a href="https://github.com/liuzhuohua" target="_blank" rel="noopener noreferrer">https://github.com/liuzhuohua</a>
<span class="sep">·</span>
<span>遵循 MIT 开源协议</span>
<span class="sep">·</span>
<span class="site-ver" id="app-ver" title="当前安装的版本">v__APP_VERSION__</span>
</footer>
</main>
</div>