Files
MIKROTIK-RMM/backend/src/index.ts
T
2GT-RichandClaude Opus 4.8 367034c2d6 feat: config history (diff/rollback) + friendly backup scheduling (v0.12.3-beta)
Configuration History:
- Per-device snapshots from the full RouterOS /export (.rsc) — config-only, so
  counters/operational state no longer cause phantom changes; deduped by content hash
- New Config History tab: snapshot timeline, readable line diff, on-demand Capture
  (with honest 'no changes' feedback), one-click rollback via the linked .rsc backup
- Snapshot and its backup are one artifact: badged 'Config Snapshot' on the Backups
  page, FK ON DELETE CASCADE keeps them in lockstep; delete is gated by a warning modal
- New config_drift alert event (off by default); periodic snapshot poll with
  configurable cadence/retention (config_snapshot_interval_min/_retention)
- New endpoints under /api/config-history (list/get/diff/capture/rollback/delete)

Scheduled Backups:
- Replaced the raw cron textbox in Settings with Frequency (Daily/Weekly/Monthly)
  + time + day pickers; cron is generated/parsed behind the scenes
- Fixed cronMatchesNow to evaluate day-of-month/month/day-of-week (it previously
  only checked minute/hour, so weekly/monthly schedules ran every day)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:01:58 -07:00

288 lines
10 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 { 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 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 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/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/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();
// 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 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);
});