Commit Graph
31 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 Fable 5 27f155f778 feat(firmware): in-app MikroTik release notes + posture findings for pending updates (v0.16.6-beta)
- New GET /api/firmware/changelog/:version proxies MikroTik's official
  per-version release notes (download.mikrotik.com/routeros/<ver>/CHANGELOG)
  with strict version validation and process-lifetime caching
- ChangelogModal: "What's new in X.Y.Z" on the device Config tab's
  update box and on the Firmware page's latest-version cells opens the
  changelog in-app, with an external mikrotik.com link and graceful
  fallback when the CDN is unreachable
- Security posture: a pending RouterOS update is now a medium-severity
  finding and a pending RouterBOOT upgrade a low-severity one; titles
  are version-free so Common Findings aggregates the fleet into one row
- chore(deps): npm audit fix bumps morgan (GHSA-4vj7-5mj6-jm8m); both
  workspaces audit clean

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:45:53 -07:00
2GT-RichandClaude Fable 5 6f0fbdb74f chore(deps): dependency upgrades — react-router 7, eslint 10, TS 6, plugin-react 6; drop unused zod (v0.16.5-beta)
Applies the open dependabot PRs in one validated pass:

- react-router-dom 6.30 -> 7.18 (drop-in for our API surface; verified
  in-browser: login, sidebar routing, ?type= deep-links, legacy redirects)
- react + react-dom -> 19.2.7, axios -> 1.18.0, lucide-react -> 1.21.0
- actions/checkout v6 -> v7 across all workflows
- backend: eslint 10 + @eslint/js 10 (fix 5 new no-useless-assignment
  findings), typescript 6.0.3 (explicit types: ["node","jest"] in
  tsconfig — TS 6 no longer auto-includes @types), remove zod (unused)
- frontend: @vitejs/plugin-react 6

Not taken: ioredis 5.11.1 (#55) — bullmq pins ioredis to exactly 5.10.1
(including latest bullmq 5.79), so the bump nests a second ioredis and
breaks BullMQ connection types.

Validation: backend tsc/lint/build + 92 tests green; frontend tsc/lint/
build + 11 tests green; docker stack rebuilt and browser-verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 20:35:21 -07:00
2GT-Rich 4e6c9f481e feat(wireless+ports): RF Health panels + physical per-port clients (v0.16.1-beta)
Wireless RF Health (fleet Wireless page + per-AP Radios tab):
- Channel usage map across 2.4/5/6 GHz with co-channel overlap; operating
  channel resolved live from /interface/wifi/monitor on the wifi package
  (auto-channel radios report no frequency in /print).
- AP deployment density: client RSSI distribution on the -90..-30 dBm scale
  with a coverage-gap warning.
- AP radio TX retries histogram (0%->35%+) with band selector, derived from
  per-client CCQ (new wireless_radio_quality InfluxDB series).
- WiFi connectivity success funnel (Association/Auth/DHCP) derived from logs.

Per-port connected clients (Ports tab):
- New card lists clients physically connected to a selected port (host/MAC/
  IP/VLAN/connection time, linked to client pages).
- Distinguishes physical clients from the raw MAC table: uplink/trunk ports
  are auto-detected (topology neighbor, multi-VLAN MAC spread, or high MAC
  count) and show an uplink explainer + "view MAC table" disclosure instead
  of every reachable MAC.
- Added clients.first_seen ("connected since"); set once, preserved across polls.
2026-06-25 15:37:08 -07:00
2GT-RichandClaude Opus 4.8 d79eb13119 feat(ui): theme-aware logo, dashboard bar graph, server-side client sorting (v0.14.1-beta)
UI/UX refinements on top of v0.14.0-beta, all validated on the test server:

- Logo: new theme-aware compass iconmark (light/dark SVGs swapped via the
  dark class), sized to 45px; favicon updated to match. Wordmark casing
  corrected to "MikroTik Manager" (font size unchanged).
- Navigation icons enlarged 15 -> 17px.
- Dashboard: "Connected clients" line chart converted to a bar chart with a
  category axis + light downsampling so bars stay readably thick across ranges.
- Clients: sorting is now server-side across the whole dataset (was sorting
  only the current page, so Type/others appeared to do nothing on a uniform
  page); table uses a fixed column layout + keepPreviousData so columns no
  longer jump or blank out when sorting/paging. Backend clients route gains
  whitelisted sort/dir params (safe numeric IP sort, deterministic tiebreaker).
- Connections tab: surfaces the live /ip/firewall/connection/tracking state
  (on/auto/off pill) and clearer empty-state copy explaining auto-mode.

Lint/typecheck/build/jest green in both workspaces; npm audit --audit-level=high
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:37:11 -07:00
2GT-RichandClaude Opus 4.8 ee9c9ff9f7 feat(security): Security Center — firewall, NAT, bandwidth, posture, connections (v0.14.0-beta)
A dedicated top-level Security section plus a Meraki/UniFi-grade firewall built
on the full RouterOS feature set.

Security Center (new /security page)
- Fleet hardening scores, per-device posture list, and a "Common Findings"
  rollup aggregating identical issues across the fleet
- "Manage" deep-links into the device's Security tab (?tab= support added)

Firewall & NAT (per device + in Security Center)
- Friendly rule builder: Allow/Drop/Reject chips, Any/Address/Address-List
  pickers, port presets, connection-state chips, logging, live plain-English
  preview
- Address lists as reusable objects, referenced from rules
- Rule reordering (/ip/firewall/{filter,nat}/move); per-rule hit counters
  (print stats) with dead-rule highlighting + reset
- NAT wizards: Port Forward / Masquerade / 1:1 NAT + Custom
- Safe-apply lockout guard: backend 409 on an unscoped input drop/reject that
  would sever management; UI confirms before forcing (detectLockoutRisk, tested)

Access & security
- Security posture audit: graduated, de-duplicated scoring that never proposes
  disabling the management service the platform connects through (no self-
  lockout); switch-aware input-firewall severity; service hardening table
- Bandwidth: simple-queue CRUD + one-click "Limit this client" on client detail
- Active connections viewer

These L3 features are available on all managed RouterOS devices (routers,
switches, APs), not gated by device type.

Backend: DeviceCollector address-list/move/stats/connections/queues/service
methods; device routes for all of the above; firewallSafety + firewallSummary
utils with unit tests (19 new).

Security: bump nodemailer 8 -> 9.0.1 to clear GHSA-p6gq-j5cr-w38f (our
createTransport/sendMail usage is unaffected by the advisory's raw-option path).

Verified live against RouterOS devices; lint/typecheck/build/jest green in both
workspaces; npm audit --audit-level=high clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:24:22 -07:00
2GT-RichandClaude Sonnet 4.6 8d24b0b998 feat: bandwidth test auto-manages server on target managed device
Backend: accept target_device_id — connect to target, enable btest
server, run test from source using target credentials, disable server
in finally (even on failure). Manual IP mode preserved as fallback.

Frontend: device picker dropdown (all managed devices except self) as
default mode; toggle to Manual IP for non-managed targets with a warning
that the server must be pre-enabled. Results show target IP in labels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:42:24 -07:00
2GT-RichandClaude Sonnet 4.6 a1788238f8 fix: discover actual PCAP path via /file/print instead of guessing
After sniffer stops, poll /file/print every 500ms (up to 10s) until
RouterOS reports the capture file, then use the exact 'name' field as
the SFTP path. Falls back to stripping the leading slash if the first
attempt gets "No such file". Eliminates the hardcoded path assumption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:32:41 -07:00
2GT-RichandClaude Sonnet 4.6 6690ac9118 fix: RouterOS sniffer interface param is filter-interface not interface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:30:09 -07:00
2GT-RichandClaude Sonnet 4.6 64ab6ccf27 fix: show real capture error + step-by-step backend logging
- Frontend: blob error responses (from responseType:'blob' calls) are now
  decoded back to text/JSON so the actual backend error message surfaces
  in the UI instead of generic "Request failed with status code 500"
- Backend: step-by-step console.log through capture flow so the exact
  failure point is visible in server logs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:28:40 -07:00
2GT-RichandClaude Sonnet 4.6 c311ddb5d2 fix: packet capture SFTP path + pre-stop + flush delay
RouterOS SFTP root maps to the device filesystem root, not /flash/.
Capture file is written as /<name>.pcap, not /flash/<name>.pcap.
Also stop any in-progress capture before reconfiguring, add a 2s
flush delay between sniffer stop and SFTP download, improve error
messages to expose exact failure point, and delete remote file after download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:16:58 -07:00
2GT-RichandClaude Sonnet 4.6 64bb8b1b93 feat: packet capture, bandwidth test (v0.11.5 Beta)
- Packet capture: RouterOS sniffer + SFTP download to PCAP for Wireshark analysis
- Bandwidth test: RouterOS /tool/bandwidth-test with TX/RX Mbps result display
- Both exposed in the Tools tab on device detail pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:50:31 -07:00
2GT-RichandClaude Sonnet 4.6 49ab1b002e feat: audit log, device tags, maintenance windows (v0.11.3 Beta)
- Audit log: middleware captures all write operations, paginated admin UI in Settings
- Device tags: full CRUD, device assignment, filter bar and chips on Devices page
- Maintenance windows: alert suppression during planned downtime, Settings management UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:33:15 -07:00
2GT-Rich d3aa72c488 feat: Copy VLANs wizard + CPU display fix for hardware switches (v0.11.0 Beta)
Add 'Copy VLANs from Another Switch' — a 3-step modal available on the
VLANs tab of any switch device. Step 1 selects a source switch and which
VLAN IDs to copy (VLAN 1 deselected by default with a management risk
warning). Step 2 lets the user assign interfaces on the target device
per VLAN: clicking a port chip cycles it through None → Tagged → Untagged
→ None, with a live summary and bridge picker per VLAN. Conflict detection
identifies VLANs already present on the target and offers per-VLAN
Skip/Overwrite with the port grid disabling itself when Skip is active.
Step 3 shows the full plan before a single apply call executes all
operations over one device connection and reports per-VLAN results.

Also fix CPU load display on the Devices page for hardware switches that
report 0% CPU via ASIC offloading — the previous filter (v > 0) silently
dropped all valid zero readings; now filters on field presence instead.
2026-05-15 14:43:53 -07:00
2GT-Rich 68d88d0306 feat: RouterBOOT upgrade support, update UI overhaul, discovery fixes (v0.10.6 Beta)
RouterOS vs RouterBOOT distinction:
- Rename "Firmware Updates" → "RouterOS Updates" everywhere (Config tab,
  dashboard banner, devices list tooltip) to accurately reflect what is
  being updated
- Add RouterBOOT bootloader upgrade support as a distinct operation:
  new DeviceCollector methods checkRouterboardUpgrade() and
  installRouterboardUpgrade() using /system/routerboard/print and
  /system/routerboard/upgrade
- New API endpoints: POST /api/devices/:id/check-routerboard and
  POST /api/devices/:id/install-routerboard
- Daily poller now also checks RouterBOOT upgrade status while already
  connected during firmware check cycle, with first-discovery alerts

Config tab update panel:
- Combine RouterOS and RouterBOOT into a single "Software & Firmware
  Updates" card with two subsections separated by a divider
- Pre-populate both sections from stored DB state on load — no longer
  requires a manual "Check" click to see a known pending update
- Enforce update order: when both are pending, show a blue info banner
  explaining RouterOS must be installed first, and disable the
  RouterBOOT "Upgrade & Reboot" button until the RouterOS update clears
- Fix GET /api/devices/:id to include firmware_update_available,
  latest_ros_version, routerboard_upgrade_available, upgrade_firmware_version
  (these were missing from the detail endpoint)

Dashboard & devices list:
- Combined amber banner now shows two distinct rows: RouterOS (N) with
  device links and RouterBOOT (N) with device links — one dismiss for both
- Devices list: amber ArrowUpCircle = RouterOS update, blue Cpu icon =
  RouterBOOT upgrade, each with distinct tooltips
- Fix GET /api/devices (list) to include routerboard_upgrade_available and
  upgrade_firmware_version (these were missing, causing RouterBOOT
  notifications to never appear on the dashboard)

DB migration:
- Add routerboard_upgrade_available BOOLEAN DEFAULT FALSE
- Add upgrade_firmware_version VARCHAR(20)

Discovery system fixes:
- Fix duplicate entries: replace single-key (mac || ip || identity) dedup
  with three cross-referenced indexes so a neighbor seen via LLDP by one
  device and via MNDP by another always merges into one row
- Fix offline devices persisting: handleDeviceFailure now deletes
  topology_links for the offline device (same pattern as clients)
- Add periodic stale-link sweep every 15 min that removes topology_links
  rows older than 20 min as a safety net for devices that go offline
  without cleanly triggering handleDeviceFailure
2026-04-23 13:46:20 -07:00
DenisandCursor Agent a12f52ebc7 feat: non-blocking bulk-add, CORS hardening, credential preset access control (#31)
* devices: hide discovered neighbors that are already managed

Discovered Devices showed every unresolved MikroTik neighbor, even when
the neighbor was obviously the same device a managed node is reporting
through a different link / MAC / alias. Since we already know every
managed device interface MAC, primary IP, and system identity, we can
flag those rows.

- /api/devices/discovered now annotates each row with duplicate_of_device_id
  and duplicate_of_device_name when the neighbor matches a managed device
  by interface MAC, primary IP, or system identity.
- Add a Hide duplicates of managed devices toggle (default on) above the
  table, with a count of how many matches were found. When enabled, the
  list hides matched rows; when disabled, matches stay visible but are
  dimmed and tagged Already managed so the origin is clear.
- Suppress the Add to Manager action for rows that are already a
  managed device, since adding them would create a second record for the
  same hardware.

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

* devices: safer duplicate handling, try-all safeguards, and timezone/firmware fixes

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

* Non-blocking follow-ups: parsePort util, tests, bulk-add queue, preset rate limit

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

* fix(security): tighten CORS, Redis rate limits, and preset access controls

Require explicit CORS origins in production, align Socket.IO CORS with HTTP,
default Docker Compose to local HTTPS origins, and remove the in-memory
mutation limiter in favor of Redis. Also add operator-use flags for credential
presets in the UI and harden bulk-add job access and client error messages.

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-21 19:54:35 -07:00
Denis 30bf691596 feature(devices,topology,tz): devices bulk-add, duplicate safety, and firmware/timezone fixes
Squash-merge of PR #28 from @denis-ev. All three review blockers addressed: encryption key management documented, string truncation replaced with skip+warn validation, and undefined return on duplicate merge fixed with proper 500. Local test stack verified against cloned prod DB — migrations, new features, and backend all clean.
2026-04-21 16:52:04 -07:00
2GT-Rich bd85f988da Fix all backend lint errors and security vulnerabilities
Backend lint (4 errors fixed):
- auth.ts: suppress @typescript-eslint/no-namespace on Express global
  augmentation (legitimate TS pattern for extending Request type)
- clients.ts: remove unnecessary \. escape in regex character class
- devices.ts: remove unnecessary \- escape by moving hyphen to front [-:]
- PollerService.ts: add comment to empty catch block

Security audit (high/critical CVEs fixed):
- frontend: bump axios (critical SSRF), lodash (high injection),
  follow-redirects (moderate) via npm audit fix
- backend: bump nodemailer 6.x → 8.0.5 (fixes 4 SMTP injection CVEs)
2026-04-16 11:08:42 -07:00
Rich Teslow 6b0fc9fdf1 Initial commit baby! 2026-04-13 18:15:41 -07:00