发 v1.0.9 前发现两个真问题,都会直接影响用户拿到的附件。 一、docker-compose.yml 里的镜像标签是硬编码的 1.0.8 这两个文件是**直接挂到 GitHub Release 上的附件**,用户照着抄命令。 早先它们硬编码具体版本号,每次发版要人工 sed 一遍 —— 这次就漏了: 附件里写着 `image: drouter:1.0.8`,而镜像 tar 加载后的标签是 `drouter:1.0.9`, `docker compose up` 直接报「找不到镜像」。DOCKER-GUIDE.md 另有 14 处同样问题。 改成 __VERSION__ 模板,由 build-docker.sh 渲染(版本从 packaging/VERSION 读)。 渲染分两步,顺序不能反:先删 TEMPLATE-NOTE 标记块(那是给维护者看的 「这是模板」提示,留着用户会看到「1.0.9 是占位符」这种自相矛盾的话), 再替换占位符。先替换的话,标记块里的 __VERSION__ 也会被换掉。 retag-docker-image.py 同样问题:NEW_TAGS 硬编码。改完参数化。 这个 bug 更隐蔽 —— 拿 1.0.9 的镜像跑重标记会打上 1.0.8 的标签, 用户 load 完看到的标签与附件说明对不上。 t-packaging.py 补 12 条判据卡住这些(4 条已做反向验证:注入即红、还原即绿)。 判据只抓「紧挨 drouter 标识或产物文件名」的版本号 —— 一刀切拦所有 x.y 会 误伤「1.0.1 起 Web 终端绕开 init」这种历史沿革说明和「GPL-3.0」许可证名。 二、t-worktree-clean.py 抓不到「注释掉整行代码」型注入 跑回归时 t-107 红了 1 项,查出来是 t-107-inject-wg.py 把 `'env': _vpn_env(),` 注释掉了没还原(SIGTERM 打断,finally 没执行)—— 和 v1.0.8 那次 `/nonexistent` 残留一模一样的坑,但**形态不同**: 不含任何哨兵字样,原判据的 4 个哨兵全都抓不到。 后果很实:_vpn_status 不返回 env,前端拿不到四态诊断, VPN 页面的三个修复按钮全废。这正是 v1.0.8 声称修好的那个 bug。 补两条判据: - 扫「被注释掉的整行代码」:去掉 # 后能 ast.parse 成功,且含调用/赋值/return/dict 项 - 扫 CRLF:Windows 工具写文件会整篇改行尾,sync.sh 预检才拦太晚 判据本身踩了三个坑,都记在注释里: - dict 项 `'env': _vpn_env(),` 单独 ast.parse 会 SyntaxError(没大括号), 真正的注入反而被放过 → 补大括号再解析 - dict 项的键**通常带引号**,正则只写 ^[A-Za-z_] 认不出来 → 改成同时认引号键 - 分隔线注释 `# ------ radvd` 里的 ----- 会被解析成负号表达式 → 按前缀排掉 双向验证:4 种注入形态(赋值/dict项/bool/列表)全部精确报到行号, 基线零误报(排掉了分隔线注释和「# module: (service, action, 中文名)」这类 长得像 dict 项的文档注释)。 三、_dev/dl-verify.py(新增) 从目标机往 Windows 拉大产物时发现的坑,记成脚本省得下次重踩: - `ssh host 'cat f' > out` 在 Windows 上做**文本模式换行转换**,二进制被破坏 (实测 deb 少 4 字节,sha256 直接对不上)→ 走 base64 再本地解码 - Windows Python 看不到 Git Bash 的 /tmp,必须给 C:/ 绝对路径 - scp 传完一定要判 sha256 —— scp/ssh 都可能「返回 0 却落地 0 字节」 - 落地走 .part + os.replace,避免写一半失败留下半个产物 顺带修了 build-docker.sh 里一处路径笔误 (packaging/docker/packaging/docker/DOCKER-GUIDE.md)和步骤编号 1/3→1/4。
308 lines
14 KiB
Python
308 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""打包产物静态自检:deb 元数据 / 维护脚本 / Dockerfile。
|
||
|
||
真机构建由 packaging/build-deb.sh 负责(需要 dpkg-deb),本脚本负责在任何机器上
|
||
都能跑的「结构正确性」检查,把「打出来的包不能用」挡在构建之前。
|
||
"""
|
||
import io
|
||
import os
|
||
import re
|
||
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=''):
|
||
# extra 只在失败时打。通过的时候打出来是纯噪声("OK → 只有 12 个" 这种
|
||
# 自相矛盾的话最糟,会让人以为真有问题),久了就没人看这输出了。
|
||
print(' %-46s %s %s' % (name, 'OK ' if cond else 'FAIL',
|
||
'' if cond else 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 守护,漏了它终端就打不开。
|
||
#
|
||
# ⚠️ 清单必须**从 build-deb.sh 的 for 循环里抽**,不能在测试里再抄一份。
|
||
# 早先这里硬编码了 9 个,而 build-deb.sh 实际校验 12 个 —— 两份清单各自
|
||
# 独立维护,结果是 backupd / alertd / quotad 三个新守护「测试说齐了、
|
||
# 打包脚本说齐了」,但谁都没真去对过对方。测试抄实现的清单,等于没测。
|
||
# ⚠️ 必须 findall 再挑「.py 最多」的那个,不能用 search 取第一个 ——
|
||
# build-deb.sh 里有好几个 for 循环(复制文件、算权限…),第一个匹配到的
|
||
# 根本不是后端校验那个。
|
||
_cands = [re.findall(r'[\w.-]+\.py', g)
|
||
for g in re.findall(r'for\s+\w+\s+in\s+(.*?);\s*do', bd, re.S)]
|
||
if not _cands:
|
||
ck('build-deb.sh 能抽出后端模块 for 循环', False,
|
||
'→ 找不到 `for f in ... ; do` 形式的校验循环')
|
||
BACKEND_MODULES = []
|
||
else:
|
||
BACKEND_MODULES = max(_cands, key=len)
|
||
ck('build-deb.sh 抽出 %d 个后端模块' % len(BACKEND_MODULES),
|
||
len(BACKEND_MODULES) >= 12,
|
||
'→ 只有 %d 个,backupd/alertd/quotad 这类新守护容易漏' %
|
||
len(BACKEND_MODULES))
|
||
|
||
miss_mod = [x for x in BACKEND_MODULES if x not in bd]
|
||
ck('build-deb 逐个校验 %d 个后端模块' % len(BACKEND_MODULES), not miss_mod,
|
||
'→ 缺 %s' % miss_mod if miss_mod else '')
|
||
|
||
# 清单里的每个文件都必须真的存在于 backend/ 下。
|
||
# 上面那条只验「文件名出现在脚本里」,写错一个字(比如 drouter-backupd.py
|
||
# 拼成 backup.py)照样能过。这条才是「打包不会漏文件」的真正保证。
|
||
miss_real = [x for x in BACKEND_MODULES
|
||
if not os.path.exists(os.path.join(ROOT, 'backend', x))]
|
||
ck('build-deb 清单里的每个模块在 backend/ 下真实存在', not miss_real,
|
||
'→ 不存在 %s' % miss_real if miss_real else '')
|
||
|
||
# 反向:新加一个后端 .py 就必须进清单。漏了的话 dpkg 包里没有它,
|
||
# 线上表现是「某个页面 404 / 某个守护根本不存在」,极难定位。
|
||
have = {f for f in os.listdir(os.path.join(ROOT, 'backend'))
|
||
if f.endswith('.py') and not f.startswith('__')}
|
||
notlisted = sorted(have - set(BACKEND_MODULES))
|
||
ck('backend/ 下没有游离于清单之外的模块', not notlisted,
|
||
'→ 未进清单:%s' % notlisted if not notlisted else '')
|
||
|
||
# ⚠️ 光查 build-deb.sh **不够** —— 打包对、真机不对,一样是功能开不起来。
|
||
# 1.0.8 加 drouter-ddnsd.py 时就踩到过:build-deb 用 `install backend/*.py`
|
||
# 通配复制,ddnsd 自动进了包,t-packaging 全绿;可 scripts/deploy.sh
|
||
# 是**逐个 install 硬名单**,ddnsd 漏在里面 —— 于是「本地全绿、真机没有文件」,
|
||
# 表现为 timer 起来报 203/EXEC,极难定位。
|
||
# 部署才是真正生效的那一步,所以**两份清单都要查**。
|
||
DP = os.path.join(ROOT, 'scripts', 'deploy.sh')
|
||
dp_src = io.open(DP, encoding='utf-8').read()
|
||
# deploy.sh 里那两段:编译校验的 for 清单 + 逐个 install 段落
|
||
dp_for = ''
|
||
m = re.search(r'for f in render\.py.*?; do', dp_src, re.S)
|
||
if m:
|
||
dp_for = m.group(0)
|
||
dp_inst = '\n'.join(l for l in dp_src.splitlines()
|
||
if re.search(r'install -m 0644 \$SRC/backend/', l))
|
||
dp_all = dp_for + '\n' + dp_inst
|
||
dp_miss = sorted(x for x in have
|
||
if not re.search(r'(?<![A-Za-z0-9_.-])%s(?![A-Za-z0-9_.-])'
|
||
% re.escape(x), dp_all))
|
||
ck('deploy.sh 里每个后端模块都出现(for 校验或逐个 install)',
|
||
not dp_miss, '→ deploy.sh 漏了:%s' % dp_miss if dp_miss else '')
|
||
|
||
# 反向:deploy.sh 的 for 清单也必须与 backend/ 对得上(抄一份就会漂移)
|
||
dp_named = set(re.findall(r'(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]+\.py)(?![A-Za-z0-9_.-])',
|
||
dp_for))
|
||
ck('deploy.sh 的 for 清单不是脱离 backend/ 的另一份',
|
||
set(BACKEND_MODULES) - dp_named == set(),
|
||
'→ build-deb 有而 deploy.sh 无:%s'
|
||
% sorted(set(BACKEND_MODULES) - dp_named))
|
||
|
||
print('\n五、Release 附件的版本号一致性')
|
||
|
||
# 背景:docker-compose.yml 和 DOCKER-GUIDE.md 是**直接挂到 GitHub Release 上**的
|
||
# 附件,用户照着抄命令。早先这两个文件里硬编码了具体版本号,于是每次发版都要
|
||
# 人工 sed 一遍 —— v1.0.9 发版前就发现附件里还写着 drouter:1.0.8,而镜像 tar
|
||
# 加载后的标签是 drouter:1.0.9,`docker compose up` 会直接报「找不到镜像」。
|
||
#
|
||
# 现在它们是 __VERSION__ 模板,由 build-docker.sh 渲染。判据要卡住两件事:
|
||
# 1) 模板里不许再出现任何具体版本号(否则占位符替换会漏掉那一处)
|
||
# 2) 模板里必须有占位符,且构建脚本真的会渲染它
|
||
# 只抓「看起来像 drouter 自身版本」的写法,不能一刀切拦所有 x.y。
|
||
# 反例(这些是**故意**固定的,拦了就是误报):
|
||
# 「1.0.1 起 Web 终端绕开 init」 —— 历史沿革说明,与当前版本无关
|
||
# 「GPL-3.0 → MIT」 —— 许可证名,不是版本
|
||
# 所以判据要求版本号**紧挨着 drouter 标识或产物文件名**才算硬编码。
|
||
VER_RE = re.compile(r'(?<![\w.])(?:drouter[:\-_]|drouter-)?'
|
||
r'(\d+\.\d+(?:\.\d+)?)(?![\w.])')
|
||
# 出现在「起」「新增」「改成」等叙述语境里的是历史沿革,放行
|
||
HISTORY_HINT = re.compile(r'(起|新增|开始|之前|以前|历史|沿革)')
|
||
|
||
|
||
def hardcoded_versions(body):
|
||
"""挑出真正碍事的版本号:形如 drouter:1.0.8 / drouter-1.0.8.tar 的。"""
|
||
bad = set()
|
||
for line in body.splitlines():
|
||
if HISTORY_HINT.search(line):
|
||
continue
|
||
for m in re.finditer(r'drouter[:\-_]v?(\d+\.\d+(?:\.\d+)?)', line):
|
||
bad.add(m.group(1))
|
||
return sorted(bad)
|
||
|
||
|
||
ATTACH_TPL = ['packaging/docker/docker-compose.yml',
|
||
'packaging/docker/DOCKER-GUIDE.md']
|
||
|
||
cur_ver = read('packaging/VERSION').strip()
|
||
ck('packaging/VERSION 形如 x.y.z', bool(re.match(r'^\d+\.\d+\.\d+$', cur_ver)),
|
||
'→ 实际 %r' % cur_ver)
|
||
|
||
for rel in ATTACH_TPL:
|
||
body = read(rel)
|
||
# 模板里不许再出现任何具体版本号(否则占位符替换会漏掉那一处)
|
||
hard = hardcoded_versions(body)
|
||
ck('%s 里没有硬编码版本号' % os.path.basename(rel), not hard,
|
||
'→ 出现 %s,应改成 __VERSION__' % hard if hard else '')
|
||
ck('%s 用了 __VERSION__ 占位符' % os.path.basename(rel),
|
||
'__VERSION__' in body)
|
||
ck('%s 无 CRLF' % os.path.basename(rel), no_crlf(rel))
|
||
|
||
bd = read('packaging/build-docker.sh')
|
||
ck('build-docker.sh 会渲染这两个附件', 'docker-compose.yml' in bd
|
||
and 'DOCKER-GUIDE.md' in bd and '__VERSION__' in bd)
|
||
ck('build-docker.sh 渲染后校验占位符已清空',
|
||
"grep -q '__VERSION__'" in bd)
|
||
|
||
# retag 脚本同理:它打的标签就是用户 docker load 后看到的名字。
|
||
rt = read('devtools/retag-docker-image.py')
|
||
ck('retag 脚本从 packaging/VERSION 读版本',
|
||
'packaging' in rt and 'VERSION' in rt)
|
||
rt_code = '\n'.join(l for l in rt.splitlines() if not l.strip().startswith('#'))
|
||
ck('retag 脚本代码里没有硬编码版本号', not hardcoded_versions(rt_code),
|
||
'→ 出现 %s' % hardcoded_versions(rt_code))
|
||
|
||
print('\n结果:失败 %d 项' % len(fails))
|
||
for f in fails:
|
||
print(' ✘ ' + f)
|
||
sys.exit(1 if fails else 0)
|