Files
peardata/server/services/notify.js
T
Raven Scott 462cce4b5b
CI / test (push) Successful in 58s
Release rolling / release (push) Successful in 7m10s
Updates
2026-07-18 19:16:58 -04:00

181 lines
5.3 KiB
JavaScript

/**
* 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=<hex HMAC-SHA256(secret, `${ts}.${body}`)>
*/
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=<hex>
*/
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<string, string>} */
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 }
}
}