fix(wireless,topology): CAPsMAN write guards, MAC neighbour resolution, unified panel (v0.23.0-beta)

Closes #95. Addresses the second round of #94 feedback. Includes a regression
introduced in v0.22.0.

Wireless write paths were not CAPsMAN-aware (regression)
  v0.22.0 widened getAP() and the bulk-SSID selector to include cap/controller
  roles so a controller — normally tagged a router — would stop being invisible to
  the wireless section. Those same helpers gate the write endpoints, so the change
  also allowed writes to devices whose radios a controller owns. Reads needed
  widening; writes needed a guard, and did not get one.

  Five paths now refuse or skip provisioned radios: bulk SSID deployment, single
  interface create, interface edit, interface delete, and guest network setup.
  Delete was the worst of them — RouterOS will not remove a provisioned interface,
  but the cached row was deleted anyway, so it vanished from the UI until the next
  poll brought it back.

  The check is shared: partitionRadios() splits physical radios into locally
  controllable and controller-owned, assertNotCapsmanManaged() refuses an edit to a
  provisioned interface and names the controller.

Neighbours identified only by MAC (#95)
  Topology resolved a neighbour to a managed device by IP address. A trunk port has
  no IP, so LLDP across one reports a MAC and an identity but no address — and
  those links stayed unresolved, drawing an external node beside the very device it
  already had on the map. Two directly connected devices appeared four times.

  Resolution by MAC now runs first, before address matching, because a MAC is
  hardware identity and unique fleet-wide where an address is not. This is the same
  join the CAPsMAN radio matching uses.

CAPsMAN panel, from the reporter's testing
  - Radios and provisioned interfaces were two tables describing the same things
    from different angles; they are now one, per interface, with the access point,
    SSID, band, frequency, channel, clients and status.
  - Provisioning rules sit inside the configuration they apply. Rules referencing a
    configuration we did not collect are listed separately rather than dropped,
    since that usually means a stale reference.
  - The Wireless header reported zero clients while the Clients page reported 32:
    it summed the CAP-local count, which is empty under central forwarding. It now
    takes the greater of that and the controller's per-radio counts, so a controller
    that is also a CAP is not double-counted.
  - A remote radio could be attributed to the controller by the five-octet prefix
    fallback when the two shared a MAC block. Prefix matches now refuse the
    controller for a non-local radio; exact matches are still honoured.

Radios tab followed device_type
  device_type is user-supplied tagging, and a router with radios had no way to
  display them. The tab now follows the detected wifi_role, which is a fact about
  the hardware.

48 tests on the CAPsMAN module, 22 on topology, including a fixture built from the
device output in #95. Verified against the fleet: topology output unchanged, so MAC
resolution merged nothing it should not have; guards correctly permissive on a
standalone AP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-20 16:48:29 -07:00
co-authored by Claude Opus 5
parent e15556b601
commit 82efcaa371
16 changed files with 415 additions and 132 deletions
@@ -1344,6 +1344,63 @@ export class DeviceCollector {
}
// ─── 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<string, string>[]; 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<string, string>[])
: await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
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<void> {
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<string, string>[]);
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;
@@ -1446,9 +1503,12 @@ export class DeviceCollector {
seen.push(radio.radioMac);
const capInfo = parseCapStatus(radio.raw);
const st = radio.interfaceName ? live.get(radio.interfaceName) : undefined;
// A remote radio belongs to a CAP, never to the controller — so a
// near-miss prefix match against the controller's own MAC block must not
// pull it back onto the controller.
const matched = radio.local
? this.device.id
: lookupDeviceForMac(radio.radioMac, macIndex);
: lookupDeviceForMac(radio.radioMac, macIndex, this.device.id);
await query(
`INSERT INTO capsman_radios
@@ -2429,21 +2489,15 @@ export class DeviceCollector {
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').catch(() => [] as Record<string, string>[])
: await this.client.execute('/interface/wireless/print').catch(() => [] as Record<string, string>[]);
let physicals = raw.filter(r => !r['master-interface'] && r['name']);
if (physicals.length === 0) throw new Error('No physical radios found to attach the guest SSID to');
? await this.client.execute('/interface/wifi/print', { detail: '' }).catch(() => [] as Record<string, string>[])
: await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
// A CAPsMAN-provisioned radio is owned by the controller. Adding a local
// virtual AP to it does not reliably take effect and can be wiped or
// contradicted the next time the controller provisions the CAP — so skip
// those radios and say so, rather than reporting success for something that
// silently will not work. Raised by the reporter of issue #94.
const role = await this.detectWifiRole();
const managedRadios = physicals.filter(r => isCapsmanManaged(r, role));
if (managedRadios.length > 0) {
physicals = physicals.filter(r => !isCapsmanManaged(r, role));
const names = managedRadios.map(r => r['name']).join(', ');
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 `
@@ -267,3 +267,30 @@ describe('clientsPerRadio', () => {
expect(clientsPerRadio(interfaces, []).size).toBe(0);
});
});
describe('lookupDeviceForMac — prefix must not steal a CAP for the controller (#94)', () => {
it('refuses a prefix match against the excluded device', () => {
// Controller (id 3) owns AA:BB:CC:00:00:01; a CAP radio one octet away must not
// be attributed to it.
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 3] as [string, number]));
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx, 3)).toBeNull();
});
it('still honours an exact match even against the excluded device', () => {
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 3] as [string, number]));
expect(lookupDeviceForMac('AA:BB:CC:00:00:01', idx, 3)).toBe(3);
});
it('still prefix-matches a different device', () => {
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 7] as [string, number]));
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx, 3)).toBe(7);
});
it('prefers an exact match over a prefix match', () => {
const idx = new Map<string, number>([
['AA:BB:CC:00:00', 7], // prefix → device 7
['AA:BB:CC:00:00:09', 9], // exact → device 9
]);
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx)).toBe(9);
});
});
+20 -4
View File
@@ -212,15 +212,31 @@ export function macIndexKeys(mac: string): string[] {
return [norm, parts.slice(0, 5).join(':')];
}
/** Look up a device for a radio MAC, falling back to the five-octet prefix. */
/**
* Look up a device for a radio MAC, falling back to the five-octet prefix.
*
* `excludeOnPrefix` refuses a *prefix* match against a given device — used to stop a
* remote CAP being attributed to the controller when the two happen to share the
* first five octets. An exact match is still honoured, since that is hardware
* identity rather than a near-miss. Reported on #94, where a CAP's radio was grouped
* under the controller instead of its access point.
*/
export function lookupDeviceForMac(
mac: string | null,
index: Map<string, number>
index: Map<string, number>,
excludeOnPrefix?: number
): number | null {
if (!mac) return null;
for (const key of macIndexKeys(mac)) {
const keys = macIndexKeys(mac);
const exact = index.get(keys[0]);
if (exact !== undefined) return exact;
for (const key of keys.slice(1)) {
const hit = index.get(key);
if (hit !== undefined) return hit;
if (hit === undefined) continue;
if (excludeOnPrefix !== undefined && hit === excludeOnPrefix) continue;
return hit;
}
return null;
}