0%

adnanh/webhook 源码分析

这篇文章将学习 webhook 技术,会重点分析开源项目 adnanh/webhook 的源码实现,以加深对 webhook 的理解。

webhook 简介

webhook 并不是什么新的专有技术技术,它只是回调机制在 http/web 场景下的一种实现。Webhook 是 用户定义的 HTTP 回调,当某个事件发生时,源站点会向 Webhook 配置里的 URL 发送 HTTP 请求,并在 HTTP 请求中携带事件的相关信息,这样 HTTP 请求的接收方就可以收到这些事件信息并执行相应的动作。

具体来说:

  • 事件接收方(消费者) 需要启动一个 HTTP 端点,用于接收 事件发送方(生产者) 发送过来的 HTTP 请求
  • 事件接收方 提前向 事件发送方 注册这个 HTTP 端点(即提供一个 URL),并订阅好感兴趣的事件
  • 当对应的事件发生时,事件发送方 向注册的 URL 发送 HTTP 请求,并在 HTTP 请求中携带事件的相关信息
  • 事件接收方 收到 HTTP 请求后,根据事件信息执行相应的动作

相比于轮询机制,webhook 是一种更高效的事件通知机制,常用于 CI/CD 系统的构建等。当然在使用 Webook 机制时也需要考虑安全问题,事件接收方 需要对收到的 HTTP 请求(事件信息)进行身份验证,以避免被欺骗攻击,同时也应该验证时间戳,防止重放攻击。

adnanh/webhook 源码分析

adnanh/webhook 是一个轻量级、Go 编写的配置工具,允许你轻松创建 Webhook 端点,当接收到事件通知时(即接收到 事件发送方 发送的 HTTP 请求时),执行所配置的命令。另外允许你从 HTTP 请求中获取数据(例如查询参数、请求头、请求 body)并传递给所要执行的命令。

该项目的核心逻辑就是:

  • 接收事件:启动一个 HTTP 服务器,接收 HTTP 请求
  • 解析数据:解析 HTTP 查询参数、请求头、请求 body 等数据
  • 匹配 hook 规则:检查该事件所匹配到的 hook 规则
  • 执行命令:执行所匹配到的 hook 规则中的命令(同时会根据规则定义,传递指定的参数/环境变量等给所要执行的命令)

接下来我们将详细分析该项目的基本实现原理。

服务启动

[adnanh/webhook] 是一个 http 服务程序,在 main 函数中完成了服务启动的所有逻辑,具体包括以下流程:

命令行选项解析

命令行选项的解析:使用标准库 flag 包来实现命令行选项的解析

  • 因为不同平台可能支持的命令行选项可能有所不同,使用 platformFlags() 函数定义平台相关的 flags
  • platform_unix.goplatform_windows.go 中分别定义了 unix 平台和 windows 平台的 platformFlags() 实现
  • 通过 Go 的构建标签 在编译时,选择性地编译对应平台的代码文件
1
2
//go:build !windows
// +build !windows

服务监听

因为 [adnanh/webhook] 是个服务程序,因此需要监听一个网络端口。为了保证服务器的安全性,服务监听做了很多额外的考虑:

  • 提供了 和 -setuid-setgid 选项,这样允许进程在完成监听动作后(可能需要更高的权限),重新切换到指定的用户身份运行。这样就可以实现:以高权限身份完成端口监听等初始化动作以低权限身份运行服务业务逻辑
  • 使用了 systemd 的 socket activation 功能,允许 systemd 先监听 socket,在实际收到连接请求时再启动服务进程,并将监听好的 socket 传递给该进程。socekt activation 机制具有 服务按需启动权限隔离(systemd 负责监听 socket)、无缝重启(服务重启时 socket 由 systemd 保持,连接不会丢失)等优点
  • 通过 go-systemd 包来使用 systemd 的 socket activation 功能

信号处理

  • 通过 signal.Notify 注册信号处理器
  • SIGUSR1SIGHUP 信号都可以用于重新加载 hooks 文件
  • 增加 InterruptSIGTERM 的信号处理,在退出前执行清理工作(例如删除 Pid 文件)

解析 hooks 文件

程序启动时,通过 -hooks 命令行选项指定 hooks 文件路径,可以指定多个文件。通过 HooksLoadFromFile 从指定的文件路径加载 hooks 配置

1
2
type Hooks []Hook
func (h *Hooks) LoadFromFile(path string, asTemplate bool) errorj

该函数支持按两种方式来解析 hooks 文件内容:

  • 一种是按照 Go 的 text/template 模板来解析内容,并将渲染后的内容按照 JSON/YAML 格式进行解析
  • 另一种是直接将文件内容按照 JSON/YAML 格式进行解析

按照模版解析时,提供了 3 个模版函数:catcredentialgetenv,我们可以在模版文件中使用这些函数

1
2
3
4
5
funcMap := template.FuncMap{
"cat": cat,
"credential": credential,
"getenv": getenv,
}
  • cat:从文件系统中读取内容
  • getenv:从操作系统环境变量中获取值
  • credential:实现了类似于 systemd 的 LoadCredential 机制。从 CREDENTIALS_DIRECTORY 环境变量指定的目录中读取内容

从 hooks 文件中解析得到一个 Hooks 对象,它是 Hook 对象的切片。Hook 对象就表示了一条 webhook 规则,我们下文再详细介绍 Hook 规则的定义。从各个 hooks 文件中解析得到的 hooks 规则都会保存到全局变量 loadedHooksFromFiles 中:

1
loadedHooksFromFiles = make(map[string]hook.Hooks)

开启 hooks 文件监控

如果程序启动时指定了 -hotReload 选项,则支持对 hooks 文件进行热重载。此时会对 hooks 的文件变化进行实时监控,项目使用了 [fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify@v1.7.0) 包来实现文件系统监控,webhook 会启动一个单独的 goroutine 来监控文件系统变化事件:

  • 如果是文件被写入,则重新加载对应的 hooks 文件
  • 如果是文件被删除,则移除对应的 hooks 文件
  • 如果是文件被重命名,则重新加载对应的 hooks 文件

reloadHooks() 函数中重新加载 hooks 规则,它其实就是继续调用 LoadFromFile 函数解析新的 hooks 文件内容,并将更新后的 hooks 规则重新保存到全局变量 loadedHooksFromFiles 中。

启动 http 服务

接下来来到服务启动最关键的部分,即启动 http 服务,[adnanh/webhook] 使用了 gorilla/mux 包来实现 http 路由,相比于标准库 http.ServeMuxgorilla/mux 提供了更灵活的路由匹配规则,并且支持中间件。如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建 http router
r := mux.NewRouter()

// 使用中间件
r.Use(middleware.RequestID(
middleware.UseXRequestIDHeaderOption(*useXRequestID),
middleware.XRequestIDLimitOption(*xRequestIDLimit),
))
r.Use(middleware.NewLogger())
r.Use(chimiddleware.Recoverer)

if *debug {
r.Use(middleware.Dumper(log.Writer()))
}

接下来完成路由的注册:hooksURL 定义了 webhook HTTP 请求的 URL 模式,它由 路由前缀 + hook 规则 id 参数 构成,路由前缀可以通过 urlprefix 选项指定,默认为 hooks,所以这个 URL 模式默认为 /hooks/:hook-id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义 hooks URL
// 默认为 /hooks/{id:.*}
// {name} 或者 {name:pattern} 表示路径中的变量
// 变量名为 name,之后可以通过 mux.Vars() 返回的 map,获取变量值
hooksURL := makeRoutePattern(hooksURLPrefix)

// 处理根路径请求
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
for _, responseHeader := range responseHeaders {
w.Header().Set(responseHeader.Name, responseHeader.Value)
}

fmt.Fprint(w, "OK")
})

// 设置 hooksURL 为 hookHandler
r.HandleFunc(hooksURL, hookHandler)

由于,mux.Router 实现了 http.Handler 接口,因此它可以直接替代标准库的 http.ServeMux,作为 http 服务器的 Handler,如下代码完成 HTTP 服务器的启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 启动 http server
// Create common HTTP server settings
svr := &http.Server{
Handler: r,
}

// Serve HTTP
if !*secure {
log.Printf("serving hooks on http://%s%s", addr, makeHumanPattern(hooksURLPrefix))
// 启动 http 服务
log.Print(svr.Serve(ln))

return
}

// TLS 处理
...
log.Print(svr.ServeTLS(ln, *cert, *key))

以上就完成了整个服务的启动过程。

webhook 规则定义

在代码中,通过如下结构体定义了 webhook 规则,理解了该结构体各个字段的含义,就基本理解了整个项目的核心功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
type Hook struct {
// Hook 规则的 ID
ID string `json:"id,omitempty"`
// 需要执行的命令
ExecuteCommand string `json:"execute-command,omitempty"`
// 命令执行的工作目录
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
// 响应消息
ResponseMessage string `json:"response-message,omitempty"`
// 响应头
ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"`
// 是否已命令的输出作为 http 响应消息 body
// 开启的话,需要等待命令执行完成后,才会返回 HTTP 响应
// 否则,命令启动后就会返回 HTTP 响应,不需要等待命令执行完成
CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"`
// 当命令执行失败时,是否仍然以命令的输出作为响应
CaptureCommandOutputOnError bool `json:"include-command-output-in-response-on-error,omitempty"`
// 哪些参数需要作为环境变量传入
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command,omitempty"`
// 哪些参数直接作为命令的参数传入
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"`
// 传递给命令的文件参数,
// 此时参数内容会保存到临时文件,文件名称则会以环境变量的形式传给命令
PassFileToCommand []Argument `json:"pass-file-to-command,omitempty"`
// 哪些参数的格式是 JSON 格式,对于这些参数,webhook 程序会负责对其进行 json 反序列化
JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"`
// webhook 规则触发的条件
TriggerRule *Rules `json:"trigger-rule,omitempty"`
// webhook 规则触发条件不匹配时的响应状态码
TriggerRuleMismatchHttpResponseCode int `json:"trigger-rule-mismatch-http-response-code,omitempty"`
// 在 OR 规则中允许签名校验失败。如果没有开启的话,签名校验会被视为错误
TriggerSignatureSoftFailures bool `json:"trigger-signature-soft-failures,omitempty"`
// 定义 webhook HTTP 请求的 payload 的 Content-Type
IncomingPayloadContentType string `json:"incoming-payload-content-type,omitempty"`
// 成功时的响应状态码
SuccessHttpResponseCode int `json:"success-http-response-code,omitempty"`
// 允许的 http 方法
HTTPMethods []string `json:"http-methods"`
}

参数定义

为了给执行的命令传递各种数据,例如环境变量、命令参数等,代码使用 Argument 结构体来表示参数,它定义如下:

1
2
3
4
5
6
7
8
9
10
type Argument struct {
// 参数的来源
Source string `json:"source,omitempty"`
// 参数的名称,指定了如何从 Source 中提取参数值
Name string `json:"name,omitempty"`
// 当把参数作为环境变量时传递的名字
EnvName string `json:"envname,omitempty"`
// 是否对数据进行 base64 解码
Base64Decode bool `json:"base64decode,omitempty"`
}

这些参数的具体值则来自于 webhook 请求,通过 Source 字段指定参数的来源,支持以下 Source 来源:

  • header:从 HTTP 请求头中提取
  • urlquery:从 HTTP URL 中的查询参数中提取
  • payload:从 HTTP 请求的 body 中提取
  • raw-request-body:直接将 HTTP 请求的原始 body 作为参数值
  • request:从请求本身元数据中提取,目前支持 remote-addrmethod 两种值
  • entire-headers:将整个请求头转换为 JSON 字符串,作为参数值
  • entire-query:将整个查询参数转换为 JSON 字符串,作为参数值
  • entire-payload:将整个请求 body 转换为 JSON 字符串,作为参数值
  • string:直接把 Name 字段的值作为参数值

Source 指定了参数的来源,而 Name 字段则指定了如何从该来源中提取参数值。例如如果 Source 是 header,则可以使用某个 http header 的名称作为 Name,这样程序就知道以该 header 的值作为参数值。Name 支持以 . 来提取嵌套数据中的某个字段,例如假设 Sourcepayload,并且 payload 是如下嵌套的 json 数据,则 commits.0.commit.id 可以提取到第一个 commit 的 id 值 1

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"commits": [
{
"commit": {
"id": 1
}
}, {
"commit": {
"id": 2
}
}
]
}

参数提取的实现代码位于如下几个函数中,其中 GetParameter 使用递归的方法来提取嵌套数据:

1
2
3
func (ha *Argument) Get(r *Request) (string, error)
func ExtractParameterAsString(s string, params interface{}) (string, error)
func GetParameter(s string, params interface{}) (interface{}, error)

需要说明的是,如果想要提取嵌套数据,数据必须已经按照 JSON 格式进行解析过,在以下两种情况下,数据会被程序进行解析:

  • 对于请求 body,如果 content-typeapplication/json,则程序自动会对 body 进行 json 解析,得到结构化数据
  • webhook 规则中,可以通过 parse-parameters-as-json 指定哪些参数的值需要按照 json 进行解析

触发规则

定义 webhook 规则时支持指定触发条件,只有当该请求满足该 webhook 规则所指定的触发条件时,才会执行该 webhook 规则中定义的命令。触发条件是通过 Rules 结构体来表示的,它定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Rules struct {
// And 规则,只有所有的子规则都满足,才满足
And *AndRule `json:"and,omitempty"`
// Or 规则,只要有一个子规则满足,就满足
Or *OrRule `json:"or,omitempty"`
// Not 规则,只有当子规则不满足时,才满足
Not *NotRule `json:"not,omitempty"`
// Match 规则,真正用来执行某个匹配逻辑
Match *MatchRule `json:"match,omitempty"`
}

type AndRule []Rules
type OrRule []Rules
type NotRule Rules

type MatchRule struct {
// 匹配类型
Type string `json:"type,omitempty"`
// 正则表达式匹配时使用
Regex string `json:"regex,omitempty"`
// 签名匹配时使用,计算 HMAC 签名时使用的密钥
Secret string `json:"secret,omitempty"`
// 匹配值时使用
Value string `json:"value,omitempty"`
// 匹配参数来源,即待匹配的数据的来源
Parameter Argument `json:"parameter,omitempty"`
// IP 白名单
IPRange string `json:"ip-range,omitempty"`
}

可以看到,AndRule、OrRule 和 NotRule 只是用来提供规则的 逻辑,真正的匹配动作则是通过 MatchRule 来实现的,如下是一个 MatchRule 的配置实例:

1
2
3
4
5
6
7
8
9
10
11
12
{
"match":
{
"type": "value",
"value": "refs/heads/development",
"parameter":
{
"source": "payload",
"name": "ref"
}
}
}

MatchRule 首先对 Parameter 字段所指定的参数进行求值,然后再将计算得到的参数值按照 匹配类型 进行匹配,目前支持以下几种匹配类型:

  • value:将参数值与 MatchRule 中的 Value 字段进行比较
  • regex:将参数值与 MatchRule 中的 Regex 字段进行正则表达式匹配
  • payload-hmac-sha1:计算请求 body 的 SHA1 HMAC 签名值,并与参数值进行比较
  • payload-hmac-sha256:计算请求 body 的 SHA256 HMAC 签名值,并与参数值进行比较
  • payload-hmac-sha512:计算请求 body 的 SHA512 HMAC 签名值,并与参数值进行比较
  • ip-whitelist:直接检查请求的 IP 地址是否
  • scalr-signature:检查 scalr 签名是否匹配,它会同时计算 Date 与请求 body 的 SHA1 HMAC 签名值,签名匹配后,还需要保证该请求中 Date 头部指定的时间与当前时间在 5min 以内

例如对于如下规则,将计算请求 body 的 SHA1 HMAC 签名值,并与请求头 X-Hub-Signature 中提供的值进行比较,如果匹配,则认为这条 MatchRule 匹配成功。

1
2
3
4
5
6
7
8
9
10
11
12
{
"match":
{
"type": "payload-hmac-sha1",
"secret": "yoursecret",
"parameter":
{
"source": "header",
"name": "X-Hub-Signature"
}
}
}
1
X-Hub-Signature: sha1=the-first-signature,sha1=the-second-signature

项目文档中提供了一个多层次匹配规则的配置实例,这里就不再展示。

webhook 请求的处理

了解完 webhook 规则的定义后,我们就可以分析程序是如何处理 webhook HTTP 请求的。从上面 启动 http 服务 的流程中我们可以看到,对 webhook HTTP 请求是通过 hookHandler 函数来处理的,它的核心实现可以分为以下几个步骤:

  1. 从请求中获取 hook 规则 id:因为 webhook 请求中的 URL 模式为 /hooks/:hook-id,其中 :hook-id 是个路径参数(或者称为路径中的变量部分),mux.Vars(r) 返回一个 map,包含了请求中的所有路径参数,以 id 为 key,就能获取到对应的 hooks 规则 id
1
id := mux.Vars(r)["id"]

这里之所以是以 id 为 key,是因为在定义路由时,我们使用了 {id:.*} 来作为路径参数,参见 makeRoutePattern 的实现:

1
2
3
func makeRoutePattern(prefix *string) string {
return makeBaseURL(prefix) + "/{id:.*}"
}
  1. 调用 matchLoadedHookloadedHooksFromFiles 查找对应 id 的 hook 规则

  2. 检查 HTTP 方法是否匹配:

  • 如果 webhook 规则中指定了请求方法,则检查该 HTTP 请求的方法是否与指定值匹配
  • 如果程序启动时,通过了 http-methods 选项指定了允许的 HTTP 方法,则检查该请求的方法是否在指定值中
  • 否则默认默认所有请求方法都允许
  1. 返回指定的 HTTP 响应头,程序启动时可以通过 -header 选项指定要返回的 HTTP 响应头
1
2
3
4
// 设置响应头
for _, responseHeader := range responseHeaders {
w.Header().Set(responseHeader.Name, responseHeader.Value)
}
  1. 读取请求 body,解析请求头、URL 中的 query 参数

  2. 根据 Content-Type,对 payload 进行解码,并得到结构化数据,支持以下 payload 类型的解码:

1
2
3
4
* json
* x-www-form-urlencoded
* xml
* multipart/form-data,如果 multipart/form-data 中携带了文件数据,并且文件是 json 格式,还会对 json 文件数据进行解码
  1. webhook 规则中,可以通过 parse-parameters-as-json 配置项来指定那些参数的值需要按照 json 格式解析,解析完成后,就可以在 webhook 规则中的参数以及 pass-arguments-to-command 中使用这些解析后的对象了

  2. 判断当前请求是否满足该 webhook 规则的触发条件

1
ok, err = matchedHook.TriggerRule.Evaluate(req)
  1. 只有当触发条件满足时,才真正执行 webhook 规则中所定义的命令,这个流程是在 handleHook 函数中执行的
1
func handleHook(h *hook.Hook, r *hook.Request) (string, error)

执行命令

handleHook 负责执行 webhook 规则中定义的命令,它的实现可以分为以下几个步骤:

  • 查找命令对应的可执行文件
  • 使用标准库 os/exec 包来执行命令,使用如下方法创建待执行的命令:
1
2
3
cmd := exec.Command(cmdPath)
// 设置命令执行的工作目录
cmd.Dir = h.CommandWorkingDirectory
  • 调用 ExtractCommandArguments 从请求中提取需要传递给命令的参数,webhook 规则中通过 pass-arguments-to-command 选项指定了需要给命令传递哪些参数
  • 调用 ExtractCommandArgumentsForEnv 从请求中提取需要传递给命令的环境变量参数,webhook 规则中通过 pass-environment-to-command 选项指定了需要给命令传递的环境变量
  • 调用 ExtractCommandArgumentsForFile 从请求中提取需要传递给命令的文件参数,然后将这些参数值写入到临时文件,最终这些临时文件的名称则会以环境变量的形式传给命令

完成命令参数、环境变量的设置后,最终就是执行该命令并获取命令的输出:

1
out, err := cmd.CombinedOutput()

实际测试

以上就完成了对该 adnanh/webhook 项目代码的基本分析,接下来实际测试一下该项目,以加深对其的理解。

  • 下载项目代码后,通过如下命令构建项目:
1
make
  • 以如下配置文件启动 webhook 服务:./webhook --hooks hooks.json --verbose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[
{
"id": "rule1",
"execute-command": "echo",
"include-command-output-in-response": true,
"parse-parameters-as-json": [
{
"source": "payload",
"name": "body"
},
{
"source": "header",
"name": "header2"
}
],
"pass-arguments-to-command": [
{
"source": "header",
"name": "header1"
},
{
"source": "header",
"name": "header2.value.1"
}
],
"trigger-rule": {
"match": {
"type": "value",
"value": "true",
"parameter":
{
"source": "payload",
"name": "body.execute"
}
}
}
}
]
  • 通过如下方式,发送 webhook 请求:
1
2
3
4
# curl 127.0.0.1:9000/hooks/rule1 -H "Content-Type: x-www-form-urlencoded" -d 'body={"execute":"true"}' -H "header1: value1" -H 'header2: {"value": ["111111", "22222"]}'

# 输出结果
value1 22222

小结

这篇文章重点分析了 adnanh/webhook 项目源码,学习其如何启动一个 HTTP 服务器以处理 webhook 请求,其支持灵活的配置参数(例如 webhook 规则中的触发条件、如何给命令传递参数),以满足不同场景下的业务需求。同时,该项目还使用了 systemd 的 socket activation 等机制,以提高服务的安全性。

Reference