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(); 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(); async function lookupIsp(ip: string): Promise { if (ispCache.has(ip)) return ispCache.get(ip)!; try { const token = process.env.IPINFO_TOKEN; const headers: Record = { 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(); 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' }, }); }