- 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)
283 lines
7.0 KiB
Go
283 lines
7.0 KiB
Go
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()
|
||
}
|