-
+
diff --git a/backend/package.json b/backend/package.json
index 95ef0e6..1d9a5ab 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.5-beta",
+ "version": "0.23.6-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts
index f211fb1..b64980b 100644
--- a/backend/src/db/migrate.ts
+++ b/backend/src/db/migrate.ts
@@ -575,6 +575,34 @@ UPDATE capsman_radios SET current_channel = NULL WHERE length(current_channel) >
-- controller does know it, on its own mirror of the CAP interface (#94).
ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS ssid VARCHAR(128);
+-- Security check suppressions (github issue #101).
+--
+-- A heuristic you disagree with and cannot dismiss discredits the whole posture
+-- score. Cleartext API is the example: on a fleet reachable only across WireGuard,
+-- TLS adds nothing to the threat model, and the warning is permanent noise.
+-- device_id NULL means fleet-wide; a row for a specific device silences it there
+-- only. Suppressed checks are still reported, marked and excluded from the score,
+-- so nothing becomes invisible.
+CREATE TABLE IF NOT EXISTS security_check_suppressions (
+ id SERIAL PRIMARY KEY,
+ check_id VARCHAR(64) NOT NULL,
+ device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
+ reason TEXT,
+ created_by VARCHAR(50),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+-- Partial indexes because a UNIQUE constraint containing NULL does not dedupe.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_seccheck_supp_global
+ ON security_check_suppressions (check_id) WHERE device_id IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_seccheck_supp_device
+ ON security_check_suppressions (check_id, device_id) WHERE device_id IS NOT NULL;
+
+-- Which wireless stack a device runs: the RouterOS 7 'wifi' packages or the legacy
+-- 'wireless' one. Needed to answer a fleet-wide question — if nothing runs the
+-- legacy driver, the TX-retries panel can never have data and should not be shown
+-- (github issue #96).
+ALTER TABLE devices ADD COLUMN IF NOT EXISTS wifi_package VARCHAR(16);
+
-- ntfy.sh notification channel (github issue #93). The channel type is a CHECK
-- constraint rather than a lookup table, so widening it means replacing it.
ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check;
@@ -690,6 +718,9 @@ const DEFAULT_SETTINGS = [
{ key: 'change_guard_timeout_sec', value: 120 },
{ key: 'config_health_enabled', value: true },
{ key: 'config_health_interval_min', value: 60 },
+ // Geocoding and map tiles are third-party requests that also disclose device
+ // locations. Off-switch for isolated networks (issue #106).
+ { key: 'maps_enabled', value: true },
];
export async function runMigrations(): Promise {
diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts
index c858044..835257e 100644
--- a/backend/src/routes/devices.ts
+++ b/backend/src/routes/devices.ts
@@ -113,6 +113,49 @@ router.get('/routers/overview', async (_req: Request, res: Response) => {
res.json(routers);
});
+// ─── Security check suppressions (issue #101) ────────────────────────────────
+
+/**
+ * A posture check the operator has judged inapplicable — cleartext API on a fleet
+ * reachable only over WireGuard, or a missing input firewall on a switch that sits
+ * behind one. Suppressed checks stay visible and marked; they simply stop counting
+ * against the score, so the posture remains auditable.
+ *
+ * `device_id` omitted suppresses fleet-wide; supplying one silences that device only.
+ */
+router.get('/security/suppressions', async (_req: Request, res: Response) => {
+ const rows = await query(`
+ SELECT s.id, s.check_id, s.device_id, s.reason, s.created_by, s.created_at,
+ d.name AS device_name
+ FROM security_check_suppressions s
+ LEFT JOIN devices d ON d.id = s.device_id
+ ORDER BY s.check_id, d.name NULLS FIRST`);
+ res.json(rows);
+});
+
+router.post('/security/suppressions', requireWrite, async (req: Request, res: Response) => {
+ const { check_id, device_id, reason } = req.body as
+ { check_id?: string; device_id?: number | null; reason?: string };
+ if (!check_id) return res.status(400).json({ error: 'check_id is required' });
+
+ const rows = await query(
+ `INSERT INTO security_check_suppressions (check_id, device_id, reason, created_by)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT DO NOTHING
+ RETURNING *`,
+ [check_id, device_id ?? null, reason ?? null,
+ (req as unknown as { user?: { username?: string } }).user?.username ?? null]
+ );
+ // ON CONFLICT DO NOTHING returns no row when it already existed, which is not an
+ // error — the caller asked for a state that now holds either way.
+ res.status(201).json(rows[0] ?? { check_id, device_id: device_id ?? null, already: true });
+});
+
+router.delete('/security/suppressions/:id', requireWrite, async (req: Request, res: Response) => {
+ await query(`DELETE FROM security_check_suppressions WHERE id = $1`, [req.params.id]);
+ res.status(204).end();
+});
+
// GET /api/devices/discovered — unresolved MikroTik neighbors from topology_links
router.get('/discovered', async (_req: Request, res: Response) => {
const rows = await query<{
@@ -1335,13 +1378,37 @@ router.get('/:id/security-posture', async (req, res) => {
});
}
+ // Suppressions (issue #101). A finding the operator has judged inapplicable is
+ // marked rather than hidden: it stays visible, greyed, and stops counting
+ // against the score. Hiding it outright would make the posture unauditable.
+ const supp = await query<{ check_id: string; device_id: number | null; reason: string | null }>(
+ `SELECT check_id, device_id, reason FROM security_check_suppressions
+ WHERE device_id IS NULL OR device_id = $1`,
+ [device.id]
+ ).catch(() => []);
+ const suppressedBy = new Map();
+ for (const row of supp) {
+ const scope = row.device_id == null ? 'fleet' : 'device';
+ // A device-specific row wins over a fleet-wide one, so the UI can offer the
+ // narrower undo.
+ if (scope === 'device' || !suppressedBy.has(row.check_id)) {
+ suppressedBy.set(row.check_id, { scope, reason: row.reason });
+ }
+ }
+
+ const marked = checks.map((c2) => {
+ const hit = suppressedBy.get(c2.id);
+ return hit ? { ...c2, suppressed: true, suppressed_scope: hit.scope, suppressed_reason: hit.reason } : c2;
+ });
+
// Graduated score: lighter weights + a floor of 5 so it never reads a hard
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
// indicator, not an absolute grade.
const WEIGHT: Record = { high: 15, medium: 7, low: 3 };
- const penalty = checks.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
- const score = checks.length === 0 ? 100 : Math.max(5, 100 - penalty);
- return { score, checks };
+ const counted = marked.filter((c2) => !('suppressed' in c2));
+ const penalty = counted.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
+ const score = counted.length === 0 ? 100 : Math.max(5, 100 - penalty);
+ return { score, checks: marked };
});
});
diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts
index cccf721..c81e98b 100644
--- a/backend/src/routes/wireless.ts
+++ b/backend/src/routes/wireless.ts
@@ -958,6 +958,31 @@ router.get('/rf/signals', async (req: Request, res: Response) => {
res.json(rows);
});
+/**
+ * GET /api/wireless/rf/capabilities — what the fleet's wireless hardware can report.
+ *
+ * TX-retry figures are derived from per-client CCQ, which only the legacy `wireless`
+ * driver exposes. On an all-RouterOS-7 fleet that panel can never have data, so the
+ * UI needs to know rather than rendering an empty box forever (issue #96).
+ */
+router.get('/rf/capabilities', async (_req: Request, res: Response) => {
+ const rows = await query<{ wifi_package: string | null; n: string }>(`
+ SELECT wifi_package, COUNT(*) AS n
+ FROM devices
+ WHERE wifi_package IS NOT NULL AND wifi_package <> 'none'
+ GROUP BY wifi_package`);
+
+ const counts = Object.fromEntries(rows.map((r) => [r.wifi_package ?? 'unknown', Number(r.n)]));
+ const legacy = counts['wireless'] ?? 0;
+ res.json({
+ packages: counts,
+ legacyRadios: legacy,
+ // Unknown means nothing has been polled yet; show the panel rather than
+ // hiding something that may well populate a minute later.
+ txRetriesAvailable: legacy > 0 || rows.length === 0,
+ });
+});
+
// GET /api/wireless/rf/tx-quality?range=6h — latest per-radio TX retry % (from CCQ)
router.get('/rf/tx-quality', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts
index b760461..f68980f 100644
--- a/backend/src/services/mikrotik/DeviceCollector.ts
+++ b/backend/src/services/mikrotik/DeviceCollector.ts
@@ -1659,8 +1659,12 @@ export class DeviceCollector {
// Recorded per device so the UI can explain a blank SSID as delegation to a
// controller rather than leaving it looking like an unconfigured radio.
const role = await this.detectWifiRole();
- await query(`UPDATE devices SET wifi_role = $2 WHERE id = $1`, [this.device.id, role])
- .catch(() => { /* role is advisory; never fail collection over it */ });
+ // The package is recorded alongside the role because TX-retry data only
+ // exists on the legacy 'wireless' driver, and a panel that can never have
+ // data on this fleet should not occupy space (issue #96).
+ await query(`UPDATE devices SET wifi_role = $2, wifi_package = $3 WHERE id = $1`,
+ [this.device.id, role, pkg])
+ .catch(() => { /* advisory; never fail collection over it */ });
// null = the fetch itself failed. Distinguished from an empty list (device
// genuinely has no wireless interfaces) so a transient API error can never
diff --git a/frontend/package.json b/frontend/package.json
index 0a344fa..87caaf7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.5-beta",
+ "version": "0.23.6-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/device-detail/DeviceLocationSection.tsx b/frontend/src/components/device-detail/DeviceLocationSection.tsx
index cc1a467..e115ed5 100644
--- a/frontend/src/components/device-detail/DeviceLocationSection.tsx
+++ b/frontend/src/components/device-detail/DeviceLocationSection.tsx
@@ -1,11 +1,11 @@
import { useState, useEffect, useRef } from 'react';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
MapPin, Building2, Hash, FileText, Pencil, Check, X, RefreshCw, AlertCircle,
} from 'lucide-react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
-import { devicesApi } from '../../services/api';
+import { devicesApi, settingsApi } from '../../services/api';
import type { Device } from '../../types';
import { useCanWrite } from '../../hooks/useCanWrite';
@@ -78,6 +78,15 @@ function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | s
export default function DeviceLocationSection({ device }: Props) {
const queryClient = useQueryClient();
const canWrite = useCanWrite();
+ // Geocoding and tiles are third-party requests that also disclose where your
+ // devices are. Operators on isolated networks can turn the whole thing off
+ // (issue #106); coordinates already stored are still shown as plain text.
+ const { data: settings } = useQuery({
+ queryKey: ['app-settings'],
+ queryFn: () => settingsApi.get().then(r => r.data),
+ staleTime: 300_000,
+ });
+ const mapsEnabled = settings?.['maps_enabled'] !== false;
const [editing, setEditing] = useState(false);
const [geocoding, setGeocoding] = useState(false);
const [saveError, setSaveError] = useState('');
@@ -99,7 +108,7 @@ export default function DeviceLocationSection({ device }: Props) {
// Re-geocode if address changed
const addressChanged = f.location_address !== (device.location_address ?? '');
- if (f.location_address && addressChanged) {
+ if (mapsEnabled && f.location_address && addressChanged) {
setGeocoding(true);
const coords = await geocodeAddress(f.location_address);
setGeocoding(false);
@@ -263,14 +272,26 @@ export default function DeviceLocationSection({ device }: Props) {
)}
- {/* Map card — only when we have coordinates and not editing */}
- {hasMap && !editing && (
+ {/* Map card — only when we have coordinates, are not editing, and outbound
+ map requests are permitted. */}
+ {hasMap && !editing && mapsEnabled && (
)}
+ {hasMap && !editing && !mapsEnabled && (
+
+
+
+ Map display is off, so no request is made to a third-party tile or geocoding
+ service. Stored position:{' '}
+ {Number(device.location_lat).toFixed(5)}, {Number(device.location_lng).toFixed(5)}.
+ Re-enable under Settings.
+
);
})}
diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx
index 60a9275..07397b3 100644
--- a/frontend/src/components/wireless/RfHealth.tsx
+++ b/frontend/src/components/wireless/RfHealth.tsx
@@ -390,13 +390,28 @@ export function ConnectivitySuccess({ deviceId }: { deviceId?: number }) {
// ─── Combined section ─────────────────────────────────────────────────────────
export default function RfHealth({ deviceId }: { deviceId?: number }) {
+ // TX retries come from CCQ, which only the legacy `wireless` driver reports. On
+ // an all-RouterOS-7 fleet the panel can never fill, so it is dropped and the
+ // density chart takes the full width rather than sitting beside an empty box
+ // (issue #96).
+ const { data: caps } = useQuery({
+ queryKey: ['rf-capabilities'],
+ queryFn: () => wirelessApi.getRfCapabilities().then(r => r.data),
+ staleTime: 300_000,
+ });
+ const showRetries = caps?.txRetriesAvailable ?? true;
+
return (
-
+ {showRetries ? (
+
+
+
+
+ ) : (
-
-
+ )}
);
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index e1b40d1..a01041e 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -609,7 +609,19 @@ function OperationsView({
});
const thingsToHandle: OpsAttentionItem[] = insights?.attention ?? [];
const capacity: OpsCapacityRow[] = insights?.capacity ?? [];
- const activity: OpsActivityItem[] = insights?.activity ?? [];
+ const activityAll: OpsActivityItem[] = insights?.activity ?? [];
+ // The app's own write requests are audit-log noise from an operations point of
+ // view (issue #103). Remembered per browser, since it is a viewing preference.
+ const [showRequests, setShowRequests] = useState(() => {
+ try { return localStorage.getItem('dash.showRequests') === '1'; } catch { return false; }
+ });
+ const toggleRequests = () => setShowRequests((v) => {
+ const next = !v;
+ try { localStorage.setItem('dash.showRequests', next ? '1' : '0'); } catch { /* private mode */ }
+ return next;
+ });
+ const activity = showRequests ? activityAll : activityAll.filter(a => a.kind !== 'audit');
+ const hiddenRequests = activityAll.length - activity.length;
// Platform update availability (checked against GitHub, cached server-side)
const { data: versionInfo } = useQuery({
@@ -732,6 +744,35 @@ function OperationsView({
+ {/* Quick actions — in the header strip, matching the other sections (issue #104) */}
+