The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
142 lines
5.3 KiB
TypeScript
142 lines
5.3 KiB
TypeScript
/*
|
|
Notices to the outside (stage 8): when HUB_WEBHOOK_URL is set, the hub POSTs a small JSON body
|
|
`{event, at, hub, items}` on a change of drift (`drift.found`, `drift.cleared`) and when an audited
|
|
write failed (`write.failed`). With HUB_WEBHOOK_SECRET the exact bytes of the body are signed:
|
|
`X-Hub-Signature: sha256=<hex hmac>`.
|
|
|
|
A delivery never blocks or fails the request that caused it: it runs on its own with a timeout and
|
|
at most one retry. Errors go to the server log with the HOST only (the URL may carry a token in its
|
|
query string) and never the error text. Items carry names and field names, never values.
|
|
*/
|
|
import { createHmac } from 'node:crypto'
|
|
import type { HubConfig } from './config.ts'
|
|
|
|
export type WebhookEvent = 'drift.found' | 'drift.cleared' | 'write.failed' | 'webhook.test'
|
|
|
|
export interface Delivery {
|
|
event: WebhookEvent
|
|
at: string
|
|
ok: boolean
|
|
/** `HTTP 500`, or an error code or name. Never a URL. */
|
|
error?: string
|
|
}
|
|
|
|
export interface WebhookStatus {
|
|
configured: boolean
|
|
/** Host and port only. */
|
|
host: string | null
|
|
signed: boolean
|
|
last: Delivery | null
|
|
}
|
|
|
|
export interface WebhookOptions {
|
|
fetch?: typeof fetch
|
|
timeoutMs?: number
|
|
retryDelayMs?: number
|
|
/** How long write.failed items are collected into one notice. */
|
|
batchMs?: number
|
|
now?: () => number
|
|
}
|
|
|
|
export const sign = (secret: string, body: string): string => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
|
|
|
export class Webhook {
|
|
#url: string | null
|
|
#secret: string | null
|
|
#domain: string
|
|
#host: string | null
|
|
#fetch: typeof fetch
|
|
#timeoutMs: number
|
|
#retryDelayMs: number
|
|
#batchMs: number
|
|
#now: () => number
|
|
#last: Delivery | null = null
|
|
#pending = new Set<Promise<unknown>>()
|
|
#queued: Record<string, unknown>[] = []
|
|
#timer: NodeJS.Timeout | null = null
|
|
|
|
constructor(config: Pick<HubConfig, 'webhookUrl' | 'webhookSecret' | 'domain'>, options: WebhookOptions = {}) {
|
|
this.#url = config.webhookUrl
|
|
this.#secret = config.webhookSecret
|
|
this.#domain = config.domain
|
|
this.#host = config.webhookUrl === null ? null : new URL(config.webhookUrl).host
|
|
this.#fetch = options.fetch ?? fetch
|
|
this.#timeoutMs = options.timeoutMs ?? 5000
|
|
this.#retryDelayMs = options.retryDelayMs ?? 1000
|
|
this.#batchMs = options.batchMs ?? 250
|
|
this.#now = options.now ?? Date.now
|
|
}
|
|
|
|
get configured(): boolean {
|
|
return this.#url !== null
|
|
}
|
|
|
|
status(): WebhookStatus {
|
|
return { configured: this.#url !== null, host: this.#host, signed: this.#secret !== null, last: this.#last }
|
|
}
|
|
|
|
/** Resolves when every delivery started so far has ended (for the tests). */
|
|
async settled(): Promise<void> {
|
|
if (this.#timer !== null) this.#flush()
|
|
while (this.#pending.size > 0) await Promise.allSettled([...this.#pending])
|
|
}
|
|
|
|
#body(event: WebhookEvent, items: Record<string, unknown>[]): string {
|
|
return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items })
|
|
}
|
|
|
|
async #attempt(body: string): Promise<string | null> {
|
|
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
if (this.#secret !== null) headers['x-hub-signature'] = sign(this.#secret, body)
|
|
try {
|
|
const res = await this.#fetch(this.#url as string, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(this.#timeoutMs) })
|
|
await res.body?.cancel().catch(() => undefined)
|
|
return res.status >= 200 && res.status < 300 ? null : `HTTP ${res.status}`
|
|
} catch (e) {
|
|
const code = (e as { cause?: { code?: unknown } })?.cause?.code ?? (e as { code?: unknown })?.code
|
|
return typeof code === 'string' ? code : e instanceof Error ? e.name : 'failed'
|
|
}
|
|
}
|
|
|
|
/* Delivers one notice and records the result. Returns it. */
|
|
async deliver(event: WebhookEvent, items: Record<string, unknown>[]): Promise<Delivery> {
|
|
if (this.#url === null) return { event, at: new Date(this.#now()).toISOString(), ok: false, error: 'not configured' }
|
|
const body = this.#body(event, items)
|
|
let error = await this.#attempt(body)
|
|
if (error !== null) {
|
|
if (this.#retryDelayMs > 0) await new Promise((r) => setTimeout(r, this.#retryDelayMs))
|
|
error = await this.#attempt(body)
|
|
}
|
|
const result: Delivery = { event, at: new Date(this.#now()).toISOString(), ok: error === null, ...(error === null ? {} : { error }) }
|
|
this.#last = result
|
|
if (error !== null) console.error(`hub: webhook delivery to ${this.#host} failed (${error}).`)
|
|
return result
|
|
}
|
|
|
|
/* Fire and forget: never throws, never waits. */
|
|
send(event: WebhookEvent, items: Record<string, unknown>[]): void {
|
|
if (this.#url === null || items.length === 0) return
|
|
const run = this.deliver(event, items).catch(() => undefined)
|
|
this.#pending.add(run)
|
|
void run.finally(() => this.#pending.delete(run))
|
|
}
|
|
|
|
/* Collects `write.failed` items for a moment, so ten failed servers make one notice. */
|
|
queueFailed(item: Record<string, unknown>): void {
|
|
if (this.#url === null) return
|
|
this.#queued.push(item)
|
|
if (this.#timer === null) {
|
|
this.#timer = setTimeout(() => this.#flush(), this.#batchMs)
|
|
this.#timer.unref()
|
|
}
|
|
}
|
|
|
|
#flush(): void {
|
|
if (this.#timer !== null) clearTimeout(this.#timer)
|
|
this.#timer = null
|
|
const items = this.#queued.splice(0, 50)
|
|
this.#queued = []
|
|
this.send('write.failed', items)
|
|
}
|
|
}
|