Files
billai/webhook/main.go
CHE LIANG ZHAO 339b8afe98 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)
2026-01-16 14:06:10 +08:00

283 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}