143 lines
5 KiB
TypeScript
143 lines
5 KiB
TypeScript
|
|
import { registerStepExecutor } from '../pipeline-engine';
|
||
|
|
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||
|
|
import { postgresClient } from '../postgres-client';
|
||
|
|
import { ZabbixClient } from '../zabbix-client';
|
||
|
|
import { ZabbixProblem } from '../../types/zabbix';
|
||
|
|
import { promises as dns } from 'dns';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Ping Flap Suppression Step
|
||
|
|
*
|
||
|
|
* Detects flapping PING alerts from Datto RMM and suppresses the corresponding
|
||
|
|
* Zabbix problem when a threshold is exceeded.
|
||
|
|
*
|
||
|
|
* Flap = same ping_target triggers >= FLAP_THRESHOLD times within FLAP_WINDOW_HOURS.
|
||
|
|
* When detected:
|
||
|
|
* - Suppresses the open Zabbix problem for the matching host for SUPPRESS_HOURS
|
||
|
|
* - Records the suppression in ping_flap_suppressions to avoid re-firing
|
||
|
|
*/
|
||
|
|
|
||
|
|
const FLAP_THRESHOLD = 5;
|
||
|
|
const FLAP_WINDOW_HOURS = 2;
|
||
|
|
const SUPPRESS_HOURS = 4;
|
||
|
|
|
||
|
|
function makeZabbix(): ZabbixClient {
|
||
|
|
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||
|
|
throw new Error('Zabbix not configured');
|
||
|
|
}
|
||
|
|
return new ZabbixClient({ apiUrl: process.env.ZABBIX_API_URL, apiToken: process.env.ZABBIX_API_TOKEN });
|
||
|
|
}
|
||
|
|
|
||
|
|
async function executePingFlapSuppress(
|
||
|
|
_step: PipelineStep,
|
||
|
|
context: PipelineContext,
|
||
|
|
_executionId: number
|
||
|
|
): Promise<StepExecutorResult> {
|
||
|
|
const payload = context.triggerData;
|
||
|
|
|
||
|
|
if (payload?.alert_type !== 'PING') {
|
||
|
|
return { success: true, output: { skipped: true } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const pingTarget: string | null = payload?.ping_target ?? null;
|
||
|
|
if (!pingTarget) {
|
||
|
|
return { success: true, output: { skipped: true, reason: 'no ping_target' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if already suppressed
|
||
|
|
const suppCheck = await postgresClient.query(
|
||
|
|
`SELECT suppressed_until FROM ping_flap_suppressions
|
||
|
|
WHERE ping_target = $1 AND suppressed_until > NOW()`,
|
||
|
|
[pingTarget]
|
||
|
|
);
|
||
|
|
if (suppCheck.rows.length > 0) {
|
||
|
|
return { success: true, output: { flap_suppressed: true, already_active: true } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Count triggers in the flap window
|
||
|
|
const countResult = await postgresClient.query(
|
||
|
|
`SELECT COUNT(*) as cnt FROM datto_rmm_alerts
|
||
|
|
WHERE alert_type = 'PING'
|
||
|
|
AND ping_target = $1
|
||
|
|
AND triggered = 'True'
|
||
|
|
AND timestamp >= NOW() - INTERVAL '${FLAP_WINDOW_HOURS} hours'`,
|
||
|
|
[pingTarget]
|
||
|
|
);
|
||
|
|
const triggerCount = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
|
||
|
|
|
||
|
|
if (triggerCount < FLAP_THRESHOLD) {
|
||
|
|
return { success: true, output: { flap_detected: false, trigger_count: triggerCount } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const suppressUntil = new Date(Date.now() + SUPPRESS_HOURS * 60 * 60 * 1000);
|
||
|
|
const message = `Auto-suppressed: flapping detected (${triggerCount} triggers in ${FLAP_WINDOW_HOURS}h). Awaiting manual resolution.`;
|
||
|
|
|
||
|
|
// Try to find and suppress the Zabbix problem
|
||
|
|
let zabbixSuppressed = false;
|
||
|
|
let zabbixHost: string | null = null;
|
||
|
|
try {
|
||
|
|
const zabbix = makeZabbix();
|
||
|
|
|
||
|
|
// Resolve DNS name to IP — Zabbix stores IPs in interfaces, not DNS names
|
||
|
|
const lookupTargets = [pingTarget];
|
||
|
|
try {
|
||
|
|
const resolved = await dns.lookup(pingTarget);
|
||
|
|
if (resolved.address && resolved.address !== pingTarget) {
|
||
|
|
lookupTargets.unshift(resolved.address);
|
||
|
|
}
|
||
|
|
} catch { /* not a resolvable hostname — may already be an IP */ }
|
||
|
|
|
||
|
|
let hosts: Array<{ hostid: string; name: string }> = [];
|
||
|
|
for (const target of lookupTargets) {
|
||
|
|
hosts = await zabbix.findHostsByInterface(target);
|
||
|
|
if (hosts.length > 0) break;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hosts.length > 0) {
|
||
|
|
const host = hosts[0];
|
||
|
|
zabbixHost = host.name;
|
||
|
|
const problems = await zabbix.getOpenProblemsForHost(host.hostid);
|
||
|
|
const unreachable = problems.find((p: ZabbixProblem) =>
|
||
|
|
p.name.toLowerCase().includes('unreachable') ||
|
||
|
|
p.name.toLowerCase().includes('unavailable')
|
||
|
|
);
|
||
|
|
|
||
|
|
if (unreachable) {
|
||
|
|
await zabbix.suppressProblem(unreachable.eventid, suppressUntil, message);
|
||
|
|
zabbixSuppressed = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error(`[ping-flap-suppress] Zabbix error for ${pingTarget}:`, err);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Record suppression to prevent repeated attempts within the window
|
||
|
|
await postgresClient.query(
|
||
|
|
`INSERT INTO ping_flap_suppressions (ping_target, trigger_count, suppressed_until, zabbix_suppressed, notes)
|
||
|
|
VALUES ($1, $2, $3, $4, $5)
|
||
|
|
ON CONFLICT (ping_target) DO UPDATE SET
|
||
|
|
trigger_count = EXCLUDED.trigger_count,
|
||
|
|
suppressed_until = EXCLUDED.suppressed_until,
|
||
|
|
zabbix_suppressed = EXCLUDED.zabbix_suppressed,
|
||
|
|
notes = EXCLUDED.notes,
|
||
|
|
updated_at = NOW()`,
|
||
|
|
[pingTarget, triggerCount, suppressUntil, zabbixSuppressed, message]
|
||
|
|
);
|
||
|
|
|
||
|
|
console.log(`[ping-flap-suppress] Flap detected: ${pingTarget} (${triggerCount} triggers) — zabbix_suppressed=${zabbixSuppressed} host=${zabbixHost}`);
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
output: {
|
||
|
|
flap_detected: true,
|
||
|
|
flap_suppressed: true,
|
||
|
|
trigger_count: triggerCount,
|
||
|
|
zabbix_suppressed: zabbixSuppressed,
|
||
|
|
zabbix_host: zabbixHost,
|
||
|
|
suppressed_until: suppressUntil.toISOString(),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
registerStepExecutor('ping_flap_suppress', executePingFlapSuppress);
|