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

219 lines
8.1 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 -*-
"""#10 Docker 面板 —— 纯逻辑与校验测试(不依赖 Docker / Linux)。
从 drouter-helper.py 抽取相关片段,用 NS 命名空间隔离执行。
覆盖:
A. docker run → compose 转换(回归,与 t-docker-conv.py 互补的关键断言)
B. _docker_validate_stack_name / stack_save 内容校验 / stack_delete 防穿越
C. act_docker 参数白名单(非法 op / 非法容器 ID / 非法 prune 类型)
"""
import os
import re
import sys
import json
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, 'backend', 'drouter-helper.py')
REG = os.path.join(ROOT, 'backend', 'render.py')
src = open(SRC, encoding='utf-8').read()
PASS = []
FAIL = []
def chk(name, got, want):
if got == want:
PASS.append(name)
return True
FAIL.append('%s:得到 %r,期望 %r' % (name, got, want))
return False
def has(name, hay, needle, want=True):
got = needle in (hay or '')
if got == want:
PASS.append(name)
return True
FAIL.append('%s:%r in 输出 = %s,期望 %s' % (name, needle, got, want))
return False
def grab(pattern, name, flags=re.S):
m = re.search(pattern, src, flags)
if not m:
raise SystemExit('抽取失败:%s' % name)
return m.group(0)
# ---------- 构建 NS ----------
NS = {
're': re, 'os': os, 'json': json, 'sys': sys,
'ValidateError': type('ValidateError', (Exception,), {
'__init__': lambda self, msg, field=None: (
setattr(self, 'msg_cn', msg), setattr(self, 'field', field))[0] or None,
}),
'sh': lambda *a, **k: (1, '', 'skip'),
'ok': lambda data=None, msg=None: {'ok': True, 'data': data, 'msg_cn': msg},
'fail': lambda msg, code='ERR', data=None: {
'ok': False, 'code': code, 'msg_cn': msg, 'data': data},
'DOCKER_STACK_DIR': '/etc/drouter/docker/stacks',
'datetime': __import__('datetime').datetime,
}
# 抽取 docker 相关纯逻辑与校验函数
for fn in ('_docker_shlex', '_docker_split_kv', '_yaml_scalar',
'_to_yaml_compose', '_docker_validate_stack_name'):
NS[fn] = None
seg = grab(r'DOCKER_BOOL_FLAGS = \{.*?(?=\ndef read_docker\()', 'DOCKER_* 常量与函数区')
exec(compile(seg, SRC, 'exec'), NS)
# 只抽取 _docker_run_to_compose 单函数(避免带入 read_docker/act_docker 的 IO)
seg2 = grab(r'def _docker_run_to_compose\(.*?(?=\ndef _docker_bin\()', '_docker_run_to_compose')
exec(compile(seg2, SRC, 'exec'), NS)
conv = NS['_docker_run_to_compose']
# ---------- A. 转换器关键回归 ----------
r = conv('docker run -d --name nginx -p 8080:80 -v /data:/usr/share/nginx/html nginx:alpine')
chk('A1 ok', r['ok'], True)
has('A1 image', r['yaml'], 'image: "nginx:alpine"')
has('A1 ports', r['yaml'], '"8080:80"')
has('A1 volume', r['yaml'], '"/data:/usr/share/nginx/html"')
has('A1 container_name', r['yaml'], 'container_name: nginx')
r = conv('docker run --rm -it alpine sh')
chk('A2 rm→no restart', 'restart: "no"' in r['yaml'], True)
has('A2 stdin_open', r['yaml'], 'stdin_open: true')
has('A2 tty', r['yaml'], 'tty: true')
has('A2 command', r['yaml'], 'command: sh')
r = conv('''docker run -d \\
--name app \\
-e "DB_HOST=host.docker.internal" \\
-e TZ=Asia/Shanghai \\
--health-cmd "curl -f http://localhost/ || exit 1" \\
--health-interval 30s \\
--ulimit nofile=1024:2048 \\
app:latest''')
chk('A3 ok', r['ok'], True)
has('A3 env quoted', r['yaml'], '"DB_HOST=host.docker.internal"')
has('A3 env plain', r['yaml'], '"TZ=Asia/Shanghai"')
has('A3 healthcheck test', r['yaml'], 'curl -f http://localhost/ || exit 1')
has('A3 healthcheck CMD-SHELL', r['yaml'], 'CMD-SHELL')
has('A3 healthcheck interval', r['yaml'], 'interval: 30s')
has('A3 ulimit soft', r['yaml'], 'soft: "1024"')
has('A3 ulimit hard', r['yaml'], 'hard: "2048"')
# 未知参数启发式:`--flag value` 应识别为带值,不吞掉镜像名
r = conv('docker run -d --some-unknown-flag value nginx:latest')
has('A4 image kept', r['yaml'], 'image: "nginx:latest"')
chk('A4 unknown not image', r['image'], 'nginx:latest')
has('A4 warning', '\n'.join(r['warnings']), '--some-unknown-flag')
# 未知布尔开关 + 镜像(不带值)也不能误吞镜像名
r = conv('docker run -d --some-bool nginx:latest')
chk('A4b image', r['image'], 'nginx:latest')
# --mount 裸标志
r = conv('docker run -d --mount type=bind,src=/a,dst=/b,readonly nginx')
has('A4c mount read_only', r['yaml'], 'read_only: true')
has('A4c mount source', r['yaml'], 'source: /a')
has('A4c mount target', r['yaml'], 'target: /b')
# 具名网络 → 顶层 networks + external
r = conv('docker run -d --network mynet nginx')
has('A5 networks top', r['yaml'], '\nnetworks:')
has('A5 external', r['yaml'], 'external: true')
has('A5 mynet key', r['yaml'], 'mynet:')
# 版本:默认不加 version 字段(只剩注释里提到,正文行不以 'version:' 开头)
r = conv('docker run -d nginx')
chk('A6 default no version line',
any(ln.startswith('version:') for ln in r['yaml'].splitlines()), False)
r = conv('docker run -d nginx', version='3.9')
has('A6 set version', r['yaml'], "version: \"3.9\"")
# 服务名自动推导
r = conv('docker run -d --name my-svc-1 nginx')
chk('A7 service name', r['service'], 'my-svc-1')
r = conv('docker run -d nginx:alpine')
chk('A7 service from image', r['service'], 'nginx')
# 非 docker run 命令
r = conv('echo hello')
chk('A8 reject non-run', r['ok'], False)
# ---------- B. stack 名称校验 ----------
V = NS['_docker_validate_stack_name']
for good in ('a', 'my-app', 'app_1', 'a' * 41):
try:
V(good)
PASS.append('B good %s' % good[:12])
except Exception as e:
FAIL.append('B good %s 被拒:%s' % (good[:12], e))
for bad in ('', 'A', 'MyApp', '-x', '_x', 'a' * 42, 'a b', 'a/b', '../x', 'a.b'):
try:
V(bad)
FAIL.append('B bad %r 未被拒' % bad)
except Exception:
PASS.append('B bad %r 已拒' % bad)
# ---------- C. act_docker 参数白名单(抽取判断体) ----------
# op 白名单
ops = set(re.findall(r"op (?:==|in) \(?'?([a-z_]+)'?\)?", src))
for need in ('convert', 'logs', 'inspect', 'prune', 'stack_save', 'stack_delete'):
chk('C op %s 存在' % need, need in ops, True)
# 容器 ID 正则
m = re.search(r"re\.match\(r'\^\[a-zA-Z0-9\]\[a-zA-Z0-9_\.\-\]\{0,63\}\$'", src)
chk('C 容器 ID 正则存在', bool(m), True)
if m:
rx = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$')
chk('C id 合法', bool(rx.match('nginx')), True)
chk('C id 合法2', bool(rx.match('a1b2c3d4e5f6')), True)
chk('C id 非法(空)', bool(rx.match('')), False)
chk('C id 非法(空格)', bool(rx.match('a b')), False)
chk('C id 非法(斜杠)', bool(rx.match('a/b')), False)
chk('C id 非法(点开头)', bool(rx.match('.hidden')), False)
chk('C id 超长', bool(rx.match('a' * 65)), False)
# prune 白名单
pws = re.findall(r"'(\w+)': \['(?:container|image|volume|network|builder)'", src)
for need in ('containers', 'images', 'volumes', 'networks', 'builder'):
chk('C prune %s' % need, need in pws, True)
# stack_delete 防穿越
has('C realpath 校验', src, 'os.path.realpath(os.path.join(DOCKER_STACK_DIR, name))')
has('C startswith root', src, "d2.startswith(root + os.sep)")
has('C 需确认项目名', src, "p.get('confirm') != name")
# stack_save 必须含 services 段
has('C services 校验', src, r"^\s*services\s*:")
has('C NUL 校验', src, "if '\\x00' in content")
# sh 支持 cwd(本轮修复)
has('C sh cwd 参数', src, "def sh(cmd, timeout=15, input_data=None, cwd=None, env=None):")
has('C sh 透传 cwd', src, "if cwd:\n kw['cwd'] = cwd")
has('C compose 使用 cwd', src, 'sh(args, timeout=600, cwd=d2)')
# ACTIONS 注册
has('C ACTIONS read:docker', src, "'read:docker': read_docker")
has('C ACTIONS docker', src, "'docker': act_docker")
# DOCKER 常量
for cn in ('DOCKER_DIR', 'DOCKER_STACK_DIR', 'DOCKER_PKGS'):
has('C 常量 %s' % cn, src, '%s = ' % cn)
print('=' * 62)
print('#10 Docker 面板 · 单测结果')
print('=' * 62)
print('通过:%d 失败:%d' % (len(PASS), len(FAIL)))
if FAIL:
print('-' * 62)
for f in FAIL:
print(' ✗ ' + f)
sys.exit(1)
print('全部通过 ✔')