From b600f02027cb4d58343dc92300d0eb0ec3a1058e Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Thu, 13 Aug 2026 09:24:50 +0800 Subject: [PATCH] feat: per-group webhook ingress, custom webhooks, GitHub App tenant isolation Add POST /webhook/{groupId} with per-group secrets (KV tenant:{groupId}), a custom provider (X-WebHooker-Signature HMAC, arbitrary JSON -> custom events through the route pipeline), and GitHub App installation isolation (Group.installationId) with automatic provisioning on installation.created (inst-{id} groups or binding matching owners groups). Includes WebhookPanel admin UI, custom route template, docs and 157 passing tests. --- AGENTS.md | 31 ++- README.md | 4 +- README.zh.md | 6 +- admin/assets/css/main.css | 56 ++++ admin/components/ConsolePage.vue | 7 + admin/components/GroupEditor.vue | 25 ++ admin/components/WebhookPanel.vue | 154 +++++++++++ admin/composables/useI18n.ts | 51 ++++ admin/composables/useWebhook.ts | 50 ++++ admin/types.ts | 6 + config.example.yaml | 1 + docs/api/overview.md | 61 +++-- docs/guide/configuration.md | 122 +++++++-- docs/zh/api/overview.md | 61 +++-- docs/zh/guide/configuration.md | 122 +++++++-- src/__tests__/admin.test.ts | 113 +++++++- src/__tests__/providers.test.ts | 75 ++++- src/__tests__/webhook-tenant.test.ts | 391 +++++++++++++++++++++++++++ src/config.ts | 5 + src/core/dispatch.ts | 8 +- src/formatters/custom.ts | 89 ++++++ src/formatters/index.ts | 3 + src/lib/locales/en.ts | 3 + src/lib/locales/zh.ts | 3 + src/providers/custom/index.ts | 44 +++ src/providers/github/parse.ts | 8 +- src/providers/index.ts | 6 +- src/providers/types.ts | 2 +- src/server.ts | 55 +--- src/types.ts | 14 +- src/web/admin-routes.ts | 80 ++++++ src/web/groups.ts | 56 ++++ src/web/tenants.ts | 38 +++ src/webhook.ts | 144 ++++++++++ 34 files changed, 1711 insertions(+), 183 deletions(-) create mode 100644 admin/components/WebhookPanel.vue create mode 100644 admin/composables/useWebhook.ts create mode 100644 src/__tests__/webhook-tenant.test.ts create mode 100644 src/formatters/custom.ts create mode 100644 src/providers/custom/index.ts create mode 100644 src/web/tenants.ts create mode 100644 src/webhook.ts diff --git a/AGENTS.md b/AGENTS.md index 50b0678..60ed52f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,9 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord - Discord interactions: HTTPS Interactions Endpoint (`POST /discord/interactions`, Ed25519-signed) — no Discord Gateway / Durable Object; bot stays offline, messages always sent via REST - Storage: Cloudflare KV (tokens, OAuth state, route config `config:routes`, group config `config:groups`, admin sessions, delivery dedup, message-update tracking `msg:*`, i18n overrides `i18n:*`) + D1 (`send_logs`, `discord_links`, `telegram_links`) - Signature verification: Web Crypto API (HMAC-SHA256 for GitHub/Gitea, Ed25519 for Discord, timing-safe secret-token compare for Telegram) -- Webhook providers: pluggable forge adapters under `src/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; GitLab etc. can be added later +- Webhook providers: pluggable forge adapters under `src/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; a `custom` provider accepts arbitrary signed JSON posts (`X-WebHooker-Signature`) as `custom` events; GitLab etc. can be added later +- Per-group webhook ingress: optional `POST /webhook/{groupId}` with a per-group secret in KV (`tenant:{groupId}`) — Gitea/classic-GitHub/custom webhooks are verified against the group's secret instead of the operator's global ones; only that group's routes fire. The legacy `POST /webhook` (global secrets, all routes) stays untouched +- GitHub App tenant isolation: `Group.installationId` binds a group to one GitHub App installation; events whose `payload.installation.id` differs are rejected at dispatch (hard isolation on top of the optional `owners` list). `installation.created` events auto-provision: a dedicated `inst-{installationId}` group is created, or existing groups whose `owners` match the installing account get bound automatically - GitHub OAuth: octokit (token is stored hashed for reverse lookup) - Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist - Access control: every group has role-based members (`owner` / `admin` / `viewer`); super admins bypass; legacy `adminIds` are read as owners (backward compatible); owners manage members + invites; `owners` field stays super-only @@ -29,7 +31,8 @@ src/ ├── index.ts # CF Workers entry (fetch + scheduled), scheduled = Discord command sync + Telegram webhook sync ├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage ├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env -├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + / +├── server.ts # Hono app: /health, /webhook, /webhook/:groupId, /discord/interactions, /telegram/webhook, mounts /auth, /admin + / +├── webhook.ts # processWebhook/handleWebhook: tenant lookup, provider detect/verify/parse, dedup, scoped dispatch ├── core/ │ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log) ├── events/ # Provider-agnostic route matching @@ -37,20 +40,22 @@ src/ ├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events) │ ├── types.ts # Provider interface (matches/verify/parse) │ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers -│ ├── index.ts # detectProvider() registry (github, gitea) -│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256 ("sha256=" prefix) +│ ├── index.ts # detectProvider() registry (gitea, github, custom) +│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256 ("sha256=" prefix); extracts installation.id │ │ ├── verify.ts # HMAC signature verify │ │ └── parse.ts # parseEvent (headers + body → WebhookEvent) -│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature (plain hex HMAC) -│ ├── verify.ts # HMAC signature verify (no prefix) -│ └── parse.ts # parse + normalize Gitea payloads to GitHub shape +│ ├── gitea/ # X-Gitea-Event + X-Gitea-Signature (plain hex HMAC) +│ │ ├── verify.ts # HMAC signature verify (no prefix) +│ │ └── parse.ts # parse + normalize Gitea payloads to GitHub shape +│ └── custom/ # X-WebHooker-Signature (sha256= HMAC) + arbitrary JSON → `custom` events +│ └── index.ts # matches/verify/parse for non-forge senders ├── formatters/ # Platform-neutral message formatters (was formatter.ts) -│ ├── index.ts # formatEvent: 28-event switch → NeutralMessage + re-exports +│ ├── index.ts # formatEvent: 29-event switch → NeutralMessage + re-exports │ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI │ ├── helpers.ts # emojiPrefix, T, buildMessage │ └── *.ts # push, pull-request, issues, comments, workflow, release, create, │ # repo, check, review, commit-comment, deployment, member, label, -│ # milestone, discussion, repository, security, generic, ping +│ # milestone, discussion, repository, security, generic, ping, custom ├── drivers/ # Platform drivers (pluggable push targets) │ ├── types.ts # PlatformDriver interface + SendResult (send + edit) │ ├── index.ts # getDriver() registry (discord default + telegram) @@ -77,6 +82,7 @@ src/ │ ├── invites.ts # Invite CRUD (KV invite:{token}, 7d TTL) + acceptInvite (join group as admin/viewer) │ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers │ ├── groups.ts # Group CRUD (config:groups), member roles (normalizeGroupMembers/memberRole), resolveScope + role helpers (roleAt/canEditRoutes/canEditGroup) +│ ├── tenants.ts # Per-group webhook secret CRUD (KV tenant:{groupId}, 32-byte random hex) │ ├── home-routes.ts # landing page (zh/en) │ ├── legal-routes.ts # /terms + /privacy pages (zh/en) │ └── richheader-routes.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card @@ -94,19 +100,22 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te - Verify GitHub webhook signatures (Web Crypto HMAC-SHA256, `X-Hub-Signature-256`) - Verify Gitea webhook signatures (Web Crypto HMAC-SHA256, plain hex `X-Gitea-Signature`) +- Verify custom webhook signatures (Web Crypto HMAC-SHA256, GitHub-style `sha256=` via `X-WebHooker-Signature`) - Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url` → `compare`, `pull_request_comment` → `pull_request_review_comment`, ...) - Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body) - Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured) - Filter events by: event type, repo name, actor, action, branch, keyword (regex supported) -- Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true` +- Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true` +- Auto-provision GitHub App installs on `installation.created`: create `inst-{installationId}` group or bind existing groups whose `owners` match the installing account - Enforce role-based access on every admin API: super admins bypass, `owner` manages the group (routes/members/invites/settings), `admin` edits routes, `viewer` is read-only; legacy `adminIds` groups resolve to `owner` members - Issue single-use 7-day group invite links (`invite:{token}`); accepting joins as admin/viewer (never owner); `ALLOW_SELF_SIGNUP=1` creates a deterministic personal group (`u-{userId}`) on first login - Record every admin operation (login/logout, group/route/member/invite changes) to D1 `audit_logs`; the scheduled trigger prunes entries past `AUDIT_RETENTION_DAYS` - Mention Discord roles on route trigger: route-level `discordRoleIds` are rendered as `<@&id>` into the Discord message `content` (Telegram targets ignore the field) -- Format 28 event types as platform-neutral messages (Discord embeds + Telegram HTML) +- Format 29 event types as platform-neutral messages (Discord embeds + Telegram HTML) - Route messages to Discord channels/threads and Telegram chats/topics via REST - Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking) - Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code) +- Serve a per-group webhook ingress (`POST /webhook/{groupId}`, per-group secret in KV `tenant:{groupId}`) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are tenant-scoped - Send a per-event summary (event, repo, delivery id, per route×target ✅/❌ outcome) to the group's `logTarget` when configured - Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals - Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing diff --git a/README.md b/README.md index 46deb5b..13a4fff 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,10 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event ## Features -- **28 event formatters** — push, pull_request, issues, issue_comment, workflow_run, workflow_job, status, deployment, deployment_status, check_run, check_suite, ping, release, create, delete, star, fork, pull_request_review, pull_request_review_comment, commit_comment, member, label, milestone, discussion, discussion_comment, repository, code_scanning_alert, dependabot_alert (+ generic fallback) +- **28 event formatters** — push, pull_request, issues, issue_comment, workflow_run, workflow_job, status, deployment, deployment_status, check_run, check_suite, ping, release, create, delete, star, fork, pull_request_review, pull_request_review_comment, commit_comment, member, label, milestone, discussion, discussion_comment, repository, code_scanning_alert, dependabot_alert (+ generic fallback, + `custom` webhooks) - **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers +- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`) +- **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it - HMAC-SHA256 signature verification (Web Crypto API) - Filter by event type, repo, actor, action, branch, keyword (supports regex) - Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML diff --git a/README.zh.md b/README.zh.md index 750bc41..c5d8c24 100644 --- a/README.zh.md +++ b/README.zh.md @@ -4,8 +4,10 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W ## 功能特性 -- **28 种事件格式化** — push、pull_request、issues、issue_comment、workflow_run、workflow_job、status、deployment、deployment_status、check_run、check_suite、ping、release、create、delete、star、fork、pull_request_review、pull_request_review_comment、commit_comment、member、label、milestone、discussion、discussion_comment、repository、code_scanning_alert、dependabot_alert(+ 通用回退) -- **多提供方 webhook** — GitHub(`X-Hub-Signature-256`)与 Gitea(`X-Gitea-Signature`)共用 `/webhook` 端点,按请求头自动识别来源 +- **28 种事件格式化** — push、pull_request、issues、issue_comment、workflow_run、workflow_job、status、deployment、deployment_status、check_run、check_suite、ping、release、create、delete、star、fork、pull_request_review、pull_request_review_comment、commit_comment、member、label、milestone、discussion、discussion_comment、repository、code_scanning_alert、dependabot_alert(+ 通用回退,+ `custom` 自定义 webhook) +- **多平台 webhook** — GitHub(`X-Hub-Signature-256`)与 Gitea(`X-Gitea-Signature`)共用 `/webhook` 端点,自动识别来源平台 +- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、classic GitHub webhook,以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON) +- **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID,只有该组织/用户的事件才能进入该分组 - HMAC-SHA256 签名验证(Web Crypto API) - 按事件类型、仓库、操作人、操作、分支、关键词(支持正则)过滤 - 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML diff --git a/admin/assets/css/main.css b/admin/assets/css/main.css index bd35559..719632c 100644 --- a/admin/assets/css/main.css +++ b/admin/assets/css/main.css @@ -1282,6 +1282,62 @@ main { color: var(--faint); } +.webhook-panel .wh-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.webhook-panel .wh-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: var(--muted); + text-transform: uppercase; + min-width: 52px; +} + +.webhook-panel .wh-value { + flex: 1; + min-width: 180px; + padding: 6px 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 12.5px; + word-break: break-all; +} + +.webhook-panel .wh-actions { + display: flex; + gap: 8px; + margin-top: 6px; +} + +.webhook-panel .wh-usage { + margin-top: 14px; +} + +.webhook-panel .wh-usage summary { + font-size: 12px; + cursor: pointer; + color: var(--muted); +} + +.webhook-panel .wh-code { + margin: 10px 0 0; + padding: 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 11.5px; + line-height: 1.6; + overflow-x: auto; + color: var(--muted); +} + @media (max-width: 720px) { .filter-row { grid-template-columns: 100px 1fr auto; diff --git a/admin/components/ConsolePage.vue b/admin/components/ConsolePage.vue index d5a4b56..be57bfd 100644 --- a/admin/components/ConsolePage.vue +++ b/admin/components/ConsolePage.vue @@ -84,6 +84,12 @@ :saving="savingGroup" @save="onSaveGroupFromPanel" /> + + @@ -227,6 +233,7 @@ diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index bc9cdb3..65e82c4 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -50,6 +50,25 @@ const en: Dict = { "members.errLastOwner": "Cannot remove the last owner.", "members.inviteOk": "Invite accepted — welcome!", "members.inviteBad": "This invite is invalid or expired.", + "webhook.title": "Webhook endpoint", + "webhook.note": "(per-group URL + secret)", + "webhook.empty": "Not configured. Generate a secret to enable the group's own webhook endpoint.", + "webhook.url": "URL", + "webhook.secret": "Secret", + "webhook.noSecret": "— no secret configured —", + "webhook.secretHidden": + "The secret is only shown once after generating/regenerating. Regenerate to reveal a new one.", + "webhook.copy": "Copy", + "webhook.copied": "Copied!", + "webhook.generate": "Generate secret", + "webhook.regenerate": "Regenerate", + "webhook.disable": "Disable", + "webhook.usageTitle": "How to use", + "webhook.usageGitHub": + "GitHub: repo/org Settings → Webhooks → Add webhook → payload URL = the URL above, Content type = application/json, Secret = the secret above.", + "webhook.usageGitea": "Gitea: repo Settings → Webhooks → Add Webhook → same URL + secret.", + "webhook.usageCustom": + "Custom: POST any JSON signed with X-WebHooker-Signature (sha256 HMAC). Create a route with event = custom to receive it:", "groups.empty": "No groups yet. Groups scope routes by org/user and delegate access.", "groups.createFirst": "Create your first group", "groups.admins": "ADMINS", @@ -95,6 +114,7 @@ const en: Dict = { "templates.createDelete": "Create / Delete", "templates.member": "Member", "templates.commitComment": "Commit Comment", + "templates.customWebhook": "Custom Webhook", "routeEditor.name": "Name", "routeEditor.namePlaceholder": "My Route", "routeEditor.id": "ID", @@ -167,6 +187,12 @@ const en: Dict = { "groupEditor.providersNote": "(leave both unchecked = all)", "groupEditor.providersHint": "Only webhook events from the checked forges (GitHub / Gitea) enter this group's routes.", + "groupEditor.installationId": "GitHub App installation ID", + "groupEditor.installationIdNote": "(optional)", + "groupEditor.installationIdPlaceholder": "e.g. 12345678", + "groupEditor.installationIdHint": + "Bind this group to one GitHub App installation (the org/user that installed the app). Only events from that installation enter this group's routes; leave empty to allow any installation (still filtered by Owner scope).", + "groupEditor.errInstallationId": "Installation ID must be a positive integer", "groupEditor.emoji": "Show emojis in messages", "groupEditor.logTarget": "Webhook log channel", "groupEditor.logTargetNote": "(optional)", @@ -276,6 +302,24 @@ const zh: Dict = { "members.errLastOwner": "不能移除最后一位 owner。", "members.inviteOk": "邀请已接受 —— 欢迎!", "members.inviteBad": "该邀请无效或已过期。", + "webhook.title": "Webhook 入口", + "webhook.note": "(分组独立的 URL + secret)", + "webhook.empty": "未配置。生成 secret 即可启用该分组的独立 webhook 入口。", + "webhook.url": "URL", + "webhook.secret": "Secret", + "webhook.noSecret": "—— 未配置 secret ——", + "webhook.secretHidden": "secret 仅在生成/重新生成时显示一次。如需查看请重新生成。", + "webhook.copy": "复制", + "webhook.copied": "已复制!", + "webhook.generate": "生成 secret", + "webhook.regenerate": "重新生成", + "webhook.disable": "停用", + "webhook.usageTitle": "使用方法", + "webhook.usageGitHub": + "GitHub:仓库/组织 Settings → Webhooks → Add webhook → Payload URL 填上面的 URL,Content type 选 application/json,Secret 填上面的 secret。", + "webhook.usageGitea": "Gitea:仓库 Settings → Webhooks → Add Webhook,填写同样的 URL 和 secret。", + "webhook.usageCustom": + "自定义:用 X-WebHooker-Signature(sha256 HMAC)签名任意 JSON 后 POST。创建一条 event = custom 的路由即可接收:", "groups.admins": "管理员", "groups.owners": "来源", "groups.members": "成员", @@ -319,6 +363,7 @@ const zh: Dict = { "templates.createDelete": "创建 / 删除", "templates.member": "协作者", "templates.commitComment": "提交评论", + "templates.customWebhook": "自定义 Webhook", "routeEditor.name": "名称", "routeEditor.namePlaceholder": "我的路由", "routeEditor.id": "ID", @@ -390,6 +435,12 @@ const zh: Dict = { "groupEditor.providersNote": "(都不勾选 = 全部来源)", "groupEditor.providersHint": "只有来自所勾选 forge(GitHub / Gitea)的 webhook 事件才会进入本分组的路由。", + "groupEditor.installationId": "GitHub App 安装 ID", + "groupEditor.installationIdNote": "(可选)", + "groupEditor.installationIdPlaceholder": "如 12345678", + "groupEditor.installationIdHint": + "将本分组绑定到一个 GitHub App 安装(安装了 App 的组织/用户)。只有来自该安装的事件才会进入本分组的路由;留空表示接受任意安装(仍受来源限定过滤)。", + "groupEditor.errInstallationId": "安装 ID 必须为正整数", "groupEditor.emoji": "消息中显示表情符号", "groupEditor.logTarget": "Webhook 日志频道", "groupEditor.logTargetNote": "(可选)", diff --git a/admin/composables/useWebhook.ts b/admin/composables/useWebhook.ts new file mode 100644 index 0000000..eae9628 --- /dev/null +++ b/admin/composables/useWebhook.ts @@ -0,0 +1,50 @@ +export interface GroupWebhookInfo { + url: string; + hasSecret: boolean; + secret?: string; +} + +export function useWebhookApi() { + const loading = ref(false); + const error = ref(""); + + async function request(path: string, init?: RequestInit): Promise { + loading.value = true; + error.value = ""; + try { + const res = await fetch(path, { + headers: { accept: "application/json" }, + credentials: "same-origin", + ...init, + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `HTTP ${res.status}`); + } + return (await res.json()) as T; + } catch (err) { + error.value = err instanceof Error ? err.message : String(err); + throw err; + } finally { + loading.value = false; + } + } + + function info(groupId: string): Promise { + return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook`); + } + + function regenerate(groupId: string): Promise { + return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook/regenerate`, { + method: "POST", + }); + } + + function disable(groupId: string): Promise<{ ok: boolean }> { + return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook`, { + method: "DELETE", + }); + } + + return { loading, error, info, regenerate, disable }; +} diff --git a/admin/types.ts b/admin/types.ts index 51ff71e..5ea4e40 100644 --- a/admin/types.ts +++ b/admin/types.ts @@ -38,6 +38,7 @@ export interface Group { members?: GroupMember[]; owners?: string[]; providers?: ("github" | "gitea" | "gitlab")[]; + installationId?: number; emoji?: boolean; lang?: string; logTarget?: RouteTarget; @@ -146,6 +147,11 @@ export const ROUTE_TEMPLATES: RouteTemplate[] = [ nameKey: "templates.commitComment", filters: [{ type: "event", match: "commit_comment" }], }, + { + id: "custom-webhook", + nameKey: "templates.customWebhook", + filters: [{ type: "event", match: "custom" }], + }, ]; export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const; diff --git a/config.example.yaml b/config.example.yaml index a0c26e5..0256bd6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,6 +12,7 @@ groups: role: owner # owners: ["myorg"] # restrict events to this org/user (optional) # providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all) + # installationId: 12345678 # GitHub App installation binding (auto-created on installation.created) # emoji: true # include emoji in messages (default true) # lang: "en" # message language for this group's routes (en/zh, default en) # Webhook log channel: a Discord channel/thread or Telegram chat/topic that diff --git a/docs/api/overview.md b/docs/api/overview.md index ca11b98..665a19c 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -10,30 +10,31 @@ https://your-worker.workers.dev ## Endpoints -| Method | Path | Auth | Description | -| -------- | ------------------------------ | ----------------- | --------------------------------------------------------- | -| `GET` | `/health` | None | Health check | -| `POST` | `/webhook` | HMAC signature | GitHub / Gitea webhook ingestion (provider auto-detected) | -| `POST` | `/discord/interactions` | Ed25519 signature | Discord interactions (slash commands, buttons, modals) | -| `POST` | `/telegram/webhook` | Secret token | Telegram updates (bot `/gh` commands) | -| `GET` | `/api/richheader` | None | Open Graph page for the Telegram avatar link-preview card | -| `GET` | `/auth/github` | None | Start GitHub OAuth flow | -| `GET` | `/auth/github/callback` | None | OAuth callback | -| `DELETE` | `/auth/token/:userId` | None | Revoke user token | -| `POST` | `/api/comment` | Bearer token | Create issue comment | -| `POST` | `/api/merge` | Bearer token | Merge pull request | -| `POST` | `/api/close` | Bearer token | Close pull request | -| `POST` | `/api/react` | Bearer token | Add reaction to issue | -| `GET` | `/admin` | Admin session | Config console UI | -| `GET` | `/admin/api/routes` | Admin session | List routes | -| `PUT` | `/admin/api/routes` | Admin session | Replace routes | -| `GET` | `/admin/api/groups` | Admin session | List groups (scoped) | -| `PUT` | `/admin/api/groups` | Admin session | Replace groups (super) | -| `GET` | `/admin/api/groups/:id/routes` | Admin session | List a group's routes | -| `PUT` | `/admin/api/groups/:id/routes` | Admin session | Replace a group's routes | -| `GET` | `/admin/api/me` | Admin session | Current session info | -| `GET` | `/admin/api/logs` | Admin session | Send logs (scoped) | -| `GET` | `/admin/api/logs/:id` | Admin session | Single send-log entry (scoped) | +| Method | Path | Auth | Description | +| -------- | ------------------------------ | ----------------- | ------------------------------------------------------------------ | +| `GET` | `/health` | None | Health check | +| `POST` | `/webhook` | HMAC signature | GitHub / Gitea / custom webhook ingestion (provider auto-detected) | +| `POST` | `/webhook/:groupId` | Per-group secret | Per-group webhook ingress (only that group's routes fire) | +| `POST` | `/discord/interactions` | Ed25519 signature | Discord interactions (slash commands, buttons, modals) | +| `POST` | `/telegram/webhook` | Secret token | Telegram updates (bot `/gh` commands) | +| `GET` | `/api/richheader` | None | Open Graph page for the Telegram avatar link-preview card | +| `GET` | `/auth/github` | None | Start GitHub OAuth flow | +| `GET` | `/auth/github/callback` | None | OAuth callback | +| `DELETE` | `/auth/token/:userId` | None | Revoke user token | +| `POST` | `/api/comment` | Bearer token | Create issue comment | +| `POST` | `/api/merge` | Bearer token | Merge pull request | +| `POST` | `/api/close` | Bearer token | Close pull request | +| `POST` | `/api/react` | Bearer token | Add reaction to issue | +| `GET` | `/admin` | Admin session | Config console UI | +| `GET` | `/admin/api/routes` | Admin session | List routes | +| `PUT` | `/admin/api/routes` | Admin session | Replace routes | +| `GET` | `/admin/api/groups` | Admin session | List groups (scoped) | +| `PUT` | `/admin/api/groups` | Admin session | Replace groups (super) | +| `GET` | `/admin/api/groups/:id/routes` | Admin session | List a group's routes | +| `PUT` | `/admin/api/groups/:id/routes` | Admin session | Replace a group's routes | +| `GET` | `/admin/api/me` | Admin session | Current session info | +| `GET` | `/admin/api/logs` | Admin session | Send logs (scoped) | +| `GET` | `/admin/api/logs/:id` | Admin session | Single send-log entry (scoped) | ## Admin Console @@ -93,6 +94,18 @@ When `X-GitHub-Delivery` is present and the same delivery was already processed | `400` | `{"error": "Invalid event"}` | Missing event header or malformed body | | `413` | `{"error": "Request too large"}` | Body exceeds 1MB limit | +### Per-Group Webhook (`POST /webhook/:groupId`) + +Verifies the payload against the **group's** secret (KV `tenant:{groupId}`, generated from the console — Webhook endpoint panel) instead of the global secrets, and dispatches only into that group's routes. Works for GitHub (`X-Hub-Signature-256`), Gitea (`X-Gitea-Signature`) and custom (`X-WebHooker-Signature`) senders. Returns `404` when the group does not exist or has no secret configured. + +### Custom Webhooks + +Any JSON payload signed with `X-WebHooker-Signature: sha256=` (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/configuration.md#custom-webhooks). + +### GitHub App Installation Events + +`installation` webhook events (`created`, ...) are auto-provisioned: a group named after the installing account (`inst-{installationId}`, bound via `installationId`) is created automatically, or existing groups whose `owners` match the installing account are bound to the installation. See [Configuration → GitHub App tenant isolation](../guide/configuration.md#github-app-tenant-isolation). + ## Error Format All error responses follow the format: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index aa75760..13c364d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -58,28 +58,88 @@ WebHooker ships with a built-in config console at `/admin` for managing routes i The console is served as an SPA at `/admin`; its tabs are deep-linkable via the URL path (`/admin/groups`, `/admin/logs`, `/admin/audit`). URLs outside `/admin` that do not match an endpoint below return a plain `404` instead of the console. -| Endpoint | Description | -| ------------------------------------ | -------------------------------------------- | -| `GET /admin` | Config console UI | -| `GET /admin/login` | Start GitHub OAuth sign-in | -| `GET /admin/logout` | Destroy session | -| `GET /admin/invite?token=…` | Accept a group invite (browser page) | -| `GET /admin/api/me` | Current session, scope, groups, and roles | -| `GET /admin/api/routes` | List routes (scoped to access) | -| `PUT /admin/api/routes` | Replace routes (owner/admin per group) | -| `GET /admin/api/groups` | List groups + the signed-in user's role each | -| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) | -| `GET /admin/api/groups/:id/routes` | List a group's routes | -| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) | -| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | -| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | -| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) | -| `GET /admin/api/groups/:id/invites` | List pending invites (owner) | -| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) | -| `GET /admin/api/audit` | Audit log (scoped to accessible groups) | +| Endpoint | Description | +| ----------------------------------------------- | ---------------------------------------------------- | +| `GET /admin` | Config console UI | +| `GET /admin/login` | Start GitHub OAuth sign-in | +| `GET /admin/logout` | Destroy session | +| `GET /admin/invite?token=…` | Accept a group invite (browser page) | +| `GET /admin/api/me` | Current session, scope, groups, and roles | +| `GET /admin/api/routes` | List routes (scoped to access) | +| `PUT /admin/api/routes` | Replace routes (owner/admin per group) | +| `GET /admin/api/groups` | List groups + the signed-in user's role each | +| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) | +| `GET /admin/api/groups/:id/routes` | List a group's routes | +| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) | +| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | +| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | +| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) | +| `GET /admin/api/groups/:id/invites` | List pending invites (owner) | +| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) | +| `GET /admin/api/audit` | Audit log (scoped to accessible groups) | +| `GET /admin/api/groups/:id/webhook` | Group webhook endpoint info (owner) | +| `POST /admin/api/groups/:id/webhook/regenerate` | Generate/regenerate the group webhook secret (owner) | +| `DELETE /admin/api/groups/:id/webhook` | Disable the group webhook ingress (owner) | The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run. +## Webhook Endpoints + +### Global endpoint (`POST /webhook`) + +The legacy global endpoint verifies payloads against the operator's global secrets (`GITHUB_WEBHOOK_SECRET`, `GITEA_WEBHOOK_SECRET`) and dispatches into **all** routes. GitHub App installations deliver here; use `installationId` on groups to keep tenants isolated. + +### Per-group endpoint (`POST /webhook/{groupId}`) + +Every group can opt into its own webhook ingress with an independent secret (generated from the group page — Webhook endpoint panel, owner role). Payloads are verified against the **group's** secret instead of the global ones, and only that group's routes are eligible. This is how SaaS users configure Gitea, classic GitHub, or custom webhooks without sharing (or knowing) the operator's secrets. + +- 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}`) +- 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=` 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. + +Payload schema: + +```json +{ + "title": "Deploy failed", + "description": "Prod rollout failed at 12:03 UTC", + "color": "red", + "url": "https://ci.example.com/runs/42", + "repo": "acme/widget", + "author": { + "name": "alice", + "iconUrl": "https://…/alice.png", + "url": "https://github.com/alice" + }, + "fields": [{ "name": "Env", "value": "prod", "inline": true }], + "footer": "my-monitor", + "deliveryId": "alert-123" +} +``` + +| Field | Type | Description | +| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `title` | string | Message title (falls back to "Custom message") | +| `description` | string | Optional message body | +| `color` | string | Optional embed color: a word (`red`, `green`, `yellow`, `blue`, `purple`, `orange`, `cyan`, `gray`) or `#rrggbb` | +| `url` | string | Optional link for the title | +| `repo` | string | Optional `owner/repo`; prefixes the title and is used as the footer | +| `author` | object | Optional `{ name, iconUrl, url }` | +| `fields` | object[] | Optional embed fields `{ name, value, inline }` | +| `footer` | string | Optional footer override | +| `deliveryId` | string | Optional id for sender-side dedup (retries) | + +### GitHub App tenant isolation + +When the GitHub App is installed, its events arrive at the global endpoint for **every** installation. To keep tenants apart, bind each group to the installation id that should feed it: `"installationId": 12345678`. The id is visible in the App's installation webhook payload (`installation.id`) or on the GitHub App installation page URL. Events from any other installation are rejected for that group even if its `owners` list is empty. Groups without `installationId` keep the legacy behavior (`owners` filtering). + +Binding is **auto-configured**: the `installation.created` webhook event creates a dedicated `inst-{installationId}` group (name = the installing account) bound to the installation, or automatically binds every existing group whose `owners` match the installing account. No manual id entry is needed — just install the app, then add routes/members to the auto-created group in the console. + ## Routes Routes define which events get forwarded to which channel (Discord or Telegram). They are stored in Cloudflare KV under the key `config:routes` as a JSON array. @@ -182,21 +242,23 @@ Routes belong to groups. Groups scope admin access and can restrict which events ], "owners": ["myorg"], "providers": ["github", "gitea"], + "installationId": 12345678, "logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" } } ``` -| Field | Type | Required | Description | -| ----------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` | -| `name` | string | Yes | Human-readable group name | -| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` | -| `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present | -| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all | -| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all | -| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) | -| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:`) — defaults to `en` | -| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch | +| Field | Type | Required | Description | +| ---------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` | +| `name` | string | Yes | Human-readable group name | +| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` | +| `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present | +| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all | +| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all | +| `installationId` | number | No | GitHub App installation id bound to this group; only that installation's events are accepted (empty = all) | +| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) | +| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:`) — defaults to `en` | +| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch | ### Roles diff --git a/docs/zh/api/overview.md b/docs/zh/api/overview.md index e597b41..41c5e0d 100644 --- a/docs/zh/api/overview.md +++ b/docs/zh/api/overview.md @@ -10,30 +10,31 @@ https://your-worker.workers.dev ## 端点 -| 方法 | 路径 | 鉴权 | 说明 | -| -------- | ------------------------------ | ------------ | ------------------------------------------------ | -| `GET` | `/health` | 无 | 健康检查 | -| `POST` | `/webhook` | HMAC 签名 | GitHub / Gitea webhook 接入(自动识别来源) | -| `POST` | `/discord/interactions` | Ed25519 签名 | Discord 交互(斜杠命令、按钮、modal) | -| `POST` | `/telegram/webhook` | Secret token | Telegram 更新(bot `/gh` 命令) | -| `GET` | `/api/richheader` | 无 | 用于 Telegram 头像链接预览卡片的 Open Graph 页面 | -| `GET` | `/auth/github` | 无 | 启动 GitHub OAuth 流程 | -| `GET` | `/auth/github/callback` | 无 | OAuth 回调 | -| `DELETE` | `/auth/token/:userId` | 无 | 撤销用户 Token | -| `POST` | `/api/comment` | Bearer Token | 创建议题评论 | -| `POST` | `/api/merge` | Bearer Token | 合并拉取请求 | -| `POST` | `/api/close` | Bearer Token | 关闭拉取请求 | -| `POST` | `/api/react` | Bearer Token | 添加议题反应 | -| `GET` | `/admin` | 管理员会话 | 配置控制台页面 | -| `GET` | `/admin/api/routes` | 管理员会话 | 列出路由 | -| `PUT` | `/admin/api/routes` | 管理员会话 | 替换路由 | -| `GET` | `/admin/api/groups` | 管理员会话 | 列出分组(按权限过滤) | -| `PUT` | `/admin/api/groups` | 管理员会话 | 替换分组(仅超级管理员) | -| `GET` | `/admin/api/groups/:id/routes` | 管理员会话 | 列出某分组的路由 | -| `PUT` | `/admin/api/groups/:id/routes` | 管理员会话 | 替换某分组的路由 | -| `GET` | `/admin/api/me` | 管理员会话 | 当前会话信息 | -| `GET` | `/admin/api/logs` | 管理员会话 | 发送日志(按权限过滤) | -| `GET` | `/admin/api/logs/:id` | 管理员会话 | 单条发送日志(按权限过滤) | +| 方法 | 路径 | 鉴权 | 说明 | +| -------- | ------------------------------ | ------------ | ---------------------------------------------------- | +| `GET` | `/health` | 无 | 健康检查 | +| `POST` | `/webhook` | HMAC 签名 | GitHub / Gitea / 自定义 webhook 接入(自动识别来源) | +| `POST` | `/webhook/:groupId` | 分组 secret | 分组级 webhook 入口(只触发该分组的路由) | +| `POST` | `/discord/interactions` | Ed25519 签名 | Discord 交互(斜杠命令、按钮、modal) | +| `POST` | `/telegram/webhook` | Secret token | Telegram 更新(bot `/gh` 命令) | +| `GET` | `/api/richheader` | 无 | 用于 Telegram 头像链接预览卡片的 Open Graph 页面 | +| `GET` | `/auth/github` | 无 | 启动 GitHub OAuth 流程 | +| `GET` | `/auth/github/callback` | 无 | OAuth 回调 | +| `DELETE` | `/auth/token/:userId` | 无 | 撤销用户 Token | +| `POST` | `/api/comment` | Bearer Token | 创建议题评论 | +| `POST` | `/api/merge` | Bearer Token | 合并拉取请求 | +| `POST` | `/api/close` | Bearer Token | 关闭拉取请求 | +| `POST` | `/api/react` | Bearer Token | 添加议题反应 | +| `GET` | `/admin` | 管理员会话 | 配置控制台页面 | +| `GET` | `/admin/api/routes` | 管理员会话 | 列出路由 | +| `PUT` | `/admin/api/routes` | 管理员会话 | 替换路由 | +| `GET` | `/admin/api/groups` | 管理员会话 | 列出分组(按权限过滤) | +| `PUT` | `/admin/api/groups` | 管理员会话 | 替换分组(仅超级管理员) | +| `GET` | `/admin/api/groups/:id/routes` | 管理员会话 | 列出某分组的路由 | +| `PUT` | `/admin/api/groups/:id/routes` | 管理员会话 | 替换某分组的路由 | +| `GET` | `/admin/api/me` | 管理员会话 | 当前会话信息 | +| `GET` | `/admin/api/logs` | 管理员会话 | 发送日志(按权限过滤) | +| `GET` | `/admin/api/logs/:id` | 管理员会话 | 单条发送日志(按权限过滤) | ## 管理控制台 @@ -93,6 +94,18 @@ POST /webhook | `400` | `{"error": "Invalid event"}` | 缺少事件头或格式错误的请求体 | | `413` | `{"error": "Request too large"}` | 请求体超过 1MB 限制 | +### 分组级 Webhook(`POST /webhook/:groupId`) + +使用**分组的** secret(KV `tenant:{groupId}`,在控制台「Webhook 入口」面板生成)验签,并且只分发到该分组的路由。支持 GitHub(`X-Hub-Signature-256`)、Gitea(`X-Gitea-Signature`)和自定义(`X-WebHooker-Signature`)发送方。分组不存在或未配置 secret 时返回 `404`。 + +### 自定义 Webhook + +任意 JSON 载荷用 `X-WebHooker-Signature: sha256=`(对原始 body 的 HMAC-SHA256,使用分组或全局 secret)签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。载荷格式见[配置 → 自定义 Webhook](../guide/configuration.md#自定义-webhook)。 + +### GitHub App 安装事件 + +`installation` webhook 事件(`created` 等)会自动配置:按安装账号自动创建分组(`inst-{installationId}`,绑定 `installationId`);或把 `owners` 匹配该账号的现有分组自动绑定到该安装。参见[配置 → GitHub App 租户隔离](../guide/configuration.md#github-app-租户隔离)。 + ## 错误格式 所有错误响应都遵循以下格式: diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index f9450f0..6bf6c8c 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -58,28 +58,88 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 控制台以 SPA 形式挂在 `/admin`,各标签页可通过 URL 路径直达(`/admin/groups`、`/admin/logs`、`/admin/audit`)。`/admin` 之外且未匹配下方端点的 URL 直接返回 `404`,不会再被吞进控制台。 -| 端点 | 说明 | -| ------------------------------------ | -------------------------------------- | -| `GET /admin` | 配置控制台页面 | -| `GET /admin/login` | 开始 GitHub OAuth 登录 | -| `GET /admin/logout` | 销毁会话 | -| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) | -| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 | -| `GET /admin/api/routes` | 列出路由(按权限过滤) | -| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) | -| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 | -| `PUT /admin/api/groups` | 替换分组(超管全量;owner 仅自己的组) | -| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | -| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) | -| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | -| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | -| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) | -| `GET /admin/api/groups/:id/invites` | 列出待接受邀请(owner) | -| `DELETE /admin/api/invites/:token` | 撤销邀请(owner) | -| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) | +| 端点 | 说明 | +| ----------------------------------------------- | ----------------------------------------- | +| `GET /admin` | 配置控制台页面 | +| `GET /admin/login` | 开始 GitHub OAuth 登录 | +| `GET /admin/logout` | 销毁会话 | +| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) | +| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 | +| `GET /admin/api/routes` | 列出路由(按权限过滤) | +| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) | +| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 | +| `PUT /admin/api/groups` | 替换分组(超管全量;owner 仅自己的组) | +| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | +| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) | +| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | +| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | +| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) | +| `GET /admin/api/groups/:id/invites` | 列出待接受邀请(owner) | +| `DELETE /admin/api/invites/:token` | 撤销邀请(owner) | +| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) | +| `GET /admin/api/groups/:id/webhook` | 分组 webhook 入口信息(owner) | +| `POST /admin/api/groups/:id/webhook/regenerate` | 生成/重新生成分组 webhook secret(owner) | +| `DELETE /admin/api/groups/:id/webhook` | 停用分组 webhook 入口(owner) | 控制台支持新增、编辑、删除和开关路由。保存后立即写入 KV `config:routes` 并使配置缓存失效,下一次 webhook 处理即会生效。 +## Webhook 端点 + +### 全局端点(`POST /webhook`) + +旧版全局端点使用运维者的全局 secret(`GITHUB_WEBHOOK_SECRET`、`GITEA_WEBHOOK_SECRET`)验签,可分发到**所有**路由。GitHub App 安装事件从该端点进入;多租户场景请用分组的 `installationId` 做隔离。 + +### 分组端点(`POST /webhook/{groupId}`) + +每个分组可以启用独立的 webhook 入口和 secret(在分组页面「Webhook 入口」面板生成,owner 权限)。载荷使用**分组的** secret 验签,且只有该分组的路由会触发。SaaS 用户可以借此配置 Gitea、classic GitHub 或自定义 webhook,无需共享(也无需知道)运维者的全局 secret。 + +- 支持所有 provider:GitHub(`X-Hub-Signature-256`)、Gitea(`X-Gitea-Signature`)、自定义(`X-WebHooker-Signature`) +- secret 为 64 位十六进制字符串;重新生成后旧值立即失效 +- 去重 key 按租户隔离(`delivery:{groupId}:{id}`) +- 分组未配置 secret(或分组不存在)时返回 `404` + +### 自定义 Webhook + +向 `POST /webhook/{groupId}`(或全局端点)POST 任意 JSON,并用分组的 secret 对原始 body 计算 HMAC-SHA256 放在 `X-WebHooker-Signature: sha256=` 头中。载荷会变成 `custom` 事件走标准路由管线——创建一条 `event: custom` 的路由(控制台有模板)即可分发到该路由的目标,并自动记录 `send_logs`、出现在分组的日志频道。 + +载荷格式: + +```json +{ + "title": "Deploy failed", + "description": "Prod rollout failed at 12:03 UTC", + "color": "red", + "url": "https://ci.example.com/runs/42", + "repo": "acme/widget", + "author": { + "name": "alice", + "iconUrl": "https://…/alice.png", + "url": "https://github.com/alice" + }, + "fields": [{ "name": "Env", "value": "prod", "inline": true }], + "footer": "my-monitor", + "deliveryId": "alert-123" +} +``` + +| 字段 | 类型 | 说明 | +| ------------- | -------- | -------------------------------------------------------------------------------------------------------- | +| `title` | string | 消息标题(缺失时回退为「自定义消息」) | +| `description` | string | 可选的消息正文 | +| `color` | string | 可选消息颜色:颜色词(`red`、`green`、`yellow`、`blue`、`purple`、`orange`、`cyan`、`gray`)或 `#rrggbb` | +| `url` | string | 可选标题链接 | +| `repo` | string | 可选 `owner/repo`;会加在标题前并作为 footer | +| `author` | object | 可选的 `{ name, iconUrl, url }` | +| `fields` | object[] | 可选的嵌入字段 `{ name, value, inline }` | +| `footer` | string | 可选的 footer 覆盖 | +| `deliveryId` | string | 可选的发送方去重 id(重试场景) | + +### GitHub App 租户隔离 + +GitHub App 安装后,**所有**安装方的事件都会到达全局端点。要让租户互相隔离,请把每个分组绑定到应当为其提供事件的安装 ID:`"installationId": 12345678`。该 ID 可从 App 安装 webhook 载荷(`installation.id`)或 GitHub App 安装页 URL 看到。即使分组的 `owners` 为空,来自其它安装的事件也会被拒绝。未设置 `installationId` 的分组保持旧行为(`owners` 过滤)。 + +绑定是**自动配置**的:`installation.created` webhook 事件会自动创建绑定到该安装的专用分组 `inst-{installationId}`(名称为安装账号),或自动把 `owners` 匹配该账号的现有分组绑定到该安装。无需手动填写 ID——安装 App 后在控制台为自动创建的分组添加路由和成员即可。 + ## 路由 路由定义了哪些事件被转发到哪些频道(Discord 或 Telegram)。它们以 JSON 数组形式存储在 Cloudflare KV 中,键为 `config:routes`。 @@ -182,21 +242,23 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 ], "owners": ["myorg"], "providers": ["github", "gitea"], + "installationId": 12345678, "logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" } } ``` -| 字段 | 类型 | 必需 | 说明 | -| ----------- | -------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 | -| `name` | string | 是 | 可读的分组名称 | -| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` | -| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 | -| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 | -| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 | -| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) | -| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:` 自定义)——默认 `en` | -| `logTarget` | object | 否 | Webhook 日志频道:Discord 目标 `{ platform, channelId, threadId? }` 或 Telegram 目标 `{ platform, chatId, topicId? }`,本分组路由每次投递 webhook 时都会向其发送摘要 | +| 字段 | 类型 | 必需 | 说明 | +| ---------------- | -------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 | +| `name` | string | 是 | 可读的分组名称 | +| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` | +| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 | +| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 | +| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 | +| `installationId` | number | 否 | 绑定到该分组的 GitHub App 安装 ID;只接受该安装的事件(为空表示全部) | +| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) | +| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:` 自定义)——默认 `en` | +| `logTarget` | object | 否 | Webhook 日志频道:Discord 目标 `{ platform, channelId, threadId? }` 或 Telegram 目标 `{ platform, chatId, topicId? }`,本分组路由每次投递 webhook 时都会向其发送摘要 | ### 角色 diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 9e1af3b..6ca69de 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -7,8 +7,20 @@ import { adminCookie, clearAdminCookie, } from "../web/session"; -import { groupAcceptsProvider } from "../web/groups"; +import { + groupAcceptsProvider, + groupAcceptsInstallation, + ensureInstallationGroup, + loadGroups, + saveGroups, +} from "../web/groups"; import { validateGroups } from "../web/admin-routes"; +import { + getTenantSecret, + setTenantSecret, + deleteTenantSecret, + generateTenantSecret, +} from "../web/tenants"; import { loadRoutes, saveRoutes, loadConfig } from "../config"; import type { Env, Route, Group } from "../types"; @@ -199,6 +211,105 @@ describe("validateGroups logTarget", () => { }); }); +describe("validateGroups installationId", () => { + const baseGroup = { + id: "g", + name: "G", + members: [{ login: "boss", role: "owner" }], + }; + + it("accepts a positive integer installation id", () => { + const res = validateGroups([{ ...baseGroup, installationId: 42 }]); + expect(res.ok).toBe(true); + if (res.ok) expect(res.groups[0]!.installationId).toBe(42); + }); + + it("rejects non-integer installation ids", () => { + expect(validateGroups([{ ...baseGroup, installationId: "42" }]).ok).toBe(false); + expect(validateGroups([{ ...baseGroup, installationId: 42.5 }]).ok).toBe(false); + }); + + it("drops a null installation id", () => { + const res = validateGroups([{ ...baseGroup, installationId: null }]); + expect(res.ok).toBe(true); + if (res.ok) expect(res.groups[0]!.installationId).toBeUndefined(); + }); +}); + +describe("groupAcceptsInstallation", () => { + const base: Group = { id: "g", name: "G", adminIds: [] }; + + it("accepts everything when the group is not bound to an installation", () => { + expect(groupAcceptsInstallation(base, 101)).toBe(true); + expect(groupAcceptsInstallation(base, undefined)).toBe(true); + }); + + it("only accepts events from the bound installation", () => { + const bound = { ...base, installationId: 101 }; + expect(groupAcceptsInstallation(bound, 101)).toBe(true); + expect(groupAcceptsInstallation(bound, 202)).toBe(false); + expect(groupAcceptsInstallation(bound, undefined)).toBe(false); + }); +}); + +describe("ensureInstallationGroup", () => { + it("creates an inst-{id} group when nothing is bound", async () => { + const kv = createMockKV(); + const group = await ensureInstallationGroup(kv, 555, "myorg"); + expect(group?.id).toBe("inst-555"); + expect(group?.installationId).toBe(555); + expect(group?.name).toBe("myorg"); + const groups = await loadGroups(kv); + expect(groups).toHaveLength(1); + expect(groups[0]!.installationId).toBe(555); + }); + + it("binds existing groups whose owners match the installing account", async () => { + const kv = createMockKV(); + await saveGroups(kv, [ + { id: "backend", name: "Backend", adminIds: [], owners: ["myorg"], members: [] }, + { id: "other", name: "Other", adminIds: [], owners: ["another-org"], members: [] }, + ]); + await ensureInstallationGroup(kv, 555, "MyOrg"); + const groups = await loadGroups(kv); + expect(groups).toHaveLength(2); + expect(groups.find((g) => g.id === "backend")?.installationId).toBe(555); + expect(groups.find((g) => g.id === "other")?.installationId).toBeUndefined(); + expect(groups.some((g) => g.id.startsWith("inst-"))).toBe(false); + }); + + it("is idempotent when a group is already bound", async () => { + const kv = createMockKV(); + await ensureInstallationGroup(kv, 555, "myorg"); + await ensureInstallationGroup(kv, 555, "myorg"); + expect((await loadGroups(kv)).filter((g) => g.installationId === 555)).toHaveLength(1); + }); +}); + +describe("tenant webhook secrets", () => { + it("generates 64-char hex secrets", () => { + const s = generateTenantSecret(); + expect(s).toMatch(/^[0-9a-f]{64}$/); + }); + + it("creates, reads, and deletes a tenant secret", async () => { + const kv = createMockKV(); + expect(await getTenantSecret(kv, "g1")).toBeNull(); + const secret = await setTenantSecret(kv, "g1"); + expect(await getTenantSecret(kv, "g1")).toBe(secret); + await deleteTenantSecret(kv, "g1"); + expect(await getTenantSecret(kv, "g1")).toBeNull(); + }); + + it("regenerating replaces the previous secret", async () => { + const kv = createMockKV(); + const a = await setTenantSecret(kv, "g1"); + const b = await setTenantSecret(kv, "g1"); + expect(a).not.toBe(b); + expect(await getTenantSecret(kv, "g1")).toBe(b); + }); +}); + describe("config routes persistence", () => { it("saves and loads routes from KV", async () => { const kv = createMockKV(); diff --git a/src/__tests__/providers.test.ts b/src/__tests__/providers.test.ts index 2b71d40..2df757d 100644 --- a/src/__tests__/providers.test.ts +++ b/src/__tests__/providers.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from "bun:test"; import { createHmac } from "crypto"; import { detectProvider } from "../providers"; +import { customProvider } from "../providers/custom"; import { verifyGiteaSignature } from "../providers/gitea/verify"; import { parseGiteaEvent } from "../providers/gitea/parse"; +import { parseEvent } from "../providers/github/parse"; import { formatEvent } from "../formatters"; -import type { Route } from "../types"; +import type { Env, Route } from "../types"; function giteaSign(body: string, secret: string): string { return createHmac("sha256", secret).update(body).digest("hex"); @@ -151,3 +153,74 @@ describe("gitea event parsing", () => { ); }); }); + +describe("custom provider", () => { + const secret = "tenant-secret"; + const body = JSON.stringify({ + title: "Deploy failed", + repo: "acme/widget", + color: "red", + description: "prod down", + }); + + function env(overrides: Record = {}): Env { + return { GITHUB_WEBHOOK_SECRET: secret, ...overrides } as Env; + } + + it("matches only requests with X-WebHooker-Signature and no forge headers", () => { + expect(customProvider.matches({ "x-webhooker-signature": "sha256=abc" })).toBe(true); + expect(customProvider.matches({})).toBe(false); + expect(customProvider.matches({ "x-github-event": "push" })).toBe(false); + expect(customProvider.matches({ "x-gitea-event": "push" })).toBe(false); + }); + + it("detectProvider finds the custom provider", () => { + expect(detectProvider({ "x-webhooker-signature": "sha256=abc" })?.id).toBe("custom"); + }); + + it("accepts a valid sha256 HMAC signature", async () => { + const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + expect(await customProvider.verify(body, { "x-webhooker-signature": sig }, env())).toBe(true); + }); + + it("rejects an invalid signature", async () => { + expect( + await customProvider.verify(body, { "x-webhooker-signature": "sha256=wrong" }, env()), + ).toBe(false); + }); + + it("rejects when the secret is missing", async () => { + const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + expect( + (await customProvider.verify(body, { "x-webhooker-signature": sig }, {})) as unknown as Env, + ).toBe(false); + }); + + it("parses the body into a custom event with optional deliveryId", () => { + const event = customProvider.parse(body, {}); + expect(event).not.toBeNull(); + expect(event!.event).toBe("custom"); + expect(event!.provider).toBe("custom"); + expect((event!.payload as { title: string }).title).toBe("Deploy failed"); + + const withId = customProvider.parse(JSON.stringify({ title: "x", deliveryId: "alert-1" }), {}); + expect(withId!.deliveryId).toBe("alert-1"); + }); + + it("returns null for invalid JSON", () => { + expect(customProvider.parse("not json", {})).toBeNull(); + }); + + it("extracts the GitHub App installation id on github events", () => { + const event = parseEvent( + { "x-github-event": "push", "x-github-delivery": "d1" }, + JSON.stringify({ installation: { id: 42 }, repository: { full_name: "a/b" } }), + ); + expect(event!.installationId).toBe(42); + const noInstall = parseEvent( + { "x-github-event": "push" }, + JSON.stringify({ repository: { full_name: "a/b" } }), + ); + expect(noInstall!.installationId).toBeUndefined(); + }); +}); diff --git a/src/__tests__/webhook-tenant.test.ts b/src/__tests__/webhook-tenant.test.ts new file mode 100644 index 0000000..6eb6a47 --- /dev/null +++ b/src/__tests__/webhook-tenant.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { createHmac } from "crypto"; +import { processWebhook } from "../webhook"; +import { invalidateConfigCache } from "../config"; +import { loadGroups } from "../web/groups"; +import type { Env, Route } from "../types"; + +function githubSign(body: string, secret: string): string { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string, type?: string) => { + const v = store.get(key); + if (v == null) return null; + if (type === "json") return JSON.parse(v); + return v; + }, + put: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ + keys: [...store.keys()].map((k) => ({ name: k })), + list_complete: true, + cacheStatus: null, + }), + } as unknown as KVNamespace; +} + +function createMockDB(): D1Database { + return { + prepare: () => ({ + bind: () => ({ + run: async () => ({ success: true }), + all: async () => ({ results: [] }), + }), + }), + } as unknown as D1Database; +} + +function createEnv(overrides: Partial = {}): Env { + return { + GITHUB_WEBHOOK_SECRET: "global-secret", + KV: createMockKV(), + DB: createMockDB(), + ...overrides, + }; +} + +describe("processWebhook", () => { + let fetched: Array<{ url: string; body: string }>; + const restoredFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = restoredFetch; + }); + + function mockFetch(): void { + fetched = []; + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + fetched.push({ url: String(input), body: String(init?.body ?? "") }); + return Promise.resolve(new Response("{}", { status: 200 })); + }; + } + + beforeEach(() => { + invalidateConfigCache(); + }); + + /** waitUntil collector: lets the test await the dispatched work. */ + function makeWait(): { + waitUntil: (p: Promise) => void; + flush: () => Promise; + } { + const pending: Promise[] = []; + return { + waitUntil: (p: Promise): void => { + pending.push(p); + }, + flush: (): Promise => Promise.allSettled(pending).then(() => undefined), + }; + } + + async function setupTenant(secret: string, extraRoutes: Route[] = []): Promise { + const kv = createMockKV(); + await kv.put("config:groups", JSON.stringify([{ id: "team-a", name: "Team A", adminIds: [] }])); + await kv.put("tenant:team-a", secret); + await kv.put( + "config:routes", + JSON.stringify([ + { + id: "team-a-push", + name: "Team A Push", + enabled: true, + groupId: "team-a", + filters: [{ type: "event", match: "push" }], + targets: [{ channelId: "111" }], + }, + { + id: "other-push", + name: "Other Push", + enabled: true, + groupId: "other", + filters: [{ type: "event", match: "push" }], + targets: [{ channelId: "222" }], + }, + ...extraRoutes, + ] as Route[]), + ); + return createEnv({ KV: kv }); + } + + it("dispatches only the tenant group's routes on a valid signature", async () => { + mockFetch(); + const secret = "tenant-secret-1"; + const env = await setupTenant(secret); + const body = JSON.stringify({ ref: "refs/heads/main", commits: [] }); + const wait = makeWait(); + const res = await processWebhook( + env, + body, + { + "x-github-event": "push", + "x-hub-signature-256": githubSign(body, secret), + "x-github-delivery": "deliv-1", + }, + wait.waitUntil, + "team-a", + ); + expect(res.status).toBe(200); + await wait.flush(); + expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true); + expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false); + }); + + it("rejects an invalid signature with 401", async () => { + const env = await setupTenant("tenant-secret-1"); + const body = JSON.stringify({ ref: "refs/heads/main" }); + const wait = makeWait(); + const res = await processWebhook( + env, + body, + { + "x-github-event": "push", + "x-hub-signature-256": "sha256=wrong", + "x-github-delivery": "deliv-2", + }, + wait.waitUntil, + "team-a", + ); + expect(res.status).toBe(401); + }); + + it("returns 404 for an unknown group", async () => { + const env = await setupTenant("tenant-secret-1"); + const wait = makeWait(); + const res = await processWebhook( + env, + "{}", + { "x-github-event": "push" }, + wait.waitUntil, + "nope", + ); + expect(res.status).toBe(404); + }); + + it("returns 404 when the group has no tenant secret", async () => { + const kv = createMockKV(); + await kv.put("config:groups", JSON.stringify([{ id: "g", name: "G", adminIds: [] }])); + const env = createEnv({ KV: kv }); + const wait = makeWait(); + const res = await processWebhook( + env, + "{}", + { "x-github-event": "push", "x-hub-signature-256": "sha256=x" }, + wait.waitUntil, + "g", + ); + expect(res.status).toBe(404); + }); + + it("dedupes delivery ids per tenant", async () => { + mockFetch(); + const secret = "tenant-secret-1"; + const env = await setupTenant(secret); + const body = JSON.stringify({ ref: "refs/heads/main", commits: [] }); + const headers = { + "x-github-event": "push", + "x-hub-signature-256": githubSign(body, secret), + "x-github-delivery": "same-deliv", + }; + const first = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a"); + const second = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a"); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.body).toEqual({ ok: true, duplicate: true }); + expect(fetched).toHaveLength(1); + }); + + it("accepts custom webhooks signed with the tenant secret", async () => { + mockFetch(); + const secret = "tenant-secret-1"; + const env = await setupTenant(secret, [ + { + id: "custom-alerts", + name: "Custom Alerts", + enabled: true, + groupId: "team-a", + filters: [{ type: "event", match: "custom" }], + targets: [{ channelId: "333" }], + }, + ]); + const body = JSON.stringify({ + title: "Deploy failed", + repo: "acme/widget", + color: "red", + description: "prod down", + }); + const wait = makeWait(); + const res = await processWebhook( + env, + body, + { "x-webhooker-signature": githubSign(body, secret) }, + wait.waitUntil, + "team-a", + ); + expect(res.status).toBe(200); + await wait.flush(); + const sent = fetched.filter((f) => f.url.includes("/333/")); + expect(sent).toHaveLength(1); + const parsed = JSON.parse(sent[0]!.body) as { + embeds?: Array<{ title?: string; color?: number; description?: string }>; + }; + expect(parsed.embeds?.[0]?.title).toBe("acme/widget: Deploy failed"); + expect(parsed.embeds?.[0]?.color).toBe(0xf85149); + expect(parsed.embeds?.[0]?.description).toBe("prod down"); + }); + + it("rejects custom webhooks without a signature header", async () => { + const env = await setupTenant("tenant-secret-1"); + const wait = makeWait(); + const res = await processWebhook( + env, + JSON.stringify({ title: "x" }), + {}, + wait.waitUntil, + "team-a", + ); + expect(res.status).toBe(400); + }); + + it("keeps the legacy global endpoint working with the global secret", async () => { + mockFetch(); + const kv = createMockKV(); + await kv.put( + "config:routes", + JSON.stringify([ + { + id: "all", + name: "All", + enabled: true, + filters: [], + targets: [{ channelId: "111" }], + }, + ] as Route[]), + ); + const env = createEnv({ KV: kv }); + const body = JSON.stringify({ ref: "refs/heads/main", commits: [] }); + const wait = makeWait(); + const res = await processWebhook( + env, + body, + { + "x-github-event": "push", + "x-hub-signature-256": githubSign(body, "global-secret"), + "x-github-delivery": "g-1", + }, + wait.waitUntil, + ); + expect(res.status).toBe(200); + await wait.flush(); + expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true); + }); + + it("keeps a group's routes isolated by GitHub App installation id", async () => { + mockFetch(); + const kv = createMockKV(); + await kv.put( + "config:groups", + JSON.stringify([ + { id: "inst-1", name: "Install 1", adminIds: [], installationId: 101 }, + { id: "inst-2", name: "Install 2", adminIds: [], installationId: 202 }, + ]), + ); + await kv.put( + "config:routes", + JSON.stringify([ + { + id: "r1", + name: "R1", + enabled: true, + groupId: "inst-1", + filters: [], + targets: [{ channelId: "111" }], + }, + { + id: "r2", + name: "R2", + enabled: true, + groupId: "inst-2", + filters: [], + targets: [{ channelId: "222" }], + }, + ] as Route[]), + ); + const env = createEnv({ KV: kv }); + + // Global endpoint: installation 101's event may only reach group inst-1. + const body = JSON.stringify({ + installation: { id: 101 }, + repository: { full_name: "org-a/repo" }, + ref: "refs/heads/main", + commits: [], + }); + const wait = makeWait(); + const res = await processWebhook( + env, + body, + { + "x-github-event": "push", + "x-hub-signature-256": githubSign(body, "global-secret"), + "x-github-delivery": "inst-1-deliv", + }, + wait.waitUntil, + ); + expect(res.status).toBe(200); + await wait.flush(); + expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true); + expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false); + }); + + it("auto-provisions a group on installation.created", async () => { + mockFetch(); + const kv = createMockKV(); + await kv.put( + "config:routes", + JSON.stringify([ + { + id: "install-events", + name: "Install Events", + enabled: true, + filters: [{ type: "event", match: "installation" }], + targets: [{ channelId: "111" }], + }, + ] as Route[]), + ); + const env = createEnv({ KV: kv }); + const body = JSON.stringify({ + action: "created", + installation: { id: 555, account: { login: "myorg", type: "Organization" } }, + repositories: [{ full_name: "myorg/repo" }], + sender: { login: "admin" }, + }); + const wait = makeWait(); + const res = await processWebhook( + env, + body, + { + "x-github-event": "installation", + "x-hub-signature-256": githubSign(body, "global-secret"), + "x-github-delivery": "inst-event-1", + }, + wait.waitUntil, + ); + expect(res.status).toBe(200); + await wait.flush(); + + const groups = await loadGroups(kv); + const bound = groups.find((g) => g.installationId === 555); + expect(bound).toBeDefined(); + expect(bound?.id).toBe("inst-555"); + expect(bound?.name).toBe("myorg"); + // The auto-created group is only visible to super admins (no members). + expect(bound?.members ?? []).toHaveLength(0); + }); +}); diff --git a/src/config.ts b/src/config.ts index d324106..1743e8c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,6 +33,11 @@ export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise { if (configCache && Date.now() < configCache.expiresAt) { return configCache.config; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index ca4805c..8a79669 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -4,7 +4,12 @@ import { matchRoute, eventOwners } from "../events/match"; import { log } from "../lib/log"; import { loadTranslations, t as translate, type Translations } from "../lib/i18n"; import { recordSend } from "../lib/send-log"; -import { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups"; +import { + loadGroups, + groupAcceptsOwners, + groupAcceptsProvider, + groupAcceptsInstallation, +} from "../web/groups"; import { getDriver } from "../drivers"; import type { SendResult } from "../drivers/types"; @@ -37,6 +42,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En if (!route.groupId) return true; const group = groupById.get(route.groupId); if (!group) return true; + if (!groupAcceptsInstallation(group, event.installationId)) return false; if (!groupAcceptsOwners(group, owners)) return false; return groupAcceptsProvider(group, event.provider); }; diff --git a/src/formatters/custom.ts b/src/formatters/custom.ts new file mode 100644 index 0000000..1a1e9e0 --- /dev/null +++ b/src/formatters/custom.ts @@ -0,0 +1,89 @@ +import type { NeutralAuthor, NeutralField, NeutralMessage } from "../types"; +import { type T, buildMessage } from "./helpers"; + +const COLOR_WORDS: Record = { + red: 0xf85149, + green: 0x3fb950, + yellow: 0xd29922, + blue: 0x58a6ff, + purple: 0xbc8cff, + orange: 0xdb6d28, + cyan: 0x39c5cf, + gray: 0x6e7681, +}; + +function parseColor(color: unknown): number | undefined { + if (typeof color !== "string") return undefined; + const key = color.trim().toLowerCase(); + if (COLOR_WORDS[key]) return COLOR_WORDS[key]; + const hex = /^#?([0-9a-f]{6})$/i.exec(key); + return hex ? parseInt(hex[1]!, 16) : undefined; +} + +function parseFields(raw: unknown): NeutralField[] | undefined { + if (!Array.isArray(raw)) return undefined; + const fields: NeutralField[] = []; + for (const f of raw) { + if (!f || typeof f !== "object") continue; + const name = (f as Record).name; + const value = (f as Record).value; + if (typeof name !== "string" || typeof value !== "string") continue; + fields.push({ + name: name || "\u200b", + value, + inline: (f as Record).inline === true, + }); + } + return fields.length > 0 ? fields : undefined; +} + +/** + * Renders a `custom` webhook payload (the message schema documented in + * docs/guide/configuration.md) into a NeutralMessage. Unlike forge events the + * title is not required to start with a repo; an optional `repo` field is + * used as the `{repo}: ` prefix and footer. + */ +export function formatCustom( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + _showEmoji: boolean, +): NeutralMessage { + const title = + typeof payload.title === "string" && payload.title.trim() + ? payload.title.trim() + : t("custom.title_fallback"); + const payloadRepo = + typeof payload.repo === "string" && payload.repo.trim() ? payload.repo.trim() : undefined; + const effectiveRepo = payloadRepo ?? repo; + const fullTitle = effectiveRepo ? `${effectiveRepo}: ${title}` : title; + const description = typeof payload.description === "string" ? payload.description : undefined; + const url = typeof payload.url === "string" ? payload.url : undefined; + const footer = typeof payload.footer === "string" ? payload.footer : undefined; + + const rawAuthor = payload.author as Record | undefined; + const customAuthor: NeutralAuthor | undefined = + rawAuthor && typeof rawAuthor === "object" + ? { + name: typeof rawAuthor.name === "string" && rawAuthor.name ? rawAuthor.name : author.name, + iconUrl: typeof rawAuthor.iconUrl === "string" ? rawAuthor.iconUrl : author.iconUrl, + url: typeof rawAuthor.url === "string" ? rawAuthor.url : author.url, + } + : undefined; + + return buildMessage( + { + author: customAuthor, + title: fullTitle, + url, + color: parseColor(payload.color) ?? 0x6e7681, + description, + fields: parseFields(payload.fields), + // A custom message without a repo gets no `{repo}` footer at all. + footer: footer ?? (effectiveRepo ? undefined : ""), + }, + t, + effectiveRepo, + ); +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts index 6303442..3ed5283 100644 --- a/src/formatters/index.ts +++ b/src/formatters/index.ts @@ -21,6 +21,7 @@ import { formatRepository } from "./repository"; import { formatCodeScanningAlert, formatDependabotAlert } from "./security"; import { formatGeneric } from "./generic"; import { formatPing } from "./ping"; +import { formatCustom } from "./custom"; export function formatEvent( route: Route, @@ -100,6 +101,8 @@ export function formatEvent( return formatCodeScanningAlert(payload, repo, author, t, showEmoji); case "dependabot_alert": return formatDependabotAlert(payload, repo, author, t, showEmoji); + case "custom": + return formatCustom(payload, repo, author, t, showEmoji); default: return formatGeneric(eventType, payload, repo, author, t, showEmoji); } diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index f51b67c..ba04883 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -195,6 +195,9 @@ export const en = { title: "{repo}: {event}{action}", }, }, + custom: { + title_fallback: "Custom message", + }, log: { title: "{repo}: {event}{action}", routes: "Routes", diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts index 393dbca..521e8ba 100644 --- a/src/lib/locales/zh.ts +++ b/src/lib/locales/zh.ts @@ -195,6 +195,9 @@ export const zh = { title: "{repo}: {event}{action}", }, }, + custom: { + title_fallback: "自定义消息", + }, log: { title: "{repo}: {event}{action}", routes: "路由", diff --git a/src/providers/custom/index.ts b/src/providers/custom/index.ts new file mode 100644 index 0000000..2b54971 --- /dev/null +++ b/src/providers/custom/index.ts @@ -0,0 +1,44 @@ +import type { Env, WebhookEvent } from "../../types"; +import type { Provider } from "../types"; +import { verifySignature } from "../github/verify"; + +/** + * Custom webhook provider: accepts arbitrary JSON posts (monitoring, CI, + * scripts, ...) that are not signed by a forge. The sender signs the raw body + * with the tenant's secret using the GitHub-style `sha256=` HMAC header + * `X-WebHooker-Signature`. Payloads become `custom` events that flow through + * the normal route matching pipeline (a route with `event: custom`). + */ +export const customProvider: Provider = { + id: "custom", + + matches(headers) { + return ( + headers["x-github-event"] === undefined && + headers["x-gitea-event"] === undefined && + headers["x-webhooker-signature"] !== undefined + ); + }, + + async verify(body, headers, env: Env) { + // The tenant webhook handler overrides GITHUB_WEBHOOK_SECRET with the + // group's secret; on the legacy global endpoint this falls back to the + // operator's global secret. + return verifySignature(body, headers["x-webhooker-signature"], env.GITHUB_WEBHOOK_SECRET); + }, + + parse(body, _headers): WebhookEvent | null { + try { + const payload = JSON.parse(body) as Record; + if (!payload || typeof payload !== "object") return null; + // Optional id for sender-side dedup (retries from monitoring systems). + const deliveryId = + typeof payload.deliveryId === "string" && payload.deliveryId + ? payload.deliveryId + : undefined; + return { event: "custom", provider: "custom", payload, deliveryId }; + } catch { + return null; + } + }, +}; diff --git a/src/providers/github/parse.ts b/src/providers/github/parse.ts index bc86223..490261c 100644 --- a/src/providers/github/parse.ts +++ b/src/providers/github/parse.ts @@ -8,8 +8,12 @@ export function parseEvent(headers: Record, body: string): Webho if (!event) return null; try { - const payload = JSON.parse(body); - return { provider: "github", event, payload, signature, deliveryId }; + const payload = JSON.parse(body) as Record; + const installationId = + typeof (payload.installation as { id?: unknown } | undefined)?.id === "number" + ? (payload.installation as { id: number }).id + : undefined; + return { provider: "github", event, payload, signature, deliveryId, installationId }; } catch { return null; } diff --git a/src/providers/index.ts b/src/providers/index.ts index 2567254..d51484a 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,7 @@ import type { Provider } from "./types"; import { githubProvider } from "./github"; import { giteaProvider } from "./gitea"; +import { customProvider } from "./custom"; export type { Provider } from "./types"; export { verifySignature } from "./github/verify"; @@ -9,9 +10,10 @@ export { verifySignature } from "./github/verify"; * Detection order matters: Gitea webhooks also send GitHub-compatible headers * (`X-GitHub-Event`, `X-Hub-Signature-256`, ...), so a Gitea request would * match the GitHub provider too. Check Gitea first — real GitHub requests - * never send `X-Gitea-Event`. + * never send `X-Gitea-Event`. Custom requests carry none of the forge headers, + * only `X-WebHooker-Signature`, so they are checked last. */ -const providers: Provider[] = [giteaProvider, githubProvider]; +const providers: Provider[] = [giteaProvider, githubProvider, customProvider]; /** * Pick the webhook provider for a request based on its headers (e.g. diff --git a/src/providers/types.ts b/src/providers/types.ts index 6eb95ab..a28af2c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -7,7 +7,7 @@ import type { Env, WebhookEvent } from "../types"; * {@link WebhookEvent} and never knows which forge produced it. */ export interface Provider { - readonly id: "github" | "gitea" | "gitlab"; + readonly id: "github" | "gitea" | "gitlab" | "custom"; /** * Whether the request headers belong to this provider (e.g. checks the * `X-Gitea-Event` header). diff --git a/src/server.ts b/src/server.ts index 6df056e..ec2529f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,6 @@ import { Hono } from "hono"; import type { Env } from "./types"; -import { detectProvider } from "./providers"; -import { dispatchEvent } from "./core/dispatch"; +import { handleWebhook } from "./webhook"; import { handleInteractionRequest } from "./drivers/discord/interactions"; import { handleTelegramWebhookRequest } from "./drivers/telegram/updates"; import { createOAuthRoutes } from "./web/oauth-routes"; @@ -10,10 +9,6 @@ import { createAdminRoutes } from "./web/admin-routes"; import { createLegalRoutes } from "./web/legal-routes"; import { createHomeRoutes } from "./web/home-routes"; import { createRichHeaderRoutes } from "./web/richheader-routes"; -import { loadConfig } from "./config"; -import { log } from "./lib/log"; - -const MAX_BODY_SIZE = 1024 * 1024; export function createServer(): Hono<{ Bindings: Env }> { const app = new Hono<{ Bindings: Env }>(); @@ -27,52 +22,8 @@ export function createServer(): Hono<{ Bindings: Env }> { app.route("/admin", createAdminRoutes()); app.route("/api", createRichHeaderRoutes()); - app.post("/webhook", async (c) => { - const contentLength = Number(c.req.header("content-length") ?? 0); - if (contentLength > MAX_BODY_SIZE) { - return c.json({ error: "Request too large" }, 413); - } - - const body = await c.req.text(); - if (body.length > MAX_BODY_SIZE) { - return c.json({ error: "Request too large" }, 413); - } - - const headers: Record = {}; - c.req.raw.headers.forEach((value, key) => { - headers[key] = value; - }); - - const provider = detectProvider(headers); - if (!provider) { - return c.json({ error: "Unknown webhook provider" }, 400); - } - - if (!(await provider.verify(body, headers, c.env))) { - return c.json({ error: "Invalid signature" }, 401); - } - - const event = provider.parse(body, headers); - if (!event) { - return c.json({ error: "Invalid event" }, 400); - } - - if (event.deliveryId) { - const seen = await c.env.KV.get(`delivery:${event.deliveryId}`); - if (seen) { - return c.json({ ok: true, duplicate: true }); - } - await c.env.KV.put(`delivery:${event.deliveryId}`, "1", { expirationTtl: 300 }); - } - - const config = await loadConfig(c.env); - const dispatch = dispatchEvent(config, event, c.env).catch((err) => - log.error(err, "Dispatch failed"), - ); - c.executionCtx.waitUntil(dispatch); - - return c.json({ ok: true }); - }); + app.post("/webhook", (c) => handleWebhook(c)); + app.post("/webhook/:groupId", (c) => handleWebhook(c, c.req.param("groupId"))); app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env)); app.post("/telegram/webhook", (c) => handleTelegramWebhookRequest(c.req.raw, c.env)); diff --git a/src/types.ts b/src/types.ts index fbc96f8..6fecec0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,6 +114,13 @@ export interface Group { * (e.g. `["github"]`, `["gitea"]`). Empty/omitted = all providers. */ providers?: WebhookProvider[]; + /** + * GitHub App installation id bound to this group. When set, only webhook + * events coming from that installation (org/user) are accepted into the + * group's routes — hard tenant isolation on top of (or instead of) the + * `owners` list. Empty/omitted = no installation restriction. + */ + installationId?: number; /** * Whether to include emoji in messages sent through this group's routes. * Defaults to true when omitted. @@ -137,7 +144,7 @@ export interface Filter { exclude?: boolean; } -export type WebhookProvider = "github" | "gitea" | "gitlab"; +export type WebhookProvider = "github" | "gitea" | "gitlab" | "custom"; export interface WebhookEvent { event: string; @@ -145,6 +152,11 @@ export interface WebhookEvent { signature?: string; deliveryId?: string; provider?: WebhookProvider; + /** + * GitHub App installation id that produced this event (extracted from + * `payload.installation.id`). Gitea/custom events have none. + */ + installationId?: number; } export interface NeutralAuthor { diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts index af53c3e..3dfbdb2 100644 --- a/src/web/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -16,6 +16,7 @@ import { import { getSendLog, getSendLogById } from "../lib/send-log"; import { getAuditLog, recordAudit } from "../lib/audit"; import { createInvite, listInvites, revokeInvite, getInvite, acceptInvite } from "./invites"; +import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants"; import { log } from "../lib/log"; const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]); @@ -277,6 +278,13 @@ export function validateGroups( error: `group "${g.id}".providers must be a list of "github" | "gitea" | "gitlab"`, }; } + if (g.installationId !== undefined && g.installationId !== null) { + if (typeof g.installationId !== "number" || !Number.isInteger(g.installationId)) { + return { ok: false, error: `group "${g.id}".installationId must be an integer` }; + } + } else { + delete g.installationId; + } if (g.emoji !== undefined && typeof g.emoji !== "boolean") { return { ok: false, error: `group "${g.id}".emoji must be a boolean` }; } @@ -463,6 +471,7 @@ export function createAdminRoutes(): Hono { if (prev.lang !== g.lang) fields.push("lang"); if (!deepEqual(prev.logTarget, g.logTarget)) fields.push("logTarget"); if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers"); + if (prev.installationId !== g.installationId) fields.push("installationId"); if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners"); if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members"); @@ -490,6 +499,7 @@ export function createAdminRoutes(): Hono { groupId: g.id, ip: clientIp(c), }); + await deleteTenantSecret(c.env.KV, g.id).catch(() => undefined); } } @@ -746,6 +756,76 @@ export function createAdminRoutes(): Hono { return c.json({ ok: true }); }); + // ---- Group webhook ingress (owner +) ---- + app.get("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => { + const groupId = param(c, "groupId"); + const access = requireGroupRole(c, groupId, "owner"); + if (!access.ok) { + return c.json( + { error: access.status === 404 ? "Group not found" : "Forbidden" }, + access.status, + ); + } + const origin = c.env.BASE_URL ?? new URL(c.req.url).origin; + return c.json({ + url: `${origin.replace(/\/$/, "")}/webhook/${groupId}`, + hasSecret: (await getTenantSecret(c.env.KV, groupId)) != null, + }); + }); + + app.post("/api/groups/:groupId/webhook/regenerate", requireAnyAccess(), async (c) => { + const groupId = param(c, "groupId"); + const access = requireGroupRole(c, groupId, "owner"); + if (!access.ok) { + return c.json( + { error: access.status === 404 ? "Group not found" : "Forbidden" }, + access.status, + ); + } + const secret = await setTenantSecret(c.env.KV, groupId); + const auth = currentAuth(c); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "webhook.secret.regenerate", + targetType: "group", + targetId: groupId, + groupId, + ip: clientIp(c), + }); + const origin = c.env.BASE_URL ?? new URL(c.req.url).origin; + return c.json({ + ok: true, + url: `${origin.replace(/\/$/, "")}/webhook/${groupId}`, + secret, + }); + }); + + app.delete("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => { + const groupId = param(c, "groupId"); + const access = requireGroupRole(c, groupId, "owner"); + if (!access.ok) { + return c.json( + { error: access.status === 404 ? "Group not found" : "Forbidden" }, + access.status, + ); + } + await deleteTenantSecret(c.env.KV, groupId); + const auth = currentAuth(c); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "webhook.secret.delete", + targetType: "group", + targetId: groupId, + groupId, + ip: clientIp(c), + }); + return c.json({ ok: true }); + }); + // ---- Audit log (any access; group admins see only their groups) ---- app.get("/api/audit", requireAnyAccess(), async (c) => { const auth = currentAuth(c); diff --git a/src/web/groups.ts b/src/web/groups.ts index c8bae00..61e490b 100644 --- a/src/web/groups.ts +++ b/src/web/groups.ts @@ -96,6 +96,62 @@ export function groupAcceptsProvider(group: Group, provider?: string): boolean { return allowed.includes(provider ?? "github"); } +/** + * Whether an event from a GitHub App `installationId` is allowed into this + * group. A group bound to an installation only accepts events from that + * installation (hard tenant isolation). Unbound groups accept everything here + * (their access is governed by `owners`/`providers` instead). + */ +export function groupAcceptsInstallation(group: Group, installationId?: number): boolean { + if (group.installationId == null) return true; + return installationId != null && group.installationId === installationId; +} + +/** + * Auto-provision a GitHub App installation on `installation.created`: + * 1. No-op when a group is already bound to this installation id. + * 2. Otherwise bind every unbound group whose `owners` match the installing + * account login (so existing org groups light up automatically). + * 3. Otherwise create a dedicated `inst-{installationId}` group bound to the + * installation. Returns the group that now owns the installation. + */ +export async function ensureInstallationGroup( + kv: KVNamespace, + installationId: number, + accountLogin: string, +): Promise { + const groups = await loadGroups(kv); + const existing = groups.find((g) => g.installationId === installationId); + if (existing) return existing; + + const login = accountLogin.trim().toLowerCase(); + const candidates = groups.filter( + (g) => + g.installationId == null && + login.length > 0 && + (g.owners ?? []).some((o) => o.trim().toLowerCase() === login), + ); + if (candidates.length > 0) { + const next = groups.map((g) => (candidates.includes(g) ? { ...g, installationId } : g)); + await saveGroups(kv, next); + return next.find((g) => g.id === candidates[0]!.id) ?? null; + } + + const gid = `inst-${installationId}`; + const dedicated = groups.find((g) => g.id === gid); + const group: Group = { + id: gid, + name: accountLogin.trim() || `Installation ${installationId}`, + adminIds: [], + installationId, + }; + await saveGroups( + kv, + dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group], + ); + return group; +} + export interface AccessScope { isSuper: boolean; /** Groups the user may view. When isSuper, this is every group. */ diff --git a/src/web/tenants.ts b/src/web/tenants.ts new file mode 100644 index 0000000..a627758 --- /dev/null +++ b/src/web/tenants.ts @@ -0,0 +1,38 @@ +import { log } from "../lib/log"; + +/** + * Per-group webhook tenant secrets. A group can opt into its own webhook + * ingress (`POST /webhook/{groupId}`) with an independent secret, so SaaS + * users can configure their own GitHub/Gitea/custom webhooks without sharing + * (or even knowing) the operator's global secrets. + */ +const TENANT_KEY = (groupId: string): string => `tenant:${groupId}`; + +/** 32 random bytes → 64 hex chars. */ +export function generateTenantSecret(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function getTenantSecret(kv: KVNamespace, groupId: string): Promise { + try { + return await kv.get(TENANT_KEY(groupId), "text"); + } catch (err) { + log.warn({ err, groupId }, "Failed to read tenant webhook secret"); + return null; + } +} + +/** Generate (or regenerate) the group's webhook secret. */ +export async function setTenantSecret(kv: KVNamespace, groupId: string): Promise { + const secret = generateTenantSecret(); + await kv.put(TENANT_KEY(groupId), secret); + return secret; +} + +export async function deleteTenantSecret(kv: KVNamespace, groupId: string): Promise { + await kv.delete(TENANT_KEY(groupId)); +} diff --git a/src/webhook.ts b/src/webhook.ts new file mode 100644 index 0000000..422940d --- /dev/null +++ b/src/webhook.ts @@ -0,0 +1,144 @@ +import type { Context } from "hono"; +import type { Env } from "./types"; +import { detectProvider } from "./providers"; +import { dispatchEvent } from "./core/dispatch"; +import { loadConfig } from "./config"; +import { loadGroups, ensureInstallationGroup } from "./web/groups"; +import { getTenantSecret } from "./web/tenants"; +import { recordAudit } from "./lib/audit"; +import { log } from "./lib/log"; + +const MAX_BODY_SIZE = 1024 * 1024; + +export interface WebhookResult { + status: 200 | 400 | 401 | 404 | 413; + body: unknown; +} + +/** + * Core webhook processing. Without `tenantId` this is the legacy global + * endpoint (`POST /webhook`): events verify against the operator's global + * secrets and may dispatch into every route. With a `tenantId` (a group id, + * `POST /webhook/{groupId}`) the group's own secret is used for verification + * (GITHUB_WEBHOOK_SECRET/GITEA_WEBHOOK_SECRET are overridden per request) and + * only that group's routes are eligible. + */ +export async function processWebhook( + env: Env, + body: string, + headers: Record, + waitUntil: (promise: Promise) => void, + tenantId?: string, +): Promise { + let effectiveEnv = env; + if (tenantId) { + const groups = await loadGroups(env.KV); + if (!groups.some((g) => g.id === tenantId)) { + return { status: 404, body: { error: "Group not found" } }; + } + const secret = await getTenantSecret(env.KV, tenantId); + if (!secret) { + return { status: 404, body: { error: "Webhook disabled for this group" } }; + } + effectiveEnv = { ...env, GITHUB_WEBHOOK_SECRET: secret, GITEA_WEBHOOK_SECRET: secret }; + } + + const provider = detectProvider(headers); + if (!provider) { + return { status: 400, body: { error: "Unknown webhook provider" } }; + } + + if (!(await provider.verify(body, headers, effectiveEnv))) { + return { status: 401, body: { error: "Invalid signature" } }; + } + + const event = provider.parse(body, headers); + if (!event) { + return { status: 400, body: { error: "Invalid event" } }; + } + + // Auto-provision GitHub App installations so tenant isolation is configured + // without manual id entry: a group is created (or existing matching groups + // are bound) before the event is dispatched. + if ( + provider.id === "github" && + event.event === "installation" && + event.payload.action === "created" && + event.installationId != null + ) { + const install = event.payload.installation as { account?: { login?: string } } | undefined; + const account = install?.account?.login ?? ""; + try { + const group = await ensureInstallationGroup(env.KV, event.installationId, account); + if (group) { + await recordAudit(env.DB, { + ts: Date.now(), + actorLogin: account || undefined, + action: "installation.created", + targetType: "group", + targetId: group.id, + groupId: group.id, + }); + } + } catch (err) { + log.warn( + { err, installationId: event.installationId }, + "Failed to auto-provision installation group", + ); + } + } + + if (event.deliveryId) { + // Tenant-scoped dedup keys: different accounts can reuse the same + // delivery id, so the global key would wrongly dedupe across tenants. + const key = tenantId + ? `delivery:${tenantId}:${event.deliveryId}` + : `delivery:${event.deliveryId}`; + const seen = await env.KV.get(key); + if (seen) { + return { status: 200, body: { ok: true, duplicate: true } }; + } + await env.KV.put(key, "1", { expirationTtl: 300 }); + } + + const config = await loadConfig(env); + if (tenantId) { + config.routes = config.routes.filter((r) => r.groupId === tenantId); + } + + const dispatch = dispatchEvent(config, event, env).catch((err) => + log.error(err, "Dispatch failed"), + ); + waitUntil(dispatch); + + return { status: 200, body: { ok: true } }; +} + +export async function handleWebhook( + c: Context<{ Bindings: Env }>, + tenantId?: string, +): Promise { + const contentLength = Number(c.req.header("content-length") ?? 0); + if (contentLength > MAX_BODY_SIZE) { + return c.json({ error: "Request too large" }, 413); + } + + const body = await c.req.text(); + if (body.length > MAX_BODY_SIZE) { + return c.json({ error: "Request too large" }, 413); + } + + const headers: Record = {}; + c.req.raw.headers.forEach((value, key) => { + headers[key] = value; + }); + + const result = await processWebhook( + c.env, + body, + headers, + (p) => c.executionCtx.waitUntil(p), + tenantId, + ); + return c.json(result.body, result.status); +}