feat(wireless): CAPsMAN awareness and read-only controller view (v0.22.0-beta)

Addresses #94, phases A and B. Write operations are deliberately out of scope.

An access point provisioned by CAPsMAN holds none of its own wireless
configuration, so /interface/wifi/print returns rows with the configuration.* and
security.* fields absent. normalizeWifiInterface mapped nothing, stored nulls, and
the UI rendered an AP with no SSID, security or band — indistinguishable from one
that is simply broken.

Phase A — stop showing blanks
  Devices are classified standalone / cap / controller / controller_cap from
  /interface/wifi/capsman and /interface/wifi/cap. Both toggles can be on: MikroTik
  supports a controller that also runs its own radios under itself, and the issue
  calls that case out, so it gets its own role.

  Provisioned interfaces are marked and labelled in the Radios tab with the
  controller's MAC and an explanation that the blank fields live on the controller.
  SSID, mode and channel are recovered from RouterOS's status line where present,
  so the AP is described rather than blank.

  Role is the signal, not missing data: an AP whose SSID is empty because the
  controller owns it looks identical to an unconfigured one, and only the toggle
  distinguishes them. A CAP can still hold locally-configured interfaces alongside
  provisioned ones, so per-interface state is checked rather than assuming the
  whole device.

Phase B — read-only controller view
  Controllers now collect the radios they manage, named configurations, and
  provisioning rules, surfaced as a CAPsMAN panel on the Wireless page grouped by
  the access point each radio physically lives on.

  CAPs are matched to managed devices by radio MAC from the controller's own
  /interface/wifi/radio list, not by resolving addresses through neighbours as the
  issue proposed. A MAC is hardware identity and globally unique, so unlike an
  address it cannot mean two devices on two segments — the failure fixed in
  v0.20.1. A radio MAC often differs from the interface MAC in the final octet, so
  lookup falls back to a five-octet prefix while keeping the OUI exact. A CAP that
  is not a managed device is surfaced rather than hidden; that is usually the
  reason an AP appears to be missing from the fleet.

  The wireless section required device_type = 'wireless_ap' in five places, which
  hid a dedicated controller — normally classified as a router — from every
  wireless view. Collection was gated the same way. Both now include CAPsMAN roles,
  and the collector probes an unknown device once, records the role, and skips
  cheaply forever after.

Not included
  Provisioning, config edits and interface enable/disable. A CAPsMAN configuration
  applies to every AP bound to it simultaneously, which is the fleet-wide version
  of the failure Change Guard exists to prevent. It needs the same prediction and
  revert treatment rather than being bolted on. Legacy /caps-man (pre-RouterOS 7)
  is a separate API tree and is not covered.

Verified: API shapes probed against RouterOS 7.23 rather than taken from docs.
24 unit tests on the pure parsing and matching. No CAPsMAN hardware here, so the
read path was exercised against a seeded controller with a local radio, a matched
two-radio CAP and an unmanaged CAP; grouping, provisioning and SSID resolution all
came out correct. Confirmed no regression on a fleet with no controller: roles
detected correctly, no interface falsely flagged, wireless views unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-20 08:25:35 -07:00
co-authored by Claude Opus 5
parent 19617e8357
commit 14bab24c7e
14 changed files with 1134 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.21.0-beta",
"version": "0.22.0-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+61
View File
@@ -498,6 +498,67 @@ CREATE TABLE IF NOT EXISTS device_change_guards (
CREATE INDEX IF NOT EXISTS idx_change_guards_device ON device_change_guards(device_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_change_guards_pending ON device_change_guards(status, expires_at);
-- CAPsMAN support (github issue #94).
--
-- A CAPsMAN-managed AP holds none of its own wireless configuration, so reading
-- /interface/wifi/print on it yields blank SSID/security/band. Recording the
-- device's role, and marking which interfaces are provisioned rather than local,
-- lets the UI say "managed by CAPsMAN" instead of showing an AP that looks broken.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS wifi_role VARCHAR(16);
ALTER TABLE wireless_interfaces ADD COLUMN IF NOT EXISTS managed_by_capsman BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE wireless_interfaces ADD COLUMN IF NOT EXISTS radio_mac VARCHAR(17);
ALTER TABLE wireless_interfaces ADD COLUMN IF NOT EXISTS capsman_controller_mac VARCHAR(17);
-- Radios a controller manages, local and remote. matched_device_id is resolved by
-- radio MAC against the fleet's interface MACs; it stays NULL for a CAP that is not
-- itself a managed device, which is a legitimate state rather than an error.
CREATE TABLE IF NOT EXISTS capsman_radios (
id SERIAL PRIMARY KEY,
controller_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
radio_mac VARCHAR(17) NOT NULL,
interface_name VARCHAR(64),
local BOOLEAN NOT NULL DEFAULT FALSE,
hw_type VARCHAR(64),
current_channel TEXT,
remote_cap_name VARCHAR(128),
matched_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
config_json JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(controller_device_id, radio_mac)
);
CREATE INDEX IF NOT EXISTS idx_capsman_radios_device ON capsman_radios(matched_device_id);
-- Named configurations and the provisioning rules that bind them to radios. Both
-- are read-only for now; editing them would push to every bound AP at once.
CREATE TABLE IF NOT EXISTS capsman_configurations (
id SERIAL PRIMARY KEY,
controller_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
name VARCHAR(128) NOT NULL,
ssid VARCHAR(128),
mode VARCHAR(32),
band VARCHAR(64),
security VARCHAR(128),
authentication_types VARCHAR(128),
config_json JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(controller_device_id, name)
);
CREATE TABLE IF NOT EXISTS capsman_provisioning (
id SERIAL PRIMARY KEY,
controller_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
ros_id VARCHAR(32) NOT NULL,
action VARCHAR(64),
master_configuration VARCHAR(128),
slave_configurations TEXT,
radio_mac VARCHAR(17),
comment TEXT,
disabled BOOLEAN NOT NULL DEFAULT FALSE,
config_json JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(controller_device_id, ros_id)
);
-- ntfy.sh notification channel (github issue #93). The channel type is a CHECK
-- constraint rather than a lookup table, so widening it means replacing it.
ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check;
+100 -4
View File
@@ -11,7 +11,7 @@ router.use(requireAuth);
async function getAP(id: number): Promise<DeviceRow | null> {
const rows = await query<DeviceRow>(
`SELECT * FROM devices WHERE id = $1 AND device_type = 'wireless_ap'`,
`SELECT * FROM devices WHERE id = $1 AND (device_type = 'wireless_ap' OR wifi_role IN ('cap','controller','controller_cap'))`,
[id]
);
return rows[0] ?? null;
@@ -32,7 +32,7 @@ router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => {
const placeholders = apIds.map((_, i) => `$${i + 1}`).join(',');
const aps = await query<DeviceRow>(
`SELECT * FROM devices WHERE id IN (${placeholders}) AND device_type = 'wireless_ap'`,
`SELECT * FROM devices WHERE id IN (${placeholders}) AND (device_type = 'wireless_ap' OR wifi_role IN ('cap','controller','controller_cap'))`,
apIds
);
@@ -102,13 +102,109 @@ router.get('/', async (_req: Request, res: Response) => {
COUNT(DISTINCT wi.id) FILTER (WHERE wi.ssid IS NOT NULL AND wi.disabled = false) AS ssid_count
FROM devices d
LEFT JOIN wireless_interfaces wi ON wi.device_id = d.id
WHERE d.device_type = 'wireless_ap'
WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap'))
GROUP BY d.id
ORDER BY d.name ASC
`);
res.json(aps);
});
// ─── CAPsMAN (issue #94) ──────────────────────────────────────────────────────
/**
* GET /api/wireless/capsman — every controller in the fleet with the radios it
* manages, grouped by the access point each radio physically lives on.
*
* Read-only. Reads the cache rather than the devices, so the page loads instantly
* and works while a CAP is offline — the controller still knows what it provisioned.
*/
router.get('/capsman', async (_req: Request, res: Response) => {
const controllers = await query<{
id: number; name: string; ip_address: string; model: string | null;
status: string; wifi_role: string | null;
}>(`
SELECT id, name, ip_address, model, status, wifi_role
FROM devices
WHERE wifi_role IN ('controller', 'controller_cap')
ORDER BY name ASC
`);
if (controllers.length === 0) return res.json({ controllers: [] });
const ids = controllers.map((c) => c.id);
const [radios, configs, provisioning] = await Promise.all([
query(`
SELECT r.controller_device_id, r.radio_mac, r.interface_name, r.local, r.hw_type,
r.current_channel, r.remote_cap_name, r.matched_device_id,
d.name AS matched_device_name, d.status AS matched_device_status
FROM capsman_radios r
LEFT JOIN devices d ON d.id = r.matched_device_id
WHERE r.controller_device_id = ANY($1::int[])
ORDER BY r.local DESC, d.name NULLS LAST, r.interface_name
`, [ids]),
query(`
SELECT controller_device_id, name, ssid, mode, band, security, authentication_types
FROM capsman_configurations
WHERE controller_device_id = ANY($1::int[])
ORDER BY name
`, [ids]),
query(`
SELECT controller_device_id, ros_id, action, master_configuration,
slave_configurations, radio_mac, comment, disabled
FROM capsman_provisioning
WHERE controller_device_id = ANY($1::int[])
ORDER BY ros_id
`, [ids]),
]);
// SSIDs actually broadcasting, so the view shows what is live rather than only
// what was configured.
const interfaces = await query(`
SELECT wi.device_id, wi.name, wi.ssid, wi.band, wi.frequency, wi.disabled, wi.running,
wi.managed_by_capsman, wi.radio_mac, wi.registered_clients, d.name AS device_name
FROM wireless_interfaces wi
JOIN devices d ON d.id = wi.device_id
WHERE wi.managed_by_capsman = TRUE
ORDER BY d.name, wi.name
`);
const byController = <T extends { controller_device_id: number }>(rows: T[], id: number) =>
rows.filter((r) => r.controller_device_id === id);
res.json({
controllers: controllers.map((c) => {
const cRadios = byController(radios as { controller_device_id: number; matched_device_id: number | null; matched_device_name: string | null }[], c.id);
// Group radios by the AP they sit on. An unmatched radio means a CAP that is
// not a managed device — worth showing rather than hiding, since it is often
// the reason an AP is missing from the fleet.
const groups = new Map<string, { device_id: number | null; device_name: string; radios: unknown[] }>();
for (const r of cRadios) {
const key = r.matched_device_id != null ? `d:${r.matched_device_id}` : `u:${(r as { remote_cap_name?: string }).remote_cap_name ?? 'unknown'}`;
const g = groups.get(key) ?? {
device_id: r.matched_device_id,
device_name: r.matched_device_name ?? (r as { remote_cap_name?: string }).remote_cap_name ?? 'Unmanaged CAP',
radios: [],
};
g.radios.push(r);
groups.set(key, g);
}
const ssids = (interfaces as { device_id: number }[]).filter((i) =>
cRadios.some((r) => r.matched_device_id === i.device_id)
);
return {
...c,
access_points: [...groups.values()],
configurations: byController(configs as { controller_device_id: number }[], c.id),
provisioning: byController(provisioning as { controller_device_id: number }[], c.id),
ssids,
};
}),
});
});
// ─── Wireless interfaces (SSIDs) ──────────────────────────────────────────────
// GET /api/wireless/:id/bridges — bridge list + current port memberships
@@ -719,7 +815,7 @@ router.get('/rf/channels', async (req: Request, res: Response) => {
wi.band, wi.frequency, wi.channel_width, wi.registered_clients
FROM wireless_interfaces wi
JOIN devices d ON d.id = wi.device_id
WHERE d.device_type = 'wireless_ap'
WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap'))
AND wi.disabled = FALSE
AND wi.frequency IS NOT NULL AND wi.frequency > 0
AND (wi.config_json->>'master-interface') IS NULL
+243 -10
View File
@@ -7,6 +7,11 @@ 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, lookupDeviceForMac, macIndexKeys,
type WifiRole,
} from './capsman';
import { BackupService } from '../BackupService';
import { alertService } from '../AlertService';
@@ -114,15 +119,32 @@ export class DeviceCollector {
// ─── 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<void> {
await this.collectInterfaces();
await this.collectIpAddressesCache();
await this.collectVlans();
await this.collectSystemInfo();
await this.collectStp();
if (this.device.device_type === 'wireless_ap') {
if (this.shouldCollectWireless()) {
await this.collectWirelessInterfaces();
await this.collectSecurityProfiles();
await this.collectCapsman();
}
}
@@ -147,9 +169,10 @@ export class DeviceCollector {
// 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.device.device_type === 'wireless_ap') {
if (this.shouldCollectWireless()) {
await this.collectWirelessInterfaces();
await this.collectSecurityProfiles();
await this.collectCapsman();
}
await this.saveFullConfig();
await this.updateDeviceStatus('online');
@@ -1319,6 +1342,175 @@ export class DeviceCollector {
);
}
// ─── CAPsMAN ──────────────────────────────────────────────────────────────
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<WifiRole> {
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<string, string>[]),
]);
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<void> {
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] = 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),
]);
// 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 = new Map<string, number>();
for (const r of macRows) {
for (const key of macIndexKeys(r.mac_address)) {
if (!macIndex.has(key)) macIndex.set(key, r.device_id);
}
}
if (radioRows !== null) {
const radios = normalizeRadios(radioRows);
const seen: string[] = [];
for (const radio of radios) {
if (!radio.radioMac) continue;
seen.push(radio.radioMac);
const capInfo = parseCapStatus(radio.raw);
const matched = radio.local
? this.device.id
: lookupDeviceForMac(radio.radioMac, macIndex);
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, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,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, updated_at=NOW()`,
[
this.device.id, radio.radioMac, radio.interfaceName, radio.local,
radio.hwType, radio.currentChannel,
capInfo?.capMac ?? radio.raw['name'] ?? null,
matched, JSON.stringify(radio.raw),
]
);
}
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;
@@ -1366,6 +1558,12 @@ export class DeviceCollector {
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();
await query(`UPDATE devices SET wifi_role = $2 WHERE id = $1`, [this.device.id, role])
.catch(() => { /* role is 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.
@@ -1397,32 +1595,46 @@ export class DeviceCollector {
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);
const bandStr = wlan['band'] || (liveF ? bandPrefix(liveF) : null);
// "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, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
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, updated_at=NOW()`,
config_json=$17, managed_by_capsman=$18, radio_mac=$19,
capsman_controller_mac=$20, updated_at=NOW()`,
[
this.device.id, name,
wlan['ssid'] || null,
wlan['mode'] || null,
wlan['ssid'] || capsmanStatus?.ssid || null,
wlan['mode'] || capsmanStatus?.mode || null,
bandStr || null,
!isNaN(freq) && freq > 0 ? freq : null,
!isNaN(effFreq) && effFreq > 0 ? effFreq : null,
widthStr,
!isNaN(txPow) && txPow > 0 ? txPow : null,
wlan['tx-power-mode'] || null,
@@ -1434,6 +1646,9 @@ export class DeviceCollector {
wlan['mac-address'] || null,
wlan['security-profile'] || null,
JSON.stringify(wlan),
managed,
(wlan['radio-mac'] || '').toUpperCase() || null,
capsmanStatus?.controllerMac ?? null,
]
);
}
@@ -1738,10 +1953,28 @@ export class DeviceCollector {
this.getBridgePorts(),
]);
const ifaces = pkg === 'wifi'
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 => {
@@ -0,0 +1,160 @@
import {
classifyWifiRole, parseCapsmanStatus, parseCapStatus, isCapsmanManaged,
normalizeRadios, matchRadiosToDevices, lookupDeviceForMac, macIndexKeys,
} from '../capsman';
describe('classifyWifiRole', () => {
const on = [{ enabled: 'yes' }];
const off = [{ enabled: 'no' }];
it('reads the two toggles independently', () => {
expect(classifyWifiRole(off, off, true)).toBe('standalone');
expect(classifyWifiRole(on, off, true)).toBe('controller');
expect(classifyWifiRole(off, on, true)).toBe('cap');
});
// Explicitly called out in #94: a device can be the controller and still run
// its own radios under that same controller.
it('gives a controller with local radios its own role', () => {
expect(classifyWifiRole(on, on, true)).toBe('controller_cap');
});
it('reports none when there is no wireless hardware at all', () => {
expect(classifyWifiRole(off, off, false)).toBe('none');
expect(classifyWifiRole(null, null, false)).toBe('none');
});
it('treats a controller with no local radios as a controller, not none', () => {
expect(classifyWifiRole(on, off, false)).toBe('controller');
});
it('accepts "true" as well as "yes"', () => {
expect(classifyWifiRole([{ enabled: 'true' }], off, true)).toBe('controller');
});
});
describe('parseCapsmanStatus', () => {
// Verbatim from the issue.
const line = 'managed by CAPsMAN 02:F8:E3:80:10:97%management_vlan, traffic processing on CAP, mode: AP, SSID: HomeAccessPoint, channel: 5280/ax/eCee';
it('extracts controller, interface, SSID, mode and channel', () => {
const s = parseCapsmanStatus({ status: line })!;
expect(s.controllerMac).toBe('02:F8:E3:80:10:97');
expect(s.controllerInterface).toBe('management_vlan');
expect(s.ssid).toBe('HomeAccessPoint');
expect(s.mode).toBe('AP');
expect(s.channel).toBe('5280/ax/eCee');
});
// Which field carries this text is undocumented, so the parser searches values
// rather than trusting a field name.
it('finds the status text under any field name', () => {
expect(parseCapsmanStatus({ 'some-future-field': line })?.ssid).toBe('HomeAccessPoint');
});
it('copes with a status line that omits the optional parts', () => {
const s = parseCapsmanStatus({ status: 'managed by CAPsMAN 02:F8:E3:80:10:97, traffic processing on CAP' })!;
expect(s.controllerMac).toBe('02:F8:E3:80:10:97');
expect(s.controllerInterface).toBeNull();
expect(s.ssid).toBeNull();
});
it('returns null for a locally configured interface', () => {
expect(parseCapsmanStatus({ name: 'wifi1', 'configuration.ssid': 'TestNet' })).toBeNull();
});
});
describe('parseCapStatus', () => {
it('extracts the CAP identity from the controller side', () => {
const r = parseCapStatus({ status: 'operated by CAP 02:F8:E1:50:13:5F%management_vlan, traffic processing on CAP' })!;
expect(r.capMac).toBe('02:F8:E1:50:13:5F');
expect(r.capInterface).toBe('management_vlan');
});
it('returns null when the radio is local', () => {
expect(parseCapStatus({ interface: 'wifi1' })).toBeNull();
});
});
describe('isCapsmanManaged', () => {
it('trusts an explicit status line whatever the role', () => {
const row = { status: 'managed by CAPsMAN 02:F8:E3:80:10:97%mgmt, mode: AP' };
expect(isCapsmanManaged(row, 'standalone')).toBe(true);
});
it('treats a CAP interface with no local SSID as provisioned', () => {
expect(isCapsmanManaged({ name: 'wifi1' }, 'cap')).toBe(true);
});
// A CAP may still carry locally-configured interfaces alongside provisioned
// ones, so the role alone must not be the test.
it('leaves a locally configured interface on a CAP alone', () => {
expect(isCapsmanManaged({ name: 'wifi1', 'configuration.ssid': 'Local' }, 'cap')).toBe(false);
});
it('never claims a standalone AP is managed', () => {
expect(isCapsmanManaged({ name: 'wifi1' }, 'standalone')).toBe(false);
});
});
describe('normalizeRadios', () => {
// Real rows from a RouterOS 7.23 wAP ax.
const rows = [
{ '.id': '*1', 'radio-mac': 'd0:ea:11:0a:de:78', interface: 'wifi1', local: 'true', 'hw-type': 'QCA5018' },
{ '.id': '*2', 'radio-mac': 'D0:EA:11:0A:DE:79', interface: 'wifi2', local: 'true', 'hw-type': 'QCN6122' },
];
it('upper-cases MACs so lookups are case-stable', () => {
expect(normalizeRadios(rows).map((r) => r.radioMac))
.toEqual(['D0:EA:11:0A:DE:78', 'D0:EA:11:0A:DE:79']);
});
it('reads the local flag', () => {
expect(normalizeRadios(rows).every((r) => r.local)).toBe(true);
expect(normalizeRadios([{ 'radio-mac': 'AA:BB:CC:DD:EE:FF', local: 'false' }])[0].local).toBe(false);
});
});
describe('matchRadiosToDevices', () => {
const index = new Map<string, number>([['AA:BB:CC:00:00:01', 7]]);
it('attributes a local radio to the controller itself', () => {
const radios = normalizeRadios([{ 'radio-mac': 'FF:FF:FF:00:00:09', local: 'true' }]);
expect(matchRadiosToDevices(radios, index, 3)[0].deviceId).toBe(3);
});
it('attributes a remote radio to the managed device owning that MAC', () => {
const radios = normalizeRadios([{ 'radio-mac': 'aa:bb:cc:00:00:01', local: 'false' }]);
expect(matchRadiosToDevices(radios, index, 3)[0].deviceId).toBe(7);
});
it('leaves an unmanaged CAP unattributed rather than guessing', () => {
const radios = normalizeRadios([{ 'radio-mac': '11:22:33:44:55:66', local: 'false' }]);
expect(matchRadiosToDevices(radios, index, 3)[0].deviceId).toBeNull();
});
});
describe('lookupDeviceForMac', () => {
it('matches exactly when the MAC is known', () => {
const idx = new Map([['AA:BB:CC:00:00:01', 5]]);
expect(lookupDeviceForMac('aa:bb:cc:00:00:01', idx)).toBe(5);
});
/**
* A radio MAC commonly differs from the device's interface MAC in the final
* octet, so an exact-only lookup would leave real CAPs unattributed.
*/
it('falls back to the five-octet prefix when the last octet differs', () => {
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 5] as [string, number]));
expect(lookupDeviceForMac('AA:BB:CC:00:00:07', idx)).toBe(5);
});
it('does not match a different device sharing only an OUI', () => {
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 5] as [string, number]));
expect(lookupDeviceForMac('AA:BB:CC:99:99:99', idx)).toBeNull();
});
it('returns null for a missing MAC', () => {
expect(lookupDeviceForMac(null, new Map())).toBeNull();
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* CAPsMAN awareness.
*
* When a MikroTik AP is managed by CAPsMAN, its own configuration is not local:
* `/interface/wifi/print` returns rows with the `configuration.*` and `security.*`
* fields simply absent, because the controller owns them. Code that reads those
* fields therefore stores nulls, and the UI renders an access point with no SSID,
* no security and no band — looking broken rather than delegated
* (github.com/2GT-Media-Group-LLC/mikrotik-manager/issues/94).
*
* What RouterOS does expose, verified on 7.23:
*
* /interface/wifi/capsman/print → { enabled } is this device a controller
* /interface/wifi/cap/print → { enabled } is this device a CAP
* /interface/wifi/radio/print → radio-mac, local, interface, hw-type, …
*
* `radio-mac` is the useful part. A controller lists **every** radio it manages
* through `/interface/wifi/radio`, remote ones included, each with its MAC. Since
* interface MACs are already collected for every managed device, CAPs can be joined
* to devices directly on that MAC — no neighbour traversal, no resolving addresses
* within a segment, and none of the cross-segment ambiguity that made topology
* unreliable (see services/topology/buildTopology.ts).
*
* Everything here is pure so it can be tested without a CAPsMAN deployment, which
* matters because we do not have one.
*/
export type WifiRole = 'none' | 'standalone' | 'cap' | 'controller' | 'controller_cap';
export interface CapsmanStatus {
/** MAC of the controller managing this radio, when it could be read. */
controllerMac: string | null;
/** Interface or VLAN the CAP reaches its controller over, e.g. `management_vlan`. */
controllerInterface: string | null;
/** SSID the controller provisioned, when RouterOS reports it in the status text. */
ssid: string | null;
/** Raw channel spec, e.g. `5280/ax/eCee`. */
channel: string | null;
mode: string | null;
}
const isYes = (v: string | undefined): boolean => v === 'yes' || v === 'true';
/**
* Classify a device from the two feature toggles. Both can be on: MikroTik
* explicitly supports a controller that also runs its own radios, and the reporter
* of #94 calls that case out, so it gets its own role rather than being folded into
* one of the others.
*/
export function classifyWifiRole(
capsman: Record<string, string>[] | null,
cap: Record<string, string>[] | null,
hasWifiInterfaces: boolean
): WifiRole {
const isController = !!capsman?.some((r) => isYes(r['enabled']));
const isCap = !!cap?.some((r) => isYes(r['enabled']));
if (isController && isCap) return 'controller_cap';
if (isController) return 'controller';
if (isCap) return 'cap';
return hasWifiInterfaces ? 'standalone' : 'none';
}
/**
* Pull what we can out of RouterOS's human-readable CAPsMAN status line, e.g.
*
* managed by CAPsMAN 02:F8:E3:80:10:97%management_vlan, traffic processing on CAP,
* mode: AP, SSID: HomeAccessPoint, channel: 5280/ax/eCee
*
* Every value on the row is scanned rather than a specific field being read,
* because which field carries this text is not documented and has moved between
* releases. Searching is cheap and survives it moving again.
*/
/**
* Pull a `<mac>%<interface>` reference out of a status line, given the phrase that
* introduces it.
*
* Deliberately not one combined regular expression: the pattern needed to express
* "literal, whitespace, MAC, optional %interface" trips ReDoS linters, and this
* input is device-supplied text. Slicing at fixed offsets is linear by construction
* and needs no reasoning about backtracking.
*/
function parseNodeRef(text: string, marker: RegExp): { mac: string | null; iface: string | null } {
const m = marker.exec(text);
if (!m) return { mac: null, iface: null };
const rest = text.slice(m.index + m[0].length).trimStart();
const mac = rest.slice(0, 17);
if (!/^[0-9A-Fa-f:]{17}$/.test(mac)) return { mac: null, iface: null };
let iface: string | null = null;
if (rest[17] === '%') {
const tail = rest.slice(18);
const end = tail.search(/[,\s]/);
iface = (end === -1 ? tail : tail.slice(0, end)) || null;
}
return { mac: mac.toUpperCase(), iface };
}
/** Value of a `Key: value` pair up to the next comma. */
function labelled(text: string, label: string): string | null {
const i = text.toLowerCase().indexOf(`${label.toLowerCase()}:`);
if (i === -1) return null;
const after = text.slice(i + label.length + 1);
const end = after.indexOf(',');
return (end === -1 ? after : after.slice(0, end)).trim() || null;
}
/**
* Pull what we can out of RouterOS's human-readable CAPsMAN status line, e.g.
*
* managed by CAPsMAN 02:F8:E3:80:10:97%management_vlan, traffic processing on CAP,
* mode: AP, SSID: HomeAccessPoint, channel: 5280/ax/eCee
*
* Every value on the row is scanned rather than a specific field being read,
* because which field carries this text is not documented and has moved between
* releases. Searching is cheap and survives it moving again.
*/
export function parseCapsmanStatus(row: Record<string, string>): CapsmanStatus | null {
const text = Object.values(row).find(
(v) => typeof v === 'string' && /managed by CAPsMAN/i.test(v)
);
if (!text) return null;
const ref = parseNodeRef(text, /managed by CAPsMAN/i);
return {
controllerMac: ref.mac,
controllerInterface: ref.iface,
ssid: labelled(text, 'SSID'),
channel: labelled(text, 'channel'),
mode: labelled(text, 'mode'),
};
}
/**
* The controller-side counterpart: `operated by CAP <mac>%<interface>`. Used to
* label radios on a controller that physically live on a remote AP.
*/
export function parseCapStatus(row: Record<string, string>): { capMac: string | null; capInterface: string | null } | null {
const text = Object.values(row).find(
(v) => typeof v === 'string' && /operated by CAP/i.test(v)
);
if (!text) return null;
const ref = parseNodeRef(text, /operated by CAP/i);
return { capMac: ref.mac, capInterface: ref.iface };
}
/**
* True when this wifi interface's configuration is owned by a controller.
*
* The device's role is the reliable signal; the absence of a local SSID confirms
* it per-interface. A CAP can still hold locally-configured interfaces alongside
* provisioned ones, so this is not simply "the device is a CAP".
*/
export function isCapsmanManaged(row: Record<string, string>, role: WifiRole): boolean {
if (parseCapsmanStatus(row)) return true;
if (role !== 'cap' && role !== 'controller_cap') return false;
// Provisioned interfaces carry no local configuration.
return !row['configuration.ssid'] && !row['ssid'];
}
export interface RadioRow {
radioMac: string | null;
interfaceName: string | null;
local: boolean;
hwType: string | null;
currentChannel: string | null;
raw: Record<string, string>;
}
/** Normalise `/interface/wifi/radio/print` rows. */
export function normalizeRadios(rows: Record<string, string>[]): RadioRow[] {
return rows.map((r) => ({
radioMac: (r['radio-mac'] || '').toUpperCase() || null,
interfaceName: r['interface'] || null,
local: isYes(r['local']),
hwType: r['hw-type'] || null,
currentChannel: r['current-channels'] || r['current-channel'] || null,
raw: r,
}));
}
/**
* Attribute each radio a controller reports to a managed device, by MAC.
*
* `macToDevice` should hold every interface MAC known across the fleet. Matching is
* on hardware identity alone: a MAC is globally unique, so unlike an address it
* cannot mean two different devices on two different segments.
*/
export function matchRadiosToDevices(
radios: RadioRow[],
macToDevice: Map<string, number>,
controllerDeviceId: number
): { radio: RadioRow; deviceId: number | null }[] {
return radios.map((radio) => {
if (radio.local) return { radio, deviceId: controllerDeviceId };
const hit = radio.radioMac ? macToDevice.get(radio.radioMac.toUpperCase()) : undefined;
return { radio, deviceId: hit ?? null };
});
}
/**
* A radio MAC is usually the interface MAC with the low bits of the last octet
* varying per radio, so an exact lookup can miss by one. Callers build the index
* with this so a near-miss still resolves, while keeping the OUI and the first five
* octets exact — enough to stay unambiguous within a fleet.
*/
export function macIndexKeys(mac: string): string[] {
const norm = mac.toUpperCase();
const parts = norm.split(':');
if (parts.length !== 6) return [norm];
return [norm, parts.slice(0, 5).join(':')];
}
/** Look up a device for a radio MAC, falling back to the five-octet prefix. */
export function lookupDeviceForMac(
mac: string | null,
index: Map<string, number>
): number | null {
if (!mac) return null;
for (const key of macIndexKeys(mac)) {
const hit = index.get(key);
if (hit !== undefined) return hit;
}
return null;
}