Files
MIKROTIK-RMM/backend/src/index.ts
T
2GT-RichandClaude Opus 4.8 9dfb8a5333 feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)
Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest
SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool,
DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits +
printable sheets; live guest sessions; walled garden.

Network intelligence: per-client device fingerprinting (vendor/hostname →
category) with a persisted per-client override on the detail page; rogue/neighbor
AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly
insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the
Operations engine.

Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with
pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and
scheduling; live per-device progress + cancel.

Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest,
Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired
from the alert pipeline + firmware orchestrator; scheduled email reports.

api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's
default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices
connect without manual cert trust. Fixes #57.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:58:02 -07:00

311 lines
11 KiB
TypeScript

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 {
setBulkAddPollerService,
startBulkAddWorker,
stopBulkAddWorker,
} from './services/DeviceBulkAddWorker';
import { netflowCollector } from './services/netflow/NetflowCollector';
import { verifyToken } from './middleware/auth';
import { decrypt } from './utils/crypto';
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
import authRoutes from './routes/auth';
import auditLogRoutes from './routes/auditLog';
import tagsRoutes from './routes/tags';
import maintenanceWindowsRoutes from './routes/maintenanceWindows';
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 guestWifiRoutes from './routes/guestWifi';
import firmwareRoutes from './routes/firmware';
import automationRoutes from './routes/automation';
import { reportService } from './services/ReportService';
import { firmwareOrchestrator } from './services/FirmwareOrchestrator';
import operationsRoutes from './routes/operations';
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 configTemplatesRoutes from './routes/configTemplates';
import configHistoryRoutes from './routes/configHistory';
import wirelessRoutes from './routes/wireless';
import networkServicesRoutes from './routes/networkServices';
import trafficAnalyticsRoutes from './routes/trafficAnalytics';
import credentialPresetsRoutes from './routes/credentialPresets';
import systemRoutes from './routes/system';
import { auditMiddleware } from './middleware/auditMiddleware';
const app = express();
// nginx sits exactly one hop in front; trust its X-Forwarded-For so req.ip is the real client IP
app.set('trust proxy', 1);
const httpServer = createServer(app);
const PORT = parseInt(process.env.PORT || '3001', 10);
// ─── Socket.io ────────────────────────────────────────────────────────────────
const io = new SocketServer(httpServer, {
cors: socketIoCorsOptions(),
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(corsMiddlewareOptions()));
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'));
}
app.use(auditMiddleware);
// ─── 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/operations', operationsRoutes);
app.use('/api/guest-wifi', guestWifiRoutes);
app.use('/api/firmware', firmwareRoutes);
app.use('/api/automation', automationRoutes);
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);
app.use('/api/traffic', trafficAnalyticsRoutes);
app.use('/api/credential-presets', credentialPresetsRoutes);
app.use('/api/audit-log', auditLogRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
app.use('/api/config-templates', configTemplatesRoutes);
app.use('/api/config-history', configHistoryRoutes);
app.use('/api/system', systemRoutes);
// ─── 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);
setBulkAddPollerService(pollerService);
setTopologyPoller(pollerService);
setClientsPoller(pollerService);
await pollerService.start();
await startBulkAddWorker();
// NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled)
await netflowCollector.start();
// Firmware rollout scheduler (starts rollouts whose scheduled_at has arrived)
firmwareOrchestrator.startScheduler();
// Scheduled report mailer (hourly check)
reportService.startScheduler();
// 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 stopBulkAddWorker();
await netflowCollector.stop();
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);
});