Files
liuzhuohua f602e69f1b 全面审计:修 30 处静默失效/安全/资源缺陷,含 4 个 P0
本轮按「每个菜单、每个功能」逐项排查,重点是家庭宽带上网必经的
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 回收、虚拟地址回收、服务停。
2026-10-02 18:58:23 +08:00

228 lines
11 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.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)