diff --git a/README.md b/README.md index 7e595fd..489e92c 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,19 @@ This is a learning/research project covering distributed systems, event-driven a ## Hardware -- **Nodes:** ESP32-S3 N16R8 (3 active, scattered across apartment) +- **Nodes:** ESP32-S3 N16R8 (4 active, scattered across apartment) - **Coordinator:** Orange Pi at `192.168.1.133`, runs 24/7 as systemd services - **Dev machine:** This PC at `192.168.1.101` — coding and flashing only - **Flashing:** Arduino CLI on this PC ### Active nodes -| node_id | MAC | Port | -|----------|-------------------|--------------| -| F68D6E30 | 44:1b:f6:8d:6e:30 | /dev/ttyACM0 | -| A1D6F190 | e0:72:a1:d6:f1:90 | /dev/ttyACM1 | -| A1D658D4 | e0:72:a1:d6:58:d4 | /dev/ttyACM2 | +| node_id | MAC | Location / Port | +|----------|-------------------|------------------------| +| F68D6E30 | 44:1b:f6:8d:6e:30 | desk /dev/ttyACM0 | +| A1D658D4 | e0:72:a1:d6:58:d4 | desk /dev/ttyACM1 | +| A1D700C4 | e0:72:a1:d7:00:c4 | desk /dev/ttyACM2 | +| A1D6F190 | e0:72:a1:d6:f1:90 | deployed (another room)| --- @@ -324,13 +325,13 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa - [x] Arduino CLI installed, ESP32 core configured - [x] Node firmware: beacon scan + probe sniffing -- [x] Three ESP32-S3 nodes flashed and running +- [x] Four ESP32-S3 nodes flashed and running - [x] UDP ingestor (`udp_ingest.py`) — beacon + probe routing with field validation - [x] Web dashboard: Feed, Networks, Cross-node, Clients, Node detail views - [x] OUI vendor lookup in Clients view - [x] Dashboard split into HTML / CSS / JS (no monolithic file) - [x] Deployed to Orange Pi as systemd services (runs 24/7) -- [x] All three nodes sending to Orange Pi, confirmed live +- [x] All four nodes sending to Orange Pi, confirmed live - [x] Node heartbeat every 10s — uptime, free heap, AP signal, drives online/offline status - [x] RSSI history chart per network (canvas, per-node coloured lines, 1h/2h/6h/24h range) - [x] Presence tab — Present Now, New Arrivals, Regulars diff --git a/burst.png b/burst.png deleted file mode 100644 index 96cad33..0000000 Binary files a/burst.png and /dev/null differ diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py index b00db67..f10731e 100644 --- a/coordinator/dashboard.py +++ b/coordinator/dashboard.py @@ -9,7 +9,7 @@ import sqlite3 import datetime import os import json -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse DB_PATH = os.path.join(os.path.dirname(__file__), "events.db") @@ -20,6 +20,46 @@ OUI_PATH = os.path.join(os.path.dirname(__file__), "oui.txt") ONLINE_SECS = 30 # node considered online if heartbeat within this many seconds EVENT_CAP = 100 # max events returned / shown in dashboard +# ─── Deauth reason codes ───────────────────────────────────────────────────── + +# classification: 'normal', 'suspicious', 'attack' +DEAUTH_REASONS: dict[int, tuple[str, str]] = { + 0: ("Reserved", "normal"), + 1: ("Unspecified", "normal"), + 2: ("Previous auth no longer valid", "attack"), + 3: ("Station leaving BSS", "normal"), + 4: ("Inactivity / idle timeout", "normal"), + 5: ("Too many associated stations", "normal"), + 6: ("Class 2 frame from non-authenticated station","attack"), + 7: ("Class 3 frame from non-associated station", "attack"), + 8: ("Station leaving BSS (disassoc)", "normal"), + 9: ("Station not authenticated", "suspicious"), + 10: ("Power capability unacceptable", "normal"), + 11: ("Supported channels unacceptable", "normal"), + 13: ("Invalid information element", "suspicious"), + 14: ("MIC failure", "suspicious"), + 15: ("4-way handshake timeout", "normal"), + 16: ("Group key handshake timeout", "normal"), + 17: ("IE in 4-way handshake differs", "suspicious"), + 18: ("Invalid group cipher", "suspicious"), + 19: ("Invalid pairwise cipher", "suspicious"), + 20: ("Invalid AKMP", "suspicious"), + 21: ("Unsupported RSNE version", "suspicious"), + 22: ("Invalid RSNE capabilities", "suspicious"), + 23: ("802.1X authentication failed", "suspicious"), + 24: ("Cipher suite rejected by policy", "suspicious"), + 34: ("Low ACK", "normal"), + 39: ("BSS transition management request", "normal"), + 45: ("Peer stakey negotiation failed", "suspicious"), + 47: ("Authorized access limit reached", "normal"), + 99: ("Peer abandoned session", "normal"), + 103: ("Disassociated — excessive frames", "suspicious"), + 147: ("Vendor-specific", "normal"), +} + +def deauth_reason_info(code: int) -> tuple[str, str]: + return DEAUTH_REASONS.get(code, ("Unknown / vendor-specific", "normal")) + # ─── OUI lookup ────────────────────────────────────────────────────────────── def _load_oui(path: str) -> dict[str, str]: @@ -63,6 +103,7 @@ def oui_lookup(mac: str) -> tuple[str, bool]: def get_conn() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") return conn @@ -147,6 +188,7 @@ def build_networks() -> list[dict]: WHERE bssid IS NOT NULL GROUP BY bssid ORDER BY times_seen DESC + LIMIT 300 """) return rows @@ -169,13 +211,18 @@ def build_clients() -> list[dict]: WHERE src_mac IS NOT NULL GROUP BY src_mac ORDER BY times_seen DESC + LIMIT 300 """) - # Fetch all probed SSIDs in one query, group in Python — avoids N+1 - ssid_rows = query(""" + # Fetch SSIDs only for the MACs we actually returned + if not rows: + return [] + mac_list = [r["src_mac"] for r in rows] + placeholders = ",".join("?" * len(mac_list)) + ssid_rows = query(f""" SELECT DISTINCT src_mac, ssid FROM probe_events - WHERE src_mac IS NOT NULL AND ssid IS NOT NULL AND ssid != '' + WHERE src_mac IN ({placeholders}) AND ssid IS NOT NULL AND ssid != '' ORDER BY src_mac, ssid - """) + """, tuple(mac_list)) ssids_by_mac: dict = {} for s in ssid_rows: ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"]) @@ -199,18 +246,24 @@ def build_cross_node() -> dict: rows = query(""" SELECT - bssid, - MAX(ssid) AS ssid, - MAX(channel) AS channel, - MAX(encryption) AS encryption, - node_id, - MAX(rssi) AS best_rssi, - ROUND(AVG(rssi), 1) AS avg_rssi, - COUNT(*) AS times_seen - FROM beacon_events - WHERE bssid IS NOT NULL - GROUP BY bssid, node_id - ORDER BY bssid, node_id + b.bssid, + MAX(b.ssid) AS ssid, + MAX(b.channel) AS channel, + MAX(b.encryption) AS encryption, + b.node_id, + MAX(b.rssi) AS best_rssi, + ROUND(AVG(b.rssi), 1) AS avg_rssi, + COUNT(*) AS times_seen + FROM beacon_events b + WHERE b.bssid IN ( + SELECT bssid FROM beacon_events + WHERE bssid IS NOT NULL + GROUP BY bssid + ORDER BY COUNT(DISTINCT node_id) DESC, COUNT(*) DESC + LIMIT 300 + ) + GROUP BY b.bssid, b.node_id + ORDER BY b.bssid, b.node_id """) networks: dict = {} @@ -455,12 +508,41 @@ def build_alerts() -> dict: d["vendor"] = vendor d["randomized"] = randomized + # Reason code breakdown — all time + reason_rows = query(""" + SELECT reason, + COUNT(*) AS total_frames, + COUNT(DISTINCT bssid) AS unique_bssids, + COUNT(DISTINCT dst) AS unique_targets, + MIN(received_at) AS first_seen, + MAX(received_at) AS last_seen + FROM deauth_events + GROUP BY reason + ORDER BY total_frames DESC + """) + for r in reason_rows: + desc, classification = deauth_reason_info(r["reason"]) + r["description"] = desc + r["classification"] = classification + + # Activity heatmap — frame count per day × hour + heatmap_rows = query(""" + SELECT date(received_at) AS day, + CAST(strftime('%H', received_at) AS INTEGER) AS hour, + COUNT(*) AS frames + FROM deauth_events + GROUP BY day, hour + ORDER BY day, hour + """) + return { - "recent": recent, - "bursts": bursts, - "summary": summary[0] if summary else {}, + "recent": recent, + "bursts": bursts, + "summary": summary[0] if summary else {}, "top_targets": top_targets, "top_targeted_devices": top_targeted_devices, + "reason_stats": reason_rows, + "heatmap": heatmap_rows, } @@ -792,7 +874,6 @@ async def api_heartbeats(): async def api_node_detail(node_id: str): detail = build_node_detail(node_id) if detail is None: - from fastapi import HTTPException raise HTTPException(status_code=404, detail="Node not found") return detail diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css index d0cf57e..0672b7a 100644 --- a/coordinator/static/dashboard.css +++ b/coordinator/static/dashboard.css @@ -470,6 +470,16 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); } margin-bottom: 6px; } +/* ── Deauth heatmap ── */ +#deauth-heatmap { + display: block; + background: var(--bg-panel); + border: 1px solid var(--border); +} +.reason-normal { color: var(--text-dim); } +.reason-suspicious { color: var(--orange); } +.reason-attack { color: #e74c3c; font-weight: 500; } + /* ── Alerts ── */ .alerts-summary { display: flex; diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js index 814ee9b..21be0a3 100644 --- a/coordinator/static/dashboard.js +++ b/coordinator/static/dashboard.js @@ -438,6 +438,96 @@ function renderPresenceView() { } } +// ── Deauth heatmap ──────────────────────────────────────────────────────────── +function renderDeauthHeatmap(rows) { + const canvas = document.getElementById('deauth-heatmap'); + const empty = document.getElementById('heatmap-empty'); + const subtitle = document.getElementById('heatmap-subtitle'); + + if (!rows.length) { + canvas.style.display = 'none'; + empty.style.display = 'block'; + subtitle.textContent = ''; + return; + } + canvas.style.display = 'block'; + empty.style.display = 'none'; + + // Build lookup + const lookup = {}; + let maxFrames = 0; + rows.forEach(r => { + if (!lookup[r.day]) lookup[r.day] = {}; + lookup[r.day][r.hour] = r.frames; + if (r.frames > maxFrames) maxFrames = r.frames; + }); + const days = Object.keys(lookup).sort(); + subtitle.textContent = `${days.length} days`; + + const dpr = window.devicePixelRatio || 1; + const padL = 100, padT = 36, padR = 16, padB = 12; + const cellH = 42; + + // Fill container width exactly + const containerW = canvas.parentElement.clientWidth; + const cellW = Math.floor((containerW - padL - padR) / 24); + + const W = padL + 24 * cellW + padR; + const H = padT + days.length * cellH + padB; + + // Set physical pixel size, CSS display size + canvas.width = W * dpr; + canvas.height = H * dpr; + canvas.style.width = W + 'px'; + canvas.style.height = H + 'px'; + + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, W, H); + + // Hour labels + ctx.font = '11px Roboto, sans-serif'; + ctx.fillStyle = '#5a7a60'; + ctx.textAlign = 'center'; + for (let h = 0; h < 24; h++) { + ctx.fillText(String(h).padStart(2, '0'), padL + h * cellW + cellW / 2, padT - 10); + } + + // Day rows + days.forEach((day, di) => { + const y = padT + di * cellH; + + // Day label + ctx.font = '11px Roboto, sans-serif'; + ctx.fillStyle = '#5a7a60'; + ctx.textAlign = 'right'; + ctx.fillText(day.slice(5), padL - 8, y + cellH / 2 + 4); + + for (let h = 0; h < 24; h++) { + const frames = (lookup[day] && lookup[day][h]) || 0; + const x = padL + h * cellW; + + if (frames === 0) { + ctx.fillStyle = '#111a14'; + } else { + const t = Math.min(frames / maxFrames, 1); + const r = Math.round(80 + t * 151); + const g = Math.round(20 + t * 10); + const b = Math.round(20 + t * 10); + ctx.fillStyle = `rgb(${r},${g},${b})`; + } + ctx.fillRect(x + 1, y + 1, cellW - 2, cellH - 2); + + if (frames > 0) { + ctx.font = '11px Roboto, sans-serif'; + ctx.fillStyle = frames > maxFrames * 0.4 ? '#fff' : '#e74c3c'; + ctx.textAlign = 'center'; + ctx.fillText(frames > 999 ? '999+' : frames, x + cellW / 2, y + cellH / 2 + 4); + } + } + }); +} + // ── Alerts view ─────────────────────────────────────────────────────────────── async function fetchAlerts() { alertsData = await fetch('/api/alerts').then(r => r.json()); @@ -471,6 +561,33 @@ function renderAlertsView() { unique sources `; + // Reason code breakdown + const reasonsTbody = document.getElementById('reasons-tbody'); + const reasonsEmpty = document.getElementById('reasons-empty'); + const reasonsCount = document.getElementById('reasons-count'); + const reasons = a.reason_stats || []; + reasonsCount.textContent = `${reasons.length} codes`; + if (!reasons.length) { + reasonsTbody.innerHTML = ''; + reasonsEmpty.style.display = 'block'; + } else { + reasonsEmpty.style.display = 'none'; + reasonsTbody.innerHTML = reasons.map(r => { + const cls = `reason-${r.classification}`; + const label = r.classification === 'attack' ? 'ATTACK' + : r.classification === 'suspicious' ? 'SUSPICIOUS' : 'normal'; + return `
| Code | Description | Class | +Frames | Unique BSSIDs | Unique Targets | Last Seen | +
|---|