Files
drouter/_dev/t-linkage.py
T
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

203 lines
9.5 KiB
Python
Raw 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 -*-
"""模块联动审计(#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)