207 lines
7.3 KiB
TypeScript
207 lines
7.3 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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;
|
|||
|
|
}
|