Vectrum
Sign In
260 lines · 9.7 KB · TypeScript
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 },
  });
}