Files
drouter/_dev/t-update-inject.py
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

401 lines
18 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 -*-
"""反向验证:把每条关键判据对应的实现改坏,确认判据真的会红。
这是防「恒绿假阳性」的关键一步 —— 判据自己也要被验。
每个用例:注入 → 跑判据 → 必须因为**这一条**而红 → 还原 → 恢复绿。
⚠️ 注入点必须打在**语义层**,不能打在被检查的那个名字上。
早先踩过:判据写 `'_sha256' in u`,反向验证就去删 `_sha256` 函数
定义 —— 而正确做法是判据查「真的拿 want 跟 actual 比」,
注入点也要跟着改成「把 want 改成 None」。
"""
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = sys.executable
JUDGE = os.path.join(ROOT, '_dev', 't-update.py')
# (说明, 目标文件, 旧串, 新串, 期望变红的判据关键词, 是否替换全部出现)
# ⚠️ 第 6 个字段默认 False(只替换第一处)。**判据查的是「文件里任何一处
# 存在某串」时,必须设 True 全替换** —— 只改第一处,文件里还剩 N-1 处,
# 判据照样绿。反向验证第一版栽在这:`${esc(d.local)}` 有 4 处,
# 只改第 1 处 → 判据仍绿 → 误判成「判据不灵」,其实是注入不彻底。
CASES = [
# 1.0.10 改成 DL_SOURCES 降级链(大陆网络实测 github.com 直连
# 100% 丢包,「下载只走官方」等于一键更新永远不可用)。
# 所以「混入代理」不再是红线,取而代之的是「必须走降级链且逐步记录来源」。
('下载源链里官方被摘掉(会直接用镜像,失去官方优先)',
'backend/drouter-update.py',
" ('GitHub 官方', 'https://github.com/{repo}/releases/download/"
"v{ver}/{name}'),",
" ('占位', 'https://example.invalid/{repo}/{ver}/{name}'),",
'下载源官方排第一'),
# 注入:把**镜像源那一项整条删掉** —— 只留官方。
# ⚠️ 不能只在 `DL_SOURCES = [` 后面加注释:判据数的是 `https://` 出现次数,
# 加注释不影响数量 → 注入不生效、判据不红(我第一版就这么写的,白跑一轮)。
('降级链被写死成只用官方(大陆网络不可用)',
'backend/drouter-update.py',
(" ('ghproxy 镜像', 'https://gh-proxy.com/https://github.com/{repo}'\n"
" '/releases/download/v{ver}/{name}'),\n", ""),
None,
'下载源 ≥ 2 个'),
('把校验结果改成 None(等于不校验)',
'backend/drouter-update.py',
" want = sums.get(target['name'])",
" want = None",
'有 SHA256 校验'),
('SSL 校验失败就降级(默许 MITM)',
'backend/drouter-update.py',
" return ssl.create_default_context()",
" c = ssl.create_default_context()\n"
" c.check_hostname = False\n"
" c.verify_mode = ssl.CERT_NONE\n"
" return c",
'SSL 校验失败不降级'),
('下载不可中断',
'backend/drouter-update.py',
" if _stop_flag.is_set():\n"
" raise InterruptedError('用户已取消')",
" pass",
'下载可中断'),
('版本比较改回字符串语义(1.0.10 < 1.0.9 的坑)',
'backend/drouter-update.py',
" return a > b",
" return '%s' % (a,) > '%s' % (b,)",
'版本比较用元组而非字符串'),
('先下载后备份(顺序反了)',
'backend/drouter-update.py',
" ok, bmsg = _do_backup()",
" ok, bmsg = (True, '')",
'先备份后下载'),
# ⚠️ 注入点必须是**真实的包管理调用**,不能改那句 docstring 文案 ——
# 判据查的是「apply_update 函数体内有无 apt/dpkg 调用」,
# 只改文案它照样绿。第一版就栽在这,误判成「判据不灵」。
# (锚点是 run 里加的那行 subprocess,不含中文,避免编码问题)
('自动安装(越过用户决定)',
'backend/drouter-update.py',
" _stop_flag.clear()\n"
" _write_state(running=True,",
" subprocess.run(['apt-get', 'install', '-y', 'drouter'])\n"
" _stop_flag.clear()\n"
" _write_state(running=True,",
'不自动安装(apply_update 内无任何包管理调用)'),
('用 import 加载带连字符的模块(必 ModuleNotFoundError)',
'backend/drouter-web.py',
" U = self._load_mod('drouter-update')",
" import drouter_update as U",
'web.py 用 importlib'),
# backupd call() 去掉 sudo 前缀:drouter 身份(更新前备份)写不进
# /opt/drouter/backups → [Errno 13] Permission denied(2026-10-10 真机修)。
('backupd call() 去掉 sudo(drouter 身份写不进备份目录)',
'backend/drouter-backupd.py',
" ['sudo', '-n', '/usr/bin/python3', HELPER, action,",
" ['/usr/bin/python3', HELPER, action,",
'backupd call() 用 sudo -n 提权'),
('/api/update 路由被摘掉',
'backend/drouter-web.py',
" if p.startswith('/api/update'):",
" if False:",
'web.py 接上 /api/update'),
# —— 已废除的 passwall 残留(用户要求彻底清除)——
('passwall 路由复活',
'backend/drouter-web.py',
" # ========== Web 终端(#18) ==========",
" if p.startswith('/api/pw'):\n"
" if not self.auth():\n"
" return\n"
" return self.json({'ok': False})\n"
" # ========== Web 终端(#18) ==========",
'web.py 不含 passwall 路由'),
('update.js 不再展示本地版本(4 处全替换)',
'web/update.js',
"${esc(d.local)}",
"无",
'update.js 展示本地版本(模板里渲染'),
# ⚠️ 锚点要写**当前源码里的原文**。i18n 之后文案被包成了 ${t('中断')},
# 旧锚点(裸 '中断')再也匹配不上 → 注入静默跳过 → 反向验证
# 「看起来跑过」,其实这条判据根本没被验(2026-10-08 实测)。
# 判据本身走 read(),会剥掉 t() 包装,所以只影响**注入锚点**。
('update.js 去掉中断按钮',
'web/update.js',
'id="upd-cancel-task"',
'id="upd-cancel-task-gone"',
'update.js 有中断按钮(查元素本身'),
('update.js 去掉备份说明',
'web/update.js',
"<li><b>${t('先自动备份')}</b>${t(':调用面板自带的备份能力,把当前配置存一份。 备份不成功就')}"
"<b>${t('直接中止更新')}</b>${t(',不会带着风险往下走。')}</li>",
'<li>${t(\'随便更新一下。\')}</li>',
'update.js 措辞提到备份(弹窗说明里'),
('update.css 引用不存在的变量且无 fallback',
'web/update.css',
'.upd-err { color:var(--txt3); font-size:12px;',
'.upd-err { color:var(--no-such-var); font-size:12px;',
'无未定义变量'),
# ↓ 下面 6 个是 1.0.10 审查发现并修掉的 P0/P1,
# 注入点打在**语义层**,验证新加的防线判据真的灵敏
('P0-1 模块缓存退化成每次重新加载(中断将失效)',
'backend/drouter-web.py',
" hit = _MOD_CACHE.get(name)\n"
" if hit and hit[0] == mt:\n"
" return hit[1]",
" pass",
'_load_mod 真的查了缓存'),
('P0-2 running 写入移出锁外(并发可起两个 worker)',
'backend/drouter-update.py',
" if read_state().get('running'):\n"
" return {'ok': False, 'msg_cn': '已有更新任务在进行中'}\n"
" _stop_flag.clear()\n"
" _write_state(running=True,",
" if read_state().get('running'):\n"
" return {'ok': False, 'msg_cn': '已有更新任务在进行中'}\n"
" _stop_flag.clear()\n"
" _write_state(running=True,",
'写 running 与检查在同一缩进层'),
('P0-3 备份只信 returncode(什么都没备份也报成功)',
'backend/drouter-update.py',
" if r.returncode == 0 and 'BK_CREATED' in out:",
" if r.returncode == 0:",
'_do_backup 校验 BK_CREATED'),
('P0-4 apply 白名单移到写库之后(数据已落库才报错)',
'backend/drouter-web.py',
" if module not in APPLY_MODULES:\n"
" return self.json({'ok': False, 'code': 'BADMODULE',\n"
" 'msg_cn': '未知配置模块'})\n"
" # 预检绝不写配置库",
" # 预检绝不写配置库",
'apply_module 的白名单检查在写库之前'),
('P1-8 holds 用 .get("data", {}) 导致 None 上再 .get() → 500',
'backend/drouter-web.py',
"out['holds'] = (helper('pkg', {'op': 'holds'}).get('data')\n"
" or {}).get('holds', [])",
"out['holds'] = helper('pkg', {'op': 'holds'}).get('data', {}).get('holds', [])",
'holds 读取用 or {} 兜底'),
('P1-2 _verify 的清理移出 finally(chrony 临时文件永久残留 /etc)',
'backend/drouter-helper.py',
" finally:\n"
" # 这里才是「任何路径都一定会走到」的位置\n"
" try:\n"
" os.remove(tmp)\n"
" except Exception:\n"
" pass",
" if False:\n pass",
'_verify 的临时文件清理在 finally 里'),
('P1-3 _verify 临时文件名退回固定(并发互相覆盖)',
'backend/drouter-helper.py',
" tmp = '%s.%d.%d.tmp' % (_vt, os.getpid(), _tid)",
" tmp = _vt",
'_verify 临时文件名唯一'),
# ↓ 1.0.10 下载源降级链的防线
# 注入:把降级链的 for 循环改成空 —— 等于永远只用第一个源,
# 且一旦第一个源失败就直接 return False(没有任何降级)。
('下载不再走降级链(改回硬编码单个 URL)',
'backend/drouter-update.py',
" for src_name, tmpl in DL_SOURCES:",
" for src_name, tmpl in []:",
'下载走降级链函数'),
('清单不再走降级链(降级下载将无法校验)',
'backend/drouter-update.py',
" for src_name, tmpl in DL_SOURCES:\n"
" url = tmpl.format(repo=REPO, ver=ver.lstrip('v'), name=name)",
" for src_name, tmpl in []:\n"
" url = tmpl.format(repo=REPO, ver=ver.lstrip('v'), name=name)",
'清单也走降级链'),
('状态里不再记录用的源(前端无法显示)',
'backend/drouter-update.py',
" _write_state(src=src, url=info,\n"
" msg_cn='正在从 %s 下载…' % src)",
" _write_state(url=info,",
'状态里记录实际用的源'),
# 注入:把**整个镜像分支删掉**(含 tag warn 那个 span)。
# ⚠️ 只把 `mirror` 改成 `false` 不够 —— tag warn 的字符串还在,
# 判据照样匹配得到(第一版就这么写的,白跑一轮)。
('前端不再显示下载源(用户不知道走的是镜像)',
'web/update.js',
# 2026-10-06:三段都包了 t(),锚点要跟着改成新形态
(" if (s.src) {\n"
" const mirror = /镜像|proxy/i.test(s.src);\n"
" meta += (mirror\n"
" ? `<span class=\"tag warn\">${t('下载源:')}${esc(updSrc(s.src))}</span>`\n"
" + `<span style=\"color:var(--txt3)\">${t(' 官方直连不通,已自动改用镜像;')}`\n"
" + t('文件完整性仍会按官方 SHA256 校验</span>')\n"
" : `<span class=\"tag ok\">${t('下载源:')}${esc(updSrc(s.src))}</span>`);\n"
" }\n", ""),
None,
'update.js 在进度区显示下载源'),
('P2-6 版本号校验退回前缀匹配(路径穿越)',
'backend/drouter-update.py',
" if not re.fullmatch(r'\\d+\\.\\d+(\\.\\d+)?([-+~][\\w.]+)?', ver):",
" if not re.match(r'^\\d+\\.\\d+', ver):",
None), # 这条由真跑套件覆盖,静态判据暂不查
# ↓ 1.0.10 用户反馈后新增的两组
# ⚠️ 判据查「bindForce 与 bindRetry **两处都有** toast」,
# 注入也必须**两处都删**。
# ⚠️⚠️ 元组的格式是「**若干个 (锚点, 替换) 对**」,
# 我第一版误写成「(old, new)」两个元素 —— 结果注入把 A2 当成了替换文本,
# 实际什么也没删,判据当然绿。手工删两处验证过:判据会如实变红(rc=1)。
('去掉「重新检查」的 toast 反馈(用户提的原问题回归)',
'web/update.js',
((" await renderCard();\n"
" // ③ toast 让用户确知结果 —— 这一条是这次改进的关键\n"
" toast(ok ? msg : msg, ok ? 'ok' : 'err', 4200);",
" await renderCard();\n void msgA;"),
(" toast(msg, ok ? 'ok' : 'err', 4200);",
" void msgB;")),
None, # old 是元组时 new 不使用
'重新检查与失败重试都有 toast 反馈'),
('去掉「检查中」提示(按钮文字会被 innerHTML 重建冲掉)',
'web/update.js',
# 2026-10-06:已包 t()
" if (w) w.textContent = t('正在连接 GitHub 查询最新版本…');",
" if (w) w.textContent = '';",
'重新检查有「进行中」提示'),
('去掉「上次检查」时间戳',
'web/update.js',
# 2026-10-06:已包 t()(形态是 ${t('上次检查:')}${esc(whenTxt)})
" <span class=\"upd-when\" id=\"upd-when\">${t('上次检查:')}${esc(whenTxt)}",
" <span class=\"upd-when\" id=\"upd-when\"></span><!-- ${esc(whenTxt)}",
'卡片常驻显示上次检查时间'),
('已连接时长去掉 boot_id 判断(重启后 uptime 归零会算出负数)',
'backend/drouter-helper.py',
" if rec.get('boot') != boot:",
" if False:",
'用 boot_id 区分重启'),
('已连接时长去掉状态持久化(关浏览器就归零)',
'backend/drouter-helper.py',
" db = os.path.join('/var/lib/drouter', 'link-since.json')",
" db = '/tmp/link-since.json' # 注入:改成易失路径",
'状态持久化到文件'),
('去掉可信度标记(变成显示裸数字)',
'backend/drouter-helper.py',
" 'uptime_s': up_sec, 'uptime_trusted': up_trusted,",
" 'uptime_s': up_sec,",
'已连接时长必须带可信度标记'),
]
def run():
r = subprocess.run([PY, JUDGE], capture_output=True, cwd=ROOT)
return r.returncode, (r.stdout or b'').decode('utf-8', 'replace')
def main():
print('基线(应全绿):')
rc, out = run()
tail = out.strip().splitlines()[-1] if out.strip() else ''
print(' rc=%d %s' % (rc, tail))
if rc != 0:
print('✘ 基线就是红的,先修好再谈反向验证')
print(out[-2000:])
return 1
bad = []
for case in CASES:
desc, rel, old, new, kw = case[:5]
if kw is None:
# 显式标记「本条不由静态判据覆盖,跳过」——
# 比悄悄留一个错关键词好,后者会被当成「判据失效」。
print(' 跳过 %-46s (由真跑套件覆盖)' % desc)
continue
p = os.path.join(ROOT, rel)
with open(p, 'rb') as f:
orig = f.read().decode('utf-8')
# ⚠️ old 的格式演进(踩了两次才搞对):
# v1 str → 单个锚点
# v2 (old, new) → 我误以为的「多组替换」,实际被当成
# 一对,于是 A2 被当替换文本,什么也没删
# v3 ((o1,n1),(o2,n2)) → 正确:若干个 (锚点, 替换) 对
# 所以这里**递归**拆:遇到嵌套就往下剥一层。
pairs = []
def _collect(x):
if isinstance(x, (tuple, list)) and len(x) == 2 \
and isinstance(x[0], (tuple, list)):
for it in x:
_collect(it)
elif isinstance(x, (tuple, list)) and len(x) == 2:
pairs.append((x[0], x[1]))
else:
pairs.append((x, new))
_collect(old)
missing = [o2 for o2, _n2 in pairs if not isinstance(o2, str)
or o2 not in orig]
if missing:
print(' 跳过 %-46s (锚点未命中)' % desc)
bad.append(desc + ' 锚点未命中')
continue
try:
patched = orig
for o2, n2 in pairs:
patched = patched.replace(o2, n2)
with open(p, 'wb') as f:
f.write(patched.encode('utf-8'))
rc, out = run()
# 必须因为「这一条」而红,不是碰巧别的红了
caused = (rc != 0) and (kw in out)
if caused:
print(' ok %-46s → 如约变红' % desc)
else:
print(' FAIL %-46s → rc=%d 关键词命中=%s'
% (desc, rc, kw in out))
bad.append(desc)
finally:
# ⚠️ 必须无条件还原:抛异常会留下污染,基线就废了
with open(p, 'wb') as f:
f.write(orig.encode('utf-8'))
rc, out = run()
tail = out.strip().splitlines()[-1] if out.strip() else ''
print()
print('还原后基线:rc=%d %s' % (rc, tail))
if rc != 0:
bad.append('还原后未恢复全绿')
print(out[-1500:])
print()
print('反向验证 %d 例,异常 %d 例' % (len(CASES), len(bad)))
for b in bad:
print(' ! %s' % b)
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())