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
+3 -3
View File
@@ -302,7 +302,7 @@ router.get('/:id', async (req: Request, res: Response) => {
notes, location_address,
location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng,
rack_name, rack_slot, created_at, updated_at
rack_name, rack_slot, wifi_role, created_at, updated_at
FROM devices WHERE id = $1`,
[req.params.id]
);
@@ -339,11 +339,11 @@ router.patch('/:id/location', requireWrite, async (req: Request, res: Response)
const updated = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model,
serial_number, firmware_version, ros_version, device_type, status, last_seen,
serial_number, firmware_version, ros_version, device_type, status, last_seen, wifi_role,
notes, location_address,
location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng,
rack_name, rack_slot, created_at, updated_at
rack_name, rack_slot, wifi_role, created_at, updated_at
FROM devices WHERE id = $1`,
[req.params.id]
);
+9 -2
View File
@@ -20,7 +20,7 @@ export function setPollerService(p: PollerService): void {
// GET /api/topology
router.get('/', async (_req: Request, res: Response) => {
const [devices, allLinks, manualLinks] = await Promise.all([
const [devices, allLinks, manualLinks, deviceMacs] = await Promise.all([
query<TopoDevice>(
`SELECT id, name, ip_address, model, device_type, status, ros_version, ip_addresses_jsonb
FROM devices ORDER BY name ASC`
@@ -40,9 +40,16 @@ router.get('/', async (_req: Request, res: Response) => {
JOIN devices fd ON fd.id = ml.from_device_id
JOIN devices td ON td.id = ml.to_device_id`
),
// Every interface MAC in the fleet, so a neighbour seen only by MAC — an LLDP
// sighting across a trunk port carries no address — still resolves to its device.
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`
),
]);
const graph = buildTopology(devices, allLinks, manualLinks);
const graph = buildTopology(devices, allLinks, manualLinks, deviceMacs);
res.json({
devices,
+46 -5
View File
@@ -42,6 +42,18 @@ router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => {
try {
await collector.connect();
const pkg = await collector.detectWifiPackage();
// Widening the device selector so CAPsMAN controllers stop being invisible
// also let CAPs through here. An SSID created on a provisioned radio is
// discarded by the controller, so refuse rather than report success.
const { local: localRadios, capsmanOwned } = await collector.partitionRadios();
if (localRadios.length === 0 && capsmanOwned.length > 0) {
throw new Error(
`${ap.name}: every radio is provisioned by CAPsMAN (${capsmanOwned.join(', ')}). `
+ `Add this SSID to the controller's configuration instead.`
);
}
const autoName = await collector.getNextInterfaceName();
// Normalize mode to the correct value for this AP's package type
@@ -98,7 +110,15 @@ router.get('/', async (_req: Request, res: Response) => {
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
COUNT(DISTINCT wi.id) AS radio_count,
COALESCE(SUM(wi.registered_clients), 0) AS client_count,
-- A CAP's own registration table is empty when the controller processes
-- traffic centrally, so its per-interface counts read zero. GREATEST
-- prefers whichever source actually has the clients without adding the
-- two together on a controller that is also a CAP.
GREATEST(
COALESCE(SUM(wi.registered_clients), 0),
COALESCE((SELECT SUM(cr.registered_peers) FROM capsman_radios cr
WHERE cr.matched_device_id = d.id), 0)
) AS client_count,
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
@@ -158,8 +178,8 @@ router.get('/capsman', async (_req: Request, res: Response) => {
`, [ids]),
]);
// SSIDs actually broadcasting, so the view shows what is live rather than only
// what was configured.
// Interfaces provisioned onto the CAPs, joined to their radio so the UI can show
// one row per interface with the access point it lives on.
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
@@ -208,8 +228,17 @@ router.get('/capsman', async (_req: Request, res: Response) => {
...g,
client_count: peers(g.radios as { registered_peers?: number | null }[]),
})),
configurations: byController(configs as { controller_device_id: number }[], c.id),
provisioning: byController(provisioning as { controller_device_id: number }[], c.id),
configurations: byController(configs as { controller_device_id: number; name: string }[], c.id)
.map((cfg) => ({
...cfg,
// The rule and the configuration it applies are one idea; showing them in
// separate boxes made the reader join them by eye (#94).
rules: byController(provisioning as { controller_device_id: number; master_configuration: string | null }[], c.id)
.filter((p) => p.master_configuration === cfg.name),
})),
provisioning: byController(provisioning as { controller_device_id: number; master_configuration: string | null }[], c.id)
.filter((p) => !configs.some((cfg) => (cfg as { controller_device_id: number; name: string }).controller_device_id === c.id
&& (cfg as { name: string }).name === p.master_configuration)),
ssids,
};
}),
@@ -278,6 +307,16 @@ router.post('/:id/interfaces', requireWrite, async (req: Request, res: Response)
try {
await collector.connect();
// A virtual AP created on a CAPsMAN-provisioned radio is discarded by the
// controller at the next provision, so refuse instead of reporting success.
const { local: localRadios, capsmanOwned } = await collector.partitionRadios();
if (localRadios.length === 0 && capsmanOwned.length > 0) {
return res.status(409).json({
error: `Every radio on ${ap.name} is provisioned by CAPsMAN (${capsmanOwned.join(', ')}). `
+ `Add this SSID to the controller's configuration so it is provisioned to the access points.`,
});
}
// Auto-generate the next available interface name (e.g. wifi3, wifi4 …)
const autoName = await collector.getNextInterfaceName();
@@ -358,6 +397,7 @@ router.put('/:id/interfaces/:name', requireWrite, async (req: Request, res: Resp
const collector = new DeviceCollector(ap);
try {
await collector.connect();
await collector.assertNotCapsmanManaged(ifaceName);
await collector.setWirelessInterface(ifaceName, params);
// Bridge port membership — only touched when 'bridge' key is present in the request
if (body.bridge !== undefined) {
@@ -386,6 +426,7 @@ router.delete('/:id/interfaces/:name', requireWrite, async (req: Request, res: R
const collector = new DeviceCollector(ap);
try {
await collector.connect();
await collector.assertNotCapsmanManaged(req.params.name);
await collector.removeWirelessInterface(req.params.name);
await query(
`DELETE FROM wireless_interfaces WHERE device_id=$1 AND name=$2`,