Files
dd0c/products/03-alert-intelligence/src/api/webhooks.ts

150 lines
5.4 KiB
TypeScript
Raw Normal View History

import type { FastifyInstance } from 'fastify';
import pino from 'pino';
import {
validateDatadogHmac,
validatePagerdutyHmac,
validateOpsgenieHmac,
normalizeDatadog,
normalizePagerduty,
normalizeOpsgenie,
type CanonicalAlert,
} from '../ingestion/webhook.js';
import { withTenant } from '../data/db.js';
const logger = pino({ name: 'api-webhooks' });
export function registerWebhookRoutes(app: FastifyInstance) {
// Datadog webhook
app.post('/webhooks/datadog/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any;
const rawBody = JSON.stringify(body);
const secret = await getWebhookSecret(tenantSlug, 'datadog');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
const hmac = validateDatadogHmac(
rawBody,
req.headers['dd-webhook-signature'] as string,
req.headers['dd-webhook-timestamp'] as string,
secret.secret,
);
if (!hmac.valid) {
logger.warn({ tenant: tenantSlug, error: hmac.error }, 'Datadog HMAC failed');
return reply.status(401).send({ error: hmac.error });
}
const alert = normalizeDatadog(body);
await ingestAlert(secret.tenantId, alert);
return reply.status(202).send({ status: 'accepted' });
});
// PagerDuty webhook
app.post('/webhooks/pagerduty/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any;
const rawBody = JSON.stringify(body);
const secret = await getWebhookSecret(tenantSlug, 'pagerduty');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
const hmac = validatePagerdutyHmac(
rawBody,
req.headers['x-pagerduty-signature'] as string,
secret.secret,
);
if (!hmac.valid) {
logger.warn({ tenant: tenantSlug, error: hmac.error }, 'PagerDuty HMAC failed');
return reply.status(401).send({ error: hmac.error });
}
const alert = normalizePagerduty(body);
await ingestAlert(secret.tenantId, alert);
return reply.status(202).send({ status: 'accepted' });
});
// OpsGenie webhook
app.post('/webhooks/opsgenie/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any;
const rawBody = JSON.stringify(body);
const secret = await getWebhookSecret(tenantSlug, 'opsgenie');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
const hmac = validateOpsgenieHmac(
rawBody,
req.headers['x-opsgenie-signature'] as string,
secret.secret,
);
if (!hmac.valid) {
logger.warn({ tenant: tenantSlug, error: hmac.error }, 'OpsGenie HMAC failed');
return reply.status(401).send({ error: hmac.error });
}
const alert = normalizeOpsgenie(body);
await ingestAlert(secret.tenantId, alert);
return reply.status(202).send({ status: 'accepted' });
});
// Grafana webhook (token-based auth, no HMAC)
app.post('/webhooks/grafana/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any;
const secret = await getWebhookSecret(tenantSlug, 'grafana');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
const token = req.headers['authorization']?.replace('Bearer ', '');
if (token !== secret.secret) {
return reply.status(401).send({ error: 'Invalid token' });
}
const alert = normalizeGrafana(body);
await ingestAlert(secret.tenantId, alert);
return reply.status(202).send({ status: 'accepted' });
});
}
function normalizeGrafana(payload: any): CanonicalAlert {
const alert = payload.alerts?.[0] ?? payload;
return {
sourceProvider: 'grafana' as any,
sourceId: alert.fingerprint ?? crypto.randomUUID(),
fingerprint: alert.fingerprint ?? '',
title: alert.labels?.alertname ?? payload.title ?? 'Grafana Alert',
severity: mapGrafanaSeverity(alert.labels?.severity),
status: alert.status === 'resolved' ? 'resolved' : 'firing',
service: alert.labels?.service,
environment: alert.labels?.env,
tags: alert.labels ?? {},
rawPayload: payload,
timestamp: alert.startsAt ? new Date(alert.startsAt).getTime() : Date.now(),
};
}
function mapGrafanaSeverity(s: string | undefined): CanonicalAlert['severity'] {
switch (s) {
case 'critical': return 'critical';
case 'warning': return 'high';
case 'info': return 'info';
default: return 'medium';
}
}
async function getWebhookSecret(tenantSlug: string, provider: string): Promise<{ tenantId: string; secret: string } | null> {
// TODO: SELECT ws.secret, t.id FROM webhook_secrets ws JOIN tenants t ON ws.tenant_id = t.id WHERE t.slug = $1 AND ws.provider = $2
return null;
}
async function ingestAlert(tenantId: string, alert: CanonicalAlert): Promise<void> {
await withTenant(tenantId, async (client) => {
await client.query(
`INSERT INTO alerts (tenant_id, source_provider, source_id, fingerprint, title, severity, status, service, environment, tags, raw_payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[tenantId, alert.sourceProvider, alert.sourceId, alert.fingerprint, alert.title, alert.severity, alert.status, alert.service, alert.environment, JSON.stringify(alert.tags), JSON.stringify(alert.rawPayload)],
);
// TODO: Feed into correlation engine
});
}