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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4ff5ed805c
commit
367034c2d6
@@ -12,7 +12,8 @@ export type AlertEventType =
|
||||
| 'high_memory'
|
||||
| 'cert_expiry'
|
||||
| 'device_discovered'
|
||||
| 'firmware_update_available';
|
||||
| 'firmware_update_available'
|
||||
| 'config_drift';
|
||||
|
||||
export interface AlertContext {
|
||||
deviceId?: number;
|
||||
@@ -51,6 +52,7 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
cert_expiry: 'Certificate Expiring Soon',
|
||||
device_discovered: 'New Device Discovered',
|
||||
firmware_update_available: 'Firmware Update Available',
|
||||
config_drift: 'Configuration Changed',
|
||||
};
|
||||
|
||||
const EVENT_EMOJI: Record<string, string> = {
|
||||
@@ -63,6 +65,7 @@ const EVENT_EMOJI: Record<string, string> = {
|
||||
cert_expiry: '🔐',
|
||||
device_discovered: '🔍',
|
||||
firmware_update_available: '🔄',
|
||||
config_drift: '📝',
|
||||
};
|
||||
|
||||
export class AlertService {
|
||||
|
||||
@@ -25,19 +25,23 @@ export class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
async createBackup(device: BackupDevice, notes?: string, type: string = 'manual'): Promise<number> {
|
||||
/** Run `/export compact` over SSH and return the raw .rsc text (no file written). */
|
||||
async exportConfig(device: BackupDevice): Promise<string> {
|
||||
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
|
||||
);
|
||||
return this.sshExport(device.ip_address, device.ssh_port || 22, sshUser, sshPass);
|
||||
}
|
||||
|
||||
/** Persist already-fetched .rsc text as a backup file + DB row. Returns the backup id. */
|
||||
async createBackupFromContent(
|
||||
device: BackupDevice,
|
||||
content: string,
|
||||
notes?: string,
|
||||
type: string = 'manual'
|
||||
): Promise<number> {
|
||||
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);
|
||||
@@ -48,7 +52,7 @@ export class BackupService {
|
||||
fs.mkdirSync(deviceDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, exportContent, 'utf8');
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
const rows = await query<{ id: number }>(
|
||||
@@ -60,6 +64,11 @@ export class BackupService {
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
async createBackup(device: BackupDevice, notes?: string, type: string = 'manual'): Promise<number> {
|
||||
const exportContent = await this.exportConfig(device);
|
||||
return this.createBackupFromContent(device, exportContent, notes, type);
|
||||
}
|
||||
|
||||
async restoreBackup(backupId: number): Promise<void> {
|
||||
const backup = await query<{
|
||||
file_path: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { alertService } from './AlertService';
|
||||
|
||||
interface PollJob {
|
||||
deviceId: number;
|
||||
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan';
|
||||
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan' | 'configsnap';
|
||||
}
|
||||
|
||||
export class PollerService {
|
||||
@@ -73,6 +73,8 @@ export class PollerService {
|
||||
await this.slowQueue.add('device-spectral', jobData, { attempts: 1 });
|
||||
} else if (type === 'apscan') {
|
||||
await this.slowQueue.add('device-apscan', jobData, { attempts: 1 });
|
||||
} else if (type === 'configsnap') {
|
||||
await this.slowQueue.add('device-configsnap', jobData, { attempts: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,8 @@ export class PollerService {
|
||||
const apScanIntervalHours = (appSettings['ap_scan_interval_hours'] as number) || 24;
|
||||
const backupScheduleEnabled = appSettings['backup_schedule_enabled'] === true;
|
||||
const backupScheduleCron = (appSettings['backup_schedule_cron'] as string) || '0 2 * * *';
|
||||
const configSnapEnabled = appSettings['config_snapshot_enabled'] !== false;
|
||||
const configSnapIntervalMin = (appSettings['config_snapshot_interval_min'] as number) || 60;
|
||||
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status != 'disabled'`
|
||||
@@ -153,6 +157,20 @@ export class PollerService {
|
||||
await this.setTimestamp(apScanKey, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Config snapshot — capture config history & detect drift, user-configured
|
||||
// interval (default 60min). The snapshot itself dedups by content hash, so
|
||||
// this only stores a new row when the device config actually changed.
|
||||
if (configSnapEnabled) {
|
||||
const configKey = `poll:configsnap:${device.id}`;
|
||||
const lastConfig = await this.getTimestamp(configKey);
|
||||
if (now - lastConfig > configSnapIntervalMin * 60_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'configsnap');
|
||||
// Keep the gate key alive for the full interval (+ buffer) so the
|
||||
// snapshot honours the configured cadence rather than the default TTL.
|
||||
await this.setTimestamp(configKey, now, configSnapIntervalMin * 60 + 120);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse DNS enrichment — global, runs every 5 minutes when enabled
|
||||
@@ -235,12 +253,17 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if the 5-part cron expression matches the current minute/hour.
|
||||
// Supports: *, exact numbers, comma lists, ranges (a-b), and step values (*/n).
|
||||
// Returns true if the 5-part cron expression matches the current time.
|
||||
// Evaluates all five fields — minute, hour, day-of-month, month, day-of-week —
|
||||
// so weekly (e.g. `0 3 * * 0`) and monthly (`0 3 1 * *`) schedules fire only on
|
||||
// the right day, not every day. Supports: *, exact numbers, comma lists,
|
||||
// ranges (a-b), and step values (*/n). Day-of-month and day-of-week follow the
|
||||
// standard cron rule: when both are restricted the job runs if either matches;
|
||||
// when only one is restricted, only that one must match.
|
||||
private cronMatchesNow(cron: string): boolean {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return false;
|
||||
const [minuteField, hourField] = parts;
|
||||
const [minuteField, hourField, domField, monthField, dowField] = parts;
|
||||
const now = new Date();
|
||||
const matchField = (field: string, val: number): boolean => {
|
||||
if (field === '*') return true;
|
||||
@@ -257,7 +280,21 @@ export class PollerService {
|
||||
return Number(f) === val;
|
||||
});
|
||||
};
|
||||
return matchField(minuteField, now.getMinutes()) && matchField(hourField, now.getHours());
|
||||
|
||||
if (!matchField(minuteField, now.getMinutes())) return false;
|
||||
if (!matchField(hourField, now.getHours())) return false;
|
||||
if (!matchField(monthField, now.getMonth() + 1)) return false; // cron months are 1-12
|
||||
|
||||
// Day-of-month (1-31) and day-of-week (0-6, Sun=0). getDay() returns 0 for Sunday.
|
||||
const domRestricted = domField !== '*';
|
||||
const dowRestricted = dowField !== '*';
|
||||
const domMatch = matchField(domField, now.getDate());
|
||||
const dowMatch = matchField(dowField, now.getDay());
|
||||
let dayMatch: boolean;
|
||||
if (!domRestricted && !dowRestricted) dayMatch = true;
|
||||
else if (domRestricted && dowRestricted) dayMatch = domMatch || dowMatch;
|
||||
else dayMatch = domRestricted ? domMatch : dowMatch;
|
||||
return dayMatch;
|
||||
}
|
||||
|
||||
private async runScheduledBackups(): Promise<void> {
|
||||
@@ -366,10 +403,10 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
private async setTimestamp(key: string, ts: number): Promise<void> {
|
||||
private async setTimestamp(key: string, ts: number, ttlSec = 600): Promise<void> {
|
||||
try {
|
||||
const { redis } = await import('../config/redis');
|
||||
await redis.set(key, String(ts), 'EX', 600);
|
||||
await redis.set(key, String(ts), 'EX', ttlSec);
|
||||
} catch { /* redis unavailable — timestamp not cached */ }
|
||||
}
|
||||
|
||||
@@ -597,6 +634,21 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
private async processConfigSnapJob(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.snapshotConfig('scheduled');
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Config snapshot 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);
|
||||
@@ -604,6 +656,9 @@ export class PollerService {
|
||||
if (data.type === 'apscan') {
|
||||
return this.processApScanJob(data);
|
||||
}
|
||||
if (data.type === 'configsnap') {
|
||||
return this.processConfigSnapJob(data);
|
||||
}
|
||||
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { RouterOSClient } from './RouterOSClient';
|
||||
import { query, queryOne } from '../../config/database';
|
||||
import { getWriteApi } from '../../config/influxdb';
|
||||
@@ -5,6 +6,8 @@ import { Point } from '@influxdata/influxdb-client';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { lookupVendor } from '../../utils/oui';
|
||||
import { buildServerArpMap } from '../../utils/serverArp';
|
||||
import { BackupService } from '../BackupService';
|
||||
import { alertService } from '../AlertService';
|
||||
|
||||
/** DB column limits for topology_links (see migrate.ts); reject oversize rows instead of silent truncation. */
|
||||
const TOPOLOGY_LINK_LIMITS = {
|
||||
@@ -1061,36 +1064,150 @@ export class DeviceCollector {
|
||||
|
||||
// ─── Full config snapshot ─────────────────────────────────────────────────
|
||||
|
||||
async saveFullConfig(): Promise<void> {
|
||||
/**
|
||||
* Strip the volatile header RouterOS stamps onto every `/export` (a line like
|
||||
* `# 2026-06-06 14:26:09 by RouterOS 7.23.1`). Everything else in the export is
|
||||
* configuration, so what remains hashes stably across exports of an unchanged
|
||||
* device. We also trim trailing whitespace for a clean diff.
|
||||
*/
|
||||
private static normalizeRsc(rsc: string): string {
|
||||
return rsc
|
||||
.split('\n')
|
||||
.filter((line) => !/^# \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} by RouterOS/.test(line))
|
||||
.join('\n')
|
||||
.replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
/** Compact summary of an .rsc change as added/removed line counts. */
|
||||
private static summarizeRscDiff(oldText: string, newText: string): string {
|
||||
const counts = (text: string): Map<string, number> => {
|
||||
const m = new Map<string, number>();
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line) m.set(line, (m.get(line) ?? 0) + 1);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const oldC = counts(oldText);
|
||||
const newC = counts(newText);
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const [line, n] of newC) added += Math.max(0, n - (oldC.get(line) ?? 0));
|
||||
for (const [line, n] of oldC) removed += Math.max(0, n - (newC.get(line) ?? 0));
|
||||
if (!added && !removed) return 'Settings modified';
|
||||
const parts: string[] = [];
|
||||
if (added) parts.push(`+${added}`);
|
||||
if (removed) parts.push(`−${removed}`);
|
||||
return `${parts.join(' / ')} line${added + removed === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/** Drop snapshots beyond the retention count, removing any linked .rsc backups too. */
|
||||
private async pruneConfigSnapshots(): Promise<void> {
|
||||
const retRow = await queryOne<{ value: number }>(
|
||||
`SELECT value FROM app_settings WHERE key = 'config_snapshot_retention'`
|
||||
);
|
||||
const retention = typeof retRow?.value === 'number' && retRow.value > 0 ? retRow.value : 30;
|
||||
|
||||
const stale = await query<{ id: number; backup_id: number | null }>(
|
||||
`SELECT id, backup_id FROM device_configs
|
||||
WHERE device_id = $1 AND id NOT IN (
|
||||
SELECT id FROM device_configs WHERE device_id = $1 ORDER BY collected_at DESC LIMIT $2
|
||||
)`,
|
||||
[this.device.id, retention]
|
||||
);
|
||||
if (!stale.length) return;
|
||||
|
||||
const backupService = new BackupService();
|
||||
for (const row of stale) {
|
||||
// Deleting the backup cascades to the device_configs row (FK ON DELETE CASCADE).
|
||||
if (row.backup_id) {
|
||||
await backupService.deleteBackup(row.backup_id).catch(() => { /* best-effort */ });
|
||||
}
|
||||
}
|
||||
// Remove any rows that had no linked backup (cascade only covers linked ones).
|
||||
await query(`DELETE FROM device_configs WHERE id = ANY($1::int[])`, [stale.map((r) => r.id)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a config snapshot from the device's `/export` .rsc — the canonical,
|
||||
* config-only, restorable representation. Deduplicates by content hash (no-op
|
||||
* when nothing changed), stores the .rsc as a linked backup so the snapshot is
|
||||
* restorable, and fires a `config_drift` alert when the config changed.
|
||||
* Returns true when a new snapshot row was created, false on dedup/failure.
|
||||
*/
|
||||
async snapshotConfig(reason = 'sync'): Promise<boolean> {
|
||||
try {
|
||||
const [interfaces, vlans, routes, firewall, dhcp, dns] = await Promise.all([
|
||||
this.client.execute('/interface/print', { detail: '' }).catch(() => []),
|
||||
this.client.execute('/interface/bridge/vlan/print', { detail: '' }).catch(() => []),
|
||||
this.client.execute('/ip/route/print', { detail: '' }).catch(() => []),
|
||||
this.client.execute('/ip/firewall/filter/print', { detail: '' }).catch(() => []),
|
||||
this.client.execute('/ip/dhcp-server/print', { detail: '' }).catch(() => []),
|
||||
this.client.execute('/ip/dns/print', { detail: '' }).catch(() => []),
|
||||
]);
|
||||
const backupService = new BackupService();
|
||||
const device = this.device as unknown as import('../BackupService').BackupDevice;
|
||||
|
||||
const config = { interfaces, vlans, routes, firewall, dhcp, dns };
|
||||
// The export requires SSH; without it there's no restorable snapshot to take.
|
||||
let rsc: string;
|
||||
try {
|
||||
rsc = await backupService.exportConfig(device);
|
||||
} catch (e) {
|
||||
console.warn(`[${this.device.name}] config snapshot skipped — /export failed: ${(e as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO device_configs (device_id, config_json) VALUES ($1, $2)`,
|
||||
[this.device.id, JSON.stringify(config)]
|
||||
);
|
||||
const text = DeviceCollector.normalizeRsc(rsc);
|
||||
const hash = createHash('sha256').update(text).digest('hex');
|
||||
|
||||
// Keep only last 10 full config snapshots per device
|
||||
await query(
|
||||
`DELETE FROM device_configs WHERE device_id = $1 AND id NOT IN (
|
||||
SELECT id FROM device_configs WHERE device_id = $1 ORDER BY collected_at DESC LIMIT 10
|
||||
)`,
|
||||
const latest = await queryOne<{ config_hash: string | null; config_text: string | null }>(
|
||||
`SELECT config_hash, config_text FROM device_configs
|
||||
WHERE device_id = $1 ORDER BY collected_at DESC LIMIT 1`,
|
||||
[this.device.id]
|
||||
);
|
||||
|
||||
// No change since the last snapshot — nothing to store.
|
||||
if (latest && latest.config_hash === hash) return false;
|
||||
|
||||
const isFirst = !latest;
|
||||
const summary = isFirst
|
||||
? 'Initial snapshot'
|
||||
: DeviceCollector.summarizeRscDiff(latest.config_text ?? '', text);
|
||||
|
||||
// Persist the exact .rsc we just hashed as a restorable backup; the snapshot
|
||||
// and this backup are one artifact (FK cascade keeps them in lockstep).
|
||||
let backupId: number | null = null;
|
||||
try {
|
||||
backupId = await backupService.createBackupFromContent(
|
||||
device,
|
||||
rsc,
|
||||
`Config snapshot (${reason})`,
|
||||
'config-snapshot'
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[${this.device.name}] snapshot backup unavailable: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO device_configs (device_id, config_json, config_text, config_hash, change_summary, backup_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[this.device.id, '{}', text, hash, summary, backupId]
|
||||
);
|
||||
|
||||
await this.pruneConfigSnapshots();
|
||||
|
||||
// Alert on drift — skip the very first snapshot (nothing to compare against).
|
||||
if (!isFirst) {
|
||||
alertService.dispatch('config_drift', `Configuration changed on ${this.device.name}: ${summary}`, {
|
||||
deviceId: this.device.id,
|
||||
deviceName: this.device.name,
|
||||
details: summary,
|
||||
}).catch(() => { /* alerting is best-effort */ });
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[${this.device.name}] Failed to save config:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Back-compat entry point used by the full-sync flow. */
|
||||
async saveFullConfig(): Promise<void> {
|
||||
await this.snapshotConfig('full-sync');
|
||||
}
|
||||
|
||||
async updateDeviceStatus(status: string): Promise<void> {
|
||||
await query(
|
||||
`UPDATE devices SET status = $1, last_seen = NOW(), updated_at = NOW() WHERE id = $2`,
|
||||
|
||||
Reference in New Issue
Block a user