feat: IT Glue integration, workflow engine, pipelines, Zabbix WAN, notification channels, backup status UI improvements, nav alignment fixes

This commit is contained in:
lorentz 2026-02-27 14:52:14 -05:00
parent ed6c4a8b65
commit 19605f82aa
97 changed files with 17080 additions and 304 deletions

View file

@ -0,0 +1,549 @@
/**
* Import Wasabi IAM credentials into Passportal
* Creates one credential per bucket entry under each client's existing Veeam subfolder
*
* Usage:
* npx tsx scripts/passportal-import-wasabi.ts --discover # List templates, clients, folders
* npx tsx scripts/passportal-import-wasabi.ts --dry-run # Preview what will be created
* npx tsx scripts/passportal-import-wasabi.ts # Run the import
*/
import { createHmac } from 'crypto';
import { readFileSync } from 'fs';
import * as path from 'path';
import * as dotenv from 'dotenv';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
// ── Config ────────────────────────────────────────────────────────────────────
const BASE_URL = 'https://us-clover.passportalmsp.com';
const HMAC_CONTENT = 'aUa&&XUQBJXz2x&';
const VEEAM_FOLDER_NAME = 'Veeam'; // case-insensitive match
const CREDS_FILE = path.resolve(process.cwd(), 'dev/WasabiIAMCredentials_20260220_172028.txt');
// These Wulf-internal buckets have no matching client — skip them
const SKIP_CLIENT_SLUGS = new Set(['internal', 'vbr', 'veeam', 'clients']);
// ── Types ─────────────────────────────────────────────────────────────────────
interface WasabiEntry {
bucket: string;
username: string;
accessKey: string;
secretKey: string;
endpoint: string;
clientSlug: string;
}
interface PassportalToken {
access_token: string;
refresh_token: string;
expiry_time: number;
}
interface PassportalClient {
id: number | string;
name: string;
}
interface PassportalFolder {
id: number | string;
name: string;
clientId?: number | string;
}
interface PassportalTemplate {
id: number | string;
name: string;
fields?: Array<{ name: string; type: string }>;
}
// ── Parse credentials file ────────────────────────────────────────────────────
function parseCredentialsFile(filePath: string): WasabiEntry[] {
const content = readFileSync(filePath, 'utf-8');
const entries: WasabiEntry[] = [];
// Parse line-by-line to handle any line endings / blank-line variations
const current: Record<string, string> = {};
const flush = () => {
const bucket = current['bucket'] ?? '';
const username = current['username'] ?? '';
const accessKey = current['access_key'] ?? '';
const secretKey = current['secret_key'] ?? '';
const endpoint = current['endpoint'] || 'https://s3.wasabisys.com';
if (bucket && accessKey && secretKey) {
// wulf.<client>.veeam[365].immutable[1] → <client>
const match = bucket.match(/^wulf\.(.+?)\.veeam/);
const clientSlug = match ? match[1] : bucket;
entries.push({ bucket, username, accessKey, secretKey, endpoint, clientSlug });
}
for (const k of Object.keys(current)) delete current[k];
};
for (const rawLine of content.split('\n')) {
const line = rawLine.replace(/\r$/, ''); // strip CR for CRLF files
if (line.trim() === '') {
flush();
continue;
}
const colonIdx = line.indexOf(':');
if (colonIdx < 0) continue; // header separator lines (===)
const rawKey = line.slice(0, colonIdx).trim().toLowerCase().replace(/\s+/g, '_');
const value = line.slice(colonIdx + 1).trim();
// Skip header lines that don't look like credential fields
if (!['bucket', 'username', 'access_key', 'secret_key', 'endpoint'].includes(rawKey)) continue;
current[rawKey] = value;
}
flush(); // handle last block if file doesn't end with blank line
return entries;
}
// ── Auth ──────────────────────────────────────────────────────────────────────
function computeHmac(secretKey: string): string {
const hmac = createHmac('sha256', secretKey);
hmac.update(HMAC_CONTENT);
return hmac.digest('hex');
}
async function authenticate(scope = 'docs_api'): Promise<string> {
const keyId = process.env.ACCESS_KEY_ID;
const secret = process.env.SECRET_ACCESS_KEY;
if (!keyId || !secret) {
throw new Error('ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set in .env');
}
const hash = computeHmac(secret);
const res = await fetch(`${BASE_URL}/api/v2/auth/client_token`, {
method: 'POST',
headers: {
'x-key': keyId,
'x-hash': hash,
'content-type': 'application/json',
},
body: JSON.stringify({ scope, content: HMAC_CONTENT }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Auth failed ${res.status}: ${text}`);
}
const data: PassportalToken = await res.json();
return data.access_token;
}
// ── API helpers ───────────────────────────────────────────────────────────────
async function apiGet<T>(token: string, endpoint: string): Promise<T> {
const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, {
headers: {
'x-access-token': token,
'content-type': 'application/json',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GET ${endpoint} failed ${res.status}: ${text}`);
}
return res.json() as Promise<T>;
}
async function apiPost<T>(token: string, endpoint: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, {
method: 'POST',
headers: {
'x-access-token': token,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`POST ${endpoint} failed ${res.status}: ${text}`);
}
return res.json() as Promise<T>;
}
// ── Client / folder matching ──────────────────────────────────────────────────
function decodeHtmlEntities(str: string): string {
return str
.replace(/&#0*39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}
function slugify(name: string): string {
return decodeHtmlEntities(name).toLowerCase().replace(/[^a-z0-9]/g, '');
}
function findClient(slug: string, clients: PassportalClient[]): PassportalClient | undefined {
const target = slugify(slug);
// Exact slug match first, then prefix match
return (
clients.find(c => slugify(c.name) === target) ||
clients.find(c => slugify(c.name).includes(target)) ||
clients.find(c => target.includes(slugify(c.name)))
);
}
function findVeeamFolder(folders: PassportalFolder[]): PassportalFolder | undefined {
return folders.find(f => f.name.toLowerCase().includes(VEEAM_FOLDER_NAME.toLowerCase()));
}
// ── Discover mode ─────────────────────────────────────────────────────────────
async function tryGet(token: string, label: string, endpoints: string[]): Promise<{ endpoint: string; data: unknown } | null> {
for (const ep of endpoints) {
try {
const data = await apiGet<unknown>(token, ep);
console.log(`${label}${ep}`);
return { endpoint: ep, data };
} catch (e) {
console.log(`${ep}: ${(e as Error).message}`);
}
}
return null;
}
interface PassportalDocument {
id: number;
client_id: number;
clientName: string;
templateId: number;
templateName: string;
type: string;
label: string;
folder_id?: number;
folderName?: string;
[key: string]: unknown;
}
async function fetchAllDocuments(token: string): Promise<PassportalDocument[]> {
const all: PassportalDocument[] = [];
let page = 1;
while (true) {
const res = await apiGet<{ results?: PassportalDocument[]; success?: boolean } | PassportalDocument[]>(
token,
`/documents?page=${page}&limit=100`
);
const batch = Array.isArray(res) ? res : (res as { results?: PassportalDocument[] }).results ?? [];
all.push(...batch);
if (batch.length < 100) break; // last page
page++;
}
return all;
}
async function fetchFolders(token: string, clientId: number | string): Promise<PassportalFolder[]> {
// The /folders endpoint requires the raw HMAC auth (x-key + x-hash), not the JWT
const keyId = process.env.ACCESS_KEY_ID!;
const secret = process.env.SECRET_ACCESS_KEY!;
const hash = computeHmac(secret);
for (const endpoint of [
`/folders?clientId=${clientId}`,
`/folders?client_id=${clientId}`,
]) {
try {
const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, {
headers: {
'x-key': keyId,
'x-hash': hash,
'x-access-token': token,
'content-type': 'application/json',
},
});
if (!res.ok) continue;
const data = await res.json() as { results?: PassportalFolder[] } | PassportalFolder[];
return Array.isArray(data) ? data : (data as { results?: PassportalFolder[] }).results ?? [];
} catch { /* try next */ }
}
return [];
}
async function runDiscover(token: string, entries: WasabiEntry[]) {
console.log('\n=== FETCHING ALL DOCUMENTS ===');
const docs = await fetchAllDocuments(token);
console.log(` Total documents: ${docs.length}`);
// Unique templates
const templates = new Map<number, { name: string; type: string }>();
for (const d of docs) {
if (!templates.has(d.templateId)) {
templates.set(d.templateId, { name: d.templateName, type: d.type });
}
}
console.log(`\n=== TEMPLATES (${templates.size} unique) ===`);
for (const [id, t] of [...templates.entries()].sort((a, b) => a[0] - b[0])) {
console.log(` templateId=${id} type=${t.type.padEnd(20)} name="${t.name}"`);
}
// Look for folder fields in any document
const docWithFolder = docs.find(d => d.folder_id || d.folderName);
if (docWithFolder) {
console.log('\n=== SAMPLE DOC WITH FOLDER FIELDS ===');
console.log(JSON.stringify(docWithFolder, null, 2));
} else {
console.log('\n No folder_id/folderName found in document list response');
}
// Try fetching a single document's full detail to see if it has more fields
if (docs.length > 0) {
console.log(`\n=== SINGLE DOCUMENT DETAIL (id=${docs[0].id}) ===`);
try {
const detail = await apiGet<unknown>(token, `/documents/${docs[0].id}`);
console.log(JSON.stringify(detail, null, 2));
} catch (e) {
console.log(' Could not fetch detail:', (e as Error).message);
}
}
// Unique clients from documents
const clientMap = new Map<number, string>();
for (const d of docs) {
if (!clientMap.has(d.client_id)) clientMap.set(d.client_id, d.clientName);
}
console.log(`\n=== CLIENTS IN DOCUMENTS (${clientMap.size}) ===`);
for (const [id, name] of [...clientMap.entries()].sort((a, b) => decodeHtmlEntities(a[1]).localeCompare(decodeHtmlEntities(b[1])))) {
console.log(` client_id=${id} name="${decodeHtmlEntities(name)}"`);
}
// Try folders endpoint with both JWT and raw auth
console.log('\n=== FOLDER ENDPOINT PROBE ===');
if (clientMap.size > 0) {
const firstClientId = [...clientMap.keys()][0];
const folders = await fetchFolders(token, firstClientId);
if (folders.length > 0) {
console.log(` ✓ Got ${folders.length} folder(s) for client ${firstClientId}:`);
console.log(JSON.stringify(folders.slice(0, 5), null, 2));
} else {
console.log(` ✗ No folders returned for client ${firstClientId}`);
// Try raw endpoint probe
const keyId = process.env.ACCESS_KEY_ID!;
const hash = computeHmac(process.env.SECRET_ACCESS_KEY!);
for (const ep of ['/folders', `/folders?clientId=${firstClientId}`, '/passwords', '/credentials']) {
try {
const res = await fetch(`${BASE_URL}/api/v2${ep}`, {
headers: { 'x-key': keyId, 'x-hash': hash, 'content-type': 'application/json' },
});
const text = await res.text();
console.log(` raw-auth ${ep}${res.status}: ${text.slice(0, 200)}`);
} catch (e) {
console.log(` raw-auth ${ep} → error: ${(e as Error).message}`);
}
}
}
}
// Show client slug matching preview
const slugs = [...new Set(entries.map(e => e.clientSlug))].filter(s => !SKIP_CLIENT_SLUGS.has(s));
console.log(`\n=== CLIENT SLUG → CLIENT MATCH PREVIEW ===`);
const clients: PassportalClient[] = [...clientMap.entries()].map(([id, name]) => ({ id, name }));
for (const slug of slugs) {
const match = findClient(slug, clients);
if (match) {
console.log(` ✓ "${slug}" → "${decodeHtmlEntities(match.name)}" (id=${match.id})`);
} else {
console.log(` ✗ "${slug}" → NO MATCH`);
}
}
}
// ── Main import ───────────────────────────────────────────────────────────────
async function runImport(token: string, entries: WasabiEntry[], dryRun: boolean) {
// 1. Build client list from documents endpoint (client API returns 500)
console.log('Fetching documents to build client list...');
const allDocs = await fetchAllDocuments(token);
console.log(` Found ${allDocs.length} documents`);
const clientMap = new Map<number, string>();
for (const d of allDocs) {
if (!clientMap.has(d.client_id)) clientMap.set(d.client_id, d.clientName);
}
const clients: PassportalClient[] = [...clientMap.entries()].map(([id, name]) => ({ id, name }));
console.log(` Found ${clients.length} unique clients`);
// 2. Fetch templates and find best fit
console.log('Fetching templates...');
let templateUid: string | number | undefined;
try {
const res = await apiGet<unknown>(token, '/templates');
const templates: PassportalTemplate[] = Array.isArray(res) ? res : ((res as { data?: PassportalTemplate[] }).data ?? []);
// Prefer a template named something like "Username & Password" or "AWS" or "S3"
const preferred = templates.find(t =>
/username|password|credential|aws|s3|wasabi/i.test(t.name)
) ?? templates[0];
if (preferred) {
templateUid = preferred.id;
console.log(` Using template: "${preferred.name}" (${preferred.id})`);
}
} catch (e) {
console.warn(' Could not fetch templates — templateUid will be omitted');
}
// 3. Group entries by client slug, skip internal buckets
const bySlug = new Map<string, WasabiEntry[]>();
for (const entry of entries) {
if (SKIP_CLIENT_SLUGS.has(entry.clientSlug)) continue;
const list = bySlug.get(entry.clientSlug) ?? [];
list.push(entry);
bySlug.set(entry.clientSlug, list);
}
const skipped = entries.filter(e => SKIP_CLIENT_SLUGS.has(e.clientSlug));
if (skipped.length > 0) {
console.log(`\nSkipping ${skipped.length} internal bucket(s): ${skipped.map(e => e.bucket).join(', ')}`);
}
// 4. Process each client slug
let created = 0;
let failed = 0;
const unmatched: string[] = [];
for (const [slug, slugEntries] of bySlug) {
const client = findClient(slug, clients);
if (!client) {
console.warn(` ✗ No Passportal client found for slug "${slug}" — skipping ${slugEntries.length} entry/entries`);
unmatched.push(slug);
continue;
}
// Fetch folders for this client
let folders: PassportalFolder[] = [];
for (const endpoint of [
`/folders?clientId=${client.id}`,
`/clients/${client.id}/folders`,
]) {
try {
const res = await apiGet<unknown>(token, endpoint);
folders = Array.isArray(res) ? res : ((res as { data?: PassportalFolder[] }).data ?? []);
if (folders.length > 0) break;
} catch { /* try next */ }
}
const veeamFolder = findVeeamFolder(folders);
if (!veeamFolder) {
console.warn(` ✗ No "${VEEAM_FOLDER_NAME}" folder found for client "${client.name}" — skipping`);
failed += slugEntries.length;
continue;
}
// Create one credential per entry
for (const entry of slugEntries) {
const title = `Wasabi S3 - ${entry.bucket}`;
const doc = {
...(templateUid !== undefined ? { templateUid } : {}),
clientId: client.id,
folderId: veeamFolder.id,
title,
// Common username/password fields (field names vary by template)
username: entry.accessKey,
password: entry.secretKey,
// Extra context fields
notes: [
`Bucket: ${entry.bucket}`,
`IAM User: ${entry.username}`,
`Access Key: ${entry.accessKey}`,
`Endpoint: ${entry.endpoint}`,
].join('\n'),
// Template-specific aliases
access_key: entry.accessKey,
secret_key: entry.secretKey,
url: entry.endpoint,
application_name: `Wasabi S3 - ${entry.bucket}`,
};
if (dryRun) {
console.log(` [DRY RUN] Would create "${title}" under ${client.name} / ${veeamFolder.name}`);
} else {
try {
await apiPost(token, '/documents', [doc]);
console.log(` ✓ Created "${title}" under ${client.name} / ${veeamFolder.name}`);
created++;
} catch (e) {
console.error(` ✗ Failed to create "${title}": ${(e as Error).message}`);
failed++;
}
}
}
}
// 5. Summary
console.log('\n=== Summary ===');
if (dryRun) {
const total = [...bySlug.values()].reduce((n, v) => n + v.length, 0);
console.log(`Would create: ${total} credentials`);
} else {
console.log(`Created: ${created}`);
console.log(`Failed: ${failed}`);
}
if (unmatched.length > 0) {
console.log(`\nUnmatched client slugs (${unmatched.length}) — no Passportal client found:`);
unmatched.forEach(s => console.log(` - ${s}`));
console.log('\nTip: run --discover to see the full client list and adjust matching manually.');
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
async function main() {
const args = process.argv.slice(2);
const isDiscover = args.includes('--discover');
const isDryRun = args.includes('--dry-run');
console.log('=== Passportal Wasabi IAM Import ===');
if (isDiscover) console.log('Mode: DISCOVER');
else if (isDryRun) console.log('Mode: DRY RUN (no changes)');
else console.log('Mode: LIVE IMPORT');
const entries = parseCredentialsFile(CREDS_FILE);
console.log(`\nParsed ${entries.length} credential entries from file`);
const slugCounts = new Map<string, number>();
for (const e of entries) {
slugCounts.set(e.clientSlug, (slugCounts.get(e.clientSlug) ?? 0) + 1);
}
console.log(`Unique client slugs: ${[...slugCounts.keys()].join(', ')}`);
console.log('\nAuthenticating...');
const token = await authenticate();
console.log('✓ Authenticated');
if (isDiscover) {
await runDiscover(token, entries);
} else {
await runImport(token, entries, isDryRun);
}
}
main().catch(err => {
console.error('\nFatal:', err.message);
process.exit(1);
});

View file

@ -0,0 +1,431 @@
<#
.SYNOPSIS
Veeam Backup Diagnostic Script Run via Datto RMM Quick Job
.DESCRIPTION
Checks Veeam services, backup job status, disk space, event logs,
and network connectivity. Returns structured JSON for pipeline consumption.
.NOTES
Deploy as a Datto RMM component. Output via Write-Host for StdOut capture.
Compatible with PowerShell 5.1+.
#>
try {
$ErrorActionPreference = 'SilentlyContinue'
$result = @{
timestamp = ([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss UTC'))
hostname = $env:COMPUTERNAME
checks = @{}
issues_found = @()
recommendations = @()
}
# ============================================================================
# 1. Veeam Services Status
# ============================================================================
$veeamServices = @(
'VeeamBackupSvc',
'VeeamBrokerSvc',
'VeeamCatalogSvc',
'VeeamCloudSvc',
'VeeamDeploySvc',
'VeeamDistributionSvc',
'VeeamMountSvc',
'VeeamNFSSvc',
'VeeamTransportSvc',
'VeeamEndpointBackupSvc',
'VeeamFilesysVssSvc'
)
$serviceResults = @()
$stoppedCritical = @()
foreach ($svcName in $veeamServices) {
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
if ($svc) {
$serviceResults += @{
name = $svc.Name
display = $svc.DisplayName
status = $svc.Status.ToString()
start_type = $svc.StartType.ToString()
}
if ($svc.Status -ne 'Running' -and $svc.StartType -ne 'Disabled') {
$stoppedCritical += $svc.DisplayName
}
}
}
$result.checks.services = @{
total_found = $serviceResults.Count
services = $serviceResults
stopped_critical = $stoppedCritical
}
if ($stoppedCritical.Count -gt 0) {
$result.issues_found += "Veeam services not running: $($stoppedCritical -join ', ')"
$result.recommendations += "Restart stopped Veeam services: $($stoppedCritical -join ', ')"
}
# ============================================================================
# 2. Veeam Backup Job Status (via PowerShell Snap-in if available)
# ============================================================================
$jobResults = @()
$vbrSnapinLoaded = $false
try {
if (Get-PSSnapin -Registered -Name VeeamPSSnapin -ErrorAction SilentlyContinue) {
Add-PSSnapin VeeamPSSnapin -ErrorAction Stop
$vbrSnapinLoaded = $true
}
elseif (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell -ErrorAction SilentlyContinue) {
Import-Module Veeam.Backup.PowerShell -ErrorAction Stop
$vbrSnapinLoaded = $true
}
} catch {
# Snap-in not available — skip VBR-specific checks
}
if ($vbrSnapinLoaded) {
try {
$jobs = Get-VBRJob -ErrorAction SilentlyContinue
foreach ($job in $jobs) {
$lastSession = $job.FindLastSession()
$jobResults += @{
name = $job.Name
type = $job.TypeToString
is_enabled = $job.IsScheduleEnabled
status = if ($lastSession) { $lastSession.Result.ToString() } else { 'NoSession' }
last_run = if ($lastSession) { $lastSession.CreationTime.ToString('yyyy-MM-dd HH:mm:ss') } else { $null }
end_time = if ($lastSession) { $lastSession.EndTime.ToString('yyyy-MM-dd HH:mm:ss') } else { $null }
duration_min = if ($lastSession -and $lastSession.EndTime -gt $lastSession.CreationTime) {
[math]::Round(($lastSession.EndTime - $lastSession.CreationTime).TotalMinutes, 1)
} else { $null }
failure_msg = if ($lastSession -and $lastSession.Result -eq 'Failed') {
($lastSession.GetTaskSessions() | Where-Object { $_.Status -eq 'Failed' } |
Select-Object -First 1 -ExpandProperty Details -ErrorAction SilentlyContinue)
} else { $null }
}
}
$failedJobs = $jobResults | Where-Object { $_.status -eq 'Failed' }
if ($failedJobs.Count -gt 0) {
$result.issues_found += "Failed backup jobs: $(($failedJobs | ForEach-Object { $_.name }) -join ', ')"
$result.recommendations += "Investigate failed jobs and check task session logs in Veeam console"
}
# Check for stuck/running jobs > 24h
$stuckJobs = $jobResults | Where-Object {
$_.status -eq 'Working' -and $_.last_run -and
((Get-Date) - [datetime]$_.last_run).TotalHours -gt 24
}
if ($stuckJobs.Count -gt 0) {
$result.issues_found += "Stuck jobs running >24h: $(($stuckJobs | ForEach-Object { $_.name }) -join ', ')"
$result.recommendations += "Consider stopping and restarting stuck backup jobs"
}
} catch {
$jobResults = @(@{ error = $_.Exception.Message })
}
}
$result.checks.backup_jobs = @{
vbr_available = $vbrSnapinLoaded
total_jobs = $jobResults.Count
jobs = $jobResults
}
# ============================================================================
# 3. Disk Space Check (all fixed drives)
# ============================================================================
$diskResults = @()
$lowDiskDrives = @()
$drives = Get-WmiObject Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue
foreach ($drive in $drives) {
$freeGB = [math]::Round($drive.FreeSpace / 1GB, 2)
$totalGB = [math]::Round($drive.Size / 1GB, 2)
$usedPct = if ($totalGB -gt 0) { [math]::Round((($totalGB - $freeGB) / $totalGB) * 100, 1) } else { 0 }
$diskResults += @{
drive = $drive.DeviceID
label = $drive.VolumeName
total_gb = $totalGB
free_gb = $freeGB
used_pct = $usedPct
}
if ($usedPct -gt 90) {
$lowDiskDrives += "$($drive.DeviceID) ($usedPct% used, $freeGB GB free)"
}
}
$result.checks.disk_space = @{
drives = $diskResults
low_disk = $lowDiskDrives
}
if ($lowDiskDrives.Count -gt 0) {
$result.issues_found += "Low disk space: $($lowDiskDrives -join ', ')"
$result.recommendations += "Free disk space or expand storage on affected drives"
}
# ============================================================================
# 4. Windows Event Log — Veeam errors (last 48 hours)
# ============================================================================
$eventResults = @()
$cutoff = (Get-Date).AddHours(-48)
# Veeam Backup log
$veeamEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Veeam Backup'
Level = @(1, 2) # Critical, Error
StartTime = $cutoff
} -MaxEvents 20 -ErrorAction SilentlyContinue
foreach ($evt in $veeamEvents) {
$eventResults += @{
source = 'Veeam Backup'
level = $evt.LevelDisplayName
id = $evt.Id
time = $evt.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')
message = $evt.Message.Substring(0, [Math]::Min(500, $evt.Message.Length))
}
}
# Veeam Agent log
$agentEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Veeam Agent'
Level = @(1, 2)
StartTime = $cutoff
} -MaxEvents 10 -ErrorAction SilentlyContinue
foreach ($evt in $agentEvents) {
$eventResults += @{
source = 'Veeam Agent'
level = $evt.LevelDisplayName
id = $evt.Id
time = $evt.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')
message = $evt.Message.Substring(0, [Math]::Min(500, $evt.Message.Length))
}
}
# Application log — Veeam source
$appEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Application'
ProviderName = @('Veeam*')
Level = @(1, 2)
StartTime = $cutoff
} -MaxEvents 10 -ErrorAction SilentlyContinue
foreach ($evt in $appEvents) {
$eventResults += @{
source = "Application/$($evt.ProviderName)"
level = $evt.LevelDisplayName
id = $evt.Id
time = $evt.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')
message = $evt.Message.Substring(0, [Math]::Min(500, $evt.Message.Length))
}
}
$result.checks.event_logs = @{
total_errors = $eventResults.Count
events = $eventResults
}
if ($eventResults.Count -gt 0) {
$result.issues_found += "$($eventResults.Count) Veeam error events in last 48h"
}
# ============================================================================
# 5. Veeam Process Check — is anything stuck?
# ============================================================================
$veeamProcesses = Get-Process -Name "Veeam*" -ErrorAction SilentlyContinue |
Select-Object Name, Id, CPU,
@{N='MemoryMB';E={[math]::Round($_.WorkingSet64/1MB,1)}},
@{N='RunningHours';E={[math]::Round(((Get-Date) - $_.StartTime).TotalHours, 1)}}
$stuckProcesses = $veeamProcesses | Where-Object { $_.RunningHours -gt 48 }
$result.checks.processes = @{
running = @($veeamProcesses | ForEach-Object {
@{ name = $_.Name; pid = $_.Id; memory_mb = $_.MemoryMB; running_hours = $_.RunningHours }
})
stuck = @($stuckProcesses | ForEach-Object { $_.Name })
}
if ($stuckProcesses.Count -gt 0) {
$result.issues_found += "Potentially stuck Veeam processes (>48h): $(($stuckProcesses | ForEach-Object { $_.Name }) -join ', ')"
$result.recommendations += "Review and potentially restart long-running Veeam processes"
}
# ============================================================================
# 6. Network Connectivity to Backup Targets
# ============================================================================
$networkResults = @()
# Try to find backup repository paths from registry
$repoKeys = Get-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam Backup and Replication" -ErrorAction SilentlyContinue
$sqlServer = $repoKeys.SqlServerName
if ($sqlServer) {
$testSql = Test-NetConnection -ComputerName $sqlServer -Port 1433 -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
$networkResults += @{
target = "SQL: $sqlServer"
port = 1433
success = $testSql.TcpTestSucceeded
}
if (-not $testSql.TcpTestSucceeded) {
$result.issues_found += "Cannot reach Veeam SQL server: $sqlServer"
$result.recommendations += "Check network connectivity and SQL Server service on $sqlServer"
}
}
# Test common backup infrastructure ports
$vbrServer = $repoKeys.SqlDatabaseName # Often same host
$localPorts = @(
@{ Name = "Veeam Backup Service"; Port = 9392 },
@{ Name = "Veeam REST API"; Port = 9419 },
@{ Name = "Veeam Cloud Connect"; Port = 6180 }
)
foreach ($p in $localPorts) {
$test = Test-NetConnection -ComputerName 'localhost' -Port $p.Port -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
$networkResults += @{
target = $p.Name
port = $p.Port
success = $test.TcpTestSucceeded
}
}
$result.checks.network = @{
tests = $networkResults
}
# ============================================================================
# 7. Summary
# ============================================================================
$result.total_issues = $result.issues_found.Count
$result.severity = if ($result.issues_found.Count -eq 0) { 'OK' }
elseif ($result.issues_found.Count -le 2) { 'WARNING' }
else { 'CRITICAL' }
# ============================================================================
# 8. Upload to B2 (S3-compatible) and output object key
# ============================================================================
$jsonOutput = $result | ConvertTo-Json -Depth 5 -Compress
# B2 credentials — set these as Datto RMM component variables or site variables
$b2KeyId = if ($env:B2_KEY_ID) { $env:B2_KEY_ID } else { $env:usrB2KeyId }
$b2AppKey = if ($env:B2_APP_KEY) { $env:B2_APP_KEY } else { $env:usrB2AppKey }
$b2Bucket = if ($env:B2_BUCKET) { $env:B2_BUCKET } else { if ($env:usrB2Bucket) { $env:usrB2Bucket } else { 'wulf-audits' } }
$b2Region = if ($env:B2_REGION) { $env:B2_REGION } else { if ($env:usrB2Region) { $env:usrB2Region } else { 'us-west-002' } }
$b2Endpoint = if ($env:B2_ENDPOINT) { $env:B2_ENDPOINT } else { "s3.$b2Region.backblazeb2.com" }
$datePrefix = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd')
$timeStamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$objectKey = "diagnostics/$($env:COMPUTERNAME)/$datePrefix/$timeStamp.json"
if ($b2KeyId -and $b2AppKey) {
try {
# S3v4 presigned PUT
$method = 'PUT'
$host_ = $b2Endpoint
$canonicalUri = "/$b2Bucket/$objectKey"
$algorithm = 'AWS4-HMAC-SHA256'
$amzDate = $timeStamp
$dateStamp = $amzDate.Substring(0, 8)
$credScope = "$dateStamp/$b2Region/s3/aws4_request"
$contentHash = [System.BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($jsonOutput)
)
).Replace('-','').ToLower()
$canonicalHeaders = "content-type:application/json`nhost:$host_`nx-amz-content-sha256:$contentHash`nx-amz-date:$amzDate`n"
$signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
$canonicalRequest = "$method`n$canonicalUri`n`n$canonicalHeaders`n$signedHeaders`n$contentHash"
$crHash = [System.BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($canonicalRequest)
)
).Replace('-','').ToLower()
$stringToSign = "$algorithm`n$amzDate`n$credScope`n$crHash"
# Derive signing key
function HmacSHA256($key, $data) {
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = if ($key -is [byte[]]) { $key } else { [System.Text.Encoding]::UTF8.GetBytes($key) }
return $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($data))
}
$kDate = HmacSHA256 "AWS4$b2AppKey" $dateStamp
$kRegion = HmacSHA256 $kDate $b2Region
$kService = HmacSHA256 $kRegion 's3'
$kSigning = HmacSHA256 $kService 'aws4_request'
$signature = [System.BitConverter]::ToString(
(HmacSHA256 $kSigning $stringToSign)
).Replace('-','').ToLower()
$authHeader = "$algorithm Credential=$b2KeyId/$credScope, SignedHeaders=$signedHeaders, Signature=$signature"
$headers = @{
'Authorization' = $authHeader
'x-amz-date' = $amzDate
'x-amz-content-sha256' = $contentHash
'Content-Type' = 'application/json'
}
$uri = "https://$host_$canonicalUri"
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($jsonOutput)
# Use .NET WebRequest for PS 5.1 compatibility
$webRequest = [System.Net.HttpWebRequest]::Create($uri)
$webRequest.Method = 'PUT'
$webRequest.ContentType = 'application/json'
$webRequest.ContentLength = $bodyBytes.Length
foreach ($h in $headers.GetEnumerator()) {
if ($h.Key -notin @('Content-Type')) {
$webRequest.Headers.Add($h.Key, $h.Value)
}
}
$stream = $webRequest.GetRequestStream()
$stream.Write($bodyBytes, 0, $bodyBytes.Length)
$stream.Close()
$response = $webRequest.GetResponse()
$statusCode = [int]$response.StatusCode
$response.Close()
if ($statusCode -eq 200) {
# Success — output object key for pipeline to fetch
Write-Host $objectKey
} else {
# Upload failed — fall back to inline JSON
Write-Host "UPLOAD_FAILED:$statusCode"
Write-Host $jsonOutput
}
} catch {
# Upload error — fall back to inline JSON
Write-Host "UPLOAD_ERROR:$($_.Exception.Message)"
Write-Host $jsonOutput
}
} else {
# No B2 credentials — output JSON directly (fallback)
Write-Host $jsonOutput
}
} catch {
# Ensure errors are visible in RMM StdErr/StdOut
$errorResult = @{
hostname = $env:COMPUTERNAME
error = $_.Exception.Message
line = $_.InvocationInfo.ScriptLineNumber
severity = 'SCRIPT_ERROR'
} | ConvertTo-Json -Compress
Write-Host $errorResult
exit 1
}

View file

@ -0,0 +1,542 @@
/**
* 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);
});