/** * Zabbix WAN IP Monitoring Setup * * Reads Datto RMM sites, resolves each site's WAN IP from online device extIpAddress, * optionally tests ICMP reachability, then creates/updates Zabbix hosts with ICMP Ping * monitoring in the "Datto RMM Sites" host group. * * Run with: npx tsx scripts/setup-zabbix-wan-monitoring.ts [options] */ import { config } from 'dotenv'; import { resolve } from 'path'; import { execSync } from 'child_process'; import { DattoRMMClient } from '../lib/services/datto-rmm-client'; import { ZabbixClient } from '../lib/services/zabbix-client'; import { DattoRMMDevice, DattoRMMSite } from '../lib/types/datto-rmm'; import { ZabbixHostMacro } from '../lib/types/zabbix'; // Load environment variables config({ path: resolve(__dirname, '../.env.local') }); // --------------------------------------------------------------------------- // CLI argument parsing // --------------------------------------------------------------------------- interface CliOptions { dryRun: boolean; site: string | undefined; skipPing: boolean; help: boolean; } function parseArgs(): CliOptions { const args = process.argv.slice(2); const options: CliOptions = { dryRun: false, site: undefined, skipPing: false, help: false, }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--dry-run' || arg === '-n') { options.dryRun = true; } else if (arg === '--site') { options.site = args[++i]; } else if (arg === '--skip-ping') { options.skipPing = true; } else if (arg === '--help' || arg === '-h') { options.help = true; } } return options; } function printHelp(): void { console.log(` Zabbix WAN IP Monitoring Setup Usage: npx tsx scripts/setup-zabbix-wan-monitoring.ts [options] Options: -n, --dry-run Preview only — no writes to Zabbix --site Process a single named site --skip-ping Skip ICMP reachability test -h, --help Show this help message Environment variables required: DATTO_RMM_API_URL Datto RMM API base URL DATTO_RMM_API_KEY Datto RMM API key DATTO_RMM_API_SECRET Datto RMM API secret ZABBIX_API_URL Zabbix instance URL (e.g. https://zabbix.example.com) ZABBIX_API_TOKEN Zabbix API token (Zabbix 6.0+) Examples: # Dry-run against a single site npx tsx scripts/setup-zabbix-wan-monitoring.ts --site "Acme Corp" --dry-run # Skip ping (useful inside Docker/cloud VMs) npx tsx scripts/setup-zabbix-wan-monitoring.ts --skip-ping --dry-run # Full run npx tsx scripts/setup-zabbix-wan-monitoring.ts `); } // --------------------------------------------------------------------------- // 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[]): string | null { const online = devices.filter( (d) => d.online === true && !d.suspended && !d.deleted ); // Group by IP const ipDevices = new Map(); for (const d of online) { const ip = d.extIpAddress; if (!ip || ip === '0.0.0.0' || ip.trim() === '') continue; 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 for (const [ip, devs] of ipDevices) { if (devs.length === 1 && isLaptop(devs[0])) { ipDevices.delete(ip); } } if (ipDevices.size === 0) return null; const sorted = Array.from(ipDevices.entries()) .map(([ip, devs]) => [ip, devs.length] as [string, number]) .sort((a, b) => b[1] - a[1]); // Warn if tie between top two if (sorted.length >= 2 && sorted[0][1] === sorted[1][1]) { console.warn( ` [WARN] IP tie: ${sorted[0][0]} and ${sorted[1][0]} both seen ${sorted[0][1]}x — using ${sorted[0][0]}` ); } return sorted[0][0]; } // --------------------------------------------------------------------------- // ICMP ping // --------------------------------------------------------------------------- interface PingResult { success: boolean; rtt: number | null; // avg RTT in ms } function pingHost(ip: string): PingResult { try { const output = execSync(`ping -c 3 -W 2 -q ${ip}`, { timeout: 10000, encoding: 'utf8', }); // Parse: rtt min/avg/max/mdev = 1.234/5.678/9.012/3.456 ms const match = output.match(/rtt[^=]+=\s*[\d.]+\/([\d.]+)\//); const rtt = match ? parseFloat(match[1]) : null; return { success: true, rtt }; } catch { return { success: false, rtt: null }; } } // --------------------------------------------------------------------------- // ICMP template discovery // --------------------------------------------------------------------------- const ICMP_TEMPLATE_NAMES = [ 'ICMP Ping', 'Template Module ICMP Ping', 'Template Module ICMP Ping by Zabbix agent', ]; async function discoverIcmpTemplate( zabbix: ZabbixClient ): Promise { for (const name of ICMP_TEMPLATE_NAMES) { const tmpl = await zabbix.findTemplate(name); if (tmpl) { console.log(` Found ICMP template: "${tmpl.host}" (id=${tmpl.templateid})`); return tmpl.templateid; } } console.warn( ` [WARN] No ICMP template found. Hosts will be created without a template.` ); return null; } // --------------------------------------------------------------------------- // Site → Autotask mapping lookup // --------------------------------------------------------------------------- interface SiteMapping { companyId: number; companyName: string; rmm_site_uid: string; } async function fetchSiteMappings(): Promise> { const baseUrl = process.env.PULSE_BASE_URL || process.env.WEBHOOK_BASE_URL || 'http://localhost:3100'; try { const res = await fetch(`${baseUrl}/api/rmm/site-mappings`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: any = await res.json(); const map = new Map(); for (const m of data.mappings ?? []) { if (m.company_id && m.rmm_site_uid) { map.set(m.rmm_site_name, { companyId: m.company_id, companyName: m.company_name ?? m.rmm_site_name, rmm_site_uid: m.rmm_site_uid, }); } } console.log(` Loaded ${map.size} site→Autotask mappings\n`); return map; } catch (err) { console.warn(` [WARN] Could not load site mappings (${err}). Hosts will be created without Autotask macros.\n`); return new Map(); } } function buildMacros(mapping: SiteMapping | undefined): ZabbixHostMacro[] | undefined { if (!mapping) return undefined; return [ { 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: mapping.rmm_site_uid, description: 'Datto RMM site UID' }, ]; } // --------------------------------------------------------------------------- // Result types // --------------------------------------------------------------------------- type SiteAction = 'created' | 'updated' | 'no-ip' | 'skipped' | 'error'; interface SiteResult { siteName: string; wanIp: string | null; pingOk: boolean | null; pingRtt: number | null; action: SiteAction; hostId: string | null; error?: string; } // --------------------------------------------------------------------------- // Table formatting // --------------------------------------------------------------------------- function padEnd(str: string, len: number): string { return str.length >= len ? str.substring(0, len) : str + ' '.repeat(len - str.length); } function printResultsTable(results: SiteResult[]): void { const COL = { site: 30, ip: 17, ping: 10, action: 10, hostid: 8 }; const header = padEnd('Site Name', COL.site) + padEnd('WAN IP', COL.ip) + padEnd('Ping', COL.ping) + padEnd('Action', COL.action) + 'Host ID'; const sep = '-'.repeat(COL.site - 2) + ' ' + '-'.repeat(COL.ip - 2) + ' ' + '-'.repeat(COL.ping - 2) + ' ' + '-'.repeat(COL.action - 2) + ' ' + '-------'; console.log('\n' + header); console.log(sep); for (const r of results) { let pingCol = '-'; if (r.pingOk === true) { pingCol = r.pingRtt !== null ? `OK(${Math.round(r.pingRtt)}ms)` : 'OK'; } else if (r.pingOk === false) { pingCol = 'FAIL'; } const row = padEnd(r.siteName, COL.site) + padEnd(r.wanIp ?? '-', COL.ip) + padEnd(pingCol, COL.ping) + padEnd(r.action, COL.action) + (r.hostId ?? '-'); console.log(row); } console.log(); const counts: Record = { created: 0, updated: 0, 'no-ip': 0, skipped: 0, error: 0, }; for (const r of results) counts[r.action]++; console.log( `TOTALS: ${counts.created} created | ${counts.updated} updated | ` + `${counts['no-ip']} no-ip | ${counts.skipped} skipped | ${counts.error} errors` ); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main(): Promise { const opts = parseArgs(); if (opts.help) { printHelp(); process.exit(0); } // Validate required env vars const requiredVars = [ 'DATTO_RMM_API_URL', 'DATTO_RMM_API_KEY', 'DATTO_RMM_API_SECRET', 'ZABBIX_API_URL', 'ZABBIX_API_TOKEN', ]; const missing = requiredVars.filter((v) => !process.env[v]); if (missing.length > 0) { console.error(`Missing required environment variables: ${missing.join(', ')}`); console.error('Add them to .env.local and try again.'); process.exit(1); } if (opts.dryRun) { console.log('[DRY RUN] No changes will be written to Zabbix.\n'); } // Initialise clients const rmmClient = new DattoRMMClient({ apiUrl: process.env.DATTO_RMM_API_URL!, apiKey: process.env.DATTO_RMM_API_KEY!, apiSecret: process.env.DATTO_RMM_API_SECRET!, }); const zabbix = new ZabbixClient({ apiUrl: process.env.ZABBIX_API_URL!, apiToken: process.env.ZABBIX_API_TOKEN!, }); // Verify Zabbix connectivity — fail fast console.log('Verifying Zabbix connectivity...'); let groupId: string; try { groupId = opts.dryRun ? 'dry-run' : await zabbix.ensureHostGroup('Datto RMM Sites'); console.log(` Host group "Datto RMM Sites" ready (id=${groupId})\n`); } catch (err) { console.error(`Failed to connect to Zabbix: ${err}`); process.exit(1); } // Discover ICMP template console.log('Discovering ICMP template...'); const icmpTemplateId = opts.dryRun ? null : await discoverIcmpTemplate(zabbix); console.log(); // Load Autotask site mappings console.log('Loading Autotask site mappings...'); const siteMappings = await fetchSiteMappings(); // Fetch sites console.log('Fetching Datto RMM sites...'); let sites: DattoRMMSite[] = await rmmClient.getAllSites(); if (opts.site) { const lower = opts.site.toLowerCase(); sites = sites.filter((s) => s.name.toLowerCase() === lower); if (sites.length === 0) { console.error(`No site found matching: "${opts.site}"`); process.exit(1); } } else { // Skip sites with no Autotask mapping unless a specific site was requested const before = sites.length; sites = sites.filter((s) => siteMappings.has(s.name)); const skipped = before - sites.length; if (skipped > 0) console.log(` Skipped ${skipped} unmapped site(s)\n`); } console.log(` Processing ${sites.length} site(s)\n`); // Process each site const results: SiteResult[] = []; for (const site of sites) { process.stdout.write(`${site.name}... `); let wanIp: string | null = null; let onlineCount = 0; try { const devices = await rmmClient.getDevicesBySite(site.uid); onlineCount = devices.filter((d) => d.online && !d.suspended && !d.deleted).length; wanIp = resolveWanIp(devices); } catch (err) { console.log('ERROR (device fetch)'); results.push({ siteName: site.name, wanIp: null, pingOk: null, pingRtt: null, action: 'error', hostId: null, error: String(err), }); continue; } if (!wanIp) { console.log('no IP'); results.push({ siteName: site.name, wanIp: null, pingOk: null, pingRtt: null, action: 'no-ip', hostId: null, }); continue; } // Ping test let pingOk: boolean | null = null; let pingRtt: number | null = null; if (!opts.skipPing) { const ping = pingHost(wanIp); pingOk = ping.success; pingRtt = ping.rtt; } // Dry-run: stop here if (opts.dryRun) { const pingLabel = opts.skipPing ? 'skipped' : pingOk ? `OK(${pingRtt !== null ? Math.round(pingRtt) + 'ms' : '?'})` : 'FAIL'; console.log(`${wanIp} ping=${pingLabel} [DRY RUN]`); results.push({ siteName: site.name, wanIp, pingOk, pingRtt, action: 'skipped', hostId: null, }); continue; } // Upsert Zabbix host try { const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined; const mapping = siteMappings.get(site.name); const macros = buildMacros(mapping); const { action, hostid } = await zabbix.upsertHost({ host: site.name, name: site.name, description: `Datto RMM site – WAN IP from ${onlineCount} online devices`, interfaces: [ { type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050', }, ], groups: [{ groupid: groupId }], templates, macros, }); const pingLabel = opts.skipPing ? 'skipped' : pingOk ? `OK(${pingRtt !== null ? Math.round(pingRtt) + 'ms' : '?'})` : 'FAIL'; console.log(`${wanIp} ping=${pingLabel} ${action} (id=${hostid})`); results.push({ siteName: site.name, wanIp, pingOk, pingRtt, action, hostId: hostid, }); } catch (err) { console.log(`ERROR (zabbix upsert): ${err}`); results.push({ siteName: site.name, wanIp, pingOk, pingRtt, action: 'error', hostId: null, error: String(err), }); } } // Print summary table printResultsTable(results); // Print any errors in detail const errors = results.filter((r) => r.action === 'error'); if (errors.length > 0) { console.log('\nERRORS:'); for (const e of errors) { console.log(` ${e.siteName}: ${e.error}`); } process.exit(1); } } // Run main() .then(() => process.exit(0)) .catch((err) => { console.error('Fatal error:', err); process.exit(1); });