diff --git a/_dev/accept-static-perf.sh b/_dev/accept-static-perf.sh
new file mode 100644
index 0000000..dc7ea3c
--- /dev/null
+++ b/_dev/accept-static-perf.sh
@@ -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 是 ,页脚有版本号元素
+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 '(键盘/读屏可用)" 1 || ck "logo 用 " 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
diff --git a/_dev/bench-appjs.js b/_dev/bench-appjs.js
new file mode 100644
index 0000000..f0d6164
--- /dev/null
+++ b/_dev/bench-appjs.js
@@ -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);
diff --git a/_dev/build-image-remote.sh b/_dev/build-image-remote.sh
index 77979de..628eebd 100644
--- a/_dev/build-image-remote.sh
+++ b/_dev/build-image-remote.sh
@@ -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
diff --git a/_dev/t-107-inject-wg.py b/_dev/t-107-inject-wg.py
index 914b8e2..fe94ab5 100644
--- a/_dev/t-107-inject-wg.py
+++ b/_dev/t-107-inject-wg.py
@@ -1,6 +1,6 @@
"""反向验证:逐个注入 bug,确认 t-107 的 C3c 新断言真的会变红。
-⚠️ 为什么必须做:静态断言最危险的失败模式是「正则没匹配上��
+⚠️ 为什么必须做:静态断言最危险的失败模式是「正则没匹配上」
于是恒绿」。之前踩过两次(heredoc 锚点没匹配上却报「反向验证通过」)。
所以这里每一条都必须**看到 NG 才算验证通过**,而且注入用的是
Write 落盘的真实脚本,不用 heredoc(heredoc 里的 old 串
diff --git a/_dev/t-cachebust.py b/_dev/t-cachebust.py
index 2d068e6..7232789 100644
--- a/_dev/t-cachebust.py
+++ b/_dev/t-cachebust.py
@@ -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 = ('\n'
'\n'
'\n'
'\n'
+ 'v__APP_VERSION__\n'
'\n')
out = inject(HTML.encode('utf-8')).decode('utf-8')
@@ -76,6 +82,10 @@ ck('不会重复追加(幂等)',
ck('输出仍是合法 utf-8 且保留 doctype', out.startswith(''))
ck('单引号属性也能处理',
"/app.css?v=" in inject(b"").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)
diff --git a/_dev/t-encoding-runtime.py b/_dev/t-encoding-runtime.py
new file mode 100644
index 0000000..05135ff
--- /dev/null
+++ b/_dev/t-encoding-runtime.py
@@ -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)
diff --git a/_dev/t-gzip.py b/_dev/t-gzip.py
index 6e04b3d..223aa47 100644
--- a/_dev/t-gzip.py
+++ b/_dev/t-gzip.py
@@ -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)
# 小响应不能被压:省下的字节还不如压缩开销
diff --git a/_dev/t-static-perf.py b/_dev/t-static-perf.py
new file mode 100644
index 0000000..a58de83
--- /dev/null
+++ b/_dev/t-static-perf.py
@@ -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 是 (键盘/读屏可用,不是 div onclick)',
+ re.search(r']*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 显式去掉 默认下划线/颜色',
+ '.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)
diff --git a/_dev/t-worktree-clean.py b/_dev/t-worktree-clean.py
new file mode 100644
index 0000000..2733495
--- /dev/null
+++ b/_dev/t-worktree-clean.py
@@ -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)
diff --git a/backend/drouter-helper.py b/backend/drouter-helper.py
index a60cbda..ff0e398 100644
--- a/backend/drouter-helper.py
+++ b/backend/drouter-helper.py
@@ -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:
diff --git a/backend/drouter-web.py b/backend/drouter-web.py
index d247e9f..f1d5a9e 100644
--- a/backend/drouter-web.py
+++ b/backend/drouter-web.py
@@ -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=(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)
diff --git a/devtools/check-jsstrip.js b/devtools/check-jsstrip.js
index e4f55b8..8c9ad5f 100644
--- a/devtools/check-jsstrip.js
+++ b/devtools/check-jsstrip.js
@@ -65,7 +65,7 @@ if (!fs.existsSync(STRIPPED)) {
// ---------- 3) 剥后关键 token 数量不该变多 ----------
// ⚠️ 判据是「只多不少」,**不是**「完全相等」。
// 写死相等会红:注释里经常提到这些标识符(比如「这里之前用
- // innerHTML 于是…���这种讲 bug 来历的话),剥注释会连带剥掉它们。
+ // innerHTML 于是…这种讲 bug 来历的话),剥注释会连带剥掉它们。
// 而「变多」才是误吃的信号 —— 原文被切开重组时 token 数会膨胀。
const TOKENS = ['function', 'const', 'innerHTML',
'addEventListener', 'querySelector'];
diff --git a/devtools/sync.sh b/devtools/sync.sh
index 580842f..994ac78 100644
--- a/devtools/sync.sh
+++ b/devtools/sync.sh
@@ -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 "=== 上传 ==="
diff --git a/packaging/VERSION b/packaging/VERSION
new file mode 100644
index 0000000..66c4c22
--- /dev/null
+++ b/packaging/VERSION
@@ -0,0 +1 @@
+1.0.9
diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh
index eee1f63..49fd5f4 100644
--- a/packaging/build-deb.sh
+++ b/packaging/build-deb.sh
@@ -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"
diff --git a/packaging/build-docker.sh b/packaging/build-docker.sh
index 3056a80..704aaf6 100644
--- a/packaging/build-docker.sh
+++ b/packaging/build-docker.sh
@@ -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; }
diff --git a/packaging/build-offline-bundle.sh b/packaging/build-offline-bundle.sh
index e7a2139..aa0b8ef 100644
--- a/packaging/build-offline-bundle.sh
+++ b/packaging/build-offline-bundle.sh
@@ -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)"
diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile
index a57141e..84c123e 100644
--- a/packaging/docker/Dockerfile
+++ b/packaging/docker/Dockerfile
@@ -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
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 5302560..75a1b28 100644
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -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
diff --git a/web/app.css b/web/app.css
index 2755695..8fb86b0 100644
--- a/web/app.css
+++ b/web/app.css
@@ -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 是 (可点击回概述页),要显式去掉 的默认外观 ——
+ 只写 .brand-home 的样式不够,浏览器给 的 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}
diff --git a/web/app.js b/web/app.js
index 4f8758d..266bb1d 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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; }
diff --git a/web/index.html b/web/index.html
index 42f386c..a20f744 100644
--- a/web/index.html
+++ b/web/index.html
@@ -51,13 +51,17 @@