package main import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "log" "net/http" "os" "os/exec" "path/filepath" "sync" "time" ) // GiteaPayload Gitea webhook 推送事件 type GiteaPayload struct { Repository struct { Name string `json:"name"` FullName string `json:"full_name"` CloneURL string `json:"clone_url"` } `json:"repository"` Pusher struct { Username string `json:"username"` } `json:"pusher"` Ref string `json:"ref"` Commit string `json:"after"` } // Config webhook 服务配置 type Config struct { Port string 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") if cfg.Port == "" { cfg.Port = "9000" } cfg.Secret = os.Getenv("WEBHOOK_SECRET") if cfg.Secret == "" { cfg.Secret = defaultSecret } cfg.RepoPath = os.Getenv("REPO_PATH") if cfg.RepoPath == "" { cfg.RepoPath = "/app" } 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) mux := http.NewServeMux() mux.HandleFunc("/webhook", handleWebhook) mux.HandleFunc("/health", handleHealth) 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) } } // handleHealth 健康检查端点 func handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "time": time.Now().Format(time.RFC3339), }) } // handleWebhook 处理 Gitea webhook func handleWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 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 { log.Printf("❌ 读取请求体失败: %v", err) http.Error(w, "Failed to read body", http.StatusBadRequest) return } defer r.Body.Close() // 验证签名 if !verifySignature(r.Header.Get("X-Gitea-Signature"), body) { log.Println("⚠️ 无效的签名") http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // 解析 payload var payload GiteaPayload if err := json.Unmarshal(body, &payload); err != nil { log.Printf("❌ 解析 JSON 失败: %v", err) http.Error(w, "Failed to parse JSON", http.StatusBadRequest) return } // 只处理主分支推送(严格匹配) 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{ "message": "非主分支,跳过部署", }) return } log.Printf("✅ 收到有效的 Webhook 请求") log.Printf(" 仓库: %s", payload.Repository.FullName) log.Printf(" 推送者: %s", payload.Pusher.Username) log.Printf(" 分支: %s", payload.Ref) commitShort := payload.Commit if len(commitShort) > 7 { commitShort = commitShort[:7] } log.Printf(" 提交: %s", commitShort) // 异步执行部署(释放 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) json.NewEncoder(w).Encode(map[string]string{ "message": "部署已启动", "commit": payload.Commit, }) } // verifySignature 验证 Gitea webhook 签名 func verifySignature(signature string, body []byte) bool { if signature == "" { return false } hash := hmac.New(sha256.New, []byte(cfg.Secret)) hash.Write(body) expected := []byte("sha256=" + hex.EncodeToString(hash.Sum(nil))) return hmac.Equal([]byte(signature), expected) } // executeDeployment 执行部署 func executeDeployment(payload GiteaPayload) { log.Println("\n🔄 开始部署流程...") startTime := time.Now() // 检查脚本是否存在 if _, err := os.Stat(cfg.ScriptPath); os.IsNotExist(err) { log.Printf("❌ 部署脚本不存在: %s", cfg.ScriptPath) 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 cmd.Stderr = &stderr log.Printf("📜 执行部署脚本: %s", cfg.ScriptPath) 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()) } return } duration := time.Since(startTime).Seconds() log.Printf("✅ 部署完成") log.Printf(" 耗时: %.2f 秒", duration) if stdout.Len() > 0 { log.Printf("输出:\n%s", stdout.String()) } log.Printf("📝 部署日志:\n") log.Printf(" 仓库: %s", payload.Repository.FullName) 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() }