cea9b941cf
Build and Push / build (push) Failing after 13m20s
Go 项目,包含: - 服务端 updater:两阶段协议,ECDSA 签名验证,AES-GCM 加密 - 发送端 dcu-send:Gitea Action CLI - internal/auth:加解密/签名/会话管理 - internal/docker:Docker CLI 容器查找/拉取/重建 - action/:Gitea Action 定义 - deploy/Dockerfile:多阶段构建 - .gitea/workflows/build.yaml:CI/CD
20 KiB
20 KiB
docker-compose-updater 实施方案 (v2)
1. 概述
Go 编写的 Webhook 工具,部署在使用 docker-compose 的服务器上。Gitea Actions 通过 Action 调用,签名验证后执行指定容器的拉取/重启操作。
核心特性:
- 非对称签名(ECDSA P-256)验证请求来源
- 对称加密(AES-256-GCM)加密请求体,防中间人窃听容器名
- 时间戳 + Nonce 防重放攻击
- 三种操作模式:拉取+重启、仅拉取、仅重启
- 一次只操作一个容器
- 一个 updater 管理多个 docker-compose 项目
2. 通讯协议设计
2.1 ECDH 密钥协商(类似 TLS 握手)
每次请求自动协商 AES 会话密钥,无需预共享对称密钥。原理类似 TLS:
发送端 (dcu-send) 接收端 (updater)
───────────────── ────────────────
内置: ECDH公钥(server_public) 内置: ECDH私钥(server_private)
ECDSA私钥(sender_private) ECDSA公钥(sender_public)
1. 生成临时 ECDH 密钥对
(ephemeral_private, ephemeral_public)
2. shared_secret = ECDH(ephemeral_private,
server_public)
3. aes_key = HKDF(shared_secret, nonce)
4. AES-256-GCM 加密 payload → ciphertext
5. 签名内容 = "v.ts.nonce.ek.data"
6. ECDSA 签名 → sig
7. 收到 {v, ts, nonce, ek, data, sig}
8. 校验 ts 在 ±30s 内
9. 校验 nonce 未使用
10. 验证 ECDSA 签名
11. shared_secret = ECDH(server_private,
ephemeral_public)
12. aes_key = HKDF(shared_secret, nonce)
13. AES-256-GCM 解密 → 明文
14. 执行操作
每次请求的临时 ECDH 密钥对 + nonce 共同推导出唯一的 AES 会话密钥,即使服务器私钥泄露也无法解密历史请求(前向安全)。
2.2 请求格式
{
"v": 1,
"ts": 1717000000,
"nonce": "a1b2c3d4e5f6g7h8",
"ek": "base64-ephemeral-ecdh-public-key",
"data": "base64-aes-gcm-ciphertext",
"sig": "base64-ecdsa-signature"
}
| 字段 | 说明 |
|---|---|
v |
协议版本,当前为 1 |
ts |
Unix 时间戳(秒),服务端检查 ±30s 内 |
nonce |
16 字节随机数 Base64,服务端缓存 60s 防重放 |
ek |
临时 ECDH 公钥(P-256 未压缩格式 65 字节 → Base64),每次请求生成新密钥对 |
data |
AES-256-GCM 加密后的 payload(key = HKDF(ECDH_shared_secret, nonce)) |
sig |
ECDSA P-256 签名,签名内容 = v.ts.nonce.ek.data 点号拼接 |
2.3 明文 Payload
{
"project": "my-app",
"service": "api",
"action": "update"
}
| 字段 | 必填 | 说明 |
|---|---|---|
project |
是 | docker-compose 项目名(对应 projects_dir 下的子目录名) |
service |
是 | compose 中定义的 service 名(如 api、frontend),每次只操作一个容器 |
action |
是 | update(拉取+重启)/ pull(仅拉取)/ restart(仅重启) |
2.4 密钥体系
| 密钥对 | 算法 | 用途 | 发送端(Gitea Action) | 接收端(updater) |
|---|---|---|---|---|
| 签名密钥对 | ECDSA P-256 | 请求认证(证明 sender 身份) | 私钥(Gitea Secret) | 公钥(内嵌二进制) |
| 协商密钥对 | ECDH P-256 | 密钥协商(建立 AES 会话密钥) | 公钥(内嵌 dcu-send) | 私钥(内嵌二进制) |
签名和协商使用不同的密钥对,避免同一密钥被用于两种密码学原语的安全风险。
2.5 密钥生成
# 1. ECDSA 签名密钥对(认证用)
openssl ecparam -genkey -name prime256v1 -out signing-private.pem
openssl ec -in signing-private.pem -pubout -out signing-public.pem
# 2. ECDH 协商密钥对(加密用)
openssl ecparam -genkey -name prime256v1 -out ecdh-private.pem
openssl ec -in ecdh-private.pem -pubout -out ecdh-public.pem
Gitea Secrets 中配置:
UPDATER_SIGNING_KEY:signing-private.pem 文件内容
编译二进制时嵌入:
- updater 二进制:signing-public.pem(验签)+ ecdh-private.pem(解密)
- dcu-send 二进制:ecdh-public.pem(加密)
3. 系统架构
┌─────────────────────────────────────────────────────────┐
│ Gitea Actions Runner │
│ │
│ Workflow: │
│ steps: │
│ - uses: user/docker-compose-updater/action@v1 │
│ with: │
│ url: https://updater.example.com │
│ project: my-app │
│ service: api │
│ action: update │
│ │ │
│ ▼ │
│ ┌─────────────────┐ private.key + aes.key │
│ │ dcu-send │◄── from Gitea Secrets │
│ │ 1. encrypt │ │
│ │ 2. sign │ │
│ │ 3. HTTP POST │ │
│ └────────┬────────┘ │
└────────────┼────────────────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────┐
│ Docker Container (updater) │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ HTTP Server │ │ Auth Module │ │
│ │ POST /hook │──► 1. timestamp │ │
│ │ GET /health │ │ 2. nonce check │ │
│ └──────────────┘ │ 3. verify sig │ │
│ │ 4. decrypt data │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ Docker Module │ │
│ │ docker compose │ │
│ │ pull api │ │
│ │ docker compose │ │
│ │ up -d api │ │
│ └────────┬─────────┘ │
│ │ │
│ ╔════════╧══════════╗ │
│ ║ /var/run/docker ║ │
│ ║ .sock (host) ║ │
│ ╚════════╤══════════╝ │
└─────────────────────────────┼───────────────────────────┘
│
▼
┌─────────────────┐
│ Docker Daemon │
│ (Host) │
└────────┬────────┘
│
┌────────▼────────┐
│ docker compose │
│ pull api │
│ up -d --no-deps │
└────────┬────────┘
│
┌────────▼────────┐
│ api 容器 (新版) │
└─────────────────┘
4. 技术选型
| 层 | 选型 | 理由 |
|---|---|---|
| 语言 | Go 1.22+ | 单二进制编译,跨平台,标准库丰富 |
| HTTP 框架 | 标准库 net/http + chi 轻路由 |
极小依赖,安全可控 |
| 签名算法 | ECDSA P-256 + SHA256 | 非对称签名,密钥短、性能好、安全性高 |
| 加密算法 | AES-256-GCM | 身份认证加密,防篡改防窃听 |
| Docker 操作 | os/exec 调用 docker compose |
接口稳定,复用 compose 原生能力 |
| 配置 | YAML + 环境变量覆盖 | 容器部署友好 |
| 日志 | slog(Go 1.21+ 标准库) |
结构化日志,零依赖 |
为什么 compose 不直接用 Go SDK? Docker Compose v2 有 Go SDK,但依赖
docker/cli内部包,API 不稳定。docker composeCLI 是稳定接口,通过os/exec调用更可靠。
5. 项目结构
docker-compose-updater/
├── cmd/
│ ├── updater/ # 服务端入口
│ │ └── main.go
│ └── dcu-send/ # Gitea Action 用发送端 CLI
│ └── main.go
├── internal/
│ ├── auth/ # 加解密 + 签名 (两端共用库)
│ │ ├── crypto.go # AES-256-GCM 加解密
│ │ ├── sign.go # ECDSA 签名 + 验签
│ │ └── replay.go # Nonce 追踪器
│ ├── server/
│ │ ├── server.go # HTTP 服务器
│ │ ├── handler.go # /hook 处理器
│ │ └── middleware.go # 日志 / 恢复 / 限体
│ ├── docker/
│ │ └── updater.go # docker compose 操作封装
│ └── config/
│ └── config.go # 配置加载
├── action/ # Gitea Action 定义
│ ├── action.yml # 动作元数据
│ └── Dockerfile # Action 运行时镜像
├── deploy/
│ ├── Dockerfile # 服务端多阶段构建
│ ├── docker-compose.yml # 部署示例
│ └── config.example.yaml
├── Makefile
├── go.mod / go.sum
└── PROPOSAL.md
6. API 设计
| 方法 | 路径 | 说明 |
|---|---|---|
POST |
/hook |
接收加密请求,执行更新操作 |
GET |
/health |
存活检查 |
GET |
/ready |
就绪检查(Docker socket 连通性) |
POST /hook
请求 body 始终为加密格式(第 2.1 节)。所有参数(project、service、action)都在加密 payload 中。
成功后响应 (200):
{
"status": "ok",
"project": "my-app",
"service": "api",
"action": "update",
"message": "镜像已拉取,容器已重启",
"logs": [
"Pulling api...",
"Digest: sha256:abc123...",
"Recreating api ... done",
"Container api started"
],
"ts": 1717000000
}
失败响应 (401/403/500):
{
"status": "error",
"message": "签名验证失败 / 时间戳超出窗口 / Nonce 已使用 / 解密失败 / 项目不存在 / 容器未找到"
}
7. Gitea Action(发送端)
7.1 action.yml
name: 'Docker Compose Updater'
description: '触发远程 docker-compose 服务更新(拉取/重启)'
author: 'your-org'
branding:
icon: 'refresh-cw'
color: 'blue'
inputs:
url:
description: 'Updater webhook 地址,如 https://updater.example.com/hook'
required: true
project:
description: 'docker-compose 项目名'
required: true
service:
description: '要操作的 service 名(如 api、frontend),必填'
required: true
action:
description: '操作类型: update(拉取+重启), pull(仅拉取), restart(仅重启)'
required: false
default: 'update'
runs:
using: 'docker'
image: 'Dockerfile'
env:
# 无需加密密钥——通过 ECDH 每次自动协商 AES 会话密钥
SIGNING_KEY: ${{ inputs.signing_key }}
UPDATER_URL: ${{ inputs.url }}
UPDATER_PROJECT: ${{ inputs.project }}
UPDATER_SERVICE: ${{ inputs.service }}
UPDATER_ACTION: ${{ inputs.action }}
7.2 Action Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
-ldflags="-X main.ecdhPubKey=$(cat deploy/keys/ecdh-public.pem | base64 -w0)" \
-o /dcu-send ./cmd/dcu-send
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /dcu-send /usr/local/bin/dcu-send
ENTRYPOINT ["dcu-send"]
ECDH 公钥通过 -ldflags 在编译时嵌入 dcu-send 二进制,发送端无需任何密钥配置。
7.3 dcu-send 内部流程
内置: ECDH 公钥(服务端协商公钥)
环境变量:
SIGNING_KEY ECDSA 签名私钥 PEM(来自 Gitea Secret)
UPDATER_URL Webhook 地址
UPDATER_PROJECT 项目名
UPDATER_SERVICE service 名
UPDATER_ACTION 操作类型
1. 构建明文 payload {project, service, action}
2. 生成 16 字节随机 nonce
3. 取当前 Unix 时间戳 ts
4. 生成临时 ECDH 密钥对 (ephemeral_private, ephemeral_public)
5. shared_secret = ECDH(ephemeral_private, 内置的 ECDH 公钥)
6. aes_key = HKDF-SHA256(shared_secret, nonce, "dcu-updater/v1")
7. AES-256-GCM 加密 payload → ciphertext
8. 组装 envelope {v:1, ts, nonce, ek: ephemeral_public, data: ciphertext}
9. 签名内容 = "v.ts.nonce.ek.data"
10. ECDSA P-256 签名 → sig (Base64)
11. HTTP POST UPDATER_URL, body = envelope
12. 将响应日志输出到 stdout
13. 非零退出码表示失败
7.4 用户在工作流中的使用方式
name: 构建并部署 API
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 构建并推送镜像
run: |
docker build -t registry.example.com/my-app/api:latest .
docker push registry.example.com/my-app/api:latest
- name: 触发远程更新
uses: your-org/docker-compose-updater/action@v1
with:
url: https://updater.example.com/hook
project: my-app
service: api
action: update
secrets:
signing_key: ${{ secrets.UPDATER_SIGNING_KEY }}
8. 服务端配置
# deploy/config.example.yaml
server:
listen: ":8080"
max_body_size: "1MB"
auth:
# 所有密钥内嵌在二进制中,无需文件配置:
# - ECDSA 公钥(验签)
# - ECDH 私钥(密钥协商解密)
timestamp_window: 30 # 时间戳容忍窗口(秒)
nonce_ttl: 60 # Nonce 缓存时间(秒)
docker:
compose_command: "docker compose"
projects_dir: "/data/projects" # 每个子目录 = 一个 docker-compose 项目
pull_timeout: "5m"
restart_timeout: "30s"
log:
level: "info"
format: "json" # json 或 text
项目目录结构约定
/data/projects/
├── my-app/
│ ├── compose.yaml
│ └── .env
├── blog/
│ ├── compose.yaml
│ └── .env
└── ...
POST /hook 解密后的 project 字段对应子目录名,service 对应 compose 文件中定义的 service 名称。
9. Docker 部署
9.1 服务端 Dockerfile
# deploy/Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
-ldflags="-X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y%m%d)" \
-o /updater ./cmd/updater
FROM alpine:3.19
RUN apk add --no-cache \
docker-cli \
docker-cli-compose \
ca-certificates \
tzdata
COPY --from=builder /updater /usr/local/bin/updater
EXPOSE 8080
ENTRYPOINT ["updater"]
9.2 docker-compose.yml
version: "3.8"
services:
updater:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: docker-compose-updater
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /data/projects:/data/projects:ro
- ./config.yaml:/etc/updater/config.yaml:ro
environment:
- TZ=Asia/Shanghai
9.3 安全注意事项
| 风险 | 缓解措施 |
|---|---|
| Docker socket 暴露 | 容器内无 docker exec 等交互能力;仅安装所需 CLI |
| 重放攻击 | Nonce 60s 去重 + 时间戳 ±30s,内存缓存 |
| 中间人窃听 | ECDH 协商 AES 会话密钥加密 payload + HTTPS 传输(双层保险) |
| 恶意请求 | ECDSA 签名验证,仅持私钥者能通过 |
| 历史请求泄露 | 每次请求使用临时 ECDH 密钥对,具备前向安全性 |
10. 无缝重启策略
快速模式:--no-deps(立即返回)
# 进入项目目录
cd /data/projects/my-app
# 拉取指定服务的最新镜像
docker compose pull api
# 仅重启指定服务,不等待健康检查,立即返回
docker compose up -d --no-deps api
--no-deps:不重启依赖容器(如数据库)- 不传
--wait,容器启动即返回,不等待 healthcheck - 服务短暂断连由 Docker 自身的重启策略处理
多副本滚动更新(进阶)
若 compose 中使用 deploy.replicas: 2,可实现真正零宕机:
# 先扩容一个新副本(使用新镜像)
docker compose up -d --no-deps --scale api=2 --no-recreate api
# 等待新副本就绪后,缩容掉老副本
docker compose up -d --no-deps --scale api=1 api
初始版本实现
--no-deps快速模式,后续可视需求扩展多副本滚动更新。
11. 项目体积估算
| 组件 | 体积 |
|---|---|
| Go 二进制(updater) | ~8MB |
| Go 二进制(dcu-send) | ~6MB |
| alpine + docker-cli | ~35MB |
| 服务端镜像总计 | ~45MB |
| Action 镜像总计 | ~15MB |
12. 实施路线
| 阶段 | 内容 | 产出 |
|---|---|---|
| P0 项目骨架 | Go module、internal/auth 加解密+签名、internal/config、Makefile |
可编译,加解密单元测试通过 |
| P1 dcu-send | cmd/dcu-send CLI,读取 env → 加密 → 签名 → HTTP POST |
可独立使用的命令行工具 |
| P2 updater | cmd/updater、internal/server handler、internal/docker 操作 |
端到端联调通过 |
| P3 Gitea Action | action/action.yml + action/Dockerfile |
可在 Gitea workflow 中引用 |
| P4 部署配置 | deploy/Dockerfile、deploy/docker-compose.yml、文档 |
可部署上线 |
| P5 测试+压测 | 单元测试、并发防重放测试 | CI 绿色 |
13. 待确认
- Action 镜像发布:推到 Docker Hub / Gitea 容器 registry?还是用户
make docker-action自行构建? - 密钥目录结构:
deploy/keys/下放密钥文件,构建时嵌入,这个布局 OK 吗?