这篇文章将学习 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.go
和platform_windows.go
中分别定义了 unix 平台和 windows 平台的platformFlags()
实现 - 通过
Go 的构建标签
在编译时,选择性地编译对应平台的代码文件
1 | //go: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
注册信号处理器 SIGUSR1
和SIGHUP
信号都可以用于重新加载 hooks 文件- 增加
Interrupt
和SIGTERM
的信号处理,在退出前执行清理工作(例如删除 Pid 文件)
解析 hooks 文件
程序启动时,通过 -hooks
命令行选项指定 hooks 文件路径,可以指定多个文件。通过 Hooks
的 LoadFromFile
从指定的文件路径加载 hooks 配置
1 | type Hooks []Hook |
该函数支持按两种方式来解析 hooks 文件内容:
- 一种是按照 Go 的
text/template
模板来解析内容,并将渲染后的内容按照 JSON/YAML 格式进行解析 - 另一种是直接将文件内容按照 JSON/YAML 格式进行解析
按照模版解析时,提供了 3 个模版函数:cat
、credential
、getenv
,我们可以在模版文件中使用这些函数
1 | funcMap := template.FuncMap{ |
- 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.ServeMux,gorilla/mux
提供了更灵活的路由匹配规则,并且支持中间件。如下代码
1 | // 创建 http router |
接下来完成路由的注册:hooksURL
定义了 webhook HTTP 请求的 URL 模式,它由 路由前缀
+ hook 规则 id 参数
构成,路由前缀可以通过 urlprefix
选项指定,默认为 hooks
,所以这个 URL 模式默认为 /hooks/:hook-id
。
1 | // 定义 hooks URL |
由于,mux.Router
实现了 http.Handler
接口,因此它可以直接替代标准库的 http.ServeMux
,作为 http 服务器的 Handler
,如下代码完成 HTTP 服务器的启动:
1 | // 启动 http server |
以上就完成了整个服务的启动过程。
webhook 规则定义
在代码中,通过如下结构体定义了 webhook 规则,理解了该结构体各个字段的含义,就基本理解了整个项目的核心功能:
1 | type Hook struct { |
参数定义
为了给执行的命令传递各种数据,例如环境变量、命令参数等,代码使用 Argument
结构体来表示参数,它定义如下:
1 | type Argument struct { |
这些参数的具体值则来自于 webhook 请求,通过 Source
字段指定参数的来源,支持以下 Source 来源:
header
:从 HTTP 请求头中提取url
、query
:从 HTTP URL 中的查询参数中提取payload
:从 HTTP 请求的 body 中提取raw-request-body
:直接将 HTTP 请求的原始 body 作为参数值request
:从请求本身元数据中提取,目前支持remote-addr
、method
两种值entire-headers
:将整个请求头转换为 JSON 字符串,作为参数值entire-query
:将整个查询参数转换为 JSON 字符串,作为参数值entire-payload
:将整个请求 body 转换为 JSON 字符串,作为参数值string
:直接把 Name 字段的值作为参数值
Source
指定了参数的来源,而 Name
字段则指定了如何从该来源中提取参数值。例如如果 Source 是 header
,则可以使用某个 http header
的名称作为 Name,这样程序就知道以该 header 的值作为参数值。Name
支持以 .
来提取嵌套数据中的某个字段,例如假设 Source
是 payload
,并且 payload 是如下嵌套的 json 数据,则 commits.0.commit.id
可以提取到第一个 commit 的 id 值 1
。
1 | { |
参数提取的实现代码位于如下几个函数中,其中 GetParameter
使用递归的方法来提取嵌套数据:
1 | func (ha *Argument) Get(r *Request) (string, error) |
需要说明的是,如果想要提取嵌套数据,数据必须已经按照 JSON 格式进行解析过,在以下两种情况下,数据会被程序进行解析:
- 对于请求 body,如果
content-type
为application/json
,则程序自动会对 body 进行 json 解析,得到结构化数据 - webhook 规则中,可以通过
parse-parameters-as-json
指定哪些参数的值需要按照 json 进行解析
触发规则
定义 webhook 规则时支持指定触发条件,只有当该请求满足该 webhook 规则所指定的触发条件时,才会执行该 webhook 规则中定义的命令。触发条件是通过 Rules
结构体来表示的,它定义如下:
1 | type Rules struct { |
可以看到,AndRule、OrRule 和 NotRule 只是用来提供规则的 与
、或
和 非
逻辑,真正的匹配动作则是通过 MatchRule 来实现的,如下是一个 MatchRule 的配置实例:
1 | { |
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 | { |
1 | X-Hub-Signature: sha1=the-first-signature,sha1=the-second-signature |
项目文档中提供了一个多层次匹配规则的配置实例,这里就不再展示。
webhook 请求的处理
了解完 webhook 规则的定义后,我们就可以分析程序是如何处理 webhook HTTP 请求的。从上面 启动 http 服务
的流程中我们可以看到,对 webhook HTTP 请求是通过 hookHandler
函数来处理的,它的核心实现可以分为以下几个步骤:
- 从请求中获取 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 | func makeRoutePattern(prefix *string) string { |
-
调用
matchLoadedHook
从loadedHooksFromFiles
查找对应 id 的 hook 规则 -
检查 HTTP 方法是否匹配:
- 如果 webhook 规则中指定了请求方法,则检查该 HTTP 请求的方法是否与指定值匹配
- 如果程序启动时,通过了
http-methods
选项指定了允许的 HTTP 方法,则检查该请求的方法是否在指定值中 - 否则默认默认所有请求方法都允许
- 返回指定的 HTTP 响应头,程序启动时可以通过
-header
选项指定要返回的 HTTP 响应头
1 | // 设置响应头 |
-
读取请求 body,解析请求头、URL 中的 query 参数
-
根据
Content-Type
,对 payload 进行解码,并得到结构化数据,支持以下 payload 类型的解码:
1 | * json |
-
webhook 规则中,可以通过
parse-parameters-as-json
配置项来指定那些参数的值需要按照 json 格式解析,解析完成后,就可以在 webhook 规则中的参数以及pass-arguments-to-command
中使用这些解析后的对象了 -
判断当前请求是否满足该 webhook 规则的触发条件
1 | ok, err = matchedHook.TriggerRule.Evaluate(req) |
- 只有当触发条件满足时,才真正执行 webhook 规则中所定义的命令,这个流程是在
handleHook
函数中执行的
1 | func handleHook(h *hook.Hook, r *hook.Request) (string, error) |
执行命令
handleHook
负责执行 webhook 规则中定义的命令,它的实现可以分为以下几个步骤:
- 查找命令对应的可执行文件
- 使用标准库
os/exec
包来执行命令,使用如下方法创建待执行的命令:
1 | cmd := exec.Command(cmdPath) |
- 调用
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 | [ |
- 通过如下方式,发送 webhook 请求:
1 | # 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"]}' |
小结
这篇文章重点分析了 adnanh/webhook 项目源码,学习其如何启动一个 HTTP 服务器以处理 webhook 请求,其支持灵活的配置参数(例如 webhook 规则中的触发条件、如何给命令传递参数),以满足不同场景下的业务需求。同时,该项目还使用了 systemd 的 socket activation
等机制,以提高服务的安全性。