wulf-pulse/lib/services/zabbix-wan-utils.ts

207 lines
7.3 KiB
TypeScript
Raw Normal View History

/**
* 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;
}