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

284 lines
13 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)。
必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443)。
用法: python3 /tmp/live-dcfg.py
会真正写 /etc/docker/daemon.json(这台机器上 Docker 未安装,所以不会、
也不能重启任何服务)。跑完会还原成「保存前的状态」:
* 原来没有 daemon.json → 删掉,并把 /etc/docker 目录也清理掉;
* 原来有 → 用自动备份还原,再删掉本次产生的备份。
安全边界:不碰 5900、不动网卡、不启停任何网络服务。
"""
import json
import os
import ssl
import sys
import urllib.request
BASE = 'https://127.0.0.1:8443'
CTX = ssl._create_unverified_context()
TOK = None
PASS = FAIL = 0
DCFG_FILE = '/etc/docker/daemon.json'
DCFG_CONF = '/etc/drouter/dcfg.json' # drouter 自己记的开关状态
DCFG_CONF_BAK = DCFG_CONF + '.livetest-bak'
DCFG_BAK_DIR = '/var/lib/drouter/dcfg-backup'
def call(path, payload=None, timeout=60):
data = json.dumps(payload).encode('utf-8') if payload is not None else None
req = urllib.request.Request(BASE + path, data=data,
method='POST' if data is not None else 'GET')
req.add_header('Content-Type', 'application/json')
if TOK:
req.add_header('X-Token', TOK)
try:
with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
return json.loads(r.read().decode('utf-8'))
except Exception as e:
return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
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))
TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
if not TOK:
print('登录失败,终止')
sys.exit(1)
had_file = os.path.isfile(DCFG_FILE)
# 开关状态文件必须先挪走:上一轮验收如果保存过 ipv6=True,这一轮读到的就不是
# 「出厂默认」了,「IPv6 默认关」会假失败。这是测试污染,不是产品问题。
had_conf = os.path.isfile(DCFG_CONF)
if had_conf:
os.rename(DCFG_CONF, DCFG_CONF_BAK)
bak_before = set()
if os.path.isdir(DCFG_BAK_DIR):
bak_before = set(os.listdir(DCFG_BAK_DIR))
print('保存前:daemon.json %s;开关状态文件 %s'
% ('存在' if had_file else '不存在(本次会新建)',
'已临时挪开(验收完还原)' if had_conf else '不存在'))
# ---------------------------------------------------------------- 1. 读取
print('\n--- 状态读取 ---')
r = call('/api/dcfg', {'op': 'status'}, timeout=60)
chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
d = r.get('data') or {}
print(' Docker 引擎:%s 服务:%s 开机自启:%s'
% (d.get('installed'), d.get('active'), d.get('enabled')))
print(' 配置文件:%s(%s)' % (d.get('file'), '存在' if d.get('exists') else '不存在'))
chk('返回配置文件路径', d.get('file') == DCFG_FILE)
chk('返回是否已安装', 'installed' in d)
chk('返回服务状态', 'active' in d and 'enabled' in d)
chk('返回镜像源清单', len(d.get('mirrors') or []) >= 5,
'→ %d 个' % len(d.get('mirrors') or []))
chk('返回未纳管键列表', 'unmanaged' in d)
chk('返回预览代码', bool((d.get('preview') or '').strip()))
print(' 默认预览:')
for ln in (d.get('preview') or '').rstrip().splitlines():
print(' ' + ln)
c0 = d.get('cfg') or {}
chk('IPv6 默认关', c0.get('ipv6') is False)
chk('日志滚动默认开', c0.get('log_rotate') is True)
chk('live-restore 默认开', c0.get('live_restore') is True)
# ---------------------------------------------------------------- 2. 预览与落盘一致
print('\n--- 预览 = 真正写入的内容 ---')
prev = call('/api/dcfg', {'op': 'preview',
'ipv6': True, 'fixed_cidr_v6': 'fdab:cdef:1234::/64',
'experimental': False,
'mirrors': ['nju', 'tencent'],
'mirror_custom': '',
'log_rotate': True, 'log_max_size': '20m', 'log_max_file': 5,
'live_restore': True, 'bip': ''}, timeout=60)
chk('preview 接口可用', prev.get('ok') is True)
p_txt = ((prev.get('data') or {}).get('preview') or '')
print(' 预览:')
for ln in p_txt.rstrip().splitlines():
print(' ' + ln)
chk('预览含 ipv6', '"ipv6": true' in p_txt)
chk('预览含指定网段', 'fdab:cdef:1234::/64' in p_txt)
chk('预览含 ip6tables', '"ip6tables": true' in p_txt)
chk('预览不含 experimental(未勾选)', '"experimental"' not in p_txt)
chk('预览含两个镜像源',
'"registry-mirrors"' in p_txt
and 'docker.nju.edu.cn' in p_txt
and 'mirror.ccs.tencentyun.com' in p_txt)
chk('预览含日志滚动参数',
'"max-size": "20m"' in p_txt and '"max-file": "5"' in p_txt)
# ---------------------------------------------------------------- 3. 保存
print('\n--- 保存 ---')
s = call('/api/dcfg', {'op': 'save',
'ipv6': True, 'fixed_cidr_v6': 'fdab:cdef:1234::/64',
'experimental': False,
'mirrors': ['nju', 'tencent'],
'mirror_custom': '',
'log_rotate': True, 'log_max_size': '20m', 'log_max_file': 5,
'live_restore': True, 'bip': ''}, timeout=60)
chk('save 接口可用', s.get('ok') is True, '→ %s' % s.get('msg_cn'))
sd = s.get('data') or {}
written = sd.get('content') or ''
chk('返回真实写入内容', bool(written))
chk('落盘内容与预览逐字节一致', written.strip() == p_txt.strip())
chk('保存时产生了备份', bool(sd.get('backup')) or not had_file,
'→ %s' % sd.get('backup'))
try:
on_disk = open(DCFG_FILE, encoding='utf-8').read()
except Exception as e:
on_disk = '读取失败:%s' % e
chk('文件里的内容与接口返回一致', on_disk == written)
try:
parsed = json.loads(on_disk)
chk('落盘的是合法 JSON', True)
chk('落盘内容含 ipv6 与网段',
parsed.get('ipv6') is True and parsed.get('fixed-cidr-v6') == 'fdab:cdef:1234::/64')
chk('落盘内容含镜像源', len(parsed.get('registry-mirrors') or []) == 2)
except Exception as e:
chk('落盘的是合法 JSON', False, str(e))
chk('保存后没有去重启 Docker(本页从不自动重启)',
'重启' not in (s.get('msg_cn') or '') or '需要重启' in (s.get('msg_cn') or ''))
# ---------------------------------------------------------------- 4. 保留用户自己的键
print('\n--- 保留用户自己写的键 ---')
with open(DCFG_FILE, 'w', encoding='utf-8') as f:
f.write(json.dumps({'data-root': '/mnt/ssd/docker',
'insecure-registries': ['10.0.0.5:5000'],
'ipv6': True, 'fixed-cidr-v6': 'fd00:dead::/64'},
ensure_ascii=False, indent=2) + '\n')
s2 = call('/api/dcfg', {'op': 'save', 'ipv6': False, 'mirrors': [],
'mirror_custom': '', 'log_rotate': True,
'log_max_size': '10m', 'log_max_file': 3,
'live_restore': True, 'bip': ''}, timeout=60)
chk('带外来键也能保存', s2.get('ok') is True, '→ %s' % s2.get('msg_cn'))
try:
p2 = json.loads(open(DCFG_FILE, encoding='utf-8').read())
except Exception as e:
p2 = {}
chk('二次保存后仍是合法 JSON', False, str(e))
chk('data-root 被保留', p2.get('data-root') == '/mnt/ssd/docker')
chk('insecure-registries 被保留', p2.get('insecure-registries') == ['10.0.0.5:5000'])
chk('关掉的 ipv6 被移除', 'ipv6' not in p2 and 'fixed-cidr-v6' not in p2)
r2 = call('/api/dcfg', {'op': 'status'}, timeout=60)
chk('状态里列出未纳管键',
set(((r2.get('data') or {}).get('unmanaged') or []))
>= {'data-root', 'insecure-registries'},
'→ %s' % ((r2.get('data') or {}).get('unmanaged')))
# ---------------------------------------------------------------- 5. ULA 生成
print('\n--- 随机私有段 ---')
u = call('/api/dcfg', {'op': 'ula'}, timeout=30)
ula = ((u.get('data') or {}).get('ula') or '')
chk('ula 接口可用', u.get('ok') is True)
print(' 生成:%s' % ula)
chk('是 fd 开头的 /48', ula.startswith('fd') and ula.endswith('/48'))
u2 = ((call('/api/dcfg', {'op': 'ula'}, timeout=30).get('data') or {}).get('ula') or '')
chk('两次生成不同', ula != u2, '%s / %s' % (ula, u2))
# ---------------------------------------------------------------- 6. 参数校验
print('\n--- 参数校验 ---')
bad = call('/api/dcfg', {'op': 'save', 'ipv6': True, 'fixed_cidr_v6': '瞎写的',
'mirrors': [], 'log_rotate': True, 'live_restore': True,
'bip': ''}, timeout=60)
chk('非法 IPv6 网段被拒', bad.get('ok') is False, '→ %s' % bad.get('msg_cn'))
bad2 = call('/api/dcfg', {'op': 'save', 'ipv6': False, 'mirrors': ['不存在的源'],
'log_rotate': True, 'live_restore': True}, timeout=60)
chk('未知镜像源被拒', bad2.get('ok') is False, '→ %s' % bad2.get('msg_cn'))
bad3 = call('/api/dcfg', {'op': 'save', 'ipv6': False, 'mirrors': [],
'mirror_custom': 'http://明文.com',
'log_rotate': True, 'live_restore': True}, timeout=60)
chk('明文 http 自定义源被拒', bad3.get('ok') is False, '→ %s' % bad3.get('msg_cn'))
bad4 = call('/api/dcfg', {'op': 'bogus'}, timeout=30)
chk('未知 op 被拒', bad4.get('ok') is False)
bad5 = call('/api/dcfg', {'op': 'restore', 'file': '../../etc/shadow'}, timeout=30)
chk('还原路径穿越被拒', bad5.get('ok') is False, '→ %s' % bad5.get('msg_cn'))
# ---------------------------------------------------------------- 7. 镜像源测速
print('\n--- 镜像源一键测速 ---')
mt = call('/api/dcfg', {'op': 'mirror_test'}, timeout=220)
chk('测速接口可用', mt.get('ok') is True, '→ %s' % mt.get('msg_cn'))
rows = ((mt.get('data') or {}).get('results') or [])
for t in rows:
flag = '可用' if t.get('ok') else ('跳过' if t.get('skipped') else '不可用')
print(' %-16s %-6s %-8s %s'
% (t.get('name'), flag, ('%d ms' % t.get('ms')) if t.get('ms') else '—',
t.get('reason') or ''))
chk('返回了全部预设的结果', len(rows) >= 6, '→ %d 条' % len(rows))
chk('阿里云占位地址被跳过',
any(t.get('skipped') and t.get('key') == 'aliyun' for t in rows))
chk('每条都有结论文案', all((t.get('reason') or '') for t in rows))
# ---------------------------------------------------------------- 8. 重启保护
print('\n--- 重启保护(这台机器上 Docker 未安装)---')
rs = call('/api/dcfg', {'op': 'restart', 'confirm': True}, timeout=60)
if (r.get('data') or {}).get('installed'):
chk('已安装时应给出明确结果', 'msg_cn' in rs, '→ %s' % rs.get('msg_cn'))
else:
chk('未安装时拒绝重启', rs.get('ok') is False, '→ %s' % rs.get('msg_cn'))
rs2 = call('/api/dcfg', {'op': 'restart'}, timeout=60)
chk('不带确认就拒绝', rs2.get('ok') is False, '→ %s' % rs2.get('msg_cn'))
# ---------------------------------------------------------------- 9. 还原
print('\n--- 备份与还原 ---')
st = call('/api/dcfg', {'op': 'status'}, timeout=60)
bks = ((st.get('data') or {}).get('backups') or [])
chk('产生了备份记录', len(bks) >= 1, '→ %d 份' % len(bks))
if bks:
pick = bks[0]['file']
rr = call('/api/dcfg', {'op': 'restore', 'file': pick}, timeout=60)
chk('还原接口可用', rr.get('ok') is True, '→ %s' % rr.get('msg_cn'))
chk('还原后文件内容与备份一致',
(rr.get('data') or {}).get('content', '').strip()
== open(DCFG_FILE, encoding='utf-8').read().strip())
# ---------------------------------------------------------------- 10. 还原现场
print('\n--- 还原现场 ---')
if had_file:
print(' 原本有 daemon.json,交由用户确认保留哪一份(备份目录里都在)')
else:
try:
os.remove(DCFG_FILE)
chk('已删除本次新建的 daemon.json', not os.path.isfile(DCFG_FILE))
except Exception as e:
chk('已删除本次新建的 daemon.json', False, str(e))
try:
if os.path.isdir('/etc/docker') and not os.listdir('/etc/docker'):
os.rmdir('/etc/docker')
print(' /etc/docker 空目录也已清理')
except Exception:
pass
if os.path.isdir(DCFG_BAK_DIR):
new = set(os.listdir(DCFG_BAK_DIR)) - bak_before
for f in new:
try:
os.remove(os.path.join(DCFG_BAK_DIR, f))
except Exception:
pass
print(' 本次产生的 %d 份备份已清理' % len(new))
if had_conf:
os.rename(DCFG_CONF_BAK, DCFG_CONF)
print(' 开关状态文件已还原')
else:
try:
os.remove(DCFG_CONF)
except Exception:
pass
print(' 本次生成的开关状态文件已清理')
print('\n' + '=' * 60)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
sys.exit(1 if FAIL else 0)