Files
docker-compose-updater/internal/server/handler.go
T
ilovintit cea9b941cf
Build and Push / build (push) Failing after 13m20s
Initial commit: docker-compose-updater
Go 项目,包含:
- 服务端 updater:两阶段协议,ECDSA 签名验证,AES-GCM 加密
- 发送端 dcu-send:Gitea Action CLI
- internal/auth:加解密/签名/会话管理
- internal/docker:Docker CLI 容器查找/拉取/重建
- action/:Gitea Action 定义
- deploy/Dockerfile:多阶段构建
- .gitea/workflows/build.yaml:CI/CD
2026-06-08 15:16:46 +08:00

256 lines
6.4 KiB
Go

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})
}