Files
MIKROTIK-RMM/backend/src/services/mikrotik/DeviceCollector.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

2710 lines
117 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from 'crypto';
import { RouterOSClient } from './RouterOSClient';
import { query, queryOne } from '../../config/database';
import { getWriteApi } from '../../config/influxdb';
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 = {
from_interface: 512,
to_interface: 512,
neighbor_address: 45,
neighbor_identity: 255,
neighbor_platform: 255,
neighbor_mac: 17,
neighbor_caps: 255,
discovered_by: 512,
} as const;
function topologyNeighborOversized(
field: keyof typeof TOPOLOGY_LINK_LIMITS,
value: string | null | undefined
): { limit: number; len: number } | null {
if (value == null || value === '') return null;
const limit = TOPOLOGY_LINK_LIMITS[field];
return value.length > limit ? { limit, len: value.length } : null;
}
export interface DeviceRow {
id: number;
name: string;
ip_address: string;
api_port: number;
ssh_port?: number;
api_username: string;
api_password_encrypted: string;
ssh_username?: string;
ssh_password_encrypted?: string;
model?: string;
ros_version?: string;
device_type: string;
status: string;
}
export class DeviceCollector {
private client: RouterOSClient;
constructor(private device: DeviceRow) {
this.client = new RouterOSClient(
device.ip_address,
device.api_port,
device.api_username,
decrypt(device.api_password_encrypted)
);
}
async connect(): Promise<void> {
await this.client.connect();
}
disconnect(): void {
this.client.disconnect();
}
// ─── Fast poll (every 30s) ─────────────────────────────────────────────────
async collectFast(): Promise<void> {
await this.collectInterfaceTraffic();
await this.collectResourceUsage();
await this.updateClients();
if (this.device.device_type === 'wireless_ap') {
await this.collectWirelessStats();
}
if (this.device.device_type === 'switch') {
await this.collectPoePower();
}
await this.updateDeviceStatus('online');
}
// ─── Slow poll (every 5 min) ───────────────────────────────────────────────
async collectSlow(): Promise<void> {
await this.collectInterfaces();
await this.collectIpAddressesCache();
await this.collectVlans();
await this.collectSystemInfo();
await this.collectStp();
if (this.device.device_type === 'wireless_ap') {
await this.collectWirelessInterfaces();
await this.collectSecurityProfiles();
}
}
// ─── Log poll (every 60s) ─────────────────────────────────────────────────
async collectLogs(): Promise<void> {
await this.collectEvents();
}
// ─── Full initial collection ───────────────────────────────────────────────
async collectAll(): Promise<void> {
await this.collectSystemInfo();
await this.collectInterfaces();
await this.collectIpAddressesCache();
await this.collectVlans();
await this.collectInterfaceTraffic();
await this.collectResourceUsage();
await this.updateClients();
await this.collectEvents();
await this.collectNeighbors();
await this.saveFullConfig();
await this.updateDeviceStatus('online');
}
// ─── System Info ──────────────────────────────────────────────────────────
async collectSystemInfo(): Promise<void> {
try {
const identity = await this.client.execute('/system/identity/print');
const resource = await this.client.execute('/system/resource/print');
const routerboard = await this.client.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]);
const info = resource[0] || {};
const rb = routerboard[0] || {};
const identityName = identity[0]?.['name'] || this.device.name;
const rosVersion = (info['version'] || '').split(' ')[0];
const model = rb['model'] || info['board-name'] || null;
const serial = rb['serial-number'] || null;
const firmware = rb['current-firmware'] || rb['factory-firmware'] || null;
await query(
`UPDATE devices SET
name = COALESCE($1, name),
model = COALESCE($2, model),
serial_number = COALESCE($3, serial_number),
firmware_version = COALESCE($4, firmware_version),
ros_version = COALESCE($5, ros_version),
updated_at = NOW()
WHERE id = $6`,
[identityName, model, serial, firmware, rosVersion, this.device.id]
);
} catch (err) {
console.error(`[${this.device.name}] Failed to collect system info:`, err);
}
}
// ─── Interface Stats → InfluxDB ───────────────────────────────────────────
async collectInterfaceTraffic(): Promise<void> {
try {
const stats = await this.client.execute('/interface/print', { stats: '' });
const writeApi = getWriteApi();
for (const iface of stats) {
const name = iface['name'];
if (!name) continue;
const point = new Point('interface_traffic')
.tag('device_id', String(this.device.id))
.tag('device_name', this.device.name)
.tag('interface', name)
.intField('rx_bytes', parseInt(iface['rx-byte'] || '0', 10))
.intField('tx_bytes', parseInt(iface['tx-byte'] || '0', 10))
.intField('rx_packets', parseInt(iface['rx-packet'] || '0', 10))
.intField('tx_packets', parseInt(iface['tx-packet'] || '0', 10))
.intField('rx_errors', parseInt(iface['rx-error'] || '0', 10))
.intField('tx_errors', parseInt(iface['tx-error'] || '0', 10))
.booleanField('running', iface['running'] === 'true')
.timestamp(new Date());
writeApi.writePoint(point);
}
await writeApi.flush().catch((e) => console.error('InfluxDB flush error:', e));
} catch (err) {
console.error(`[${this.device.name}] Failed to collect interface traffic:`, err);
}
}
// ─── Resource Usage → InfluxDB ────────────────────────────────────────────
async collectResourceUsage(): Promise<void> {
try {
const res = await this.client.execute('/system/resource/print');
const r = res[0];
if (!r) return;
const writeApi = getWriteApi();
const point = new Point('device_resources')
.tag('device_id', String(this.device.id))
.tag('device_name', this.device.name)
.floatField('cpu_load', parseFloat(r['cpu-load'] || '0'))
.intField('memory_total', parseInt(r['total-memory'] || '0', 10))
.intField(
'memory_used',
parseInt(r['total-memory'] || '0', 10) - parseInt(r['free-memory'] || '0', 10)
)
.intField('hdd_total', parseInt(r['total-hdd-space'] || '0', 10))
.intField(
'hdd_used',
parseInt(r['total-hdd-space'] || '0', 10) -
parseInt(r['free-hdd-space'] || '0', 10)
)
.intField('uptime_seconds', this.parseUptime(r['uptime'] || '0s'))
.timestamp(new Date());
writeApi.writePoint(point);
await writeApi.flush().catch((e) => console.error('InfluxDB flush error:', e));
} catch (err) {
console.error(`[${this.device.name}] Failed to collect resources:`, err);
}
}
private parseUptime(uptime: string): number {
let seconds = 0;
const weeks = uptime.match(/(\d+)w/);
const days = uptime.match(/(\d+)d/);
const hours = uptime.match(/(\d+)h/);
const mins = uptime.match(/(\d+)m/);
const secs = uptime.match(/(\d+)s/);
if (weeks) seconds += parseInt(weeks[1]) * 604800;
if (days) seconds += parseInt(days[1]) * 86400;
if (hours) seconds += parseInt(hours[1]) * 3600;
if (mins) seconds += parseInt(mins[1]) * 60;
if (secs) seconds += parseInt(secs[1]);
return seconds;
}
// ─── Interfaces → Postgres ────────────────────────────────────────────────
async collectInterfaces(): Promise<void> {
try {
const [ifaces, bridges, bonds] = await Promise.all([
this.client.execute('/interface/print', { detail: '' }),
this.client.execute('/interface/bridge/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
this.client.execute('/interface/bonding/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
]);
const bridgeNames = new Set(bridges.map((b) => b['name']).filter(Boolean));
const bondNames = new Set(bonds.map((b) => b['name']).filter(Boolean));
// Map bridge name → full bridge data (includes vlan-filtering, etc.)
const bridgeDataMap = new Map<string, Record<string, string>>();
for (const b of bridges) {
if (b['name']) bridgeDataMap.set(b['name'], b);
}
const bridgesNotInIfaces = bridges.filter((b) => b['name'] && !ifaces.some((i) => i['name'] === b['name']));
const bondsNotInIfaces = bonds.filter((b) => b['name'] && !ifaces.some((i) => i['name'] === b['name']));
const allIfaces = [...ifaces, ...bridgesNotInIfaces, ...bondsNotInIfaces];
for (const iface of allIfaces) {
const name = iface['name'];
if (!name) continue;
const rosType = iface['type'] || 'ether';
const resolvedType = bridgeNames.has(name) ? 'bridge' : bondNames.has(name) ? 'bond' : rosType;
// For bridge interfaces, merge bridge-specific properties (vlan-filtering, etc.)
// /interface/print lacks bridge-only fields; /interface/bridge/print has them.
const enrichedIface = resolvedType === 'bridge' && bridgeDataMap.has(name)
? { ...iface, ...bridgeDataMap.get(name)! }
: iface;
// RouterOS bridges may report mtu as 'auto' or '...' (inherited); use actual-mtu as fallback
const rawMtu = parseInt(enrichedIface['actual-mtu'] || enrichedIface['mtu'] || '0', 10);
const mtu = !isNaN(rawMtu) && rawMtu > 0 ? rawMtu : null;
try {
await query(
`INSERT INTO interfaces (device_id, name, type, mac_address, mtu, running, disabled, comment, speed, config_json, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
ON CONFLICT (device_id, name) DO UPDATE SET
type=$3, mac_address=$4, mtu=$5, running=$6, disabled=$7, comment=$8, speed=$9, config_json=$10, updated_at=NOW()`,
[
this.device.id,
name,
resolvedType,
enrichedIface['mac-address'] || null,
mtu,
enrichedIface['running'] === 'true',
enrichedIface['disabled'] === 'true',
enrichedIface['comment'] || null,
enrichedIface['speed'] || null,
JSON.stringify(enrichedIface),
]
);
} catch (insertErr) {
console.error(`[${this.device.name}] Failed to insert interface ${name}:`, insertErr);
}
}
} catch (err) {
console.error(`[${this.device.name}] Failed to collect interfaces:`, err);
}
}
/** Store /ip/address rows (sanitized) for topology matching of neighbor IPs. */
async collectIpAddressesCache(): Promise<void> {
try {
const rows = await this.client
.execute('/ip/address/print', { detail: '' })
.catch(() => [] as Record<string, string>[]);
const minimalist = rows
.filter((r) => r['disabled'] !== 'true' && r['invalid'] !== 'true')
.map((r) => ({
address: (r['address'] || '').trim(),
interface: (r['interface'] || '').trim(),
dynamic: r['dynamic'] === 'true',
}))
.filter((x) => x.address.length > 0);
await query(
`UPDATE devices SET ip_addresses_jsonb = $1::jsonb, updated_at = NOW() WHERE id = $2`,
[JSON.stringify(minimalist), this.device.id]
);
} catch (err) {
console.error(`[${this.device.name}] Failed to cache IP addresses:`, err);
}
}
// ─── VLANs → Postgres ────────────────────────────────────────────────────
async collectVlans(): Promise<void> {
try {
// Bridge VLAN table (CRS switches / bridge-based switching)
const bridgeVlans = await this.client
.execute('/interface/bridge/vlan/print', { detail: '' })
.catch(() => []);
for (const vlan of bridgeVlans) {
const vlanId = parseInt(vlan['vlan-ids'] || '0', 10);
if (!vlanId) continue;
await query(
`INSERT INTO vlans (device_id, vlan_id, name, bridge, tagged_ports, untagged_ports, config_json)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (device_id, vlan_id) DO UPDATE SET
name=$3, bridge=$4, tagged_ports=$5, untagged_ports=$6, config_json=$7`,
[
this.device.id,
vlanId,
vlan['comment'] || `VLAN ${vlanId}`,
vlan['bridge'] || null,
this.parseList(vlan['tagged']),
this.parseList(vlan['untagged']),
JSON.stringify(vlan),
]
);
}
// Also collect bridge port PVIDs
const bridgePorts = await this.client
.execute('/interface/bridge/port/print', { detail: '' })
.catch(() => []);
for (const port of bridgePorts) {
const pvid = parseInt(port['pvid'] || '1', 10);
const bridge = port['bridge'] || '';
const portName = port['interface'] || '';
if (!portName || !bridge) continue;
await query(
`INSERT INTO bridge_vlan_entries (device_id, bridge, port, pvid, tagged, config_json, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (device_id, bridge, port) DO UPDATE SET
pvid=$4, tagged=$5, config_json=$6, updated_at=NOW()`,
[
this.device.id,
bridge,
portName,
pvid,
false,
JSON.stringify(port),
]
);
}
} catch (err) {
console.error(`[${this.device.name}] Failed to collect VLANs:`, err);
}
}
private parseList(val: string | undefined): string[] {
if (!val) return [];
return val
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
// ─── Clients ──────────────────────────────────────────────────────────────
async updateClients(): Promise<void> {
try {
await query(`UPDATE clients SET active = FALSE WHERE device_id = $1`, [this.device.id]);
// Detect which wireless package is in use before the parallel fetch so we
// query the correct registration table path (new wifi pkg vs legacy wireless pkg).
const wifiPkg = await this.detectWifiPackage().catch(() => 'none' as const);
const regTableCmd = wifiPkg === 'wifi'
? '/interface/wifi/registration-table/print'
: '/interface/wireless/registration-table/print';
// Collect all data sources in parallel.
// Note: { detail: '' } is omitted — the RouterOS binary API always returns all fields,
// and passing =detail= causes a silent !trap on some RouterOS builds.
const [arpEntries, dhcpLeases, wirelessClients, bridgeHosts] = await Promise.all([
this.client.execute('/ip/arp/print').catch(() => []),
this.client.execute('/ip/dhcp-server/lease/print').catch(() => []),
wifiPkg === 'none'
? Promise.resolve([] as Record<string, string>[])
: this.client.execute(regTableCmd).catch(() => [] as Record<string, string>[]),
this.client.execute('/interface/bridge/host/print').catch(() => []),
]);
// DHCP hostname + IP lookup
const dhcpHostnames: Record<string, string> = {};
const dhcpIPs: Record<string, string> = {};
for (const lease of dhcpLeases) {
const mac = (lease['mac-address'] || '').toLowerCase();
if (mac) {
dhcpHostnames[mac] = lease['host-name'] || lease['comment'] || '';
dhcpIPs[mac] = lease['address'] || '';
}
}
// Wireless signal / traffic data
// Field names differ between packages:
// legacy: signal-strength (e.g. "-65dBm"), bytes ("rx,tx" combined)
// new wifi pkg: signal (e.g. "-65"), tx-bytes / rx-bytes (separate fields)
const wifiSignal: Record<string, number> = {};
const wifiInterface: Record<string, string> = {};
const wifiTx: Record<string, number> = {};
const wifiRx: Record<string, number> = {};
for (const wc of wirelessClients) {
const mac = (wc['mac-address'] || '').toLowerCase();
if (!mac) continue;
// Signal strength — strip any trailing unit suffix (e.g. "dBm")
const rawSignal = wc['signal-strength'] || wc['signal'] || '0';
wifiSignal[mac] = parseInt(rawSignal, 10) || 0;
wifiInterface[mac] = wc['interface'] || '';
// Traffic counters — new wifi pkg has separate fields; legacy combines as "rx,tx"
if (wc['tx-bytes'] !== undefined || wc['rx-bytes'] !== undefined) {
wifiTx[mac] = parseInt(wc['tx-bytes'] || '0', 10) || 0;
wifiRx[mac] = parseInt(wc['rx-bytes'] || '0', 10) || 0;
} else {
const parts = (wc['bytes'] || '0,0').split(',');
wifiRx[mac] = parseInt(parts[0] || '0', 10) || 0;
wifiTx[mac] = parseInt(parts[1] || '0', 10) || 0;
}
}
// ARP table — IP enrichment and activity validation.
// Only accept entries where ARP resolution succeeded (status != failed).
// RouterOS v7 marks stale/unreachable entries with status=failed.
const arpIPs: Record<string, string> = {};
const arpInterfaces: Record<string, string> = {};
const arpFailed = new Set<string>(); // MACs confirmed offline by this device's ARP
for (const arp of arpEntries) {
const mac = (arp['mac-address'] || '').toLowerCase();
if (!mac) continue;
if (arp['incomplete'] === 'true') continue;
if (arp['BCAST'] === 'true') continue;
if (arp['status'] === 'failed') { arpFailed.add(mac); continue; }
arpIPs[mac] = arp['address'] || '';
arpInterfaces[mac] = arp['interface'] || '';
}
// Bridge host table — primary client source for switched/bridged devices.
// RouterOS includes an 'age' field (seconds since last frame seen).
// On software bridges (CCR, CHR) the table ages out at ~300s, so anything
// present is genuinely recent. On hardware CRS switches the ASIC may hold
// entries for hours after a host goes offline.
// Strategy:
// - Skip static/local entries
// - Skip any entry where age > 600s AND the device's own ARP marks it failed
// (belt-and-suspenders for CRS hardware whose FDB doesn't self-age)
// - Keep all others (missing age field = software bridge, treat as fresh)
const MAX_BRIDGE_AGE_S = 600;
const bridgeHostMap: Record<string, { port: string; vid: number | null }> = {};
for (const host of bridgeHosts) {
if (host['local'] === 'true') continue;
if (host['dynamic'] === 'false') continue;
const mac = (host['mac-address'] || '').toLowerCase();
if (!mac) continue;
const age = host['age'] ? parseInt(host['age'], 10) : 0;
// Exclude stale hardware-FDB entries: old AND ARP-confirmed offline
if (age > MAX_BRIDGE_AGE_S && arpFailed.has(mac)) continue;
bridgeHostMap[mac] = {
port: host['on-interface'] || host['interface'] || '',
vid: host['vid'] ? parseInt(host['vid'], 10) : null,
};
}
const allMacs = new Set<string>([
...Object.keys(wifiSignal),
...Object.keys(arpIPs),
...Object.keys(bridgeHostMap),
]);
let totalClients = 0;
for (const mac of allMacs) {
if (!mac) continue;
const isWireless = mac in wifiSignal;
const entry = bridgeHostMap[mac];
const interfaceName = isWireless
? (wifiInterface[mac] || entry?.port || null)
: (entry?.port || arpInterfaces[mac] || null);
const vlanId = entry?.vid ?? null;
await query(
`INSERT INTO clients (device_id, mac_address, hostname, ip_address, interface_name, vlan_id, tx_bytes, rx_bytes, signal_strength, client_type, active, last_seen)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,NOW())
ON CONFLICT (device_id, mac_address) DO UPDATE SET
hostname=COALESCE($3, clients.hostname),
ip_address=COALESCE(NULLIF($4,''), clients.ip_address),
interface_name=COALESCE($5, clients.interface_name),
vlan_id=COALESCE($6, clients.vlan_id),
tx_bytes=$7,
rx_bytes=$8,
signal_strength=$9,
client_type=$10,
active=TRUE,
last_seen=NOW()`,
[
this.device.id,
mac,
dhcpHostnames[mac] || null,
dhcpIPs[mac] || arpIPs[mac] || null,
interfaceName,
vlanId,
wifiTx[mac] || 0,
wifiRx[mac] || 0,
isWireless ? wifiSignal[mac] : null,
isWireless ? 'wireless' : 'wired',
]
);
totalClients++;
}
// OUI vendor lookup: fill in up to 10 clients that are missing vendor per cycle
await this.lookupMissingVendors();
// Write per-client presence points so the detail page can show a timeline
const writeApi = getWriteApi();
for (const mac of allMacs) {
if (!mac) continue;
const presencePoint = new Point('client_presence')
.tag('mac_address', mac)
.tag('device_id', String(this.device.id))
.intField('online', 1);
if (mac in wifiSignal) {
presencePoint.intField('signal_strength', wifiSignal[mac]);
presencePoint.intField('tx_bytes', wifiTx[mac] ?? 0);
presencePoint.intField('rx_bytes', wifiRx[mac] ?? 0);
}
presencePoint.timestamp(new Date());
writeApi.writePoint(presencePoint);
}
// Write per-device metric (for per-device breakdowns)
const point = new Point('client_counts')
.tag('device_id', String(this.device.id))
.tag('device_name', this.device.name)
.intField('total_clients', totalClients)
.intField('wireless_clients', Object.keys(wifiSignal).length)
.intField('wired_clients', Math.max(0, totalClients - Object.keys(wifiSignal).length))
.timestamp(new Date());
writeApi.writePoint(point);
// Write a global deduplicated metric so the dashboard graph shows the true
// unique client count across all devices (not a per-device sum that double-counts
// clients visible from multiple devices simultaneously).
const dedupedRows = await query<{ count: string }>(
`SELECT COUNT(DISTINCT mac_address) AS count FROM clients WHERE active = TRUE`
);
const globalTotal = parseInt(dedupedRows[0]?.count || '0', 10);
const globalPoint = new Point('client_counts')
.tag('device_id', '_global')
.tag('device_name', '_global')
.intField('total_clients', globalTotal)
.timestamp(new Date());
writeApi.writePoint(globalPoint);
await writeApi.flush().catch(() => {});
} catch (err) {
console.error(`[${this.device.name}] Failed to update clients:`, err);
}
}
private async lookupMissingVendors(): Promise<void> {
try {
// Include vendor='' entries: those were set by the old API-based lookup
// when it was rate-limited and never actually resolved.
const rows = await query<{ mac_address: string }>(
`SELECT mac_address FROM clients
WHERE device_id = $1 AND (vendor IS NULL OR vendor = '') AND active = TRUE`,
[this.device.id]
);
for (const row of rows) {
const vendor = lookupVendor(row.mac_address);
// Always write the result (even empty) so it's consistent across all
// device rows for the same MAC.
await query(
`UPDATE clients SET vendor = $1 WHERE device_id = $2 AND mac_address = $3`,
[vendor, this.device.id, row.mac_address]
);
}
} catch {
// Non-critical; ignore vendor lookup failures
}
}
// ─── Events/Logs ─────────────────────────────────────────────────────────
async collectEvents(): Promise<void> {
try {
const logs = await this.client.execute('/log/print');
if (!logs.length) return;
// RouterOS log .id values are hex strings like "*1A2F". Parse to int for comparison.
const parseRosId = (id: string): number => {
const hex = (id || '').replace(/^\*/, '');
return hex ? parseInt(hex, 16) : 0;
};
// Find the highest RouterOS log ID we've already stored for this device.
const lastRow = await queryOne<{ log_id: string }>(
`SELECT log_id FROM events WHERE device_id = $1 AND log_id IS NOT NULL AND log_id != ''
ORDER BY id DESC LIMIT 1`,
[this.device.id]
);
const lastIdNum = parseRosId(lastRow?.log_id || '');
// Always get the latest stored event time — needed for timestamp fallback.
const latestStored = await queryOne<{ event_time: Date }>(
`SELECT event_time FROM events WHERE device_id = $1 ORDER BY event_time DESC LIMIT 1`,
[this.device.id]
);
const latestTime = latestStored?.event_time ? new Date(latestStored.event_time) : new Date(0);
// Detect log buffer overflow or device reboot: if all current IDs are below
// our stored lastIdNum, RouterOS has cleared/reset its log buffer.
// In that case, fall back to timestamp-based deduplication to avoid missing events.
const currentIds = logs.map(l => parseRosId(l['.id'] || '')).filter(id => id > 0);
const maxCurrentId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
const logReset = lastRow && maxCurrentId > 0 && maxCurrentId < lastIdNum;
let newCount = 0;
for (const log of logs) {
const logId = (log['.id'] || '') as string;
const logIdNum = parseRosId(logId);
if (logId && !logReset) {
// Primary: skip entries already stored by RouterOS ID
if (logIdNum <= lastIdNum) continue;
} else {
// Fallback: no .id field, or log buffer has been reset — use timestamp deduplication
const time = this.parseLogTime(log['time'] || '');
if (time <= latestTime) continue;
}
const time = this.parseLogTime(log['time'] || '');
const severity = this.mapLogSeverity(log['topics'] || '');
await query(
`INSERT INTO events (device_id, event_time, severity, topic, message, raw_json, log_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (device_id, log_id) DO NOTHING`,
[
this.device.id,
time.toISOString(),
severity,
log['topics'] || null,
log['message'] || '',
JSON.stringify(log),
logId || null,
]
);
newCount++;
}
if (newCount > 0) {
await query(
`DELETE FROM events WHERE device_id = $1 AND event_time < NOW() - INTERVAL '30 days'`,
[this.device.id]
);
console.log(`[${this.device.name}] Collected ${newCount} new log entries`);
}
} catch (err) {
console.error(`[${this.device.name}] Failed to collect events:`, err);
}
}
private parseLogTime(timeStr: string): Date {
// RouterOS log time formats:
// "jan/01 00:00:05" — month/day only (no year) → default to current year
// "jan/01/2024 00:00:05" — full date with year
// "00:00:05" — time only (today, e.g. device uptime < 1 day)
if (!timeStr) return new Date();
try {
if (timeStr.includes('/')) {
const spaceIdx = timeStr.lastIndexOf(' ');
if (spaceIdx === -1) return new Date();
const datePart = timeStr.substring(0, spaceIdx);
const timePart = timeStr.substring(spaceIdx + 1);
const parts = datePart.split('/');
const monthStr = parts[0];
const day = parts[1];
if (!monthStr || !day) return new Date();
const year = parts[2] !== undefined ? parts[2] : String(new Date().getFullYear());
const months: Record<string, string> = {
jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06',
jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12',
};
const month = months[monthStr.toLowerCase()] || '01';
const d = new Date(`${year}-${month}-${day.padStart(2, '0')}T${timePart}Z`);
if (isNaN(d.getTime())) return new Date();
return d;
} else {
// Time only (today)
const today = new Date().toISOString().split('T')[0];
const d = new Date(`${today}T${timeStr}Z`);
if (isNaN(d.getTime())) return new Date();
return d;
}
} catch {
return new Date();
}
}
private mapLogSeverity(topics: string): string {
if (topics.includes('critical') || topics.includes('error')) return 'error';
if (topics.includes('warning')) return 'warning';
if (topics.includes('info')) return 'info';
return 'info';
}
// ─── MAC Scan (switch IP enrichment) ─────────────────────────────────────
async runMacScan(): Promise<void> {
if (this.device.device_type !== 'switch') return;
try {
const bridges = await this.client
.execute('/interface/bridge/print', { detail: '' })
.catch(() => [] as Record<string, string>[]);
const bridgeNames = bridges.map((b) => b['name']).filter(Boolean);
if (bridgeNames.length === 0) return;
const macIpMap: Record<string, string> = {};
// Scan each bridge for 5 seconds; executeStreaming handles the !done or
// cuts off at 8 s so we don't stall the poller indefinitely
for (const iface of bridgeNames) {
const results = await this.client
.executeStreaming('/tool/mac-scan', { interface: iface, duration: '5' }, 8_000)
.catch(() => [] as Record<string, string>[]);
for (const entry of results) {
const mac = (entry['mac-address'] || '').toLowerCase();
const ip = entry['address'] || '';
if (mac && ip) macIpMap[mac] = ip;
}
}
if (Object.keys(macIpMap).length === 0) return;
// Enrich client records across all devices: fill in missing IPs only,
// so DHCP-assigned addresses that already exist are not overwritten.
for (const [mac, ip] of Object.entries(macIpMap)) {
await query(
`UPDATE clients SET ip_address = $1
WHERE mac_address = $2
AND (ip_address IS NULL OR ip_address = '')`,
[ip, mac]
);
}
console.log(`[${this.device.name}] MAC scan found ${Object.keys(macIpMap).length} MAC/IP pair(s)`);
} catch (err) {
console.error(`[${this.device.name}] MAC scan failed:`, err);
}
}
// ─── Neighbor Discovery (for topology) ───────────────────────────────────
async collectNeighbors(): Promise<void> {
try {
const [neighbors, arpEntries, dhcpLeases, serverArpMap] = await Promise.all([
this.client.execute('/ip/neighbor/print').catch(() => []),
this.client.execute('/ip/arp/print').catch(() => []),
this.client.execute('/ip/dhcp-server/lease/print').catch(() => []),
buildServerArpMap(),
]);
// Build MAC → IPv4 lookup from ARP and DHCP as fallback for neighbors
// whose MNDP advertisement only contains an IPv6 address.
const macToIpv4: Record<string, string> = {};
for (const arp of arpEntries) {
const mac = (arp['mac-address'] || '').toLowerCase().trim();
const ip = (arp['address'] || '').trim();
if (!mac || !ip || ip.includes(':')) continue;
if (arp['complete'] === 'false' || arp['status'] === 'failed') continue;
macToIpv4[mac] = ip;
}
for (const lease of dhcpLeases) {
const mac = (lease['mac-address'] || '').toLowerCase().trim();
const ip = (lease['address'] || '').trim();
if (mac && ip && !ip.includes(':') && !macToIpv4[mac]) macToIpv4[mac] = ip;
}
// Wipe existing rows for this device so removed neighbors don't linger
await query(`DELETE FROM topology_links WHERE from_device_id = $1`, [this.device.id]);
for (const nb of neighbors) {
// RouterOS returns comma-separated interface names like "ether1,bridge1" — the
// first entry is always the physical port; the rest are the bridge/bond parents.
// Store only the physical port so topology edges show the actual cable endpoint.
const rawInterface = nb['interface'] || '';
const fromInterface = (rawInterface.split(',')[0].trim() || rawInterface);
// interface-name is the neighbor's own outgoing interface (format: bridge/port or just port)
const toInterfaceRaw = (nb['interface-name'] || '').trim() || null;
const toInterface = toInterfaceRaw || null;
// discovered-by lists protocols that found this neighbor: lldp, cdp, mndp, etc.
// Rank them by reliability: lldp (point-to-point) > cdp (also flooded in ROS but
// implies same segment) > mndp (MikroTik broadcast, can span entire bridge domain).
const discoveredByRaw = (nb['discovered-by'] || '').toLowerCase();
let discoveredBy: string;
if (discoveredByRaw.includes('lldp')) discoveredBy = 'lldp';
else if (discoveredByRaw.includes('cdp')) discoveredBy = 'cdp';
else if (discoveredByRaw.includes('mndp')) discoveredBy = 'mndp';
else discoveredBy = discoveredByRaw || 'mndp';
// Store the full RouterOS list in discovered_by (column is VARCHAR(512)); the raw
// string can be a long comma-separated list and used to exceed VARCHAR(50).
const discoveredByStored = discoveredByRaw || null;
// RouterOS v7+ may put an IPv6 link-local in 'address'. Try all known
// IPv4 field names, split on whitespace/commas in case multiple are packed
// into one field, and take the first value that looks like IPv4.
const mac = (nb['mac-address'] || '').toLowerCase().trim();
const ipv4FromNeighbor = [
nb['ipv4-address'],
nb['ip-address'],
nb['address'],
]
.flatMap((f) => (f || '').split(/[\s,]+/))
.map((s) => s.trim())
.find((s) => s && !s.includes(':')) ?? null;
const neighborAddress = ipv4FromNeighbor
|| (mac ? macToIpv4[mac] ?? null : null)
|| (mac ? serverArpMap[mac] ?? null : null);
const neighborIdentity = (nb['identity'] || '');
const neighborPlatform = (nb['platform'] || '');
const neighborMac = nb['mac-address'] ? String(nb['mac-address']) : null;
const neighborCaps = (nb['system-caps-enabled'] || nb['system-caps'] || '');
if (!fromInterface) continue;
const oversize: { field: keyof typeof TOPOLOGY_LINK_LIMITS; limit: number; len: number }[] = [];
const check = (field: keyof typeof TOPOLOGY_LINK_LIMITS, val: string | null | undefined) => {
const o = topologyNeighborOversized(field, val);
if (o) oversize.push({ field, ...o });
};
check('from_interface', fromInterface);
check('to_interface', toInterface);
check('neighbor_address', neighborAddress ? String(neighborAddress) : null);
check('neighbor_identity', neighborIdentity || null);
check('neighbor_platform', neighborPlatform || null);
check('neighbor_mac', neighborMac);
check('neighbor_caps', neighborCaps || null);
check('discovered_by', discoveredByStored);
if (oversize.length > 0) {
console.warn(
`[${this.device.name}] Skipping topology neighbor row: value(s) exceed DB column limits ` +
`(no silent truncation). Details: ${JSON.stringify(oversize)}`
);
continue;
}
await query(
`INSERT INTO topology_links
(from_device_id, from_interface, to_interface, neighbor_address, neighbor_identity,
neighbor_platform, neighbor_mac, neighbor_caps, link_type, discovered_by, discovered_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())`,
[
this.device.id,
fromInterface,
toInterface,
neighborAddress ? String(neighborAddress) : null,
neighborIdentity,
neighborPlatform,
neighborMac,
neighborCaps || null,
discoveredBy,
discoveredByStored,
]
);
}
// Try to resolve neighbor_address to a known device
await query(
`UPDATE topology_links tl
SET to_device_id = d.id
FROM devices d
WHERE tl.from_device_id = $1
AND d.ip_address = tl.neighbor_address
AND tl.to_device_id IS DISTINCT FROM d.id`,
[this.device.id]
);
} catch (err) {
console.error(`[${this.device.name}] Failed to collect neighbors:`, err);
}
}
async collectStp(): Promise<void> {
try {
const bridgePorts = await this.client
.execute('/interface/bridge/port/print', { detail: '' })
.catch(() => []);
for (const port of bridgePorts) {
const iface = (port['interface'] as string) || '';
const role = (port['role'] as string) || '';
const bridgeName = (port['bridge'] as string) || '';
// Derive state from role — alternate/backup are blocking, root/designated are forwarding
const state = (role === 'alternate' || role === 'backup') ? 'blocking' : 'forwarding';
if (!iface) continue;
await query(
`UPDATE topology_links
SET stp_role=$3, stp_state=$4, bridge_name=$5
WHERE from_device_id=$1 AND from_interface=$2`,
[this.device.id, iface, role || null, state, bridgeName || null]
);
}
} catch (err) {
console.error(`[${this.device.name}] Failed to collect STP:`, err);
}
}
// ─── LLDP Management ──────────────────────────────────────────────────────
async getLldpEnabled(): Promise<{ enabled: boolean; protocol: string }> {
try {
const result = await this.client
.execute('/ip/neighbor/discovery-settings/print')
.catch(() => [] as Record<string, string>[]);
const settings = result[0] as Record<string, string> | undefined;
const protocol = settings?.['protocol'] ?? '';
if (!protocol) return { enabled: true, protocol: 'unknown' };
const enabled = protocol.toLowerCase().includes('lldp');
return { enabled, protocol };
} catch {
return { enabled: true, protocol: 'unknown' };
}
}
async setLldpEnabled(enabled: boolean): Promise<void> {
const current = await this.getLldpEnabled();
const currentProtocol = current.protocol === 'unknown' ? 'cdp,lldp,mndp' : current.protocol;
let protocols = currentProtocol
.split(',')
.map((p: string) => p.trim())
.filter((p: string) => p && p.toLowerCase() !== 'lldp');
if (enabled) protocols = [...protocols, 'lldp'];
const newProtocol = protocols.join(',') || 'mndp';
await this.client.execute('/ip/neighbor/discovery-settings/set', { protocol: newProtocol });
}
// ─── SNMP Management ──────────────────────────────────────────────────────
async getSnmpConfig(): Promise<{
enabled: boolean; contact: string; location: string; trap_target: string;
community_name: string; version: 'v1' | 'v2c' | 'v3';
auth_protocol: string; priv_protocol: string;
}> {
try {
const [globals, communities] = await Promise.all([
this.client.execute('/snmp/print').catch(() => [] as Record<string, string>[]),
this.client.execute('/snmp/community/print').catch(() => [] as Record<string, string>[]),
]);
const g = (globals as Record<string, string>[])[0] ?? {};
const enabled = g['enabled'] !== 'false' && g['enabled'] !== 'no';
const contact = g['contact'] ?? '';
const location = g['location'] ?? '';
const trap_target = g['trap-target'] ?? '';
const trapVersion = g['trap-version'] ?? '2';
// Pick first non-"public" community, fall back to first
const comm = ((communities as Record<string, string>[]).find(c => c['name'] !== 'public')
?? (communities as Record<string, string>[])[0]) ?? {};
const community_name = comm['name'] ?? 'public';
const security = comm['security'] ?? 'none';
const auth_protocol = comm['authentication-protocol'] ?? 'MD5';
const priv_protocol = comm['encryption-protocol'] ?? 'none';
let version: 'v1' | 'v2c' | 'v3';
if (security === 'authorized' || security === 'private') {
version = 'v3';
} else {
version = trapVersion === '1' ? 'v1' : 'v2c';
}
return { enabled, contact, location, trap_target, community_name, version, auth_protocol, priv_protocol };
} catch {
return { enabled: false, contact: '', location: '', trap_target: '', community_name: 'public', version: 'v2c', auth_protocol: 'MD5', priv_protocol: 'none' };
}
}
async setSnmpConfig(config: {
enabled: boolean; contact?: string; location?: string; trap_target?: string;
community_name: string; version: 'v1' | 'v2c' | 'v3';
auth_protocol?: string; auth_password?: string;
priv_protocol?: string; priv_password?: string;
}): Promise<void> {
// 1. Global settings
const globalParams: Record<string, string> = { enabled: config.enabled ? 'yes' : 'no' };
if (config.contact !== undefined) globalParams['contact'] = config.contact;
if (config.location !== undefined) globalParams['location'] = config.location;
if (config.trap_target !== undefined) globalParams['trap-target'] = config.trap_target;
globalParams['trap-version'] = config.version === 'v1' ? '1' : config.version === 'v3' ? '3' : '2';
await this.client.execute('/snmp/set', globalParams);
// 2. Community security level
let security = 'none';
if (config.version === 'v3') {
const hasPriv = config.priv_protocol && config.priv_protocol !== 'none' && config.priv_password;
security = hasPriv ? 'private' : 'authorized';
}
const communityParams: Record<string, string> = { name: config.community_name, security };
if (config.version === 'v3') {
if (config.auth_protocol && config.auth_protocol !== 'none') {
communityParams['authentication-protocol'] = config.auth_protocol;
if (config.auth_password) communityParams['authentication-password'] = config.auth_password;
}
if (config.priv_protocol && config.priv_protocol !== 'none') {
communityParams['encryption-protocol'] = config.priv_protocol;
if (config.priv_password) communityParams['encryption-password'] = config.priv_password;
}
}
// Find existing community by name or fall back to first
const communities = await this.client.execute('/snmp/community/print').catch(() => [] as Record<string, string>[]);
const existing = (communities as Record<string, string>[]).find(c => c['name'] === config.community_name)
?? (communities as Record<string, string>[])[0];
if (existing?.['.id']) {
await this.client.execute('/snmp/community/set', { '.id': existing['.id'], ...communityParams });
} else {
await this.client.execute('/snmp/community/add', communityParams);
}
}
// ─── Full config snapshot ─────────────────────────────────────────────────
/**
* 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 backupService = new BackupService();
const device = this.device as unknown as import('../BackupService').BackupDevice;
// 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;
}
const text = DeviceCollector.normalizeRsc(rsc);
const hash = createHash('sha256').update(text).digest('hex');
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`,
[status, this.device.id]
);
}
// ─── Wifi package detection ───────────────────────────────────────────────
private wifiPackageCache: 'wifi' | 'wireless' | 'none' | null = null;
async detectWifiPackage(): Promise<'wifi' | 'wireless' | 'none'> {
if (this.wifiPackageCache !== null) return this.wifiPackageCache;
// Try RouterOS 7 "wifi" package first (Wi-Fi 6/7 hardware — wlan names are wifi1, wifi2)
try {
await this.client.execute('/interface/wifi/print');
this.wifiPackageCache = 'wifi';
return 'wifi';
} catch { /* fall through */ }
// Try legacy "wireless" package (RouterOS 6.x / older 7.x hardware)
try {
await this.client.execute('/interface/wireless/print');
this.wifiPackageCache = 'wireless';
return 'wireless';
} catch { /* fall through */ }
this.wifiPackageCache = 'none';
return 'none';
}
// Normalize RouterOS 7 wifi package dot-notation fields to flat keys
private normalizeWifiInterface(raw: Record<string, string>): Record<string, string> {
const out: Record<string, string> = { ...raw };
if (raw['configuration.ssid'] !== undefined) out['ssid'] = raw['configuration.ssid'];
if (raw['configuration.mode'] !== undefined) out['mode'] = raw['configuration.mode'];
if (raw['channel.band'] !== undefined) out['band'] = raw['channel.band'];
if (raw['channel.frequency'] !== undefined) out['frequency'] = raw['channel.frequency'];
if (raw['channel.width'] !== undefined) out['channel-width'] = raw['channel.width'];
if (raw['security.authentication-types'] !== undefined) out['authentication-types'] = raw['security.authentication-types'];
if (raw['security.passphrase'] !== undefined) out['passphrase'] = raw['security.passphrase'];
if (raw['security.encryption'] !== undefined) out['encryption'] = raw['security.encryption'];
// "inactive" field: true = radio is NOT running
if (raw['inactive'] !== undefined && out['running'] === undefined) {
out['running'] = raw['inactive'] === 'false' ? 'true' : 'false';
}
return out;
}
// ─── Wireless Interfaces → Postgres ───────────────────────────────────────
async collectWirelessInterfaces(): Promise<void> {
try {
const pkg = await this.detectWifiPackage();
if (pkg === 'none') return;
const rawList = pkg === 'wifi'
? await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[])
: await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList;
if (wlans.length === 0) return;
for (const wlan of wlans) {
const name = wlan['name'];
if (!name) continue;
const freq = parseInt(wlan['frequency'] || '0', 10);
const txPow = parseInt(wlan['tx-power'] || '0', 10);
const gain = parseInt(wlan['antenna-gain'] || '0', 10);
await query(
`INSERT INTO wireless_interfaces
(device_id, name, ssid, mode, band, frequency, channel_width, tx_power,
tx_power_mode, antenna_gain, country, installation, disabled, running,
mac_address, security_profile, config_json, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
ON CONFLICT (device_id, name) DO UPDATE SET
ssid=$3, mode=$4, band=$5, frequency=$6, channel_width=$7, tx_power=$8,
tx_power_mode=$9, antenna_gain=$10, country=$11, installation=$12,
disabled=$13, running=$14, mac_address=$15, security_profile=$16,
config_json=$17, updated_at=NOW()`,
[
this.device.id, name,
wlan['ssid'] || null,
wlan['mode'] || null,
wlan['band'] || null,
!isNaN(freq) && freq > 0 ? freq : null,
wlan['channel-width'] || null,
!isNaN(txPow) && txPow > 0 ? txPow : null,
wlan['tx-power-mode'] || null,
!isNaN(gain) ? gain : null,
wlan['country'] || null,
wlan['installation'] || 'indoor',
wlan['disabled'] === 'true',
wlan['running'] === 'true',
wlan['mac-address'] || null,
wlan['security-profile'] || null,
JSON.stringify(wlan),
]
);
}
} catch (err) {
console.error(`[${this.device.name}] Failed to collect wireless interfaces:`, err);
}
}
// ─── Wireless Stats → InfluxDB ────────────────────────────────────────────
async collectWirelessStats(): Promise<void> {
try {
const pkg = await this.detectWifiPackage();
if (pkg === 'none') return;
const [rawList, regTable] = await Promise.all([
pkg === 'wifi'
? this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[])
: this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
pkg === 'wifi'
? this.client.execute('/interface/wifi/registration-table/print').catch(() => [] as Record<string, string>[])
: this.client.execute('/interface/wireless/registration-table/print').catch(() => [] as Record<string, string>[]),
]);
const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList;
if (wlans.length === 0) return;
const clientsByIface: Record<string, number> = {};
for (const r of regTable) {
const iface = r['interface'];
if (iface) clientsByIface[iface] = (clientsByIface[iface] || 0) + 1;
}
const writeApi = getWriteApi();
for (const wlan of wlans) {
const name = wlan['name'];
if (!name || wlan['disabled'] === 'true') continue;
const clientCount = clientsByIface[name] || 0;
const noiseFloor = parseInt(wlan['noise-floor'] || '0', 10);
const point = new Point('wireless_stats')
.tag('device_id', String(this.device.id))
.tag('device_name', this.device.name)
.tag('interface', name)
.tag('ssid', wlan['ssid'] || name)
.intField('registered_clients', clientCount)
.timestamp(new Date());
if (!isNaN(noiseFloor) && noiseFloor !== 0) {
point.intField('noise_floor', noiseFloor);
}
writeApi.writePoint(point);
await query(
`UPDATE wireless_interfaces SET registered_clients=$1, updated_at=NOW()
WHERE device_id=$2 AND name=$3`,
[clientCount, this.device.id, name]
);
}
await writeApi.flush().catch((e) => console.error('InfluxDB flush error:', e));
} catch (err) {
console.error(`[${this.device.name}] Failed to collect wireless stats:`, err);
}
}
// ─── Security Profiles → Postgres ─────────────────────────────────────────
async collectSecurityProfiles(): Promise<void> {
try {
const pkg = await this.detectWifiPackage();
if (pkg === 'none') return;
if (pkg === 'wifi') {
// Try named /interface/wifi/security profiles first.
// Fall back to synthesizing from interface inline security.* fields if none exist.
const named = await this.client.execute('/interface/wifi/security/print').catch(() => [] as Record<string, string>[]);
const sourceList: Array<{ name: string; raw: Record<string, string> }> = named.length > 0
? named.map(p => ({ name: p['name'], raw: p }))
: (await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[]))
.filter(r => r['security.authentication-types'] || r['security.passphrase'])
.map(r => ({
name: r['name'],
raw: {
'authentication-types': r['security.authentication-types'] || '',
passphrase: r['security.passphrase'] || '',
encryption: r['security.encryption'] || '',
},
}));
for (const { name, raw } of sourceList) {
if (!name) continue;
const authTypes = (raw['authentication-types'] || '').split(',').filter(Boolean);
await query(
`INSERT INTO wireless_security_profiles
(device_id, name, mode, authentication_types, unicast_ciphers, group_ciphers,
management_protection, config_json, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (device_id, name) DO UPDATE SET
mode=$3, authentication_types=$4, unicast_ciphers=$5, group_ciphers=$6,
management_protection=$7, config_json=$8, updated_at=NOW()`,
[
this.device.id, name,
'dynamic-keys', authTypes, [], [], 'disabled',
JSON.stringify(raw),
]
);
}
return;
}
// Legacy wireless package
const profiles = await this.client
.execute('/interface/wireless/security-profiles/print')
.catch(() => [] as Record<string, string>[]);
for (const p of profiles) {
const name = p['name'];
if (!name) continue;
const authTypes = (p['authentication-types'] || '').split(',').filter(Boolean);
const unicastCiphers = (p['unicast-ciphers'] || '').split(',').filter(Boolean);
const groupCiphers = (p['group-ciphers'] || '').split(',').filter(Boolean);
await query(
`INSERT INTO wireless_security_profiles
(device_id, name, mode, authentication_types, unicast_ciphers, group_ciphers,
management_protection, config_json, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (device_id, name) DO UPDATE SET
mode=$3, authentication_types=$4, unicast_ciphers=$5, group_ciphers=$6,
management_protection=$7, config_json=$8, updated_at=NOW()`,
[
this.device.id, name,
p['mode'] || 'none',
authTypes, unicastCiphers, groupCiphers,
p['management-protection'] || 'disabled',
JSON.stringify(p),
]
);
}
} catch (err) {
console.error(`[${this.device.name}] Failed to collect security profiles:`, err);
}
}
// ─── Wireless live-query helpers (for routes) ──────────────────────────────
// Field map for translating old wireless-style flat keys → new wifi dot-notation
private static readonly WIFI_FIELD_MAP: Record<string, string> = {
'ssid': 'configuration.ssid',
'mode': 'configuration.mode',
'band': 'channel.band',
'frequency': 'channel.frequency',
'channel-width': 'channel.width',
'passphrase': 'security.passphrase',
'wpa2-pre-shared-key': 'security.passphrase',
'authentication-types': 'security.authentication-types',
'encryption': 'security.encryption',
'security-profile': 'security', // old pkg "security-profile" → new pkg "security"
'disabled': 'disabled',
'master-interface': 'master-interface',
'name': 'name',
};
// Params valid only in the legacy wireless package — silently dropped for new wifi package
private static readonly WIFI_UNSUPPORTED_PARAMS = new Set([
'tx-power-mode', 'tx-power', 'antenna-gain',
'country', 'installation',
'wpa-pre-shared-key', 'unicast-ciphers', 'group-ciphers', 'management-protection',
]);
private translateToWifiParams(params: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
// Virtual APs (master-interface set) inherit mode from their master — RouterOS rejects
// configuration.mode if it is sent explicitly for a virtual interface.
const isVirtualAp = !!params['master-interface'];
for (const [k, v] of Object.entries(params)) {
if (DeviceCollector.WIFI_UNSUPPORTED_PARAMS.has(k)) continue;
if (k === 'mode' && isVirtualAp) continue;
const mapped = DeviceCollector.WIFI_FIELD_MAP[k];
out[mapped ?? k] = v;
}
return out;
}
// ─── Bridge helpers ───────────────────────────────────────────────────────
async getBridges(): Promise<Record<string, string>[]> {
return this.client.execute('/interface/bridge/print').catch(() => []);
}
async getBridgePorts(): Promise<Record<string, string>[]> {
return this.client.execute('/interface/bridge/port/print').catch(() => []);
}
/**
* Add, update, or remove a bridge port membership for a wifi interface.
* Pass bridge=null to remove the interface from any bridge it's in.
*/
async setInterfaceBridge(
ifaceName: string,
bridge: string | null,
pvid?: number,
): Promise<void> {
const ports = await this.getBridgePorts();
const existing = ports.find(p => p['interface'] === ifaceName);
if (!bridge) {
if (existing) {
await this.client.execute('/interface/bridge/port/remove', { '.id': existing['.id'] });
}
return;
}
const extra: Record<string, string> = {};
if (pvid !== undefined && pvid > 0) extra['pvid'] = String(pvid);
if (existing) {
await this.client.execute('/interface/bridge/port/set', {
'.id': existing['.id'], bridge, ...extra,
});
} else {
await this.client.execute('/interface/bridge/port/add', {
interface: ifaceName, bridge, ...extra,
});
}
}
// ─── Live wireless interface list (enriched with bridge port data) ─────────
async getWirelessInterfaces(): Promise<Record<string, string>[]> {
const pkg = await this.detectWifiPackage();
if (pkg === 'none') return [];
const [rawIfaces, bridgePorts] = await Promise.all([
pkg === 'wifi'
? this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[])
: this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
this.getBridgePorts(),
]);
const ifaces = pkg === 'wifi'
? rawIfaces.map(r => this.normalizeWifiInterface(r))
: rawIfaces;
// Enrich each interface with its bridge port membership (if any)
const portByIface = new Map(bridgePorts.map(p => [p['interface'], p]));
return ifaces.map(iface => {
const port = portByIface.get(iface['name']);
if (!port) return iface;
return {
...iface,
bridge: port['bridge'] || '',
'bridge-pvid': port['pvid'] || '',
'bridge-port-id': port['.id'] || '',
};
});
}
/**
* Returns the next available interface name (e.g. wifi3, wifi4 …) by
* fetching the live list from the device and skipping any names already in use.
* Uses the correct prefix for the package in use (wifi vs wlan).
*/
async getNextInterfaceName(): Promise<string> {
const pkg = await this.detectWifiPackage();
const prefix = pkg === 'wifi' ? 'wifi' : 'wlan';
const raw = pkg === 'wifi'
? await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[])
: await this.client.execute('/interface/wireless/print').catch(() => [] as Record<string, string>[]);
const existing = new Set(raw.map(i => i['name'] || ''));
let idx = 1;
while (existing.has(`${prefix}${idx}`)) idx++;
return `${prefix}${idx}`;
}
async setWirelessInterface(name: string, params: Record<string, string>): Promise<void> {
const pkg = await this.detectWifiPackage();
if (pkg === 'wifi') {
await this.client.execute('/interface/wifi/set', { '.id': name, ...this.translateToWifiParams(params) });
} else {
// Legacy wireless: drop inline security params — not supported directly on the interface
const { passphrase, 'authentication-types': _at, ...legacyParams } = params;
void passphrase; void _at;
await this.client.execute('/interface/wireless/set', { '.id': name, ...legacyParams });
}
}
async addWirelessInterface(params: Record<string, string>): Promise<void> {
const pkg = await this.detectWifiPackage();
if (pkg === 'wifi') {
await this.client.execute('/interface/wifi/add', this.translateToWifiParams(params));
} else {
// Legacy wireless: drop inline security params — not supported directly on the interface
const { passphrase, 'authentication-types': _at, ...legacyParams } = params;
void passphrase; void _at;
await this.client.execute('/interface/wireless/add', legacyParams);
}
}
async removeWirelessInterface(name: string): Promise<void> {
const pkg = await this.detectWifiPackage();
const cmd = pkg === 'wifi' ? '/interface/wifi/remove' : '/interface/wireless/remove';
await this.client.execute(cmd, { '.id': name });
}
async getSecurityProfilesLive(): Promise<Record<string, string>[]> {
const pkg = await this.detectWifiPackage();
if (pkg === 'wifi') {
// Check for named /interface/wifi/security profiles first.
const named = await this.client.execute('/interface/wifi/security/print').catch(() => [] as Record<string, string>[]);
if (named.length > 0) {
return named.map(p => ({
'name': p['name'],
'mode': 'dynamic-keys',
'authentication-types': p['authentication-types'] || '',
'passphrase': p['passphrase'] || '',
'encryption': p['encryption'] || '',
'ft': p['ft'] || 'false',
'ft-over-ds': p['ft-over-ds'] || 'false',
}));
}
// No named profiles — synthesize from inline interface security.* fields.
const ifaces = await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[]);
return ifaces
.filter(r => r['security.authentication-types'] || r['security.passphrase'])
.map(r => ({
'name': r['name'],
'mode': 'dynamic-keys',
'authentication-types': r['security.authentication-types'] || '',
'passphrase': r['security.passphrase'] || '',
'encryption': r['security.encryption'] || '',
'ft': r['security.ft'] || 'false',
'ft-over-ds': r['security.ft-over-ds'] || 'false',
}));
}
return this.client.execute('/interface/wireless/security-profiles/print').catch(() => []);
}
async addSecurityProfile(params: Record<string, string>): Promise<void> {
const pkg = await this.detectWifiPackage();
if (pkg === 'wifi') {
const secParams: Record<string, string> = {};
if (params['name']) secParams['name'] = params['name'];
if (params['authentication-types']) secParams['authentication-types'] = params['authentication-types'];
if (params['passphrase']) secParams['passphrase'] = params['passphrase'];
if (params['encryption']) secParams['encryption'] = params['encryption'];
await this.client.execute('/interface/wifi/security/add', secParams);
} else {
// Legacy wireless package uses wpa-pre-shared-key / wpa2-pre-shared-key
const legacyParams: Record<string, string> = { ...params };
if (params['passphrase']) {
legacyParams['wpa-pre-shared-key'] = params['passphrase'];
legacyParams['wpa2-pre-shared-key'] = params['passphrase'];
delete legacyParams['passphrase'];
}
await this.client.execute('/interface/wireless/security-profiles/add', legacyParams);
}
}
async setSecurityProfile(name: string, params: Record<string, string>): Promise<void> {
const pkg = await this.detectWifiPackage();
if (pkg === 'wifi') {
const secParams: Record<string, string> = { '.id': name };
if (params['mode']) secParams['mode'] = params['mode'];
if (params['authentication-types']) secParams['authentication-types'] = params['authentication-types'];
if (params['passphrase']) secParams['passphrase'] = params['passphrase'];
if (params['encryption']) secParams['encryption'] = params['encryption'];
await this.client.execute('/interface/wifi/security/set', secParams);
} else {
const legacyParams: Record<string, string> = { ...params };
if (params['passphrase']) {
legacyParams['wpa-pre-shared-key'] = params['passphrase'];
legacyParams['wpa2-pre-shared-key'] = params['passphrase'];
delete legacyParams['passphrase'];
}
await this.client.execute('/interface/wireless/security-profiles/set', { '.id': name, ...legacyParams });
}
}
async removeSecurityProfile(name: string): Promise<void> {
const pkg = await this.detectWifiPackage();
const cmd = pkg === 'wifi'
? '/interface/wifi/security/remove'
: '/interface/wireless/security-profiles/remove';
await this.client.execute(cmd, { '.id': name });
}
async getWifiRadioInfo(): Promise<Record<string, string>[]> {
const pkg = await this.detectWifiPackage();
if (pkg !== 'wifi') return [];
return this.client.execute('/interface/wifi/radio/print').catch(() => []);
}
async getWirelessRegistrationTable(): Promise<Record<string, string>[]> {
const pkg = await this.detectWifiPackage();
const cmd = pkg === 'wifi'
? '/interface/wifi/registration-table/print'
: '/interface/wireless/registration-table/print';
return this.client.execute(cmd).catch(() => []);
}
async getWirelessMonitor(iface: string): Promise<Record<string, string>[]> {
const pkg = await this.detectWifiPackage();
const cmd = pkg === 'wifi' ? '/interface/wifi/monitor' : '/interface/wireless/monitor';
return this.client.execute(cmd, { '.id': iface, once: '' }).catch(() => []);
}
async scanWireless(iface: string, durationMs = 5_000): Promise<Record<string, string>[]> {
const pkg = await this.detectWifiPackage();
const cmd = pkg === 'wifi' ? '/interface/wifi/scan' : '/interface/wireless/scan';
return this.client.executeStreaming(cmd, { '.id': iface }, durationMs).catch(() => []);
}
// Run a spectral scan on a wifi interface for ~10 s and return the raw rows.
// Only supported on the new wifi package — returns [] if unavailable.
async collectSpectralScan(iface: string): Promise<Record<string, string>[]> {
const pkg = await this.detectWifiPackage();
if (pkg !== 'wifi') return [];
return this.client
.executeStreaming('/interface/wifi/spectral-scan', { '.id': iface }, 10_000)
.catch(() => []);
}
// ─── Write-back operations ─────────────────────────────────────────────────
async setInterfaceEnabled(name: string, enabled: boolean): Promise<void> {
const cmd = enabled ? '/interface/enable' : '/interface/disable';
await this.client.execute(cmd, { numbers: name });
}
async setInterfaceComment(name: string, comment: string): Promise<void> {
await this.client.execute('/interface/set', { numbers: name, comment });
}
async getDetailedInterface(name: string): Promise<Record<string, string> | null> {
const results = await this.client.execute('/interface/print', { detail: '' }, [`?name=${name}`]);
return results[0] || null;
}
async getRoutingTable(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/route/print', { detail: '' });
}
async getFirewallRules(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/filter/print', { detail: '' });
}
async addFirewallRule(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/filter/add', params);
}
async updateFirewallRule(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/filter/set', { '.id': id, ...params });
}
async deleteFirewallRule(id: string): Promise<void> {
await this.client.execute('/ip/firewall/filter/remove', { '.id': id });
}
async getNatRules(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/nat/print', { detail: '' });
}
async addNatRule(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/nat/add', params);
}
async updateNatRule(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/nat/set', { '.id': id, ...params });
}
async deleteNatRule(id: string): Promise<void> {
await this.client.execute('/ip/firewall/nat/remove', { '.id': id });
}
// ─── Bridge ────────────────────────────────────────────────────────────────
async setBridgeVlanFiltering(bridgeName: string, enabled: boolean): Promise<void> {
const bridges = await this.client.execute('/interface/bridge/print');
const bridge = bridges.find((b) => b['name'] === bridgeName);
if (!bridge || !bridge['.id']) throw new Error(`Bridge '${bridgeName}' not found`);
await this.client.execute('/interface/bridge/set', {
'.id': bridge['.id'],
'vlan-filtering': enabled ? 'yes' : 'no',
});
}
async getSystemResource(): Promise<Record<string, string>> {
const res = await this.client.execute('/system/resource/print');
return res[0] || {};
}
async getSystemIdentity(): Promise<string> {
const res = await this.client.execute('/system/identity/print');
return res[0]?.['name'] || '';
}
async setBridgePortPvid(bridge: string, port: string, pvid: number): Promise<void> {
const ports = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${port}`, `?bridge=${bridge}`]);
if (ports[0]?.['.id']) {
await this.client.execute('/interface/bridge/port/set', { '.id': ports[0]['.id'], pvid: String(pvid) });
}
}
// ─── MTU ──────────────────────────────────────────────────────────────────
async setInterfaceMtu(name: string, mtu: number): Promise<void> {
// Step 1: raise l2mtu so RouterOS doesn't cap the L3 mtu below the requested value.
// This silently fails on logical interfaces (bridge, vlan) where l2mtu is derived —
// that is safe because RouterOSClient now properly drains !done after !trap.
await this.client.execute('/interface/set', {
numbers: name,
'l2mtu': String(mtu + 8),
}).catch(() => {});
// Step 2: set the L3 mtu — always runs regardless of l2mtu outcome above.
await this.client.execute('/interface/set', { numbers: name, mtu: String(mtu) });
}
// ─── PoE ──────────────────────────────────────────────────────────────────
async setPoeOut(name: string, poeOut: 'auto-on' | 'forced-on' | 'off'): Promise<void> {
const ports = await this.client
.execute('/interface/ethernet/poe/print', {}, [`?name=${name}`])
.catch(() => []);
if (ports[0]?.['.id']) {
await this.client.execute('/interface/ethernet/poe/set', {
'.id': ports[0]['.id'],
'poe-out': poeOut,
});
}
}
async getPoEStatus(name: string): Promise<Record<string, string> | null> {
const result = await this.client
.execute('/interface/ethernet/poe/print', {}, [`?name=${name}`])
.catch(() => []);
return result[0] || null;
}
async getAllPoEStatus(): Promise<Record<string, string>[]> {
return this.client.execute('/interface/ethernet/poe/print', { detail: '' }).catch(() => []);
}
async collectPoePower(): Promise<void> {
try {
const ports = await this.client
.execute('/interface/ethernet/poe/monitor', { once: '' })
.catch(() => [] as Record<string, string>[]);
if (ports.length === 0) return;
const writeApi = getWriteApi();
for (const port of ports) {
const name = port['name'] || port['port-name'] || '';
if (!name) continue;
const watts = parseFloat(port['poe-out-voltage'] || '0') * parseFloat(port['poe-out-current'] || '0') / 1000;
const currentMa = parseFloat(port['poe-out-current'] || '0');
const voltageV = parseFloat(port['poe-out-voltage'] || '0');
writeApi.writePoint(
new Point('poe_power')
.tag('device_id', String(this.device.id))
.tag('device_name', this.device.name)
.tag('port', name)
.floatField('watts', parseFloat(watts.toFixed(2)))
.floatField('current_ma', currentMa)
.floatField('voltage_v', voltageV)
.timestamp(new Date())
);
}
await writeApi.flush().catch((e) => console.error('InfluxDB PoE flush error:', e));
} catch (err) {
console.error(`[${this.device.name}] Failed to collect PoE power:`, err);
}
}
// ─── Port VLAN config ─────────────────────────────────────────────────────
async setPortVlanConfig(
portName: string,
pvid: number,
taggedVlans: number[],
untaggedVlans: number[]
): Promise<void> {
// 1. Find the bridge port entry and set PVID
const bridgePorts = await this.client
.execute('/interface/bridge/port/print', {}, [`?interface=${portName}`])
.catch(() => []);
const bridgePortEntry = bridgePorts[0];
if (bridgePortEntry?.['.id']) {
await this.client.execute('/interface/bridge/port/set', {
'.id': bridgePortEntry['.id'],
pvid: String(pvid),
});
}
const bridge = bridgePortEntry?.['bridge'] || '';
if (!bridge) return;
// 2. Update tagged VLANs in bridge VLAN table
for (const vlanId of taggedVlans) {
const existing = await this.client
.execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`])
.catch(() => []);
if (existing[0]?.['.id']) {
const currentTagged = this.parseList(existing[0]['tagged'] || '');
const currentUntagged = this.parseList(existing[0]['untagged'] || '');
const newTagged = [...new Set([...currentTagged, portName])];
const newUntagged = currentUntagged.filter((p) => p !== portName);
await this.client.execute('/interface/bridge/vlan/set', {
'.id': existing[0]['.id'],
tagged: newTagged.join(','),
untagged: newUntagged.join(','),
});
}
}
// 3. Update untagged VLANs in bridge VLAN table
for (const vlanId of untaggedVlans) {
const existing = await this.client
.execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`])
.catch(() => []);
if (existing[0]?.['.id']) {
const currentTagged = this.parseList(existing[0]['tagged'] || '');
const currentUntagged = this.parseList(existing[0]['untagged'] || '');
const newUntagged = [...new Set([...currentUntagged, portName])];
const newTagged = currentTagged.filter((p) => p !== portName);
await this.client.execute('/interface/bridge/vlan/set', {
'.id': existing[0]['.id'],
tagged: newTagged.join(','),
untagged: newUntagged.join(','),
});
}
}
}
/**
* Ensure a given interface is an untagged member of `vlanId` in the bridge
* VLAN table. Looks up which bridge the interface belongs to, then either
* updates an existing VLAN entry or creates a new one.
*/
async ensureVlanMembership(interfaceName: string, vlanId: number): Promise<void> {
const bridgePorts = await this.client
.execute('/interface/bridge/port/print', {}, [`?interface=${interfaceName}`])
.catch(() => []);
const bridgePort = (bridgePorts[0] as Record<string, string> | undefined);
const bridge = bridgePort?.['bridge'];
if (!bridge) return; // interface not in a bridge yet
const existing = await this.client
.execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`])
.catch(() => []);
const entry = (existing[0] as Record<string, string> | undefined);
if (entry?.['.id']) {
const currentTagged = this.parseList(entry['tagged'] || '');
const currentUntagged = this.parseList(entry['untagged'] || '');
if (!currentUntagged.includes(interfaceName)) {
const newUntagged = [...currentUntagged, interfaceName];
const newTagged = currentTagged.filter(p => p !== interfaceName);
await this.client.execute('/interface/bridge/vlan/set', {
'.id': entry['.id'],
tagged: newTagged.join(','),
untagged: newUntagged.join(','),
});
}
} else {
await this.client.execute('/interface/bridge/vlan/add', {
bridge,
'vlan-ids': String(vlanId),
untagged: interfaceName,
});
}
}
// ─── System Config ────────────────────────────────────────────────────────
async getSystemConfig(): Promise<{
identity: string;
ntp: Record<string, string>;
dns: Record<string, string>;
}> {
const [identity, ntp, dns] = await Promise.all([
this.client.execute('/system/identity/print').catch(() => [{}]),
this.client.execute('/system/ntp/client/print').catch(() => [{}]),
this.client.execute('/ip/dns/print').catch(() => [{}]),
]);
const ntpConfig: Record<string, string> = { ...(ntp[0] as Record<string, string>) };
// RouterOS v7 removed primary-ntp/secondary-ntp; servers live in a separate list
if (!ntpConfig['primary-ntp']) {
const servers = await this.client
.execute('/system/ntp/client/servers/print')
.catch(() => []);
if (servers[0]) ntpConfig['primary-ntp'] = (servers[0] as Record<string, string>)['address'] || '';
if (servers[1]) ntpConfig['secondary-ntp'] = (servers[1] as Record<string, string>)['address'] || '';
}
// RouterOS 7 DNS returns booleans as "true"/"false" instead of "yes"/"no"
const dnsRaw = (dns[0] as Record<string, string>) || {};
const dnsNormalized: Record<string, string> = {};
for (const [k, v] of Object.entries(dnsRaw)) {
dnsNormalized[k] = v === 'true' ? 'yes' : v === 'false' ? 'no' : v;
}
return {
identity: (identity[0] as Record<string, string>)?.['name'] || '',
ntp: ntpConfig,
dns: dnsNormalized,
};
}
async setSystemIdentity(name: string): Promise<void> {
await this.client.execute('/system/identity/set', { name });
}
async setNtpConfig(enabled: boolean, primaryNtp: string, secondaryNtp: string): Promise<void> {
// Try RouterOS v6 format first (primary-ntp / secondary-ntp as direct properties)
try {
await this.client.execute('/system/ntp/client/set', {
enabled: enabled ? 'yes' : 'no',
'primary-ntp': primaryNtp,
'secondary-ntp': secondaryNtp,
});
return;
} catch {
// RouterOS v7 dropped primary-ntp/secondary-ntp — fall through to server-list approach
}
// RouterOS v7: enabled flag is separate; servers are managed as a list
await this.client.execute('/system/ntp/client/set', {
enabled: enabled ? 'yes' : 'no',
});
// Replace existing server entries
const existing = await this.client
.execute('/system/ntp/client/servers/print')
.catch(() => []);
for (const s of existing) {
await this.client
.execute('/system/ntp/client/servers/remove', { '.id': (s as Record<string, string>)['.id'] })
.catch(() => {});
}
if (primaryNtp) {
await this.client.execute('/system/ntp/client/servers/add', { address: primaryNtp });
}
if (secondaryNtp) {
await this.client.execute('/system/ntp/client/servers/add', { address: secondaryNtp });
}
}
async setDnsConfig(servers: string, allowRemoteRequests: boolean): Promise<void> {
await this.client.execute('/ip/dns/set', {
servers,
'allow-remote-requests': allowRemoteRequests ? 'yes' : 'no',
});
}
// ─── IP Addresses ─────────────────────────────────────────────────────────
async getIpAddresses(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/address/print', { detail: '' }).catch(() => []);
}
async addIpAddress(address: string, interfaceName: string): Promise<void> {
await this.client.execute('/ip/address/add', { address, interface: interfaceName });
}
async removeIpAddress(id: string): Promise<void> {
await this.client.execute('/ip/address/remove', { numbers: id });
}
// ─── Clock / Time ─────────────────────────────────────────────────────────
async getClockConfig(): Promise<{ date: string; time: string; timezone: string }> {
const result = await this.client.execute('/system/clock/print').catch(() => [{}]);
const r = (result[0] || {}) as Record<string, string>;
// RouterOS date format: "mar/25/2026" → "2026-03-25"
const months: Record<string, string> = {
jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06',
jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12',
};
let isoDate = '';
const rosDate = r['date'] || '';
if (rosDate) {
if (/^\d{4}-\d{2}-\d{2}$/.test(rosDate)) {
// RouterOS 7 may already return ISO format
isoDate = rosDate;
} else {
// RouterOS 6 format: "mar/25/2026"
const parts = rosDate.split('/');
if (parts.length === 3) {
const [mon, day, year] = parts;
const monthNum = months[mon?.toLowerCase()];
if (monthNum && day && year) {
isoDate = `${year}-${monthNum}-${day.padStart(2, '0')}`;
}
}
}
}
return {
date: isoDate,
time: (r['time'] || '').substring(0, 5), // "HH:MM:SS" → "HH:MM"
timezone: r['time-zone-name'] || 'UTC',
};
}
async setClockConfig(params: { date?: string; time?: string; timezone?: string }): Promise<void> {
const months: Record<string, string> = {
'01': 'jan', '02': 'feb', '03': 'mar', '04': 'apr', '05': 'may', '06': 'jun',
'07': 'jul', '08': 'aug', '09': 'sep', '10': 'oct', '11': 'nov', '12': 'dec',
};
const args: Record<string, string> = {};
if (params.date) {
// "2026-03-25" → "mar/25/2026"
const [year, month, day] = params.date.split('-');
args['date'] = `${months[month] || 'jan'}/${day}/${year}`;
}
if (params.time) args['time'] = params.time.length === 5 ? `${params.time}:00` : params.time;
if (params.timezone) args['time-zone-name'] = params.timezone;
if (Object.keys(args).length) {
await this.client.execute('/system/clock/set', args);
}
}
// ─── Route management ─────────────────────────────────────────────────────
async addRoute(dstAddress: string, gateway: string, distance?: number, comment?: string): Promise<void> {
const params: Record<string, string> = { 'dst-address': dstAddress, gateway };
if (distance !== undefined) params['distance'] = String(distance);
if (comment) params['comment'] = comment;
await this.client.execute('/ip/route/add', params);
}
async removeRoute(routeId: string): Promise<void> {
await this.client.execute('/ip/route/remove', { numbers: routeId });
}
// ─── VLAN management ──────────────────────────────────────────────────────
async addBridgeVlan(bridge: string, vlanId: number, taggedPorts: string[], untaggedPorts: string[]): Promise<void> {
const params: Record<string, string> = { bridge, 'vlan-ids': String(vlanId) };
if (taggedPorts.length) params['tagged'] = taggedPorts.join(',');
if (untaggedPorts.length) params['untagged'] = untaggedPorts.join(',');
await this.client.execute('/interface/bridge/vlan/add', params);
}
async updateBridgeVlan(bridge: string, vlanId: number, taggedPorts: string[], untaggedPorts: string[]): Promise<void> {
await this.removeBridgeVlan(bridge, vlanId);
await this.addBridgeVlan(bridge, vlanId, taggedPorts, untaggedPorts);
}
async removeBridgeVlan(bridge: string, vlanId: number): Promise<void> {
const entries = await this.client
.execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`])
.catch(() => []);
for (const entry of entries) {
const id = (entry as Record<string, string>)['.id'];
if (id) await this.client.execute('/interface/bridge/vlan/remove', { numbers: id }).catch(() => {});
}
}
// ─── Bond (LAG / LACP) management ────────────────────────────────────────
// RouterOS uses '1sec' / '30sec' for lacp-rate, not 'fast' / 'slow'
private mapLacpRate(rate: string): string {
if (rate === 'fast') return '1sec';
if (rate === 'slow') return '30sec';
return rate;
}
// RouterOS uses 'layer-2', 'layer-2-and-3', 'layer-3-and-4'
private mapHashPolicy(policy: string): string {
if (policy === 'layer2') return 'layer-2';
if (policy === 'layer2+3') return 'layer-2-and-3';
if (policy === 'layer3+4') return 'layer-3-and-4';
return policy;
}
async createBond(name: string, slaves: string[], mode: string, opts: {
lacpRate?: string; hashPolicy?: string; mtu?: number; minLinks?: number;
}): Promise<void> {
// Remove each slave from any bridge, recording the first bridge found
let originalBridge: string | null = null;
for (const slave of slaves) {
const bridgePorts = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${slave}`]).catch(() => []);
for (const bp of bridgePorts) {
if (bp['.id']) {
if (!originalBridge) originalBridge = bp['bridge'] ?? null;
await this.client.execute('/interface/bridge/port/remove', { '.id': bp['.id'] });
}
}
}
const params: Record<string, string> = { name, slaves: slaves.join(','), mode };
if (opts.lacpRate) params['lacp-rate'] = this.mapLacpRate(opts.lacpRate);
if (opts.hashPolicy) params['transmit-hash-policy'] = this.mapHashPolicy(opts.hashPolicy);
if (opts.mtu) params['mtu'] = String(opts.mtu);
if (opts.minLinks != null) params['min-links'] = String(opts.minLinks);
await this.client.execute('/interface/bonding/add', params);
// Add the new bond interface to the same bridge the slaves were removed from
if (originalBridge) {
await this.client.execute('/interface/bridge/port/add', {
bridge: originalBridge,
interface: name,
}).catch(() => {});
}
}
async updateBond(name: string, slaves: string[], mode: string, opts: {
lacpRate?: string; hashPolicy?: string; mtu?: number; minLinks?: number;
}): Promise<void> {
const list = await this.client.execute('/interface/bonding/print', {}, [`?name=${name}`]);
const id = list[0]?.['.id'];
if (!id) throw new Error(`Bond '${name}' not found on device`);
const params: Record<string, string> = { '.id': id, slaves: slaves.join(','), mode };
if (opts.lacpRate) params['lacp-rate'] = this.mapLacpRate(opts.lacpRate);
if (opts.hashPolicy) params['transmit-hash-policy'] = this.mapHashPolicy(opts.hashPolicy);
if (opts.mtu) params['mtu'] = String(opts.mtu);
if (opts.minLinks != null) params['min-links'] = String(opts.minLinks);
await this.client.execute('/interface/bonding/set', params);
}
async deleteBond(name: string): Promise<void> {
const list = await this.client.execute('/interface/bonding/print', {}, [`?name=${name}`]);
const id = list[0]?.['.id'];
if (!id) throw new Error(`Bond '${name}' not found on device`);
const slaves = (list[0]?.['slaves'] ?? '').split(',').map((s: string) => s.trim()).filter(Boolean);
// Check if the bond is itself a bridge member
const bondBridgePorts = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${name}`]).catch(() => []);
const bridgeName = bondBridgePorts[0]?.['bridge'] ?? null;
if (bondBridgePorts[0]?.['.id']) {
await this.client.execute('/interface/bridge/port/remove', { '.id': bondBridgePorts[0]['.id'] });
}
await this.client.execute('/interface/bonding/remove', { '.id': id });
// Re-add each slave to the bridge the bond was in
if (bridgeName) {
for (const slave of slaves) {
await this.client.execute('/interface/bridge/port/add', {
bridge: bridgeName,
interface: slave,
}).catch(() => {});
}
}
}
// ─── Hardware Health ──────────────────────────────────────────────────────
async getHardware(): Promise<{
health: Record<string, string>[];
disks: Record<string, string>[];
}> {
const [health, externalDisks, resource] = await Promise.all([
this.client.execute('/system/health/print').catch(() => []),
this.client.execute('/disk/print').catch(() => []),
this.client.execute('/system/resource/print').catch(() => []),
]);
const disks: Record<string, string>[] = [];
// Internal flash storage from system resource (values are raw bytes)
const res = (resource[0] || {}) as Record<string, string>;
const hddTotal = res['hdd-total'] || res['total-hdd-space'] || '';
const hddFree = res['hdd-free'] || res['free-hdd-space'] || '';
if (hddTotal) {
disks.push({
name: 'flash',
label: 'Internal Flash',
type: 'flash',
total: hddTotal,
free: hddFree,
});
}
// External / additional disks (values are human-readable, e.g. "7.5GiB")
for (const d of externalDisks) {
disks.push(d as Record<string, string>);
}
return { health, disks };
}
// ─── Firmware updates ─────────────────────────────────────────────────────
async checkForUpdates(): Promise<Record<string, string>> {
await this.client.execute('/system/package/update/check-for-updates').catch(() => {});
// Give the device a moment to complete the check
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
const result = await this.client
.execute('/system/package/update/print')
.catch(() => [] as Record<string, string>[]);
return (result[0] as Record<string, string>) || {};
}
async installUpdate(): Promise<void> {
await this.client.execute('/system/package/update/install');
}
async checkRouterboardUpgrade(): Promise<{ currentFirmware: string; upgradeFirmware: string; upgradeAvailable: boolean }> {
const result = await this.client.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]);
const rb = (result[0] as Record<string, string>) || {};
const currentFirmware = (rb['current-firmware'] || '').trim();
const upgradeFirmware = (rb['upgrade-firmware'] || '').trim();
const upgradeAvailable = Boolean(upgradeFirmware && currentFirmware && upgradeFirmware !== currentFirmware);
return { currentFirmware, upgradeFirmware, upgradeAvailable };
}
async installRouterboardUpgrade(): Promise<void> {
await this.client.execute('/system/routerboard/upgrade');
await this.client.execute('/system/reboot');
}
async reboot(): Promise<void> {
await this.client.execute('/system/reboot');
}
// ─── Ethernet monitor / SFP DDM ───────────────────────────────────────────
async getPortMonitor(name: string): Promise<Record<string, string>> {
const results = await this.client
.execute('/interface/ethernet/monitor', { numbers: name, once: '' })
.catch(() => []);
return (results[0] as Record<string, string>) || {};
}
async setFecMode(name: string, fecMode: string): Promise<void> {
await this.client.execute('/interface/ethernet/set', { numbers: name, 'fec-mode': fecMode });
}
async setFlowControl(name: string, txFc: string, rxFc: string): Promise<void> {
await this.client.execute('/interface/ethernet/set', {
numbers: name,
'tx-flow-control': txFc,
'rx-flow-control': rxFc,
});
}
async setAutoNegotiation(name: string, autoNeg: boolean, speed?: string): Promise<void> {
const params: Record<string, string> = {
numbers: name,
'auto-negotiation': autoNeg ? 'yes' : 'no',
};
if (!autoNeg && speed) params['speed'] = speed;
await this.client.execute('/interface/ethernet/set', params);
}
// ─── Routing Protocols ────────────────────────────────────────────────────
async getOspfData(): Promise<{
instances: Record<string, string>[];
areas: Record<string, string>[];
interfaceTemplates: Record<string, string>[];
neighbors: Record<string, string>[];
}> {
const [instances, areas, interfaceTemplates, neighbors] = await Promise.all([
this.client.execute('/routing/ospf/instance/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
this.client.execute('/routing/ospf/area/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
// ROS7 uses interface-template, ROS6 uses interface
this.client.execute('/routing/ospf/interface-template/print', { detail: '' })
.catch(() => this.client.execute('/routing/ospf/interface/print', { detail: '' }).catch(() => [] as Record<string, string>[])),
this.client.execute('/routing/ospf/neighbor/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
]);
return { instances, areas, interfaceTemplates, neighbors };
}
async addOspfInstance(params: Record<string, string>): Promise<void> {
await this.client.execute('/routing/ospf/instance/add', params);
}
async removeOspfInstance(id: string): Promise<void> {
await this.client.execute('/routing/ospf/instance/remove', { '.id': id });
}
async addOspfArea(params: Record<string, string>): Promise<void> {
await this.client.execute('/routing/ospf/area/add', params);
}
async removeOspfArea(id: string): Promise<void> {
await this.client.execute('/routing/ospf/area/remove', { '.id': id });
}
async getBgpData(): Promise<{
connections: Record<string, string>[];
sessions: Record<string, string>[];
templates: Record<string, string>[];
}> {
// ROS7 uses /routing/bgp/connection, ROS6 uses /routing/bgp/peer
const [connections, sessions, templates] = await Promise.all([
this.client.execute('/routing/bgp/connection/print', { detail: '' })
.catch(() => this.client.execute('/routing/bgp/peer/print', { detail: '' }).catch(() => [] as Record<string, string>[])),
this.client.execute('/routing/bgp/session/print', { detail: '' }).catch(() => [] as Record<string, string>[]),
this.client.execute('/routing/bgp/template/print', { detail: '' })
.catch(() => this.client.execute('/routing/bgp/instance/print', { detail: '' }).catch(() => [] as Record<string, string>[])),
]);
return { connections, sessions, templates };
}
async addBgpConnection(params: Record<string, string>): Promise<void> {
// Try ROS7 path first, then ROS6
await this.client.execute('/routing/bgp/connection/add', params)
.catch(() => this.client.execute('/routing/bgp/peer/add', params));
}
async removeBgpConnection(id: string): Promise<void> {
await this.client.execute('/routing/bgp/connection/remove', { '.id': id })
.catch(() => this.client.execute('/routing/bgp/peer/remove', { '.id': id }));
}
async getRoutingTablesData(): Promise<Record<string, string>[]> {
return this.client.execute('/routing/table/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addRoutingTable(params: Record<string, string>): Promise<void> {
await this.client.execute('/routing/table/add', params);
}
async removeRoutingTable(id: string): Promise<void> {
await this.client.execute('/routing/table/remove', { '.id': id });
}
async getRouteFiltersData(): Promise<{
rules: Record<string, string>[];
chains: string[];
}> {
// ROS7 uses /routing/filter/rule, ROS6 uses /routing/filter
const rules = await this.client.execute('/routing/filter/rule/print', { detail: '' })
.catch(() => this.client.execute('/routing/filter/print', { detail: '' }).catch(() => [] as Record<string, string>[]));
const chains = [...new Set(rules.map(r => r['chain']).filter(Boolean))];
return { rules, chains };
}
async addFilterRule(params: Record<string, string>): Promise<void> {
await this.client.execute('/routing/filter/rule/add', params)
.catch(() => this.client.execute('/routing/filter/add', params));
}
async updateFilterRule(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/routing/filter/rule/set', { '.id': id, ...params })
.catch(() => this.client.execute('/routing/filter/set', { '.id': id, ...params }));
}
async removeFilterRule(id: string): Promise<void> {
await this.client.execute('/routing/filter/rule/remove', { '.id': id })
.catch(() => this.client.execute('/routing/filter/remove', { '.id': id }));
}
async getRouterIds(): Promise<Record<string, string>[]> {
return this.client.execute('/routing/id/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
// ─── Network Services ─────────────────────────────────────────────────────────
// DHCP ────────────────────────────────────────────────────────────────────────
async getDhcpInterfaces(): Promise<Record<string, string>[]> {
const all = await this.client.execute('/interface/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
// Exclude interface types that can't meaningfully serve as a DHCP binding
const excluded = new Set(['pppoe-out', 'pptp-client', 'l2tp-client', 'ovpn-client', 'sstp-client', 'lte', 'lo', 'ipip', 'eoip', 'gre', 'vxlan', 'wireguard']);
return all.filter(i => !excluded.has(i['type'] || ''));
}
async getDhcpServers(protocol: 'ipv4' | 'ipv6'): Promise<Record<string, string>[]> {
const cmd = protocol === 'ipv4' ? '/ip/dhcp-server/print' : '/ipv6/dhcp-server/print';
return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addDhcpServer(params: Record<string, string>, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server';
await this.client.execute(`${base}/add`, params);
}
async updateDhcpServer(id: string, params: Record<string, string>, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server';
await this.client.execute(`${base}/set`, { '.id': id, ...params });
}
async removeDhcpServer(id: string, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server';
await this.client.execute(`${base}/remove`, { '.id': id });
}
async setDhcpServerDisabled(id: string, disabled: boolean, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server';
const cmd = disabled ? `${base}/disable` : `${base}/enable`;
await this.client.execute(cmd, { '.id': id });
}
async getDhcpPools(protocol: 'ipv4' | 'ipv6'): Promise<Record<string, string>[]> {
const cmd = protocol === 'ipv4' ? '/ip/pool/print' : '/ipv6/pool/print';
return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addDhcpPool(params: Record<string, string>, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/pool' : '/ipv6/pool';
await this.client.execute(`${base}/add`, params);
}
async removeDhcpPool(id: string, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/pool' : '/ipv6/pool';
await this.client.execute(`${base}/remove`, { '.id': id });
}
async getDhcpLeases(protocol: 'ipv4' | 'ipv6'): Promise<Record<string, string>[]> {
const cmd = protocol === 'ipv4'
? '/ip/dhcp-server/lease/print'
: '/ipv6/dhcp-server/binding/print';
return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addStaticDhcpLease(params: Record<string, string>, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/dhcp-server/lease' : '/ipv6/dhcp-server/binding';
await this.client.execute(`${base}/add`, params);
}
async removeStaticDhcpLease(id: string, protocol: 'ipv4' | 'ipv6'): Promise<void> {
const base = protocol === 'ipv4' ? '/ip/dhcp-server/lease' : '/ipv6/dhcp-server/binding';
await this.client.execute(`${base}/remove`, { '.id': id });
}
// DNS ─────────────────────────────────────────────────────────────────────────
async getDnsSettings(): Promise<Record<string, string>> {
const rows = await this.client.execute('/ip/dns/print', {}).catch(() => [] as Record<string, string>[]);
const row = rows[0] ?? {};
// RouterOS 7 DNS returns booleans as "true"/"false" instead of "yes"/"no"
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(row)) {
out[k] = v === 'true' ? 'yes' : v === 'false' ? 'no' : v;
}
return out;
}
async setDnsSettings(settings: {
servers?: string;
allow_remote_requests?: boolean;
max_udp_packet_size?: string;
cache_size?: string;
cache_max_ttl?: string;
}): Promise<void> {
const params: Record<string, string> = {};
if (settings.servers !== undefined) params['servers'] = settings.servers;
if (settings.allow_remote_requests !== undefined) {
params['allow-remote-requests'] = settings.allow_remote_requests ? 'yes' : 'no';
}
if (settings.max_udp_packet_size) params['max-udp-packet-size'] = settings.max_udp_packet_size;
if (settings.cache_size) params['cache-size'] = settings.cache_size;
if (settings.cache_max_ttl) params['cache-max-ttl'] = settings.cache_max_ttl;
await this.client.execute('/ip/dns/set', params);
}
async getDnsStaticEntries(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/dns/static/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addDnsStaticEntry(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/dns/static/add', params);
}
async updateDnsStaticEntry(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/dns/static/set', { '.id': id, ...params });
}
async removeDnsStaticEntry(id: string): Promise<void> {
await this.client.execute('/ip/dns/static/remove', { '.id': id });
}
async flushDnsCache(): Promise<void> {
await this.client.execute('/ip/dns/cache/flush', {});
}
// NTP ─────────────────────────────────────────────────────────────────────────
async getNtpSettings(): Promise<{
server: Record<string, string>;
client: Record<string, string>;
}> {
// RouterOS 7 NTP module returns boolean fields as "true"/"false" rather than
// the "yes"/"no" used by every other RouterOS API. Normalize here so the rest
// of the app can use a consistent contract.
const normalizeBools = (r: Record<string, string>): Record<string, string> => {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(r)) {
out[k] = v === 'true' ? 'yes' : v === 'false' ? 'no' : v;
}
return out;
};
const [server, client] = await Promise.allSettled([
this.client.execute('/system/ntp/server/print', {}).then(r => normalizeBools(r[0] ?? {})),
this.client.execute('/system/ntp/client/print', {}).then(r => normalizeBools(r[0] ?? {})),
]);
return {
server: server.status === 'fulfilled' ? server.value : {},
client: client.status === 'fulfilled' ? client.value : {},
};
}
async setNtpSettings(settings: {
server_enabled?: boolean;
server_broadcast?: boolean;
server_manycast?: boolean;
client_enabled?: boolean;
client_mode?: string;
client_servers?: string;
}): Promise<void> {
const serverParams: Record<string, string> = {};
if (settings.server_enabled !== undefined) serverParams['enabled'] = settings.server_enabled ? 'yes' : 'no';
if (settings.server_broadcast !== undefined) serverParams['broadcast'] = settings.server_broadcast ? 'yes' : 'no';
if (settings.server_manycast !== undefined) serverParams['manycast'] = settings.server_manycast ? 'yes' : 'no';
if (Object.keys(serverParams).length > 0) {
await this.client.execute('/system/ntp/server/set', serverParams);
}
const clientParams: Record<string, string> = {};
if (settings.client_enabled !== undefined) clientParams['enabled'] = settings.client_enabled ? 'yes' : 'no';
if (settings.client_mode) clientParams['mode'] = settings.client_mode;
if (settings.client_servers !== undefined) clientParams['servers'] = settings.client_servers;
if (Object.keys(clientParams).length > 0) {
await this.client.execute('/system/ntp/client/set', clientParams);
}
}
// Syslog (logging actions + rules) ───────────────────────────────────────────
async getSyslogActions(): Promise<Record<string, string>[]> {
return this.client.execute('/system/logging/action/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addSyslogAction(params: Record<string, string>): Promise<void> {
await this.client.execute('/system/logging/action/add', params);
}
async updateSyslogAction(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/system/logging/action/set', { '.id': id, ...params });
}
async removeSyslogAction(id: string): Promise<void> {
await this.client.execute('/system/logging/action/remove', { '.id': id });
}
async getSyslogRules(): Promise<Record<string, string>[]> {
return this.client.execute('/system/logging/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addSyslogRule(params: Record<string, string>): Promise<void> {
await this.client.execute('/system/logging/add', params);
}
async updateSyslogRule(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/system/logging/set', { '.id': id, ...params });
}
async removeSyslogRule(id: string): Promise<void> {
await this.client.execute('/system/logging/remove', { '.id': id });
}
async toggleSyslogRule(id: string, disabled: boolean): Promise<void> {
await this.client.execute('/system/logging/set', { '.id': id, 'disabled': disabled ? 'yes' : 'no' });
}
// WireGuard ───────────────────────────────────────────────────────────────────
async getWireGuardInterfaces(): Promise<Record<string, string>[]> {
return this.client.execute('/interface/wireguard/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addWireGuardInterface(params: Record<string, string>): Promise<Record<string, string>[]> {
await this.client.execute('/interface/wireguard/add', params);
// Return updated list so caller gets the new interface with its generated public key
return this.getWireGuardInterfaces();
}
async updateWireGuardInterface(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/interface/wireguard/set', { '.id': id, ...params });
}
async removeWireGuardInterface(id: string): Promise<void> {
await this.client.execute('/interface/wireguard/remove', { '.id': id });
}
async setWireGuardInterfaceDisabled(id: string, disabled: boolean): Promise<void> {
const cmd = disabled ? '/interface/wireguard/disable' : '/interface/wireguard/enable';
await this.client.execute(cmd, { '.id': id });
}
async getWireGuardPeers(): Promise<Record<string, string>[]> {
return this.client.execute('/interface/wireguard/peers/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addWireGuardPeer(params: Record<string, string>): Promise<void> {
await this.client.execute('/interface/wireguard/peers/add', params);
}
async updateWireGuardPeer(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/interface/wireguard/peers/set', { '.id': id, ...params });
}
async removeWireGuardPeer(id: string): Promise<void> {
await this.client.execute('/interface/wireguard/peers/remove', { '.id': id });
}
}