一套跑在 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.5 KiB
Python
203 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""模块联动审计(#6 收尾):专查「前端点的按钮 → 后端能不能接住」这条链。
|
||
|
||
前五类检查:
|
||
1. 前端 PAGE_MODULES 里的每个 save/apply 模块,后端必须能接住
|
||
—— save 走 /api/config(白名单 keys),apply 走 APPLY_SPEC 或纯配置模块;
|
||
漏一个就是「点了应用弹一个『未知的模块』」,属于用户能直接撞上的 bug。
|
||
2. render.py 的 ALIASES 值必须都真的存在渲染器,且 canonical() 能解析;
|
||
3. act_apply 的语法预检必须传「渲染模块名」而不是前端别名,
|
||
否则 ntp / upnp 这类别名会静默跳过语法检查;
|
||
4. 八个功能模块的动作必须注册 + 有路由(storage/share/deps/nat/qos/
|
||
docker/vlan/theme);
|
||
5. 前端各视图读取的关键字段,必须在对应后端动作源码里出现
|
||
—— 读到后端从来没有的 key 就是「界面永远空白」。
|
||
"""
|
||
import ast
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.join(HERE, '..')
|
||
fails = 0
|
||
|
||
|
||
def chk(label, cond, extra=''):
|
||
global fails
|
||
if not cond:
|
||
fails += 1
|
||
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
|
||
|
||
|
||
def read(rel):
|
||
with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
|
||
return f.read()
|
||
|
||
|
||
HELPER = read('backend/drouter-helper.py')
|
||
WEB = read('backend/drouter-web.py')
|
||
APP = read('web/app.js')
|
||
RENDER = read('backend/render.py')
|
||
|
||
# ------------------------------------------------------- 1. 前端页面 → 后端模块
|
||
# 从 app.js 里把 PAGE_MODULES 的字面量抠出来(不执行 JS,纯文本解析)
|
||
seg = APP[APP.index('const PAGE_MODULES = {'):]
|
||
seg = seg[:seg.index('\n};')]
|
||
page_mods = {}
|
||
for line in seg.splitlines():
|
||
m = re.match(r"\s*(\w+):\s*\{\s*save:\s*\[([^\]]*)\],\s*apply:\s*\[([^\]]*)\]", line)
|
||
if not m:
|
||
continue
|
||
get = lambda s: re.findall(r"'([^']+)'", s)
|
||
page_mods[m.group(1)] = (get(m.group(2)), get(m.group(3)))
|
||
chk('解析到 PAGE_MODULES 页面映射', len(page_mods) >= 10, '实际 %d 个' % len(page_mods))
|
||
|
||
# /api/config 的可保存模块白名单
|
||
cfg_keys = set()
|
||
m = re.search(r"keys = \[([^\]]*)\]", WEB)
|
||
if m:
|
||
cfg_keys = set(re.findall(r"'([^']+)'", m.group(1)))
|
||
chk('取到 /api/config 的模块白名单', len(cfg_keys) >= 10, '实际 %d 个' % len(cfg_keys))
|
||
|
||
# APPLY_SPEC 的键(正则会命中所有形如 'x': ('y' 的字典,所以先切到 APPLY_SPEC 段)
|
||
seg2 = HELPER[HELPER.index('APPLY_SPEC = {'):]
|
||
seg2 = seg2[:seg2.index('\n}')]
|
||
apply_keys = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg2, re.M))
|
||
chk('取到 APPLY_SPEC 模块', len(apply_keys) >= 10, '实际 %d 个' % len(apply_keys))
|
||
|
||
# 纯配置模块
|
||
seg3 = HELPER[HELPER.index('CONFIG_ONLY_MODULES = {'):]
|
||
seg3 = seg3[:seg3.index('\n}')]
|
||
config_only = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg3, re.M))
|
||
chk('存在纯配置模块声明', config_only == {'system', 'portfwd'}, '实际 %s' % sorted(config_only))
|
||
|
||
bad_save, bad_apply = [], []
|
||
for page, (saves, applies) in page_mods.items():
|
||
for m in saves:
|
||
if m not in cfg_keys:
|
||
bad_save.append((page, m))
|
||
for m in applies:
|
||
if m not in apply_keys and m not in config_only:
|
||
bad_apply.append((page, m))
|
||
chk('每个页面的 save 模块都能被 /api/config 接受', not bad_save, '→ %s' % bad_save)
|
||
chk('每个页面的 apply 模块后端都认得', not bad_apply, '→ %s' % bad_apply)
|
||
|
||
# 纯配置模块不该出现在任何页面的 apply 列表里(它压根没有配置文件)
|
||
mis = [(p, m) for p, (_s, a) in page_mods.items() for m in a if m in config_only]
|
||
chk('纯配置模块没有被当成渲染目标', not mis, '→ %s' % mis)
|
||
|
||
# 有 save 却没有 apply 的页面,前端必须给出「无独立配置文件」的提示文案
|
||
chk('无渲染目标的页面有对应提示文案',
|
||
'没有独立的配置文件需要渲染' in APP or '没有独立配置文件' in APP)
|
||
|
||
# ------------------------------------------------------- 2. render 别名闭环
|
||
seg4 = RENDER[RENDER.index('ALIASES = {'):]
|
||
seg4 = seg4[:seg4.index('\n}')]
|
||
aliases = dict(re.findall(r"'([a-z_0-9]+)':\s*'([a-z_0-9]+)'", seg4))
|
||
renderers = set(re.findall(r"^RENDERERS = \{", RENDER))
|
||
seg5 = RENDER[RENDER.index('RENDERERS = {'):]
|
||
seg5 = seg5[:seg5.index('\n}')]
|
||
rend_keys = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg5, re.M))
|
||
bad_alias = {k: v for k, v in aliases.items() if v not in rend_keys}
|
||
chk('ALIASES 的每个值都有对应渲染器', not bad_alias, '→ %s' % bad_alias)
|
||
chk('render 暴露了 canonical()', 'def canonical(' in RENDER)
|
||
chk('pppoe 已别名到 ppp(WAN 口页面此前会报未知模块)',
|
||
aliases.get('pppoe') == 'ppp', '实际 %s' % aliases.get('pppoe'))
|
||
chk('APPLY_SPEC 认得 pppoe', 'pppoe' in apply_keys)
|
||
|
||
# ------------------------------------------------------- 3. 语法预检用渲染模块名
|
||
chk('act_apply 用 render.canonical 取渲染模块名',
|
||
'rmodule = render.canonical(module)' in HELPER)
|
||
chk('语法预检传的是 rmodule 不是 module',
|
||
'_verify(rmodule, files)' in HELPER)
|
||
# 别名模块的语法检查以前被整段跳过:ntp->chrony、upnp->miniupnpd
|
||
chk('ntp 别名解析为 chrony(语法检查才不会跳过)',
|
||
aliases.get('ntp') == 'chrony' and "'chrony'" in HELPER)
|
||
chk('upnp 别名解析为 miniupnpd', aliases.get('upnp') == 'miniupnpd')
|
||
# 校验目录必须是能 traverse 的:放在 0700 的私有目录里,chronyd 降权后读不到,
|
||
# NTP 页就会弹「配置检查未通过:Permission denied」
|
||
chk('存在独立的语法预检目录 VERIFY_DIR', "VERIFY_DIR = '/run/drouter-verify'" in HELPER)
|
||
chk('_verify 用的是 VERIFY_DIR 而不是 root 私有目录',
|
||
'checkdir = VERIFY_DIR' in HELPER)
|
||
# chronyd 被 AppArmor 圈死,只能读 /etc/chrony/** 与 /etc/chrony.*
|
||
chk('chrony 的预检文件落在 AppArmor 允许的路径上',
|
||
"'chrony': '/etc/chrony.drouter-verify.conf'" in HELPER)
|
||
chk('_verify 会按模块选用预检路径', 'tmp = VERIFY_PATH.get(module)' in HELPER)
|
||
|
||
# ------------------------------------------------------- 4. 功能模块动作注册
|
||
h_tree = ast.parse(HELPER)
|
||
ACTIONS = set()
|
||
for node in ast.walk(h_tree):
|
||
if isinstance(node, ast.Assign):
|
||
for t in node.targets:
|
||
if isinstance(t, ast.Name) and t.id == 'ACTIONS' and isinstance(node.value, ast.Dict):
|
||
for k in node.value.keys:
|
||
if isinstance(k, ast.Constant) and isinstance(k.value, str):
|
||
ACTIONS.add(k.value)
|
||
# web.py 里登记过的路由(写法有 dict 键、元组 p in ('/api/a','/api/b')、
|
||
# p == '/api/x 三种,只抽 dict 会漏掉大半)
|
||
routes = set()
|
||
for node in ast.walk(ast.parse(WEB)):
|
||
if isinstance(node, ast.Dict):
|
||
for k in node.keys:
|
||
if isinstance(k, ast.Constant) and isinstance(k.value, str) \
|
||
and k.value.startswith('/api'):
|
||
routes.add(k.value)
|
||
if isinstance(node, (ast.Tuple, ast.List)):
|
||
for e in node.elts:
|
||
if isinstance(e, ast.Constant) and isinstance(e.value, str) \
|
||
and e.value.startswith('/api'):
|
||
routes.add(e.value)
|
||
if isinstance(node, ast.Compare) and isinstance(node.left, ast.Name) \
|
||
and node.left.id == 'p':
|
||
for c in node.comparators:
|
||
if isinstance(c, ast.Constant) and isinstance(c.value, str) \
|
||
and c.value.startswith('/api'):
|
||
routes.add(c.value)
|
||
|
||
# 模块名 → (后端动作名, 后端路由, 前端请求的路径)
|
||
MODULES = {
|
||
'storage': ('storage', '/api/storage', '/api/storage'),
|
||
'share': ('share', '/api/share', '/api/share'),
|
||
'deps': ('depcheck', '/api/deps/check', '/api/deps/check'),
|
||
'nat': ('nat_check', '/api/nat/check', '/api/nat/check'),
|
||
'qos': ('qos', '/api/qos', '/api/qos'),
|
||
'docker': ('docker', '/api/docker', '/api/docker'),
|
||
'vlan': ('vlan', '/api/vlan', '/api/vlan'),
|
||
'theme': ('theme', '/api/theme', '/api/theme'),
|
||
}
|
||
for name, (act, route, used) in MODULES.items():
|
||
chk('模块 %s 的动作已注册' % name, act in ACTIONS, '→ 缺 %s' % act)
|
||
chk('模块 %s 有后端路由' % name, route in routes, '→ 路由表缺 %s' % route)
|
||
# 主题页的请求带 query(/api/theme?op=list),只比对路径主干
|
||
chk('模块 %s 前端会请求' % name,
|
||
("api('%s'" % used) in APP or ("api('%s?" % used) in APP
|
||
or ("api('%s/op'" % used) in APP)
|
||
|
||
# ------------------------------------------------------- 5. 前端字段 ↔ 后端键名
|
||
# 只查最有代表性的几个「界面永远空白」高发点:字段名在后端源码里必须出现过。
|
||
FIELD_CASES = [
|
||
# 外置存储挂在「文件共享」页里,视图函数是 viewNfs
|
||
('storage', 'viewNfs', ('enable_smb', 'enable_nfs', 'samba', 'nfs')),
|
||
('deps', 'viewDepCheck', ('items', 'required', 'missing', 'extra')),
|
||
('vlan', 'viewVlan', ('parent', 'vid', 'purpose')),
|
||
('docker', 'viewDocker', ('containers', 'images', 'compose')),
|
||
('theme', 'viewTheme', ('themes', 'active', 'id')),
|
||
]
|
||
for name, fn, fields in FIELD_CASES:
|
||
i = APP.find('function %s(' % fn)
|
||
chk('找到前端函数 %s' % fn, i >= 0)
|
||
if i < 0:
|
||
continue
|
||
body = APP[i:i + 9000]
|
||
miss = [f for f in fields if f not in body]
|
||
chk('%s 视图仍在使用这些字段' % name, not miss, '→ 缺失 %s' % miss)
|
||
miss2 = [f for f in fields if f not in HELPER]
|
||
chk('%s 的字段后端确实会产出' % name, not miss2, '→ 后端无 %s' % miss2)
|
||
|
||
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
|
||
sys.exit(1 if fails else 0)
|