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

468 lines
18 KiB
TypeScript
Raw 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.

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' },
});
}