Files
drouter/_dev/live-audit106.py
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

243 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.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('结果: 全部通过')