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