Compare commits

...
10 Commits
Author SHA1 Message Date
Gustavo c4db0dc1e2 fix: harden auth site access and SSH validation 2026-09-28 14:54:03 -03:00
GustavoandClaude Sonnet 5 5b926d637b docs: record upstream security audit (Mission 01)
- docs/AUDITORIA_BASE_2GT.md: full audit of upstream 1a4b635 (v0.24.5-beta)
  - 0 critical, 1 high (SEC-002: no per-site RBAC), 4 medium, 5 low, 5 informational
  - Verdict: APROVADO COM CORREÇÕES OBRIGATÓRIAS
  - First read-only RB: authorized (SIM)
- test: add regression coverage proving SEC-001 (no JWT revocation) and
  SEC-002 (site scoping has no per-user authorization component)
- docs/SISTEMA_ATUAL.md: record confirmed facts (RBAC scope, Change Guard
  coverage, RouterOS TLS verification) surfaced by the audit

No production code changed — audit-only per mission scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:32:03 -03:00
GustavoandClaude Sonnet 5 7a4979e0e1 chore: bootstrap MikroRMM local development baseline
- Baseline sobre upstream 2GT-Media-Group-LLC/mikrotik-manager @ 1a4b635 (v0.24.5-beta)
- Adiciona docs/SISTEMA_ATUAL.md e CLAUDE.md (documentação operacional)
- Ignora .secrets-local/ (nunca versionar secrets)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:20:03 -03:00
2GT-RichandClaude Opus 5 1a4b635052 fix(firmware): halt-on-failure stopped waiting for the wave to finish (v0.24.5-beta)
CI / Deployment files in sync (push) Canceled after 0s
CI / Frontend — Lint, Type-check, Test & Build (push) Canceled after 0s
CI / Backend — Lint, Type-check & Build (push) Canceled after 0s
CI / Security Audit (push) Canceled after 0s
CodeQL Security Analysis / Analyze (javascript-typescript) (push) Canceled after 0s
Documentation / Build (check only) (push) Canceled after 0s
Documentation / Publish versioned site (push) Canceled after 0s
Publish to GHCR / Build & Push Backend (push) Canceled after 0s
Publish to GHCR / Build & Push Nginx (Frontend) (push) Canceled after 0s
Release / release (push) Canceled after 0s
A regression I introduced in v0.24.3, and described inaccurately in its own
commit message.

Adding concurrency moved the halt check to the end of a wave. I wrote that
"at the default of 1 those are the same moment". That is only true of a wave
holding one device. With several devices in one wave and the default
sequential setting, the old code stopped the moment one failed; mine went on
to attempt every remaining device in that wave first. Anyone relying on
halt-on-failure with a wide wave 1 got a wider blast radius than before,
without changing a setting.

Halting now stops further devices from *starting* as soon as a failure is
known, whatever the concurrency. Devices already in flight are allowed to
finish, because interrupting one mid-write is how you brick it. At
concurrency 1 that is precisely the previous behaviour. Above 1, only the
devices genuinely running side by side can carry a bad build -- which is the
unavoidable part of the trade in #135, rather than a cost imposed on rollouts
that never asked for concurrency.

The firmware documentation said the same wrong thing and is corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:54:51 -07:00
2GT-RichandClaude Opus 5 d9fa120abc fix(logs): a line with no RouterOS id had no protection against re-insertion (v0.24.4-beta)
Follows #137. The v0.24.1 fix made the count honest and stopped a thousand
round trips per poll, but it assumed the unique index on (device_id, log_id)
would reject lines already stored. For most devices it does. For a device
whose log entries carry no `.id` it never could: the column was set to NULL,
and PostgreSQL treats NULLs as *distinct* in a unique index, so every NULL
conflicts with nothing. Those devices re-inserted their entire buffer on
every poll, for ever, and the constraint that was supposed to stop it was
structurally incapable of firing.

A line with no id now gets a key derived from its own content -- timestamp,
topics and message -- so the existing index does the work. The `#` prefix
keeps it out of the space of real RouterOS ids (`*` plus hex), so the two
cannot collide, and parseRosId already rejects it, which matters because the
watermark is a max over real ids and must not be poisoned by a synthetic one.

The trade, stated rather than buried: two genuinely separate lines sharing a
timestamp, topic and message collapse into one. RouterOS timestamps are
second-resolution, so that can happen. Losing a repeat of an identical
message is a much smaller loss than growing the table by the whole buffer
every minute without bound.

This was found by reading a comment on #137 I had not read before closing it
-- log output from a device with eleven lines total, reporting eleven new
entries every minute for forty-five minutes. My own fleet cannot reproduce
it: every event here carries a real `.id`, with no nulls and no duplicates,
which is exactly why the hole was invisible from here. So this closes a
defect demonstrable from the schema; whether it is the whole of that
reporter's problem is still an open question and the issue is reopened to ask.

Verified no regression where ids are healthy: no surrogate keys generated, no
nulls, no duplicates, and counts that vary with real activity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:45:29 -07:00
2GT-RichandClaude Opus 5 6bc7480b89 ci(release): tag by version, not by comparing with the previous commit
v0.24.3-beta shipped without a release. The workflow compared the version in
HEAD against HEAD~1, so a bump pushed together with any follow-up commit —
here a docs commit — was no longer the tip and nothing looked changed.

It now asks whether a tag for the current version already exists. That does
not care how commits are batched, is safe to re-run, and makes the job
idempotent rather than dependent on push shape. The paths filter is dropped
for the same reason: the tag check is the real guard.

Release notes are taken from the commit that last touched the version rather
than the tip, since a bump is often followed by docs or CI commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:01:40 -07:00
2GT-RichandClaude Opus 5 6c3d2aaa35 docs: firmware rollouts and backups
Neither area had a page, and both changed materially in v0.24.2/v0.24.3.

The firmware page records why the pipeline downloads and verifies before
rebooting, and what raising "at once" actually costs: halting becomes less
precise, devices reboot together, and the upstream warning is conservative
enough that its absence is not proof of safety.

The backups page leads with the fact that a backup is /export text rather
than a binary blob, since that is what makes preview and diff possible and
what limits what a restore can do. It also states plainly that a backup and
its Config History snapshot are one artifact, so deleting one removes the
other's rollback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:42:55 -07:00
2GT-RichandClaude Opus 5 df8f6dca44 feat(firmware): optional parallel upgrades within a wave (v0.24.3-beta)
Closes #135. The reporter's numbers: CRS switches take ~5 minutes each to
reboot, so ten devices is an hour of mostly waiting.

Devices in a wave can now upgrade concurrently, up to a per-rollout limit.
Waves themselves stay strictly sequential — that is the decision point, and
what makes wave 1 a canary. The default is 1, which is the behaviour every
existing rollout was built around, and existing rows backfill to it.

CommandRunner already runs a wave with Promise.all, but an upgrade is not a
bulk command: it holds an API connection for minutes across a download, a
reboot and a verification, and connection pressure is exactly where this
system has been shown to break. So concurrency is bounded rather than
unlimited, via mapWithConcurrency, capped at 20 and clamped server-side.

The trade is stated rather than hidden. Halting is now evaluated at the end
of a wave instead of the instant a device fails, so a wave of five can put a
bad build on five devices before stopping. At the default of 1 those are the
same moment. The UI says so plainly whenever the setting is above 1.

An upstream warning covers the risk Change Guard cannot: several devices
rebooting together is fine for independent routers and dangerous for a switch
the others are reached through, and a device unreachable because its uplink
rebooted has no config change to revert.

The first implementation of that warning was wrong in an instructive way. It
flagged any device another selected device linked to — and on the real
four-device fleet it flagged all four, because discovered links are
bidirectional and a trunk port often resolves to several neighbours at once.
A warning that fires on everything is worse than none: it teaches people to
dismiss it. It now requires the link to be the reporting device's STP root
port *and* that port to resolve to exactly one neighbour, so it stays quiet
when it cannot tell — which, on this fleet's ambiguous topology, it correctly
does.

Verified on hardware with devices already up to date, so the wave machinery
ran end to end without flashing anything:

  - sequential: 04:20:21→:26, then :26→:31. No overlap, 12s.
  - concurrency 2: both started within 10ms of each other, 7s.
  - concurrency 5 across two waves: wave 2 started 3ms *after* wave 1
    finished, never during it.

Clamping checked at the API: 999 stored as 20, 0 and "abc" as 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:23:08 -07:00
2GT-RichandClaude Opus 5 719563dd22 feat(backups): filter, preview, compare and bulk delete (v0.24.2-beta)
Closes #134.

Backups are `/export compact` output — plain RouterOS script, not the binary
`.backup` blob — which is what makes the reporter's "it's just a text, after
all" correct and most of this cheap.

  - Filters: device, type, date range and free text over device name,
    filename and notes. Dates are inclusive at both ends, because a picker
    showing "to: 3 Sep" that stops at that day's first instant is a trap.
  - Preview in place, on row click. No round trip through the downloads
    folder to see what a backup contains.
  - Compare any two, reusing the lineDiff that Config History already uses
    rather than adding a second diff implementation.
  - Bulk delete, with selection and a confirmation that names what goes.

Two deliberate choices.

Bulk delete had to respect an existing invariant: a backup and its config
snapshot are one artifact, joined by ON DELETE CASCADE so they cannot drift
apart. Removing fifty in one click can therefore destroy restore points
nobody looked at. The confirmation is built from a server-side preview that
reports the backups per device *and* how many config snapshots will go with
them, and it is capped at 200 per call. The action is also site-scoped, so it
cannot reach into another site's history.

The Filename column is gone from the table but the filename is not gone: it
is the natural handle for a file — device and timestamp, the thing you would
quote to a colleague — so it moved to the preview and diff headers where you
are actually looking at that file, rather than being dropped.

Diff direction is extracted and tested. Selection order in a table is
whatever the user happened to click and carries no meaning, so the pair is
ordered oldest-first; rendered the other way round every addition reads as a
deletion, and nothing about the output looks wrong while it does.

Incidentally fixed: 'pre-upgrade' backups were labelled "Manual" in the table
because the badge only knew three types. Type wording now has one home.

The list query's placeholder counter is replaced by a bind() helper deriving
from params.length. The counter's final increment was dead, and whoever added
the next filter would have inherited a stale index.

Verified against 110 real backups: every filter and combination returns the
expected counts and partitions cleanly, preview and diff read real .rsc with
file_path withheld, and bulk delete removed three throwaway backups plus
their files and left the other 110 untouched. Guards checked: empty, missing,
non-numeric and injected ids, unknown ids, and the 200 cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:05:55 -07:00
2GT-RichandClaude Opus 5 63b5677522 fix(firmware,ssh,logs): five reported bugs (v0.24.1-beta)
Closes #136. Also fixes #133, #137, #138, #139 (GitHub closes only the first).

None of these came from multi-site; two came from me.

#136 — waves failed on the second device. A regression from v0.23.31, the
firmware rewrite that replaced fire-and-forget `install` with `download` plus
polling. `/system/package/update/download` blocks for a minute or more, and
the client's read timeout is 30 seconds. The timeout path was the real damage:
it nulled the pending read but left the socket open, so when the reply finally
arrived processBuffer() queued it and the *next* command took it as its own
answer. Every later call on that connection returned the previous one's data,
which is why the status polls could never see "Downloaded".

  - a timed-out read now abandons the connection instead of silently
    desynchronising it, so the failure is loud rather than wrong
  - long-running commands can state their own budget; check-for-updates,
    download and /log/print now do
  - a download whose command times out is re-checked on a fresh connection
    rather than assumed failed, because the image may well have landed

#133 — the SSH terminal could not use a deployed key. It read only
ssh_password_encrypted and refused to connect without one, so retiring the
password after deploying a key -- the workflow the feature exists for -- broke
the console. It now uses the same resolver as every other SSH consumer.
preferredAuth(), written to make exactly this decision, had never run outside
its own tests, and disagreed with the resolver that did: it accepted a
'deployed' key where the live path required 'verified'. Unified on the
stricter rule and wired in.

#139 — the rollout was written before start() was attempted, so a rejected
start left the rows behind: "already running", press again, another rollout
banked each time. Devices already in an unfinished rollout are now refused by
name, before anything is persisted.

#138 — cancel worked but the API's explanation that the in-flight device is
never interrupted was discarded, so the button looked dead. It is shown.

#137 — log overfetching. /log/print returns the whole ring buffer, and the
count was incremented per attempted insert rather than per accepted row, so a
poll that stored nothing still reported "Collected 1000 new log entries" after
a thousand round trips. Inserts are batched and counted by RETURNING, and the
id watermark is the maximum over recent rows rather than the last one
inserted, which could move backwards and make the whole buffer look new.

One self-inflicted wound worth recording. The first cut of the desync guard
discarded sentences whenever `connected` was false -- but `connected` is only
set *after* login succeeds, so it threw away every login reply and took the
whole fleet offline until it was caught. "Not yet connected" and "abandoned"
are different states and now have different flags. Every existing test pre-set
connected = true, so none of them could see it; the test added for it fails
against the bad version.

Verified on hardware: devices back online, check-update 5.2s (was a 30s
failure), full sync completes, and log collection now reports 298 / 22 / 12 /
12 across successive polls -- real decreasing counts rather than a constant
1000 -- with no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:17:52 -07:00
65 changed files with 3461 additions and 262 deletions
+19 -8
View File
@@ -9,8 +9,7 @@ name: Release
on:
push:
branches: [main]
paths:
- 'frontend/package.json'
workflow_dispatch:
permissions:
contents: write
@@ -21,17 +20,26 @@ jobs:
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 2
# Full history and tags: the check below asks whether this version has
# already been released, which needs both.
fetch-depth: 0
- name: Read version, and stop if it did not change
- name: Read version, and stop if it is already released
id: v
run: |
VERSION=$(node -p "require('./frontend/package.json').version")
PREV=$(git show HEAD~1:frontend/package.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" || echo "")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "$VERSION" = "$PREV" ]; then
# Asking "does a tag for this version exist?" rather than "did the
# version change since the previous commit?".
#
# The old comparison was against HEAD~1, which silently skipped the
# release whenever a version bump was pushed together with any
# follow-up commit — the bump was no longer the tip, so nothing looked
# changed. v0.24.3 was lost exactly that way. This form does not care
# how commits are batched, and is safe to re-run.
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
echo "unchanged=true" >> "$GITHUB_OUTPUT"
echo "Version unchanged ($VERSION) — nothing to release."
echo "v$VERSION is already tagged — nothing to release."
fi
- name: Compose notes from the commit that shipped it
@@ -44,8 +52,11 @@ jobs:
# a shell command lets the shell evaluate it — which is both why the
# first attempt failed and a way to run arbitrary code in CI from a
# commit message.
# The commit that last touched the version, not necessarily the tip:
# a version bump is often followed by docs or CI commits.
SHIP=$(git log -1 --format=%H -- frontend/package.json)
{
git log -1 --pretty=%B
git log -1 --pretty=%B "$SHIP"
printf '\n---\n'
printf 'Container image: `ghcr.io/2gt-media-group-llc/mikrotik-manager:%s`\n\n' "$VERSION"
printf 'Documentation: https://2gt-media-group-llc.github.io/mikrotik-manager/\n'
+1
View File
@@ -145,3 +145,4 @@ vite.config.ts.timestamp-*
# macOS
.DS_Store
site/
.secrets-local/
+24
View File
@@ -0,0 +1,24 @@
# CLAUDE.md — MikroRMM
Antes de alterar qualquer coisa neste projeto, leia nesta ordem:
1. Este arquivo (`CLAUDE.md`)
2. `docs/SISTEMA_ATUAL.md`
3. `git status`
4. `git log -5 --oneline`
## Regras permanentes
- Buscar causa-raiz antes de aplicar qualquer correção.
- Não alterar código sem entender o fluxo afetado.
- Preservar compatibilidade DEV/PROD (mesmo `docker-compose.yml` como base).
- Nunca versionar secrets. `.env` e `.secrets-local/` ficam fora do Git.
- Mudança de schema exige migration (`backend/src/db/migrate.ts`), nunca alteração manual do banco.
- Testes focados primeiro (teste/módulo afetado), depois suíte completa como gate final.
- Smoke real (`docker compose ps` + `curl https://localhost/api/health`) quando a mudança afetar runtime.
- Atualizar `docs/SISTEMA_ATUAL.md` quando a mudança alterar arquitetura ou estado de verdade do sistema (não é changelog).
- Não executar ações destrutivas em RouterBOARD sem missão explícita autorizando.
- Não conectar clientes reais durante desenvolvimento.
- Não considerar uma missão concluída com Git sujo (`git status` deve estar limpo, salvo arquivos intencionalmente não commitados e justificados).
## Ambiente local
Ver seção 12 de `docs/SISTEMA_ATUAL.md`. Stack sobe com `docker compose up -d` a partir de `docker-compose.yml` (build local, não usar `docker-compose.ghcr.yml` em DEV).
+5 -3
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.24.0_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/version-0.24.5_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
@@ -339,8 +339,8 @@ Each network service supports multi-device management with conflict detection:
### Fleet operations
- **Firmware orchestration** — fleet version overview, live update checks, and MikroTik's official release notes in-app. Staged rollouts run in **waves** (wave 1 = canary) through a verified pipeline per device: pre-upgrade backup → install → ride out the reboot → confirm it returned healthy on the new version → next. **Halt on failure** stops the rollout if any device fails, and a device that comes back on the *old* version counts as a failure. Rollouts can be scheduled, and cancelling never interrupts an in-flight flash
- **Backups** — on demand or on a daily/weekly/monthly schedule for all online devices, downloadable from the UI. Config snapshots and their restorable `.rsc` are one artifact: delete either and both go, so they never drift apart
- **Firmware orchestration** — fleet version overview, live update checks, and MikroTik's official release notes in-app. Staged rollouts run in **waves** (wave 1 = canary) through a verified pipeline per device: pre-upgrade backup → download and confirm the image landed → reboot → prove it restarted and came back healthy on the new version → next. **Halt on failure** stops the rollout if any device fails, and a device that comes back on the *old* version counts as a failure. Devices in a wave upgrade one at a time by default; raise **at once** to run them concurrently when a fleet of five-minute reboots would otherwise take all evening — waves stay sequential either way, so the canary still gates the rest. Rollouts can be scheduled, and cancelling never interrupts an in-flight flash
- **Backups** — on demand or on a daily/weekly/monthly schedule for all online devices. Filter by device, type and date range; read one in place instead of downloading it; and compare any two as a diff — they are `/export` text, not opaque blobs. Config snapshots and their restorable `.rsc` are one artifact: delete either and both go, so they never drift apart, and a bulk delete says how many snapshots it will take with it
- **Configuration templates** — reusable sets (DNS, NTP, syslog) pushed to many devices with per-device result reporting
- **Bulk device add** — "Try All" on discovered devices runs as a server-side job that survives a closed browser tab, with live progress and cancel
- **Device organisation** — colour-coded tags, rack location, physical address with map support, and per-device notes
@@ -370,6 +370,8 @@ so it moves when behaviour does.
| [Configuration](docs/configuration.md) | Environment variables, secret management, key rotation, TLS |
| [Change Guard and Config Health](docs/change-guard.md) | How the safety system works, its settings, and what to expect when it fires |
| [Sites](docs/sites.md) | Grouping devices into sites, switching between them, and what each site scopes |
| [Firmware rollouts](docs/firmware.md) | Waves, the upgrade pipeline, and what upgrading several at once costs |
| [Backups](docs/backups.md) | Finding, reading, comparing and deleting configuration backups |
| [Alerting](docs/alerting.md) | Rules, channels, ntfy priority mapping, webhooks, maintenance windows |
| [Single sign-on (OIDC)](docs/sso-oidc.md) | Identity provider setup, group-to-role mapping, break-glass behaviour |
| [CAPsMAN](docs/capsman.md) | How centrally provisioned APs are modelled, and current limits |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.24.0-beta",
"version": "0.24.5-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+64
View File
@@ -608,6 +608,10 @@ ALTER TABLE devices ADD COLUMN IF NOT EXISTS wifi_package VARCHAR(16);
-- now meant visiting every device by hand — the exact task staged rollouts exist to
-- remove.
ALTER TABLE firmware_rollouts ADD COLUMN IF NOT EXISTS routerboot_after BOOLEAN NOT NULL DEFAULT FALSE;
-- How many devices in a wave may upgrade at once (issue #135). Default 1 keeps
-- the sequential behaviour every existing rollout was built around; raising it
-- trades canary strictness for wall-clock time, which is the operator's call.
ALTER TABLE firmware_rollouts ADD COLUMN IF NOT EXISTS wave_concurrency INTEGER NOT NULL DEFAULT 1;
-- Temporarily dismissed "Things to handle" items (github issue #102).
--
@@ -988,6 +992,41 @@ CREATE TABLE IF NOT EXISTS sites (
-- devices in it. The delete endpoint refuses while devices remain.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS site_id INTEGER REFERENCES sites(id);
CREATE INDEX IF NOT EXISTS idx_devices_site ON devices(site_id);
-- Mission 02 (SEC-001): server-side session revocation.
--
-- JWTs are stateless by design (no per-request DB write), but logout, a
-- password change, disabling a compromised account, or an admin "sign this
-- user out everywhere" action all need existing tokens to stop working
-- without waiting out the 24h expiry. token_version is the mechanism: every
-- signed JWT embeds the version current at sign time; requireAuth rejects a
-- token whose embedded version no longer matches the user's row (see
-- utils/sessionAuth.ts). Bumping the column is the "revoke" action.
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0;
-- A deactivated account must stop authenticating immediately, same path as
-- token_version -- both are checked together in one cached lookup.
ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
-- Mission 02 (SEC-002): explicit user <-> site authorization.
--
-- 'admin' is a global role and bypasses this table entirely (see
-- middleware/site.ts). Every other role must hold an explicit row here for a
-- site to be visible or writable -- no row means no access, not "unscoped".
CREATE TABLE IF NOT EXISTS user_sites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, site_id)
);
CREATE INDEX IF NOT EXISTS idx_user_sites_site ON user_sites(site_id);
-- Mission 02 (SEC-003): TLS trust-on-first-use pinning for the RouterOS
-- API-SSL connection (see services/mikrotik/tlsTrust.ts). A device's first
-- successful connection records the peer certificate's fingerprint; every
-- later connection must present the same one, or the connection is refused
-- until an admin explicitly re-trusts it (POST /api/devices/:id/tls/reset).
ALTER TABLE devices ADD COLUMN IF NOT EXISTS tls_fingerprint VARCHAR(95);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS tls_mode VARCHAR(20) NOT NULL DEFAULT 'pinned';
`;
const DEFAULT_SETTINGS = [
@@ -1080,6 +1119,31 @@ export async function runMigrations(): Promise<void> {
console.log('⚠️ Please change the default password after first login!');
}
// user_sites backfill (SEC-002 migration), run exactly once. An existing
// install had no concept of per-user site access -- every user could see
// every site. Preserve that for accounts that predate this table by
// granting every non-admin user access to every site that already exists
// at the moment this runs. Gated by a settings flag rather than "has zero
// rows" -- a user created *after* the backfill is meant to start with no
// site access until explicitly granted, and would otherwise look
// identical to a pre-existing, never-backfilled user on every later boot.
const backfillFlag = await client.query(
`SELECT 1 FROM app_settings WHERE key = 'user_sites_backfilled_v1'`
);
if (backfillFlag.rows.length === 0) {
await client.query(
`INSERT INTO user_sites (user_id, site_id)
SELECT u.id, s.id FROM users u CROSS JOIN sites s
WHERE u.role <> 'admin'
ON CONFLICT DO NOTHING`
);
await client.query(
`INSERT INTO app_settings (key, value) VALUES ('user_sites_backfilled_v1', 'true')
ON CONFLICT (key) DO NOTHING`
);
console.log('user_sites backfilled for pre-existing non-admin users');
}
console.log('Database migrations completed successfully');
} finally {
client.release();
+47 -20
View File
@@ -7,6 +7,7 @@ import morgan from 'morgan';
import { createServer } from 'http';
import { Server as SocketServer } from 'socket.io';
import { Client as SshClient } from 'ssh2';
import { resolveAuth, type SshExecDevice } from './services/sshExec';
import type { ClientChannel } from 'ssh2';
import dotenv from 'dotenv';
@@ -27,10 +28,11 @@ import {
import { netflowCollector } from './services/netflow/NetflowCollector';
import { verifyToken, type AuthPayload } from './middleware/auth';
import { rateLimitRedis } from './middleware/rateLimitRedis';
import { getSessionState } from './utils/sessionAuth';
import { userCanAccessDevice } from './middleware/site';
import { initSecrets } from './utils/secrets';
import { reencryptStaleCredentials } from './db/reencryptCredentials';
import { reconcileStaleGuards } from './services/changeGuard/ChangeGuard';
import { decrypt } from './utils/crypto';
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
import authRoutes from './routes/auth';
@@ -160,11 +162,24 @@ terminalNs.use((socket, next) => {
if (!token) return next(new Error('No token'));
try {
const payload = verifyToken(token);
if (!TERMINAL_ROLES.has(payload.role)) {
return next(new Error('Console access denied for this role'));
}
socket.data.user = payload;
next();
// SEC-001: the terminal namespace authenticates over its own handshake,
// outside the HTTP requireAuth pipeline, so it needs its own session
// revocation check -- otherwise logout/deactivate/revoke-sessions would
// not apply to an already-open or newly-opened console.
getSessionState(payload.userId)
.then((state) => {
if (!state || !state.active || (payload.tokenVersion ?? 0) !== state.tokenVersion) {
next(new Error('Session no longer valid'));
return;
}
if (!TERMINAL_ROLES.has(state.role)) {
next(new Error('Console access denied for this role'));
return;
}
socket.data.user = { ...payload, role: state.role };
next();
})
.catch(() => next(new Error('Could not verify session')));
} catch {
next(new Error('Invalid or expired token'));
}
@@ -189,23 +204,35 @@ terminalNs.on('connection', (socket) => {
socket.emit('error', 'Too many terminal sessions started. Please wait a moment and try again.');
return;
}
const device = await queryOne<{
ip_address: string;
ssh_port: number | null;
ssh_username: string | null;
ssh_password_encrypted: string | null;
}>(
`SELECT ip_address, ssh_port, ssh_username, ssh_password_encrypted FROM devices WHERE id = $1`,
// SEC-002: a restricted user can't open a shell on a device outside
// their authorized sites just by knowing/guessing its id.
if (!(await userCanAccessDevice(user, deviceId))) {
socket.emit('error', 'You do not have access to this device');
return;
}
const device = await queryOne<SshExecDevice>(
`SELECT id, name, ip_address, ssh_port, ssh_username, ssh_password_encrypted,
api_username, api_password_encrypted
FROM devices WHERE id = $1`,
[deviceId]
);
if (!device) { socket.emit('error', 'Device not found'); return; }
if (!device.ssh_username || !device.ssh_password_encrypted) {
socket.emit('error', 'No SSH credentials configured for this device. Add an SSH username and password in device settings.');
// Resolve credentials the same way every other SSH consumer does, so a
// deployed key works here too. Doing this locally, and looking only at
// the stored password, is what broke the console once a key replaced the
// password (#133).
let auth: Awaited<ReturnType<typeof resolveAuth>>;
try {
auth = await resolveAuth(device);
} catch (e) {
socket.emit('error',
`${(e as Error).message}. Add an SSH username and password in device settings, ` +
`or deploy an SSH key for this device.`);
return;
}
const password = decrypt(device.ssh_password_encrypted);
sshClient = new SshClient();
sshClient.on('ready', () => {
@@ -214,7 +241,7 @@ terminalNs.on('connection', (socket) => {
(err, stream) => {
if (err) { socket.emit('error', err.message); return; }
shellStream = stream;
auditTerminal(user, clientIp, `opened SSH shell on device ${deviceId} (${device.ip_address})`, deviceId);
auditTerminal(user, clientIp, `opened SSH shell on device ${deviceId} (${device.ip_address}) using ${auth.kind} auth`, deviceId);
socket.emit('ready');
stream.on('data', (data: Buffer) => {
@@ -238,8 +265,8 @@ terminalNs.on('connection', (socket) => {
sshClient.connect({
host: device.ip_address,
port: device.ssh_port ?? 22,
username: device.ssh_username,
password,
username: auth.username,
...auth.auth,
readyTimeout: 10_000,
algorithms: {
kex: [
@@ -0,0 +1,39 @@
/**
* Regression coverage for findings recorded in docs/AUDITORIA_BASE_2GT.md.
*
* These tests prove the *current* upstream behavior with evidence, rather than
* asserting an opinion. They are not fixes — see the audit report for
* classification, impact and recommendation of each finding.
*/
import { AuthPayload } from '../auth';
import { resolveSiteId } from '../../utils/siteScope';
// ── SEC-001: FIXED in Mission 02 ─────────────────────────────────────────────
//
// This finding ("a JWT could not be revoked before its 24h expiry") is now
// covered by dedicated regression tests in middleware/__tests__/auth.test.ts
// ("SEC-001 regression coverage": revoked-version, deactivated-account, and
// deleted-user cases), backed by utils/sessionAuth.ts and the users.token_version
// / users.active columns. See docs/AUDITORIA_BASE_2GT.md for status.
// ── SEC-002: "sites" are a display filter, not an authorization boundary ────
//
// resolveSiteId is the entire enforcement surface for site scoping: it turns
// a client-supplied header/query value into a SQL predicate. It has no
// knowledge of req.user and no lookup of which sites a user may access —
// confirmed by its signature (raw value in, id-or-null out) and by grep: no
// route file cross-references req.user with a site membership table (none
// exists in the schema). Any authenticated user (any role) can omit the
// header entirely to see every site's devices.
describe('SEC-002: site scoping has no per-user authorization component', () => {
it('resolves any syntactically valid site id with no user/session context ' +
'available to the function at all', () => {
// Note the signature: no user, no session, no membership check — just a
// string. This is the one function that decides "which fleet subset",
// and it never sees who is asking.
expect(resolveSiteId('1')).toBe(1);
expect(resolveSiteId('2')).toBe(2);
expect(resolveSiteId(undefined)).toBeNull(); // unscoped = whole fleet
expect(resolveSiteId('all')).toBeNull(); // explicit whole fleet
});
});
+80 -4
View File
@@ -1,8 +1,14 @@
jest.mock('../../config/database');
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { signToken, verifyToken, requireAuth, requireAdmin, requireWrite, AuthPayload } from '../auth';
import { queryOne } from '../../config/database';
import { _resetSessionCacheForTest } from '../../utils/sessionAuth';
const TEST_PAYLOAD: AuthPayload = { userId: 1, username: 'alice', role: 'admin' };
const mockedQueryOne = jest.mocked(queryOne);
const TEST_PAYLOAD: AuthPayload = { userId: 1, username: 'alice', role: 'admin', tokenVersion: 0 };
// Minimal Express mock helpers
function mockReq(authHeader?: string): Request {
@@ -16,6 +22,19 @@ function mockRes() {
return res;
}
/** requireAuth resolves its session check asynchronously; wait for `next`/`res.status`. */
function waitForAuth(req: Request, res: Response, next: jest.Mock): Promise<void> {
requireAuth(req, res, next as unknown as NextFunction);
return new Promise((resolve) => setImmediate(resolve));
}
beforeEach(() => {
jest.clearAllMocks();
_resetSessionCacheForTest();
// Default: session is current and active, matching TEST_PAYLOAD.
mockedQueryOne.mockResolvedValue({ token_version: 0, active: true, role: 'admin' } as never);
});
// ── signToken / verifyToken ─────────────────────────────────────────────────
describe('signToken / verifyToken', () => {
@@ -25,6 +44,7 @@ describe('signToken / verifyToken', () => {
expect(decoded.userId).toBe(TEST_PAYLOAD.userId);
expect(decoded.username).toBe(TEST_PAYLOAD.username);
expect(decoded.role).toBe(TEST_PAYLOAD.role);
expect(decoded.tokenVersion).toBe(0);
});
it('throws on an expired token', () => {
@@ -69,16 +89,72 @@ describe('requireAuth', () => {
expect(next).not.toHaveBeenCalled();
});
it('calls next and sets req.user for a valid token', () => {
it('calls next and sets req.user for a valid, current-version token', async () => {
const token = signToken(TEST_PAYLOAD);
const req = mockReq(`Bearer ${token}`);
const res = mockRes();
const next = jest.fn() as unknown as NextFunction;
requireAuth(req, res, next);
const next = jest.fn();
await waitForAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user?.userId).toBe(TEST_PAYLOAD.userId);
expect(req.user?.role).toBe('admin');
});
// ── SEC-001 regression coverage ───────────────────────────────────────────
it('rejects a token whose session was revoked (token_version bumped)', async () => {
const token = signToken(TEST_PAYLOAD); // signed with tokenVersion: 0
mockedQueryOne.mockResolvedValue({ token_version: 1, active: true, role: 'admin' } as never);
const req = mockReq(`Bearer ${token}`);
const res = mockRes();
const next = jest.fn();
await waitForAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
});
it('rejects a token for a deactivated account', async () => {
const token = signToken(TEST_PAYLOAD);
mockedQueryOne.mockResolvedValue({ token_version: 0, active: false, role: 'admin' } as never);
const req = mockReq(`Bearer ${token}`);
const res = mockRes();
const next = jest.fn();
await waitForAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
});
it('rejects a token for a user that no longer exists', async () => {
const token = signToken(TEST_PAYLOAD);
mockedQueryOne.mockResolvedValue(null as never);
const req = mockReq(`Bearer ${token}`);
const res = mockRes();
const next = jest.fn();
await waitForAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
});
it('uses the current DB role rather than the one embedded in the token', async () => {
// Token was signed while the user was an operator; the account has since
// been promoted to admin. The request must see the current role.
const staleToken = signToken({ userId: 1, username: 'alice', role: 'operator', tokenVersion: 0 });
mockedQueryOne.mockResolvedValue({ token_version: 0, active: true, role: 'admin' } as never);
const req = mockReq(`Bearer ${staleToken}`);
const res = mockRes();
const next = jest.fn();
await waitForAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user?.role).toBe('admin');
});
});
// ── requireAdmin ─────────────────────────────────────────────────────────────
@@ -0,0 +1,128 @@
jest.mock('../../config/database');
import { Request, Response, NextFunction } from 'express';
import { query, queryOne } from '../../config/database';
import {
siteContext, enforceSiteAccess, isDeviceAccessible, unauthorizedDeviceIds,
invalidateUserSiteCache, deviceIdSiteGuard,
} from '../site';
import type { AuthPayload } from '../auth';
const mockedQuery = jest.mocked(query);
const mockedQueryOne = jest.mocked(queryOne);
function mockReqRes(user: AuthPayload | undefined, siteIdHeader?: string) {
const req = {
headers: siteIdHeader !== undefined ? { 'x-site-id': siteIdHeader } : {},
query: {},
user,
} as unknown as Request;
const res = {} as Response;
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return { req, res };
}
beforeEach(() => {
jest.clearAllMocks();
invalidateUserSiteCache(1);
invalidateUserSiteCache(2);
});
describe('SEC-002: enforceSiteAccess', () => {
it('leaves admins unrestricted (siteScope mirrors the raw request)', async () => {
const { req, res } = mockReqRes({ userId: 1, username: 'admin', role: 'admin' }, '5');
siteContext(req, res, (() => {}) as NextFunction);
const next = jest.fn();
await enforceSiteAccess(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect(req.siteScope).toBe(5);
});
it('scopes a restricted user with no explicit site to their allowed sites', async () => {
mockedQuery.mockResolvedValueOnce([{ site_id: 2 }, { site_id: 3 }] as never);
const { req, res } = mockReqRes({ userId: 2, username: 'op', role: 'operator' });
siteContext(req, res, (() => {}) as NextFunction);
const next = jest.fn();
await enforceSiteAccess(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect(req.siteScope).toEqual([2, 3]);
});
it('403s a restricted user requesting a site they are not a member of (IDOR)', async () => {
mockedQuery.mockResolvedValueOnce([{ site_id: 2 }] as never); // only site 2
const { req, res } = mockReqRes({ userId: 2, username: 'op', role: 'operator' }, '99');
siteContext(req, res, (() => {}) as NextFunction);
const next = jest.fn();
await enforceSiteAccess(req, res, next as unknown as NextFunction);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('allows a restricted user requesting a site they are a member of', async () => {
mockedQuery.mockResolvedValueOnce([{ site_id: 2 }] as never);
const { req, res } = mockReqRes({ userId: 2, username: 'op', role: 'operator' }, '2');
siteContext(req, res, (() => {}) as NextFunction);
const next = jest.fn();
await enforceSiteAccess(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect(req.siteScope).toBe(2);
});
it('scopes a viewer with zero site grants to nothing, not the whole fleet', async () => {
mockedQuery.mockResolvedValueOnce([] as never);
const { req, res } = mockReqRes({ userId: 2, username: 'v', role: 'viewer' });
siteContext(req, res, (() => {}) as NextFunction);
const next = jest.fn();
await enforceSiteAccess(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect(req.siteScope).toEqual([]);
});
});
describe('SEC-002: isDeviceAccessible / deviceIdSiteGuard (cross-site device IDOR)', () => {
it('denies a restricted user reaching a device in a site they do not belong to', async () => {
const req = { siteScope: [2, 3], user: { userId: 2, role: 'operator' } } as unknown as Request;
mockedQueryOne.mockResolvedValueOnce({ site_id: 99 } as never); // device belongs to site 99
await expect(isDeviceAccessible(req, 42)).resolves.toBe(false);
});
it('allows a restricted user reaching a device in one of their sites', async () => {
const req = { siteScope: [2, 3], user: { userId: 2, role: 'operator' } } as unknown as Request;
mockedQueryOne.mockResolvedValueOnce({ site_id: 3 } as never);
await expect(isDeviceAccessible(req, 42)).resolves.toBe(true);
});
it('never restricts an admin (siteScope null)', async () => {
const req = { siteScope: null, user: { userId: 1, role: 'admin' } } as unknown as Request;
await expect(isDeviceAccessible(req, 42)).resolves.toBe(true);
expect(mockedQueryOne).not.toHaveBeenCalled();
});
it('deviceIdSiteGuard 403s the request end-to-end for a cross-site device id', async () => {
const req = { siteScope: [2] } as unknown as Request;
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as unknown as Response;
const next = jest.fn();
mockedQueryOne.mockResolvedValueOnce({ site_id: 7 } as never); // not in [2]
await deviceIdSiteGuard(req, res, next as unknown as NextFunction, '42');
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe('SEC-002: unauthorizedDeviceIds (bulk operations)', () => {
it('flags device ids outside the caller scope', async () => {
const req = { siteScope: [2] } as unknown as Request;
mockedQuery.mockResolvedValueOnce([
{ id: 1, site_id: 2 },
{ id: 2, site_id: 9 },
] as never);
await expect(unauthorizedDeviceIds(req, [1, 2])).resolves.toEqual([2]);
});
it('returns an empty list for an unrestricted (admin) scope', async () => {
const req = { siteScope: null } as unknown as Request;
await expect(unauthorizedDeviceIds(req, [1, 2, 3])).resolves.toEqual([]);
expect(mockedQuery).not.toHaveBeenCalled();
});
});
+41 -2
View File
@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
import { createHash } from 'crypto';
import { query } from '../config/database';
import { jwtSigningSecret, jwtVerifierSecrets } from '../utils/secrets';
import { getSessionState } from '../utils/sessionAuth';
export interface AuthPayload {
userId: number;
@@ -10,6 +11,13 @@ export interface AuthPayload {
role: string;
/** Set when the request authenticated with an API token instead of a session. */
tokenAuth?: boolean;
/**
* The user's token_version at the moment this JWT was signed (SEC-001).
* Absent on tokens from before this field existed -- those are treated as
* version 0, which is also where every account's counter starts, so
* pre-existing sessions keep working until the next revoking action.
*/
tokenVersion?: number;
}
declare global {
@@ -90,6 +98,8 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
const token = authHeader.slice(7);
if (token.startsWith('mtm_')) {
// API tokens are revoked by deleting the row (see routes/automation.ts);
// they carry no JWT-style version to check here.
authenticateApiToken(token)
.then((payload) => {
if (!payload) { res.status(401).json({ error: 'Invalid or expired API token' }); return; }
@@ -100,12 +110,41 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
return;
}
let payload: AuthPayload;
try {
req.user = verifyToken(token);
next();
payload = verifyToken(token);
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
return;
}
// SEC-001: reject a token whose embedded session has since been revoked
// (logout-everywhere, password change, TOTP disable, deactivation, or an
// admin ending the session) rather than trusting the signature alone until
// natural expiry. getSessionState is cached for a few seconds, so this adds
// no DB round trip on the common path.
getSessionState(payload.userId)
.then((state) => {
if (!state || !state.active) {
res.status(401).json({ error: 'Session no longer valid' });
return;
}
if ((payload.tokenVersion ?? 0) !== state.tokenVersion) {
res.status(401).json({ error: 'Session was revoked' });
return;
}
// The role in the DB is authoritative going forward -- a demotion/
// promotion takes effect on the next request rather than waiting for
// the token to expire, at the same cached-lookup cost as the version
// check above.
req.user = { ...payload, role: state.role };
next();
})
.catch(() => {
// Fail closed: if we can't confirm the session is still valid, don't
// authenticate the request.
res.status(401).json({ error: 'Could not verify session' });
});
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
+179 -8
View File
@@ -1,12 +1,20 @@
import { Request, Response, NextFunction } from 'express';
import { resolveSiteId } from '../utils/siteScope';
import { resolveSiteId, SiteScope } from '../utils/siteScope';
import { queryOne, query } from '../config/database';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
/** Active site, or null for the whole fleet (all-sites view). */
/** Raw site id requested via header/query, before authorization. */
siteId?: number | null;
/**
* The effective, authorized scope for this request (SEC-002):
* `null` = unrestricted (admin/API token), a `number` = one authorized
* site, `number[]` = every site a restricted user may see when no
* explicit site was requested. Set by enforceSiteAccess.
*/
siteScope?: SiteScope;
}
}
}
@@ -14,9 +22,11 @@ declare global {
/**
* Reads the active site from the X-Site-Id header (issue #130).
*
* An absent, empty or 'all' header means unscoped. That default is what keeps
* every pre-existing API consumer -- scripts, API tokens, the docs' curl
* examples -- behaving exactly as it did before sites existed.
* An absent, empty or 'all' header means "no explicit selection". That
* default is what keeps every pre-existing API consumer -- scripts, API
* tokens, the docs' curl examples -- behaving exactly as it did before sites
* existed, *for an admin*. For a restricted user it is narrowed to their
* accessible sites by enforceSiteAccess below, not left unscoped.
*
* A query-string ?site_id= is also honoured, for links and for anything that
* cannot set headers.
@@ -28,7 +38,168 @@ export function siteContext(req: Request, _res: Response, next: NextFunction): v
next();
}
/** Convenience accessor so routes don't repeat the `?? null`. */
export function activeSite(req: Request): number | null {
return req.siteId ?? null;
// Cached per user for a few seconds -- same trade-off as sessionAuth's cache:
// a grant/revoke of site access takes a few seconds to propagate, in exchange
// for not hitting the DB on every request of every route that scopes by site.
const SITE_CACHE_TTL_MS = 5_000;
const siteCache = new Map<number, { ids: number[]; cachedAt: number }>();
async function getUserSiteIds(userId: number): Promise<number[]> {
const cached = siteCache.get(userId);
if (cached && Date.now() - cached.cachedAt < SITE_CACHE_TTL_MS) return cached.ids;
const rows = await query<{ site_id: number }>(`SELECT site_id FROM user_sites WHERE user_id = $1`, [userId]);
const ids = rows.map((r) => r.site_id);
siteCache.set(userId, { ids, cachedAt: Date.now() });
return ids;
}
/** Test/admin-action seam: force the next read to hit the DB. */
export function invalidateUserSiteCache(userId: number): void {
siteCache.delete(userId);
}
/**
* Same authorization rule as isDeviceAccessible, usable outside an Express
* request -- the Socket.IO terminal namespace authenticates over its own
* handshake and has no `req` to hang a scope on.
*/
export async function userCanAccessDevice(
user: { userId: number; role: string; tokenAuth?: boolean }, deviceId: number
): Promise<boolean> {
if (user.role === 'admin' || user.tokenAuth) return true;
const device = await queryOne<{ site_id: number | null }>(`SELECT site_id FROM devices WHERE id = $1`, [deviceId]);
if (!device || device.site_id === null) return true; // let the caller 404, or unassigned = visible
const allowed = await getUserSiteIds(user.userId);
return allowed.includes(device.site_id);
}
/**
* Authorize the site requested by siteContext against the authenticated user
* (SEC-002). Must run after both requireAuth and siteContext.
*
* - admin and API-token callers are unrestricted (role model predates sites;
* scoping tokens is tracked as residual work, see docs/AUDITORIA_BASE_2GT.md).
* - a restricted user requesting a specific site must be a member of it, or
* the request is refused (403) -- this is what closes the IDOR: sending
* X-Site-Id for a site you don't belong to no longer works.
* - a restricted user requesting no specific site ("all") is scoped to every
* site they belong to, not the whole fleet.
*/
export async function enforceSiteAccess(req: Request, res: Response, next: NextFunction): Promise<void> {
const user = req.user;
if (!user) { res.status(401).json({ error: 'No token provided' }); return; }
if (user.role === 'admin' || user.tokenAuth) {
req.siteScope = req.siteId ?? null;
next();
return;
}
try {
const allowed = await getUserSiteIds(user.userId);
if (req.siteId !== null && req.siteId !== undefined) {
if (!allowed.includes(req.siteId)) {
res.status(403).json({ error: 'You do not have access to this site' });
return;
}
req.siteScope = req.siteId;
} else {
req.siteScope = allowed; // may be [] -- "no access to anything", not "all"
}
next();
} catch (e) {
res.status(500).json({ error: `Could not resolve site access: ${(e as Error).message}` });
}
}
/** Convenience accessor so routes don't repeat the optional chaining. */
export function activeSite(req: Request): SiteScope {
return req.siteScope ?? null;
}
/**
* Direct-by-id guard for routes keyed on a specific device (SEC-002).
*
* Meant to be wired with `router.param('id', ...)` (or called explicitly for
* a body/query-supplied deviceId, e.g. bulk commands and the terminal) so a
* restricted user can't reach a device outside their sites by simply knowing
* or guessing its numeric id -- the classic IDOR this mission calls out.
*
* Returns true if access is allowed (or the device doesn't exist -- the
* handler's own 404 is the right response, not a 403 that would confirm the
* id exists in someone else's site).
*/
export async function isDeviceAccessible(req: Request, deviceId: number): Promise<boolean> {
const scope = activeSite(req);
if (scope === null) return true; // unrestricted (admin/token), or explicit single site already checked below
const device = await queryOne<{ site_id: number | null }>(`SELECT site_id FROM devices WHERE id = $1`, [deviceId]);
if (!device) return true; // let the handler 404
if (device.site_id === null) return true; // unassigned devices are visible to everyone (should not occur post-migration)
return Array.isArray(scope) ? scope.includes(device.site_id) : scope === device.site_id;
}
/**
* Express `router.param('id', ...)` handler that 403s a request for a device
* id outside the caller's authorized sites. Mount with:
* router.param('id', deviceIdSiteGuard);
* on any router whose `:id` is a device id.
*/
export async function deviceIdSiteGuard(
req: Request, res: Response, next: NextFunction, idParam: string
): Promise<void> {
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) { next(); return; } // let the handler reject the malformed id
try {
if (!(await isDeviceAccessible(req, id))) {
res.status(403).json({ error: 'You do not have access to this device' });
return;
}
next();
} catch (e) {
res.status(500).json({ error: `Could not resolve device access: ${(e as Error).message}` });
}
}
/**
* Bulk-operation guard (SEC-002): given a list of device ids supplied in a
* request body (commands, bulk backup/sync, the terminal's single deviceId),
* returns the subset the caller is NOT authorized for. An empty result means
* every id is in scope. Used where router.param can't help because the ids
* don't come from the URL.
*/
export async function unauthorizedDeviceIds(req: Request, deviceIds: number[]): Promise<number[]> {
const scope = activeSite(req);
if (scope === null || deviceIds.length === 0) return [];
const rows = await query<{ id: number; site_id: number | null }>(
`SELECT id, site_id FROM devices WHERE id = ANY($1::int[])`, [deviceIds]
);
const allowed = new Set(Array.isArray(scope) ? scope : [scope]);
return rows.filter((r) => r.site_id !== null && !allowed.has(r.site_id)).map((r) => r.id);
}
/**
* Same idea as deviceIdSiteGuard, for a route param that identifies a *row*
* belonging to a device rather than the device itself (a backup, a config
* snapshot, a command run...). `resolveDeviceId` looks up that row's owning
* device id; returning null (row not found) lets the handler's own 404 win,
* same reasoning as isDeviceAccessible.
*/
export function ownedByDeviceSiteGuard(
resolveDeviceId: (rowId: number) => Promise<number | null>
): (req: Request, res: Response, next: NextFunction, idParam: string) => Promise<void> {
return async (req, res, next, idParam) => {
const rowId = parseInt(idParam, 10);
if (Number.isNaN(rowId)) { next(); return; }
try {
const deviceId = await resolveDeviceId(rowId);
if (deviceId === null) { next(); return; } // handler will 404
if (!(await isDeviceAccessible(req, deviceId))) {
res.status(403).json({ error: 'You do not have access to this resource' });
return;
}
next();
} catch (e) {
res.status(500).json({ error: `Could not resolve site access: ${(e as Error).message}` });
}
};
}
+30 -9
View File
@@ -7,6 +7,7 @@ import { signToken, signRawToken, verifyRawToken, requireAuth } from '../middlew
import { getSecretsInfo } from '../utils/secrets';
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
import { validatePassword } from '../utils/passwordPolicy';
import { bumpTokenVersion } from '../utils/sessionAuth';
const router = Router();
@@ -50,9 +51,14 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
password_hash: string;
role: string;
totp_enabled: boolean;
}>(`SELECT id, username, password_hash, role, totp_enabled FROM users WHERE username = $1`, [username]);
active: boolean;
token_version: number;
}>(`SELECT id, username, password_hash, role, totp_enabled, active, token_version FROM users WHERE username = $1`, [username]);
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
// Same generic error for "no such user", "wrong password" and "deactivated"
// -- distinguishing them would leak which usernames exist and which are
// disabled.
if (!user || !user.active || !(await bcrypt.compare(password, user.password_hash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -61,7 +67,7 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
return res.json({ requires_totp: true, totp_token: totpToken });
}
const token = signToken({ userId: user.id, username: user.username, role: user.role });
const token = signToken({ userId: user.id, username: user.username, role: user.role, tokenVersion: user.token_version });
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
});
@@ -81,11 +87,14 @@ router.post('/totp/verify', rateLimitRedis({ windowSec: 60, max: 5, keyPrefix: '
return res.status(401).json({ error: 'Invalid token type' });
}
const user = await queryOne<{ id: number; username: string; role: string; totp_secret: string | null; totp_enabled: boolean }>(
`SELECT id, username, role, totp_secret, totp_enabled FROM users WHERE id = $1`,
const user = await queryOne<{
id: number; username: string; role: string; totp_secret: string | null; totp_enabled: boolean;
active: boolean; token_version: number;
}>(
`SELECT id, username, role, totp_secret, totp_enabled, active, token_version FROM users WHERE id = $1`,
[payload.userId]
);
if (!user || !user.totp_enabled || !user.totp_secret) {
if (!user || !user.active || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'TOTP not configured' });
}
@@ -95,7 +104,7 @@ router.post('/totp/verify', rateLimitRedis({ windowSec: 60, max: 5, keyPrefix: '
return res.status(401).json({ error: 'Invalid TOTP code' });
}
const token = signToken({ userId: user.id, username: user.username, role: user.role });
const token = signToken({ userId: user.id, username: user.username, role: user.role, tokenVersion: user.token_version });
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
});
@@ -165,6 +174,8 @@ router.post('/totp/disable', requireAuth, async (req: Request, res: Response) =>
}
await query(`UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = $1`, [req.user!.userId]);
// SEC-001: disabling 2FA is a security-relevant auth change; force re-auth.
await bumpTokenVersion(req.user!.userId);
return res.json({ ok: true });
});
@@ -181,8 +192,14 @@ router.get('/me', requireAuth, (req: Request, res: Response) => {
res.json({ user: req.user });
});
router.post('/logout', requireAuth, (_req: Request, res: Response) => {
// JWT is stateless; client just discards token
// Logout revokes the session server-side (SEC-001) by bumping token_version,
// rather than relying on the client to discard the token. This invalidates
// every token issued for the account, not just the one presented here --
// there is no per-device/per-tab session identity to revoke selectively, and
// "sign out everywhere" is an acceptable, documented trade for the added
// safety (see docs/AUDITORIA_BASE_2GT.md, SEC-001).
router.post('/logout', requireAuth, async (req: Request, res: Response) => {
await bumpTokenVersion(req.user!.userId);
res.json({ message: 'Logged out' });
});
@@ -207,6 +224,10 @@ router.put('/password', requireAuth, async (req: Request, res: Response) => {
const hash = await bcrypt.hash(newPassword, 12);
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, req.user!.userId]);
// SEC-001: a password change is exactly the moment a session might be
// compromised -- kill every existing token immediately rather than let
// them ride out their 24h expiry.
await bumpTokenVersion(req.user!.userId);
return res.json({ message: 'Password updated' });
});
+204 -18
View File
@@ -4,35 +4,149 @@ import * as path from 'path';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess, ownedByDeviceSiteGuard } from '../middleware/site';
import { BackupService } from '../services/BackupService';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// SEC-002: :id/:fromId/:toId here are backup ids, not device ids -- resolve
// each to its owning device before letting a handler run.
const backupSiteGuard = ownedByDeviceSiteGuard(async (backupId) => {
const row = await queryOne<{ device_id: number }>(`SELECT device_id FROM backups WHERE id = $1`, [backupId]);
return row?.device_id ?? null;
});
router.param('id', backupSiteGuard);
router.param('fromId', backupSiteGuard);
router.param('toId', backupSiteGuard);
const backupService = new BackupService();
// GET /api/backups
router.get('/', async (req: Request, res: Response) => {
const { deviceId } = req.query;
let sql = `
SELECT b.*, d.name as device_name
FROM backups b JOIN devices d ON d.id = b.device_id
WHERE 1=1
`;
const params: unknown[] = [];
if (deviceId) {
sql += ` AND b.device_id = $1`;
params.push(deviceId);
}
const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
if (siteFilter) sql += ` AND ${siteFilter}`;
sql += ' ORDER BY b.created_at DESC';
/** Preview reads the file into memory, so it needs a ceiling. */
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
/** A bulk delete is irreversible; keep one click from reaching an unbounded set. */
const MAX_BULK_DELETE = 200;
const backups = await query(sql, params);
// GET /api/backups — filter by device, type, date range and free text (#134)
router.get('/', async (req: Request, res: Response) => {
const { deviceId, type, from, to, search } = req.query as Record<string, string | undefined>;
const filters: string[] = [];
const params: unknown[] = [];
/**
* Bind a value and return its placeholder.
*
* Derived from params.length rather than a hand-kept counter: with a counter,
* the last filter's increment is dead and whoever adds the next one inherits a
* stale index. This cannot get out of step.
*/
const bind = (v: unknown): string => { params.push(v); return `$${params.length}`; };
if (deviceId) filters.push(`b.device_id = ${bind(deviceId)}`);
if (type) filters.push(`b.backup_type = ${bind(type)}`);
// Dates are inclusive at both ends: a picker showing "to: 3 Sep" should include
// everything that happened on the 3rd, not stop at its first instant.
if (from) filters.push(`b.created_at >= ${bind(from)}`);
if (to) filters.push(`b.created_at < (${bind(to)}::date + INTERVAL '1 day')`);
if (search) {
const p = bind(`%${search}%`);
filters.push(`(b.filename ILIKE ${p} OR b.notes ILIKE ${p} OR d.name ILIKE ${p})`);
}
const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
if (siteFilter) filters.push(siteFilter);
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const backups = await query(
`SELECT b.*, d.name as device_name
FROM backups b JOIN devices d ON d.id = b.device_id
${where}
ORDER BY b.created_at DESC`,
params
);
res.json(backups);
});
// GET /api/backups/types — the backup types actually present, for the filter
router.get('/types', async (req: Request, res: Response) => {
const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
const rows = await query<{ backup_type: string; n: string }>(
`SELECT b.backup_type, COUNT(*)::text AS n
FROM backups b
${siteFilter ? `WHERE ${siteFilter}` : ''}
GROUP BY b.backup_type ORDER BY b.backup_type`
);
res.json(rows.map((r) => ({ type: r.backup_type, count: parseInt(r.n, 10) })));
});
/**
* POST /api/backups/bulk-delete
*
* Deleting a backup cascades to the config snapshot that shares it — they are
* deliberately one artifact so they cannot drift apart. Doing that fifty times
* from one click deserves to say so, so the count of snapshots that will go with
* them is reported here and shown in the confirmation.
*/
router.post('/bulk-delete', requireWrite, async (req: Request, res: Response) => {
const { ids } = req.body as { ids?: unknown };
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: 'ids array is required' });
}
const clean = ids.map((v) => parseInt(String(v), 10)).filter(Number.isInteger);
if (clean.length === 0) return res.status(400).json({ error: 'ids array is required' });
if (clean.length > MAX_BULK_DELETE) {
return res.status(400).json({ error: `Cannot delete more than ${MAX_BULK_DELETE} backups at once` });
}
// Only ever touch backups in the active site, so a bulk action cannot reach
// across into another site's history.
const siteFilter = siteScopeByDevice(activeSite(req), 'device_id');
const allowed = await query<{ id: number }>(
`SELECT id FROM backups WHERE id = ANY($1::int[]) ${siteFilter ? `AND ${siteFilter}` : ''}`,
[clean]
);
const allowedIds = allowed.map((r) => r.id);
if (allowedIds.length === 0) return res.status(404).json({ error: 'No matching backups' });
let deleted = 0;
const failures: { id: number; error: string }[] = [];
for (const id of allowedIds) {
try { await backupService.deleteBackup(id); deleted++; }
catch (e) { failures.push({ id, error: (e as Error).message }); }
}
return res.json({ deleted, requested: clean.length, failures });
});
// GET /api/backups/bulk-delete/preview?ids=1,2,3 — what a bulk delete would remove
router.get('/bulk-delete/preview', async (req: Request, res: Response) => {
const ids = String(req.query.ids || '')
.split(',').map((v) => parseInt(v, 10)).filter(Number.isInteger);
if (ids.length === 0) return res.json({ backups: 0, snapshots: 0, devices: [] });
const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
const rows = await query<{ n: string; snapshots: string }>(
`SELECT COUNT(*)::text AS n,
COUNT(dc.id)::text AS snapshots
FROM backups b
LEFT JOIN device_configs dc ON dc.backup_id = b.id
WHERE b.id = ANY($1::int[]) ${siteFilter ? `AND ${siteFilter}` : ''}`,
[ids]
);
const devices = await query<{ name: string; n: string }>(
`SELECT d.name, COUNT(*)::text AS n
FROM backups b JOIN devices d ON d.id = b.device_id
WHERE b.id = ANY($1::int[]) ${siteFilter ? `AND ${siteFilter}` : ''}
GROUP BY d.name ORDER BY d.name`,
[ids]
);
return res.json({
backups: parseInt(rows[0]?.n || '0', 10),
snapshots: parseInt(rows[0]?.snapshots || '0', 10),
devices: devices.map((d) => ({ name: d.name, count: parseInt(d.n, 10) })),
});
});
// POST /api/backups
router.post('/', requireWrite, async (req: Request, res: Response) => {
const { deviceId, notes } = req.body;
@@ -93,4 +207,76 @@ router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
return res.json({ message: 'Backup deleted' });
});
/**
* Read a backup's text.
*
* Backups are `/export compact` output, i.e. plain RouterOS script — not the
* binary `.backup` blob — so they can be read in place instead of downloaded
* and opened elsewhere (#134).
*/
async function readBackupText(id: string | number): Promise<
{ meta: BackupMeta; text: string } | { error: string; status: number }
> {
const meta = await queryOne<BackupMeta>(
`SELECT b.id, b.filename, b.size_bytes, b.backup_type, b.notes, b.created_at,
b.file_path, b.device_id, d.name AS device_name
FROM backups b JOIN devices d ON d.id = b.device_id
WHERE b.id = $1`,
[id]
);
if (!meta) return { error: 'Backup not found', status: 404 };
if (!fs.existsSync(meta.file_path)) {
return { error: 'Backup file not found on disk', status: 404 };
}
const stat = fs.statSync(meta.file_path);
if (stat.size > MAX_PREVIEW_BYTES) {
return {
error: `This backup is ${Math.round(stat.size / 1024)} KB, too large to preview. Download it instead.`,
status: 413,
};
}
return { meta, text: fs.readFileSync(meta.file_path, 'utf8') };
}
interface BackupMeta {
id: number;
filename: string;
size_bytes: number | null;
backup_type: string | null;
notes: string | null;
created_at: string;
file_path: string;
device_id: number;
device_name: string;
}
// GET /api/backups/:id/content — preview in place
router.get('/:id/content', async (req: Request, res: Response) => {
const result = await readBackupText(req.params.id);
if ('error' in result) return res.status(result.status).json({ error: result.error });
const { file_path: _omit, ...meta } = result.meta;
return res.json({ ...meta, content: result.text });
});
// GET /api/backups/:fromId/diff/:toId — compare two backups
// Mirrors the config-history diff contract: both texts are returned and the
// browser renders them with the same lineDiff used for config snapshots.
router.get('/:fromId/diff/:toId', async (req: Request, res: Response) => {
const [from, to] = await Promise.all([
readBackupText(req.params.fromId),
readBackupText(req.params.toId),
]);
for (const r of [from, to]) {
if ('error' in r) return res.status(r.status).json({ error: r.error });
}
const f = from as { meta: BackupMeta; text: string };
const t = to as { meta: BackupMeta; text: string };
return res.json({
from: { id: f.meta.id, filename: f.meta.filename, device_name: f.meta.device_name,
created_at: f.meta.created_at, text: f.text },
to: { id: t.meta.id, filename: t.meta.filename, device_name: t.meta.device_name,
created_at: t.meta.created_at, text: t.text },
});
});
export default router;
+2 -1
View File
@@ -4,7 +4,7 @@ import { query } from '../config/database';
import { logSafe } from '../utils/logSafe';
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
import { siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { PollerService } from '../services/PollerService';
import { getQueryApi, bucket } from '../config/influxdb';
import { fingerprintClient, DEVICE_CATEGORIES, DeviceCategory } from '../utils/clientFingerprint';
@@ -23,6 +23,7 @@ function withCategory<T extends Record<string, unknown>>(row: T): T & { device_c
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
let pollerService: PollerService | null = null;
export function setPollerService(p: PollerService): void { pollerService = p; }
+10 -1
View File
@@ -9,12 +9,13 @@ import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess, unauthorizedDeviceIds } from '../middleware/site';
import { query, queryOne } from '../config/database';
import { commandRunner } from '../services/CommandRunner';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
/** Commands that change how a device is reached, and so deserve a warning. */
const RISKY_PATTERNS: { pattern: RegExp; why: string }[] = [
@@ -46,6 +47,10 @@ router.post('/preview', async (req: Request, res: Response) => {
if (!Array.isArray(device_ids) || device_ids.length === 0) {
return res.status(400).json({ error: 'device_ids array is required' });
}
const forbidden = await unauthorizedDeviceIds(req, device_ids);
if (forbidden.length) {
return res.status(403).json({ error: 'One or more devices are outside your authorized sites', device_ids: forbidden });
}
const size = Math.max(1, Math.min(50, Number(wave_size) || 1));
const devices = await query<{ id: number; name: string; ip_address: string; status: string }>(
@@ -85,6 +90,10 @@ router.post('/runs', requireWrite, async (req: Request, res: Response) => {
if (!Array.isArray(device_ids) || device_ids.length === 0) {
return res.status(400).json({ error: 'device_ids array is required' });
}
const forbidden = await unauthorizedDeviceIds(req, device_ids);
if (forbidden.length) {
return res.status(403).json({ error: 'One or more devices are outside your authorized sites', device_ids: forbidden });
}
const size = Math.max(1, Math.min(50, Number(wave_size) || 1));
const run = await queryOne<{ id: number }>(
+3
View File
@@ -1,11 +1,14 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { enforceSiteAccess, deviceIdSiteGuard } from '../middleware/site';
import { BackupService } from '../services/BackupService';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
router.param('deviceId', deviceIdSiteGuard);
const backupService = new BackupService();
+5
View File
@@ -3,6 +3,7 @@ import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth';
import { encrypt } from '../utils/crypto';
import { rateLimitRedis } from '../middleware/rateLimitRedis';
import { validateRouterOsUsername } from '../utils/routerOsUsername';
const router = Router();
router.use(requireAuth);
@@ -106,6 +107,8 @@ router.post('/', requireAdmin, presetMutationLimiter, async (req: Request, res:
.status(400)
.json({ error: 'name, api_username, and api_password are required' });
}
const sshUsernameError = validateRouterOsUsername(ssh_username);
if (sshUsernameError) return res.status(400).json({ error: sshUsernameError });
const existing = await queryOne<{ id: number }>(
`SELECT id FROM credential_presets WHERE name = $1`,
@@ -184,6 +187,8 @@ router.put('/:id', requireAdmin, presetMutationLimiter, async (req: Request, res
);
if (clash) return res.status(409).json({ error: 'A preset with this name already exists' });
}
const sshUsernameError = validateRouterOsUsername(ssh_username);
if (sshUsernameError) return res.status(400).json({ error: sshUsernameError });
const newApiPass = api_password ? encrypt(api_password) : existing.api_password_encrypted;
let newSshPass: string | null = existing.ssh_password_encrypted;
+18 -5
View File
@@ -2,7 +2,8 @@ import { Router, Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { Client as SshClient } from 'ssh2';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { requireAuth, requireWrite, requireAdmin } from '../middleware/auth';
import { deviceIdSiteGuard } from '../middleware/site';
import { encrypt, decrypt } from '../utils/crypto';
import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
@@ -12,6 +13,7 @@ import { createDeviceFromBody, type CreateDeviceInput, type CreateDeviceContext
import { parsePort } from '../utils/parsePort';
import { safeConnectionError } from '../utils/safeClientError';
import { detectLockoutRisk } from '../utils/firewallSafety';
import { validateRouterOsUsername } from '../utils/routerOsUsername';
import { withSafeApply, probeCapability, probeUndoCapability, type GuardDevice } from '../services/changeGuard/ChangeGuard';
import { captureSnapshot, resolveManagementPath } from '../services/changeGuard/pathModel';
import { analyzeChange, type PlannedChange } from '../services/changeGuard/analyzeChange';
@@ -22,8 +24,8 @@ import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAdd
import { logSafe } from '../utils/logSafe';
import { DEVICE_BASE_COLUMNS } from '../services/deviceColumns';
import { buildSegments, summarise, summariseBands } from '../utils/lteDwell';
import { siteScopeDevices, andSite } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { siteScopeDevices, andSite, singleSiteOrNull } from '../utils/siteScope';
import { activeSite, enforceSiteAccess } from '../middleware/site';
/** Postgres interval text to milliseconds, for the few windows we offer. */
function intervalToMs(interval: string): number {
@@ -70,6 +72,12 @@ async function loadCredentialPreset(
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// SEC-002: every :id in this router is a device id -- refuse it up front if
// the device belongs to a site the caller isn't authorized for, before any
// handler runs. Closes the IDOR of reaching another site's device by guessing
// or enumerating its numeric id.
router.param('id', deviceIdSiteGuard);
let pollerService: PollerService | null = null;
export function setPollerService(p: PollerService): void {
@@ -285,7 +293,7 @@ router.post('/', requireWrite, async (req: Request, res: Response) => {
const result = await createDeviceFromBody(req.body, pollerService, {
requestingUserRole: req.user?.role,
// A device added while viewing a site joins that site (issue #130).
siteId: activeSite(req),
siteId: singleSiteOrNull(activeSite(req)),
});
return res.status(result.status).json(result.body);
});
@@ -316,7 +324,7 @@ router.post('/bulk-add/jobs', requireWrite, async (req: Request, res: Response)
BULK_ADD_META_TTL_SEC
);
await redis.set(`device-bulk-add:${jobId}:results`, '[]', 'EX', BULK_ADD_META_TTL_SEC);
await enqueueBulkAddJob(jobId, items as CreateDeviceInput[], activeSite(req));
await enqueueBulkAddJob(jobId, items as CreateDeviceInput[], singleSiteOrNull(activeSite(req)));
return res.status(202).json({ job_id: jobId, total: items.length });
});
@@ -450,6 +458,11 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => {
const ssh_username = preset ? preset.ssh_username : req.body.ssh_username;
const ssh_password = preset ? preset.ssh_password : req.body.ssh_password;
// SEC-006: reject an unsafe ssh_username before it can ever reach the
// RouterOS CLI command SshKeyService builds from it.
const sshUsernameError = validateRouterOsUsername(ssh_username);
if (sshUsernameError) return res.status(400).json({ error: sshUsernameError });
// If the user is changing the IP (or port / username / password), verify the
// RouterOS API is still reachable with the new values before persisting —
// otherwise we could silently brick the device record.
+2 -1
View File
@@ -2,10 +2,11 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeByNullableDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// GET /api/events
/**
+90 -6
View File
@@ -2,12 +2,15 @@ import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { firmwareOrchestrator } from '../services/FirmwareOrchestrator';
import { clampConcurrency, MAX_WAVE_CONCURRENCY } from '../utils/concurrency';
import { findUpstreamWithinSelection } from '../utils/rolloutTopology';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// GET /api/firmware/overview — fleet versions + latest rollout
router.get('/overview', async (req: Request, res: Response) => {
@@ -64,9 +67,13 @@ router.post('/check-all', requireWrite, async (req: Request, res: Response) => {
// POST /api/firmware/rollouts — create a rollout (optionally scheduled)
router.post('/rollouts', requireWrite, async (req: Request, res: Response) => {
const { name, halt_on_failure, pre_backup, routerboot_after, scheduled_at, devices, start } = req.body as {
const {
name, halt_on_failure, pre_backup, routerboot_after, scheduled_at, devices, start,
wave_concurrency,
} = req.body as {
name?: string; halt_on_failure?: boolean; pre_backup?: boolean; routerboot_after?: boolean; scheduled_at?: string | null;
devices?: { device_id: number; wave: number }[]; start?: boolean;
wave_concurrency?: number;
};
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
if (!Array.isArray(devices) || devices.length === 0) return res.status(400).json({ error: 'devices array is required' });
@@ -77,10 +84,46 @@ router.post('/rollouts', requireWrite, async (req: Request, res: Response) => {
}
if (scheduled_at && isNaN(Date.parse(scheduled_at))) return res.status(400).json({ error: 'scheduled_at must be a valid timestamp' });
// Refuse before creating anything (#139). The rollout used to be written
// first and started second, so a rejected start left the rows behind: the
// user saw "already running", pressed again, and each press quietly banked
// another rollout for the same devices. Nothing is persisted unless the
// request can actually be honoured.
const busy = await query<{ device_id: number; name: string; rollout_id: number }>(
`SELECT frd.device_id, d.name, frd.rollout_id
FROM firmware_rollout_devices frd
JOIN firmware_rollouts r ON r.id = frd.rollout_id
JOIN devices d ON d.id = frd.device_id
WHERE frd.device_id = ANY($1::int[])
AND r.status IN ('pending','running')
AND frd.status NOT IN ('success','failed','skipped')`,
[devices.map((d) => d.device_id)]
);
if (busy.length > 0) {
const names = [...new Set(busy.map((b) => b.name))];
return res.status(409).json({
error:
`${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} already part of ` +
`rollout #${busy[0].rollout_id}, which has not finished. ` +
`Wait for it to complete or cancel it first.`,
conflicting_rollout_id: busy[0].rollout_id,
device_ids: [...new Set(busy.map((b) => b.device_id))],
});
}
// An immediate start needs the orchestrator free; a scheduled one does not.
if (start && !scheduled_at && firmwareOrchestrator.running) {
return res.status(409).json({
error: `Rollout #${firmwareOrchestrator.running} is still running. ` +
`Wait for it to finish, or schedule this one for later.`,
conflicting_rollout_id: firmwareOrchestrator.running,
});
}
const rollout = await queryOne<{ id: number }>(
`INSERT INTO firmware_rollouts (name, halt_on_failure, pre_backup, routerboot_after, scheduled_at)
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
[name.trim().slice(0, 100), halt_on_failure !== false, pre_backup !== false, routerboot_after === true, scheduled_at || null]);
`INSERT INTO firmware_rollouts (name, halt_on_failure, pre_backup, routerboot_after, scheduled_at, wave_concurrency)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[name.trim().slice(0, 100), halt_on_failure !== false, pre_backup !== false, routerboot_after === true,
scheduled_at || null, clampConcurrency(wave_concurrency ?? 1)]);
for (const d of devices) {
await query(
`INSERT INTO firmware_rollout_devices (rollout_id, device_id, wave) VALUES ($1,$2,$3)`,
@@ -89,11 +132,52 @@ router.post('/rollouts', requireWrite, async (req: Request, res: Response) => {
if (start && !scheduled_at) {
try { await firmwareOrchestrator.start(rollout!.id); }
catch (e) { return res.status(409).json({ error: (e as Error).message, id: rollout!.id }); }
catch (e) {
// Lost a race for the orchestrator. Remove the rollout we just created
// rather than leaving an unstartable shell behind.
await query(`DELETE FROM firmware_rollouts WHERE id = $1`, [rollout!.id]).catch(() => {});
return res.status(409).json({ error: (e as Error).message });
}
}
res.status(201).json({ id: rollout!.id });
});
/**
* GET /api/firmware/rollouts/upstream-check?ids=1,2,3
*
* Which of the selected devices sit upstream of the others (#135).
*
* Running a wave concurrently means several devices reboot together, and a
* switch that carries the uplink to the rest takes them with it -- the manager
* then cannot reach the devices it is mid-upgrade on. Change Guard does not
* cover this: it protects configuration, not the path back to the device.
*
* Advisory only. It warns, it does not refuse: the operator may know the
* topology better than the discovered links do.
*/
router.get('/rollouts/upstream-check', async (req: Request, res: Response) => {
const ids = String(req.query.ids || '')
.split(',').map((v) => parseInt(v, 10)).filter(Number.isInteger);
if (ids.length < 2) return res.json({ upstream: [] });
const links = await query<{
from_device_id: number; to_device_id: number | null;
from_interface: string | null; stp_role: string | null;
}>(
`SELECT from_device_id, to_device_id, from_interface, stp_role
FROM topology_links WHERE to_device_id IS NOT NULL`
);
const names = await query<{ id: number; name: string }>(
`SELECT id, name FROM devices WHERE id = ANY($1::int[])`, [ids]
);
const nameById = new Map(names.map((n) => [n.id, n.name]));
const upstream = findUpstreamWithinSelection(ids, links).map((id) => ({
id, name: nameById.get(id) ?? `Device ${id}`,
}));
return res.json({ upstream, maxConcurrency: MAX_WAVE_CONCURRENCY });
});
// GET /api/firmware/rollouts — recent rollouts with progress counts
router.get('/rollouts', async (req: Request, res: Response) => {
// A rollout is shown when it touched at least one device in the active site.
+44 -28
View File
@@ -2,11 +2,13 @@ import { Router, Request, Response } from 'express';
import { getQueryApi, bucket } from '../config/influxdb';
import { query } from '../config/database';
import { requireAuth } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice, clientSeriesTag } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice, clientSeriesTag, singleSiteOrNull } from '../utils/siteScope';
import { activeSite, enforceSiteAccess, deviceIdSiteGuard } from '../middleware/site';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
router.param('deviceId', deviceIdSiteGuard);
function rangeToFlux(range: string): string {
const allowed = ['1h', '3h', '6h', '12h', '24h', '7d', '30d'];
@@ -25,7 +27,15 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
// the global series there is not a shortcut -- it keeps the chart's full
// history, which the per-site series only accumulates from the upgrade
// onward. That covers every single-site install.
const siteId = activeSite(req);
// clientSeriesTag needs one concrete site id or the true fleet-wide case.
// SEC-002: a restricted user viewing several sites at once has neither --
// there is no single dedup series for "these particular sites", and
// falling back to `_global` would hand them the whole fleet's count. Such a
// scope skips the dedup series entirely and goes straight to the per-device
// fallback below, filtered to exactly the devices they may see.
const scope = activeSite(req);
const siteId = singleSiteOrNull(scope);
const restrictedToMultipleSites = Array.isArray(scope);
let seriesTag = '_global';
if (siteId !== null) {
const counts = await query<{ in_site: number; total: number }>(
@@ -40,29 +50,31 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
// Use the global deduplicated metric (_global tag) written by DeviceCollector.
// This avoids double-counting clients seen by multiple devices simultaneously.
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_counts")
|> filter(fn: (r) => r._field == "total_clients")
|> filter(fn: (r) => r.device_id == "${seriesTag}")
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|> yield(name: "clients_over_time")
`;
const rawPoints: { time: string; value: number }[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
rawPoints.push({ time, value: Math.round(value) });
});
} catch {
// InfluxDB might not have data yet
if (!restrictedToMultipleSites) {
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_counts")
|> filter(fn: (r) => r._field == "total_clients")
|> filter(fn: (r) => r.device_id == "${seriesTag}")
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|> yield(name: "clients_over_time")
`;
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
rawPoints.push({ time, value: Math.round(value) });
});
} catch {
// InfluxDB might not have data yet
}
}
// If no deduplicated series exists yet (first run, or a site whose history
// predates the per-site series), fall back to max across real devices. max()
// predates the per-site series), or the caller is scoped to several sites
// at once, fall back to max across the exact devices they may see. max()
// is better than sum() since the device with the most clients is the gateway
// that sees everyone — summing would double-count clients visible from
// multiple devices.
@@ -72,12 +84,16 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
// it matters: without it the fleet-wide fallback would fold the per-site
// totals back in and count everyone twice.
let deviceFilter: string | null = `|> filter(fn: (r) => not (r.device_id =~ /^_/))`;
if (siteId !== null) {
const ids = await query<{ id: number }>(
`SELECT id FROM devices WHERE site_id = $1`, [siteId]
);
// An empty site has no series to fall back to, and must show an empty
// chart rather than the fleet's (issue #130).
if (scope !== null) {
const scopePredicate = Array.isArray(scope) ? scope : [scope];
const ids = scopePredicate.length
? await query<{ id: number }>(
`SELECT id FROM devices WHERE site_id = ANY($1::int[])`, [scopePredicate]
)
: [];
// An empty site (or a viewer authorized for zero sites) has no series
// to fall back to, and must show an empty chart rather than the fleet's
// (issue #130 / SEC-002).
deviceFilter = ids.length
? `|> filter(fn: (r) => ${ids.map((d) => `r.device_id == "${d.id}"`).join(' or ')})`
: null;
+2 -1
View File
@@ -2,12 +2,13 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { netflowCollector } from '../services/netflow/NetflowCollector';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// ─── Helper ───────────────────────────────────────────────────────────────────
+9 -1
View File
@@ -10,6 +10,7 @@
import { Router, Request, Response } from 'express';
import { Issuer } from 'openid-client';
import { requireAuth, requireAdmin, signToken } from '../middleware/auth';
import { queryOne } from '../config/database';
import { rateLimitRedis } from '../middleware/rateLimitRedis';
import {
loadOidcConfig, saveOidcConfig, maskedConfig, APP_ROLES, type AppRole, type OidcConfig,
@@ -65,7 +66,14 @@ router.get(
const params = req.query as Record<string, string>;
if (params.error) throw new Error(params.error_description || params.error);
const { user, returnTo } = await completeLogin(params);
const token = signToken({ userId: user.id, username: user.username, role: user.role });
// SEC-001: SSO users go through the same revocable-session mechanism as
// local accounts -- embed the current token_version so a "revoke
// sessions" action or deactivation applies to them too.
const current = await queryOne<{ token_version: number; active: boolean }>(
`SELECT token_version, active FROM users WHERE id = $1`, [user.id]
);
if (!current || !current.active) throw new Error('Account is deactivated');
const token = signToken({ userId: user.id, username: user.username, role: user.role, tokenVersion: current.token_version });
const dest = safeReturnTo(returnTo);
// Hand the token to the SPA via fragment (never sent to the server/logs).
res.redirect(`/auth/callback#token=${encodeURIComponent(token)}&returnTo=${encodeURIComponent(dest)}`);
+5 -4
View File
@@ -2,13 +2,14 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { getQueryApi } from '../config/influxdb';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice, singleSiteOrNull, SiteScope } from '../utils/siteScope';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { BackupService, BackupDevice } from '../services/BackupService';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
const backupService = new BackupService();
@@ -454,7 +455,7 @@ async function influxGroupValues(flux: string): Promise<Map<string, number>> {
return out;
}
async function detectAnomalies(siteId: number | null): Promise<AttentionItem[]> {
async function detectAnomalies(siteId: SiteScope): Promise<AttentionItem[]> {
const items: AttentionItem[] = [];
const hour = new Date().getUTCHours();
const nameById = new Map<string, { id: number; name: string }>();
@@ -556,7 +557,7 @@ async function detectAnomalies(siteId: number | null): Promise<AttentionItem[]>
// who did what to the install -- they carry no device and so belong to no site,
// and repeating "admin changed a setting" into every site would misattribute it.
// Device-scoped history (config changes, events) is filtered normally.
async function buildActivity(siteId: number | null) {
async function buildActivity(siteId: SiteScope) {
const cfgFilter = siteScopeByDevice(siteId, 'dc.device_id');
const evtFilter = siteScopeByNullableDevice(siteId, 'e.device_id');
const [configs, audits, events] = await Promise.all([
+2 -1
View File
@@ -2,11 +2,12 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// GET /api/routers/lldp — LLDP enabled/disabled status per online router
router.get('/lldp', async (req: Request, res: Response) => {
+2 -1
View File
@@ -2,10 +2,11 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth } from '../middleware/auth';
import { siteScopeDevices, siteScopeByNullableDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// GET /api/search?q=<term>
router.get('/', async (req: Request, res: Response) => {
+33 -4
View File
@@ -5,6 +5,7 @@ import { requireAuth, requireAdmin } from '../middleware/auth';
import { netflowCollector } from '../services/netflow/NetflowCollector';
import { validatePassword } from '../utils/passwordPolicy';
import { OIDC_SETTINGS_KEY } from '../services/oidc/oidcConfig';
import { bumpTokenVersion } from '../utils/sessionAuth';
const router = Router();
router.use(requireAuth);
@@ -47,7 +48,7 @@ router.put('/', requireAdmin, async (req: Request, res: Response) => {
// GET /api/settings/users
router.get('/users', requireAdmin, async (_req: Request, res: Response) => {
const users = await query(
`SELECT id, username, email, role, auth_provider, created_at FROM users ORDER BY username`
`SELECT id, username, email, role, active, auth_provider, created_at FROM users ORDER BY username`
);
res.json(users);
});
@@ -82,10 +83,10 @@ router.post('/users', requireAdmin, async (req: Request, res: Response) => {
return res.status(201).json(created);
});
// PUT /api/settings/users/:id - update role and/or reset password
// PUT /api/settings/users/:id - update role, active flag and/or reset password
router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const { role, password } = req.body;
const { role, password, active } = req.body as { role?: string; password?: string; active?: boolean };
const existing = await queryOne<{ id: number; role: string }>(
`SELECT id, role FROM users WHERE id = $1`,
@@ -97,14 +98,29 @@ router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
if (userId === req.user!.userId && role && role !== 'admin') {
return res.status(400).json({ error: 'Cannot change your own role away from admin' });
}
// Cannot deactivate your own account (mirrors the self-delete guard below;
// an admin locking themselves out would need another admin to undo it).
if (userId === req.user!.userId && active === false) {
return res.status(400).json({ error: 'Cannot deactivate your own account' });
}
const validRoles = ['admin', 'operator', 'viewer'];
if (role && !validRoles.includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
// A role change or deactivation is exactly the case SEC-001 exists for:
// the account's existing sessions must stop working immediately, not at
// their natural 24h expiry.
let mustRevoke = false;
if (role) {
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, userId]);
mustRevoke = true;
}
if (typeof active === 'boolean') {
await query(`UPDATE users SET active = $1 WHERE id = $2`, [active, userId]);
if (!active) mustRevoke = true;
}
if (password) {
const pwError = validatePassword(password);
@@ -113,15 +129,28 @@ router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
}
const hash = await bcrypt.hash(password, 12);
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
mustRevoke = true;
}
if (mustRevoke) await bumpTokenVersion(userId);
const updated = await queryOne(
`SELECT id, username, role, created_at FROM users WHERE id = $1`,
`SELECT id, username, role, active, created_at FROM users WHERE id = $1`,
[userId]
);
return res.json(updated);
});
// POST /api/settings/users/:id/revoke-sessions — end every session for a user
// right now, without changing their password or role (SEC-001). Useful for
// "this laptop was stolen" without also forcing an unrelated password reset.
router.post('/users/:id/revoke-sessions', requireAdmin, async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const existing = await queryOne<{ id: number }>(`SELECT id FROM users WHERE id = $1`, [userId]);
if (!existing) return res.status(404).json({ error: 'User not found' });
await bumpTokenVersion(userId);
return res.json({ message: 'All sessions for this user have been revoked' });
});
// DELETE /api/settings/users/:id
router.delete('/users/:id', requireAdmin, async (req: Request, res: Response) => {
if (parseInt(req.params.id) === req.user!.userId) {
+72 -4
View File
@@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { requireAuth, requireWrite, requireAdmin } from '../middleware/auth';
import { invalidateUserSiteCache } from '../middleware/site';
const router = Router();
router.use(requireAuth);
@@ -36,8 +37,15 @@ function normaliseCoords(lat: unknown, lng: unknown): { lat: number | null; lng:
return ok ? { lat: latN, lng: lngN } : { lat: null, lng: null };
}
// GET /api/sites — list with device counts, for the selector and the all-sites table
router.get('/', async (_req: Request, res: Response) => {
// GET /api/sites — list with device counts, for the selector and the all-sites table.
// SEC-002: a restricted (non-admin) caller only sees sites they're a member
// of -- otherwise every viewer could enumerate every other client's site
// names regardless of the X-Site-Id enforcement on device data itself.
router.get('/', async (req: Request, res: Response) => {
const restricted = req.user!.role !== 'admin' && !req.user!.tokenAuth;
const memberFilter = restricted
? `WHERE s.id IN (SELECT site_id FROM user_sites WHERE user_id = $1)`
: '';
const sites = await query<SiteRow & { device_count: string }>(
`SELECT s.id, s.name, s.address,
s.location_lat::float8 AS location_lat,
@@ -47,8 +55,10 @@ router.get('/', async (_req: Request, res: Response) => {
COUNT(d.id) FILTER (WHERE d.status = 'online')::text AS online_count
FROM sites s
LEFT JOIN devices d ON d.site_id = s.id
${memberFilter}
GROUP BY s.id
ORDER BY s.is_default DESC, LOWER(s.name) ASC`
ORDER BY s.is_default DESC, LOWER(s.name) ASC`,
restricted ? [req.user!.userId] : []
);
res.json(sites.map((s) => ({
...s,
@@ -180,4 +190,62 @@ router.post('/:id/devices', requireWrite, async (req: Request, res: Response) =>
res.json({ ok: true, moved: ids.length });
});
// ─── SEC-002: site membership management (admin only) ───────────────────────
// GET /api/sites/:id/users — who currently has access to this site
router.get('/:id/users', requireAdmin, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) { res.status(400).json({ error: 'Invalid site id' }); return; }
const users = await query<{ id: number; username: string; role: string }>(
`SELECT u.id, u.username, u.role FROM user_sites us
JOIN users u ON u.id = us.user_id
WHERE us.site_id = $1
ORDER BY u.username`,
[id]
);
res.json(users);
});
// POST /api/sites/:id/users — grant a user access to this site
router.post('/:id/users', requireAdmin, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const userId = parseInt(String((req.body as { userId?: unknown }).userId), 10);
if (!Number.isInteger(id) || !Number.isInteger(userId)) {
res.status(400).json({ error: 'Invalid site id or userId' });
return;
}
const [site, user] = await Promise.all([
queryOne<{ id: number }>(`SELECT id FROM sites WHERE id = $1`, [id]),
queryOne<{ id: number; role: string }>(`SELECT id, role FROM users WHERE id = $1`, [userId]),
]);
if (!site) { res.status(404).json({ error: 'Site not found' }); return; }
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
if (user.role === 'admin') {
// Admins already see every site implicitly; a membership row for one
// would be dead data and would misleadingly suggest their access is
// scoped.
res.status(400).json({ error: 'Admins already have access to every site' });
return;
}
await query(
`INSERT INTO user_sites (user_id, site_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[userId, id]
);
invalidateUserSiteCache(userId);
res.status(201).json({ ok: true });
});
// DELETE /api/sites/:id/users/:userId — revoke a user's access to this site
router.delete('/:id/users/:userId', requireAdmin, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const userId = parseInt(req.params.userId, 10);
if (!Number.isInteger(id) || !Number.isInteger(userId)) {
res.status(400).json({ error: 'Invalid site id or userId' });
return;
}
await query(`DELETE FROM user_sites WHERE site_id = $1 AND user_id = $2`, [id, userId]);
invalidateUserSiteCache(userId);
res.json({ ok: true });
});
export default router;
+2 -1
View File
@@ -2,11 +2,12 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// GET /api/switches — all switch devices with port statistics
router.get('/', async (req: Request, res: Response) => {
+2 -1
View File
@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { PollerService } from '../services/PollerService';
import {
buildTopology,
@@ -13,6 +13,7 @@ import {
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
let pollerService: PollerService | null = null;
export function setPollerService(p: PollerService): void {
+2 -1
View File
@@ -2,13 +2,14 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { activeSite, enforceSiteAccess } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { lookupVendor } from '../utils/oui';
import { analyzeSpectrum, summarize, BAND_RANGES, parseChannelSpec } from '../utils/rfSpectrum';
const router = Router();
router.use(requireAuth);
router.use(enforceSiteAccess);
// ─── Helper ───────────────────────────────────────────────────────────────────
+77 -16
View File
@@ -1,6 +1,7 @@
// Staged firmware rollout orchestrator.
//
// Executes a rollout wave-by-wave (wave 1 = canary), each device sequentially:
// Executes a rollout wave-by-wave (wave 1 = canary). Devices within a wave run
// sequentially by default, or up to wave_concurrency at once (#135):
// pre-upgrade backup → install RouterOS update → wait through the reboot →
// verify it came back healthy on the new version → next device.
// A failure marks the device failed and (with halt_on_failure) stops the whole
@@ -11,6 +12,7 @@
import { query, queryOne } from '../config/database';
import { DeviceCollector, DeviceRow } from './mikrotik/DeviceCollector';
import { BackupService } from './BackupService';
import { mapWithConcurrency, clampConcurrency } from '../utils/concurrency';
const REBOOT_GRACE_MS = 25_000; // let the device actually go down
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000; // large images on slow links
@@ -28,6 +30,8 @@ interface RolloutRow {
halt_on_failure: boolean; pre_backup: boolean;
/** Follow the RouterOS upgrade with the pending RouterBOOT upgrade (issue #113). */
routerboot_after: boolean;
/** How many devices in a wave may upgrade at once; 1 is sequential (#135). */
wave_concurrency: number;
}
interface RolloutDeviceRow {
id: number; rollout_id: number; device_id: number; wave: number; status: string;
@@ -87,18 +91,54 @@ export class FirmwareOrchestrator {
`SELECT * FROM firmware_rollout_devices WHERE rollout_id=$1 ORDER BY wave ASC, id ASC`,
[rollout.id]);
const concurrency = clampConcurrency(rollout.wave_concurrency);
const waves = [...new Set(items.map((i) => i.wave))].sort((a, b) => a - b);
let halted = false;
for (const item of items) {
if (this.cancelRequested || halted) {
await query(`UPDATE firmware_rollout_devices SET status='skipped',
error=$2 WHERE id=$1 AND status='pending'`,
[item.id, this.cancelRequested ? 'Rollout cancelled' : 'Halted: earlier device failed']);
continue;
// Waves stay strictly sequential -- that is the decision point, and it is
// what makes wave 1 a canary. Concurrency applies only *within* a wave.
//
// Halt-on-failure stops *starting* further devices the moment a failure is
// known, rather than at the end of the wave. Devices already in flight are
// allowed to finish, because interrupting a device mid-write is how you
// brick it.
//
// At the default concurrency of 1 that is exactly the previous behaviour:
// a failure stops the next device immediately. Only devices genuinely
// running side by side can carry a bad build, which is the unavoidable part
// of the trade in #135 -- not a wider blast radius imposed on rollouts that
// never asked for concurrency.
for (const wave of waves) {
if (this.cancelRequested || halted) break;
const inWave = items.filter((i) => i.wave === wave);
if (concurrency > 1 && inWave.length > 1) {
console.log(`[Firmware] rollout #${rollout.id}: wave ${wave} — ` +
`${inWave.length} device(s), up to ${concurrency} at once`);
}
let failedInWave = false;
const results = await mapWithConcurrency(inWave, concurrency, async (item) => {
if (this.cancelRequested) return false;
// Do not begin another device once this wave has already failed.
if (rollout.halt_on_failure && failedInWave) return false;
const ok = await this.upgradeDevice(rollout, item);
if (!ok) failedInWave = true;
return ok;
});
if (rollout.halt_on_failure && results.some((ok) => !ok)) {
halted = true;
console.warn(`[Firmware] rollout #${rollout.id}: halting after failures in wave ${wave}`);
}
const ok = await this.upgradeDevice(rollout, item);
if (!ok && rollout.halt_on_failure) halted = true;
}
// Anything never reached is skipped rather than failed -- it did not run.
await query(
`UPDATE firmware_rollout_devices SET status='skipped', error=$2, finished_at=NOW()
WHERE rollout_id=$1 AND status='pending'`,
[rollout.id, this.cancelRequested ? 'Rollout cancelled' : 'Halted: earlier device failed']);
const finalStatus = this.cancelRequested ? 'cancelled' : halted ? 'failed' : 'completed';
await query(`UPDATE firmware_rollouts SET status=$2, finished_at=NOW() WHERE id=$1`, [rollout.id, finalStatus]);
console.log(`[Firmware] rollout #${rollout.id} ${finalStatus}`);
@@ -188,16 +228,37 @@ export class FirmwareOrchestrator {
const freeMb = Math.round((parseInt(res['free-hdd-space'] || '0', 10) / 1048576) * 10) / 10;
const spaceNote = freeMb > 0 ? ` The device reports ${freeMb} MB free.` : '';
await collector.downloadUpdate();
// Wait for the device to say the image has landed. Large images on slow
// links take minutes, and the old fixed 25-second guess is what made this
// look like a failure while the device was still working.
const dlDeadline = Date.now() + DOWNLOAD_TIMEOUT_MS;
// The download command blocks until the image has landed, which on an
// ordinary link is a minute or more. If that outruns its budget the
// connection can no longer be trusted, so it is replaced rather than
// reused -- but a timed-out download is *not* evidence of failure. The
// device is asked on a fresh connection what actually happened, because
// declaring failure here is what made a working download look broken
// when several devices ran in one wave (#136).
let downloaded = false;
try {
await collector.downloadUpdate(DOWNLOAD_TIMEOUT_MS);
} catch (e) {
console.warn(`[Firmware] ${device.name}: download command did not return cleanly ` +
`(${(e as Error).message}); re-checking on a new connection`);
collector.disconnect();
try {
await collector.connect();
} catch (re) {
return fail(
`Lost contact with the device while downloading ${latest}: ${(re as Error).message}. ` +
`Nothing was rebooted.${spaceNote}`
);
}
}
// Confirm the image is on the device. Normally this is true immediately,
// because the download command only returns once it is; the wait covers
// RouterOS versions that return early and report progress instead.
const dlDeadline = Date.now() + DOWNLOAD_TIMEOUT_MS;
while (Date.now() < dlDeadline) {
if (this.cancelRequested) break;
const s = await collector.getUpdateStatus();
const s = await collector.getUpdateStatus().catch(() => '');
if (/downloaded/i.test(s)) { downloaded = true; break; }
if (/error|fail/i.test(s)) {
collector.disconnect();
+8
View File
@@ -17,6 +17,7 @@ import { encrypt, decrypt } from '../utils/crypto';
import {
generateDeviceKeyPair, keyFileName, keyComment, isUsablePrivateKey, type KeyStatus,
} from '../utils/sshKeys';
import { isValidRouterOsUsername } from '../utils/routerOsUsername';
import { RouterOSClient } from './mikrotik/RouterOSClient';
const CONNECT_TIMEOUT_MS = 20_000;
@@ -173,6 +174,13 @@ export class SshKeyService {
async deploy(target: SshTarget, opts: { rotate?: boolean } = {}): Promise<DeviceKeyRow> {
const username = target.ssh_username?.trim();
if (!username) throw new Error('No SSH username configured for this device');
// SEC-006: username is interpolated unescaped into a RouterOS CLI command
// below (`user=${username}`). Validating here, at the point of use, is
// defense in depth even though device writes already reject an invalid
// username before it reaches storage (routes/devices.ts, deviceCreation.ts).
if (!isValidRouterOsUsername(username)) {
throw new Error('Stored SSH username contains characters that are not safe to use — fix it in device settings before deploying a key');
}
const existing = await this.getKey(target.id);
const canUseExistingKey = existing && existing.status === 'verified';
+8
View File
@@ -2,6 +2,7 @@ import { query, queryOne } from '../config/database';
import { encrypt, decrypt } from '../utils/crypto';
import { parsePort } from '../utils/parsePort';
import { safeConnectionError } from '../utils/safeClientError';
import { validateRouterOsUsername } from '../utils/routerOsUsername';
import { RouterOSClient } from './mikrotik/RouterOSClient';
import type { PollerService } from './PollerService';
import type { CredentialPresetRow } from '../routes/credentialPresets';
@@ -112,6 +113,13 @@ export async function createDeviceFromBody(
body: { error: 'name, ip_address, api_username, api_password are required' },
};
}
// SEC-006: ssh_username is later interpolated unescaped into a RouterOS CLI
// command (services/SshKeyService.ts). Reject an unsafe value at the point
// it's first stored, not just where it happens to be used.
const sshUsernameError = validateRouterOsUsername(ssh_username);
if (sshUsernameError) {
return { ok: false, status: 400, body: { error: sshUsernameError } };
}
const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000);
let detectedSerial: string | null;
@@ -19,6 +19,18 @@ import {
import { bytesSince, periodKey, shouldSend } from '../../utils/dataCap';
import { parseDeviceLogTime } from '../../utils/deviceTime';
import { BackupService } from '../BackupService';
/** RouterOS update commands reach out to MikroTik's servers and are slow by nature. */
const UPDATE_CHECK_TIMEOUT_MS = 90_000;
const UPDATE_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
/**
* `/log/print` returns the device's entire ring buffer -- a thousand sentences
* is normal -- and measured against real hardware it routinely outruns the 30s
* default. Timing it out cost the device's whole log *and*, before the client
* learned to abandon a timed-out connection, desynchronised the session (#137).
*/
const LOG_READ_TIMEOUT_MS = 120_000;
import { selectNewLogLines, highestStoredId, surrogateLogId, type RawLogLine } from '../../utils/logDedup';
import { alertService } from '../AlertService';
/** DB column limits for topology_links (see migrate.ts); reject oversize rows instead of silent truncation. */
@@ -800,22 +812,21 @@ export class DeviceCollector {
async collectEvents(): Promise<void> {
try {
const logs = await this.client.execute('/log/print');
const logs = await this.client.execute(
'/log/print', {}, [], { timeoutMs: LOG_READ_TIMEOUT_MS }
);
if (!logs.length) return;
// RouterOS log .id values are hex strings like "*1A2F". Parse to int for comparison.
const parseRosId = (id: string): number => {
const hex = (id || '').replace(/^\*/, '');
return hex ? parseInt(hex, 16) : 0;
};
// Find the highest RouterOS log ID we've already stored for this device.
const lastRow = await queryOne<{ log_id: string }>(
// The highest id we hold, taken across recent rows rather than from the
// single most recently inserted one. Those are usually the same, but when
// they differ the watermark slips backwards and the entire buffer looks
// new on every poll (#137).
const storedIds = await query<{ log_id: string }>(
`SELECT log_id FROM events WHERE device_id = $1 AND log_id IS NOT NULL AND log_id != ''
ORDER BY id DESC LIMIT 1`,
ORDER BY id DESC LIMIT 2000`,
[this.device.id]
);
const lastIdNum = parseRosId(lastRow?.log_id || '');
const lastIdNum = highestStoredId(storedIds.map((r) => r.log_id));
// Always get the latest stored event time — needed for timestamp fallback.
const latestStored = await queryOne<{ event_time: Date }>(
@@ -824,45 +835,61 @@ export class DeviceCollector {
);
const latestTime = latestStored?.event_time ? new Date(latestStored.event_time) : new Date(0);
// Detect log buffer overflow or device reboot: if all current IDs are below
// our stored lastIdNum, RouterOS has cleared/reset its log buffer.
// In that case, fall back to timestamp-based deduplication to avoid missing events.
const currentIds = logs.map(l => parseRosId(l['.id'] || '')).filter(id => id > 0);
const maxCurrentId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
const logReset = lastRow && maxCurrentId > 0 && maxCurrentId < lastIdNum;
// Collect the candidates first, then insert them in batches.
//
// This used to issue one INSERT per line and count every attempt as new,
// so a poll that re-read the whole ring buffer and inserted nothing still
// reported "Collected 1000 new log entries" — a thousand round trips a
// minute per device, described as work that had not happened (#137).
// Counting what the database actually accepted makes the log line true
// and the duplicate case cheap.
const fresh = selectNewLogLines(logs as RawLogLine[], {
lastStoredId: lastIdNum,
latestStoredTime: latestTime,
parseTime: (raw) => this.parseLogTime(raw),
});
const pending: unknown[][] = [];
for (const log of fresh) {
const rawId = (log['.id'] || '') as string;
const topics = (log['topics'] as string) || '';
const message = (log['message'] as string) || '';
const time = this.parseLogTime(log['time'] || '');
const severity = this.mapLogSeverity(topics);
// Never store a null log_id: the unique index that stops re-insertion
// does not constrain nulls, so a device whose lines carry no `.id`
// would accumulate the same entries for ever (#137).
const logId = rawId || surrogateLogId(log['time'] || '', topics, message);
pending.push([
this.device.id,
time.toISOString(),
severity,
topics || null,
message,
JSON.stringify(log),
logId,
]);
}
let newCount = 0;
for (const log of logs) {
const logId = (log['.id'] || '') as string;
const logIdNum = parseRosId(logId);
if (logId && !logReset) {
// Primary: skip entries already stored by RouterOS ID
if (logIdNum <= lastIdNum) continue;
} else {
// Fallback: no .id field, or log buffer has been reset — use timestamp deduplication
const time = this.parseLogTime(log['time'] || '');
if (time <= latestTime) continue;
}
const time = this.parseLogTime(log['time'] || '');
const severity = this.mapLogSeverity(log['topics'] || '');
await query(
const COLS = 7;
const BATCH = 200;
for (let i = 0; i < pending.length; i += BATCH) {
const chunk = pending.slice(i, i + BATCH);
const values = chunk
.map((_, r) => `(${Array.from({ length: COLS }, (_, c) => `$${r * COLS + c + 1}`).join(',')})`)
.join(',');
// RETURNING reports only the rows actually written; ON CONFLICT rows are
// silently dropped, which is exactly the count we want.
const inserted = await query<{ id: number }>(
`INSERT INTO events (device_id, event_time, severity, topic, message, raw_json, log_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (device_id, log_id) DO NOTHING`,
[
this.device.id,
time.toISOString(),
severity,
log['topics'] || null,
log['message'] || '',
JSON.stringify(log),
logId || null,
]
VALUES ${values}
ON CONFLICT (device_id, log_id) DO NOTHING
RETURNING id`,
chunk.flat()
);
newCount++;
newCount += inserted.length;
}
if (newCount > 0) {
@@ -3757,12 +3784,16 @@ export class DeviceCollector {
// ─── Firmware updates ─────────────────────────────────────────────────────
async checkForUpdates(): Promise<Record<string, string>> {
await this.client.execute('/system/package/update/check-for-updates').catch(() => {});
// Give the device a moment to complete the check
// Reaches out to MikroTik's servers, so it can outlast the ordinary read
// timeout on a slow or filtered link. The failure was previously swallowed
// (.catch(() => {})), which hid a timed-out check and left the caller
// reading whatever /print happened to return (#136).
await this.client.execute(
'/system/package/update/check-for-updates', {}, [], { timeoutMs: UPDATE_CHECK_TIMEOUT_MS }
);
// Give the device a moment to settle the result before reading it back.
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
const result = await this.client
.execute('/system/package/update/print')
.catch(() => [] as Record<string, string>[]);
const result = await this.client.execute('/system/package/update/print');
return (result[0] as Record<string, string>) || {};
}
@@ -3776,8 +3807,11 @@ export class DeviceCollector {
* be observed — and on at least one CCR it neither downloaded nor rebooted
* while reporting nothing at all (#firmware).
*/
async downloadUpdate(): Promise<void> {
await this.client.execute('/system/package/update/download');
async downloadUpdate(timeoutMs = UPDATE_DOWNLOAD_TIMEOUT_MS): Promise<void> {
// Blocks until the image has landed -- a minute or more is normal, and the
// 30-second default read timeout was cutting it off and corrupting the
// connection (#136).
await this.client.execute('/system/package/update/download', {}, [], { timeoutMs });
}
/** The `status` line from /system/package/update, e.g. "Downloaded, please reboot…". */
@@ -40,6 +40,8 @@ export class RouterOSClient extends EventEmitter {
timeout: ReturnType<typeof setTimeout>;
} | null = null;
private sentenceQueue: RouterOSSentence[] = [];
/** Set when a reply stream can no longer be trusted; distinct from `connected`. */
private poisoned = false;
private tagCounter = 0;
@@ -62,6 +64,10 @@ export class RouterOSClient extends EventEmitter {
async connect(): Promise<void> {
if (this.connected) return;
// A fresh connection starts trusted, so a client that was abandoned earlier
// can be reconnected and used again.
this.poisoned = false;
this.sentenceQueue = [];
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -124,10 +130,18 @@ export class RouterOSClient extends EventEmitter {
}
// Enqueue a command so concurrent calls don't interleave
/**
* @param opts.timeoutMs How long to wait for each reply sentence. The 30s
* default suits ordinary reads, but some RouterOS commands legitimately run
* far longer -- `/system/package/update/download` takes a minute or more on
* an ordinary link -- and timing those out corrupted the connection (#136).
* Callers issuing a long-running command must say so.
*/
async execute(
command: string,
params: Record<string, string> = {},
queries: string[] = []
queries: string[] = [],
opts: { timeoutMs?: number } = {}
): Promise<Record<string, string>[]> {
let result!: Record<string, string>[];
let error!: Error;
@@ -135,7 +149,7 @@ export class RouterOSClient extends EventEmitter {
await new Promise<void>((resolve) => {
this.operationChain = this.operationChain.then(async () => {
try {
result = await this._executeRaw(command, params, queries);
result = await this._executeRaw(command, params, queries, opts.timeoutMs);
} catch (err) {
error = err as Error;
}
@@ -150,7 +164,8 @@ export class RouterOSClient extends EventEmitter {
private async _executeRaw(
command: string,
params: Record<string, string>,
queries: string[]
queries: string[],
timeoutMs?: number
): Promise<Record<string, string>[]> {
if (!this.socket || !this.connected) {
throw new RouterOSError('Not connected');
@@ -168,7 +183,7 @@ export class RouterOSClient extends EventEmitter {
const results: Record<string, string>[] = [];
while (true) {
const sentence = await this.readNextSentence();
const sentence = await this.readNextSentence(timeoutMs);
if (sentence.type === '!done') break;
if (sentence.type === '!re') {
results.push(sentence.words);
@@ -341,6 +356,17 @@ export class RouterOSClient extends EventEmitter {
const sentence = this.tryParseSentence();
if (!sentence) break;
// Once a connection is abandoned, anything still arriving belongs to the
// command that gave up. Queueing it would hand it to the next caller as
// that caller's own reply, which is the desynchronisation poison() exists
// to prevent -- destroying the socket usually stops this, but data already
// buffered can still surface.
//
// This must test `poisoned`, not `connected`: `connected` is only set once
// login has *finished*, so testing it here discarded the login exchange
// itself and every connection timed out.
if (this.poisoned) continue;
if (this.pendingRead) {
clearTimeout(this.pendingRead.timeout);
const { resolve } = this.pendingRead;
@@ -425,6 +451,19 @@ export class RouterOSClient extends EventEmitter {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRead = null;
// A timed-out read leaves a reply in flight. Previously the socket was
// left open, so when that reply finally arrived processBuffer() found no
// pending read and queued it -- and the *next* command took it as its
// own answer. Every later command on that connection then returned the
// previous one's data.
//
// That is how a slow `/system/package/update/download` turned into
// "did not finish downloading within 10 minutes" (#136): the status
// polls afterwards were reading shifted replies and could never see
// "Downloaded". A desynchronised connection can also make an unrelated
// command look like it succeeded, so the only safe response is to stop
// using it.
this.poison('Read timeout waiting for API response');
reject(new RouterOSError('Read timeout waiting for API response'));
}, timeoutMs);
@@ -436,6 +475,22 @@ export class RouterOSClient extends EventEmitter {
});
}
/**
* Abandon a connection whose reply stream can no longer be trusted.
*
* Anything already queued belongs to a command that gave up, so it is
* discarded rather than handed to the next caller.
*/
private poison(reason: string): void {
this.poisoned = true;
this.connected = false;
this.sentenceQueue = [];
this.buffer = Buffer.alloc(0);
try { this.socket?.destroy(); } catch { /* already gone */ }
this.socket = null;
this.emit('poisoned', reason);
}
// Execute a streaming/generator command (one that never sends !done on its own,
// e.g. /tool/ip-scan). Collects !re rows until maxDurationMs elapses, then
// sends /cancel to terminate the command and drains the cleanup response.
@@ -77,3 +77,91 @@ describe('sentence parsing', () => {
expect(REPEATED_KEY).toBe(REPEATED_ATTRIBUTES_KEY);
});
});
/**
* Read-timeout handling (#136).
*
* A timed-out read leaves a reply in flight. The connection used to stay open,
* so when that reply eventually arrived it was queued and handed to the *next*
* command as its own answer — every subsequent call on that connection returned
* the previous one's data.
*
* That is what turned a slow `/system/package/update/download` into "did not
* finish downloading within 10 minutes": the status polls afterwards were
* reading shifted replies and could never see "Downloaded". The same
* desynchronisation could make any unrelated command appear to succeed, so
* these tests matter well beyond firmware.
*/
describe('read timeout', () => {
type Internals = {
buffer: Buffer;
connected: boolean;
socket: unknown;
sentenceQueue: RouterOSSentence[];
pendingRead: unknown;
readNextSentence(timeoutMs?: number): Promise<RouterOSSentence>;
processBuffer(): void;
};
const makeClient = () => {
const client = new RouterOSClient('127.0.0.1', 8728, 'x', 'x');
const internals = client as unknown as Internals;
internals.connected = true;
internals.socket = { destroy() {} };
return { client, internals };
};
// The regression these were written without: the discard guard first tested
// `connected`, which is only set *after* login succeeds. Every login reply was
// therefore thrown away and every connection timed out — the whole fleet went
// offline. The tests above all pre-set connected = true, so none of them saw
// it. "Not yet connected" and "abandoned" are different states.
it('delivers replies that arrive before login has completed', () => {
const { internals } = makeClient();
internals.connected = false; // mid-login, as connect() leaves it
internals.buffer = sentence('ret=abc123');
internals.processBuffer();
expect(internals.sentenceQueue).toHaveLength(1);
});
it('rejects when no reply arrives in time', async () => {
const { internals } = makeClient();
await expect(internals.readNextSentence(20)).rejects.toThrow(/Read timeout/);
});
it('does not hand a late reply to the next command', async () => {
const { internals } = makeClient();
await expect(internals.readNextSentence(20)).rejects.toThrow(/Read timeout/);
// The reply the timed-out command was waiting for now turns up.
internals.buffer = sentence('status=Downloaded, please reboot');
internals.processBuffer();
// It must not be sitting in the queue waiting to be mistaken for the next
// command's answer.
expect(internals.sentenceQueue).toHaveLength(0);
});
it('marks the connection unusable so it cannot be reused blind', async () => {
const { client, internals } = makeClient();
await expect(internals.readNextSentence(20)).rejects.toThrow(/Read timeout/);
expect(internals.connected).toBe(false);
// A caller that carries on regardless gets a clean error rather than
// another command's data.
await expect(client.execute('/system/resource/print')).rejects.toThrow(/Not connected/);
});
// The guard above must not break ordinary pipelining: a healthy connection
// legitimately queues replies that arrive faster than they are read.
it('still serves queued replies while the connection is healthy', async () => {
const { internals } = makeClient();
internals.buffer = sentence('status=ok');
internals.processBuffer();
expect(internals.sentenceQueue).toHaveLength(1);
await expect(internals.readNextSentence(20)).resolves.toMatchObject({
words: { status: 'ok' },
});
});
});
+22 -8
View File
@@ -13,6 +13,7 @@
import { Client as SSHClient } from 'ssh2';
import { queryOne } from '../config/database';
import { decrypt } from '../utils/crypto';
import { preferredAuth, type KeyStatus } from '../utils/sshKeys';
export interface SshExecDevice {
id: number;
@@ -39,19 +40,33 @@ const DEFAULT_TIMEOUT_MS = 30_000;
*
* A key is used only when it has been *proved* — a key that was generated or
* pushed but never authenticated is not evidence the device will accept it.
*
* Exported because every SSH consumer must agree on this. The interactive
* terminal used to resolve credentials itself, looked only at the stored
* password, and so broke the moment a key was deployed and the password
* retired — the exact workflow the key feature exists to enable (#133).
*/
async function resolveAuth(device: SshExecDevice): Promise<{
export async function resolveAuth(device: SshExecDevice): Promise<{
username: string;
auth: { password: string } | { privateKey: string };
kind: 'key' | 'password';
}> {
const key = await queryOne<{ private_key_encrypted: string; ssh_username: string | null }>(
`SELECT private_key_encrypted, ssh_username FROM device_ssh_keys
WHERE device_id = $1 AND status = 'verified'`,
const key = await queryOne<{
private_key_encrypted: string; ssh_username: string | null; status: KeyStatus;
}>(
`SELECT private_key_encrypted, ssh_username, status FROM device_ssh_keys
WHERE device_id = $1`,
[device.id]
);
if (key) {
const encryptedPassword = device.ssh_password_encrypted || device.api_password_encrypted;
const choice = preferredAuth(
key?.status ?? null,
!!key?.private_key_encrypted,
!!encryptedPassword
);
if (choice === 'key' && key) {
const username = key.ssh_username || device.ssh_username || device.api_username;
if (!username) throw new Error('No SSH username available for key authentication');
return { username, auth: { privateKey: decrypt(key.private_key_encrypted) }, kind: 'key' };
@@ -59,9 +74,8 @@ async function resolveAuth(device: SshExecDevice): Promise<{
const username = device.ssh_username || device.api_username;
if (!username) throw new Error('No SSH username configured');
const encrypted = device.ssh_password_encrypted || device.api_password_encrypted;
if (!encrypted) throw new Error('No SSH credential configured');
return { username, auth: { password: decrypt(encrypted) }, kind: 'password' };
if (choice === 'none' || !encryptedPassword) throw new Error('No SSH credential configured');
return { username, auth: { password: decrypt(encryptedPassword) }, kind: 'password' };
}
/**
@@ -0,0 +1,103 @@
import { mapWithConcurrency, clampConcurrency, MAX_WAVE_CONCURRENCY } from '../concurrency';
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
describe('mapWithConcurrency', () => {
it('returns results in input order, not completion order', async () => {
// First item is slowest, so completion order is the reverse of input order.
const out = await mapWithConcurrency([30, 20, 1], 3, async (ms) => {
await tick(ms);
return ms;
});
expect(out).toEqual([30, 20, 1]);
});
it('never exceeds the limit', async () => {
let inFlight = 0;
let peak = 0;
await mapWithConcurrency(Array.from({ length: 12 }, (_, i) => i), 3, async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await tick();
inFlight--;
});
expect(peak).toBe(3);
});
// The behaviour every existing rollout depends on: unchanged unless asked for.
it('is strictly sequential at a limit of 1', async () => {
const order: number[] = [];
let inFlight = 0;
let peak = 0;
await mapWithConcurrency([1, 2, 3, 4], 1, async (n) => {
inFlight++; peak = Math.max(peak, inFlight);
await tick();
order.push(n);
inFlight--;
});
expect(peak).toBe(1);
expect(order).toEqual([1, 2, 3, 4]);
});
it('runs every item exactly once', async () => {
const seen: number[] = [];
await mapWithConcurrency(Array.from({ length: 25 }, (_, i) => i), 4, async (n) => {
await tick(1);
seen.push(n);
});
expect(seen.sort((a, b) => a - b)).toEqual(Array.from({ length: 25 }, (_, i) => i));
});
it('handles an empty list without starting anything', async () => {
let started = 0;
const out = await mapWithConcurrency([], 5, async () => { started++; return 1; });
expect(out).toEqual([]);
expect(started).toBe(0);
});
it('does not start more runners than there is work', async () => {
let peak = 0;
let inFlight = 0;
await mapWithConcurrency([1, 2], 10, async () => {
inFlight++; peak = Math.max(peak, inFlight);
await tick();
inFlight--;
});
expect(peak).toBe(2);
});
// A rejecting worker would abandon the wave mid-flight; the orchestrator
// catches per device, but the helper must not be the thing that hides it.
it('propagates a worker rejection', async () => {
await expect(
mapWithConcurrency([1, 2, 3], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n;
})
).rejects.toThrow('boom');
});
});
describe('clampConcurrency', () => {
it('defaults to sequential for anything unusable', () => {
for (const bad of [undefined, null, '', 'abc', NaN, {}, []]) {
expect(clampConcurrency(bad)).toBe(1);
}
});
it('never returns less than 1', () => {
expect(clampConcurrency(0)).toBe(1);
expect(clampConcurrency(-8)).toBe(1);
});
// This number decides how many devices may be rebooting at once.
it('caps the upper end', () => {
expect(clampConcurrency(1000)).toBe(MAX_WAVE_CONCURRENCY);
expect(clampConcurrency(Infinity)).toBe(MAX_WAVE_CONCURRENCY);
});
it('floors fractional values', () => {
expect(clampConcurrency(3.9)).toBe(3);
expect(clampConcurrency('4')).toBe(4);
});
});
@@ -0,0 +1,140 @@
import {
parseRosId, highestStoredId, isLogReset, selectNewLogLines,
surrogateLogId, isSurrogateLogId, type RawLogLine,
} from '../logDedup';
// Fixed clock so timestamp comparisons are deterministic.
const T = (iso: string) => new Date(iso);
const parseTime = (raw: string) => (raw ? T(raw) : new Date(0));
const line = (id: string, time = '', message = 'm'): RawLogLine =>
({ '.id': id, time, message, topics: 'system,info' });
describe('parseRosId', () => {
it('reads RouterOS hex ids', () => {
expect(parseRosId('*1A2F')).toBe(0x1a2f);
expect(parseRosId('1a2f')).toBe(0x1a2f);
});
it('returns 0 for anything unusable', () => {
for (const bad of ['', undefined, '*', '*zz', 'not-hex', '*-5']) {
expect(parseRosId(bad as string)).toBe(0);
}
});
});
describe('highestStoredId', () => {
// The bug this exists for: taking the last *inserted* row instead of the
// highest id lets the watermark move backwards, after which every line looks
// new and the whole buffer is reprocessed every poll.
it('takes the maximum, not the last element', () => {
expect(highestStoredId(['*10', '*FF', '*20'])).toBe(0xff);
});
it('ignores nulls and junk', () => {
expect(highestStoredId([null, undefined, '', '*zz', '*7'])).toBe(7);
expect(highestStoredId([])).toBe(0);
});
});
describe('isLogReset', () => {
it('detects ids going backwards', () => {
expect(isLogReset(5, 900)).toBe(true);
});
it('is not fooled by a first run or an empty buffer', () => {
expect(isLogReset(900, 0)).toBe(false);
expect(isLogReset(0, 900)).toBe(false);
});
it('treats normal forward movement as no reset', () => {
expect(isLogReset(950, 900)).toBe(false);
});
});
describe('selectNewLogLines', () => {
const latestStoredTime = T('2026-09-08T00:00:00Z');
it('returns nothing when the whole buffer is already stored', () => {
// The reported symptom: a thousand lines re-offered every minute.
const logs = Array.from({ length: 1000 }, (_, i) => line(`*${(i + 1).toString(16)}`));
const out = selectNewLogLines(logs, { lastStoredId: 1000, latestStoredTime, parseTime });
expect(out).toHaveLength(0);
});
it('returns only the lines above the watermark', () => {
const logs = [line('*8'), line('*9'), line('*A'), line('*B')];
const out = selectNewLogLines(logs, { lastStoredId: 9, latestStoredTime, parseTime });
expect(out.map((l) => l['.id'])).toEqual(['*A', '*B']);
});
it('falls back to timestamps when the buffer has reset', () => {
// Ids went backwards, so they cannot be compared against the watermark.
const logs = [
line('*1', '2026-09-07T23:00:00Z'), // older than what we hold
line('*2', '2026-09-08T00:30:00Z'), // newer
];
const out = selectNewLogLines(logs, { lastStoredId: 900, latestStoredTime, parseTime });
expect(out.map((l) => l['.id'])).toEqual(['*2']);
});
it('uses timestamps for lines carrying no id', () => {
const logs = [
{ time: '2026-09-07T23:00:00Z', message: 'old' },
{ time: '2026-09-08T01:00:00Z', message: 'new' },
];
const out = selectNewLogLines(logs, { lastStoredId: 5, latestStoredTime, parseTime });
expect(out.map((l) => l.message)).toEqual(['new']);
});
it('treats a line exactly on the boundary as already stored', () => {
const logs = [{ time: '2026-09-08T00:00:00Z', message: 'boundary' }];
expect(selectNewLogLines(logs, { lastStoredId: 0, latestStoredTime, parseTime })).toHaveLength(0);
});
});
describe('surrogateLogId', () => {
const line = ['sep/08 02:38:10', 'system,info', 'router rebooted'] as const;
it('is stable for the same line', () => {
expect(surrogateLogId(...line)).toBe(surrogateLogId(...line));
});
it('differs when any field differs', () => {
const base = surrogateLogId(...line);
expect(surrogateLogId('sep/08 02:38:11', line[1], line[2])).not.toBe(base);
expect(surrogateLogId(line[0], 'system,error', line[2])).not.toBe(base);
expect(surrogateLogId(line[0], line[1], 'something else')).not.toBe(base);
});
// Field boundaries are separated, so shifting text between fields cannot
// produce the same key.
it('does not confuse field boundaries', () => {
expect(surrogateLogId('a', 'b', 'c')).not.toBe(surrogateLogId('ab', '', 'c'));
});
// The column is VARCHAR(20); an over-long key would throw on insert.
it('fits the log_id column', () => {
const id = surrogateLogId('x'.repeat(500), 'y'.repeat(500), 'z'.repeat(5000));
expect(id.length).toBeLessThanOrEqual(20);
});
it('cannot be mistaken for a RouterOS id', () => {
const id = surrogateLogId(...line);
expect(id.startsWith('#')).toBe(true);
expect(isSurrogateLogId(id)).toBe(true);
expect(isSurrogateLogId('*CC75')).toBe(false);
expect(isSurrogateLogId(null)).toBe(false);
});
/**
* The watermark is a max over RouterOS's own hex ids. A synthesised id must
* not be parsed as one, or it would poison the high-water mark and make every
* real line look already-seen.
*/
it('is ignored by the id watermark', () => {
const id = surrogateLogId(...line);
expect(parseRosId(id)).toBe(0);
expect(highestStoredId([id, '*10'])).toBe(0x10);
});
});
@@ -0,0 +1,80 @@
import { findUpstreamWithinSelection, type TopologyEdge } from '../rolloutTopology';
const link = (
from: number, to: number | null, iface = 'ether1', role: string | null = 'root'
): TopologyEdge => ({ from_device_id: from, to_device_id: to, from_interface: iface, stp_role: role });
describe('findUpstreamWithinSelection', () => {
// The case the warning exists for: two devices reach the network through a
// switch, and all three are being upgraded together.
it('flags a device reached through, when the link is unambiguous', () => {
const links = [
link(2, 1, 'ether1', 'root'),
link(3, 1, 'ether1', 'root'),
];
expect(findUpstreamWithinSelection([1, 2, 3], links)).toEqual([1]);
});
/**
* The failure that prompted the rewrite. Links are bidirectional and a trunk
* port often resolves to several neighbours, so counting adjacency flagged
* every device on a real four-device fleet. A warning that fires on
* everything trains people to ignore it.
*/
it('says nothing when a port resolves to several neighbours', () => {
const links = [
link(7, 1, 'sfp28-2', 'root'),
link(7, 8, 'sfp28-2', 'root'),
link(7, 2, 'sfp28-2', 'root'),
];
expect(findUpstreamWithinSelection([1, 2, 7, 8], links)).toEqual([]);
});
it('is not fooled by the reverse direction of the same link', () => {
// Both ends report each other; only the root-port end means "upstream".
const links = [
link(2, 1, 'ether1', 'root'),
link(1, 2, 'sfp1', 'designated'),
];
expect(findUpstreamWithinSelection([1, 2], links)).toEqual([1]);
});
it('ignores designated ports, which face away from the root', () => {
const links = [link(1, 2, 'sfp1', 'designated')];
expect(findUpstreamWithinSelection([1, 2], links)).toEqual([]);
});
it('tolerates the -port suffix RouterOS sometimes reports', () => {
const links = [link(2, 1, 'ether1', 'root-port')];
expect(findUpstreamWithinSelection([1, 2], links)).toEqual([1]);
});
it('ignores links to devices outside the selection', () => {
// A switch upstream of something not in this rollout is not a risk to it.
const links = [link(50, 1, 'ether1', 'root')];
expect(findUpstreamWithinSelection([1, 2], links)).toEqual([]);
});
it('needs at least two selected devices', () => {
expect(findUpstreamWithinSelection([1], [link(2, 1)])).toEqual([]);
expect(findUpstreamWithinSelection([], [link(2, 1)])).toEqual([]);
});
it('tolerates unresolved neighbours, self links and missing roles', () => {
const links = [
link(2, null, 'ether1', 'root'),
link(1, 1, 'ether1', 'root'),
link(3, 1, 'ether5', null),
link(2, 1, 'ether9', 'root'),
];
expect(findUpstreamWithinSelection([1, 2, 3], links)).toEqual([1]);
});
it('reports several upstream devices in a stable order', () => {
const links = [
link(3, 2, 'ether1', 'root'),
link(4, 1, 'ether1', 'root'),
];
expect(findUpstreamWithinSelection([1, 2, 3, 4], links)).toEqual([1, 2]);
});
});
@@ -0,0 +1,40 @@
import { isValidRouterOsUsername, validateRouterOsUsername } from '../routerOsUsername';
describe('SEC-006: RouterOS SSH username allowlist', () => {
it('accepts ordinary usernames', () => {
for (const u of ['admin', 'svc-rmm', 'ops_user', 'user.name', 'a1', 'A']) {
expect(isValidRouterOsUsername(u)).toBe(true);
}
});
it('rejects command-injection payloads that rely on RouterOS token separators', () => {
const payloads = [
'admin;/system/reboot',
'admin /system/reboot',
'admin\n/system/reboot',
'admin\t/system/reboot',
'admin"; /system/reboot; "',
'admin$(reboot)',
'admin`reboot`',
'admin/../root',
'admin=x comment=y',
'',
' ',
'a'.repeat(33), // over the length cap
];
for (const p of payloads) {
expect(isValidRouterOsUsername(p)).toBe(false);
}
});
it('validateRouterOsUsername treats an absent value as fine (field is optional)', () => {
expect(validateRouterOsUsername(undefined)).toBeNull();
expect(validateRouterOsUsername(null)).toBeNull();
expect(validateRouterOsUsername('')).toBeNull();
});
it('validateRouterOsUsername rejects a non-string and an unsafe string with a message', () => {
expect(validateRouterOsUsername(123)).toMatch(/string/i);
expect(validateRouterOsUsername('admin; reboot')).toMatch(/letters, digits/i);
});
});
+2 -1
View File
@@ -67,7 +67,8 @@ describe('preferredAuth', () => {
// bound to them, confirmed on hardware by removing the key and watching
// password login start working again.
expect(preferredAuth('verified', true, true)).toBe('key');
expect(preferredAuth('deployed', true, true)).toBe('key');
// Pushed but never proved: not yet evidence the device will accept it.
expect(preferredAuth('deployed', true, true)).toBe('password');
});
it('uses the password only when no key is installed', () => {
+54
View File
@@ -0,0 +1,54 @@
/**
* Run tasks with a ceiling on how many are in flight (issue #135).
*
* Firmware upgrades are not like bulk commands. A command is a short round
* trip, so CommandRunner can fire a whole wave at once with Promise.all. An
* upgrade holds an API connection for minutes across a download, a reboot and a
* verification — and connection pressure is precisely where this system has
* been shown to break. So concurrency is bounded rather than unlimited.
*
* Results are returned in input order regardless of completion order, because
* callers pair them with the devices they came from.
*/
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const max = Math.max(1, Math.floor(limit) || 1);
const results = new Array<R>(items.length);
let next = 0;
async function runner(): Promise<void> {
while (true) {
const i = next++;
if (i >= items.length) return;
results[i] = await worker(items[i], i);
}
}
// Never start more runners than there is work for them to do.
await Promise.all(
Array.from({ length: Math.min(max, items.length) }, () => runner())
);
return results;
}
/**
* Clamp an operator-supplied concurrency to something sane.
*
* Rejecting silly values matters more here than elsewhere: this number decides
* how many devices can be rebooting simultaneously.
*/
export const MAX_WAVE_CONCURRENCY = 20;
export function clampConcurrency(raw: unknown): number {
const n = Number(raw);
// NaN means "not a number at all" -> fall back to sequential. Infinity means
// "as many as possible", so it clamps to the cap: treating it as unusable
// would quietly do the opposite of what was asked.
if (Number.isNaN(n)) return 1;
if (n === Infinity) return MAX_WAVE_CONCURRENCY;
if (n === -Infinity) return 1;
return Math.min(MAX_WAVE_CONCURRENCY, Math.max(1, Math.floor(n)));
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Deciding which RouterOS log lines are actually new (issue #137).
*
* `/log/print` returns the device's whole ring buffer every time — commonly a
* thousand lines — so almost all of it is already stored. Getting this decision
* right is the difference between inserting a handful of rows and re-offering
* the entire buffer once a minute.
*/
import { createHash } from 'crypto';
export interface RawLogLine {
'.id'?: string;
time?: string;
topics?: string;
message?: string;
[k: string]: unknown;
}
/** RouterOS log ids are hex strings like "*1A2F"; 0 means "no usable id". */
export function parseRosId(id: string | undefined): number {
const hex = (id || '').replace(/^\*/, '');
if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return 0;
const n = parseInt(hex, 16);
return Number.isSafeInteger(n) && n > 0 ? n : 0;
}
/**
* The highest log id we have already stored.
*
* Taken as the maximum over recent rows rather than "the last row inserted".
* Those are usually the same, but not always — and when they differ the
* watermark goes backwards, every later line looks new, and the whole buffer is
* reprocessed on every poll.
*/
export function highestStoredId(storedIds: (string | null | undefined)[]): number {
let max = 0;
for (const id of storedIds) {
const n = parseRosId(id ?? undefined);
if (n > max) max = n;
}
return max;
}
/**
* Has the device's log buffer been cleared or the device rebooted?
*
* Detected by ids going backwards. When it happens the id watermark is
* meaningless and timestamps are the only thing left to compare.
*/
export function isLogReset(currentMaxId: number, lastStoredId: number): boolean {
return lastStoredId > 0 && currentMaxId > 0 && currentMaxId < lastStoredId;
}
/**
* Which lines should be inserted.
*
* `parseTime` is injected so the caller's RouterOS timestamp handling is used
* without this module needing to know about it.
*/
export function selectNewLogLines(
logs: RawLogLine[],
opts: {
lastStoredId: number;
latestStoredTime: Date;
parseTime: (raw: string) => Date;
}
): RawLogLine[] {
const ids = logs.map((l) => parseRosId(l['.id'])).filter((n) => n > 0);
const currentMax = ids.length ? Math.max(...ids) : 0;
const reset = isLogReset(currentMax, opts.lastStoredId);
const out: RawLogLine[] = [];
for (const log of logs) {
const id = parseRosId(log['.id']);
if (id > 0 && !reset) {
if (id <= opts.lastStoredId) continue;
} else {
// No usable id, or the buffer reset: fall back to timestamps.
if (opts.parseTime(log.time || '') <= opts.latestStoredTime) continue;
}
out.push(log);
}
return out;
}
/**
* A stable identifier for a log line that RouterOS gave no `.id`.
*
* The events table has a unique index on (device_id, log_id) and relies on it
* to reject lines already stored. PostgreSQL treats NULLs as *distinct* in a
* unique index, so a NULL log_id conflicts with nothing: a device whose log
* entries carry no `.id` had no protection at all and re-inserted the same
* lines on every poll, without bound.
*
* Deriving the key from the line's own content restores that protection. The
* `#` prefix keeps it out of the space of real RouterOS ids, which are `*`
* followed by hex, so the two can never collide or be mistaken for each other.
*
* The trade, stated plainly: two genuinely separate lines with the same
* timestamp, topic and text collapse into one. RouterOS timestamps are
* second-resolution, so that is possible. Losing a repeat of an identical
* message is a far smaller loss than growing the table by the whole buffer
* every minute for ever.
*/
export function surrogateLogId(time: string, topics: string, message: string): string {
const hash = createHash('sha256')
.update(`${time}\u0000${topics}\u0000${message}`)
.digest('hex')
.slice(0, 16);
return `#${hash}`; // 17 chars, inside the column's VARCHAR(20)
}
/** True for the ids this module synthesised, as opposed to RouterOS's own. */
export function isSurrogateLogId(id: string | null | undefined): boolean {
return typeof id === 'string' && id.startsWith('#');
}
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
/**
* Positive-allowlist validation for a RouterOS SSH/API username (SEC-006).
*
* The username is stored by an operator and later interpolated directly into
* RouterOS CLI commands sent over an SSH exec channel (see
* services/SshKeyService.ts: `/user/ssh-keys/import ... user=${username}`).
* Escaping after the fact is fragile — RouterOS's own command parser has its
* own quoting rules, not shell rules, so a shell-style escape can still leave
* a value that RouterOS itself parses as more than one token. Validating
* against a known-safe character set before it is ever stored is simpler and
* closes the injection regardless of where the value is later used.
*
* RouterOS itself limits usernames to letters, digits, and a handful of
* punctuation characters, with no whitespace — this allowlist is deliberately
* narrower than what the device might tolerate, because "narrower than
* necessary but provably safe" is the right trade for a value that ends up
* unescaped in a command line.
*/
const VALID_USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
export function isValidRouterOsUsername(username: string): boolean {
return VALID_USERNAME.test(username);
}
/** Returns an error message if invalid, or null if the username is acceptable. */
export function validateRouterOsUsername(username: unknown): string | null {
if (username === null || username === undefined || username === '') return null; // optional field
if (typeof username !== 'string') return 'Username must be a string';
if (!isValidRouterOsUsername(username)) {
return 'Username may only contain letters, digits, "." "_" "-", must start with a letter or digit, and be at most 32 characters';
}
return null;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Server-side JWT revocation (SEC-001).
*
* JWTs stay stateless for signing/verifying (no DB write on every request),
* but every session must be killable before its natural expiry: logout,
* a password change, disabling an account, or an admin "sign this user out
* everywhere" action. The mechanism is a `token_version` column on `users`:
* every signed token embeds the version current at sign time, and a request
* is rejected the moment that version no longer matches the row -- bumping
* the column *is* the revoke.
*
* Checking on every request would mean a DB round trip per authenticated
* call. A short in-process cache (a few seconds) keeps the common case at
* zero extra latency while capping how long a revoked token can keep working
* to a small, documented window -- far better than the 24h it had before.
*/
import { query, queryOne } from '../config/database';
export interface SessionState {
tokenVersion: number;
active: boolean;
role: string;
}
interface CacheEntry {
state: SessionState | null; // null = user no longer exists
cachedAt: number;
}
const CACHE_TTL_MS = 5_000;
const cache = new Map<number, CacheEntry>();
async function fetchState(userId: number): Promise<SessionState | null> {
const row = await queryOne<{ token_version: number; active: boolean; role: string }>(
`SELECT token_version, active, role FROM users WHERE id = $1`,
[userId]
);
return row ? { tokenVersion: row.token_version, active: row.active, role: row.role } : null;
}
/** Current session state for a user, cached for a few seconds. */
export async function getSessionState(userId: number): Promise<SessionState | null> {
const cached = cache.get(userId);
if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
return cached.state;
}
const state = await fetchState(userId);
cache.set(userId, { state, cachedAt: Date.now() });
return state;
}
/**
* Bump a user's token_version, invalidating every JWT issued before now for
* that account. Used by logout, password change, TOTP disable, account
* deactivation, and the admin "revoke sessions" action.
*
* Returns the new version so callers that also sign a fresh token (e.g.
* password-change-while-staying-logged-in flows, if ever added) can embed it.
*/
export async function bumpTokenVersion(userId: number): Promise<number> {
const rows = await query<{ token_version: number }>(
`UPDATE users SET token_version = token_version + 1 WHERE id = $1 RETURNING token_version`,
[userId]
);
cache.delete(userId); // don't serve a stale version for the next CACHE_TTL_MS
return rows[0]?.token_version ?? 0;
}
/** Test seam: clear the cache between tests / after direct DB mutation in tests. */
export function _resetSessionCacheForTest(): void {
cache.clear();
}
+50 -16
View File
@@ -1,22 +1,35 @@
/**
* Site scoping helpers (issue #130).
* Site scoping helpers (issue #130), extended for per-user authorization in
* Mission 02 (SEC-002).
*
* Every device-derived table hangs off devices(id), so a single site_id on
* devices scopes clients, events, topology, backups and the rest transitively.
* These helpers turn an active site into the SQL predicate that does it.
* These helpers turn an *effective* site scope into the SQL predicate that
* does it.
*
* The site id is inlined rather than parameterised, and that is a deliberate
* A scope is one of:
* - `null` unrestricted (the whole fleet) -- admins and API tokens.
* - `number` exactly one site -- either an explicit selection the caller
* has already been authorized for (middleware/site.ts), or a
* restricted user's only accessible site.
* - `number[]` a restricted user viewing "all sites" (no X-Site-Id sent):
* scoped to every site they're a member of, which may be
* empty -- meaning no access to anything, not "everything".
*
* The id(s) are inlined rather than parameterised, and that is a deliberate
* choice worth justifying: the collection routes build their WHERE clauses by
* incrementing a shared $n counter, so injecting a parameter mid-build would
* mean renumbering every filter in ~23 routes -- exactly the kind of manual
* bookkeeping that produces silent off-by-one bugs. Inlining is safe here
* because resolveSiteId() below admits nothing but a positive integer; a value
* that is not one becomes null (unscoped), never text that reaches SQL.
* because every id passing through this module is validated as a positive
* integer first (resolveSiteId, or read back from user_sites via a parameterised
* query) -- never raw text reaching SQL.
*/
export type SiteScope = number | number[] | null;
/**
* Normalise an untrusted site id (header, query string) into either a positive
* integer or null, where null means "no scoping -- show the whole fleet".
* integer or null, where null means "no explicit selection".
*
* 'all' is the explicit all-sites view. Anything unparseable is also treated as
* unscoped rather than rejected, so an existing API consumer that knows nothing
@@ -27,7 +40,7 @@ export function resolveSiteId(raw: unknown): number | null {
const s = String(raw).trim();
if (s === '' || s.toLowerCase() === 'all') return null;
// Matched as a literal digit string rather than via Number(), deliberately.
// Number() would accept '1e3', '0x1' and even Arabic-Indic '\u0661\u0662' as
// Number() would accept '1e3', '0x1' and even Arabic-Indic '١٢' as
// integers -- all harmless here, but it makes the safety of inlining rest on
// coercion trivia. A digits-only regex makes it self-evident instead.
if (!/^\d+$/.test(s)) return null;
@@ -36,23 +49,41 @@ export function resolveSiteId(raw: unknown): number | null {
return n;
}
/**
* Collapse a scope to a single concrete site id, or null. For an unrestricted
* caller (null) or a restricted user viewing several sites at once (array),
* there is no single "the site this belongs to" answer -- callers that need
* one (e.g. "which site does a newly created device join") get null and fall
* back to their own default-site behavior, same as an admin always has.
*/
export function singleSiteOrNull(scope: SiteScope): number | null {
return typeof scope === 'number' ? scope : null;
}
function idList(ids: number[]): string {
// An empty IN () is invalid SQL and, more importantly, its intent ("match
// nothing") is easy to get backwards. Spell it out instead.
return ids.length ? `(${ids.join(',')})` : '(NULL)';
}
/**
* Predicate for a query that selects from `devices` itself.
* Returns null when unscoped, so callers can skip pushing a filter.
*/
export function siteScopeDevices(siteId: number | null, alias = ''): string | null {
if (siteId === null) return null;
export function siteScopeDevices(scope: SiteScope, alias = ''): string | null {
if (scope === null) return null;
const col = alias ? `${alias}.site_id` : 'site_id';
return `${col} = ${siteId}`;
return Array.isArray(scope) ? `${col} IN ${idList(scope)}` : `${col} = ${scope}`;
}
/**
* Predicate for a query on any table carrying a device_id.
* `column` is the fully-qualified device id column, e.g. 'c.device_id'.
*/
export function siteScopeByDevice(siteId: number | null, column = 'device_id'): string | null {
if (siteId === null) return null;
return `${column} IN (SELECT id FROM devices WHERE site_id = ${siteId})`;
export function siteScopeByDevice(scope: SiteScope, column = 'device_id'): string | null {
if (scope === null) return null;
const inner = Array.isArray(scope) ? `site_id IN ${idList(scope)}` : `site_id = ${scope}`;
return `${column} IN (SELECT id FROM devices WHERE ${inner})`;
}
/**
@@ -61,9 +92,10 @@ export function siteScopeByDevice(siteId: number | null, column = 'device_id'):
* site is selected -- showing them in every site would misrepresent them as
* that site's own.
*/
export function siteScopeByNullableDevice(siteId: number | null, column = 'device_id'): string | null {
if (siteId === null) return null;
return `(${column} IS NOT NULL AND ${column} IN (SELECT id FROM devices WHERE site_id = ${siteId}))`;
export function siteScopeByNullableDevice(scope: SiteScope, column = 'device_id'): string | null {
if (scope === null) return null;
const inner = Array.isArray(scope) ? `site_id IN ${idList(scope)}` : `site_id = ${scope}`;
return `(${column} IS NOT NULL AND ${column} IN (SELECT id FROM devices WHERE ${inner}))`;
}
/**
@@ -87,6 +119,8 @@ export function andSite(base: string, predicate: string | null): string {
*
* When a site holds every device, its total *is* the global total -- an
* identity, not an approximation -- so `_global` is both correct and complete.
* Only meaningful for a single explicit site; a restricted multi-site scope
* has no single tag and callers should fall back to per-site queries instead.
*/
export function clientSeriesTag(
siteId: number | null,
+7 -1
View File
@@ -96,13 +96,19 @@ export type KeyStatus = 'pending' | 'deployed' | 'verified' | 'failed';
*
* The real recovery path is the binary API, which is unaffected by any of this
* and can always remove the key.
*
* Only a *verified* key counts. One that was generated or pushed but never
* authenticated is not evidence the device will accept it, and 'deployed' is a
* transient state on the way to being proved. This function previously admitted
* it, which disagreed with the resolver that actually ran -- the disagreement
* went unnoticed because nothing outside these tests ever called this (#133).
*/
export function preferredAuth(
status: KeyStatus | null,
hasPrivateKey: boolean,
hasPassword: boolean,
): 'key' | 'password' | 'none' {
if (hasPrivateKey && (status === 'verified' || status === 'deployed')) return 'key';
if (hasPrivateKey && status === 'verified') return 'key';
if (hasPassword) return 'password';
return 'none';
}
+222
View File
@@ -0,0 +1,222 @@
# Auditoria Técnica e de Segurança — Base Upstream 2GT (MikroTik Manager)
- Commit auditado: `1a4b6350528b8c656ab7a84ee4806a8a86cf9932` (v0.24.5-beta)
- Branch: `mikrormm-dev`, sobre bootstrap `7a4979e`
- Escopo: código-fonte completo (backend, frontend, Docker), sem RouterBOARD real conectada
- Método: leitura de código, grep dirigido, testes automatizados (jest/vitest), `npm audit`, análise de Dockerfiles/compose. Nenhuma credencial real foi usada ou impressa.
## Mapa factual da arquitetura
- **Frontend**: React 19 + Vite, SPA servida por nginx, autentica via Bearer JWT em `localStorage`.
- **Backend**: Node/Express (`backend/src/index.ts`), monta ~28 routers sob `/api/*`, todos com `router.use(requireAuth)` no topo do arquivo exceto os endpoints públicos explícitos de `oidc.ts` (`/status`, `/login`, `/callback`).
- **Autenticação**: `routes/auth.ts` + `middleware/auth.ts` — JWT HS256, bcryptjs, TOTP opcional (otpauth), OIDC opcional (openid-client).
- **Autorização**: 3 papéis globais (`admin`/`operator`/`viewer`) checados em `middleware/auth.ts` (`requireAdmin`, `requireWrite`). Sem escopo por site/tenant.
- **Banco**: PostgreSQL, schema em `db/migrate.ts` (migrations idempotentes, `CREATE TABLE IF NOT EXISTS` + `ALTER` incrementais), executado automaticamente no boot do backend.
- **Redis/BullMQ**: filas de polling/automação; rate limiting (`middleware/rateLimitRedis.ts`) também usa Redis com fallback em memória.
- **InfluxDB**: métricas de série temporal (`config/influxdb.ts`, consultado em `routes/metrics.ts`).
- **Jobs/Scheduler/Polling**: `services/PollerService.ts`, `services/DeviceBulkAddWorker.ts`.
- **Alertas**: `services/AlertService.ts` + `routes/alerts.ts`.
- **RouterOS integration**: `services/mikrotik/RouterOSClient.ts` (API binário RouterOS, TCP/TLS), `services/mikrotik/DeviceCollector.ts`.
- **SSH**: `services/sshExec.ts`, `services/SshKeyService.ts`, `services/BackupService.ts` (ssh2), terminal interativo via Socket.IO (`index.ts` namespace `/terminal`).
- **API**: ~28 arquivos de rota, ~250 endpoints.
- **SNMP**: não encontrada implementação SNMP no backend (ausente — não fazia parte do escopo upstream atual).
- **NetFlow**: `services/netflow/NetflowCollector.ts`, coletor UDP na porta 2055, exposto no `docker-compose.yml`.
- **Backups**: `services/BackupService.ts` + `routes/backups.ts`, arquivos `.rsc` (`/export compact`) em `backups_data` volume.
- **Firmware upgrade**: `services/FirmwareOrchestrator.ts` + `routes/firmware.ts`, waves com concorrência limitada (`utils/concurrency.ts`, `MAX_WAVE_CONCURRENCY=20`).
- **Terminal**: Socket.IO namespace `/terminal`, restrito a `admin`/`operator`, com auditoria e rate limit.
- **Change Guard**: `services/changeGuard/ChangeGuard.ts` — dead-man-switch no próprio dispositivo (scheduler RouterOS que desfaz a mudança se a conexão não for reconfirmada).
- **Auditoria**: `middleware/auditMiddleware.ts` (global) + tabela `audit_log`, consultável via `routes/auditLog.ts` (admin-only).
## Achados
### SEC-001 — Sessões JWT sem revogação server-side
**Severidade:** MÉDIO
**Componente:** `backend/src/middleware/auth.ts`, `routes/auth.ts`
**Evidência:** `signToken`/`verifyToken` não consultam o banco; `POST /api/auth/logout` só responde OK sem invalidar nada ("JWT is stateless; client just discards token"); `PUT /api/auth/password` e `POST /api/auth/totp/disable` não alteram nenhum estado que `verifyToken` observe. Comprovado por teste automatizado (`middleware/__tests__/auditFindings.test.ts`, SEC-001).
**Causa-raiz:** design stateless sem `jti`/versão de sessão nem lista de revogação.
**Impacto:** um token vazado (XSS, log, dispositivo comprometido) continua válido por até 24h mesmo depois de o usuário trocar a senha, desabilitar TOTP ou ser removido/rebaixado por um admin — só uma rotação de `JWT_SECRET` (que derruba TODAS as sessões) resolve.
**Exploração/condição:** requer vazamento prévio do token; não é explorável remotamente sem esse pré-requisito.
**Recomendação:** adicionar `jti` + tabela de revogação (ou coluna `token_version` em `users`, checada em `requireAuth`), e reduzir `expiresIn` para algo como 1–4h com refresh token.
**Status:** OPEN
### SEC-002 — "Sites" são um filtro de exibição, não um limite de autorização
**Severidade:** ALTO (crítico para uso multi-cliente GTI; baixo para uso interno de um único tenant)
**Componente:** `backend/src/utils/siteScope.ts`, `middleware/site.ts`, schema `users`
**Evidência:** `siteContext` lê `X-Site-Id` do cliente sem checar identidade; `resolveSiteId` não recebe `req.user`; tabela `users` (`db/migrate.ts:6-12`) não tem nenhuma coluna/relação com `sites`. Comprovado por teste (`auditFindings.test.ts`, SEC-002) e por inspeção do schema.
**Causa-raiz:** o recurso "sites" (#130) foi implementado como agrupamento operacional (dashboards por site), não como fronteira de tenant.
**Impacto:** qualquer usuário autenticado — mesmo `viewer` — enxerga e (se `operator`/`admin`) opera sobre TODOS os dispositivos de TODOS os sites simplesmente omitindo o header `X-Site-Id`. Se a GTI pretende dar a um técnico/cliente acesso restrito a "seus" equipamentos, essa garantia **não existe hoje**.
**Recomendação:** antes de onboarding com múltiplos clientes reais no mesmo backend, implementar RBAC por site (tabela `user_sites`, checagem no middleware, nunca confiar em header sem cruzar com o usuário).
**Status:** OPEN
### SEC-003 — RouterOS API-SSL sem verificação de certificado
**Severidade:** MÉDIO
**Componente:** `backend/src/services/mikrotik/RouterOSClient.ts:97`
**Evidência:** `tls.connect({ host, port, rejectUnauthorized: false }, ...)`.
**Causa-raiz:** RouterOS normalmente usa certificado autoassinado; verificar CA exigiria gerenciamento de certificados por dispositivo, não implementado.
**Impacto:** conexão à RouterBOARD é suscetível a MITM na rede entre backend e device — um atacante na mesma rede/segmento poderia interceptar credenciais RouterOS e comandos.
**Recomendação:** oferecer *trust-on-first-use* com fixação de fingerprint por dispositivo (guardar o hash do cert na primeira conexão e alertar/rejeitar se mudar), em vez de desabilitar globalmente.
**Status:** OPEN
### SEC-004 — Chaves de criptografia default nunca expiram como fallback de decrypt
**Severidade:** MÉDIO
**Componente:** `backend/src/utils/secrets.ts:154-161`, `utils/crypto.ts`
**Evidência:** `encDecryptors` sempre inclui `KNOWN_DEFAULT_ENC` (as duas chaves públicas do repositório) na lista de chaves tentadas para decrypt, permanentemente, mesmo depois de anos de rotação.
**Causa-raiz:** decisão deliberada de nunca quebrar dados antigos cifrados com a chave default (documentada no próprio código).
**Impacto:** se uma instalação alguma vez rodou sem `ENCRYPTION_KEY` customizada (mesmo que temporariamente) e um `pg_dump` daquele período vazar, os segredos daquele período são decifráveis com uma chave pública e conhecida deste repositório — para sempre.
**Recomendação:** aceitável como *tradeoff* de migração, mas documentar explicitamente no runbook de produção; considerar expurgar as chaves default do array de decrypt após confirmar (via `needsReencryption`) que nenhum registro as usa mais.
**Status:** ACCEPTED (mitigação: `ENCRYPTION_KEY` DEV já é forte e aleatória desde a Missão 00; nunca esteve no default)
### SEC-005 — Change Guard cobre apenas um subconjunto pequeno das operações de escrita
**Severidade:** MÉDIO (expectativa vs. realidade)
**Componente:** `backend/src/routes/devices.ts` (único chamador de `withSafeApply`), `routes/commands.ts`, terminal Socket.IO
**Evidência:** `grep -rl withSafeApply routes/` retorna **apenas** `devices.ts`, usado no fluxo de "preflight"/apply genérico de mudanças analisadas (`analyzeChange`). As dezenas de endpoints diretos de firewall, NAT, rotas, VLAN, bonds, endereços IP, interfaces (mesmo arquivo `devices.ts`) chamam a API RouterOS diretamente, sem snapshot nem dead-man-switch. `commands.ts` (execução em massa) e o terminal SSH interativo também não passam por `ChangeGuard`.
**Causa-raiz:** Change Guard foi construído para o fluxo guiado de "mudança arriscada analisada", não como interceptador universal de toda escrita.
**Impacto:** um operador pode remover uma rota, uma regra de firewall ou uma interface via endpoint direto (ou via terminal/comando em massa) e perder conectividade com o dispositivo sem qualquer rede de segurança automática.
**O que o Change Guard REALMENTE garante:** para o fluxo que passa por `withSafeApply`, garante rollback automático se a nova conexão de verificação falhar, com um restore point salvo no próprio dispositivo antes da mudança.
**O que ele NÃO garante:** proteção em firewall/NAT/rotas/VLAN/interfaces editados diretamente, em `commands.ts` (bulk), no terminal interativo, em `configHistory.ts` rollback, ou contra falha de hardware/queda de energia do device.
**Recomendação:** documentar essa cobertura parcial para os operadores GTI e avaliar estender `withSafeApply` aos endpoints de firewall/rotas/VLAN mais usados antes de operar em RBs de produção.
**Status:** OPEN (documentação atualizada nesta auditoria)
### SEC-006 — Injeção de comando RouterOS via `ssh_username` não sanitizado
**Severidade:** BAIXO
**Componente:** `backend/src/services/SshKeyService.ts:196-204`
**Evidência:** `` `/user/ssh-keys/import public-key-file=${fileName} user=${username}` `` e `` `/file/remove ${fileName}` `` interpolam `username`/`fileName` sem escaping. `fileName` é gerado internamente (seguro); `username` vem de `target.ssh_username`, campo configurável por qualquer usuário `operator`/`admin` ao cadastrar o dispositivo.
**Causa-raiz:** ausência de validação/allowlist de caracteres para `ssh_username`.
**Impacto:** um operador que já tem permissão de configurar credenciais de um dispositivo (logo, já tem acesso legítimo a ele) poderia usar um `ssh_username` malformado para injetar comandos RouterOS adicionais na mesma sessão SSH. Não é escalação de privilégio (o ator já controla o dispositivo), mas quebra o princípio de defesa em profundidade.
**Recomendação:** validar `ssh_username` contra o alfabeto aceito pelo RouterOS para nomes de usuário antes de interpolar em qualquer comando.
**Status:** OPEN
### SEC-007 — `nginx:alpine` sem tag de versão e demais imagens sem pinning por digest
**Severidade:** BAIXO (reprodutibilidade/supply chain)
**Componente:** `nginx/Dockerfile:12`, `docker-compose.yml`
**Evidência:** `FROM nginx:alpine` (sem versão nenhuma); `FROM node:26-alpine`; `postgres:15-alpine`, `redis:7-alpine`, `influxdb:2.7` — nenhuma imagem fixada por digest sha256.
**Impacto:** dois builds em datas diferentes podem produzir imagens de base diferentes (patches de SO, ou até uma nova major do nginx), reduzindo a reprodutibilidade entre DEV e PROD e dificultando auditoria de supply chain.
**Recomendação:** fixar ao menos a versão do nginx (`nginx:1.27-alpine`) e considerar pinning por digest em PROD.
**Status:** OPEN
### SEC-008 — Dockerfiles usam `npm install` em vez de `npm ci`
**Severidade:** BAIXO
**Componente:** `backend/Dockerfile`, `nginx/Dockerfile` (estágio frontend-builder)
**Evidência:** ambos os estágios de build rodam `npm install` em vez de `npm ci`, apesar de existir `package-lock.json` no repositório.
**Impacto:** `npm install` pode resolver versões ligeiramente diferentes das travadas no lockfile em rebuilds futuros (dentro dos ranges semver), reduzindo reprodutibilidade bit-a-bit do build.
**Recomendação:** trocar para `npm ci` nos dois Dockerfiles.
**Status:** OPEN
### SEC-009 — `qs`/`body-parser`/`express` — DoS moderado (transitivo)
**Severidade:** BAIXO
**Componente:** dependência transitiva `qs` (via `express@4.22.2` → `body-parser`)
**Evidência:** `npm audit --omit=dev` no backend reporta 3 vulnerabilidades moderadas (GHSA-x5fp-wj9c-mxmx, GHSA-4mjr-xmp4-gh2g) — bypass de limite de array / DoS via `isBuffer` manipulado.
**Contexto de exploração:** a API já tem rate limit global (120 req/min/IP, `rateLimitRedis`), reduzindo o valor prático do DoS. Correção disponível via `npm audit fix` dentro do range semver do Express 4.x.
**Recomendação:** aplicar o fix na próxima janela de manutenção de dependências (fora do escopo desta missão — "não atualizar dependências" foi respeitado aqui).
**Status:** OPEN (não corrigido nesta missão, por instrução explícita da missão)
### SEC-010 — Rate limit de login é só por IP, sem lockout por conta
**Severidade:** BAIXO/INFORMATIVO
**Componente:** `backend/src/middleware/rateLimitRedis.ts` (`loginRateLimit`)
**Evidência:** chave `rl:login:ip:${ip}`; nenhuma chave por `username`.
**Impacto:** um atacante distribuído (múltiplos IPs/proxies) não é throttled por conta — mas a mensagem de erro é genérica ("Invalid credentials") em ambos os casos (usuário inexistente ou senha errada), então não há enumeração de usuários.
**Recomendação:** considerar um limite adicional por `username` além do IP.
**Status:** ACCEPTED (mitigação parcial já existe; risco baixo)
### SEC-011 — Política de senha mínima aceitável, sem verificação de senha vazada
**Severidade:** INFORMATIVO
**Componente:** `backend/src/utils/passwordPolicy.ts`
**Evidência:** mínimo 10 caracteres, 1 letra, 1 número; sem checagem contra listas de senhas vazadas (ex. HaveIBeenPwned) nem bloqueio de senhas triviais como `Password123`.
**Recomendação:** aceitável para baseline; considerar reforço futuro.
**Status:** ACCEPTED
### SEC-012 — Criptografia de credenciais RouterOS (AES-256-GCM) — bem desenhada
**Severidade:** INFORMATIVO (achado positivo)
**Componente:** `backend/src/utils/crypto.ts`, `utils/secrets.ts`
**Evidência:** AES-256-GCM com IV aleatório de 16 bytes por operação, tag de autenticação verificada (`setAuthTag`/`getAuthTag`), chave derivada via SHA-256 quando não tem exatamente 32 bytes, chave nunca persistida no banco (arquivo `secrets.json` fora do Postgres, modo `0o600`), suporte a rotação com múltiplas chaves de decrypt. Design correto e documentado no próprio código.
**Status:** FALSE_POSITIVE (não é um achado negativo — registrado como ponto forte confirmado)
### SEC-013 — Backups: nomes de arquivo sanitizados corretamente, sem path traversal encontrado
**Severidade:** INFORMATIVO
**Componente:** `backend/src/services/BackupService.ts:73-74`
**Evidência:** `filename = device.name.replace(/[^a-z0-9]/gi, '_')`; `file_path` sempre construído com `path.join(BACKUPS_DIR, String(device.id), filename)`, `device.id` é `SERIAL` numérico. Endpoints de download/preview/restore buscam `file_path` do banco por `id` numérico, nunca aceitam path do cliente.
**Status:** FALSE_POSITIVE
### SEC-014 — RBAC de backend (rotas) consistente com o esperado
**Severidade:** INFORMATIVO
**Evidência:** todas as 28 rotas verificadas têm `router.use(requireAuth)` (ou, no caso de `oidc.ts`, `requireAuth`/`requireAdmin` por rota, corretamente deixando só `/status`, `/login`, `/callback` públicos). Endpoints administrativos sensíveis (`users`, `oidc/config`, `audit-log`, `cert/regenerate`, `credential-presets`, `maintenance-windows`) usam `requireAdmin`. Autoproteção contra auto-rebaixamento/auto-exclusão de admin implementada em `routes/settings.ts`.
**Status:** FALSE_POSITIVE (sem achado — RBAC de rota está correto)
## Achados por severidade
- Crítico: 0
- Alto: 1 (SEC-002)
- Médio: 4 (SEC-001, SEC-003, SEC-004, SEC-005)
- Baixo: 5 (SEC-006, SEC-007, SEC-008, SEC-009, SEC-010)
- Informativo: 5 (SEC-011 a SEC-014, incl. 2 achados positivos)
## Principais 10 achados
1. SEC-002 — Sites não são fronteira de autorização (ALTO para multi-tenant).
2. SEC-005 — Change Guard cobre só um subconjunto pequeno de operações de escrita.
3. SEC-001 — Sessões JWT sem revogação server-side.
4. SEC-003 — RouterOS API-SSL sem verificação de certificado (`rejectUnauthorized: false`).
5. SEC-004 — Chaves de criptografia default como fallback de decrypt permanente.
6. SEC-006 — Injeção potencial de comando RouterOS via `ssh_username`.
7. SEC-009 — DoS moderado transitivo em `qs`/`express` (não corrigido por instrução da missão).
8. SEC-007/SEC-008 — Supply chain: imagens sem pinning, `npm install` em vez de `npm ci`.
9. SEC-010 — Rate limit de login só por IP.
10. SEC-012/SEC-013/SEC-014 — Pontos fortes confirmados: criptografia de credenciais, sanitização de path de backup, RBAC de rota consistente.
## Credenciais RouterOS
**Avaliação: aceitável com ressalva.** Armazenamento em repouso é sólido (AES-256-GCM, IV único, tag autenticada, chave fora do banco, rotação suportada). A ressalva é em trânsito: TLS sem verificação de certificado (SEC-003) expõe a credencial a MITM na rede local. Não há exposição das credenciais em API, logs (`logSafe`/`safeClientError` fazem redação deliberada) ou frontend (`maskedConfig` para OIDC, sem exposição de `*_encrypted` observada nas rotas revisadas).
## RBAC
**Avaliação: seguro dentro de um único tenant, inseguro para multi-cliente.** A separação admin/operator/viewer é aplicada corretamente no backend (não só na UI) e resiste a manipulação direta de endpoint. A falha estrutural é SEC-002: não existe isolamento por site/cliente — inaceitável se a GTI planeja hospedar múltiplos clientes finais na mesma instância antes de implementar `user_sites`.
## Operações remotas
**Avaliação: seguro com uma ressalva de TLS.** Terminal SSH interativo tem controle de papel (admin/operator), auditoria e rate limit por usuário. RouterOS API usa protocolo binário estruturado (não concatenação de string), reduzindo risco de injeção clássica. A ressalva é SEC-003 (TLS sem verificação).
## Change Guard
**O que realmente protege:** mudanças que passam pelo fluxo de "preflight + apply analisado" em `devices.ts`, com snapshot no dispositivo e rollback automático via scheduler RouterOS se a verificação de conectividade pós-mudança falhar.
**O que NÃO protege:** a maioria das edições diretas de firewall/NAT/rotas/VLAN/interfaces, execução de comandos em massa (`commands.ts`) e o terminal interativo — nenhum desses passa por `withSafeApply`.
## Backups
**Estado: seguro para o escopo atual.** Nomes de arquivo sanitizados, paths nunca vêm do cliente, autorização de escrita (`requireWrite`) presente em criar/restaurar/excluir. Sem criptografia em repouso do arquivo `.rsc` em si (fica em texto plano no volume `backups_data`) — aceitável para DEV, a avaliar antes de integrar com SFTP GTI (fora do escopo desta missão).
## Docker
**Estado: funcional, com dívida de reprodutibilidade.** Non-root user no backend, capability mínima necessária (`NET_RAW` só para arp-scan/ping), healthchecks presentes. Pontos a melhorar: pinning de imagens (SEC-007) e `npm ci` (SEC-008).
## Dependências
**Estado: aceitável.** Backend: 3 vulnerabilidades moderadas transitivas (SEC-009), sem crítico/alto. Frontend: 0 vulnerabilidades em dependências de produção. Nenhuma atualização automática foi aplicada, conforme instrução da missão.
## Testes
```
BACKEND TESTS: 545/545 (543 originais + 2 de auditoria, SEC-001/SEC-002)
FRONTEND TESTS: 56/56
TYPECHECK: PASS
LINT: PASS (0 erros; 270 warnings backend / 108 frontend, security/detect-object-injection e afins — nenhum classificado como vulnerabilidade real nesta auditoria; ver observação abaixo)
BUILD: PASS
```
**Nota sobre os warnings do `eslint-plugin-security`:** os 378 warnings (270+108) foram amostrados por arquivo (`rfSpectrum.ts`, `serverArp.ts`, `secrets.ts`, `wifiChannels.ts`, `topologyLayout.ts` entre outros). Nenhum ponto analisado usa índice vindo diretamente de entrada HTTP não validada para acessar um objeto sensível (a maioria é iteração sobre arrays/enums internos, falso positivo típico da regra `detect-object-injection`). Classificação: **falso positivo majoritário** — não foi encontrada uma cadeia de exploração real amostrando os arquivos mais citados pela ferramenta. Não corrigidos mecanicamente, conforme instrução da missão.
## Primeira RB GTI read-only
**AUTORIZADA PARA PRIMEIRA RB GTI READ-ONLY: SIM**
Para uma **única** RouterBOARD, em modo **read-only** (sem escrita, sem Change Guard necessário, sem terminal), com um único usuário/tenant, a base é segura o suficiente: credenciais cifradas corretamente, RBAC de rota correto, auditoria presente. As ressalvas (TLS sem verificação — SEC-003, sessões sem revogação — SEC-001) são aceitáveis para um piloto controlado e monitorado.
## Correções obrigatórias antes da primeira RB **de escrita** ou multi-cliente
1. **SEC-002** — implementar RBAC por site antes de conectar RBs de mais de um cliente final na mesma instância.
2. **SEC-005** — decidir explicitamente (e documentar para os operadores GTI) quais operações de escrita ficam sem rede de segurança do Change Guard, ou estender `withSafeApply` a firewall/rotas antes de liberar escrita irrestrita em produção.
3. **SEC-003** — avaliar fixação de fingerprint TLS antes de operar RBs fora de uma rede de gestão confiável.
## Documentação operacional
`docs/SISTEMA_ATUAL.md` foi atualizado (seções 11 e 22) para refletir SEC-001, SEC-002, SEC-003 e SEC-005 como fatos comprovados sobre o sistema atual.
## Git
- Commit desta missão: ver `git log -1` após o commit de auditoria (arquivos: este relatório, teste `middleware/__tests__/auditFindings.test.ts`, atualização de `docs/SISTEMA_ATUAL.md`).
- Nenhuma refatoração ampla foi feita; nenhuma correção de vulnerabilidade foi aplicada ao código de produção (todos os achados ficam como `OPEN`/`ACCEPTED`, conforme regra da missão — só se corrige o que é necessário para provar/criar o teste).
- Worktree limpo ao final.
## VEREDITO FINAL
# APROVADO COM CORREÇÕES OBRIGATÓRIAS
A arquitetura é sólida e o nível de engenharia é notavelmente maduro para uma base open source (comentários de causa-raiz, testes extensivos, defesa em profundidade em vários pontos, Change Guard bem desenhado onde é usado). Não há achado CRÍTICO. Mas existe um achado ALTO (SEC-002) que é estrutural para o modelo de negócio pretendido pela GTI (múltiplos clientes) e um MÉDIO (SEC-005) que muda a expectativa operacional sobre o que o Change Guard cobre. Nenhum dos dois inviabiliza a base — ambos são corrigíveis sem trocar de projeto — mas devem ser resolvidos antes de qualquer RB de escrita ou multi-cliente real.
## Próxima missão recomendada
**MISSÃO 02 — RBAC por site (`user_sites`) + extensão do Change Guard aos endpoints de firewall/rotas mais usados**, antes de iniciar qualquer integração GTI (SFTP, GLPI, Slack) ou conectar a primeira RouterBOARD real em modo de escrita.
+2
View File
@@ -14,6 +14,8 @@ whether to try it, the [project README](https://github.com/2GT-Media-Group-LLC/m
| [Cellular (LTE)](cellular.md) | Signal, carriers, tower movement, and the data-cap reset SMS |
| [SSH keys](ssh-keys.md) | Per-device keypairs, rotation, and what installing one costs |
| [Bulk commands](commands.md) | Running one command across a fleet, in waves |
| [Firmware rollouts](firmware.md) | Waves, the upgrade pipeline, and what upgrading several at once costs |
| [Backups](backups.md) | Finding, reading, comparing and deleting configuration backups |
| [Polling and scaling](scaling.md) | Poller health, headroom, tuning, and clearing a backlog |
| [API and automation](api.md) | Scoped tokens, authentication, and driving the platform from scripts |
| [Architecture](architecture.md) | How the system is put together, for contributors |
+136
View File
@@ -0,0 +1,136 @@
# MikroRMM — Sistema Atual
## 1. Objetivo do sistema
Plataforma de gestão e monitoramento de dispositivos MikroTik (RouterOS): inventário de clientes/dispositivos, polling de métricas, backups, automação, alertas, change guard, firmware rollout, topologia de rede.
## 2. Base upstream
- Projeto: MikroTik Manager (2GT Media Group LLC)
- Repositório: https://github.com/2GT-Media-Group-LLC/mikrotik-manager
- Licença: ver `LICENSE` na raiz do repo
- Remote local: `origin` → https://github.com/2GT-Media-Group-LLC/mikrotik-manager.git
- Commit baseline: `1a4b6350528b8c656ab7a84ee4806a8a86cf9932` (branch `main`, tag semântica v0.24.5-beta)
- Branch de trabalho MikroRMM: `mikrormm-dev`
## 3. Arquitetura atual
Stack Docker Compose com 5 serviços: `nginx` (proxy/reverse proxy + estáticos do frontend), `backend` (API Node/Express + poller + coletor NetFlow), `postgres` (dados relacionais), `redis` (filas BullMQ), `influxdb` (métricas de série temporal).
## 4. Estrutura do repositório
- `backend/` — API Node.js/TypeScript (Express, BullMQ, Socket.IO, SSH2)
- `frontend/` — SPA React 19 + Vite + TailwindCSS
- `nginx/` — Dockerfile, `nginx.conf`, `entrypoint.sh` (gera cert self-signed se ausente)
- `docs/` — documentação (mkdocs)
- `scripts/` — utilitários e fixtures de seed
## 5. Containers e serviços
| Serviço | Imagem | Porta host (DEV) | Healthcheck |
|----------|-----------------------|------------------------|-------------|
| nginx | build local | 80, 443 | nenhum definido no compose |
| backend | build local | 2055/udp (NetFlow) | `GET /api/health` |
| postgres | postgres:15-alpine | interno | `pg_isready` |
| redis | redis:7-alpine | interno | `redis-cli ping` |
| influxdb | influxdb:2.7 | interno | `influx ping` |
## 6. Banco de dados
PostgreSQL 15, banco `mikrotik_manager`, usuário `mikrotik`. Migrations em `backend/src/db/migrate.ts`, executadas automaticamente no boot do backend (idempotente — cria usuário admin default apenas se a tabela `users` estiver vazia).
## 7. Redis / filas
BullMQ para jobs de polling/automação. `maxmemory` configurável via `REDIS_MAXMEMORY` (default 512mb), política `noeviction` (ver comentário no `docker-compose.yml`).
## 8. InfluxDB / métricas
InfluxDB 2.7, org/bucket configuráveis via env (`INFLUXDB_ORG`, `INFLUXDB_BUCKET`), token de admin gerado no setup inicial.
## 9. Frontend
React 19 + Vite + TailwindCSS, servido como estático pelo nginx. Build gera chunk principal >500KB (aviso do Vite, não bloqueante).
## 10. Backend
Node.js/TypeScript, Express, autenticação JWT, TOTP opcional, OIDC opcional, coletor NetFlow/IPFIX na porta 2055/udp, backups, SSH (ssh2) para RouterOS.
## 11. Autenticação
JWT (`JWT_SECRET`, HS256, expiração 24h), senha com bcryptjs, suporte a TOTP e OIDC. Usuário admin default criado na migration inicial (`admin/admin`) — **a senha padrão DEVE ser trocada imediatamente após o primeiro login** (feito nesta instalação DEV; ver seção 12 do CLAUDE.md).
RBAC: 3 papéis globais (`admin`/`operator`/`viewer`), aplicados corretamente no backend em toda rota (ver `docs/AUDITORIA_BASE_2GT.md`). **Não existe isolamento por site/cliente** — o filtro `X-Site-Id` é apenas de exibição; qualquer usuário autenticado enxerga todos os dispositivos de todos os sites se omitir o header (achado SEC-002). Sessões JWT não têm revogação server-side: trocar senha, desabilitar TOTP ou remover um usuário não invalida tokens já emitidos até expirarem (achado SEC-001).
## 12. Ambiente DEV
- Diretório: `D:\PROJETOS\MIKROTIK RMM\mikrormm` (Windows 11, Git Bash/MINGW64)
- Docker: 29.6.2 / Compose v5.3.1
- Compose usado: `docker-compose.yml` (build local a partir do código-fonte, não `docker-compose.ghcr.yml`)
- Portas publicadas: 80 (HTTP), 443 (HTTPS self-signed), 2055/udp (NetFlow)
- `.env` local com secrets DEV gerados aleatoriamente (nunca versionado)
- `TZ=America/Bahia`
## 13. Ambiente PROD planejado
Infraestrutura Docker dedicada da GTI. Deve manter paridade com DEV (mesmo `docker-compose.yml` como base, variando apenas `.env`/secrets/domínio/CORS). Não implementado nesta missão.
## 14. Variáveis de ambiente
Somente nomes e finalidade (ver `.env.example` para a lista completa):
- `HTTP_PORT`, `HTTPS_PORT` — portas publicadas do nginx
- `TZ` — timezone dos containers
- `CORS_ORIGIN` — origens permitidas pela API
- `DB_PASSWORD` — senha do PostgreSQL
- `JWT_SECRET` — segredo de assinatura JWT
- `ENCRYPTION_KEY` — chave de criptografia de senhas de dispositivos (32 bytes)
- `INFLUXDB_TOKEN`, `INFLUXDB_ORG`, `INFLUXDB_BUCKET`, `INFLUXDB_ADMIN_PASSWORD` — InfluxDB
- `POLLER_*` — tuning do poller de dispositivos
- `REDIS_MAXMEMORY` — teto de memória do Redis
## 15. Como iniciar
```bash
docker compose up -d
```
## 16. Como parar
```bash
docker compose down
```
## 17. Como rebuildar
```bash
docker compose build
docker compose up -d
```
## 18. Como verificar saúde
```bash
docker compose ps
curl -k https://localhost/api/health
```
## 19. Como executar testes
```bash
# backend
cd backend && npm ci && npm test && npm run lint && npx tsc --noEmit
# frontend
cd frontend && npm ci && npm test -- --run && npm run lint && npm run build
```
## 20. Persistência e volumes
Volumes nomeados: `postgres_data`, `redis_data`, `influxdb_data`, `backups_data`, `certs_data`, `app_data`. Persistência comprovada via `docker compose restart` nesta missão.
## 21. Backup do próprio MikroRMM
Backend expõe rotas de backup de dispositivos (`backups_data`); backup do próprio stack MikroRMM (dump de `postgres_data`/`influxdb_data`) ainda não documentado/procedimentado — pendente de missão futura.
## 22. Segurança atual
- `.env` fora do Git (confirmado via `git check-ignore`)
- Secrets DEV gerados aleatoriamente (DB_PASSWORD 32c, JWT_SECRET 64c hex, ENCRYPTION_KEY 32c, INFLUXDB_TOKEN 64c hex, INFLUXDB_ADMIN_PASSWORD 24c)
- Senha padrão do admin alterada (ver `.secrets-local/dev-admin-password.txt`, fora do Git)
- Certificado HTTPS self-signed gerado pelo nginx (`nginx/entrypoint.sh`) — adequado só para DEV
- Lint de segurança (`eslint-plugin-security`) roda no baseline com 270 (backend) + 108 (frontend) warnings, 0 erros — auditados na Missão 01 (`docs/AUDITORIA_BASE_2GT.md`), majoritariamente falso positivo
- Credenciais RouterOS cifradas em repouso com AES-256-GCM, chave fora do banco (`SECRETS_DIR/secrets.json`), IV único por operação, tag autenticada — design confirmado correto na auditoria
- Conexão RouterOS API-SSL usa `rejectUnauthorized: false` (sem verificação de certificado) — suscetível a MITM na rede local (achado SEC-003, OPEN)
- Change Guard (dead-man-switch) só cobre o fluxo de "preflight + apply analisado" em `devices.ts`; a maioria das edições diretas de firewall/rotas/VLAN, comandos em massa e o terminal interativo não passam por ele (achado SEC-005, OPEN)
- Ver `docs/AUDITORIA_BASE_2GT.md` para a lista completa de achados, severidades e status
## 23. Integrações atuais
Nenhuma integração externa real conectada (GLPI, Slack, SFTP, RouterBOARD real) — fora do escopo desta missão.
## 24. Funcionalidades atualmente disponíveis
Conforme upstream v0.24.5-beta: gestão de clientes/dispositivos, polling, backups, automação, alertas, change guard, firmware rollout, topologia, NetFlow, OIDC, TOTP. Ver `README.md` do upstream para detalhes completos.
## 25. Funcionalidades GTI ainda não implementadas
Integração GLPI, SFTP, Slack, onboarding customizado, renomeação para "MikroRMM", RouterBOARD real conectada.
## 26. Procedimento para mudanças com IA
Ver `CLAUDE.md` na raiz do projeto.
## 27. Prompt/missão operacional atual
**Missão 00 — Preparar Ambiente Dev Local** (concluída): bootstrap e baseline apenas. Clonar upstream, subir stack local via build de código-fonte, validar saúde/login/testes, documentar, manter Git limpo, zero secrets versionados. Proibido nesta missão: renomear o produto, redesenhar UI, implementar GLPI/SFTP/Slack, alterar autenticação/schema/Change Guard, conectar RouterBOARD real, corrigir achados de auditoria. Próxima missão recomendada: **Auditoria Técnica e de Segurança da Base Upstream**.
+85
View File
@@ -0,0 +1,85 @@
# Backups
[← Documentation index](README.md)
Configuration backups for every device, on demand or on a schedule, with the tools to
find, read and compare them.
## What a backup actually is
`/export compact` run over SSH, stored as a `.rsc` file — **plain RouterOS script, not
the binary `.backup` blob**.
That distinction shapes everything else on this page. A `.rsc` can be read, diffed,
edited and applied selectively; a `.backup` can only be restored wholesale onto the
same device. It also means a backup is human-readable text, which is why previewing
and comparing them is possible at all.
It also means a backup does **not** capture things `/export` omits: user passwords,
certificates and files on disk. A `.rsc` restores a configuration, not a device.
## Finding one
The table filters by **device**, **type**, a **date range** and free text across
device name, filename and notes. Date ranges include both ends — "to: 3 September"
covers everything that happened on the 3rd.
Types:
| Type | Created by |
|---|---|
| `manual` | The Create Backup button |
| `scheduled` | The daily/weekly/monthly schedule |
| `pre-upgrade` | A firmware rollout, before it touches the device |
| `config-snapshot` | Config History, when it captures a change |
## Reading and comparing
Click any row to read the backup in place. Select exactly two and choose **Compare**
for a line diff — the same diff Config History uses for snapshots.
The comparison is always ordered oldest to newest regardless of the order you clicked,
so additions read as additions.
The **filename** is not a column in the table; it appears in the preview and diff
headers instead. It is a device name and a timestamp — useful when you are looking at
a specific file or quoting it to someone, less useful as a column competing for width
with the device and date you actually filter on.
## Deleting, and the one thing to know first
A backup and its Config History snapshot are **one artifact**. They are joined in the
database with `ON DELETE CASCADE` precisely so they cannot drift apart — a snapshot
whose restorable `.rsc` had been deleted would offer a rollback it could not perform.
So deleting a `config-snapshot` backup also removes that snapshot from the device's
Config History, and the ability to roll back to it.
Single deletes of a snapshot-linked backup ask for confirmation and say this plainly.
**Bulk delete** does the same, but with the numbers: it reports how many backups per
device will go, and how many Config History snapshots will go with them, before you
confirm. There is no undo.
Bulk delete is capped at 200 per action and is scoped to the [current site](sites.md),
so it cannot reach into another site's history.
## Restoring
Restore uploads the `.rsc` and applies it. This is a real configuration change with
all the risk that implies — read the backup first if you are not certain what is in
it, which is what the preview is for.
## API
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/backups` | List, with `deviceId`, `type`, `from`, `to`, `search` |
| `GET` | `/api/backups/types` | Types present, with counts |
| `POST` | `/api/backups` | Create one for a device |
| `GET` | `/api/backups/:id/content` | Read the text (capped at 2 MB) |
| `GET` | `/api/backups/:from/diff/:to` | Both texts, for a client-side diff |
| `GET` | `/api/backups/:id/download` | Download the `.rsc` |
| `POST` | `/api/backups/:id/restore` | Apply it to the device |
| `DELETE` | `/api/backups/:id` | Delete one |
| `GET` | `/api/backups/bulk-delete/preview` | What a bulk delete would remove |
| `POST` | `/api/backups/bulk-delete` | Delete many |
+104
View File
@@ -0,0 +1,104 @@
# Firmware rollouts
[← Documentation index](README.md)
Upgrade RouterOS across a fleet in waves, with a verified pipeline per device and
a stopping point before a bad build reaches everything.
## The pipeline
Each device goes through the same sequence:
1. **Pre-upgrade backup** (optional, on by default)
2. **Check for updates** — a device already on the target version is *skipped*, not failed
3. **Download the image, and confirm it landed**
4. **Reboot**
5. **Prove the reboot happened** — uptime must have gone *backwards*
6. **Verify the version moved**
7. **RouterBOOT** (optional) — a second flash and a second reboot
Steps 3 and 5 exist because of a specific failure. `/system/package/update/install`
bundles download and reboot into one call whose progress cannot be observed, and a
CCR2216 was seen to accept it, do neither, and report nothing. That surfaced as
"rebooted but still reports 7.24.1", because nothing could tell a device that never
restarted from one that restarted unchanged.
Downloading first makes success verifiable *before* anything reboots, so a device is
never restarted for an image it does not have.
!!! note "Downloads are slow, and that is normal"
`/system/package/update/download` blocks until the image has landed — a minute or
more on an ordinary link. It is given a ten-minute budget. If the command does not
return cleanly the device is asked again on a fresh connection what actually
happened, rather than being declared failed: a slow download is not a broken one.
## Waves
Waves are the point. Devices are assigned to wave 1, 2 or 3, and **waves run strictly
in sequence**. Wave 1 is your canary: put one device in it, let it finish, and the
rest of the fleet is still untouched if it goes wrong.
**Halt on failure** stops the rollout rather than continuing into the next wave.
Devices never reached are marked `skipped`, not `failed` — they did not run.
A device that comes back on the *old* version counts as a failure.
## Upgrading several devices at once
By default the devices inside a wave upgrade **one at a time**. That is safe and slow:
a CRS switch can take five minutes to reboot, so ten of them is most of an hour spent
waiting.
The **At once** control raises how many devices in the same wave may upgrade
simultaneously. Waves remain sequential either way, so the canary still gates
everything after it.
There are two costs, and both are worth understanding before you raise it.
**Halting becomes less precise.** A failure stops any further device from *starting*,
immediately — but devices already running finish, because interrupting one mid-write is
how you brick it. So with **At once** set to five, up to five devices can carry a bad
build before anything stops. At the default of 1 nothing else has started, so a failure
stops the rollout exactly as it always has.
**Devices reboot together.** That is fine for independent routers. It is not fine for
a switch that the others are reached *through*: rebooting it alongside them cuts the
path to devices that are mid-upgrade. Change Guard does not cover this — it protects
configuration, and a device that is unreachable because its uplink rebooted has no
configuration change to revert.
The rollout builder warns when it can see that one selected device carries another's
traffic. That warning is deliberately conservative: it fires only when a link is the
device's spanning-tree root port *and* that port resolves to exactly one neighbour.
Discovered topology is often ambiguous — a trunk port can resolve to several
neighbours at once — and naming the wrong switch would be worse than saying nothing.
**Absence of a warning is not proof that it is safe to reboot everything together.**
A reasonable pattern for a large fleet on a well-known patch release:
| Wave | Devices | At once |
|---|---|---|
| 1 | one representative device | 1 |
| 2 | the access layer | 5–10 |
| 3 | anything upstream of wave 2 | 1 |
## Scheduling and cancelling
A rollout can be scheduled; pair it with a maintenance window by scheduling inside
one. One rollout runs at a time.
Devices already belonging to an unfinished rollout are refused by name when you try
to create another for them, before anything is written.
**Cancelling never interrupts an in-flight flash.** It stops before the *next* device,
because interrupting a device mid-write is how you brick it. The UI says so when you
press the button — the currently upgrading device will finish first.
## When it fails
Failure messages name the mechanism rather than the symptom. "Device did not finish
downloading within ten minutes. Nothing was rebooted. The device reports 91.6 MB free"
tells you where to look; "upgrade failed" does not.
If a rollout reports a device stuck at the old version, check the device's own
`/system/package/update` state before re-running — it may have the image already.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.24.0-beta",
"version": "0.24.5-beta",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,172 @@
import { useQuery } from '@tanstack/react-query';
import { X, FileText, GitCompare, Download, Loader2, AlertCircle } from 'lucide-react';
import { format } from 'date-fns';
import clsx from 'clsx';
import { backupsApi } from '../../services/api';
import { lineDiff } from '../../utils/lineDiff';
/**
* Reads a backup in place, or compares two (#134).
*
* Backups are `/export compact` output — plain RouterOS script, not the binary
* `.backup` blob — so both are just text. The diff reuses the same lineDiff
* that Config History already uses, rather than introducing a second one.
*/
interface Props {
/** One id previews; two compare, oldest first. */
ids: number[];
onClose: () => void;
onDownload: (id: number, filename: string) => void;
}
function errorText(e: unknown): string {
const err = e as { response?: { data?: { error?: string } }; message?: string };
return err.response?.data?.error ?? err.message ?? 'Could not read this backup.';
}
export default function BackupViewerModal({ ids, onClose, onDownload }: Props) {
const comparing = ids.length === 2;
const single = useQuery({
queryKey: ['backup-content', ids[0]],
queryFn: () => backupsApi.content(ids[0]).then((r) => r.data),
enabled: !comparing && ids.length === 1,
retry: false,
});
const pair = useQuery({
queryKey: ['backup-diff', ids[0], ids[1]],
queryFn: () => backupsApi.diff(ids[0], ids[1]).then((r) => r.data),
enabled: comparing,
retry: false,
});
const loading = comparing ? pair.isLoading : single.isLoading;
const error = comparing ? pair.error : single.error;
const rows = pair.data ? lineDiff(pair.data.from.text, pair.data.to.text) : [];
const added = rows.filter((r) => r.type === 'add').length;
const removed = rows.filter((r) => r.type === 'del').length;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={onClose}
>
<div
className="card w-full max-w-5xl max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3 p-4 border-b border-gray-200 dark:border-slate-700">
{comparing
? <GitCompare className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
: <FileText className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />}
<div className="min-w-0 flex-1">
{comparing ? (
<>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Comparing backups
</h3>
{pair.data && (
// The filename lives here rather than in the table: it is how you
// would refer to this file to someone else, so it belongs where
// you are actually looking at the file.
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 break-all mt-0.5">
{pair.data.from.filename} → {pair.data.to.filename}
</p>
)}
</>
) : (
<>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{single.data?.device_name ?? 'Backup'}
</h3>
{single.data && (
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 break-all mt-0.5">
{single.data.filename}
</p>
)}
</>
)}
{!comparing && single.data && (
<p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
{format(new Date(single.data.created_at), 'MMM d, yyyy HH:mm')}
{single.data.notes ? ` · ${single.data.notes}` : ''}
</p>
)}
{comparing && pair.data && (
<p className="text-xs mt-0.5">
<span className="text-green-600 dark:text-green-400">+{added}</span>{' '}
<span className="text-red-600 dark:text-red-400">−{removed}</span>
<span className="text-gray-400 dark:text-slate-500">
{' '}· {format(new Date(pair.data.from.created_at), 'MMM d HH:mm')}
{' → '}{format(new Date(pair.data.to.created_at), 'MMM d HH:mm')}
</span>
</p>
)}
</div>
{!comparing && single.data && (
<button
onClick={() => onDownload(single.data!.id, single.data!.filename)}
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20"
title="Download"
>
<Download className="w-4 h-4" />
</button>
)}
<button
onClick={onClose}
className="p-1.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-4">
{loading && (
<div className="flex items-center justify-center gap-2 py-16 text-gray-400">
<Loader2 className="w-4 h-4 animate-spin" /> Reading…
</div>
)}
{!loading && error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<AlertCircle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-600 dark:text-red-400">{errorText(error)}</p>
</div>
)}
{!loading && !error && comparing && (
added + removed === 0 ? (
<p className="text-sm text-gray-400 py-8 text-center">
These two backups are identical.
</p>
) : (
<pre className="text-xs font-mono bg-gray-50 dark:bg-slate-900/60 border border-gray-200 dark:border-slate-700 rounded-lg p-3 leading-relaxed">
{rows.map((row, i) => (
<div
key={i}
className={clsx(
'whitespace-pre-wrap',
row.type === 'add' && 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400',
row.type === 'del' && 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400',
row.type === 'ctx' && 'text-gray-500 dark:text-slate-400'
)}
>
{row.type === 'add' ? '+' : row.type === 'del' ? '−' : ' '} {row.text}
</div>
))}
</pre>
)
)}
{!loading && !error && !comparing && single.data && (
<pre className="text-xs font-mono bg-gray-50 dark:bg-slate-900/60 border border-gray-200 dark:border-slate-700 rounded-lg p-3 leading-relaxed whitespace-pre-wrap">
{single.data.content}
</pre>
)}
</div>
</div>
</div>
);
}
+293 -18
View File
@@ -1,12 +1,25 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react';
import {
HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, AlertTriangle,
Loader2, X, Search, GitCompare, FileText, FilterX,
} from 'lucide-react';
import { backupsApi, devicesApi } from '../services/api';
import { useCanWrite } from '../hooks/useCanWrite';
import type { Backup } from '../types';
import BackupViewerModal from '../components/backups/BackupViewerModal';
import { orderForDiff } from '../utils/backupCompare';
import { format } from 'date-fns';
import clsx from 'clsx';
/** One home for backup-type wording, used by the filter and the table badge. */
const TYPE_LABEL: Record<string, string> = {
'config-snapshot': 'Config Snapshot',
scheduled: 'Scheduled',
manual: 'Manual',
'pre-upgrade': 'Pre-upgrade',
};
function formatBytes(bytes?: number): string {
if (!bytes) return '—';
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -25,11 +38,63 @@ export default function BackupsPage() {
const [snapshotDeleteTarget, setSnapshotDeleteTarget] = useState<Backup | null>(null);
const [error, setError] = useState('');
// Filters (#134). Held together so the query key is a single object.
const [filters, setFilters] = useState({ deviceId: '', type: '', from: '', to: '', search: '' });
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const clearFilters = () => setFilters({ deviceId: '', type: '', from: '', to: '', search: '' });
const [selected, setSelected] = useState<number[]>([]);
const [viewing, setViewing] = useState<number[] | null>(null);
const [bulkConfirm, setBulkConfirm] = useState(false);
const { data: backups = [], isLoading } = useQuery({
queryKey: ['backups'],
queryFn: () => backupsApi.list().then((r) => r.data),
queryKey: ['backups', filters],
queryFn: () => backupsApi.list(filters).then((r) => r.data),
});
const { data: types = [] } = useQuery({
queryKey: ['backup-types'],
queryFn: () => backupsApi.types().then((r) => r.data),
});
// What a bulk delete would actually remove, including the config snapshots
// that go with it. Fetched before asking, so the confirmation can be specific
// rather than "are you sure?".
const { data: bulkPreview } = useQuery({
queryKey: ['backup-bulk-preview', selected],
queryFn: () => backupsApi.bulkDeletePreview(selected).then((r) => r.data),
enabled: bulkConfirm && selected.length > 0,
});
const bulkDeleteMutation = useMutation({
mutationFn: () => backupsApi.bulkDelete(selected).then((r) => r.data),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
queryClient.invalidateQueries({ queryKey: ['backup-types'] });
setBulkConfirm(false);
setSelected([]);
if (res.failures.length > 0) {
setError(`${res.deleted} deleted, ${res.failures.length} failed.`);
}
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(msg || 'Bulk delete failed');
setBulkConfirm(false);
},
});
const toggle = (id: number) =>
setSelected((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]));
const allShownSelected = backups.length > 0 && backups.every((b) => selected.includes(b.id));
const toggleAll = () =>
setSelected(allShownSelected ? [] : backups.map((b) => b.id));
// Exactly two, oldest first, so the diff reads forwards in time (tested in
// utils/backupCompare — a reversed diff shows additions as deletions and
// looks perfectly normal while doing it).
const comparePair = orderForDiff(selected, backups);
const { data: devices = [] } = useQuery({
queryKey: ['devices'],
queryFn: () => devicesApi.list().then((r) => r.data),
@@ -169,16 +234,110 @@ export default function BackupsPage() {
</div>
)}
{/* Filters (#134) */}
<div className="card p-3 flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-[180px]">
<label className="label text-[11px]">Search</label>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="input pl-8"
placeholder="Device, filename or note…"
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
/>
</div>
</div>
<div className="min-w-[150px]">
<label className="label text-[11px]">Device</label>
<select
className="input"
value={filters.deviceId}
onChange={(e) => setFilters((f) => ({ ...f, deviceId: e.target.value }))}
>
<option value="">All devices</option>
{devices.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
<div className="min-w-[140px]">
<label className="label text-[11px]">Type</label>
<select
className="input"
value={filters.type}
onChange={(e) => setFilters((f) => ({ ...f, type: e.target.value }))}
>
<option value="">All types</option>
{types.map((t) => (
<option key={t.type} value={t.type}>{TYPE_LABEL[t.type] ?? t.type} ({t.count})</option>
))}
</select>
</div>
<div>
<label className="label text-[11px]">From</label>
<input type="date" className="input"
value={filters.from}
onChange={(e) => setFilters((f) => ({ ...f, from: e.target.value }))} />
</div>
<div>
<label className="label text-[11px]">To</label>
<input type="date" className="input"
value={filters.to}
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value }))} />
</div>
{activeFilterCount > 0 && (
<button onClick={clearFilters} className="btn-secondary flex items-center gap-1.5 text-xs">
<FilterX className="w-3.5 h-3.5" /> Clear
</button>
)}
</div>
{/* Selection actions */}
{selected.length > 0 && (
<div className="card px-4 py-2.5 flex flex-wrap items-center gap-3 border-blue-200 dark:border-blue-800">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{selected.length} selected
</span>
<button onClick={() => setSelected([])} className="text-xs text-gray-400 hover:text-gray-600">
Clear
</button>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => comparePair && setViewing(comparePair)}
disabled={!comparePair}
title={comparePair ? 'Compare the two selected backups' : 'Select exactly two backups to compare'}
className="btn-secondary flex items-center gap-1.5 text-xs disabled:opacity-40"
>
<GitCompare className="w-3.5 h-3.5" /> Compare
</button>
{canWrite && (
<button
onClick={() => setBulkConfirm(true)}
className="btn-danger flex items-center gap-1.5 text-xs"
>
<Trash2 className="w-3.5 h-3.5" /> Delete {selected.length}
</button>
)}
</div>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
) : backups.length === 0 ? (
<div className="card p-12 flex flex-col items-center gap-4 text-center">
<HardDrive className="w-16 h-16 text-gray-300 dark:text-slate-600" />
<div>
<p className="font-medium text-gray-700 dark:text-slate-300">No backups yet</p>
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
Create a backup to save your device configurations
<p className="font-medium text-gray-700 dark:text-slate-300">
{activeFilterCount > 0 ? 'No backups match these filters' : 'No backups yet'}
</p>
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
{activeFilterCount > 0
? 'Try widening the date range or clearing a filter.'
: 'Create a backup to save your device configurations'}
</p>
{activeFilterCount > 0 && (
<button onClick={clearFilters} className="btn-secondary mt-3 text-xs">Clear filters</button>
)}
</div>
</div>
) : (
@@ -187,24 +346,46 @@ export default function BackupsPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700">
<th className="table-header px-3 py-2.5 w-9">
<input
type="checkbox"
aria-label="Select all shown"
checked={allShownSelected}
onChange={toggleAll}
className="cursor-pointer"
/>
</th>
<th className="table-header px-4 py-2.5 text-left">Device</th>
<th className="table-header px-4 py-2.5 text-left">Filename</th>
<th className="table-header px-4 py-2.5 text-left">Type</th>
<th className="table-header px-4 py-2.5 text-left">Size</th>
<th className="table-header px-4 py-2.5 text-left">Created</th>
<th className="table-header px-4 py-2.5 text-left">Notes</th>
<th className="table-header px-4 py-2.5 text-left w-32">Actions</th>
<th className="table-header px-4 py-2.5 text-left w-36">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
{backups.map((backup) => (
<tr key={backup.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
<tr
key={backup.id}
onClick={() => setViewing([backup.id])}
title="Click to preview"
className={clsx(
'cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700/30',
selected.includes(backup.id) && 'bg-blue-50/60 dark:bg-blue-900/10'
)}
>
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select ${backup.filename}`}
checked={selected.includes(backup.id)}
onChange={() => toggle(backup.id)}
className="cursor-pointer"
/>
</td>
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
{backup.device_name || '—'}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
{backup.filename}
</td>
<td className="px-4 py-2.5">
<span
className={clsx(
@@ -222,11 +403,7 @@ export default function BackupsPage() {
: undefined
}
>
{backup.backup_type === 'config-snapshot'
? 'Config Snapshot'
: backup.backup_type === 'scheduled'
? 'Scheduled'
: 'Manual'}
{TYPE_LABEL[backup.backup_type ?? ''] ?? backup.backup_type ?? 'Manual'}
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
@@ -238,8 +415,15 @@ export default function BackupsPage() {
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500 italic">
{backup.notes || ''}
</td>
<td className="px-4 py-2.5">
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1">
<button
onClick={() => setViewing([backup.id])}
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
title="Preview"
>
<FileText className="w-3.5 h-3.5" />
</button>
<button
onClick={() => downloadBackup(backup.id, backup.filename)}
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
@@ -312,6 +496,97 @@ export default function BackupsPage() {
</div>
)}
{viewing && (
<BackupViewerModal
ids={viewing}
onClose={() => setViewing(null)}
onDownload={downloadBackup}
/>
)}
{/* Bulk delete confirmation.
Backups and their config snapshots are deliberately one artifact, so a
bulk delete can remove restore points the operator never looked at.
The confirmation therefore names exactly what goes, per device, and
says plainly that there is no undo. */}
{bulkConfirm && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="card w-full max-w-lg">
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Delete {selected.length} backup{selected.length === 1 ? '' : 's'}?
</h3>
<button
onClick={() => setBulkConfirm(false)}
disabled={bulkDeleteMutation.isPending}
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-4">
{!bulkPreview ? (
<div className="flex items-center gap-2 text-sm text-gray-400 py-2">
<Loader2 className="w-4 h-4 animate-spin" /> Checking what this removes…
</div>
) : (
<>
<div className="text-sm text-gray-600 dark:text-slate-300">
<ul className="space-y-1">
{bulkPreview.devices.map((d) => (
<li key={d.name} className="flex justify-between gap-4">
<span className="text-gray-900 dark:text-white">{d.name}</span>
<span className="text-gray-400">{d.count} backup{d.count === 1 ? '' : 's'}</span>
</li>
))}
</ul>
</div>
{bulkPreview.snapshots > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3">
<p className="text-sm text-amber-800 dark:text-amber-300">
<span className="font-semibold">{bulkPreview.snapshots}</span> of these
{bulkPreview.snapshots === 1 ? ' is' : ' are'} tied to a{' '}
<span className="font-medium">Config History snapshot</span>. Deleting
{bulkPreview.snapshots === 1 ? ' it' : ' them'} also removes
{bulkPreview.snapshots === 1 ? ' that snapshot' : ' those snapshots'} and the
ability to roll back to {bulkPreview.snapshots === 1 ? 'it' : 'them'}.
</p>
</div>
)}
<p className="text-sm text-gray-500 dark:text-slate-400">
This cannot be undone.
</p>
</>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={() => setBulkConfirm(false)}
disabled={bulkDeleteMutation.isPending}
className="btn-secondary"
>
Cancel
</button>
<button
onClick={() => bulkDeleteMutation.mutate()}
disabled={bulkDeleteMutation.isPending || !bulkPreview}
className="btn-danger inline-flex items-center gap-2 disabled:opacity-50"
>
{bulkDeleteMutation.isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <Trash2 className="w-4 h-4" />}
Delete {bulkPreview?.backups ?? selected.length}
{bulkPreview && bulkPreview.snapshots > 0 ? ' + snapshots' : ''}
</button>
</div>
</div>
</div>
</div>
)}
{/* Confirm deletion of a snapshot-linked backup (removes the config snapshot too) */}
{snapshotDeleteTarget && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
+87 -6
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ArrowUpCircle, RefreshCw, CheckCircle, XCircle, AlertTriangle, Clock,
HardDrive, ShieldAlert, Rocket, Ban, ChevronRight, Cpu,
HardDrive, ShieldAlert, Rocket, Ban, ChevronRight, Cpu, Zap,
} from 'lucide-react';
import clsx from 'clsx';
import { firmwareApi } from '../services/api';
@@ -48,9 +48,22 @@ function RolloutPanel({ rolloutId, canWrite }: { rolloutId: number; canWrite: bo
},
});
// The API answers a cancel with a note explaining that the device currently
// being upgraded is never interrupted mid-write. That note was discarded, so
// pressing Cancel looked like it did nothing at all (#138).
const [cancelNote, setCancelNote] = useState<string | null>(null);
const cancelMutation = useMutation({
mutationFn: () => firmwareApi.cancelRollout(rolloutId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fw-rollout', rolloutId] }),
mutationFn: () => firmwareApi.cancelRollout(rolloutId).then(r => r.data),
onSuccess: (data) => {
setCancelNote(
data?.note ?? 'Rollout cancelled. No further devices will be upgraded.'
);
qc.invalidateQueries({ queryKey: ['fw-rollout', rolloutId] });
},
onError: (e: unknown) => {
const err = e as { response?: { data?: { error?: string } } };
setCancelNote(err.response?.data?.error ?? 'Could not cancel the rollout.');
},
});
if (!rollout) return null;
@@ -74,15 +87,28 @@ function RolloutPanel({ rolloutId, canWrite }: { rolloutId: number; canWrite: bo
{rollout.pre_backup && <span className="flex items-center gap-1"><HardDrive className="w-3 h-3" />pre-backup</span>}
{rollout.halt_on_failure && <span className="flex items-center gap-1"><ShieldAlert className="w-3 h-3" />halt on failure</span>}
{rollout.routerboot_after && <span className="flex items-center gap-1"><Cpu className="w-3 h-3" />+ RouterBOOT</span>}
{(rollout.wave_concurrency ?? 1) > 1 && (
<span className="flex items-center gap-1" title="Devices in a wave upgrading at once">
<Zap className="w-3 h-3" />{rollout.wave_concurrency} at once
</span>
)}
{canWrite && active && (
<button onClick={() => cancelMutation.mutate()} disabled={cancelMutation.isPending}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<Ban className="w-3 h-3" />Cancel
<button onClick={() => cancelMutation.mutate()} disabled={cancelMutation.isPending || !!cancelNote}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50">
<Ban className="w-3 h-3" />
{cancelMutation.isPending ? 'Cancelling…' : cancelNote ? 'Cancelling' : 'Cancel'}
</button>
)}
</span>
</div>
{cancelNote && (
<div className="px-5 py-2 flex items-start gap-2 text-xs bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300 border-b border-amber-200 dark:border-amber-900">
<Ban className="w-3.5 h-3.5 mt-px flex-shrink-0" />
<span>{cancelNote}</span>
</div>
)}
{waves.map(w => (
<div key={w}>
<div className="px-5 py-1.5 bg-gray-50 dark:bg-slate-800/50 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-slate-500">
@@ -127,6 +153,9 @@ export default function FirmwarePage() {
const [haltOnFailure, setHaltOnFailure] = useState(true);
// Off by default: it is a second flash and a second reboot per device (#113).
const [routerbootAfter, setRouterbootAfter] = useState(false);
// 1 = sequential, the long-standing behaviour. Raising it trades canary
// strictness for wall-clock time, which is the operator's call (#135).
const [waveConcurrency, setWaveConcurrency] = useState(1);
const [scheduleAt, setScheduleAt] = useState('');
const [viewRolloutId, setViewRolloutId] = useState<number | null>(null);
const [checkResult, setCheckResult] = useState<string | null>(null);
@@ -174,6 +203,7 @@ export default function FirmwarePage() {
halt_on_failure: haltOnFailure,
pre_backup: preBackup,
routerboot_after: routerbootAfter,
wave_concurrency: waveConcurrency,
scheduled_at: scheduleAt ? new Date(scheduleAt).toISOString() : null,
start: !scheduleAt,
devices: [...selected.entries()].map(([device_id, wave]) => ({ device_id, wave })),
@@ -196,6 +226,17 @@ export default function FirmwarePage() {
const setWave = (id: number, wave: number) => setSelected(prev => new Map(prev).set(id, wave));
const selectedCount = selected.size;
// Advisory only: which selected devices other selected devices reach the
// network through. Discovered topology is incomplete, so this warns rather
// than blocks (#135).
const selectedIds = useMemo(() => [...selected.keys()].sort((a, b) => a - b), [selected]);
const { data: upstreamCheck } = useQuery({
queryKey: ['fw-upstream', selectedIds],
queryFn: () => firmwareApi.upstreamCheck(selectedIds).then(r => r.data),
enabled: waveConcurrency > 1 && selectedIds.length > 1,
});
const upstreamWarning = upstreamCheck?.upstream ?? [];
const waveSummary = useMemo(() => {
const byWave = new Map<number, number>();
for (const w of selected.values()) byWave.set(w, (byWave.get(w) ?? 0) + 1);
@@ -356,11 +397,51 @@ export default function FirmwarePage() {
<input type="checkbox" className="w-4 h-4 rounded" checked={routerbootAfter} onChange={e => setRouterbootAfter(e.target.checked)} />
Then RouterBOOT
</label>
<label
className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300"
title="How many devices in the same wave may upgrade at once. 1 upgrades them one at a time."
>
<Zap className="w-4 h-4 text-gray-400" />
At once
<select
className="input py-1 text-xs w-auto"
value={waveConcurrency}
onChange={e => setWaveConcurrency(parseInt(e.target.value, 10))}
>
{[1, 2, 3, 5, 8, 10].map(n => (
<option key={n} value={n}>{n === 1 ? '1 — one at a time' : n}</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300">
<Clock className="w-4 h-4 text-gray-400" />
<input type="datetime-local" className="input py-1 text-xs w-auto" value={scheduleAt} onChange={e => setScheduleAt(e.target.value)} />
</label>
</div>
{waveConcurrency > 1 && (
<div className="flex items-start gap-2 text-xs rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 px-3 py-2 text-amber-800 dark:text-amber-300">
<ShieldAlert className="w-3.5 h-3.5 mt-px flex-shrink-0" />
<div className="space-y-1">
<p>
Up to {waveConcurrency} devices in a wave will reboot at the same time, and a
failure stops the rollout at the <em>end</em> of the wave — so up to{' '}
{waveConcurrency} devices can take a bad build before it halts.
</p>
{upstreamWarning.length > 0 && (
<p>
<span className="font-semibold">
{upstreamWarning.map(u => u.name).join(', ')}
</span>{' '}
{upstreamWarning.length === 1 ? 'carries' : 'carry'} traffic for other selected
devices. Rebooting {upstreamWarning.length === 1 ? 'it' : 'them'} alongside those
devices can cut the path to them mid-upgrade. Consider putting{' '}
{upstreamWarning.length === 1 ? 'it' : 'them'} in a later wave.
</p>
)}
</div>
</div>
)}
<div className="flex items-center gap-3">
<button className="btn-primary flex items-center gap-2" disabled={createRollout.isPending}
onClick={() => createRollout.mutate()}>
+58 -3
View File
@@ -892,6 +892,8 @@ export interface FirmwareDeviceRow {
export interface FirmwareRollout {
id: number; name: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
halt_on_failure: boolean; pre_backup: boolean; routerboot_after: boolean;
/** Devices per wave allowed to upgrade at once; 1 is sequential (#135). */
wave_concurrency?: number;
scheduled_at: string | null; started_at: string | null; finished_at: string | null; created_at: string;
device_count?: number; success_count?: number; failed_count?: number;
}
@@ -912,13 +914,21 @@ export const firmwareApi = {
createRollout: (data: {
name: string; halt_on_failure: boolean; pre_backup: boolean; routerboot_after?: boolean;
scheduled_at?: string | null; start?: boolean;
/** Devices per wave allowed to upgrade at once; 1 is sequential (#135). */
wave_concurrency?: number;
devices: { device_id: number; wave: number }[];
}) => api.post<{ id: number }>('/firmware/rollouts', data),
/** Advisory: selected devices that other selected devices depend on. */
upstreamCheck: (ids: number[]) =>
api.get<{ upstream: { id: number; name: string }[]; maxConcurrency: number }>(
'/firmware/rollouts/upstream-check', { params: { ids: ids.join(',') } }
),
listRollouts: () => api.get<FirmwareRollout[]>('/firmware/rollouts'),
getRollout: (id: number) =>
api.get<FirmwareRollout & { devices: FirmwareRolloutDevice[] }>(`/firmware/rollouts/${id}`),
startRollout: (id: number) => api.post(`/firmware/rollouts/${id}/start`),
cancelRollout: (id: number) => api.post(`/firmware/rollouts/${id}/cancel`),
cancelRollout: (id: number) =>
api.post<{ ok: boolean; note?: string }>(`/firmware/rollouts/${id}/cancel`),
changelog: (version: string) =>
api.get<{ version: string; url: string; text: string }>(`/firmware/changelog/${encodeURIComponent(version)}`),
};
@@ -964,15 +974,60 @@ export const operationsApi = {
syncAll: () => api.post<OpsActionResult>('/operations/sync-all', undefined, { timeout: 180_000 }),
};
export interface BackupFilters {
deviceId?: number | string;
type?: string;
from?: string;
to?: string;
search?: string;
}
export interface BackupContent {
id: number;
filename: string;
device_name: string;
device_id: number;
backup_type: string | null;
notes: string | null;
size_bytes: number | null;
created_at: string;
content: string;
}
export interface BackupDiffSide {
id: number;
filename: string;
device_name: string;
created_at: string;
text: string;
}
export const backupsApi = {
list: (deviceId?: number) =>
api.get<Backup[]>('/backups', { params: deviceId ? { deviceId } : {} }),
list: (filters: BackupFilters = {}) => {
// Drop empty values so an unset filter doesn't become `?type=`.
const params = Object.fromEntries(
Object.entries(filters).filter(([, v]) => v !== undefined && v !== '')
);
return api.get<Backup[]>('/backups', { params });
},
types: () => api.get<{ type: string; count: number }[]>('/backups/types'),
create: (deviceId: number, notes?: string) =>
api.post<Backup>('/backups', { deviceId, notes }),
download: (id: number) =>
api.get(`/backups/${id}/download`, { responseType: 'blob' }),
content: (id: number) => api.get<BackupContent>(`/backups/${id}/content`),
diff: (fromId: number, toId: number) =>
api.get<{ from: BackupDiffSide; to: BackupDiffSide }>(`/backups/${fromId}/diff/${toId}`),
restore: (id: number) => api.post(`/backups/${id}/restore`),
delete: (id: number) => api.delete(`/backups/${id}`),
bulkDeletePreview: (ids: number[]) =>
api.get<{ backups: number; snapshots: number; devices: { name: string; count: number }[] }>(
'/backups/bulk-delete/preview', { params: { ids: ids.join(',') } }
),
bulkDelete: (ids: number[]) =>
api.post<{ deleted: number; requested: number; failures: { id: number; error: string }[] }>(
'/backups/bulk-delete', { ids }
),
};
// ─── Config History ────────────────────────────────────────────────────────────
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { orderForDiff, type ComparableBackup } from './backupCompare';
const backups: ComparableBackup[] = [
{ id: 10, created_at: '2026-09-01T10:00:00Z' },
{ id: 20, created_at: '2026-09-05T10:00:00Z' },
{ id: 30, created_at: '2026-09-08T10:00:00Z' },
];
describe('orderForDiff', () => {
// The property that matters: whichever order the user clicked in, the diff
// reads forwards in time. Selecting newest-then-oldest must not invert it.
it('always returns [older, newer]', () => {
expect(orderForDiff([10, 30], backups)).toEqual([10, 30]);
expect(orderForDiff([30, 10], backups)).toEqual([10, 30]);
expect(orderForDiff([30, 20], backups)).toEqual([20, 30]);
});
it('refuses anything other than a pair', () => {
expect(orderForDiff([], backups)).toBeNull();
expect(orderForDiff([10], backups)).toBeNull();
expect(orderForDiff([10, 20, 30], backups)).toBeNull();
});
// Two backups taken in the same second is unlikely but possible; the result
// must still be stable rather than depending on click order.
it('is stable when timestamps tie', () => {
const tied: ComparableBackup[] = [
{ id: 7, created_at: '2026-09-01T10:00:00Z' },
{ id: 9, created_at: '2026-09-01T10:00:00Z' },
];
expect(orderForDiff([9, 7], tied)).toEqual([7, 9]);
expect(orderForDiff([7, 9], tied)).toEqual([7, 9]);
});
it('falls back to id when a backup is missing or undated', () => {
expect(orderForDiff([99, 98], [])).toEqual([98, 99]);
const bad: ComparableBackup[] = [
{ id: 1, created_at: 'not-a-date' },
{ id: 2, created_at: 'not-a-date' },
];
expect(orderForDiff([2, 1], bad)).toEqual([1, 2]);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Ordering two selected backups for comparison (#134).
*
* A diff is directional: rendered the wrong way round, every addition reads as a
* deletion and vice versa. Nothing about the output looks broken when this is
* backwards, which is exactly why it is worth pinning down — selection order in
* the table is whatever the user happened to click, and carries no meaning.
*/
export interface ComparableBackup {
id: number;
created_at: string;
}
/**
* Returns [older, newer] so the diff reads forwards in time.
*
* Returns null unless exactly two are selected: comparing one thing, or three,
* is not a diff, and the caller should disable the action rather than guess.
*/
export function orderForDiff(
selectedIds: number[],
backups: ComparableBackup[]
): [number, number] | null {
if (selectedIds.length !== 2) return null;
const at = (id: number): number => {
const b = backups.find((x) => x.id === id);
const t = b ? new Date(b.created_at).getTime() : NaN;
return Number.isNaN(t) ? 0 : t;
};
const [a, b] = selectedIds;
if (at(a) === at(b)) {
// Same timestamp, or both unknown: fall back to id, which still increases
// with time, so the direction stays meaningful rather than arbitrary.
return a <= b ? [a, b] : [b, a];
}
return at(a) < at(b) ? [a, b] : [b, a];
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.24.0 Beta';
export const APP_VERSION = 'v0.24.5 Beta';
+2
View File
@@ -65,6 +65,8 @@ nav:
- Alerting: alerting.md
- SSH keys: ssh-keys.md
- Bulk commands: commands.md
- Firmware rollouts: firmware.md
- Backups: backups.md
- Devices:
- Cellular (LTE): cellular.md
- CAPsMAN: capsman.md