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

368 lines
14 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 -*-
"""前后端 API 契约交叉检查。
要抓的就是 1.0.5 那个 bug 的整类:前端 `api('/api/x', {method:'POST', body:{k:...}})`
发出去的字段,Web 层压根没读(`b.get('只')`),于是被静默丢掉、helper 拿到空值,
而接口还返回 ok —— 用户点了「安装」什么都没发生,却提示「全部依赖已安装完成」。
光靠 code review 抓不到这类:字段名两边都「看起来对」,只是不在同一层。
所以这里机械地把三层对齐:
前端 body 字段 × Web 层实际读取的字段 × Web 层转发给 helper 的字段
另外还查:前端调了但后端根本没有的路由(404)、前端 body 用了变量无法静态解析的调用。
用法: python _dev/t-apicheck.py
退出码 0 = 全过。
"""
import io
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
APP = os.path.join(ROOT, 'web', 'app.js')
WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
FAIL = []
def t(name, cond, detail=''):
print(' %s %s%s' % ('[OK]' if cond else '[NG]', name,
(' -> ' + detail) if (detail and not cond) else ''))
if not cond:
FAIL.append(name)
return 0 if cond else 1
# ---------------------------------------------------------------- JS 侧解析
def _match_brace(s, i):
"""s[i] == '{' 时返回配对 '}' 的下标。"""
assert s[i] == '{'
depth = 0
while i < len(s):
c = s[i]
if c in '"\'':
i = _skip_str(s, i)
continue
if c == '{':
depth += 1
elif c == '}':
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _skip_str(s, i):
q = s[i]
i += 1
while i < len(s):
if s[i] == '\\':
i += 2
continue
if s[i] == q:
return i + 1
i += 1
return i
def _obj_keys(obj):
"""取对象字面量的一层 key(跳过嵌套的 {} [] () 与字符串)。"""
keys = []
depth = 0
i = 0
cur = ''
while i < len(obj):
c = obj[i]
if c in '"\'':
j = _skip_str(obj, i)
cur += obj[i:j]
i = j
continue
if c in '{[(':
depth += 1
elif c in '}])':
depth -= 1
if c == ':' and depth == 1:
k = cur.strip()
m = re.match(r"^['\"]([^'\"]+)['\"]$", k.strip())
if m:
keys.append(m.group(1))
elif re.match(r'^[A-Za-z_$][\w$]*$', k.strip()):
keys.append(k.strip()) # 变量当 key:记录下来人工看
cur = ''
i += 1
# 跳过值
while i < len(obj) and obj[i] not in ',}':
if obj[i] in '"\'':
i = _skip_str(obj, i)
continue
if obj[i] in '{[(':
i = _match_any(obj, i)
continue
i += 1
continue
if c == ',' and depth == 1:
cur = ''
i += 1
continue
cur += c
i += 1
return keys
def _match_any(s, i):
pairs = {'{': '}', '[': ']', '(': ')'}
close = pairs[s[i]]
depth = 0
while i < len(s):
c = s[i]
if c in '"\'':
i = _skip_str(s, i)
continue
if c in pairs:
depth += 1
elif c in '}]).':
if c in ')]}':
depth -= 1
if depth == 0 and c == close:
return i + 1
i += 1
return i
def parse_frontend(js):
"""返回 [(path, method, body_keys_or_None, line)],只收字面量路径。"""
out = []
for m in re.finditer(r"""\bapi\(\s*(['"])(/[^'"]*)\1\s*,""", js):
path = m.group(2)
rest = js[m.end():]
# options 对象
mo = re.match(r'\s*\{', rest)
if not mo:
out.append((path, 'GET', None, js[:m.start()].count('\n') + 1))
continue
end = _match_brace(rest, mo.end() - 1)
opts = rest[mo.end():end] if end > 0 else rest
line = js[:m.start()].count('\n') + 1
mm = re.search(r"method\s*:\s*['\"](\w+)['\"]", opts)
method = (mm.group(1) if mm else 'GET').upper()
mb = re.search(r'\bbody\s*:', opts)
keys = None
if mb:
tail = opts[mb.end():].lstrip()
if tail.startswith('{'):
e2 = _match_brace(tail, 0)
keys = _obj_keys(tail[1:e2]) if e2 > 0 else []
else:
keys = None # body 是变量,静态解析不了
out.append((path, method, keys, line))
return out
# ------------------------------------------------------------- Python 侧解析
def parse_backend(py):
"""把 Web 层每个 `if p == '/api/x' and method == '...'` 块的原文切出来。
也要认 `p.startswith('/api/files')` 这类前缀分发(文件管理 / Web 终端 /
主题都走前缀),否则会把正常的子路由全报成「后端没这个接口」。
"""
lines = py.split('\n')
routes = {}
prefixes = {}
i = 0
# 路由判断有三种写法:p == '/api/x'、p in ('/api/x', '/api/x/op')、
# p.startswith('/api/files')。三种都要认,否则正常的子路由
# 会被误报成「后端没这个接口」。
start = re.compile(r"^\s*if\s+(?:p\s*==\s*|.*\bin\s*)\(?\s*'(/api/[^']+)'")
pfx = re.compile(r"^\s*if\s+p\.startswith\(\s*'(/api/[^']+)'")
while i < len(lines):
m = start.match(lines[i])
mp = pfx.match(lines[i])
if m or mp:
base = len(lines[i]) - len(lines[i].lstrip())
j = i
body = [lines[i]]
while j + 1 < len(lines):
nxt = lines[j + 1]
if nxt.strip() and (len(nxt) - len(nxt.lstrip())) <= base:
break
body.append(nxt)
j += 1
seg = '\n'.join(body)
mm = re.search(r"method\s*==\s*'(\w+)'", lines[i])
method = mm.group(1).upper() if mm else 'ANY'
if mp and not m:
prefixes.setdefault(mp.group(1), []).append((method, seg))
else:
# 同一行里可能列了多个路径(p in ('/a', '/a/op')),
# 全部登记到同一个块上。
paths = re.findall(r"'(/api/[^']+)'", lines[i])
for pth in paths:
routes.setdefault(pth, []).append((method, seg))
i = j + 1
continue
i += 1
return routes, prefixes
def block_reads(seg):
"""Web 层在这个路由里读了哪些 body 字段。"""
keys = set()
for m in re.finditer(r"""b\.get\(\s*['"]([\w.-]+)['"]""", seg):
keys.add(m.group(1))
for m in re.finditer(r"""b\.pop\(\s*['"]([\w.-]+)['"]""", seg):
keys.add(m.group(1))
for m in re.finditer(r"""b\[['"](\w+)['"]\]""", seg):
keys.add(m.group(1))
if re.search(r"""['"]values?['"]\s*:""", seg) and 'json.loads' in seg:
keys.add('*') # 整包透传,无法逐字段核对
# 整个 body 原样转发给 helper:self.helper('x', b) / dict(b, op=op)
# 这种写法下 b 里的每个字段都到得了 helper,逐字段核对没有意义。
if re.search(r"""self\.helper\(\s*['"][\w.-]+['"]\s*,\s*b\s*[,)]""", seg):
keys.add('*')
if re.search(r"""\bdict\(b\s*,""", seg):
keys.add('*')
# 路由直接委托给独立方法(self.migrate_preview() 等),
# 字段读取在别处,静态看不出读没读 —— 交给该方法自己的测试去覆盖。
if re.search(r"""\breturn\s+self\.[a-z_]+\(\)""", seg):
keys.add('*')
return keys
def block_forwards(seg):
"""Web 层转发给 helper 的 dict 里带了哪些键。"""
keys = set()
for m in re.finditer(r"""['"]op['"]\s*:""", seg):
pass
for m in re.finditer(r"""self\.helper\(\s*['"][\w.-]+['"]\s*,\s*\{(.*?)\}\s*,""", seg, re.S):
for k in re.finditer(r"""['"]([\w.-]+)['"]\s*:""", m.group(1)):
keys.add(k.group(1))
return keys
def resolve_body_var(js, call_line, var):
"""body 传的是变量时,往上找最近一次 `var = {` 字面量赋值,把字段解析出来。"""
head = '\n'.join(js.split('\n')[:call_line - 1])
best = None
for m in re.finditer(r'\b(?:const|let|var)?\s*%s\s*=\s*\{' % re.escape(var), head):
best = m
if not best:
return None
tail = head[best.end() - 1:]
e = _match_brace(tail, 0)
if e <= 0:
return None
return _obj_keys(tail[1:e])
def main():
js = io.open(APP, encoding='utf-8').read()
py = io.open(WEB, encoding='utf-8').read()
calls = parse_frontend(js)
routes, prefixes = parse_backend(py)
def segs_of(path, method):
out = [s for m, s in routes.get(path, []) if m in (method, 'ANY')]
for pfx, lst in prefixes.items():
if path.startswith(pfx):
out += [s for m, s in lst if m in (method, 'ANY')]
return out
print('---- 前端调用 → 后端路由 ----')
n = 0
unknown = []
dynamic = []
multi = {}
for path, method, keys, line in calls:
multi.setdefault((path, method), []).append((keys, line))
for (path, method), lst in sorted(multi.items()):
segs = segs_of(path, method)
if not segs:
unknown.append('%s %s (app.js:%d)' % (method, path, lst[0][1]))
continue
n += t('%s %s 有后端路由' % (method, path), True)
for u in unknown:
n += t('前端调用的路由存在: %s' % u, False, '后端 drouter-web.py 里找不到这个路径')
dyn = [c for c in calls if not c[0].startswith('/api/')]
for d in dyn:
dynamic.append(d[0])
n += t('所有 api() 路径都是字面量(便于静态核对)', not dynamic,
'这些是变量拼接,需人工确认: %s' % ', '.join(sorted(set(dynamic))[:8]))
print('\n---- POST body 字段是否被 Web 层读到 ----')
manual = []
for (path, method), lst in sorted(multi.items()):
if method != 'POST':
continue
segs = segs_of(path, method)
if not segs:
continue
seg = '\n'.join(segs)
reads = block_reads(seg)
fwd = block_forwards(seg)
for keys, line in lst:
if keys is None:
mv = re.search(r'\bbody\s*:\s*([A-Za-z_$][\w$]*)\s*[,}]', js[
js.find('\n', 0):][0:0] or '')
manual.append('%s @%d' % (path, line))
continue
for k in keys:
hit = (k in reads) or ('*' in reads) or (k in fwd)
n += t('%s 传 %s 被读取/转发' % (path, k), hit,
'Web 层没读这个字段 —— 会被静默丢弃(keys/only 那类 bug)')
if manual:
print(' [--] %d 处 body 用变量,需人工核对: %s'
% (len(manual), ', '.join(manual)))
print('\n---- Web 层转发的字段名 helper 认不认 ----')
hp = io.open(HELPER, encoding='utf-8').read()
hread = set(re.findall(r"""p\.get\(\s*['"]([\w.-]+)['"]""", hp))
hread |= set(re.findall(r"""payload\.get\(\s*['"]([\w.-]+)['"]""", hp))
hread |= set(re.findall(r"""args\.get\(\s*['"]([\w.-]+)['"]""", hp))
hread |= set(re.findall(r"""cfg\.get\(\s*['"]([\w.-]+)['"]""", hp))
for path, lst in sorted(routes.items()):
seg = '\n'.join(s for _, s in lst)
fwd = block_forwards(seg)
bad = [k for k in fwd if k not in hread and k != 'op']
n += t('%s 转发的字段 helper 都认' % path, not bad,
'helper 里找不到 p.get(%s)' % bad[0] if bad else '')
print('\n---- 分支可达性:readings 之类的「通用表」不能吃掉写路由 ----')
# 1.0.5 最大的 P0:dispatch 开头有一张 `readings` 字典同时匹配 GET 和 POST,
# 而 /api/acl、/api/share、/api/docker、/api/ddns、/api/theme、/api/ulog
# 六个模块的写分支在它之后 —— POST 永远走不到,返回的却是读结果 + ok:true,
# 前端照样弹「保存完成」。静态比对路径存在性完全查不出来(路径确实在文件里),
# 必须检查「谁先 return」。
rpos = py.find('readings = {')
guard = re.search(r'if p in readings and[^\n]*', py[rpos:rpos + 4000]) if rpos >= 0 else None
n += t('存在 readings 通用只读表', bool(guard))
if guard:
g = guard.group(0)
n += t('readings 拦截 POST 时有白名单排除(否则会遮蔽写路由)',
"'POST'" not in g or '_has_post_branch' in g,
'readings 仍在拦 POST 且没有排除名单 → 写路由不可达')
# 每个「有写 op」的路径都必须有一条 method == 'POST' 的分支,
# 且它自身不能只出现在 readings 表里。
for pth in ('/api/acl', '/api/share', '/api/docker', '/api/ddns',
'/api/theme', '/api/ulog'):
segs2 = segs_of(pth, 'POST')
has_write = any(re.search(r"""helper\(\s*['"](?:acl|share|docker|ddns|theme|ulog)['"]""", s)
and 'op' in s for s in segs2)
n += t('%s 的 POST 写分支可达' % pth, has_write,
'没有找到处理 op 的 POST 分支 —— 可能被 readings 遮蔽了')
print('\n共 %d 项检查,失败 %d 项' % (n + len(FAIL), len(FAIL)))
if FAIL:
print('失败项:')
for f in FAIL:
print(' - ' + f)
return 1 if FAIL else 0
if __name__ == '__main__':
sys.exit(main())