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