/** * Outbound anomaly notifications (webhook). * * Env: * PEARDATA_WEBHOOK_URL — POST JSON AnomalyEvent * PEARDATA_WEBHOOK_SECRET — HMAC key (+ optional legacy shared secret header) * PEARDATA_WEBHOOK_SIGN — default on when secret set; `0` disables HMAC * PEARDATA_WEBHOOK_LEGACY_SECRET — `1` also send X-PearData-Secret plaintext * PEARDATA_NOTIFY_CLEARS — 1 = also notify on clear * PEARDATA_NOTIFY_MIN_SEVERITY — warning | critical (default warning) * * Signed request headers: * X-PearData-Timestamp — unix ms * X-PearData-Signature — sha256= */ import http from 'http' import https from 'https' import crypto from 'crypto' import b4a from 'b4a' import logger from '../utils/logger.js' const log = logger.child('notify') export function isWebhookEnabled() { return Boolean(process.env.PEARDATA_WEBHOOK_URL) } export function isWebhookSigningEnabled() { if (!process.env.PEARDATA_WEBHOOK_SECRET) return false const v = process.env.PEARDATA_WEBHOOK_SIGN if (v === '0' || v === 'off' || v === 'false') return false return true } /** * @param {'warning'|'critical'} severity */ export function meetsMinSeverity(severity) { const min = String(process.env.PEARDATA_NOTIFY_MIN_SEVERITY || 'warning').toLowerCase() if (min === 'critical') return severity === 'critical' return severity === 'warning' || severity === 'critical' } /** * @param {import('../../shared/data-model.js').AnomalyEvent} ev */ export function shouldNotify(ev) { if (!isWebhookEnabled()) return false if (ev.cleared) { const v = process.env.PEARDATA_NOTIFY_CLEARS return v === '1' || v === 'on' || v === 'true' } return meetsMinSeverity(ev.severity) } /** * @param {string} secret * @param {string} timestamp * @param {string} body * @returns {string} sha256= */ export function signWebhookPayload(secret, timestamp, body) { const mac = crypto .createHmac('sha256', String(secret)) .update(`${timestamp}.${body}`) .digest('hex') return `sha256=${mac}` } /** * @param {string} secret * @param {string} timestamp * @param {string} body * @param {string} signatureHeader * @param {{ maxSkewMs?: number, now?: number }} [opts] */ export function verifyWebhookSignature(secret, timestamp, body, signatureHeader, opts = {}) { const now = opts.now ?? Date.now() const maxSkew = opts.maxSkewMs ?? 5 * 60_000 const ts = Number(timestamp) if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkew) return false const expected = signWebhookPayload(secret, String(timestamp), body) const got = String(signatureHeader || '') try { const a = b4a.from(expected) const b = b4a.from(got) if (a.length !== b.length) return false return crypto.timingSafeEqual(a, b) } catch { return expected === got } } /** * @param {string} url * @param {object} body * @param {number} [timeoutMs] */ export function postWebhook(url, body, timeoutMs = 8000) { return new Promise((resolve, reject) => { const payload = JSON.stringify(body) const u = new URL(url) const mod = u.protocol === 'https:' ? https : http const ts = String(Date.now()) /** @type {Record} */ const headers = { 'Content-Type': 'application/json', 'Content-Length': String(b4a.byteLength(payload)), 'User-Agent': 'peardata-notify/0.1', 'X-PearData-Timestamp': ts, } const secret = process.env.PEARDATA_WEBHOOK_SECRET if (secret && isWebhookSigningEnabled()) { headers['X-PearData-Signature'] = signWebhookPayload(secret, ts, payload) } if (secret && (process.env.PEARDATA_WEBHOOK_LEGACY_SECRET === '1' || !isWebhookSigningEnabled())) { headers['X-PearData-Secret'] = secret } const req = mod.request( { hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + u.search, method: 'POST', headers, timeout: timeoutMs, }, (res) => { let data = '' res.on('data', (c) => { data += c }) res.on('end', () => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`webhook HTTP ${res.statusCode}: ${data.slice(0, 160)}`)) return } resolve({ status: res.statusCode || 200, signed: Boolean(headers['X-PearData-Signature']), }) }) } ) req.on('timeout', () => { req.destroy() reject(new Error('webhook timeout')) }) req.on('error', reject) req.write(payload) req.end() }) } /** * Fire-and-forget notify for one anomaly event. * @param {import('../../shared/data-model.js').AnomalyEvent} ev */ export async function notifyAnomaly(ev) { if (!shouldNotify(ev)) return { skipped: true } const url = process.env.PEARDATA_WEBHOOK_URL try { const res = await postWebhook(url, { type: 'anomaly', ...ev, notifiedAt: Date.now(), }) log.info('Webhook delivered', { chart: ev.chart, severity: ev.severity, status: res.status, signed: res.signed, }) return { ok: true, ...res } } catch (err) { log.warn('Webhook failed', { error: err.message, chart: ev.chart }) return { ok: false, error: err.message } } }