wulf-pulse/app/api/zabbix/sync-wan/route.ts

469 lines
18 KiB
TypeScript
Raw Normal View History

import { NextRequest } from 'next/server';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { ZabbixClient } from '@/lib/services/zabbix-client';
import { postgresClient } from '@/lib/services/postgres-client';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import { ZabbixHostMacro, ZabbixHostTag } from '@/lib/types/zabbix';
export const maxDuration = 300;
type SyncMode = 'all' | 'client' | 'site';
type SiteAction = 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped';
interface IspInfo {
isp: string; // "Comcast Cable Communications, LLC"
asn: string; // "AS7922"
city: string;
region: string;
country: string;
}
interface WanResolution {
ip: string | null;
count: number;
multiWan: boolean; // true when qualifying devices report 2+ distinct IPs
allIps: string[]; // all distinct IPs seen (for multi-WAN visibility)
singleDeviceFallback: boolean; // true when result came from the single-device fallback
noIpReason?: string; // set only when ip is null
}
interface SiteResult {
siteName: string;
siteUid: string;
companyId: number | null;
companyName: string | null;
wanIp: string | null;
qualifyingDevices: number;
multiWan: boolean;
singleDeviceFallback: boolean;
isp: string | null;
asn: string | null;
action: SiteAction;
hostId: string | null;
filterReason?: string;
error?: string;
}
interface StreamMessage {
type: 'site' | 'summary' | 'error';
result?: SiteResult;
stats?: {
total: number;
created: number;
updated: number;
filtered: number;
noIp: number;
errors: number;
skipped: number;
multiWan: number;
};
message?: string;
}
// ---------------------------------------------------------------------------
// WAN IP resolution
// ---------------------------------------------------------------------------
function isLaptop(d: DattoRMMDevice): boolean {
const cat = (d.deviceType?.category ?? '').toLowerCase();
const type = (d.deviceType?.type ?? '').toLowerCase();
return cat.includes('laptop') || cat.includes('notebook') ||
type.includes('laptop') || type.includes('notebook');
}
function resolveWanIp(
devices: DattoRMMDevice[],
minDevices: number,
maxLastSeenHours: number,
allowSingleDevice: boolean,
): WanResolution {
const cutoffMs = Date.now() - maxLastSeenHours * 3600 * 1000;
const qualifying = devices.filter(
(d) =>
!d.suspended &&
!d.deleted &&
d.extIpAddress &&
d.extIpAddress !== '0.0.0.0' &&
d.extIpAddress.trim() !== '' &&
d.lastSeen != null &&
d.lastSeen > cutoffMs
);
// Group devices by IP
const ipDevices = new Map<string, DattoRMMDevice[]>();
for (const d of qualifying) {
const ip = d.extIpAddress;
if (!ipDevices.has(ip)) ipDevices.set(ip, []);
ipDevices.get(ip)!.push(d);
}
// Drop IPs seen only once from a single laptop — likely a remote/travelling device
const filtered = new Map(ipDevices);
for (const [ip, devs] of filtered) {
if (devs.length === 1 && isLaptop(devs[0])) {
filtered.delete(ip);
}
}
// If filtering wiped everything out and allowSingleDevice is on, fall back to
// the full set (accepts any device type including a lone laptop/server/etc.)
const effective = filtered.size > 0
? filtered
: allowSingleDevice && ipDevices.size > 0
? ipDevices
: null;
if (!effective) {
let noIpReason = 'No qualifying devices';
if (devices.length === 0) {
noIpReason = 'No devices in site';
} else if (qualifying.length === 0) {
const active = devices.filter((d) => !d.suspended && !d.deleted);
if (active.length === 0) {
noIpReason = `All ${devices.length} devices suspended or deleted`;
} else {
const withIp = active.filter(
(d) => d.extIpAddress && d.extIpAddress !== '0.0.0.0' && d.extIpAddress.trim() !== ''
);
if (withIp.length === 0) {
noIpReason = `${active.length} active device${active.length !== 1 ? 's' : ''}, none report a public IP`;
} else {
noIpReason = `${withIp.length} device${withIp.length !== 1 ? 's' : ''} have an IP but none seen in last ${maxLastSeenHours}h`;
}
}
} else {
// qualifying > 0 but all unique-laptop IPs were dropped and fallback is off
noIpReason = 'Only laptops found — enable single-device fallback';
}
return { ip: null, count: 0, multiWan: false, allIps: [], singleDeviceFallback: false, noIpReason };
}
const sorted = Array.from(effective.entries())
.map(([ip, devs]) => [ip, devs.length] as [string, number])
.sort((a, b) => b[1] - a[1]);
const allIps = sorted.map(([ip]) => ip);
const [topIp, topCount] = sorted[0];
const multiWan = sorted.length > 1;
const singleDeviceFallback = filtered.size === 0; // used the fallback path
return { ip: topIp, count: topCount, multiWan, allIps, singleDeviceFallback };
}
// ---------------------------------------------------------------------------
// ISP lookup via ipinfo.io (free, no key required for basic fields)
// Results are cached within a run to avoid duplicate lookups for the same IP
// ---------------------------------------------------------------------------
const ispCache = new Map<string, IspInfo | null>();
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;
}
}
// ---------------------------------------------------------------------------
// 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).
// ---------------------------------------------------------------------------
function sanitizeHostname(name: string): string {
return name
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
.replace(/\s+/g, ' ') // collapse multiple spaces
.trim();
}
// ---------------------------------------------------------------------------
// API route
// ---------------------------------------------------------------------------
export async function POST(request: NextRequest) {
const body = await request.json();
const {
mode = 'all' as SyncMode,
companyId,
siteUid,
minDevices = 1,
maxLastSeenHours = 48,
allowSingleDevice = false,
dryRun = false,
} = body;
ispCache.clear(); // fresh cache per request
const encoder = new TextEncoder();
const transform = new TransformStream<Uint8Array, Uint8Array>();
const writer = transform.writable.getWriter();
const send = async (msg: StreamMessage) => {
await writer.write(encoder.encode(JSON.stringify(msg) + '\n'));
};
(async () => {
try {
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
await send({ type: 'error', message: 'Zabbix is not configured. Add ZABBIX_API_URL and ZABBIX_API_TOKEN to your environment.' });
return;
}
const rmmClient = getDattoRMMClient();
const zabbix = new ZabbixClient({
apiUrl: process.env.ZABBIX_API_URL!,
apiToken: process.env.ZABBIX_API_TOKEN!,
});
// Ensure base group + ICMP template (skipped in dry-run)
let globalGroupId = 'dry-run';
let icmpTemplateId: string | null = null;
if (!dryRun) {
globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
for (const name of ['ICMP Ping', 'Template Module ICMP Ping', 'Template Module ICMP Ping by Zabbix agent']) {
const tmpl = await zabbix.findTemplate(name);
if (tmpl) { icmpTemplateId = tmpl.templateid; break; }
}
}
// Load site → Autotask mappings (keyed by RMM site UID)
const mappingRows = await postgresClient.query<{
rmm_site_uid: string;
company_id: number;
company_name: string;
}>(
`SELECT rsm.rmm_site_uid, rsm.company_id, c.company_name
FROM rmm_site_mappings rsm
JOIN companies c ON c.id = rsm.company_id`
);
const mappingBySiteUid = new Map(
mappingRows.rows.map((r) => [r.rmm_site_uid, { companyId: r.company_id, companyName: r.company_name }])
);
// Determine which sites to process
let sites: { uid: string; name: string }[] = [];
if (mode === 'all') {
const allSites = await rmmClient.getAllSites();
sites = allSites
.filter((s) => mappingBySiteUid.has(s.uid))
.map((s) => ({ uid: s.uid, name: s.name }));
} else if (mode === 'client' && companyId) {
const res = await postgresClient.query<{ rmm_site_uid: string; rmm_site_name: string }>(
'SELECT rmm_site_uid, rmm_site_name FROM rmm_site_mappings WHERE company_id = $1',
[companyId]
);
sites = res.rows.map((r) => ({ uid: r.rmm_site_uid, name: r.rmm_site_name }));
} else if (mode === 'site' && siteUid) {
const res = await postgresClient.query<{ rmm_site_name: string }>(
'SELECT rmm_site_name FROM rmm_site_mappings WHERE rmm_site_uid = $1 LIMIT 1',
[siteUid]
);
sites = [{ uid: siteUid, name: res.rows[0]?.rmm_site_name ?? siteUid }];
}
if (sites.length === 0) {
await send({ type: 'error', message: 'No sites found to process.' });
return;
}
const stats = { total: sites.length, created: 0, updated: 0, filtered: 0, noIp: 0, errors: 0, skipped: 0, multiWan: 0 };
for (const site of sites) {
const mapping = mappingBySiteUid.get(site.uid);
let devices: DattoRMMDevice[] = [];
try {
devices = await rmmClient.getDevicesBySite(site.uid);
} catch (err) {
stats.errors++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp: null, qualifyingDevices: 0, multiWan: false, singleDeviceFallback: false, isp: null, asn: null,
action: 'error', hostId: null, error: String(err),
}});
continue;
}
const { ip: wanIp, count: qualifyingDevices, multiWan, allIps, singleDeviceFallback, noIpReason } = resolveWanIp(devices, minDevices, maxLastSeenHours, allowSingleDevice);
if (multiWan) stats.multiWan++;
if (!wanIp) {
stats.noIp++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp: null, qualifyingDevices: 0, multiWan, singleDeviceFallback: false, isp: null, asn: null,
action: 'no-ip', hostId: null, filterReason: noIpReason,
}});
continue;
}
// ISP lookup (runs even in dry-run and for filtered sites so we can show it in preview)
const ispInfo = await lookupIsp(wanIp);
// Skip minDevices gate if the single-device fallback is active — the user
// explicitly opted in, so enforcing the threshold here would silently undo it.
if (!singleDeviceFallback && qualifyingDevices < minDevices) {
stats.filtered++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback: false,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'filtered', hostId: null,
filterReason: `${qualifyingDevices} device${qualifyingDevices !== 1 ? 's' : ''} at IP (min ${minDevices})`,
}});
continue;
}
if (dryRun) {
stats.skipped++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'skipped', hostId: null,
}});
continue;
}
try {
const onlineCount = devices.filter((d) => d.online && !d.suspended && !d.deleted).length;
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
// Build groups: always global, + per-client, + per-ISP
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
if (mapping) {
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${mapping.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 (mapping) {
macros.push(
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(mapping.companyId), description: 'Autotask company ID' },
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: mapping.companyName, description: 'Autotask company name' },
{ macro: '{$RMM_SITE_UID}', value: site.uid, 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) {
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 tags: ZabbixHostTag[] = [{ tag: 'source', value: 'datto-rmm' }];
if (mapping) {
tags.push({ tag: 'client', value: mapping.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 description = [
`Datto RMM site WAN IP from ${onlineCount} online devices`,
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
multiWan ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
].filter(Boolean).join('\n');
const { action, hostid } = await zabbix.upsertHost({
host: sanitizeHostname(site.name), name: site.name, description,
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
groups, templates,
macros: macros.length > 0 ? macros : undefined,
tags,
});
if (action === 'created') stats.created++; else stats.updated++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action, hostId: hostid,
}});
} catch (err) {
stats.errors++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'error', hostId: null, error: String(err),
}});
}
}
await send({ type: 'summary', stats });
} catch (err) {
await send({ type: 'error', message: String(err) });
} finally {
await writer.close();
}
})();
return new Response(transform.readable, {
headers: { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' },
});
}