Initial commit baby!
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { Pool } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('PostgreSQL pool error:', err);
|
||||
});
|
||||
|
||||
export async function query<T = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T[]> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const result = await client.query(text, params);
|
||||
return result.rows as T[];
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryOne<T = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T | null> {
|
||||
const rows = await query<T>(text, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function transaction<T>(
|
||||
fn: (client: import('pg').PoolClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { InfluxDB, WriteApi, QueryApi } from '@influxdata/influxdb-client';
|
||||
|
||||
const url = process.env.INFLUXDB_URL || 'http://influxdb:8086';
|
||||
const token = process.env.INFLUXDB_TOKEN || '';
|
||||
const org = process.env.INFLUXDB_ORG || 'mikrotik-manager';
|
||||
const bucket = process.env.INFLUXDB_BUCKET || 'metrics';
|
||||
|
||||
let influxClient: InfluxDB | null = null;
|
||||
let writeClient: WriteApi | null = null;
|
||||
let queryClient: QueryApi | null = null;
|
||||
|
||||
export function getInfluxClient(): InfluxDB {
|
||||
if (!influxClient) {
|
||||
influxClient = new InfluxDB({ url, token });
|
||||
}
|
||||
return influxClient;
|
||||
}
|
||||
|
||||
export function getWriteApi(): WriteApi {
|
||||
if (!writeClient) {
|
||||
writeClient = getInfluxClient().getWriteApi(org, bucket, 'ns');
|
||||
writeClient.useDefaultTags({ source: 'mikrotik-manager' });
|
||||
}
|
||||
return writeClient;
|
||||
}
|
||||
|
||||
export function getQueryApi(): QueryApi {
|
||||
if (!queryClient) {
|
||||
queryClient = getInfluxClient().getQueryApi(org);
|
||||
}
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
export { bucket, org };
|
||||
@@ -0,0 +1,26 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://redis:6379';
|
||||
|
||||
export const redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('Redis error:', err.message);
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
console.log('Redis connected');
|
||||
});
|
||||
|
||||
// Separate connection for BullMQ (requires maxRetriesPerRequest: null)
|
||||
export function createRedisConnection(): Redis {
|
||||
return new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { pool } from '../config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const MIGRATION_SQL = `
|
||||
-- Users
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'admin',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Devices
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
api_port INTEGER NOT NULL DEFAULT 8728,
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
api_username VARCHAR(50) NOT NULL,
|
||||
api_password_encrypted TEXT NOT NULL,
|
||||
ssh_username VARCHAR(50),
|
||||
ssh_password_encrypted TEXT,
|
||||
model VARCHAR(100),
|
||||
serial_number VARCHAR(50),
|
||||
firmware_version VARCHAR(50),
|
||||
ros_version VARCHAR(20),
|
||||
device_type VARCHAR(20) DEFAULT 'router',
|
||||
status VARCHAR(20) DEFAULT 'unknown',
|
||||
last_seen TIMESTAMPTZ,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Device full config snapshots
|
||||
CREATE TABLE IF NOT EXISTS device_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
config_json JSONB NOT NULL,
|
||||
collected_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_configs_device ON device_configs(device_id, collected_at DESC);
|
||||
|
||||
-- Network interfaces
|
||||
CREATE TABLE IF NOT EXISTS interfaces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
type VARCHAR(30),
|
||||
mac_address VARCHAR(17),
|
||||
mtu INTEGER,
|
||||
running BOOLEAN DEFAULT FALSE,
|
||||
disabled BOOLEAN DEFAULT FALSE,
|
||||
comment TEXT,
|
||||
speed VARCHAR(20),
|
||||
full_duplex BOOLEAN,
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, name)
|
||||
);
|
||||
|
||||
-- VLANs
|
||||
CREATE TABLE IF NOT EXISTS vlans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
vlan_id INTEGER NOT NULL,
|
||||
name VARCHAR(100),
|
||||
bridge VARCHAR(50),
|
||||
tagged_ports TEXT[],
|
||||
untagged_ports TEXT[],
|
||||
config_json JSONB,
|
||||
UNIQUE(device_id, vlan_id)
|
||||
);
|
||||
|
||||
-- Bridge VLAN table entries (for switch port VLAN mapping)
|
||||
CREATE TABLE IF NOT EXISTS bridge_vlan_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
bridge VARCHAR(50) NOT NULL,
|
||||
port VARCHAR(50) NOT NULL,
|
||||
vlan_ids TEXT[],
|
||||
pvid INTEGER,
|
||||
tagged BOOLEAN DEFAULT FALSE,
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, bridge, port)
|
||||
);
|
||||
|
||||
-- Network clients (ARP/DHCP/wireless leases)
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
mac_address VARCHAR(17) NOT NULL,
|
||||
hostname VARCHAR(255),
|
||||
ip_address VARCHAR(45),
|
||||
interface_name VARCHAR(50),
|
||||
tx_bytes BIGINT DEFAULT 0,
|
||||
rx_bytes BIGINT DEFAULT 0,
|
||||
signal_strength INTEGER,
|
||||
comment TEXT,
|
||||
client_type VARCHAR(20) DEFAULT 'wired',
|
||||
active BOOLEAN DEFAULT FALSE,
|
||||
last_seen TIMESTAMPTZ,
|
||||
UNIQUE(device_id, mac_address)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_active ON clients(active, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_mac ON clients(mac_address);
|
||||
|
||||
-- Events and alerts
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
|
||||
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
severity VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
topic VARCHAR(100),
|
||||
message TEXT NOT NULL,
|
||||
raw_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_device_time ON events(device_id, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_time ON events(event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_severity ON events(severity, event_time DESC);
|
||||
|
||||
-- Backups
|
||||
CREATE TABLE IF NOT EXISTS backups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
size_bytes INTEGER,
|
||||
backup_type VARCHAR(20) DEFAULT 'manual',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Topology links (discovered via LLDP/CDP/neighbor)
|
||||
CREATE TABLE IF NOT EXISTS topology_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
|
||||
from_interface VARCHAR(50),
|
||||
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
to_interface VARCHAR(50),
|
||||
neighbor_address VARCHAR(45),
|
||||
neighbor_identity VARCHAR(255),
|
||||
neighbor_platform VARCHAR(255),
|
||||
link_type VARCHAR(20) DEFAULT 'lldp',
|
||||
discovered_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(from_device_id, from_interface)
|
||||
);
|
||||
|
||||
-- Application settings
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Incremental schema updates
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vendor VARCHAR(255);
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vlan_id INTEGER;
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255);
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS stp_role VARCHAR(20);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS stp_state VARCHAR(20);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS bridge_name VARCHAR(50);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_address TEXT;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_lat NUMERIC(10,7);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_lng NUMERIC(10,7);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_name VARCHAR(100);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_slot VARCHAR(20);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_caps VARCHAR(255);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS discovered_by VARCHAR(50);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS firmware_update_available BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS latest_ros_version VARCHAR(20);
|
||||
|
||||
-- Allow multiple neighbors per interface (one row per neighbor, not per port)
|
||||
ALTER TABLE topology_links DROP CONSTRAINT IF EXISTS topology_links_from_device_id_from_interface_key;
|
||||
|
||||
-- Alert rules — one row per event type
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
event_type VARCHAR(50) PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
threshold INTEGER,
|
||||
cooldown_min INTEGER NOT NULL DEFAULT 15,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Alert channels — email / Slack / Discord / Telegram
|
||||
CREATE TABLE IF NOT EXISTS alert_channels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('email','slack','discord','telegram')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Alert send history
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
device_name VARCHAR(255),
|
||||
message TEXT NOT NULL,
|
||||
channels_notified JSONB,
|
||||
sent_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_sent ON alert_history(sent_at DESC);
|
||||
|
||||
-- Wireless interfaces (radio hardware config + SSID settings)
|
||||
CREATE TABLE IF NOT EXISTS wireless_interfaces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
ssid VARCHAR(100),
|
||||
mode VARCHAR(30),
|
||||
band VARCHAR(50),
|
||||
frequency INTEGER,
|
||||
channel_width VARCHAR(30),
|
||||
tx_power INTEGER,
|
||||
tx_power_mode VARCHAR(30),
|
||||
antenna_gain INTEGER,
|
||||
country VARCHAR(50),
|
||||
installation VARCHAR(20) DEFAULT 'indoor',
|
||||
disabled BOOLEAN DEFAULT FALSE,
|
||||
running BOOLEAN DEFAULT FALSE,
|
||||
mac_address VARCHAR(17),
|
||||
security_profile VARCHAR(100),
|
||||
noise_floor INTEGER,
|
||||
registered_clients INTEGER DEFAULT 0,
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, name)
|
||||
);
|
||||
|
||||
-- Spectral scan snapshots
|
||||
CREATE TABLE IF NOT EXISTS spectral_scan_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
scanned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
data JSONB NOT NULL,
|
||||
scan_type TEXT DEFAULT 'scheduled'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_spectral_scan_device
|
||||
ON spectral_scan_data(device_id, interface_name, scanned_at DESC);
|
||||
|
||||
-- AP scan results (nearby access points discovered by wireless scan)
|
||||
CREATE TABLE IF NOT EXISTS ap_scan_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
scanned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
data JSONB NOT NULL,
|
||||
scan_type TEXT DEFAULT 'scheduled'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ap_scan_device
|
||||
ON ap_scan_data(device_id, scanned_at DESC);
|
||||
|
||||
-- Wireless security profiles (WPA/WPA2/WPA3 config)
|
||||
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
mode VARCHAR(30) DEFAULT 'none',
|
||||
authentication_types TEXT[] DEFAULT '{}',
|
||||
unicast_ciphers TEXT[] DEFAULT '{}',
|
||||
group_ciphers TEXT[] DEFAULT '{}',
|
||||
management_protection VARCHAR(20) DEFAULT 'disabled',
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, name)
|
||||
);
|
||||
`;
|
||||
|
||||
const DEFAULT_SETTINGS = [
|
||||
{ key: 'polling_fast_interval', value: 30 },
|
||||
{ key: 'polling_slow_interval', value: 300 },
|
||||
{ key: 'polling_logs_interval', value: 60 },
|
||||
{ key: 'retention_events_days', value: 30 },
|
||||
{ key: 'backup_schedule_enabled', value: false },
|
||||
{ key: 'backup_schedule_cron', value: '0 2 * * *' },
|
||||
{ key: 'mac_scan_enabled', value: true },
|
||||
{ key: 'mac_scan_interval', value: 300 },
|
||||
{ key: 'reverse_dns_enabled', value: false },
|
||||
{ key: 'retention_clients_days', value: 7 },
|
||||
{ key: 'spectral_scan_enabled', value: false },
|
||||
{ key: 'spectral_scan_interval_hours', value: 24 },
|
||||
{ key: 'ap_scan_enabled', value: false },
|
||||
{ key: 'ap_scan_interval_hours', value: 24 },
|
||||
];
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
console.log('Running database migrations...');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(MIGRATION_SQL);
|
||||
console.log('Schema created/verified');
|
||||
|
||||
// Insert default settings
|
||||
for (const setting of DEFAULT_SETTINGS) {
|
||||
await client.query(
|
||||
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
|
||||
[setting.key, JSON.stringify(setting.value)]
|
||||
);
|
||||
}
|
||||
|
||||
// Create default admin user if no users exist
|
||||
const userCount = await client.query('SELECT COUNT(*) FROM users');
|
||||
if (parseInt(userCount.rows[0].count, 10) === 0) {
|
||||
const hash = await bcrypt.hash('admin', 12);
|
||||
await client.query(
|
||||
`INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)`,
|
||||
['admin', hash, 'admin']
|
||||
);
|
||||
console.log('Default admin user created (username: admin, password: admin)');
|
||||
console.log('⚠️ Please change the default password after first login!');
|
||||
}
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Run directly if called as script
|
||||
if (require.main === module) {
|
||||
runMigrations()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import 'express-async-errors';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
import { createServer } from 'http';
|
||||
import { Server as SocketServer } from 'socket.io';
|
||||
import { Client as SshClient } from 'ssh2';
|
||||
import type { ClientChannel } from 'ssh2';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import { pool, queryOne, query } from './config/database';
|
||||
import { initOuiDatabase } from './utils/oui';
|
||||
import { redis } from './config/redis';
|
||||
import { runMigrations } from './db/migrate';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { PollerService } from './services/PollerService';
|
||||
import { verifyToken } from './middleware/auth';
|
||||
import { decrypt } from './utils/crypto';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/devices';
|
||||
import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients';
|
||||
import eventsRoutes from './routes/events';
|
||||
import backupsRoutes from './routes/backups';
|
||||
import metricsRoutes from './routes/metrics';
|
||||
import topologyRoutes, { setPollerService as setTopologyPoller } from './routes/topology';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import certRoutes from './routes/cert';
|
||||
import searchRoutes from './routes/search';
|
||||
import switchesRoutes from './routes/switches';
|
||||
import routersRoutes from './routes/routers';
|
||||
import alertsRoutes from './routes/alerts';
|
||||
import wirelessRoutes from './routes/wireless';
|
||||
import networkServicesRoutes from './routes/networkServices';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────────────────────
|
||||
const io = new SocketServer(httpServer, {
|
||||
cors: { origin: '*', methods: ['GET', 'POST'] },
|
||||
path: '/socket.io',
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`Socket connected: ${socket.id}`);
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Socket disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SSH Terminal namespace ────────────────────────────────────────────────────
|
||||
const terminalNs = io.of('/terminal');
|
||||
|
||||
terminalNs.use((socket, next) => {
|
||||
const token = (socket.handshake.auth as { token?: string })?.token;
|
||||
if (!token) return next(new Error('No token'));
|
||||
try {
|
||||
verifyToken(token);
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('Invalid or expired token'));
|
||||
}
|
||||
});
|
||||
|
||||
terminalNs.on('connection', (socket) => {
|
||||
let sshClient: SshClient | null = null;
|
||||
let shellStream: ClientChannel | null = null;
|
||||
|
||||
socket.on('start', async (payload: { deviceId: number; cols?: number; rows?: number }) => {
|
||||
const { deviceId, cols = 80, rows = 24 } = payload;
|
||||
try {
|
||||
const device = await queryOne<{
|
||||
ip_address: string;
|
||||
ssh_port: number | null;
|
||||
ssh_username: string | null;
|
||||
ssh_password_encrypted: string | null;
|
||||
}>(
|
||||
`SELECT ip_address, ssh_port, ssh_username, ssh_password_encrypted FROM devices WHERE id = $1`,
|
||||
[deviceId]
|
||||
);
|
||||
|
||||
if (!device) { socket.emit('error', 'Device not found'); return; }
|
||||
if (!device.ssh_username || !device.ssh_password_encrypted) {
|
||||
socket.emit('error', 'No SSH credentials configured for this device. Add an SSH username and password in device settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = decrypt(device.ssh_password_encrypted);
|
||||
sshClient = new SshClient();
|
||||
|
||||
sshClient.on('ready', () => {
|
||||
sshClient!.shell(
|
||||
{ term: 'xterm-256color', cols, rows },
|
||||
(err, stream) => {
|
||||
if (err) { socket.emit('error', err.message); return; }
|
||||
shellStream = stream;
|
||||
socket.emit('ready');
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString('binary'));
|
||||
});
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString('binary'));
|
||||
});
|
||||
stream.on('close', () => {
|
||||
socket.emit('close');
|
||||
sshClient?.end();
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
sshClient.on('error', (err) => {
|
||||
socket.emit('error', `SSH error: ${err.message}`);
|
||||
});
|
||||
|
||||
sshClient.connect({
|
||||
host: device.ip_address,
|
||||
port: device.ssh_port ?? 22,
|
||||
username: device.ssh_username,
|
||||
password,
|
||||
readyTimeout: 10_000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
],
|
||||
serverHostKey: [
|
||||
'ssh-rsa',
|
||||
'ecdsa-sha2-nistp256',
|
||||
'ecdsa-sha2-nistp384',
|
||||
'ssh-ed25519',
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
socket.emit('error', `Connection failed: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('data', (data: string) => {
|
||||
shellStream?.write(data);
|
||||
});
|
||||
|
||||
socket.on('resize', ({ cols, rows }: { cols: number; rows: number }) => {
|
||||
shellStream?.setWindow(rows, cols, 0, 0);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
shellStream?.end();
|
||||
sshClient?.end();
|
||||
shellStream = null;
|
||||
sshClient = null;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Middleware ───────────────────────────────────────────────────────────────
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors({ origin: '*', credentials: true }));
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(morgan('combined'));
|
||||
}
|
||||
|
||||
// ─── Health Check ─────────────────────────────────────────────────────────────
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/devices', devicesRoutes);
|
||||
app.use('/api/clients', clientsRoutes);
|
||||
app.use('/api/events', eventsRoutes);
|
||||
app.use('/api/backups', backupsRoutes);
|
||||
app.use('/api/metrics', metricsRoutes);
|
||||
app.use('/api/topology', topologyRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/cert', certRoutes);
|
||||
app.use('/api/search', searchRoutes);
|
||||
app.use('/api/switches', switchesRoutes);
|
||||
app.use('/api/routers', routersRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/wireless', wirelessRoutes);
|
||||
app.use('/api/network-services', networkServicesRoutes);
|
||||
|
||||
// ─── Error Handler ────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
||||
// ─── Startup ─────────────────────────────────────────────────────────────────
|
||||
async function start(): Promise<void> {
|
||||
// Wait for DB to be ready
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
break;
|
||||
} catch (err) {
|
||||
if (i === 9) throw err;
|
||||
console.log(`Waiting for database... (${i + 1}/10)`);
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
await runMigrations();
|
||||
|
||||
// Reset vendor entries that were previously set to '' due to API rate-limiting,
|
||||
// so they get re-resolved by the new local OUI database.
|
||||
await query(`UPDATE clients SET vendor = NULL WHERE vendor = ''`).catch(() => {});
|
||||
|
||||
// Start loading the OUI database in the background (doesn't block startup)
|
||||
initOuiDatabase().catch(() => {});
|
||||
|
||||
// Connect Redis
|
||||
await redis.connect().catch(() => console.warn('Redis connection warning'));
|
||||
|
||||
// Start poller
|
||||
const pollerService = new PollerService();
|
||||
pollerService.setSocketServer(io);
|
||||
setDevicesPoller(pollerService);
|
||||
setTopologyPoller(pollerService);
|
||||
setClientsPoller(pollerService);
|
||||
await pollerService.start();
|
||||
|
||||
// Start HTTP server
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✓ Mikrotik Manager backend running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down...');
|
||||
await pollerService.stop();
|
||||
await redis.quit().catch(() => {});
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface AuthPayload {
|
||||
userId: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
||||
|
||||
export function signToken(payload: AuthPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): AuthPayload {
|
||||
return jwt.verify(token, JWT_SECRET) as AuthPayload;
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
req.user = verifyToken(token);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireWrite(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user || req.user.role === 'viewer') {
|
||||
res.status(403).json({ error: 'Write access denied for viewer role' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void {
|
||||
console.error(`[${req.method} ${req.path}]`, err.message);
|
||||
|
||||
if (res.headersSent) return;
|
||||
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
res.status(status).json({
|
||||
error: err.message || 'Internal server error',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { alertService } from '../services/AlertService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All alert routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// ── Default rules seed ─────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_RULES = [
|
||||
{ event_type: 'device_offline', enabled: true, threshold: null, cooldown_min: 5 },
|
||||
{ event_type: 'device_online', enabled: true, threshold: null, cooldown_min: 5 },
|
||||
{ event_type: 'log_error', enabled: false, threshold: null, cooldown_min: 15 },
|
||||
{ event_type: 'log_warning', enabled: false, threshold: null, cooldown_min: 30 },
|
||||
{ event_type: 'high_cpu', enabled: false, threshold: 90, cooldown_min: 30 },
|
||||
{ event_type: 'high_memory', enabled: false, threshold: 90, cooldown_min: 30 },
|
||||
{ event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 },
|
||||
{ event_type: 'device_discovered', enabled: false, threshold: null, cooldown_min: 60 },
|
||||
{ event_type: 'firmware_update_available', enabled: true, threshold: null, cooldown_min: 1440 },
|
||||
];
|
||||
|
||||
async function ensureDefaultRules(): Promise<void> {
|
||||
for (const rule of DEFAULT_RULES) {
|
||||
await query(
|
||||
`INSERT INTO alert_rules (event_type, enabled, threshold, cooldown_min)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (event_type) DO NOTHING`,
|
||||
[rule.event_type, rule.enabled, rule.threshold, rule.cooldown_min]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alert Rules ────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/alerts/rules — list all rules (seeds defaults on first call) */
|
||||
router.get('/rules', async (_req, res) => {
|
||||
await ensureDefaultRules();
|
||||
const rules = await query(
|
||||
`SELECT event_type, enabled, threshold, cooldown_min, updated_at
|
||||
FROM alert_rules ORDER BY event_type`
|
||||
);
|
||||
res.json(rules);
|
||||
});
|
||||
|
||||
/** PUT /api/alerts/rules/:type — update a single rule */
|
||||
router.put('/rules/:type', requireWrite, async (req, res) => {
|
||||
const { type } = req.params;
|
||||
const { enabled, threshold, cooldown_min } = req.body;
|
||||
|
||||
const row = await alertService.upsertRule(type, {
|
||||
enabled: enabled !== undefined ? Boolean(enabled) : undefined,
|
||||
threshold: threshold !== undefined ? (Number(threshold) || null) : undefined,
|
||||
cooldown_min: cooldown_min !== undefined ? Number(cooldown_min) : undefined,
|
||||
});
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
// ── Alert Channels ─────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/alerts/channels */
|
||||
router.get('/channels', async (_req, res) => {
|
||||
const channels = await query(
|
||||
`SELECT id, name, type, enabled, config, created_at, updated_at
|
||||
FROM alert_channels ORDER BY id`
|
||||
);
|
||||
// Mask sensitive fields before returning
|
||||
const masked = channels.map((ch: Record<string, unknown>) => ({
|
||||
...ch,
|
||||
config: maskConfig(ch.type as string, ch.config as Record<string, unknown>),
|
||||
}));
|
||||
res.json(masked);
|
||||
});
|
||||
|
||||
/** POST /api/alerts/channels */
|
||||
router.post('/channels', requireWrite, async (req, res) => {
|
||||
const { name, type, enabled = true, config = {} } = req.body;
|
||||
if (!name || !type) {
|
||||
return res.status(400).json({ error: 'name and type are required' });
|
||||
}
|
||||
const validTypes = ['email', 'slack', 'discord', 'telegram'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` });
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`INSERT INTO alert_channels (name, type, enabled, config)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name, type, Boolean(enabled), JSON.stringify(config)]
|
||||
);
|
||||
const ch = rows[0] as Record<string, unknown>;
|
||||
res.status(201).json({
|
||||
...ch,
|
||||
config: maskConfig(type, config),
|
||||
});
|
||||
});
|
||||
|
||||
/** PUT /api/alerts/channels/:id */
|
||||
router.put('/channels/:id', requireWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const { name, enabled, config } = req.body;
|
||||
|
||||
// Fetch existing so we can merge config (preserve masked/unchanged fields)
|
||||
const existing = await query<{ config: Record<string, unknown>; type: string }>(
|
||||
`SELECT config, type FROM alert_channels WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!existing[0]) return res.status(404).json({ error: 'Channel not found' });
|
||||
|
||||
const existingConfig = existing[0].config as Record<string, unknown>;
|
||||
const type = existing[0].type;
|
||||
|
||||
// Merge new config over existing, preserving existing passwords when placeholders sent
|
||||
const mergedConfig = mergeConfig(type, existingConfig, config ?? {});
|
||||
|
||||
const rows = await query(
|
||||
`UPDATE alert_channels
|
||||
SET name = COALESCE($2, name),
|
||||
enabled = COALESCE($3, enabled),
|
||||
config = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, name ?? null, enabled !== undefined ? Boolean(enabled) : null, JSON.stringify(mergedConfig)]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Channel not found' });
|
||||
|
||||
const ch = rows[0] as Record<string, unknown>;
|
||||
res.json({ ...ch, config: maskConfig(type, mergedConfig) });
|
||||
});
|
||||
|
||||
/** DELETE /api/alerts/channels/:id */
|
||||
router.delete('/channels/:id', requireWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
await query(`DELETE FROM alert_channels WHERE id = $1`, [id]);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
/** POST /api/alerts/channels/:id/test */
|
||||
router.post('/channels/:id/test', requireWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
try {
|
||||
await alertService.testChannel(id);
|
||||
res.json({ message: 'Test alert sent successfully' });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Alert History ──────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/alerts/history?limit=50 */
|
||||
router.get('/history', async (req, res) => {
|
||||
const limit = Math.min(parseInt(String(req.query.limit ?? 50), 10), 200);
|
||||
const rows = await query(
|
||||
`SELECT h.id, h.event_type, h.device_id, h.device_name, h.message,
|
||||
h.channels_notified, h.sent_at
|
||||
FROM alert_history h
|
||||
ORDER BY h.sent_at DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SENSITIVE_KEYS: Record<string, string[]> = {
|
||||
email: ['smtp_pass'],
|
||||
slack: [],
|
||||
discord: [],
|
||||
telegram: ['bot_token'],
|
||||
};
|
||||
|
||||
function maskConfig(type: string, config: Record<string, unknown>): Record<string, unknown> {
|
||||
const masked = { ...config };
|
||||
for (const key of SENSITIVE_KEYS[type] ?? []) {
|
||||
if (masked[key]) masked[key] = '••••••••';
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/** When updating, if a sensitive field comes back as the mask placeholder, keep the original. */
|
||||
function mergeConfig(
|
||||
type: string,
|
||||
existing: Record<string, unknown>,
|
||||
incoming: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...existing, ...incoming };
|
||||
for (const key of SENSITIVE_KEYS[type] ?? []) {
|
||||
if (incoming[key] === '••••••••') {
|
||||
merged[key] = existing[key]; // restore original
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { signToken, requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const user = await queryOne<{
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
}>(`SELECT id, username, password_hash, role FROM users WHERE username = $1`, [username]);
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = signToken({ userId: user.id, username: user.username, role: user.role });
|
||||
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req: Request, res: Response) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
router.post('/logout', requireAuth, (_req: Request, res: Response) => {
|
||||
// JWT is stateless; client just discards token
|
||||
res.json({ message: 'Logged out' });
|
||||
});
|
||||
|
||||
router.put('/password', requireAuth, async (req: Request, res: Response) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current and new password required' });
|
||||
}
|
||||
|
||||
const user = await queryOne<{ id: number; password_hash: string }>(
|
||||
`SELECT id, password_hash FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
|
||||
if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, req.user!.userId]);
|
||||
return res.json({ message: 'Password updated' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { BackupService } from '../services/BackupService';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const backupService = new BackupService();
|
||||
|
||||
// GET /api/backups
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const { deviceId } = req.query;
|
||||
let sql = `
|
||||
SELECT b.*, d.name as device_name
|
||||
FROM backups b JOIN devices d ON d.id = b.device_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
if (deviceId) {
|
||||
sql += ` AND b.device_id = $1`;
|
||||
params.push(deviceId);
|
||||
}
|
||||
sql += ' ORDER BY b.created_at DESC';
|
||||
|
||||
const backups = await query(sql, params);
|
||||
res.json(backups);
|
||||
});
|
||||
|
||||
// POST /api/backups
|
||||
router.post('/', requireWrite, async (req: Request, res: Response) => {
|
||||
const { deviceId, notes } = req.body;
|
||||
if (!deviceId) return res.status(400).json({ error: 'deviceId is required' });
|
||||
|
||||
const device = await queryOne<any>(
|
||||
`SELECT id, name, ip_address, ssh_port, ssh_username, ssh_password_encrypted,
|
||||
api_username, api_password_encrypted
|
||||
FROM devices WHERE id = $1`,
|
||||
[deviceId]
|
||||
);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
|
||||
try {
|
||||
const backupId = await backupService.createBackup(device, notes);
|
||||
const backup = await queryOne(`SELECT * FROM backups WHERE id = $1`, [backupId]);
|
||||
return res.status(201).json(backup);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: `Backup failed: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/backups/:id/download
|
||||
router.get('/:id/download', async (req: Request, res: Response) => {
|
||||
const backup = await queryOne<{ file_path: string; filename: string }>(
|
||||
`SELECT file_path, filename FROM backups WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!backup) return res.status(404).json({ error: 'Backup not found' });
|
||||
if (!fs.existsSync(backup.file_path)) {
|
||||
return res.status(404).json({ error: 'Backup file not found on disk' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${backup.filename}"`);
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
return res.sendFile(path.resolve(backup.file_path));
|
||||
});
|
||||
|
||||
// POST /api/backups/:id/restore
|
||||
router.post('/:id/restore', requireWrite, async (req: Request, res: Response) => {
|
||||
const backup = await queryOne(`SELECT id FROM backups WHERE id = $1`, [req.params.id]);
|
||||
if (!backup) return res.status(404).json({ error: 'Backup not found' });
|
||||
|
||||
try {
|
||||
await backupService.restoreBackup(parseInt(req.params.id));
|
||||
return res.json({ message: 'Restore initiated successfully' });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: `Restore failed: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/backups/:id
|
||||
router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const backup = await queryOne(`SELECT id FROM backups WHERE id = $1`, [req.params.id]);
|
||||
if (!backup) return res.status(404).json({ error: 'Backup not found' });
|
||||
|
||||
await backupService.deleteBackup(parseInt(req.params.id));
|
||||
return res.json({ message: 'Backup deleted' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { readFile, writeFile, unlink } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CERTS_DIR = '/certs';
|
||||
const CERT_PATH = path.join(CERTS_DIR, 'server.crt');
|
||||
const KEY_PATH = path.join(CERTS_DIR, 'server.key');
|
||||
const RELOAD_SIGNAL = path.join(CERTS_DIR, '.reload');
|
||||
|
||||
function parseCertInfo(pem: string) {
|
||||
const cert = new crypto.X509Certificate(pem);
|
||||
const now = new Date();
|
||||
const validTo = new Date(cert.validTo);
|
||||
const daysLeft = Math.floor((validTo.getTime() - now.getTime()) / 86400000);
|
||||
const isSelfSigned = cert.subject === cert.issuer;
|
||||
|
||||
// Extract CN from subject string like "CN=Mikrotik Manager, O=Self-Signed"
|
||||
const cnMatch = cert.subject.match(/CN=([^,\n]+)/);
|
||||
const cn = cnMatch ? cnMatch[1].trim() : cert.subject;
|
||||
|
||||
const issuerCnMatch = cert.issuer.match(/CN=([^,\n]+)/);
|
||||
const issuerCn = issuerCnMatch ? issuerCnMatch[1].trim() : cert.issuer;
|
||||
|
||||
return {
|
||||
subject: cn,
|
||||
subject_full: cert.subject,
|
||||
issuer: issuerCn,
|
||||
issuer_full: cert.issuer,
|
||||
serial_number: cert.serialNumber,
|
||||
valid_from: cert.validFrom,
|
||||
valid_to: cert.validTo,
|
||||
days_remaining: daysLeft,
|
||||
is_self_signed: isSelfSigned,
|
||||
san: cert.subjectAltName || null,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/cert — current certificate info
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
if (!existsSync(CERT_PATH)) {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
try {
|
||||
const pem = await readFile(CERT_PATH, 'utf8');
|
||||
return res.json({ exists: true, ...parseCertInfo(pem) });
|
||||
} catch {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cert/regenerate — generate a fresh self-signed certificate
|
||||
router.post('/regenerate', requireAdmin, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
await execFileAsync('openssl', [
|
||||
'req', '-x509', '-nodes', '-days', '3650',
|
||||
'-newkey', 'rsa:2048',
|
||||
'-keyout', KEY_PATH,
|
||||
'-out', CERT_PATH,
|
||||
'-subj', '/CN=Mikrotik Manager/O=Self-Signed/OU=Local/C=US',
|
||||
'-addext', 'subjectAltName=IP:127.0.0.1,DNS:localhost',
|
||||
], { timeout: 30000 });
|
||||
|
||||
// Signal nginx to reload
|
||||
await writeFile(RELOAD_SIGNAL, '');
|
||||
|
||||
const pem = await readFile(CERT_PATH, 'utf8');
|
||||
return res.json({ message: 'Self-signed certificate regenerated', ...parseCertInfo(pem) });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ error: `Failed to generate certificate: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cert/upload — install a custom certificate and private key
|
||||
router.post('/upload', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { certificate, private_key } = req.body as { certificate?: string; private_key?: string };
|
||||
|
||||
if (!certificate || !private_key) {
|
||||
return res.status(400).json({ error: 'Both certificate and private_key are required' });
|
||||
}
|
||||
|
||||
// Validate PEM format
|
||||
if (!certificate.includes('-----BEGIN CERTIFICATE-----')) {
|
||||
return res.status(400).json({ error: 'certificate does not appear to be a valid PEM certificate' });
|
||||
}
|
||||
if (!private_key.includes('-----BEGIN')) {
|
||||
return res.status(400).json({ error: 'private_key does not appear to be a valid PEM key' });
|
||||
}
|
||||
|
||||
// Parse the certificate
|
||||
let certInfo;
|
||||
try {
|
||||
certInfo = parseCertInfo(certificate);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Failed to parse certificate — ensure it is valid PEM format' });
|
||||
}
|
||||
|
||||
// Verify the private key matches the certificate's public key
|
||||
try {
|
||||
const certObj = new crypto.X509Certificate(certificate);
|
||||
const privKey = crypto.createPrivateKey(private_key);
|
||||
const certPubKey = certObj.publicKey;
|
||||
|
||||
// Sign test data with private key, verify with cert's public key
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update('mikrotik-manager-cert-verify');
|
||||
const signature = sign.sign(privKey);
|
||||
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
verify.update('mikrotik-manager-cert-verify');
|
||||
const matches = verify.verify(certPubKey, signature);
|
||||
|
||||
if (!matches) {
|
||||
return res.status(400).json({ error: 'Private key does not match the certificate' });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(400).json({ error: `Key/certificate validation failed: ${msg}` });
|
||||
}
|
||||
|
||||
// Write files atomically (write to temp, then rename)
|
||||
const tmpCert = CERT_PATH + '.tmp';
|
||||
const tmpKey = KEY_PATH + '.tmp';
|
||||
try {
|
||||
await writeFile(tmpCert, certificate.trim() + '\n', { mode: 0o644 });
|
||||
await writeFile(tmpKey, private_key.trim() + '\n', { mode: 0o600 });
|
||||
|
||||
// Rename into place
|
||||
const { rename } = await import('fs/promises');
|
||||
await rename(tmpCert, CERT_PATH);
|
||||
await rename(tmpKey, KEY_PATH);
|
||||
|
||||
// Signal nginx to reload
|
||||
await writeFile(RELOAD_SIGNAL, '');
|
||||
|
||||
return res.json({ message: 'Certificate installed successfully', ...certInfo });
|
||||
} catch (err) {
|
||||
// Clean up temp files if they exist
|
||||
await unlink(tmpCert).catch(() => {});
|
||||
await unlink(tmpKey).catch(() => {});
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ error: `Failed to write certificate: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,322 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as dgram from 'dgram';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
|
||||
import { PollerService } from '../services/PollerService';
|
||||
import { getQueryApi, bucket } from '../config/influxdb';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
let pollerService: PollerService | null = null;
|
||||
export function setPollerService(p: PollerService): void { pollerService = p; }
|
||||
|
||||
// GET /api/clients
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const { deviceId, active, search, client_type, limit = '100', offset = '0' } = req.query;
|
||||
|
||||
// Build WHERE clause shared by both queries
|
||||
const filters: string[] = [];
|
||||
const filterParams: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (deviceId) {
|
||||
filters.push(`c.device_id = $${idx++}`);
|
||||
filterParams.push(deviceId);
|
||||
}
|
||||
if (active === 'true') {
|
||||
filters.push(`c.active = TRUE`);
|
||||
}
|
||||
if (client_type) {
|
||||
filters.push(`c.client_type = $${idx++}`);
|
||||
filterParams.push(client_type);
|
||||
}
|
||||
if (search) {
|
||||
filters.push(`(c.mac_address ILIKE $${idx} OR c.custom_name ILIKE $${idx} OR c.hostname ILIKE $${idx} OR c.ip_address ILIKE $${idx} OR c.vendor ILIKE $${idx})`);
|
||||
filterParams.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
|
||||
// Deduplicate by MAC address across devices: prefer active rows, then most recently seen.
|
||||
// The inner DISTINCT ON picks the best row per MAC; the outer query applies final sort + pagination.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (c.mac_address)
|
||||
c.*, d.name as device_name,
|
||||
wi.ssid as ssid
|
||||
FROM clients c
|
||||
JOIN devices d ON d.id = c.device_id
|
||||
LEFT JOIN wireless_interfaces wi
|
||||
ON wi.device_id = c.device_id AND wi.name = c.interface_name
|
||||
${where}
|
||||
ORDER BY c.mac_address, (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST
|
||||
) deduped
|
||||
ORDER BY last_seen DESC NULLS LAST
|
||||
LIMIT $${idx++} OFFSET $${idx++}
|
||||
`;
|
||||
const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]);
|
||||
|
||||
const countResult = await query<{ total: string }>(
|
||||
`SELECT COUNT(DISTINCT c.mac_address) as total FROM clients c ${where}`,
|
||||
filterParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
clients,
|
||||
total: parseInt(countResult[0]?.total || '0', 10),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac — detail view with SSID, VLAN name, device type, upstream topology
|
||||
router.get('/:mac', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
|
||||
// Order: wireless records before wired (AP knows the client best), then active,
|
||||
// then most recently seen. Use LATERAL for topology so we never multiply rows.
|
||||
const rows = await query<Record<string, unknown>>(
|
||||
`SELECT c.*, d.name as device_name, d.device_type,
|
||||
wi.ssid as ssid,
|
||||
v.name as vlan_name,
|
||||
topo.upstream_device_id,
|
||||
topo.upstream_interface,
|
||||
topo.upstream_device_name,
|
||||
topo.upstream_device_type,
|
||||
topo.upstream_device_ip
|
||||
FROM clients c
|
||||
JOIN devices d ON d.id = c.device_id
|
||||
LEFT JOIN wireless_interfaces wi
|
||||
ON wi.device_id = c.device_id AND wi.name = c.interface_name
|
||||
LEFT JOIN vlans v
|
||||
ON v.device_id = c.device_id AND v.vlan_id = c.vlan_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tl.to_device_id AS upstream_device_id,
|
||||
tl.to_interface AS upstream_interface,
|
||||
ud.name AS upstream_device_name,
|
||||
ud.device_type AS upstream_device_type,
|
||||
ud.ip_address AS upstream_device_ip
|
||||
FROM topology_links tl
|
||||
JOIN devices ud ON ud.id = tl.to_device_id
|
||||
WHERE tl.from_device_id = c.device_id
|
||||
LIMIT 1
|
||||
) topo ON TRUE
|
||||
WHERE LOWER(c.mac_address) = $1
|
||||
ORDER BY (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST
|
||||
LIMIT 1`,
|
||||
[mac]
|
||||
);
|
||||
|
||||
if (!rows.length) return res.status(404).json({ error: 'Client not found' });
|
||||
return res.json(rows[0]);
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac/presence?range=2h|24h|7d — connectivity timeline from InfluxDB
|
||||
router.get('/:mac/presence', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
const rangeRaw = String(req.query.range || '24h');
|
||||
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
|
||||
const range = allowedRanges[rangeRaw] || '24h';
|
||||
|
||||
// Window size: 5m for 2h/24h, 30m for 7d
|
||||
const window = range === '7d' ? '30m' : '5m';
|
||||
|
||||
const queryApi = getQueryApi();
|
||||
// Group by mac_address only (drop device_id) so that all devices reporting the
|
||||
// same client are merged into one series. fn:max means online=1 if any device
|
||||
// saw the client in that window; createEmpty+fill gives offline=0 for gaps.
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_presence")
|
||||
|> filter(fn: (r) => r._field == "online")
|
||||
|> filter(fn: (r) => r.mac_address == "${mac}")
|
||||
|> group(columns: ["_measurement", "_field", "mac_address"])
|
||||
|> aggregateWindow(every: ${window}, fn: max, createEmpty: true)
|
||||
|> fill(value: 0)
|
||||
|> yield(name: "presence")
|
||||
`;
|
||||
|
||||
const points: { time: string; online: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
points.push({ time, online: value != null ? Math.round(value) : 0 });
|
||||
});
|
||||
} catch {
|
||||
// InfluxDB not available or no data yet
|
||||
}
|
||||
|
||||
return res.json(points);
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac/traffic?range=2h|24h|7d — wireless tx/rx rate graph
|
||||
router.get('/:mac/traffic', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
const rangeRaw = String(req.query.range || '24h');
|
||||
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
|
||||
const range = allowedRanges[rangeRaw] || '24h';
|
||||
|
||||
const window = range === '7d' ? '30m' : '5m';
|
||||
|
||||
const queryApi = getQueryApi();
|
||||
// Simple query: get the last cumulative byte counter value per time window.
|
||||
// Non-negative differences are computed in TypeScript below — avoids the
|
||||
// fragile non_negative_difference+pivot combination in Flux.
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_presence")
|
||||
|> filter(fn: (r) => r._field == "tx_bytes" or r._field == "rx_bytes")
|
||||
|> filter(fn: (r) => r.mac_address == "${mac}")
|
||||
|> group(columns: ["_measurement", "_field", "mac_address"])
|
||||
|> aggregateWindow(every: ${window}, fn: last, createEmpty: false)
|
||||
|> yield(name: "traffic_raw")
|
||||
`;
|
||||
|
||||
const raw: { time: string; field: string; value: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const field = tableMeta.get(row, '_field') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
if (time && field && value != null) raw.push({ time, field, value });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[clients/traffic] Flux error for ${mac}:`, err);
|
||||
}
|
||||
|
||||
// Separate tx and rx series, sort by time, then compute non-negative deltas
|
||||
const txSeries = raw.filter(p => p.field === 'tx_bytes').sort((a, b) => a.time.localeCompare(b.time));
|
||||
const rxSeries = raw.filter(p => p.field === 'rx_bytes').sort((a, b) => a.time.localeCompare(b.time));
|
||||
|
||||
const txDeltas = new Map<string, number>();
|
||||
for (let i = 1; i < txSeries.length; i++) {
|
||||
const diff = txSeries[i].value - txSeries[i - 1].value;
|
||||
if (diff >= 0) txDeltas.set(txSeries[i].time, diff);
|
||||
}
|
||||
|
||||
const rxDeltas = new Map<string, number>();
|
||||
for (let i = 1; i < rxSeries.length; i++) {
|
||||
const diff = rxSeries[i].value - rxSeries[i - 1].value;
|
||||
if (diff >= 0) rxDeltas.set(rxSeries[i].time, diff);
|
||||
}
|
||||
|
||||
const allTimes = new Set([...txDeltas.keys(), ...rxDeltas.keys()]);
|
||||
const points = [...allTimes].sort().map(time => ({
|
||||
time,
|
||||
tx_bytes: txDeltas.get(time) ?? 0,
|
||||
rx_bytes: rxDeltas.get(time) ?? 0,
|
||||
}));
|
||||
|
||||
return res.json(points);
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac/signal?range=2h|24h|7d — signal strength over time
|
||||
router.get('/:mac/signal', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
const rangeRaw = String(req.query.range || '24h');
|
||||
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
|
||||
const range = allowedRanges[rangeRaw] || '24h';
|
||||
|
||||
const window = range === '7d' ? '30m' : '5m';
|
||||
|
||||
const queryApi = getQueryApi();
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_presence")
|
||||
|> filter(fn: (r) => r._field == "signal_strength")
|
||||
|> filter(fn: (r) => r.mac_address == "${mac}")
|
||||
|> group(columns: ["_measurement", "_field", "mac_address"])
|
||||
|> aggregateWindow(every: ${window}, fn: mean, createEmpty: false)
|
||||
|> yield(name: "signal")
|
||||
`;
|
||||
|
||||
const points: { time: string; signal_strength: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
if (time && value != null) points.push({ time, signal_strength: Math.round(value) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[clients/signal] Flux error for ${mac}:`, err);
|
||||
}
|
||||
|
||||
return res.json(points);
|
||||
});
|
||||
|
||||
// POST /api/clients/:mac/wol — send Wake-on-LAN magic packet
|
||||
router.post('/:mac/wol', requireWrite, async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.replace(/[^0-9a-fA-F:.-]/g, '');
|
||||
const hexMac = mac.replace(/[:\-\.]/g, '');
|
||||
if (hexMac.length !== 12) {
|
||||
return res.status(400).json({ error: 'Invalid MAC address' });
|
||||
}
|
||||
|
||||
// Build magic packet: 6 bytes of 0xFF + MAC repeated 16 times
|
||||
const macBytes = Buffer.from(hexMac, 'hex');
|
||||
const magic = Buffer.alloc(102);
|
||||
magic.fill(0xff, 0, 6);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
macBytes.copy(magic, 6 + i * 6);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const socket = dgram.createSocket('udp4');
|
||||
socket.once('error', (err) => {
|
||||
socket.close();
|
||||
res.status(500).json({ error: `WoL send failed: ${err.message}` });
|
||||
resolve();
|
||||
});
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(magic, 0, magic.length, 9, '255.255.255.255', (err) => {
|
||||
socket.close();
|
||||
if (err) {
|
||||
res.status(500).json({ error: `WoL send failed: ${err.message}` });
|
||||
} else {
|
||||
res.json({ success: true, message: `Magic packet sent to ${mac}` });
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/clients/:mac/hostname (writes to custom_name — user-set names persist across collector cycles)
|
||||
router.put('/:mac/hostname', requireWrite, async (req: Request, res: Response) => {
|
||||
const { hostname } = req.body as { hostname?: string };
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
|
||||
const result = await query(
|
||||
`UPDATE clients SET custom_name = $1 WHERE LOWER(mac_address) = $2 RETURNING *`,
|
||||
[hostname || null, mac]
|
||||
);
|
||||
if (!result.length) return res.status(404).json({ error: 'Client not found' });
|
||||
return res.json(result[0]);
|
||||
});
|
||||
|
||||
// PUT /api/clients/:mac/notes — update the comment/notes field
|
||||
router.put('/:mac/notes', requireWrite, async (req: Request, res: Response) => {
|
||||
const { notes } = req.body as { notes?: string };
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
|
||||
const result = await query(
|
||||
`UPDATE clients SET comment = $1 WHERE LOWER(mac_address) = $2 RETURNING *`,
|
||||
[notes ?? null, mac]
|
||||
);
|
||||
if (!result.length) return res.status(404).json({ error: 'Client not found' });
|
||||
return res.json(result[0]);
|
||||
});
|
||||
|
||||
// POST /api/clients/purge — immediately delete inactive clients older than retention period
|
||||
router.post('/purge', requireAdmin, async (_req: Request, res: Response) => {
|
||||
if (!pollerService) return res.status(503).json({ error: 'Poller not available' });
|
||||
const count = await pollerService.pruneStaleClientsNow();
|
||||
return res.json({ message: `Purged ${count} stale client record(s)`, count });
|
||||
});
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/events
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const {
|
||||
deviceId,
|
||||
severity,
|
||||
topic,
|
||||
search,
|
||||
since,
|
||||
limit = '200',
|
||||
offset = '0',
|
||||
} = req.query;
|
||||
|
||||
const filters: string[] = ['1=1'];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (deviceId) {
|
||||
filters.push(`e.device_id = $${idx++}`);
|
||||
params.push(deviceId);
|
||||
}
|
||||
if (severity) {
|
||||
// Accept comma-separated list e.g. "error,warning"
|
||||
// 'error' also includes 'critical' rows
|
||||
const requested = String(severity).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const expanded = new Set<string>();
|
||||
for (const s of requested) {
|
||||
expanded.add(s);
|
||||
if (s === 'error') expanded.add('critical');
|
||||
}
|
||||
const placeholders = Array.from(expanded).map(() => `$${idx++}`).join(',');
|
||||
filters.push(`e.severity IN (${placeholders})`);
|
||||
params.push(...Array.from(expanded));
|
||||
}
|
||||
if (topic) {
|
||||
filters.push(`e.topic ILIKE $${idx++}`);
|
||||
params.push(`%${topic}%`);
|
||||
}
|
||||
if (since) {
|
||||
filters.push(`e.event_time >= $${idx++}`);
|
||||
params.push(since);
|
||||
}
|
||||
if (search) {
|
||||
filters.push(`(e.message ILIKE $${idx} OR e.topic ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = filters.join(' AND ');
|
||||
|
||||
const [events, totalResult, criticalCount] = await Promise.all([
|
||||
query(
|
||||
`SELECT e.*, d.name as device_name
|
||||
FROM events e
|
||||
LEFT JOIN devices d ON d.id = e.device_id
|
||||
WHERE ${where}
|
||||
ORDER BY e.event_time DESC
|
||||
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||
[...params, parseInt(String(limit), 10), parseInt(String(offset), 10)]
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM events e WHERE ${where}`,
|
||||
params
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM events WHERE severity IN ('error','critical') AND event_time > NOW() - INTERVAL '24 hours'`
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
events,
|
||||
total: parseInt(totalResult[0]?.count || '0', 10),
|
||||
criticalCount: parseInt(criticalCount[0]?.count || '0', 10),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/events (clear events for a device or all)
|
||||
router.delete('/', requireWrite, async (req: Request, res: Response) => {
|
||||
const { deviceId } = req.query;
|
||||
if (deviceId) {
|
||||
await query(`DELETE FROM events WHERE device_id = $1`, [deviceId]);
|
||||
} else {
|
||||
await query(`DELETE FROM events`);
|
||||
}
|
||||
res.json({ message: 'Events cleared' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getQueryApi, bucket } from '../config/influxdb';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
function rangeToFlux(range: string): string {
|
||||
const allowed = ['1h', '3h', '6h', '12h', '24h', '7d', '30d'];
|
||||
return allowed.includes(range) ? range : '24h';
|
||||
}
|
||||
|
||||
// GET /api/metrics/clients-over-time?range=24h
|
||||
router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const queryApi = getQueryApi();
|
||||
|
||||
// Use the global deduplicated metric (_global tag) written by DeviceCollector.
|
||||
// This avoids double-counting clients seen by multiple devices simultaneously.
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id == "_global")
|
||||
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|
||||
|> yield(name: "clients_over_time")
|
||||
`;
|
||||
|
||||
const rawPoints: { time: string; value: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// InfluxDB might not have data yet
|
||||
}
|
||||
|
||||
// If no global metric exists yet (first run or old data), fall back to max across devices.
|
||||
// max() is better than sum() since the device with the most clients is the gateway that
|
||||
// sees everyone — summing would double-count clients visible from multiple devices.
|
||||
if (rawPoints.length === 0) {
|
||||
const fallbackQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id != "_global")
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> group()
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> yield(name: "clients_over_time_fallback")
|
||||
`;
|
||||
try {
|
||||
await queryApi.collectRows(fallbackQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
}
|
||||
|
||||
const points = rawPoints.sort((a, b) => a.time.localeCompare(b.time));
|
||||
res.json(points);
|
||||
});
|
||||
|
||||
// GET /api/metrics/top-clients?limit=10&range=24h
|
||||
router.get('/top-clients', async (req: Request, res: Response) => {
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '10'), 10), 50);
|
||||
|
||||
// Use postgres data (most recent client data)
|
||||
const clients = await query(
|
||||
`SELECT c.mac_address, c.hostname, c.ip_address, c.interface_name,
|
||||
c.tx_bytes + c.rx_bytes as total_bytes,
|
||||
c.tx_bytes, c.rx_bytes, c.client_type, d.name as device_name
|
||||
FROM clients c JOIN devices d ON d.id = c.device_id
|
||||
WHERE c.active = TRUE AND (c.tx_bytes + c.rx_bytes) > 0
|
||||
ORDER BY total_bytes DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
res.json(clients);
|
||||
});
|
||||
|
||||
// GET /api/metrics/interface/:deviceId/:interface?range=1h
|
||||
router.get('/interface/:deviceId/:interface', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '1h'));
|
||||
const queryApi = getQueryApi();
|
||||
const deviceId = req.params.deviceId;
|
||||
const iface = req.params.interface;
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "interface_traffic")
|
||||
|> filter(fn: (r) => r.device_id == "${deviceId}")
|
||||
|> filter(fn: (r) => r.interface == "${iface}")
|
||||
|> filter(fn: (r) => r._field == "rx_bytes" or r._field == "tx_bytes")
|
||||
|> aggregateWindow(every: 1m, fn: last, createEmpty: false)
|
||||
|> derivative(unit: 1s, nonNegative: true)
|
||||
|> yield(name: "traffic")
|
||||
`;
|
||||
|
||||
const points: Record<string, unknown>[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
points.push({
|
||||
time: tableMeta.get(row, '_time'),
|
||||
field: tableMeta.get(row, '_field'),
|
||||
value: tableMeta.get(row, '_value'),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
// Pivot rx/tx into single objects per timestamp
|
||||
const pivoted: Record<string, { time: string; rx: number; tx: number }> = {};
|
||||
for (const p of points) {
|
||||
const t = p['time'] as string;
|
||||
if (!pivoted[t]) pivoted[t] = { time: t, rx: 0, tx: 0 };
|
||||
if (p['field'] === 'rx_bytes') pivoted[t].rx = Number(p['value']) || 0;
|
||||
if (p['field'] === 'tx_bytes') pivoted[t].tx = Number(p['value']) || 0;
|
||||
}
|
||||
|
||||
res.json(Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)));
|
||||
});
|
||||
|
||||
// GET /api/metrics/interface/:deviceId/:interface/packets?range=1h
|
||||
router.get('/interface/:deviceId/:interface/packets', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '1h'));
|
||||
const queryApi = getQueryApi();
|
||||
const deviceId = req.params.deviceId;
|
||||
const iface = req.params.interface;
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "interface_traffic")
|
||||
|> filter(fn: (r) => r.device_id == "${deviceId}")
|
||||
|> filter(fn: (r) => r.interface == "${iface}")
|
||||
|> filter(fn: (r) => r._field == "rx_packets" or r._field == "tx_packets")
|
||||
|> aggregateWindow(every: 1m, fn: last, createEmpty: false)
|
||||
|> derivative(unit: 1s, nonNegative: true)
|
||||
|> yield(name: "packets")
|
||||
`;
|
||||
|
||||
const points: Record<string, unknown>[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
points.push({
|
||||
time: tableMeta.get(row, '_time'),
|
||||
field: tableMeta.get(row, '_field'),
|
||||
value: tableMeta.get(row, '_value'),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
const pivoted: Record<string, { time: string; rx: number; tx: number }> = {};
|
||||
for (const p of points) {
|
||||
const t = p['time'] as string;
|
||||
if (!pivoted[t]) pivoted[t] = { time: t, rx: 0, tx: 0 };
|
||||
if (p['field'] === 'rx_packets') pivoted[t].rx = Number(p['value']) || 0;
|
||||
if (p['field'] === 'tx_packets') pivoted[t].tx = Number(p['value']) || 0;
|
||||
}
|
||||
|
||||
res.json(Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)));
|
||||
});
|
||||
|
||||
// GET /api/metrics/device/:deviceId/resources?range=24h
|
||||
router.get('/device/:deviceId/resources', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const queryApi = getQueryApi();
|
||||
const deviceId = req.params.deviceId;
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "device_resources")
|
||||
|> filter(fn: (r) => r.device_id == "${deviceId}")
|
||||
|> filter(fn: (r) => r._field == "cpu_load" or r._field == "memory_used" or r._field == "memory_total")
|
||||
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|
||||
|> yield(name: "resources")
|
||||
`;
|
||||
|
||||
const points: Record<string, unknown>[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
points.push({
|
||||
time: tableMeta.get(row, '_time'),
|
||||
field: tableMeta.get(row, '_field'),
|
||||
value: tableMeta.get(row, '_value'),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
const pivoted: Record<string, Record<string, unknown>> = {};
|
||||
for (const p of points) {
|
||||
const t = p['time'] as string;
|
||||
if (!pivoted[t]) pivoted[t] = { time: t };
|
||||
pivoted[t][p['field'] as string] = Number(p['value']) || 0;
|
||||
}
|
||||
|
||||
res.json(Object.values(pivoted).sort((a, b) => String(a['time']).localeCompare(String(b['time']))));
|
||||
});
|
||||
|
||||
// GET /api/metrics/summary - dashboard summary stats
|
||||
router.get('/summary', async (_req: Request, res: Response) => {
|
||||
const [deviceStats, clientStats, alertStats] = await Promise.all([
|
||||
query<{ total: string; online: string; offline: string }>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status='online') as online,
|
||||
COUNT(*) FILTER (WHERE status='offline') as offline
|
||||
FROM devices`
|
||||
),
|
||||
query<{ total: string; active: string }>(
|
||||
`SELECT COUNT(DISTINCT mac_address) as total,
|
||||
COUNT(DISTINCT mac_address) FILTER (WHERE active=TRUE) as active
|
||||
FROM clients`
|
||||
),
|
||||
query<{ critical: string; warning: string }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE severity='error') as critical,
|
||||
COUNT(*) FILTER (WHERE severity='warning') as warning
|
||||
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
devices: {
|
||||
total: parseInt(deviceStats[0]?.total || '0', 10),
|
||||
online: parseInt(deviceStats[0]?.online || '0', 10),
|
||||
offline: parseInt(deviceStats[0]?.offline || '0', 10),
|
||||
},
|
||||
clients: {
|
||||
total: parseInt(clientStats[0]?.total || '0', 10),
|
||||
active: parseInt(clientStats[0]?.active || '0', 10),
|
||||
},
|
||||
alerts: {
|
||||
critical: parseInt(alertStats[0]?.critical || '0', 10),
|
||||
warning: parseInt(alertStats[0]?.warning || '0', 10),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function withDevice<T>(
|
||||
deviceId: number,
|
||||
res: Response,
|
||||
fn: (collector: DeviceCollector, device: DeviceRow) => Promise<T>
|
||||
): Promise<T | void> {
|
||||
const rows = await query<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [deviceId]);
|
||||
const device = rows[0];
|
||||
if (!device) {
|
||||
res.status(404).json({ error: 'Device not found' });
|
||||
return;
|
||||
}
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
return await fn(collector, device);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function deviceIdParam(req: Request, res: Response): number | null {
|
||||
const id = parseInt(req.query.deviceId as string);
|
||||
if (!id) { res.status(400).json({ error: 'deviceId query param is required' }); return null; }
|
||||
return id;
|
||||
}
|
||||
|
||||
// ─── Overview (all online devices, all services) ──────────────────────────────
|
||||
|
||||
router.get('/overview', async (_req: Request, res: Response) => {
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
devices.map(async (device: DeviceRow) => {
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
const [dhcpV4, dhcpV6, dns, ntp, wg] = await Promise.allSettled([
|
||||
collector.getDhcpServers('ipv4'),
|
||||
collector.getDhcpServers('ipv6'),
|
||||
collector.getDnsSettings(),
|
||||
collector.getNtpSettings(),
|
||||
collector.getWireGuardInterfaces(),
|
||||
]);
|
||||
|
||||
const v4Servers = dhcpV4.status === 'fulfilled' ? dhcpV4.value : [];
|
||||
const v6Servers = dhcpV6.status === 'fulfilled' ? dhcpV6.value : [];
|
||||
const dnsRow = dns.status === 'fulfilled' ? dns.value : null;
|
||||
const ntpRow = ntp.status === 'fulfilled' ? ntp.value : null;
|
||||
const wgIfaces = wg.status === 'fulfilled' ? wg.value : [];
|
||||
|
||||
return {
|
||||
id: device.id, name: device.name, ip_address: device.ip_address,
|
||||
dhcp_v4: { total: v4Servers.length, enabled: v4Servers.filter(s => s['disabled'] !== 'true').length },
|
||||
dhcp_v6: { total: v6Servers.length, enabled: v6Servers.filter(s => s['disabled'] !== 'true').length },
|
||||
dns: dnsRow ? { allow_remote: dnsRow['allow-remote-requests'] === 'yes', servers: dnsRow['servers'] || '' } : null,
|
||||
ntp: ntpRow ? { server_enabled: ntpRow.server['enabled'] === 'yes', client_enabled: ntpRow.client['enabled'] === 'yes' } : null,
|
||||
wireguard: { total: wgIfaces.length, running: wgIfaces.filter(i => i['running'] === 'true').length },
|
||||
};
|
||||
} catch (err) {
|
||||
return { id: device.id, name: device.name, ip_address: device.ip_address,
|
||||
dhcp_v4: null, dhcp_v6: null, dns: null, ntp: null, wireguard: null,
|
||||
error: (err as Error).message };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json(results.map(r => r.status === 'fulfilled' ? r.value : { error: (r.reason as Error).message }));
|
||||
});
|
||||
|
||||
// ─── DHCP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/dhcp?deviceId=X
|
||||
router.get('/dhcp', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const [v4, v6, pv4, pv6] = await Promise.allSettled([
|
||||
collector.getDhcpServers('ipv4'),
|
||||
collector.getDhcpServers('ipv6'),
|
||||
collector.getDhcpPools('ipv4'),
|
||||
collector.getDhcpPools('ipv6'),
|
||||
]);
|
||||
res.json({
|
||||
ipv4: v4.status === 'fulfilled' ? v4.value : [],
|
||||
ipv6: v6.status === 'fulfilled' ? v6.value : [],
|
||||
pools_v4: pv4.status === 'fulfilled' ? pv4.value : [],
|
||||
pools_v6: pv6.status === 'fulfilled' ? pv6.value : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dhcp/server?deviceId=X — create DHCP server
|
||||
router.post('/dhcp/server', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol must be "ipv4" or "ipv6"' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addDhcpServer(params, protocol as 'ipv4' | 'ipv6');
|
||||
const updated = await collector.getDhcpServers(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dhcp/server/:id?deviceId=X — update DHCP server
|
||||
router.put('/dhcp/server/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateDhcpServer(req.params.id, params, protocol as 'ipv4' | 'ipv6');
|
||||
const updated = await collector.getDhcpServers(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dhcp/server/:id?deviceId=X&protocol=X — delete DHCP server
|
||||
router.delete('/dhcp/server/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = req.query.protocol as string;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeDhcpServer(req.params.id, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dhcp/server — toggle enable/disable (legacy compat)
|
||||
router.put('/dhcp/server', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { serverId, disabled, protocol } = req.body;
|
||||
if (!serverId) return res.status(400).json({ error: 'serverId is required' });
|
||||
if (typeof disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setDhcpServerDisabled(serverId, disabled, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/network-services/dhcp/pools?deviceId=X&protocol=X
|
||||
router.get('/dhcp/pools', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = req.query.protocol as string;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const pools = await collector.getDhcpPools(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(pools);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dhcp/pool?deviceId=X — add pool
|
||||
router.post('/dhcp/pool', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addDhcpPool(params, protocol as 'ipv4' | 'ipv6');
|
||||
const updated = await collector.getDhcpPools(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dhcp/pool/:id?deviceId=X&protocol=X — remove pool
|
||||
router.delete('/dhcp/pool/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = req.query.protocol as string;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeDhcpPool(req.params.id, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/network-services/dhcp/leases?deviceId=X&protocol=X
|
||||
router.get('/dhcp/leases', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = (req.query.protocol as string) || 'ipv4';
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol must be ipv4 or ipv6' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const leases = await collector.getDhcpLeases(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(leases);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dhcp/static-lease?deviceId=X — add static lease
|
||||
router.post('/dhcp/static-lease', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addStaticDhcpLease(params, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dhcp/static-lease/:id?deviceId=X&protocol=X
|
||||
router.delete('/dhcp/static-lease/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = (req.query.protocol as string) || 'ipv4';
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeStaticDhcpLease(req.params.id, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DNS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/dns?deviceId=X
|
||||
router.get('/dns', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const [settings, statics] = await Promise.allSettled([
|
||||
collector.getDnsSettings(),
|
||||
collector.getDnsStaticEntries(),
|
||||
]);
|
||||
res.json({
|
||||
settings: settings.status === 'fulfilled' ? settings.value : {},
|
||||
statics: statics.status === 'fulfilled' ? statics.value : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dns?deviceId=X
|
||||
router.put('/dns', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setDnsSettings(req.body);
|
||||
const updated = await collector.getDnsSettings();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dns/flush?deviceId=X
|
||||
router.post('/dns/flush', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.flushDnsCache();
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/network-services/dns/static?deviceId=X
|
||||
router.get('/dns/static', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const entries = await collector.getDnsStaticEntries();
|
||||
res.json(entries);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dns/static?deviceId=X
|
||||
router.post('/dns/static', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addDnsStaticEntry(req.body);
|
||||
const updated = await collector.getDnsStaticEntries();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dns/static/:id?deviceId=X
|
||||
router.put('/dns/static/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateDnsStaticEntry(req.params.id, req.body);
|
||||
const updated = await collector.getDnsStaticEntries();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dns/static/:id?deviceId=X
|
||||
router.delete('/dns/static/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeDnsStaticEntry(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NTP ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/ntp?deviceId=X
|
||||
router.get('/ntp', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const settings = await collector.getNtpSettings();
|
||||
res.json(settings);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/ntp?deviceId=X
|
||||
router.put('/ntp', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setNtpSettings(req.body);
|
||||
const updated = await collector.getNtpSettings();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WireGuard ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/wireguard?deviceId=X
|
||||
router.get('/wireguard', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const [interfaces, peers] = await Promise.allSettled([
|
||||
collector.getWireGuardInterfaces(),
|
||||
collector.getWireGuardPeers(),
|
||||
]);
|
||||
res.json({
|
||||
interfaces: interfaces.status === 'fulfilled' ? interfaces.value : [],
|
||||
peers: peers.status === 'fulfilled' ? peers.value : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/wireguard?deviceId=X — create interface
|
||||
router.post('/wireguard', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const interfaces = await collector.addWireGuardInterface(req.body);
|
||||
res.json(interfaces);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/wireguard/toggle — enable/disable (must be before /:id)
|
||||
router.put('/wireguard/toggle', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { interfaceId, disabled } = req.body;
|
||||
if (!interfaceId) return res.status(400).json({ error: 'interfaceId is required' });
|
||||
if (typeof disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setWireGuardInterfaceDisabled(interfaceId, disabled);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/wireguard/:id?deviceId=X — update interface
|
||||
router.put('/wireguard/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateWireGuardInterface(req.params.id, req.body);
|
||||
const updated = await collector.getWireGuardInterfaces();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/wireguard/:id?deviceId=X — delete interface
|
||||
router.delete('/wireguard/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeWireGuardInterface(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/wireguard/peer?deviceId=X — add peer
|
||||
router.post('/wireguard/peer', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addWireGuardPeer(req.body);
|
||||
const peers = await collector.getWireGuardPeers();
|
||||
res.json(peers);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/wireguard/peer/:id?deviceId=X — update peer
|
||||
router.put('/wireguard/peer/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateWireGuardPeer(req.params.id, req.body);
|
||||
const peers = await collector.getWireGuardPeers();
|
||||
res.json(peers);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/wireguard/peer/:id?deviceId=X — delete peer
|
||||
router.delete('/wireguard/peer/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeWireGuardPeer(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/routers/lldp — LLDP enabled/disabled status per online router
|
||||
router.get('/lldp', async (_req: Request, res: Response) => {
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
const lldp = await collector.getLldpEnabled();
|
||||
return { id: r.id, name: r.name, ip_address: r.ip_address, ...lldp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: routers[i].id,
|
||||
name: routers[i].name,
|
||||
ip_address: routers[i].ip_address,
|
||||
enabled: null as boolean | null,
|
||||
protocol: null as string | null,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/routers/lldp — enable or disable LLDP on all online routers
|
||||
router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: '"enabled" (boolean) is required' });
|
||||
}
|
||||
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setLldpEnabled(enabled);
|
||||
return { id: r.id, name: r.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: routers[i].id,
|
||||
name: routers[i].name,
|
||||
success: false,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
applied: statuses.filter(s => s.success).length,
|
||||
total: routers.length,
|
||||
results: statuses,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/routers/snmp — SNMP config/status per online router
|
||||
router.get('/snmp', async (_req: Request, res: Response) => {
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
const snmp = await collector.getSnmpConfig();
|
||||
return { id: r.id, name: r.name, ip_address: r.ip_address, ...snmp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: routers[i].id, name: routers[i].name, ip_address: routers[i].ip_address,
|
||||
enabled: null as boolean | null, error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/routers/snmp — apply SNMP config to all online routers
|
||||
router.put('/snmp', requireWrite, async (req: Request, res: Response) => {
|
||||
const config = req.body as {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
};
|
||||
if (!config.community_name || !config.version) {
|
||||
return res.status(400).json({ error: 'community_name and version are required' });
|
||||
}
|
||||
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setSnmpConfig(config);
|
||||
return { id: r.id, name: r.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return { id: routers[i].id, name: routers[i].name, success: false, error: (r.reason as Error).message };
|
||||
});
|
||||
|
||||
return res.json({ applied: statuses.filter(s => s.success).length, total: routers.length, results: statuses });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/search?q=<term>
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q.length < 2) {
|
||||
return res.json({ devices: [], clients: [], events: [] });
|
||||
}
|
||||
|
||||
const like = `%${q}%`;
|
||||
|
||||
const [devices, clients, events] = await Promise.all([
|
||||
query(
|
||||
`SELECT id, name, ip_address, model, device_type, status
|
||||
FROM devices
|
||||
WHERE name ILIKE $1 OR ip_address ILIKE $1 OR model ILIKE $1 OR serial_number ILIKE $1
|
||||
ORDER BY (status = 'online') DESC, name ASC
|
||||
LIMIT 6`,
|
||||
[like]
|
||||
),
|
||||
query(
|
||||
`SELECT c.mac_address, c.hostname, c.ip_address, c.device_id, c.active,
|
||||
d.name as device_name
|
||||
FROM clients c
|
||||
LEFT JOIN devices d ON d.id = c.device_id
|
||||
WHERE c.mac_address ILIKE $1 OR c.hostname ILIKE $1 OR c.ip_address ILIKE $1
|
||||
ORDER BY c.active DESC, c.last_seen DESC
|
||||
LIMIT 6`,
|
||||
[like]
|
||||
),
|
||||
query(
|
||||
`SELECT e.id, e.message, e.severity, e.event_time, e.topic, e.device_id,
|
||||
d.name as device_name
|
||||
FROM events e
|
||||
LEFT JOIN devices d ON d.id = e.device_id
|
||||
WHERE e.message ILIKE $1 OR e.topic ILIKE $1 OR d.name ILIKE $1
|
||||
ORDER BY e.event_time DESC
|
||||
LIMIT 6`,
|
||||
[like]
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({ devices, clients, events });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/settings
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const settings = await query(`SELECT key, value FROM app_settings ORDER BY key`);
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const s of settings as { key: string; value: unknown }[]) {
|
||||
map[s.key] = s.value;
|
||||
}
|
||||
res.json(map);
|
||||
});
|
||||
|
||||
// PUT /api/settings
|
||||
router.put('/', requireAdmin, async (req: Request, res: Response) => {
|
||||
const updates = req.body as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
await query(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, JSON.stringify(value)]
|
||||
);
|
||||
}
|
||||
res.json({ message: 'Settings updated' });
|
||||
});
|
||||
|
||||
// GET /api/settings/users
|
||||
router.get('/users', requireAdmin, async (_req: Request, res: Response) => {
|
||||
const users = await query(
|
||||
`SELECT id, username, role, created_at FROM users ORDER BY username`
|
||||
);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// POST /api/settings/users
|
||||
router.post('/users', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { username, password, role = 'viewer' } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'username and password required' });
|
||||
}
|
||||
const validRoles = ['admin', 'operator', 'viewer'];
|
||||
if (!validRoles.includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be admin, operator, or viewer' });
|
||||
}
|
||||
|
||||
const existing = await queryOne(`SELECT id FROM users WHERE username = $1`, [username]);
|
||||
if (existing) return res.status(409).json({ error: 'Username already exists' });
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const rows = await query<{ id: number }>(
|
||||
`INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id`,
|
||||
[username, hash, role]
|
||||
);
|
||||
const created = await queryOne(
|
||||
`SELECT id, username, role, created_at FROM users WHERE id = $1`,
|
||||
[rows[0].id]
|
||||
);
|
||||
return res.status(201).json(created);
|
||||
});
|
||||
|
||||
// PUT /api/settings/users/:id - update role and/or reset password
|
||||
router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { role, password } = req.body;
|
||||
|
||||
const existing = await queryOne<{ id: number; role: string }>(
|
||||
`SELECT id, role FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
if (!existing) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Cannot demote yourself
|
||||
if (userId === req.user!.userId && role && role !== 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot change your own role away from admin' });
|
||||
}
|
||||
|
||||
const validRoles = ['admin', 'operator', 'viewer'];
|
||||
if (role && !validRoles.includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
if (role) {
|
||||
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, userId]);
|
||||
}
|
||||
if (password) {
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
|
||||
}
|
||||
|
||||
const updated = await queryOne(
|
||||
`SELECT id, username, role, created_at FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /api/settings/users/:id
|
||||
router.delete('/users/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
if (parseInt(req.params.id) === req.user!.userId) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
const result = await query(`DELETE FROM users WHERE id = $1 RETURNING id`, [req.params.id]);
|
||||
if (!result.length) return res.status(404).json({ error: 'User not found' });
|
||||
return res.json({ message: 'User deleted' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/switches — all switch devices with port statistics
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const switches = await query(`
|
||||
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
|
||||
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
|
||||
COUNT(i.id) FILTER (WHERE i.running = true AND i.disabled = false) AS ports_up,
|
||||
COUNT(i.id) FILTER (WHERE i.running = false AND i.disabled = false) AS ports_down,
|
||||
COUNT(i.id) FILTER (WHERE i.disabled = true) AS ports_disabled,
|
||||
COUNT(i.id) AS ports_total
|
||||
FROM devices d
|
||||
LEFT JOIN interfaces i ON i.device_id = d.id
|
||||
AND (i.type ILIKE 'ether%' OR i.type ILIKE 'sfp%'
|
||||
OR i.name ILIKE 'ether%' OR i.name ILIKE 'sfp%')
|
||||
WHERE d.device_type = 'switch'
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
res.json(switches);
|
||||
});
|
||||
|
||||
// GET /api/switches/lldp — LLDP enabled/disabled status per online switch
|
||||
router.get('/lldp', async (_req: Request, res: Response) => {
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
const lldp = await collector.getLldpEnabled();
|
||||
return { id: sw.id, name: sw.name, ip_address: sw.ip_address, ...lldp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: switches[i].id,
|
||||
name: switches[i].name,
|
||||
ip_address: switches[i].ip_address,
|
||||
enabled: null as boolean | null,
|
||||
protocol: null as string | null,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/switches/lldp — enable or disable LLDP on all online switches
|
||||
router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: '"enabled" (boolean) is required' });
|
||||
}
|
||||
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setLldpEnabled(enabled);
|
||||
return { id: sw.id, name: sw.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: switches[i].id,
|
||||
name: switches[i].name,
|
||||
success: false,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
applied: statuses.filter(s => s.success).length,
|
||||
total: switches.length,
|
||||
results: statuses,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/switches/snmp — SNMP config/status per online switch
|
||||
router.get('/snmp', async (_req: Request, res: Response) => {
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
const snmp = await collector.getSnmpConfig();
|
||||
return { id: sw.id, name: sw.name, ip_address: sw.ip_address, ...snmp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: switches[i].id, name: switches[i].name, ip_address: switches[i].ip_address,
|
||||
enabled: null as boolean | null, error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/switches/snmp — apply SNMP config to all online switches
|
||||
router.put('/snmp', requireWrite, async (req: Request, res: Response) => {
|
||||
const config = req.body as {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
};
|
||||
if (!config.community_name || !config.version) {
|
||||
return res.status(400).json({ error: 'community_name and version are required' });
|
||||
}
|
||||
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setSnmpConfig(config);
|
||||
return { id: sw.id, name: sw.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return { id: switches[i].id, name: switches[i].name, success: false, error: (r.reason as Error).message };
|
||||
});
|
||||
|
||||
return res.json({ applied: statuses.filter(s => s.success).length, total: switches.length, results: statuses });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { PollerService } from '../services/PollerService';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
let pollerService: PollerService | null = null;
|
||||
export function setPollerService(p: PollerService): void {
|
||||
pollerService = p;
|
||||
}
|
||||
|
||||
// GET /api/topology
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
// Protocol priority: lldp (point-to-point) > cdp > mndp (L2 broadcast, can be indirect)
|
||||
const PROTO_RANK: Record<string, number> = { lldp: 0, cdp: 1, mndp: 2 };
|
||||
const protoRank = (p: string | null) => PROTO_RANK[p ?? ''] ?? 3;
|
||||
|
||||
interface LinkRow {
|
||||
id: number;
|
||||
from_device_id: number | null;
|
||||
from_interface: string | null;
|
||||
to_interface: string | null;
|
||||
to_device_id: number | null;
|
||||
neighbor_address: string | null;
|
||||
neighbor_identity: string | null;
|
||||
neighbor_platform: string | null;
|
||||
neighbor_mac: string | null;
|
||||
stp_role: string | null;
|
||||
stp_state: string | null;
|
||||
bridge_name: string | null;
|
||||
neighbor_caps: string | null;
|
||||
link_type: string | null;
|
||||
discovered_by: string | null;
|
||||
from_device_name: string | null;
|
||||
to_device_name: string | null;
|
||||
}
|
||||
|
||||
const [devices, allLinks] = await Promise.all([
|
||||
query(
|
||||
`SELECT id, name, ip_address, model, device_type, status, ros_version
|
||||
FROM devices ORDER BY name ASC`
|
||||
),
|
||||
query<LinkRow>(
|
||||
`SELECT tl.*,
|
||||
fd.name as from_device_name, fd.ip_address as from_device_ip,
|
||||
td.name as to_device_name, td.ip_address as to_device_ip
|
||||
FROM topology_links tl
|
||||
LEFT JOIN devices fd ON fd.id = tl.from_device_id
|
||||
LEFT JOIN devices td ON td.id = tl.to_device_id
|
||||
ORDER BY tl.discovered_at DESC`
|
||||
),
|
||||
]);
|
||||
|
||||
// Deduplicate: for each (from_device, neighbor) pair keep only the highest-priority
|
||||
// protocol link. This removes MNDP duplicates when the same neighbor was also found
|
||||
// via LLDP or CDP on the same or a better-quality path.
|
||||
const bestLinks = new Map<string, LinkRow>();
|
||||
for (const link of allLinks as LinkRow[]) {
|
||||
if (!link.from_device_id) continue;
|
||||
const neighborKey = link.to_device_id
|
||||
? String(link.to_device_id)
|
||||
: (link.neighbor_mac || link.neighbor_address || link.neighbor_identity || '');
|
||||
const key = `${link.from_device_id}::${neighborKey}`;
|
||||
const existing = bestLinks.get(key);
|
||||
if (!existing || protoRank(link.link_type) < protoRank(existing.link_type)) {
|
||||
bestLinks.set(key, link);
|
||||
}
|
||||
}
|
||||
const links = Array.from(bestLinks.values());
|
||||
|
||||
// Build synthetic external nodes from unresolved neighbor entries
|
||||
const externalMap = new Map<string, {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
platform: string;
|
||||
mac: string;
|
||||
caps: string;
|
||||
}>();
|
||||
|
||||
for (const link of links) {
|
||||
if (link.to_device_id) continue; // resolved — skip
|
||||
const key = link.neighbor_address || link.neighbor_mac || link.neighbor_identity || '';
|
||||
if (!key) continue;
|
||||
if (!externalMap.has(key)) {
|
||||
const safeId = key.replace(/[.: ]/g, '');
|
||||
externalMap.set(key, {
|
||||
id: `ext-${safeId}`,
|
||||
name: link.neighbor_identity || link.neighbor_address || 'Unknown',
|
||||
address: link.neighbor_address || '',
|
||||
platform: link.neighbor_platform || '',
|
||||
mac: link.neighbor_mac || '',
|
||||
caps: link.neighbor_caps || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ devices, links, externalNodes: Array.from(externalMap.values()) });
|
||||
});
|
||||
|
||||
// POST /api/topology/discover - trigger neighbor discovery on all devices
|
||||
router.post('/discover', requireWrite, async (_req: Request, res: Response) => {
|
||||
const devices = await query<{ id: number }>(`SELECT id FROM devices WHERE status='online'`);
|
||||
|
||||
if (pollerService) {
|
||||
for (const d of devices) {
|
||||
await pollerService.scheduleDeviceSync(d.id, 'slow');
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: `Discovery triggered for ${devices.length} device(s)` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,704 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { lookupVendor } from '../utils/oui';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getAP(id: number): Promise<DeviceRow | null> {
|
||||
const rows = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE id = $1 AND device_type = 'wireless_ap'`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ─── Bulk SSID deployment ─────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/wireless/ssid/bulk — create the same SSID on multiple APs simultaneously
|
||||
router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => {
|
||||
const { apIds, ...ssidBody } = req.body as { apIds: number[] } & Record<string, unknown>;
|
||||
|
||||
if (!Array.isArray(apIds) || apIds.length === 0) {
|
||||
return res.status(400).json({ error: '"apIds" must be a non-empty array' });
|
||||
}
|
||||
if (!ssidBody.ssid) {
|
||||
return res.status(400).json({ error: '"ssid" is required' });
|
||||
}
|
||||
|
||||
const placeholders = apIds.map((_, i) => `$${i + 1}`).join(',');
|
||||
const aps = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE id IN (${placeholders}) AND device_type = 'wireless_ap'`,
|
||||
apIds
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
aps.map(async (ap) => {
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const pkg = await collector.detectWifiPackage();
|
||||
const autoName = await collector.getNextInterfaceName();
|
||||
|
||||
// Normalize mode to the correct value for this AP's package type
|
||||
let resolvedMode = String(ssidBody.mode || 'ap-bridge');
|
||||
if (pkg === 'wifi' && (resolvedMode === 'ap-bridge' || resolvedMode === 'bridge')) {
|
||||
resolvedMode = 'ap';
|
||||
} else if (pkg !== 'wifi' && resolvedMode === 'ap') {
|
||||
resolvedMode = 'ap-bridge';
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
name: autoName,
|
||||
ssid: String(ssidBody.ssid),
|
||||
mode: resolvedMode,
|
||||
disabled: ssidBody.disabled ? 'yes' : 'no',
|
||||
};
|
||||
const fieldMap: Record<string, string> = {
|
||||
band: 'band', frequency: 'frequency', channel_width: 'channel-width',
|
||||
tx_power: 'tx-power', tx_power_mode: 'tx-power-mode',
|
||||
antenna_gain: 'antenna-gain', country: 'country', installation: 'installation',
|
||||
security_profile: 'security-profile', passphrase: 'passphrase',
|
||||
};
|
||||
for (const [key, rosKey] of Object.entries(fieldMap)) {
|
||||
if (ssidBody[key]) params[rosKey] = String(ssidBody[key]);
|
||||
}
|
||||
if (ssidBody.authentication_types) {
|
||||
params['authentication-types'] = Array.isArray(ssidBody.authentication_types)
|
||||
? (ssidBody.authentication_types as string[]).join(',')
|
||||
: String(ssidBody.authentication_types);
|
||||
}
|
||||
await collector.addWirelessInterface(params);
|
||||
await collector.collectWirelessInterfaces();
|
||||
return { id: ap.id, name: ap.name as string, ok: true };
|
||||
} catch (err) {
|
||||
return { id: ap.id, name: ap.name as string, ok: false, error: (err as Error).message };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const results = settled.map(r =>
|
||||
r.status === 'fulfilled' ? r.value : { id: 0, name: 'unknown', ok: false, error: String(r.reason) }
|
||||
);
|
||||
|
||||
return res.json({ results });
|
||||
});
|
||||
|
||||
// ─── Section overview ─────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless — all wireless_ap devices with aggregated stats
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const aps = await query(`
|
||||
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
|
||||
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
|
||||
COUNT(DISTINCT wi.id) AS radio_count,
|
||||
COALESCE(SUM(wi.registered_clients), 0) AS client_count,
|
||||
COUNT(DISTINCT wi.id) FILTER (WHERE wi.ssid IS NOT NULL AND wi.disabled = false) AS ssid_count
|
||||
FROM devices d
|
||||
LEFT JOIN wireless_interfaces wi ON wi.device_id = d.id
|
||||
WHERE d.device_type = 'wireless_ap'
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
res.json(aps);
|
||||
});
|
||||
|
||||
// ─── Wireless interfaces (SSIDs) ──────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless/:id/bridges — bridge list + current port memberships
|
||||
router.get('/:id/bridges', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const [bridges, ports] = await Promise.all([
|
||||
collector.getBridges(),
|
||||
collector.getBridgePorts(),
|
||||
]);
|
||||
return res.json({ bridges, ports });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/interfaces — live from device
|
||||
router.get('/:id/interfaces', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const ifaces = await collector.getWirelessInterfaces();
|
||||
return res.json(ifaces);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/wireless/:id/interfaces — create new SSID/virtual AP
|
||||
router.post('/:id/interfaces', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const {
|
||||
ssid, mode = 'ap-bridge', band, frequency, channel_width,
|
||||
tx_power, tx_power_mode, antenna_gain, country, installation,
|
||||
security_profile, disabled = false,
|
||||
passphrase, authentication_types,
|
||||
bridge, vlan_id,
|
||||
// advanced
|
||||
master_interface,
|
||||
} = req.body as Record<string, string | boolean | number | string[] | undefined>;
|
||||
|
||||
if (!ssid) {
|
||||
return res.status(400).json({ error: '"ssid" is required' });
|
||||
}
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
|
||||
// Auto-generate the next available interface name (e.g. wifi3, wifi4 …)
|
||||
const autoName = await collector.getNextInterfaceName();
|
||||
|
||||
const params: Record<string, string> = {
|
||||
name: autoName,
|
||||
ssid: String(ssid),
|
||||
mode: String(mode),
|
||||
disabled: disabled ? 'yes' : 'no',
|
||||
};
|
||||
if (band) params['band'] = String(band);
|
||||
if (frequency) params['frequency'] = String(frequency);
|
||||
if (channel_width) params['channel-width'] = String(channel_width);
|
||||
if (tx_power) params['tx-power'] = String(tx_power);
|
||||
if (tx_power_mode) params['tx-power-mode'] = String(tx_power_mode);
|
||||
if (antenna_gain !== undefined) params['antenna-gain'] = String(antenna_gain);
|
||||
if (country) params['country'] = String(country);
|
||||
if (installation) params['installation'] = String(installation);
|
||||
if (security_profile) params['security-profile'] = String(security_profile);
|
||||
if (passphrase) params['passphrase'] = String(passphrase);
|
||||
if (authentication_types) {
|
||||
params['authentication-types'] = Array.isArray(authentication_types)
|
||||
? authentication_types.join(',')
|
||||
: String(authentication_types);
|
||||
}
|
||||
if (master_interface) params['master-interface'] = String(master_interface);
|
||||
|
||||
await collector.addWirelessInterface(params);
|
||||
// Bridge port membership — must be done after interface exists
|
||||
if (bridge) {
|
||||
await collector.setInterfaceBridge(
|
||||
autoName,
|
||||
String(bridge),
|
||||
vlan_id ? Number(vlan_id) : undefined,
|
||||
);
|
||||
// Ensure the VLAN ID is also in the bridge VLAN table (untagged)
|
||||
if (vlan_id) {
|
||||
await collector.ensureVlanMembership(autoName, Number(vlan_id));
|
||||
}
|
||||
}
|
||||
// Refresh cache
|
||||
await collector.collectWirelessInterfaces();
|
||||
return res.status(201).json({ ok: true, name: autoName });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/wireless/:id/interfaces/:name — update SSID/radio config
|
||||
router.put('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const ifaceName = req.params.name;
|
||||
const body = req.body as Record<string, string | boolean | number | undefined>;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
const map: Record<string, string> = {
|
||||
ssid: 'ssid', mode: 'mode', band: 'band', frequency: 'frequency',
|
||||
channel_width: 'channel-width', tx_power: 'tx-power',
|
||||
tx_power_mode: 'tx-power-mode', antenna_gain: 'antenna-gain',
|
||||
country: 'country', installation: 'installation',
|
||||
security_profile: 'security-profile', master_interface: 'master-interface',
|
||||
passphrase: 'passphrase',
|
||||
};
|
||||
|
||||
for (const [key, rosKey] of Object.entries(map)) {
|
||||
if (body[key] !== undefined) params[rosKey] = String(body[key]);
|
||||
}
|
||||
if (body.disabled !== undefined) params['disabled'] = body.disabled ? 'yes' : 'no';
|
||||
if (body.authentication_types !== undefined) {
|
||||
params['authentication-types'] = Array.isArray(body.authentication_types)
|
||||
? (body.authentication_types as string[]).join(',')
|
||||
: String(body.authentication_types);
|
||||
}
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setWirelessInterface(ifaceName, params);
|
||||
// Bridge port membership — only touched when 'bridge' key is present in the request
|
||||
if (body.bridge !== undefined) {
|
||||
const bridgeName = body.bridge ? String(body.bridge) : null;
|
||||
const pvid = body.vlan_id ? Number(body.vlan_id) : undefined;
|
||||
await collector.setInterfaceBridge(ifaceName, bridgeName, pvid);
|
||||
// Ensure the VLAN ID is in the bridge VLAN table (untagged)
|
||||
if (bridgeName && body.vlan_id) {
|
||||
await collector.ensureVlanMembership(ifaceName, Number(body.vlan_id));
|
||||
}
|
||||
}
|
||||
await collector.collectWirelessInterfaces();
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/wireless/:id/interfaces/:name
|
||||
router.delete('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.removeWirelessInterface(req.params.name);
|
||||
await query(
|
||||
`DELETE FROM wireless_interfaces WHERE device_id=$1 AND name=$2`,
|
||||
[ap.id, req.params.name]
|
||||
);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Security Profiles ────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless/:id/security-profiles — live from device
|
||||
router.get('/:id/security-profiles', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const profiles = await collector.getSecurityProfilesLive();
|
||||
return res.json(profiles);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/wireless/:id/security-profiles
|
||||
router.post('/:id/security-profiles', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const {
|
||||
name, mode = 'dynamic-keys',
|
||||
authentication_types, passphrase,
|
||||
unicast_ciphers, group_ciphers, management_protection = 'disabled',
|
||||
management_protection_key,
|
||||
} = req.body as Record<string, string | string[] | undefined>;
|
||||
|
||||
if (!name) return res.status(400).json({ error: '"name" is required' });
|
||||
|
||||
const params: Record<string, string> = {
|
||||
name: String(name),
|
||||
mode: String(mode),
|
||||
'management-protection': String(management_protection),
|
||||
};
|
||||
|
||||
if (authentication_types) {
|
||||
params['authentication-types'] = Array.isArray(authentication_types)
|
||||
? authentication_types.join(',')
|
||||
: String(authentication_types);
|
||||
}
|
||||
if (unicast_ciphers) {
|
||||
params['unicast-ciphers'] = Array.isArray(unicast_ciphers)
|
||||
? unicast_ciphers.join(',')
|
||||
: String(unicast_ciphers);
|
||||
}
|
||||
if (group_ciphers) {
|
||||
params['group-ciphers'] = Array.isArray(group_ciphers)
|
||||
? group_ciphers.join(',')
|
||||
: String(group_ciphers);
|
||||
}
|
||||
if (passphrase) params['passphrase'] = String(passphrase);
|
||||
if (management_protection_key) params['management-protection-key'] = String(management_protection_key);
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.addSecurityProfile(params);
|
||||
await collector.collectSecurityProfiles();
|
||||
return res.status(201).json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/wireless/:id/security-profiles/:name
|
||||
router.put('/:id/security-profiles/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const profileName = req.params.name;
|
||||
const body = req.body as Record<string, string | string[] | undefined>;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (body.mode) params['mode'] = String(body.mode);
|
||||
if (body.management_protection) params['management-protection'] = String(body.management_protection);
|
||||
if (body.management_protection_key) params['management-protection-key'] = String(body.management_protection_key);
|
||||
if (body.passphrase) params['passphrase'] = String(body.passphrase);
|
||||
|
||||
if (body.authentication_types !== undefined) {
|
||||
params['authentication-types'] = Array.isArray(body.authentication_types)
|
||||
? body.authentication_types.join(',')
|
||||
: String(body.authentication_types);
|
||||
}
|
||||
if (body.unicast_ciphers !== undefined) {
|
||||
params['unicast-ciphers'] = Array.isArray(body.unicast_ciphers)
|
||||
? body.unicast_ciphers.join(',')
|
||||
: String(body.unicast_ciphers);
|
||||
}
|
||||
if (body.group_ciphers !== undefined) {
|
||||
params['group-ciphers'] = Array.isArray(body.group_ciphers)
|
||||
? body.group_ciphers.join(',')
|
||||
: String(body.group_ciphers);
|
||||
}
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setSecurityProfile(profileName, params);
|
||||
await collector.collectSecurityProfiles();
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/wireless/:id/security-profiles/:name
|
||||
router.delete('/:id/security-profiles/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.removeSecurityProfile(req.params.name);
|
||||
await query(
|
||||
`DELETE FROM wireless_security_profiles WHERE device_id=$1 AND name=$2`,
|
||||
[ap.id, req.params.name]
|
||||
);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Radio monitoring / diagnostics ──────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless/:id/radios — hardware radio capabilities (wifi package only)
|
||||
router.get('/:id/radios', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const radios = await collector.getWifiRadioInfo();
|
||||
return res.json(radios);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/registration-table — connected wireless clients
|
||||
router.get('/:id/registration-table', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const table = await collector.getWirelessRegistrationTable();
|
||||
return res.json(table);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/monitor/:iface — real-time radio monitor snapshot
|
||||
router.get('/:id/monitor/:iface', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const data = await collector.getWirelessMonitor(req.params.iface);
|
||||
return res.json(data[0] ?? {});
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/scan/:iface — scan for nearby APs (5 s)
|
||||
router.get('/:id/scan/:iface', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const results = await collector.scanWireless(req.params.iface);
|
||||
return res.json(results);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate raw RouterOS spectral-scan rows (35 k+ per 10 s scan) into one point
|
||||
// per unique frequency. Each row has: freq (MHz, string), magn (dBm, string),
|
||||
// peak (dBm, string). We average magn and keep the highest peak per frequency.
|
||||
function 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,
|
||||
}));
|
||||
}
|
||||
|
||||
// POST /api/wireless/:id/spectral-scan/:iface — run an on-demand spectral scan,
|
||||
// save the aggregated result and return it immediately.
|
||||
router.post('/:id/spectral-scan/:iface', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const rows = await collector.collectSpectralScan(req.params.iface);
|
||||
if (rows.length === 0) {
|
||||
return res.status(422).json({ error: 'No spectral data returned — interface may not support spectral scan' });
|
||||
}
|
||||
const aggregated = aggregateSpectralRows(rows);
|
||||
const saved = await query<{ id: number; scanned_at: string }>(
|
||||
`INSERT INTO spectral_scan_data (device_id, interface_name, data, scan_type)
|
||||
VALUES ($1, $2, $3, 'manual')
|
||||
RETURNING id, scanned_at`,
|
||||
[ap.id, req.params.iface, JSON.stringify(aggregated)]
|
||||
);
|
||||
return res.json({ id: saved[0].id, scanned_at: saved[0].scanned_at, data: aggregated });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/spectral-history/:iface?limit=5 — return last N scans from DB
|
||||
router.get('/:id/spectral-history/:iface', async (req: Request, res: Response) => {
|
||||
const deviceId = parseInt(req.params.id);
|
||||
const iface = req.params.iface;
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '5'), 10), 20);
|
||||
|
||||
const scans = await query<{ id: number; scanned_at: string; scan_type: string; data: unknown }>(
|
||||
`SELECT id, scanned_at, scan_type, data
|
||||
FROM spectral_scan_data
|
||||
WHERE device_id = $1 AND interface_name = $2
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT $3`,
|
||||
[deviceId, iface, limit]
|
||||
);
|
||||
return res.json(scans);
|
||||
});
|
||||
|
||||
// ─── AP scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface APBandEntry {
|
||||
bssid: string; vendor: string; signal: number;
|
||||
freq: number; band: string; channel_width: string;
|
||||
}
|
||||
interface APNetworkEntry {
|
||||
ssid: string; security: string; hidden: boolean; entries: APBandEntry[];
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function aggregateAPScanRows(
|
||||
allRows: { iface: string; rows: Record<string, string>[] }[]
|
||||
): APNetworkEntry[] {
|
||||
const byKey = new Map<string, APNetworkEntry>();
|
||||
|
||||
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);
|
||||
// security: prefer explicit field; if auth-types present but no explicit security, call it WPA
|
||||
const security = row['security']
|
||||
|| (row['authentication-types'] ? '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;
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/wireless/:id/ap-scan — scan all interfaces, combine results, save + return
|
||||
router.post('/:id/ap-scan', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
// Only scan physical radios (master=true). Virtual APs (master=false, e.g. extra SSIDs)
|
||||
// share a radio with their master interface and cannot initiate scans independently.
|
||||
const ifaces = await query<{ name: string }>(
|
||||
`SELECT name FROM wireless_interfaces
|
||||
WHERE device_id = $1
|
||||
AND disabled = FALSE
|
||||
AND (config_json->>'master' IS NULL OR config_json->>'master' != 'false')`,
|
||||
[ap.id]
|
||||
);
|
||||
if (ifaces.length === 0) {
|
||||
return res.status(422).json({ error: 'No active wireless interfaces found' });
|
||||
}
|
||||
|
||||
// Use a separate TCP connection per radio — a 10 s streaming scan can leave
|
||||
// residual bytes in the socket that corrupt a subsequent scan on the same connection.
|
||||
const allRows: { iface: string; rows: Record<string, string>[] }[] = [];
|
||||
for (const iface of ifaces) {
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const rows = await collector.scanWireless(iface.name, 10_000);
|
||||
if (rows.length > 0) allRows.push({ iface: iface.name, rows });
|
||||
} catch {
|
||||
// ignore per-interface errors; continue to next radio
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
if (allRows.length === 0) {
|
||||
return res.status(422).json({ error: 'No AP scan data returned' });
|
||||
}
|
||||
|
||||
try {
|
||||
const aggregated = aggregateAPScanRows(allRows);
|
||||
const saved = await query<{ id: number; scanned_at: string }>(
|
||||
`INSERT INTO ap_scan_data (device_id, data, scan_type)
|
||||
VALUES ($1, $2, 'manual') RETURNING id, scanned_at`,
|
||||
[ap.id, JSON.stringify(aggregated)]
|
||||
);
|
||||
return res.json({ id: saved[0].id, scanned_at: saved[0].scanned_at, data: aggregated });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/ap-scan-history?limit=5 — last N saved scans
|
||||
router.get('/:id/ap-scan-history', async (req: Request, res: Response) => {
|
||||
const deviceId = parseInt(req.params.id);
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '5'), 10), 20);
|
||||
|
||||
const scans = await query<{ id: number; scanned_at: string; scan_type: string; data: unknown }>(
|
||||
`SELECT id, scanned_at, scan_type, data
|
||||
FROM ap_scan_data
|
||||
WHERE device_id = $1
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT $2`,
|
||||
[deviceId, limit]
|
||||
);
|
||||
return res.json(scans);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const key = process.env.ENCRYPTION_KEY || 'defaultkey32byteslongencryptkey!';
|
||||
// Ensure exactly 32 bytes via SHA-256 hash if needed
|
||||
if (key.length === 32) {
|
||||
return Buffer.from(key, 'utf8');
|
||||
}
|
||||
return crypto.createHash('sha256').update(key).digest();
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv:tag:encrypted (all hex)
|
||||
return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':');
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const key = getKey();
|
||||
const parts = encryptedText.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted text format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const tag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = Buffer.from(parts[2], 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Local IEEE OUI database lookup.
|
||||
* Downloads the official IEEE MA-L CSV on first use, caches it for 30 days,
|
||||
* then serves all vendor lookups from memory — no rate limits, no external calls
|
||||
* per client.
|
||||
*/
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const CACHE_FILE = '/tmp/oui-ieee.json';
|
||||
const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const IEEE_CSV_URL = 'https://standards-oui.ieee.org/oui/oui.csv';
|
||||
|
||||
let db: Map<string, string> | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
export function initOuiDatabase(): Promise<void> {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = _load();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function lookupVendor(mac: string): string {
|
||||
if (!db) return '';
|
||||
const oui = mac.replace(/[:\-.]/g, '').substring(0, 6).toUpperCase();
|
||||
return db.get(oui) ?? '';
|
||||
}
|
||||
|
||||
async function _load(): Promise<void> {
|
||||
// Try valid cache first
|
||||
try {
|
||||
const stat = fs.statSync(CACHE_FILE);
|
||||
if (Date.now() - stat.mtimeMs < CACHE_MAX_AGE_MS) {
|
||||
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as Record<string, string>;
|
||||
const map = new Map(Object.entries(raw));
|
||||
if (map.size > 10_000) {
|
||||
db = map;
|
||||
console.log(`OUI database loaded from cache: ${db.size} entries`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* cache miss */ }
|
||||
|
||||
// Download fresh from IEEE
|
||||
try {
|
||||
console.log('Downloading IEEE OUI database…');
|
||||
const csv = await _fetch(IEEE_CSV_URL);
|
||||
const map = _parseCsv(csv);
|
||||
if (map.size > 10_000) {
|
||||
db = map;
|
||||
console.log(`OUI database downloaded: ${db.size} entries`);
|
||||
// Persist cache
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of map) obj[k] = v;
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj));
|
||||
} else {
|
||||
console.warn('OUI: Downloaded file too small, ignoring');
|
||||
db = new Map();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`OUI: Download failed (${(err as Error).message}). Vendor lookups will be empty.`);
|
||||
db = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function _parseCsv(csv: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of csv.split('\n')) {
|
||||
// IEEE CSV: Registry,Assignment,Organization Name,Organization Address
|
||||
// Quoted fields may contain commas, e.g. "Cisco Systems, Inc"
|
||||
const fields = _csvSplit(line);
|
||||
if (fields[0]?.trim() !== 'MA-L') continue;
|
||||
const oui = fields[1]?.trim().toUpperCase();
|
||||
const name = fields[2]?.trim();
|
||||
if (oui?.length === 6 && name) map.set(oui, name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function _csvSplit(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let cur = '';
|
||||
let inQuote = false;
|
||||
for (const ch of line) {
|
||||
if (ch === '"') { inQuote = !inQuote; }
|
||||
else if (ch === ',' && !inQuote) { fields.push(cur); cur = ''; }
|
||||
else { cur += ch; }
|
||||
}
|
||||
fields.push(cur);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function _fetch(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: 60_000 }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Read the backend server's kernel ARP cache from /proc/net/arp.
|
||||
* Returns a MAC → IPv4 map of all currently cached entries.
|
||||
* Works on Linux; on macOS Docker Desktop the container is NATted so
|
||||
* this will only show Docker-internal bridge entries.
|
||||
*/
|
||||
async function readKernelArpCache(): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {};
|
||||
try {
|
||||
const content = await readFile('/proc/net/arp', 'utf8');
|
||||
for (const line of content.split('\n').slice(1)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
// Format: IP HW-type Flags HW-address Mask Device
|
||||
if (parts.length >= 4) {
|
||||
const ip = parts[0];
|
||||
const mac = parts[3].toLowerCase();
|
||||
if (ip && mac && mac !== '00:00:00:00:00:00' && ip !== '0.0.0.0') {
|
||||
map[mac] = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /proc/net/arp not available (non-Linux or permission denied)
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run arp-scan on all local network interfaces to actively discover
|
||||
* MAC → IPv4 mappings on the same subnet as the backend container.
|
||||
*
|
||||
* Requires: arp-scan installed + NET_RAW capability (set via docker-compose
|
||||
* cap_add / setcap on the binary). Fails gracefully if unavailable.
|
||||
*
|
||||
* Note: On macOS Docker Desktop the container is NATted inside a Linux VM,
|
||||
* so arp-scan will only see other Docker containers, NOT physical network hosts.
|
||||
* On a Linux host with bridge or macvlan Docker networking this WILL find
|
||||
* devices on the same physical subnet.
|
||||
*/
|
||||
async function runArpScan(): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {};
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'arp-scan',
|
||||
['--localnet', '--quiet', '--retry=2'],
|
||||
{ timeout: 12000 }
|
||||
);
|
||||
for (const line of stdout.split('\n')) {
|
||||
// Output lines: "<IP>\t<MAC>\t<description>"
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2 && /^\d+\.\d+\.\d+\.\d+$/.test(parts[0])) {
|
||||
const ip = parts[0];
|
||||
const mac = parts[1].toLowerCase();
|
||||
if (ip && mac) map[mac] = ip;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// arp-scan not installed, lacks permissions, or timed out
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combined MAC → IPv4 map using the server's own ARP data.
|
||||
* Called once per collectNeighbors() run to avoid re-scanning per neighbour.
|
||||
*/
|
||||
export async function buildServerArpMap(): Promise<Record<string, string>> {
|
||||
const [passive, active] = await Promise.all([
|
||||
readKernelArpCache(),
|
||||
runArpScan(),
|
||||
]);
|
||||
// Passive cache takes precedence over scan (it's live kernel state)
|
||||
return { ...active, ...passive };
|
||||
}
|
||||
Reference in New Issue
Block a user