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

542 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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