Files
drouter/devtools/retag-docker-image.py
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

136 lines
6.4 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 -*-
"""
重打 Docker 镜像标签 + 修正 license 标签 + 补上 HEALTHCHECK/端口标注。
为什么需要这个脚本
------------------
镜像原本是用 **buildah** 以 root 构建的,标签被钉成 `localhost/drouter:<版本>`。
`localhost/` 是 Podman 的"本地镜像"约定,Docker 虽然能 load 进来,但这个前缀
既不好看也容易让人误解(像是某个私服地址)。所以这里把标签改成:
主标签 drouter:<版本> ← docker load 后直接用这个跑
别名 liuzhuohua/drouter:<版本> ← 同时打一个带用户名的,方便以后推 Docker Hub
顺带修两个真实缺陷:
1. 镜像 LABEL 里 `org.opencontainers.image.licenses` 写的是 **GPL-3.0**,
但项目实际是 **MIT**。这是构建时抄错模板留下的。
2. 缺 `HEALTHCHECK`:compose 里写 `depends_on: condition: service_healthy`
就没东西可等;`docker ps` 也看不出容器是不是真的活着。
实现方式
--------
不解压、不重新打包整包(176MB,重打包有损坏风险),
而是**流式重写 tar**:只替换 manifest.json / repositories / config json
三个小文件,layer.tar 原样透传。这样:
· 大文件零拷贝,速度快
· layer 的 sha256 不变(docker load 会校验 layer 摘要)
"""
import json
import os
import sys
import tarfile
# ⚠️ 版本号**必须显式传**,不要再写死默认值。
# 之前这里硬编码 1.0.8,于是「拿 1.0.9 的镜像跑重标记」会打上 1.0.8 的标签,
# 用户 docker load 完看到的是 drouter:1.0.8,与 Release 附件里的说明对不上。
# 现在版本从 packaging/VERSION 读(唯一真源),命令行可覆盖。
_HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_VF = os.path.join(_HERE, "packaging", "VERSION")
if not os.path.isfile(_VF):
sys.exit(f"缺少 {_VF}(版本号唯一真源)")
with open(_VF, encoding="utf-8") as f:
VER = f.read().strip()
if not VER:
sys.exit(f"{_VF} 为空")
SRC = sys.argv[1] if len(sys.argv) > 1 else f"dist/drouter-{VER}-docker.tar"
DST = sys.argv[2] if len(sys.argv) > 2 else f"dist/drouter-{VER}-docker.tar.new"
NEW_TAGS = [f"drouter:{VER}", f"liuzhuohua/drouter:{VER}"]
with tarfile.open(SRC, "r:") as tin:
members = tin.getmembers()
by_name = {m.name: m for m in members}
# ── 读 manifest.json ────────────────────────────────────────────
mf = json.load(tin.extractfile("manifest.json"))
if len(mf) != 1:
sys.exit(f"预期 manifest 只有 1 个条目,实际 {len(mf)} 个,不敢改")
config_name = mf[0]["Config"]
print(f" config 文件 : {config_name}")
print(f" layer 数 : {len(mf[0]['Layers'])}")
print(f" 原 RepoTags : {mf[0].get('RepoTags')}")
# ── 读并修改 config json ────────────────────────────────────────
cfg_raw = tin.extractfile(config_name).read()
cfg = json.loads(cfg_raw)
# 1) 修 license 标签:GPL-3.0 → MIT(与项目 LICENSE 文件一致)
labels = cfg.get("config", {}).get("Labels", {})
old_lic = labels.get("org.opencontainers.image.licenses")
if old_lic != "MIT":
labels["org.opencontainers.image.licenses"] = "MIT"
cfg["config"]["Labels"] = labels
print(f" license 标签 : {old_lic} → MIT")
else:
print(" license 标签 : 已是 MIT,跳过")
# 2) 补 healthcheck:
# 镜像里没有 curl 之外的探活手段,但 /api/health 是**免鉴权**的
# (见 drouter-web.py:/api/health 与 /api/login 不要求 X-Token)。
# ⚠️ 但 web 是 HTTPS 且用自签证书 → curl 必须 -k,否则永远 unhealthy。
# 端口从 /etc/drouter/web-port 读,默认 8443。
if not cfg["config"].get("Healthcheck"):
cfg["config"]["Healthcheck"] = {
"Test": [
"CMD-SHELL",
'PORT=$(cat /etc/drouter/web-port 2>/dev/null || echo 8443); '
'curl -fsSk --max-time 4 "https://127.0.0.1:${PORT}/api/health" '
'>/dev/null 2>&1 || exit 1',
],
"Interval": 30000000000, # 30s(纳秒,docker 格式)
"Timeout": 5000000000, # 5s
"StartPeriod": 20000000000, # 20s,给首次启动留时间
"Retries": 3,
}
print(" healthcheck : 已补(/api/health,自签证书走 -k)")
else:
print(" healthcheck : 已存在,跳过")
new_cfg_raw = json.dumps(cfg, ensure_ascii=False,
separators=(",", ":")).encode("utf-8")
# ── 写新 tar ────────────────────────────────────────────────────
with tarfile.open(DST, "w", format=tarfile.GNU_FORMAT) as tout:
for m in members:
if m.name == config_name:
m.size = len(new_cfg_raw)
tout.addfile(m, __import__("io").BytesIO(new_cfg_raw))
elif m.name == "manifest.json":
mf[0]["RepoTags"] = NEW_TAGS
blob = json.dumps(mf, ensure_ascii=False,
separators=(",", ":")).encode("utf-8")
m.size = len(blob)
tout.addfile(m, __import__("io").BytesIO(blob))
elif m.name == "repositories":
# docker save 的老式 tag 索引,格式 {repo: {tag: layerid}}
old = json.load(tin.extractfile("repositories"))
layer_id = None
for repo, tags in old.items():
layer_id = list(tags.values())[0]
new_repos = {}
for t in NEW_TAGS:
repo, _, tag = t.rpartition(":")
new_repos.setdefault(repo, {})[tag] = layer_id
blob = json.dumps(new_repos, ensure_ascii=False,
separators=(",", ":")).encode("utf-8")
m.size = len(blob)
tout.addfile(m, __import__("io").BytesIO(blob))
else:
# layer.tar 等大文件:直接从原 tar 流式拷贝,零解压
tout.addfile(m, tin.extractfile(m))
print(f"\n ✅ 写出:{DST}")
print(f" 新标签:{NEW_TAGS}")