From 339b8afe9859634af7f90c7c7e194b8f86432928 Mon Sep 17 00:00:00 2001 From: CHE LIANG ZHAO Date: Fri, 16 Jan 2026 14:06:10 +0800 Subject: [PATCH] fix(webhook): harden security and reliability - Require non-default WEBHOOK_SECRET\n- Strict main/master ref matching\n- Constant-time HMAC signature check\n- Limit request body and add server timeouts\n- Single-flight deploy lock; pass ref/commit to deploy.sh\n- deploy.sh deploys correct branch (main/master) --- deploy.sh | 32 +++++++++++++- webhook/main.go | 108 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 122 insertions(+), 18 deletions(-) diff --git a/deploy.sh b/deploy.sh index bf00245..7e00ea0 100644 --- a/deploy.sh +++ b/deploy.sh @@ -8,6 +8,10 @@ set -e REPO_ROOT="/app" LOG_FILE="/tmp/billai_deploy.log" +# 可由 webhook 传入:GIT_REF=refs/heads/main 或 refs/heads/master +GIT_REF="${GIT_REF:-}" +GIT_COMMIT="${GIT_COMMIT:-}" + # 颜色输出 RED='\033[0;31m' GREEN='\033[0;32m' @@ -36,13 +40,39 @@ log "==========================================" cd "$REPO_ROOT" || error "无法进入仓库目录: $REPO_ROOT" log "📁 工作目录: $(pwd)" +if [ -n "$GIT_REF" ]; then + log "🌿 触发分支: $GIT_REF" +fi +if [ -n "$GIT_COMMIT" ]; then + log "🧾 触发提交: ${GIT_COMMIT:0:7}" +fi + # 拉取最新代码 log "📥 正在拉取最新代码..." if ! git fetch origin; then error "git fetch 失败" fi -if ! git reset --hard origin/master; then +# 选择部署分支(优先使用 webhook 传入的 ref) +DEPLOY_BRANCH="" +if [ "$GIT_REF" = "refs/heads/main" ]; then + DEPLOY_BRANCH="main" +elif [ "$GIT_REF" = "refs/heads/master" ]; then + DEPLOY_BRANCH="master" +fi + +# 兜底:按远端分支存在性选择(兼容仓库从 master 切到 main) +if [ -z "$DEPLOY_BRANCH" ]; then + if git show-ref --verify --quiet refs/remotes/origin/main; then + DEPLOY_BRANCH="main" + else + DEPLOY_BRANCH="master" + fi +fi + +log "🌿 部署分支: $DEPLOY_BRANCH" + +if ! git reset --hard "origin/$DEPLOY_BRANCH"; then error "git reset 失败" fi diff --git a/webhook/main.go b/webhook/main.go index 5bdc228..cad582b 100644 --- a/webhook/main.go +++ b/webhook/main.go @@ -6,13 +6,14 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "io" "log" "net/http" "os" "os/exec" "path/filepath" - "strings" + "sync" "time" ) @@ -36,10 +37,19 @@ type Config struct { Secret string RepoPath string ScriptPath string + MaxBody int64 } var cfg Config +var deployMu sync.Mutex +var deployRunning bool + +const ( + defaultSecret = "your-webhook-secret" + maxBodyBytes = int64(1 << 20) // 1MB +) + func init() { // 从环境变量读取配置 cfg.Port = os.Getenv("WEBHOOK_PORT") @@ -49,7 +59,7 @@ func init() { cfg.Secret = os.Getenv("WEBHOOK_SECRET") if cfg.Secret == "" { - cfg.Secret = "your-webhook-secret" + cfg.Secret = defaultSecret } cfg.RepoPath = os.Getenv("REPO_PATH") @@ -58,18 +68,34 @@ func init() { } cfg.ScriptPath = filepath.Join(cfg.RepoPath, "deploy.sh") + cfg.MaxBody = maxBodyBytes } func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) + if cfg.Secret == "" || cfg.Secret == defaultSecret { + log.Fatalf("❌ WEBHOOK_SECRET 未配置或仍为默认值;请设置强随机 secret(例如 openssl rand -hex 32)") + } + log.Printf("🚀 Webhook 服务启动 | 监听端口: %s\n", cfg.Port) log.Printf("📁 仓库路径: %s\n", cfg.RepoPath) log.Printf("📜 部署脚本: %s\n", cfg.ScriptPath) + log.Printf("🔒 请求体大小限制: %d bytes\n", cfg.MaxBody) - http.HandleFunc("/webhook", handleWebhook) - http.HandleFunc("/health", handleHealth) + mux := http.NewServeMux() + mux.HandleFunc("/webhook", handleWebhook) + mux.HandleFunc("/health", handleHealth) - if err := http.ListenAndServe(":"+cfg.Port, nil); err != nil { + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("❌ 服务器启动失败: %v", err) } } @@ -91,6 +117,21 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) { return } + // 单实例部署:避免并发执行 deploy.sh 引发互相踩踏 + if !tryStartDeploy() { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{ + "message": "部署进行中,请稍后再试", + }) + return + } + defer finishDeploy() + + // 限制请求体大小,防止大包占用内存 + r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBody) + // 读取请求体 body, err := io.ReadAll(r.Body) if err != nil { @@ -115,8 +156,8 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) { return } - // 只处理 master 分支的推送 - if !strings.Contains(payload.Ref, "master") && !strings.Contains(payload.Ref, "main") { + // 只处理主分支推送(严格匹配) + if payload.Ref != "refs/heads/master" && payload.Ref != "refs/heads/main" { log.Printf("⏭️ 忽略非主分支推送: %s", payload.Ref) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ @@ -135,8 +176,16 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) { } log.Printf(" 提交: %s", commitShort) - // 异步执行部署 - go executeDeployment(payload) + // 异步执行部署(释放 HTTP 线程) + go func() { + defer func() { + // 避免 goroutine panic 导致“卡住 running 状态” + if r := recover(); r != nil { + log.Printf("❌ 部署流程 panic: %v", r) + } + }() + executeDeployment(payload) + }() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) @@ -148,16 +197,14 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) { // verifySignature 验证 Gitea webhook 签名 func verifySignature(signature string, body []byte) bool { - if cfg.Secret == "your-webhook-secret" { - log.Println("⚠️ 警告: 使用默认 secret,建议设置 WEBHOOK_SECRET 环境变量") - return true + if signature == "" { + return false } hash := hmac.New(sha256.New, []byte(cfg.Secret)) hash.Write(body) - expected := "sha256=" + hex.EncodeToString(hash.Sum(nil)) - - return signature == expected + expected := []byte("sha256=" + hex.EncodeToString(hash.Sum(nil))) + return hmac.Equal([]byte(signature), expected) } // executeDeployment 执行部署 @@ -172,9 +219,13 @@ func executeDeployment(payload GiteaPayload) { return } - // 执行部署脚本 + // 执行部署脚本(把 ref/commit 传给脚本,便于按分支部署与日志记录) cmd := exec.Command("bash", cfg.ScriptPath) cmd.Dir = cfg.RepoPath + cmd.Env = append(os.Environ(), + "GIT_REF="+payload.Ref, + "GIT_COMMIT="+payload.Commit, + ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -184,6 +235,9 @@ func executeDeployment(payload GiteaPayload) { if err := cmd.Run(); err != nil { log.Printf("❌ 部署脚本执行失败: %v", err) + if stdout.Len() > 0 { + log.Printf("输出:\n%s", stdout.String()) + } if stderr.Len() > 0 { log.Printf("错误输出:\n%s", stderr.String()) } @@ -201,8 +255,28 @@ func executeDeployment(payload GiteaPayload) { log.Printf("📝 部署日志:\n") log.Printf(" 仓库: %s", payload.Repository.FullName) - log.Printf(" 提交: %s", payload.Commit[:7]) + commitShort := payload.Commit + if len(commitShort) > 7 { + commitShort = commitShort[:7] + } + log.Printf(" 提交: %s", commitShort) log.Printf(" 分支: %s", payload.Ref) log.Printf(" 推送者: %s", payload.Pusher.Username) log.Printf(" 时间: %s", time.Now().Format(time.RFC3339)) } + +func tryStartDeploy() bool { + deployMu.Lock() + defer deployMu.Unlock() + if deployRunning { + return false + } + deployRunning = true + return true +} + +func finishDeploy() { + deployMu.Lock() + deployRunning = false + deployMu.Unlock() +}