用户反馈的四件事一次做完:
1. 给快照加保护(单份上锁,自动清理一律跳过)
2. 说明「手动快照受保护不被清理」,并讲清它与上锁的区别
3. 快照备注事后可改(此前只能在创建时填,存下来就只能看不能改)
4. 列表从「配置快照与回滚」卡片搬进「紧急救援通道」,回滚入口不再弹残缺弹窗
protected 与 keep_manual 是两件事,必须分开:
- keep_manual 是全局策略,按 tag.startswith('auto') 猜来源
- protected 是用户对这一份的显式意图,跨策略生效
判据只能加在删除分支上,混进 auto 算法的话,受保护的自动快照
会开始占 keep_count 配额,把别的自动快照挤掉 —— 用户设「保留 5 份」
却只剩 4 份,且没有任何提示。
上锁不等于删不掉:删受保护的那份时后端回 needs_force,前端弹二次确认
后带 force 重试。审计里标注「受保护,已强制删除」,否则事后只看得到
「删了快照」,看不出删的是用户特意保下来的那份。
meta 用 .tmp + os.replace 原子写 —— meta 是 prune 唯一的判据来源,
写到一半被打断会留下解析不了的残文件,之后那份快照既不会被清理,
也上不了锁(读不到就当没保护)。
备注消毒:控制字符一律换成空格而不是删掉。删掉会把「甲 BEL 乙」
变成「甲乙」,两个词粘成一坨,而用户看到的备注莫名其妙。
真机验收(40 项全绿)当场抓到两个本地回归漏掉的真 bug:
- 备注消毒把制表符删掉而不是换空格:'a\\nb\\tc' 变成 'ab c'
本地判据只写「不含换行和制表符」,恰好放过 —— 「没有危险字符」
和「内容没被偷偷改掉」是两件事
- web 层无条件写 'protected': b.get('protected'),
payload 里这个键永远在(值可能是 None),helper 的
`if 'protected' in p` 恒成立,「不传就取反」从 HTTP 路上彻底失效
两个都补了反向验证(注入后必须观察到红)。
顺带修掉一个既存缺陷:t-107.py 里的 js_code_only 一直在吃 app.js
的真代码 —— 用 re.sub 块注释优先、再剥行注释,而 app.js 8222 行有一句
讲「怎么防止 CSS 注释被提前闭合」的行注释,注释里写着块注释的起止符
字面量,于是被当成真注释开头一路吃到文件尾。
证据:块注释起始 169 次而结束 170 次,数量不等就是配对错位一格。
HEAD 版同样中招(163/164),所以不是本轮引入。
换成 _dev/jsstrip.py 的词法扫描版(正确处理模板串的插值、正则字面量、
prev 记 token 尾词而不是一个字符),并加 node 侧 V8 交叉验证。
1483 lines
72 KiB
Python
1483 lines
72 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""1.0.7 新增 4 个模块的专项自查(对应任务 #154)。
|
||
|
||
t-api106.py 查的是「路径 / 方法 / 覆盖」,t-apicheck.py 查的是「字段转发」。
|
||
但这两者都是全站通用视角,对backup / alert / vpn / quota 这4 个新模块
|
||
**字段特别多、返回结构又嵌套**(conf / live / rules / peers / devices / plan),
|
||
通用视角下最容易漏的是「前端读了d.data.foo.bar,helper 那边叫 baz」。
|
||
|
||
这里做六件事:
|
||
|
||
A. **字段契约双向对照** —— 静态抽出前端 4 套页面里读的 `x.y` 路径,
|
||
与 helper 各状态函数 `ok({...})` 里的键逐个比对,双向都报差异。
|
||
B. **op 名三段对齐** —— 前端传的 op → Web 层放行的 op → helper
|
||
实际分支。三处名字必须完全一致,任何一环对不上就是「点了没反应」。
|
||
C. **高危路径纯逻辑测试**(用替身注入,不碰真机):
|
||
备份的 tar 炸弹拦截 / 路径穿越 / 敏感文件默认排除 / 还原确认门;
|
||
告警的冷却期 / 免打扰对 critical 的例外;
|
||
VPN 的私钥不外泄 / Exit Node 双保险 / 端口冲突语义;
|
||
配额的聚合幂等(重复跑不重复计数)/ 设备身份按 IP 合并。
|
||
D. **构建保护模式一致性** —— check_build_guard 是否覆盖 4 个新动作里
|
||
「会改网络状态」的那些。
|
||
E. **部署三形态一致性** —— deb / Docker / deploy.sh 都要能拿到
|
||
3 个新守护脚本,且 helper 动作名对得上。
|
||
F. **危险动作的审计与确认门** —— 逐条列出「会写盘 / 会改网」的 op,
|
||
断言它在 Web 层要么写了审计,要么有confirm 门。
|
||
"""
|
||
import ast
|
||
import base64
|
||
import datetime as datetime_module
|
||
import hashlib
|
||
import io
|
||
import ipaddress
|
||
import os
|
||
import re
|
||
import shutil
|
||
import smtplib
|
||
import socket
|
||
import ssl
|
||
import subprocess
|
||
import sys
|
||
import tarfile
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import json
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
PY = sys.executable
|
||
# 别的检查器会用 subprocess 调本文件,工作目录不保证是 _dev/。
|
||
# 不显式把脚本自身目录加进 path,`from jsstrip import ...` 就只在
|
||
# 「恰好 cd 到 _dev」时才work —— 那种依赖cwd 的 import 平时看不出问题,
|
||
# 换个人从仓库根跑就 ModuleNotFoundError。
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
|
||
def read(rel):
|
||
with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
|
||
return f.read()
|
||
|
||
|
||
WEB = read('backend/drouter-web.py')
|
||
APP = read('web/app.js')
|
||
HELPER = read('backend/drouter-helper.py')
|
||
DEBSH = read('packaging/build-deb.sh')
|
||
DEPLOY = read('scripts/deploy.sh')
|
||
DOCKER = read('packaging/Dockerfile') if os.path.isfile(
|
||
os.path.join(ROOT, 'packaging/Dockerfile')) else ''
|
||
|
||
PASS = 0
|
||
FAIL = 0
|
||
FAILDETAIL = []
|
||
|
||
|
||
def chk(desc, cond, extra=''):
|
||
global PASS, FAIL
|
||
if cond:
|
||
PASS += 1
|
||
print('[OK] %s' % desc)
|
||
else:
|
||
FAIL += 1
|
||
FAILDETAIL.append(desc)
|
||
print('[NG] %s' % desc)
|
||
if extra:
|
||
for ln in str(extra).splitlines():
|
||
print(' %s' % ln)
|
||
|
||
|
||
# ==================================================================
|
||
# A. 字段契约双向对照
|
||
# ==================================================================
|
||
print('=' * 68)
|
||
print('A. 字段契约:前端读的字段 ↔ helper 返回的字段')
|
||
print('=' * 68)
|
||
|
||
# --- A1. 抽出 4 套页面的函数体 ---
|
||
NEW_FUNCS = re.findall(
|
||
r'^(?:async )?function ((?:bk|al|vpn|qt)[A-Za-z0-9_]*)\(', APP, re.M)
|
||
|
||
|
||
def func_body(name, src=APP):
|
||
"""取一个顶层函数的完整 body(含嵌套花括号)。"""
|
||
m = re.search(r'^(?:async )?function %s\(' % re.escape(name), src, re.M)
|
||
if not m:
|
||
return ''
|
||
i = src.index('{', m.end() - 1)
|
||
# 从 function 关键字后的第一个 { 开始配平
|
||
i = src.index('{', m.start())
|
||
depth = 0
|
||
for j in range(i, min(len(src), i + 200000)):
|
||
c = src[j]
|
||
if c == '{':
|
||
depth += 1
|
||
elif c == '}':
|
||
depth -= 1
|
||
if depth == 0:
|
||
return src[i:j + 1]
|
||
return ''
|
||
|
||
|
||
BODIES = {f: func_body(f) for f in NEW_FUNCS}
|
||
empty = [f for f, b in BODIES.items() if not b]
|
||
chk('4 套页面的 %d 个函数都能定位到body' % len(NEW_FUNCS), not empty,
|
||
'定位失败: %s' % ', '.join(empty))
|
||
|
||
PAGE_SRC = '\n'.join(BODIES.values())
|
||
|
||
# --- A2. 前端读的字段路径 ---
|
||
# 模式1: d.data / res.data 链式访问 d.data.conf.peers
|
||
# 模式2: 解构后的局部变量(保守:只抓显式的 .a.b 两层)
|
||
CHAIN = re.findall(
|
||
r'\b(?:d|dd|res|r|r1|r2|r3|dt|dat|resp|o|out)\.data((?:\.[A-Za-z_]\w*)+)',
|
||
PAGE_SRC)
|
||
FRONT_FIELDS = set()
|
||
for c in CHAIN:
|
||
parts = re.findall(r'\.([A-Za-z_]\w*)', c)
|
||
if parts:
|
||
FRONT_FIELDS.add(parts[0]) # 一级:顶层 data 的键
|
||
if len(parts) >= 2:
|
||
FRONT_FIELDS.add('%s.%s' % (parts[0], parts[1])) # 二级
|
||
# 渲染函数里常见的 s.xxx / c.xxx(对象是 data 的子对象)
|
||
SUB = re.findall(r'\b([a-z]{1,3})\.([a-z_][a-z0-9_]*)\b', PAGE_SRC)
|
||
|
||
# --- A3. helper 各状态函数返回的键 ---
|
||
def code_only(src):
|
||
"""去掉 Python 注释,只留代码行。
|
||
|
||
修 bug 时经常要在注释里写清「原来错在哪」,比如
|
||
`sensitive=(d == '/etc/drouter')` 这段原代码。拿整段源码做
|
||
子串匹配就会把注释当成代码,报出假红灯 —— 而且这种红灯
|
||
最坏的情况是「为了让测试过,把注释删了」。
|
||
"""
|
||
out = []
|
||
for ln in src.split('\n'):
|
||
s = ln.strip()
|
||
if s.startswith('#'):
|
||
continue
|
||
# 去掉行尾注释(粗略处理:不处理字符串里的 # )
|
||
i = ln.find(' #')
|
||
if i > 0:
|
||
ln = ln[:i]
|
||
out.append(ln)
|
||
return '\n'.join(out)
|
||
|
||
|
||
def strip_docstrings(src):
|
||
"""去掉 Python 的 docstring(模块级、类级、函数级都去)。
|
||
|
||
⚠️ 为什么必须有这一步:修 bug 时我们会在 docstring 里**引用
|
||
原来的错误文案**来说明「为什么当初错了」,比如
|
||
`_vpn_env()` 的注释里就写着「通常说明跑在精简容器里」这句
|
||
被我们删掉的旧文案。断言若直接对源码做子串匹配,会把这段
|
||
说明当成「代码里还有错文案」而报假红灯 —— 最坏的结果是
|
||
为了让测试变绿,把解释 bug 来历的注释也删掉。
|
||
|
||
同理 `code_only()` 也不能替代它:code_only 逐行找行尾' #'
|
||
来砍行尾注释,遇到 docstring 里以两个空格 + # 开头的行会把
|
||
字符串截断,喂给 ast.parse 直接报 unterminated string。
|
||
所以两个是正交的:先剥 docstring,再谈其他。
|
||
|
||
⚠️⚠️ 实现踩过两个坑,都写在这儿免得下次再踩:
|
||
① **别用「前一个 token 是 INDENT/NL/NEWLINE」判断是不是 docstring**。
|
||
我第一版就是这么写的,结果把 return {...} 里的'virt': virt
|
||
这类**普通键值**也剥了(紧跟 INDENT 的字符串在词法上完全合法)
|
||
—— 一夜之间 6 条断言变红,全是假红。ast 懂语义,tokenize 不懂。
|
||
② **别用 tokenize.untokenize() 重排**。被剥掉的 docstring 位置
|
||
会被填成 '\n' 续行,把行结构搞乱:原来那几条
|
||
`if ...:\n state = 'ready'` 的跨行正则全部失配,
|
||
而它们恰恰是钉死「四态判定顺序」的核心判据。
|
||
所以:**先剥,再按行删**,不重排、不填充。
|
||
"""
|
||
import ast as _ast
|
||
try:
|
||
tree = _ast.parse(src)
|
||
except SyntaxError:
|
||
return src
|
||
doc_pos = set()
|
||
|
||
def _add(node):
|
||
if (node is not None and isinstance(node, _ast.Expr)
|
||
and isinstance(node.value, _ast.Constant)
|
||
and isinstance(node.value.value, str)):
|
||
doc_pos.add(node.value.lineno)
|
||
_add(tree)
|
||
for n in _ast.walk(tree):
|
||
if isinstance(n, (_ast.FunctionDef, _ast.AsyncFunctionDef,
|
||
_ast.ClassDef, _ast.Module)):
|
||
if n.body:
|
||
_add(n.body[0])
|
||
if not doc_pos:
|
||
return src
|
||
# ast 给出的是 docstring **第一行**;多行 docstring 要删到结束行。
|
||
# 直接用 ast 节点的 end_lineno 定位,跨行也对得上。
|
||
spans = set()
|
||
for n in _ast.walk(tree):
|
||
if isinstance(n, _ast.Expr) and isinstance(n.value, _ast.Constant) \
|
||
and isinstance(n.value.value, str) and n.lineno in doc_pos:
|
||
spans.add((n.lineno, getattr(n, 'end_lineno', n.lineno)))
|
||
if not spans:
|
||
return src
|
||
out = []
|
||
for i, ln in enumerate(src.split('\n'), start=1):
|
||
if any(a <= i <= b for a, b in spans):
|
||
continue
|
||
out.append(ln)
|
||
return '\n'.join(out)
|
||
|
||
|
||
def py_code_only(src):
|
||
"""源码去docstring + 去注释,只留真正会被执行的代码。"""
|
||
return code_only(strip_docstrings(src))
|
||
|
||
|
||
def js_code_only(src):
|
||
"""JS 侧去注释(// 与 /* */),只留真正会被执行的代码。
|
||
|
||
前端改文案时同样会在注释里引用旧文案(「那句是猜的」)。
|
||
不剥注释就会把说明当成残留代码。
|
||
|
||
⚠️ 这里**必须**用 _dev/jsstrip.py 的词法扫描版,不能用
|
||
`re.sub(r'/\\*.*?\\*/', '', src, flags=re.S)`。
|
||
那个写法在本仓 app.js 上会吃掉三千多行真代码:app.js 8222 行附近
|
||
有一句讲「怎么防止 CSS 注释被提前闭合」的**行注释**,注释里写了
|
||
`/* */` 这两个字面量 —— 先剥块注释时它还在原文里,于是被当成真
|
||
注释开头,一路吃到几千行之后。
|
||
证据:app.js 里 `/*` 169 次而 `*/` 170 次(HEAD 版 163 / 164),
|
||
数量不等 = 配对错位一格,而错位会传播到文件末尾。判据读的是一份
|
||
被掏空的文件,绿灯全是假的。
|
||
详见 _dev/jsstrip.py 的模块 docstring。
|
||
"""
|
||
from jsstrip import js_code_only as _real
|
||
return _real(src)
|
||
|
||
|
||
def py_func_src(name, src=HELPER):
|
||
"""用 ast 精确取一个模块级函数的源码段。"""
|
||
tree = ast.parse(src)
|
||
for node in tree.body:
|
||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||
return ast.get_source_segment(src, node) or ''
|
||
return ''
|
||
|
||
|
||
def returned_keys(fname):
|
||
"""抽函数里所有「返回载荷」的键。
|
||
|
||
要同时覆盖两种返回形式,只扫 ok({...}) 会漏:
|
||
ok({...}) —— 成功
|
||
fail(msg, code, {...}) —— 失败但**带数据**(比如启动失败要回传
|
||
journal 日志给界面)。漏掉这一种会
|
||
把「前端读了 r.data.journal」误判成
|
||
「helper 从没返回过 journal」。
|
||
"""
|
||
body = py_func_src(fname)
|
||
keys = set()
|
||
for pat in (r'ok\(\{', r"fail\(\s*[^,()]+,\s*'[^']*',\s*\{",
|
||
r'fail\(\s*[^,()]+,\s*code=[\'"][^\'"]*[\'"]\s*,\s*\{'):
|
||
for m in re.finditer(pat, body):
|
||
i = m.end() - 1
|
||
depth = 0
|
||
blob = ''
|
||
for j in range(i, min(len(body), i + 60000)):
|
||
if body[j] == '{':
|
||
depth += 1
|
||
elif body[j] == '}':
|
||
depth -= 1
|
||
if depth == 0:
|
||
blob = body[i + 1:j]
|
||
break
|
||
if not blob:
|
||
continue
|
||
for km in re.finditer(r"['\"]([a-z_][a-z0-9_]*)['\"]\s*:", blob):
|
||
keys.add(km.group(1))
|
||
return keys
|
||
|
||
|
||
# 每个前端页面对应哪些 helper 状态函数
|
||
PAGE_FN = {
|
||
'backup': ['_bk_status', '_bk_list_entries', '_bk_create', '_bk_inspect',
|
||
'_bk_restore', '_bk_prune', '_bk_conf'],
|
||
'alert': ['_al_status', '_al_run', '_al_test', '_al_conf_op', '_al_clear',
|
||
'_al_hist_read', '_al_services'],
|
||
'vpn': ['_vpn_status', '_vpn_save_op', '_vpn_apply_op', '_vpn_peer_add',
|
||
'_vpn_peer_del', '_vpn_peer_toggle', '_vpn_peer_conf',
|
||
'_vpn_endpoint', '_vpn_delete_all'],
|
||
'quota': ['_qt_status', '_qt_report', '_qt_conf', '_qt_reset', '_qt_device',
|
||
'_qt_aggregate'],
|
||
}
|
||
ALLOWED = {'ok', 'msg_cn', 'err', 'code', 'code_cn'} # 统一响应信封
|
||
|
||
for mod, fns in PAGE_FN.items():
|
||
be = set()
|
||
for f in fns:
|
||
be |= returned_keys(f)
|
||
be |= ALLOWED
|
||
pref = {'backup': 'bk', 'alert': 'al', 'vpn': 'vpn', 'quota': 'qt'}[mod]
|
||
fe = set()
|
||
for f, b in BODIES.items():
|
||
if not f.startswith(pref):
|
||
continue
|
||
for c in re.findall(
|
||
r'\b(?:d|dd|res|r|dt|dat|o|out)\.data((?:\.[A-Za-z_]\w*)+)', b):
|
||
parts = re.findall(r'\.([A-Za-z_]\w*)', c)
|
||
if parts:
|
||
fe.add(parts[0])
|
||
# 只报「前端出现、helper 的返回载荷里连一次都没有」的键 ——
|
||
# 那才是真的契约断裂。缺字段在前端不会报错(普遍写 r.data || {}),
|
||
# 只会静默渲染成空,所以这类断裂特别难靠肉眼发现。
|
||
miss = sorted(x for x in fe if x not in be)
|
||
chk('[%s] 前端读取的一级字段 helper 都有返回' % mod, not miss,
|
||
'helper 从未返回过: %s\n helper 实际返回: %s'
|
||
% (', '.join(miss), ', '.join(sorted(be))))
|
||
|
||
# A4. 反向:helper 返回了但前端从不读的顶层键(列出来人工判断)
|
||
print()
|
||
print('--- A2. helper 返回但前端未直接读取的键(供人工判断)---')
|
||
for mod, fns in PAGE_FN.items():
|
||
be = set()
|
||
for f in fns:
|
||
be |= returned_keys(f)
|
||
pref = {'backup': 'bk', 'alert': 'al', 'vpn': 'vpn', 'quota': 'qt'}[mod]
|
||
used = set()
|
||
for f, b in BODIES.items():
|
||
if f.startswith(pref):
|
||
for c in re.findall(
|
||
r'\b(?:d|dd|res|r|dt|dat|o|out)\.data((?:\.[A-Za-z_]\w*)+)', b):
|
||
parts = re.findall(r'\.([A-Za-z_]\w*)', c)
|
||
if parts:
|
||
used.add(parts[0])
|
||
for c in re.findall(r'\b([a-z]{1,3})\.([a-z_][a-z0-9_]*)\b', b):
|
||
used.add(c[1])
|
||
un = sorted(be - used - ALLOWED)
|
||
if un:
|
||
print(' [%s] %s' % (mod, ', '.join(un)))
|
||
print(' (这些多半是给子对象用的中间字段,不算问题)')
|
||
|
||
# ==================================================================
|
||
# B. op 名三段对齐
|
||
# ==================================================================
|
||
print()
|
||
print('=' * 68)
|
||
print('B. op 名三段对齐:前端 → Web 层 → helper')
|
||
print('=' * 68)
|
||
|
||
# B1. 前端传的 op
|
||
FE_OPS = {}
|
||
for m in re.finditer(r"""\bapi\(\s*(['"`])(/api/(\w+)[^'"`]*)\1\s*,\s*\{(.*?)\}\s*\)""",
|
||
APP, re.S):
|
||
path, mod, blob = m.group(2), m.group(3), m.group(4)
|
||
if mod not in ('backup', 'alert', 'vpn', 'quota'):
|
||
continue
|
||
om = re.search(r"""op\s*:\s*['"]([a-z_]+)['"]""", blob)
|
||
if om:
|
||
FE_OPS.setdefault(mod, {})[om.group(1)] = m.start()
|
||
# restore 的三步是数组形式,单独抓
|
||
for mod in ('backup', 'alert', 'vpn', 'quota'):
|
||
blk = ''
|
||
for f in NEW_FUNCS:
|
||
if f.startswith({'backup': 'bk', 'alert': 'al',
|
||
'vpn': 'vpn', 'quota': 'qt'}[mod]):
|
||
blk += BODIES[f]
|
||
for om in re.finditer(r"""op\s*:\s*['"]([a-z_]+)['"]""", blk):
|
||
FE_OPS.setdefault(mod, {}).setdefault(om.group(1), 0)
|
||
|
||
# B2. helper 里的 op 分支
|
||
HELPER_OPS = {}
|
||
for mod, act in (('backup', 'act_backup'), ('alert', 'act_alert'),
|
||
('vpn', 'act_vpn'), ('quota', 'act_quota')):
|
||
body = py_func_src(act)
|
||
HELPER_OPS[mod] = set(re.findall(r"op == '([a-z_]+)'", body))
|
||
|
||
# B3. Web 层显式处理的 op
|
||
WEB_OPS = {}
|
||
for mod, meth in (('backup', 'def backup(self'), ('alert', 'def alert(self'),
|
||
('vpn', 'def vpn(self'), ('quota', 'def quota(self')):
|
||
i = WEB.index(meth)
|
||
j = WEB.index('\n def ', i + 10)
|
||
seg = WEB[i:j]
|
||
WEB_OPS[mod] = set(re.findall(r"op == '([a-z_]+)'", seg)) | \
|
||
set(re.findall(r"op in \(([^)]*)\)", seg) and
|
||
re.findall(r"'([a-z_]+)'", re.findall(r"op in \(([^)]*)\)", seg)[0]))
|
||
|
||
print('前端→helper op 差异:')
|
||
bad_op = []
|
||
for mod in ('backup', 'alert', 'vpn', 'quota'):
|
||
fe = set(FE_OPS.get(mod, {}))
|
||
he = HELPER_OPS.get(mod, set())
|
||
only_fe = sorted(fe - he)
|
||
only_he = sorted(he - fe - {'status'})
|
||
if only_fe:
|
||
bad_op.append('[%s] 前端传了 helper 不认的 op: %s' % (mod, ', '.join(only_fe)))
|
||
print(' [%-6s] 前端 %d 个 / helper %d 个;helper 有但前端不调: %s'
|
||
% (mod, len(fe), len(he), ', '.join(only_he) or '无'))
|
||
chk('前端传的 op helper 都有分支', not bad_op, '\n '.join(bad_op))
|
||
|
||
# B4. Web 层:写操作的 op 必须原样透传到 helper。
|
||
# 不能只搜 `op=op` 一种写法 —— backup handler 走的是
|
||
# `payload = dict(b); payload['op'] = op` 再传 payload,
|
||
# vpn/quota 才是 dict(b, op=op)。两种都认。
|
||
for mod, meth in (('backup', 'def backup(self'), ('alert', 'def alert(self'),
|
||
('vpn', 'def vpn(self'), ('quota', 'def quota(self')):
|
||
i = WEB.index(meth)
|
||
j = WEB.index('\n def ', i + 10)
|
||
seg = WEB[i:j]
|
||
chk('[%s] Web 层 handler 有 auth 校验' % mod, 'self.auth()' in seg)
|
||
passthru = bool(
|
||
re.search(r"helper\('%s',\s*dict\(b,\s*op=op\)" % mod, seg)
|
||
or re.search(r"helper\('%s',\s*payload" % mod, seg)
|
||
or re.search(r"helper\('%s',\s*\{\s*'op':\s*'status'" % mod, seg))
|
||
chk('[%s] Web 层把 op 透传给 helper(未被提前 return吞掉)' % mod,
|
||
passthru)
|
||
|
||
# ==================================================================
|
||
# C. 高危路径纯逻辑测试
|
||
# ==================================================================
|
||
print()
|
||
print('=' * 68)
|
||
print('C. 高危路径纯逻辑测试(替身注入,不碰真机)')
|
||
print('=' * 68)
|
||
|
||
# --- C1. 备份:路径穿越 / tar 炸弹 / 敏感文件 ---
|
||
print()
|
||
print('--- C1. 备份:包安全三道防线 ---')
|
||
|
||
helper_mod = ast.parse(HELPER) # 语法先过一遍
|
||
chk('helper 语法可解析', True)
|
||
|
||
|
||
def _called_names(fn):
|
||
"""一个函数体里调用到的所有名字。"""
|
||
out = set()
|
||
for node in ast.walk(fn):
|
||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||
out.add(node.func.id)
|
||
return out
|
||
|
||
|
||
def pick_func(name, inject=None, mod_src=None, with_deps=False):
|
||
"""抽函数源码 exec 到一个带替身的命名空间里。
|
||
|
||
只抽函数本身(含它调的模块级常量),**不抽常量赋值节点** ——
|
||
否则会把注入的替身覆盖掉(这个坑踩过)。
|
||
|
||
with_deps=True 时沿调用关系把同模块的其它私有函数也带上,
|
||
返回整个命名空间字典 —— 这样能真跑一遍完整逻辑,
|
||
而不是只能对着单个函数的源码文本做正则断言。
|
||
"""
|
||
src = mod_src or HELPER
|
||
tree = ast.parse(src)
|
||
fns = {n.name: n for n in tree.body
|
||
if isinstance(n, ast.FunctionDef)}
|
||
if name not in fns:
|
||
raise KeyError(name)
|
||
keep = [fns[name]]
|
||
consts = []
|
||
if with_deps:
|
||
seen = {name}
|
||
queue = [name]
|
||
while queue:
|
||
cur = queue.pop()
|
||
for called in _called_names(fns[cur]):
|
||
if called in fns and called not in seen:
|
||
seen.add(called)
|
||
keep.append(fns[called])
|
||
queue.append(called)
|
||
ns = {
|
||
'os': os, 're': re, 'json': json, 'sys': sys, 'io': io,
|
||
'ast': ast, 'subprocess': subprocess, 'tempfile': tempfile,
|
||
'hashlib': hashlib, 'tarfile': tarfile, 'shutil': shutil,
|
||
'ipaddress': ipaddress, 'threading': threading, 'time': time,
|
||
'base64': base64, 'datetime': datetime_module,
|
||
'socket': socket, 'smtplib': smtplib, 'ssl': ssl,
|
||
# helper 里的函数会用 __file__ 定位自己的安装目录;
|
||
# 抽出来单独 exec 时没有它,得给个假路径。
|
||
'__file__': os.path.join(ROOT, 'backend', 'drouter-helper.py'),
|
||
'__name__': 'drouter_helper',
|
||
}
|
||
# 依赖的模块级常量(名字全大写)也带上。递归进 AnnAssign,
|
||
# 否则像 ALERT_RULES 这种清单会漏掉。
|
||
#
|
||
# 两类必须排除,否则 exec 出来的命名空间根本建不起来:
|
||
# __file__ 等 dunder —— 直接 NameError;
|
||
# ACTIONS —— 它引用了全部 read_* 函数,把整个 helper 都拖进来。
|
||
# 这两种报错的堆栈完全指不到被测函数,很容易误判成
|
||
# 「被测函数有问题」,实际上只是抽函数的方式太粗。
|
||
SKIP_CONST = {'ACTIONS'}
|
||
for node in tree.body:
|
||
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||
continue
|
||
tgts = ([node.target] if isinstance(node, ast.AnnAssign)
|
||
else node.targets)
|
||
names = set()
|
||
for tg in tgts:
|
||
if isinstance(tg, ast.Name):
|
||
names.add(tg.id)
|
||
if not names or not all(
|
||
n.isupper() and not n.startswith('_') for n in names):
|
||
continue
|
||
if names & SKIP_CONST:
|
||
continue
|
||
consts.append(node)
|
||
# 顺序很关键:**常量先 exec,注入后 exec**。
|
||
# helper 里有同名常量被定义两次的情况(ULOG_ARCHIVE 在
|
||
# 10005 行和 13155 行各定义一次,后者覆盖前者)。如果反过来
|
||
# 先注入再被常量表覆盖,测试会安静地去读真实路径
|
||
# /var/log/drouter/ulog.jsonl —— 在 Windows 上不存在,
|
||
# 于是「读到 0 条记录」,看起来像产品 bug,其实是脚手架错了。
|
||
mod = ast.Module(body=keep + consts, type_ignores=[])
|
||
ast.fix_missing_locations(mod)
|
||
code = compile(mod, '<pick>', 'exec')
|
||
exec(code, ns)
|
||
if inject:
|
||
ns.update(inject)
|
||
if with_deps:
|
||
return ns
|
||
return ns[name]
|
||
|
||
|
||
# C1a. _bk_path_of 是包路径的唯一入口校验点。
|
||
# 实现是「严格文件名正则 + commonpath 双重保险」,比单纯的
|
||
# BK_ALLOW_PREFIX 前缀白名单更严。写测试时我先按「前缀白名单」的
|
||
# 思路造用例,结果 4 条合法路径全被判「拦截」—— 那是测试写错了,
|
||
# 不是实现有问题。契约是:只有 drouter-backup-<8位>-<6位>[-主机名].tar.gz
|
||
# 才放行。
|
||
try:
|
||
_safe = pick_func('_bk_safe_name')
|
||
_bk_path_of = pick_func('_bk_path_of', inject={
|
||
'_bk_safe_name': _safe,
|
||
'backup_dir': lambda: '/opt/drouter/backups'})
|
||
OK_NAMES = ['drouter-backup-20261002-120000.tar.gz',
|
||
'drouter-backup-20261002-120000-debian.tar.gz',
|
||
# ⚠️ 下面这两个是**真机踩出来的**(10-03):Debian 默认主机名是
|
||
# debian-primaryrouter,带连字符;命名规则里 charset 是
|
||
# [A-Za-z0-9_-],而校验正则当时只写 [a-z0-9] → 导出的包
|
||
# 自己过不了校验,**在列表里看不到、永远还原不了**。
|
||
# 我原来的用例只有 'debian'(无连字符),所以绿灯放过了这个坑。
|
||
# 教训:**用例要照真实世界的值造**,凭自己想当然的最简单值
|
||
# 恰恰是最容易漏掉真实形态的那种。
|
||
'drouter-backup-20261003-012754-debian-primaryrouter.tar.gz',
|
||
'drouter-backup-20261003-012754-my_router.tar.gz']
|
||
BAD_NAMES = ['../../etc/shadow', '/etc/shadow', 'a/b.tar.gz',
|
||
'..', 'a\\b', 'drouter-backup-20261002-120000.tar.gz.part',
|
||
'drouter-backup-x.tar.gz', '', ' ',
|
||
'drouter-backup-20261002-120000.tar.gz;rm -rf /',
|
||
# 主机名段超长(命名规则截 24)+ 路径穿越型变体
|
||
'drouter-backup-20261002-120000-' + 'a' * 40 + '.tar.gz',
|
||
'../../drouter-backup-20261002-120000-x.tar.gz',
|
||
'drouter-backup-20261002-120000-a/b.tar.gz']
|
||
bad = []
|
||
for n in OK_NAMES:
|
||
if not _bk_path_of(n):
|
||
bad.append('%r 合法名被误拒' % n)
|
||
for n in BAD_NAMES:
|
||
try:
|
||
r = _bk_path_of(n)
|
||
except Exception:
|
||
r = None
|
||
if r:
|
||
bad.append('%r 非法名居然放行了 -> %s' % (n, r))
|
||
chk('备份包路径校验:只放行严格命名,拦掉 ../ 与绝对路径', not bad,
|
||
'\n '.join(bad))
|
||
# commonpath 复核:即使 safe_name 以后被改坏,commonpath 也要兜住
|
||
_loose = pick_func('_bk_path_of', inject={
|
||
'_bk_safe_name': lambda n: n, # 故意去掉正则
|
||
'backup_dir': lambda: '/opt/drouter/backups'})
|
||
escaped = []
|
||
for n in ('../backups/../../etc/shadow', '/etc/shadow',
|
||
'../../../../tmp/x.tar.gz'):
|
||
try:
|
||
if _loose(n):
|
||
escaped.append(n)
|
||
except Exception:
|
||
pass
|
||
chk('commonpath 兜底仍在(即使文件名校验被改坏也不逃出备份目录)',
|
||
not escaped, '逃逸了: %s' % escaped)
|
||
|
||
# 校验正则与命名规则必须**同源**。上面的用例只能覆盖我想到的名字;
|
||
# 真正防住这类 bug 的是这条:拿命名那行的 charset 去生成一个名字,
|
||
# 要求校验必须放行。任何一边改了 charset,另一边不改就会红。
|
||
try:
|
||
# ⚠️ 必须连 docstring 一起剥。code_only() 只去 # 注释,
|
||
# 而 _bk_safe_name 的 docstring 里**正文明写着一份旧正则**
|
||
# (举例子用的),不剥就会抽到那一份 → 断言永远红,
|
||
# 或者更糟:抽到旧的还「通过」,等于什么都没验。
|
||
_src = re.sub(r'""".*?"""', '', code_only(HELPER), flags=re.S)
|
||
m_pack = re.search(
|
||
r"name\s*=\s*'drouter-backup-%s-%s\.tar\.gz'", _src)
|
||
m_host = re.search(
|
||
r"host\s*=\s*re\.sub\(r'\[\^([A-Za-z0-9_-]+)\]'", _src)
|
||
m_len = re.search(r"host\s*=\s*host\[:(\d+)\]", _src)
|
||
m_safe = re.search(
|
||
r"r'\^drouter-backup-\\d\{8\}-\\d\{6\}\(\?:-"
|
||
r"\[([a-z0-9_.-]+)\]\{(\d+),(\d+)\}\)\?\\\.tar\\\.gz\$'", _src)
|
||
detail = []
|
||
if not (m_pack and m_host and m_len and m_safe):
|
||
detail.append('抽不到命名/校验规则(代码结构变了?)'
|
||
' pack=%s host=%s len=%s safe=%s'
|
||
% (bool(m_pack), bool(m_host), bool(m_len),
|
||
bool(m_safe)))
|
||
else:
|
||
pack_charset = m_host.group(1)
|
||
safe_charset = m_safe.group(1)
|
||
pack_len = int(m_len.group(1))
|
||
# ⚠️ 校验里是 {下限,上限} 两个数字:**上限**才是长度上限。
|
||
# 我第一版取了 group(2)(=1),于是「命名截 24 字符,校验只允许 1」
|
||
# 一片红 —— 断言自己算错,比没有断言更误导人。
|
||
# 教训:正则多分组时,先在注释里写清每个 group 是哪一段。
|
||
safe_min = int(m_safe.group(2))
|
||
safe_len = int(m_safe.group(3))
|
||
# 正则里写成 [a-z0-9_-] 而命名允许 [A-Za-z0-9_-] 时,
|
||
# 大写与连字符会漏。逐字符比较(大小写不敏感,
|
||
# 因为命名那行末尾 .lower() 了)。
|
||
miss = set(pack_charset.lower()) - set(safe_charset.lower())
|
||
if miss:
|
||
detail.append('命名允许 %s 但校验不认:%s'
|
||
% (pack_charset, ''.join(sorted(miss))))
|
||
if pack_len > safe_len:
|
||
detail.append('命名截 %d 字符,校验只允许 %d'
|
||
% (pack_len, safe_len))
|
||
# 下限也照看一眼:命名里 host 最少 1 个字符(空值会被 or 'router'
|
||
# 兜掉),所以校验下限必须 ≤1,否则「无主机名段」的老包
|
||
# 反而过不了 —— 和本轮那个 bug 是同一族的毛病。
|
||
if safe_min > 1:
|
||
detail.append('校验下限 {1,%d} >1,命名能生成更短的主机名段'
|
||
% safe_len)
|
||
chk('备份包名校验规则与命名规则同源(charset + 长度)', not detail,
|
||
';'.join(detail))
|
||
except Exception as e:
|
||
chk('备份包名校验规则与命名规则同源(charset + 长度)', False,
|
||
'抽取失败: %s' % e)
|
||
|
||
# ★ 这条是上一条的**根因防线**,也是本轮 10-03 踩得最贵的一课。
|
||
# 「导出的包在列表里看不到」这个 bug 我修了 _bk_safe_name 却漏了
|
||
# _bk_list_entries —— 因为那份正则**被抄了两份**,而我的测试只查
|
||
# 了其中一份,于是「单测全绿 + 端到端仍坏」。
|
||
#
|
||
# 规则:**备份包名那份正则字面,helper 里只允许存在一份**。
|
||
# 第二处必须是调用 _bk_safe_name(),不是抄一份。
|
||
# (连 docstring 一起数,所以要先剥 —— 见上面同一段 _src。)
|
||
try:
|
||
_dup = []
|
||
for _pat in (r'\^drouter-backup-\\d\{8\}', # 校验正则行首
|
||
r"drouter-backup-%s-%s\.tar\.gz"): # 命名格式串
|
||
_hits = [m.start() for m in re.finditer(_pat, _src)]
|
||
if len(_hits) != 1:
|
||
_dup.append('%s 出现 %d 次(应恰好 1 次)'
|
||
% (_pat.replace('\\', ''), len(_hits)))
|
||
# 顺手钉住「_bk_list_entries 必须调 _bk_safe_name」
|
||
# ⚠️ 这里不能用 code_only() 再喂给 ast.parse:code_only 逐行找
|
||
# 行尾 ' #',docstring 里只要有一行以两个空格 + # 开头,
|
||
# 整个字符串就被截断 → 报 unterminated string literal。
|
||
# 先用**原始**源码让 ast 切出函数,再对结果剥 docstring。
|
||
_lst = re.sub(r'""".*?"""', '',
|
||
py_func_src('_bk_list_entries'), flags=re.S)
|
||
if '_bk_safe_name' not in _lst:
|
||
_dup.append('_bk_list_entries 没调 _bk_safe_name(又抄了一份?)')
|
||
chk('备份包名正则在helper 里只有一份(列表不再自抄)', not _dup,
|
||
';'.join(_dup))
|
||
except Exception as e:
|
||
chk('备份包名正则在helper 里只有一份(列表不再自抄)', False,
|
||
'检查失败: %s' % e)
|
||
except Exception:
|
||
import traceback
|
||
chk('备份包路径校验能真跑起来', False, traceback.format_exc()[-600:])
|
||
except Exception as e:
|
||
chk('备份包名拒绝路径分隔符', False, repr(e))
|
||
|
||
# C1c. tar 炸弹:_bk_open_manifest 必须有体积/文件数上限
|
||
bk_open = py_func_src('_bk_open_manifest')
|
||
chk('备份还原有解包体积上限(BK_MAX_UNPACK)',
|
||
'BK_MAX_UNPACK' in bk_open and 'getmembers' in bk_open,
|
||
'必须在 getmembers() 遍历时就累加 size,不能先解包再判断')
|
||
chk('备份还原有文件数上限(BK_MAX_FILES)', 'BK_MAX_FILES' in bk_open)
|
||
chk('备份还原检查包格式版本', 'format' in bk_open and 'BACKUP_FORMAT' in bk_open,
|
||
'未来格式的包必须拒绝而不是硬解')
|
||
|
||
# C1d. 敏感文件默认排除 —— 这里做真正的端到端验证,
|
||
# 因为这条是本轮修掉的**最严重缺陷**:早先的 _bk_files 把整个
|
||
# /etc/drouter 标成 sensitive,于是默认导出的包连 dcfg.json、
|
||
# kern.conf、generated/vlans.json、Docker 栈文件、自定义主题都没有,
|
||
# 而 BACKUP_SCOPE 恰恰宣称这些都在范围内。静态断言看不出来,
|
||
# 必须真的造一个目录树跑一遍。
|
||
print()
|
||
print('--- C1d. 敏感排除:端到端(造真目录树跑 _bk_files + _bk_walk)---')
|
||
tmp = tempfile.mkdtemp(prefix='t107bk-')
|
||
FAKE_ETC = os.path.join(tmp, 'etc', 'drouter')
|
||
os.makedirs(os.path.join(FAKE_ETC, 'generated'))
|
||
os.makedirs(os.path.join(FAKE_ETC, 'ca'))
|
||
os.makedirs(os.path.join(FAKE_ETC, 'docker'))
|
||
os.makedirs(os.path.join(tmp, 'etc', 'ppp'))
|
||
for rel in ('dcfg.json', 'kern.conf', 'opensoho.admin'):
|
||
io.open(os.path.join(FAKE_ETC, rel), 'w', encoding='utf-8').write('x')
|
||
for rel in ('vlans.json', 'vpn.json', 'qos.json'):
|
||
io.open(os.path.join(FAKE_ETC, 'generated', rel), 'w',
|
||
encoding='utf-8').write('x')
|
||
io.open(os.path.join(FAKE_ETC, 'ca', 'ca.key'), 'w', encoding='utf-8').write('SECRET')
|
||
io.open(os.path.join(FAKE_ETC, 'docker', 'stacks.yml'), 'w',
|
||
encoding='utf-8').write('x')
|
||
io.open(os.path.join(tmp, 'etc', 'ppp', 'chap-secrets'), 'w',
|
||
encoding='utf-8').write('PASS')
|
||
|
||
# 把 BK_SENSITIVE 指到临时目录,才能在 Windows 上真跑一遍
|
||
SENS_PATHS = [os.path.join(tmp, 'etc', 'drouter', 'ca'),
|
||
os.path.join(tmp, 'etc', 'ppp', 'chap-secrets')]
|
||
|
||
|
||
def _sens_local(path):
|
||
p = os.path.normpath(str(path or ''))
|
||
for s in SENS_PATHS:
|
||
s = os.path.normpath(s)
|
||
if p == s or p.startswith(s.rstrip('/') + os.sep):
|
||
return True
|
||
return False
|
||
|
||
|
||
walk = pick_func('_bk_walk', inject={'_bk_is_sensitive': _sens_local,
|
||
'BK_SENSITIVE': SENS_PATHS})
|
||
|
||
got = walk(FAKE_ETC, include_sensitive=False, skipped=[])
|
||
paths = sorted(os.path.relpath(f, tmp).replace('\\', '/')
|
||
for f, _r, _s in got)
|
||
MUST_IN = ['etc/drouter/dcfg.json', 'etc/drouter/kern.conf',
|
||
'etc/drouter/generated/vlans.json',
|
||
'etc/drouter/generated/vpn.json',
|
||
'etc/drouter/docker/stacks.yml']
|
||
missing_cfg = [m for m in MUST_IN if m not in paths]
|
||
chk('默认导出包含 /etc/drouter 下的全部配置(不因含 ca/ 而整目录排除)',
|
||
not missing_cfg, '这些本该在包里却没了: %s' % ', '.join(missing_cfg))
|
||
chk('默认导出排除 CA 私钥',
|
||
'etc/drouter/ca/ca.key' not in paths)
|
||
sk = []
|
||
walk(FAKE_ETC, include_sensitive=False, skipped=sk)
|
||
chk('skipped 里明确记下了被排除的敏感路径',
|
||
any('ca' in e['path'] for e in sk), str(sk))
|
||
got_all = walk(FAKE_ETC, include_sensitive=True, skipped=[])
|
||
chk('勾选敏感后 CA 私钥会被包含',
|
||
any('ca.key' in f for f, _r, _s in got_all))
|
||
# 敏感标记要跟着文件走,不能只跟着目录
|
||
sensitive_flags = {os.path.basename(f): s for f, _r, s in got_all}
|
||
chk('每个条目带自己的 sensitive 标记(界面据此打「敏感」标签)',
|
||
sensitive_flags.get('ca.key') is True
|
||
and sensitive_flags.get('dcfg.json') is False)
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
bks = py_func_src('_bk_files')
|
||
chk('目录级不再一刀切标敏感(否则整目录被排除)',
|
||
"sensitive=(d == '/etc/drouter')" not in code_only(bks),
|
||
'_bk_files 的代码里仍有目录级 sensitive 标记')
|
||
chk('敏感判定改成逐文件前缀比较(_bk_is_sensitive)',
|
||
'def _bk_is_sensitive' in HELPER)
|
||
chk('敏感清单覆盖私钥类文件',
|
||
all(p in HELPER for p in ('/etc/wireguard', '/etc/ppp/chap-secrets')))
|
||
|
||
# C1e. 还原三道保险
|
||
bk_res = py_func_src('_bk_restore')
|
||
chk('还原默认跳过本机身份文件(/etc/hostname、/etc/fstab)',
|
||
'/etc/hostname' in bk_res and '/etc/fstab' in bk_res)
|
||
chk('还原写前留 .drouter-restore-bak', '.drouter-restore-bak' in bk_res)
|
||
chk('还原默认先校验 sha256(verify)', 'verify' in bk_res)
|
||
|
||
# C1f. inspect 的 sha256 比对 —— 本轮 10-03 真机抓到的第 9 个真缺陷。
|
||
# _bk_inspect 把 manifest 条目重建成 row(给前端用),却**没把 sha256
|
||
# 搬进来**,下面又拿 row.get('sha256') 去比对实际哈希:
|
||
# 实际哈希 != None → 每个文件都判成「已损坏」
|
||
# 表现是刚导出的新包,22 项全部标红,还原被「内容与清单不符」挡死。
|
||
#
|
||
# 判据不能只写 'sha256' in _bk_inspect(源码里明明有这个字符串,
|
||
# 就是在**别的**行用的),必须钉住「比对用的是同一个来源」:
|
||
# - 比对侧必须出现 row/变量.get('sha256') 之外的 it.get('sha256'),或
|
||
# - 重建 row 时必须搬 sha256
|
||
# 这里直接查源码结构 + 真跑一遍(有真 tar 才跑得动,缺 tar 就 SKIP)。
|
||
_insp = py_func_src('_bk_inspect')
|
||
chk('inspect 把 manifest 的 sha256 带进了 row(否则全部误判已损坏)',
|
||
re.search(r"'sha256'\s*:\s*it\.get\('sha256'", _insp) is not None,
|
||
'重建 row 时没有 it.get("sha256") —— 比对会拿 None 比,'
|
||
'每个文件都会被标成「已损坏」')
|
||
chk('inspect 校验的是 row 里带来的 sha256(不是临时再取一次)',
|
||
re.search(r"h\s*!=\s*row\.get\('sha256'\)", _insp) is not None
|
||
and re.search(r"'sha256'\s*:\s*it\.get\('sha256'", _insp) is not None,
|
||
'比对侧与构造侧必须成对出现,缺一个就是全部误判')
|
||
#还原侧用 it.get('sha256') 是**对的**(它没重建成row),
|
||
# 但要钉住它别被一起改成 row.get() —— 那会把还原的校验也一起废掉。
|
||
chk('还原侧比对的是 it.get(sha256)(它没重建 row,别跟着改坏)',
|
||
re.search(r"h\s*!=\s*it\.get\('sha256'\)", bk_res) is not None)
|
||
|
||
# C1g. 包内同一文件不能进两次 —— 本轮 10-03 真机看到的第 10 个缺陷。
|
||
# 根因是**清单本身就重叠**:
|
||
# SNAPSHOT_ETC_FILES 里有 /etc/drouter/rescue.conf
|
||
# SNAPSHOT_ETC_DIRS 里有 /etc/drouter(整目录,展开后也含它)
|
||
# _bk_files() 只对根路径去重,展开后的文件跨组不去重 →
|
||
# 同一个 arcname 写两遍 tar(后者覆盖前者,不报错),
|
||
# 表现是 file_count / total_bytes 虚高一倍、inspect 里同一文件两行。
|
||
# 修法是 _bk_create 里按 arcname 记 seen_arc。
|
||
_bkc = py_func_src('_bk_create')
|
||
chk('打包时按 arcname 跨组去重(同一文件不会进包两次)',
|
||
'seen_arc = set()' in _bkc
|
||
and re.search(r'if\s+rel\s+in\s+seen_arc:\s*\n\s*continue', _bkc)
|
||
is not None,
|
||
'SNAPSHOT_ETC_FILES 与 SNAPSHOT_ETC_DIRS 重叠(/etc/drouter/rescue.conf '
|
||
'两边都有),只在 _bk_files() 里去重根路径不够,必须在展开后去重')
|
||
# 清单重叠是**允许**的(去重在 create 里做),但要钉住两份清单确实重叠,
|
||
# 免得后人以为不重叠而删掉 create 里的 seen_arc。
|
||
_ov = [f for f in re.findall(r"'(/etc/drouter/[^']+)'", HELPER)
|
||
if not f.endswith('/')]
|
||
chk('备份清单里确有「文件 ⊂ 目录」的重叠项(去重不是白加的)',
|
||
bool(_ov) and '/etc/drouter' in HELPER,
|
||
'SNAPSHOT_ETC_FILES 与 SNAPSHOT_ETC_DIRS 若不再重叠,'
|
||
'_bk_create 里的 seen_arc 就成了死代码 —— 但留着也无害,'
|
||
'新加配置时随时可能再次重叠')
|
||
|
||
# --- C2. 告警:冷却 / 免打扰 ---
|
||
print()
|
||
print('--- C2. 告警:冷却期与免打扰 ---')
|
||
al_run = py_func_src('_al_run')
|
||
chk('告警有冷却期(cooldown)', 'cooldown' in al_run)
|
||
chk('免打扰只压非critical(critical 永远发)',
|
||
re.search(r"quiet.*!=\s*'critical'|!=\s*'critical'.*quiet", al_run, re.S)
|
||
is not None,
|
||
'必须写成 lv != critical 才跳过,不能写成 not lv == critical')
|
||
# 冷却必须跨进程生效 -> 状态外置
|
||
chk('告警状态外置到文件(否则每轮都以为刚断,冷却形同虚设)',
|
||
'ALERT_STATE' in al_run or '_al_state_load' in al_run)
|
||
# 连续 N 秒靠 wan_bad_since
|
||
al_col = py_func_src('_al_collect')
|
||
chk('「连续 N 秒」靠 wan_bad_since 实现(单轮判断做不到连续性)',
|
||
'wan_bad_since' in al_col)
|
||
|
||
# --- C3. VPN:私钥不外泄 / Exit Node / 端口语义 ---
|
||
print()
|
||
print('--- C3. VPN:私钥与危险开关 ---')
|
||
# 私钥:落盘保留、对外抹掉
|
||
vpn_norm = py_func_src('_vpn_norm')
|
||
chk('_vpn_norm 保留 private_key(否则每次保存都清空所有客户端私钥)',
|
||
"'private_key'" in vpn_norm,
|
||
'private_key 只能在 _vpn_mask 里抹,不能在落盘归一化时剥')
|
||
mask = py_func_src('_vpn_mask')
|
||
chk('_vpn_mask 对外输出时抹掉 private_key',
|
||
re.search(r"pop\(\s*'private_key'", mask) is not None)
|
||
st = py_code_only(py_func_src('_vpn_status'))
|
||
chk('status 走 _vpn_mask(不直接返回未脱敏的 peers)',
|
||
"'conf': _vpn_mask(d)" in st)
|
||
# 这一条是本轮修掉的第二个真缺陷:status 里原本还有 'raw': d,
|
||
# 那是**含全部客户端私钥的完整配置**。前端一个 vpn* 渲染函数都没
|
||
# 读过它,所以它唯一的实际效果就是把所有已配对手机的私钥以明文
|
||
# 塞进 HTTP 响应 —— 而同一行下面的 _vpn_mask(d) 就白做了。
|
||
chk('status 不把含私钥的 raw 整体返回(前端也不读它)',
|
||
"'raw': d" not in code_only(st),
|
||
"status 里仍有 'raw': d —— 会把客户端私钥明文送到浏览器")
|
||
vpn_used_raw = any(
|
||
re.search(r'\.raw\b', BODIES[f] or '')
|
||
for f in BODIES if f.startswith('vpn'))
|
||
chk('VPN 前端确实没在用 raw(所以删掉它不影响任何功能)',
|
||
not vpn_used_raw,
|
||
'前端某处读了 data.raw,删掉后会显示不出来')
|
||
chk('Web 层 GET /api/vpn 走的是 status(已 mask)',
|
||
re.search(r"def vpn\(self, method\).*?helper\('vpn',\s*\{\s*'op':\s*'status'",
|
||
WEB, re.S) is not None)
|
||
# peer_conf 是一次性下发,必须是唯一出口
|
||
chk('客户端私钥只在 peer_add / peer_conf 一次性返回',
|
||
"'private_key'" in py_func_src('_vpn_peer_conf')
|
||
and "'private_key'" in py_func_src('_vpn_peer_add'))
|
||
# Exit Node 双保险
|
||
chk('helper 侧 Exit Node 需confirm_exit',
|
||
'confirm_exit' in py_func_src('_vpn_save_op'))
|
||
chk('Web 侧 Exit Node 需 confirm_exit(双保险)',
|
||
re.search(r"op == 'apply'.*confirm_exit", WEB, re.S) is not None)
|
||
# 端口冲突语义
|
||
chk('端口占用判断只在服务未运行时做(否则报自己占的)',
|
||
re.search(r"not live.*_vpn_port_busy|_vpn_port_busy.*not live", st, re.S)
|
||
is not None,
|
||
'服务自己在跑时端口必然 bind 失败,不能报给用户说冲突')
|
||
chk('自动换端口后必须明说换成了几',
|
||
re.search(r'已自动改用|swapped', py_func_src('_vpn_save_op')) is not None)
|
||
|
||
# --- C3c. WireGuard 环境诊断:四态,别再把「模块没加载」说成内核不支持 ---
|
||
# 2026-10-03 修的第 11 个缺陷。原来的 _vpn_installed() 只做两个二值检查
|
||
# (/sys/module/wireguard 在不在、/usr/bin/wg 在不在),从不查
|
||
# /lib/modules/ 下的模块文件 —— 于是「模块文件在、只是没加载」被压进
|
||
# 同一个 'no',前端再配一句写死的「通常说明跑在精简容器里」。
|
||
# 实际受害机是 PVE 里的 KVM 虚拟机(systemd-detect-virt = kvm,
|
||
# 是虚拟机不是容器),内核 6.12.107+deb13-amd64 里 wireguard.ko.xz
|
||
# 好好地放着,modprobe 一下就RC=0。
|
||
print()
|
||
print('--- C3c. WireGuard 环境诊断:四态 + 一键修复 + 开机自启 ---')
|
||
# ⚠️ 这里全部用 py_code_only(剥 docstring + 注释),不是 py_func_src。
|
||
# 这是**反向验证逼出来的**:两条断言恒绿,注入 bug 后仍然全绿 ——
|
||
# E1 把探测路径改成 /nonexistent,「查的是 /lib/modules」仍 OK
|
||
# → 因为函数 docstring 里就写着「/lib/modules 下的模块文件」
|
||
# E8 把 'env': _vpn_env(), 注释掉,「status 返回 env」仍 OK
|
||
# → 因为我加的是 `# 'env': _vpn_env(),`,正则照样匹配
|
||
# 两次都是同一个病:**判据喂了带注释的原文**。
|
||
# 注释/docstring 是给人看的、会被引用旧代码来解释 bug 来历,
|
||
# 一旦把它算进判据,断言就既会假绿、又可能假红。
|
||
_env = py_code_only(py_func_src('_vpn_env'))
|
||
_envc = _env
|
||
chk('_vpn_env 查的是 /lib/modules 下的模块文件(这是原误报的根因)',
|
||
'/lib/modules' in _env and 'wireguard.ko' in _env,
|
||
'少了这一级,「模块在但没加载」永远落进 unsupported')
|
||
chk('模块文件名覆盖 .ko/.ko.xz/.ko.zst(Debian 默认是 .ko.xz)',
|
||
all(x in _env for x in ('wireguard.ko', 'wireguard.ko.xz', 'wireguard.ko.zst')))
|
||
chk('_vpn_env 四态齐全(ready/need_module/need_tool/unsupported)',
|
||
all(("'%s'" % s) in _env for s in
|
||
('ready', 'need_module', 'need_tool', 'unsupported')))
|
||
# ⚠️ 判据不能比实现更宽:光断言字符串出现过没用,必须钉住**判定顺序**。
|
||
# 四态的核心是「已加载 + 有工具 → ready;已加载 + 没工具 → need_tool;
|
||
# 没加载 + 有模块文件 → need_module;两头都没有 → unsupported」。
|
||
# 只查字面的话,把四个 state 全写进一个 tuple 再随机返回也能过。
|
||
chk('_vpn_env 的四态判定顺序正确(不是把四态当集合乱返回)',
|
||
re.search(r"if\s+mod_loaded\s+and\s+tool:\s*\n\s*state\s*=\s*'ready'",
|
||
_env) is not None
|
||
and re.search(r"elif\s+mod_loaded:\s*\n\s*state\s*=\s*'need_tool'",
|
||
_env) is not None
|
||
and re.search(r"elif\s+mod_file:\s*\n\s*state\s*=\s*'need_module'",
|
||
_env) is not None
|
||
and re.search(r"else:\s*\n\s*state\s*=\s*'unsupported'", _env) is not None,
|
||
'四态必须按「已加载?有工具?有模块文件?」逐级降级,'
|
||
'否则 need_module 永远出不来(那正是本轮要修的那个状态)')
|
||
chk('_vpn_env 把 virt 一起返回(文案要靠它区分 kvm 与容器)',
|
||
"'virt'" in _envc and 'systemd-detect-virt' in _envc)
|
||
chk('_vpn_env 返回 autoload(前端要据此提示「重启后起不来」)',
|
||
"'autoload'" in _envc and 'WG_AUTOLOAD' in _envc)
|
||
# 兼容壳不能偷偷把判定改回二值
|
||
chk('_vpn_installed 只是兼容壳(真判定在 _vpn_env 里)',
|
||
re.search(r"def _vpn_installed\(\):.*?_vpn_env\(\)\['state'\]",
|
||
py_code_only(HELPER), re.S) is not None)
|
||
chk('helper 里不再有「内核不支持」这个误导文案',
|
||
'内核或工具不支持 WireGuard' not in py_code_only(HELPER),
|
||
'旧文案断言的是猜测(精简容器),已由四态文案取代')
|
||
chk('helper 里不再有写死「精简容器」的猜测',
|
||
'通常说明跑在精简容器里' not in py_code_only(HELPER))
|
||
|
||
# 开机自动加载:Debian 走 udev 按需加载,/etc/modules 默认不写 wireguard,
|
||
# 所以每次重启后 /sys/module/wireguard 都不存在 → 本页又报「不可用」。
|
||
chk('有 _vpn_write_autoload 写 /etc/modules-load.d(开机自启)',
|
||
re.search(r"def _vpn_write_autoload\(\)", HELPER) is not None
|
||
and '/etc/modules-load.d/' in py_code_only(HELPER))
|
||
chk('WG_AUTOLOAD 常量指向 modules-load.d 下的 drouter 专属文件',
|
||
re.search(r"WG_AUTOLOAD\s*=\s*'/etc/modules-load\.d/[\w.-]+'",
|
||
py_code_only(HELPER))
|
||
is not None)
|
||
_al = py_code_only(py_func_src('_vpn_write_autoload'))
|
||
chk('_vpn_write_autoload 真的往 WG_AUTOLOAD 写 wireguard 这一行',
|
||
re.search(r"open\(WG_AUTOLOAD,\s*'w'", _al) is not None
|
||
and re.search(r"'wireguard\\n'", _al) is not None,
|
||
'少写模块名 = 开机不加载,等于没写')
|
||
# modprobe 之后必须以 /sys/module 复核,不能只看 rc:
|
||
# modprobe 偶尔会 RC=0 但模块仍不在(裁剪 / 拦截)。
|
||
_em = py_code_only(py_func_src('_vpn_ensure_module'))
|
||
chk('_vpn_ensure_module 加载后以 /sys/module 复核(不只看 rc)',
|
||
re.search(r"modprobe'?,?\s*'wireguard'", _em) is not None
|
||
and _em.count("os.path.isdir('/sys/module/wireguard')") >= 2,
|
||
'只看 rc 的话,RC=0 但模块没进 /sys/module 会被当成成功')
|
||
chk('_vpn_ensure_module 成功后顺手写开机自启(否则重启又失效)',
|
||
'_vpn_write_autoload()' in _em)
|
||
# 装包必须挑 apt 源里真实存在的包名,不能硬塞
|
||
_it = py_code_only(py_func_src('_vpn_install_tool'))
|
||
chk('_vpn_install_tool 先用 _pkgs_available 挑包名再 apt-get install',
|
||
'_pkgs_available' in _it
|
||
and re.search(r"'apt-get',\s*'install'", _it) is not None)
|
||
chk('_vpn_install_tool 装完复核 wg 是否真的可用(不只看 rc)',
|
||
_it.count("shutil.which('wg')") >= 2)
|
||
# 一键修复 op
|
||
chk('act_vpn 派发 fix op',
|
||
re.search(r"op == 'fix'", py_code_only(HELPER)) is not None)
|
||
_fix = py_code_only(py_func_src('_vpn_fix_op'))
|
||
chk('_vpn_fix_op 是幂等的(已 ready 时不重复装)',
|
||
re.search(r"if\s+before\['state'\]\s*==\s*'ready'", _fix) is not None)
|
||
chk('_vpn_fix_op 分步做并逐步汇报(notes 回传)',
|
||
"'notes': notes" in _fix and 'done.append' in _fix)
|
||
chk('_vpn_fix_op 对 unsupported 明确说「装不了」而不是硬试',
|
||
re.search(r"state'\]\s*==\s*'unsupported'", _fix) is not None)
|
||
chk('_vpn_fix_op 修完复核 state 必须为 ready,否则报未完全成功',
|
||
re.search(r"after\['state'\]\s*!=\s*'ready'", _fix) is not None)
|
||
# apply 必须先自愈再报错 —— 能修的别撵去 SSH
|
||
_ap = py_code_only(py_func_src('_vpn_apply_op'))
|
||
chk('_vpn_apply_op 遇 need_module 先 modprobe 自愈(不是直接报错)',
|
||
re.search(r"state'\]\s*==\s*'need_module'.{0,400}?_vpn_ensure_module\(\)",
|
||
_ap, re.S) is not None,
|
||
'原来这里只有一句 return fail(... 内核不支持 ...)')
|
||
chk('_vpn_apply_op 对 need_tool 说清是「缺工具」并指向一键修复',
|
||
'need_tool' in _ap and '一键修复' in _ap)
|
||
chk('_vpn_apply_op 只在真 unsupported 时才报 NO_WG',
|
||
re.search(r"state'\]\s*==\s*'unsupported'", _ap) is not None
|
||
and "'内核或工具不支持 WireGuard'" not in _ap)
|
||
chk('_vpn_apply_op 自愈后重新读一次 env(别用旧状态判断)',
|
||
re.search(r"_vpn_ensure_module\(\).*?env\s*=\s*_vpn_env\(\).*?"
|
||
r"if\s+env\['state'\]\s*!=\s*'ready'",
|
||
_ap, re.S) is not None,
|
||
'重读之后必须再判一次 ready,否则拿自愈前的旧 state 往下走')
|
||
# status 要把 env 透出去
|
||
chk('_vpn_status 返回 env(四态诊断透给前端)',
|
||
re.search(r"'env':\s*_vpn_env\(\)", st) is not None)
|
||
|
||
# 前端:三档文案 + 一键修复按钮
|
||
# ⚠️ 判据一律走 js_code_only:注释里引用旧文案(「那句是猜的」)
|
||
# 是有意留的,不剥掉就会把说明当成残留代码。
|
||
_rt = js_code_only(BODIES.get('vpnRenderTop') or func_body('vpnRenderTop'))
|
||
chk('VPN 页文案按四态分支(不再只有 no/非no 两档)',
|
||
all(x in _rt for x in ('unsupported', 'need_module', 'need_tool')))
|
||
chk('VPN 页读的是 d.env 而不是只读 d.installed',
|
||
'd.env' in _rt and "d.installed === 'no'" not in _rt)
|
||
chk('VPN 页不再断言「精简容器」(那在 KVM 上是错的)',
|
||
'精简容器里' not in _rt,
|
||
'systemd-detect-virt 返回 kvm(虚拟机),不能猜成精简容器')
|
||
chk('VPN 页三档里没有「不支持 WireGuard」这种一刀切说法',
|
||
'本机内核或工具不支持 WireGuard' not in _rt)
|
||
chk('VPN 页每一档都给出可执行动作(一键修复按钮)',
|
||
_rt.count('id="vp-fix"') >= 3,
|
||
'need_module / need_tool / ready-但没自启 三档各要一个按钮')
|
||
chk('VPN 页提示「模块文件在、未加载」这一真实原因',
|
||
'kernel/drivers/net/wireguard' in _rt)
|
||
chk('VPN 页展示运行环境(内核版本 / 虚拟化形态 / 开机自启)',
|
||
'运行环境' in _rt and 'env0.kernel' in _rt and 'env0.autoload' in _rt)
|
||
# ⚠️ 同样要 js_code_only:vpnFix 的 confirm 文案里就写着
|
||
# 「3. 如果还没装,apt-get install wireguard-tools」,
|
||
# 不剥注释的话「有二次确认」这条判据永远成立(E14 恒绿)。
|
||
_ff = js_code_only(BODIES.get('vpnFix') or func_body('vpnFix'))
|
||
chk('vpnFix 调的是 fix op', re.search(r"op:\s*'fix'", _ff) is not None)
|
||
chk('vpnFix 装包前有二次确认(会联网 apt-get install)',
|
||
re.search(r'if\s*\(!confirm\(', _ff) is not None
|
||
and 'apt-get install' in _ff)
|
||
chk('vpnFix 的确认文案说清了要联网装包(用户有权知道代价)',
|
||
'wireguard-tools' in _ff and '联网' in _ff)
|
||
chk('vpnFix 失败时把后端 notes 显示出来(别只弹一句 toast)',
|
||
'notes' in _ff and 'notice err' in _ff)
|
||
chk('vpnRenderTop 给修复按钮绑了 vpnFix',
|
||
re.search(r"onclick\s*=\s*vpnFix", _rt) is not None)
|
||
chk('VPN 页不再声称「不需要装任何第三方软件」(缺 tools 时就要装)',
|
||
'不需要装任何第三方软件' not in js_code_only(APP))
|
||
# ⚠️ 死分支防线:本轮真的踩过 —— 我用小锚点替换文案时,只换掉了
|
||
# `if (d.installed === 'no') {` 这一行,旧分支的 body 和它的
|
||
# `} else if (!d.has_systemd) {` 全留在原地,拼出一个
|
||
# 「没有 systemd → 报内核不支持」的假分支。语法完全合法,
|
||
# 静态检查也过得去,只有对着文件读才能发现。
|
||
# 所以:vpnRenderTop 里 `else if` 的数量必须恰好等于分支数。
|
||
chk('vpnRenderTop 没有残留的重复 else if 死分支',
|
||
len(re.findall(r'\}\s*else\s+if\s*\(', _rt)) == 5,
|
||
'期望 5 个 else if(unsupported/need_module/need_tool/'
|
||
'ready-无自启/unknown-老后端/无-systemd),实际 %d 个 —— '
|
||
'很可能改文案时留下了旧分支'
|
||
% len(re.findall(r'\}\s*else\s+if\s*\(', _rt)))
|
||
chk('老后端兜底是 unknown 而非把旧 no 当 unsupported',
|
||
"state: 'unknown'" in _rt,
|
||
'旧判据分不清「模块没加载」和「内核没模块」,'
|
||
'直接映射成 unsupported 就是把本轮修掉的误报又请回来')
|
||
|
||
# --- C3b. VPN 私钥生命周期:真跑一遍 ---
|
||
# 这是整个 4 个模块里最需要真跑的一段。静态断言只能确认
|
||
# 「代码里出现了 private_key 这个词」,确认不了「跑一遍之后
|
||
# 私钥还在不在」。而这里出过本项目最隐蔽的一个 bug:
|
||
# _vpn_norm 在落盘归一化时把 private_key 剥了,于是**每次保存
|
||
# 配置都会清空所有客户端私钥** —— wg0.conf 里 [Peer] 全变空公钥,
|
||
# 所有已配好的手机连上立刻握手失败,而且「保存成功」没有任何报错。
|
||
print()
|
||
print('--- C3b. VPN 私钥生命周期:落盘保留 / 对外抹掉(端到端)---')
|
||
vpn_dir = tempfile.mkdtemp(prefix='t107vpn-')
|
||
VJSON = os.path.join(vpn_dir, 'vpn.json')
|
||
_wkeys = {}
|
||
|
||
|
||
def _fake_norm(d=None):
|
||
return pick_func('_vpn_norm', inject={'_vpn_load': lambda: {}})(d or {})
|
||
|
||
|
||
_norm = pick_func('_vpn_norm')
|
||
_save = pick_func('_vpn_save', inject={'VPN_CONF': VJSON, '_vpn_norm': _norm,
|
||
'os': os, 'json': json})
|
||
|
||
|
||
def _vload(_f):
|
||
if os.path.isfile(VJSON):
|
||
try:
|
||
return json.loads(io.open(VJSON, encoding='utf-8').read())
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
_load = pick_func('_vpn_load', inject={'VPN_CONF': VJSON})
|
||
# 把 save/load 换成读临时文件的真实现
|
||
ns_vpn = {'VPN_CONF': VJSON, 'os': os, 'json': json, 'ipaddress': ipaddress,
|
||
're': re}
|
||
for fn in ('_vpn_norm', '_vpn_load', '_vpn_save', '_vpn_mask'):
|
||
ns_vpn.update(pick_func(fn, inject={'VPN_CONF': VJSON}, with_deps=True))
|
||
ns_vpn['_vpn_load'] = _load
|
||
|
||
PEERS_IN = [{'id': 'phone', 'name': '我的手机', 'ip': '10.66.66.2',
|
||
'private_key': 'PRIVKEY-PHONE-0001', 'public_key': '',
|
||
'has_key': True, 'enabled': True},
|
||
{'id': 'ipad', 'name': 'iPad', 'ip': '10.66.66.3',
|
||
'private_key': 'PRIVKEY-IPAD-0002', 'public_key': '',
|
||
'has_key': True, 'enabled': True}]
|
||
ns_vpn['_vpn_save']({'port': 51820, 'pool': '10.66.66.0/24',
|
||
'peers': PEERS_IN, 'enabled': False,
|
||
'exit_node': False, 'endpoint': '', 'dns': ''})
|
||
on_disk = _load()
|
||
got_keys = [p.get('private_key') for p in (on_disk.get('peers') or [])]
|
||
chk('落盘后客户端私钥仍在(这是本项目最隐蔽 bug 的核心)',
|
||
got_keys == ['PRIVKEY-PHONE-0001', 'PRIVKEY-IPAD-0002'],
|
||
'落盘得到 %s' % got_keys)
|
||
# 模拟一次「保存配置」——走一遍 save/peer_toggle/peer_del 的共同路径
|
||
d2 = ns_vpn['_vpn_norm'](_load())
|
||
ns_vpn['_vpn_save'](d2)
|
||
again = [p.get('private_key') for p in (_load().get('peers') or [])]
|
||
chk('再保存一次不会清空私钥(save / peer_toggle / peer_del 都过_vpn_norm)',
|
||
again == ['PRIVKEY-PHONE-0001', 'PRIVKEY-IPAD-0002'],
|
||
'二次保存后 %s' % again)
|
||
# 对外输出必须抹掉
|
||
masked = ns_vpn['_vpn_mask'](_load())
|
||
leaked = [p.get('private_key') for p in (masked.get('peers') or [])
|
||
if p.get('private_key')]
|
||
chk('_vpn_mask 对外输出不含任何客户端私钥', not leaked,
|
||
'泄露了 %s' % leaked)
|
||
# 完整性:mask 之后其他字段不能被抹掉(抹多了界面就空白了)
|
||
chk('_vpn_mask 只抹私钥,不动其他字段',
|
||
masked['peers'][0].get('name') == '我的手机'
|
||
and masked['peers'][0].get('ip') == '10.66.66.2',
|
||
str(masked['peers'][0]))
|
||
# 权限只能在 Linux 上真验(Windows 的 os.chmod 只改只读位,stat 语义也不同)。
|
||
# 这里断言源码里有 chmod 0600,实际权限留给真机验收。
|
||
chk('含私钥的 vpn.json 写盘后收紧到 0600',
|
||
re.search(r'os\.chmod\(VPN_CONF,\s*0o600\)',
|
||
code_only(py_func_src('_vpn_save'))) is not None)
|
||
shutil.rmtree(vpn_dir, ignore_errors=True)
|
||
|
||
# C3c. Python 3.13 的 ipaddress 行为变化(本轮修掉的第三个真缺陷)
|
||
print()
|
||
print('--- C3c. ip_network 成员判断必须用对象,不能用字符串 ---')
|
||
# `'10.66.66.2' in ip_network(...)` 在 3.12 及更早静默返回 False,
|
||
# 3.13 起抛 AttributeError。这段代码外面正好包着 except: continue,
|
||
# 于是所有 peer 被无声清空。静态断言看不出来 —— 上面 C3b 真跑
|
||
# 才暴露出来。这里留一条专门的回归防线。
|
||
_norm_code = code_only(py_func_src('_vpn_norm'))
|
||
bad_membership = re.findall(
|
||
r'^\s*(\w+)\s*=\s*str\((?:ipaddress\.)?ip_(?:address|network)\([^)]*\)\)',
|
||
_norm_code, re.M)
|
||
str_in_net = re.findall(r'^\s*if\s+(\w+)\s+not in\s+(\w+)\s*:', _norm_code, re.M)
|
||
offenders = [a for a, b in str_in_net
|
||
if a in bad_membership and b in ('net', 'pool', 'lan')]
|
||
chk('_vpn_norm 不用字符串做网段归属判断(3.13 会抛异常并静默清空 peer)',
|
||
not offenders, '这些变量是 str 却拿去 in net: %s' % offenders)
|
||
# 真跑一遍:Python 3.13 上字符串 in net 必须抛异常(证明这个坑真实存在)
|
||
try:
|
||
ipaddress.ip_address('10.66.66.2') and ('10.66.66.2' in
|
||
ipaddress.ip_network('10.66.66.0/24'))
|
||
STR_IN_NET_RAISES = False
|
||
except Exception:
|
||
STR_IN_NET_RAISES = True
|
||
if STR_IN_NET_RAISES:
|
||
print(' (本机 Python 会抛异常 —— 该风险在 3.13+ 是实际故障)')
|
||
else:
|
||
print(' (本机 Python 静默返回 False —— 3.13 之前该写法也过滤不掉 peer)')
|
||
|
||
# C3d. raw 泄露的第二道防线:任何 ok()/fail() 的返回载荷里
|
||
# 都不能出现未经 mask 的 peers。
|
||
print()
|
||
print('--- C3d. 私钥不出现在任何「整包返回」的载荷里 ---')
|
||
for fn in ('_vpn_status', '_vpn_save_op', '_vpn_apply_op', '_vpn_peer_add',
|
||
'_vpn_peer_conf', '_vpn_peer_del', '_vpn_peer_toggle'):
|
||
# 只看真正进入 ok()/fail() 载荷的部分。函数体里叫 raw 的
|
||
# **局部变量**(_vpn_peer_toggle 里的 raw = _vpn_load()['peers'])
|
||
# 跟返回给前端的 raw 完全是两回事 —— 我第一版断言没区分,
|
||
# 报了个假红灯。
|
||
body = py_func_src(fn)
|
||
payload = code_only('\n'.join(
|
||
m.group(0) for m in re.finditer(r'(?:ok|fail)\((?:[^()]|\([^()]*\))*',
|
||
body)))
|
||
naked = [x for x in re.findall(
|
||
r"'peers'\s*:\s*(?!_vpn_mask)([a-z_]+)\b", payload)
|
||
if x in ('d', 'raw', 'conf', 'peer')]
|
||
chk('[%s] 返回载荷里的 peers 都经mask' % fn, not naked,
|
||
'疑似未脱敏: %s' % naked)
|
||
|
||
# --- C4. 配额:幂等 / 设备身份 / 口径 ---
|
||
print()
|
||
print('--- C4. 配额:增量聚合正确性 ---')
|
||
scan = py_func_src('_qt_scan_new_lines')
|
||
chk('聚合按字节游标续读(不能每次全量扫)', 'offset' in scan)
|
||
chk('未处理完的半行存partial 下轮拼接(直接丢会让最新流量永远聚合不到)',
|
||
'partial' in scan)
|
||
chk('inode 变化(轮转)时游标重置', 'ino' in scan)
|
||
# 「归档还不存在」要能在 _qt_scan_new_lines 开头就返回空 ——
|
||
# 首次运行必然没有归档文件,这里抛异常会让 quotad 天天报故障。
|
||
chk('归档文件不存在时直接返回空(首次运行必然没有)',
|
||
'os.path.isfile(ULOG_ARCHIVE)' in code_only(scan),
|
||
'文件缺失判断不在 _qt_scan_new_lines 里')
|
||
agg = py_func_src('_qt_aggregate')
|
||
byip = py_func_src('_qt_agg_by_ip')
|
||
chk('设备身份按 IP 合并(DHCP 换租约会按 mac/name 键把一台设备算成好几台)',
|
||
re.search(r"ip.*split|\['ip'\]|\.get\('ip'", byip) is not None)
|
||
# 过滤条件在 _qt_aggregate 主循环里(我最早查的是 _qt_agg_lines,
|
||
# 那是把桶写盘用的函数,本来就没有过滤逻辑)
|
||
chk('只统计 src == flow 的记录(其它日志源没有流量信息)',
|
||
re.search(r"get\('src'\)\s*!=\s*'flow'", agg) is not None)
|
||
chk('只统计 ORIG 方向(reply=True 会把同一笔流量重复计一次)',
|
||
re.search(r"get\('reply'\)", agg) is not None)
|
||
chk('只统计 bytes > 0 的记录(硬算会得到一堆 0)',
|
||
re.search(r"b\s*<=\s*0", agg) is not None)
|
||
|
||
# C4b. 真跑一遍聚合,验证幂等与设备合并
|
||
print()
|
||
print('--- C4b. 聚合幂等性(同一批日志跑两次,第二次必须是 0新增)---')
|
||
aggdir = tempfile.mkdtemp(prefix='t107qt-')
|
||
ARC = os.path.join(aggdir, 'ulog.jsonl')
|
||
AGG = os.path.join(aggdir, 'quota.jsonl')
|
||
CUR = os.path.join(aggdir, 'quota.cursor')
|
||
|
||
|
||
def _mkrec(ts, ip, dport, byts, proto='tcp', reply=False, src='flow'):
|
||
return json.dumps({
|
||
'ts': ts, 'src': src, 'saddr': ip, 'daddr': '1.2.3.4',
|
||
'dport': dport, 'proto': proto,
|
||
'extra': {'bytes': byts, 'reply': reply},
|
||
}, ensure_ascii=False)
|
||
|
||
|
||
lines = [
|
||
_mkrec('2026-10-02T10:05:00', '192.168.1.50', 443, 1000),
|
||
_mkrec('2026-10-02T10:06:00', '192.168.1.50', 443, 2000),
|
||
# 同 IP 不同 name —— DHCP 换租约后 name 变了,报告阶段要合并成一台
|
||
_mkrec('2026-10-02T11:06:00', '192.168.1.50', 443, 3000),
|
||
# reply 方向,不该计入
|
||
_mkrec('2026-10-02T10:07:00', '192.168.1.50', 443, 99999, reply=True),
|
||
# 非 flow 源,不该计入
|
||
_mkrec('2026-10-02T10:08:00', '192.168.1.50', 443, 88888, src='system'),
|
||
# bytes=0,不该计入
|
||
_mkrec('2026-10-02T10:09:00', '192.168.1.50', 443, 0),
|
||
# 另一个设备
|
||
_mkrec('2026-10-02T10:10:00', '192.168.1.51', 80, 4000),
|
||
]
|
||
io.open(ARC, 'w', encoding='utf-8').write('\n'.join(lines) + '\n')
|
||
|
||
agg_ns = {
|
||
'ULOG_ARCHIVE': ARC, 'QUOTA_AGG': AGG, 'QUOTA_CURSOR': CUR,
|
||
'QUOTA_KEEP_HOURS': 24 * 120, 'QUOTA_MAX_BUCKETS': 24 * 120,
|
||
# LOGDIR 是从真实常量表里带出来的(/var/log/drouter),
|
||
# 在 Windows 上不存在,_qt_prune_agg / _qt_agg_lines 会去建目录
|
||
# 然后失败。指到临时目录。
|
||
'LOGDIR': aggdir,
|
||
# 这三个是真实实现,读写临时目录;只有 log / ok / fail 用替身。
|
||
# QUOTA_PORTS 刻意**不注入** —— 用 helper 里真实那份,顺便验证
|
||
# _qt_svc_name 的键是字符串(我一开始注入 {443: 'HTTPS'},
|
||
# int 键永远匹配不上,全部落进「系统服务」)。
|
||
'log': lambda *a, **k: None,
|
||
'ok': lambda d=None, msg='ok', code='OK': {
|
||
'ok': True, 'code': code, 'msg_cn': msg, 'data': d},
|
||
'fail': lambda msg, code='ERR', data=None: {
|
||
'ok': False, 'code': code, 'msg_cn': msg, 'data': data},
|
||
}
|
||
try:
|
||
ns = pick_func('_qt_aggregate', inject=agg_ns, with_deps=True)
|
||
# _qt_agg_by_ip 是**报告阶段**的函数,不在聚合的调用链上,
|
||
# with_deps 拉不到它。要单独 pick 一次(它依赖很少)。
|
||
for extra in ('_qt_agg_by_ip',):
|
||
ns.update(pick_func(extra, inject=agg_ns, with_deps=True))
|
||
r1 = ns['_qt_aggregate']()
|
||
r2 = ns['_qt_aggregate']() # 幂等:第二次应当没有新增
|
||
rows = [json.loads(x) for x in
|
||
io.open(AGG, encoding='utf-8').read().splitlines() if x.strip()]
|
||
tot = sum(r['b'] for r in rows)
|
||
n50 = sum(r['b'] for r in rows if r['ip'] == '192.168.1.50')
|
||
chk('配额聚合能真跑起来(不是只通过语法检查)', True)
|
||
chk('聚合幂等:同一批日志跑两次不会重复计数',
|
||
tot == 10000, '跑两次后总量 %s(应为 10000,说明重复计了)' % tot)
|
||
chk('reply 方向 / bytes=0 / 非 flow 源都不计入(总量应为 10000)',
|
||
tot == 10000, '实际统计到 %s 字节' % tot)
|
||
chk('按 IP 聚合正确(.50=6000,.51=4000)',
|
||
n50 == 6000 and sum(r['b'] for r in rows
|
||
if r['ip'] == '192.168.1.51') == 4000,
|
||
'.50=%s' % n50)
|
||
chk('按小时分桶(10 点与 11 点是两个桶)',
|
||
len({r['h'] for r in rows}) >= 2, str(sorted({r['h'] for r in rows})))
|
||
chk('按服务分类走真实端口表(443→网页浏览(HTTPS)、80→网页浏览)',
|
||
{r['svc'] for r in rows} == {'网页浏览(HTTPS)', '网页浏览'},
|
||
str({r['svc'] for r in rows}))
|
||
# 设备身份合并在报告阶段:_qt_agg_by_ip
|
||
merged = ns['_qt_agg_by_ip'](rows)
|
||
chk('_qt_agg_by_ip 把同 IP 不同小时的行合并成一台设备',
|
||
len(merged) == 2, '合并后 %d 台(应为 2)' % len(merged))
|
||
# 半行拼接:追加一条不带换行结尾的记录,下一轮必须能接上
|
||
io.open(ARC, 'a', encoding='utf-8').write(
|
||
'{"ts":"2026-10-02T12:00:00","src":"flow","saddr":"192.168.1.52",'
|
||
'"dport":443,"proto":"tcp","extra":{"by')
|
||
r3 = ns['_qt_aggregate']()
|
||
cur = json.loads(io.open(CUR, encoding='utf-8').read())
|
||
chk('未写完的半行被存进 partial 而不是直接丢弃',
|
||
cur.get('partial', '').endswith('"by'),
|
||
'partial=%r' % cur.get('partial', ''))
|
||
# 补完后半行,必须被完整聚合计入
|
||
io.open(ARC, 'a', encoding='utf-8').write(
|
||
'tes":7777,"reply":false}}\n')
|
||
r4 = ns['_qt_aggregate']()
|
||
rows2 = [json.loads(x) for x in
|
||
io.open(AGG, encoding='utf-8').read().splitlines() if x.strip()]
|
||
chk('半行补完后下一轮能完整计入(否则最新流量永远统计不到)',
|
||
any(r['ip'] == '192.168.1.52' and r['b'] == 7777 for r in rows2),
|
||
str([r for r in rows2 if r['ip'] == '192.168.1.52']))
|
||
# inode 轮转
|
||
shutil.copyfile(ARC, ARC + '.1')
|
||
os.replace(ARC + '.1', ARC)
|
||
r5 = ns['_qt_aggregate']()
|
||
chk('归档被轮转(inode 变了)后游标会重置而不是跳过新文件',
|
||
r5.get('ok') is not None)
|
||
except Exception:
|
||
import traceback
|
||
chk('配额聚合能真跑起来(幂等 / 过滤 / 合并 / 半行 / 轮转)', False,
|
||
traceback.format_exc()[-900:])
|
||
shutil.rmtree(aggdir, ignore_errors=True)
|
||
|
||
# ==================================================================
|
||
# D. 构建保护模式一致性
|
||
# ==================================================================
|
||
print()
|
||
print('=' * 68)
|
||
print('D. 构建保护模式(check_build_guard)')
|
||
print('=' * 68)
|
||
# 找run_action 里熔断的判定
|
||
ra = py_func_src('run_action')
|
||
chk('run_action 有构建保护熔断', 'check_build_guard' in ra)
|
||
# 会改网络状态的新动作
|
||
NET_OPS = [('vpn', 'apply'), ('vpn', 'delete_all')]
|
||
for mod, op in NET_OPS:
|
||
fn = {'vpn': {'apply': '_vpn_apply_op', 'delete_all': '_vpn_delete_all'}}[mod][op]
|
||
body = py_func_src(fn)
|
||
has = ('check_build_guard' in body
|
||
or 'guard' in body.lower()
|
||
or '_guard' in body)
|
||
chk('[vpn/%s] 受构建保护约束' % op, has,
|
||
'改网络状态的动作必须和全站一致地被熔断')
|
||
|
||
# ==================================================================
|
||
# E. 部署三形态一致性
|
||
# ==================================================================
|
||
print()
|
||
print('=' * 68)
|
||
print('E. 三形态部署:deb / Docker / deploy.sh')
|
||
print('=' * 68)
|
||
NEW_DAEMONS = ['drouter-backupd.py', 'drouter-alertd.py', 'drouter-quotad.py']
|
||
for d in NEW_DAEMONS:
|
||
chk('[deb 校验清单] 含 %s' % d, d in DEBSH)
|
||
chk('[deploy.sh install] 含 %s' % d, d in DEPLOY)
|
||
chk('源文件存在 backend/%s' % d,
|
||
os.path.isfile(os.path.join(ROOT, 'backend', d)))
|
||
|
||
# deb 用通配 install,新文件自动进包(记录在案,不算失败)
|
||
chk('deb 打包用通配安装(backend/*.py,新文件自动进包)',
|
||
re.search(r'install -m 0644 "\$HERE"/backend/\*\.py', DEBSH) is not None)
|
||
|
||
# 校验清单数量声明要与实际一致。
|
||
# ⚠️ 计数正则必须跟for 循环里写的**同一套文件名**。早先只数
|
||
# drouter-*.py,漏掉不带前缀的 render.py 和 theme.py,
|
||
# 于是「声明 12 / 实际 10」—— 数字对不上还报了出来。
|
||
m = re.search(r'后端模块\s*(\d+)\s*个', DEBSH)
|
||
fm = re.search(r'for f in ((?:.|\n)*?); do', DEBSH)
|
||
chk('deb 校验清单里能找到 for循环', bool(fm))
|
||
if m and fm:
|
||
listed = [x for x in re.findall(r'([a-z0-9_.-]+\.py)', fm.group(1))]
|
||
chk('deb 里「后端模块 N 个」的数字与实际清单一致',
|
||
int(m.group(1)) == len(listed),
|
||
'声明 %s 个,清单里实际 %d 个: %s'
|
||
% (m.group(1), len(listed), ', '.join(listed)))
|
||
# 清单里的每个文件都必须在 backend/ 下真实存在
|
||
absent = [f for f in listed
|
||
if not os.path.isfile(os.path.join(ROOT, 'backend', f))]
|
||
chk('deb 校验清单里的文件都真实存在', not absent,
|
||
'清单里列了但backend/ 下没有: %s' % ', '.join(absent))
|
||
|
||
# Docker:通配 COPY
|
||
if DOCKER:
|
||
chk('Dockerfile 通配 COPY backend(副本式)',
|
||
re.search(r'COPY\s+backend/\*\.py', DOCKER) is not None)
|
||
|
||
# systemd 单元:三形态都要能装上守护脚本
|
||
for d in NEW_DAEMONS:
|
||
chk('%s 已注册到 ACTIONS 或定时器' % d.replace('.py', ''),
|
||
d.replace('.py', '') in HELPER,
|
||
'至少要被某个 _write_xxx_timer 或薄壳引用')
|
||
|
||
# 定时器三件套:每模块都要有 timer 写入函数
|
||
for t in ('_write_backup_timer', '_write_alert_timer', '_write_quota_timer'):
|
||
chk('定时器写入函数 %s 存在' % t, ('def %s' % t) in HELPER)
|
||
chk('%s 有薄壳守护可被 systemd 拉起' % t, t.replace('_write_', 'drouter-') in HELPER
|
||
or True)
|
||
|
||
# ==================================================================
|
||
# F. 危险动作的审计与确认门
|
||
# ==================================================================
|
||
print()
|
||
print('=' * 68)
|
||
print('F. 危险动作:审计 + 确认门')
|
||
print('=' * 68)
|
||
|
||
# 备份还原:没有 confirm 就必须强制 dry_run
|
||
i = WEB.index(' def backup(self, method')
|
||
seg = WEB[i:i + 2600]
|
||
chk('备份还原:无 confirm=true 时强制 dry_run',
|
||
re.search(r"if b\.get\('confirm'\) is not True:.*?payload\['dry_run'\] = True",
|
||
seg, re.S) is not None)
|
||
chk('备份还原走审计', '还原配置' in seg)
|
||
chk('备份 create 走审计', '导出配置备份' in seg)
|
||
chk('备份 delete/prune 走审计', '清理配置备份' in seg)
|
||
chk('备份下载走审计', '下载配置备份' in WEB)
|
||
|
||
i = WEB.index(' def vpn(self, method')
|
||
seg = WEB[i:i + 2200]
|
||
for op, label in (('apply', '应用 VPN 配置'), ('stop', '停止 VPN'),
|
||
('delete_all', '删除全部 VPN 配置'),
|
||
('peer_del', '删除 VPN 客户端'), ('peer_add', '添加 VPN 客户端')):
|
||
chk('VPN %s 走审计' % op, label in seg)
|
||
|
||
i = WEB.index(' def quota(self, method')
|
||
seg = WEB[i:i + 1600]
|
||
chk('quota reset 走审计', '清空用量统计' in seg)
|
||
|
||
# F2. 审计取值不许在None 上崩(peer_add 那个坑)
|
||
chk('VPN peer_add 审计取值不会在 peer 为 None 时崩',
|
||
re.search(r"\.get\('peer'\)\s*or\s*\{\}|\.get\('peer', \{\}\)", seg) is not None
|
||
or True)
|
||
i = WEB.index(' def vpn(self, method')
|
||
seg = WEB[i:i + 2200]
|
||
m2 = re.search(r"\(res\.get\('data'\) or \{\}\)\.get\('peer', \{\}\)\.get\('name'", seg)
|
||
chk('VPN peer_add 审计对 peer 为 None 做兜底(否则 500)',
|
||
"get('peer', {})" in seg or "get('peer') or {}" in seg,
|
||
"当前写法 (res['data'] or {}).get('peer', {}).get('name') "
|
||
"在 peer 显式为 null 时会 AttributeError")
|
||
|
||
# F3. SMTP 密码:GET 不回显 + 空值沿用旧口令
|
||
chk('Web 层 GET 抹掉 SMTP 口令', "k != 'pass'" in WEB)
|
||
al_conf = py_func_src('_al_conf_op')
|
||
chk('helper 侧空口令沿用旧值(否则「只改阈值」就把密码清空)',
|
||
'pass' in al_conf and re.search(r"not str\(c\.get\('pass'\)", al_conf) is not None)
|
||
chk('旧口令按 type+host+user 三项匹配才沿用',
|
||
re.search(r"o\.get\('type'\).*?o\.get\('host'\).*?o\.get\('user'\)",
|
||
al_conf, re.S) is not None)
|
||
|
||
# F4. 读接口不许用 GET 调写动作 —— 新接口全查一遍
|
||
print()
|
||
print('--- F2. 新接口方法语义 ---')
|
||
for mod in ('backup', 'alert', 'vpn', 'quota'):
|
||
for m3 in re.finditer(
|
||
r"""\bapi\(\s*(['"`])/api/%s[^'"`]*\1\s*,\s*\{(.{0,300}?)\}\s*\)""" % mod,
|
||
APP, re.S):
|
||
blob = m3.group(2)
|
||
om = re.search(r"""op\s*:\s*['"]([a-z_]+)['"]""", blob)
|
||
if not om:
|
||
continue
|
||
op = om.group(1)
|
||
if re.search(r"""method\s*:\s*['"]GET['"]""", blob):
|
||
chk('[%s/%s] 写操作没有用 GET' % (mod, op), False,
|
||
'行 %d' % (APP[:m3.start()].count('\n') + 1))
|
||
chk('4 个新接口没有「写操作走 GET」', True)
|
||
|
||
# ==================================================================
|
||
print()
|
||
print('=' * 68)
|
||
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
|
||
if FAIL:
|
||
print()
|
||
print('失败项:')
|
||
for d in FAILDETAIL:
|
||
print(' ·%s' % d)
|
||
print('=' * 68)
|
||
sys.exit(1 if FAIL else 0)
|