Files
liuzhuohua 8303fa3359 fix(update): 更新前自动备份以 drouter 身份跑时写不进备份目录
真机报错:更新功能「备份失败,已中止更新」——
[Errno 13] Permission denied: /opt/drouter/backups/xxx.part。

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

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

判据:t-update.py 新增「backupd call() 用 sudo -n 提权」(查命令数组语义),
t-update-inject.py 加对应反向验证注入(34 例 0 异常)。
真机验收:sudo -u drouter backupd --run-now → BK_CREATED files=21 size=23767。
2026-10-10 16:29:19 +08:00

1110 lines
60 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""判据:概述页「检测新版本 / 一键更新」。
(原名 t-update-passwall.py —— passwall 功能已按用户要求整体废除,
本文件随之重写并改名。保留下来的只有版本检测这一块。)
覆盖三类:
A. 存在性与接线(模块在、路由接上、静态资源引对)
B. 供应链安全(探测多源 + 下载官方优先/失败降级镜像 + 强制 SHA256)
C. 前端接线与「不出现」约束
每条关键判据都在 t-update-inject.py 里做了双向验证。
"""
import ast
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
P = lambda *a: os.path.join(ROOT, *a)
fails = []
n = 0
def ck(desc, cond, extra=''):
global n
n += 1
if not cond:
fails.append('%s %s' % (desc, extra))
print('FAIL %s %s' % (desc, extra))
else:
print('ok %s' % desc)
def read(p):
"""读源码,并把 t()/T() 的包装**剥掉**。
2026-10-07:前端补 i18n 后,界面文案从裸中文
'<span title="套用预设名称">'
变成
'<span title="${t('套用预设名称')}">'
于是判据里 `'套用预设名称' in read(APP_JS)` 这类**裸中文匹配全部失效**
(t-update 一次红 63 条)。剥掉包装后,判据关心的是
「代码里有没有这段中文」,与它是否被包 t() 无关 ——
这才是这些判据本来要表达的意思。
"""
with open(p, encoding='utf-8') as f:
src = f.read()
# ${t('x')} / ${T('x')} → x ——**只在 x 含汉字时剥**
#
# ⛔ 两条边界,都踩过:
# ① 不剥裸 t('x'):有判据查的是「这行用了 t('nd.stale')」这种
# **代码形态**,剥掉就永远找不到(第一次改时一起剥,红 3 条)。
# ② **只剥含汉字的**:`${t('ups.xxx')}` 这种 key 是 ASCII,
# 剥成 `ups.xxx` 后 `aj.count("t('ups.")` 恒为 0 ——
# 「概览页已抽成 t()」这条判据因此假红。
# 判据关心的是「中文文案有没有被抽出来」,
# ASCII key 本来就该保持原样。
def _strip(m):
return m.group(1) if re.search(r'[一-鿿]', m.group(1)) else m.group(0)
src = re.sub(r"\$\{[tT]\('((?:[^'\\]|\\.)*)'\)\}", _strip, src)
return src
def code_only(src, lang='py'):
"""剥掉注释(**还剥 docstring**),只留真正会被执行的代码。
⚠️ 为什么非剥不可:判据经常要写「⚠️ 不能写 XXX」这类说明性注释,
而注释里正写着那个被禁的字符串 —— 不剥会把注释当成违规,基线假红。
这个坑本轮踩了**三次**(import drouter_update / 硬编码路径 / __APP_VERSION__)。
⚠️ Python 还要剥 docstring:只剥 # 的话,`apply_update` 的 docstring 里
写着「不自动安装 —— 安装要么走 apt」,于是「无 apt 调用」那条判据永远红。
docstring 用 ast 按行号区间精确删(不重排、不填充)。
JS 要剥的是 // 和 /* */ 两层 —— 字符串里的 URL(如 https://)不能误伤,
所以只做「行内 // 之后」和整块 /* */ 的粗略处理。
"""
if lang == 'js':
out, in_block = [], False
for ln in src.splitlines():
if in_block:
if '*/' in ln:
ln = ln.split('*/', 1)[1]
in_block = False
else:
continue
if '/*' in ln:
if '*/' in ln:
ln = ln.split('/*', 1)[0] + ln.split('*/', 1)[1]
else:
ln = ln.split('/*', 1)[0]
in_block = True
# 行内 //:跳过 http:// 这类协议里的双斜杠
i = ln.find('//')
if i >= 0 and ln[max(0, i - 1):i] != ':':
ln = ln[:i]
out.append(re.sub(r'/\*.*?\*/', '', ln))
return '\n'.join(out)
lines = src.splitlines()
try:
import ast
tree = ast.parse(src)
except SyntaxError:
tree = None
drop = set()
if tree is not None:
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.FunctionDef,
ast.AsyncFunctionDef, ast.ClassDef)):
if ast.get_docstring(node, clean=False):
first = node.body[0]
drop.update(range(first.lineno, first.end_lineno + 1))
out = []
for i, ln in enumerate(lines, 1):
if i in drop:
continue
out.append(re.sub(r'#.*$', '', ln))
return '\n'.join(out)
print('=== A. 存在性与接线 ===')
up_py = P('backend', 'drouter-update.py')
web_py = P('backend', 'drouter-web.py')
ck('drouter-update.py 存在', os.path.isfile(up_py))
ck('update.js 存在', os.path.isfile(P('web', 'update.js')))
ck('update.css 存在', os.path.isfile(P('web', 'update.css')))
# ⚠️ 必须 compile() 不是 ast.parse():后者抓不到「重复关键字参数」
# 这类语义期错误(1.0.10 实测:ast.parse 通过、import 时炸)。
for f in (up_py, web_py):
raw = open(f, 'rb').read()
rel = os.path.relpath(f, ROOT)
try:
compile(raw.decode('utf-8'), rel, 'exec')
ck('%s compile 通过' % rel, True)
except SyntaxError as e:
ck('%s compile 通过' % rel, False, 'line %s: %s' % (e.lineno, e.msg))
ck('%s 换行符为 LF' % rel, raw.count(b'\r\n') == 0,
'CRLF=%d' % raw.count(b'\r\n'))
w = read(web_py)
ck('web.py 接上 /api/update', "p.startswith('/api/update')" in w)
ck('web.py 实现 update_api', 'def update_api(' in w)
ck('web.py 注入 sys.path', 'sys.path.insert(0, _HERE)' in w)
ck('web.py 用 importlib 按路径加载(连字符文件名 import 不了)',
'spec_from_file_location' in w
and not re.search(r'^\s*import\s+drouter_update\b', w, re.M),
'(drouter-update.py 带连字符,import 必 ModuleNotFoundError)')
print()
print('=== B. 供应链安全(本块是本功能的核心红线) ===')
u = read(up_py)
uc = code_only(u)
# hv2(helper 源码)与它的剥注释版在本段就要用,
# 原先定义在 F2 段(后面)→ 这里用会 NameError。提到前面,F2 段仍可复用。
hv2 = read(P('backend', 'drouter-helper.py'))
# —— 探测可多源 ——
_pm = re.search(r'PROBES\s*=\s*\[(.*?)\n\]', u, re.S)
ck('PROBES 定义存在', _pm is not None)
if _pm:
body = _pm.group(1)
ck('探测源 ≥ 3 个', body.count('https://') >= 3, str(body.count('https://')))
_first = re.search(r"\(\s*'([^']+)'\s*,", body)
ck('官方源排第一(能直连就不碰第三方)',
_first is not None and '官方' in _first.group(1),
'第一个源是:%s' % (_first.group(1) if _first else '?'))
ck('探测源只有 2 元组(名称, URL)',
not re.search(r',\s*(True|False)\s*\)', body),
'第三字段已移除 —— 下载源改由 DL_SOURCES 独立管理')
# ⚠️⚠️ 1.0.10 真实踩到:把 PROBES 从 3 元组改成 2 元组后,
# 判据只查了「定义处是 2 元组」,**没查解包处** ——
# `for name, tmpl, _downloadable in PROBES:` 照旧解包 3 个,
# 真机一调 check() 就 `ValueError: not enough values to unpack`,
# 表现为「版本检测永远失败」。
# 这条要查 drouter-update.py 的代码,而它在本段只有 uc(无注释)版本,
# 所以下面直接用 uc —— 但解包处可能有 # 注释,所以两条都查。
ck('check() 按 2 元组解包 PROBES(与定义一致)',
re.search(r'for\s+name,\s*tmpl\s+in\s+PROBES', uc) is not None
and not re.search(r'for\s+name,\s*tmpl,\s*\w+\s+in\s+PROBES', uc),
'(改元组长度时必须同步解包处,否则真机才炸)')
# —— 下载:官方优先,失败降级到镜像(1.0.10 实测大陆网络必需)——
# ⚠️ 判据第一版查 `ASSET_TMPL` 且要求「不含任何代理域名」——
# 那条红线在大陆网络下等于「一键更新永远不可用」
# (实测 github.com TCP 100% 丢包)。现在改成查 DL_SOURCES 降级链。
_dm = re.search(r"DL_SOURCES\s*=\s*\[(.*?)\n\]", uc, re.S)
ck('DL_SOURCES 下载源链存在', _dm is not None,
'(DL_SOURCES 定义在 drouter-update.py,不在 helper —— '
'判据第一版查错了文件,查的是 hv2)')
if _dm:
_dl = _dm.group(1)
ck('下载源 ≥ 2 个(官方 + 至少一个镜像)',
_dl.count('https://') >= 2, str(_dl.count('https://')))
_dfirst = re.search(r"\(\s*'([^']+)'\s*,", _dl)
ck('下载源官方排第一',
_dfirst is not None and '官方' in _dfirst.group(1),
'第一个下载源是:%s' % (_dfirst.group(1) if _dfirst else '?'))
ck('下载走降级链函数而非硬编码 URL',
'def _download_with_fallback(' in uc
and re.search(r'for\s+src_name,\s*tmpl\s+in\s+DL_SOURCES', uc)
is not None)
ck('降级只在连接层失败时发生(内容被篡改不降级)',
# ⚠️⚠️ 判据第三版才写对,原因值得记:
# ① 前两版查的是**中文注释文字**(「其他错误(如 404)换源也没用」),
# 但 code_only() 最后有 `re.sub(r'#.*$', '', ln)` —— 它会把
# **# 之后全部删掉**(不只 docstring),所以查注释必然 False。
# 我前两次诊断时复刻 code_only **漏了这一步**,得出「匹配成功」
# 的错误结论,绕了很大一圈。
# ② 现在只查**代码**:连接层关键词 + continue 紧跟 return 结构。
(_i0 := uc.find('def _download_with_fallback')) >= 0
and (_fn := uc[_i0:_i0 + 3000])
and all(k in _fn for k in
("'timed out' in s", "'refused' in s", "'resolve' in s",
"'network' in s"))
and re.search(r'continue\b[\s\S]{0,200}?return False', _fn)
and re.search(r"'timed out' in s[\s\S]{0,200}?continue", _fn),
'(连接层失败 → continue 降级;其他错误 → return 不降级)')
ck('清单也走降级链(否则降级下载无法校验)',
re.search(r"def _fetch_manifest[\s\S]{0,900}?for\s+src_name,\s*tmpl\s+"
r"in\s+DL_SOURCES", uc) is not None)
# ⚠️ 判据第一版查 `'src':` 字面量,但实现是用 **关键字参数** 传的:
# _write_state(..., src=src_name)。所以要查 src= 形式。
ck('状态里记录实际用的源(前端要显示)',
# ⚠️ 判据第二版:原来还要求 `_write_state(src=src_name)` ——
# 那是实现降级链**之前**的写法(预写状态),降级后改成用函数的
# 返回值 `src=src` 写在 worker 里。旧条件一直匹配不到,
# 但因为 `and` 的第二个条件成立,判据偶尔会「碰巧对」一次,
# 掩盖了问题。现在只查实际生效的那个。
re.search(r'_write_state\([^)]*src=src\b', uc, re.S) is not None,
'(用 _write_state(src=src) 关键字参数传,不是字典字面量)')
ck('校验结果里写明下载源',
re.search(r'官方 SHA256(下载源:%s)', uc) is not None)
ck('未校验时特别标明走了镜像',
re.search(r'未做完整性校验\*\*[\s\S]{0,80}?下载源:%s', uc) is not None,
'(文案跨两行,正则要放宽)')
# —— 校验 ——
ck('有 SHA256 校验(比对而非仅取清单)',
"want = sums.get(target['name'])" in uc
and "want.lower() != actual.lower()" in uc
and 'actual = _sha256(dest)' in uc)
ck('校验失败会删文件',
re.search(r"校验不通过[\s\S]{0,400}?os\.remove\(dest\)", uc) is not None,
'(删文件必须与「校验不通过」同段,否则可能删在别处 = 看似有其实没接上)')
ck('拿不到清单时明说未校验',
re.search(r'未做完整性校验', u) is not None)
ck('SSL 校验失败不降级',
'create_default_context' in u
and not re.search(r'CERT_NONE|check_hostname\s*=\s*False', uc),
'(遇到证书错误就跳过验证 = 默许 MITM,与本模块的安全底线自相矛盾)')
print()
print('=== C. 行为约束 ===')
# ⚠️ 必须比**调用点**位置,不是 `def _do_backup` 的定义位置 ——
# 定义在文件靠后,调用在 _apply_worker 内部。拿定义位置去比会得出
# 「下载在备份之前」的反结论(判据第一版就这么错的)。
_w = uc.index('def _apply_worker')
ck('版本比较用元组而非字符串',
'return a > b' in uc and 'parse_ver' in u,
'(字符串比较会把 1.0.10 判成比 1.0.9 旧)')
ck('探测有超时(不能无限等)', 'PROBE_TIMEOUT' in u)
ck('检测结果有缓存 TTL', 'CHECK_TTL' in u)
ck('下载可中断', '_stop_flag.is_set()' in u)
# ⚠️ 半截文件的清理现在在 **finally** 里(1.0.10 修 P1-5 时挪的)。
# 原来只有 InterruptedError 分支删 .part,网络重置/磁盘满/超时都会留下
# 完整的 169MB 文件,4GB 机器失败三次就撑满根分区。所以判据也改了:
# 不再查「某个 except 分支里有没有 remove」,而是查 finally 块。
ck('半截文件在 finally 里统一清理(覆盖所有失败路径)',
re.search(r'finally:[\s\S]{0,400}?os\.remove\(dest \+ \'\.part\'\)', uc)
is not None,
'(必须在 finally,不能只在 InterruptedError 分支 —— '
'网络/磁盘错误同样会留下 .part)')
ck('清理前先判断存在(避免异常)',
re.search(r"os\.path\.exists\(dest \+ '\.part'\)", uc) is not None)
# ⚠️ 下面三条曾写成「关键词在文件里存在」,反向验证时全部漏网:
# 同一个关键词在文件里出现 N 处,注入只改 1 处 → 判据照样绿。
# 查「关键词存在」时,必须同时要求它出现在**正确的上下文**里。
ck('先备份后下载',
uc.index('_do_backup()', _w) < uc.index("phase='download'", _w)
and uc.index('_do_backup()', _w) > _w,
'(备份调用必须在 _apply_worker 内、且在下载步骤之前)')
ck('备份失败即中止(不带风险往下走)',
re.search(r'备份失败[\s\S]{0,200}?中止', u) is not None)
# ⚠️ 「不自动安装」的判据**不能查文案**(docstring/注释里都写着这句话,
# 剥注释也剥不掉 docstring)。必须查**行为**:
# apply_update 函数体内除了起下载线程,不许出现任何包管理调用。
# 反向验证第一版就是改那句文案,判据照样绿 —— 绿灯来自查错了对象。
_au = uc[uc.index('def apply_update'):uc.index('def _apply_worker')]
ck('不自动安装(apply_update 内无任何包管理调用)',
not re.search(r'\b(apt|apt-get|dpkg|dpkg-deb|install_offline|'
r'aptitude|systemctl)\b', _au),
'检测到包管理调用:%s'
% re.findall(r'\b(?:apt|dpkg|systemctl)\b', _au))
ck('不自动安装(只起下载线程)',
'threading.Thread' in _au and '_worker.start()' in _au)
ck('状态写文件供前端轮询', 'state.json' in u)
ck('状态原子落盘(.part + replace)',
"STATE_FILE + '.part'" in uc and 'os.replace' in uc)
print()
print('=== D. 前端接线 ===')
idx = read(P('web', 'index.html'))
ck('index.html 引入 update.js', 'update.js' in idx)
ck('index.html 引入 update.css', 'update.css' in idx)
ck('update.js 在 app.js 之后(要用 app.js 的 api()/esc)',
idx.index('app.js') < idx.index('update.js'))
aj = read(P('web', 'app.js'))
ck('app.js 有版本卡片容器', 'id="upd-card"' in aj)
ck('app.js 触发卡片渲染', 'drouterRenderUpdateCard' in aj)
uj = read(P('web', 'update.js'))
# 剥注释版源码:下面多处判据要查**代码**而不是注释 ——
# 注释里出现的关键词会让判据假绿/假红(本项目已栽 5 次)。
# 这里统一算好,全文件复用。
_hvc = code_only(hv2, 'py')
_us = code_only(read(P('web', 'upstream.js')), 'js')
_ujc = code_only(uj, 'js')
ck('update.js 导出渲染函数', 'drouterRenderUpdateCard' in uj)
# ⚠️ 本地版本号**不能**从 __APP_VERSION__ 读(1.0.10 修的真 bug):
# 后端只对 index.html 做字符串替换,app.js 是独立静态文件不经过注入 ——
# 在 app.js 里写 __APP_VERSION__ 会直接 ReferenceError,概览页整页崩。
# 离线渲染检查器 devtools/check-render.js 抓到了这个。
# 现在统一从页脚 #app-ver 读(那是服务端已注入真实版本号的元素)。
ck('app.js 里没有裸用 __APP_VERSION__(该变量只存在于 index.html)',
not re.search(r'\$\{esc\(__APP_VERSION__\)\}', aj)
and not re.search(r'\besc\(__APP_VERSION__\)', aj),
'(app.js 不经过后端注入,用它会 ReferenceError → 概览页崩)')
ck('update.js 从页脚读取本地版本',
re.search(r"getElementById\('app-ver'\)", uj) is not None)
# ⚠️ 判据查「update.js 代码里没用 __APP_VERSION__」,**必须剥注释** ——
# 那段注释正好解释了「为什么不能用它」,里面就写着这个变量名。
# 全文搜会把自己的注释当成违规(这个坑本轮第三次踩,见 helper 的 selfcheck)。
ck('update.js 代码里不用 __APP_VERSION__',
'__APP_VERSION__' not in code_only(uj, 'js'),
'(注释里可以提,代码里不能出现)')
# ⚠️ 下面这几条曾写成「关键词在文件里存在」,反向验证**全部漏网**:
# 同一关键词在 update.js 里出现 N 处(如 `d.local` 4 处、
# `upd-cancel-task` 3 处、`备份` 6 处),注入只改 1 处 → 判据照样绿。
# 现在改成查「该关键词出现在正确的元素/上下文里」。
ck('update.js 展示本地版本(模板里渲染 d.local)',
re.search(r'\$\{esc\(d\.local\)\}', uj) is not None)
ck('update.js 展示最新版本(仅在有更新时)',
re.search(r'\$\{esc\(d\.latest\)\}', uj) is not None)
ck('update.js 有中断按钮(查元素本身,不是 querySelector)',
re.search(r'<button[^>]*id="upd-cancel-task"', uj) is not None)
ck('update.js 进度条元素存在',
re.search(r'id="upd-(bar|fill|prog)"', uj) is not None)
ck('update.js 措辞提到备份(弹窗说明里的具体表述)',
re.search(r'<b>先自动备份</b>', uj) is not None)
ck('update.js 措辞提到可中断',
re.search(r'随时可中断', uj) is not None)
ck('update.js 措辞提到下载源的取舍',
# ⚠️ 1.0.10 改了策略(大陆网络实测 github.com 直连 100% 丢包,
# 「下载一律走官方」等于一键更新永远不可用),所以判据不能还查旧文案。
re.search(r'优先从 GitHub 官方下载', uj) is not None
and re.search(r'自动改用', uj) is not None)
ck('update.js 说明下载全程显示实际用的源',
re.search(r'全程都会显示实际用的是哪一个', uj) is not None)
ck('update.js 在进度区显示下载源',
re.search(r'下载源:', uj) is not None)
ck('镜像源会被醒目标注(tag warn)',
re.search(r"tag warn\">下载源", uj) is not None
or re.search(r"mirror[\s\S]{0,120}?tag warn", uj) is not None)
ck('update.js 强调校验一步不省',
re.search(r'校验完整性一步都不省', uj) is not None)
ck('update.js 说明不自动安装',
re.search(r'<b>不会自动安装</b>', uj) is not None)
# ---- 2026-10-10 用户反馈:「只有重新检查,没看到升级按钮,放哪了?」----
# 根因:「查看更新内容并升级」只在 has_update 为真时渲染,而用户自己是
# 1.0.9、线上也是 1.0.9 → 走「已是最新」分支,那里没有升级入口,
# 用户合理地怀疑「按钮是不是坏了 / 藏哪了」。
# 「看看新版本发布了什么」本来就不该依赖有没有更新 → 最新版也要有入口。
ck('「已是最新」分支也提供发布说明入口',
# ⚠️ 判据第二版:原来要求「按钮 id 和 openReleaseNotes 之间不超过 200 字符」,
# 实际中间隔着 5 行(含一行长的 whenTxt 模板),超了 → 假红。
# 改成分别存在即可。第三版又踩了一次:判据写 `querySelector('#upd-info')`,
# 而实现用的是项目通用的 `$('#upd-info')` 简写 → 又假红。
# **判据里的写法必须和实现一致**,别想当然。
# 绑定的正确性由 check-render.js 兜(点不到会因 #un-close 缺失暴露)。
re.search(r'id="upd-info"', _ujc) is not None
and re.search(r"\$\('#upd-info'\)", _ujc) is not None
and re.search(r'ib\.onclick\s*=\s*\(\)\s*=>\s*openReleaseNotes', _ujc)
is not None,
'(用户是最新版时也必须能看到「这版是什么/从哪拿/怎么装」,'
'否则会以为功能坏了)')
ck('发布说明窗口存在且是只读的(不触发下载流程)',
re.search(r'function openReleaseNotes', _ujc) is not None
and re.search(r"id=\"un-close\"", _ujc) is not None)
ck('发布说明里给出三种安装方式',
re.search(r'apt install', _ujc) is not None
and re.search(r'install-offline\.sh', _ujc) is not None
and re.search(r'docker load', _ujc) is not None)
ck('发布说明里写明下载源策略(官方优先 / 降级 / 校验)',
re.search(r'function openReleaseNotes[\s\S]{0,4000}?官方 SHA256 校验',
_ujc) is not None)
ck('后端在无更新时也返回 assets(发布说明要用)',
re.search(r"r\.get\('ok'\)\s+and\s+r\.get\('latest'\)", w) is not None
and re.search(r"if\s+r\.get\('ok'\)\s+and\s+r\.get\('latest'\):", w)
is not None,
'(原先只在 has_update 时给 assets → 发布说明窗口里没有下载地址,'
'而那恰是最新版用户最想看的)')
print()
print('=== F8. 实时柱状流动图(1.0.10)===')
rt = read(P('web', 'realtime.js'))
_rtc = code_only(rt, 'js') # 剥注释版
ck('realtime.js 存在且被 index.html 引入',
'realtime.js' in idx
and idx.index('app.js') < idx.index('realtime.js'))
ck('概览页有 realtime-box 容器', 'id="realtime-box"' in aj)
ck('三块图都用 canvas(不是 DOM 条)',
rt.count('<canvas') == 0 # 容器写在 app.js 里
and aj.count('data-rt="') == 3,
'三块:load / net / conn')
ck('app.js 调用了渲染入口', 'drouterRenderRealtime' in aj)
ck('轮询已登记进 stopPageTimers', '__drouterRealtimeStop' in aj)
ck('用 quick 模式的 metrics(轻量)', 'quick=1' in _rtc)
# —— 降采样必须三条 series + labels 一起做(1.0.10 真跑抓到的不一致)——
ck('降采样在 push 末尾统一做(不逐条判断)',
# ⛔ 逐条 forEach 里判断 `if (arr.length > MAX)` 会出现:
# ① hist.series[i] = ... 只是换引用,forEach 迭代的仍是旧数组
# ② 各条 series 判断的起点不同 → 有的降了有的没降
# ③ labels 单独降采样 → 与 series 长度对不上,时间轴错位
re.search(r"hist\.labels\.length\s*>\s*MAX_POINTS\s*\*\s*2", _rtc)
is not None
and re.search(r"hist\.series\.forEach[\s\S]{0,200}?"
r"hist\.series\[i\]\s*=\s*arr\.filter", _rtc) is not None,
'(降采样必须在所有 series push 完之后统一做)')
ck('Y 轴保留下限(负载 0.05 不被放大成满格)',
re.search(r'function yRange\(arr,\s*hardMin\)', _rtc) is not None
and re.search(r'hardMin\s*\|\|\s*0', _rtc) is not None)
ck('Y 轴上限留 15% 余量(峰值不顶格)',
re.search(r'\*\s*1\.15', _rtc) is not None)
ck('DPR 上限为 2(高 DPI 下不做超大 canvas)',
re.search(r'DPR_CAP\s*=\s*2', _rtc) is not None)
ck('值为 0 时不画 1px 假柱',
re.search(r'v\s*>\s*0\s*\?\s*1\s*:\s*0', _rtc) is not None)
ck('重传标注为累计值(免得被当成速率)',
'累计' in _rtc and 'retrans' in _rtc)
print()
print('=== F9. i18n 框架(1.0.10 第 1 批)===')
ij = read(P('web', 'i18n.js'))
ck('i18n.js 存在且被 index.html 引入', 'i18n.js' in idx)
def _script_at(name):
"""index.html 里 <script src="...name..."> 的位置;找不到返回 -1。
⚠️ 不能再用 idx.index('app.js') —— 那是「文件里第一次出现这个字符串」,
注释里提一句 app.js 就会被当成脚本位置。2026-10-09 实测踩过:
给 #page-health 加的行内注释写了「由 app.js 的 paintPageLight() 填」,
于是 i18n.js 的位置看起来排到了 app.js 之后,判据假红。
"""
m = re.search(r'<script[^>]*\b' + re.escape(name) + r'\b', idx)
return m.start() if m else -1
_ij_at = _script_at('i18n.js')
_aj_at = _script_at('app.js')
ck('i18n.js 在 app.js **之前**(app.js 的 t() 要靠它)',
_ij_at >= 0 and _aj_at > _ij_at,
'(i18n@%d app@%d;顺序反了 app.js 里的 t() 会 ReferenceError)' % (_ij_at, _aj_at))
ck('暴露了全局 t()', re.search(r'window\.t\s*=\s*t', ij) is not None)
ck('暴露了 setLang / toggle / getLang',
re.search(r'setLang:\s*setLang', ij) is not None
and re.search(r'toggle:\s*toggle', ij) is not None
and re.search(r'getLang', ij) is not None)
ck('语言持久化到 localStorage',
re.search(r'localStorage\.setItem\(STORE_KEY', ij) is not None)
ck('首次进入跟随浏览器语言',
re.search(r'navigator\.language', ij) is not None)
# —— 切换闭环的三个环节,缺一不可 ——
ck('切语言会重绘 DOM(data-i18n)',
re.search(r'function applyDom', ij) is not None
and re.search(r"querySelectorAll\('\[data-i18n\]'\)", ij) is not None)
ck('切语言会通知订阅者', re.search(r'listeners\.forEach', ij) is not None)
ck('提供 register(异步模块登记重绘)',
re.search(r'function register\(name, fn\)', ij) is not None)
ck('提供 rerenderAll', re.search(r'function rerenderAll', ij) is not None)
ck('缺词降级为 key 原文(不是空白)',
re.search(r"console\.warn\('\[i18n\] 缺少词条", ij) is not None
and re.search(r'return key;', ij) is not None,
'(空白按钮会被用户当成 bug,比显示 key 更糟)')
ck('非法语言码回落中文', re.search(r"next\s*=\s*lang\s*===\s*'en-US'", ij)
is not None)
ck('字典里 en 缺词时回落 zh(不显示空白)',
# ⚠️ 2026-10-07 修真 bug:真值判断 `&& entry.en` 会把**合法的空串**
# 译文(量词/助词在英文里就该消失)误判成「没配 en」→ 掉回中文。
# 现在是 `entry.en != null`。判据只认旧写法会假红。
re.search(r"if\s*\(LANG === 'en-US' && entry\.en != null\)", ij) is not None)
# —— 4 个异步模块必须登记,否则切语言后停在旧语言 ——
for mod, fn in (('update', 'renderCard'), ('upstream', 'render'),
('netdetail', 'render'), ('realtime', 'renderAll')):
ck('%s.js 登记了 i18n 重绘' % mod,
re.search(r"i18n\.register\('%s'" % mod, read(P('web', mod + '.js')))
is not None,
'(这些卡片是 fetch 回来才填的,go() 不会顺带重建它们)')
ck('app.js 的切换钩子调用了 rerenderAll',
re.search(r'i18n\.rerenderAll', aj) is not None)
ck('顶栏有语言切换按钮',
'id="btn-lang"' in read(P('web', 'index.html')))
ck('按钮文字显示当前语言(EN / 中)',
# 中文侧的「中」已抽成词条 T('中')(raw 分组),不再是裸字面量;
# 英文侧 EN 两个字母本来就不需要翻译。两种写法都认。
re.search(r"lab\.textContent\s*=\s*en\s*\?\s*'EN'\s*:", aj) is not None
and re.search(r"const\s+en\s*=\s*window\.i18n\.getLang\(\)\s*===\s*'en-US'", aj) is not None,
'(显示「点了会变成什么」是反的 —— 用户看到的是当前状态)')
ck('按钮文案分成两条词条(当前/目标方向相反)',
re.search(r"langToEn", ij) is not None
and re.search(r"langToZh", ij) is not None,
'(一条词条表达不了两个相反方向,会自相矛盾)')
ck('语言名词条标记了 i18n-allow-han',
'i18n-allow-han' in ij,
'("switch to 中文"里的「中文」是语言名,硬译成 Chinese 反而看不懂)')
def _cn(src, prefix):
"""统计 src 里 [tT]('<prefix> 的出现次数。
⚠️ 2026-10-07:app.js 的翻译调用统一成别名 T('…'),
原因见 app.js 顶部注释。判据里写死 t(' 会把
「做完了」判成「没做」 —— 假红比不判更糟。
"""
return len(re.findall(r"[tT]\('" + re.escape(prefix), src))
ck('概览页已抽成 t()',
# ⚠️ 阈值第一版定高了:app.js 里 ups/nd 各只有 2 处(容器标题 + 读秒中),
# 因为这两个模块的主要文案在**它们自己的文件**里。
# 判据要按「各自文件里抽了多少」来看,不是全堆在 app.js。
_cn(aj, "dash.") >= 10 and _cn(aj, "ups.") >= 2
and _cn(aj, "nd.") >= 2
and _cn(read(P('web', 'upstream.js')), 'ups.') >= 8
and _cn(read(P('web', 'netdetail.js')), 'nd.') >= 15,
'app.js: dash=%d ups=%d nd=%d;模块内: ups=%d nd=%d'
% (_cn(aj, "dash."), _cn(aj, "ups."), _cn(aj, "nd."),
_cn(read(P('web', 'upstream.js')), 'ups.'),
_cn(read(P('web', 'netdetail.js')), 'nd.')))
print()
print('=== F10. 导航菜单 i18n(1.0.10 第 2 批)===')
ck('导航分组名走 t()(nav.g.g<序号>)',
re.search(r"[tT]\('nav\.g\.g'\s*\+\s*gi\)", aj) is not None)
# ⚠️ 2026-10-09:这里从 `T('nav.n.'+k) || it.n` 换成了 navText(...)。
# 原因:T() 查不到词条时返回的是 **key 原文**(非空!),
# `|| it.n` 的兜底永远不触发 —— 新增导航项忘了登记 nav.n.* 时,
# 菜单直接显示 `nav.n.health`。navText 显式判「查没查到」再回落。
ck('页面名走 navText/t()(nav.n.<key>,查不到会回落)',
re.search(r"navText\('nav\.n\.'\s*\+\s*it\.k\s*,", aj) is not None
or re.search(r"[tT]\('nav\.n\.'\s*\+\s*it\.k\)", aj) is not None)
ck('页面说明走 t()(nav.t.<key>)',
_cn(aj, 'nav.t.') >= 2)
ck('顶栏标题走 navText/t()(不是硬编码 p.t)',
re.search(r"\$\('#page-title'\)\.textContent\s*=\s*\(p\.k\s*\?\s*"
r"navText\('nav\.t\.'\s*\+\s*p\.k\s*,", aj) is not None
or re.search(r"\$\('#page-title'\)\.textContent\s*=\s*\(p\.k\s*&&\s*"
r"[tT]\('nav\.t\.'\s*\+\s*p\.k\)\)", aj) is not None)
ck('navText 自己判「查没查到」(v !== key)',
re.search(r"function navText\(key, fallback\)\s*\{[\s\S]{0,200}?v\s*!==\s*key",
aj) is not None)
# —— key 稳定性:持久化的键不能随语言变 ——
ck('navOpen 的键用分组序号而非分组名',
# ⚠️ 1.0.10 真踩过:用分组名当键,而分组名会随语言变(安全/Security),
# navOpen 又持久化在 localStorage —— 用户切一次语言,
# 展开状态就再也对不上了。真跑验证抓到英文 HTML 里残留 data-g="安全"。
re.search(r"data-gh=\"\$\{esc\(gk\)\}\"", aj) is not None
and re.search(r"S\.navOpen\['g' \+ gi\]", aj) is not None,
'(分组名随语言变,持久化键必须稳定)')
ck('老数据的展开状态做了迁移(不静默丢弃)',
re.search(r'1\.0\.10 起键从', aj) is not None
and re.search(r"moved\['g' \+ i\]\s*=\s*true", aj) is not None)
ck('搜索用当前语言的文案(英文界面能搜 firewall)',
re.search(r"const hay = \(it\.n \+ it\.t \+ gzh \+ iname\(it\)", aj)
is not None)
ck('搜索框 placeholder 走 i18n',
'data-i18n-attr="placeholder:nav.search"' in read(P('web', 'index.html')))
ck('「待完善」与「无匹配」有词条',
re.search(r"soon:\s*\{ zh: '待完善'", ij) is not None
and re.search(r"noMatch:", ij) is not None)
# —— 词条完整性(47 个页面都有 n/t)——
ck('47 个页面都有英文名词条',
len(re.findall(r"^\s+'[a-z0-9]+': \{ zh: '[^']*', en: '[^']*' \},",
ij, re.M)) >= 100,
'(8 分组 + 47 页名 + 47 页说明 ≈ 102 条)')
ck('生成脚本已入库(加页面后可重跑)',
os.path.isfile(P('_dev', 'gen-nav-i18n.py')))
print()
print('=== F11. 全局 t() 不能被局部变量遮蔽(1.0.10 抓到)===')
# ⛔ i18n 的 t() 是**全局**函数。任何局部 `const t = ...` / 参数 `(t, i) =>`
# 都会在那个闭包里把 t() 遮蔽掉 —— 后面写 t('key') 静默变成 TypeError。
# 实测 app.js 有 9 处(toast 元素、模板项、时间组、快照输出…)。
# 这些地方**将来**抽 i18n 时必然踩到,所以现在就查出来。
_T_SHADOW = [
(re.compile(r'^\s*const\s+t\s*='), 'const t ='),
(re.compile(r'\(\s*t\s*,\s*\w+\s*\)\s*=>'), '回调参数 (t, i) =>'),
(re.compile(r',\s*t\s*\)\s*=>'), '参数 (…, t) =>'),
(re.compile(r'\bvar\s+t\s*='), 'var t ='),
]
_shadows = []
# ⚠️ 2026-10-07:判据收紧为「遮蔽 **且该作用域内真的调了 t()**」。
# 原来只要看到 `const t =` 就报 → 一次红 20 处。但其中 19 处
# (toast() 里的 `const t = $('#toast')`、share.js 里的 `const t = 模板`…)
# **函数体内并不调 t('中文')**,遮蔽无害 —— 判据该报的是真隐患。
# 实测真正会炸的只有 1 处:renderShareHints 的
# `rows.map((t, i) => ...)` 里嵌了 ${t('名称')},那处已改用 T。
_LINES = aj.split('\n')
for _i, _l in enumerate(_LINES, 1):
_ls = _l.strip()
if _ls.startswith('//') or _ls.startswith('*'):
continue
_hit = None
for _pat, _desc in _T_SHADOW:
if _pat.search(_ls):
_hit = _desc
break
if not _hit:
continue
# 往后看 12 行(同一个函数体 / 同一个 map 回调)
# 窗口保持 12 行:放大到 30 会把 toast() 这类**无害**的也拉进来,
# 反而变成假红。12 行刚好能区分二者(实测 14 vs 3)。
_scope = '\n'.join(_LINES[_i - 1:_i + 12])
# ⛔ 只认**小写 t(**:T( 是 t() 的安全别名(app.js 顶部注释),
# 用了 T( 就说明已经避开遮蔽,不该再报。
if re.search(r"(?<![A-Za-z0-9_$.])t\('", _scope):
_shadows.append((_i, _hit, _ls[:58]))
ck('app.js 里遮蔽 t() 且同作用域真调 t() 的地方为空', not _shadows,
'→ %d 处会静默破坏 t():%s' % (len(_shadows), _shadows[:3]))
if _shadows:
print(' 这 %d 处会报 "t is not a function":' % len(_shadows))
for _i, _d, _s in _shadows[:8]:
print(' line %-6d %-18s %s' % (_i, _d, _s))
# i18n.js 自己不能有局部 t
ck('i18n.js 内部没有同名遮蔽', not re.search(r'^\s*(const|var|let)\s+t\s*=',
ij, re.M))
print()
print('=== F12. 后端 i18n 骨架(1.0.10)===')
# 1) ok()/fail() 加 en 参数
ck('helper 的 ok() 有 en 参数',
re.search(r'def ok\(data=None, msg=.*, code=.*, en=None\)', hv2)
is not None)
ck('helper 的 fail() 有 en 参数',
re.search(r'def fail\(msg, code=.*, data=None, en=None\)', hv2)
is not None)
# ⚠️ 这两条随 i18n 骨架从「ok() 直接调 _pick」改成「ok() 调 _resolve()」而变。
# 2026-10-05 实测:改完骨架后这两条判据变红,但红的是**判据的旧形状**,
# 不是产品坏了 —— 判据必须跟着契约一起改,否则下次真出问题时
# 「红=坏」这条前提就失效了(跟 MEMORY 里「判据不能比实现宽/窄」同源)。
ck('响应带 msg(按语言选好的)与 msg_cn/msg_en 孪生',
re.search(r"'msg_cn': msg, 'msg_en': en,\s*\n\s*'msg': m,", hv2)
is not None)
ck('ok()/fail() 走 _resolve(统一填 msg_en,不能只填 msg)',
hv2.count('en, m = _resolve(msg, en)') == 2,
'出现 %d 次(期望 2:ok + fail)' % hv2.count('en, m = _resolve(msg, en)'))
ck('en 为空时回落中文(不能显示空白)',
re.search(r"if\s+_cur_lang\[0\]\s*==\s*'en-US':\s*\n"
r"\s+if en:\s*\n\s+return en\s*\n"
r"\s+return _msg_lookup\(cn\) or cn", hv2)
is not None)
ck('默认语言是中文(老代码不传 en 时行为不变)',
re.search(r"_DEFAULT_LANG\s*=\s*'zh-CN'", hv2) is not None)
# msg_en 必须被填好,不能是 None:前端 api() 回写的是后端选好的 `msg`,
# 而任何按 msg_en 自选语言的代码路径都会拿到 null。真跑验证见 t-msg-en.py。
ck('msg_en 不是原样透传 en(否则没查表时恒为 None)',
"'msg_en': en,\n" in hv2.replace('\r\n', '\n')
and hv2.count('en, m = _resolve(msg, en)') == 2)
# 2) 语言传递链:前端 X-Lang → web 读 → helper 透传 → helper set_lang
ck('web 读 X-Lang 头', re.search(r"headers\.get\('X-Lang'\)", w) is not None)
ck('web 在 dispatch 最开头就设语言(任何分支都取得到)',
re.search(r"def dispatch\(self.*\n.*_cur_lang\[0\] = ", w, re.S)
is not None)
ck('helper() 把 lang 透传给 helper 进程',
re.search(r"payload\['_lang'\] = lang", w) is not None)
ck('helper 进程在 run_action 分发之前 set_lang',
re.search(r"if payload and payload\.get\('_lang'\):\s*\n\s*set_lang", hv2)
is not None)
# 3) 前端
ck('前端 api() 每次都带 X-Lang',
re.search(r"headers\['X-Lang'\]\s*=\s*_lang", aj) is not None)
ck('前端不缓存 lang(切语言后必须立刻生效)',
re.search(r"window\.i18n\.getLang\(\)", aj) is not None
and not re.search(r"const\s+_lang\s*=\s*['\"]zh-CN['\"]", aj))
ck('api() 统一回写 msg_cn(271 处调用点不用逐个改)',
re.search(r"_j\.msg_cn_raw\s*=\s*_j\.msg_cn", aj) is not None
and re.search(r"_j\.msg_cn\s*=\s*_j\.msg;", aj) is not None)
# 2026-10-07:optT 已被 bt4 完全取代(bt4 直接用后端表名 + 真实 key),
# optT 函数本身已删除。原来那两条判据查的是一个**已经不存在的机制**,
# 留着就是永远假红 —— 判据该钉「当前设计」,不是「历史设计」。
ck('optT 已由 bt4 取代(不再有 optT 函数与假表名 key)',
'optT' not in aj,
'(optT 用的是 i18n.js 里的假表名 key,与后端表名对不上,查不到就回落中文)')
# 2026-10-05:这三处从 optT('cleanItem.' / 'accelImpact.' / 'printItem.')
# 改成了 bt4('CLEAN_ITEMS' / 'ACCEL_IMPACT' / 'PRINT_ITEMS', …) ——
# 因为后端那三张表的键**不叫** cleanItem/accelImpact/printItem,
# 原来的字典 key 与后端表名对不上,查不到就回落中文(英文界面下仍中文)。
# bt4 直接用**后端表名 + 真实 key**,后端零改动。
ck('磁盘清理/加速影响/打印三处已改走 bt4(查后端表名)',
aj.count("bt4('CLEAN_ITEMS'") >= 1
and "bt4('ACCEL_IMPACT'" in aj
and "bt4('PRINT_ITEMS'" in aj,
'CLEAN_ITEMS=%d ACCEL=%s PRINT=%s'
% (aj.count("bt4('CLEAN_ITEMS'"), "bt4('ACCEL_IMPACT'" in aj,
"bt4('PRINT_ITEMS'" in aj))
ck('后端下发表不再用 optT 的假表名查英文',
"optT('accelImpact." not in aj and "optT('printItem." not in aj,
'accelImpact/printItem 还在用 —— 它们的 key 与后端表名对不上')
ck('i18n.js 有 opt 组(后端下发的选项文案)',
re.search(r'^\s{4}opt:\s*\{', ij, re.M) is not None)
ck('opt 组含 printItem / cleanItem / accelImpact',
all((' %s: {' % g) in ij or (' %s: {' % g) in ij
for g in ('printItem', 'cleanItem', 'accelImpact')))
ck('生成脚本已入库(后端加新表后可重跑)',
os.path.isfile(P('_dev', 'gen-opt-i18n.py'))
and os.path.isfile(P('_dev', 'gen-opt-dict.py')))
ck('update.js 不自己发起下载(URL 由后端 DL_SOURCES 决定)',
# ⚠️ 判据第三版。前两版范围都写错了:
# v1「前端不含 gh-proxy 字样」→ 1.0.10 要**显示**「下载源:gh-proxy 镜像」,假红;
# v2「前端不含 releases/download」→ 1.0.10 加的「版本发布说明」窗口里
# 要**展示** GitHub 公开下载链接(给人复制到浏览器用),又假红。
# 真正要保证的是:**不把 URL 交给 fetch / XHR**(那才是程序下载)。
# 展示用的 GitHub 链接出现在 UI 文案里不算违规。
not re.search(r'fetch\s*\([^)]*releases/download', _ujc)
and not re.search(r'fetch\s*\([^)]*api\.github\.com', _ujc)
and not re.search(r'XMLHttpRequest[\s\S]{0,200}releases/download', _ujc)
and re.search(r'/api/update/apply', _ujc) is not None,
'(前端只调 /api/update/*,下载与降级由后端负责)')
print()
print('=== E2. 本轮修的 6 个 P0/P1 的防线 ===')
# 这 6 个都是「静态全绿但实际会坏」的类型,逐个钉住。
# ① P0-1:_load_mod 必须缓存,否则模块级 Event/Lock 失效 → 中断永远不生效
ck('web.py 有模块缓存字典', '_MOD_CACHE' in w)
ck('web.py 有模块缓存锁', '_MOD_CACHE_LOCK' in w)
_lm = w[w.index('def _load_mod'):w.index('def update_api')]
ck('_load_mod 真的查了缓存(否则缓存字典是摆设)',
re.search(r'_MOD_CACHE\.get\(name\)', _lm) is not None
and re.search(r'_MOD_CACHE\[name\]\s*=', _lm) is not None,
'(只定义缓存不用 = 每次仍重新 exec,模块级 _stop_flag/_lock 照样失效)')
ck('_load_mod 缓存带 mtime(部署新版本后能失效)',
'st_mtime' in _lm)
# ② P0-2:running=True 必须与检查在**同一缩进层**(即同一把锁内)
# ⚠️ 判据第一版只查「两者出现在同一段文本里」,结果注入把写操作减一级
# 缩进(挪到锁外)后它照样绿 —— 文本还在,只是不在锁里了。
# 正确判据要查**缩进**:锁内的语句是 8 空格,锁外是 4。
_au2 = uc[uc.index('def apply_update'):uc.index('def _apply_worker')]
_lines = _au2.split('\n')
_i_chk = next((i for i, l in enumerate(_lines)
if 'read_state().get(' in l and 'running' in l), -1)
_i_wr = next((i for i, l in enumerate(_lines)
if '_write_state(running=True' in l), -1)
def _ind(ls, i):
if i < 0:
return -1
return len(ls[i]) - len(ls[i].lstrip())
ck('apply 的 running 检查存在', _i_chk >= 0)
ck('apply 的写 running 存在', _i_wr >= 0)
ck('写 running 与检查在同一缩进层(= 同一把锁内)',
_i_chk >= 0 and _i_wr >= 0 and _ind(_lines, _i_chk) == _ind(_lines, _i_wr)
and _ind(_lines, _i_wr) > 4,
'检查缩进=%d 写缩进=%d;两者必须相等且都 >4(4 = 锁外)'
% (_ind(_lines, _i_chk), _ind(_lines, _i_wr)))
# ③ P0-3:备份必须真的产出备份包
ck('backupd 支持 --run-now(绕过 auto_enabled)',
'--run-now' in read(P('backend', 'drouter-backupd.py')))
ck('backupd 有 emit() 把结果打到 stdout',
re.search(r'def emit\(', read(P('backend', 'drouter-backupd.py')))
is not None, '(log() 只写 jsonl 文件,stdout 什么都没有,'
'调用方无法判断是否真的产出了备份)')
ck('backupd 在 --run-now 时 emit BK_CREATED',
re.search(r"emit\('BK_CREATED", read(P('backend', 'drouter-backupd.py')))
is not None)
ck('_do_backup 校验 BK_CREATED(不只看 returncode)',
'BK_CREATED' in uc[uc.index('def _do_backup'):],
'(returncode==0 在「自动备份未开启」时也会出现,'
'只看它会把「什么都没备份」当成「备份成功」)')
ck('_do_backup 用 --run-now 调用', "'--run-now'" in uc)
# ⚠️ backupd 的 call() 必须用 sudo -n 提权(2026-10-10 真机修的 bug):
# 「更新前备份」由 drouter-web(drouter 用户)spawn backupd,这条链不带 sudo
# 时 helper 继承 drouter 身份,写 /opt/drouter/backups(root:root)直接
# [Errno 13] Permission denied,更新被卡死在备份这步。sudoers 已放行
# drouter 免密跑 helper 白名单,所以 root/drouter 两种身份都通。
# 判据查**语义**(subprocess 命令数组里真有 sudo 前缀),不能只查「出现 sudo 这个词」。
_bkd = read(P('backend', 'drouter-backupd.py'))
ck('backupd call() 用 sudo -n 提权(否则 drouter 身份写不进备份目录)',
re.search(r"\['sudo',\s*'-n',\s*'/usr/bin/python3',\s*HELPER", _bkd) is not None)
# ④ P0-4:apply 必须有白名单且在写库前
ck('apply_module 有模块白名单', 'APPLY_MODULES' in w)
# ⚠️ 切片必须只取 apply_module **自己的**函数体:直接对 w 全文 find()
# 会命中 save_config 里那处 merge_cfg(它在 apply_module 之前),
# 得出「白名单比写库晚」的反结论(判据第一版就这么错的)。
_am = w[w.index('def apply_module'):]
_am = _am[:_am.index('\n def ', 10)]
_i_guard = _am.find('module not in APPLY_MODULES')
_i_write = _am.find('merge_cfg(module, data)')
ck('apply_module 的白名单检查在写库之前',
0 <= _i_guard < _i_write,
'(先写库再由 helper 报错 = 接口说失败但数据已落库)'
'实际 guard@%d write@%d' % (_i_guard, _i_write))
ck('apply_module 确实调用了 merge_cfg(切片没截空)',
_i_write > 0, '切片长度=%d' % len(_am))
# ⑤ P1-8:helper().get('data') 可能是 None
# ⚠️ 必须剥注释再查:那段解释「为什么 get('data', {}) 不生效」的注释里
# 正写着 `or {}`,全文匹配会把自己的注释当成修复证据(判据第一版栽这)。
_hl = [re.sub(r'#.*$', '', l).strip()
for l in w.splitlines() if 'holds' in l and 'get(' in l]
ck('holds 读取用 or {} 兜底(None 会导致 500)',
any("or {}" in l and "get('holds'" in l for l in _hl),
'(helper 的 fail() 返回 {"data": None},键存在所以 .get("data", {}) '
'不生效,拿到 None 再 .get() 就 AttributeError → 500)实际行:%s' % _hl)
# ⑥ P1-2:_verify 的清理必须在 finally
hv = read(P('backend', 'drouter-helper.py'))
_vf = hv[hv.index('def _verify('):hv.index('def _verify(') + 9000]
ck('_verify 的临时文件清理在 finally 里',
re.search(r'finally:[\s\S]{0,200}?os\.remove\(tmp\)', _vf) is not None,
'(原来 os.remove 写在循环末尾,四个分支的 return 直接跳过 → '
'chrony 的校验文件永久残留在 /etc)')
ck('_verify 临时文件名唯一(并发预检不互相覆盖)',
# ⚠️ 判据第一版数 `_thread_id()` 出现次数 ≥2,判红了一次 ——
# 实现里是 `_tid = _thread_id()` 一次取值、两处格式化串里复用,
# 所以只出现 1 次,但**两个分支都是唯一的**。
# 正确的判据是查「两个分支的格式化串里都带唯一化成分」。
re.search(r"_vt \+ \('|%s\.%d\.%d\.tmp", _vf) is not None
and re.search(r"basename\(path\), _tid\)", _vf) is not None
and 'os.getpid()' in _vf and '_tid' in _vf,
'(固定文件名 + ThreadingUnixStreamServer = 线程A 校验的是线程B 的配置;'
'VERIFY_PATH 分支和通用分支都要唯一化)')
print()
print('=== E. 已废除的 passwall 不得有残留 ===')
ck('backend 下无 drouter-passwall.py',
not os.path.exists(P('backend', 'drouter-passwall.py')))
ck('web 下无 passwall.html', not os.path.exists(P('web', 'passwall.html')))
ck('web.py 不含 passwall 路由',
not re.search(r'api/pw|passwall_api', w))
ck('build-deb.sh 不含 passwall',
'passwall' not in read(P('packaging', 'build-deb.sh')))
ck('deploy.sh 不含 passwall',
'passwall' not in read(P('scripts', 'deploy.sh')))
ck('README 不含 passwall', 'passwall' not in read(P('README.md')).lower())
ck('主菜单(app.js)不含 passwall', 'passwall' not in aj.lower())
ck('openapi 不含 passwall', 'passwall' not in w)
print()
print('=== F2. 上游链路 / 网卡统计(1.0.10 新增) ===')
up_js = read(P('web', 'upstream.js'))
ck('read_upstream 已注册到 ACTIONS', "'read:upstream': read_upstream" in hv2)
ck('/api/upstream 已注册到 web 路由', "'/api/upstream': 'read:upstream'" in w)
ck('upstream.js 存在且被 index.html 引入', 'upstream.js' in read(P('web', 'index.html')))
ck('upstream.js 在 app.js 之后(要用 api/esc)',
read(P('web', 'index.html')).index('app.js')
< read(P('web', 'index.html')).index('upstream.js'))
ck('upstream.js 提供渲染入口', 'drouterRenderUpstream' in up_js)
ck('app.js 调用了渲染入口', 'drouterRenderUpstream' in aj)
ck('上游轮询已登记进 stopPageTimers',
'__drouterUpstreamStop' in w or '__drouterUpstreamStop' in aj)
ck('_dhcp6_stats 读 /proc/net/snmp6', '/proc/net/snmp6' in hv2)
ck('DHCPv6 统计按位置配对(不是同名查)',
"lines[i].split()[1:]" in hv2 and "lines[i + 1].split()[1:]" in hv2)
ck('snmp6 读不到时返回空列表而非全 0',
re.search(r"dhcp6s[\s\S]{0,600}?return \[\]", hv2) is not None,
'(全 0 会让用户以为「一条 DHCPv6 都没发过」,真相是没开着)')
# ⚠️ 这两条原来是「不返回已连接时长」——1.0.10 实现了该功能(uptime 差值算法),
# 所以判据方向反过来了:现在要验的是「**给了数值但必须带可信度标记**」,
# 而不是在没有标记的情况下也显示数字。
ck('已连接时长必须带可信度标记(不给裸数字)',
re.search(r"'uptime_trusted':\s*(up_trusted|trusted)", hv2) is not None
or "'uptime_trusted': up_trusted" in hv2,
'(起点可能是「面板首次记录到 up」而非插线时刻,不标出来就是误导)')
ck('前端对不可信的时长如实说明',
'非插线时刻' in up_js or '非插线' in up_js)
ck('_ethtool_stats 存在并接入 read_ifaces',
'def _ethtool_stats(' in hv2
and "'ethtool': et" in hv2)
ck('ethtool 缺失时说明原因而非显示 0',
# ⚠️ 判据第一版写 `out['ok'] = False`,但实现是**字典字面量**初始化,
# 没有这句赋值 —— 判红是判据写错,不是实现缺功能。
re.search(r"out\s*=\s*\{\s*'ok':\s*False", hv2) is not None
and '未安装 ethtool' in code_only(hv2),
'(ok 初始为 False + 给出中文原因,前端据此显示「为什么没有」而非显示 0)')
ck('前端 ethtool 不可用时显示原因', 'eth-off' in aj and 'ethBlock' in aj)
ck('接口名做了白名单校验(防命令注入)',
re.search(r"islink\('_a-zA-Z0-9_\.\:-\+'", hv2) is not None
or re.search(r"re\.match\(r'\^\[a-zA-Z0-9_", hv2) is not None)
print()
print('=== F3. 打包 / 部署清单必须覆盖全部 web 文件 ===')
# ⚠️ 2026-10-04 首次部署真机上踩到:drouter-update.py 装上了,
# 但 update.js / update.css / upstream.js 三个都没装 —— 因为
# deploy.sh 的前端安装段是**逐个 install**,不是通配符。
# 结果真机概览页 JS 404,白部署一次。
# 这条判据的作用:以后再加 web/*.js|css,它会立刻报出来。
import glob as _glob
_web = sorted(os.path.basename(p) for p in _glob.glob(P('web', '*'))
if os.path.basename(p).rsplit('.', 1)[-1] in ('js', 'css', 'html'))
_dep = read(P('scripts', 'deploy.sh'))
_bd = read(P('packaging', 'build-deb.sh'))
_miss_dep = [w for w in _web if w not in _dep]
_miss_bd = [w for w in _web if w not in _bd]
ck('deploy.sh 覆盖全部 web 文件', not _miss_dep,
'→ 漏登记:%s(真机上会 404)' % _miss_dep)
ck('build-deb.sh 覆盖全部 web 文件', not _miss_bd,
'→ 漏登记:%s(装 deb 后会 404)' % _miss_bd)
ck('后端模块也在两处清单里', 'drouter-update.py' in _dep
and 'drouter-update.py' in _bd)
print()
print('=== F4. 新增端点必须登记进 openapi 描述符 ===')
# ⚠️ 2026-10-04 真机部署后才发现:build_openapi 是**逐个 ep()/字典显式登记**的,
# 不是从 dispatch 的 readings 自动生成。所以新端点光在 readings 里加一行,
# API 文档里根本看不到 —— 第一次部署就漏了 /api/upstream 和 /api/update。
_oa = w[w.index('def build_openapi'):]
ck('/api/upstream 登记进 openapi', "'/api/upstream'" in _oa)
ck('/api/update/apply 登记进 openapi', "'/api/update/apply'" in _oa)
ck('/api/update/cancel 登记进 openapi', "'/api/update/cancel'" in _oa)
ck('readings 里有 /api/upstream', "'/api/upstream': 'read:upstream'" in w)
ck('openapi 摘要说明了「不自动安装」',
'SHA256' in _oa and '下载' in _oa)
print()
print('=== F5. 网络明细(邻居 / 路由 / 规则) ===')
nd = read(P('web', 'netdetail.js'))
# 剥注释版:i18n 改造后文案都在 t() 里,而 t() 本身**不在 netdetail.js**,
# 所以这些判据要查「netdetail.js 用了哪些 key」。
_ndc = code_only(nd, 'js')
idx2 = read(P('web', 'index.html'))
ck('read_netdetail 已注册到 ACTIONS', "'read:netdetail': read_netdetail" in hv2)
ck('/api/netdetail 已注册到 readings', "'/api/netdetail': 'read:netdetail'" in w)
ck('/api/netdetail 已登记进 openapi', "'/api/netdetail'" in _oa)
ck('netdetail.js 引入且在 app.js 之后',
'netdetail.js' in idx2 and idx2.index('app.js') < idx2.index('netdetail.js'))
ck('app.js 调用了渲染入口', 'drouterRenderNetDetail' in aj)
ck('轮询已登记进 stopPageTimers', '__drouterNetDetailStop' in aj)
# 路由必须只取 main 表 —— local 表有十几条回环/链路本地路由,会淹掉 default
ck('IPv6 路由只取 main 表',
re.search(r"'-6', '-j', 'route', 'show', 'table', 'main'", hv2) is not None,
'(不限定表的话 local 表的 ::1 / fe80::1 等噪音会灌进来)')
# STALE 不是故障 —— 这是内核语义,界面上不能标红
ck('区分 STALE 与 FAILED(STALE 不是故障)',
re.search(r"'stale':\s*'STALE' in states", hv2) is not None
and re.search(r"'failed':\s*'FAILED' in states", hv2) is not None)
ck('前端把 STALE 显示为「静默」而非错误',
# ⚠️ 判据第三版:1.0.10 把这些文案抽成 t() 了(原来是硬编码中文),
# 所以不能查「代码里有没有『静默』两个字」—— 要查**用了哪个词条 key**。
# 这正是 i18n 改造的通用副作用:文案判据全部要从「查字面量」改成「查 key」。
re.search(r"x\.stale\)\s*tag\s*=\s*'<span class=\"tag gray\">'\s*\+\s*"
r"t\('nd\.stale'\)", _ndc) is not None
and re.search(r"x\.failed\)\s*tag\s*=\s*'<span class=\"tag warn\">'\s*\+\s*"
r"t\('nd\.failed'\)", _ndc) is not None)
ck('前端对 STALE 排序放后(活跃在前)',
re.search(r'rank\(a\).*rank\(b\)', _ndc) is not None
and re.search(r'x\.stale \? 3 : 2', _ndc) is not None)
ck('IPv6 规则标出内核自动建的(0 / 32766)',
re.search(r"builtin.*'0', '32766'", hv2, re.S) is not None
and re.search(r"t\('nd\.builtin'\)", _ndc) is not None)
ck('默认路由在界面上有强调标记',
'row-def' in _ndc and re.search(r"t\('nd\.defRoute'\)", _ndc) is not None)
# 「暂无条目」抽成了 EMPTY 常量复用(这是好设计),所以只出现一次,
# 判据要查「EMPTY 被几个表格函数用了」而不是数出现次数。
ck('表格缺失时显示「暂无条目」而不是空白',
re.search(r"t\('nd\.empty'\)", _ndc) is not None
and _ndc.count('return EMPTY') >= 2, '(EMPTY 常量被 neighTable / routeTable / ruleTable 三处复用;'
'数出现次数会因抽了常量而假红)')
ck('用 ip -j JSON 而不是解析文本',
re.search(r"'ip', '-4', '-j', 'neigh'", hv2) is not None)
print()
print('=== F6. 「重新检查」必须有反馈闭环(1.0.10 用户反馈) ===')
us = read(P('web', 'upstream.js'))
# 用户的原话:「如果已是最新版,我再按重新检查,没有任何提示,
# 无法确定是不是已经是最新的了」——
# 根因:renderCard() 会重建 innerHTML 把按钮换掉,
# 按钮文字从「检查中…」变回「重新检查」,**整个过程零提示**。
ck('重新检查有「进行中」提示(不只是改按钮文字)',
# ⚠️ 同上:bindForce 与 bindRetry 两处都该有,删任一处都要判红
_ujc.count('正在连接 GitHub 查询最新版本') >= 2,
'(bindForce / bindRetry 两处都要有进行中提示;'
'当前出现 %d 次)' % _ujc.count('正在连接 GitHub 查询最新版本'))
# ⚠️ 反向验证实测:这条原本只查 bindForce 段内有没有 toast,
# 结果注入删掉 bindForce 那处后判据仍绿 —— 因为 **bindRetry 里还有一处**
# toast。两条路径(正常重查 / 失败重试)都必须有反馈,所以判据要查**两处都在**。
ck('重新检查与失败重试都有 toast 反馈',
re.search(r'bindForce[\s\S]{0,2000}?toast\(', _ujc) is not None
and re.search(r'bindRetry[\s\S]{0,2000}?toast\(', _ujc) is not None,
'(两条路径都要有 toast,删掉任一处都应判红)')
ck('toast 内容区分「已是最新」与「有新版本」',
re.search(r"has_update[\s\S]{0,200}?发现新版本", _ujc) is not None
and '已是最新版本' in _ujc)
ck('卡片常驻显示上次检查时间',
re.search(r'上次检查:', _ujc) is not None
and re.search(r'new Date\(d\.ts \* 1000\)', _ujc) is not None)
ck('「有更新」分支也提供重新检查入口',
re.search(r"has_update[\s\S]{0,900}?id=\"upd-force\"", _ujc) is not None)
ck('检测失败分支也有 toast 反馈',
re.search(r'bindRetry[\s\S]{0,1500}?toast\(', _ujc) is not None)
ck('错误路径不会被吞成「无更新」',
re.search(r'catch \(e\)[\s\S]{0,120}?msg = ', _ujc) is not None)
print()
print('=== F7. 已连接时长:用 uptime 差值算,不用第三方库 ===')
ck('_link_since 已实现',
re.search(r'def _link_since\(name\)', _hvc) is not None)
ck('用 /proc/uptime 做基准',
re.search(r"open\('/proc/uptime'\)", _hvc) is not None)
ck('用 boot_id 区分重启(否则 uptime 归零会算出负数)',
re.search(r"kernel/random/boot_id", _hvc) is not None
and re.search(r"rec\.get\('boot'\)\s*!=\s*boot", _hvc) is not None,
'(必须查**实际比较表达式**,不能只查 boot_id 这个词 —— '
'词在 docstring 里也有)')
ck('状态持久化到文件(关浏览器不影响计时)',
re.search(r"link-since\.json", _hvc) is not None
and re.search(r"os\.path\.join\('/var/lib/drouter'", _hvc) is not None)
ck('接口 down→up 会重记起点',
re.search(r"oper != 'up'", _hvc) is not None)
# ⚠️ 这里**必须数出现次数**:字段在 3 处被写入(read_ifaces 1 处 +
# read_upstream 的 v4/v6 各 1 处)。反向验证实测:只删 1 处时判据仍绿
# —— 「有没有」型判据在「多处都需要」的字段上天然不灵敏。
ck('带可信度标记(起点可能是面板首次记录而非插线时刻)',
_hvc.count("'uptime_trusted': up_trusted") >= 3,
'(3 个写入点都要带:read_ifaces + read_upstream 的 v4/v6;'
'当前 %d 处)' % _hvc.count("'uptime_trusted': up_trusted"))
ck('read_ifaces 接进已连接时长',
re.search(r"'uptime_s':\s*up_sec", _hvc) is not None)
# ⛔ 真实踩到:read_ifaces 里的 oper 是 **IFF_UP 的数字标志**(16),
# 不是 /sys 的 operstate 字符串('up')。加 `if oper == 'up'` 条件后
# 判断永远为假,uptime_s 全是 0 —— 真机才发现(read_upstream 那个
# 走的是另一条路径所以正常,掩盖了问题)。
ck('read_ifaces 里不拿 IFF_UP 标志当 operstate 用',
not re.search(r"if\s+oper\s*==\s*'up'", _hvc)
and not re.search(r"if\s*\(?\s*'up'\s*in\s+oper", _hvc),
'(oper 是数字标志 IFF_UP=16,与 operstate 字符串比较永远为假)')
ck('read_upstream 接进已连接时长',
re.search(r"mac, bridge, up_sec, up_trusted = _if_common", _hvc)
is not None)
ck('前端显示时长而不是「内核不提供」',
'linkUp(u)' in _us and '正在累计' in _us)
ck('不可信时如实说明(不装作精确)',
re.search(r'uptime_trusted', _us) is not None
and re.search(r'非插线时刻', _us) is not None)
# ⛔ 纯标准库:不得引入任何第三方模块。
# ⚠️ 判据第一版手写了一份标准库白名单,结果把 zipfile / tarfile / random /
# uuid / io / stat … 全判成「第三方」—— 它们**都是标准库**,
# 只是我没想到。→ 改用 sys.stdlib_module_names(Python 3.10+ 自带),
# 这样永远不会漏、也不会误判。
try:
import sys as _sys
_STDLIB = set(_sys.stdlib_module_names) | {'render', 'theme', 'drouter_helper'}
except AttributeError: # 3.9 及更早
_STDLIB = set()
_hv_imports = set(re.findall(r'^\s*(?:import|from)\s+([\w.]+)', hv2, re.M))
_top = set(m.split('.')[0] for m in _hv_imports)
_third = sorted(m for m in _top
if _STDLIB and m not in _STDLIB and not m.startswith('_'))
ck('未引入任何第三方库(纯标准库)', not _third,
'→ 发现非标准库:%s' % _third)
ck('已连接时长确实只用了标准库(/proc + json + os)',
re.search(r"import json", hv2) is not None
and re.search(r"/proc/uptime", hv2) is not None)
print()
print('=== F. CSS 变量(带 fallback 的不算缺失) ===')
def undefined_vars(text, defined):
out = set()
for mm in re.finditer(r'var\(\s*(--[a-z0-9-]+)\s*(,|\))', text):
if mm.group(2) == ',':
continue
if mm.group(1) not in defined:
out.add(mm.group(1))
return out
css = read(P('web', 'app.css'))
defined = set(re.findall(r'(--[a-z0-9-]+)\s*:', css))
ck('update.css 无未定义变量',
not undefined_vars(read(P('web', 'update.css')), defined),
str(undefined_vars(read(P('web', 'update.css')), defined)))
print()
print('=' * 56)
print('判据 %d 条,失败 %d 条' % (n, len(fails)))
if fails:
for f in fails:
print(' FAIL: %s' % f)
sys.exit(1 if fails else 0)