Initial commit baby!
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import { query } from '../config/database';
|
||||
|
||||
export type AlertEventType =
|
||||
| 'device_offline'
|
||||
| 'device_online'
|
||||
| 'log_error'
|
||||
| 'log_warning'
|
||||
| 'high_cpu'
|
||||
| 'high_memory'
|
||||
| 'cert_expiry'
|
||||
| 'device_discovered'
|
||||
| 'firmware_update_available';
|
||||
|
||||
export interface AlertContext {
|
||||
deviceId?: number;
|
||||
deviceName?: string;
|
||||
details?: string;
|
||||
/** Override the per-alert cooldown key (default: `${eventType}:${deviceId|'global'}`). */
|
||||
cooldownKey?: string;
|
||||
}
|
||||
|
||||
interface AlertRule {
|
||||
event_type: string;
|
||||
enabled: boolean;
|
||||
threshold: number | null;
|
||||
cooldown_min: number;
|
||||
}
|
||||
|
||||
interface AlertChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram';
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// In-memory cooldown map (keyed by "eventType:deviceId")
|
||||
// Persists across calls within the same process lifetime
|
||||
const cooldownUntil = new Map<string, number>();
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
device_offline: 'Device Offline',
|
||||
device_online: 'Device Online (Recovery)',
|
||||
log_error: 'Log Error Detected',
|
||||
log_warning: 'Log Warning Detected',
|
||||
high_cpu: 'High CPU Usage',
|
||||
high_memory: 'High Memory Usage',
|
||||
cert_expiry: 'Certificate Expiring Soon',
|
||||
device_discovered: 'New Device Discovered',
|
||||
firmware_update_available: 'Firmware Update Available',
|
||||
};
|
||||
|
||||
const EVENT_EMOJI: Record<string, string> = {
|
||||
device_offline: '🔴',
|
||||
device_online: '🟢',
|
||||
log_error: '❌',
|
||||
log_warning: '⚠️',
|
||||
high_cpu: '🔥',
|
||||
high_memory: '🔥',
|
||||
cert_expiry: '🔐',
|
||||
device_discovered: '🔍',
|
||||
firmware_update_available: '🔄',
|
||||
};
|
||||
|
||||
export class AlertService {
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async dispatch(eventType: AlertEventType, message: string, ctx: AlertContext = {}): Promise<void> {
|
||||
try {
|
||||
const rule = await this.getRule(eventType);
|
||||
if (!rule?.enabled) return;
|
||||
|
||||
// Cooldown check
|
||||
const cooldownKey = ctx.cooldownKey ?? `${eventType}:${ctx.deviceId ?? 'global'}`;
|
||||
const cooldownMs = (rule.cooldown_min ?? 15) * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const until = cooldownUntil.get(cooldownKey) ?? 0;
|
||||
if (now < until) return;
|
||||
cooldownUntil.set(cooldownKey, now + cooldownMs);
|
||||
|
||||
// Get enabled channels
|
||||
const channels = await query<AlertChannel>(
|
||||
`SELECT * FROM alert_channels WHERE enabled = true ORDER BY id`
|
||||
);
|
||||
if (channels.length === 0) return;
|
||||
|
||||
// Dispatch concurrently, don't let one failure block others
|
||||
const notified: string[] = [];
|
||||
await Promise.allSettled(
|
||||
channels.map(async (ch) => {
|
||||
try {
|
||||
await this.sendToChannel(ch, eventType, message, ctx);
|
||||
notified.push(ch.name);
|
||||
} catch (err) {
|
||||
console.error(`[AlertService] Channel "${ch.name}" failed:`, (err as Error).message);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Log to history — use subquery for device_id so a deleted device doesn't cause a FK violation
|
||||
await query(
|
||||
`INSERT INTO alert_history (event_type, device_id, device_name, message, channels_notified)
|
||||
VALUES ($1, (SELECT id FROM devices WHERE id = $2), $3, $4, $5)`,
|
||||
[eventType, ctx.deviceId ?? null, ctx.deviceName ?? null, message, JSON.stringify(notified)]
|
||||
);
|
||||
|
||||
if (notified.length > 0) {
|
||||
console.log(`[AlertService] ${eventType} → sent to: ${notified.join(', ')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AlertService] Dispatch error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async getRule(eventType: string): Promise<AlertRule | null> {
|
||||
const rows = await query<AlertRule>(
|
||||
`SELECT * FROM alert_rules WHERE event_type = $1`,
|
||||
[eventType]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async getRules(): Promise<AlertRule[]> {
|
||||
return query<AlertRule>(`SELECT * FROM alert_rules ORDER BY event_type`);
|
||||
}
|
||||
|
||||
async upsertRule(eventType: string, data: Partial<AlertRule>): Promise<AlertRule> {
|
||||
const rows = await query<AlertRule>(
|
||||
`INSERT INTO alert_rules (event_type, enabled, threshold, cooldown_min)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (event_type) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
threshold = EXCLUDED.threshold,
|
||||
cooldown_min = EXCLUDED.cooldown_min,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[
|
||||
eventType,
|
||||
data.enabled ?? false,
|
||||
data.threshold ?? null,
|
||||
data.cooldown_min ?? 15,
|
||||
]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/** Send a test message to a single channel (ignores cooldown/rules). */
|
||||
async testChannel(channelId: number): Promise<void> {
|
||||
const rows = await query<AlertChannel>(
|
||||
`SELECT * FROM alert_channels WHERE id = $1`,
|
||||
[channelId]
|
||||
);
|
||||
const ch = rows[0];
|
||||
if (!ch) throw new Error('Channel not found');
|
||||
await this.sendToChannel(
|
||||
ch,
|
||||
'device_online',
|
||||
'This is a test alert from Mikrotik Manager.',
|
||||
{ deviceName: 'Test Device' }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sending ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async sendToChannel(
|
||||
ch: AlertChannel,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
switch (ch.type) {
|
||||
case 'email': await this.sendEmail(ch.config, eventType, message, ctx); break;
|
||||
case 'slack': await this.sendSlack(ch.config, eventType, message, ctx); break;
|
||||
case 'discord': await this.sendDiscord(ch.config, eventType, message, ctx); break;
|
||||
case 'telegram': await this.sendTelegram(ch.config, eventType, message, ctx); break;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendEmail(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const host = cfg.smtp_host as string;
|
||||
const port = (cfg.smtp_port as number) || 587;
|
||||
const secure = !!(cfg.smtp_secure);
|
||||
const user = cfg.smtp_user as string | undefined;
|
||||
const pass = cfg.smtp_pass as string | undefined;
|
||||
const from = (cfg.from_address as string) || user || 'alerts@mikrotik-manager';
|
||||
const recipients = (cfg.recipients as string[]) || [];
|
||||
|
||||
if (!host || recipients.length === 0) {
|
||||
throw new Error('Email channel missing smtp_host or recipients');
|
||||
}
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
tls: { rejectUnauthorized: false },
|
||||
} as nodemailer.TransportOptions);
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const subject = `[Mikrotik Manager] ${label}`;
|
||||
const device = ctx.deviceName ? `<b>Device:</b> ${ctx.deviceName}<br>` : '';
|
||||
const details = ctx.details ? `<br><pre>${ctx.details}</pre>` : '';
|
||||
|
||||
const html = `
|
||||
<div style="font-family:sans-serif;max-width:600px">
|
||||
<div style="background:#1e40af;color:#fff;padding:12px 20px;border-radius:8px 8px 0 0">
|
||||
<strong>Mikrotik Manager Alert</strong>
|
||||
</div>
|
||||
<div style="border:1px solid #e5e7eb;border-top:none;padding:20px;border-radius:0 0 8px 8px">
|
||||
<h2 style="margin:0 0 12px;color:#1f2937">${EVENT_EMOJI[eventType] ?? '🔔'} ${label}</h2>
|
||||
<p style="color:#374151">${message}</p>
|
||||
<div style="color:#6b7280;font-size:13px">${device}${details}</div>
|
||||
<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb">
|
||||
<p style="font-size:12px;color:#9ca3af">Sent by Mikrotik Manager · ${new Date().toUTCString()}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
await transport.sendMail({ from, to: recipients.join(', '), subject, html });
|
||||
}
|
||||
|
||||
private async sendSlack(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const webhookUrl = cfg.webhook_url as string;
|
||||
if (!webhookUrl) throw new Error('Slack channel missing webhook_url');
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const emoji = EVENT_EMOJI[eventType] ?? ':bell:';
|
||||
const device = ctx.deviceName ? `\n*Device:* ${ctx.deviceName}` : '';
|
||||
const detail = ctx.details ? `\n\`\`\`${ctx.details}\`\`\`` : '';
|
||||
|
||||
const body = JSON.stringify({
|
||||
text: `${emoji} *${label}*`,
|
||||
attachments: [{
|
||||
color: this.slackColor(eventType),
|
||||
text: `${message}${device}${detail}`,
|
||||
footer: 'Mikrotik Manager',
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
}],
|
||||
});
|
||||
|
||||
await this.postJson(webhookUrl, body);
|
||||
}
|
||||
|
||||
private async sendDiscord(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const webhookUrl = cfg.webhook_url as string;
|
||||
if (!webhookUrl) throw new Error('Discord channel missing webhook_url');
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const device = ctx.deviceName ? `\n**Device:** ${ctx.deviceName}` : '';
|
||||
const detail = ctx.details ? `\n\`\`\`${ctx.details}\`\`\`` : '';
|
||||
|
||||
const body = JSON.stringify({
|
||||
embeds: [{
|
||||
title: `${EVENT_EMOJI[eventType] ?? '🔔'} ${label}`,
|
||||
description: `${message}${device}${detail}`,
|
||||
color: this.discordColor(eventType),
|
||||
footer: { text: 'Mikrotik Manager' },
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
|
||||
await this.postJson(webhookUrl, body);
|
||||
}
|
||||
|
||||
private async sendTelegram(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const botToken = cfg.bot_token as string;
|
||||
const chatId = cfg.chat_id as string;
|
||||
if (!botToken || !chatId) throw new Error('Telegram channel missing bot_token or chat_id');
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const device = ctx.deviceName ? `\n📡 <b>Device:</b> ${ctx.deviceName}` : '';
|
||||
const detail = ctx.details ? `\n<pre>${ctx.details}</pre>` : '';
|
||||
|
||||
const text = `${EVENT_EMOJI[eventType] ?? '🔔'} <b>${label}</b>\n${message}${device}${detail}`;
|
||||
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' });
|
||||
|
||||
await this.postJson(url, body);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private postJson(url: string, body: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
private slackColor(eventType: string): string {
|
||||
if (eventType === 'device_online') return 'good';
|
||||
if (['device_offline', 'log_error', 'high_cpu', 'high_memory'].includes(eventType)) return 'danger';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
private discordColor(eventType: string): number {
|
||||
if (eventType === 'device_online') return 0x22c55e; // green
|
||||
if (eventType === 'log_warning' || eventType === 'cert_expiry') return 0xf59e0b; // amber
|
||||
return 0xef4444; // red
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const alertService = new AlertService();
|
||||
@@ -0,0 +1,217 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Client as SSHClient } from 'ssh2';
|
||||
import { query } from '../config/database';
|
||||
import { decrypt } from '../utils/crypto';
|
||||
|
||||
const BACKUPS_DIR = process.env.BACKUPS_DIR || '/app/backups';
|
||||
|
||||
export interface BackupDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
ssh_port: number;
|
||||
ssh_username?: string;
|
||||
ssh_password_encrypted?: string;
|
||||
api_username: string;
|
||||
api_password_encrypted: string;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
constructor() {
|
||||
// Ensure backups directory exists
|
||||
if (!fs.existsSync(BACKUPS_DIR)) {
|
||||
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async createBackup(device: BackupDevice, notes?: string): Promise<number> {
|
||||
const sshUser = device.ssh_username || device.api_username;
|
||||
const sshPass = device.ssh_password_encrypted
|
||||
? decrypt(device.ssh_password_encrypted)
|
||||
: decrypt(device.api_password_encrypted);
|
||||
|
||||
const exportContent = await this.sshExport(
|
||||
device.ip_address,
|
||||
device.ssh_port || 22,
|
||||
sshUser,
|
||||
sshPass
|
||||
);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${device.name.replace(/[^a-z0-9]/gi, '_')}_${timestamp}.rsc`;
|
||||
const filePath = path.join(BACKUPS_DIR, String(device.id), filename);
|
||||
|
||||
// Ensure device backup directory exists
|
||||
const deviceDir = path.join(BACKUPS_DIR, String(device.id));
|
||||
if (!fs.existsSync(deviceDir)) {
|
||||
fs.mkdirSync(deviceDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, exportContent, 'utf8');
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
const rows = await query<{ id: number }>(
|
||||
`INSERT INTO backups (device_id, filename, file_path, size_bytes, backup_type, notes)
|
||||
VALUES ($1,$2,$3,$4,'manual',$5) RETURNING id`,
|
||||
[device.id, filename, filePath, stats.size, notes || null]
|
||||
);
|
||||
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
async restoreBackup(backupId: number): Promise<void> {
|
||||
const backup = await query<{
|
||||
file_path: string;
|
||||
device_id: number;
|
||||
filename: string;
|
||||
}>(`SELECT b.*, d.ip_address, d.ssh_port, d.ssh_username, d.ssh_password_encrypted, d.api_username, d.api_password_encrypted
|
||||
FROM backups b JOIN devices d ON d.id = b.device_id
|
||||
WHERE b.id = $1`, [backupId]);
|
||||
|
||||
if (!backup[0]) throw new Error('Backup not found');
|
||||
|
||||
const b = backup[0] as unknown as BackupDevice & { file_path: string };
|
||||
const content = fs.readFileSync(b.file_path, 'utf8');
|
||||
|
||||
const sshUser = b.ssh_username || b.api_username;
|
||||
const sshPass = b.ssh_password_encrypted
|
||||
? decrypt(b.ssh_password_encrypted)
|
||||
: decrypt(b.api_password_encrypted);
|
||||
|
||||
await this.sshImport(b.ip_address, b.ssh_port || 22, sshUser, sshPass, content);
|
||||
}
|
||||
|
||||
async deleteBackup(backupId: number): Promise<void> {
|
||||
const rows = await query<{ file_path: string }>(
|
||||
`DELETE FROM backups WHERE id = $1 RETURNING file_path`,
|
||||
[backupId]
|
||||
);
|
||||
if (rows[0]?.file_path && fs.existsSync(rows[0].file_path)) {
|
||||
fs.unlinkSync(rows[0].file_path);
|
||||
}
|
||||
}
|
||||
|
||||
getBackupFilePath(backupId: number, deviceId: number): string | null {
|
||||
const rows = fs.readdirSync(path.join(BACKUPS_DIR, String(deviceId))).filter(
|
||||
(f) => f.includes(String(backupId))
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
return path.join(BACKUPS_DIR, String(deviceId), rows[0]);
|
||||
}
|
||||
|
||||
private sshExport(
|
||||
host: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
let output = '';
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error('SSH timeout during backup'));
|
||||
}, 30_000);
|
||||
|
||||
conn.on('ready', () => {
|
||||
conn.exec('/export compact', (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
console.warn('SSH stderr:', data.toString());
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
resolve(output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.connect({ host, port, username, password, readyTimeout: 10_000 });
|
||||
});
|
||||
}
|
||||
|
||||
private sshImport(
|
||||
host: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error('SSH timeout during restore'));
|
||||
}, 60_000);
|
||||
|
||||
conn.on('ready', () => {
|
||||
// Upload via SFTP then execute
|
||||
conn.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const remoteFile = '/restore_config.rsc';
|
||||
const writeStream = sftp.createWriteStream(remoteFile);
|
||||
|
||||
writeStream.on('close', () => {
|
||||
// Execute the import
|
||||
conn.exec(`/import file-name=${remoteFile}`, (err2, stream) => {
|
||||
if (err2) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err2);
|
||||
}
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
stream.on('error', (e: Error) => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
writeStream.on('error', (e: Error) => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
reject(e);
|
||||
});
|
||||
|
||||
writeStream.end(Buffer.from(content, 'utf8'));
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.connect({ host, port, username, password, readyTimeout: 10_000 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { createRedisConnection } from '../config/redis';
|
||||
import { query } from '../config/database';
|
||||
import { DeviceCollector, DeviceRow } from './mikrotik/DeviceCollector';
|
||||
import { Server as SocketServer } from 'socket.io';
|
||||
import { getWriteApi } from '../config/influxdb';
|
||||
import { Point } from '@influxdata/influxdb-client';
|
||||
import { alertService } from './AlertService';
|
||||
|
||||
interface PollJob {
|
||||
deviceId: number;
|
||||
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan';
|
||||
}
|
||||
|
||||
export class PollerService {
|
||||
private fastQueue: Queue;
|
||||
private slowQueue: Queue;
|
||||
private logsQueue: Queue;
|
||||
private fastWorker: Worker | null = null;
|
||||
private slowWorker: Worker | null = null;
|
||||
private logsWorker: Worker | null = null;
|
||||
private schedulerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private io: SocketServer | null = null;
|
||||
|
||||
constructor() {
|
||||
const conn1 = createRedisConnection();
|
||||
const conn2 = createRedisConnection();
|
||||
const conn3 = createRedisConnection();
|
||||
|
||||
this.fastQueue = new Queue('poll-fast', { connection: conn1 });
|
||||
this.slowQueue = new Queue('poll-slow', { connection: conn2 });
|
||||
this.logsQueue = new Queue('poll-logs', { connection: conn3 });
|
||||
}
|
||||
|
||||
setSocketServer(io: SocketServer): void {
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.startWorkers();
|
||||
this.startScheduler();
|
||||
console.log('PollerService started');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.schedulerInterval) {
|
||||
clearInterval(this.schedulerInterval);
|
||||
}
|
||||
await this.fastWorker?.close();
|
||||
await this.slowWorker?.close();
|
||||
await this.logsWorker?.close();
|
||||
await this.fastQueue.close();
|
||||
await this.slowQueue.close();
|
||||
await this.logsQueue.close();
|
||||
}
|
||||
|
||||
async scheduleDeviceSync(deviceId: number, type: PollJob['type'] = 'full'): Promise<void> {
|
||||
const jobData: PollJob = { deviceId, type };
|
||||
if (type === 'full') {
|
||||
await this.fastQueue.add('device-full-sync', jobData, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
});
|
||||
} else if (type === 'fast') {
|
||||
await this.fastQueue.add('device-fast-poll', jobData, { attempts: 2 });
|
||||
} else if (type === 'slow') {
|
||||
await this.slowQueue.add('device-slow-poll', jobData, { attempts: 2 });
|
||||
} else if (type === 'logs') {
|
||||
await this.logsQueue.add('device-logs-poll', jobData, { attempts: 2 });
|
||||
} else if (type === 'macscan') {
|
||||
await this.fastQueue.add('device-macscan', jobData, { attempts: 1 });
|
||||
} else if (type === 'spectral') {
|
||||
await this.slowQueue.add('device-spectral', jobData, { attempts: 1 });
|
||||
} else if (type === 'apscan') {
|
||||
await this.slowQueue.add('device-apscan', jobData, { attempts: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
private startScheduler(): void {
|
||||
// Schedule polls every 30 seconds
|
||||
this.schedulerInterval = setInterval(async () => {
|
||||
await this.schedulePollCycle();
|
||||
}, 30_000);
|
||||
|
||||
// Also run immediately
|
||||
setTimeout(() => this.schedulePollCycle(), 5000);
|
||||
}
|
||||
|
||||
private async schedulePollCycle(): Promise<void> {
|
||||
try {
|
||||
const appSettings = await this.getAppSettings();
|
||||
const macScanEnabled = appSettings['mac_scan_enabled'] !== false;
|
||||
const macScanInterval = (appSettings['mac_scan_interval'] as number) || 300;
|
||||
const reverseDnsEnabled = appSettings['reverse_dns_enabled'] === true;
|
||||
const spectralEnabled = appSettings['spectral_scan_enabled'] === true;
|
||||
const spectralIntervalHours = (appSettings['spectral_scan_interval_hours'] as number) || 24;
|
||||
const apScanEnabled = appSettings['ap_scan_enabled'] === true;
|
||||
const apScanIntervalHours = (appSettings['ap_scan_interval_hours'] as number) || 24;
|
||||
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status != 'disabled'`
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
for (const device of devices) {
|
||||
// Fast poll every 30s
|
||||
await this.scheduleDeviceSync(device.id, 'fast');
|
||||
|
||||
// Slow poll every 5min (300s)
|
||||
const slowKey = `poll:slow:${device.id}`;
|
||||
const lastSlow = await this.getTimestamp(slowKey);
|
||||
if (now - lastSlow > 300_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'slow');
|
||||
await this.setTimestamp(slowKey, now);
|
||||
}
|
||||
|
||||
// Logs poll every 60s
|
||||
const logsKey = `poll:logs:${device.id}`;
|
||||
const lastLogs = await this.getTimestamp(logsKey);
|
||||
if (now - lastLogs > 60_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'logs');
|
||||
await this.setTimestamp(logsKey, now);
|
||||
}
|
||||
|
||||
// MAC scan — switches only, user-configured interval
|
||||
if (macScanEnabled && device.device_type === 'switch') {
|
||||
const macKey = `poll:macscan:${device.id}`;
|
||||
const lastMac = await this.getTimestamp(macKey);
|
||||
if (now - lastMac > macScanInterval * 1_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'macscan');
|
||||
await this.setTimestamp(macKey, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Spectral scan — wireless_ap only, user-configured interval (default 24h)
|
||||
if (spectralEnabled && device.device_type === 'wireless_ap') {
|
||||
const spectralKey = `poll:spectral:${device.id}`;
|
||||
const lastSpectral = await this.getTimestamp(spectralKey);
|
||||
if (now - lastSpectral > spectralIntervalHours * 3_600_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'spectral');
|
||||
await this.setTimestamp(spectralKey, now);
|
||||
}
|
||||
}
|
||||
|
||||
// AP scan — wireless_ap only, user-configured interval (default 24h)
|
||||
if (apScanEnabled && device.device_type === 'wireless_ap') {
|
||||
const apScanKey = `poll:apscan:${device.id}`;
|
||||
const lastApScan = await this.getTimestamp(apScanKey);
|
||||
if (now - lastApScan > apScanIntervalHours * 3_600_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'apscan');
|
||||
await this.setTimestamp(apScanKey, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse DNS enrichment — global, runs every 5 minutes when enabled
|
||||
if (reverseDnsEnabled) {
|
||||
const rdnsKey = 'task:reverse_dns';
|
||||
const lastRdns = await this.getTimestamp(rdnsKey);
|
||||
if (now - lastRdns > 300_000) {
|
||||
await this.setTimestamp(rdnsKey, now);
|
||||
this.resolveClientHostnames().catch((e) =>
|
||||
console.error('[Poller] Reverse DNS error:', e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware update check — runs once per day
|
||||
const firmwareKey = 'task:firmware_check';
|
||||
const lastFirmware = await this.getTimestamp(firmwareKey);
|
||||
if (now - lastFirmware > 86_400_000) {
|
||||
await this.setTimestamp(firmwareKey, now);
|
||||
this.checkAllDevicesFirmware(devices).catch((e) =>
|
||||
console.error('[Poller] Firmware check error:', e)
|
||||
);
|
||||
}
|
||||
|
||||
// Stale client pruning — runs once per hour
|
||||
// Deletes inactive client records not seen for longer than retention_clients_days.
|
||||
const pruneKey = 'task:prune_clients';
|
||||
const lastPrune = await this.getTimestamp(pruneKey);
|
||||
if (now - lastPrune > 3_600_000) {
|
||||
await this.setTimestamp(pruneKey, now);
|
||||
this.pruneStaleClients(appSettings).catch((e) =>
|
||||
console.error('[Poller] Client prune error:', e)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Scheduler error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppSettings(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const rows = await query<{ key: string; value: unknown }>(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN ('mac_scan_enabled', 'mac_scan_interval', 'reverse_dns_enabled',
|
||||
'retention_clients_days', 'spectral_scan_enabled',
|
||||
'spectral_scan_interval_hours', 'ap_scan_enabled',
|
||||
'ap_scan_interval_hours')`
|
||||
);
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
return map;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveClientHostnames(): Promise<void> {
|
||||
const { reverse } = await import('dns/promises');
|
||||
|
||||
const clients = await query<{ mac_address: string; ip_address: string }>(
|
||||
`SELECT DISTINCT ON (ip_address) mac_address, ip_address
|
||||
FROM clients
|
||||
WHERE ip_address IS NOT NULL AND ip_address != ''
|
||||
AND (hostname IS NULL OR hostname = '')
|
||||
ORDER BY ip_address, last_seen DESC
|
||||
LIMIT 50`
|
||||
);
|
||||
if (clients.length === 0) return;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
clients.map(async (c) => {
|
||||
const names = await reverse(c.ip_address);
|
||||
return { mac: c.mac_address, hostname: names[0] };
|
||||
})
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
await query(
|
||||
`UPDATE clients SET hostname = $1
|
||||
WHERE mac_address = $2 AND (hostname IS NULL OR hostname = '')`,
|
||||
[r.value.hostname, r.value.mac]
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
console.log(`[Poller] Reverse DNS enriched ${updated} client hostname(s)`);
|
||||
this.io?.emit('clients:updated', {});
|
||||
}
|
||||
}
|
||||
|
||||
private async pruneStaleClients(settings: Record<string, unknown>): Promise<void> {
|
||||
// Delete inactive clients not seen for longer than the configured retention period.
|
||||
// Default: 7 days. Preserves any client that was active within the window so
|
||||
// short-lived or intermittent devices aren't wiped prematurely.
|
||||
const retentionDays = (settings['retention_clients_days'] as number) || 7;
|
||||
const result = await query<{ count: string }>(
|
||||
`WITH deleted AS (
|
||||
DELETE FROM clients
|
||||
WHERE active = FALSE
|
||||
AND last_seen < NOW() - ($1 || ' days')::interval
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) AS count FROM deleted`,
|
||||
[retentionDays]
|
||||
);
|
||||
const count = parseInt(result[0]?.count || '0', 10);
|
||||
if (count > 0) {
|
||||
console.log(`[Poller] Pruned ${count} stale client record(s) (inactive > ${retentionDays} days)`);
|
||||
this.io?.emit('clients:updated', {});
|
||||
}
|
||||
}
|
||||
|
||||
async pruneStaleClientsNow(): Promise<number> {
|
||||
const rows = await query<{ value: unknown }>(
|
||||
`SELECT value FROM app_settings WHERE key = 'retention_clients_days'`
|
||||
);
|
||||
const retentionDays = (rows[0]?.value as number) || 7;
|
||||
const result = await query<{ count: string }>(
|
||||
`WITH deleted AS (
|
||||
DELETE FROM clients
|
||||
WHERE active = FALSE
|
||||
AND last_seen < NOW() - ($1 || ' days')::interval
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) AS count FROM deleted`,
|
||||
[retentionDays]
|
||||
);
|
||||
return parseInt(result[0]?.count || '0', 10);
|
||||
}
|
||||
|
||||
private async getTimestamp(key: string): Promise<number> {
|
||||
try {
|
||||
const { redis } = await import('../config/redis');
|
||||
const val = await redis.get(key);
|
||||
return val ? parseInt(val, 10) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async setTimestamp(key: string, ts: number): Promise<void> {
|
||||
try {
|
||||
const { redis } = await import('../config/redis');
|
||||
await redis.set(key, String(ts), 'EX', 600);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private startWorkers(): void {
|
||||
const workerOptions = {
|
||||
connection: createRedisConnection(),
|
||||
concurrency: 3,
|
||||
};
|
||||
|
||||
this.fastWorker = new Worker(
|
||||
'poll-fast',
|
||||
async (job: Job<PollJob>) => {
|
||||
await this.processPollJob(job.data);
|
||||
},
|
||||
workerOptions
|
||||
);
|
||||
|
||||
this.slowWorker = new Worker(
|
||||
'poll-slow',
|
||||
async (job: Job<PollJob>) => {
|
||||
await this.processSlowJob(job.data);
|
||||
},
|
||||
{ ...workerOptions, connection: createRedisConnection() }
|
||||
);
|
||||
|
||||
this.logsWorker = new Worker(
|
||||
'poll-logs',
|
||||
async (job: Job<PollJob>) => {
|
||||
await this.processLogsJob(job.data);
|
||||
},
|
||||
{ ...workerOptions, connection: createRedisConnection() }
|
||||
);
|
||||
|
||||
this.fastWorker.on('failed', (job, err) => {
|
||||
if (job) {
|
||||
this.handleDeviceFailure(job.data.deviceId, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async processPollJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const prevStatus = device.status; // capture before poll
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
|
||||
if (data.type === 'full') {
|
||||
await collector.collectAll();
|
||||
} else if (data.type === 'macscan') {
|
||||
await collector.runMacScan();
|
||||
this.io?.emit('clients:updated', { deviceId: device.id });
|
||||
return;
|
||||
} else {
|
||||
await collector.collectFast();
|
||||
}
|
||||
|
||||
// Device came online (first poll after add, or recovery from offline)
|
||||
if (prevStatus !== 'online') {
|
||||
alertService.dispatch('device_online', `${device.name} is back online`, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
this.io?.emit('clients:updated', { deviceId: device.id });
|
||||
} catch (err) {
|
||||
await this.handleDeviceFailure(device.id, (err as Error).message);
|
||||
throw err;
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static aggregateSpectralRows(
|
||||
rows: Record<string, string>[]
|
||||
): { freq: number; magn: number; peak: number }[] {
|
||||
const map = new Map<number, { sum: number; count: number; peak: number }>();
|
||||
for (const row of rows) {
|
||||
const freq = parseFloat(row['freq'] || '0');
|
||||
const magn = parseInt(row['magn'] || '-120', 10);
|
||||
const peak = parseInt(row['peak'] || magn.toString(), 10);
|
||||
if (freq <= 0) continue;
|
||||
const existing = map.get(freq);
|
||||
if (existing) {
|
||||
existing.sum += magn;
|
||||
existing.count += 1;
|
||||
existing.peak = Math.max(existing.peak, peak);
|
||||
} else {
|
||||
map.set(freq, { sum: magn, count: 1, peak });
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([freq, { sum, count, peak }]) => ({
|
||||
freq,
|
||||
magn: Math.round(sum / count),
|
||||
peak,
|
||||
}));
|
||||
}
|
||||
|
||||
private static aggregateAPScanRows(
|
||||
allRows: { iface: string; rows: Record<string, string>[] }[],
|
||||
lookupVendor: (mac: string) => string
|
||||
): unknown[] {
|
||||
interface BandEntry { bssid: string; vendor: string; signal: number; freq: number; band: string; channel_width: string }
|
||||
interface NetworkEntry { ssid: string; security: string; hidden: boolean; entries: BandEntry[] }
|
||||
const byKey = new Map<string, NetworkEntry>();
|
||||
|
||||
function normBand(band: string, freq: number): string {
|
||||
if (band.includes('6ghz') || freq >= 5925) return '6 GHz';
|
||||
if (band.includes('5ghz') || (freq >= 4900 && freq < 5925)) return '5 GHz';
|
||||
return '2.4 GHz';
|
||||
}
|
||||
|
||||
for (const { rows } of allRows) {
|
||||
for (const row of rows) {
|
||||
const ssid = row['network-name'] || row['ssid'] || '';
|
||||
const bssid = (row['address'] || row['bssid'] || '').toLowerCase();
|
||||
if (!bssid) continue;
|
||||
const rawSig = row['signal-strength'] || row['signal'] || '-100';
|
||||
const signal = parseInt(rawSig, 10) || -100;
|
||||
const freq = parseFloat(row['frequency'] || row['channel'] || '0');
|
||||
const band = normBand(row['band'] || row['radio-band'] || '', freq);
|
||||
const security = row['security'] || row['authentication-types'] ? (row['security'] || 'WPA') : 'open';
|
||||
const channelWidth = row['channel-width'] || '';
|
||||
const key = ssid || `hidden:${bssid}`;
|
||||
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, { ssid, security, hidden: !ssid, entries: [] });
|
||||
}
|
||||
const net = byKey.get(key)!;
|
||||
const existing = net.entries.find(e => e.bssid === bssid && e.freq === freq);
|
||||
if (existing) {
|
||||
if (signal > existing.signal) existing.signal = signal;
|
||||
} else {
|
||||
net.entries.push({ bssid, vendor: lookupVendor(bssid), signal, freq, band, channel_width: channelWidth });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).sort((a, b) => {
|
||||
const aBest = Math.max(...a.entries.map(e => e.signal));
|
||||
const bBest = Math.max(...b.entries.map(e => e.signal));
|
||||
return bBest - aBest;
|
||||
});
|
||||
}
|
||||
|
||||
private async processSpectralJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
// Fetch wireless interfaces for this device so we know which radios to scan
|
||||
const ifaces = await query<{ name: string }>(
|
||||
`SELECT name FROM wireless_interfaces WHERE device_id = $1 AND disabled = FALSE`,
|
||||
[device.id]
|
||||
);
|
||||
if (ifaces.length === 0) return;
|
||||
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
for (const iface of ifaces) {
|
||||
const rows = await collector.collectSpectralScan(iface.name);
|
||||
if (rows.length === 0) continue;
|
||||
const aggregated = PollerService.aggregateSpectralRows(rows);
|
||||
await query(
|
||||
`INSERT INTO spectral_scan_data (device_id, interface_name, data, scan_type)
|
||||
VALUES ($1, $2, $3, 'scheduled')`,
|
||||
[device.id, iface.name, JSON.stringify(aggregated)]
|
||||
);
|
||||
console.log(`[Poller] Spectral scan saved for ${device.name}/${iface.name} (${aggregated.length} freq points)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Spectral scan failed for ${device.name}:`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async processApScanJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const ifaces = await query<{ name: string }>(
|
||||
`SELECT name FROM wireless_interfaces WHERE device_id = $1 AND disabled = FALSE`,
|
||||
[device.id]
|
||||
);
|
||||
if (ifaces.length === 0) return;
|
||||
|
||||
const { lookupVendor } = await import('../utils/oui');
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
const allRows: { iface: string; rows: Record<string, string>[] }[] = [];
|
||||
for (const iface of ifaces) {
|
||||
const rows = await collector.scanWireless(iface.name).catch(() => [] as Record<string, string>[]);
|
||||
if (rows.length > 0) allRows.push({ iface: iface.name, rows });
|
||||
}
|
||||
if (allRows.length === 0) return;
|
||||
|
||||
const aggregated = PollerService.aggregateAPScanRows(allRows, lookupVendor);
|
||||
await query(
|
||||
`INSERT INTO ap_scan_data (device_id, data, scan_type) VALUES ($1, $2, 'scheduled')`,
|
||||
[device.id, JSON.stringify(aggregated)]
|
||||
);
|
||||
console.log(`[Poller] AP scan saved for ${device.name} (${aggregated.length} networks)`);
|
||||
} catch (err) {
|
||||
console.error(`[Poller] AP scan failed for ${device.name}:`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async processSlowJob(data: PollJob): Promise<void> {
|
||||
if (data.type === 'spectral') {
|
||||
return this.processSpectralJob(data);
|
||||
}
|
||||
if (data.type === 'apscan') {
|
||||
return this.processApScanJob(data);
|
||||
}
|
||||
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.collectSlow();
|
||||
await collector.collectNeighbors();
|
||||
await collector.collectStp();
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
|
||||
// Fire device_discovered for any LLDP neighbors not matched to a managed device.
|
||||
// AlertService's per-cooldownKey cooldown prevents repeat alerts for the same neighbor.
|
||||
const unresolved = await query<{ neighbor_address: string; neighbor_identity: string }>(
|
||||
`SELECT DISTINCT neighbor_address, neighbor_identity
|
||||
FROM topology_links
|
||||
WHERE from_device_id = $1
|
||||
AND to_device_id IS NULL
|
||||
AND neighbor_address IS NOT NULL`,
|
||||
[device.id]
|
||||
);
|
||||
for (const nb of unresolved) {
|
||||
alertService.dispatch('device_discovered',
|
||||
`Unmanaged device discovered: ${nb.neighbor_identity || nb.neighbor_address} (${nb.neighbor_address})`,
|
||||
{
|
||||
details: nb.neighbor_identity || undefined,
|
||||
cooldownKey: `device_discovered:${nb.neighbor_address}`,
|
||||
}
|
||||
).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
await this.handleDeviceFailure(device.id, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async processLogsJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.collectLogs();
|
||||
this.io?.emit('events:updated', { deviceId: device.id });
|
||||
|
||||
// Fire log_error / log_warning alerts if new entries appeared in the last 90s
|
||||
const recent = await query<{ severity: string; message: string }>(
|
||||
`SELECT severity, message FROM events
|
||||
WHERE device_id = $1
|
||||
AND event_time > NOW() - INTERVAL '90 seconds'
|
||||
ORDER BY event_time DESC LIMIT 1`,
|
||||
[device.id]
|
||||
);
|
||||
for (const ev of recent) {
|
||||
if (ev.severity === 'error') {
|
||||
alertService.dispatch('log_error', ev.message, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
}).catch(() => {});
|
||||
} else if (ev.severity === 'warning') {
|
||||
alertService.dispatch('log_warning', ev.message, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[PollerService] Log collection failed for device ${device.id} (${device.name}):`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeviceFailure(deviceId: number, message: string): Promise<void> {
|
||||
const device = await this.getDevice(deviceId);
|
||||
const prevStatus = device?.status;
|
||||
await query(`UPDATE devices SET status = 'offline', updated_at = NOW() WHERE id = $1`, [deviceId]);
|
||||
if (prevStatus !== 'offline') {
|
||||
alertService.dispatch('device_offline', `${device?.name ?? `Device #${deviceId}`} is offline: ${message}`, {
|
||||
deviceId,
|
||||
deviceName: device?.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
// Mark all clients for this device inactive — updateClients() never ran because connect() failed.
|
||||
await query(`UPDATE clients SET active = FALSE WHERE device_id = $1`, [deviceId]);
|
||||
// Write updated global deduped count and a per-device zero so history is continuous.
|
||||
if (device) {
|
||||
const writeApi = getWriteApi();
|
||||
writeApi.writePoint(
|
||||
new Point('client_counts')
|
||||
.tag('device_id', String(deviceId))
|
||||
.tag('device_name', device.name)
|
||||
.intField('total_clients', 0)
|
||||
.intField('wireless_clients', 0)
|
||||
.intField('wired_clients', 0)
|
||||
.timestamp(new Date())
|
||||
);
|
||||
// Recompute global deduplicated count after marking this device's clients inactive.
|
||||
const dedupedRows = await query<{ count: string }>(
|
||||
`SELECT COUNT(DISTINCT mac_address) AS count FROM clients WHERE active = TRUE`
|
||||
);
|
||||
const globalTotal = parseInt(dedupedRows[0]?.count || '0', 10);
|
||||
writeApi.writePoint(
|
||||
new Point('client_counts')
|
||||
.tag('device_id', '_global')
|
||||
.tag('device_name', '_global')
|
||||
.intField('total_clients', globalTotal)
|
||||
.timestamp(new Date())
|
||||
);
|
||||
await writeApi.flush().catch(() => {});
|
||||
}
|
||||
this.io?.emit('device:status', { deviceId, status: 'offline', message });
|
||||
this.io?.emit('clients:updated', { deviceId });
|
||||
}
|
||||
|
||||
private async checkAllDevicesFirmware(devices: DeviceRow[]): Promise<void> {
|
||||
// Only check devices that are currently online to avoid long timeouts
|
||||
const onlineDevices = devices.filter((d) => d.status === 'online');
|
||||
console.log(`[Poller] Starting firmware check for ${onlineDevices.length} online device(s)`);
|
||||
|
||||
for (const device of onlineDevices) {
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
const updateInfo = await collector.checkForUpdates();
|
||||
|
||||
const latestVersion = (updateInfo['latest-version'] ?? '').trim();
|
||||
const installedVersion = (updateInfo['installed-version'] ?? '').trim();
|
||||
const statusText = (updateInfo['status'] ?? '').toLowerCase();
|
||||
|
||||
const hasUpdate =
|
||||
statusText.includes('available') ||
|
||||
(latestVersion && installedVersion && latestVersion !== installedVersion);
|
||||
|
||||
// Read current flag before updating so we can detect first-discovery
|
||||
const current = await query<{ firmware_update_available: boolean }>(
|
||||
`SELECT firmware_update_available FROM devices WHERE id = $1`,
|
||||
[device.id]
|
||||
);
|
||||
const wasAvailable = current[0]?.firmware_update_available ?? false;
|
||||
|
||||
await query(
|
||||
`UPDATE devices
|
||||
SET firmware_update_available = $1,
|
||||
latest_ros_version = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[hasUpdate, latestVersion || null, device.id]
|
||||
);
|
||||
|
||||
if (hasUpdate) {
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
// Alert only on first discovery (not on every daily check)
|
||||
if (!wasAvailable) {
|
||||
const msg = latestVersion
|
||||
? `${device.name} has a firmware update available: ${latestVersion}`
|
||||
: `${device.name} has a firmware update available`;
|
||||
alertService.dispatch('firmware_update_available', msg, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
details: latestVersion ? `Current: ${installedVersion} → Latest: ${latestVersion}` : undefined,
|
||||
}).catch(() => {});
|
||||
console.log(`[Poller] Firmware update detected for ${device.name}: ${installedVersion} → ${latestVersion}`);
|
||||
}
|
||||
} else if (wasAvailable) {
|
||||
// Update was installed — clear the flag and notify the UI
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Firmware check failed for ${device.name}:`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
console.log(`[Poller] Firmware check complete`);
|
||||
}
|
||||
|
||||
private async getDevice(deviceId: number): Promise<DeviceRow | null> {
|
||||
const rows = await query<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [deviceId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,488 @@
|
||||
import * as net from 'net';
|
||||
import * as crypto from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface RouterOSSentence {
|
||||
type: string;
|
||||
words: Record<string, string>;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export class RouterOSError extends Error {
|
||||
constructor(message: string, public readonly code?: string) {
|
||||
super(message);
|
||||
this.name = 'RouterOSError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RouterOSClient extends EventEmitter {
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer: Buffer = Buffer.alloc(0);
|
||||
private connected = false;
|
||||
private authenticated = false;
|
||||
|
||||
// Sequential command queue: each command waits for previous to complete
|
||||
private operationChain: Promise<void> = Promise.resolve();
|
||||
|
||||
// Pending read resolver (one outstanding read at a time)
|
||||
private pendingRead: {
|
||||
resolve: (s: RouterOSSentence) => void;
|
||||
reject: (e: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
} | null = null;
|
||||
private sentenceQueue: RouterOSSentence[] = [];
|
||||
|
||||
private tagCounter = 0;
|
||||
|
||||
constructor(
|
||||
private readonly host: string,
|
||||
private readonly port: number = 8728,
|
||||
private readonly username: string,
|
||||
private readonly password: string,
|
||||
private readonly connectTimeoutMs: number = 15000,
|
||||
private readonly readTimeoutMs: number = 30000
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new RouterOSError(`Connection timeout to ${this.host}:${this.port}`));
|
||||
this.socket?.destroy();
|
||||
}, this.connectTimeoutMs);
|
||||
|
||||
this.socket = net.createConnection({ host: this.host, port: this.port });
|
||||
|
||||
this.socket.on('connect', async () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
await this.authenticate();
|
||||
this.connected = true;
|
||||
this.authenticated = true;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
this.socket?.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
if (this.pendingRead) {
|
||||
this.pendingRead.reject(new RouterOSError(`Socket error: ${err.message}`));
|
||||
this.pendingRead = null;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
if (this.pendingRead) {
|
||||
this.pendingRead.reject(new RouterOSError('Connection closed'));
|
||||
this.pendingRead = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enqueue a command so concurrent calls don't interleave
|
||||
async execute(
|
||||
command: string,
|
||||
params: Record<string, string> = {},
|
||||
queries: string[] = []
|
||||
): Promise<Record<string, string>[]> {
|
||||
let result!: Record<string, string>[];
|
||||
let error!: Error;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.operationChain = this.operationChain.then(async () => {
|
||||
try {
|
||||
result = await this._executeRaw(command, params, queries);
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _executeRaw(
|
||||
command: string,
|
||||
params: Record<string, string>,
|
||||
queries: string[]
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new RouterOSError('Not connected');
|
||||
}
|
||||
|
||||
const words = [command];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
words.push(`=${key}=${value}`);
|
||||
}
|
||||
for (const q of queries) {
|
||||
words.push(q);
|
||||
}
|
||||
|
||||
await this.sendSentence(words);
|
||||
|
||||
const results: Record<string, string>[] = [];
|
||||
while (true) {
|
||||
const sentence = await this.readNextSentence();
|
||||
if (sentence.type === '!done') break;
|
||||
if (sentence.type === '!re') {
|
||||
results.push(sentence.words);
|
||||
} else if (sentence.type === '!trap') {
|
||||
// RouterOS always sends !done after !trap — drain it to keep the stream in sync.
|
||||
// Without this, the stale !done is consumed by the next command, causing it to
|
||||
// exit immediately with empty results before its real response arrives.
|
||||
await this.readNextSentence().catch(() => {});
|
||||
throw new RouterOSError(
|
||||
sentence.words['message'] || `Command failed: ${command}`,
|
||||
sentence.words['category']
|
||||
);
|
||||
} else if (sentence.type === '!fatal') {
|
||||
this.connected = false;
|
||||
throw new RouterOSError(sentence.words['message'] || 'Fatal error received');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
this.sentenceQueue = [];
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.authenticated;
|
||||
}
|
||||
|
||||
// ─── RouterOS API Protocol ────────────────────────────────────────────────
|
||||
|
||||
private async authenticate(): Promise<void> {
|
||||
// Try plain-text login (RouterOS 6.49+ and RouterOS 7.x)
|
||||
await this.sendSentence(['/login', `=name=${this.username}`, `=password=${this.password}`]);
|
||||
const first = await this.readNextSentence();
|
||||
|
||||
if (first.type === '!done') {
|
||||
return; // v7 / newer v6 plain-text login succeeded
|
||||
}
|
||||
|
||||
if (first.type === '!re' && first.words['ret']) {
|
||||
// Legacy v6 challenge-response
|
||||
const challengeHex = first.words['ret'];
|
||||
// Drain the !done that follows the !re
|
||||
await this.readNextSentence();
|
||||
|
||||
const challenge = Buffer.from(challengeHex, 'hex');
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from([0x00]));
|
||||
md5.update(Buffer.from(this.password, 'utf8'));
|
||||
md5.update(challenge);
|
||||
const responseHex = '00' + md5.digest('hex');
|
||||
|
||||
await this.sendSentence([
|
||||
'/login',
|
||||
`=name=${this.username}`,
|
||||
`=response=${responseHex}`,
|
||||
]);
|
||||
const result = await this.readNextSentence();
|
||||
if (result.type !== '!done') {
|
||||
throw new RouterOSError(
|
||||
result.words['message'] || 'Authentication failed (legacy MD5)',
|
||||
result.words['category']
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.type === '!trap') {
|
||||
throw new RouterOSError(
|
||||
first.words['message'] || 'Authentication failed',
|
||||
first.words['category']
|
||||
);
|
||||
}
|
||||
|
||||
throw new RouterOSError(`Unexpected login response: ${first.type}`);
|
||||
}
|
||||
|
||||
private encodeLength(len: number): Buffer {
|
||||
if (len < 0x80) {
|
||||
return Buffer.from([len]);
|
||||
} else if (len < 0x4000) {
|
||||
return Buffer.from([(len >> 8) | 0x80, len & 0xff]);
|
||||
} else if (len < 0x200000) {
|
||||
return Buffer.from([(len >> 16) | 0xc0, (len >> 8) & 0xff, len & 0xff]);
|
||||
} else if (len < 0x10000000) {
|
||||
return Buffer.from([
|
||||
(len >> 24) | 0xe0,
|
||||
(len >> 16) & 0xff,
|
||||
(len >> 8) & 0xff,
|
||||
len & 0xff,
|
||||
]);
|
||||
} else {
|
||||
return Buffer.from([
|
||||
0xf0,
|
||||
(len >> 24) & 0xff,
|
||||
(len >> 16) & 0xff,
|
||||
(len >> 8) & 0xff,
|
||||
len & 0xff,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeLength(
|
||||
buf: Buffer,
|
||||
offset: number
|
||||
): { length: number; bytesConsumed: number } | null {
|
||||
if (offset >= buf.length) return null;
|
||||
const b = buf[offset];
|
||||
|
||||
if (b < 0x80) {
|
||||
return { length: b, bytesConsumed: 1 };
|
||||
} else if (b < 0xc0) {
|
||||
if (offset + 1 >= buf.length) return null;
|
||||
return {
|
||||
length: ((b & 0x3f) << 8) | buf[offset + 1],
|
||||
bytesConsumed: 2,
|
||||
};
|
||||
} else if (b < 0xe0) {
|
||||
if (offset + 2 >= buf.length) return null;
|
||||
return {
|
||||
length: ((b & 0x1f) << 16) | (buf[offset + 1] << 8) | buf[offset + 2],
|
||||
bytesConsumed: 3,
|
||||
};
|
||||
} else if (b < 0xf0) {
|
||||
if (offset + 3 >= buf.length) return null;
|
||||
return {
|
||||
length:
|
||||
((b & 0x0f) << 24) |
|
||||
(buf[offset + 1] << 16) |
|
||||
(buf[offset + 2] << 8) |
|
||||
buf[offset + 3],
|
||||
bytesConsumed: 4,
|
||||
};
|
||||
} else {
|
||||
if (offset + 4 >= buf.length) return null;
|
||||
return {
|
||||
length:
|
||||
(buf[offset + 1] << 24) |
|
||||
(buf[offset + 2] << 16) |
|
||||
(buf[offset + 3] << 8) |
|
||||
buf[offset + 4],
|
||||
bytesConsumed: 5,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async sendSentence(words: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket) return reject(new RouterOSError('Socket not available'));
|
||||
const parts: Buffer[] = [];
|
||||
for (const word of words) {
|
||||
const wb = Buffer.from(word, 'utf8');
|
||||
parts.push(this.encodeLength(wb.length), wb);
|
||||
}
|
||||
parts.push(Buffer.from([0x00])); // end-of-sentence
|
||||
this.socket.write(Buffer.concat(parts), (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private processBuffer(): void {
|
||||
while (true) {
|
||||
const sentence = this.tryParseSentence();
|
||||
if (!sentence) break;
|
||||
|
||||
if (this.pendingRead) {
|
||||
clearTimeout(this.pendingRead.timeout);
|
||||
const { resolve } = this.pendingRead;
|
||||
this.pendingRead = null;
|
||||
resolve(sentence);
|
||||
} else {
|
||||
this.sentenceQueue.push(sentence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tryParseSentence(): RouterOSSentence | null {
|
||||
const words: string[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < this.buffer.length) {
|
||||
const dec = this.decodeLength(this.buffer, offset);
|
||||
if (!dec) return null; // need more bytes
|
||||
|
||||
const { length, bytesConsumed } = dec;
|
||||
|
||||
if (length === 0) {
|
||||
// End of sentence — consume it
|
||||
this.buffer = this.buffer.slice(offset + 1);
|
||||
|
||||
if (words.length === 0) return null;
|
||||
|
||||
const type = words[0];
|
||||
const parsed: Record<string, string> = {};
|
||||
let tag: string | undefined;
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const w = words[i];
|
||||
if (w.startsWith('=')) {
|
||||
const eq = w.indexOf('=', 1);
|
||||
if (eq > 0) parsed[w.slice(1, eq)] = w.slice(eq + 1);
|
||||
} else if (w.startsWith('.tag=')) {
|
||||
tag = w.slice(5);
|
||||
}
|
||||
}
|
||||
|
||||
return { type, words: parsed, tag };
|
||||
}
|
||||
|
||||
if (offset + bytesConsumed + length > this.buffer.length) {
|
||||
return null; // need more bytes
|
||||
}
|
||||
|
||||
words.push(
|
||||
this.buffer
|
||||
.slice(offset + bytesConsumed, offset + bytesConsumed + length)
|
||||
.toString('utf8')
|
||||
);
|
||||
offset += bytesConsumed + length;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readNextSentence(timeoutMs = this.readTimeoutMs): Promise<RouterOSSentence> {
|
||||
// Check queue first
|
||||
if (this.sentenceQueue.length > 0) {
|
||||
return Promise.resolve(this.sentenceQueue.shift()!);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRead = null;
|
||||
reject(new RouterOSError('Read timeout waiting for API response'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRead = {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: timer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Execute a streaming/generator command (one that never sends !done on its own,
|
||||
// e.g. /tool/ip-scan). Collects !re rows until maxDurationMs elapses, then
|
||||
// sends /cancel to terminate the command and drains the cleanup response.
|
||||
async executeStreaming(
|
||||
command: string,
|
||||
params: Record<string, string> = {},
|
||||
maxDurationMs = 30_000
|
||||
): Promise<Record<string, string>[]> {
|
||||
let result!: Record<string, string>[];
|
||||
let error!: Error;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.operationChain = this.operationChain.then(async () => {
|
||||
try {
|
||||
result = await this._executeStreamingRaw(command, params, maxDurationMs);
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _executeStreamingRaw(
|
||||
command: string,
|
||||
params: Record<string, string>,
|
||||
maxDurationMs: number
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new RouterOSError('Not connected');
|
||||
}
|
||||
|
||||
const tag = String(++this.tagCounter);
|
||||
const words = [command];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
words.push(`=${key}=${value}`);
|
||||
}
|
||||
words.push(`.tag=${tag}`);
|
||||
|
||||
await this.sendSentence(words);
|
||||
|
||||
const results: Record<string, string>[] = [];
|
||||
const deadline = Date.now() + maxDurationMs;
|
||||
|
||||
// Collect !re sentences until deadline or until !done / !fatal arrives
|
||||
while (true) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
|
||||
let sentence: RouterOSSentence;
|
||||
try {
|
||||
sentence = await this.readNextSentence(Math.min(remaining, this.readTimeoutMs));
|
||||
} catch {
|
||||
// Timeout or socket error — fall through to cancel
|
||||
break;
|
||||
}
|
||||
|
||||
if (sentence.type === '!re') {
|
||||
results.push(sentence.words);
|
||||
} else if (sentence.type === '!done') {
|
||||
// Command finished on its own (shouldn't happen for generators, but handle it)
|
||||
return results;
|
||||
} else if (sentence.type === '!trap') {
|
||||
// Drain the !done that follows !trap
|
||||
await this.readNextSentence(5_000).catch(() => {});
|
||||
throw new RouterOSError(
|
||||
sentence.words['message'] || `Command failed: ${command}`,
|
||||
sentence.words['category']
|
||||
);
|
||||
} else if (sentence.type === '!fatal') {
|
||||
this.connected = false;
|
||||
throw new RouterOSError(sentence.words['message'] || 'Fatal error received');
|
||||
}
|
||||
}
|
||||
|
||||
// Send /cancel and drain responses (RouterOS replies !trap + !done to cancel)
|
||||
try {
|
||||
await this.sendSentence(['/cancel', `=tag=${tag}`]);
|
||||
// Drain up to 2 sentences (!trap and/or !done) with a short timeout each
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const s = await this.readNextSentence(5_000).catch(() => null);
|
||||
if (!s || s.type === '!done') break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user