Files
drouter/_dev/t-snapshot.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

123 lines
4.8 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):专防「界面报成功、实际没备份」。
踩过的坑:/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)