Updates
This commit is contained in:
+149
-11
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* Threshold-based anomaly detection (MVP).
|
||||
* Later: scoring, k-means consensus, ML jobs.
|
||||
* Threshold-based anomaly detection (+ optional z-score hybrid).
|
||||
*
|
||||
* PEARDATA_ANOMALY_MODE:
|
||||
* threshold (default) — fixed warn/crit
|
||||
* zscore — fire when |z| exceeds warnZ/critZ
|
||||
* hybrid — threshold OR z-score
|
||||
*/
|
||||
import { EventEmitter } from 'events'
|
||||
import { normalizeAnomaly } from '../../shared/data-model.js'
|
||||
import { stats, zScore, thresholdsFromStats, seriesFromStore } from './zscore.js'
|
||||
|
||||
/** @typedef {import('../../shared/data-model.js').AlertConfig} AlertConfig */
|
||||
/** @typedef {import('../../shared/data-model.js').AnomalyEvent} AnomalyEvent */
|
||||
@@ -78,6 +83,38 @@ function compare(op, value, threshold) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous score in [0, 1] from how far value sits past warn→crit.
|
||||
* @param {number} value
|
||||
* @param {number|null} warn
|
||||
* @param {number|null} crit
|
||||
* @param {string} op
|
||||
* @param {'warning'|'critical'} severity
|
||||
*/
|
||||
export function scoreSeverity(value, warn, crit, op, severity) {
|
||||
if (severity === 'critical') {
|
||||
if (warn == null || crit == null || warn === crit) return 1
|
||||
const span = Math.abs(crit - warn) || 1
|
||||
if (op === '<' || op === '<=') {
|
||||
// lower is worse: crit < warn
|
||||
const over = Math.max(0, warn - value)
|
||||
return Math.min(1, 0.6 + 0.4 * Math.min(1, over / span))
|
||||
}
|
||||
const over = Math.max(0, value - warn)
|
||||
return Math.min(1, 0.6 + 0.4 * Math.min(1, over / span))
|
||||
}
|
||||
// warning band
|
||||
if (warn == null) return 0.6
|
||||
if (crit == null) return 0.6
|
||||
const span = Math.abs(crit - warn) || 1
|
||||
if (op === '<' || op === '<=') {
|
||||
const into = Math.max(0, warn - value)
|
||||
return Math.min(0.99, 0.35 + 0.25 * Math.min(1, into / span))
|
||||
}
|
||||
const into = Math.max(0, value - warn)
|
||||
return Math.min(0.99, 0.35 + 0.25 * Math.min(1, into / span))
|
||||
}
|
||||
|
||||
export class AnomalyEngine extends EventEmitter {
|
||||
/**
|
||||
* @param {{ cpuCount?: number }} [opts]
|
||||
@@ -85,13 +122,84 @@ export class AnomalyEngine extends EventEmitter {
|
||||
constructor(opts = {}) {
|
||||
super()
|
||||
this.cpuCount = opts.cpuCount || 1
|
||||
/** @type {Map<string, AlertConfig & { _dynamicLoad?: boolean }>} */
|
||||
/** @type {Map<string, AlertConfig & { _dynamicLoad?: boolean, _mean?: number, _stdev?: number }>} */
|
||||
this.configs = new Map(DEFAULT_THRESHOLDS.map((c) => [c.id, { ...c }]))
|
||||
/** @type {Map<string, string>} status CLEAR|WARNING|CRITICAL */
|
||||
this.status = new Map()
|
||||
/** @type {AnomalyEvent[]} */
|
||||
this.recent = []
|
||||
this.recentMax = 500
|
||||
/** @type {Map<string, number[]>} rolling values for z-score */
|
||||
this._windows = new Map()
|
||||
this.windowMax = Number(process.env.PEARDATA_ANOMALY_WINDOW) || 120
|
||||
this.warnZ = Number(process.env.PEARDATA_ANOMALY_WARN_Z) || 2
|
||||
this.critZ = Number(process.env.PEARDATA_ANOMALY_CRIT_Z) || 3
|
||||
}
|
||||
|
||||
anomalyMode() {
|
||||
const m = String(process.env.PEARDATA_ANOMALY_MODE || 'threshold').toLowerCase()
|
||||
if (m === 'zscore' || m === 'hybrid') return m
|
||||
return 'threshold'
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a sample into the rolling window for a config.
|
||||
* @param {string} cfgId
|
||||
* @param {number} value
|
||||
*/
|
||||
_pushWindow(cfgId, value) {
|
||||
let arr = this._windows.get(cfgId)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
this._windows.set(cfgId, arr)
|
||||
}
|
||||
arr.push(value)
|
||||
while (arr.length > this.windowMax) arr.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrain warn/crit from MetricStore history (or rolling windows).
|
||||
* @param {import('./store.js').MetricStore} [store]
|
||||
* @param {{ warnZ?: number, critZ?: number, minPoints?: number }} [opts]
|
||||
*/
|
||||
retrain(store = null, opts = {}) {
|
||||
const warnZ = opts.warnZ ?? this.warnZ
|
||||
const critZ = opts.critZ ?? this.critZ
|
||||
const minPoints = opts.minPoints ?? 30
|
||||
/** @type {Array<{ id: string, n: number, mean: number, stdev: number, warn: number, crit: number }>} */
|
||||
const updated = []
|
||||
|
||||
for (const cfg of this.configs.values()) {
|
||||
if (cfg._dynamicLoad) continue
|
||||
let values = []
|
||||
if (store) {
|
||||
values = seriesFromStore(store, cfg.chart, cfg.dimension, this.windowMax)
|
||||
}
|
||||
if (values.length < minPoints) {
|
||||
values = this._windows.get(cfg.id) || values
|
||||
}
|
||||
if (values.length < minPoints) continue
|
||||
|
||||
const s = stats(values)
|
||||
const thr = thresholdsFromStats(s, cfg.comparator || '>', { warnZ, critZ })
|
||||
this.setConfig({
|
||||
...cfg,
|
||||
warn: thr.warn,
|
||||
crit: thr.crit,
|
||||
_mean: thr.mean,
|
||||
_stdev: thr.stdev,
|
||||
info: cfg.info,
|
||||
})
|
||||
updated.push({
|
||||
id: cfg.id,
|
||||
n: s.n,
|
||||
mean: thr.mean,
|
||||
stdev: thr.stdev,
|
||||
warn: thr.warn,
|
||||
crit: thr.crit,
|
||||
})
|
||||
}
|
||||
return { ok: true, mode: this.anomalyMode(), updated, warnZ, critZ }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +244,8 @@ export class AnomalyEngine extends EventEmitter {
|
||||
const value = sample.values[cfg.dimension]
|
||||
if (value == null) continue
|
||||
|
||||
this._pushWindow(cfg.id, value)
|
||||
|
||||
let warn = cfg.warn
|
||||
let crit = cfg.crit
|
||||
if (cfg._dynamicLoad) {
|
||||
@@ -144,14 +254,38 @@ export class AnomalyEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
const op = cfg.comparator || '>'
|
||||
const mode = this.anomalyMode()
|
||||
let severity = null
|
||||
let threshold = null
|
||||
if (compare(op, value, crit)) {
|
||||
severity = 'critical'
|
||||
threshold = crit
|
||||
} else if (compare(op, value, warn)) {
|
||||
severity = 'warning'
|
||||
threshold = warn
|
||||
let z = null
|
||||
|
||||
if (mode === 'threshold' || mode === 'hybrid') {
|
||||
if (compare(op, value, crit)) {
|
||||
severity = 'critical'
|
||||
threshold = crit
|
||||
} else if (compare(op, value, warn)) {
|
||||
severity = 'warning'
|
||||
threshold = warn
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'zscore' || mode === 'hybrid') {
|
||||
const win = this._windows.get(cfg.id) || []
|
||||
if (win.length >= 15) {
|
||||
const s = stats(win.slice(0, -1)) // exclude current for baseline
|
||||
z = zScore(value, s.mean, s.stdev)
|
||||
const absZ = Math.abs(z)
|
||||
let zSev = null
|
||||
if (absZ >= this.critZ) zSev = 'critical'
|
||||
else if (absZ >= this.warnZ) zSev = 'warning'
|
||||
if (zSev) {
|
||||
const rank = { warning: 1, critical: 2 }
|
||||
if (!severity || rank[zSev] > rank[severity]) {
|
||||
severity = zSev
|
||||
threshold = zSev === 'critical' ? this.critZ : this.warnZ
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prev = this.status.get(cfg.id) || 'CLEAR'
|
||||
@@ -177,7 +311,11 @@ export class AnomalyEngine extends EventEmitter {
|
||||
this._push(cleared)
|
||||
fired.push(cleared)
|
||||
} else if (severity) {
|
||||
const score = severity === 'critical' ? 1 : 0.6
|
||||
const score =
|
||||
z != null
|
||||
? Math.min(1, Math.abs(z) / Math.max(this.critZ, 1))
|
||||
: scoreSeverity(value, warn, crit, op, severity)
|
||||
const zPart = z != null ? ` z=${round2(z)}` : ''
|
||||
const ev = normalizeAnomaly({
|
||||
id: `${cfg.id}:${sample.ts}`,
|
||||
chart: cfg.chart,
|
||||
@@ -188,7 +326,7 @@ export class AnomalyEngine extends EventEmitter {
|
||||
value,
|
||||
threshold,
|
||||
comparator: op,
|
||||
message: `${cfg.info || cfg.id}: ${cfg.dimension}=${round2(value)} ${op} ${threshold}`,
|
||||
message: `${cfg.info || cfg.id}: ${cfg.dimension}=${round2(value)} ${op} ${threshold}${zPart} (score ${score.toFixed(2)})`,
|
||||
ts: sample.ts,
|
||||
})
|
||||
this._push(ev)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* PearDock bridge collector (Phase 3 spike).
|
||||
*
|
||||
* Dials dock / agent peers and remaps their docker/container charts into
|
||||
* peardock.* — no PearDock source copied (AGPL boundary).
|
||||
*
|
||||
* Enable: PEARDATA_PEARDOCK=1
|
||||
* Peers: PEARDATA_PEARDOCK_PEERS=hex,hex (or PEARDATA_PEARDOCK_PEERS_FILE)
|
||||
* Optional: PEARDATA_PEARDOCK_SEED, PEARDATA_PEARDOCK_POLL_MS
|
||||
*
|
||||
* Charts: peardock.containers, peardock.cpu.<id>, peardock.mem.<id>
|
||||
* (sourced from remote docker.* charts when present)
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import { EventEmitter } from 'events'
|
||||
import { PearDataConnection } from '../../../client/connection.js'
|
||||
import { Methods } from '../../../shared/protocol.js'
|
||||
import { registerChart } from '../../../shared/metrics.js'
|
||||
import logger from '../../utils/logger.js'
|
||||
|
||||
const log = logger.child('peardock')
|
||||
|
||||
export function isPearDockEnabled() {
|
||||
const v = process.env.PEARDATA_PEARDOCK
|
||||
return v === '1' || v === 'on' || v === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function parsePearDockPeers() {
|
||||
const raw = process.env.PEARDATA_PEARDOCK_PEERS || ''
|
||||
const fromEnv = raw
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => /^[0-9a-f]{64}$/.test(s))
|
||||
const file = process.env.PEARDATA_PEARDOCK_PEERS_FILE
|
||||
if (!file) return [...new Set(fromEnv)]
|
||||
try {
|
||||
const text = fs.readFileSync(file, 'utf8')
|
||||
const fromFile = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/#.*$/, '').trim().toLowerCase())
|
||||
.filter((s) => /^[0-9a-f]{64}$/.test(s))
|
||||
return [...new Set([...fromEnv, ...fromFile])]
|
||||
} catch {
|
||||
return [...new Set(fromEnv)]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map remote docker/container chart id → peardock chart id.
|
||||
* @param {string} chartId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function remapDockChart(chartId) {
|
||||
const id = String(chartId || '')
|
||||
if (id.startsWith('docker.')) return `peardock.${id.slice('docker.'.length)}`
|
||||
if (id.startsWith('container.')) return `peardock.${id.slice('container.'.length)}`
|
||||
if (id.startsWith('cgroup.docker.')) return `peardock.${id.slice('cgroup.docker.'.length)}`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} metrics getAllMetrics-style payload
|
||||
* @returns {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string }>}
|
||||
*/
|
||||
export function extractDockCharts(metrics) {
|
||||
const root = metrics?.body || metrics
|
||||
const charts = root?.charts || {}
|
||||
/** @type {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string }>} */
|
||||
const out = []
|
||||
for (const [chartId, point] of Object.entries(charts)) {
|
||||
const mapped = remapDockChart(chartId)
|
||||
if (!mapped) continue
|
||||
const values = point?.dimensions || point?.values || {}
|
||||
/** @type {Record<string, number|null>} */
|
||||
const nums = {}
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
const n = typeof v === 'object' && v != null ? Number(v.value) : Number(v)
|
||||
nums[k] = Number.isFinite(n) ? n : null
|
||||
}
|
||||
const parts = mapped.split('.')
|
||||
const context = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : mapped
|
||||
out.push({
|
||||
chart: mapped,
|
||||
context,
|
||||
values: nums,
|
||||
sourceChart: chartId,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function registerMappedChart(mappedId, context, values) {
|
||||
const dims = Object.keys(values).map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
algorithm: 'absolute',
|
||||
}))
|
||||
if (!dims.length) dims.push({ id: 'value', name: 'value', algorithm: 'absolute' })
|
||||
registerChart({
|
||||
id: mappedId,
|
||||
name: mappedId,
|
||||
context,
|
||||
title: mappedId,
|
||||
units: context.includes('mem') ? 'MiB' : context.includes('cpu') ? 'percentage' : 'count',
|
||||
family: 'peardock',
|
||||
chartType: 'line',
|
||||
priority: 8500,
|
||||
plugin: 'peardock',
|
||||
dimensions: dims,
|
||||
})
|
||||
}
|
||||
|
||||
export class PearDockCollector extends EventEmitter {
|
||||
constructor(opts = {}) {
|
||||
super()
|
||||
this.pollMs = opts.pollMs || Number(process.env.PEARDATA_PEARDOCK_POLL_MS) || 5000
|
||||
this.adminSeed = opts.adminSeed || process.env.PEARDATA_PEARDOCK_SEED || null
|
||||
this.peerKeys = opts.peers || parsePearDockPeers()
|
||||
/** @type {Map<string, PearDataConnection|null>} */
|
||||
this.conns = new Map()
|
||||
this._timer = null
|
||||
this._busy = false
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this._timer) return
|
||||
registerChart({
|
||||
id: 'peardock.containers',
|
||||
name: 'peardock.containers',
|
||||
context: 'peardock.containers',
|
||||
title: 'PearDock bridged containers',
|
||||
units: 'containers',
|
||||
family: 'peardock',
|
||||
chartType: 'line',
|
||||
priority: 8490,
|
||||
plugin: 'peardock',
|
||||
dimensions: [
|
||||
{ id: 'charts', name: 'charts', algorithm: 'absolute' },
|
||||
{ id: 'peers', name: 'peers', algorithm: 'absolute' },
|
||||
],
|
||||
})
|
||||
log.info('PearDock bridge started', { peers: this.peerKeys.length, pollMs: this.pollMs })
|
||||
this._tick()
|
||||
this._timer = setInterval(() => this._tick(), this.pollMs)
|
||||
if (typeof this._timer.unref === 'function') this._timer.unref()
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer)
|
||||
this._timer = null
|
||||
}
|
||||
for (const [pk, conn] of this.conns) {
|
||||
if (conn) conn.destroy().catch(() => {})
|
||||
this.conns.set(pk, null)
|
||||
}
|
||||
}
|
||||
|
||||
async _ensure(pk) {
|
||||
let conn = this.conns.get(pk)
|
||||
if (conn?.connected) return conn
|
||||
try {
|
||||
if (conn) await conn.destroy().catch(() => {})
|
||||
conn = new PearDataConnection(pk, {
|
||||
adminSeed: this.adminSeed,
|
||||
timeoutMs: 20_000,
|
||||
})
|
||||
await conn.connect()
|
||||
this.conns.set(pk, conn)
|
||||
conn.on('disconnected', () => this.conns.set(pk, null))
|
||||
return conn
|
||||
} catch (err) {
|
||||
this.conns.set(pk, null)
|
||||
log.warn('PearDock dial failed', { peer: pk.slice(0, 12), error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async _tick() {
|
||||
if (this._busy) return
|
||||
this._busy = true
|
||||
try {
|
||||
const ts = Date.now()
|
||||
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||
const batch = []
|
||||
let peersUp = 0
|
||||
let chartCount = 0
|
||||
|
||||
for (const pk of this.peerKeys) {
|
||||
const conn = await this._ensure(pk)
|
||||
if (!conn) continue
|
||||
try {
|
||||
const metrics = await conn.request(Methods.getAllMetrics, { format: 'json' })
|
||||
const mapped = extractDockCharts(metrics)
|
||||
peersUp++
|
||||
for (const row of mapped) {
|
||||
registerMappedChart(row.chart, row.context, row.values)
|
||||
batch.push({
|
||||
chart: row.chart,
|
||||
context: row.context,
|
||||
ts,
|
||||
values: row.values,
|
||||
})
|
||||
chartCount++
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('PearDock poll failed', { peer: pk.slice(0, 12), error: err.message })
|
||||
try {
|
||||
await conn.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.conns.set(pk, null)
|
||||
}
|
||||
}
|
||||
|
||||
batch.unshift({
|
||||
chart: 'peardock.containers',
|
||||
context: 'peardock.containers',
|
||||
ts,
|
||||
values: { charts: chartCount, peers: peersUp },
|
||||
})
|
||||
this.emit('samples', batch)
|
||||
} finally {
|
||||
this._busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {PearDockCollector|null} */
|
||||
let singleton = null
|
||||
|
||||
export function getPearDockCollector() {
|
||||
if (!singleton) singleton = new PearDockCollector()
|
||||
return singleton
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Pushes } from '../../shared/protocol.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
import { getCollector } from './collector.js'
|
||||
import { getStore } from './store.js'
|
||||
import { getAnomalyEngine } from './anomaly.js'
|
||||
import { jobExportSnapshot, jobPrometheusPush } from './export.js'
|
||||
|
||||
const JOB_HANDLERS = {
|
||||
@@ -20,6 +21,14 @@ const JOB_HANDLERS = {
|
||||
},
|
||||
exportSnapshot: jobExportSnapshot,
|
||||
prometheusPush: jobPrometheusPush,
|
||||
retrainAnomaly: async (args = {}) => {
|
||||
const eng = getAnomalyEngine()
|
||||
return eng.retrain(getStore(), {
|
||||
warnZ: args.warnZ != null ? Number(args.warnZ) : undefined,
|
||||
critZ: args.critZ != null ? Number(args.critZ) : undefined,
|
||||
minPoints: args.minPoints != null ? Number(args.minPoints) : undefined,
|
||||
})
|
||||
},
|
||||
gcBuffers: async () => {
|
||||
// ring buffers self-trim; placeholder for future disk GC
|
||||
return { ok: true }
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Holesail-style HyperDHT TCP tunnel for the local REST API.
|
||||
*
|
||||
* Enable: PEARDATA_REST_TUNNEL=1
|
||||
* Optional: PEARDATA_REST_TUNNEL_SEED=<64 hex> for a stable public key
|
||||
*
|
||||
* Peers connect with HyperDHT to the tunnel public key; each connection is
|
||||
* proxied to PEARDATA_REST_HOST:PEARDATA_REST_PORT (default 127.0.0.1:19999).
|
||||
*
|
||||
* Uses existing `hyperdht` — no holesail package dependency (Bare-friendly).
|
||||
*/
|
||||
import net from 'net'
|
||||
import DHT from 'hyperdht'
|
||||
import b4a from 'b4a'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
const log = logger.child('rest-tunnel')
|
||||
|
||||
/** @type {import('hyperdht')|null} */
|
||||
let dht = null
|
||||
/** @type {any} */
|
||||
let server = null
|
||||
/** @type {string|null} */
|
||||
let publicKeyHex = null
|
||||
|
||||
export function isRestTunnelEnabled() {
|
||||
const v = process.env.PEARDATA_REST_TUNNEL
|
||||
return v === '1' || v === 'on' || v === 'true'
|
||||
}
|
||||
|
||||
function tunnelKeyPair() {
|
||||
const seedHex = process.env.PEARDATA_REST_TUNNEL_SEED
|
||||
if (seedHex && /^[0-9a-fA-F]{64}$/.test(seedHex)) {
|
||||
return DHT.keyPair(b4a.from(seedHex, 'hex'))
|
||||
}
|
||||
return DHT.keyPair()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ publicKeyHex: string, target: string }|null>}
|
||||
*/
|
||||
export async function startRestTunnel() {
|
||||
if (!isRestTunnelEnabled()) {
|
||||
log.info('REST tunnel disabled (set PEARDATA_REST_TUNNEL=1)')
|
||||
return null
|
||||
}
|
||||
if (server) {
|
||||
return {
|
||||
publicKeyHex,
|
||||
target: `${process.env.PEARDATA_REST_HOST || '127.0.0.1'}:${Number(process.env.PEARDATA_REST_PORT) || 19999}`,
|
||||
}
|
||||
}
|
||||
|
||||
const host = process.env.PEARDATA_REST_HOST || '127.0.0.1'
|
||||
const port = Number(process.env.PEARDATA_REST_PORT) || 19999
|
||||
const keyPair = tunnelKeyPair()
|
||||
publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
|
||||
|
||||
dht = new DHT()
|
||||
server = dht.createServer()
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
const remote = socket.remotePublicKey
|
||||
? b4a.toString(socket.remotePublicKey, 'hex').slice(0, 12)
|
||||
: '?'
|
||||
log.info('Tunnel peer connected', { remote })
|
||||
const local = net.connect({ host, port })
|
||||
const cleanup = () => {
|
||||
try {
|
||||
socket.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
local.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
socket.on('error', cleanup)
|
||||
local.on('error', cleanup)
|
||||
socket.on('close', cleanup)
|
||||
local.on('close', cleanup)
|
||||
socket.pipe(local).pipe(socket)
|
||||
})
|
||||
|
||||
await server.listen(keyPair)
|
||||
log.info('REST tunnel listening on HyperDHT', {
|
||||
publicKeyHex,
|
||||
target: `${host}:${port}`,
|
||||
tip: `Dial HyperDHT ${publicKeyHex} → localhost REST`,
|
||||
})
|
||||
return { publicKeyHex, target: `${host}:${port}` }
|
||||
}
|
||||
|
||||
export function getRestTunnelInfo() {
|
||||
if (!publicKeyHex) return { enabled: isRestTunnelEnabled(), active: false }
|
||||
return {
|
||||
enabled: true,
|
||||
active: Boolean(server),
|
||||
publicKeyHex,
|
||||
target: `${process.env.PEARDATA_REST_HOST || '127.0.0.1'}:${Number(process.env.PEARDATA_REST_PORT) || 19999}`,
|
||||
seedConfigured: Boolean(process.env.PEARDATA_REST_TUNNEL_SEED),
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopRestTunnel() {
|
||||
if (server) {
|
||||
try {
|
||||
await server.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
server = null
|
||||
}
|
||||
if (dht) {
|
||||
try {
|
||||
await dht.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
dht = null
|
||||
}
|
||||
publicKeyHex = null
|
||||
log.info('REST tunnel stopped')
|
||||
}
|
||||
|
||||
/** Exported for tests — stable key from seed */
|
||||
export function publicKeyFromTunnelSeed(seedHex) {
|
||||
const kp = DHT.keyPair(b4a.from(seedHex, 'hex'))
|
||||
return b4a.toString(kp.publicKey, 'hex')
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Rolling z-score helpers for anomaly retrain / hybrid detection.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {number[]} values
|
||||
* @returns {{ mean: number, stdev: number, n: number }}
|
||||
*/
|
||||
export function stats(values) {
|
||||
const xs = values.filter((v) => v != null && Number.isFinite(v))
|
||||
const n = xs.length
|
||||
if (!n) return { mean: 0, stdev: 0, n: 0 }
|
||||
const mean = xs.reduce((a, b) => a + b, 0) / n
|
||||
if (n < 2) return { mean, stdev: 0, n }
|
||||
let sumSq = 0
|
||||
for (const v of xs) sumSq += (v - mean) ** 2
|
||||
const stdev = Math.sqrt(sumSq / (n - 1))
|
||||
return { mean, stdev, n }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {number} mean
|
||||
* @param {number} stdev
|
||||
*/
|
||||
export function zScore(value, mean, stdev) {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(mean)) return 0
|
||||
if (!stdev || stdev < 1e-9) return value === mean ? 0 : value > mean ? 3 : -3
|
||||
return (value - mean) / stdev
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive warn/crit absolute thresholds from baseline + z multipliers.
|
||||
* For `>` comparator: warn = mean + warnZ*stdev, crit = mean + critZ*stdev
|
||||
* For `<` comparator: warn = mean - warnZ*stdev, crit = mean - critZ*stdev
|
||||
*
|
||||
* @param {{ mean: number, stdev: number }} s
|
||||
* @param {string} comparator
|
||||
* @param {{ warnZ?: number, critZ?: number, minStdev?: number }} [opts]
|
||||
*/
|
||||
export function thresholdsFromStats(s, comparator, opts = {}) {
|
||||
const warnZ = opts.warnZ ?? 2
|
||||
const critZ = opts.critZ ?? 3
|
||||
const minStdev = opts.minStdev ?? 0.01
|
||||
const stdev = Math.max(s.stdev, minStdev)
|
||||
const op = comparator || '>'
|
||||
if (op === '<' || op === '<=') {
|
||||
return {
|
||||
warn: s.mean - warnZ * stdev,
|
||||
crit: s.mean - critZ * stdev,
|
||||
mean: s.mean,
|
||||
stdev,
|
||||
}
|
||||
}
|
||||
return {
|
||||
warn: s.mean + warnZ * stdev,
|
||||
crit: s.mean + critZ * stdev,
|
||||
mean: s.mean,
|
||||
stdev,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract numeric dimension series from MetricStore points.
|
||||
* @param {import('./store.js').MetricStore} store
|
||||
* @param {string} chart
|
||||
* @param {string} dimension
|
||||
* @param {number} [maxPoints]
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function seriesFromStore(store, chart, dimension, maxPoints = 300) {
|
||||
const entry = store.series?.get?.(chart)
|
||||
const points = entry?.points || []
|
||||
const slice = points.slice(-maxPoints)
|
||||
/** @type {number[]} */
|
||||
const out = []
|
||||
for (const p of slice) {
|
||||
const v = p.values?.[dimension]
|
||||
if (v != null && Number.isFinite(v)) out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user