// Webhook receiver for Terminal alert deliveries. // Header: Terminal-Signature: t=,v1=.")>. // Refuse a missing or bad signature, refuse a t more than 300 seconds off, drop a seen id, // and log a sequence gap. handleEvent only logs. This file does not place an order or call the API. // The same checks, in the same order, live in ../python/receiver.py. // An id counts as seen only after handleEvent returned, and the answer is sent after that, so a // handler that fails answers 500 and Terminal sends the event again. import { createHmac, timingSafeEqual } from "node:crypto" import { readFileSync, realpathSync } from "node:fs" import { createServer } from "node:http" import { fileURLToPath } from "node:url" export const TOLERANCE_SECONDS = 300 export const SEEN_CAP = 10000 export const BODY_LIMIT = 1000000 const VERDICTS = { accepted: 200, accepted_gap: 200, dropped_duplicate: 200, refused_missing_header: 401, refused_signature: 401, refused_stale: 401, refused_body: 400, handler_failed: 500, in_progress: 503, } export function handleEvent(event, log = console.error) { const sequence = event.sequence == null ? "null" : event.sequence log(`event ${event.id} sequence ${sequence} type ${event.type}`) } export function newState(cap = SEEN_CAP) { return { seen: new Map(), last: new Map(), inflight: new Set(), cap } } function done(verdict) { return { verdict, status: VERDICTS[verdict], event: null, gap: null } } function headerValue(headers, name) { if (!headers) return undefined const want = name.toLowerCase() for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() !== want) continue const first = Array.isArray(value) ? value[0] : value return first == null ? "" : String(first) } return undefined } // t=,v1=. A part named twice is refused, so a second header or // a second v1 cannot stand in for the first. function signatureFields(header) { const fields = {} for (const part of String(header).split(",")) { const eq = part.indexOf("=") if (eq === -1) return null const key = part.slice(0, eq).trim() const value = part.slice(eq + 1).trim() if (!key || Object.hasOwn(fields, key)) return null fields[key] = value } return fields } function digestHex(secret, raw, t) { return createHmac("sha256", secret).update(`${t}.`).update(raw).digest("hex") } // Terminal sends 64 lowercase hex characters. Anything else is refused before // the compare, which needs two buffers of the same length. function hexMatches(expected, given) { if (typeof given !== "string" || !/^[0-9a-f]{64}$/.test(given)) return false return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(given, "hex")) } const UTF8 = new TextDecoder("utf-8", { fatal: true }) function sequenceNumber(value) { if (typeof value !== "number" || !Number.isInteger(value)) return null return value } function remember(state, id) { state.seen.delete(id) state.seen.set(id, true) while (state.seen.size > state.cap) { const oldest = state.seen.keys().next().value state.seen.delete(oldest) } } function destinationOf(url) { const path = String(url || "/").split("?")[0] return path || "/" } // Order: header, signature, window, duplicate id, then sequence. judge only // reads the state: commit records the id and the sequence once the event was // handled. A failed signature never counts as seen, so a bad delivery cannot // block a later good one. export function judge({ secret, headers, body, nowMs, state, destination = "/" }) { const header = headerValue(headers, "Terminal-Signature") if (!header) return done("refused_missing_header") const fields = signatureFields(header) if (!fields || !/^[0-9]{1,15}$/.test(fields.t || "") || !fields.v1) return done("refused_signature") const t = Number(fields.t) const raw = Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8") const expected = digestHex(secret, raw, t) if (!hexMatches(expected, fields.v1)) return done("refused_signature") if (!Number.isFinite(nowMs) || Math.abs(nowMs / 1000 - t) > TOLERANCE_SECONDS) return done("refused_stale") let event try { event = JSON.parse(UTF8.decode(raw)) } catch { return done("refused_body") } if (!event || typeof event !== "object" || Array.isArray(event)) return done("refused_body") if (typeof event.id !== "string" || event.id.length === 0 || event.id.length > 200) return done("refused_body") if (state.seen.has(event.id)) return done("dropped_duplicate") const sequence = sequenceNumber(event.sequence) let gap = null if (sequence != null) { const last = state.last.get(destination) // A lower number is a late send of an earlier alert, not a new gap. if (last != null && sequence > last + 1) { gap = `sequence gap on ${destination}: had ${last}, got ${sequence}. Will read GET /v1/events to fill it.` } } return { verdict: gap ? "accepted_gap" : "accepted", status: 200, event, gap, sequence, destination } } // Records a handled event: its id as seen, and the highest sequence of its path. export function commit(state, result) { remember(state, result.event.id) if (result.sequence != null) { const last = state.last.get(result.destination) if (last == null || result.sequence > last) state.last.set(result.destination, result.sequence) } } // judge, then handleEvent (it may be async), then commit. A handler that // throws or rejects leaves the id unseen and answers 500, so Terminal's retry // brings the event back. A copy that arrives while the same id is still being // handled answers 503: Terminal tries again later, and by then the id is seen // or free again. async function receive(input, { state, write, handle = handleEvent }) { const result = judge({ ...input, state }) if (!result.event) return result const id = result.event.id if (state.inflight.has(id)) return done("in_progress") if (result.gap) write(result.gap) state.inflight.add(id) try { await handle(result.event, write) } catch { state.inflight.delete(id) write(`handleEvent failed for ${id}. Answered 500, so Terminal sends it again.`) return done("handler_failed") } commit(state, result) state.inflight.delete(id) return result } function send(res, status, verdict) { const payload = Buffer.from(verdict) res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "content-length": String(payload.length), }) res.end(payload) } // handle: your code for a new event (default handleEvent, which only logs). export function startReceiver({ secret, host = "127.0.0.1", port = 0, now, log, seenCap = SEEN_CAP, handle = handleEvent } = {}) { if (!secret) throw new Error("Set TERMINAL_WEBHOOK_SECRET") const state = newState(seenCap) const gaps = [] const events = [] const write = log || console.error const clock = now || (() => Date.now()) const server = createServer((req, res) => { if (req.method !== "POST") { send(res, 405, "method_not_allowed") return } const chunks = [] let size = 0 let settled = false const fail = (status, verdict) => { if (settled) return settled = true send(res, status, verdict) } req.on("data", (chunk) => { if (settled) return size += chunk.length if (size > BODY_LIMIT) { fail(400, "refused_body") req.destroy() return } chunks.push(chunk) }) req.on("error", () => fail(400, "refused_body")) req.on("end", async () => { if (settled) return settled = true // Node joins a repeated header into one value; Terminal sends one. const signatureHeaders = req.rawHeaders.filter((name, i) => i % 2 === 0 && name.toLowerCase() === "terminal-signature") if (signatureHeaders.length > 1) { send(res, 401, "refused_signature") return } let result try { result = await receive({ secret, headers: req.headers, body: Buffer.concat(chunks), nowMs: clock(), destination: destinationOf(req.url), }, { state, write, handle: async (event, log) => { await handle(event, log) events.push(event) }, }) } catch { result = done("refused_body") } if (result.gap) gaps.push(result.gap) send(res, result.status, result.verdict) }) }) return new Promise((resolve, reject) => { const onError = (error) => reject(error) server.once("error", onError) server.listen(port, host, () => { server.removeListener("error", onError) const address = server.address() resolve({ url: `http://${host}:${address.port}/`, port: address.port, gaps, events, close: () => new Promise((done) => { server.closeAllConnections() server.close(() => done()) }), }) }) }) } export async function dryRun(file, secret, log = console.error) { if (!secret) throw new Error("Set TERMINAL_WEBHOOK_SECRET") const text = readFileSync(file, "utf8") const state = newState() const verdicts = [] for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue let record try { record = JSON.parse(line) } catch { verdicts.push("refused_body") continue } if (!record || typeof record.body !== "string" || !record.headers || typeof record.headers !== "object") { verdicts.push("refused_body") continue } let nowMs = Date.now() if (record.now != null) nowMs = Number(record.now) * 1000 const destination = typeof record.path === "string" && record.path ? record.path : "/" const result = await receive({ secret, headers: record.headers, body: record.body, nowMs, destination }, { state, write: log }) verdicts.push(result.verdict) } return verdicts } function isDirect() { if (!process.argv[1]) return false try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]) } catch { return false } } function main() { const secret = process.env.TERMINAL_WEBHOOK_SECRET || "" const dry = process.argv.indexOf("--dry-run") if (dry !== -1) { const file = process.argv[dry + 1] if (!secret || !file) { console.error("Set TERMINAL_WEBHOOK_SECRET and pass --dry-run .") process.exit(1) } dryRun(file, secret).then((verdicts) => { process.stdout.write(`${verdicts.join("\n")}\n`) }).catch((error) => { console.error(error.message) process.exit(1) }) return } if (!secret) { console.error("Set TERMINAL_WEBHOOK_SECRET to the signing secret from the webhook destination.") process.exit(1) } const port = Number(process.env.PORT || 8080) const host = process.env.HOST || "127.0.0.1" if (!Number.isInteger(port) || port < 0 || port > 65535) { console.error("PORT must be a whole number from 0 to 65535.") process.exit(1) } startReceiver({ secret, host, port }).then((receiver) => { console.error(`listening on ${receiver.url}`) }).catch((error) => { console.error(error.message) process.exit(1) }) } if (isDirect()) main()