一套跑在 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
123 lines
4.8 KiB
Python
123 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""快照覆盖率与容错测试(#6):专防「界面报成功、实际没备份」。
|
||
|
||
踩过的坑:/etc/drouter 下有一个 0600 属主 root 的 rescue.conf,
|
||
以 drouter 身份运行的快照读不到 → shutil.copytree 整体抛异常 →
|
||
这个目录半截留在盘上、saved 列表里也不记 → 回滚时根本还原不了。
|
||
"""
|
||
import ast
|
||
import io
|
||
import os
|
||
import shutil
|
||
import stat
|
||
import sys
|
||
import tempfile
|
||
|
||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
|
||
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))
|
||
|
||
|
||
with io.open(os.path.join(ROOT, 'backend/drouter-helper.py'), encoding='utf-8') as f:
|
||
HELPER = f.read()
|
||
with io.open(os.path.join(ROOT, 'scripts/deploy.sh'), encoding='utf-8') as f:
|
||
DEPLOY = f.read()
|
||
TREE = ast.parse(HELPER)
|
||
|
||
|
||
def const(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 ast.literal_eval(n.value)
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- 1. 覆盖范围
|
||
FILES = const('SNAPSHOT_ETC_FILES') or []
|
||
DIRS = const('SNAPSHOT_ETC_DIRS') or []
|
||
chk('快照包含 /etc/fstab(外置存储自动挂载)', '/etc/fstab' in FILES)
|
||
chk('快照包含 /etc/drouter 整目录', '/etc/drouter' in DIRS)
|
||
chk('快照包含 /etc/systemd/network', '/etc/systemd/network' in DIRS)
|
||
# 曾经把清单命名为 ETC_FILES,与另一处(helper 临时目录相关)重名被静默覆盖
|
||
chk('不存在与快照清单重名的旧 ETC_FILES 常量', const('ETC_FILES') is None)
|
||
|
||
# ---------------------------------------------------------------- 2. 容错复制
|
||
fn = None
|
||
for n in TREE.body:
|
||
if isinstance(n, ast.FunctionDef) and n.name == '_copytree_soft':
|
||
fn = n
|
||
chk('定义了 _copytree_soft(逐文件容错复制)', fn is not None)
|
||
if fn is None:
|
||
print('\n结果: %d 项失败' % fails)
|
||
sys.exit(1)
|
||
|
||
ns = {'os': os, 'shutil': shutil}
|
||
exec(compile(ast.Module(body=[fn], type_ignores=[]), '<x>', 'exec'), ns)
|
||
copytree_soft = ns['_copytree_soft']
|
||
|
||
# 造一个含「读不到的文件」的目录
|
||
tmp = tempfile.mkdtemp(prefix='snap-')
|
||
try:
|
||
src = os.path.join(tmp, 'src')
|
||
os.makedirs(os.path.join(src, 'sub'))
|
||
for name, body in (('a.conf', 'A'), ('b.conf', 'B'), ('secret.conf', 'S')):
|
||
with io.open(os.path.join(src, name), 'w') as f:
|
||
f.write(body)
|
||
with io.open(os.path.join(src, 'sub', 'c.conf'), 'w') as f:
|
||
f.write('C')
|
||
# 让其中一个文件不可读(Windows 上 chmod 000 不一定生效,能生效就测容错)
|
||
os.chmod(os.path.join(src, 'secret.conf'), 0o000)
|
||
blocked = True
|
||
try:
|
||
with io.open(os.path.join(src, 'secret.conf')) as f:
|
||
f.read()
|
||
blocked = False
|
||
except Exception:
|
||
pass
|
||
dst = os.path.join(tmp, 'dst')
|
||
n, sk = copytree_soft(src, dst)
|
||
if blocked:
|
||
chk('读不到的文件被跳过而不是报废整个目录', n == 3, '实际复制 %d 个' % n)
|
||
chk('跳过文件有记录', len(sk) == 1 and 'secret.conf' in sk[0], sk)
|
||
else:
|
||
# 当前平台 chmod 000 不生效(Windows),至少保证全量复制成功
|
||
chk('全量复制成功(该平台无法模拟权限拒绝)', n == 4, '实际复制 %d 个' % n)
|
||
chk('无跳过记录', sk == [], sk)
|
||
chk('其余文件都复制到了', os.path.isfile(os.path.join(dst, 'a.conf'))
|
||
and os.path.isfile(os.path.join(dst, 'b.conf'))
|
||
and os.path.isfile(os.path.join(dst, 'sub', 'c.conf')))
|
||
# 空目录也要建出来(还原时目录结构不能少)
|
||
os.makedirs(os.path.join(src, 'emptydir'))
|
||
n2, sk2 = copytree_soft(src, dst)
|
||
chk('空目录也会被创建', os.path.isdir(os.path.join(dst, 'emptydir')))
|
||
finally:
|
||
for base, dirs, files in os.walk(tmp):
|
||
for f in files:
|
||
try:
|
||
os.chmod(os.path.join(base, f), stat.S_IRWXU)
|
||
except Exception:
|
||
pass
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
# ---------------------------------------------------------------- 3. 调用点
|
||
chk('快照目录复制不再直接用 shutil.copytree',
|
||
'shutil.copytree(sub, dst' not in HELPER and 'shutil.copytree(src, sub' not in HELPER)
|
||
chk('快照会记录 skipped 到元数据', "'skipped': skipped_all" in HELPER
|
||
or "'skipped': skipped_all[:50]" in HELPER)
|
||
chk('rescue.conf 放宽到 0640 且属组 drouter',
|
||
'0o640' in HELPER and "shutil.chown(RESCUE_CONF, group='drouter')" in HELPER)
|
||
chk('部署脚本有存量 rescue.conf 权限迁移',
|
||
'chmod 0640 /etc/drouter/rescue.conf' in DEPLOY)
|
||
|
||
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
|
||
sys.exit(1 if fails else 0)
|