本轮按「每个菜单、每个功能」逐项排查,重点是家庭宽带上网必经的
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 回收、虚拟地址回收、服务停。
368 lines
14 KiB
Python
368 lines
14 KiB
Python
#!/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())
|