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

271 lines
10 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 -*-
"""
drouter 统一日志采集守护脚本(由 drouter-logd.timer 定时调用,#11)
职责:
1. 每隔一小段时间采集一次各源日志(防火墙 / 连接跟踪 / WAN / DDNS / 应用 / 系统)
2. 规范化后追加到 /var/log/drouter/ulog.jsonl(这样即使 journal 轮转,历史仍在)
3. 按保留策略(保留天数 + 最大条数)清理归档
4. 全程写结构化日志,便于自检
策略来源:/etc/drouter/generated/log.conf(由 Web 界面写入),本脚本只读。
设计原则:
* 轻量:单次采集默认只取 800 条,间隔 1 分钟,对 4GB/2CPU 的小机器友好。
* 安全:只读系统信息,不修改任何网络状态,也不加载任何内核规则。
* 幂等:重复运行不会重复归档(同一时间窗的记录按 ts+src+内容去重)。
"""
import json
import os
import subprocess
import sys
import hashlib
from datetime import datetime
# ---------------------------------------------------------------- PATH 归一化
# Debian 上非 root / 非登录 shell 的 PATH 里没有 /usr/sbin 和 /sbin,而 nft、
# dnsmasq、radvd、chronyd 等路由器关键命令全都装在 /usr/sbin 下。
# systemd 服务的默认 PATH 是 /usr/local/bin:/usr/bin:/bin,于是所有 sh(['nft', ...])
# 都会以「未找到命令」静默失败 —— 表现是防火墙页面空白、日志为空。这里补上。
_SBIN_DIRS = ('/usr/local/sbin', '/usr/sbin', '/sbin')
def _ensure_sbin_path():
cur = os.environ.get('PATH') or ''
parts = [p for p in cur.split(os.pathsep) if p]
for d in _SBIN_DIRS:
if d not in parts and os.path.isdir(d):
parts.insert(0, d)
os.environ['PATH'] = os.pathsep.join(parts)
_ensure_sbin_path()
HELPER = '/opt/drouter/backend/drouter-helper.py'
LOG_DIR = '/var/log/drouter'
ARCHIVE = os.path.join(LOG_DIR, 'ulog.jsonl')
CONF = '/etc/drouter/generated/log.conf'
# 单次采集上限与时间窗(由 timer 间隔决定,宁小勿大,避免日志风暴)
COLLECT_LIMIT = 800
SINCE = '5 min ago'
CALL_TIMEOUT = 120
def log(level, code, msg_cn, detail=None):
try:
os.makedirs(LOG_DIR, exist_ok=True)
rec = {'ts': datetime.now().isoformat(timespec='seconds'), 'level': level,
'module': 'ulogd', 'code': code, 'msg_cn': msg_cn,
'detail': detail or ''}
line = json.dumps(rec, ensure_ascii=False)
for fn in ('all.jsonl', 'ulogd.jsonl'):
with open(os.path.join(LOG_DIR, fn), 'a', encoding='utf-8') as f:
f.write(line + '\n')
except Exception:
pass
def call(action, payload=None, timeout=CALL_TIMEOUT):
try:
p = subprocess.run(
['/usr/bin/python3', HELPER, action,
json.dumps(payload or {}, ensure_ascii=False)],
capture_output=True, text=True, timeout=timeout, errors='replace')
return json.loads(p.stdout or '{}')
except Exception as e:
return {'ok': False, 'msg_cn': str(e)}
def load_conf():
conf = {'enabled': True, 'archive': True, 'keep_days': 7,
'keep_rows': 200000, 'min_level': 'debug'}
try:
with open(CONF, encoding='utf-8') as f:
saved = json.load(f) or {}
if isinstance(saved, dict):
conf.update({k: v for k, v in saved.items() if k != 'sources'})
except Exception:
pass
return conf
def recent_keys(n=4000):
"""读出归档尾部若干条的指纹,用于去重。"""
keys = set()
try:
if not os.path.isfile(ARCHIVE):
return keys
with open(ARCHIVE, encoding='utf-8', errors='replace') as f:
lines = f.read().splitlines()[-n:]
for ln in lines:
try:
r = json.loads(ln)
except Exception:
continue
keys.add(_fp(r))
except Exception:
pass
return keys
def _fp(r):
"""记录指纹:时间 + 源 + 主体内容。"""
return '|'.join([str(r.get('ts', '')), str(r.get('src', '')),
str(r.get('action', '')), str(r.get('saddr', '')),
str(r.get('daddr', '')), str(r.get('sport', '')),
str(r.get('dport', '')), str(r.get('msg_cn', ''))[:120]])
def _disk_ok(path):
"""归档前的磁盘底线检查。
和 drouter-snapshotd 同一套逻辑,那边有、这边原先没有。
缺了它的后果不是「写失败」这么简单:根分区将满时归档写不进去,
而 _ulog_archive_write 把异常吞成 return 0 —— 日志看起来「一切正常」,
只是流量统计从此静默停更,用户完全不知道原因。
"""
try:
st = os.statvfs(path if os.path.isdir(path) else '/')
free_gb = st.f_bavail * st.f_frsize / (1024.0 ** 3)
if free_gb < 0.5:
return False, '剩余空间 %.2f GB,不足 0.5 GB' % free_gb
return True, ''
except Exception as e:
return True, str(e)
def main():
conf = load_conf()
# 0) 累计流量落盘:与日志开关无关,只要本轮跑就必须记一次,
# 否则跨重启的「历史累计已上传/已下载」会停滞不前。
try:
r = call('net_totals_update')
if not r.get('ok'):
log('warn', 'NET_TOTAL_FAIL', '累计流量落盘失败:%s' % r.get('msg_cn'))
except Exception as e:
log('warn', 'NET_TOTAL_FAIL', '累计流量落盘异常:%s' % e)
if not conf.get('enabled', True):
log('info', 'ULOG_SKIP', '统一日志已关闭,跳过本次采集')
return 0
if not conf.get('archive', True):
log('info', 'ULOG_SKIP', '归档已关闭,跳过本次采集')
return 0
# 磁盘将满时直接跳过采集:写不进去还要一直试,只会把失败信息刷满日志
ok, why = _disk_ok(LOG_DIR)
if not ok:
log('error', 'ULOG_DISK_LOW', '磁盘空间不足,跳过本次日志采集:%s' % why)
return 1
# 1) 采集 + 归档(helper 内部完成归档与清理)
r = call('ulog', {'op': 'archive', 'since': SINCE, 'limit': COLLECT_LIMIT})
if not r.get('ok'):
log('error', 'ULOG_ARCHIVE_FAIL', '日志归档失败:%s' % r.get('msg_cn'))
return 1
d = r.get('data') or {}
n = d.get('archived', 0)
col = d.get('collected', 0)
if n:
log('info', 'ULOG_ARCHIVE_OK', '已归档 %d 条日志(采集 %d 条)' % (n, col),
{'sources': d.get('sources'), 'prune': d.get('prune')})
else:
log('debug', 'ULOG_ARCHIVE_IDLE', '本次无新增日志(采集 %d 条)' % col)
errs = d.get('errors') or {}
if errs:
log('warn', 'ULOG_SRC_ERR', '部分日志源采集失败:%s' %
';'.join('%s=%s' % (k, v) for k, v in errs.items()), errs)
# 2) 额外做一次去重压缩(同指纹的记录只留一份,防止 timer 重叠导致重复)
deduped = dedupe_archive()
if deduped:
log('info', 'ULOG_DEDUP', '归档去重移除 %d 条重复记录' % deduped)
return 0
def dedupe_archive():
"""把归档文件里的重复记录去掉(保留首次出现的位置)。
两个要点(都是踩过的坑):
1. **必须加锁**。过去没有任何互斥:手工跑一次和 timer 重叠、或者采集进程
在读完之后又追加了新行,最后用「按旧内容生成的 .tmp」整体替换,
新追加的记录就被无声吞掉了。
2. **流式处理**。过去是 `f.read().splitlines()` 把整个文件变成 Python 对象
列表,再 `join` 成一份新字符串 —— 归档越大 CPU/内存/IO 越重,
每分钟跑一次,20 万行在 Python 对象开销下可以占几百 MB。
现在逐行读、逐行写,只在内存里留一份「已见指纹」。
"""
try:
# 局部 import:这个函数会被单测单独抽出来 exec,不能依赖模块级名字
import hashlib
if not os.path.isfile(ARCHIVE):
return 0
try:
import fcntl # Windows 上没有:那里不做锁,但去重照跑
except ImportError:
fcntl = None
f = open(ARCHIVE, 'r', encoding='utf-8', errors='replace')
tmp = ARCHIVE + '.tmp'
seen = set()
total, kept = 0, 0
try:
if fcntl is not None:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception:
# 抢不到锁说明有人在写(或上一次去重还没完):这一轮跳过,
# 总比把别人刚追加的日志覆盖掉强。
return 0
with open(tmp, 'w', encoding='utf-8') as w:
for ln in f:
ln = ln.rstrip('\n')
total += 1
if not ln.strip():
continue
try:
k = _fp(json.loads(ln))
except Exception:
w.write(ln + '\n')
kept += 1
continue
# 指纹压成 8 字节摘要再进 set:20 万条也能把内存压住
h = hashlib.blake2b(k.encode('utf-8', 'replace'),
digest_size=8).digest()
if h in seen:
continue
seen.add(h)
w.write(ln + '\n')
kept += 1
except Exception:
try:
os.unlink(tmp)
except Exception:
pass
return 0
finally:
# 必须先关掉读句柄再 os.replace:Windows 上不允许替换一个
# 还开着的文件(Linux 无所谓,但不能只在 Linux 上是对的)。
try:
f.close()
except Exception:
pass
removed = total - kept
if removed > 0:
os.replace(tmp, ARCHIVE)
else:
try:
os.unlink(tmp)
except Exception:
pass
return max(0, removed)
except Exception:
return 0
if __name__ == '__main__':
sys.exit(main())