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

337 lines
16 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 -*-
"""Docker 引擎配置 daemon.json(#5):静态契约 + 合并逻辑单测。
盯四件事:
① 用户自己写在 daemon.json 里的、本页不管的键必须原样保留 ——
静默丢掉别人的 data-root / insecure-registries 是最糟的体验;
② 保存只写盘,绝不自动重启 Docker(重启会中断所有容器,是重动作);
③ IPv6 默认关、默认用 fd00::/8 私有段 —— 开了不等于能上 v6 外网,
公网段还需要上游做 NDP 代理,默认给公网段是坑;
④ 页面显示的「配置代码」必须和真正写进文件的逐字节一致,
所以预览一律调后端 op=preview,前端不许自己再拼一份。
"""
import ast
import io
import os
import re
import sys
import json
import ipaddress
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
APP = os.path.join(ROOT, 'web', 'app.js')
PASS = FAIL = 0
def chk(name, cond, extra=''):
global PASS, FAIL
if cond:
PASS += 1
print('[OK] %s %s' % (name, extra))
else:
FAIL += 1
print('[FAIL] %s %s' % (name, extra))
HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
WEB_SRC = io.open(WEB, encoding='utf-8').read()
APP_SRC = io.open(APP, encoding='utf-8').read()
TREE = ast.parse(HELPER_SRC)
# 假的 `ip -o -4 addr show` 输出:ens18 是 192.168.7.3/24(这台路由器的真实 LAN 口)
FAKE_IP_OUT = (
'1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever\n'
'2: ens18 inet 192.168.7.3/24 brd 192.168.7.255 scope global ens18\\ valid_lft forever\n'
)
def node(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 n
elif isinstance(n, ast.FunctionDef) and n.name == name:
return n
return None
def build(names, ns=None):
ns = ns if ns is not None else {}
body = []
for nm in names:
n = node(nm)
if n is None:
raise AssertionError('源码里找不到 %s' % nm)
body.append(n)
mod = ast.Module(body=body, type_ignores=[])
ast.fix_missing_locations(mod)
exec(compile(mod, '<dcfg>', 'exec'), ns)
return ns
NS = build([
'DCFG_MANAGED', 'DCFG_DEFAULTS', 'DCFG_MIRRORS',
'_dcfg_gen_ula', '_dcfg_check_v6_prefix', '_dcfg_check_bip',
'_dcfg_parse_ipv4_addrs', '_dcfg_subnet_in_use', '_dcfg_check_mirror_url',
'_dcfg_log_opts', '_dcfg_build', '_dcfg_render',
], {
're': re, 'os': os, 'json': json, 'ipaddress': ipaddress,
'sh': lambda *a, **k: (0, FAKE_IP_OUT, ''),
})
MANAGED = NS['DCFG_MANAGED']
DEFAULTS = NS['DCFG_DEFAULTS']
MIRRORS = NS['DCFG_MIRRORS']
build_cfg = NS['_dcfg_build']
render_cfg = NS['_dcfg_render']
gen_ula = NS['_dcfg_gen_ula']
chk_v6 = NS['_dcfg_check_v6_prefix']
chk_bip = NS['_dcfg_check_bip']
chk_url = NS['_dcfg_check_mirror_url']
log_opts = NS['_dcfg_log_opts']
def cfg_of(**kw):
c = dict(DEFAULTS)
c.update(kw)
return c
def main():
print('--- 后端动作与路由 ---')
chk('定义了 act_dcfg', 'def act_dcfg(' in HELPER_SRC)
chk('注册到 ACTIONS', re.search(r"^\s*'dcfg':\s*act_dcfg,", HELPER_SRC, re.M) is not None)
chk('Web 路由 /api/dcfg', "'/api/dcfg'" in WEB_SRC)
chk('测速给了长超时', re.search(r"to = 200 if op == 'mirror_test'", WEB_SRC) is not None)
chk('重启给了长超时', "190 if op == 'restart'" in WEB_SRC)
chk('未知 op 返回 fail', '不支持的引擎配置操作' in HELPER_SRC)
chk('配置文件路径正确', "DCFG_FILE = '/etc/docker/daemon.json'" in HELPER_SRC)
print('\n--- 默认值(我替用户做的判断)---')
chk('IPv6 默认关(开了不等于能上 v6 外网)', DEFAULTS['ipv6'] is False)
chk('fixed-cidr-v6 默认留空(首次开启自动生成)', DEFAULTS['fixed_cidr_v6'] == '')
chk('experimental 默认关(26+ 不需要,个别版本会拒绝启动)',
DEFAULTS['experimental'] is False)
chk('镜像源默认不选(直连官方)', DEFAULTS['mirrors'] == [])
chk('日志滚动默认开(容器日志是撑爆小硬盘头号元凶)', DEFAULTS['log_rotate'] is True)
chk('live-restore 默认开(重启 dockerd 别杀容器)', DEFAULTS['live_restore'] is True)
chk('bip 默认不动(用 Docker 默认 172.17.0.1/16)', DEFAULTS['bip'] == '')
print('\n--- ULA 私有段生成 ---')
ulas = set()
for _ in range(30):
u = gen_ula()
ulas.add(u)
n = ipaddress.IPv6Network(u)
if not (str(n).startswith('fd') and n.prefixlen == 48 and n.is_private):
chk('生成的 ULA 合规:%s' % u, False)
break
else:
chk('30 次生成的都是 fd00::/8 私有 /48', True)
chk('每次生成都不一样(随机 40 bit)', len(ulas) > 1, '%d 种' % len(ulas))
print('\n--- fixed-cidr-v6 校验 ---')
ok, cidr, err, warn = chk_v6('fd12:3456:789a::/64')
chk('合法 ULA /64 通过', ok is True and cidr == 'fd12:3456:789a::/64')
chk('合法 ULA 无警告', warn == '')
ok, cidr, err, warn = chk_v6('fd00::1/64')
chk('带主机位也能归一化', ok is True and cidr == 'fd00::/64', cidr)
ok, _c, err, _w = chk_v6('fd12:3456:789a::')
chk('缺前缀长度被拒', ok is False and '前缀/长度' in err)
ok, _c, err, _w = chk_v6('fd12:3456:789a::/32')
chk('/32 被拒(超出 48–64)', ok is False and '/48' in err)
ok, _c, err, _w = chk_v6('fd12:3456:789a::/96')
chk('/96 被拒', ok is False)
ok, _c, err, _w = chk_v6('')
chk('空值被拒', ok is False)
ok, _c, err, _w = chk_v6('这不是ipv6/64')
chk('非法串被拒', ok is False)
ok, cidr, err, warn = chk_v6('2408:8207:2410::/64')
chk('公网段允许但有警告', ok is True and '公网段' in warn, warn)
ok, cidr, err, warn = chk_v6('fd12:3456:789a::/56')
chk('非 /64 提醒改回 /64', ok is True and '/64' in warn, warn)
print('\n--- 默认网桥网段 bip 校验 ---')
ok, cidr, err, warn = chk_bip('')
chk('留空直接通过且不写', ok is True and cidr == '')
ok, cidr, err, warn = chk_bip('172.17.0.1/16')
chk('不冲突的段无警告', ok is True and warn == '' and cidr == '172.17.0.0/16')
ok, cidr, err, warn = chk_bip('192.168.7.1/24')
chk('与 ens18 同段时给出冲突警告', ok is True and '重叠' in warn, warn)
ok, _c, err, _w = chk_bip('这不是ip')
chk('非法网段被拒', ok is False)
ok, _c, err, _w = chk_bip('10.0.0.1/33')
chk('/33 被拒', ok is False)
ok, _c, err, _w = chk_bip('10.0.0.1/31')
chk('/31 被拒(上限 /30)', ok is False)
ok, _c, err, _w = chk_bip('10.0.0.1/4')
chk('/4 被拒(下限 /8)', ok is False)
addrs = NS['_dcfg_parse_ipv4_addrs'](FAKE_IP_OUT)
chk('能从 ip -o -4 addr 里抽出网段',
any(str(a) == '192.168.7.0/24' for a in addrs), [str(a) for a in addrs])
chk('网段重叠判定准确',
NS['_dcfg_subnet_in_use'](ipaddress.IPv4Network('192.168.7.0/24'), addrs) is True
and NS['_dcfg_subnet_in_use'](ipaddress.IPv4Network('172.17.0.0/16'), addrs) is False)
print('\n--- 镜像源地址校验 ---')
ok, u, err = chk_url('https://docker.nju.edu.cn')
chk('标准 https 地址通过', ok is True and u == 'https://docker.nju.edu.cn')
ok, u, err = chk_url('docker.nju.edu.cn')
chk('缺协议时自动补 https', ok is True and u == 'https://docker.nju.edu.cn')
ok, u, err = chk_url('https://docker.nju.edu.cn/')
chk('结尾斜杠被去掉', ok is True and u == 'https://docker.nju.edu.cn')
ok, _u, err = chk_url('http://docker.nju.edu.cn')
chk('明文 http 被拒', ok is False and 'https' in err)
ok, _u, err = chk_url('https://a b.com')
chk('含空格被拒', ok is False)
ok, _u, err = chk_url('https://a.com"x')
chk('含引号被拒', ok is False)
ok, _u, err = chk_url('')
chk('空值被拒', ok is False)
ok, _u, err = chk_url('https://' + 'a' * 320)
chk('过长被拒', ok is False)
ok, _u, err = chk_url('https://.bad')
chk('主机名以点开头被拒', ok is False)
ok, _u, err = chk_url('https://a..b')
chk('主机名含连续点被拒', ok is False)
print('\n--- 预设镜像源清单 ---')
chk('至少 5 个预设', len(MIRRORS) >= 5, '%d 个' % len(MIRRORS))
chk('key 不重复', len(set(m['key'] for m in MIRRORS)) == len(MIRRORS))
for m in MIRRORS:
chk('%-8s 有名称/地址/说明' % m['key'],
bool(m.get('name')) and bool(m.get('url')) and len(m.get('note') or '') >= 10)
ali = [m for m in MIRRORS if '<' in (m.get('url') or '')]
chk('阿里云是占位地址(不参与写入与测速)', len(ali) == 1,
[m['key'] for m in ali])
print('\n--- 合并逻辑:保留用户自己的键 ---')
existing = {'data-root': '/mnt/ssd/docker', 'insecure-registries': ['10.0.0.5:5000']}
d, errs, warns = build_cfg(cfg_of(), existing)
chk('未纳管的键原样保留',
d.get('data-root') == '/mnt/ssd/docker'
and d.get('insecure-registries') == ['10.0.0.5:5000'])
chk('默认配置只有日志滚动与 live-restore',
sorted(d.keys()) == sorted(['data-root', 'insecure-registries',
'log-driver', 'log-opts', 'live-restore']),
sorted(d.keys()))
chk('默认不写 ipv6', 'ipv6' not in d)
chk('默认不写 registry-mirrors', 'registry-mirrors' not in d)
print('\n--- 合并逻辑:纳管的键要能被彻底关掉 ---')
existing2 = {'ipv6': True, 'fixed-cidr-v6': 'fd00::/64',
'ip6tables': True, 'experimental': True,
'registry-mirrors': ['https://old.example.com'],
'log-driver': 'journald'}
d, errs, warns = build_cfg(cfg_of(), existing2)
for k in ('ipv6', 'fixed-cidr-v6', 'ip6tables', 'experimental', 'registry-mirrors'):
chk('关掉后 %s 不再残留' % k, k not in d)
chk('旧的 log-driver 被本页的覆盖', d.get('log-driver') == 'json-file')
print('\n--- 合并逻辑:IPv6 开关 ---')
d, errs, warns = build_cfg(cfg_of(ipv6=True), {})
chk('开 IPv6 写 ipv6: true', d.get('ipv6') is True)
chk('开 IPv6 自动生成 fixed-cidr-v6',
(d.get('fixed-cidr-v6') or '').startswith('fd'), d.get('fixed-cidr-v6'))
chk('开 IPv6 同时写 ip6tables', d.get('ip6tables') is True)
chk('默认不写 experimental', 'experimental' not in d)
d, errs, warns = build_cfg(cfg_of(ipv6=True, experimental=True), {})
chk('勾上 experimental 才写', d.get('experimental') is True)
d, errs, warns = build_cfg(cfg_of(ipv6=True, fixed_cidr_v6='fdab:cdef:1234::/64'), {})
chk('指定网段时用它', d.get('fixed-cidr-v6') == 'fdab:cdef:1234::/64')
d, errs, warns = build_cfg(cfg_of(ipv6=True, fixed_cidr_v6='瞎写的'), {})
chk('网段非法时报错拦住', bool(errs) and d.get('ipv6') is None, errs)
print('\n--- 合并逻辑:镜像源 ---')
d, errs, warns = build_cfg(cfg_of(mirrors=['nju', 'tencent']), {})
chk('按选择顺序写 registry-mirrors',
d.get('registry-mirrors') == ['https://docker.nju.edu.cn',
'https://mirror.ccs.tencentyun.com'],
d.get('registry-mirrors'))
d, errs, warns = build_cfg(cfg_of(mirrors=['nju'], mirror_custom='https://x.mirror.aliyuncs.com'), {})
chk('自定义源排在预设之后',
d.get('registry-mirrors') == ['https://docker.nju.edu.cn',
'https://x.mirror.aliyuncs.com'])
d, errs, warns = build_cfg(cfg_of(mirrors=['nju'], mirror_custom='https://docker.nju.edu.cn'), {})
chk('自定义源与预设重复时去重',
d.get('registry-mirrors') == ['https://docker.nju.edu.cn'])
d, errs, warns = build_cfg(cfg_of(mirrors=['aliyun']), {})
chk('占位地址不会被写进配置', 'registry-mirrors' not in d)
d, errs, warns = build_cfg(cfg_of(mirror_custom='http://明文.com'), {})
chk('自定义源非法时报错', bool(errs) and 'registry-mirrors' not in d, errs)
print('\n--- 合并逻辑:日志滚动 ---')
d, errs, warns = build_cfg(cfg_of(log_rotate=True), {})
chk('写 json-file 驱动', d.get('log-driver') == 'json-file')
chk('默认 10m / 3 份', d.get('log-opts') == {'max-size': '10m', 'max-file': '3'})
chk('日志参数非法时回落到 10m',
log_opts({'log_max_size': '很大', 'log_max_file': 'x'}) == {'max-size': '10m', 'max-file': '3'})
chk('份数钳制在 1–20',
log_opts({'log_max_size': '5m', 'log_max_file': 999})['max-file'] == '20'
and log_opts({'log_max_size': '5m', 'log_max_file': 0})['max-file'] == '1')
d, errs, warns = build_cfg(cfg_of(log_rotate=False), {'log-driver': 'journald'})
chk('关掉日志滚动时不写 log-opts', 'log-opts' not in d)
print('\n--- 渲染结果 ---')
text = render_cfg({'ipv6': True, 'live-restore': True})
chk('是缩进 2 格的 JSON', text.startswith('{\n "ipv6"'), repr(text[:20]))
chk('结尾有换行', text.endswith('\n'))
chk('能被解析回来', json.loads(text) == {'ipv6': True, 'live-restore': True})
chk('中文不被转义', render_cfg({'x': '中文'}) == '{\n "x": "中文"\n}\n')
print('\n--- 安全:保存绝不动 Docker ---')
i_save = HELPER_SRC.index('def _dcfg_save_op(p):')
i_restart = HELPER_SRC.index('def _dcfg_restart_op(p):')
seg_save = HELPER_SRC[i_save:i_restart]
chk('保存流程里没有 systemctl', 'systemctl' not in seg_save)
chk('保存流程里没有 docker 命令调用', 'docker restart' not in seg_save
and 'dockerd' not in seg_save)
chk('重启需要显式确认', "p.get('confirm')" in HELPER_SRC
and 'NEED_CONFIRM' in HELPER_SRC)
chk('构建保护模式下拒绝重启', 'BUILD_MODE' in HELPER_SRC
and '已拒绝重启 Docker' in HELPER_SRC)
chk('Docker 未安装时拒绝重启', '无需重启' in HELPER_SRC)
chk('解析不了现有文件时拒绝保存(不覆盖用户数据)',
'本页不会覆盖它' in HELPER_SRC)
chk('备份文件名做了白名单校验',
re.search(r"r'\^daemon-\\d\{8\}-\\d\{6\}\\.json\$'", HELPER_SRC) is not None)
chk('还原做了 realpath 防穿越',
'os.path.realpath(os.path.join(DCFG_BAK_DIR, fn))' in HELPER_SRC
and 'src.startswith(root + os.sep)' in HELPER_SRC)
chk('测速带 --noproxy(不被环境代理掩盖)', "'--noproxy', '*'" in HELPER_SRC)
chk('测速认 200 与 401 为可用', "'200', '401'" in HELPER_SRC)
chk('缺少 curl 时给出明确提示', '缺少 curl' in HELPER_SRC)
print('\n--- 前端接线 ---')
chk('菜单里有 dcfg', re.search(r"k: 'dcfg'", APP_SRC) is not None)
chk('菜单文案含「Docker 引擎配置」', 'Docker 引擎配置' in APP_SRC)
chk('VIEWS 注册 dcfg', re.search(r"^\s*dcfg: viewDcfg,", APP_SRC, re.M) is not None)
for fn in ('viewDcfg', 'dcfgLoad', 'dcfgRender', 'dcfgSave', 'dcfgPreview',
'dcfgForm', 'dcfgBind', 'dcfgAskRestart'):
chk('前端函数 %s' % fn, ('function %s(' % fn) in APP_SRC)
chk('调用 /api/dcfg', "'/api/dcfg'" in APP_SRC)
chk('配置代码实时显示', 'id="dc-code-pre"' in APP_SRC)
chk('预览走后端(前端不自己拼 JSON)',
"dcfgForm('preview')" in APP_SRC)
chk('保存后用服务端返回内容校准预览',
"pre.textContent = d.content" in APP_SRC)
chk('重启前有二次确认弹窗', '重启 Docker 使配置生效' in APP_SRC)
chk('重启确认文案说明会中断容器', '中断所有正在运行的容器' in APP_SRC)
chk('Docker 页面有跳转入口', "onclick=\"go('dcfg')\"" in APP_SRC)
chk('未安装 Docker 时给出说明', '还没有安装 Docker' in APP_SRC)
chk('未纳管键会提示用户会保留', '原样保留' in APP_SRC)
chk('experimental 有背景说明(哪版需要)', '24 / 25' in APP_SRC)
print('\n' + '=' * 60)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
return 1 if FAIL else 0
if __name__ == '__main__':
sys.exit(main())