Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4db0dc1e2 | ||
|
|
5b926d637b | ||
|
|
7a4979e0e1 | ||
|
|
1a4b635052 | ||
|
|
d9fa120abc | ||
|
|
6bc7480b89 | ||
|
|
6c3d2aaa35 | ||
|
|
df8f6dca44 | ||
|
|
719563dd22 | ||
|
|
63b5677522 |
@@ -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'
|
||||
|
||||
@@ -145,3 +145,4 @@ vite.config.ts.timestamp-*
|
||||
# macOS
|
||||
.DS_Store
|
||||
site/
|
||||
.secrets-local/
|
||||
|
||||
@@ -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).
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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,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,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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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,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,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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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**.
|
||||
@@ -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 |
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = 'v0.24.0 Beta';
|
||||
export const APP_VERSION = 'v0.24.5 Beta';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user