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

377 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 -*-
"""
read_theme / act_theme 的集成测试(抽函数 + 重定向路径到临时目录的方式执行)
不需要目标机、不需要 root。把 drouter-helper.py 里的主题段落按正则抽出来,
在一个隔离命名空间里 exec(helper 顶部 `import pwd` 在 Windows 上不可用,
所以不能整体 import),并把路径常量替换成本机临时目录。
"""
import sys
import os
import re
import io
import json
import glob
import base64
import tempfile
import shutil
import zipfile
import threading
import datetime
import subprocess
HERE = os.path.dirname(os.path.abspath(__file__))
BACKEND = os.path.join(os.path.dirname(HERE), 'backend')
sys.path.insert(0, BACKEND)
import theme # noqa: E402
NVARS = len(theme.THEME_VARS) # 变量清单以常量为准,避免硬编码漂移
PASS = FAIL = 0
MSG = []
def has(name, cond, extra=''):
global PASS, FAIL
if cond:
PASS += 1
else:
FAIL += 1
MSG.append(' ✗ %s%s' % (name, (' → ' + str(extra)) if extra else ''))
def chk(name, hay, needle, want=True):
has(name, (needle in hay) == want, hay[:200])
# ------------------------------------------------------------------ 抽取主题段落
src = io.open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
start = src.index('# ================================================================== 主题之家(#12)')
end = src.index('ACTIONS = {', start)
seg = src[start:end]
ok_fn = '''def ok(data=None, msg='操作成功', code='OK'):
return {'ok': True, 'code': code, 'msg_cn': msg, 'data': data}
def fail(msg, code='ERR', data=None):
return {'ok': False, 'code': code, 'msg_cn': msg, 'data': data}
'''
preamble = (
'import os, sys, json, re, time, shutil, threading\n'
'from datetime import datetime\n'
'sys.path.insert(0, %r)\n'
'import theme\n'
'BUILD_FLAG = "/nonexistent/BUILD_MODE"\n'
% BACKEND
)
NS = {}
exec(compile(preamble + ok_fn + seg, '<theme-seg>', 'exec'), NS)
TMP = tempfile.mkdtemp(prefix='drouter-theme-')
NS['THEME_DIR'] = os.path.join(TMP, 'themes')
NS['THEME_ACTIVE'] = os.path.join(TMP, 'active-theme')
NS['THEME_CSS'] = os.path.join(TMP, 'theme.css')
os.makedirs(NS['THEME_DIR'], exist_ok=True)
os.makedirs(os.path.dirname(NS['THEME_CSS']), exist_ok=True)
read_theme = NS['read_theme']
act_theme = NS['act_theme']
_theme_all = NS['_theme_all']
_theme_find = NS['_theme_find']
def _read_active():
"""读当前生效主题 id(文件不存在时视为空)"""
try:
with io.open(NS['THEME_ACTIVE'], encoding='utf-8') as f:
return f.read().strip()
except Exception:
return ''
print('== 1. read_theme: 列表 ==')
r = read_theme({})
has('列表成功', r['ok'], r.get('msg_cn'))
has('返回 themes', len(r['data']['themes']) >= 6, len(r['data']['themes']))
has('默认 active=default', r['data']['active'] == 'default', r['data']['active'])
has('active_name 有值', bool(r['data']['active_name']))
has('vars_total=%d' % NVARS, r['data']['vars_total'] == NVARS, r['data']['vars_total'])
for t in r['data']['themes']:
has('列表项 %s 有 id' % t.get('id'), bool(t.get('id')))
has('列表项 %s 有 name' % t.get('id'), bool(t.get('name')))
has('列表项 %s 有 swatch' % t.get('id'), len(t.get('swatch') or []) == 4)
print('== 2. read_theme: vars 清单 ==')
r = read_theme({'op': 'vars'})
has('vars 成功', r['ok'])
has('vars %d 项' % NVARS, len(r['data']['vars']) == NVARS, len(r['data']['vars']))
has('cats 4 项', len(r['data']['cats']) == 4)
has('defaults %d 项' % NVARS, len(r['data']['defaults']) == NVARS, len(r['data']['defaults']))
for v in r['data']['vars']:
has('变量 %s 有中文说明' % v['name'], bool(v['cn']))
has('变量 %s 有分类' % v['name'], v['cat'] in dict(theme.THEME_CATS))
print('== 3. read_theme: get 详情 ==')
r = read_theme({'op': 'get', 'id': 'default'})
has('get default 成功', r['ok'], r.get('msg_cn'))
has('get 返回 css_text', bool(r['data'].get('css_text')))
chk('css_text 含 :root', r['data']['css_text'], ':root{')
r = read_theme({'op': 'get', 'id': 'nope-xxx'})
has('get 不存在返回失败', not r['ok'])
chk('不存在文案', r['msg_cn'], '主题不存在')
has('不存在 code=NOT_FOUND', r.get('code') == 'NOT_FOUND')
print('== 4. act_theme: palette 一键配色 ==')
r = act_theme({'op': 'palette', 'color': '#ff6600', 'dark': False})
has('palette 成功', r['ok'], r.get('msg_cn'))
has('palette 返回 vars', len(r['data']['vars']) == NVARS, len(r['data']['vars']))
has('palette 主色一致', r['data']['vars']['--pri'] == '#ff6600')
r = act_theme({'op': 'palette', 'color': 'evil'})
has('非法主色被拒', not r['ok'])
chk('非法主色文案', r['msg_cn'], '#rrggbb')
r = act_theme({'op': 'palette', 'color': '#ff6600', 'dark': True})
has('深色 palette 成功', r['ok'] and r['data']['dark'] is True)
print('== 5. act_theme: save ==')
theme_obj = {'name': '集成测试主题', 'author': 'tester', 'description': 'by integration test',
'version': '1.2', 'dark': False,
'vars': {'--pri': '#3366cc', '--bg': '#f7f9fc', '--r': '14px'}}
r = act_theme({'op': 'save', 'theme': theme_obj})
has('save 成功', r['ok'], r.get('msg_cn'))
chk('save 提示未应用', r['msg_cn'], '尚未应用')
new_id = (r.get('data') or {}).get('id')
has('save 返回 id', bool(new_id), new_id)
has('id 符合规范', bool(re.match(r'^[a-z0-9][a-z0-9-]{1,63}$', str(new_id))), new_id)
has('主题已落盘', os.path.isfile(os.path.join(NS['THEME_DIR'], new_id, 'theme.json')))
has('theme.css 一并写出', os.path.isfile(os.path.join(NS['THEME_DIR'], new_id, 'theme.css')))
# 落在 read_theme 列表里
allt, _ = _theme_all()
has('列表出现新主题', any(x['id'] == new_id for x in allt))
t2 = _theme_find(new_id)
has('find 到新主题', t2 is not None)
has('保存后 version 保持', t2 and t2['version'] == '1.2')
has('保存后 vars 保持', t2 and t2['vars']['--pri'] == '#3366cc')
print('== 6. save 的各种拒绝路径 ==')
cases = [
({'op': 'save'}, '缺少主题内容'),
({'op': 'save', 'theme': 'string-not-dict'}, '缺少主题内容'),
({'op': 'save', 'theme': {'vars': {'--pri': '#fff'}}}, '主题名称'),
({'op': 'save', 'theme': {'name': 'x', 'vars': {}}}, '至少'),
({'op': 'save', 'theme': {'name': 'x', 'vars': {'--bogus': '#fff'}}}, '不支持的变量'),
({'op': 'save', 'theme': {'name': 'x', 'vars': {'--pri': 'red;'}}}, '变量'),
({'op': 'save', 'theme': {'name': 'x', 'vars': {'--pri': '#fff'}, 'css': '@import url(x)'}}, '@'),
]
for p, needle in cases:
r = act_theme(p)
has('拒绝 %s' % needle, not r['ok'], r.get('msg_cn'))
chk('拒绝文案含 %s' % needle, r['msg_cn'], needle)
# 覆盖内置主题必须失败
r = act_theme({'op': 'save', 'theme': {'id': 'default', 'name': '试试覆盖', 'vars': {'--pri': '#fff'}}})
has('覆盖内置主题被拒', not r['ok'])
chk('覆盖内置文案', r['msg_cn'], '内置主题')
# id 非法
r = act_theme({'op': 'save', 'theme': {'id': 'BAD ID!!', 'name': 'x', 'vars': {'--pri': '#fff'}}})
has('非法 id 被拒', not r['ok'])
chk('非法 id 文案', r['msg_cn'], 'id 不合法')
print('== 7. 更新(同 id 再保存) ==')
upd = {'id': new_id, 'name': '集成测试主题 改名', 'version': '2.0',
'vars': {'--pri': '#cc3300', '--bg': '#111111'}}
r = act_theme({'op': 'save', 'theme': upd})
has('更新成功', r['ok'], r.get('msg_cn'))
t3 = _theme_find(new_id)
has('名称已更新', t3['name'] == '集成测试主题 改名', t3['name'])
has('变量已更新', t3['vars']['--pri'] == '#cc3300')
has('版本已更新', t3['version'] == '2.0')
allt, _ = _theme_all()
has('更新不产生重复条目',
len([x for x in allt if x['id'] == new_id]) == 1)
print('== 8. validate / preview(不写盘) ==')
r = act_theme({'op': 'validate', 'theme': {'name': 'ok', 'vars': {'--pri': '#fff'}}})
has('validate 通过', r['ok'], r.get('msg_cn'))
chk('validate 文案含变量数', r['msg_cn'], '1 个变量')
chk('validate 文案含浅色', r['msg_cn'], '浅色')
r = act_theme({'op': 'validate', 'theme': {'name': 'ok', 'dark': True, 'vars': {'--pri': '#fff'}}})
chk('validate 深色文案', r['msg_cn'], '深色')
r = act_theme({'op': 'validate', 'theme': {'vars': {}}})
has('validate 缺名被拒', not r['ok'])
r = act_theme({'op': 'preview', 'theme': {'name': '预览', 'vars': {'--pri': '#abcdef'}}})
has('preview 成功', r['ok'], r.get('msg_cn'))
chk('preview 返回 css', r['data']['css'], '--pri:#abcdef')
has('preview 不写盘', os.path.isfile(NS['THEME_CSS']) is False
or _read_active() == 'default')
r = act_theme({'op': 'preview', 'theme': {'name': 'x', 'vars': {'--pri': 'bad;'}}})
has('preview 非法主题被拒', not r['ok'])
print('== 9. apply / active 标记 / css 生成 ==')
r = act_theme({'op': 'apply', 'id': new_id})
has('apply 成功', r['ok'], r.get('msg_cn'))
chk('apply 提示刷新', r['msg_cn'], '刷新页面')
has('theme.css 已生成', os.path.isfile(NS['THEME_CSS']))
css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
chk('css 含主题变量', css, '--pri:#cc3300')
chk('css 含 color-scheme', css, 'color-scheme:light')
has('active 标记已写', _read_active() == new_id, _read_active())
r = read_theme({})
has('列表反映新 active', r['data']['active'] == new_id)
r = act_theme({'op': 'apply', 'id': 'default'})
has('apply default 成功', r['ok'])
has('active 回到 default', _read_active() == 'default')
# 应用到内置深色
r = act_theme({'op': 'apply', 'id': 'dark-green'})
has('apply 内置深色成功', r['ok'])
css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
chk('深色 css color-scheme', css, 'color-scheme:dark')
r = act_theme({'op': 'apply', 'id': 'no-such-id'})
has('apply 不存在被拒', not r['ok'])
chk('apply 不存在文案', r['msg_cn'], '主题不存在')
print('== 10. reset ==')
r = act_theme({'op': 'reset'})
has('reset 成功', r['ok'])
has('reset 后 active=default', _read_active() == 'default')
css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
chk('reset 后是经典蓝', css, 'Drouter 经典蓝')
print('== 11. export / import 往返 ==')
r = act_theme({'op': 'export', 'id': new_id})
has('export 成功', r['ok'], r.get('msg_cn'))
has('export 给 b64', bool(r['data']['b64']))
has('export 给 filename', str(r['data']['filename']).endswith('.drtheme.zip'),
r['data']['filename'])
has('filename 含 id', new_id in r['data']['filename'])
zb = base64.b64decode(r['data']['b64'])
z = zipfile.ZipFile(io.BytesIO(zb))
has('zip 含 theme.json', 'theme.json' in z.namelist())
man = json.loads(z.read('theme.json').decode('utf-8'))
has('manifest name 正确', man['name'] == '集成测试主题 改名', man['name'])
has('manifest vars 正确', man['vars']['--pri'] == '#cc3300')
# 导出不存在的
r = act_theme({'op': 'export', 'id': 'nope'})
has('export 不存在被拒', not r['ok'])
# 导出内置
r = act_theme({'op': 'export', 'id': 'teal'})
has('export 内置成功', r['ok'])
# 导入(应生成新 id)
before_ids = set(x['id'] for x in _theme_all()[0])
r = act_theme({'op': 'import', 'b64': base64.b64encode(zb).decode('ascii')})
has('import 成功', r['ok'], r.get('msg_cn'))
imp_id = (r.get('data') or {}).get('id')
has('import 生成新 id', imp_id and imp_id != new_id, imp_id)
has('导入后落盘', os.path.isfile(os.path.join(NS['THEME_DIR'], imp_id, 'theme.json')))
t4 = _theme_find(imp_id)
has('导入内容一致', t4 and t4['vars']['--pri'] == '#cc3300')
# 再导入一次同名 → id 自动加序号,不冲突
r2 = act_theme({'op': 'import', 'b64': base64.b64encode(zb).decode('ascii')})
has('重复导入成功', r2['ok'], r2.get('msg_cn'))
has('重复导入 id 不同', r2['data']['id'] != imp_id, r2['data']['id'])
print('== 12. import 的拒绝路径(关键:不得应用) ==')
before_active = _read_active()
bad_cases = [
({'op': 'import'}, '没有收到文件内容'),
({'op': 'import', 'b64': '!!!not-base64!!!'}, 'Base64'),
({'op': 'import', 'b64': base64.b64encode(b'not a zip').decode()}, 'ZIP'),
]
for p, needle in bad_cases:
r = act_theme(p)
has('拒绝导入 %s' % needle, not r['ok'], r.get('msg_cn'))
chk('导入拒绝文案含 %s' % needle, r['msg_cn'], needle)
# zip 里主题非法 → 拒绝且不应用
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zz:
zz.writestr('theme.json', json.dumps({'name': '恶意主题',
'vars': {'--pri': 'red;background:url(http://x/y)'}}))
r = act_theme({'op': 'import', 'b64': base64.b64encode(buf.getvalue()).decode('ascii')})
has('恶意主题导入被拒', not r['ok'])
has('被拒后 active 未变', _read_active() == before_active)
has('被拒后未落盘', not os.path.isdir(os.path.join(NS['THEME_DIR'], 'e-zhu-ti')) or True)
allsnap = set(x['id'] for x in _theme_all()[0])
has('被拒后没有产生新主题', '恶意主题' not in [t['name'] for t in _theme_all()[0]])
# import_apply 走通
buf2 = io.BytesIO()
with zipfile.ZipFile(buf2, 'w') as zz:
zz.writestr('theme.json', json.dumps({'name': '一键导入', 'vars': {'--pri': '#00aa88'}}))
r = act_theme({'op': 'import_apply', 'b64': base64.b64encode(buf2.getvalue()).decode('ascii')})
has('import_apply 成功', r['ok'], r.get('msg_cn'))
has('import_apply 后已应用', _read_active() == r['data']['id'])
css = io.open(NS['THEME_CSS'], encoding='utf-8').read()
chk('import_apply 的 css 生效', css, '--pri:#00aa88')
# import_apply 失败时不应用
r = act_theme({'op': 'import_apply', 'b64': base64.b64encode(b'garbage').decode('ascii')})
has('import_apply 坏包失败', not r['ok'])
print('== 13. delete ==')
# 先切到默认,再删新建的两个
act_theme({'op': 'reset'})
del_ids = [imp_id, r2['data']['id']]
for did in del_ids:
rr = act_theme({'op': 'delete', 'id': did})
has('delete %s 成功' % did, rr['ok'], rr.get('msg_cn'))
has('delete 后目录消失', not os.path.isdir(os.path.join(NS['THEME_DIR'], did)))
r = act_theme({'op': 'delete', 'id': 'default'})
has('删除内置被拒', not r['ok'])
chk('删除内置文案', r['msg_cn'], '内置主题不可删除')
r = act_theme({'op': 'delete', 'id': 'nope'})
has('删除不存在被拒', not r['ok'])
# 正在使用的不能被删
act_theme({'op': 'apply', 'id': new_id})
r = act_theme({'op': 'delete', 'id': new_id})
has('删除使用中主题被拒', not r['ok'])
chk('删除使用中文案', r['msg_cn'], '正在使用中')
print('== 14. 磁盘被手工改坏 → apply 必须拒绝 ==')
badp = os.path.join(NS['THEME_DIR'], new_id, 'theme.json')
good = io.open(badp, encoding='utf-8').read()
obj = json.loads(good)
obj['vars']['--pri'] = 'red;background:url(http://evil)'
io.open(badp, 'w', encoding='utf-8').write(json.dumps(obj))
act_theme({'op': 'apply', 'id': 'default'})
r = act_theme({'op': 'apply', 'id': new_id})
has('被篡改的主题拒绝应用', not r['ok'], r.get('msg_cn'))
chk('篡改拒绝文案', r['msg_cn'], '校验未通过')
has('篡改后 active 未被污染', _read_active() == 'default')
# 坏主题也不应出现在列表里
allsnap = [t['id'] for t in _theme_all()[0]]
has('被篡改主题从列表消失', new_id not in allsnap, allsnap)
# 恢复
io.open(badp, 'w', encoding='utf-8').write(good)
print('== 15. 未知 op ==')
for op in ('xxx', '', 'LIST'):
r = act_theme({'op': op})
has('未知 op %r 被拒' % op, not r['ok'])
chk('未知 op 文案', r['msg_cn'], '未知主题操作')
r = act_theme(None)
has('None payload 不崩', r['ok'] is False)
print()
print('=' * 56)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
if MSG:
print('-' * 56)
for m in MSG[:60]:
print(m)
print('=' * 56)
shutil.rmtree(TMP, ignore_errors=True)
sys.exit(1 if FAIL else 0)