本轮按「每个菜单、每个功能」逐项排查,重点是家庭宽带上网必经的
IP / DHCP / DNS / PPPoE 链路,外加日志、监控、依赖三类基础能力。
所有缺陷的共同特征是「静默」:界面提示成功、配置写盘成功、服务
照常运行,但实际什么都没发生 —— 这类 bug 功能测试抓不到。
P0(安全 / 完全失效)
- readings 遮蔽 6 个模块的 POST 写分支:readings 字典同时匹配 GET 与
POST 且位于所有写路由之前,/api/{acl,share,docker,ddns,theme,ulog}
的保存操作永远走不到,还返回 ok:true + 读数据,前端照样提示
「保存完成」。这六个模块从 1.0.5 之前就一直是废的。
- PPPoE 缺 ip-up 钩子:usepeerdns 只把运营商 DNS 写进
/etc/ppp/resolv.conf,而 dnsmasq 读的是 resolv-file 指向的
generated/isp-dns.conf。dns_mode=isp/both 时上游全空,全家断网。
补 render_ppp 的 ip-up/ip-down-script + 新增 sync-isp-dns.sh。
- 紧急救援通道零凭据:开启时 169.254.0.1/16 挂到每一张物理网卡,
「救援网段内」等于局域网任意主机,而确认短语是页面源码常量 ——
等于任何人都能回滚配置并重启全机。改为开启即生成 6 位 token,
所有写操作强制校验,失败 10 次锁 60 秒;老配置无 token 时拒绝
放行(不退化成无凭据)。
- SNMP 注入:community 有 [\s#] 校验,sysname/location/contact 只截
长度。塞一个换行就能凭空插出 rwcommunity,把只读团体名升级成可写。
三个字段补齐换行与 # 过滤。
P1(静默失效)
- 静态租约写入 host/无 enabled,渲染器读 name/enabled → 整条被
v_bool 过滤,「转静态」提示成功但 dnsmasq 里一条 dhcp-host 都没有。
- UPnP 网段永远退回 192.168.0.0/16:render_miniupnpd 读
system.lan_address,但 apply 与 render 两条路径都只注入 ext_iface。
- 防火墙「仅语法检查」会写配置库:live=False 只代表写盘不生效,
真正不写盘必须传 check_only。前端漏传,后端 merge_cfg 也照写。
- 构建保护模式下 rollback+reload 绕过守卫:会 restart dnsmasq /
radvd / dhcpcd —— 正是这个模式要防的污染源。
- sys 页改了 access_scope 却提示「点击保存并应用」,而 PAGE_MODULES
里没有 sys → 保存按钮根本不存在。
- DHCP 页 rebuild() 整体重渲染,新闭包覆盖 stopLeaseTimer,旧 interval
句柄永久丢失 → 每加 10 条自定义 Option 泄漏一个 10 秒轮询。
- DHCPv6 prefix_len 在 IPv6 页写数字、DHCPv6 页写字符串 → 下拉框全不
选中,动别的字段还会反向污染。
- 端口转发草稿:编辑只写 S.pfRules,重进从 c.rules 重建 → 切页丢草稿。
- 通用保存/应用成功后从不 pageClean(S.page):20 多个页面的脏标记
保存后永久残留,之后每次进页面都用旧草稿盖掉库里读回的新值。
- DHCP 池三项校验只在向导路径,直存路径绕过 → 池含本机 LAN 地址时
表现为「网络时通时不通」且无任何日志。校验提到 render_dnsmasq。
- 多拨 service_name 无白名单,含引号即可闭合引号续写 pppd 指令。
- 救援放行规则只 add 不 delete,改端口 + 关闭后旧 accept 永久残留。
改独立 chain drouter_rescue,关闭时按 handle 删 jump 再删 chain。
P2(资源 / 性能 / 一致性)
- _ulog_prune 用 f.read().splitlines() + 第二个 kept 列表,keep_rows
默认 20 万行 ≈ 160MB 峰值,而它每 60 秒跑一次 → 4GB 机器上的
OOM 杀手。改流式:先只数行,未超上限零重写,超了才滑窗 + os.replace。
- /api/metrics 每 2 秒轮询,而 _route_latency 在 _metric_lock() 内跑
ping(timeout 10s)→ 目标不可达时整个仪表盘阻塞十几秒。移出锁。
- br0 的 RX/TX 是成员口之和,与成员口一起累加使总流量约翻倍。
- dnsmasq cache-size=0(禁用缓存)被 `or 1000` 吞成 1000。
- radvd pool_enabled / dhcpv6 enabled 用 `is not False`,把字符串
'false'/'0' 当 True。改 v_bool。
- Number('') === 0,清空的 MTU/MRU/租期/端口被提交成 0。新增 numOr。
- drouter-logd 缺磁盘将满保护(snapshotd 早有),根分区将满时归档
失败被 except 吞掉,日志静默停更。
- multiupnpd local 域写死 /lan/,不跟随用户域名。
- 多拨 MRU 独立取值并钳 576–1500(原来直接写 mtu,界面填的 MRU
被静默忽略)。
- rescue.conf 注释说 0600 实际 0640,改注释与实现对齐。
测试
- 新增 _dev/t-apicheck.py:前后端 API 契约 + 分支可达性检查器
(169 项)。静态比对「路径在文件里出现过」查不出遮蔽,必须检查
「谁先 return」。
- 新增 _dev/t-audit106.py:83 项,覆盖本轮全部修复。
- 新增 _dev/live-audit106.py:真机验收,重点验 6 个曾失效模块的写
分支可达、check_only 不写库、静态租约字段、SNMP 净化、多拨白名单。
- t-fefix 的 value="${...}" 断言过严:map 回调形参取自源码字面量
数组,零风险却一直误报。改为只对含点/调用/拼接的插值强制 esc()。
- 离线回归 35 个测试全绿;真机 192.168.7.3 验收全通过。
真机验证(192.168.7.3)
- 6 个模块 POST 均走到专属分支(用不存在 op 打出模块级 BAD_OP,
不再是读数据 + ok:true);主题切换 active 读回一致、ulog save_conf
成功、DDNS 非法 provider 被拒。
- check_only 预检前后配置库逐字节一致。
- 静态绑定写库为 {enabled,mac,ip,name},与渲染器读取字段一致。
- SNMP 库里 sysname 无换行、无 rwcommunity。
- 救援通道:无/错 token 403,正确 token 200,正确 token + 错短语被
拒不执行,错 10 次 429 锁 60 秒,rescue.conf 0640 root:drouter;
关闭后 token 清空、nft chain 回收、虚拟地址回收、服务停。
228 lines
11 KiB
Python
228 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""前端深度修复回归(v1.0.5):锁住这批修过的点,防回潮。
|
||
|
||
覆盖:
|
||
1. 脏页保护:ACL / 文件共享 / DDNS 切走再回来不丢未保存草稿;
|
||
2. 心跳定时器:boot() 重入不得叠加心跳;
|
||
3. 离开 Web 终端页必须主动断开 PTY(不然白占 30 分钟);
|
||
4. 主题导出注释同时剥 /* 与 */(只剥一半照样能闭合注入);
|
||
5. value="${...}" 一律 esc()(防配置里的引号闭合属性注入);
|
||
6. href 只放行 http/https(javascript: 伪协议);
|
||
7. LAN 页网关/域名绑到 dnsmasq 模块(原先写进 system 死键,改了等于没改);
|
||
8. UPnP 映射表:查询不顺手起服务,走后端只读 read:upnpmap;
|
||
9. 后台标签页降频:心跳跳过、终端轮询降频;
|
||
10. keep_days/keep_rows 不被 || 吞 0。
|
||
"""
|
||
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()
|
||
|
||
|
||
APP = read('web/app.js')
|
||
HELPER = read('backend/drouter-helper.py')
|
||
WEB = read('backend/drouter-web.py')
|
||
|
||
print('--- 1. 脏页保护(ACL / NFS / DDNS) ---')
|
||
chk('PAGE_DIRTY 三件套已定义',
|
||
all(s in APP for s in ('const PAGE_DIRTY = {};', 'function pageDirty(k)',
|
||
'function pageClean(k)', 'function pageIsDirty(k)')))
|
||
chk('viewAcl 有脏页守卫', "pageIsDirty('acl') && S.acl" in APP)
|
||
chk('viewNfs 有脏页守卫', "pageIsDirty('nfs') && S.share" in APP)
|
||
chk('viewDdns 用草稿合并渲染', "pageIsDirty('ddns') && S.ddnsDirty" in APP
|
||
and 'Object.assign({}, d.cfg || {}, draft)' in APP)
|
||
chk('viewDdns 有草稿时不再重置 S.ddnsDirty', 'if (!draft) S.ddnsDirty = {' in APP)
|
||
# 三页各自的「改动」提示处都必须标脏(段落级统计)
|
||
_acl_seg = APP[APP.index('/* ===================== 访问控制'):APP.index('/* ================== 内网文件共享')]
|
||
_nfs_seg = APP[APP.index('/* ================== 内网文件共享'):APP.index('/* ============================ 外置存储设备')]
|
||
_dd_seg = APP[APP.index('async function viewDdns()'):APP.index('/* =', APP.index('function renderDdFields'))]
|
||
chk('ACL 段全部改动点标脏', _acl_seg.count("pageDirty('acl')") >= 14,
|
||
'→ %d 处' % _acl_seg.count("pageDirty('acl')"))
|
||
chk('NFS 段全部改动点标脏', _nfs_seg.count("pageDirty('nfs')") >= 5,
|
||
'→ %d 处' % _nfs_seg.count("pageDirty('nfs')"))
|
||
chk('DDNS 段全部改动点标脏', _dd_seg.count("pageDirty('ddns')") >= 7,
|
||
'→ %d 处' % _dd_seg.count("pageDirty('ddns')"))
|
||
chk('保存/应用成功清脏(acl)', APP.count("pageClean('acl')") >= 3)
|
||
chk('保存/应用成功清脏(nfs)', APP.count("pageClean('nfs')") >= 3)
|
||
chk('DDNS 保存成功清脏', "pageClean('ddns'); setTimeout(viewDdns, 400)" in APP)
|
||
|
||
print('--- 2. 心跳定时器 ---')
|
||
chk('HEARTBEAT_TIMER 句柄已声明', 'let HEARTBEAT_TIMER = null;' in APP)
|
||
chk('boot() 起心跳前先清旧', 'if (HEARTBEAT_TIMER) clearInterval(HEARTBEAT_TIMER);' in APP)
|
||
chk('boot() 里不再有裸 setInterval 心跳',
|
||
'HEARTBEAT_TIMER = setInterval(async () => {' in APP)
|
||
|
||
print('--- 3. 离开终端页断开 PTY ---')
|
||
chk('stopPageTimers 断开旧终端会话',
|
||
"S.page === 'webshell' && typeof wsDisconnect === 'function' && WS.sid" in APP)
|
||
|
||
print('--- 4. 主题导出注释剥离 ---')
|
||
_m = re.search(r'const cmt = s => (.+);', APP)
|
||
chk('cmt 同时剥 /* 与 */', bool(_m) and '/\\/\\*/g' in _m.group(1) and '/\\*\\//g' in _m.group(1))
|
||
|
||
print('--- 5. value 属性转义扫尾 ---')
|
||
# 只对「可能携带外部数据」的插值强制 esc():含点(obj.attr)、含函数调用、
|
||
# 含运算符拼接的一律要求 esc(...) 包裹。
|
||
# 纯裸标识符(map 回调形参等,取值来自源码里的字面量数组)放行,
|
||
# 否则 DHCPv6 前缀下拉 `[56,60,62,64].map(x => ...)` 这种零风险写法会一直误报。
|
||
_value_ok = lambda e: (e.strip().startswith('esc(')
|
||
or not re.search(r'[.()+\[\]]', e))
|
||
_leftover = [m.group(1) for m in re.finditer(r'value="\$\{([^{}]+)\}"', APP)
|
||
if not _value_ok(m.group(1))]
|
||
chk('value="${...}" 含外部数据的已全部 esc()', not _leftover, '→ 漏网 %s' % _leftover[:5])
|
||
|
||
print('--- 6. href 伪协议 ---')
|
||
chk('httpUrl 辅助已定义', 'const httpUrl = u =>' in APP and '/^https?:\\/\\//i' in APP)
|
||
chk('console_url 过 httpUrl', 'href="${esc(httpUrl(d.console_url))}"' in APP)
|
||
chk('DDNS 文档链接过 httpUrl', 'href="${esc(httpUrl(prov.doc))}"' in APP)
|
||
|
||
print('--- 7. LAN 页假输入框 ---')
|
||
chk('lan 保存模块含 dnsmasq', "lan: { save: ['system', 'dnsmasq'], apply: [] }," in APP)
|
||
chk('#ln-gw 显示 dnsmasq.option_gateway', "$w('dnsmasq').option_gateway" in APP)
|
||
chk('#ln-gw/#ln-dom 绑定到 dnsmasq 模块',
|
||
"bindQ('#ln-gw', 'option_gateway'); bindQ('#ln-dom', 'domain');" in APP)
|
||
chk('system 死键不再被 LAN 页写入', "bind('#ln-gw', 'gateway')" not in APP)
|
||
|
||
print('--- 8. UPnP 映射表 ---')
|
||
chk('前端查询不再顺手 start miniupnpd',
|
||
"body: { name: 'miniupnpd', op: 'start' }" not in APP)
|
||
chk('前端走 /api/upnpmap', "api('/api/upnpmap')" in APP)
|
||
chk('helper 实现 read_upnpmap', 'def read_upnpmap(_):' in HELPER)
|
||
chk('helper 注册 read:upnpmap', "'read:upnpmap': read_upnpmap," in HELPER)
|
||
chk('web 路由 /api/upnpmap', "'/api/upnpmap': 'read:upnpmap'," in WEB)
|
||
chk('read_upnpmap 不拉服务(无 start)',
|
||
'def read_upnpmap' in HELPER
|
||
and "'start'" not in HELPER[HELPER.index('def read_upnpmap'):HELPER.index('def read_leases')])
|
||
|
||
print('--- 9. 后台标签页降频 ---')
|
||
chk('心跳在后台跳过', 'if (document.hidden) return;' in APP)
|
||
chk('终端轮询后台降频', 'document.hidden ? 1500 : 120' in APP)
|
||
|
||
print('--- 10. keep_days/keep_rows 不吞 0 ---')
|
||
chk('keep_days 显式判空', 'u.conf.keep_days != null ? u.conf.keep_days : 7' in APP)
|
||
chk('keep_rows 显式判空', 'u.conf.keep_rows != null ? u.conf.keep_rows : 200000' in APP)
|
||
|
||
print('--- 11. 性能与资源收尾(v1.0.5 后端) ---')
|
||
chk('/api/config 单连接取多键', 'def get_cfg_many(keys, default=None):' in WEB
|
||
and 'out = get_cfg_many(keys, {})' in WEB)
|
||
chk('get_cfg_many 用 IN 查询', "'SELECT key,value FROM settings WHERE key IN (%s)'" in WEB)
|
||
chk('cleanup 状态扫描有 TTL 缓存', '_CLEAN_STATUS_TTL = 30.0' in HELPER
|
||
and '_clean_status_cache' in HELPER)
|
||
chk('保存清理配置即失效缓存',
|
||
"indent=2) + '\\n')\n _clean_status_cache_invalidate()" in HELPER)
|
||
chk('执行清理后失效缓存',
|
||
'if not dry:\n _clean_status_cache_invalidate()' in HELPER)
|
||
chk('fs 下载封顶与传输通道对齐(20MB)',
|
||
"size > 20 * 1024 * 1024" in HELPER and '文件超过 512MB' not in HELPER)
|
||
chk('rescue 单元有内存闸(packaging)',
|
||
'MemoryMax=150M' in read('packaging/deb/units/drouter-rescue.service'))
|
||
chk('rescue 单元有内存闸(deploy.sh)',
|
||
'MemoryMax=150M' in read('scripts/deploy.sh'))
|
||
chk('snapshot 单元有内存闸(三处同步)',
|
||
read('packaging/deb/units/drouter-snapshot.service').count('MemoryMax=300M') == 1
|
||
and read('scripts/deploy.sh').count('MemoryMax=300M') >= 1
|
||
and 'MemoryMax=300M' in HELPER[HELPER.index('def _write_snapshot_timer'):HELPER.index('def act_snapshotd_sync')])
|
||
chk('dhcp_release 已登记 DEPS(可选)',
|
||
"'dhcp_release', False," in HELPER)
|
||
|
||
print('--- 12. 守护模块顶层「先用后定义」扫描 ---')
|
||
# v1.0.5 事故:往 drouter-shelld.py 顶部插 MAX_BUF 块时把 MAX_SESSIONS = 8
|
||
# 顶掉了,AST 抽片段的测试全绿,真机一启动就 NameError 崩溃。
|
||
# 这里按模块顶层语句顺序模拟执行:任何 Load 的名字必须已在前面赋过值
|
||
# (不进入函数/类/Lambda 体 —— 函数内的前向引用是合法的)。
|
||
import ast as _ast
|
||
import builtins as _builtins
|
||
|
||
|
||
def _top_level_names(stmt):
|
||
"""本语句内(不下降进函数/类/Lambda 体)所有 Load 的名字。
|
||
推导式变量有自己的作用域,先收集再剔除,否则 `[f(s) for s in x]`
|
||
会被误报成「s 先用后定义」。"""
|
||
loads = []
|
||
comp_vars = set()
|
||
|
||
class V(_ast.NodeVisitor):
|
||
def visit_Name(self, n):
|
||
if isinstance(n.ctx, _ast.Load):
|
||
loads.append(n.id)
|
||
def visit_FunctionDef(self, n):
|
||
pass # 不进入函数体
|
||
def visit_AsyncFunctionDef(self, n):
|
||
pass
|
||
def visit_ClassDef(self, n):
|
||
for d in n.decorator_list:
|
||
self.visit(d)
|
||
for b in n.bases:
|
||
self.visit(b)
|
||
def visit_Lambda(self, n):
|
||
pass
|
||
def _comp(self, n):
|
||
for g in n.generators:
|
||
for t in _ast.walk(g.target):
|
||
if isinstance(t, _ast.Name):
|
||
comp_vars.add(t.id)
|
||
self.generic_visit(n)
|
||
visit_ListComp = _comp
|
||
visit_SetComp = _comp
|
||
visit_DictComp = _comp
|
||
visit_GeneratorExp = _comp
|
||
V().visit(stmt)
|
||
return [x for x in loads if x not in comp_vars]
|
||
|
||
|
||
def _assigned_names(stmt):
|
||
"""本语句顶层(含 if/try/for 等块内,乐观视为已定义)赋出的名字。"""
|
||
out = []
|
||
class V(_ast.NodeVisitor):
|
||
def visit_FunctionDef(self, n):
|
||
out.append(n.name)
|
||
def visit_AsyncFunctionDef(self, n):
|
||
out.append(n.name)
|
||
def visit_ClassDef(self, n):
|
||
out.append(n.name)
|
||
def visit_Import(self, n):
|
||
out.extend((a.asname or a.name).split('.')[0] for a in n.names)
|
||
def visit_ImportFrom(self, n):
|
||
out.extend(a.asname or a.name for a in n.names)
|
||
def visit_Name(self, n):
|
||
if isinstance(n.ctx, (_ast.Store, _ast.Del)):
|
||
out.append(n.id)
|
||
V().visit(stmt)
|
||
return out
|
||
|
||
|
||
_BUILTINS = set(dir(_builtins)) | {'__name__', '__file__', '__doc__'}
|
||
for rel in ('backend/drouter-shelld.py', 'backend/drouter-web.py',
|
||
'backend/drouter-helpd.py', 'backend/drouter-logd.py',
|
||
'backend/drouter-snapshotd.py', 'backend/drouter-rescue.py',
|
||
'backend/drouter-helper.py'):
|
||
tree = _ast.parse(read(rel), rel)
|
||
defined = set(_BUILTINS)
|
||
bad = []
|
||
for stmt in tree.body:
|
||
for nm in _top_level_names(stmt):
|
||
if nm not in defined and nm not in bad:
|
||
bad.append(nm)
|
||
defined.update(_assigned_names(stmt))
|
||
chk('%s 顶层无先用后定义' % rel.split('/')[-1], not bad, '→ %s' % bad)
|
||
|
||
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
|
||
sys.exit(1 if fails else 0)
|