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
+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