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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user