import { randomUUID } from "node:crypto"; import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; import { isPrivateAddress, signature } from "./webhook-rules"; import { db, filter, findFirst, orNull, type RecordModel } from "./db"; import type { RepoEvent } from "./events"; import type { RepoRecord } from "./repos"; /* * Webhooks: a repository's events, POSTed as JSON to URLs its owner chose. Each delivery is signed * with the hook's secret (X-Vectrum-Signature-256, and GitHub's X-Hub-Signature-256 so existing * verifiers work), retried a few times when the receiver fails, and kept for a while to inspect. * * Hooks may not point at this machine or a private network unless WEBHOOKS_ALLOW_PRIVATE=1: on a * shared server, they'd let anyone with a repository reach services that aren't meant to be public. */ export type Webhook = { id: string; repoId: string; url: string; hasSecret: boolean; events: string[]; active: boolean; createdAt: number; lastDeliveryAt: number | null; lastStatus: number | null; }; export type Delivery = { id: string; event: string; delivery: string; status: number | null; error: string | null; durationMs: number; attempt: number; request: string; response: string; createdAt: number; }; export class WebhookError extends Error {} const toHook = (r: RecordModel): Webhook => ({ id: r.id, repoId: r.repo, url: r.url, hasSecret: !!r.secret, events: r.events ?? ["*"], active: r.active, createdAt: r.created_at, lastDeliveryAt: orNull(r.last_delivery_at), lastStatus: orNull(r.last_status), }); const toDelivery = (r: RecordModel): Delivery => ({ id: r.id, event: r.event, delivery: r.delivery, status: orNull(r.status), error: orNull(r.error), durationMs: r.duration_ms, attempt: r.attempt, request: r.request, response: r.response, createdAt: r.created_at, }); const ALLOW_PRIVATE = process.env.WEBHOOKS_ALLOW_PRIVATE === "1"; /** Retries after a failed delivery. */ const RETRY_DELAYS_MS = [30_000, 5 * 60_000]; const KEEP_DELIVERIES = 50; // --------------------------------------------------------------------------- // Addresses /** Checks a hook URL: http(s), and (unless allowed) not pointing into a private network. */ export async function checkHookUrl(input: string) { let url: URL; try { url = new URL(input.trim()); } catch { throw new WebhookError("Enter the full URL, e.g. https://example.com/hooks/vectrum."); } if (url.protocol !== "https:" && url.protocol !== "http:") throw new WebhookError("Webhook URLs must use https or http."); if (url.username || url.password) throw new WebhookError("Put credentials in the secret, not in the URL."); if (ALLOW_PRIVATE) return url; const host = url.hostname.replace(/^\[|\]$/g, ""); const addresses = isIP(host) ? [host] : await lookup(host, { all: true }).then((r) => r.map((a) => a.address)).catch(() => null); if (!addresses?.length) throw new WebhookError(`${url.hostname} can't be resolved.`); if (addresses.some(isPrivateAddress)) { throw new WebhookError(`${url.hostname} is a local or private address. The server's admin can allow those with WEBHOOKS_ALLOW_PRIVATE=1.`); } return url; } // --------------------------------------------------------------------------- // Hooks export async function listWebhooks(repoId: string) { const rows = await (await db()).collection("webhooks").getFullList({ filter: filter("repo = {:repoId}", { repoId }), sort: "created_at" }); return rows.map(toHook); } export async function getWebhook(repoId: string, hookId: string) { const row = await findFirst("webhooks", filter("id = {:hookId} && repo = {:repoId}", { hookId, repoId })); return row ? toHook(row) : null; } function cleanEvents(events: string[], known: readonly string[]) { if (events.includes("*")) return ["*"]; const chosen = [...new Set(events)].filter((e) => known.includes(e)); if (chosen.length === 0) throw new WebhookError("Choose at least one event."); return chosen; } export async function createWebhook(repoId: string, input: { url: string; secret: string; events: string[] }, known: readonly string[]) { const url = await checkHookUrl(input.url); const row = await (await db()).collection("webhooks").create({ repo: repoId, url: url.toString(), secret: input.secret.trim().slice(0, 200), events: cleanEvents(input.events, known), active: true, created_at: Date.now(), }); return toHook(row); } export async function updateWebhook( repoId: string, hookId: string, input: { url?: string; secret?: string; events?: string[]; active?: boolean }, known: readonly string[], ) { const hook = await getWebhook(repoId, hookId); if (!hook) throw new WebhookError("That webhook was deleted."); const fields: Record = {}; if (input.url !== undefined) fields.url = (await checkHookUrl(input.url)).toString(); if (input.secret !== undefined) fields.secret = input.secret.trim().slice(0, 200); if (input.events !== undefined) fields.events = cleanEvents(input.events, known); if (input.active !== undefined) fields.active = input.active; return toHook(await (await db()).collection("webhooks").update(hook.id, fields)); } export async function deleteWebhook(repoId: string, hookId: string) { const hook = await getWebhook(repoId, hookId); if (hook) await (await db()).collection("webhooks").delete(hook.id); } export async function listDeliveries(hookId: string, limit = 20) { const { items } = await (await db()).collection("webhook_deliveries").getList(1, limit, { filter: filter("webhook = {:hookId}", { hookId }), sort: "-created_at,-@rowid", skipTotal: true, }); return items.map(toDelivery); } // --------------------------------------------------------------------------- // Delivering async function send(hook: RecordModel, kind: string, delivery: string, body: string, attempt: number) { const started = Date.now(); let status = 0; let error = ""; let response = ""; try { await checkHookUrl(hook.url); const headers: Record = { "Content-Type": "application/json", "User-Agent": "Vectrum-Hookshot/1", "X-Vectrum-Event": kind, "X-Vectrum-Delivery": delivery, "X-Vectrum-Hook-ID": hook.id, "X-GitHub-Event": kind, "X-GitHub-Delivery": delivery, }; if (hook.secret) { headers["X-Vectrum-Signature-256"] = signature(hook.secret, body); headers["X-Hub-Signature-256"] = headers["X-Vectrum-Signature-256"]; } // Redirects aren't followed: they could lead past the address check. const res = await fetch(hook.url, { method: "POST", headers, body, redirect: "manual", signal: AbortSignal.timeout(10_000) }); status = res.status; response = (await res.text().catch(() => "")).slice(0, 10_000); } catch (e) { error = e instanceof Error ? (e.name === "TimeoutError" ? "Timed out after 10 seconds." : e.message) : String(e); } const pb = await db(); const now = Date.now(); await pb.collection("webhook_deliveries").create({ webhook: hook.id, event: kind, delivery, status, error: error.slice(0, 2000), duration_ms: now - started, attempt, request: body.slice(0, 100_000), response, created_at: now, }); await pb.collection("webhooks").update(hook.id, { last_delivery_at: now, last_status: status }); pruneDeliveries(hook.id).catch(() => {}); return { ok: status >= 200 && status < 300, retry: status === 0 || status >= 500 || status === 429 }; } async function pruneDeliveries(hookId: string) { const pb = await db(); const { items } = await pb.collection("webhook_deliveries").getList(2, KEEP_DELIVERIES, { filter: filter("webhook = {:hookId}", { hookId }), sort: "-created_at,-@rowid", fields: "id", skipTotal: true, }); for (const { id } of items) await pb.collection("webhook_deliveries").delete(id); } /** Sends one payload to one hook, retrying in the background if the receiver fails. */ async function deliver(hook: RecordModel, kind: string, payload: unknown) { const delivery = randomUUID(); const body = JSON.stringify(payload); const attempt = async (n: number) => { const result = await send(hook, kind, delivery, body, n); if (!result.ok && result.retry && n <= RETRY_DELAYS_MS.length) { setTimeout(() => attempt(n + 1).catch((e) => console.error("[webhooks] retry failed:", e)), RETRY_DELAYS_MS[n - 1]).unref(); } return result; }; return attempt(1); } const wants = (hook: RecordModel, kind: string) => { const events: string[] = hook.events ?? ["*"]; return events.includes("*") || events.includes(kind); }; /** The body a webhook receives for an event. */ export const eventBody = (e: RepoEvent) => ({ event: e.kind, id: e.id, seq: e.seq, created_at: new Date(e.createdAt).toISOString(), ...e.payload }); /** Hands an event to the repository's active hooks that want it. */ export async function deliverEvent(repo: RepoRecord, event: RepoEvent) { const hooks = await (await db()).collection("webhooks").getFullList({ filter: filter("repo = {:repoId} && active = true", { repoId: repo.id }) }); await Promise.all(hooks.filter((h) => wants(h, event.kind)).map((h) => deliver(h, event.kind, eventBody(event)))); } /** Sends a ping, to check a hook is set up right. Resolves with how it went. */ export async function pingWebhook(repo: RepoRecord, hookId: string) { const row = await findFirst("webhooks", filter("id = {:hookId} && repo = {:repoId}", { hookId, repoId: repo.id })); if (!row) throw new WebhookError("That webhook was deleted."); return deliver(row, "ping", { event: "ping", zen: "Agents first, people in charge.", hook_id: row.id, events: row.events, repository: { full_name: `${repo.owner}/${repo.name}`, owner: repo.owner, name: repo.name, visibility: repo.visibility }, }); }