feat: IT Glue integration, workflow engine, pipelines, Zabbix WAN, notification channels, backup status UI improvements, nav alignment fixes
This commit is contained in:
parent
ed6c4a8b65
commit
19605f82aa
97 changed files with 17080 additions and 304 deletions
549
scripts/passportal-import-wasabi.ts
Normal file
549
scripts/passportal-import-wasabi.ts
Normal 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(/�*39;/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/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);
|
||||
});
|
||||
431
scripts/rmm-diagnostics/veeam-backup-diagnostic.ps1
Normal file
431
scripts/rmm-diagnostics/veeam-backup-diagnostic.ps1
Normal 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
|
||||
}
|
||||
542
scripts/setup-zabbix-wan-monitoring.ts
Normal file
542
scripts/setup-zabbix-wan-monitoring.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue