From cea9b941cf7ab53bed1e07a0c767dfc902b508a6 Mon Sep 17 00:00:00 2001 From: ilovintit Date: Mon, 8 Jun 2026 15:16:46 +0800 Subject: [PATCH] Initial commit: docker-compose-updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/build.yaml | 28 ++ .gitignore | 19 ++ Makefile | 34 +++ PROPOSAL.md | 542 ++++++++++++++++++++++++++++++++++++ action/Dockerfile | 11 + action/action.yml | 34 +++ cmd/dcu-send/main.go | 145 ++++++++++ cmd/updater/main.go | 65 +++++ deploy/Dockerfile | 26 ++ go.mod | 7 + go.sum | 4 + internal/auth/crypto.go | 75 +++++ internal/auth/keys.go | 39 +++ internal/auth/session.go | 136 +++++++++ internal/auth/sign.go | 30 ++ internal/config/config.go | 56 ++++ internal/docker/updater.go | 283 +++++++++++++++++++ internal/server/handler.go | 255 +++++++++++++++++ internal/server/server.go | 59 ++++ keys/generate.sh | 22 ++ keys/signing-public.pem | 4 + 21 files changed, 1874 insertions(+) create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 PROPOSAL.md create mode 100644 action/Dockerfile create mode 100644 action/action.yml create mode 100644 cmd/dcu-send/main.go create mode 100644 cmd/updater/main.go create mode 100644 deploy/Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/crypto.go create mode 100644 internal/auth/keys.go create mode 100644 internal/auth/session.go create mode 100644 internal/auth/sign.go create mode 100644 internal/config/config.go create mode 100644 internal/docker/updater.go create mode 100644 internal/server/handler.go create mode 100644 internal/server/server.go create mode 100755 keys/generate.sh create mode 100644 keys/signing-public.pem diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..2e325da --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,28 @@ +name: Build and Push +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build updater binary (test only) + run: | + go mod download + go build -o /dev/null ./cmd/updater + go build -o /dev/null ./cmd/dcu-send + + - name: Log in to registry + run: docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASS }} ${{ secrets.REGISTRY_URL }} + + - name: Build and push updater image + run: | + PUBLIC_KEY_BASE64=$(base64 -w0 < keys/signing-public.pem) + docker build \ + --build-arg PUBLIC_KEY_BASE64=$PUBLIC_KEY_BASE64 \ + -t ${{ secrets.REGISTRY_URL }}/docker-compose-updater:latest \ + -f deploy/Dockerfile . + docker push ${{ secrets.REGISTRY_URL }}/docker-compose-updater:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9aa811 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# 编译产物 +/updater +/dcu-send +/bin/ + +# 私钥(绝对不提交) +keys/*-private.pem + +# IDE +.vscode/ +.idea/ + +# 系统 +.DS_Store +Thumbs.db + +# 临时文件 +*.log +/tmp/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..adc459a --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# docker-compose-updater Makefile + +PUBLIC_KEY_BASE64 := $(shell base64 -w0 < keys/signing-public.pem) + +.PHONY: all build-updater build-send tidy clean + +all: build-updater build-send + +# 构建 updater(服务端) +build-updater: + go build -ldflags="-X main.publicKeyBase64=$(PUBLIC_KEY_BASE64)" \ + -o updater ./cmd/updater + +# 构建 dcu-send(发送端) +build-send: + go build -o dcu-send ./cmd/dcu-send + +# 构建 Docker 镜像 +docker-updater: + docker build \ + --build-arg PUBLIC_KEY_BASE64=$(PUBLIC_KEY_BASE64) \ + -t docker-compose-updater:latest \ + -f deploy/Dockerfile . + +docker-action: + docker build -t dcu-send:latest -f action/Dockerfile . + +# Go mod 管理 +tidy: + go mod tidy + go mod verify + +clean: + rm -f updater dcu-send diff --git a/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..caf5c7e --- /dev/null +++ b/PROPOSAL.md @@ -0,0 +1,542 @@ +# 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 吗? diff --git a/action/Dockerfile b/action/Dockerfile new file mode 100644 index 0000000..34c302d --- /dev/null +++ b/action/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /dcu-send ./cmd/dcu-send + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /dcu-send /usr/local/bin/dcu-send +ENTRYPOINT ["dcu-send"] diff --git a/action/action.yml b/action/action.yml new file mode 100644 index 0000000..3bf339f --- /dev/null +++ b/action/action.yml @@ -0,0 +1,34 @@ +name: Docker Compose Updater +description: 触发远程 docker-compose 服务更新(拉取/重启) +author: docker-compose-updater +branding: + icon: refresh-cw + color: blue + +inputs: + url: + description: Updater 地址,如 https://updater.example.com + required: true + project: + description: docker-compose 项目名 + required: true + service: + description: 要操作的 service 名(api、frontend 等) + required: true + action: + description: '操作类型: update(拉取+重启) / pull(仅拉取) / restart(仅重启)' + required: false + default: update + signing_key: + description: ECDSA 签名私钥 PEM 内容(Gitea Secret) + required: true + +runs: + using: docker + image: Dockerfile + env: + SIGNING_KEY: ${{ inputs.signing_key }} + UPDATER_URL: ${{ inputs.url }} + UPDATER_PROJECT: ${{ inputs.project }} + UPDATER_SERVICE: ${{ inputs.service }} + UPDATER_ACTION: ${{ inputs.action }} diff --git a/cmd/dcu-send/main.go b/cmd/dcu-send/main.go new file mode 100644 index 0000000..b36b7ad --- /dev/null +++ b/cmd/dcu-send/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "gitea.songhuwan.com/actions/docker-compose-updater/internal/auth" +) + +func main() { + signingKeyPEM := os.Getenv("SIGNING_KEY") + url := os.Getenv("UPDATER_URL") + project := os.Getenv("UPDATER_PROJECT") + service := os.Getenv("UPDATER_SERVICE") + action := os.Getenv("UPDATER_ACTION") + + if signingKeyPEM == "" || url == "" || project == "" || service == "" || action == "" { + fmt.Fprintln(os.Stderr, "required env: SIGNING_KEY, UPDATER_URL, UPDATER_PROJECT, UPDATER_SERVICE, UPDATER_ACTION") + os.Exit(1) + } + + if action != "update" && action != "pull" && action != "restart" { + fmt.Fprintf(os.Stderr, "invalid action: %s (must be update/pull/restart)\n", action) + os.Exit(1) + } + + // 解析 ECDSA 私钥 + signingKey, err := auth.ParseECDSAPrivateKey([]byte(signingKeyPEM)) + if err != nil { + fmt.Fprintf(os.Stderr, "parse signing key: %v\n", err) + os.Exit(1) + } + + client := &http.Client{Timeout: 30 * time.Second} + + // === Phase 1: 获取会话密钥 === + nonce := randomBase64(16) + ts := time.Now().Unix() + + signData := fmt.Sprintf("1.%d.%s", ts, nonce) + sig, err := auth.Sign(signingKey, []byte(signData)) + if err != nil { + fmt.Fprintf(os.Stderr, "sign: %v\n", err) + os.Exit(1) + } + + phase1Body, _ := json.Marshal(map[string]any{ + "v": 1, + "ts": ts, + "nonce": nonce, + "sig": sig, + }) + + baseURL := url + resp, err := client.Post(baseURL+"/session", "application/json", bytes.NewReader(phase1Body)) + if err != nil { + fmt.Fprintf(os.Stderr, "phase1 request: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "phase1 failed (%d): %s\n", resp.StatusCode, string(body)) + os.Exit(1) + } + + var phase1Resp struct { + Key string `json:"key"` + KeyID string `json:"key_id"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &phase1Resp); err != nil { + fmt.Fprintf(os.Stderr, "parse phase1 response: %v\n", err) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "phase1 ok, key_id=%s\n", phase1Resp.KeyID) + + // === Phase 2: 发送加密请求 === + payload, _ := json.Marshal(map[string]string{ + "project": project, + "service": service, + "action": action, + }) + + sessionKey, err := base64.StdEncoding.DecodeString(phase1Resp.Key) + if err != nil { + fmt.Fprintf(os.Stderr, "decode session key: %v\n", err) + os.Exit(1) + } + + ciphertext, err := auth.Encrypt(payload, sessionKey) + if err != nil { + fmt.Fprintf(os.Stderr, "encrypt: %v\n", err) + os.Exit(1) + } + + phase2Nonce := randomBase64(16) + phase2Body, _ := json.Marshal(map[string]any{ + "v": 1, + "ts": time.Now().Unix(), + "nonce": phase2Nonce, + "key_id": phase1Resp.KeyID, + "data": base64.StdEncoding.EncodeToString(ciphertext), + }) + + resp, err = client.Post(baseURL+"/hook", "application/json", bytes.NewReader(phase2Body)) + if err != nil { + fmt.Fprintf(os.Stderr, "phase2 request: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + body, _ = io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "phase2 failed (%d): %s\n", resp.StatusCode, string(body)) + os.Exit(1) + } + + // 输出响应(供 Action 日志查看) + var result map[string]any + json.Unmarshal(body, &result) + fmt.Fprintf(os.Stderr, "result: %s\n", string(body)) + + // 打印关键信息到 stdout + if msg, ok := result["message"].(string); ok { + fmt.Println(msg) + } +} + +func randomBase64(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/cmd/updater/main.go b/cmd/updater/main.go new file mode 100644 index 0000000..ba0f815 --- /dev/null +++ b/cmd/updater/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/base64" + "log/slog" + "os" + + "gitea.songhuwan.com/actions/docker-compose-updater/internal/auth" + "gitea.songhuwan.com/actions/docker-compose-updater/internal/config" + "gitea.songhuwan.com/actions/docker-compose-updater/internal/docker" + "gitea.songhuwan.com/actions/docker-compose-updater/internal/server" +) + +// 构建时注入:-ldflags="-X main.publicKeyBase64=$(base64 -w0 < keys/signing-public.pem)" +var publicKeyBase64 string + +func main() { + cfg := config.Load() + + var logLevel slog.Level + switch cfg.LogLevel { + case "debug": + logLevel = slog.LevelDebug + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))) + + if publicKeyBase64 == "" { + slog.Error("public key not set - build with -ldflags") + os.Exit(1) + } + pemBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64) + if err != nil { + slog.Error("decode public key", "error", err) + os.Exit(1) + } + pubKey, err := auth.ParseECDSAPublicKey(pemBytes) + if err != nil { + slog.Error("parse public key", "error", err) + os.Exit(1) + } + slog.Info("public key loaded") + + updater := docker.NewUpdater(cfg.DockerPullTimeout, cfg.DockerRestartTimeout) + + srv := server.New( + cfg.Listen, + pubKey, + updater, + cfg.SessionTTL, + cfg.NonceTTL, + cfg.TimestampWindow, + ) + + slog.Info("starting server", "addr", cfg.Listen) + if err := srv.Start(); err != nil { + slog.Error("server exited", "error", err) + os.Exit(1) + } +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..3d861c9 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,26 @@ +# 构建阶段 +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . + +# 注入公钥 +ARG PUBLIC_KEY_BASE64 +RUN test -n "$PUBLIC_KEY_BASE64" || (echo "PUBLIC_KEY_BASE64 required" && exit 1) + +RUN CGO_ENABLED=0 go build \ + -ldflags="-X main.publicKeyBase64=${PUBLIC_KEY_BASE64}" \ + -o /updater ./cmd/updater + +# 运行阶段 +FROM alpine:3.20 +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"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cd44e7d --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module gitea.songhuwan.com/actions/docker-compose-updater + +go 1.25.0 + +require golang.org/x/crypto v0.52.0 + +require github.com/go-chi/chi/v5 v5.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b8cbdd --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= diff --git a/internal/auth/crypto.go b/internal/auth/crypto.go new file mode 100644 index 0000000..a5d97f8 --- /dev/null +++ b/internal/auth/crypto.go @@ -0,0 +1,75 @@ +// Package auth 提供加解密、签名验签和会话密钥管理。 +// 发送端 (dcu-send) 和接收端 (updater) 共用此包。 +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" +) + +// Encrypt 用 AES-256-GCM 加密明文,返回 nonce+ciphertext。 +// key 必须是 32 字节。 +func Encrypt(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("aes gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("nonce: %w", err) + } + + // GCM 附加认证数据 (AAD) 传空 + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// Decrypt 用 AES-256-GCM 解密,输入为 nonce+ciphertext。 +func Decrypt(data []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("aes gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("aes decrypt: %w", err) + } + return plaintext, nil +} + +// DeriveKey 用 HKDF-SHA256 从 shared_secret 和 context 派生出 AES-256 密钥。 +// salt: 每个请求的 nonce(16 字节) +// info: 协议标识,如 "dcu-updater/v1" +func DeriveKey(sharedSecret []byte, salt []byte, info string) []byte { + hkdf := hkdf.New(sha256.New, sharedSecret, salt, []byte(info)) + key := make([]byte, 32) // AES-256 + if _, err := io.ReadFull(hkdf, key); err != nil { + // HKDF 使用 SHA-256,从不会返回错误 + panic(fmt.Sprintf("hkdf read: %v", err)) + } + return key +} diff --git a/internal/auth/keys.go b/internal/auth/keys.go new file mode 100644 index 0000000..8e23d56 --- /dev/null +++ b/internal/auth/keys.go @@ -0,0 +1,39 @@ +package auth + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "fmt" +) + +// ParseECDSAPrivateKey 解析 PEM 编码的 ECDSA 私钥。 +func ParseECDSAPrivateKey(pemData []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(pemData) + if block == nil || block.Type != "EC PRIVATE KEY" { + return nil, fmt.Errorf("invalid EC private key PEM") + } + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse EC private key: %w", err) + } + return key, nil +} + +// ParseECDSAPublicKey 解析 PEM 编码的 ECDSA 公钥。 +func ParseECDSAPublicKey(pemData []byte) (*ecdsa.PublicKey, error) { + block, _ := pem.Decode(pemData) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("invalid public key PEM") + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse public key: %w", err) + } + + pubKey, ok := key.(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("key is not ECDSA") + } + return pubKey, nil +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..f2acaf4 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,136 @@ +package auth + +import ( + "crypto/rand" + "fmt" + "sync" + "time" +) + +// sessionEntry 存储一次 Phase 1 协商的会话密钥。 +type sessionEntry struct { + key []byte // AES-256 会话密钥 + expiry time.Time +} + +// SessionManager 管理内存中的会话密钥(Nonce → AES Key)。 +// 每个 Phase 1 请求生成一个 session,Phase 2 消费后删除。 +type SessionManager struct { + mu sync.Mutex + store map[string]*sessionEntry + ttl time.Duration +} + +// NewSessionManager 创建会话密钥管理器。 +// ttl: 密钥过期时间(如 30 秒) +func NewSessionManager(ttl time.Duration) *SessionManager { + sm := &SessionManager{ + store: make(map[string]*sessionEntry), + ttl: ttl, + } + // 后台协程定期清理过期密钥 + go sm.cleanupLoop() + return sm +} + +// GenerateKey 生成 AES-256 会话密钥,用指定的 keyID 存储(通常用 nonce)。 +func (sm *SessionManager) GenerateKey(keyID string) ([]byte, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate session key: %w", err) + } + + sm.mu.Lock() + sm.store[keyID] = &sessionEntry{ + key: key, + expiry: time.Now().Add(sm.ttl), + } + sm.mu.Unlock() + + return key, nil +} + +// GetKey 获取并删除会话密钥(一次性使用)。 +// 返回 nil 表示 keyID 不存在或已过期。 +func (sm *SessionManager) GetKey(keyID string) []byte { + sm.mu.Lock() + defer sm.mu.Unlock() + + entry, ok := sm.store[keyID] + if !ok { + return nil + } + + delete(sm.store, keyID) + + if time.Now().After(entry.expiry) { + return nil + } + + return entry.key +} + +// cleanupLoop 定期清理过期密钥。 +func (sm *SessionManager) cleanupLoop() { + ticker := time.NewTicker(sm.ttl) + defer ticker.Stop() + + for range ticker.C { + sm.mu.Lock() + now := time.Now() + for id, entry := range sm.store { + if now.After(entry.expiry) { + delete(sm.store, id) + } + } + sm.mu.Unlock() + } +} + +// NonceCache 用于防重放攻击的 Nonce 缓存。 +type NonceCache struct { + mu sync.Mutex + store map[string]time.Time + ttl time.Duration +} + +// NewNonceCache 创建 Nonce 缓存。 +// ttl: Nonce 有效时间(如 60 秒) +func NewNonceCache(ttl time.Duration) *NonceCache { + nc := &NonceCache{ + store: make(map[string]time.Time), + ttl: ttl, + } + go nc.cleanupLoop() + return nc +} + +// Check 检查并记录 Nonce。返回 true 表示 Nonce 有效(未使用过)。 +// 返回 false 表示 Nonce 已存在(重放攻击)。 +func (nc *NonceCache) Check(nonce string) bool { + nc.mu.Lock() + defer nc.mu.Unlock() + + if _, exists := nc.store[nonce]; exists { + return false + } + + nc.store[nonce] = time.Now().Add(nc.ttl) + return true +} + +func (nc *NonceCache) cleanupLoop() { + ticker := time.NewTicker(nc.ttl) + defer ticker.Stop() + + for range ticker.C { + nc.mu.Lock() + now := time.Now() + for n, expiry := range nc.store { + if now.After(expiry) { + delete(nc.store, n) + } + } + nc.mu.Unlock() + } +} diff --git a/internal/auth/sign.go b/internal/auth/sign.go new file mode 100644 index 0000000..2114f4d --- /dev/null +++ b/internal/auth/sign.go @@ -0,0 +1,30 @@ +package auth + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// Sign ECDSA P-256 签名,返回 DER 编码的 Base64 签名。 +func Sign(key *ecdsa.PrivateKey, data []byte) (string, error) { + hash := sha256.Sum256(data) + sig, err := ecdsa.SignASN1(rand.Reader, key, hash[:]) + if err != nil { + return "", fmt.Errorf("ecdsa sign: %w", err) + } + return base64.StdEncoding.EncodeToString(sig), nil +} + +// Verify 验证 ECDSA P-256 签名。 +// sigBase64 是 DER 编码的 Base64 签名。 +func Verify(key *ecdsa.PublicKey, data []byte, sigBase64 string) bool { + sig, err := base64.StdEncoding.DecodeString(sigBase64) + if err != nil { + return false + } + hash := sha256.Sum256(data) + return ecdsa.VerifyASN1(key, hash[:], sig) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..859a140 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,56 @@ +// Package config 提供全局配置,全部通过环境变量设置。 +package config + +import ( + "os" + "time" +) + +// Config 应用配置。 +type Config struct { + // 监听地址 + Listen string + // Docker 操作超时 + DockerPullTimeout time.Duration + DockerRestartTimeout time.Duration + // 会话密钥 TTL + SessionTTL time.Duration + // Nonce 缓存 TTL + NonceTTL time.Duration + // 时间戳容忍窗口 + TimestampWindow time.Duration + // 日志级别 + LogLevel string +} + +// Load 从环境变量加载配置,未设置则用默认值。 +func Load() *Config { + return &Config{ + Listen: getEnv("LISTEN", ":8080"), + DockerPullTimeout: getDuration("DOCKER_PULL_TIMEOUT", 5*time.Minute), + DockerRestartTimeout: getDuration("DOCKER_RESTART_TIMEOUT", 30*time.Second), + SessionTTL: getDuration("SESSION_TTL", 30*time.Second), + NonceTTL: getDuration("NONCE_TTL", 60*time.Second), + TimestampWindow: getDuration("TIMESTAMP_WINDOW", 30*time.Second), + LogLevel: getEnv("LOG_LEVEL", "info"), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getDuration(key string, fallback time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + d, err := time.ParseDuration(v) + if err == nil { + return d + } + } + return fallback +} + + diff --git a/internal/docker/updater.go b/internal/docker/updater.go new file mode 100644 index 0000000..b3de088 --- /dev/null +++ b/internal/docker/updater.go @@ -0,0 +1,283 @@ +// Package docker 封装 Docker CLI 调用,负责查找、拉取和重建容器。 +package docker + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +const ( + composeProjectLabel = "com.docker.compose.project" + composeServiceLabel = "com.docker.compose.service" +) + +// Updater 封装 Docker 容器更新操作。 +type Updater struct { + pullTimeout time.Duration + restartTimeout time.Duration +} + +// NewUpdater 创建 Updater 实例。 +func NewUpdater(pullTimeout, restartTimeout time.Duration) *Updater { + return &Updater{ + pullTimeout: pullTimeout, + restartTimeout: restartTimeout, + } +} + +// ContainerInfo 容器信息摘要。 +type ContainerInfo struct { + ID string `json:"ID"` + Name string `json:"Name"` + Image string `json:"Image"` + Project string `json:"Project"` + Service string `json:"Service"` +} + +// containerInspect 对应 docker inspect 的部分字段。 +type containerInspect struct { + ID string `json:"Id"` + Name string `json:"Name"` + Config struct { + Image string `json:"Image"` + Cmd []string `json:"Cmd"` + Entrypoint []string `json:"Entrypoint"` + Env []string `json:"Env"` + ExposedPorts map[string]struct{} `json:"ExposedPorts"` + Labels map[string]string `json:"Labels"` + WorkingDir string `json:"WorkingDir"` + User string `json:"User"` + Hostname string `json:"Hostname"` + } `json:"Config"` + HostConfig struct { + NetworkMode string `json:"NetworkMode"` + Privileged bool `json:"Privileged"` + RestartPolicy struct { + Name string `json:"Name"` + } `json:"RestartPolicy"` + PortBindings map[string][]struct { + HostPort string `json:"HostPort"` + } `json:"PortBindings"` + Binds []string `json:"Binds"` + Links []string `json:"Links"` + ExtraHosts []string `json:"ExtraHosts"` + } `json:"HostConfig"` + Mounts []struct { + Type string `json:"Type"` + Source string `json:"Source"` + Target string `json:"Target"` + } `json:"Mounts"` + NetworkSettings struct { + Networks map[string]struct { + Aliases []string `json:"Aliases"` + } `json:"Networks"` + } `json:"NetworkSettings"` +} + +// FindContainerByLabels 通过 compose project + service 标签查找容器。 +func (u *Updater) FindContainerByLabels(project, service string) (*ContainerInfo, error) { + args := []string{ + "ps", + "--filter", fmt.Sprintf("label=%s=%s", composeProjectLabel, project), + "--filter", fmt.Sprintf("label=%s=%s", composeServiceLabel, service), + "--format", "{{.ID}}\t{{.Image}}\t{{.Names}}", + "--latest", + } + out, err := u.docker(args...) + if err != nil { + return nil, fmt.Errorf("find container: %w", err) + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) == 0 || lines[0] == "" { + return nil, fmt.Errorf("container not found: project=%s service=%s", project, service) + } + parts := strings.SplitN(lines[0], "\t", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("unexpected docker ps output: %s", out) + } + return &ContainerInfo{ + ID: parts[0], + Image: parts[1], + Name: parts[2], + Project: project, + Service: service, + }, nil +} + +// PullImage 拉取指定镜像。 +func (u *Updater) PullImage(imageName string) error { + _, err := u.docker("pull", imageName) + return err +} + +// RecreateContainer 重建容器:拉取 → 重命名 → 创建 → 启动 → 清理。 +// 返回新容器 ID。镜像无变化时跳过重建。 +func (u *Updater) RecreateContainer(project, service string) (string, error) { + info, err := u.FindContainerByLabels(project, service) + if err != nil { + return "", err + } + // 拉取新镜像 + if err := u.PullImage(info.Image); err != nil { + return "", fmt.Errorf("pull: %w", err) + } + // 检查镜像是否有变化 + oldImageID, err := u.getContainerImageID(info.ID) + if err != nil { + return "", fmt.Errorf("old image ID: %w", err) + } + newImageID, err := u.getImageID(info.Image) + if err != nil { + return "", fmt.Errorf("new image ID: %w", err) + } + if oldImageID == newImageID { + return info.ID, nil + } + // 检查旧容器配置 + inspectJSON, err := u.inspectContainer(info.ID) + if err != nil { + return "", fmt.Errorf("inspect: %w", err) + } + + oldName := strings.TrimPrefix(inspectJSON.Name, "/") + oldRenamed := oldName + "-old-" + time.Now().Format("150405") + + // 重命名旧容器 + if _, err := u.docker("rename", info.ID, oldRenamed); err != nil { + return "", fmt.Errorf("rename old: %w", err) + } + + // 创建新容器 + runArgs := u.buildRunArgs(inspectJSON, info.Image, oldName) + createOut, err := u.docker(runArgs...) + if err != nil { + _, _ = u.docker("rename", info.ID, oldName) // 恢复 + return "", fmt.Errorf("create: %w", err) + } + newID := strings.TrimSpace(createOut) + + // 删除旧容器 + if _, err := u.docker("rm", "-f", info.ID); err != nil { + fmt.Printf("warning: remove old %s: %v\n", info.ID[:12], err) + } + + return newID, nil +} + +// RestartContainer 仅重启(会拉取最新镜像后再重建)。 +func (u *Updater) RestartContainer(project, service string) (string, error) { + _, err := u.FindContainerByLabels(project, service) + if err != nil { + return "", err + } + return u.RecreateContainer(project, service) +} + +func (u *Updater) getContainerImageID(containerID string) (string, error) { + return u.inspectField(containerID, "{{.Image}}") +} + +func (u *Updater) getImageID(imageName string) (string, error) { + out, err := u.docker("image", "inspect", "--format", "{{.Id}}", imageName) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +func (u *Updater) inspectField(containerID, format string) (string, error) { + out, err := u.docker("inspect", "--format", format, containerID) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +func (u *Updater) inspectContainer(containerID string) (*containerInspect, error) { + out, err := u.docker("inspect", containerID) + if err != nil { + return nil, err + } + var containers []containerInspect + if err := json.Unmarshal([]byte(out), &containers); err != nil { + return nil, fmt.Errorf("parse inspect: %w", err) + } + if len(containers) == 0 { + return nil, fmt.Errorf("container not found: %s", containerID) + } + return &containers[0], nil +} + +// buildRunArgs 从旧容器配置构建 docker run 参数。 +func (u *Updater) buildRunArgs(inspect *containerInspect, imageName, containerName string) []string { + args := []string{"run", "-d"} + args = append(args, "--name", containerName) + + rp := inspect.HostConfig.RestartPolicy.Name + if rp == "" { + rp = "unless-stopped" + } + args = append(args, "--restart", rp) + + for _, env := range inspect.Config.Env { + args = append(args, "-e", env) + } + for _, m := range inspect.Mounts { + if m.Source == "" { + continue + } + args = append(args, "-v", fmt.Sprintf("%s:%s", m.Source, m.Target)) + } + for port, bindings := range inspect.HostConfig.PortBindings { + for _, b := range bindings { + if b.HostPort == "" { + args = append(args, "-p", port) + } else { + args = append(args, "-p", fmt.Sprintf("%s:%s", b.HostPort, port)) + } + } + } + if nm := inspect.HostConfig.NetworkMode; nm != "" && nm != "default" { + args = append(args, "--network", string(nm)) + } + for _, h := range inspect.HostConfig.ExtraHosts { + args = append(args, "--add-host", h) + } + for k, v := range inspect.Config.Labels { + args = append(args, "-l", fmt.Sprintf("%s=%s", k, v)) + } + if wd := inspect.Config.WorkingDir; wd != "" { + args = append(args, "-w", wd) + } + if u := inspect.Config.User; u != "" { + args = append(args, "-u", u) + } + if hn := inspect.Config.Hostname; hn != "" { + args = append(args, "--hostname", hn) + } + if inspect.HostConfig.Privileged { + args = append(args, "--privileged") + } + for _, link := range inspect.HostConfig.Links { + args = append(args, "--link", link) + } + args = append(args, imageName) + return args +} + +// docker 执行 docker CLI 命令。 +func (u *Updater) docker(args ...string) (string, error) { + cmd := exec.Command("docker", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("docker %s: %v\nstderr: %s", + strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} diff --git a/internal/server/handler.go b/internal/server/handler.go new file mode 100644 index 0000000..857befb --- /dev/null +++ b/internal/server/handler.go @@ -0,0 +1,255 @@ +package server + +import ( + "crypto/ecdsa" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "gitea.songhuwan.com/actions/docker-compose-updater/internal/auth" + "gitea.songhuwan.com/actions/docker-compose-updater/internal/docker" +) + +// Handler 持有 HTTP handler 的依赖。 +type Handler struct { + verifyKey *ecdsa.PublicKey + sessionMgr *auth.SessionManager + nonceCache *auth.NonceCache + docker *docker.Updater + timeWindow time.Duration +} + +// NewHandler 创建 Handler。 +func NewHandler( + verifyKey *ecdsa.PublicKey, + sessionMgr *auth.SessionManager, + nonceCache *auth.NonceCache, + docker *docker.Updater, + timeWindow time.Duration, +) *Handler { + return &Handler{ + verifyKey: verifyKey, + sessionMgr: sessionMgr, + nonceCache: nonceCache, + docker: docker, + timeWindow: timeWindow, + } +} + +// --- 请求/响应结构 --- + +// phase1Req 第一阶段请求。 +type phase1Req struct { + V int `json:"v"` + TS int64 `json:"ts"` + Nonce string `json:"nonce"` + Sig string `json:"sig"` +} + +// phase2Req 第二阶段请求。 +type phase2Req struct { + V int `json:"v"` + TS int64 `json:"ts"` + Nonce string `json:"nonce"` + KeyID string `json:"key_id"` + Data string `json:"data"` // AES-GCM 密文 base64 +} + +// plainPayload 解密后的明文请求。 +type plainPayload struct { + Project string `json:"project"` + Service string `json:"service"` + Action string `json:"action"` // update / pull / restart +} + +// --- 处理函数 --- + +// HandleSession Phase 1: 验证签名 → 生成会话密钥 → 返回。 +func (h *Handler) HandleSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "only POST allowed") + return + } + + var req phase1Req + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + if req.V != 1 { + writeError(w, http.StatusBadRequest, "unsupported protocol version") + return + } + if req.Sig == "" { + writeError(w, http.StatusBadRequest, "missing sig") + return + } + if err := h.checkTimestamp(req.TS); err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + if !h.nonceCache.Check(req.Nonce) { + writeError(w, http.StatusUnauthorized, "nonce reused") + return + } + + // 验证 ECDSA 签名:sign("v.ts.nonce") + signData := fmt.Sprintf("%d.%d.%s", req.V, req.TS, req.Nonce) + if !auth.Verify(h.verifyKey, []byte(signData), req.Sig) { + writeError(w, http.StatusUnauthorized, "signature verification failed") + return + } + + // 生成会话密钥,用 nonce 作为 key_id + key := make([]byte, 32) + // 使用随机数作为会话密钥 + keyBytes, err := h.sessionMgr.GenerateKey(req.Nonce) + if err != nil { + slog.Error("generate session key", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + _ = key + + writeJSON(w, http.StatusOK, map[string]any{ + "key": base64.StdEncoding.EncodeToString(keyBytes), + "key_id": req.Nonce, + "expires_in": 30, + }) +} + +// HandleHook Phase 2: 解密 payload → 执行 Docker 操作。 +func (h *Handler) HandleHook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "only POST allowed") + return + } + + var req phase2Req + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + if req.V != 1 { + writeError(w, http.StatusBadRequest, "unsupported protocol version") + return + } + if err := h.checkTimestamp(req.TS); err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + if !h.nonceCache.Check(req.Nonce) { + writeError(w, http.StatusUnauthorized, "nonce reused") + return + } + + // 获取会话密钥 + sessionKey := h.sessionMgr.GetKey(req.KeyID) + if sessionKey == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired session key") + return + } + + // 解密 payload + ciphertext, err := base64.StdEncoding.DecodeString(req.Data) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid data encoding") + return + } + plaintext, err := auth.Decrypt(ciphertext, sessionKey) + if err != nil { + writeError(w, http.StatusUnauthorized, "decrypt failed") + return + } + + var payload plainPayload + if err := json.Unmarshal(plaintext, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload json") + return + } + if payload.Project == "" || payload.Service == "" || payload.Action == "" { + writeError(w, http.StatusBadRequest, "project, service, action required") + return + } + + slog.Info("hook", + "project", payload.Project, + "service", payload.Service, + "action", payload.Action, + ) + + var result string + switch payload.Action { + case "pull": + info, err := h.docker.FindContainerByLabels(payload.Project, payload.Service) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err := h.docker.PullImage(info.Image); err != nil { + writeError(w, http.StatusInternalServerError, "pull: "+err.Error()) + return + } + result = "image pulled" + + case "update": + id, err := h.docker.RecreateContainer(payload.Project, payload.Service) + if err != nil { + writeError(w, http.StatusInternalServerError, "update: "+err.Error()) + return + } + result = fmt.Sprintf("container recreated: %s", id[:12]) + + case "restart": + id, err := h.docker.RestartContainer(payload.Project, payload.Service) + if err != nil { + writeError(w, http.StatusInternalServerError, "restart: "+err.Error()) + return + } + result = fmt.Sprintf("container restarted: %s", id[:12]) + + default: + writeError(w, http.StatusBadRequest, "unknown action: "+payload.Action) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "project": payload.Project, + "service": payload.Service, + "action": payload.Action, + "message": result, + }) +} + +// Health 健康检查。 +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// --- 工具函数 --- + +func (h *Handler) checkTimestamp(ts int64) error { + now := time.Now().Unix() + diff := now - ts + if diff < 0 { + diff = -diff + } + if diff > int64(h.timeWindow.Seconds()) { + return fmt.Errorf("timestamp outside window") + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..63c0809 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,59 @@ +package server + +import ( + "log/slog" + "net/http" + "time" + + "crypto/ecdsa" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "gitea.songhuwan.com/actions/docker-compose-updater/internal/auth" + "gitea.songhuwan.com/actions/docker-compose-updater/internal/docker" +) + +// Server 封装 HTTP 服务器。 +type Server struct { + addr string + router *chi.Mux + srv *http.Server +} + +// New 创建 Server。 +func New( + addr string, + verifyKey *ecdsa.PublicKey, + updater *docker.Updater, + sessionTTL time.Duration, + nonceTTL time.Duration, + timeWindow time.Duration, +) *Server { + sessionMgr := auth.NewSessionManager(sessionTTL) + nonceCache := auth.NewNonceCache(nonceTTL) + h := NewHandler(verifyKey, sessionMgr, nonceCache, updater, timeWindow) + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestSize(1024 * 1024)) // 1MB + + r.Get("/health", h.Health) + r.Post("/session", h.HandleSession) + r.Post("/hook", h.HandleHook) + + return &Server{ + addr: addr, + router: r, + } +} + +// Start 启动 HTTP 服务器。 +func (s *Server) Start() error { + s.srv = &http.Server{ + Addr: s.addr, + Handler: s.router, + } + slog.Info("server starting", "addr", s.addr) + return s.srv.ListenAndServe() +} diff --git a/keys/generate.sh b/keys/generate.sh new file mode 100755 index 0000000..6b9f7b4 --- /dev/null +++ b/keys/generate.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== 生成 ECDSA P-256 密钥对 ===" + +# 生成私钥 +openssl ecparam -genkey -name prime256v1 -out "$DIR/signing-private.pem" +chmod 600 "$DIR/signing-private.pem" + +# 导出公钥 +openssl ec -in "$DIR/signing-private.pem" -pubout -out "$DIR/signing-public.pem" + +echo "" +echo "✅ 已生成:" +echo " 公钥: $DIR/signing-public.pem ← 提交仓库,构建时嵌入 updater" +echo " 私钥: $DIR/signing-private.pem ← 不要提交!拷到 Gitea Secrets" +echo "" +echo "添加到 Gitea Secrets:" +echo " 名称: UPDATER_SIGNING_KEY" +echo " 值: cat $DIR/signing-private.pem 的内容" diff --git a/keys/signing-public.pem b/keys/signing-public.pem new file mode 100644 index 0000000..40e163f --- /dev/null +++ b/keys/signing-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1z1U8uShOCNxeMK6cYtHsyyVkPbt +T+7ZuBKNuV1cDmDb3WtVLK1cPwW3oMXCs2Q2tgeDDidlPsO2+ypTKx3Igw== +-----END PUBLIC KEY-----