Commit Graph
157 Commits
Author SHA1 Message Date
2GT-RichandClaude Opus 5 4ff8eec200 fix(topology): stop breadth translating into width (v0.23.22-beta)
Closes #115.

The map gave every leaf its own column, so a subtree's width was simply its leaf
count. That is reasonable for a deep branching network and useless for a real
one: a core switch with fifty-eight access switches under it produced a single
row fifty-eight slots wide — over ten thousand pixels, which is exactly how it
was reported.

Real networks are shallow and wide, so breadth had to stop translating directly
into horizontal distance. Large groups of sibling leaves are now laid out as a
block rather than a row, trading width for height and turning linear growth into
roughly square-root. The reported shape goes from 58 slots to 10, six rows deep.

Children that have subtrees of their own keep the classic tidy-tree treatment,
because there the hierarchy is the information and flattening it would destroy
meaning. Only leaf groups — where the sibling order carries nothing — are packed.

Block proportions are deliberately wider than tall: screens are landscape, and a
column of forty reads worse than a band four rows deep.

The layout moved to utils/topologyLayout.ts in slot and row units, with pixels
left to the renderer, and 12 tests. That split is what makes the fix checkable at
all — the old arithmetic was only observable by looking at a rendered graph of a
network none of us has. The tests pin the reported case and, as importantly, the
shapes that must not regress: a three-node row stays a row, a branching tree
keeps its levels, a deep chain stays one slot wide, and no two nodes land on the
same coordinate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:45:18 -07:00
2GT-RichandClaude Opus 5 7d02bedc5f feat: schedule in the operator's timezone, and make Top Talkers searchable (v0.23.21-beta)
Closes #117.

**Scheduled work ran on the container's clock.** cronMatchesNow used Date's own
accessors, which read the process timezone — UTC in the shipped image. A backup
set for 02:00 therefore fired at 02:00 UTC wherever the operator was: seven hours
early on the US west coast, and on the wrong day entirely for anyone far enough
east. The settings page said "server time", which was accurate and precisely the
problem. Reported by a user managing devices across regions, who described it as
a configuration gap; it is a correctness bug.

There is now an app_timezone setting, chosen from the zones the runtime knows
and offered alongside the browser's own as a one-click default. Schedules are
evaluated in it, and the page states the zone and the current time in it, so a
wrong setting is obvious immediately rather than at 2am three days later.

The matching logic moved to utils/cron.ts with 13 tests, because the bug was
invisible where it lived: it only manifests when the process zone and the
operator's differ, which never happens in a developer's own shell. The tests
pin the cases that matter — the same instant matching in exactly one of three
zones, a job on the 2nd UTC still being the 1st in Los Angeles, day-of-week
resolved locally, and daylight saving handled by Intl rather than by a fixed
offset. Zone handling goes through Intl for that last reason; offset arithmetic
would be wrong twice a year.

An unknown zone falls back to UTC rather than throwing, so a bad value cannot
stop every schedule in the product.

**Top Talkers was ten rows with no way to look anything up**, which is unusable
on a network of any size — you could see the loudest ten and nothing else. It
now filters on name, IP, MAC or vendor, and ranks 10, 25 or 50. Filtering is
client-side because the rows are already present; asking the server per keystroke
would be slower and no more accurate. Requested in the discussion thread, where
the backend already served everything needed and only the UI was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:18:08 -07:00
2GT-RichandClaude Opus 5 dd71271473 fix(poller): the health panel must not blank the page, and pass the tunables through (v0.23.20-beta)
Two faults reported against v0.23.19, one of which makes the other much worse.

**POLLER_CONCURRENCY was never passed into the container.** It is read from the
environment by the backend and documented in .env.example, but docker-compose
did not forward it, so setting it did nothing at all. The same was true of the
job timeout, interval and both retention limits. Every poller tunable shipped in
v0.23.17 was inert for anyone running the compose stack — including the one knob
the #114 reply told the reporter to reach for. All five are now forwarded.

**The health panel crashed on an unexpected payload**, taking the whole page
with it: `cap is undefined` from reading data.capacity without a guard. It also
rendered "Loading…" indefinitely on a request error, so a failing endpoint and a
slow one looked identical.

Both are the wrong failure mode for this screen in particular. It is the page an
operator opens precisely when monitoring already looks broken, and a blank white
panel at that moment is worse than no panel: it suggests the whole tool is dead
rather than that one query failed.

Every field is now defaulted, so a backend a version behind or one that answered
partially degrades the panel instead of destroying the page, and a failed request
renders what actually went wrong with a retry. Verified against the four shapes
that could produce the reported crash: capacity missing, an error body arriving
with a 2xx, an older backend without the status field, and an empty object.

The endpoint is hardened to match. Device count, timing and the stale-device
listing are each guarded independently, so a partial failure returns partial
numbers rather than a 500 — the same reasoning applied at the other end.

I could not reproduce the original crash: at concurrency 48 the endpoint returns
a complete payload here, there is no route shadowing, and the axios interceptor
rejects errors correctly rather than passing them through as data. The defensive
work stands on its own regardless of which of those shapes he actually hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:14:26 -07:00
2GT-RichandClaude Opus 5 745a7cda93 fix(poller): drain stale backlogs, and stop reporting health that isn't (v0.23.19-beta)
v0.23.17 stopped the poller creating an unbounded backlog but did nothing about
the one already there. A reporter with sixty devices upgraded and still had
752,318 jobs queued, nine stale devices, and an endpoint cheerfully reporting
1.59x headroom. Fair to call that not fixed.

Three faults, all mine.

**Retention never touched pending work.** cleanupJobHistory cleaned 'completed'
and 'failed' only. The queue of outstanding polls — the part the operator
actually feels — was out of scope, so an upgrade left it draining for days.

Stale pending jobs are now swept, at startup and every five minutes. The rule is
that a periodic poll which has waited longer than its own cadence is worthless:
running it yields a reading the next cycle would have taken anyway, while
occupying a worker the live fleet is waiting on. Discarding queued work is safe
precisely because it is periodic — the schedule re-enqueues within one interval.

**Deduplication failed in the case it existed for.** The in-flight claim was a
Redis key with a 135-second TTL. Under a large backlog a job waits hours, so the
claim lapsed long before it ran and the scheduler re-enqueued every cycle: the
guard worked only on a queue that was already short.

Replaced with a deterministic job id, which cannot lapse. While a job for a
device and kind exists in any state, adding it again is a no-op. That requires
finished jobs to be removed immediately or the id would stay taken and the
device would never be polled again — which costs nothing now that
device_poll_stats records every attempt, duration and error.

**The headroom metric lied.** It describes flow and is blind to accumulated
work, so it read "healthy" beside three-quarters of a million queued jobs. It
now reports backlog and drain ETA alongside, plus a single status verdict that
cannot say ok while a backlog exists. Verified by injecting 15,125 jobs: status
went to 'draining' with headroom still at 85x, which is exactly the case the old
metric got wrong.

Also added, because the reporter asked and was right to: an operator-triggered
drain (POST /api/system/poller/drain), and a Polling tab in Settings showing
queue depth, headroom, backlog, per-device staleness and the drain control. It
was all buried in an API response before. A dashboard insight fires when the
poller falls behind, so a starved poller announces itself rather than presenting
as a fleet of broken devices.

Two corrections found by testing rather than reasoning:

  * BullMQ rejects ':' in a custom job id. `fast:2` threw on every enqueue and
    killed the scheduler outright — three of four devices went unpolled for
    eight minutes before it was caught. Hyphens now, and the dedup is exercised
    directly: 500 enqueues of one device yield one queued job.
  * Redis maxmemory-policy moves from volatile-lru to noeviction. I had reasoned
    that only TTL-bearing keys are evicted and BullMQ job hashes carry none —
    true, but its *control* keys do. Job locks and stalled-check markers both
    expire, and evicting one causes duplicate processing or a stalled-job storm.
    BullMQ warns about this on connect and was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:07:49 -07:00
2GT-RichandClaude Opus 5 deb71363c9 fix(poller): cap Redis memory, and stop retrying periodic polls (v0.23.18-beta)
The two follow-ups left open by v0.23.17. One is a real fix; the other turned
out to need explaining rather than fixing.

**Redis had no memory ceiling.** v0.23.17 stopped the poller from growing Redis
without bound, but nothing stopped anything else from doing so, and the host had
no protection either way. Redis now runs with a 512 MB cap (REDIS_MAXMEMORY).

The eviction policy is the part worth care. `allkeys-lru` would be actively
dangerous here: BullMQ job data carries no TTL, so an LRU sweep across all keys
could discard queued work with no trace. `volatile-lru` evicts only keys that
set an expiry — which is exactly the poller's caches and in-flight claims, all
of which are safe to lose because losing one merely causes an earlier re-poll.
Verified rather than assumed: sampled BullMQ job keys report ttl=-1 and poller
cache keys 528-3408s. If pressure ever exhausts the volatile keys Redis refuses
writes instead of evicting queue state, which is loud and survivable.

**The 14% fast-poll failure rate is not a bug.** Current rate across all devices
and job kinds is 0% over 1,462 attempts. The historical 230,707 failures were
genuine device downtime: device_availability records 324 outages totalling ~1,003
hours across four lab devices, which at a 30-second cadence and two attempts per
cycle predicts ~240,700 failures — within 4% of what was recorded. Nothing to
fix; retention had simply been hoarding an accurate count.

It did expose real waste, though. Periodic polls no longer retry in-job, because
the schedule already is the retry: a device that did not answer is unlikely to
answer a second later, and each extra attempt occupies a worker for up to the
job timeout. On a fleet with several devices down that doubled the capacity they
consumed — the same capacity the rest of the fleet was short of in #114. It also
doubled the recorded failure count, which is how ~1,000 hours of downtime came
to look like 231k distinct faults. User-initiated full syncs keep their retries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:20:56 -07:00
2GT-RichandClaude Opus 5 38d23cde02 fix(poller): stop the queue outrunning the workers, and cap job retention (v0.23.17-beta)
Addresses #114, where a ~60 device fleet saw devices stop reporting entirely
while others crawled, new devices never started polling, and a manual Sync
worked exactly once. All of it is one failure with two compounding causes.

**The queue grew faster than it drained.** Worker concurrency was hardcoded to 3
with no per-job timeout, and the scheduler enqueued a fast poll per device every
30 seconds regardless of whether the previous one had finished. Measured against
a live fleet a fast poll took ~1s at the median but 13s at p90, so three workers
sustained about 0.87 jobs/sec against an arrival rate of 2.0/sec at sixty
devices. The difference — roughly 98,000 jobs a day — accumulated in an unbounded
FIFO, and devices at the back of it were simply never polled. That is why Sync
appeared to work once and then stop: the Sync route runs inline and bypasses the
queue entirely, so it was the only thing still reaching those devices.

Three changes: concurrency is configurable and defaults to 12; every job runs
under a timeout so one unreachable device cannot hold a worker while the fleet
starves; and a device with a poll already queued or running is not enqueued
again. That last one is the important one — it makes a backlog impossible rather
than merely less likely. The claim is a Redis key with a TTL, so a worker that
dies frees its device on the next cycle instead of wedging it forever.

**Finished jobs were never removed.** BullMQ keeps them unless told otherwise.
A four-device development instance had accumulated 1,412,935 completed and
230,707 failed jobs across the three queues — 2,570,474 Redis keys and 1.16 GB,
against a Redis with no maxmemory configured. Left alone it grows until the host
dies. Retention is now bounded by age and count, and a background pass on
startup trims history from before the limits existed.

The two faults fed each other, which is measurable: after trimming 2.16M jobs
the mean fast poll on the same fleet fell from 3,448ms to 602ms and the slowest
from 16,095ms to 1,205ms. Multi-million-element sorted sets were slowing every
queue operation, which reduced throughput, which grew the backlog further.

**The reporter's follow-up is the real lesson** and is addressed too: there was
no way to tell whether a device had been missed, was unresponsive, or was merely
slow, and no view of queue depth or per-poll cost — so polling frequency was
being chosen by guesswork. device_poll_stats now records attempt and success
separately per device and kind, with duration and last error; their difference
is precisely the missing distinction. GET /api/system/poller reports queue
depths, worker settings, measured poll cost, and a headroom figure that states
whether the configured cadence is achievable at the current fleet size. Below
1.0 the backlog grows every cycle.

Verified on the live instance: 2,570,474 keys reduced to 2,336, memory 1.16 GB
to 32.25 MB, queues drained, per-device stats recording, no stranded in-flight
claims, and headroom reported at 149x for four devices — the same arithmetic
that would have read 0.43 for sixty devices before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:28:51 -07:00
2GT-RichandClaude Opus 5 1332f7384d fix(firmware): make the per-device update button actually install (v0.23.16-beta)
The "Install update" button on a device's Config tab reported success and did
nothing. Staged rollouts were unaffected, which is why this survived: the two
paths do the same job through different code, and only one of them was right.

RouterOS installs the version discovered by `check-for-updates` **in the same
API session**. Issued alone, `/system/package/update/install` replies !done and
takes no action — no download, no reboot, no error. The route issued it alone.
The rollout orchestrator calls the check first, so it always worked.

Confirmed by A/B on one switch, two minutes apart. Through the button: HTTP 200,
"Update installation initiated. Device will reboot", and ninety seconds later
the device still reported 7.24 with "New version is available" and had not
rebooted. Through a rollout: reboot within fourteen seconds, upgraded to 7.24.1.

Two further faults in the same handler, both of which made the first one harder
to see:

  * `firmware_update_available` was cleared the moment the command was sent, so
    every screen showed the device as current while it sat unchanged. The next
    poll flipped it back, which reads as an update that "keeps coming back".
  * Success was reported on submit rather than on outcome, so a device that was
    already up to date and a device that had genuinely started an upgrade
    produced identical messages.

The route now checks first, distinguishes "installing 7.24.1" from "already
running 7.24.1 — nothing to install", and leaves the flag alone: an upgrade
takes minutes and a reboot, neither of which an HTTP request can wait for.

Ownership of that flag moves to the version read itself. collectSystemInfo now
clears it when the observed version matches the known latest, because the
dedicated firmware check runs only once a day — without this, not clearing
optimistically would leave a device flagged for up to 24 hours after it was
upgraded. It also fixes the same staleness when a device is upgraded outside the
platform entirely.

install-routerboard had the identical shape and gets the identical treatment.
It matters more there: `/system/routerboard/upgrade` on a current bootloader is
a no-op, but the code rebooted the device afterwards regardless — an outage in
exchange for nothing.

Verified against the live fleet: the fixed button upgraded 2GT-NW-BigSwitch
7.24 -> 7.24.1; an already-current device answered "nothing to install" instead
of claiming a reboot; the flag stayed set during the upgrade and cleared once
the new version was observed; and a device genuinely behind stayed flagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:40:24 -07:00
2GT-RichandClaude Opus 5 f011a40748 fix(dashboard): render the snooze button, and cap the things-to-handle list (v0.23.15-beta)
Three faults in the same card, all layout.

**Snooze rendered as an unreadable sliver.** The row is a three-column grid —
severity bar, text, action — but carried four children once the snooze button
was added in v0.23.10. The fourth wrapped onto an implicit second row and landed
in column one, which is 3px wide, so the button was squeezed to nothing. The two
buttons now share the third column in a flex wrapper, which is what the grid
always assumed.

Only the rendering was broken: the dismiss round-trip was verified end to end
against the running instance — POST returns 201, the item leaves the list, the
snoozed count rises, and DELETE restores it.

**A long backlog pushed the rest of the dashboard off the page.** The list grew
without bound, so a fleet with real problems displaced everything below it. It
now caps at min(52vh, 460px) and scrolls, keeping the card a fixed part of the
page. The snoozed-count footer stays outside the scroll area so it remains
visible.

**The card rendered at 57% width with dead space beside it.** It was the only
child of a two-column grid left behind when quick actions moved to the header
(#104) — the comment above it already claimed full width, which it had not been
since. The empty column and its wrapper are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:39:46 -07:00
2GT-RichandClaude Opus 5 7a548abfd8 fix(clients): filter the row the table shows, not the rows behind it (v0.23.14-beta)
Closes #100.

The clients list deduplicates by MAC: a client seen by several devices collapses
to one row, and DISTINCT ON resolves the collision in favour of the wireless
sighting. The filters, however, ran against the raw rows before that happened,
so a filter and the column beside it could disagree.

The wired/wireless filter showed it plainly. A wireless client's MAC is also
learned by the switch its AP uplinks to, so it holds both a wireless row and a
wired one — on the test fleet every wireless client does. Filtering by "wired"
therefore returned all 176 clients rather than 163, including the 13 the table
was labelling wireless. Filtering by signal had the same flaw in a subtler form:
a client seen by two APs at different strengths could appear in a band its
displayed row was not in.

Filters now sit on the correct side of deduplication according to what they
mean. Device and active-state choose which sightings to consider, so they still
run before. Type, VLAN and signal describe what the table displays, so they run
after. The count query deduplicates identically, so totals and rows agree.

Verified against the live fleet by running the same predicates as SQL and
through the API in the same moment: 176 total, 162 wired, 14 wireless, 29 on
VLAN 1, 13 at -67 dBm or stronger — matching on every one, with the wired and
wireless halves now summing to the total.

Also closes four issues that were implemented in v0.23.9 and v0.23.10 but stayed
open: their commit messages listed several numbers behind a single "Closes"
keyword, which GitHub applies only to the first.

  #97  — location selector on the Channel Usage panel
  #102 — snoozeable "Things to handle" items
  #107 — rack and location filters on the device table
  #108 — topic, device and severity filters on the events table

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:58:58 -07:00
2GT-RichandClaude Opus 5 ec873ab8bd fix(wireless): keep the 2.4 GHz channel map inside its card (v0.23.13-beta)
The 2.4 GHz row of the Channel Usage panel painted over the edge of the card.

The cause is the band table, not the layout. A 40 MHz channel 1 occupies
2392-2432 MHz, but the drawn 2.4 GHz band started at 2400, so that carrier
resolved to `left: -8%` and an absolutely positioned bar at a negative offset
renders outside its container. 2.4 GHz is the only band where this can happen:
channel 1 is the lowest channel in the plan and the only one whose 40 MHz form
reaches below the round number the axis started on.

The band now starts at 2390, which is also more honest — the radio really does
reach below 2400, and clamping alone would have drawn it as though it did not.

Geometry moves to utils/spectrumGeometry.ts with tests, and keeps a clamp even
though the range now covers channel 1: band ranges are static assumptions, and
region-specific channels and widths they do not anticipate will keep appearing.
No bar can leave the card again regardless of what the backend reports.

An earlier attempt at this fixed a real but different bug in the same row: ticks
at 2400 and 2480 fall outside the channel plan, so an axis labelled by channel
fell back to printing raw frequencies, and the 2400 label sat at exactly 0% with
a centring transform and hung half its width outside the card. That fix stands;
it simply was not the whole of what the user was seeing. The verification script
used at the time filtered radios to those starting inside their band, which
silently discarded the very row that was overflowing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:35:22 -07:00
2GT-RichandClaude Opus 5 2625fea8de feat(lte): cellular telemetry, band details and roaming history (v0.23.12-beta)
Adds LTE support, requested in discussion #85 by a contributor running a
MikroTik ATL 18 on a fixed antenna. Cellular was previously a blank page: the
only occurrence of "lte" in the tree excluded LTE interfaces from DHCP binding.

Built against two real field captures rather than documentation, which
disagreed with the hardware in several places — the modem reports `data-class`
not `access-technology`, `iccid` not `uicc`, `status: running` not `connected`,
embeds units inside values (`-97dBm`, `17dB`), and omits `registration-status`,
`pin-status` and `lac`/`tac` entirely. What an LTE interface reports depends on
the modem chipset, not on RouterOS, so every field is optional by construction.

Fixes a latent bug in the API client while it was in reach. A sentence carrying
the same attribute twice kept only the last value, and a Cat-18 modem reports
one `ca-band` per aggregated carrier. On the contributor's 4-carrier capture
that yielded B3 + B20 — 30 MHz — on a device actually running 70 MHz, because
the narrowest carrier happens to arrive last. Repeats are now preserved
alongside the flat value, so existing callers are byte-for-byte unaffected.

Signal quality is graded rather than merely displayed. RSRP alone misleads: the
capture shows −97 dBm, nominally "fair", on a link running 256QAM at CQI 15 with
two spatial streams. SINR leads the grade and RSRP moderates it.

Surfaces the uplink anchor, which no MikroTik UI shows. Left on automatic a
modem anchors to whichever band it hears loudest, often a narrow one, and
aggregates wider bands for downlink only — but uplink rides the primary carrier
alone, so upload stays capped. This is the contributor's actual reason for
locking bands, and it reframes band selection as anchor control rather than
coverage.

Band changes, tower handovers and re-registrations are reconstructed by
comparing consecutive polls, since scanning interrupts service and finds nothing
at a site served by a single base station. Observed bands accumulate as the
evidence a future band lock will be checked against.

Band selection itself is deliberately absent: it needs Change Guard plus a
free-space check, as the target hardware has 1.1 MB free of 16 MB flash.

Also fixes an unrelated display bug in the RF Health channel map. The 2.4 GHz
axis is labelled by channel, but ticks at 2400 and 2480 fall outside the channel
plan and fell back to printing raw frequencies — and the 2400 label sat at
exactly 0% with a centring transform, hanging half its width outside the card.
2.4 GHz is the only band with a tick at an edge. Unlabelled ticks are now
dropped and edge labels anchor inward.

  - backend/src/utils/lte.ts — parsing, quality, change detection (59 tests)
  - RouterOSClient — repeated attributes preserved (6 tests)
  - lte_interfaces, lte_cell_history, lte_observed_bands
  - GET /devices/:id/lte, /lte/history, /lte/metrics
  - LteTab, gated on has_lte from the interface list
  - scripts/seed-lte-fixture.sql — a cellular device for local development

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:06:28 -07:00
2GT-RichandClaude Opus 5 57d650c159 feat(wireless): reconstruct per-client roaming history from AP logs (v0.23.11-beta)
Closes #105.

RouterOS narrates the whole life of an association in its log — association, every
band or AP change, and the disconnect — each line carrying the signal at that
moment. Nothing else in the platform can see any of it: roaming happens between
polls, so a poll only ever observes where a client ended up, never that it moved.

Sessions are now rebuilt from those lines and shown on the client detail page: the
path across radios, signal at each step, duration, and RouterOS's own disconnect
reason. Sessions averaging six or more roams an hour are flagged, since a client
bouncing between radios is usually a roaming-threshold problem rather than a faulty
client.

No new collection and no new storage. It reads the events already gathered, so it
works retroactively over whatever log history exists.

Two things came out of testing against real logs rather than the samples in the
issue, and both were wrong in ways the samples could not have shown:

  Ordering. RouterOS log timestamps are second-granular, so a reassociation emits
  its disconnect and reconnect inside the same logged second, and collection
  assigns sub-second values in whichever order they were read — observed on a live
  AP as both `.849/.849` and `.096/.097` for the same kind of pair. Sorting on the
  raw timestamp attached the disconnect to the session the connect had just opened,
  so every reassociation reconstructed as a 0-second session while the real one
  disappeared. On one IoT client that was 255 phantom sessions out of 422. Events
  are now ordered by whole second and ties broken by meaning: a disconnect closes
  what came before, a connect opens what comes after. The same client now shows its
  real behaviour — 900s, 840s and 930s sessions, reassociating every 15 minutes.

  Signal sanity. Real logs contain `signal strength 91`. dBm is negative, so a
  positive reading is not a measurement; recording it would draw a client with an
  impossibly perfect signal. Treated as unknown.

21 tests, including the full ping-pong trace quoted in the issue — which the
reconstruction correctly identifies as flapping — plus both timestamp-collision
shapes seen in production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:27:14 -07:00
2GT-RichandClaude Opus 5 37887d38c2 feat: RouterBOOT in staged rollouts, and snoozeable insights (v0.23.10-beta)
Closes #113, #102.

#113 — RouterBOOT as a follow-on rollout step
  Firmware rollouts upgraded RouterOS and stopped, leaving the bootloader to be
  done by hand on every device — the exact task staged rollouts exist to remove.
  The data was already collected and the collector already had the two methods; the
  orchestrator simply never called them.

  An optional "Then RouterBOOT" step now runs per device after the RouterOS upgrade
  has been verified, rides out the second reboot, and confirms the firmware version
  actually moved.

  Ordering is deliberate: the bootloader ships inside the RouterOS package, so
  upgrading it first would apply the old one. It runs only once the OS half is
  confirmed good, and off by default, because it is a second flash and a second
  reboot per device.

  A device with nothing pending is a success rather than a failure — most of a fleet
  is already current, and treating "nothing to do" as an error would halt rollouts
  for no reason. When RouterBOOT does fail after a successful RouterOS upgrade the
  error says so explicitly, rather than reporting the device as simply failed and
  sending someone to re-run an upgrade it already has.

#102 — snooze an item in "Things to handle"
  Insights are recomputed from live state on every request and have no id, so a
  dismissal is keyed on a fingerprint of category, target and title. That survives a
  reworded title without swallowing a different device's version of the same issue.

  Dismissals expire, defaulting to 24 hours. "Handled" and "not now" look identical
  to the code, and an item that is genuinely resolved stops being generated anyway —
  so a permanent dismissal could only ever hide something still true. Expired rows
  are cleared on read, so a lapse needs no scheduled job, and the panel reports how
  many items are currently hidden rather than quietly shrinking.

Verified against the fleet: snoozing took the list from 8 items to 7 with
dismissedCount 1, and removing the dismissal restored it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:42:16 -07:00
2GT-RichandClaude Opus 5 60d7d13bd3 feat(ui): filters on the clients, devices and events tables (v0.23.9-beta)
Closes #99, #100, #107, #108.

Four reports with one root complaint: the tables sort but do not filter, which is
fine at four devices and painful at forty.

#100 / #99 — clients
  VLAN and signal-range filters, both server-side so counts and pagination stay
  correct across the whole dataset rather than the page in hand. Filter state lives
  in the URL alongside the existing wired/wireless choice, which makes a filtered
  view shareable and gives the AP Deployment Density chart somewhere to point.

  Clicking a density bubble now opens the clients list filtered to wireless clients
  in that signal zone (#99). The zone, not the 2 dB drawing bin — the bin is a
  rendering detail and the zone is the question being asked. Verified against the
  fleet: the four zones partition all 17 wireless clients exactly.

  VLAN options are built from VLANs actually present, so the list never offers a
  choice that returns nothing.

  Category filtering is deliberately absent. The effective category is a user
  override where set and otherwise a fingerprint computed in JS from vendor and
  hostname, so there is no column to filter on, and filtering after pagination would
  report wrong totals. Persisting the fingerprint would fix it and is noted in the
  route, along with the cost: a stored value goes stale whenever the rules change.

#107 — devices
  Rack and location selectors, populated from what is actually recorded so neither
  offers an empty choice. Free-text search now also covers location, rack and slot,
  so "datacenter" or "rack-4" finds devices without touching a dropdown. A RACK
  column joins the table, sortable like the rest.

#108 — events
  RouterOS reports topics as a comma-separated list, so the useful unit of filtering
  is the token rather than the string. A new endpoint splits and counts them across
  the whole table — not merely the loaded page — and the free-text topic box becomes
  a populated selector.

  Topic filtering also changed from ILIKE '%token%' to exact array membership. The
  badges present topics as discrete tags, so `error` must not quietly also match a
  hypothetical `no-error`.

On this fleet the endpoint reports 537,346 `info`, 535,439 `system` and 535,362
`account` — which makes the reporter's aside about login noise dominating the events
table concretely true, and worth its own issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:32:18 -07:00
2GT-RichandClaude Opus 5 5071e1f512 chore(dev): seedable CAPsMAN fixture so managed-wireless paths can be tested (v0.23.8-beta)
Two bugs shipped in the last three releases because CAPsMAN code was written and
"verified" against a fleet that contains no CAPsMAN. Neither was reachable by unit
tests, because both lived in SQL: a join that could never match, and a WHERE clause
that filtered out every managed radio before the analysis ran. Both were found by
the reporter, in production, after release.

The fix is not another test of the pure functions — those were already right. It is
to stop the local environment from being unrepresentative.

scripts/seed-capsman-fixture.sql builds a deployment shaped like a real one:

  - the controller is a ROUTER with no radios of its own, so anything keyed on
    device_type = 'wireless_ap' misses it
  - CAP interfaces carry NULL frequency, SSID and band, because the controller owns
    the configuration — the exact condition that removed them from Channel Usage
  - the controller mirrors every CAP interface locally, so a CAP's radio MAC
    legitimately appears on two devices — the condition behind the misattribution
  - channels exist only as "<freq>/<phy>/<control-positions>", never a plain number
  - the radios are arranged to produce one partial overlap and one co-channel pair,
    so interference classification has something real to get wrong

Running it against v0.23.7 confirms the fix shipped blind two commits ago actually
works: six managed radios now group under the controller, the controller appears in
the location selector, the standalone group holds only the genuinely standalone AP,
5680/ax/eCee/D resolves to 80 MHz, client counts come from the controller where the
CAP rows read zero, and the controller's mirror interfaces do not double-count.

Documented in docs/architecture.md, including the point that when a report arrives
with device output attached, that output is the most valuable thing in it and
belongs in the fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:21:19 -07:00
2GT-RichandClaude Opus 5 4a00b675d9 fix(wireless): read CAPsMAN radio channels from the controller (v0.23.7-beta)
Follow-ups on #97 and #106, both reported against the releases they shipped in.

#97 — managed radios were missing from Channel Usage entirely
  The query required wireless_interfaces.frequency > 0. A CAPsMAN-managed access
  point stores no frequency of its own: the controller owns the channel, and the
  AP's own rows carry nulls. Every managed radio was therefore filtered out before
  analysis ran, which is why the reporter saw all access points collapse into
  "Standalone" and a controller vanish from the selector — the group had no
  surviving radios to name it.

  The channel now comes from capsman_radios.current_channel where present, parsed
  from RouterOS's `<freq>/<phy>/<control-positions>` form. The control letters give
  a precise operating width — Ce is 40 MHz, Ceee is 80 — rather than the capability
  list a width field reports. Client counts prefer the controller's per-radio figure
  for the same reason.

  Verified only by unit test here, since we run no CAPsMAN. The local fleet still
  reports its two standalone radios unchanged.

#106 — the setting had no way to change it
  maps_enabled shipped without a control, which made it a setting in name only.
  Added under Settings, with the reasoning stated: tiles and geocoding are outbound
  requests to a third party, and the address entered is sent to them.

  Also removed the "map display is off" placeholder. The reporter pointed out it can
  never appear in the environment it was written for — with maps disabled from the
  start no coordinates are ever geocoded, so the block was dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:05:49 -07:00
2GT-RichandClaude Opus 5 dcd89fe86b feat(ui): quieten the dashboard — mute checks, hide app noise, honest sparkline (v0.23.6-beta)
Closes #101, #103, #104, #106, #111. Partially addresses #96.

A batch of signal-to-noise fixes reported together. Individually small; together
they are the difference between a dashboard someone reads and one they learn to
ignore.

#101 — security heuristics can be muted
  A warning you disagree with and cannot dismiss discredits every other finding
  beside it. Cleartext API is the reported case: on a fleet reachable only across
  WireGuard, TLS adds nothing to the threat model, and the check is permanent noise.
  Missing input-chain firewall is the same on a switch behind a router — the message
  even says "confirm that is the case", while offering no way to record that you did.

  Checks can now be muted per device or fleet-wide. Muted findings are still listed,
  greyed and labelled, and excluded from the score — not hidden. Hiding them would
  make the posture unauditable, which is a worse failure than noise.

#103 — the app's own requests are hidden from Recent activity
  Audit entries for writes made through the UI are noise from an operations point of
  view. Off by default now, with a toggle that names how many are hidden, remembered
  per browser since it is a viewing preference rather than fleet configuration.

#104 — quick actions moved into the header strip
  Matching the Wireless page. "Things to handle" takes the full width it was already
  overflowing.

#106 — map lookups can be turned off
  Geocoding hits nominatim.openstreetmap.org and the map pulls OSM tiles. On an
  isolated network those hang; everywhere else they disclose where your devices are
  to a third party, which the report understates. A maps_enabled setting stops both
  requests; stored coordinates are still shown as plain text so nothing is lost.

#111 — the CPU sparkline says something
  It was drawn in one colour and auto-scaled to each device's own peak, so an idle
  device and a saturated one produced an identical picture. Now plotted against a
  fixed 0-100 scale, coloured by the band its peak falls into — a flat line low in
  the box is an idle device and looks like one.

#96 — groundwork
  TX-retry figures come from CCQ, which only the legacy `wireless` driver reports, so
  the panel is permanently empty on a RouterOS 7 fleet. The wireless package each
  device runs is now recorded, and a capabilities endpoint answers the fleet-wide
  question. The panel is dropped and the density chart takes the full width when
  nothing can supply the data — and shown when nothing has been polled yet, rather
  than hiding a panel that may populate a minute later.

Verified against the fleet: muting cleartext API moved a device's score from 87 to
90 with the finding still listed and marked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:18:41 -07:00
2GT-RichandClaude Opus 5 4cc8c84ea6 fix(wireless): model channel usage as spectrum, not channel numbers (v0.23.5-beta)
Closes #98, #97.

The Channel Usage panel treated a radio as occupying a channel *number* and
coloured a channel amber when two radios shared it. Both halves are wrong, and the
result is not merely incomplete — it gives actively bad planning advice.

Channels are not slots. In 2.4 GHz the centres are 5 MHz apart while a channel is
20 MHz wide, so an AP on channel 1 occupies about 2402-2422 MHz and pollutes
channels 1 through 5. Showing 2 through 5 as free is why 1/6/11 exists as the only
three-way non-overlapping plan, and a model built on channel indices cannot express
that.

Sharing a channel is also not the problem. Two radios on exactly the same channel
hear each other and take turns; CSMA/CA does its job and the cost is airtime. Two
radios partially overlapping cannot decode each other, transmit over one another,
and the cost is corruption. Partial overlap has to rank above co-channel, and it
was not represented at all.

Occupancy is now modelled in megahertz and overlap classified by how two ranges
relate. On the local fleet, a 40 MHz radio on channel 1 is correctly shown bleeding
5 MHz into two channel-6 radios — which are themselves correctly identified as
co-channel with each other rather than as the worst problem in the band.

The model lives in the backend (utils/rfSpectrum.ts) with 26 tests rather than in
the browser, because it is correctness-critical and the frontend has no test runner.
Tests pin the arithmetic that is easy to get wrong: 1/6/11 is clear, 1/3/5 is three
partial overlaps, channels 4 and 8 abut exactly rather than overlapping, a 20 MHz
radio nested inside an 80 MHz one is partial rather than co-channel, and a lone
radio is clear rather than co-channel with itself.

The panel now draws real spectrum to scale, stacking overlapping radios so each
stays visible, with the clash list on hover naming the other radio and how many
megahertz they share.

Width is taken as the widest entry of a RouterOS capability list such as
20/40/80mhz, since that is the spectrum the radio may occupy and therefore what a
planner must treat as unavailable.

Per-location scoping (#97): analysis runs per CAPsMAN controller, with a selector,
because two APs on channel 6 in different buildings are not a conflict. Radios with
no controller are analysed together as standalone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:06:57 -07:00
2GT-RichandClaude Opus 5 e10f7ba568 docs: split reference material into docs/, keeping the README a shop window (v0.23.4-beta)
Documentation only — no functional change.

The README was carrying two jobs for two different readers. Its Reference section was
the single largest block at 154 lines, and nobody deciding whether to try the project
reads an InfluxDB environment variable table — while the people who need it were
scrolling past the pitch to reach it.

Split along that seam: anything you would read before installing stays in the README,
anything you would read after installing moves to docs/.

  README.md              430 lines   sell, prove, install, feature overview
  docs/change-guard.md   193         safety system, settings, guard states
  docs/architecture.md   116         data stores, polling model, module layout
  docs/capsman.md         84         role model, radio matching, current limits
  docs/alerting.md        81         rules, channels, webhooks, maintenance windows
  docs/api.md             77         scoped tokens, preflight, scripting
  docs/configuration.md   76         environment, secrets, rotation, TLS
  docs/sso-oidc.md        71         identity provider setup and behaviour
  docs/README.md          20         index

In-repo rather than a documentation site, deliberately. A change to Change Guard's
semantics can edit its documentation in the same commit as the code — which, given how
often that behaviour has been refined recently, is the property that actually matters.
A static site generator can point at docs/ later without moving a file.

The Change Guard pitch stays on the front page: the problem framing, the verified
recovery timeline and the quoted prediction warning are what make someone care, and a
reference page is the wrong home for them.

Three sections are new rather than relocated, written because the split exposed the gaps:

  - architecture.md did not exist. It now records why there are three data stores, the
    polling cadences, and which modules are pure functions over a snapshot and why.
  - Scoped API tokens shipped without usage documentation. api.md shows preflighting a
    change from a script and reading the guard block afterwards.
  - change-guard.md records why ip-on-bridge-port is informational rather than critical
    on RouterOS 7 — reasoning that previously existed only in a commit message.

Every internal anchor and cross-file link verified to resolve, including anchors into
other documents. No content dropped, checked by keyword comparison against the previous
single file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:26:18 -07:00
2GT-RichandClaude Opus 5 2201759aff docs: restructure README around what the project actually does differently (v0.23.3-beta)
Documentation only — no functional change.

The previous README opened with eighty lines of screenshots before saying what the
software was, then listed twenty-five feature sections in no particular order.
Change Guard — the one capability nothing else in this space has — sat fifth, under
a heading that gave no hint of it.

Reordered around the questions a new visitor actually asks:

  what is it → why is it different → show me → how do I run it → full features
  → reference

"What makes it different" now leads with the problem rather than the feature: that
RouterOS applies every command immediately, with no transaction and no cross-object
validation, and that MikroTik's own documented advice for VLAN filtering is to have
a serial console ready. Change Guard, lockout prediction and Config Health follow as
answers to it, with the verified auto-revert timeline and a real prediction warning
quoted rather than described.

The twenty-five flat sections are grouped into seven, safety first. Reference
material — environment variables, Change Guard settings, secret management, OIDC
setup, tech stack, project layout — is consolidated at the end instead of being
interleaved with features, and the deepest auto-revert mechanics move into a
collapsible block: present for anyone relying on it, out of the way of a skim.

Added a table of contents; the document was seven hundred lines with no navigation.
Headings that anchors point at use "and" rather than an ampersand, because GitHub
slugifies an entity into a double hyphen and six links would have broken silently.

No content dropped: every feature, table and screenshot from the previous version is
still present, verified by keyword and image-path comparison. 713 lines to 573,
entirely from removing repetition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:54:17 -07:00
2GT-RichandClaude Opus 5 9128573801 chore: stop tracking .vscode editor settings (v0.23.2-beta)
Personal editor preferences — font size and colour theme — were swept into the
previous commit by git add -A. Committing them would override every other
contributor's editor configuration. Removed from tracking and .vscode/ added to
.gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:26:28 -07:00
2GT-RichandClaude Opus 5 0258ee47dc fix(wireless): attribute CAP radios to the access point, not the controller (v0.23.1-beta)
Third round of #94 field testing. The placement bug survived v0.23.0 because the
previous fix addressed the wrong mechanism.

Cause
  A CAPsMAN controller mirrors every CAP interface locally, so a CAP's radio MAC
  legitimately appears on two devices: the CAP that owns the hardware, and the
  controller that mirrors it. The MAC index was single-valued and first-wins over an
  unordered UNION, so each radio landed on whichever row the database happened to
  return first. On the reporter's fleet six radios attached to a router with no
  radios at all, while their siblings attached correctly — the same access point
  appearing in two places, and liable to move between polls.

  v0.23.0 excluded the controller from *prefix* matches only. These are exact
  matches, so the exclusion never applied. Consecutive MACs on one physical AP
  resolving to different devices is what gave it away.

Fix
  The index is multi-valued: a MAC maps to every device carrying it. A radio the
  controller reports as local:false lives on a CAP by definition, so the controller
  is rejected as a candidate at exact strength as well as prefix — an exact match
  against it only ever means the mirror. Attribution now runs through
  matchRadiosToDevices, which was previously tested but unused while the collector
  kept its own copy of the logic.

  With no radios left attributed to it, a controller that has none of its own stops
  appearing in the access point list, which was the reporter's follow-up point.

SSID on provisioned interfaces
  A CAP holds no configuration, so its interface rows carry a generic name and a
  null SSID. The controller knows it, on its mirror of the same interface. Radios
  now record the provisioned SSID from there, and the table is driven by the
  controller's radio list rather than the CAPs' own rows — the controller is the
  only side that knows the SSID, channel and client count together.

Band and frequency columns removed
  Both were empty while the channel column beside them read 5680/ax/eCee/D. The
  channel string already carries the band and the extension layout; parsing it
  properly is its own task, and half-parsing it produced empty columns next to
  populated ones. Requested by the reporter.

75 tests across the CAPsMAN and topology modules, including fixtures built from the
reporter's MAC set: both radios of one AP resolve to that AP, order-independently,
with a genuinely local radio still landing on the controller and an unknown CAP left
unattributed rather than absorbed. Reproduced the mirror scenario against the local
fleet before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:24:18 -07:00
2GT-RichandClaude Opus 5 82efcaa371 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>
2026-08-20 16:48:29 -07:00
2GT-RichandClaude Opus 5 e15556b601 fix(wireless): CAPsMAN panel corrections from field testing (v0.22.1-beta)
All five points raised on #94 after the reporter tested v0.22.0 against a live
CAPsMAN deployment. Two were regressions in that release.

Operating channel instead of a capability list (mine)
  current_channel was stored from /interface/wifi/radio's current-channels, which
  is every channel the radio supports rather than the one it is using. Measured on
  a wAP ax: 535 characters on the 2.4 GHz radio and 3037 on the 5 GHz one — the
  "two kilobytes of text" reported. /interface/wifi/monitor gives the operating
  channel (5500/ax/Ceeeeeee/D), and carries state, peer counts and TX power too,
  all now shown on the radio chip.

Client counts that were zero everywhere (mine)
  The panel read registered_clients from the CAP's own wireless_interfaces rows,
  which are empty when traffic is processed centrally by the controller.

  Monitor's registered-peers is not the fix either, and testing caught that before
  it shipped: clients register on the virtual AP carrying the SSID, not on the
  physical radio, so monitor on a physical radio reports zero on a busy access
  point. On the test AP, wifi1 and wifi2 both read 0 while wifi3-wifi6 held all ten
  clients.

  Counts now come from the registration table, with each entry attributed to its
  radio by following master-interface up to the interface owning a radio-mac. That
  field requires /interface/wifi/print detail — it is absent from the plain
  listing. Verified against real hardware: ten registrations, attributed 8 and 2
  across the two radios, none dropped.

"no network" on remote interfaces
  The Network column resolved from the AP's own bridge ports. A CAPsMAN-provisioned
  interface is not a local bridge port, so nothing matched and the UI asserted the
  interface had no network at all. It now falls back to the datapath — inline
  fields or a named/.id reference — and labels the source. Where the datapath names
  no bridge it says "via controller" rather than claiming no network.

Status labels
  "Serving Clients" claimed something the underlying field does not say: running
  means the radio is up, not that clients are connected. Now "Running" and
  "Enabled, not running".

Table title
  "SSIDs / Wireless Interfaces" conflated two different things; the table lists
  interfaces, virtual and physical. Now "Wireless Interfaces".

Guest WiFi on CAPsMAN-provisioned radios
  Raised by the reporter as an aside, and it was a real hazard already shipping.
  setupGuestNetwork created virtual APs locally on whatever physical radios it
  found, with no awareness that a controller might own them — the SSID would be
  ignored or overwritten on the next provision while the UI reported success.
  Provisioned radios are now skipped with an explanation, and if every radio is
  provisioned the setup is refused, pointing at the controller instead.

37 unit tests on the CAPsMAN module. No regression on a fleet without a
controller: local bridge-port resolution unchanged, roles still correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:11:43 -07:00
2GT-RichandClaude Opus 5 14bab24c7e 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>
2026-08-20 08:25:35 -07:00
2GT-RichandClaude Opus 5 19617e8357 feat(alerts): ntfy.sh notification channel (v0.21.0-beta)
Closes #93.

Routing push notifications through email ingestion was a workaround. ntfy is now
a first-class channel alongside Slack, Discord, Telegram and email, using the
same alert_channels row and the same dispatch switch.

Built to cover the whole plausible configuration space rather than waiting on the
reporter's answers, so no config migration is needed once they arrive:

  - server_url is operator-supplied and defaults to https://ntfy.sh, so a
    self-hosted instance works — the likely case for infrastructure alerting
  - both of ntfy's auth schemes: an access token (preferred; scopable and
    revocable) or username/password, with none required for an open topic
  - optional click_url deep-links each notification to the device it refers to

Published as JSON to the server root rather than POST /{topic} with header
metadata: the header form is ASCII-only and these titles carry per-event emoji.

Severity maps onto ntfy's priority scale so the channel stays worth being woken
by — 4 (breaks through do-not-disturb) for device_offline, log_error and
CPU/memory pressure; 2 for recoveries and discoveries; 3 for everything else.
The event type is attached as a tag so a client can be filtered to wake only for
chosen events without needing a channel per event.

Secrets follow the existing pattern: token and password are added to
SENSITIVE_KEYS, so they are masked on read and the mask placeholder is restored
to the stored value on write rather than overwriting it.

The type column is a CHECK constraint rather than a lookup table, so widening it
means dropping and recreating the constraint; done idempotently for existing
installs.

Note on trust: server_url lets an admin point the channel at an internal address.
That is the same exposure the existing Slack and Discord webhook URLs already
have, and only an admin can configure any of them. Restricting it would break the
self-hosted case this feature is mostly for.

Verified against the real ntfy.sh — a test alert arrived with the correct title,
emoji, body, priority, tag and click URL; masking verified to hide the token on
read while preserving it across a save that did not retype it. 13 unit tests
cover priority mapping, both auth schemes, URL normalisation, and rejection of
a missing topic or non-http server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:02:56 -07:00
2GT-RichandClaude Opus 5 b03ea87f11 fix(topology,ui): spurious cross-segment links, CPU rounding, serial column (v0.20.1-beta)
Closes #90, #91, #92.

#90 — Spurious topology links
  Topology treated IP addresses and system identities as unique across the whole
  fleet. They are unique only within a broadcast domain, so an installation with
  physically separate segments that each reuse 10.2.0.0/24 saw devices in one
  segment joined to devices in another.

  The reporter pointed at shared-segment detection, and that is one of four sites
  with the same flaw — but not the first. Step 2 resolves a neighbour IP to a
  managed device from a global first-wins map, so a reused address silently
  resolves to whichever device was loaded first; the union-find in step 6 then
  merges on the resulting device id, a correct-looking key derived from bad data.
  Fixing only step 6 would have left the reported link in place.

  Addresses and identities are now scoped to the device that reported them
  wherever they name a neighbour, so they still collapse one device's duplicate
  sightings but can never join two devices. Resolution and LLDP-coverage
  suppression additionally require the address to be unambiguous fleet-wide.
  MAC addresses and resolved device ids are unaffected — they identify hardware,
  and genuinely shared segments still merge on them.

  Ambiguity detection deliberately ignores MAC divergence. A switch assigns a MAC
  per port, so a device with two uplinks is reported under several MACs; an
  earlier version of this fix treated that as a conflict and refused to resolve
  ordinary multi-port hardware. Caught on a live fleet, where three sightings of
  one CRS510 disagreed on MAC but agreed on identity. The signals used instead are
  an address claimed by two managed devices, or sightings that disagree about the
  neighbour's identity.

  The graph builder is extracted from the route into a pure module with 16 tests,
  including a fixture of the reporter's five-device, three-segment network. It had
  no test coverage before, which is why a flaw this systemic went unnoticed.

  Verified against a live fleet: graph output is identical before and after, and
  the ambiguity list is empty when addressing is clean.

#91 — CPU load not rounded
  The devices table rendered the raw value from a 5-minute Influx mean, so a
  device idling between 1% and 2% displayed as 1.3333333333333333%. Rounded for
  display; the sparkline keeps the unrounded series so its curve stays smooth.

#92 — Serial number in the device table
  Serials were already collected, stored, returned by the API and matched by
  global search, so this is presentation only: a sortable SERIAL column, and
  serial added to the table's filter predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:47:18 -07:00
2GT-RichandClaude Opus 5 93ff1fbc53 feat(safety): Config Health audit, verified auto-revert, VLAN cache fixes (v0.20.0-beta)
Completes the lockout-prevention work: a standing audit for configurations
RouterOS accepts but that do not work, an empirical answer on /system history
undo, and corrections to the VLAN cache the editors are built on.

Config Health (new)
  Eleven rules over a live snapshot, encoding MikroTik's documented Layer2
  misconfigurations plus the latent lockout cases: IP or VLAN interface on a
  bridge slave port, a bridged VLAN interface, a bond slave that is also a
  bridge port, MTU above L2MTU, a multi-VLAN entry with untagged ports,
  bridges competing for hardware offload, a port in no VLAN on a filtering
  bridge, a PVID that frame-type=admit-only-vlan-tagged makes inert, a VLAN
  interface whose VLAN the bridge is not tagged in, duplicate addresses, and
  management that survives only on a dynamic entry. Each finding names the
  consequence, the fix, how long it has been present, and the vendor doc.
  Runs hourly, surfaces on the Security tab and the dashboard.

  Calibrated against hardware: RouterOS 7 relocates an address off a bridge
  slave port onto the bridge (actual-interface), so the stock defconf layout
  works. Reporting that as critical would have fired on a default install; it
  is informational unless RouterOS did not relocate it.

Change Guard: severed-connection handling
  Proven on a CRS by deleting its own management address — the switch locked
  itself out and self-restored unattended in ~3 minutes, twice.

  That test exposed a real defect. withSafeApply treated any exception from
  the apply step as "the change failed", disarming the guard and rethrowing.
  But the most dangerous changes sever the connection carrying them, so the
  call times out even though the change applied. The guard was stripping the
  safety net off a device that had just gone silent, and reporting HTTP 500
  "Read timeout" instead of telling the user the device was restoring itself.
  Only luck saved it: the disarm needed the same dead connection.

  Reachability now decides, not the exception. Unreachable leaves the revert
  armed and returns auto_reverting; still reachable means a genuine failure,
  disarmed so nothing reboots for nothing. committed_at is no longer stamped
  on failed or reverted guards; resolved_at records completion instead.

/system history undo probe
  probeUndoCapability tests the device rather than assuming. On 7.23.x over
  the binary API: /system/history/print works, /system/history/undo does not
  exist, /undo numbers= rejects the parameter, bare /undo works. Undo is
  therefore untargeted, and cannot replace auto-revert since a dead-man switch
  must fire after contact is lost. Binary restore stays the default.

VLAN cache correctness
  - vlans keyed on (device_id, bridge, vlan_id); the old key collapsed the
    same VID on two bridges
  - vlan-ids ranges expanded instead of parseInt truncating "10-20" to 10
  - both VLAN tables pruned, but only when the device returned an
    authoritative list, so a failed read leaves the cache stale not empty
  - bridge_vlan_entries.vlan_ids written and .tagged derived rather than
    hardcoded false, which had left the port editor unable to see a trunk
  - removeBridgeVlan matched by expanded VIDs; the ?vlan-ids= server filter
    compares raw strings, so deleting one VID of a range silently no-opped
    while reporting success. Multi-VLAN entries are refused in the route,
    before the guard arms, since a mid-apply failure would cost a reboot
  - interfaces.vlan_filtering promoted to a column

  Membership is deliberately the STATIC tagged/untagged, never current-*.
  These tables back the editors and updateBridgeVlan is remove-then-add, so
  whatever is shown is written back; the effective view baked RouterOS's
  dynamic PVID entries into saved configuration on every save. Analysis that
  needs the effective view reads the device live. Extracted to pure functions
  with tests, because that path was previously untestable inline.

Verified on four devices; 195 backend tests; all CI gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:47:14 -07:00
2GT-RichandClaude Opus 4.8 d67698bac9 feat(safety): wire the lockout verdict into every guarded UI surface (v0.19.2-beta)
The verdict dialog only reached the VLANs tab, so a predicted lockout on the other
guarded paths still surfaced as a generic "failed" message with the reasoning lost.
Every surface that can push a guarded change now explains the refusal:

- SwitchPortDiagram — port VLAN (PVID / tagged / untagged, the shape of the real
  incident), bond create and destroy, and the bridge vlan-filtering toggle.
- RoutingTab — static route add and delete.
- SystemConfigTab — IP address add and remove.
- SecurityTab — /ip/service enable/disable. This route also has an older hard
  refusal that returns 409 with a reason but no verdict; that text is now shown
  instead of a generic error, where previously it was swallowed entirely.

Supporting changes: confirm_lockout threaded through the affected API methods
(DELETE routes send it as a request body), and deviceName threaded down to
SwitchPortDiagram, RoutingTab and SecurityTab so the typed confirmation can ask
for the actual device name.

Verified the shipped bundle contains the dialog and the typed-confirm prompt.
Full CI preflight green; 152 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:30:17 -07:00
2GT-RichandClaude Opus 4.8 118dd9ee8f feat(safety): explain predicted lockouts in the UI with typed confirmation (v0.19.1-beta)
The backend already refused a predicted lockout with 409 + a verdict, but the
frontend surfaced it as a generic "failed" message, so the reasoning never reached
the user.

- LockoutVerdictDialog renders the verdict properly: each violated invariant with
  its plain-English consequence, and the resolved management path ("how the manager
  reaches this device") so the user can verify the claim rather than take it on
  trust. Critical verdicts require typing the device name to override; warnings
  need only an acknowledgement, per the agreed friction levels.
- lockoutVerdictOf() extracts the 409 payload, mirroring the existing lockoutOf()
  helper used by the firewall guard.
- VLAN add/update/delete now capture the verdict and re-send with confirm_lockout
  when the user accepts, instead of showing an error banner.

Verified end to end on the production switch: changing the ingress port's PVID off
the management VLAN returns severity=critical with the ingress-port-pvid violation
and the hop chain (192.168.0.40/24 -> bridge -> sfp28-1), and the shipped bundle
contains the new dialog. Full CI preflight green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:04:29 -07:00
2GT-RichandClaude Opus 4.8 518063e64f feat(safety): predict management lockouts before applying a change (v0.19.0-beta)
Phase 2a of the change-safety work. Change Guard could already rescue a device
after a bad change; this predicts the break beforehand and refuses it.

Rather than blocklisting "risky operations" — which is how deleting an *unused*
bond and a routine VLAN edit both slipped through — the engine derives the
invariants that must hold for the manager to keep reaching the device, simulates
the planned change against LIVE device state, and reports any invariant that flips
from satisfied to violated. Novel change shapes are covered for free, and each
invariant explains itself, so the warning names the real mechanism.

- pathModel.ts: captures a live snapshot over the API (no SSH) and resolves the
  management path — which address, interface, bridge, VLAN and physical ingress
  port the session depends on, each with the reason it is load-bearing.
- invariants.ts: nine checks drawn from MikroTik's documented behaviour, notably
  that the bridge IS the CPU port and must be a member of the management VLAN.
- analyzeChange.ts: simulates the change and diffs invariants, returning
  safe / warning / critical plus the path used to reach the verdict.
- withGuardedChange now runs the analysis first and returns 409 with the verdict
  unless the caller passes confirm_lockout; analysis failures never block a change.
- New GET /:id/management-path and POST /:id/preflight (analyse without applying).

Three bugs were found by testing against the live switch rather than fixtures:
- Invariants read vlan-filtering from the frozen path instead of the snapshot under
  evaluation, so "enable vlan-filtering" — the single most important case — was
  invisible.
- VLAN membership is spread across BOTH a static row and a dynamic "added by pvid"
  row. Simulating only the first match hid the danger; membership now tracks static
  and effective separately, and a bridge left in the VLAN only by the dynamic row is
  reported as a warning rather than silently safe.
- Requiring the ingress port to be UNTAGGED in the management VLAN flagged a healthy
  tagged trunk. Because a pre-existing violation is (correctly) not reported as new,
  that false positive silently suppressed real detections. Verdicts now also list
  pre-existing violations so this can't hide again.

Verified on the production 100G switch: changing the ingress port's PVID off the
management VLAN is refused with 409 and the switch is left untouched (pvid still 1);
removing the bridge from VLAN 1 is correctly reported as a warning because a dynamic
PVID row would still cover it; unrelated VLAN edits stay safe. 29 changeGuard tests,
152 backend tests, full CI preflight green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:45:30 -07:00
2GT-RichandClaude Opus 4.8 78e1c114e5 feat(safety): extend Change Guard to VLAN, IP and route changes (v0.18.2-beta)
Change Guard shipped covering only 4 routes (service toggle, bond create/delete,
bridge vlan-filtering). The most recent real incident — a VLAN change that removed
a switch from management — went through PUT /:id/ports/:name/vlan, which was NOT
guarded. Neither were the bridge-VLAN CRUD routes, IP address changes, or route
changes, all of which can sever the manager's own path.

Guarded routes go from 4 to 12, adding:
- port.vlan     PUT  /:id/ports/:name/vlan      (PVID + tagged/untagged membership)
- vlan.add      POST /:id/vlans
- vlan.update   PUT  /:id/vlans/:vlanDbId
- vlan.delete   DELETE /:id/vlans/:vlanDbId
- ip.add        POST /:id/ip-addresses
- ip.remove     DELETE /:id/ip-addresses/:addrId
- route.add     POST /:id/routing
- route.remove  DELETE /:id/routing/:routeId

Each now takes a device-side restore point and arms a self-restore before applying,
disarming only once the device is proven still reachable.

Two correctness details while converting:
- vlan.delete removes the local DB row in afterConfirmed, so a change that
  auto-reverts no longer leaves the UI claiming a VLAN was deleted when the device
  restored it.
- The VLAN CRUD routes previously returned a bare array, which would have been
  spread into the guard response object. They now return a message; the frontend
  ignores these bodies and re-queries, so no client change is needed.

Verified live against the production 100G switch: a guarded VLAN add and delete
both returned protected/confirmed, were recorded in device_change_guards, and the
switch was left in its original state. 133 backend tests pass; full CI preflight green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 23:27:03 -07:00
2GT-RichandClaude Opus 4.8 d44a0ea8a7 fix(deps): patch socket.io-parser advisory; add local CI preflight gate
Security Audit failed on a newly-published advisory, GHSA-2m8v-j782-fhvr
(socket.io-parser "Zero-attachment Memory Exhaustion", high). It affected both
projects transitively via socket.io / socket.io-client. Patched to 4.2.7 in both
lockfiles; no direct dependency versions changed.

Also adds the gate that should have caught the previous two CI failures before
they reached GitHub:

- scripts/ci-preflight.sh runs every gate from .github/workflows/ci.yml
  (frontend and backend lint/type-check/build, plus both prod-dependency audits
  with the same audit-ci flags and allowlist) and additionally the backend Jest
  suite, which CI does not currently run. It runs all gates rather than failing
  fast, so one invocation surfaces every problem. ~35s.
- scripts/claude-push-gate.sh wires that into a Claude Code PreToolUse hook so a
  `git push` is refused while any gate fails, with the failure detail returned
  inline. Non-push commands and --dry-run pass straight through.

Verified: with the fix reverted, the gate correctly denied the push and reported
"backend: lint FAIL"; with it restored, all 9 gates pass and both audit-ci
invocations exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 09:27:40 -07:00
2GT-RichandClaude Opus 4.8 23a45703a5 fix(ci): attach cause when rethrowing bridge-restore failure (v0.18.1-beta)
The backend Lint job failed on the new createBond error path: ESLint's
preserve-caught-error rule (in effect since the eslint 10.7 bump) requires a
rethrown error to carry the original. Pass `{ cause: err }`, which also makes the
underlying RouterOS failure visible in the error chain instead of only inside the
message string.

No behavior change beyond the richer error. Verified with the exact CI commands:
`npm run lint` and `npm run build` both exit 0, 133 backend tests pass, and the
redeployed build still guards correctly (guarded change commits, disabling the
management service is refused with 409).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 08:34:58 -07:00
2GT-RichandClaude Opus 4.8 3a18cb1d91 feat(safety): Change Guard — device-side auto-revert for risky config changes (v0.18.0-beta)
A user change can sever the manager's own path to a device with no warning and no
way back. Most recently, deleting an *unused* bond on a switch took the whole
switch offline. This adds a safety net, and fixes the bug that caused that
specific incident.

Root cause of the bond incident (DeviceCollector.deleteBond):
- Slaves were re-added to the bridge ONLY when the bond itself was a bridge
  member. For an unbridged bond the restore loop never ran, so the member ports
  were released from the bond and left in no bridge at all — the ports stopped
  forwarding and the switch dropped off the network.
- The restore in both deleteBond and createBond was wrapped in `.catch(() => {})`,
  so a failed restore was swallowed and the route still returned 200 OK.
- Restores also dropped PVID, silently returning tagged ports to VLAN 1.
Now: port settings (PVID, frame-types, ingress-filtering, edge, horizon) are
captured and carried over, and restore failures are reported instead of hidden.

Change Guard (services/changeGuard/ChangeGuard.ts):
Before a risky change the device saves a restore point and arms a scheduler that
restores it after N seconds. The change is applied, then reachability is proven
on a NEW connection; success disarms the scheduler, failure leaves the device to
restore itself and come back. Because the restore point is created before the
scheduler, a binary restore rolls back to a config containing neither the
scheduler nor the change — self-cleaning, with no revert loop.

- Works entirely over the RouterOS API. This matters: every recovery artifact
  today requires SSH, so devices without SSH credentials had no rollback target
  at all (cf. #86).
- Modes: `binary` (exact restore incl. deleted interfaces, reboots) or `script`
  (/import, no reboot but additive). Configurable via change_guard_* settings.
- Per-device Redis lock, `device_change_guards` audit table, and startup
  reconciliation for guards left pending by a manager restart.
- Degrades gracefully: if protection can't be armed the change still runs and the
  response says it was unprotected.

Wired into bond create/delete, bridge vlan-filtering, and /ip/service. Disabling
the service the manager is connected through is now refused outright (409) — the
mgmt-service signal already existed but was confined to the read-only posture
endpoint.

Also: replaces the bare confirm() on "Destroy Bond" with a ChangeGuardDialog that
explains the risk and the protection, and reports the outcome (including "the
device is restoring itself"). Fixes getAppSettings omitting
config_snapshot_enabled/interval, which made the v0.17.4 snapshot toggle a no-op.

Verified on real hardware (hAP-class switch): capability probe confirmed
/system/backup/save and scheduler add/remove work over the API; a guarded change
armed, applied, verified and committed leaving zero schedulers and zero files on
the device; disabling the api service was refused with 409. 133 backend tests
pass, including 7 guard state-machine tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:52:07 -07:00
2GT-RichandClaude Opus 4.8 5895e905b7 fix(netflow): bound template cache, throttle UDP ingress, canonical IPv6 (v0.17.7-beta)
Follow-up to the decoder DoS fix (7e11115), closing the remaining hardening
items from the same adversarial-review triage.

Bounded template cache (was unbounded):
- Templates now carry a lastUsed timestamp, refreshed whenever one decodes data.
- New exported pruneTemplateCache() evicts templates idle past a TTL then
  enforces a hard cap, least-recently-used first. The collector runs it on its
  existing 60s map-refresh timer (30 min TTL, 10k entries) — no new timer.

Bounded per-source bookkeeping:
- unknownExporterIds is capped; sources beyond the cap share one overflow
  pseudo-exporter instead of allocating a new id each (client attribution
  doesn't depend on exporter identity, so only per-exporter stats coarsen).
- Pseudo ids now come from a monotonic counter rather than map size, so they
  can't collide after eviction.

UDP ingress guards (the socket is unauthenticated by protocol design):
- Drop datagrams too small to carry a header before doing any parsing work.
- New PacketRateLimiter: fixed-window per-source cap (2000 pkt/s), with the
  counter map cleared each window and a cap on distinct sources per window so
  spoofed source IPs can't grow it.
- getStats() now reports packetsDropped and templatesCached.

Canonical IPv6 rendering:
- formatIPv6 applies RFC 5952 zero-compression (2001:db8::1, not the fully
  expanded form) and unwraps IPv4-mapped addresses (::ffff:192.0.2.1 ->
  192.0.2.1) so those flows match client/exporter IPs instead of appearing as
  separate IPv6 endpoints.

Adds 7 tests (rate limiter windows//caps/isolation, cache TTL + LRU eviction,
lastUsed refresh, IPv6 canonical + mapped + no-run forms); 126 backend tests pass.

Verified live: flooding the collector with 3000 packets from one source dropped
614 once over the cap while the backend stayed healthy and served the API,
malicious templates were still refused (cache held only the 2 real ones), and
real exporter flows kept decoding right through it (260 -> 336).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 21:48:50 -07:00
2GT-RichandClaude Opus 4.8 7e11115a11 fix(netflow): reject zero-length template fields to stop decoder DoS (v0.17.6-beta)
A NetFlow v9/IPFIX template declaring a field of length 0 produced a template
with minLength 0. parseDataSet's loop condition (off + minLength <= setEnd) then
stayed true while the field pass advanced `off` by 0 — an infinite loop.

Impact: the collector socket is UDP and unauthenticated, so any host able to
reach the NetFlow port could hang the entire backend with a single 36-byte
packet — Node is single-threaded, so the event loop never yields again and the
API, poller, sockets and health checks all stop. Only reachable while NetFlow is
enabled (off by default).

Two layers of defense in decoder.ts:
- parseTemplates rejects any field with length 0 (meaningless in v9/IPFIX) and
  refuses to cache a template whose computed minLength is 0.
- parseDataSet asserts forward progress: if a full field pass consumes no bytes,
  break instead of spinning. Guards any future zero-width path.

Adds 5 regression tests covering the hang case, a zero-length field among valid
ones, a variable-length field whose length byte is 0, absurd/truncated field
lengths, and that valid v9/IPFIX traffic still decodes afterwards.

Verified: the exact packet that previously hung the process now returns
immediately with no template cached; 20 attack packets fired at the live
collector left the backend healthy and serving, and real exporter flows kept
decoding (102 -> 152 flows across the test). 119 backend tests pass.

Found via an adversarial review; the report identified the infinite loop but
attributed it to the variable-length branch, which provably terminates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 21:31:25 -07:00
2GT-RichandClaude Opus 4.8 b6d161fc9d fix(wireless): prune interfaces deleted on the device; collect wireless on full sync (v0.17.5-beta)
Resolves #87 — virtual APs removed directly on the device (WinBox/WebFig) kept
showing in the radios/interface lists, and neither refresh button cleared them.

Two distinct bugs:

1. Stale rows. The collectors upsert every interface found on the device but
   never removed rows for interfaces that disappeared, so deleted radios lingered
   forever. Added a prune to collectWirelessInterfaces, collectInterfaces (same
   bug for deleted VLANs/bridges/bonds) and collectSecurityProfiles, deleting
   stored rows whose names are no longer present on the device.

   Safety: the prune only runs when the device list was fetched successfully.
   Fetches that previously swallowed errors into an empty array now return null
   and skip the prune, so a transient API failure can never be mistaken for
   "everything was deleted". /interface/print already throws on failure.

2. Full sync skipped wireless entirely. collectAll() — what POST /devices/:id/sync
   runs — never called collectWirelessInterfaces/collectSecurityProfiles, so a
   manual resync could not refresh radios or clear stale ones; only the 5-minute
   slow poll touched them. collectAll now collects wireless data for wireless_ap
   devices, making the fix reachable on demand.

Verified on a live hAP-class AP: injected a ghost virtual-AP row, ran a sync, and
the ghost was removed while all 7 real interfaces (and offline devices' rows)
were retained. 114 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 19:36:35 -07:00
2GT-Rich 4ef9fd0e37 chore(ci): fix Security Audit — audit prod deps via audit-ci, patch dev advisories
The Security Audit job failed because new advisories dropped against existing
deps and `npm audit --audit-level=high` audits the whole tree (incl. dev/build
tooling that isn't shipped).

- Switch both audit steps to `audit-ci --high --skip-dev`, so the gate reflects
  what's actually deployed (backend ships --omit=dev; frontend ships a static
  build) while still failing on real high/critical production advisories.
- Allowlist GHSA-qwww-vcr4-c8h2 (React Router "RSC Mode CSRF") for the frontend:
  this is a client-side SPA using BrowserRouter — RSC mode is not used — and
  there is no forward-compatible fix for react-router-dom 7.x (only a downgrade).
- `npm audit fix` on both lockfiles to patch non-breaking transitive dev
  advisories (js-yaml, postcss, brace-expansion, minimatch). No direct/prod
  dependency versions changed; app runtime is unchanged.

Verified: backend 114 tests pass, frontend builds, and audit-ci passes for both
projects locally.
2026-07-29 15:47:52 -07:00
2GT-RichandClaude Opus 4.8 f0176efd95 fix(config-history): only auto-snapshot devices with SSH configured; add settings UI (v0.17.4-beta)
Resolves #86 — MikroTik Manager was attempting SSH connections to devices that
have no SSH credentials configured, flooding RouterOS logs with failed logins.

Cause: config snapshots (a separate feature from scheduled backups, enabled by
default, hourly) capture `/export` over SSH via BackupService.exportConfig, which
silently falls back to the API credentials for the SSH login. On devices where
SSH was never set up, that auth fails every interval (and on each restart), and
there was no UI switch to turn it off.

- PollerService.processConfigSnapJob now skips devices without SSH credentials,
  so no SSH connection is attempted where SSH isn't configured. Manual "Capture
  snapshot" / backup actions are unchanged (user-initiated, with error feedback).
- Add a "Configuration Snapshots" section to Settings → General: enable toggle,
  interval, and retention (the feature was on-by-default with no UI control),
  with a note that it runs over SSH and only on devices that have SSH creds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:38:08 -07:00
2GT-RichandClaude Opus 4.8 28701cfc67 fix(security): harden 2FA setup, OIDC default role, and rate-limit fallback (v0.17.3-beta)
Address the actionable findings from an adversarial review:

- 2FA enrollment now requires the current password (/totp/setup), so a
  hijacked session or XSS can't enable TOTP and lock the real user out.
  SSO-only accounts (no local password) are told to set one first. Frontend
  collects the password before starting setup.
- OIDC config rejects default_role='admin': auto-provisioning unmatched users
  straight to admin is almost never intended — admin must come from an explicit
  group → role mapping.
- Rate limiter no longer fails fully open on a Redis outage. Redis calls are
  bounded by an 800ms timeout (the client uses maxRetriesPerRequest:null, so
  commands would otherwise hang, not throw), and on failure a per-process
  in-memory sliding-window fallback keeps /login and other limited endpoints
  throttled. Verified with Redis stopped: attempts throttle (429) instead of
  hanging or passing unlimited.

Findings intentionally not actioned: the "API-token auth is fire-and-forget"
report was a false positive (Express awaits next(), which is called inside the
promise), and the webhook DB-pool and JWT-revocation items are either
overstated or a separate architectural decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:21:04 -07:00
2GT-RichandClaude Opus 4.8 b3ba2b98dc chore(security): resolve CodeQL findings — unbiased voucher RNG, linear email check, rate-limit query filter (v0.17.2-beta)
Address the open CodeQL code-scanning alerts:
- guestWifi voucher codes now use rejection sampling so the character
  distribution is uniform (fixes js/biased-cryptographic-random).
- Scheduled-report recipient validation replaced the backtracking email regex
  with a linear split-based check (fixes js/polynomial-redos).
- Add .github/codeql/codeql-config.yml excluding js/missing-rate-limiting:
  rate limiting is applied globally via app.use('/api', rateLimitRedis) plus
  per-route limiters, which CodeQL can't attribute to individual handlers, so
  that rule fired ~38 false positives.

Remaining alerts are dismissed as accepted/false-positive (RouterOS MD5 API
login and self-signed cert acceptance are protocol-mandated; log sites already
strip CRLF).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:56:18 -07:00
2GT-RichandClaude Opus 4.8 3fe8341b71 chore(deps): batch Dependabot bumps + ES2022 tsconfig (v0.17.1-beta)
Apply the open Dependabot updates as one validated batch (build + test + lint +
local Docker smoke test all green) rather than 11 separate merges.

Backend:
- @types/node 20.19.43 -> 26.1.1 (aligns types with the Node 26 runtime)
- typescript-eslint 8.58.2 -> 8.64.0
- eslint 10.5.0 -> 10.7.0
- eslint-plugin-security 4.0.0 -> 4.0.1
- nodemailer 9.0.1 -> 9.0.3
- tsconfig target/lib ES2020 -> ES2022 so Array.prototype.at() (devices.ts)
  type-checks under the new @types/node — this is what unblocked #72.

Frontend:
- axios 1.18.0 -> 1.18.1
- react-router-dom 7.18.0 -> 7.18.1
- lucide-react 1.21.0 -> 1.23.0
- @xyflow/react 12.10.2 -> 12.11.1
- @vitejs/plugin-react 6.0.2 -> 6.0.3

CI:
- docker/setup-qemu-action v3 -> v4

Closes #58
Closes #60
Closes #62
Closes #65
Closes #66
Closes #67
Closes #68
Closes #69
Closes #70
Closes #71
Closes #72

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:33:26 -07:00
2GT-RichandClaude Opus 4.8 c0f230bdf2 feat(auth): OIDC / SSO single sign-on (v0.17.0-beta)
Add OpenID Connect single sign-on so the platform can authenticate against
any standards-compliant provider (Entra ID, Okta, Google Workspace, Keycloak,
Authentik, …) alongside local accounts. Resolves the feature request in #73.

Flow (backend-driven Authorization Code + PKCE):
- GET /api/auth/oidc/login builds the authorize URL and stores the PKCE/nonce
  transaction in Redis (single-use, 10m TTL); the callback validates the ID
  token via openid-client (JWKS signature, iss/aud/exp/nonce), resolves the
  identity to a local user, and mints the platform's normal session JWT —
  handed to the SPA via URL fragment. No sessions/cookies introduced.

Identity mapping (users table gains email/oidc_subject/oidc_issuer/
auth_provider; password_hash now nullable; partial unique indexes):
- First sign-in auto-provisions a user, or links to an existing local account
  when the IdP email is verified and matches.
- Roles derive from a configurable IdP group -> role map with a default
  fallback; existing users are never auto-demoted on a groupless login.

Configuration (admin-only, no env/redeploy needed):
- Stored as an app_settings row with the client secret encrypted at rest
  (utils/crypto); masked in responses and excluded from GET /api/settings.
- New UI: Settings -> SSO / OIDC panel (issuer/client, claim + group->role
  mapping, provisioning toggles, discovery test, copyable redirect URI), a
  login-page SSO button, and an SSO badge in Users & Roles.

Security: local break-glass login preserved; PKCE + single-use state; email
linking requires email_verified; same-site-only post-login redirect.

Adds openid-client@^5 (CommonJS). 17 new unit tests (claim/role mapping,
username derivation, config secret round-trip/masking); 114 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:54:36 -07:00
2GT-RichandClaude Opus 4.8 ea1ad956ff fix(ui): escape apostrophe in Operations update notice to satisfy lint (v0.16.12-beta)
The v0.16.11 update-notice text used a raw apostrophe in JSX ("You're"),
which trips react/no-unescaped-entities and failed the frontend lint job.
Escape it as &apos;. No visual change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:09:05 -07:00
2GT-RichandClaude Opus 4.8 8e564fc813 fix(system): report real version so update banner clears; move update notice to Operations (v0.16.11-beta)
Update-availability fixes:
- The backend read its version from npm_package_version, which is unset
  when the container runs `node dist/index.js` directly, so current was
  always "0.0.0" and every release looked newer — the "update available"
  banner never cleared. Read the bundled package.json instead
  (routes/system.ts), falling back to npm_package_version.
- Move the update notification out of the top-of-app banner into the
  Operations dashboard (a dedicated notice under the network-status hero),
  shown only when genuinely behind and cleared once up to date.

Security banner:
- Replace the now-dead jwt_secret_default / encryption_key_default checks
  with the current secrets_not_persisted warning (auto-generated secrets
  that couldn't be persisted), matching the self-healing secrets model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:18:25 -07:00
2GT-RichandClaude Opus 4.8 9e962939ea fix(security): self-healing secrets + auth/SSRF/header hardening (v0.16.10-beta)
Follow-up to the /terminal authorization fix: a full-codebase security
review surfaced several more issues, fixed here. All changes are designed
to roll out with no interruption to existing deployments — no forced
migration, no manual re-key, no boot failure.

Self-healing secret management (utils/secrets.ts):
- JWT_SECRET / ENCRYPTION_KEY auto-generate to strong values on first boot
  and persist to the app_data volume (0600, outside the DB) when not set
  to a strong value in the environment. Replaces the prior behavior of
  running on the public repo defaults.
- decrypt() tries the current key plus every legacy/default key, so
  existing device credentials keep decrypting after a key change; a
  startup sweep re-encrypts stale rows forward (db/reencryptCredentials.ts).
- verifyToken() accepts the current signing secret plus prior *strong*
  secrets (never the public defaults), so rotation doesn't invalidate live
  sessions; sessions signed with a default secret require one re-login.
- Dockerfile pre-creates /app/data as the app user so the non-root
  process can persist to a fresh named volume; app_data volume added to
  both compose files; compose secret defaults changed to empty (auto-gen).

Authorization / input:
- Default Socket.IO namespace now requires a valid JWT (was unauthenticated
  and broadcasting fleet-activity events); frontend sends the token and
  drops the shared socket on logout.
- Webhook delivery blocks SSRF to loopback/private/link-local/CGNAT ranges
  and pins the request to the validated IP (WebhookService.ts).
- Rate limit added to /auth/totp/verify; global mutation backstop on /api.
- Password policy (>=10 chars, letter+digit) enforced on set/change/create.
- system.ts used verifyToken as middleware (always 500); now requireAuth.

Hardening:
- CSP + HSTS/nosniff/frame-deny headers in nginx, scoped to allow the
  OpenStreetMap tile/geocoding hosts the maps use.

Docs/tests:
- README secret section rewritten for the self-healing model.
- New unit tests for secret resolution, persistence, rotation, and
  legacy-key decryption (97 tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:42:46 -07:00
2GT-RichandClaude Opus 4.8 27560fa1d6 fix(security): enforce role auth on /terminal SSH namespace (v0.16.8-beta)
The Socket.IO /terminal namespace validated the JWT but discarded the
role, so a read-only viewer (403'd on every REST write) could connect
and open an interactive SSH shell on any managed device using the
device's stored router-admin credentials — a full bypass of the role
model (reported CVSS 9.6).

- Gate the namespace handshake by role: only admin/operator may connect;
  viewers are rejected with "Console access denied for this role".
- Re-check the role at the start handler (defense in depth) and attach
  the verified payload to socket.data.
- Audit-log terminal session opens (and rate-limit hits) to audit_log.
- Rate-limit shell starts per user (5 / 60s, in-memory sliding window).
- Frontend: gate the Terminal button (DeviceDetailPage) and dashboard
  "Open terminal" quick action behind canWrite so viewers don't see it.

Validated on the local docker stack: viewer rejected from /terminal,
admin still connects; viewer remains 403 on POST /api/devices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:34:09 -07:00
2GT-Rich 79e97e14cc fix(ui): firmware state syncs across screens immediately (v0.16.7-beta)
Firmware actions on the device Config tab (check/install RouterOS,
check/install RouterBOOT) and Firmware-page actions now invalidate the
shared react-query caches on success — devices list, device header,
firmware overview, dashboard "Things to handle", and Security Center
findings. Previously they only updated local component state, so other
screens showed stale "update available" data for up to 30s until their
own background refetch fired, even though the backend had already
persisted the new state.

Browser-verified: after a firmware check on the Config tab, /api/devices
refetches within the same second (sidebar observer) and the Devices page
fetches fresh data on mount; both screens agree immediately.
2026-07-11 22:05:12 -07:00
2GT-RichandClaude Fable 5 27f155f778 feat(firmware): in-app MikroTik release notes + posture findings for pending updates (v0.16.6-beta)
- New GET /api/firmware/changelog/:version proxies MikroTik's official
  per-version release notes (download.mikrotik.com/routeros/<ver>/CHANGELOG)
  with strict version validation and process-lifetime caching
- ChangelogModal: "What's new in X.Y.Z" on the device Config tab's
  update box and on the Firmware page's latest-version cells opens the
  changelog in-app, with an external mikrotik.com link and graceful
  fallback when the CDN is unreachable
- Security posture: a pending RouterOS update is now a medium-severity
  finding and a pending RouterBOOT upgrade a low-severity one; titles
  are version-free so Common Findings aggregates the fleet into one row
- chore(deps): npm audit fix bumps morgan (GHSA-4vj7-5mj6-jm8m); both
  workspaces audit clean

Validation: backend tsc/lint/92 tests, frontend tsc/lint/build/11 tests,
endpoint tested (valid/invalid/unknown versions), browser-verified on
Firmware page, device Config tab, Security Center, and device Security
tab; security review of the diff found no findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:45:53 -07:00