# 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 加密后的 payload(key = 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 吗?