Commit Graph
4 Commits
Author SHA1 Message Date
Gustavo c4db0dc1e2 fix: harden auth site access and SSH validation 2026-09-28 14:54:03 -03:00
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 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 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