Skip to content

gin实现webhook功能

背景

之前,我写文章的时候,每次都需要 git push 之后,再到服务器上执行git pull命令,然后再执行 npm run docs:build 命令,才能更新文章,这样非常麻烦,就想着自动化这整个过程,所以就写了一个 webhook 功能,实现自动更新文章。

实现

前期准备

1. 创建一个 gitee 仓库

这里创建一个 test 仓库,点击 管理 按钮,会打开仓库管理界面 创建一个gitee仓库

2. 仓库管理页面

仓库管理页面

3. 打开 WebHooks 管理页面

点击 WebHooks 按钮后,打开 WebHooks 管理 页面 点击WebHooks按钮

4. WebHooks 管理页面

点击 添加 webHook 按钮,打开添加 WebHooks 页面 添加 webHooks

5. 添加 WebHook 页面

添加WebHook页面

6. 填写 URL 和 WebHook 密码

填写URL和WebHook密码

7. 填写 URL 和 WebHook 签名密钥

填写URL和WebHook签名密钥

8. 项目的准备工作

  • 创建项目目录:
shell
mkdir webhook
  • 初始化 go 模块
shell
go mod init webhook
  • 安装依赖:
shell
# gin 框架
go get github.com/gin-gonic/gin
# 日志
go get gopkg.in/natefinch/lumberjack.v2
# 环境变量
go get github.com/joho/godotenv

代码实现

1. 解析配置

  • 创建 config 目录

  • 创建 config.go 文件:

    shell
    touch config.go

    配置参考:

    txt
    TIMESTAMP_TOLERANCE=300
    GITEE_SIGN_KEY=xxx
    MAX_SHELL_EXEC_CONCURRENT=1
    PORT=8080
    LOG_FILE_PATH=logs/app.log

    写入以下内容:

    go
    package config
    
    import (
        "log"
        "os"
        "strconv"
    
        "github.com/joho/godotenv"
    )
    
    // LoadEnv 加载 .env 文件中的配置
    func LoadEnv() {
        err := godotenv.Load(".env")
        if err != nil {
            log.Fatalf("加载 .env 文件失败: %v", err)
        }
    }
    
    // getEnv 获取指定键的值,如果不存在则返回默认值
    func getEnv(key string, defaultValue string) string {
        value := os.Getenv(key)
        if value == "" {
            return defaultValue
        }
        return value
    }
    
    func GetTimestampTolerance() int64 {
        value := getEnv("TIMESTAMP_TOLERANCE", "300")
        timestampTolerance, err := strconv.ParseInt(value, 10, 64)
        if err != nil {
            return 300
        }
        return timestampTolerance
    }
    
    func GetGiteeSignKey() string {
        return getEnv("GITEE_SIGN_KEY", "")
    }
    
    func GetMaxShellExecConcurrentSize() int {
        value := getEnv("MAX_CONCURRENT", "1")
        maxShellExecConcurrentSize, err := strconv.Atoi(value)
        if err != nil {
            return 1
        }
        if maxShellExecConcurrentSize < 1 {
            return 1
        }
        return maxShellExecConcurrentSize
    }
    
    func GetPort() int {
        value := getEnv("PORT", "8080")
        port, err := strconv.Atoi(value)
        if err != nil {
            return 8080
        }
        return port
    }
    
    func GetLogFilePath() string {
        return getEnv("LOG_FILE_PATH", "logs/app.log")
    }

2. 核心逻辑

  • 创建 main.go 文件:
shell
touch main.go
  • 写入以下内容:
go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"gopkg.in/natefinch/lumberjack.v2"

	"log"
	"os/exec"
	"strconv"
	"time"
	"webhook/config"
)

// 控制最大并发执行 shell 命令的数量
var maxShellExecConcurrent chan struct{}

// 处理Gitee 仓库的 Webhook请求
func test(c *gin.Context) {
	err := validateGiteeToken(c)
	if err != nil {
		log.Printf("验证gitee token失败: %v", err)
		return
	}

	// 执行shell命令
	ExecuteShellCommandAsync("cd /www/wwwroot/test && git pull origin master && npm run docs:build")

	// 返回成功响应
	c.String(http.StatusOK, "webhook处理成功")
}

func validateGiteeToken(c *gin.Context) error {
	// 获取Gitee请求头
	giteeToken := c.GetHeader("X-Gitee-Token")
	giteeTimestamp := c.GetHeader("X-Gitee-Timestamp")

	if giteeToken == "" || giteeTimestamp == "" {
		c.String(http.StatusBadRequest, "没有header头")
		return errors.New("没有header头")
	}

	// 从配置中读取时间差
	timestampTolerance := config.GetTimestampTolerance()

	// 验证时间戳是否在有效期内
	currentTime := time.Now().Unix()
	timestamp, err := strconv.ParseInt(giteeTimestamp, 10, 64)
	if err != nil {
		c.String(http.StatusInternalServerError, "时间戳格式错误")
		return errors.New("时间戳格式错误")
	}

	// 将毫秒转换为秒
	timestampInSeconds := timestamp / 1000

	if abs(currentTime-timestampInSeconds) > int64(timestampTolerance) {
		c.String(http.StatusUnauthorized, "时间戳不正确")
		return errors.New("时间戳不正确")
	}

	// 计算签名
	signKey := config.GetGiteeSignKey()
	secStr := fmt.Sprintf("%s\n%s", giteeTimestamp, signKey)

	mac := hmac.New(sha256.New, []byte(signKey))
	mac.Write([]byte(secStr))
	computeToken := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	// 安全比较token
	if !hmac.Equal([]byte(computeToken), []byte(giteeToken)) {
		c.String(http.StatusUnauthorized, "token不正确")
		return errors.New("token不正确")
	}
	return nil
}

// 计算两个数的绝对值
func abs(x int64) int64 {
	if x < 0 {
		return -x
	}
	return x
}

func ExecuteShellCommandAsync(command string) {

	maxShellExecConcurrent <- struct{}{}

	go func() {
		// 执行 shell 命令
		cmd := exec.Command("sh", "-c", command)
		// 不能缺少 HOME=/root 否则会报错:
		// fatal: detected dubious ownership in repository at '/www/wwwroot/test'
		// To add an exception for this directory, call:
		// git config --global --add safe.directory /www/wwwroot/test
		cmd.Env = append(os.Environ(), "HOME=/root")
		// 获取输出结果
		output, err := cmd.CombinedOutput()
		if err != nil {
			log.Printf("执行shell命令失败,失败原因: %s", output)
			return
		}
		log.Printf("执行成功,输出结果: %s", output)

		<-maxShellExecConcurrent
	}()
}

// 新增日志配置函数
func newLogger() *lumberjack.Logger {
	return &lumberjack.Logger{
		Filename:   config.GetLogFilePath(),
		MaxSize:    10,
		MaxBackups: 0,
		MaxAge:     0,
		Compress:   false,
	}
}

func main() {
	// 加载环境变量
	config.LoadEnv()
	// 配置日志
	log.SetOutput(newLogger())

	// 从配置中读取执行 shell 命令的最大并发数
	maxShellExecConcurrentSize := config.GetMaxShellExecConcurrentSize()
	maxShellExecConcurrent = make(chan struct{}, maxShellExecConcurrentSize)
	port := config.GetPort()

	router := gin.Default()

	// 首页
	router.GET("", func(c *gin.Context) {
		c.String(http.StatusOK, "shenlink gitee/github webhook")
	})

	// Gitee Webhook 路由分组
	giteeGroup := router.Group("/test")
	giteeGroup.POST("/test", test)

	// 启动服务
	router.Run(":" + strconv.Itoa(port))
}

部署

go 项目部署还是挺容易的,但是考虑到后续的运维工作,直接使用宝塔面板来部署了。

  • 打开宝塔面板,点击左侧菜单栏的 网站,打开网站管理页面后,选择 Go项目,点击 添加GO项目按钮 打开宝塔面板

  • 打开添加GO项目页面后,可以看到需要填写的信息 添加GO项目页面

  • 填写项目信息 填写项目信息

注意:这里我使用的是 root 用户,是因为 git 所属的用户也是 root ,一般情况下,使用 www 用户就行了

点击 确定 按钮后,go 项目就会自动运行了。

  • 打开项目管理页面 打开项目管理页面

  • GO 项目管理页面 GO项目管理页面 点击左侧的 SSL 选项,配置 SSL证书

  • SSL证书配置页面 SSL证书配置页面

选择 Let's Encrypt 选项,勾选 全选,然后点击 申请证书 按钮,稍等一段时间,证书就会申请下来了

  • 保存证书 保存证书

  • 查看运行状态 查看运行状态

测试运行

测试首页

测试运行

测试 webhook

测试webhook

  • 点击查看 webhook 结果 查看webhook结果

  • 查看 webhook 结果 查看webhook结果