cea9b941cf
Build and Push / build (push) Failing after 13m20s
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
256 lines
6.4 KiB
Go
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})
|
|
}
|