wulf-pulse/lib/services/zabbix-wan-utils.ts
lorentz c518eefdb2 feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
2026-03-11 09:34:51 -04:00

206 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Shared utilities for Zabbix WAN host management.
* Used by both the RMM discovery sync and the manual host creation endpoints.
*/
import { ZabbixClient } from '@/lib/services/zabbix-client';
import { ZabbixHostMacro, ZabbixHostTag, ZabbixHostCreateParams } from '@/lib/types/zabbix';
// ---------------------------------------------------------------------------
// ISP info type
// ---------------------------------------------------------------------------
export interface IspInfo {
isp: string; // "Comcast Cable Communications, LLC"
asn: string; // "AS7922"
city: string;
region: string;
country: string;
}
// ---------------------------------------------------------------------------
// Zabbix host technical name sanitization
// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field.
// We sanitize to alphanumeric, spaces, hyphens, underscores only.
// The display `name` field is left as-is (accepts any UTF-8).
// ---------------------------------------------------------------------------
export function sanitizeHostname(name: string): string {
return name
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
.replace(/\s+/g, ' ') // collapse multiple spaces
.trim();
}
// ---------------------------------------------------------------------------
// ISP lookup via ipinfo.io (free, no key required for basic fields)
// Results are cached within the module lifetime to avoid duplicate lookups.
// Call clearIspCache() at the start of each request if needed.
// ---------------------------------------------------------------------------
const ispCache = new Map<string, IspInfo | null>();
export function clearIspCache(): void {
ispCache.clear();
}
export async function lookupIsp(ip: string): Promise<IspInfo | null> {
if (ispCache.has(ip)) return ispCache.get(ip)!;
try {
const token = process.env.IPINFO_TOKEN;
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`https://ipinfo.io/${ip}/json`, {
headers,
cache: 'no-store',
signal: AbortSignal.timeout(6000),
});
if (!res.ok) { ispCache.set(ip, null); return null; }
const data = await res.json();
// org field format: "AS7922 Comcast Cable Communications, LLC"
const org: string = data.org ?? '';
const m = org.match(/^(AS\d+)\s+(.+)$/);
const info: IspInfo = {
isp: m ? m[2] : org,
asn: m ? m[1] : '',
city: data.city ?? '',
region: data.region ?? '',
country: data.country ?? '',
};
ispCache.set(ip, info);
return info;
} catch {
ispCache.set(ip, null);
return null;
}
}
// ---------------------------------------------------------------------------
// Build the full Zabbix host upsert params from common inputs.
// ---------------------------------------------------------------------------
export interface BuildHostParamsInput {
siteName: string;
wanIp: string;
companyId?: number;
companyName?: string;
rmmSiteUid?: string;
ispInfo: IspInfo | null;
multiWan?: boolean;
allIps?: string[];
singleDeviceFallback?: boolean;
onlineDeviceCount?: number;
source?: string; // tag value for "source" (default "datto-rmm")
icmpTemplateId: string | null;
globalGroupId: string;
zabbix: ZabbixClient;
}
export async function buildHostParams(input: BuildHostParamsInput): Promise<ZabbixHostCreateParams> {
const {
siteName, wanIp, companyId, companyName, rmmSiteUid,
ispInfo, multiWan, allIps, singleDeviceFallback,
onlineDeviceCount, source, icmpTemplateId, globalGroupId, zabbix,
} = input;
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
// Build groups: always global, + per-client, + per-ISP
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
if (companyName) {
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${companyName}`);
groups.push({ groupid: clientGroupId });
}
if (ispInfo?.isp) {
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
groups.push({ groupid: ispGroupId });
}
// Build macros: Autotask identity + ISP context
const macros: ZabbixHostMacro[] = [];
if (companyId && companyName) {
macros.push(
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(companyId), description: 'Autotask company ID' },
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: companyName, description: 'Autotask company name' },
);
}
if (rmmSiteUid) {
macros.push(
{ macro: '{$RMM_SITE_UID}', value: rmmSiteUid, description: 'Datto RMM site UID' },
);
}
if (ispInfo) {
macros.push(
{ macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' },
{ macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' },
{ macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' },
{ macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' },
{ macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' },
);
}
if (multiWan && allIps) {
macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' });
}
// Build tags: for dashboard filtering and problem correlation
const sourceTag = source ?? 'datto-rmm';
const tags: ZabbixHostTag[] = [{ tag: 'source', value: sourceTag }];
if (companyName) {
tags.push({ tag: 'client', value: companyName });
}
if (ispInfo?.isp) {
tags.push({ tag: 'isp', value: ispInfo.isp });
}
if (ispInfo?.asn) {
tags.push({ tag: 'asn', value: ispInfo.asn });
}
if (multiWan) {
tags.push({ tag: 'multi-wan', value: 'true' });
}
if (singleDeviceFallback) {
tags.push({ tag: 'single-device-fallback', value: 'true' });
}
const descParts = [
onlineDeviceCount != null
? `Datto RMM site WAN IP from ${onlineDeviceCount} online devices`
: `Manual host WAN IP ${wanIp}`,
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
multiWan && allIps ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
].filter(Boolean).join('\n');
return {
host: sanitizeHostname(siteName),
name: siteName,
description: descParts,
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
groups,
templates,
macros: macros.length > 0 ? macros : undefined,
tags,
};
}
// ---------------------------------------------------------------------------
// Discover ICMP template — tries multiple common names
// ---------------------------------------------------------------------------
const ICMP_TEMPLATE_NAMES = [
'ICMP Ping',
'Template Module ICMP Ping',
'Template Module ICMP Ping by Zabbix agent',
];
export async function discoverIcmpTemplate(zabbix: ZabbixClient): Promise<string | null> {
for (const name of ICMP_TEMPLATE_NAMES) {
const tmpl = await zabbix.findTemplate(name);
if (tmpl) return tmpl.templateid;
}
return null;
}