diff --git a/README.md b/README.md index 802d612..44b58e2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript 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/device-detail/SecurityTab.tsx b/frontend/src/components/device-detail/SecurityTab.tsx index 0a8564b..3e6e58a 100644 --- a/frontend/src/components/device-detail/SecurityTab.tsx +++ b/frontend/src/components/device-detail/SecurityTab.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock } from 'lucide-react'; +import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock, BellOff, Bell } from 'lucide-react'; import { devicesApi } from '../../services/api'; import type { SecurityCheck } from '../../services/api'; import { useCanWrite } from '../../hooks/useCanWrite'; @@ -50,7 +50,27 @@ export default function SecurityTab({ deviceId, deviceName }: { deviceId: number const invalidate = () => { qc.invalidateQueries({ queryKey: ['security-posture', deviceId] }); qc.invalidateQueries({ queryKey: ['services', deviceId] }); + qc.invalidateQueries({ queryKey: ['security-suppressions'] }); }; + // Suppression (issue #101): a heuristic the operator has judged inapplicable is + // marked and excluded from the score rather than hidden, so posture stays auditable. + const suppress = useMutation({ + mutationFn: ({ checkId, scope }: { checkId: string; scope: 'device' | 'fleet' }) => + devicesApi.suppressSecurityCheck(checkId, scope === 'device' ? deviceId : null), + onSuccess: invalidate, + }); + const { data: suppressions = [] } = useQuery({ + queryKey: ['security-suppressions'], + queryFn: () => devicesApi.listSecuritySuppressions().then(r => r.data), + }); + const unsuppress = useMutation({ + mutationFn: (id: number) => devicesApi.unsuppressSecurityCheck(id), + onSuccess: invalidate, + }); + const suppressionFor = (checkId: string) => + suppressions.find(s => s.check_id === checkId && s.device_id === deviceId) + ?? suppressions.find(s => s.check_id === checkId && s.device_id === null); + const toggleSvc = useMutation({ mutationFn: ({ id, disabled, confirm = false }: { id: string; disabled: boolean; confirm?: boolean }) => devicesApi.setServiceDisabled(deviceId, id, disabled, confirm), @@ -111,19 +131,44 @@ export default function SecurityTab({ deviceId, deviceName }: { deviceId: number {checks.map(c => { const s = SEV_STYLE[c.severity] ?? SEV_STYLE.low; return ( -
- +
+
-
+
{c.title} - {s.label} + {s.label} + {c.suppressed && ( + + muted {c.suppressed_scope === 'fleet' ? 'fleet-wide' : 'on this device'} · not counted + + )}

{c.detail}

- {canWrite && c.serviceId && ( - - )} +
+ {canWrite && c.serviceId && !c.suppressed && ( + + )} + {canWrite && (c.suppressed ? ( + + ) : ( + <> + + + + ))} +
); })} 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) */} +
+ {actions.map(({ key, label, icon: Icon, color, run }) => { + const isBusy = busy === key; + return ( + + ); + })} + {actionMsg && ( + + {actionMsg.text} + + )} +
+ {/* Platform update available */} {versionInfo?.update_available && versionInfo.latest && (
@@ -758,7 +799,7 @@ function OperationsView({
)} - {/* Things to handle + Quick actions */} + {/* Things to handle — full width now that quick actions live in the header */}
{/* Things to handle */}
@@ -808,46 +849,6 @@ function OperationsView({ )}
- {/* Quick actions */} -
-
Quick actions
-
- {actions.map(({ key, label, sub, icon: Icon, color, run }) => { - const isBusy = busy === key; - return ( - - ); - })} -
- {actionMsg && ( -
- {actionMsg.ok ? : } - {actionMsg.text} -
- )} -
{/* Capacity + Security rollup */} @@ -930,6 +931,16 @@ function OperationsView({
Recent activity +
{activity.length === 0 ? (
No recent activity.
diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index 57ab4b3..8c117da 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -95,9 +95,24 @@ function CpuValue({ value }: { value: number | undefined }) { ); } +/** + * A single-colour sparkline says nothing about magnitude — an idle device and a + * saturated one draw the same shape (issue #111). The line takes the colour of the + * band its peak falls into, and is plotted against a fixed 0–100 scale so height + * means load rather than "relative to this device's own worst moment". + */ +function loadColor(pct: number): string { + if (pct >= 85) return 'var(--bad)'; + if (pct >= 60) return 'var(--warn)'; + if (pct >= 30) return '#84cc16'; + return 'var(--accent)'; +} + function LoadSparkline({ data }: { data: number[] }) { if (data.length < 2) return —; - const max = Math.max(...data) || 1; + const peak = Math.max(...data); + // Fixed scale: a flat line low in the box is an idle device, and looks like one. + const max = 100; const w = 72, h = 20; const pts = data.map((v, i) => [ (i / (data.length - 1)) * w, @@ -106,7 +121,7 @@ function LoadSparkline({ data }: { data: number[] }) { const d = 'M' + pts.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' L'); return ( - + ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 192f951..2f6caa0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -174,6 +174,20 @@ export interface SecurityCheck { title: string; detail: string; serviceId?: string; + /** Operator judged this inapplicable — still shown, but excluded from the score. */ + suppressed?: boolean; + suppressed_scope?: 'device' | 'fleet'; + suppressed_reason?: string | null; +} + +export interface SecuritySuppression { + id: number; + check_id: string; + device_id: number | null; + device_name: string | null; + reason: string | null; + created_by: string | null; + created_at: string; } /** @@ -340,6 +354,12 @@ export const devicesApi = { setServiceDisabled: (id: number, serviceId: string, disabled: boolean, confirmLockout = false) => api.put[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`, { disabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }), + listSecuritySuppressions: () => + api.get('/devices/security/suppressions'), + suppressSecurityCheck: (check_id: string, device_id: number | null, reason?: string) => + api.post('/devices/security/suppressions', { check_id, device_id, reason }), + unsuppressSecurityCheck: (id: number) => + api.delete(`/devices/security/suppressions/${id}`), getSecurityPosture: (id: number) => api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`), // Config Health — cached findings load instantly; the scan re-reads the device. @@ -1035,6 +1055,9 @@ export const wirelessApi = { // RF Health (fleet-wide, or scoped to one AP via deviceId) getChannelUsage: (deviceId?: number) => api.get('/wireless/rf/channels', { params: { deviceId } }), + getRfCapabilities: () => + api.get<{ packages: Record; legacyRadios: number; txRetriesAvailable: boolean }>( + '/wireless/rf/capabilities'), getClientSignals: (deviceId?: number) => api.get('/wireless/rf/signals', { params: { deviceId } }), getTxQuality: (deviceId?: number, range = '6h') => diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 0546d8e..73d2e4a 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.5 Beta'; +export const APP_VERSION = 'v0.23.6 Beta';