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:
co-authored by
Claude Opus 5
parent
19617e8357
commit
14bab24c7e
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user