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<string, unknown> = {};
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<string, string> = {
"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 },
});
}