一套跑在 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
176 lines
7.1 KiB
Python
176 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""打包产物静态自检:deb 元数据 / 维护脚本 / Dockerfile。
|
||
|
||
真机构建由 packaging/build-deb.sh 负责(需要 dpkg-deb),本脚本负责在任何机器上
|
||
都能跑的「结构正确性」检查,把「打出来的包不能用」挡在构建之前。
|
||
"""
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.normpath(os.path.join(HERE, '..'))
|
||
|
||
fails = []
|
||
|
||
|
||
def ck(name, cond, extra=''):
|
||
print(' %-46s %s %s' % (name, 'OK ' if cond else 'FAIL', extra))
|
||
if not cond:
|
||
fails.append(name)
|
||
|
||
|
||
def read(rel):
|
||
with open(os.path.join(ROOT, rel), encoding='utf-8') as f:
|
||
return f.read()
|
||
|
||
|
||
def no_crlf(rel):
|
||
with open(os.path.join(ROOT, rel), 'rb') as f:
|
||
return b'\r' not in f.read()
|
||
|
||
|
||
def bash_ok(rel):
|
||
"""能用 bash 就做真语法检查;Windows 上没有 bash 就跳过(不算失败)。"""
|
||
exe = subprocess.run(['bash', '-n', os.path.join(ROOT, rel)],
|
||
capture_output=True)
|
||
if exe.returncode == 127 or (b'not found' in exe.stderr.lower()):
|
||
return None
|
||
return exe.returncode == 0
|
||
|
||
|
||
print('一、deb 元数据(packaging/deb/control)')
|
||
ctl = read('packaging/deb/control')
|
||
ck('含 Package', 'Package: drouter' in ctl)
|
||
ck('Version 是占位符(构建时替换)', '__VERSION__' in ctl)
|
||
ck('Architecture: all', 'Architecture: all' in ctl)
|
||
ck('Depends 非空', 'Depends:' in ctl and len(ctl.split('Depends:')[1].split('\n\n')) > 0)
|
||
for pkg in ('python3', 'nftables', 'dnsmasq', 'radvd', 'ppp', 'systemd', 'sudo'):
|
||
ck('Depends 含 %s' % pkg, pkg in ctl.split('Recommends:')[0])
|
||
# smartmontools 装完会自动起 smartd,不该出现在 apt 默认安装的 Recommends 里
|
||
rec = ctl.split('Recommends:')[1].split('\n')[0] if 'Recommends:' in ctl else ''
|
||
ck('Recommends 不含 smartmontools(会自启守护)', 'smartmontools' not in rec, rec.strip()[:50])
|
||
ck('smartmontools 放进了 Suggests',
|
||
'smartmontools' in (ctl.split('Suggests:')[1] if 'Suggests:' in ctl else ''))
|
||
ck('control 无 CRLF', no_crlf('packaging/deb/control'))
|
||
# DEBIAN/control 是 deb822 格式,官方并不保证支持 "#" 注释 —— 别往里写注释
|
||
ck('control 不含 # 注释行(deb822 不保证支持)',
|
||
not [l for l in ctl.split('\n') if l.lstrip().startswith('#')])
|
||
# 每一行必须是「空行」或「字段名: 值」或「以空格/制表符开头的续行」
|
||
bad_cont = []
|
||
for l in ctl.split('\n'):
|
||
if not l.strip():
|
||
continue
|
||
if l[0] in (' ', '\t'):
|
||
continue
|
||
if ':' not in l:
|
||
bad_cont.append(l)
|
||
ck('每行都是「字段: 值」或续行', not bad_cont, str(bad_cont[:2]))
|
||
# 依赖策略的理由写在 build-deb.sh 里(control 不能写注释)
|
||
ck('依赖策略说明在 build-deb.sh 里',
|
||
'smartmontools' in read('packaging/build-deb.sh'))
|
||
|
||
print('\n二、维护脚本(postinst / prerm / postrm)')
|
||
for m in ('postinst', 'prerm', 'postrm'):
|
||
rel = 'packaging/deb/' + m
|
||
src = read(rel)
|
||
ck('%s 存在' % m, bool(src))
|
||
ck('%s 无 CRLF' % m, no_crlf(rel))
|
||
ck('%s 有 shebang' % m, src.startswith('#!/bin/bash'))
|
||
ck('%s 处理了所有 dpkg 动作' % m, 'case "$1" in' in src and '*)' in src)
|
||
ck('%s 以 exit 0 收尾' % m, 'exit 0' in src)
|
||
r = bash_ok(rel)
|
||
if r is not None:
|
||
ck('%s bash 语法' % m, r)
|
||
|
||
# postinst 的安全红线:绝不能 enable/start DHCP、DNS、UPnP
|
||
pi = read('packaging/deb/postinst')
|
||
for svc in ('dnsmasq', 'radvd', 'miniupnpd'):
|
||
ck('postinst 不启动 %s' % svc,
|
||
('systemctl enable %s' % svc) not in pi and ('systemctl start %s' % svc) not in pi)
|
||
ck('postinst 用构建保护模式', 'BUILD_MODE' in pi)
|
||
ck('postinst 会装 drouter-ctl', 'drouter-ctl' in pi)
|
||
ck('postinst 会跑 deploy.sh', 'deploy.sh' in pi)
|
||
ck('postinst 用 DROUTER_SRC 指向 /opt/drouter', 'DROUTER_SRC=/opt/drouter' in pi)
|
||
|
||
prm = read('packaging/deb/postrm')
|
||
ck('postrm purge 清 /etc/drouter', 'rm -rf /etc/drouter' in prm)
|
||
ck('postrm purge 清 /var/lib/drouter(累计流量)', '/var/lib/drouter' in prm)
|
||
ck('postrm remove 保留配置', 'remove)' in prm)
|
||
|
||
print('\n三、Dockerfile')
|
||
df = read('packaging/docker/Dockerfile')
|
||
ck('无 CRLF', no_crlf('packaging/docker/Dockerfile'))
|
||
INSTR = ('FROM', 'LABEL', 'ENV', 'RUN', 'COPY', 'EXPOSE', 'VOLUME',
|
||
'HEALTHCHECK', 'ENTRYPOINT', 'CMD', 'WORKDIR', 'ARG', 'USER', 'SHELL')
|
||
lines = df.split('\n')
|
||
bad = []
|
||
for i, l in enumerate(lines):
|
||
s = l.strip()
|
||
if not s or s.startswith('#'):
|
||
continue
|
||
if s.split()[0].startswith(INSTR):
|
||
continue
|
||
# 上一行以 \ 结尾则是续行,合法
|
||
if i > 0 and lines[i - 1].rstrip().endswith('\\'):
|
||
continue
|
||
bad.append((i + 1, s))
|
||
ck('所有指令合法', not bad, str(bad[:2]))
|
||
ck('有 FROM', df.startswith('FROM ') or '\nFROM ' in df)
|
||
ck('有 ENTRYPOINT', 'ENTRYPOINT' in df)
|
||
ck('有 CMD', 'CMD' in df)
|
||
ck('有 HEALTHCHECK', 'HEALTHCHECK' in df)
|
||
|
||
# COPY 的源必须真实存在,否则 docker build 才失败就太晚了
|
||
missing = []
|
||
for l in lines:
|
||
if l.strip().startswith('COPY'):
|
||
parts = l.split()
|
||
if len(parts) >= 2:
|
||
src = parts[1]
|
||
if '*' not in src and not os.path.exists(os.path.join(ROOT, src.rstrip('/'))):
|
||
missing.append(src)
|
||
ck('COPY 源路径都存在', not missing, str(missing))
|
||
|
||
# HEALTHCHECK / 端口不应写死,否则用户改端口后永远 unhealthy
|
||
hc = [l for l in lines if 'HEALTHCHECK' in l or 'curl -sk' in l]
|
||
ck('健康检查读取实际端口(不写死 8443)',
|
||
any('web-port' in x for x in hc), ''.join(hc)[:80])
|
||
|
||
ini = read('packaging/docker/docker-init.sh')
|
||
ck('docker-init 无 CRLF', no_crlf('packaging/docker/docker-init.sh'))
|
||
ck('docker-init 以 exec 结尾(信号可传递)', 'exec "$@"' in ini)
|
||
ck('docker-init 播种端口后 unset(避免压住界面改端口)',
|
||
'unset DROUTER_WEB_PORT' in ini)
|
||
r = bash_ok('packaging/docker/docker-init.sh')
|
||
if r is not None:
|
||
ck('docker-init bash 语法', r)
|
||
|
||
print('\n四、构建脚本')
|
||
for rel in ('packaging/build-deb.sh', 'packaging/build-docker.sh',
|
||
'scripts/drouter-ctl.sh', 'scripts/install-base.sh'):
|
||
ck('%s 存在' % rel, os.path.exists(os.path.join(ROOT, rel)))
|
||
ck('%s 无 CRLF' % rel, no_crlf(rel))
|
||
r = bash_ok(rel)
|
||
if r is not None:
|
||
ck('%s bash 语法' % rel, r)
|
||
|
||
bd = read('packaging/build-deb.sh')
|
||
ck('build-deb 先校验后端再打包', 'ast.parse' in bd)
|
||
ck('build-deb 生成 md5sums', 'md5sums' in bd)
|
||
# 逐个点名,别再用「数 .py 出现次数」这种会被注释里的文件名骗过的写法。
|
||
# drouter-shelld.py 是 Web 终端的 PTY 守护,漏了它终端就打不开。
|
||
BACKEND_MODULES = ['render.py', 'drouter-helper.py', 'drouter-web.py',
|
||
'drouter-logd.py', 'drouter-snapshotd.py',
|
||
'drouter-rescue.py', 'drouter-shelld.py',
|
||
'drouter-helpd.py', 'theme.py']
|
||
miss_mod = [m for m in BACKEND_MODULES if m not in bd]
|
||
ck('build-deb 逐个校验 %d 个后端模块' % len(BACKEND_MODULES), not miss_mod,
|
||
'→ 缺 %s' % miss_mod if miss_mod else '')
|
||
|
||
print('\n结果:失败 %d 项' % len(fails))
|
||
for f in fails:
|
||
print(' ✘ ' + f)
|
||
sys.exit(1 if fails else 0)
|