一套跑在 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
203 lines
9.6 KiB
Python
203 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""内核转发与加速(#4):静态契约 + 渲染逻辑单测。
|
||
|
||
盯三件事:
|
||
① 五项开关的默认值是我替用户定的判断,改错了会直接改变这台机器的网络行为;
|
||
② 每一项都必须有「通俗说明 + 联动影响」,用户明确要求过;
|
||
③ MSS / masquerade 最终落在 nft_v4 / nft_v6 配置库里 ——
|
||
本页只是统一入口,绝不能另存一份,否则两处配置会打架。
|
||
"""
|
||
import ast
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
sys.path.insert(0, os.path.join(ROOT, 'backend'))
|
||
import render # noqa: E402
|
||
|
||
HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
|
||
WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
|
||
APP = os.path.join(ROOT, 'web', 'app.js')
|
||
RENDER_SRC = io.open(os.path.join(ROOT, 'backend', 'render.py'), encoding='utf-8').read()
|
||
|
||
PASS = FAIL = 0
|
||
|
||
|
||
def chk(name, cond, extra=''):
|
||
global PASS, FAIL
|
||
if cond:
|
||
PASS += 1
|
||
print('[OK] %s %s' % (name, extra))
|
||
else:
|
||
FAIL += 1
|
||
print('[FAIL] %s %s' % (name, extra))
|
||
|
||
|
||
HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
|
||
WEB_SRC = io.open(WEB, encoding='utf-8').read()
|
||
APP_SRC = io.open(APP, encoding='utf-8').read()
|
||
TREE = ast.parse(HELPER_SRC)
|
||
|
||
|
||
def node(name):
|
||
for n in TREE.body:
|
||
if isinstance(n, ast.Assign):
|
||
for t in n.targets:
|
||
if isinstance(t, ast.Name) and t.id == name:
|
||
return n
|
||
elif isinstance(n, ast.FunctionDef) and n.name == name:
|
||
return n
|
||
return None
|
||
|
||
|
||
def build(names, ns=None):
|
||
ns = ns if ns is not None else {}
|
||
body = []
|
||
for nm in names:
|
||
n = node(nm)
|
||
if n is None:
|
||
raise AssertionError('源码里找不到 %s' % nm)
|
||
body.append(n)
|
||
mod = ast.Module(body=body, type_ignores=[])
|
||
ast.fix_missing_locations(mod)
|
||
exec(compile(mod, '<kern>', 'exec'), ns)
|
||
return ns
|
||
|
||
|
||
def main():
|
||
print('--- 后端动作与路由 ---')
|
||
chk('定义了 act_kern', 'def act_kern(' in HELPER_SRC)
|
||
chk('注册到 ACTIONS', re.search(r"^\s*'kern':\s*act_kern,", HELPER_SRC, re.M) is not None)
|
||
chk('Web 路由 /api/kern', "'/api/kern'" in WEB_SRC)
|
||
chk('save 给了更长超时', re.search(r"to = 90 if op == 'save' else 30", WEB_SRC) is not None)
|
||
chk('未知 op 返回 fail', '未知的内核选项操作' in HELPER_SRC)
|
||
|
||
print('\n--- 五项开关的默认值(我替用户做的判断)---')
|
||
ns = build(['KERN_DEFAULTS', 'KERN_ITEMS', 'KERN_FW_ITEMS'])
|
||
dft = ns['KERN_DEFAULTS']
|
||
chk('IPv4 转发默认开', dft['fwd_v4'] is True)
|
||
chk('IPv6 转发默认开', dft['fwd_v6'] is True)
|
||
chk('BBR 默认开', dft['bbr'] is True)
|
||
chk('SNMP 默认关(多一个监听多一分风险)', dft['snmp']['enabled'] is False)
|
||
chk('SNMP 有可改的团体名', bool(dft['snmp'].get('community')))
|
||
|
||
items = ns['KERN_ITEMS']
|
||
fws = ns['KERN_FW_ITEMS']
|
||
chk('四项内核开关齐全',
|
||
[i['key'] for i in items] == ['fwd_v4', 'fwd_v6', 'bbr', 'snmp'],
|
||
[i['key'] for i in items])
|
||
chk('四项防火墙参数齐全',
|
||
[i['key'] for i in fws] == ['masquerade_v4', 'mss_v4', 'masquerade_v6', 'mss_v6'],
|
||
[i['key'] for i in fws])
|
||
|
||
for it in items + fws:
|
||
k = it['key']
|
||
chk('%-14s 有通俗说明' % k, len(it.get('why') or '') >= 20)
|
||
chk('%-14s 有联动影响且不止一条' % k,
|
||
isinstance(it.get('impact'), list) and len(it['impact']) >= 2)
|
||
chk('%-14s 的 default 与 KERN_DEFAULTS 一致' % k,
|
||
'default' in it)
|
||
# msq/mss 的默认值要在后端与前端文案两处对得上
|
||
by_key = {i['key']: i['default'] for i in fws}
|
||
chk('masquerade_v4 默认开', by_key['masquerade_v4'] is True)
|
||
chk('mss_v4 默认开', by_key['mss_v4'] is True)
|
||
chk('masquerade_v6 默认关(IPv6 推荐端到端)', by_key['masquerade_v6'] is False)
|
||
chk('mss_v6 默认关(IPv6 靠 PMTUD)', by_key['mss_v6'] is False)
|
||
|
||
print('\n--- sysctl 生成 ---')
|
||
ns2 = build(['_kern_sysctl_lines'])
|
||
lines_of = ns2['_kern_sysctl_lines']
|
||
L = lines_of({'fwd_v4': True, 'fwd_v6': True, 'bbr': True}, True)
|
||
joined = '\n'.join(L)
|
||
chk('开启转发写 ip_forward=1', 'net.ipv4.ip_forward = 1' in joined)
|
||
chk('开启 IPv6 转发写 forwarding=1', 'net.ipv6.conf.all.forwarding = 1' in joined)
|
||
# 不设 accept_ra=2,靠 SLAAC 拿地址的家宽会在租期到期后丢掉整个 IPv6
|
||
chk('BBR 打开时指定 bbr', 'net.ipv4.tcp_congestion_control = bbr' in joined)
|
||
chk('BBR 打开时搭档 fq', 'net.core.default_qdisc = fq' in joined)
|
||
L2 = '\n'.join(lines_of({'fwd_v4': False, 'fwd_v6': False, 'bbr': False}, False))
|
||
chk('关闭转发写 0', 'net.ipv4.ip_forward = 0' in L2
|
||
and 'net.ipv6.conf.all.forwarding = 0' in L2)
|
||
chk('BBR 关闭时回退 cubic', 'tcp_congestion_control = cubic' in L2)
|
||
chk('关闭 IPv6 转发时不写 accept_ra', 'accept_ra' not in L2)
|
||
# 关键:bbr_ok=False 时即便用户勾了 BBR 也不能写 bbr(否则 sysctl -w 必然失败)
|
||
L3 = '\n'.join(lines_of({'fwd_v4': True, 'fwd_v6': True, 'bbr': True}, False))
|
||
chk('内核不支持时不强写 bbr', 'tcp_congestion_control = cubic' in L3
|
||
and '= bbr' not in L3)
|
||
|
||
print('\n--- 持久化与构建保护 ---')
|
||
chk('有 sysctl.d 持久化文件', "KERN_SYSCTL_D = '/etc/sysctl.d/99-drouter.conf'" in HELPER_SRC)
|
||
chk('构建保护模式下不改内核', '构建保护模式】:配置已写入磁盘,但未修改内核参数' in HELPER_SRC)
|
||
# 两个「会真的动手」的调用必须都排在 in_build_mode 分支之后(即 else 里)
|
||
_i_guard = HELPER_SRC.index('当前处于【构建保护模式】:配置已写入磁盘')
|
||
chk('构建保护模式下不启停服务',
|
||
HELPER_SRC.index('applied, bbr_ok, e2 = _kern_apply_sysctl(cfg)') > _i_guard
|
||
and HELPER_SRC.index('_kern_snmp_apply(cfg[\'snmp\'], errs)') > _i_guard)
|
||
|
||
print('\n--- 参数校验 ---')
|
||
chk('团体名禁止空格与 #', r"re.search(r'[\s#]', comm)" in HELPER_SRC)
|
||
chk('端口钳制在 1-65535', "min(int(s['port']), 65535)" in HELPER_SRC)
|
||
chk('MSS 钳制在 576-9000', 'min(int(fw[' in HELPER_SRC and '9000)' in HELPER_SRC)
|
||
chk('监听地址做了格式校验', 'SNMP 监听地址不合法' in HELPER_SRC)
|
||
|
||
print('\n--- 渲染器:masquerade / MSS ---')
|
||
t4 = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18']})
|
||
chk('v4 默认含 masquerade', 'masquerade' in t4)
|
||
chk('v4 默认含 MSS 钳制', 'maxseg size set rt mtu' in t4)
|
||
t4_off = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'masquerade': False})
|
||
chk('v4 关闭后不再有 masquerade 规则', 'masquerade comment' not in t4_off)
|
||
chk('v4 关闭后仍保留 nat_post 链与说明',
|
||
'chain nat_post' in t4_off and '出向地址伪装已关闭' in t4_off)
|
||
t4_fix = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'mss_mode': 'fixed', 'mss': 1400})
|
||
chk('fixed 模式写死数值', 'maxseg size set 1400' in t4_fix)
|
||
try:
|
||
render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'mss_mode': 'bogus'})
|
||
chk('非法 mss_mode 被拒绝', False)
|
||
except Exception:
|
||
chk('非法 mss_mode 被拒绝', True)
|
||
|
||
t6 = render.render_nft('v6', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18']})
|
||
chk('v6 默认不做 NAT66', 'masquerade' not in t6)
|
||
chk('v6 默认不钳制 MSS', 'maxseg' not in t6)
|
||
t6_on = render.render_nft('v6', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'masquerade': True, 'mss_clamp': True})
|
||
chk('v6 打开后生成 nat_post', 'chain nat_post' in t6_on and 'masquerade' in t6_on)
|
||
chk('v6 打开后生成 MSS 规则', 'maxseg size set rt mtu' in t6_on)
|
||
# 参数透传:render_nft 必须真的把 masquerade 交给 _default_v4/v6
|
||
chk('render.py 透传 masquerade 给 v4',
|
||
"masquerade=masq" in RENDER_SRC and "_default_v4(wan, lans" in RENDER_SRC)
|
||
chk('render.py 透传 masquerade 给 v6',
|
||
re.search(r"_default_v6\(wan, lans.*?masquerade=masq", RENDER_SRC, re.S) is not None)
|
||
|
||
print('\n--- 配置落点(不能在两处各存一份)---')
|
||
chk('写回 nft_v4 配置库', "_save_setting('nft_v4', v4)" in HELPER_SRC)
|
||
chk('写回 nft_v6 配置库', "_save_setting('nft_v6', v6)" in HELPER_SRC)
|
||
chk('web 默认配置含 v4 的 masquerade',
|
||
"'mss_mode': 'clamp', 'masquerade': True" in WEB_SRC)
|
||
chk('web 默认配置含 v6 的 masquerade=False',
|
||
"'mss_mode': 'clamp', 'masquerade': False" in WEB_SRC)
|
||
|
||
print('\n--- 前端接线 ---')
|
||
chk('菜单里有 kern', re.search(r"k: 'kern'", APP_SRC) is not None)
|
||
chk('菜单文案含「内核转发与加速」', '内核转发与加速' in APP_SRC)
|
||
chk('VIEWS 注册 kern', re.search(r"^\s*kern: viewKern,", APP_SRC, re.M) is not None)
|
||
chk('定义了 viewKern', 'async function viewKern(' in APP_SRC)
|
||
for fn in ('kernLoad', 'kernRender', 'kernSave', 'kernCard', 'kernImpact'):
|
||
chk('前端函数 %s' % fn, ('function %s(' % fn) in APP_SRC)
|
||
chk('调用 /api/kern', "'/api/kern'" in APP_SRC)
|
||
chk('BBR 未生效时有醒目提示', '未生效(内核未加载 tcp_bbr)' in APP_SRC)
|
||
chk('提示用户去防火墙页应用', '防火墙 IPv4 / IPv6' in APP_SRC)
|
||
|
||
print('\n' + '=' * 60)
|
||
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
|
||
return 1 if FAIL else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|