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:
2GT-Rich
2026-06-06 16:01:58 -07:00
co-authored by Claude Opus 4.8
parent 4ff5ed805c
commit 367034c2d6
20 changed files with 1075 additions and 81 deletions
+4 -1
View File
@@ -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 {
+17 -8
View File
@@ -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;
+62 -7
View File
@@ -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;
+136 -19
View File
@@ -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`,