Files
liuzhuohua d0a72bbb5f fix(release): Release 附件版本号单一真源 + 堵住「注释掉整行代码」型注入残留
发 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。
2026-10-04 14:05:33 +08:00

308 lines
14 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 -*-
"""打包产物静态自检: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)