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)
This commit is contained in:
32
deploy.sh
32
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
|
||||
|
||||
|
||||
108
webhook/main.go
108
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user