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 { aggregateBridgeVlans, portVlanMembership, expandVlanIds } from '../../utils/vlan'; import { classifyWifiRole, isCapsmanManaged, parseCapsmanStatus, parseCapStatus, normalizeRadios, macIndexKeys, parseRadioMonitor, resolveDatapath, clientsPerRadio, buildMacIndex, matchRadiosToDevices, type WifiRole, } from './capsman'; import { parseLteMonitor, overallQuality, detectChanges, type LteStatus, } from '../../utils/lte'; import { bytesSince, periodKey, shouldSend } from '../../utils/dataCap'; import { parseDeviceLogTime } from '../../utils/deviceTime'; 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; } /** Normalise a RouterOS band string + frequency (MHz) to a coarse RF band tag. */ function rfBand(band: string | undefined, frequency: number): '2.4' | '5' | '6' | 'unknown' { const b = (band || '').toLowerCase(); if (b.includes('6ghz') || (frequency >= 5925 && frequency <= 7125)) return '6'; if (b.includes('5ghz') || (frequency >= 4900 && frequency < 5925)) return '5'; if (b.includes('2ghz') || (frequency >= 2400 && frequency < 2500)) return '2.4'; return 'unknown'; } /** A coarse RF band tag → the RouterOS-style band prefix we store for display. */ function bandPrefix(frequency: number): string | null { const b = rfBand(undefined, frequency); return b === '2.4' ? '2ghz' : b === '5' ? '5ghz' : b === '6' ? '6ghz' : null; } /** * Parse the new wifi package's monitor "channel" string into frequency + width. * Format: "/[/]" e.g. "2412/ax/Ce", "5180/ax/Ceee". * The control-position letters encode width: 1→20, 2→40, 4→80, 8→160 MHz. */ function parseWifiMonitorChannel(channel: string): { frequency: number; width: string } | null { if (!channel) return null; const segs = channel.split('/'); const frequency = parseInt(segs[0], 10); if (!frequency || isNaN(frequency)) return null; const letters = (segs[2] || '').replace(/[^a-zA-Z]/g, '').length; const width = letters >= 8 ? '160mhz' : letters >= 4 ? '80mhz' : letters >= 2 ? '40mhz' : '20mhz'; return { frequency, width }; } 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 { await this.client.connect(); } disconnect(): void { this.client.disconnect(); } // ─── Fast poll (every 30s) ───────────────────────────────────────────────── async collectFast(): Promise { 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(); } if ((this.device as unknown as { has_lte?: boolean }).has_lte) { await this.collectLte(); } await this.updateDeviceStatus('online'); } // ─── Slow poll (every 5 min) ─────────────────────────────────────────────── /** * Should wireless data be collected from this device? * * `device_type` alone is wrong for CAPsMAN: a dedicated controller is usually * classified as a router, has no radios of its own, and yet owns the wireless * configuration of the entire fleet. Probing every device once and remembering * the answer costs one call on first sight and nothing afterwards, which beats * both probing forever and never noticing a controller. */ private shouldCollectWireless(): boolean { if (this.device.device_type === 'wireless_ap') return true; const role = (this.device as unknown as { wifi_role?: string | null }).wifi_role; if (role == null) return true; // never determined — probe once return role !== 'none' && role !== 'standalone'; } async collectSlow(): Promise { await this.collectInterfaces(); await this.collectIpAddressesCache(); await this.collectVlans(); await this.collectSystemInfo(); await this.collectStp(); await this.collectLte(); if (this.shouldCollectWireless()) { await this.collectWirelessInterfaces(); await this.collectSecurityProfiles(); await this.collectCapsman(); } } // ─── Log poll (every 60s) ───────────────────────────────────────────────── async collectLogs(): Promise { await this.collectEvents(); } // ─── Full initial collection ─────────────────────────────────────────────── async collectAll(): Promise { 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.collectLte(); // Wireless data belongs in a full resync too — without this, a manual sync // never refreshed radios/SSIDs (or pruned ones deleted on the device), so // the UI could only catch up on the 5-minute slow poll. if (this.shouldCollectWireless()) { await this.collectWirelessInterfaces(); await this.collectSecurityProfiles(); await this.collectCapsman(); } await this.saveFullConfig(); await this.updateDeviceStatus('online'); } // ─── System Info ────────────────────────────────────────────────────────── async collectSystemInfo(): Promise { 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[]); // Needed to place log timestamps correctly — they are device-local (#117). const clock = await this.client.execute('/system/clock/print').catch(() => [] as Record[]); 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), time_zone_name = COALESCE($7, time_zone_name), gmt_offset = COALESCE($8, gmt_offset), -- An update that has landed is no longer pending. The dedicated -- firmware check only runs once a day, so without this a device stays -- flagged "update available" for up to 24 hours after it has already -- been upgraded — including when someone upgrades it outside the -- platform entirely. The version read here is the authority. firmware_update_available = CASE WHEN NULLIF($5,'') IS NOT NULL AND latest_ros_version IS NOT NULL AND NULLIF($5,'') = latest_ros_version THEN FALSE ELSE firmware_update_available END, updated_at = NOW() WHERE id = $6`, [identityName, model, serial, firmware, rosVersion, this.device.id, clock[0]?.['time-zone-name'] || null, clock[0]?.['gmt-offset'] || null] ); } catch (err) { console.error(`[${this.device.name}] Failed to collect system info:`, err); } } // ─── Interface Stats → InfluxDB ─────────────────────────────────────────── async collectInterfaceTraffic(): Promise { 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 { 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 { try { const [ifaces, bridges, bonds] = await Promise.all([ this.client.execute('/interface/print', { detail: '' }), this.client.execute('/interface/bridge/print', { detail: '' }).catch(() => [] as Record[]), this.client.execute('/interface/bonding/print', { detail: '' }).catch(() => [] as Record[]), ]); 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>(); 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 { // Only bridges have vlan-filtering; null everywhere else keeps the column // meaningful for queries rather than defaulting to a misleading false. const vlanFiltering = resolvedType === 'bridge' ? enrichedIface['vlan-filtering'] === 'true' : null; await query( `INSERT INTO interfaces (device_id, name, type, mac_address, mtu, running, disabled, comment, speed, vlan_filtering, config_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW()) ON CONFLICT (device_id, name) DO UPDATE SET type=$3, mac_address=$4, mtu=$5, running=$6, disabled=$7, comment=$8, speed=$9, vlan_filtering=$10, config_json=$11, 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, vlanFiltering, JSON.stringify(enrichedIface), ] ); } catch (insertErr) { console.error(`[${this.device.name}] Failed to insert interface ${name}:`, insertErr); } } // Drop rows for interfaces removed on the device (deleted VLAN/bridge/bond). // Safe: /interface/print above throws on failure, so reaching this point // means we have an authoritative list rather than a failed fetch. const presentNames = allIfaces.map((i) => i['name']).filter(Boolean); await query( `DELETE FROM interfaces WHERE device_id = $1 AND NOT (name = ANY($2::text[]))`, [this.device.id, presentNames] ); } 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 { try { const rows = await this.client .execute('/ip/address/print', { detail: '' }) .catch(() => [] as Record[]); 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 { try { // Bridge VLAN table (CRS switches / bridge-based switching). // // Both fetches are tracked for success separately: a stale cache beats a // wrongly-empty one, so pruning only happens when we know the device // actually reported an authoritative list. let bridgeVlans: Record[] = []; let bridgeVlansOk = true; try { bridgeVlans = await this.client.execute('/interface/bridge/vlan/print', { detail: '' }); } catch { bridgeVlansOk = false; } const agg = aggregateBridgeVlans(bridgeVlans); for (const v of agg) { await query( `INSERT INTO vlans (device_id, vlan_id, name, bridge, vlan_ids, tagged_ports, untagged_ports, config_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW()) ON CONFLICT (device_id, bridge, vlan_id) DO UPDATE SET name=$3, vlan_ids=$5, tagged_ports=$6, untagged_ports=$7, config_json=$8, updated_at=NOW()`, [ this.device.id, v.vlanId, v.name || `VLAN ${v.vlanId}`, v.bridge, v.spec, v.tagged, v.untagged, JSON.stringify(v.rows.length === 1 ? v.rows[0] : v.rows), ] ); } const seenVlans = agg.map((v) => ({ bridge: v.bridge, vlanId: v.vlanId })); if (bridgeVlansOk) { // Drop VLANs deleted on the device. Comparing composite keys needs two // parallel arrays because Postgres has no tuple-array membership test. await query( `DELETE FROM vlans WHERE device_id = $1 AND NOT (bridge || ':' || vlan_id::text = ANY($2::text[]))`, [this.device.id, seenVlans.map((v) => `${v.bridge}:${v.vlanId}`)] ); } // Per-port membership, derived from the same VLAN table so the port editor // can tell a trunk from an access port. let bridgePorts: Record[] = []; let bridgePortsOk = true; try { bridgePorts = await this.client.execute('/interface/bridge/port/print', { detail: '' }); } catch { bridgePortsOk = false; } const membership = portVlanMembership(bridgeVlans); const seenPorts: string[] = []; for (const port of bridgePorts) { const pvid = parseInt(port['pvid'] || '1', 10); const bridge = port['bridge'] || ''; const portName = port['interface'] || ''; if (!portName || !bridge) continue; seenPorts.push(`${bridge}:${portName}`); const m = membership.get(portName) ?? { tagged: [], untagged: [] }; const taggedIds = m.tagged.map(String); const untaggedIds = m.untagged.map(String); const allIds = [...new Set([...m.tagged, ...m.untagged])].sort((a, b) => a - b).map(String); await query( `INSERT INTO bridge_vlan_entries (device_id, bridge, port, pvid, tagged, vlan_ids, tagged_vlan_ids, untagged_vlan_ids, config_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW()) ON CONFLICT (device_id, bridge, port) DO UPDATE SET pvid=$4, tagged=$5, vlan_ids=$6, tagged_vlan_ids=$7, untagged_vlan_ids=$8, config_json=$9, updated_at=NOW()`, [ this.device.id, bridge, portName, pvid, taggedIds.length > 0, allIds, taggedIds, untaggedIds, JSON.stringify(port), ] ); } if (bridgePortsOk) { await query( `DELETE FROM bridge_vlan_entries WHERE device_id = $1 AND NOT (bridge || ':' || port = ANY($2::text[]))`, [this.device.id, seenPorts] ); } } 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 { 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[]) : this.client.execute(regTableCmd).catch(() => [] as Record[]), this.client.execute('/interface/bridge/host/print').catch(() => []), ]); // DHCP hostname + IP lookup const dhcpHostnames: Record = {}; const dhcpIPs: Record = {}; 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 = {}; const wifiInterface: Record = {}; const wifiTx: Record = {}; const wifiRx: Record = {}; 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 = {}; const arpInterfaces: Record = {}; const arpFailed = new Set(); // 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 = {}; 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([ ...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, first_seen) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,NOW(),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(), first_seen=COALESCE(clients.first_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 { 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 { 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); } } /** * A log timestamp as a real instant, using the device's own clock. * * Falls back to the collection time only when the device timezone is unknown * or the format is unrecognised — approximate, but never confidently wrong by * a whole timezone, which is what the previous version was (#117). */ private parseLogTime(timeStr: string): Date { const d = this.device as unknown as { time_zone_name?: string | null; gmt_offset?: string | null }; const parsed = parseDeviceLogTime(timeStr, { timeZoneName: d.time_zone_name, gmtOffset: d.gmt_offset, }); return parsed ?? 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 { if (this.device.device_type !== 'switch') return; try { const bridges = await this.client .execute('/interface/bridge/print', { detail: '' }) .catch(() => [] as Record[]); const bridgeNames = bridges.map((b) => b['name']).filter(Boolean); if (bridgeNames.length === 0) return; const macIpMap: Record = {}; // 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[]); 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 { 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 = {}; 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 { 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[]); const settings = result[0] as Record | 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 { 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[]), this.client.execute('/snmp/community/print').catch(() => [] as Record[]), ]); const g = (globals as Record[])[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[]).find(c => c['name'] !== 'public') ?? (communities as Record[])[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 { // 1. Global settings const globalParams: Record = { 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 = { 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[]); const existing = (communities as Record[]).find(c => c['name'] === config.community_name) ?? (communities as Record[])[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 => { const m = new Map(); 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 { 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 { 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 { await this.snapshotConfig('full-sync'); } async updateDeviceStatus(status: string): Promise { await query( `UPDATE devices SET status = $1, last_seen = NOW(), updated_at = NOW() WHERE id = $2`, [status, this.device.id] ); } // ─── CAPsMAN ────────────────────────────────────────────────────────────── /** * Split this device's physical radios into ones we may configure locally and ones * a CAPsMAN controller owns. * * Writing to a provisioned radio does not reliably take effect: the change is * either rejected or silently replaced the next time the controller provisions the * CAP, while the API reports success. Callers use this to skip those radios and * say so rather than claim a change that will not survive. */ async partitionRadios(): Promise<{ local: Record[]; capsmanOwned: string[] }> { const pkg = await this.detectWifiPackage(); if (pkg === 'none') return { local: [], capsmanOwned: [] }; const raw = pkg === 'wifi' ? await this.client.execute('/interface/wifi/print', { detail: '' }).catch(() => [] as Record[]) : await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]); const physicals = raw.filter((r) => !r['master-interface'] && r['name']); if (pkg !== 'wifi') return { local: physicals, capsmanOwned: [] }; const role = await this.detectWifiRole(); const owned = physicals.filter((r) => isCapsmanManaged(r, role)); return { local: physicals.filter((r) => !isCapsmanManaged(r, role)), capsmanOwned: owned.map((r) => r['name']).filter(Boolean), }; } /** * Refuse a local edit to an interface a controller owns. * * RouterOS will not let a provisioned interface be changed or removed locally, and * where it appears to accept one the controller undoes it. Failing here with an * explanation beats reporting success for a change that never happened — which is * what the delete path did, removing the cached row while the interface stayed. */ async assertNotCapsmanManaged(interfaceName: string): Promise { const pkg = await this.detectWifiPackage(); if (pkg !== 'wifi') return; const role = await this.detectWifiRole(); if (role !== 'cap' && role !== 'controller_cap') return; const rows = await this.client.execute('/interface/wifi/print', { detail: '' }) .catch(() => [] as Record[]); const row = rows.find((r) => r['name'] === interfaceName); if (!row || !isCapsmanManaged(row, role)) return; const status = parseCapsmanStatus(row); throw new Error( `${interfaceName} is provisioned by CAPsMAN` + (status?.controllerMac ? ` from controller ${status.controllerMac}` : '') + `, so it cannot be changed on this device. Edit it in the controller's configuration ` + `instead — a local change would be discarded the next time the CAP is provisioned.` ); } private wifiRoleCache: WifiRole | null = null; /** * Is this device a CAPsMAN controller, a CAP, both, or neither? * * Read from the two feature toggles rather than inferred from missing data — an * AP whose SSID is blank because the controller owns it looks identical to one * that is simply unconfigured, and only this tells them apart. */ async detectWifiRole(): Promise { if (this.wifiRoleCache !== null) return this.wifiRoleCache; const pkg = await this.detectWifiPackage(); if (pkg !== 'wifi') { // Legacy /caps-man is a separate API tree and is not covered yet; treat the // old wireless package as standalone rather than guessing. this.wifiRoleCache = pkg === 'none' ? 'none' : 'standalone'; return this.wifiRoleCache; } const [capsman, cap, ifaces] = await Promise.all([ this.client.execute('/interface/wifi/capsman/print').catch(() => null), this.client.execute('/interface/wifi/cap/print').catch(() => null), this.client.execute('/interface/wifi/print').catch(() => [] as Record[]), ]); this.wifiRoleCache = classifyWifiRole(capsman, cap, ifaces.length > 0); return this.wifiRoleCache; } /** * Controller-side inventory: the radios it manages, the named configurations, and * the provisioning rules binding them together. * * Remote radios are attributed to managed devices by MAC. That is a hardware * identity and therefore globally unique, so unlike an address it cannot mean two * different devices in two different segments — the failure that made topology * unreliable before v0.20.1. */ async collectCapsman(): Promise { try { const role = await this.detectWifiRole(); if (role !== 'controller' && role !== 'controller_cap') { // Not a controller: clear any inventory left from when it was one. await query(`DELETE FROM capsman_radios WHERE controller_device_id = $1`, [this.device.id]); await query(`DELETE FROM capsman_configurations WHERE controller_device_id = $1`, [this.device.id]); await query(`DELETE FROM capsman_provisioning WHERE controller_device_id = $1`, [this.device.id]); return; } const [radioRows, configRows, provRows, ifaceRows, regRows] = await Promise.all([ this.client.execute('/interface/wifi/radio/print').catch(() => null), this.client.execute('/interface/wifi/configuration/print', { detail: '' }).catch(() => null), this.client.execute('/interface/wifi/provisioning/print', { detail: '' }).catch(() => null), // `detail` is required: master-interface, which links a virtual AP to its // radio, is absent from the plain listing. this.client.execute('/interface/wifi/print', { detail: '' }).catch(() => [] as Record[]), this.client.execute('/interface/wifi/registration-table/print').catch(() => [] as Record[]), ]); // Clients register on the virtual AP, not the physical radio, so monitor's // registered-peers reads zero on a busy access point. Count from the // registration table and attribute each entry to its owning radio instead. const clientCounts = clientsPerRadio(ifaceRows, regRows); // Fleet-wide MAC → device index, including a five-octet prefix key because a // radio MAC often differs from the interface MAC in the final octet only. const macRows = await query<{ device_id: number; mac_address: string }>( `SELECT device_id, mac_address FROM interfaces WHERE mac_address IS NOT NULL UNION SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL` ).catch(() => []); const macIndex = buildMacIndex(macRows); if (radioRows !== null) { const radios = normalizeRadios(radioRows); // `current-channels` from /interface/wifi/radio lists every channel the // radio *supports* — kilobytes of text on a multi-band radio — not the one // it is on. Monitor reports the operating channel, and it carries the peer // counts too, which under CAPsMAN only the controller knows: the CAP's own // registration table is empty when traffic is processed centrally. const live = new Map>(); await Promise.all(radios.map(async (radio) => { const iface = radio.interfaceName; if (!iface) return; const mon = await this.client .execute('/interface/wifi/monitor', { '.id': iface, once: '' }) .catch(() => [] as Record[]); live.set(iface, parseRadioMonitor(mon[0])); })); // The controller mirrors each CAP interface locally, which is also where the // provisioned SSID is visible — the CAP's own row has none. const ssidByRadioMac = new Map(); for (const iface of ifaceRows) { const mac = (iface['radio-mac'] || '').toUpperCase(); const ssid = iface['configuration.ssid'] || iface['ssid']; if (mac && ssid && !ssidByRadioMac.has(mac)) ssidByRadioMac.set(mac, ssid); } const attributed = new Map( matchRadiosToDevices(radios, macIndex, this.device.id) .map((m) => [m.radio.radioMac ?? '', m.deviceId]) ); const seen: string[] = []; for (const radio of radios) { if (!radio.radioMac) continue; seen.push(radio.radioMac); const capInfo = parseCapStatus(radio.raw); const st = radio.interfaceName ? live.get(radio.interfaceName) : undefined; const matched = attributed.get(radio.radioMac) ?? null; await query( `INSERT INTO capsman_radios (controller_device_id, radio_mac, interface_name, local, hw_type, current_channel, remote_cap_name, matched_device_id, config_json, state, registered_peers, authorized_peers, tx_power, ssid, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW()) ON CONFLICT (controller_device_id, radio_mac) DO UPDATE SET interface_name=$3, local=$4, hw_type=$5, current_channel=$6, remote_cap_name=$7, matched_device_id=$8, config_json=$9, state=$10, registered_peers=$11, authorized_peers=$12, tx_power=$13, ssid=$14, updated_at=NOW()`, [ this.device.id, radio.radioMac, radio.interfaceName, radio.local, radio.hwType, st?.channel ?? null, capInfo?.capMac ?? radio.raw['name'] ?? null, matched, JSON.stringify(radio.raw), st?.state ?? null, clientCounts.get(radio.radioMac) ?? st?.registeredPeers ?? null, st?.authorizedPeers ?? null, st?.txPower ?? null, ssidByRadioMac.get(radio.radioMac) ?? null, ] ); } await query( `DELETE FROM capsman_radios WHERE controller_device_id = $1 AND NOT (radio_mac = ANY($2::text[]))`, [this.device.id, seen] ); } if (configRows !== null) { const seen: string[] = []; for (const cfg of configRows) { const name = cfg['name']; if (!name) continue; seen.push(name); await query( `INSERT INTO capsman_configurations (controller_device_id, name, ssid, mode, band, security, authentication_types, config_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW()) ON CONFLICT (controller_device_id, name) DO UPDATE SET ssid=$3, mode=$4, band=$5, security=$6, authentication_types=$7, config_json=$8, updated_at=NOW()`, [ this.device.id, name, cfg['ssid'] ?? null, cfg['mode'] ?? null, cfg['channel.band'] ?? cfg['band'] ?? null, cfg['security'] ?? null, cfg['security.authentication-types'] ?? null, JSON.stringify(cfg), ] ); } await query( `DELETE FROM capsman_configurations WHERE controller_device_id = $1 AND NOT (name = ANY($2::text[]))`, [this.device.id, seen] ); } if (provRows !== null) { const seen: string[] = []; for (const prov of provRows) { const rosId = prov['.id']; if (!rosId) continue; seen.push(rosId); await query( `INSERT INTO capsman_provisioning (controller_device_id, ros_id, action, master_configuration, slave_configurations, radio_mac, comment, disabled, config_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW()) ON CONFLICT (controller_device_id, ros_id) DO UPDATE SET action=$3, master_configuration=$4, slave_configurations=$5, radio_mac=$6, comment=$7, disabled=$8, config_json=$9, updated_at=NOW()`, [ this.device.id, rosId, prov['action'] ?? null, prov['master-configuration'] ?? null, prov['slave-configurations'] ?? null, (prov['radio-mac'] || '').toUpperCase() || null, prov['comment'] ?? null, prov['disabled'] === 'true', JSON.stringify(prov), ] ); } await query( `DELETE FROM capsman_provisioning WHERE controller_device_id = $1 AND NOT (ros_id = ANY($2::text[]))`, [this.device.id, seen] ); } } catch (err) { console.error(`[${this.device.name}] Failed to collect CAPsMAN data:`, (err as Error).message); } } // ─── 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): Record { const out: Record = { ...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 { try { const pkg = await this.detectWifiPackage(); if (pkg === 'none') return; // Recorded per device so the UI can explain a blank SSID as delegation to a // controller rather than leaving it looking like an unconfigured radio. const role = await this.detectWifiRole(); // The package is recorded alongside the role because TX-retry data only // exists on the legacy 'wireless' driver, and a panel that can never have // data on this fleet should not occupy space (issue #96). await query(`UPDATE devices SET wifi_role = $2, wifi_package = $3 WHERE id = $1`, [this.device.id, role, pkg]) .catch(() => { /* advisory; never fail collection over it */ }); // null = the fetch itself failed. Distinguished from an empty list (device // genuinely has no wireless interfaces) so a transient API error can never // be mistaken for "everything was deleted" during the prune below. const rawList = pkg === 'wifi' ? await this.client.execute('/interface/wifi/print').catch(() => null) : await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => null); if (rawList === null) return; const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList; // New wifi package: the configured channel is usually "auto", so // /interface/wifi/print returns no frequency. Pull the live operating // channel from monitor for each physical radio; virtual APs inherit their // master's channel. (Legacy "wireless" print already includes frequency.) const liveFreq: Record = {}; const liveWidth: Record = {}; if (pkg === 'wifi') { const physical = wlans.filter(w => w['name'] && !w['master-interface']); await Promise.all(physical.map(async (w) => { const rname = w['name']; try { const mon = await this.client.execute('/interface/wifi/monitor', { '.id': rname, once: '' }); const parsed = parseWifiMonitorChannel(mon[0]?.['channel'] || ''); if (parsed) { liveFreq[rname] = parsed.frequency; liveWidth[rname] = parsed.width; } } catch { /* ignore per-radio monitor failures */ } })); } for (const wlan of wlans) { const name = wlan['name']; if (!name) continue; // A CAP holds no local configuration for provisioned interfaces, so the // status line is the only place an SSID or channel appears. Fall back to it // rather than storing nulls that render as an empty, broken-looking AP. const capsmanStatus = parseCapsmanStatus(wlan); const managed = isCapsmanManaged(wlan, role); const master = wlan['master-interface']; const liveF = liveFreq[name] ?? (master ? liveFreq[master] : undefined); const liveW = liveWidth[name] ?? (master ? liveWidth[master] : undefined); const freq = liveF ?? parseInt(wlan['frequency'] || '0', 10); const txPow = parseInt(wlan['tx-power'] || '0', 10); const gain = parseInt(wlan['antenna-gain'] || '0', 10); // "channel: 5280/ax/eCee" — frequency first, then the PHY mode. const statusFreq = parseInt((capsmanStatus?.channel ?? '').split('/')[0] || '', 10); const effFreq = !isNaN(freq) && freq > 0 ? freq : (!isNaN(statusFreq) && statusFreq > 0 ? statusFreq : freq); const bandStr = wlan['band'] || (liveF ? bandPrefix(liveF) : null) || (!isNaN(statusFreq) && statusFreq > 0 ? bandPrefix(statusFreq) : null); const widthStr = wlan['channel-width'] || liveW || null; 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, managed_by_capsman, radio_mac, capsman_controller_mac, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,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, managed_by_capsman=$18, radio_mac=$19, capsman_controller_mac=$20, updated_at=NOW()`, [ this.device.id, name, wlan['ssid'] || capsmanStatus?.ssid || null, wlan['mode'] || capsmanStatus?.mode || null, bandStr || null, !isNaN(effFreq) && effFreq > 0 ? effFreq : null, widthStr, !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), managed, (wlan['radio-mac'] || '').toUpperCase() || null, capsmanStatus?.controllerMac ?? null, ] ); } // Drop rows for interfaces that no longer exist on the device (e.g. a // virtual AP removed directly in WinBox/WebFig). Without this the upsert // above only ever adds, so deleted radios linger in the UI forever. const present = wlans.map((w) => w['name']).filter(Boolean); await query( `DELETE FROM wireless_interfaces WHERE device_id = $1 AND NOT (name = ANY($2::text[]))`, [this.device.id, present] ); } catch (err) { console.error(`[${this.device.name}] Failed to collect wireless interfaces:`, err); } } // ─── Wireless Stats → InfluxDB ──────────────────────────────────────────── async collectWirelessStats(): Promise { 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[]) : this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]), pkg === 'wifi' ? this.client.execute('/interface/wifi/registration-table/print').catch(() => [] as Record[]) : this.client.execute('/interface/wireless/registration-table/print').catch(() => [] as Record[]), ]); const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList; if (wlans.length === 0) return; const clientsByIface: Record = {}; // Per-radio transmit link quality. Legacy "wireless" registration tables // expose tx-ccq (0-100, higher = better); we derive a retry % as 100-CCQ // and average it per radio. The new "wifi" package omits CCQ, so this is // simply left empty there (the quality panel shows a clean empty state). const ccqSumByIface: Record = {}; const ccqCountByIface: Record = {}; for (const r of regTable) { const iface = r['interface']; if (!iface) continue; clientsByIface[iface] = (clientsByIface[iface] || 0) + 1; const ccqRaw = r['tx-ccq']; if (ccqRaw != null && ccqRaw !== '') { const ccq = parseInt(ccqRaw, 10); if (!isNaN(ccq)) { ccqSumByIface[iface] = (ccqSumByIface[iface] || 0) + ccq; ccqCountByIface[iface] = (ccqCountByIface[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); // Per-radio TX retry % time series (only when CCQ data is available) const ccqCount = ccqCountByIface[name] || 0; if (ccqCount > 0) { const avgCcq = ccqSumByIface[name] / ccqCount; const retryPct = Math.max(0, Math.min(100, 100 - avgCcq)); writeApi.writePoint( new Point('wireless_radio_quality') .tag('device_id', String(this.device.id)) .tag('device_name', this.device.name) .tag('interface', name) .tag('band', rfBand(wlan['band'], parseInt(wlan['frequency'] || '0', 10))) .floatField('tx_retry_pct', Math.round(retryPct * 10) / 10) .intField('avg_ccq', Math.round(avgCcq)) .timestamp(new Date()) ); } 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); } } /** Remove stored security profiles that no longer exist on the device. */ private async pruneSecurityProfiles(present: string[]): Promise { await query( `DELETE FROM wireless_security_profiles WHERE device_id = $1 AND NOT (name = ANY($2::text[]))`, [this.device.id, present] ); } // ─── Security Profiles → Postgres ───────────────────────────────────────── async collectSecurityProfiles(): Promise { 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. // null = fetch failed (leave existing rows alone); [] = genuinely none. const named = await this.client.execute('/interface/wifi/security/print').catch(() => null); if (named === null) return; let sourceList: Array<{ name: string; raw: Record }>; if (named.length > 0) { sourceList = named.map(p => ({ name: p['name'], raw: p })); } else { const ifaceList = await this.client.execute('/interface/wifi/print').catch(() => null); if (ifaceList === null) return; sourceList = ifaceList .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), ] ); } await this.pruneSecurityProfiles(sourceList.map((s) => s.name).filter(Boolean)); return; } // Legacy wireless package const profiles = await this.client .execute('/interface/wireless/security-profiles/print') .catch(() => null); if (profiles === null) return; 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), ] ); } await this.pruneSecurityProfiles(profiles.map((p) => p['name']).filter(Boolean)); } 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 = { '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): Record { const out: Record = {}; // 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[]> { return this.client.execute('/interface/bridge/print').catch(() => []); } async getInterfacesLive(): Promise[]> { return this.client.execute('/interface/print').catch(() => []); } async getBridgePorts(): Promise[]> { 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 { 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 = {}; 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[]> { const pkg = await this.detectWifiPackage(); if (pkg === 'none') return []; const [rawIfaces, bridgePorts, datapaths] = await Promise.all([ pkg === 'wifi' ? this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) : this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]), this.getBridgePorts(), pkg === 'wifi' ? this.client.execute('/interface/wifi/datapath/print', { detail: '' }).catch(() => [] as Record[]) : Promise.resolve([] as Record[]), ]); const rawNormalized = pkg === 'wifi' ? rawIfaces.map(r => this.normalizeWifiInterface(r)) : rawIfaces; // This endpoint reads the device live rather than the cache, so the CAPsMAN // annotation has to be applied here too — otherwise the Radios tab shows blank // fields with no indication that a controller owns them. const role = await this.detectWifiRole(); const ifaces = rawNormalized.map((iface, i) => { const status = parseCapsmanStatus(rawIfaces[i] ?? iface); const managed = isCapsmanManaged(rawIfaces[i] ?? iface, role); if (!managed && !status) return iface; return { ...iface, managed_by_capsman: 'true', ...(status?.controllerMac ? { capsman_controller_mac: status.controllerMac } : {}), ...(status?.ssid && !iface['ssid'] ? { ssid: status.ssid } : {}), ...(status?.mode && !iface['mode'] ? { mode: status.mode } : {}), ...(status?.channel ? { capsman_channel: status.channel } : {}), }; }); // Enrich each interface with its bridge port membership (if any) const portByIface = new Map(bridgePorts.map(p => [p['interface'], p])); return ifaces.map((iface, i) => { const port = portByIface.get(iface['name']); if (!port) { // A CAPsMAN-provisioned interface is not a local bridge port, so the port // table has nothing for it and the UI would claim it has no network at all. // The bridge and VLAN it actually lands on come from the datapath. const dp = resolveDatapath(rawIfaces[i] ?? iface, datapaths); if (!dp.bridge && !dp.vlanId) return iface; return { ...iface, ...(dp.bridge ? { bridge: dp.bridge } : {}), ...(dp.vlanId ? { 'bridge-pvid': dp.vlanId } : {}), network_source: 'capsman-datapath', }; } 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 { 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[]) : await this.client.execute('/interface/wireless/print').catch(() => [] as Record[]); 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): Promise { 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): Promise { 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 { 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[]> { 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[]); 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[]); 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): Promise { const pkg = await this.detectWifiPackage(); if (pkg === 'wifi') { const secParams: Record = {}; 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 = { ...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): Promise { const pkg = await this.detectWifiPackage(); if (pkg === 'wifi') { const secParams: Record = { '.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 = { ...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 { 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[]> { const pkg = await this.detectWifiPackage(); if (pkg !== 'wifi') return []; return this.client.execute('/interface/wifi/radio/print').catch(() => []); } async getWirelessRegistrationTable(): Promise[]> { 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[]> { 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[]> { 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[]> { 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 { const cmd = enabled ? '/interface/enable' : '/interface/disable'; await this.client.execute(cmd, { numbers: name }); } async setInterfaceComment(name: string, comment: string): Promise { await this.client.execute('/interface/set', { numbers: name, comment }); } async getDetailedInterface(name: string): Promise | null> { const results = await this.client.execute('/interface/print', { detail: '' }, [`?name=${name}`]); return results[0] || null; } async getRoutingTable(): Promise[]> { return this.client.execute('/ip/route/print', { detail: '' }); } // `stats` makes RouterOS include the per-rule bytes/packets hit counters so // the UI can show which rules are matching (and flag dead, zero-hit rules). async getFirewallRules(): Promise[]> { return this.client.execute('/ip/firewall/filter/print', { detail: '', stats: '' }); } async addFirewallRule(params: Record): Promise { await this.client.execute('/ip/firewall/filter/add', params); } async updateFirewallRule(id: string, params: Record): Promise { await this.client.execute('/ip/firewall/filter/set', { '.id': id, ...params }); } async deleteFirewallRule(id: string): Promise { await this.client.execute('/ip/firewall/filter/remove', { '.id': id }); } // Reorder: RouterOS evaluates rules top-to-bottom, so order is decisive. // `destination` is the .id the rule should be placed *before* (RouterOS // semantics), or omitted to move to the end. async moveFirewallRule(id: string, destination?: string): Promise { const params: Record = { numbers: id }; if (destination) params.destination = destination; await this.client.execute('/ip/firewall/filter/move', params); } async resetFirewallCounters(): Promise { await this.client.execute('/ip/firewall/filter/reset-counters-all', {}); } async getNatRules(): Promise[]> { return this.client.execute('/ip/firewall/nat/print', { detail: '', stats: '' }); } async addNatRule(params: Record): Promise { await this.client.execute('/ip/firewall/nat/add', params); } async updateNatRule(id: string, params: Record): Promise { await this.client.execute('/ip/firewall/nat/set', { '.id': id, ...params }); } async deleteNatRule(id: string): Promise { await this.client.execute('/ip/firewall/nat/remove', { '.id': id }); } async moveNatRule(id: string, destination?: string): Promise { const params: Record = { numbers: id }; if (destination) params.destination = destination; await this.client.execute('/ip/firewall/nat/move', params); } // ─── Firewall Address Lists (reusable address objects) ────────────────────── async getAddressLists(): Promise[]> { return this.client.execute('/ip/firewall/address-list/print', { detail: '' }).catch(() => [] as Record[]); } async addAddressListEntry(params: Record): Promise { await this.client.execute('/ip/firewall/address-list/add', params); } async updateAddressListEntry(id: string, params: Record): Promise { await this.client.execute('/ip/firewall/address-list/set', { '.id': id, ...params }); } async removeAddressListEntry(id: string): Promise { await this.client.execute('/ip/firewall/address-list/remove', { '.id': id }); } // ─── Connection tracking (live active connections) ────────────────────────── async getConnections(): Promise[]> { return this.client.execute('/ip/firewall/connection/print', { detail: '' }).catch(() => [] as Record[]); } // /ip/firewall/connection/tracking holds the global conntrack state. `enabled` // is yes / no / auto (auto = on only once firewall/NAT/mangle rules exist). async getConnectionTracking(): Promise> { const rows = await this.client.execute('/ip/firewall/connection/tracking/print', {}).catch(() => [] as Record[]); return rows[0] || {}; } // ─── Simple Queues (per-client / per-subnet bandwidth control) ────────────── async getSimpleQueues(): Promise[]> { return this.client.execute('/queue/simple/print', { detail: '', stats: '' }).catch(() => [] as Record[]); } async addSimpleQueue(params: Record): Promise { await this.client.execute('/queue/simple/add', params); } async updateSimpleQueue(id: string, params: Record): Promise { await this.client.execute('/queue/simple/set', { '.id': id, ...params }); } async removeSimpleQueue(id: string): Promise { await this.client.execute('/queue/simple/remove', { '.id': id }); } // ─── Hotspot (Guest WiFi captive portal + vouchers) ───────────────────────── async getHotspotServers(): Promise[]> { return this.client.execute('/ip/hotspot/print', { detail: '' }).catch(() => [] as Record[]); } async setHotspotServerDisabled(id: string, disabled: boolean): Promise { await this.client.execute('/ip/hotspot/set', { '.id': id, disabled: disabled ? 'yes' : 'no' }); } async removeHotspotServer(id: string): Promise { await this.client.execute('/ip/hotspot/remove', { '.id': id }); } async getHotspotProfiles(): Promise[]> { return this.client.execute('/ip/hotspot/profile/print', { detail: '' }).catch(() => [] as Record[]); } async addHotspotProfile(params: Record): Promise { await this.client.execute('/ip/hotspot/profile/add', params); } async getHotspotUserProfiles(): Promise[]> { return this.client.execute('/ip/hotspot/user/profile/print', { detail: '' }).catch(() => [] as Record[]); } async addHotspotUserProfile(params: Record): Promise { await this.client.execute('/ip/hotspot/user/profile/add', params); } async getHotspotUsers(): Promise[]> { return this.client.execute('/ip/hotspot/user/print', { detail: '' }).catch(() => [] as Record[]); } async addHotspotUser(params: Record): Promise { await this.client.execute('/ip/hotspot/user/add', params); } async removeHotspotUser(id: string): Promise { await this.client.execute('/ip/hotspot/user/remove', { '.id': id }); } async getHotspotActive(): Promise[]> { return this.client.execute('/ip/hotspot/active/print', { detail: '' }).catch(() => [] as Record[]); } async disconnectHotspotActive(id: string): Promise { await this.client.execute('/ip/hotspot/active/remove', { '.id': id }); } async getWalledGarden(): Promise[]> { return this.client.execute('/ip/hotspot/walled-garden/print', { detail: '' }).catch(() => [] as Record[]); } async addWalledGardenEntry(dstHost: string, comment?: string): Promise { const params: Record = { 'dst-host': dstHost, action: 'allow' }; if (comment) params['comment'] = comment; await this.client.execute('/ip/hotspot/walled-garden/add', params); } async removeWalledGardenEntry(id: string): Promise { await this.client.execute('/ip/hotspot/walled-garden/remove', { '.id': id }); } /** * Full guest-network orchestration. Optionally creates the guest SSID itself * (a virtual AP on every physical radio) and segregates the traffic onto a * VLAN, then runs the captive-portal chain on the resulting interface: * * [virtual APs] → [bridge/VLAN wiring] → pool → gw address → DHCP → * hotspot profile → portal server → guest user profile → [masquerade NAT] * * Topologies: * - ssid + vlanId: new SSIDs joined to the main bridge with PVID=vlanId, * portal binds to a new /interface/vlan on that bridge. * - ssid only: new SSIDs joined to a dedicated guest bridge, portal * binds to that bridge (guests stay off the main LAN). * - neither: portal binds to the provided existing interface. */ async setupGuestNetwork(opts: { name: string; gatewayCidr: string; poolRange: string; dnsName?: string; rateLimit?: string; interfaceName?: string; // existing-interface path ssid?: { ssid: string; passphrase?: string }; vlanId?: number; masquerade?: boolean; }): Promise<{ server: string; profile: string; userProfile: string; pool: string; targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string; warnings: string[]; }> { const { name, ssid, vlanId, masquerade } = opts; const warnings: string[] = []; const ssidInterfaces: string[] = []; let targetInterface = opts.interfaceName || ''; let vlanInterface: string | undefined; if (ssid) { // 1. Create a virtual AP for the guest SSID on every physical radio const pkg = await this.detectWifiPackage(); if (pkg === 'none') throw new Error('No wireless package detected on this device — cannot create a guest SSID'); const raw = pkg === 'wifi' ? await this.client.execute('/interface/wifi/print', { detail: '' }).catch(() => [] as Record[]) : await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]); const { local: physicals, capsmanOwned } = await this.partitionRadios(); if (physicals.length === 0 && capsmanOwned.length === 0) { throw new Error('No physical radios found to attach the guest SSID to'); } if (capsmanOwned.length > 0) { const names = capsmanOwned.join(', '); if (physicals.length === 0) { throw new Error( `Every radio on this device (${names}) is provisioned by CAPsMAN, so a guest SSID ` + `created here would be overwritten by the controller. Create the guest network on the ` + `CAPsMAN controller instead, so it is provisioned to the access points.` ); } warnings.push( `Skipped ${names}: provisioned by CAPsMAN, so a locally created SSID would be ` + `overwritten by the controller. Add it to the controller's configuration instead.` ); } const existingBySsid = raw.filter(r => (r['ssid'] || r['configuration.ssid'] || '') === ssid.ssid && r['master-interface']); if (existingBySsid.length > 0) { // Idempotency: SSID already exists — reuse it for (const e of existingBySsid) ssidInterfaces.push(e['name']); } else { for (const radio of physicals) { const ifaceName = await this.getNextInterfaceName(); const params: Record = { name: ifaceName, 'master-interface': radio['name'], ssid: ssid.ssid, disabled: 'no', }; if (pkg !== 'wifi') params['mode'] = 'ap-bridge'; if (ssid.passphrase) { params['passphrase'] = ssid.passphrase; params['authentication-types'] = 'wpa2-psk'; } await this.addWirelessInterface(params); ssidInterfaces.push(ifaceName); } if (pkg !== 'wifi' && ssid.passphrase) { warnings.push('Legacy wireless package: passphrase requires a security profile — the guest SSID was created OPEN. Assign a security profile manually if you need WPA2.'); } } // 2. Wire the new SSIDs into the L2 topology const bridges = await this.getBridges(); if (vlanId) { const mainBridge = bridges[0]?.['name']; if (!mainBridge) throw new Error('No bridge found on this device to attach the guest VLAN to'); for (const iface of ssidInterfaces) { await this.setInterfaceBridge(iface, mainBridge, vlanId); await this.ensureVlanMembership(iface, vlanId); } // The bridge itself must be a tagged member so the L3 VLAN interface works await this.ensureBridgeTaggedMember(mainBridge, vlanId); // L3 VLAN interface the portal binds to vlanInterface = `${name}-vlan${vlanId}`; const vlans = await this.client.execute('/interface/vlan/print').catch(() => [] as Record[]); const existing = vlans.find(v => v['interface'] === mainBridge && v['vlan-id'] === String(vlanId)); if (existing) { vlanInterface = existing['name']; } else { await this.client.execute('/interface/vlan/add', { name: vlanInterface, 'vlan-id': String(vlanId), interface: mainBridge, }); } targetInterface = vlanInterface; if (bridges[0]?.['vlan-filtering'] !== 'true') { warnings.push(`Bridge "${mainBridge}" has VLAN filtering disabled — guest VLAN ${vlanId} tagging won't isolate traffic until you enable it (Ports → bridge → VLAN filtering). Enabling it can interrupt management access, so review trunk/tagged config first.`); } } else { // Dedicated guest bridge keeps guests off the main LAN const guestBridge = `${name}-br`; if (!bridges.some(b => b['name'] === guestBridge)) { await this.client.execute('/interface/bridge/add', { name: guestBridge }); } for (const iface of ssidInterfaces) { await this.setInterfaceBridge(iface, guestBridge); } targetInterface = guestBridge; } } if (!targetInterface) throw new Error('No target interface — provide interfaceName or ssid'); // 3. Captive-portal chain on the target interface const chain = await this.setupHotspot({ name, interfaceName: targetInterface, gatewayCidr: opts.gatewayCidr, poolRange: opts.poolRange, dnsName: opts.dnsName, rateLimit: opts.rateLimit, }); // 4. Masquerade so guests can reach the internet if (masquerade) { const gwIp = opts.gatewayCidr.split('/')[0]; const prefix = opts.gatewayCidr.split('/')[1] || '24'; const network = `${this.networkAddressOf(gwIp, parseInt(prefix, 10))}/${prefix}`; const natComment = `${name}-guest-masquerade`; const natRules = await this.client.execute('/ip/firewall/nat/print').catch(() => [] as Record[]); if (!natRules.some(r => (r['comment'] || '') === natComment)) { await this.client.execute('/ip/firewall/nat/add', { chain: 'srcnat', action: 'masquerade', 'src-address': network, comment: natComment, }); } } return { ...chain, targetInterface, ssidInterfaces, vlanInterface, warnings }; } /** Ensure the bridge itself is a tagged member of a VLAN (needed for L3 VLAN interfaces). */ private async ensureBridgeTaggedMember(bridge: string, vlanId: number): Promise { const rows = await this.client .execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`]) .catch(() => [] as Record[]); const entry = rows[0]; if (entry?.['.id']) { const tagged = (entry['tagged'] || '').split(',').map(s => s.trim()).filter(Boolean); if (!tagged.includes(bridge)) { await this.client.execute('/interface/bridge/vlan/set', { '.id': entry['.id'], tagged: [...tagged, bridge].join(','), }); } } else { await this.client.execute('/interface/bridge/vlan/add', { bridge, 'vlan-ids': String(vlanId), tagged: bridge, }); } } /** * Orchestrated guest-hotspot setup — the API equivalent of RouterOS's * interactive `/ip hotspot setup` wizard. Creates (idempotently, keyed on the * guest network name): IP pool → gateway address on the interface → DHCP * server + network → hotspot profile → hotspot server → guest user profile. * Returns the names it created so the route can report/rollback. */ async setupHotspot(opts: { name: string; // logical name, e.g. "guest" → pool/profile/server names derive from it interfaceName: string; // bridge/vlan/wifi interface to run the portal on gatewayCidr: string; // e.g. "10.5.50.1/24" — address added to the interface poolRange: string; // e.g. "10.5.50.10-10.5.50.254" dnsName?: string; // portal hostname, e.g. "wifi.guest" rateLimit?: string; // default guest speed, e.g. "10M/10M" (rx/tx) }): Promise<{ server: string; profile: string; userProfile: string; pool: string }> { const { name, interfaceName, gatewayCidr, poolRange, dnsName, rateLimit } = opts; const poolName = `${name}-pool`; const profileName = `${name}-profile`; const userProfileName = `${name}-guest`; const gwIp = gatewayCidr.split('/')[0]; // 1. IP pool const pools = await this.client.execute('/ip/pool/print').catch(() => []); if (!pools.some(p => p['name'] === poolName)) { await this.client.execute('/ip/pool/add', { name: poolName, ranges: poolRange }); } // 2. Gateway address on the interface const addrs = await this.client.execute('/ip/address/print').catch(() => []); if (!addrs.some(a => (a['address'] || '') === gatewayCidr && (a['interface'] || '') === interfaceName)) { await this.client.execute('/ip/address/add', { address: gatewayCidr, interface: interfaceName }); } // 3. DHCP server + network const dhcpServers = await this.client.execute('/ip/dhcp-server/print').catch(() => []); if (!dhcpServers.some(s => s['name'] === `${name}-dhcp`)) { await this.client.execute('/ip/dhcp-server/add', { name: `${name}-dhcp`, interface: interfaceName, 'address-pool': poolName, disabled: 'no', }); } const prefix = gatewayCidr.split('/')[1] || '24'; const network = this.networkAddressOf(gwIp, parseInt(prefix, 10)); const dhcpNets = await this.client.execute('/ip/dhcp-server/network/print').catch(() => []); if (!dhcpNets.some(n => (n['address'] || '') === `${network}/${prefix}`)) { await this.client.execute('/ip/dhcp-server/network/add', { address: `${network}/${prefix}`, gateway: gwIp, 'dns-server': gwIp, }); } // 4. Hotspot profile const profiles = await this.getHotspotProfiles(); if (!profiles.some(p => p['name'] === profileName)) { const params: Record = { name: profileName, 'hotspot-address': gwIp, 'login-by': 'http-chap,http-pap', }; if (dnsName) params['dns-name'] = dnsName; await this.addHotspotProfile(params); } // 5. Hotspot server const servers = await this.getHotspotServers(); if (!servers.some(s => s['name'] === name)) { await this.client.execute('/ip/hotspot/add', { name, interface: interfaceName, 'address-pool': poolName, profile: profileName, disabled: 'no', }); } // 6. Guest user profile (bandwidth cap + no inter-guest traffic) const userProfiles = await this.getHotspotUserProfiles(); if (!userProfiles.some(p => p['name'] === userProfileName)) { const params: Record = { name: userProfileName, 'shared-users': '1', }; if (rateLimit) params['rate-limit'] = rateLimit; await this.addHotspotUserProfile(params); } return { server: name, profile: profileName, userProfile: userProfileName, pool: poolName }; } /** Compute the network address for an IPv4 + prefix (e.g. 10.5.50.1/24 → 10.5.50.0). */ private networkAddressOf(ip: string, prefix: number): string { const parts = ip.split('.').map(n => parseInt(n, 10)); if (parts.length !== 4 || parts.some(isNaN)) return ip; const addr = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; const net = (addr & mask) >>> 0; return [net >>> 24, (net >>> 16) & 255, (net >>> 8) & 255, net & 255].join('.'); } // ─── IP Services (for security-posture audit + hardening) ─────────────────── async getServices(): Promise[]> { return this.client.execute('/ip/service/print', { detail: '' }).catch(() => [] as Record[]); } async setServiceDisabled(id: string, disabled: boolean): Promise { await this.client.execute('/ip/service/set', { '.id': id, disabled: disabled ? 'yes' : 'no' }); } // ─── Bridge ──────────────────────────────────────────────────────────────── async setBridgeVlanFiltering(bridgeName: string, enabled: boolean): Promise { 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> { const res = await this.client.execute('/system/resource/print'); return res[0] || {}; } async getSystemIdentity(): Promise { const res = await this.client.execute('/system/identity/print'); return res[0]?.['name'] || ''; } async setBridgePortPvid(bridge: string, port: string, pvid: number): Promise { 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 { // 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 { 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 | null> { const result = await this.client .execute('/interface/ethernet/poe/print', {}, [`?name=${name}`]) .catch(() => []); return result[0] || null; } async getAllPoEStatus(): Promise[]> { return this.client.execute('/interface/ethernet/poe/print', { detail: '' }).catch(() => []); } async collectPoePower(): Promise { try { const ports = await this.client .execute('/interface/ethernet/poe/monitor', { once: '' }) .catch(() => [] as Record[]); 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); } } /** * Send an SMS through the modem. * * Used for carrier data-cap resets, where texting a short code lifts a daily * throttle. The message is deliberately allowed to be empty — that is exactly * what several carriers expect, and an API that refused it would be useless * for the case it exists to serve. */ async sendSms(port: string, phoneNumber: string, message = ''): Promise { await this.client.execute('/tool/sms/send', { port, 'phone-number': phoneNumber, message, }); } /** * Current byte counters for one interface, for data-cap accounting. * * The filter is a RouterOS *query word* and has to be passed as one. Smuggled * in as a parameter it becomes `=?name=lte1`, which the device rejects with * "unknown parameter ?name" — and because that error was being swallowed, the * data-cap counter silently read nothing on every poll while the rest of the * feature looked healthy. Errors here are logged rather than discarded for * exactly that reason. */ async getInterfaceCounters(name: string): Promise<{ rxBytes: number; txBytes: number } | null> { let rows: Record[] | null = await this.client .execute('/interface/print', { stats: '' }, [`?name=${name}`]) .catch((err) => { console.error(`[${this.device.name}] counter read for ${name} failed:`, (err as Error).message); return null; }); // Some versions answer a filtered print with nothing at all; falling back to // the full list costs one call and removes a whole class of silent zero. if (rows && rows.length === 0) { rows = await this.client.execute('/interface/print', { stats: '' }).catch(() => null); } if (!rows) return null; const row = rows.find((r) => r['name'] === name); if (!row) { console.warn(`[${this.device.name}] interface ${name} not present in counter read`); return null; } return { rxBytes: parseInt(row['rx-byte'] || '0', 10), txBytes: parseInt(row['tx-byte'] || '0', 10), }; } // ─── LTE / cellular → Postgres + InfluxDB ───────────────────────────────── /** * Collect cellular state from every LTE interface on the device. * * Called on the fast poll (for the signal series) and on the slow poll, where * it also refreshes `devices.has_lte`. The device is only contacted when the * interface table already shows an LTE interface, so the overwhelming majority * of a wired fleet costs one local query and no API call at all. * * Field handling is deliberately forgiving. What a modem reports depends on * its chipset rather than on RouterOS, so a missing value is an ordinary state * and never a reason to abandon the rest of the row. */ async collectLte(): Promise { try { const lteInterfaces = await query<{ name: string }>( `SELECT name FROM interfaces WHERE device_id = $1 AND type = 'lte'`, [this.device.id], ); if (lteInterfaces.length === 0) { // Clear the flag if a modem was removed, so the fast poll stops looking. if ((this.device as unknown as { has_lte?: boolean }).has_lte) { await query(`UPDATE devices SET has_lte = FALSE WHERE id = $1`, [this.device.id]); (this.device as unknown as { has_lte?: boolean }).has_lte = false; } return; } if (!(this.device as unknown as { has_lte?: boolean }).has_lte) { await query(`UPDATE devices SET has_lte = TRUE WHERE id = $1`, [this.device.id]); (this.device as unknown as { has_lte?: boolean }).has_lte = true; } const configs = await this.client .execute('/interface/lte/print', { detail: '' }) .catch(() => [] as Record[]); const configByName = new Map(configs.filter(c => c['name']).map(c => [c['name'], c])); const writeApi = getWriteApi(); for (const { name } of lteInterfaces) { // `monitor` addresses the interface by name and must be told not to stream. const rows = await this.client .execute('/interface/lte/monitor', { numbers: name, once: '' }) .catch(() => [] as Record[]); if (rows.length === 0) continue; const status = parseLteMonitor(rows[0]); const config = configByName.get(name) || {}; const quality = overallQuality(status); const previous = await this.previousLteStatus(name); await this.recordLteChanges(name, previous, status); await query( `INSERT INTO lte_interfaces ( device_id, interface_name, status, data_class, modem_model, modem_revision, operator, cell_id, enb_id, sector_id, phy_cell_id, session_uptime_s, primary_band, ca_bands, rssi, rsrp, rsrq, sinr, cqi, rank_indicator, mcs, dl_modulation, quality, allowed_bands, network_mode, apn_profiles, allow_roaming, config_json, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, $19,$20,$21,$22,$23,$24,$25,$26,$27,$28,NOW()) ON CONFLICT (device_id, interface_name) DO UPDATE SET status = EXCLUDED.status, data_class = EXCLUDED.data_class, modem_model = EXCLUDED.modem_model, modem_revision = EXCLUDED.modem_revision, operator = EXCLUDED.operator, cell_id = EXCLUDED.cell_id, enb_id = EXCLUDED.enb_id, sector_id = EXCLUDED.sector_id, phy_cell_id = EXCLUDED.phy_cell_id, session_uptime_s = EXCLUDED.session_uptime_s, primary_band = EXCLUDED.primary_band, ca_bands = EXCLUDED.ca_bands, rssi = EXCLUDED.rssi, rsrp = EXCLUDED.rsrp, rsrq = EXCLUDED.rsrq, sinr = EXCLUDED.sinr, cqi = EXCLUDED.cqi, rank_indicator = EXCLUDED.rank_indicator, mcs = EXCLUDED.mcs, dl_modulation = EXCLUDED.dl_modulation, quality = EXCLUDED.quality, allowed_bands = EXCLUDED.allowed_bands, network_mode = EXCLUDED.network_mode, apn_profiles = EXCLUDED.apn_profiles, allow_roaming = EXCLUDED.allow_roaming, config_json = EXCLUDED.config_json, updated_at = NOW()`, [ this.device.id, name, status.status, status.dataClass, status.modemModel, status.modemRevision, status.operator, status.cellId, status.enbId, status.sectorId, status.phyCellId, status.sessionUptimeSeconds, status.primaryBand ? JSON.stringify(status.primaryBand) : null, JSON.stringify(status.caBands), status.rssi, status.rsrp, status.rsrq, status.sinr, status.cqi, status.ri, status.mcs, status.dlModulation, quality, config['band'] || null, config['network-mode'] || null, config['apn-profiles'] || null, config['allow-roaming'] ? config['allow-roaming'] === 'yes' : null, JSON.stringify({ ...rows[0], ...config }), ], ); await this.recordObservedBands(name, status); await this.trackDataCap(name); // Signal series. Fields are written only when the modem reported them — // a zero here would read as a signal no transmitter produces. const point = new Point('lte_signal') .tag('device_id', String(this.device.id)) .tag('device_name', this.device.name) .tag('interface', name) .tag('operator', status.operator || 'unknown') .tag('band', status.primaryBand ? `B${status.primaryBand.band}` : 'unknown') .timestamp(new Date()); let hasField = false; const addField = (key: string, value: number | null, integer = false) => { if (value == null) return; if (integer) point.intField(key, Math.round(value)); else point.floatField(key, value); hasField = true; }; addField('rsrp', status.rsrp); addField('rsrq', status.rsrq); addField('sinr', status.sinr); addField('rssi', status.rssi); addField('cqi', status.cqi, true); addField('rank', status.ri, true); addField('mcs', status.mcs, true); // Aggregated carriers, primary included — the shape of the link over time. point.intField('carriers', status.caBands.length + (status.primaryBand ? 1 : 0)); if (hasField) writeApi.writePoint(point); } } catch (error) { console.error(`LTE collection failed for ${this.device.name}:`, error); } } /** * Accumulate cellular usage and fire the carrier's reset SMS when due. * * Runs on every LTE poll rather than on a timer of its own, because the byte * delta is only meaningful between consecutive samples — a separate schedule * would either double-count or miss traffic depending on how the two drifted. * * Failures here are logged and swallowed. A data-cap rule is a convenience; * it must never be able to break the collection it rides on. */ private async trackDataCap(interfaceName: string): Promise { try { const rule = await queryOne<{ id: number; enabled: boolean; phone_number: string; message: string; threshold_bytes: string; margin_pct: number; reset_hour: number; reset_minute: number; timezone: string; cooldown_minutes: number; period_key: string | null; period_bytes: string; last_rx: string | null; last_tx: string | null; last_sent_at: string | null; }>( `SELECT * FROM lte_data_cap_rules WHERE device_id = $1 AND interface_name = $2`, [this.device.id, interfaceName] ); if (!rule) return; const counters = await this.getInterfaceCounters(interfaceName); if (!counters) { // A rule that exists but never counts is the failure the user actually // sees, so it must not be silent. console.warn(`[DataCap] ${this.device.name}/${interfaceName}: no counters available; usage not updated`); return; } const now = new Date(); const key = periodKey(now, rule.reset_hour, rule.reset_minute, rule.timezone); // A new period starts fresh, but the counter baseline carries over — // otherwise the first sample after midnight would be discarded and the // traffic between it and the previous poll would vanish. const rolled = rule.period_key !== key; const prev = rule.last_rx == null || rule.last_tx == null ? null : { rxBytes: Number(rule.last_rx), txBytes: Number(rule.last_tx) }; const delta = bytesSince(prev, counters); const periodBytes = (rolled ? 0 : Number(rule.period_bytes)) + delta; const decision = shouldSend({ periodBytes, thresholdBytes: Number(rule.threshold_bytes), marginPct: rule.margin_pct, lastSentAt: rolled ? null : (rule.last_sent_at ? new Date(rule.last_sent_at) : null), cooldownMinutes: rule.cooldown_minutes, enabled: rule.enabled, }, now); let sentAt: Date | null = rolled ? null : (rule.last_sent_at ? new Date(rule.last_sent_at) : null); // Sending the reset SMS zeroes the carrier's counter — that is what the // message is for, and it is the alternative to waiting for midnight. Our // counter has to follow, or two things go wrong: the bar keeps climbing // past an allowance that no longer applies, and usage stays above the // threshold so the rule re-fires on every cooldown for the rest of the day. let periodBytesAfter = periodBytes; if (decision.send) { try { await this.sendSms(interfaceName, rule.phone_number, rule.message); sentAt = now; periodBytesAfter = 0; await this.logDataCapSend(interfaceName, 'threshold', rule.phone_number, periodBytes, true, null); console.log(`[DataCap] ${this.device.name}/${interfaceName}: reset SMS sent to ${rule.phone_number}`); } catch (e) { await this.logDataCapSend(interfaceName, 'threshold', rule.phone_number, periodBytes, false, (e as Error).message); console.error(`[DataCap] ${this.device.name}/${interfaceName}: SMS failed:`, (e as Error).message); } } await query( `UPDATE lte_data_cap_rules SET period_key = $2, period_bytes = $3, last_rx = $4, last_tx = $5, last_sent_at = $6, updated_at = NOW() WHERE id = $1`, [rule.id, key, periodBytesAfter, counters.rxBytes, counters.txBytes, sentAt] ); } catch (e) { console.error(`[DataCap] ${this.device.name}: tracking failed:`, (e as Error).message); } } private async logDataCapSend( interfaceName: string, trigger: string, phone: string, periodBytes: number, ok: boolean, error: string | null, ): Promise { await query( `INSERT INTO lte_data_cap_sends (device_id, interface_name, trigger, phone_number, period_bytes, ok, error) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [this.device.id, interfaceName, trigger, phone, periodBytes, ok, error] ).catch(() => {}); } /** Last stored state for an interface, for comparing consecutive polls. */ private async previousLteStatus(name: string): Promise { const row = await queryOne<{ cell_id: string | null; session_uptime_s: string | null; primary_band: { band: number } | null; ca_bands: { band: number }[] | null; }>( `SELECT cell_id, session_uptime_s, primary_band, ca_bands FROM lte_interfaces WHERE device_id = $1 AND interface_name = $2`, [this.device.id, name], ); if (!row) return null; // Only the fields detectChanges compares need to be faithful here. return { status: null, dataClass: null, modemModel: null, modemRevision: null, operator: null, cellId: row.cell_id, enbId: null, sectorId: null, phyCellId: null, sessionUptimeSeconds: row.session_uptime_s == null ? null : Number(row.session_uptime_s), primaryBand: row.primary_band ? { band: row.primary_band.band, bandwidthMhz: null, earfcn: null, phyCellId: null, raw: '' } : null, caBands: (row.ca_bands || []).map(b => ({ band: b.band, bandwidthMhz: null, earfcn: null, phyCellId: null, raw: '', })), rssi: null, rsrp: null, rsrq: null, sinr: null, cqi: null, ri: null, mcs: null, dlModulation: null, }; } /** Persist handovers, re-registrations and band changes seen between polls. */ private async recordLteChanges( name: string, previous: LteStatus | null, next: LteStatus, ): Promise { const bands = [next.primaryBand, ...next.caBands] .filter(Boolean).map(b => b!.band).sort((a, b) => a - b).join(','); for (const change of detectChanges(previous, next)) { await query( `INSERT INTO lte_cell_history (device_id, interface_name, kind, detail, cell_id, enb_id, bands, rsrp, sinr) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [this.device.id, name, change.kind, change.detail, next.cellId, next.enbId, bands || null, next.rsrp, next.sinr], ); } } /** * Remember which bands have actually served this device. * * This is the evidence a band lock is checked against later. Scanning is not a * dependable alternative — it interrupts service and finds nothing at a site * served by one base station — but a band already used here demonstrably works. */ private async recordObservedBands(name: string, status: LteStatus): Promise { const bands = [...new Set( [status.primaryBand, ...status.caBands].filter(Boolean).map(b => b!.band), )]; for (const band of bands) { await query( `INSERT INTO lte_observed_bands (device_id, interface_name, band) VALUES ($1,$2,$3) ON CONFLICT (device_id, interface_name, band) DO UPDATE SET last_seen = NOW(), observations = lte_observed_bands.observations + 1`, [this.device.id, name, band], ); } } // ─── Port VLAN config ───────────────────────────────────────────────────── async setPortVlanConfig( portName: string, pvid: number, taggedVlans: number[], untaggedVlans: number[] ): Promise { // 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 { const bridgePorts = await this.client .execute('/interface/bridge/port/print', {}, [`?interface=${interfaceName}`]) .catch(() => []); const bridgePort = (bridgePorts[0] as Record | 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 | 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; dns: Record; }> { 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 = { ...(ntp[0] as Record) }; // 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)['address'] || ''; if (servers[1]) ntpConfig['secondary-ntp'] = (servers[1] as Record)['address'] || ''; } // RouterOS 7 DNS returns booleans as "true"/"false" instead of "yes"/"no" const dnsRaw = (dns[0] as Record) || {}; const dnsNormalized: Record = {}; for (const [k, v] of Object.entries(dnsRaw)) { dnsNormalized[k] = v === 'true' ? 'yes' : v === 'false' ? 'no' : v; } return { identity: (identity[0] as Record)?.['name'] || '', ntp: ntpConfig, dns: dnsNormalized, }; } async setSystemIdentity(name: string): Promise { await this.client.execute('/system/identity/set', { name }); } async setNtpConfig(enabled: boolean, primaryNtp: string, secondaryNtp: string): Promise { // 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)['.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 { await this.client.execute('/ip/dns/set', { servers, 'allow-remote-requests': allowRemoteRequests ? 'yes' : 'no', }); } // ─── IP Addresses ───────────────────────────────────────────────────────── async getIpAddresses(): Promise[]> { return this.client.execute('/ip/address/print', { detail: '' }).catch(() => []); } async addIpAddress(address: string, interfaceName: string): Promise { await this.client.execute('/ip/address/add', { address, interface: interfaceName }); } async removeIpAddress(id: string): Promise { 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; // RouterOS date format: "mar/25/2026" → "2026-03-25" const months: Record = { 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 { const months: Record = { '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 = {}; 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 { const params: Record = { '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 { await this.client.execute('/ip/route/remove', { numbers: routeId }); } // ─── VLAN management ────────────────────────────────────────────────────── async addBridgeVlan(bridge: string, vlanId: number, taggedPorts: string[], untaggedPorts: string[]): Promise { const params: Record = { 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 { await this.removeBridgeVlan(bridge, vlanId); await this.addBridgeVlan(bridge, vlanId, taggedPorts, untaggedPorts); } async removeBridgeVlan(bridge: string, vlanId: number): Promise { // Matching is done here rather than with a `?vlan-ids=` server-side filter: // that filter compares the raw string, so it misses a row written as a range // or list ("10-20") that nonetheless contains this VLAN — and missing it would // fail silently, leaving the VLAN in place while reporting success. const entries = await this.client .execute('/interface/bridge/vlan/print', { detail: '' }, [`?bridge=${bridge}`]) .catch(() => []); const matches = (entries as Record[]).filter((e) => expandVlanIds(e['vlan-ids']).includes(vlanId) ); const multi = matches.find((e) => expandVlanIds(e['vlan-ids']).length > 1); if (multi) { throw new Error( `VLAN ${vlanId} on ${bridge} is part of the multi-VLAN entry "${multi['vlan-ids']}". ` + `Removing it here would delete every VLAN in that entry, so it must be split on the device first.` ); } for (const entry of matches) { // Dynamic rows cannot be removed; RouterOS rejects the attempt and the row // reappears from the port PVID that created it. if (entry['dynamic'] === 'true') continue; const id = entry['.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; } /** * Bridge-port attributes that must survive a port being re-created. PVID above * all: re-adding a port without it silently drops the port back to VLAN 1, which * breaks tagged management even though the port looks bridged again. */ private static readonly BRIDGE_PORT_CARRY = [ 'pvid', 'frame-types', 'ingress-filtering', 'edge', 'horizon', 'point-to-point', ] as const; private static carryBridgePortAttrs(port: Record | undefined): Record { const out: Record = {}; if (!port) return out; for (const key of DeviceCollector.BRIDGE_PORT_CARRY) { const value = port[key]; if (value !== undefined && value !== '') out[key] = value; } return out; } async createBond(name: string, slaves: string[], mode: string, opts: { lacpRate?: string; hashPolicy?: string; mtu?: number; minLinks?: number; }): Promise { // Remove each slave from any bridge, recording the first bridge found along // with its port settings so the bond can inherit them. let originalBridge: string | null = null; let carry: Record = {}; 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; carry = DeviceCollector.carryBridgePortAttrs(bp); } await this.client.execute('/interface/bridge/port/remove', { '.id': bp['.id'] }); } } } const params: Record = { 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); // Put the new bond into the bridge the slaves came out of. If this fails the // member ports are left unbridged — never swallow it, the device may be // unreachable and the caller must know. if (originalBridge) { try { await this.client.execute('/interface/bridge/port/add', { bridge: originalBridge, interface: name, ...carry, }); } catch (err) { throw new Error( `Bond '${name}' was created, but adding it to bridge '${originalBridge}' failed: ` + `${(err as Error).message}. The member ports (${slaves.join(', ')}) are no longer bridged ` + `and may not forward traffic — check the device.`, { cause: err } ); } } } async updateBond(name: string, slaves: string[], mode: string, opts: { lacpRate?: string; hashPolicy?: string; mtu?: number; minLinks?: number; }): Promise { 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 = { '.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 { 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); // Capture the bond's bridge membership *and its port settings* before removing // it, so the freed slaves can inherit them (notably PVID). const bondBridgePorts = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${name}`]).catch(() => []); const bondPort = bondBridgePorts[0]; const bridgeName = bondPort?.['bridge'] ?? null; const carry = DeviceCollector.carryBridgePortAttrs(bondPort); if (bondPort?.['.id']) { await this.client.execute('/interface/bridge/port/remove', { '.id': bondPort['.id'] }); } await this.client.execute('/interface/bonding/remove', { '.id': id }); // Removing a bond releases its slaves as standalone interfaces. If the bond was // bridged, put each slave back in that bridge with the same port settings — // otherwise the physical ports stop forwarding and the device can drop off the // network. A failure here is exactly how a switch goes silently offline, so it // is reported rather than swallowed. const failures: string[] = []; if (bridgeName) { for (const slave of slaves) { try { await this.client.execute('/interface/bridge/port/add', { bridge: bridgeName, interface: slave, ...carry, }); } catch (err) { failures.push(`${slave} (${(err as Error).message})`); } } } if (failures.length > 0) { throw new Error( `Bond '${name}' was removed, but restoring bridge membership on '${bridgeName}' failed for: ` + `${failures.join('; ')}. Those ports may not forward traffic — check the device.` ); } } // ─── Hardware Health ────────────────────────────────────────────────────── async getHardware(): Promise<{ health: Record[]; disks: Record[]; }> { 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[] = []; // Internal flash storage from system resource (values are raw bytes) const res = (resource[0] || {}) as Record; 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); } return { health, disks }; } // ─── Firmware updates ───────────────────────────────────────────────────── async checkForUpdates(): Promise> { await this.client.execute('/system/package/update/check-for-updates').catch(() => {}); // Give the device a moment to complete the check await new Promise((resolve) => setTimeout(resolve, 3000)); const result = await this.client .execute('/system/package/update/print') .catch(() => [] as Record[]); return (result[0] as Record) || {}; } /** * Download the pending update without rebooting. * * Preferred over `install` because it is *verifiable*: afterwards the device * reports "Downloaded, please reboot", so the caller can confirm the image is * actually on the device before restarting anything. `install` bundles the * download and the reboot into one fire-and-forget call whose success cannot * be observed — and on at least one CCR it neither downloaded nor rebooted * while reporting nothing at all (#firmware). */ async downloadUpdate(): Promise { await this.client.execute('/system/package/update/download'); } /** The `status` line from /system/package/update, e.g. "Downloaded, please reboot…". */ async getUpdateStatus(): Promise { const rows = await this.client.execute('/system/package/update/print').catch(() => []); return (rows[0]?.['status'] || '').trim(); } /** Seconds since the device last booted, for proving a reboot happened. */ async getUptimeSeconds(): Promise { const res = await this.getSystemResource().catch(() => ({} as Record)); const raw = res['uptime']; if (!raw) return null; const units: Record = { w: 604800, d: 86400, h: 3600, m: 60, s: 1 }; let total = 0; let matched = false; for (const [, n, u] of raw.matchAll(/(\d+)([wdhms])/g)) { total += parseInt(n, 10) * units[u]; matched = true; } return matched ? total : null; } async installUpdate(): Promise { 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[]); const rb = (result[0] as Record) || {}; 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 { await this.client.execute('/system/routerboard/upgrade'); await this.client.execute('/system/reboot'); } /** * Reboot the device. * * A reboot that works necessarily kills the session that asked for it, so the * transport error that follows is the *expected* outcome, not a failure. Left * unhandled it surfaced as "Update failed before reboot: Connection closed" on * a device that had in fact rebooted and upgraded correctly. */ async reboot(): Promise { try { await this.client.execute('/system/reboot'); } catch (e) { const msg = (e as Error).message || ''; if (/closed|reset|EPIPE|not connected|timed? ?out|socket/i.test(msg)) return; throw e; } } // ─── Ethernet monitor / SFP DDM ─────────────────────────────────────────── async getPortMonitor(name: string): Promise> { const results = await this.client .execute('/interface/ethernet/monitor', { numbers: name, once: '' }) .catch(() => []); return (results[0] as Record) || {}; } async setFecMode(name: string, fecMode: string): Promise { await this.client.execute('/interface/ethernet/set', { numbers: name, 'fec-mode': fecMode }); } async setFlowControl(name: string, txFc: string, rxFc: string): Promise { 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 { const params: Record = { 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[]; areas: Record[]; interfaceTemplates: Record[]; neighbors: Record[]; }> { const [instances, areas, interfaceTemplates, neighbors] = await Promise.all([ this.client.execute('/routing/ospf/instance/print', { detail: '' }).catch(() => [] as Record[]), this.client.execute('/routing/ospf/area/print', { detail: '' }).catch(() => [] as Record[]), // 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[])), this.client.execute('/routing/ospf/neighbor/print', { detail: '' }).catch(() => [] as Record[]), ]); return { instances, areas, interfaceTemplates, neighbors }; } async addOspfInstance(params: Record): Promise { await this.client.execute('/routing/ospf/instance/add', params); } async removeOspfInstance(id: string): Promise { await this.client.execute('/routing/ospf/instance/remove', { '.id': id }); } async addOspfArea(params: Record): Promise { await this.client.execute('/routing/ospf/area/add', params); } async removeOspfArea(id: string): Promise { await this.client.execute('/routing/ospf/area/remove', { '.id': id }); } async getBgpData(): Promise<{ connections: Record[]; sessions: Record[]; templates: Record[]; }> { // 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[])), this.client.execute('/routing/bgp/session/print', { detail: '' }).catch(() => [] as Record[]), this.client.execute('/routing/bgp/template/print', { detail: '' }) .catch(() => this.client.execute('/routing/bgp/instance/print', { detail: '' }).catch(() => [] as Record[])), ]); return { connections, sessions, templates }; } async addBgpConnection(params: Record): Promise { // 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 { await this.client.execute('/routing/bgp/connection/remove', { '.id': id }) .catch(() => this.client.execute('/routing/bgp/peer/remove', { '.id': id })); } async getRoutingTablesData(): Promise[]> { return this.client.execute('/routing/table/print', { detail: '' }).catch(() => [] as Record[]); } async addRoutingTable(params: Record): Promise { await this.client.execute('/routing/table/add', params); } async removeRoutingTable(id: string): Promise { await this.client.execute('/routing/table/remove', { '.id': id }); } async getRouteFiltersData(): Promise<{ rules: Record[]; 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[])); const chains = [...new Set(rules.map(r => r['chain']).filter(Boolean))]; return { rules, chains }; } async addFilterRule(params: Record): Promise { await this.client.execute('/routing/filter/rule/add', params) .catch(() => this.client.execute('/routing/filter/add', params)); } async updateFilterRule(id: string, params: Record): Promise { 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 { await this.client.execute('/routing/filter/rule/remove', { '.id': id }) .catch(() => this.client.execute('/routing/filter/remove', { '.id': id })); } async getRouterIds(): Promise[]> { return this.client.execute('/routing/id/print', { detail: '' }).catch(() => [] as Record[]); } // ─── Network Services ───────────────────────────────────────────────────────── // DHCP ──────────────────────────────────────────────────────────────────────── async getDhcpInterfaces(): Promise[]> { const all = await this.client.execute('/interface/print', { detail: '' }).catch(() => [] as Record[]); // 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[]> { const cmd = protocol === 'ipv4' ? '/ip/dhcp-server/print' : '/ipv6/dhcp-server/print'; return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record[]); } async addDhcpServer(params: Record, protocol: 'ipv4' | 'ipv6'): Promise { const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server'; await this.client.execute(`${base}/add`, params); } async updateDhcpServer(id: string, params: Record, protocol: 'ipv4' | 'ipv6'): Promise { 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 { 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 { 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[]> { const cmd = protocol === 'ipv4' ? '/ip/pool/print' : '/ipv6/pool/print'; return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record[]); } async addDhcpPool(params: Record, protocol: 'ipv4' | 'ipv6'): Promise { const base = protocol === 'ipv4' ? '/ip/pool' : '/ipv6/pool'; await this.client.execute(`${base}/add`, params); } async removeDhcpPool(id: string, protocol: 'ipv4' | 'ipv6'): Promise { const base = protocol === 'ipv4' ? '/ip/pool' : '/ipv6/pool'; await this.client.execute(`${base}/remove`, { '.id': id }); } async getDhcpLeases(protocol: 'ipv4' | 'ipv6'): Promise[]> { const cmd = protocol === 'ipv4' ? '/ip/dhcp-server/lease/print' : '/ipv6/dhcp-server/binding/print'; return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record[]); } async addStaticDhcpLease(params: Record, protocol: 'ipv4' | 'ipv6'): Promise { 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 { const base = protocol === 'ipv4' ? '/ip/dhcp-server/lease' : '/ipv6/dhcp-server/binding'; await this.client.execute(`${base}/remove`, { '.id': id }); } // DNS ───────────────────────────────────────────────────────────────────────── async getDnsSettings(): Promise> { const rows = await this.client.execute('/ip/dns/print', {}).catch(() => [] as Record[]); const row = rows[0] ?? {}; // RouterOS 7 DNS returns booleans as "true"/"false" instead of "yes"/"no" const out: Record = {}; 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 { const params: Record = {}; 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[]> { return this.client.execute('/ip/dns/static/print', { detail: '' }).catch(() => [] as Record[]); } async addDnsStaticEntry(params: Record): Promise { await this.client.execute('/ip/dns/static/add', params); } async updateDnsStaticEntry(id: string, params: Record): Promise { await this.client.execute('/ip/dns/static/set', { '.id': id, ...params }); } async removeDnsStaticEntry(id: string): Promise { await this.client.execute('/ip/dns/static/remove', { '.id': id }); } async flushDnsCache(): Promise { await this.client.execute('/ip/dns/cache/flush', {}); } // NTP ───────────────────────────────────────────────────────────────────────── async getNtpSettings(): Promise<{ server: Record; client: Record; }> { // 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): Record => { const out: Record = {}; 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 { const serverParams: Record = {}; 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 = {}; 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[]> { return this.client.execute('/system/logging/action/print', { detail: '' }).catch(() => [] as Record[]); } async addSyslogAction(params: Record): Promise { await this.client.execute('/system/logging/action/add', params); } async updateSyslogAction(id: string, params: Record): Promise { await this.client.execute('/system/logging/action/set', { '.id': id, ...params }); } async removeSyslogAction(id: string): Promise { await this.client.execute('/system/logging/action/remove', { '.id': id }); } async getSyslogRules(): Promise[]> { return this.client.execute('/system/logging/print', { detail: '' }).catch(() => [] as Record[]); } async addSyslogRule(params: Record): Promise { await this.client.execute('/system/logging/add', params); } async updateSyslogRule(id: string, params: Record): Promise { await this.client.execute('/system/logging/set', { '.id': id, ...params }); } async removeSyslogRule(id: string): Promise { await this.client.execute('/system/logging/remove', { '.id': id }); } async toggleSyslogRule(id: string, disabled: boolean): Promise { await this.client.execute('/system/logging/set', { '.id': id, 'disabled': disabled ? 'yes' : 'no' }); } // WireGuard ─────────────────────────────────────────────────────────────────── async getWireGuardInterfaces(): Promise[]> { return this.client.execute('/interface/wireguard/print', { detail: '' }).catch(() => [] as Record[]); } async addWireGuardInterface(params: Record): Promise[]> { 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): Promise { await this.client.execute('/interface/wireguard/set', { '.id': id, ...params }); } async removeWireGuardInterface(id: string): Promise { await this.client.execute('/interface/wireguard/remove', { '.id': id }); } async setWireGuardInterfaceDisabled(id: string, disabled: boolean): Promise { const cmd = disabled ? '/interface/wireguard/disable' : '/interface/wireguard/enable'; await this.client.execute(cmd, { '.id': id }); } async getWireGuardPeers(): Promise[]> { return this.client.execute('/interface/wireguard/peers/print', { detail: '' }).catch(() => [] as Record[]); } async addWireGuardPeer(params: Record): Promise { await this.client.execute('/interface/wireguard/peers/add', params); } async updateWireGuardPeer(id: string, params: Record): Promise { await this.client.execute('/interface/wireguard/peers/set', { '.id': id, ...params }); } async removeWireGuardPeer(id: string): Promise { await this.client.execute('/interface/wireguard/peers/remove', { '.id': id }); } // Traffic Flow (NetFlow/IPFIX export) ───────────────────────────────────────── async getTrafficFlowSettings(): Promise | null> { const rows = await this.client.execute('/ip/traffic-flow/print', {}).catch(() => [] as Record[]); return rows[0] || null; } async setTrafficFlowSettings(params: Record): Promise { await this.client.execute('/ip/traffic-flow/set', params); } async getTrafficFlowTargets(): Promise[]> { return this.client.execute('/ip/traffic-flow/target/print', { detail: '' }).catch(() => [] as Record[]); } async addTrafficFlowTarget(params: Record): Promise { await this.client.execute('/ip/traffic-flow/target/add', params); } async updateTrafficFlowTarget(id: string, params: Record): Promise { await this.client.execute('/ip/traffic-flow/target/set', { '.id': id, ...params }); } async removeTrafficFlowTarget(id: string): Promise { await this.client.execute('/ip/traffic-flow/target/remove', { '.id': id }); } }