Files
ilovintit cea9b941cf
Build and Push / build (push) Failing after 13m20s
Initial commit: docker-compose-updater
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
2026-06-08 15:16:46 +08:00

543 lines
20 KiB
Markdown
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.
# 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 请求格式
```json
{
"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 加密后的 payloadkey = HKDF(ECDH_shared_secret, nonce) |
| `sig` | ECDSA P-256 签名,签名内容 = `v.ts.nonce.ek.data` 点号拼接 |
### 2.3 明文 Payload
```json
{
"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 密钥生成
```bash
# 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 compose` CLI 是稳定接口,通过 `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)
```json
{
"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)
```json
{
"status": "error",
"message": "签名验证失败 / 时间戳超出窗口 / Nonce 已使用 / 解密失败 / 项目不存在 / 容器未找到"
}
```
---
## 7. Gitea Action(发送端)
### 7.1 action.yml
```yaml
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
```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 用户在工作流中的使用方式
```yaml
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. 服务端配置
```yaml
# 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
```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
```yaml
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(立即返回)
```bash
# 进入项目目录
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`,可实现真正零宕机:
```bash
# 先扩容一个新副本(使用新镜像)
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. 待确认
1. **Action 镜像发布**:推到 Docker Hub / Gitea 容器 registry?还是用户 `make docker-action` 自行构建?
2. **密钥目录结构**`deploy/keys/` 下放密钥文件,构建时嵌入,这个布局 OK 吗?