产物校验时进容器抽查,发现 1.0.6 的 PPPoE 断网修复自己有三个问题: 1. 脚本装错位置。初版由 deploy.sh 用 heredoc 写进 $OPT/bin/,而 build-deb.sh 与 Dockerfile 都是 install scripts/*.sh → $OPT/scripts/。 结果装 deb、跑容器的机器上 ip-up-script 指向不存在的文件,DNS 选 「仅运营商/两者合并」时照样全家断网。改为 scripts/ 下的实体文件, 三条部署路径共用一份;deploy.sh 顺带迁移存量占位文件里的旧路径 (只换注释行,不碰已拿到的 nameserver)。 2. 容器里重载不掉。脚本只 systemctl reload dnsmasq,容器形态没有 systemd,等于什么都不做。改为检测不到 /run/systemd/system 时回退 kill -HUP $(pidof dnsmasq),与 helper 内部处理方式一致。 3. 写盘失败仍返回 0。pppd 只看 ip-up-script 的退出码来决定要不要记 日志,原来目录不可写时脚本照样 exit 0,把「DNS 没同步」彻底藏起来。 现在写临时文件或 mv 失败一律非零退出。 另把 deb 体积 447→452 KB、compose/guide 体积按实测更新到 README 与 RELEASE_NOTES。 真机(192.168.7.3)实测四条路径:拨号成功两条 nameserver 落盘 rc=0; 重复执行内容不变 rc=0 不重载;断线写占位 rc=0;目录不可写 rc=1。 回归 8 模块全绿,安全不变量(53/67 端口 0 占用、服务未启动、 ens18 IP 不变、VNC 5900 完好)全部保持。
359 lines
17 KiB
Python
359 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""v1.0.6 回归:锁住本轮「全面审计」批次修掉的缺陷,防回潮。
|
||
|
||
覆盖(按缺陷类别分组):
|
||
|
||
A. 路由可达性
|
||
1. readings 不能吃掉有专属 POST 分支的路径(6 个模块保存全废的 P0);
|
||
B. 前后端字段契约
|
||
2. 静态租约写入字段名与渲染器读取字段名一致;
|
||
3. read_leases 的 is_static 判定改用 v_bool;
|
||
4. UPnP lan_address 在 apply / render 两条路径都注入;
|
||
5. v_bool 统一开关判定(is not False 会把 'false' 当 True);
|
||
6. v_int 的 default= 形态(`or 1000` 会吞掉 0);
|
||
C. 配置渲染
|
||
7. dnsmasq DHCP 池三项校验(倒序 / 含本机 LAN / 静态绑定落池内);
|
||
8. PPPoE 的 ip-up/ip-down 钩子(否则 dns_mode=isp 时全家断网);
|
||
9. miniupnpd 网段取自 system.lan_address;
|
||
10. local= 域跟随用户域名,不写死 /lan/;
|
||
D. 安全
|
||
11. 救援通道 token:helper 生成 / 页面带 token / 服务端强制校验 / 失败限速;
|
||
12. SNMP sysname/location/contact 过滤换行与 #;
|
||
13. PPPoE 多拨 service_name 白名单;
|
||
14. 救援 nft chain 生命周期(关闭时回收,别只加不删);
|
||
E. 语义正确性
|
||
15. check_only 预检不得写配置库;
|
||
16. 构建保护模式下 rollback+reload 必须被拒;
|
||
17. 防火墙预检前端必须传 check_only;
|
||
18. numOr:空串返回 undefined(Number('')===0 会把 MTU 提交成 0);
|
||
19. DHCPv6 prefix_len 统一为数字;
|
||
20. PAGE_MODULES 含 sys(否则没有保存按钮);
|
||
21. leaseTimer 在 rebuild() 开头停掉(闭包变量被覆盖 → interval 泄漏);
|
||
22. portfwd 草稿保护(pageDirty/pageIsDirty/pageClean);
|
||
E. 资源与性能
|
||
23. _ulog_prune 不得整文件读入内存;
|
||
24. ping 不得在 _metric_lock() 内执行;
|
||
25. 网桥口计数不得与成员口重复累加;
|
||
26. logd 必须有磁盘将满保护(snapshotd 早就有了)。
|
||
"""
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.join(HERE, '..')
|
||
fails = 0
|
||
|
||
|
||
def chk(label, cond, extra=''):
|
||
global fails
|
||
if not cond:
|
||
fails += 1
|
||
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
|
||
|
||
|
||
def read(rel):
|
||
with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
|
||
return f.read()
|
||
|
||
|
||
def block(src, start, n=None):
|
||
"""从 `start`(行首匹配的 def/函数名)起,按缩进切出完整函数体。
|
||
|
||
不要用固定行数窗口:函数常有 200+ 行,一截断 ast.parse 就炸,
|
||
断言也会因为看不到后半段而假阴性。
|
||
"""
|
||
lines = src.splitlines()
|
||
for i, ln in enumerate(lines):
|
||
if not ln.startswith(start):
|
||
continue
|
||
ind = len(ln) - len(ln.lstrip())
|
||
out = []
|
||
for ln2 in lines[i:]:
|
||
dedent = ln2.strip() and (len(ln2) - len(ln2.lstrip())) <= ind
|
||
# JS 的收尾 `}` 与 def 同缩进,不能在那里截断
|
||
if dedent and not ln2.startswith(('}', start)):
|
||
break
|
||
out.append(ln2)
|
||
if n and len(out) >= n:
|
||
break
|
||
return '\n'.join(out)
|
||
return ''
|
||
|
||
|
||
def code_of(src, start, n=200):
|
||
"""取函数体并剥掉注释与字符串字面量。
|
||
|
||
断言「代码里不该出现某个模式」时必须先剥文档字符串 —— 修复说明里
|
||
常常正好引用了旧写法(`f.read().splitlines()` 写在 docstring 里解释
|
||
为什么不能那么干),直接匹配源码会把说明本身当成违规。
|
||
"""
|
||
|
||
seg = block(src, start, n)
|
||
if not seg:
|
||
return ''
|
||
try:
|
||
tree = ast.parse(seg)
|
||
except SyntaxError:
|
||
# 截断导致语法不完整:退化为逐行剥注释,够用
|
||
return '\n'.join(l for l in seg.splitlines()
|
||
if not l.strip().startswith('#'))
|
||
for node in ast.walk(tree):
|
||
# 只清空「裸表达式位置的字符串」—— 也就是 docstring。
|
||
# 不能无脑清空所有 str 常量:那会把 'mru'、'^[A-Za-z0-9._-]*$'
|
||
# 这些真正要断言的字面量也抹掉,断言就成了永远失败。
|
||
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
|
||
and isinstance(node.value.value, str)):
|
||
node.value.value = ''
|
||
try:
|
||
return ast.unparse(tree)
|
||
except Exception:
|
||
return seg
|
||
|
||
|
||
import ast # noqa: E402 (放在 code_of 之后统一导入)
|
||
|
||
|
||
APP = read('web/app.js')
|
||
WEB = read('backend/drouter-web.py')
|
||
HELPER = read('backend/drouter-helper.py')
|
||
RENDER = read('backend/render.py')
|
||
RESCUE = read('backend/drouter-rescue.py')
|
||
LOGD = read('backend/drouter-logd.py')
|
||
SNAPD = read('backend/drouter-snapshotd.py')
|
||
DEPLOY = read('scripts/deploy.sh')
|
||
SYN = read('scripts/sync-isp-dns.sh')
|
||
|
||
# ---------------------------------------------------------------- A
|
||
print('--- A. 路由可达性 ---')
|
||
chk('readings 分支排除有 POST 分支的路径',
|
||
'_has_post_branch' in WEB and "p not in _has_post_branch" in WEB)
|
||
_m = re.search(r"_has_post_branch = \{(.*?)\}", WEB, re.S)
|
||
_paths = re.findall(r"'(/api/[a-z/]+)'", _m.group(1)) if _m else []
|
||
chk('六个曾失效模块都在排除名单里',
|
||
set(_paths) >= {'/api/acl', '/api/share', '/api/docker',
|
||
'/api/ddns', '/api/theme', '/api/ulog'}, '→ %d 项' % len(_paths))
|
||
for p in _paths:
|
||
chk(' %s 确有专属 POST 写分支' % p,
|
||
re.search(r"p in \('%s'" % re.escape(p), WEB)
|
||
or re.search(r"p == '%s'" % re.escape(p), WEB))
|
||
# readings 判定必须在所有写路由之前,且只对 POST 加了排除
|
||
_i_readings = WEB.find('if p in readings')
|
||
chk('readings 判定存在', _i_readings > 0)
|
||
chk('排除名单定义在 readings 判定之前',
|
||
0 < WEB.find('_has_post_branch = {') < _i_readings)
|
||
|
||
# ---------------------------------------------------------------- B
|
||
print('--- B. 前后端字段契约 ---')
|
||
_ml = block(HELPER, 'def act_lease_make_static')
|
||
chk('静态租约写入 enabled 字段', "'enabled': True" in _ml and "'mac': mac" in _ml)
|
||
chk('静态租约写入 name 字段(渲染器读 name)', "'name': host" in _ml)
|
||
chk('read_leases 用 v_bool 判 enabled', "v_bool(sl.get('enabled'))" in HELPER)
|
||
|
||
_up = len(re.findall(r"cfg\['lan_address'\] = \(cfg\.get\('lan_address'\)\s*\n?\s*or syscfg\.get\('lan_address'\)", WEB))
|
||
chk('UPnP lan_address 在两条路径都注入(apply + preview)', _up >= 2, '→ %d 处' % _up)
|
||
chk('miniupnpd 取 system.lan_address', 'lan_address' in block(RENDER, 'def render_miniupnpd'))
|
||
chk('radvd pool_enabled 用 v_bool', re.search(
|
||
r'def render_radvd.*?v_bool\(', RENDER, re.S) is not None)
|
||
chk('dhcpv6 enabled 用 v_bool(不再是 is not False)',
|
||
"enabled') is not False" not in block(RENDER, 'def render_dhcpv6'))
|
||
chk('cache-size 用 v_int(default=) 而非 or 1000',
|
||
re.search(r'cache-size=\{v_int\(cfg\.get\(\'dns_cache_size\'\).*?default=1000\)', RENDER) is not None
|
||
and 'or 1000' not in code_of(RENDER, 'def render_dnsmasq', 400))
|
||
|
||
# ---------------------------------------------------------------- C
|
||
print('--- C. 配置渲染 ---')
|
||
_dn = block(RENDER, 'def render_dnsmasq', 200)
|
||
chk('DHCP 池起始 < 结束', re.search(r'pool.*?start.*?>.*?end|起.*?止', _dn) is not None
|
||
and ('start > end' in _dn or '起始' in _dn or '起止' in _dn), '(含倒序校验文案)')
|
||
chk('池不得包含本机 LAN 地址', 'LAN' in _dn and 'ValidateError' in _dn)
|
||
chk('静态绑定必须落在池内', re.search(r'静态|static', _dn) is not None and 'ValidateError' in _dn)
|
||
|
||
_ppp = block(RENDER, 'def render_ppp', 200)
|
||
chk('render_ppp 带 ip-up-script 钩子',
|
||
'ip-up-script /opt/drouter/scripts/sync-isp-dns.sh' in _ppp)
|
||
chk('render_ppp 带 ip-down-script 钩子',
|
||
'ip-down-script /opt/drouter/scripts/sync-isp-dns.sh' in _ppp)
|
||
# 1.0.6 打包校验时发现的真漏网:脚本原本由 deploy.sh 用 heredoc 写进
|
||
# $OPT/bin/,而 deb / 镜像走的都是 `install scripts/*.sh` → $OPT/scripts/,
|
||
# 于是装 deb、跑容器的机器上 ip-up-script 指向不存在的文件(PPPoE 断网
|
||
# 修复等于没修)。现在脚本是 scripts/ 下的实体文件,三条路径共用一份。
|
||
chk('sync-isp-dns.sh 不再由 deploy.sh heredoc 生成',
|
||
'cat > $OPT/bin/sync-isp-dns.sh' not in DEPLOY
|
||
and 'SYNC' not in DEPLOY.split('isp-dns 占位文件')[1][:900])
|
||
chk('sync-isp-dns.sh 是 scripts/ 下的实体文件', 'generated/isp-dns.conf' in SYN)
|
||
chk('deploy.sh 仍会安装 scripts/*.sh 到 $OPT/scripts/',
|
||
re.search(r'install -m 0755 "\$s" "\$OPT/scripts/', DEPLOY) is not None)
|
||
chk('render_ppp 的路径与 install 目标目录一致(scripts/ 而非 bin/)',
|
||
'/opt/drouter/bin/sync-isp-dns.sh' not in RENDER)
|
||
_syn = SYN
|
||
chk('sync-isp-dns.sh 用 $TMP 比较后再 mv(避免无谓重启 dnsmasq)',
|
||
'TMP=$DST.tmp.$$' in _syn and re.search(r'if \[ -f "\$DST" \] && diff', _syn) is not None
|
||
and 'mv -f "$TMP" "$DST"' in _syn)
|
||
chk('sync-isp-dns.sh 仅在 dnsmasq.conf 引用该文件时才重载',
|
||
re.search(r'if grep -qs "resolv-file=\$DST"', _syn) is not None)
|
||
# 容器形态没有 systemd,只有 systemctl reload 等于什么都不做
|
||
chk('sync-isp-dns.sh 无 systemd 时回退 SIGHUP(容器形态)',
|
||
re.search(r'\[ -d /run/systemd/system \]', _syn) is not None
|
||
and re.search(r'kill -HUP "\$\(pidof dnsmasq\)"', _syn) is not None)
|
||
# pppd 只看 ip-up-script 的退出码。写盘失败还 exit 0 的话,
|
||
# 「DNS 没同步」这件事没有任何日志痕迹 —— 而此时 DNS 选「仅运营商/
|
||
# 两者合并」,表现是全家断网且界面无异常(本轮主题:静默失效)。
|
||
chk('sync-isp-dns.sh 写盘失败必须非零退出(不能静默 rc=0)',
|
||
re.search(r'\} > "\$TMP" \|\| \{ rm -f "\$TMP"; exit 1; \}', _syn) is not None
|
||
and re.search(r'mv -f "\$TMP" "\$DST" \|\| \{ rm -f "\$TMP"; exit 1; \}', _syn) is not None)
|
||
chk('sync-isp-dns.sh 写前确保 generated 目录存在',
|
||
re.search(r'mkdir -p "\$\(dirname "\$DST"\)"', _syn) is not None)
|
||
chk('deploy.sh 迁移存量占位文件里的旧路径(不碰 nameserver)',
|
||
re.search(r"sed -i 's#bin/sync-isp-dns", DEPLOY) is not None)
|
||
|
||
_chk_local = re.search(r"local=/\{?([\w%{}.\-]*)", _dn)
|
||
chk('dnsmasq local 域跟随用户域名', 'domain' in _dn and "local=/lan/" not in _dn)
|
||
|
||
# ---------------------------------------------------------------- D
|
||
print('--- D. 安全 ---')
|
||
chk('helper 有 _rescue_new_token', 'def _rescue_new_token' in HELPER)
|
||
chk('token 生成 6 位数字', re.search(
|
||
r"def _rescue_new_token.*?return ''\.join\(str\(uuid\.uuid4\(\)\.int\)\[i\] for i in range\(6\)\)",
|
||
HELPER, re.S) is not None)
|
||
chk('开启救援即轮换 token', "conf['token'] = token" in HELPER)
|
||
chk('关闭救援作废 token', re.search(
|
||
r"else:.*?conf\['token'\] = ''|conf\.pop\('token'", HELPER, re.S) is not None)
|
||
chk('rescue.conf 权限 0640 属组 drouter', 'mode=0o640' in HELPER)
|
||
chk('rescue 服务端有 _token_ok', 'def _token_ok' in RESCUE)
|
||
chk('老配置无 token 时拒绝放行(不退化为无凭据)',
|
||
re.search(r'def _token_ok.*?if not want:.*?return False', RESCUE, re.S) is not None)
|
||
chk('/api/snapshots 强制校验 token',
|
||
re.search(r"/api/snapshots'.*?_token_ok\(", RESCUE, re.S) is not None)
|
||
chk('/api/restore 强制校验 token',
|
||
re.search(r"/api/restore'.*?_token_ok\(body\.get\('token'\)\)", RESCUE, re.S) is not None)
|
||
chk('校验失败走计数并拒绝',
|
||
RESCUE.count('self._bump_fails()') >= 2)
|
||
chk('令牌失败有计数与锁定', 'def _bump_fails' in RESCUE
|
||
and 'n >= 10' in RESCUE and '_lock_until' in RESCUE)
|
||
chk('rescue 页面有 token 输入框', 'id="tok"' in RESCUE)
|
||
chk('rescue 前端还原时发送 token',
|
||
re.search(r'restore[\s\S]{0,600}?token', RESCUE) is not None)
|
||
chk('主面板开启救援后展示 token', 'id="rs-token"' in APP)
|
||
|
||
_sn = block(HELPER, 'def _kern_save_op', 400)
|
||
chk('SNMP 三个字段过滤换行', re.search(
|
||
r"for k in \('contact', 'location', 'sysname'\).*?re\.sub\(r'\[\\r\\n\]', ' '", _sn, re.S) is not None)
|
||
chk('SNMP 三个字段过滤 #', re.search(
|
||
r"for k in \('contact', 'location', 'sysname'\).*?replace\('#', ' '\)", _sn, re.S) is not None)
|
||
chk('SNMP 三个字段截长 128', re.search(
|
||
r"for k in \('contact', 'location', 'sysname'\).*?\[:128\]", _sn, re.S) is not None)
|
||
|
||
_pm = code_of(HELPER, 'def act_pppoe_multi', 400)
|
||
chk('多拨 service_name 白名单',
|
||
re.search(r"re\.match\(r?'\^\[A-Za-z0-9\._-\]\*\$', svc\)", _pm) is not None)
|
||
chk('多拨 service_name 截长 64', '[:64]' in _pm)
|
||
chk('多拨 mru 独立取值(不再直接取 mtu)',
|
||
re.search(r"mru = int\(s\.get\('mru'\) or mtu\)", _pm) is not None)
|
||
chk('多拨 mru 钳制 576-1500', 'max(576, min(1500, mru))' in _pm)
|
||
|
||
chk('救援放行走独立 chain', 'drouter_rescue' in HELPER
|
||
and 'add chain inet drouter drouter_rescue' in HELPER)
|
||
chk('救援关闭时回收 chain', 'def _rescue_drop_nft_chain' in HELPER)
|
||
_drop = block(HELPER, 'def _rescue_drop_nft_chain', 60)
|
||
chk('回收时先删 input jump 再删 chain',
|
||
_drop.find('jump drouter_rescue') < _drop.find("'delete', 'chain'")
|
||
and "delete', 'rule', 'inet', 'drouter', 'input'" in _drop)
|
||
|
||
# ---------------------------------------------------------------- E
|
||
print('--- E. 语义正确性 ---')
|
||
_ap = block(WEB, ' check_only = bool(b.get', 20)
|
||
chk('apply_module 读 check_only', 'check_only' in _ap)
|
||
chk('check_only 时不写配置库',
|
||
re.search(r"check_only = bool\(b\.get\('check_only'\)\)", WEB) is not None
|
||
and 'if isinstance(data, dict) and not check_only:' in WEB)
|
||
chk('check_only 时用合并后的临时 cfg 渲染',
|
||
re.search(r"if check_only and isinstance\(data, dict\):[\s\S]{0,160}?cfg = dict\(get_cfg",
|
||
WEB) is not None)
|
||
chk('前端防火墙预检传 check_only',
|
||
re.search(r"check_only: true, live: false", APP) is not None)
|
||
|
||
_bg = block(HELPER, 'def check_build_guard', 40)
|
||
chk('构建模式 rollback 区分 reload', 'if action ==' in _bg and "'rollback'" in _bg
|
||
and "get('reload')" in _bg)
|
||
chk('rollback+reload 在构建模式被拒', 'return fail(' in _bg)
|
||
|
||
chk('numOr 空串返回 undefined', "return s === '' ? undefined : Number(s)" in APP)
|
||
for sel in ('#pp-mtu', '#pp-mru', '#pp-holdoff', '#pp-maxfail', '#ln-mtu', '#dd-ttl'):
|
||
chk(' %s 走 numOr' % sel,
|
||
re.search(re.escape(sel) + r"'\s*,\s*'[\w.]+'\s*,\s*numOr", APP) is not None)
|
||
|
||
chk('dhcpv6 prefix_len 用 Number 选中', 'Number(c.prefix_len || 60)' in APP)
|
||
chk('dhcpv6 prefix_len 绑定为数字', "bind('#d6-len','prefix_len',Number)" in APP
|
||
or re.search(r"bind\('#d6-len',\s*'prefix_len',\s*Number\)", APP) is not None)
|
||
chk('PAGE_MODULES 含 sys', re.search(r"sys:\s*\{\s*save: \['system'\]", APP) is not None)
|
||
_vd = block(APP, 'function viewDhcp()', 220)
|
||
chk('viewDhcp.rebuild 开头停租约定时器',
|
||
re.search(r"const rebuild = \(\) => \{\s*\n\s*stopLeaseTimer\(\);", _vd) is not None)
|
||
_vp = block(APP, 'function viewPortfwd()', 300)
|
||
chk('portfwd 编辑触发 pageDirty', "pageDirty('portfwd')" in _vp)
|
||
chk('portfwd 进页保留草稿', "pageIsDirty('portfwd')" in _vp)
|
||
chk('通用保存成功后清脏标记(PAGE_DIRTY 不再永久残留)',
|
||
re.search(r'if \(allOk\) pageClean\(S\.page\);', APP) is not None)
|
||
chk('通用应用成功后清脏标记', re.search(r'if \(ok\) pageClean\(S\.page\);', APP) is not None)
|
||
|
||
# ---------------------------------------------------------------- F
|
||
print('--- F. 资源与性能 ---')
|
||
_up2 = code_of(HELPER, 'def _ulog_prune', 200)
|
||
chk('ulog_prune 不整文件读入', 'f.read()' not in _up2 and 'readlines' not in _up2)
|
||
chk('ulog_prune 走流式滑窗 + os.replace',
|
||
'os.replace' in _up2 and 'for ln in f' in _up2)
|
||
chk('ulog_prune 先数行再决定是否重写',
|
||
'if total <= keep_rows' in _up2)
|
||
def ast_pick(src, start):
|
||
"""返回切出的函数的 ast(找不到/语法错返回 None)。"""
|
||
seg = block(src, start)
|
||
if not seg:
|
||
return None
|
||
try:
|
||
return ast.parse(seg)
|
||
except SyntaxError:
|
||
return None
|
||
|
||
|
||
def lock_bodies(tree):
|
||
"""所有 `with _metric_lock():` 代码块的源码文本。
|
||
|
||
必须按 AST 取而不是正则扫行:`with` 块之后的兄弟语句(如 if/for)
|
||
在 unparse 后同样带缩进,正则的「连续缩进行」会一路吃到函数末尾,
|
||
连带把块外的 ping 算进来 —— 那是假的。
|
||
"""
|
||
out = []
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, ast.With):
|
||
continue
|
||
names = [w.context_expr.func.id if isinstance(w.context_expr, ast.Call)
|
||
and isinstance(w.context_expr.func, ast.Name) else ''
|
||
for w in node.items]
|
||
if '_metric_lock' in names:
|
||
out.append('\n'.join(ast.unparse(s) for s in node.body))
|
||
return out
|
||
|
||
|
||
_rl = code_of(HELPER, 'def _route_latency', 120)
|
||
_rl_tree = ast_pick(HELPER, 'def _route_latency')
|
||
chk('ping 不在 with _metric_lock 块内(无子进程持锁)',
|
||
_rl_tree is not None and bool(lock_bodies(_rl_tree))
|
||
and all('ping' not in b for b in lock_bodies(_rl_tree)))
|
||
chk('ping 结果仍写回缓存(锁内只更新字典)',
|
||
re.search(r'with _metric_lock\(\):[\s\S]{0,200}?st\.update', _rl) is not None)
|
||
_nc = block(HELPER, 'def _net_counters', 60)
|
||
chk('网桥口跳过(避免 br0 与成员口重复累加)',
|
||
'/bridge' in _nc)
|
||
chk('logd 有磁盘将满保护', 'def _disk_ok' in LOGD)
|
||
chk('logd 采集前检查磁盘', re.search(r'main\(\).*?_disk_ok', LOGD, re.S) is not None)
|
||
chk('snapshotd 同样有 _disk_ok(两侧一致)', 'def _disk_ok' in SNAPD)
|
||
|
||
print()
|
||
if fails:
|
||
print('结果: %d 项失败' % fails)
|
||
sys.exit(1)
|
||
print('结果: 全部通过')
|