wulf-pulse/scripts/setup-zabbix-wan-monitoring.ts

543 lines
15 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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);
});