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>
311 lines
11 KiB
TypeScript
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);
|
|
});
|