Files
drouter/_dev/t-docker-conv.py
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

200 lines
8.4 KiB
Python

# -*- coding: utf-8 -*-
"""验证 docker run → docker-compose.yml 转换器(#10)。纯逻辑抽取测试。"""
import re, io, sys
SRC = r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py"
src = io.open(SRC, encoding='utf-8').read()
def grab(n):
m = re.search(r'^def %s\(.*?(?=\n(?:def |[A-Z_]+ *=|# ---|class ))' % re.escape(n),
src, re.S | re.M)
if not m:
raise SystemExit('未找到函数:%s' % n)
return m.group(0)
NS = {'re': re}
for c in ('DOCKER_BOOL_FLAGS', 'DOCKER_SHORT', 'DOCKER_LIST_FLAGS', 'DOCKER_RESTART',
'DOCKER_RUN_MAP'):
m = re.search(r'^%s = \{.*?\n\}' % c, src, re.S | re.M)
if not m:
raise SystemExit('未找到常量:%s' % c)
exec(m.group(0), NS)
for f in ('_docker_shlex', '_docker_split_kv', '_docker_run_to_compose',
'_yaml_scalar', '_to_yaml_compose'):
exec(grab(f), NS)
CV = NS['_docker_run_to_compose']
SH = NS['_docker_shlex']
fails = 0
def chk(name, got, want):
global fails
ok = (got == want)
if not ok:
fails += 1
print('[%s] %-46s got=%-26s want=%s' % ('OK' if ok else 'FAIL', name, repr(got), repr(want)))
def has(name, needle, hay, want=True):
chk(name, needle in hay, want)
print('=== 词法分析 _docker_shlex ===')
chk('普通空格切分', SH('a b c'), ['a', 'b', 'c'])
chk('双引号保空格', SH('--name "my app"'), ['--name', 'my app'])
chk('单引号保空格', SH("-e 'A B=1'"), ['-e', 'A B=1'])
chk('转义空格', SH(r'a\ b c'), ['a b', 'c'])
chk('续行合并', SH('a \\\n b'), ['a', 'b'])
chk('引号内 $ 不展开', SH('-e "PATH=$PATH"'), ['-e', 'PATH=$PATH'])
chk('空串', SH(''), [])
chk('连续空格', SH('a b'), ['a', 'b'])
print('\n=== 基础:名称 / 端口 / 卷 / 环境变量 ===')
r = CV('docker run -d --name web -p 8080:80 -p 8443:443 '
'-v /srv/www:/usr/share/nginx/html:ro -e TZ=Asia/Shanghai nginx:alpine')
chk('解析成功', r['ok'], True)
chk('服务名用 --name', r['service'], 'web')
chk('镜像', r['image'], 'nginx:alpine')
has('含 container_name', 'container_name: web', r['yaml'])
has('端口 8080', '- "8080:80"', r['yaml'])
has('端口 8443', '- "8443:443"', r['yaml'])
has('卷 ro 后缀', '- "/srv/www:/usr/share/nginx/html:ro"', r['yaml'])
has('环境变量', '- "TZ=Asia/Shanghai"', r['yaml'])
has('无 version 行', 'version:', r['yaml'], False)
print('\n=== 短参数紧凑写法 -p8080:80 / -it / -m512m ===')
r2 = CV('docker run -it -p8080:80 -m512m ubuntu:24.04 bash')
chk('解析成功', r2['ok'], True)
has('紧凑端口识别', '- "8080:80"', r2['yaml'])
has('内存紧凑识别', 'mem_limit: 512m', r2['yaml'])
has('stdin_open', 'stdin_open: true', r2['yaml'])
has('tty', 'tty: true', r2['yaml'])
has('command 是 bash', 'command: bash', r2['yaml'])
print('\n=== 网络:host / none / container / 具名 ===')
r3 = CV('docker run -d --net=host nginx')
has('host → network_mode', 'network_mode: host', r3['yaml'])
r4 = CV('docker run -d --network none alpine')
has('none → network_mode', 'network_mode: none', r4['yaml'])
r5 = CV('docker run -d --network container:other alpine')
has('container: → network_mode', 'network_mode: "container:other"', r5['yaml'])
r6 = CV('docker run -d --network mynet alpine')
has('具名网络 → networks 列表', '- mynet', r6['yaml'])
has('顶层 networks 段', 'networks:', r6['yaml'])
has('external 声明', 'external: true', r6['yaml'])
print('\n=== 资源限制 / 权限 ===')
r7 = CV('docker run -d --cpus=1.5 -m 512m --memory-swap 1g --cpu-shares 512 '
'--cap-add NET_ADMIN --cap-add SYS_TIME --cap-drop ALL '
'--security-opt no-new-privileges --privileged alpine')
has('cpus', 'cpus: "1.5"', r7['yaml'])
has('mem_limit', 'mem_limit: 512m', r7['yaml'])
has('memswap_limit', 'memswap_limit: 1g', r7['yaml'])
has('cpu_shares', 'cpu_shares: "512"', r7['yaml'])
chk('cap_add 两个', r7['yaml'].count('- NET_ADMIN') + r7['yaml'].count('- SYS_TIME'), 2)
has('cap_drop', '- ALL', r7['yaml'])
has('security_opt', '- no-new-privileges', r7['yaml'])
has('privileged', 'privileged: true', r7['yaml'])
print('\n=== 只读 / init / 设备 / sysctl / ulimit ===')
r8 = CV('docker run -d --read-only --init --device /dev/dri:/dev/dri '
'--sysctl net.ipv4.ip_forward=1 --ulimit nofile=1024:2048 alpine')
has('read_only', 'read_only: true', r8['yaml'])
has('init', 'init: true', r8['yaml'])
has('devices', '/dev/dri:/dev/dri', r8['yaml'])
has('sysctls', '- "net.ipv4.ip_forward=1"', r8['yaml'])
has('ulimits soft', 'soft: "1024"', r8['yaml'])
has('ulimits hard', 'hard: "2048"', r8['yaml'])
print('\n=== 日志 / 健康检查 ===')
r9 = CV('docker run -d --log-driver json-file --log-opt max-size=10m '
'--health-cmd "curl -f http://localhost || exit 1" '
'--health-interval 30s --health-retries 3 --health-timeout 5s '
'--health-start-period 10s nginx')
has('logging.driver', 'driver: json-file', r9['yaml'])
has('log opt', 'max-size: 10m', r9['yaml'])
has('healthcheck CMD-SHELL', 'CMD-SHELL', r9['yaml'])
has('healthcheck interval', 'interval: 30s', r9['yaml'])
has('healthcheck retries', 'retries: "3"', r9['yaml'])
has('healthcheck timeout', 'timeout: 5s', r9['yaml'])
has('healthcheck start_period', 'start_period: 10s', r9['yaml'])
print('\n=== GPU ===')
rg = CV('docker run -d --gpus all nvidia/cuda:12.0-base')
has('deploy.resources', 'devices:', rg['yaml'])
has('nvidia driver', 'driver: nvidia', rg['yaml'])
has('count all', 'count: all', rg['yaml'])
print('\n=== --mount 长语法 ===')
rm = CV('docker run -d --mount type=bind,src=/srv/data,dst=/data,readonly alpine')
has('volumes 长语法生效', 'target: /data', rm['yaml'])
has('source 保留', 'source: /srv/data', rm['yaml'])
has('read_only', 'read_only: true', rm['yaml'])
print('\n=== --rm / -P 的提示 ===')
rr = CV('docker run --rm alpine echo hi')
chk('--rm 有 note', len(rr['notes']) >= 1, True)
has('restart no', 'restart: "no"', rr['yaml'])
rp = CV('docker run -d -P nginx')
chk('-P 有 note', any('publish-all' in n or '随机' in n for n in rp['notes']), True)
print('\n=== restart 值归一 ===')
chk('unless-stopped', CV('docker run -d --restart unless-stopped x')['fields'].get('restart'),
'unless-stopped')
chk('on-failure', CV('docker run -d --restart on-failure x')['fields'].get('restart'),
'on-failure')
chk('--restart=always', CV('docker run -d --restart=always x')['fields'].get('restart'),
'always')
print('\n=== 服务名推断 ===')
chk('无 --name 用镜像名', CV('docker run -d nginx:alpine')['service'], 'nginx')
chk('带 registry 去路径', CV('docker run -d ghcr.io/linuxserver/sonarr:latest')['service'],
'sonarr')
chk('无 tag 提取', CV('docker run -d traefik')['service'], 'traefik')
chk('指定服务名覆盖', CV('docker run -d nginx', service_name='my-svc')['service'], 'my-svc')
print('\n=== 错误处理 ===')
chk('空输入', CV('')['ok'], False)
chk('非 docker 命令', CV('ls -la')['ok'], False)
chk('compose 命令提示', 'docker compose' in CV('docker compose up -d')['msg_cn'], True)
chk('无镜像名', CV('docker run -d -p 80:80')['ok'], False)
chk('只有 docker run', CV('docker run')['ok'], False)
print('\n=== 未知参数 → 警告而非崩溃 ===')
ru = CV('docker run -d --some-future-flag=1 --another-future-flag nginx')
chk('仍然成功', ru['ok'], True)
chk('产生警告', len(ru['warnings']) >= 1, True)
has('镜像仍然正确', 'image: nginx', ru['yaml'])
print('\n=== 多行续行命令 ===')
rl = CV('''docker run -d \\
--name app \\
-p 3000:3000 \\
-v /srv/app:/app \\
node:20-alpine''')
chk('多行解析成功', rl['ok'], True)
has('多行 name', 'container_name: app', rl['yaml'])
has('多行 ports', '- "3000:3000"', rl['yaml'])
has('多行 volumes', '- "/srv/app:/app"', rl['yaml'])
has('多行 image', 'image: "node:20-alpine"', rl['yaml'])
print('\n=== YAML 引号安全 ===')
chk('含冒号加引号', NS['_yaml_scalar']('a:b'), '"a:b"')
chk('纯数字加引号', NS['_yaml_scalar']('8080'), '"8080"')
chk('布尔词加引号', NS['_yaml_scalar']('true'), '"true"')
chk('普通词不加引号', NS['_yaml_scalar']('alpine'), 'alpine')
chk('True 渲染 true', NS['_yaml_scalar'](True), 'true')
chk('空串加引号', NS['_yaml_scalar'](''), '""')
print('\n=== 老式 --link / volumes-from ===')
ro = CV('docker run -d --link db:db --volumes-from data alpine')
has('links', 'db:db', ro['yaml'])
has('volumes_from', '- data', ro['yaml'])
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
sys.exit(1 if fails else 0)