一句话定位:HookHub 是一个可插件化扩展的 Webhook 中间件——从任意服务接收 Webhook,按规则解析、转换并转发到任意目标。
Receive webhooks from anywhere, route them anywhere.
相对 v0.2:以当前仓库实现为准,对齐 Viper、Driver()、模板 { Event, Vars }、先 ACK 再异步处理等已落地决策。v0.2 保留为历史草案,不再回改。
| 决策 | 取值 |
|---|---|
| 投递语义 | at-most-once:Verify/Parse 成功后立即 HTTP 200,再异步 Process;可丢,无持久化队列 |
| 配置加载 | spf13/viper + 读入后 os.ExpandEnv;插件子配置 mapstructure + yaml tag |
| 插件标识 | 接口方法 Driver() string;配置字段 driver;常量如 DriverVikunja / DriverNtfy |
| 路径语义 | POST /hook/{name},name = sources[].name(实例),不是 driver |
Event.Source |
仅表示实例名;由 handler 写入;Parse 不填 |
| 模板上下文 | RenderContext{ Event, Vars };Vars 来自 sources[].vars |
| 事件匹配 | source 精确 + type 按 . 分段通配(* = 一段);无 CEL |
| 包结构 | 扁平:cmd/hookhub、internal/{app,model,source/*,target/*};编排在 app |
| 密钥 | 示例与本地配置用 ${ENV};config.yaml gitignore |
各类 SaaS / 自建服务推送原始 JSON Webhook,结构不一,直接进 IM 可读性差;常需扇出到多个目标。
- 统一入口
/hook/{name}多来源接入 - 统一
Event→ 配置化路由 →Message→ Target - 编译期插件(driver 注册),单二进制
- 尽力投递、可观测(结构化日志)
- Web UI、多租户、集群
- CEL /
when - 持久化队列、死信、跨进程去重
- Query 参数鉴权(ntfy
?auth=)
POST /hook/{name}
│
▼
Source 实例(map[name])
Verify → Parse → Event
handler 写入 Event.Source = name
│
├─ ?debug=1 → 日志预览,200,结束
│
├─ 立即 200 ACK
│
└─ goroutine → Pipeline
MatchingRoutes
Template.Render({Event, Vars})
Target.Send
三层解耦:Source(来源)→ Event → Target(目标)。Router/模板在 app + model。
type Event struct {
ID string
Source string // 实例名
Type string // 事件种类,如 task.reminder.fired
Time time.Time
Subject string
Data map[string]any
Raw []byte
Headers http.Header
}type Message struct {
Title, Body, Format, Priority string
Extra map[string]string // Target 扩展,如 ntfy tags/click
}type Route struct {
Name string
Match Match // Source 实例名;Type 分段通配
Actions []Action // Target 实例名 + Template
}
type RenderContext struct {
Event *Event
Vars map[string]string
}通配:段数相等;* 匹配恰好一段。空 Type = 该匹配下全部类型。
type Source interface {
Driver() string
Verify(req *http.Request, body []byte) error
Parse(req *http.Request, body []byte) (*model.Event, error)
}
type Target interface {
Driver() string
Send(ctx context.Context, msg *model.Message, cfg map[string]any) error
}- 装配时校验
impl.Driver() == config.driver Send的cfg预留 Action 级覆盖;当前 ntfy 忽略
已实现驱动:vikunja、ntfy。
见仓库根目录 config.example.yaml。
要点:
| 字段 | 含义 |
|---|---|
sources[].name |
实例 → URL / Event.Source / match.source |
sources[].driver |
插件 |
sources[].vars |
模板 .Vars(如 base_url) |
sources[].config |
driver 配置(鉴权等) |
targets[].name |
actions[].target 引用 |
routes |
匹配 + 模板 + 扇出 |
密钥:${VIKUNJA_WEBHOOK_SECRET} 等,加载时 ExpandEnv。
- 事件字段:
{{ .Event.Subject }}、{{ .Event.Data.task.title }} - 实例变量:
{{ .Vars.base_url }} missingkey=error:缺字段则该 Action 失败并跳过
handleHook
1. 按 name 取 Source
2. 读 body(≤1MiB)
3. Verify → 401
4. Parse → 400;写入 Source/Time/Headers
5. debug=1 → 截断预览日志,200,返回
6. 200 + Flush
7. go Process(timeout=30s)
MatchingRoutes → Render → Send
单 Action 失败只打日志,继续扇出
| 项 | 行为 |
|---|---|
| 投递保证 | at-most-once |
| 去重 / 队列 / 重试 | 未实现(后续) |
| debug | 不转发;body 日志有长度上限 |
hookhub/
├── cmd/hookhub/main.go
├── config.example.yaml
├── README.md
├── docs/draft/v0.3.md # 本文
└── internal/
├── app/ # 配置、装配、HTTP、匹配表、pipeline
├── model/ # Event、Message、Route、Match、Render
├── source/ # 接口 + vikunja/
└── target/ # 接口 + ntfy/
- 验签:Vikunja HMAC / none(显式)
- Body 上限 1MiB
text/template无任意代码执行- ntfy Extra 头白名单 + 别名
- 日志:debug body 截断;避免默认打印密钥
未做:持久化队列、SSRF 白名单(通用 http target 尚未实现)、Prometheus。
| 项 | 选型 |
|---|---|
| 语言 | Go |
| 配置 | Viper + ExpandEnv + mapstructure |
| 模板 | text/template |
| 匹配 | 自研分段通配 |
| 日志 | log/slog |
| HTTP | net/http ServeMux(Go 1.22+ 方法路由) |
| 阶段 | 状态 |
|---|---|
| P0 骨架 HTTP + 健康检查 | 已完成 |
| P1 Vikunja + ntfy | 已完成 |
| P2 YAML 路由 + 模板 + Vars | 已完成 |
| P3 有界队列 / LRU 去重 / 重试 | 未做 |
| P4 更多驱动 / CEL / 指标 | 按需 |
- 内存有界队列 + 指数退避重试 + LRU 去重
- 模板预编译
- generic source / http target
- CEL
when、死信、Web UI、CloudEvents
文档状态:v0.3,对齐当前主干实现。运行说明见根目录 README.md。