542 lines
15 KiB
TypeScript
542 lines
15 KiB
TypeScript
/**
|
||
* 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 <name> 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<string, DattoRMMDevice[]>();
|
||
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<string | null> {
|
||
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<Map<string, SiteMapping>> {
|
||
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<string, SiteMapping>();
|
||
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<SiteAction, number> = {
|
||
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<void> {
|
||
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);
|
||
});
|