Files
liuzhuohua 64df3cf331 feat: Drouter 1.0.0 —— Debian 13 软路由管理系统
一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的
Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、
访问控制、DDNS、文件共享、Docker 管理等。

核心设计原则:不重新实现任何协议
  PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。
  Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。
  面板删掉后,系统仍是一台配置完好的 Debian 路由器。

安全默认值
  · 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务
  · 所有改网络的动作都需要用户在面板上亲手点「应用」
  · 避开 5900 端口,不触碰 VNC / X 会话
  · 配置写入前先做语法预检,失败则回滚

架构
  · backend/  Python 3 标准库,零第三方依赖(9 个模块)
  · web/      原生 JS/CSS,无构建步骤
  · 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess
  · 鉴权走 X-Token 请求头

打包
  · packaging/build-deb.sh            → drouter_1.0.0_all.deb(含 6 个 systemd 单元)
  · scripts/make-offline-deps.sh      → 236 个依赖 .deb + 本地仓库索引
  · packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装
  · packaging/build-docker.sh         → 容器镜像(体验用)

测试
  40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配),
  全部挂在 devtools/sync.sh 的预检链里。

许可:MIT
2026-09-30 19:26:24 +08:00

375 lines
15 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 -*-
"""#11 连接跟踪与流量日志(统一日志系统)—— 纯逻辑测试。
从 drouter-helper.py 抽取 #11 相关片段,在 NS 命名空间内隔离执行。
覆盖:
A. ULog 统一记录结构
B. conntrack -E 事件行解析
C. conntrack -L 连接表行解析(flow)
D. 统一过滤(级别/源/动作/协议/地址/端口/关键字)
E. 聚合统计
F. 保留策略 _ulog_prune
G. act_ulog 参数与导出格式
"""
import os
import re
import sys
import json
import time
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, 'backend', 'drouter-helper.py')
src = open(SRC, encoding='utf-8').read()
PASS, FAIL = [], []
def chk(name, got, want):
if got == want:
PASS.append(name)
return True
FAIL.append('%s:得到 %r,期望 %r' % (name, got, want))
return False
def has(name, hay, needle, want=True):
got = needle in (hay or '')
if got == want:
PASS.append(name)
return True
FAIL.append('%s:%r in 输出 = %s,期望 %s' % (name, needle, got, want))
return False
def grab(pattern, name):
m = re.search(pattern, src, re.S)
if not m:
raise SystemExit('抽取失败:%s' % name)
return m.group(0)
TMP = tempfile.mkdtemp(prefix='ulog-test-')
ARCHIVE = os.path.join(TMP, 'ulog.jsonl')
CONF = os.path.join(TMP, 'log.conf')
NS = {
're': re, 'os': os, 'json': json, 'sys': sys, 'datetime': __import__('datetime').datetime,
'ValidateError': type('ValidateError', (Exception,), {
'__init__': lambda self, msg, field=None: (
setattr(self, 'msg_cn', msg), setattr(self, 'field', field))[0] or None}),
'sh': lambda *a, **k: (1, '', 'skip'),
'ok': lambda d=None, m=None: {'ok': True, 'data': d, 'msg_cn': m},
'fail': lambda m, c='ERR', d=None: {'ok': False, 'code': c, 'msg_cn': m, 'data': d},
'LOGDIR': TMP,
'ULOG_ARCHIVE': ARCHIVE,
'ULOG_CONF': CONF,
'_load_setting': lambda k, d=None: d if d is not None else {},
'_split_journal_line': lambda line: ('', line),
'translate_fw_log': lambda msg: None,
'read_wan_log': lambda p: {'ok': True, 'data': {'items': []}},
'_fw_log_enabled': lambda: True,
'read_logs': lambda p: {'ok': True, 'data': []},
}
# 抽取整个 #11 区块
seg = grab(r'ULOG_LEVELS = \[.*?(?=\ndef read_logs\(p\):)', '#11 区块')
exec(compile(seg, SRC, 'exec'), NS)
# 需要的辅助函数单独抽取
for fn in ('_split_journal_line', '_human_bytes'):
pass # 已由桩替代 / 在区块内
rec = NS['_ulog_rec']
parse_ev = NS['_ulog_parse_conntrack_event']
parse_tbl = NS['_ulog_parse_conntrack_table']
flt = NS['_ulog_filter']
stats = NS['_ulog_stats']
prune = NS['_ulog_prune']
act = NS['act_ulog']
# ---------------- A. 统一记录结构 ----------------
r = rec('fw', 'warn', action='DROP', proto='TCP', saddr='203.0.113.9',
sport='54321', daddr='192.168.7.3', dport='22', iface='ens19',
msg_cn='丢弃数据包')
for k in ('ts', 'src', 'level', 'action', 'proto', 'saddr', 'sport',
'daddr', 'dport', 'iface', 'host', 'port', 'msg_cn', 'raw', 'extra'):
chk('A 字段存在 %s' % k, k in r, True)
chk('A level_cn', r['level_cn'], '警告')
chk('A action 大写', r['action'], 'DROP')
chk('A proto 大写', r['proto'], 'TCP')
chk('A sport 字符串', r['sport'], '54321')
chk('A 非法 level 归一', rec('fw', 'boom')['level'], 'info')
chk('A extra 默认', r['extra'], {})
chk('A raw 截断', len(rec('fw', raw='x' * 2000)['raw']), 800)
# ---------------- B. conntrack -E 事件 ----------------
ev = (' [NEW] tcp 6 120 SYN_SENT src=192.168.7.100 dst=1.1.1.1 '
'sport=51234 dport=443 [UNREPLIED]')
p = parse_ev(ev)
chk('B NEW 解析', bool(p), True)
chk('B src 名', p['src'], 'conntrack')
chk('B action', p['action'], 'NEW')
chk('B proto', p['proto'], 'TCP')
chk('B saddr', p['saddr'], '192.168.7.100')
chk('B daddr', p['daddr'], '1.1.1.1')
chk('B sport', p['sport'], '51234')
chk('B dport', p['dport'], '443')
chk('B level', p['level'], 'info')
has('B 中文消息', p['msg_cn'], '新建连接')
has('B 中文消息含地址', p['msg_cn'], '192.168.7.100')
p2 = parse_ev('[DESTROY] tcp 6 src=10.0.0.5 dst=8.8.8.8 sport=1000 dport=53 [CLOSE]')
chk('B DESTROY', p2['action'], 'DESTROY')
has('B DESTROY 中文', p2['msg_cn'], '连接关闭')
chk('B 状态 extra', p2['extra']['state'], 'CLOSE')
p3 = parse_ev('[UPDATE] udp 17 src=10.0.0.5 dst=224.0.0.251 sport=5353 dport=5353')
chk('B UDP', p3['proto'], 'UDP')
chk('B UDP level', p3['level'], 'debug')
chk('B 非事件行不解析', parse_ev('this is not conntrack'), None)
chk('B 空行不解析', parse_ev(''), None)
# IPv6 事件
p4 = parse_ev('[NEW] tcp 6 src=2408:8207:1234::1 dst=2408:8888::2 sport=40000 dport=80 [SYN_SENT]')
chk('B IPv6 saddr', p4['saddr'], '2408:8207:1234::1')
chk('B IPv6 daddr', p4['daddr'], '2408:8888::2')
# ---------------- C. conntrack -L 连接表 ----------------
tbl = ('tcp 6 431999 ESTABLISHED src=192.168.7.100 dst=1.1.1.1 sport=51234 '
'dport=443 src=1.1.1.1 dst=192.168.7.100 sport=443 dport=51234 '
'[ASSURED] mark=0 use=1')
t = parse_tbl(tbl)
chk('C 解析成功', bool(t), True)
chk('C src 名', t['src'], 'flow')
chk('C proto', t['proto'], 'TCP')
chk('C saddr', t['saddr'], '192.168.7.100')
chk('C dport', t['dport'], '443')
chk('C state', t['extra']['state'], 'ESTABLISHED')
has('C 中文状态', t['msg_cn'], '已建立')
# 无 src/dst → None
chk('C 无效行', parse_tbl('garbage'), None)
chk('C 表头行', parse_tbl('conntrack v1.4.6 (conntrack-tools)'), None)
# ---------------- D. 统一过滤 ----------------
def mk(src, level, action, proto, sa, sp, da, dp, msg):
return rec(src, level, action=action, proto=proto, saddr=sa, sport=sp,
daddr=da, dport=dp, msg_cn=msg)
DATA = [
mk('fw', 'warn', 'DROP', 'TCP', '203.0.113.9', '54321', '192.168.7.3', '22', '丢弃 SSH'),
mk('fw', 'info', 'ACCEPT', 'TCP', '192.168.7.5', '40000', '8.8.8.8', '443', '放行 HTTPS'),
mk('conntrack', 'info', 'NEW', 'UDP', '192.168.7.9', '5353', '224.0.0.251', '5353', '新建 UDP'),
mk('system', 'err', '', '', '', '', '', '', '磁盘错误'),
mk('fw', 'debug', 'ACCEPT', 'ICMP', '10.0.0.1', '', '10.0.0.2', '', 'ICMP 通过'),
]
chk('D 无过滤=全部', len(flt(DATA, {})), 5)
chk('D 源过滤 fw', len(flt(DATA, {'src': 'fw'})), 3)
chk('D 动作过滤 DROP', len(flt(DATA, {'action': 'DROP'})), 1)
chk('D 协议过滤 TCP', len(flt(DATA, {'proto': 'TCP'})), 2)
chk('D 地址过滤', len(flt(DATA, {'addr': '192.168.7'})), 3)
chk('D 端口过滤 443', len(flt(DATA, {'port': '443'})), 1)
chk('D 关键字过滤', len(flt(DATA, {'q': 'ssh'})), 1)
# 级别:warn 及更严重(warn, err)
chk('D 级别 warn 及以上', len(flt(DATA, {'level': 'warn'})), 2)
chk('D 级别 err 以上', len(flt(DATA, {'level': 'err'})), 1)
chk('D 级别 debug 全要', len(flt(DATA, {'level': 'debug'})), 5)
# 组合
chk('D 组合 src+proto', len(flt(DATA, {'src': 'fw', 'proto': 'TCP'})), 2)
chk('D 组合无结果', len(flt(DATA, {'src': 'ddns'})), 0)
# ---------------- E. 聚合统计 ----------------
st = stats(DATA)
chk('E total', st['total'], 5)
chk('E by_src fw', st['by_src'].get('fw'), 3)
chk('E by_level warn', st['by_level'].get('warn'), 1)
chk('E by_action DROP', st['by_action'].get('DROP'), 1)
chk('E by_proto TCP', st['by_proto'].get('TCP'), 2)
chk('E top_src_ip 非空', len(st['top_src_ip']) > 0, True)
chk('E top_dst_port 非空', len(st['top_dst_port']) > 0, True)
# 排序:计数多的在前
chk('E top_src 排序', st['top_src_ip'][0]['n'] >= st['top_src_ip'][-1]['n'], True)
# ---------------- F. 保留策略 ----------------
now = time.time()
old = time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(now - 30 * 86400))
fresh = time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(now - 3600))
rows = []
for i in range(5):
rows.append(json.dumps({'ts': old, 'src': 'fw', 'msg_cn': 'old%d' % i}))
for i in range(3):
rows.append(json.dumps({'ts': fresh, 'src': 'fw', 'msg_cn': 'new%d' % i}))
rows.append('{broken json')
with open(ARCHIVE, 'w', encoding='utf-8') as f:
f.write('\n'.join(rows) + '\n')
res = prune({'keep_days': 7, 'keep_rows': 200000})
chk('F 删除超期', res['removed'], 5)
chk('F 保留新鲜', res['kept'], 4) # 3 新鲜 + 1 无法解析(保留)
with open(ARCHIVE, encoding='utf-8') as f:
kept = f.read().splitlines()
chk('F 文件行数', len(kept), 4)
has('F 保留 new1', '\n'.join(kept), 'new1')
has('F 删除 old1', '\n'.join(kept), 'old1', want=False)
has('F 保留破损行', '\n'.join(kept), '{broken json')
# 按条数裁剪
with open(ARCHIVE, 'w', encoding='utf-8') as f:
for i in range(10):
f.write(json.dumps({'ts': fresh, 'src': 'fw', 'msg_cn': 'r%d' % i}) + '\n')
res2 = prune({'keep_days': 30, 'keep_rows': 4})
chk('F 条数裁剪 removed', res2['removed'], 6)
chk('F 条数裁剪 kept', res2['kept'], 4)
with open(ARCHIVE, encoding='utf-8') as f:
last = f.read().splitlines()
has('F 保留最新 r9', last[-1], 'r9')
has('F 丢弃最旧 r0', '\n'.join(last), '"r0"', want=False)
# 文件不存在
os.remove(ARCHIVE)
chk('F 无文件', prune({'keep_days': 7}), {'removed': 0, 'kept': 0})
# ---------------- G. act_ulog 参数 ----------------
# save_conf 校验
NS['_ulog_load'] = lambda: dict(NS['ULOG_DEFAULT_CONF'],
sources=dict(NS['ULOG_DEFAULT_CONF']['sources']))
NS['_ulog_save'] = lambda d: None
r = act({'op': 'save_conf', 'keep_days': 'abc'})
chk('G keep_days 非法', r['ok'], False)
r = act({'op': 'save_conf', 'keep_rows': 0})
chk('G keep_rows 0 被拒', r['ok'], False)
r = act({'op': 'save_conf', 'keep_days': 14, 'keep_rows': 5000,
'sources': {'fw': True, 'system': False}, 'min_level': 'info'})
chk('G save_conf 成功', r['ok'], True)
chk('G keep_days 生效', r['data']['conf']['keep_days'], 14)
chk('G sources 生效', r['data']['conf']['sources']['system'], False)
r = act({'op': 'save_conf', 'min_level': 'nonsense'})
chk('G min_level 归一 debug', r['data']['conf']['min_level'], 'debug')
# 未知 op
r = act({'op': 'nonsense'})
chk('G 未知 op', r['ok'], False)
has('G 未知 op 中文', r['msg_cn'], '不支持的日志操作')
# export 格式(用桩数据)
NS['_ulog_collect'] = lambda p: (list(DATA), {'sources_used': ['fw']})
r = act({'op': 'export', 'format': 'csv'})
chk('G export csv ok', r['ok'], True)
has('G csv 表头', r['data']['text'], 'ts,src,level,action,proto')
chk('G csv 行数', r['data']['rows'], 5)
r = act({'op': 'export', 'format': 'json'})
chk('G export json ok', r['ok'], True)
chk('G json 行数', len(r['data']['text'].splitlines()), 5)
# JSONL 每行可解析
okj = all(json.loads(x) for x in r['data']['text'].splitlines())
chk('G json 每行合法', okj, True)
# clear_archive
with open(ARCHIVE, 'w') as f:
f.write('x\n')
r = act({'op': 'clear_archive'})
chk('G clear_archive ok', r['ok'], True)
chk('G 归档已删', os.path.isfile(ARCHIVE), False)
# ---------------- H. 归档与去重(drouter-logd) ----------------
LOGD_SRC = os.path.join(ROOT, 'backend', 'drouter-logd.py')
ldsrc = open(LOGD_SRC, encoding='utf-8').read()
# 抽出去重函数与指纹函数做隔离测试
LNS = {'os': os, 'json': json, 'ARCHIVE': ARCHIVE,
'datetime': __import__('datetime').datetime}
for fname in ('_fp', 'dedupe_archive'):
m = re.search(r'\ndef %s\(.*?(?=\ndef |\nif __name__)' % fname, ldsrc, re.S)
if not m:
raise SystemExit('抽取失败:%s' % fname)
exec(compile(m.group(0), LOGD_SRC, 'exec'), LNS)
fp = LNS['_fp']
dedupe = LNS['dedupe_archive']
# 指纹:字段不同 → 指纹不同
a = {'ts': '2026-09-28T10:00:00', 'src': 'fw', 'action': 'DROP',
'saddr': '1.1.1.1', 'daddr': '2.2.2.2', 'sport': '1', 'dport': '2', 'msg_cn': 'x'}
b = dict(a, dport='3')
chk('H 指纹不同', fp(a) != fp(b), True)
chk('H 指纹稳定', fp(a), fp(dict(a)))
# 去重:3 条重复 + 1 条唯一
rows = [a, a, b, a, {'ts': '2026-09-28T11:00:00', 'src': 'system', 'msg_cn': 'disk'}]
with open(ARCHIVE, 'w', encoding='utf-8') as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + '\n')
removed = dedupe()
chk('H 去重移除数', removed, 2) # a 出现 3 次 → 去 2 条
with open(ARCHIVE, encoding='utf-8') as f:
out = f.read().splitlines()
chk('H 去重后行数', len(out), 3) # a / b / system 各一条
has('H 保留 system', '\n'.join(out), 'disk')
# 空文件 / 不存在
os.remove(ARCHIVE)
chk('H 无文件去重', dedupe(), 0)
# 保留无法解析的行(不误删)
with open(ARCHIVE, 'w', encoding='utf-8') as f:
f.write('{broken\n' + json.dumps(a) + '\n{broken\n')
chk('H 破损行不去重', dedupe(), 0)
with open(ARCHIVE, encoding='utf-8') as f:
chk('H 破损行保留', len(f.read().splitlines()), 3)
# archive op(act_ulog)
NS['_ulog_collect'] = lambda p: (list(DATA), {'sources_used': ['fw'], 'errors': {}})
NS['_ulog_archive_write'] = lambda recs, conf=None: len(recs)
NS['_ulog_prune'] = lambda conf: {'removed': 2, 'kept': 10}
r = act({'op': 'archive', 'since': '5 min ago'})
chk('H archive ok', r['ok'], True)
chk('H archive 条数', r['data']['archived'], 5)
chk('H archive prune', r['data']['prune']['removed'], 2)
# 关闭时不归档
NS['_ulog_load'] = lambda: dict(NS['ULOG_DEFAULT_CONF'], enabled=False,
sources=dict(NS['ULOG_DEFAULT_CONF']['sources']))
r = act({'op': 'archive'})
chk('H 关闭时跳过', r['data']['archived'], 0)
has('H 关闭提示', r['msg_cn'], '已关闭')
# 守护脚本静态检查
has('H logd 归档路径', ldsrc, "ARCHIVE = os.path.join(LOG_DIR, 'ulog.jsonl')")
has('H logd 配置路径', ldsrc, "CONF = '/etc/drouter/generated/log.conf'")
has('H logd 调用 archive', ldsrc, "'op': 'archive'")
has('H logd 有时间窗', ldsrc, "SINCE = '5 min ago'")
has('H logd 有去重', ldsrc, 'def dedupe_archive()')
# 守护脚本必须只读:不得出现 nft 规则加载、不得启停服务。
# 只看「非注释行」:注释里为了说明背景提到 nft 是正常的,不能算违规。
ldcode = '\n'.join(l for l in ldsrc.split('\n') if not l.lstrip().startswith('#'))
has('H logd 不加载规则', ldcode, 'nft', want=False)
has('H logd 不改网络', ldcode, 'ip link', want=False)
# ---------------- 静态结构检查 ----------------
has('S ACTIONS read:ulog', src, "'read:ulog': read_ulog")
has('S ACTIONS ulog', src, "'ulog': act_ulog")
has('S ACTIONS read:ulog_flow', src, "'read:ulog_flow': read_ulog_flow")
has('S ACTIONS read:ulog_conf', src, "'read:ulog_conf': read_ulog_conf")
has('S 归档函数', src, 'def _ulog_archive_write(')
has('S 采集函数', src, 'def _ulog_collect(')
has('S 配置路径', src, "ULOG_CONF = '/etc/drouter/generated/log.conf'")
chk('S 源数量', len(NS['ULOG_SOURCES']), 7)
chk('S 级别数量', len(NS['ULOG_LEVELS']), 8)
print('=' * 62)
print('#11 统一日志系统 · 单测结果')
print('=' * 62)
print('通过:%d 失败:%d' % (len(PASS), len(FAIL)))
if FAIL:
print('-' * 62)
for f in FAIL:
print(' ✗ ' + f)
sys.exit(1)
print('全部通过 ✔')