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
This commit is contained in:
@@ -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
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# 编译产物
|
||||||
|
/updater
|
||||||
|
/dcu-send
|
||||||
|
/bin/
|
||||||
|
|
||||||
|
# 私钥(绝对不提交)
|
||||||
|
keys/*-private.pem
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# 系统
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.log
|
||||||
|
/tmp/
|
||||||
@@ -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
|
||||||
+542
@@ -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 吗?
|
||||||
@@ -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"]
|
||||||
@@ -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 }}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Executable
+22
@@ -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 的内容"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1z1U8uShOCNxeMK6cYtHsyyVkPbt
|
||||||
|
T+7ZuBKNuV1cDmDb3WtVLK1cPwW3oMXCs2Q2tgeDDidlPsO2+ypTKx3Igw==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
Reference in New Issue
Block a user