181 lines
5.3 KiB
JavaScript
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 }
|
|
}
|
|
}
|