feat(reliability): idempotency store, custom webhook replay protection, correlation ids

Add LICENSE (MIT), an IdempotencyStore abstraction with a KV implementation and provider-scoped delivery keys, optional replay protection for custom webhooks (X-WebHooker-Timestamp + X-WebHooker-Nonce), and per-request correlation ids in webhook responses and logs.
This commit is contained in:
RhenCloud 2026-08-15 14:39:41 +08:00
parent 92ba2203a5
commit 737dd7af98
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
15 changed files with 378 additions and 26 deletions

View file

@ -94,7 +94,7 @@ Verifies the payload against the **group's** secret (KV `tenant:{groupId}`, gene
### Custom Webhooks
Any JSON payload signed with `X-WebHooker-Signature: sha256=<hex>` (HMAC-SHA256 of the raw body, group or global secret) becomes a `custom` event. Route it with a route whose filter is `event: custom`. Payload schema: see [Configuration → Custom webhooks](../guide/ingress.md#custom-webhooks).
Any JSON payload signed with `X-WebHooker-Signature: sha256=<hex>` (HMAC-SHA256 of the raw body, group or global secret) becomes a `custom` event. Route it with a route whose filter is `event: custom`. Optional `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` headers enable replay protection (signature over `{timestamp}.{nonce}.{body}`, ±5 min window, nonce dedup). Payload schema: see [Configuration → Custom webhooks](../guide/ingress.md#custom-webhooks).
### GitHub App Installation Events

View file

@ -23,13 +23,32 @@ Every group can opt into its own webhook ingress with an independent secret (gen
- Supported for any provider: GitHub (`X-Hub-Signature-256`), Gitea (`X-Gitea-Signature`), custom (`X-WebHooker-Signature`)
- The secret is a 64-char hex string; regenerate from the console invalidates the old one immediately
- Delivery-id dedup keys are tenant-scoped (`delivery:{groupId}:{id}`)
- Delivery-id dedup keys are provider- and tenant-scoped (`delivery:{provider}:{groupId}:{id}`)
- When the group has no secret (or no longer exists) the endpoint returns `404`
## Custom Webhooks
Post arbitrary JSON to `POST /webhook/{groupId}` (or the global endpoint) with the body signed as `X-WebHooker-Signature: sha256=<hmac-sha256 hex of the raw body>` using the group's secret. The payload becomes a `custom` event that flows through the normal route pipeline — create a route with `event: custom` (there is a console template) and it dispatches to that route's targets, records `send_logs`, and appears in the group's webhook log channel.
### Replay Protection
Custom webhooks support optional replay protection via two extra headers alongside the signature:
- `X-WebHooker-Timestamp` — Unix seconds the request was sent
- `X-WebHooker-Nonce` — a unique, unpredictable value per request (e.g. a UUID)
When **both** headers are present, the signature is computed over `{timestamp}.{nonce}.{rawBody}` instead of the raw body, and the request is accepted only if:
1. The timestamp is within ±5 minutes of the server clock (rejects replays and clock-drift abusers)
2. The nonce has never been seen before (stored in KV for 10 minutes; a replayed nonce is rejected)
```bash
input="${timestamp}.${nonce}.${body}"
signature="sha256=$(printf '%s' "$input" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/.*= //')"
```
When the headers are omitted, WebHooker falls back to the legacy body-only signature, so existing senders keep working unchanged.
Payload schema:
```json

View file

@ -94,7 +94,7 @@ POST /webhook
### 自定义 Webhook
任意 JSON 载荷用 `X-WebHooker-Signature: sha256=<hex>`(对原始 body 的 HMAC-SHA256使用分组或全局 secret签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。载荷格式见[配置 → 自定义 Webhook](../guide/ingress.md#自定义-webhook)。
任意 JSON 载荷用 `X-WebHooker-Signature: sha256=<hex>`(对原始 body 的 HMAC-SHA256使用分组或全局 secret签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。可选的 `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` 请求头启用重放防护(对 `{timestamp}.{nonce}.{body}` 签名、±5 分钟窗口、nonce 去重)。载荷格式见[配置 → 自定义 Webhook](../guide/ingress.md#自定义-webhook)。
### GitHub App 安装事件

View file

@ -23,13 +23,32 @@ Gitea 载荷会被归一化为与 GitHub 事件相同的内部结构,因此路
- 支持任意提供方GitHub`X-Hub-Signature-256`、Gitea`X-Gitea-Signature`)、自定义(`X-WebHooker-Signature`
- 密钥为 64 位 hex 字符串;在控制台重新生成会立即失效旧密钥
- 投递 id 去重键按租户隔离(`delivery:{groupId}:{id}`
- 投递 id 去重键按提供方与租户隔离(`delivery:{provider}:{groupId}:{id}`
- 分组没有密钥(或已不存在)时端点返回 `404`
## 自定义 Webhook
`POST /webhook/{groupId}`或全局端点POST 任意 JSON并使用分组密钥将原始 body 的 HMAC-SHA256 以 `X-WebHooker-Signature: sha256=<hex>` 签名。载荷会变成 `custom` 事件,走正常的路由管线——创建一条 `event: custom` 的路由(控制台有模板),即可分发到该路由的目标、记录 `send_logs`,并出现在分组的 webhook 日志频道中。
### 重放防护
自定义 webhook 支持可选的防重放机制,在签名之外再附带两个请求头:
- `X-WebHooker-Timestamp` — 请求发送时的 Unix 秒数
- `X-WebHooker-Nonce` — 每次请求唯一且不可预测的值(如 UUID
当**同时**提供这两个请求头时,签名改为对 `{timestamp}.{nonce}.{原始body}` 计算(而非仅原始 body且仅当以下条件满足时才被接受
1. 时间戳与服务器时钟相差不超过 ±5 分钟(拒绝重放与时钟漂移滥用)
2. nonce 从未被使用过(存入 KV 保留 10 分钟;重放的 nonce 会被拒绝)
```bash
input="${timestamp}.${nonce}.${body}"
signature="sha256=$(printf '%s' "$input" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/.*= //')"
```
省略这些请求头时WebHooker 回退到旧版的仅对 body 签名,现有发送方无需改动即可继续工作。
载荷模式:
```json