本轮按「每个菜单、每个功能」逐项排查,重点是家庭宽带上网必经的
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 回收、虚拟地址回收、服务停。
243 lines
11 KiB
Python
243 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""v1.0.6 真机验收(192.168.7.3)。
|
||
|
||
重点验六个曾被 readings 遮蔽的模块:它们的 POST 必须走到自己的写分支,
|
||
而不是被 readings 吃掉后返回「读数据 + ok:true」—— 那种情况下前端会
|
||
提示「保存完成」而实际什么都没发生。
|
||
|
||
判定方式:用一个**不存在的 op**去打,如果请求能到模块自己的 handler,
|
||
返回的一定是模块级错误(BAD_OP / 「不支持的操作:xxx」),
|
||
而如果又被 readings 吃掉,返回的就是 ok:true + 读出来的数据。
|
||
"""
|
||
import json
|
||
import ssl
|
||
import sys
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
BASE = sys.argv[1] if len(sys.argv) > 1 else 'https://192.168.7.3:8443'
|
||
CTX = ssl.create_default_context()
|
||
CTX.check_hostname = False
|
||
CTX.verify_mode = ssl.CERT_NONE
|
||
fails = []
|
||
|
||
# 这几个路径在修复前会被 readings 吃掉。它们的 GET 都返回配置数据,
|
||
# 所以「POST 返回了读数据」就是遮蔽仍在的信号。
|
||
SHADOWED = {
|
||
'/api/theme': 'config',
|
||
'/api/acl': 'cfg',
|
||
'/api/share': 'cfg',
|
||
'/api/docker': 'info',
|
||
'/api/ddns': 'cfg',
|
||
'/api/ulog': 'conf',
|
||
}
|
||
|
||
|
||
def chk(label, cond, extra=''):
|
||
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
|
||
if not cond:
|
||
fails.append(label)
|
||
|
||
|
||
def call(path, body=None, tok=None, method=None):
|
||
url = BASE + path
|
||
data = json.dumps(body).encode() if body is not None else None
|
||
req = urllib.request.Request(url, data=data,
|
||
method=method or ('POST' if data else 'GET'))
|
||
req.add_header('Content-Type', 'application/json')
|
||
if tok:
|
||
req.add_header('X-Token', tok)
|
||
try:
|
||
with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
|
||
return json.loads(r.read().decode('utf-8', 'replace'))
|
||
except urllib.error.HTTPError as e:
|
||
try:
|
||
return json.loads(e.read().decode('utf-8', 'replace'))
|
||
except Exception:
|
||
return {'ok': False, 'http': e.code}
|
||
except Exception as e:
|
||
return {'ok': False, 'msg_cn': str(e)[:200]}
|
||
|
||
|
||
r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
|
||
TOK = (r.get('data') or {}).get('token') or ''
|
||
chk('登录拿到 token', bool(TOK))
|
||
if not TOK:
|
||
print('登录失败:', json.dumps(r, ensure_ascii=False)[:300])
|
||
sys.exit(1)
|
||
|
||
print('--- 1. readings 不再遮蔽六个模块的 POST 写分支 ---')
|
||
for p, leak_key in sorted(SHADOWED.items()):
|
||
# 先确认 GET 确实会返回这个键(说明「读数据」长这样,才好辨认)
|
||
g = call(p, None, TOK)
|
||
has_read = g.get('ok') is True and isinstance(g.get('data'), dict) \
|
||
and leak_key in (g.get('data') or {})
|
||
# 再用不存在的 op 打 POST
|
||
r = call(p, {'op': '__probe__'}, TOK)
|
||
leaked = r.get('ok') is True and isinstance(r.get('data'), dict) \
|
||
and leak_key in (r.get('data') or {})
|
||
chk(' %s POST 走专属分支(未返回读数据)' % p, not leaked,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:130]
|
||
+ ('' if has_read else ' [注: GET 未含 %s]' % leak_key))
|
||
|
||
print('--- 2. 六个模块的真实写操作可用(读回配置确认没写坏)---')
|
||
# theme: GET /api/theme 的 data 里 themes 是数组、active 是当前主题 id
|
||
g = call('/api/theme', None, TOK)
|
||
td = g.get('data') or {}
|
||
cur = td.get('active') or ''
|
||
names = [t.get('id') for t in (td.get('themes') or []) if isinstance(t, dict)]
|
||
print(' 当前主题:%r,可选:%r' % (cur, names))
|
||
if names:
|
||
tgt = names[0] if names[0] != cur else (names[1] if len(names) > 1 else names[0])
|
||
r = call('/api/theme', {'op': 'apply', 'id': tgt}, TOK)
|
||
if r.get('ok') is not True: # apply 的参数名可能是 name 而非 id
|
||
r = call('/api/theme', {'op': 'apply', 'name': tgt}, TOK)
|
||
chk(' 主题切换生效(写分支真的执行了)', r.get('ok') is True,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:140])
|
||
now = (call('/api/theme', None, TOK).get('data') or {}).get('active')
|
||
chk(' 切换后读回 active 已变', now == tgt, '→ active=%r 期望 %r' % (now, tgt))
|
||
if cur and cur != tgt:
|
||
call('/api/theme', {'op': 'apply', 'id': cur}, TOK)
|
||
call('/api/theme', {'op': 'apply', 'name': cur}, TOK)
|
||
else:
|
||
print(' [跳过] 无可切换主题')
|
||
|
||
# ddns: 非法配置必须被拒(说明写分支在校验,不是照单全收)
|
||
r = call('/api/ddns', {'op': 'save', 'enable': True, 'provider': '__nope__',
|
||
'domain': 'x.example.com'}, TOK)
|
||
chk(' DDNS 非法 provider 被拒(写分支在校验)',
|
||
r.get('ok') is False, '→ %s' % json.dumps(r, ensure_ascii=False)[:140])
|
||
|
||
# ulog: op 名是 save_conf(不是 save)
|
||
g = call('/api/ulog', {'op': 'conf'}, TOK)
|
||
old = ((g.get('data') or {}).get('conf') or {}).get('keep_days')
|
||
r = call('/api/ulog', {'op': 'save_conf', 'keep_days': (old or 7)}, TOK)
|
||
chk(' 流日志 save_conf 成功(写分支可用)', r.get('ok') is True,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:140])
|
||
now = ((call('/api/ulog', {'op': 'conf'}, TOK).get('data') or {})
|
||
.get('conf') or {}).get('keep_days')
|
||
chk(' keep_days 读回一致', now == (old or 7), '→ %r 期望 %r' % (now, old or 7))
|
||
|
||
# acl / share / docker: 只读探测,不改用户配置
|
||
for p, body in (('/api/acl', {'op': 'read'}), ('/api/share', {'op': 'read'}),
|
||
('/api/docker', {'op': 'read'})):
|
||
r = call(p, body, TOK)
|
||
chk(' %s 专属分支可达(返回模块级反馈)' % p,
|
||
r.get('ok') is False or r.get('ok') is True,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:110])
|
||
|
||
print('--- 3. 防火墙预检 check_only 不写配置库 ---')
|
||
b = ((call('/api/config', None, TOK).get('data') or {}).get('nft_v4') or {})
|
||
r = call('/api/apply', {'module': 'nft_v4', 'check_only': True, 'live': False,
|
||
'data': dict(b, table='inet drouter_v106probe')}, TOK)
|
||
chk(' 预检给出明确结论', r.get('ok') is not None,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:130])
|
||
a = ((call('/api/config', None, TOK).get('data') or {}).get('nft_v4') or {})
|
||
chk(' 预检未把数据写进配置库', a.get('table') != 'inet drouter_v106probe',
|
||
'→ table=%r' % a.get('table'))
|
||
chk(' 预检前后配置库逐字节一致',
|
||
json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True))
|
||
|
||
print('--- 4. 静态租约字段契约(enabled/name/is_static)---')
|
||
def cfg(mod):
|
||
"""读单个配置模块。/api/config 无参数一次返回全部,不要用 /api/config/<mod>。"""
|
||
return (call('/api/config', None, TOK).get('data') or {}).get(mod) or {}
|
||
|
||
|
||
# 注意:/api/leases 的 data 直接是数组,不是 {'leases': [...]}。
|
||
# 而且它读的是 dnsmasq 的**活动租约**;静态绑定写在配置库的 static_leases 里,
|
||
# 两边不是一回事 —— 所以静态绑定要从 /api/config 读回。
|
||
MAC = 'AA:BB:CC:DD:EE:01'
|
||
r = call('/api/lease/static', {'mac': MAC, 'ip': '192.168.7.240',
|
||
'host': 'v106probe'}, TOK)
|
||
chk(' 转静态接口成功', r.get('ok') is True,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:150])
|
||
dns = cfg('dnsmasq')
|
||
sl = dns.get('static_leases') or []
|
||
hit = [x for x in sl if str(x.get('mac') or '').lower() == MAC.lower()]
|
||
chk(' 静态绑定写进了配置库(渲染器读得到)', bool(hit),
|
||
'→ static_leases 共 %d 条' % len(sl))
|
||
if hit:
|
||
h = hit[0]
|
||
chk(' 带 enabled=True(v_bool 才认它)', h.get('enabled') is True,
|
||
'→ %s' % json.dumps(h, ensure_ascii=False)[:180])
|
||
chk(' 用 name 而非 host 作主机名(渲染器读 name)',
|
||
h.get('name') == 'v106probe', '→ %r' % h.get('name'))
|
||
lr = call('/api/leases', None, TOK)
|
||
ld = lr.get('data') or []
|
||
chk(' /api/leases 仍可读(未被本轮改动破坏)', isinstance(ld, list),
|
||
'→ %d 条活动租约' % (len(ld) if isinstance(ld, list) else -1))
|
||
# 清理:把测试用的静态绑定删掉,别留在用户机器上
|
||
call('/api/config', {'module': 'dnsmasq',
|
||
'data': dict(dns, static_leases=[
|
||
x for x in sl if str(x.get('mac') or '').lower() != MAC.lower()])},
|
||
TOK)
|
||
left = [x for x in (cfg('dnsmasq').get('static_leases') or [])
|
||
if str(x.get('mac') or '').lower() in (MAC.lower(), 'aa:bb:cc:dd:ee:02')]
|
||
chk(' 测试数据已清理', not left, '→ 残留 %r' % left)
|
||
|
||
print('--- 5. SNMP 配置注入过滤 ---')
|
||
r = call('/api/kern', {'op': 'save', 'snmp': {
|
||
'enable': False, 'port': 161, 'community': 'public',
|
||
'sysname': 'drouter\nrwcommunity evil', 'location': 'lab', 'contact': 'me'}}, TOK)
|
||
chk(' 含换行的 sysname 被净化后接受', r.get('ok') is True,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:130])
|
||
s = ((call('/api/kern', {'op': 'read', 'snmp': 1}, TOK).get('data') or {}).get('snmp') or {})
|
||
sn = str(s.get('sysname') or '')
|
||
chk(' 库里 sysname 无换行', '\n' not in sn and '\r' not in sn, '→ %r' % sn)
|
||
chk(' 库里 sysname 无 rwcommunity(没被升级成可写团体名)',
|
||
'rwcommunity' not in sn, '→ %r' % sn)
|
||
# 还原
|
||
call('/api/kern', {'op': 'save', 'snmp': {
|
||
'enable': False, 'port': 161, 'community': 'public',
|
||
'sysname': 'drouter', 'location': '', 'contact': ''}}, TOK)
|
||
|
||
print('--- 6. PPPoE 多拨 service_name 白名单 ---')
|
||
# 保存时会先校验 WAN 网卡名,所以必须把 iface 带上,
|
||
# 否则请求在更早一道就被拦掉,压根到不了 service_name 校验。
|
||
IFACE = cfg('system').get('wan_iface') or 'ens19'
|
||
print(' 用 iface=%s 试' % IFACE)
|
||
BAD = 'x" ; }'
|
||
r = call('/api/pppoe-multi', {'op': 'save', 'iface': IFACE, 'sessions': [{
|
||
'user': 'probe@isp', 'password': 'p', 'weight': 1,
|
||
'mtu': 1492, 'mru': 1400, 'service_name': BAD, 'isp': 'auto'}]}, TOK)
|
||
chk(' 含引号/空格的 service_name 被拒',
|
||
r.get('ok') is False and '服务名' in str(r.get('msg_cn')),
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:200])
|
||
r = call('/api/pppoe-multi', None, TOK)
|
||
chk(' 多拨配置未被污染(仍无恶意 service_name)',
|
||
all(BAD not in json.dumps(s, ensure_ascii=False)
|
||
for s in ((r.get('data') or {}).get('sessions') or [])),
|
||
'→ %d 个会话' % len((r.get('data') or {}).get('sessions') or []))
|
||
|
||
print('--- 7. 构建保护模式下的 rollback+reload ---')
|
||
import subprocess
|
||
try:
|
||
out = subprocess.run(
|
||
['bash', 'devtools/rsudo.sh',
|
||
"test -e /etc/drouter/BUILD_MODE && echo ON || echo OFF"],
|
||
capture_output=True, text=True, timeout=40).stdout
|
||
except Exception as e:
|
||
out = 'ERR %s' % e
|
||
mode = out.strip().splitlines()[-1] if out.strip() else '?'
|
||
print(' 目标机 BUILD_MODE:%s' % mode)
|
||
if 'ON' in out:
|
||
r = call('/api/rollback', {'ts': '19700101-000000', 'reload': True}, TOK)
|
||
chk(' 保护模式下 rollback+reload 被拒', r.get('ok') is False,
|
||
'→ %s' % json.dumps(r, ensure_ascii=False)[:150])
|
||
else:
|
||
print(' [跳过] 未开启构建保护模式')
|
||
|
||
print('--- 8. 关键服务仍活着 ---')
|
||
for p in ('/api/health', '/api/metrics', '/api/dhcp'):
|
||
r = call(p, None, TOK)
|
||
chk(' %s 可用' % p, r.get('ok') is not False or r.get('http') != 502,
|
||
'' if r.get('ok') else '→ %s' % json.dumps(r, ensure_ascii=False)[:100])
|
||
|
||
print()
|
||
if fails:
|
||
print('结果: %d 项失败 → %s' % (len(fails), fails))
|
||
sys.exit(1)
|
||
print('结果: 全部通过')
|