Commit Graph
119 Commits
Author SHA1 Message Date
2GT-RichandClaude Opus 5 7b994ad935 feat(sites): one install, many networks (v0.24.0-beta)
Closes #130. Multi-site, not multi-tenancy: a site is a named collection of
devices whose clients, events, topology and dashboards are viewed separately.
Sites separate what you see, not who you are — the admin/manage/read-only
roles are unchanged and every user sees every site. Per-site permissions
would have multiplied the work for a need nobody stated.

Two measurements decided the design.

Every device-derived table hangs off devices(id) — 25 of 50 tables, 35
foreign keys — so a single site_id column on devices scopes clients, events,
topology, backups and the rest transitively. No other table needed one.

The frontend has 162 useQuery call sites across 57 files. Threading a site
through each would be a large change that leaves a permanent trap: every
future query another chance to forget. The site is injected in the existing
axios interceptor instead, beside the auth token, so a new query is scoped by
default. Only 23 collection routes needed filtering; the 131 per-device
routes are already scoped by their device.

Existing installs are untouched. Migration creates one "Default Site", adopts
every device into it, and a single-site install never meets the concept. An
absent X-Site-Id header means unscoped, so existing API tokens and scripts
behave exactly as before.

Polling stays fleet-wide. Sites are a view over data already gathered, not a
change to how it is gathered — a device does not stop being monitored because
you are looking elsewhere. Rogue-AP detection scopes the scans but keeps the
"these are ours" BSSID set fleet-wide, or your own AP in another site reads
as an evil twin. Bulk actions do obey the selector: an operation that
silently spanned two customers is the blast radius the wave guards exist to
contain.

Four rounds of defects came out of testing the UI rather than the API, and
the first is the one worth recording. Switching sites called
queryClient.clear(), which empties the cache and reviews as correct. It
isn't: clear() destroys each query but a *mounted* observer keeps its own
reference and last result, so every rendered page went on showing the
previous site's data. The QueryClient is now keyed on the site, which
remounts the tree against an empty cache. Three tests pin the old behaviour
down so the shortcut cannot return.

The others: an unscoped fallback path behind the Connected Clients chart;
Config Health, TX retries and the activity feed reading fleet-wide; and
site_id added to the device list query but none of the other four that build
a device payload, so the Site field could not read its own value and appeared
to reject every change while the writes were landing. That last one is now
structural — DEVICE_BASE_COLUMNS is shared by all five, with a test that
fails when a device payload is hand-rolled, and which found two further
instances while being written.

Frontend tests were never run by CI despite vitest being configured and six
suites existing; they now run in both the workflow and ci-preflight.

Verified against a live four-device fleet: the migration left every figure
identical, an empty site returns zero from every read endpoint, moving a
device carries its history with it, and deleting a site with devices is
refused.

Minor bump rather than patch: sites change the shape of the product rather
than adding to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:02:00 -07:00
2GT-RichandClaude Opus 5 e47819e232 fix(topology): read the spanning-tree root instead of guessing it (v0.23.35-beta)
Closes #131.

Reported as the wrong device being marked root: an RB4011 at priority 4096 says
"root bridge: yes", two APs at 8192 say they are not and name the RB4011's MAC,
and the map crowned an AP anyway — consistently, always the same one.

The detection had never worked at all. It looked for a device with no port whose
STP role was 'root', but RouterOS reports the role as 'root-port'. Nothing ever
matched, so the set of devices-with-a-root-port was permanently empty and the
lookup returned the first device in the list. It was not choosing badly; it was
not choosing.

The same mismatch broke link state. Blocking was derived from a role of
'alternate' or 'backup' against values that arrive as 'alternate-port' and
'backup-port', so blocked links were drawn as forwarding — which is the "traffic
connection path" half of the report, since a blocked link shown as live yields a
path that does not exist.

Roles are now normalised on write so both ends of the system share one
vocabulary, and the root is no longer inferred. RouterOS answers it directly:
/interface/bridge/monitor reports whether a bridge is the root and names the root
it sees, which is exactly what the reporter's own screenshots show the device
knowing. That is collected into device_bridges and resolved in
utils/stpRoot.ts with 13 tests.

Two properties of real networks shaped the resolution, both observed on the
development fleet rather than reasoned about:

  * **A root belongs to a spanning tree, not to a fleet.** Two of four devices
    here legitimately report root at once, being separate bridge domains, so
    roots are resolved per connected component.
  * **The root may not be ours.** One switch points at priority 8192 on a MAC
    belonging to no device in the inventory. Naming a local device in that case
    is precisely the original bug, so the crown is withheld and the foreign
    bridge id reported instead.

Verified against that fleet: the managed root resolves to the device that claims
it, a self-rooted bridge keeps its own crown, and the switch with an external
root now reports "not ours" where the old logic confidently crowned it — the
reported symptom, reproduced and fixed.

Also adds collectStp to the full sync. It previously ran only on the five-minute
slow poll, so pressing Sync after rewiring did not refresh the map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:58:29 -07:00
2GT-RichandClaude Opus 5 273a0da737 fix(deploy): restore NetFlow to the published compose file, and generate it (v0.23.34-beta)
Closes #121.

The Quick Deploy file was missing NETFLOW_BIND_PORT and the 2055/udp mapping, so
the collector bound inside the container and nothing reached it. NetFlow did
nothing, silently, with an empty traffic page and no error — for everyone
following the path the README tells new users to take.

The report was accurate and understated. docker-compose.ghcr.yml had drifted for
seven weeks, and three closed issues had never reached it:

  * NETFLOW_BIND_PORT and the UDP port — as reported
  * all five POLLER_* tunables — #114, whose fix was "pass the tunables through"
  * the Redis memory ceiling and eviction policy — #116
  * TZ on the postgres service, which nobody had noticed at all

The middle one is the uncomfortable part. Four days ago #114 was closed by
forwarding poller settings that were documented but inert, with a comment saying
setting them "did precisely nothing". On Quick Deploy that remained true, because
one compose file was fixed and the other was not — including the advice given to
that reporter about which knob to turn.

So patching the four gaps would have been treating the symptom. The two files
describe the same system and differ only in where images come from; maintaining
both by hand meant remembering every change twice, and the one forgotten was
always the file no maintainer runs.

docker-compose.ghcr.yml is now generated from docker-compose.yml by
scripts/generate-ghcr-compose.mjs, which performs the two build-to-image swaps
and nothing else. Substitution is textual rather than parse-and-reserialise so
comments survive — the reasoning about Redis eviction is worth more than the two
lines it explains. The file stays committed and browsable, because the README
tells people to curl it directly.

A --check mode runs as the first preflight gate and as its own CI job, both of
which fail when the committed file does not match what the generator produces.
Verified by deleting the NetFlow line and watching the guard reject it, with the
reason and the fix in the failure message.

Reported by @mamb1329, on their first issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:08:56 -07:00
2GT-RichandClaude Opus 5 6c5b8ac21a feat(lte): time-on-band and time-on-cell statistics (v0.23.33-beta)
Closes #120.

The Tower and band changes panel listed every handover, newest first, growing
without bound. On a link that hands over several times a day it reads as a wall
of near-identical rows — it answers what happened, but not what the pattern is,
which is the question an operator actually has.

Dwell totals now sit above it: how long the modem has spent on each band and on
each cell, over 24 hours, 7 days or 30 days, ordered by time spent so the answer
is the first row rather than something to be worked out. Visit counts sit
alongside, because six brief stays and one long one are different situations
that share a total.

No new collection. History is already written on every change, so dwell is the
interval between consecutive events — the same reconstruction used for WiFi
roaming sessions.

Three decisions worth recording.

**The state in force when the window opened is included.** Without it, a link
that changed once yesterday would report a few minutes of dwell across seven
days, having silently dropped everything before the first change in range. The
last event *before* the window is fetched specifically to anchor that first
stretch.

**Aggregated carriers each count for the whole stretch.** A period on B1 and B3
together credits both with the full duration, so band shares describe how much of
the window each band was in use rather than dividing it between them. Totals can
exceed 100%, which is the honest description of carrier aggregation rather than a
rounding artefact.

**Absence is reported as absence.** A segment whose cell was never reported is
excluded rather than attributed to "unknown", a device with no history reads as
"no changes recorded" rather than an empty panel, and a window longer than our
records says so instead of implying the quiet period was real.

Logic lives in utils/lteDwell.ts with 17 tests. Verified end to end against
seeded history matching the reporter's own pattern: a 24-hour window covered
exactly 24 hours, B3 correctly showed 19 of them against a five-hour stretch
without it, cell totals summed to the window, and a 30-day request over 9 days of
records reported itself truncated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 09:30:07 -07:00
2GT-RichandClaude Opus 5 33ca7cc651 chore: clear the dependency backlog, and fix a log-injection finding (v0.23.32-beta)
Two pieces of housekeeping that had gone quiet.

**A code-scanning alert nobody had looked at.** CodeQL flagged js/log-injection
in the preflight handler: a request value interpolated straight into a log line.
Log entries are newline-delimited, so a value containing a line break forges
additional entries — and an audit trail its own subject can write into is worse
than none, because it is believed.

The interesting part is that the risk had already been recognised. Two sites in
the clients routes strip carriage returns inline, ad hoc, and the third was
simply missed. That is what a shared helper is for, so there is now a `logSafe`
utility with tests, used at all three. It replaces control characters with a
visible marker rather than stripping them, so a tampered value reads as tampered
instead of being quietly tidied, and truncates absurd input so one field cannot
flood the log.

**Twelve Dependabot pull requests, the oldest six weeks old.** Nine had passing
checks, but those runs were from July against a main that has moved a long way,
so rather than merging stale branches serially the eight safe ones are applied
here as one change and verified together: bullmq, pg, helmet, @types/supertest,
tailwind-merge, vite, actions/setup-node and github/codeql-action.

Deliberately excluded, and left open:

  * bcryptjs 2.4.3 to 3.0.3 (#79) — a major on the password-hashing path.
    Dependabot groups it with routine bumps; it is not one, and it deserves
    its own verification rather than riding along in a batch.
  * typescript 5.9 to 7.0 (#81), tailwindcss 3.4 to 4.3 (#76) and
    eslint-plugin-react-hooks (#78) — all three already fail their own checks.

Verified beyond the gates: the application was rebuilt and exercised on the new
dependencies — authentication, the BullMQ-backed poller, Postgres reads and
helmet's headers all behave.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:17:35 -07:00
2GT-RichandClaude Opus 5 8474f8e789 fix(firmware): download and verify before rebooting, and prove the reboot (v0.23.31-beta)
Reported as rollouts failing with "Device rebooted but still reports 7.24.1 —
the update did not apply", intermittently and on ad-hoc updates too. Three
separate faults, found by watching one rollout that succeeded on an access point
and failed on a switch in the same run.

**The device had never rebooted.** Its uptime was one week. The old sequence
issued `/system/package/update/install`, slept a fixed 25 seconds, reconnected,
read the version and — finding it unchanged — declared the upgrade failed. It
could not tell a device that restarted unchanged from one that never restarted
at all, and 25 seconds is nowhere near long enough: the access point that
*worked* took 245 seconds. So a device still busy downloading was routinely
reported as broken, and with halt-on-failure that aborted the rest of the fleet.

**`install` was the wrong command.** It bundles download and reboot into one
fire-and-forget call whose progress cannot be observed. On the CCR2216 it
accepted the command and then neither downloaded nor rebooted, reporting
nothing; an explicit `download` on the same device worked immediately. The
sequence is now download → confirm the device reports the image as downloaded →
reboot, which makes success verifiable *before* anything restarts and means a
device is never rebooted for an image it does not have.

**A successful reboot was being read as a failure.** Rebooting necessarily kills
the session that asked for it, so the transport error that follows is the
expected outcome. Unhandled, it surfaced as "Update failed before reboot:
Connection closed" on a device that had in fact rebooted and upgraded correctly.

The reboot is now proven by comparing uptime across it rather than assumed from
elapsed time, so "never restarted" is reported as itself — with the device left
running and reachable — instead of being mistaken for a failed upgrade.

Download failures now carry the device's free space, because that is the usual
cause and "did not finish in ten minutes" otherwise sends someone hunting the
network. A CRS309 with 1.06 MB free now says so instead of timing out silently.

Verified on hardware: the CCR2216 that produced the original report upgraded
7.24.1 to 7.24.2 cleanly, and the CRS309 that genuinely cannot fit the image
failed with an accurate message and was left untouched — same version, uptime
unbroken, never rebooted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:22:58 -07:00
2GT-RichandClaude Opus 5 2eda3d47ae ci: cut a release when the version changes (v0.23.30-beta)
The dashboard sends people to the releases page to see what changed. That page
held a single entry from five months earlier while the product shipped several
times a day, which tells a visitor the project is abandoned — raised on #119,
and the more useful half of that thread.

A release is now cut whenever the shipped version changes, tagged to match the
container image so the thing you are running and the notes you are reading carry
the same number. Notes are the commit body rather than a generated list of
subjects, because the commit messages here already explain what changed and why.
Betas are marked pre-release so nobody is pointed at one by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:58:25 -07:00
2GT-RichandClaude Opus 5 324ceba676 fix: place log events in real time, reset the data cap on send, export runs (v0.23.29-beta)
Three reports from this morning.

**Event timestamps were not the times the events happened.** RouterOS writes log
times in the device's own timezone and never says so, and it uses more than one
format. The parser handled `sep/03 08:37:38` and `08:37:38` but labelled both
UTC, shifting every event by the device's offset — reported as an event log
seven hours out from the device page. It did not handle
`2026-09-03 08:37:38` at all, which is what current RouterOS actually returns
over the API, so those fell through to the collection time instead: plausible on
a live poll, badly wrong for a backlog, where a thousand historical lines all
arrive stamped "now".

Devices now report their clock, and timestamps are converted from device-local
to UTC through it, with the numeric gmt-offset as a fallback and daylight saving
handled by the runtime rather than a fixed number. Where the zone is unknown the
parser returns null and the caller falls back to collection time, deliberately —
a confidently wrong timestamp is worse than an openly approximate one.

Verified on hardware: 18 of 18 events in a steady-state window now store exactly
seven hours ahead of what the device printed, which is the offset the device
itself reports. Existing rows are left alone; they were written by three
different code paths and cannot be corrected uniformly.

**The data-cap counter kept climbing after the SMS was sent.** Reported from the
field, and correct: the message is what zeroes the carrier's allowance — it is
the alternative to waiting for midnight — so ours has to follow. Left as it was,
two things went wrong: the bar counted against an allowance that no longer
applied, and usage stayed above the threshold, so the rule would re-fire on
every cooldown for the rest of the day. Both send paths now restart the count.

**Command runs export to CSV**, asked for on #118 for archiving and audit.
Output is quoted so multi-line device responses survive, and timestamps are ISO
rather than the driver's default string form, which no spreadsheet parses. The
download goes through the authenticated client rather than a plain link, because
a link cannot carry a Bearer header and would simply 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:50:01 -07:00
2GT-RichandClaude Opus 5 98a72e1f7a docs: versioned documentation site, and the four pages that were missing (v0.23.28-beta)
Closes #112.

Source stays `docs/*.md`, rendered by MkDocs-Material and published to GitHub
Pages by Actions. Same repository on purpose: documentation drifts, and
co-location is the only thing that reliably prevents it — the pull request that
changes behaviour changes the description, and review sees both. A separate repo
would double the ceremony and guarantee divergence.

The markdown remains readable when browsing the repository, so the site renders
those files rather than replacing them.

**Versioned from the start**, using mike. Images are published to GHCR and this
project ships betas most days, so readers are routinely running something other
than the newest build; a single "latest" would confidently describe a release
they do not have. Versions follow the product rather than the docs, so the site
and the running image carry the same number.

**Content came first, and it had to.** The docs were eleven days and sixteen
releases stale — SSH keys, bulk commands, the data-cap SMS, poller health and the
timezone setting appeared in exactly zero files between them. Publishing that
would have given documentation missing five of the most consequential features a
polished, authoritative home, and a well-presented wrong answer is worse than an
obviously rough one. Four pages now cover them:

  - cellular.md — signal grading, carrier aggregation, the uplink anchor,
    observed bands, and the data-cap SMS including why it fires early and how to
    calibrate the margin
  - ssh-keys.md — leading with what installing a key costs, because RouterOS
    disables password SSH for that user and the database becomes load-bearing
  - commands.md — why waves exist, and why reading output matters when RouterOS
    reports failure in text rather than an exit code
  - scaling.md — headroom, why it is insufficient on its own, and every tunable

Pull requests build the site with `--strict`, which turns warnings into errors.
That is the point of running it there at all, and it earned itself immediately:
the first build failed on two links to the project README, which sits outside the
docs tree. They are absolute now, so they resolve both in the rendered site and
when reading the markdown on GitHub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:42:40 -07:00
2GT-RichandClaude Opus 5 0e364b1558 feat(commands): run one command across a fleet, in waves (v0.23.27-beta)
Closes #118. Unblocked by #110, which is what makes SSH on sixty devices
practical.

Arbitrary RouterOS console commands over SSH, because `:put`, `:foreach` and
`/interface print` are console syntax that the binary API cannot express, and
because a curated command library cannot cover "add a channel to LACP" or
"rewrite a WireGuard endpoint" — the things people actually asked for.

**Waves are the feature, not the safety rail around it.** A shell loop already
runs a command on many hosts, costs nothing, and prints errors as they happen.
What it cannot do is stop. Devices run together within a wave, waves run strictly
in sequence, and halt-on-failure ends the run before a mistake becomes sixty
mistakes. Without that, this would be a worse bash.

Proved on hardware rather than argued: a bad command on two devices, one per
wave, failed on the first and left the second `skipped`. RouterOS reports most
errors in output text rather than an exit code, so output is read for failure
markers — otherwise a rejected command and a successful one are indistinguishable
and the run would sail on. Conservatively: only unambiguous markers count, since
misreading ordinary output would halt a run that was working.

Change Guard wraps each device by default, so one that stops answering after the
command restores itself. It is one click to turn off, because a sharp tool that
refuses to cut is not a tool — the requester's argument, and a fair one. The
combination that gets an explicit acknowledgement is a command that can sever
management *with the guard disabled*; that is the only case where nothing catches
a mistake.

A preview names devices, wave assignment, and the reasons a command looks capable
of cutting management access. It warns, never blocks. Devices with no SSH
credential are listed before the run rather than producing a wave of identical
authentication failures.

Per-device output is captured and kept, because "it failed" without the device's
own words is not actionable, and because some commands exist purely for output.

Verified end to end against real hardware: a read-only command across two
devices, halt-on-failure stopping a bad command after one device, and a genuine
write applied under Change Guard, confirmed, then reverted — with both devices
returned to their original configuration afterwards.

Also adds services/sshExec.ts, shared with the key work: it selects a verified
key or a password, and throws rather than swallowing. That last part is
deliberate — a swallowed error is exactly how the data-cap feature spent a week
doing nothing while looking healthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:20:02 -07:00
2GT-RichandClaude Opus 5 19e2d914f5 fix(lte): data-cap usage was never counted (v0.23.26-beta)
Reported from the field: manual SMS send works, but no traffic is accumulated.
Correct on both counts, and the cause is a single wrong call shape made invisible
by a swallowed error.

The interface filter was passed as a parameter rather than a RouterOS query
word, so the request went out as `=?name=lte1` and the device answered "unknown
parameter ?name". That threw on every poll, a `.catch(() => [])` turned it into
an empty result, and the empty result made the tracker return early — every
time, in silence. The rule looked configured, the usage bar sat at zero, and
nothing anywhere said why.

Proved against hardware rather than reasoned about:

  as-shipped   -> THREW: unknown parameter ?name
  query-word   -> rows: 1  ether1 rx=4997765728

The filter now goes through the client's query-word argument, falls back to an
unfiltered read if a filtered one comes back empty, and — the part that matters
more — logs instead of discarding. A counter read that fails, or an interface
that is not in the result, now says so. The tracker also warns when it has no
counters, because a rule that exists and never counts is precisely the failure
the user experiences.

Accumulation verified end to end on real hardware afterwards: first poll
establishes a baseline and counts nothing, second and third accumulate 118 KB
then 144 KB, with the period keyed to the configured local date.

Worth recording why this shipped. The pure accounting logic has eighteen tests
and all of them pass — reboot handling, midnight rollover across zones, margin,
cooldown. None of it ran, because the one integration point those tests cannot
reach was wrong. Verifying the API paths and the configuration flow, as was done
before release, exercised everything except the thing the feature is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:31:52 -07:00
2GT-RichandClaude Opus 5 864ca40427 fix(ssh): state what installing a key actually costs, and ask before doing it (v0.23.25-beta)
v0.23.24 shipped the SSH key feature with a warning that undersold it and a
confirmation flow that was the wrong way round. Five gaps, all in how the change
was presented rather than in what it does.

**Installing was one click; revoking asked for confirmation.** Backwards.
Revoking restores password SSH, while installing takes it away — the
consequential action was the unguarded one. Installation now requires an
explicit acknowledgement naming the account that will lose password access.

**The warning disappeared once a key existed.** Whoever arrives at a device
later and finds their password refused over SSH is exactly the person who needs
the explanation, and they were the one person not shown it. The notice is now
permanent, worded for the current state rather than only the impending one.

**It said "this user" rather than naming the account**, which is the difference
between a caution and a fact someone can act on.

**It never mentioned losing the database.** That is the serious one. Password
SSH is already refused once a key is bound, so a manager that loses its database
or encryption key leaves SSH recoverable only through the API or WinBox. That
consequence outlives the session, the operator, and quite possibly the person who
clicked the button, and it went unstated.

**Fleet-wide deployment had no guard whatsoever.** One request would disable
password SSH on every device it touched, while the single-device path asked for
an acknowledgement — an endpoint that can do a thing sixty times should not be
easier to trigger than the one that does it once. It now refuses without an
explicit confirm and explains why in the refusal.

The notice also spells out what was previously left implicit: colleagues, scripts
and other tools that SSH in with a password will stop working. On a shared fleet
that is a change to announce, not to discover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:01:20 -07:00
2GT-RichandClaude Opus 5 1ab2e6547e feat(ssh): per-device SSH keys, deployed and proved (v0.23.24-beta)
Closes #110. Also the prerequisite for #118 — arbitrary console commands need
SSH on every device, and stored passwords do not scale to sixty of them.

Each device gets its own ed25519 keypair, generated on the manager, pushed over
the existing password session, imported, and then *proved* by opening a fresh
connection and authenticating with it. Nothing trusts a key until that last step
succeeds. Backups and config export use a verified key where one exists.

Verified end to end on hardware: deploy, rotate twice leaving exactly one
authorised key each time, verify, and a 4.6 KB config backup pulled over
key-only SSH with no password fallback.

**The design note on the issue was wrong about the central safety property**, and
testing on a switch is what proved it. I claimed the stored password remained as
a fallback. It does not: RouterOS binds a key to a user and then refuses password
authentication for that user entirely. Removing the key restored password login
immediately, which settles the causation.

That mistake compounded with a second one. Deployment wrote the new private key
to the database before proving it, so a rotation that failed halfway left the
database holding a key the device had never authorised while the device demanded
one we no longer had — SSH lost on the test switch, recoverable only through the
binary API. Three changes follow:

  * Rotation authenticates with the *existing* key, since the password is no
    longer an option once a key is installed.
  * Nothing is written until a fresh connection has authenticated with the new
    key. A deployment that fails verification removes the key it just installed
    rather than leaving the device demanding a credential nobody holds.
  * The binary API, unaffected by any of this, is the removal path — and is
    therefore the real recovery route, not the password.

Two further faults came out of live testing rather than reasoning. Every key for
a device shared one comment, so removing "the old key" during a rotation removed
the new one too; comments are now unique per key. And RouterOS stores that
comment in `info`, not `key-owner` — matching the wrong field silently matched
nothing and left every rotation's old key behind, which is why the switch
accumulated keys that later blocked it.

Revoke now clears *every* key this manager installed for a device, not just the
one on record. Orphans from a half-failed rotation otherwise keep password SSH
disabled with no way back through the product, which is exactly the state the
test switch got stuck in. When deployment fails with an authentication error and
orphans are present, the message says so instead of leaving someone guessing.

The UI states the trade before it is made: installing a key disables password SSH
for that user, the API and WinBox are unaffected, and revoking restores it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:39:38 -07:00
2GT-RichandClaude Opus 5 a06ebd4d71 feat(lte): daily data-cap tracking and carrier reset SMS (v0.23.23-beta)
Requested in discussion #85. Several European carriers sell "unlimited" mobile
data that is throttled hard past a daily allowance, and lift the throttle when
the subscriber texts a short code — 10 GB then 64 kbps until you text 1237, in
the reporter's case. Doing that by hand every evening is exactly the sort of
thing this should handle.

Two facts from the contributor's hardware shaped the whole design.

**Interface byte counters do not survive a reboot.** So there is no running
total to read; usage has to be accumulated from poll to poll on our side. That
makes our figure a permanent undercount — traffic during a reboot, before the
device was adopted, or between a missed poll and the next is invisible. An
undercount fires late, and late means the user is already throttled, so the
trigger deliberately fires below the configured allowance (5% by default, and
adjustable). Firing early costs one SMS; firing late costs the evening.

**The carrier's own counter resets at local midnight.** That anchors the period
and, more usefully, bounds the error: both counters reset together, so our drift
cannot accumulate past a single day. Periods are keyed by local date string
rather than timestamp, which detects the rollover without converting local
wall-clock back to UTC — where daylight saving usually goes wrong.

A counter that reads *lower* than the previous sample is treated as a reboot and
the new value taken as the delta, rather than as negative usage that would
corrupt the running total for the rest of the day.

The manual send is deliberately independent of everything else: not gated on the
rule being enabled, on a threshold, or on our usage estimate. It is the half of
this feature that is trustworthy — one known action, for a reason the operator
already has — and it is what makes the automatic half optional rather than
load-bearing. The automatic trigger carries a cooldown because usage sits *at*
the cap once reached rather than above it; without one, a link parked on its
limit would text the carrier on every poll. Sending twice is a nuisance, forty
times is an incident.

Every send is recorded with the counter reading at the time, so a misfire is
explainable rather than mysterious.

Accounting logic lives in utils/dataCap.ts with 18 tests, covering the reboot
case, the local-midnight rollover across zones and a non-midnight reset, the
margin, and the cooldown. Failures in tracking are logged and swallowed: a
convenience feature must never break the collection it rides on.

Also fixes an error path found while testing. DeviceCollector decrypts the
stored credential in its constructor, which sat outside the route's try block, so
an unreadable credential escaped as a raw 500 carrying an internal message and
left no record of the attempt. It now returns 502, says the credentials could not
be read rather than quoting a cipher error at someone hunting a modem fault, and
logs the failure like any other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:03:33 -07:00
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