468 lines
18 KiB
TypeScript
468 lines
18 KiB
TypeScript
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' },
|
||
});
|
||
}
|