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

101 lines
3.7 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 -*-
"""环境契约测试:守护进程 PATH 归一化 + 虚拟网卡归类。
这两条都是「接口照常返回 ok,但功能悄悄不工作」的隐形缺陷,必须常驻回归:
1) systemd 默认 PATH 不含 /usr/sbin,nft/dnsmasq 会「未找到命令」;
2) 本机自建的虚拟设备(QoS 的 ifb-*)若被当成物理网卡,界面会一直提示指派角色。
"""
import ast
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, '..')
BACKEND = os.path.join(ROOT, 'backend')
GUARDS = ['drouter-web.py', 'drouter-helper.py', 'drouter-logd.py',
'drouter-snapshotd.py', 'drouter-rescue.py']
UNITS = [os.path.join(ROOT, 'scripts', 'deploy.sh')]
fails = []
def ck(name, cond, extra=''):
print(' %-46s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
if not cond:
fails.append(name)
print('一、守护进程 PATH 归一化')
for f in GUARDS:
src = open(os.path.join(BACKEND, f), encoding='utf-8').read()
ck('%s 定义了 _ensure_sbin_path' % f, '_ensure_sbin_path' in src)
ck('%s 启动时调用了它' % f, '_ensure_sbin_path()' in src)
# 真正执行一遍,确认 PATH 里确实被补进 sbin
src = open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
tree = ast.parse(src)
picked = [n for n in tree.body
if isinstance(n, ast.FunctionDef) and n.name == '_ensure_sbin_path']
picked += [n for n in tree.body
if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == '_SBIN_DIRS' for t in n.targets)]
# 注:本机(Windows)上没有 /usr/sbin,_ensure_sbin_path 里的 os.path.isdir 守卫
# 会正确地跳过它们。测试要验证的是「在 Debian 上的行为」,所以这里伪造 isdir。
_real_isdir = os.path.isdir
class FakePath:
def __init__(self):
self.isdir = self._isdir
self.sep = os.path.sep
@staticmethod
def _isdir(p):
return True if p in ('/usr/sbin', '/sbin', '/usr/local/sbin') else _real_isdir(p)
class FakeOs:
environ = os.environ
path = FakePath()
pathsep = os.pathsep
sep = os.path.sep
ns = {'os': FakeOs()}
exec(compile(ast.Module(body=picked, type_ignores=[]), 'helper', 'exec'), ns)
ns['_ensure_sbin_path']()
parts = os.environ['PATH'].split(os.pathsep)
ck('调用后 PATH 含 /usr/sbin', '/usr/sbin' in parts)
ck('调用后 PATH 含 /sbin', '/sbin' in parts)
ck('sbin 排在前面(优先命中)',
parts.index('/usr/sbin') < len(parts) - 1)
ck('原有 PATH 未被清空', len(parts) >= 2, '%d 段' % len(parts))
# 幂等:再调一次不应重复插入
before = os.environ['PATH']
ns['_ensure_sbin_path']()
ck('重复调用幂等(不重复插入)', os.environ['PATH'] == before)
print('\n二、systemd 单元显式声明 PATH')
for p in UNITS:
txt = open(p, encoding='utf-8').read()
n = txt.count('Environment=PATH=/usr/local/sbin')
cnt = txt.count('[Service]')
ck('deploy.sh 每个 [Service] 都带 PATH(%d/%d)' % (n, cnt), n == cnt, '%d 个单元' % cnt)
print('\n三、虚拟网卡归类(ifb-* 不该要求指派角色)')
h = open(os.path.join(BACKEND, 'drouter-helper.py'), encoding='utf-8').read()
ck('is_virtual 前缀含 ifb', "'ifb'" in h and 'is_virtual' in h)
ck('read_ifaces 标注 managed_by', 'managed_by' in h)
js = open(os.path.join(ROOT, 'web', 'app.js'), encoding='utf-8').read()
ck('前端新网卡提示排除虚拟设备', 'managed_by' in js)
ck('前端提示带关闭按钮', 'newiface-x' in js)
ck('关闭状态持久化', 'drouter_newiface_dismissed' in js)
print('\n结果:失败 %d 项' % len(fails))
for f in fails:
print(' ✘ ' + f)
sys.exit(1 if fails else 0)