Files
technitium-console/hub/webhook.ts
T
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
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 <noreply@anthropic.com>
2026-11-01 02:11:19 +07:00

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)
}
}