Files
liuzhuohua 3d028f5188 release: v1.0.6 打包校验发现 sync-isp-dns.sh 装错位置,一并修掉
产物校验时进容器抽查,发现 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 完好)全部保持。
2026-10-02 20:57:32 +08:00

359 lines
17 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 -*-
"""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('结果: 全部通过')