From 5c0ec8cc5ab22b5f115243fc7829ea1645da5a23 Mon Sep 17 00:00:00 2001 From: bot Date: Sat, 4 Apr 2026 12:47:43 +0300 Subject: [PATCH] Add deauth detection, Alerts tab, Search tab, node detail cards, UI improvements --- README.md | 56 +++++-- burst.png | Bin 0 -> 22330 bytes coordinator/dashboard.py | 214 ++++++++++++++++++++++++++- coordinator/static/dashboard.css | 158 ++++++++++++++++++-- coordinator/static/dashboard.js | 208 ++++++++++++++++++++++++++ coordinator/templates/dashboard.html | 74 +++++++-- firmware/node/config.h | 1 + 7 files changed, 668 insertions(+), 43 deletions(-) create mode 100644 burst.png diff --git a/README.md b/README.md index 876a7f6..7e595fd 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,26 @@ ## What this is -A distributed WiFi reconnaissance system using ESP32-S3 nodes and an Orange Pi as coordinator. +A distributed WiFi reconnaissance system using ESP32-S3 nodes and an Orange Pi as coordinator. The goal is passive RF awareness — mapping what is happening in the surrounding WiFi environment without transmitting or interacting with any networks. Each ESP32 node scans the WiFi environment, builds structured events (not raw data), and ships them over UDP to a coordinator. The coordinator stores events to SQLite. A separate web dashboard reads the database and displays everything in real time via SSE. +The system is designed around a clear separation of concerns: nodes are dumb sensors, the Orange Pi does all storage and analysis, the browser does all rendering. No logic lives on the nodes beyond capturing and formatting events. + +Practical use cases this has already demonstrated: +- Mapping every network and client device in a city-center apartment building +- Detecting sustained deauth flood attacks targeting specific devices (confirmed live) +- Reconstructing device travel history from probe request SSID lists +- Identifying device types by OUI (phones, IoT devices, laptops, cars, smart home hardware) +- Correlating attack victims across multiple impersonated APs using deauth dst MAC + This is a learning/research project covering distributed systems, event-driven architecture, sensor fusion, and detection engineering concepts. --- ## Hardware -- **Nodes:** ESP32-S3 N16R8 (2 active, scaling later) +- **Nodes:** ESP32-S3 N16R8 (3 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 @@ -206,15 +215,16 @@ The same binary works on every node — `node_id` is auto-derived from the MAC, ### config.h reference -| Constant | Default | Purpose | -|-------------------|------------------|-------------------------------------------| -| `WIFI_SSID` | `sandbox` | WiFi network to connect to | -| `COORDINATOR_IP` | `192.168.1.133` | Orange Pi address | -| `COORDINATOR_PORT`| `5005` | UDP port | -| `SCAN_INTERVAL_MS`| `15000` | How often to run a beacon scan (ms) | -| `PROBE_SNIFF` | `1` | Enable/disable probe sniffing (1/0) | -| `PROBE_DEDUP_SECS`| `30` | Suppress duplicate probe MAC+SSID (secs) | -| `PROBE_QUEUE_SIZE`| `32` | Max probe events buffered between scans | +| Constant | Default | Purpose | +|--------------------|------------------|-------------------------------------------| +| `WIFI_SSID` | `sandbox` | WiFi network to connect to | +| `COORDINATOR_IP` | `192.168.1.133` | Orange Pi address | +| `COORDINATOR_PORT` | `5005` | UDP port | +| `SCAN_INTERVAL_MS` | `15000` | How often to run a beacon scan (ms) | +| `PROBE_SNIFF` | `1` | Enable/disable probe sniffing (1/0) | +| `PROBE_DEDUP_SECS` | `30` | Suppress duplicate probe MAC+SSID (secs) | +| `PROBE_QUEUE_SIZE` | `32` | Max probe events buffered between scans | +| `DEAUTH_QUEUE_SIZE`| `32` | Max deauth/disassoc events buffered | --- @@ -249,20 +259,32 @@ Open `http://192.168.1.133:8080` in your browser. - **RSSI history chart** — opens from any Networks row. Shows signal strength over time per node (1h / 2h / 6h / 24h selectable). Each node gets its own coloured line. Useful for spotting interference patterns, seeing how signal fluctuates by time of day, and comparing which node consistently hears a given AP better. Updates live as new scans arrive. - **Cross-node** — side-by-side per-node RSSI for every AP. Shows which node is physically closer to each network. Filter to multi-node only to focus on confirmed cross-node observations. - **Clients** — probe request data: unique MACs, vendor (OUI lookup), what SSIDs they're searching for, signal, which nodes saw them. -- **Alerts** — deauth/disassoc frame detection with two sections: - - *Detected Bursts* — fires when ≥10 deauth or disassoc frames are seen for the same BSSID within a 5-minute window. Highlighted red when confirmed by 2+ nodes (high confidence, source is physically nearby). Burst rows include BSSID, SSID, frame count, unique source MACs, and node count. - - *Raw Feed* — last 100 deauth/disassoc events with src, dst, BSSID, reason code, and RSSI. Useful for inspecting specific events. `dst = FF:FF:FF:FF:FF:FF` (broadcast deauth) is a classic attack tool signature. +- **Alerts** — deauth/disassoc frame detection with four sections: + - *1h Summary strip* — total frames, deauth vs disassoc counts, unique BSSIDs and sources in the last hour + - *Most Impersonated Networks* — APs whose BSSID is being spoofed as the deauth source, ranked by frame count + - *Most Targeted Devices* — actual victim devices (by `dst` MAC), ranked by frames received across all impersonated APs + - *Detected Bursts* — fires when ≥10 frames for the same BSSID within 5 minutes. Highlighted red when confirmed by 2+ nodes. Reason code 2 at volume is the primary attack indicator. + - *Raw Feed* — last 100 deauth/disassoc events with full src/dst/BSSID/reason/RSSI detail. +- **Search** — cross-table lookup by any identifier. Supports MAC address, SSID, BSSID, and vendor name (e.g. "Raspberry", "Tuya", "Intel"). Returns all matching data across beacon, probe, and deauth tables in labelled sections. Vendor name search works by matching against the OUI database and finding all devices with those MAC prefixes. - **Presence** — three sections driven entirely by probe data: - *Present Now* — real (non-randomized) MACs seen 2+ times in the last hour. Filters out the city-center noise of transient devices. - *New Arrivals* — devices and networks first seen in the last 24 hours, split into two side-by-side tables. - *Regulars* — devices and networks seen on 2 or more distinct calendar days. Takes at least two days of data to populate. - **Node detail** — click a node in the sidebar for stats: total events, unique SSIDs/BSSIDs, RSSI range, first/last seen, uptime, free heap, AP signal, last 100 events. +### Node detail + +Click any node in the sidebar to open the detail view. Ten stat cards arranged in a 2×5 grid show: status, total events, unique SSIDs, unique BSSIDs, avg RSSI, first seen, last seen, uptime, free heap, and AP signal. Below the cards is a table of the last 100 events for that node. + ### Sidebar -Always visible: node list with online/offline status dot, last seen time, event count, and a compact heartbeat line (uptime · free heap · AP signal). +Always visible: node list with online/offline status dot, last seen time, event count, and a compact heartbeat line (uptime · free heap · AP signal). Nodes are sorted alphabetically by node ID so the order stays fixed as more nodes are added. A node is considered **online** if a heartbeat was received within the last 30 seconds. Falls back to beacon timestamp (60s threshold) if no heartbeat has been received yet. +### UI + +Font: Roboto (Google Fonts). Chosen for readability at small sizes — the previous monospace font felt too thin at the smaller label sizes used throughout the dashboard. + ### Live updates SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node, presence, alerts) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests. @@ -313,6 +335,10 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa - [x] RSSI history chart per network (canvas, per-node coloured lines, 1h/2h/6h/24h range) - [x] Presence tab — Present Now, New Arrivals, Regulars - [x] Deauth/disassoc frame detection — burst detection with multi-node confirmation, Alerts tab +- [x] Alerts tab — most impersonated networks, most targeted devices, burst detection, raw feed +- [x] Search tab — cross-table lookup by MAC, SSID, BSSID, or vendor name +- [x] Node detail view — 2×5 stat cards with animated border, fixed alphabetical sidebar order +- [ ] Attack session reconstruction — group deauth events into discrete sessions by source/time - [ ] Scan interval control from dashboard - [ ] DB pruning / retention policy (events.db grows indefinitely) diff --git a/burst.png b/burst.png new file mode 100644 index 0000000000000000000000000000000000000000..96cad3347f25e1c1c4ad760fa1e4798a9858baae GIT binary patch literal 22330 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 list[dict]: MIN(received_at) AS first_seen FROM beacon_events GROUP BY node_id - ORDER BY last_beacon DESC + ORDER BY node_id ASC """) # Latest heartbeat per node hb_rows = query(""" @@ -410,13 +410,214 @@ def build_alerts() -> dict: WHERE received_at > ? """, (cutoff_1h,)) + # Most targeted BSSIDs — all time + top_targets = query(""" + SELECT + bssid, + COUNT(*) AS total_frames, + COUNT(DISTINCT src) AS unique_srcs, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, + MAX(received_at) AS last_seen + FROM deauth_events + GROUP BY bssid + ORDER BY total_frames DESC + LIMIT 10 + """) + if top_targets: + bssid_list = [t["bssid"] for t in top_targets] + placeholders = ",".join("?" * len(bssid_list)) + ssid_rows = query(f""" + SELECT bssid, MAX(ssid) AS ssid + FROM beacon_events WHERE bssid IN ({placeholders}) + GROUP BY bssid + """, tuple(bssid_list)) + ssid_map = {r["bssid"]: r["ssid"] for r in ssid_rows} + for t in top_targets: + t["ssid"] = ssid_map.get(t["bssid"]) + + # Most targeted devices — all time (grouped by dst, the actual victim) + top_targeted_devices = query(""" + SELECT + dst, + COUNT(*) AS total_frames, + COUNT(DISTINCT bssid) AS networks_used, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, + MAX(received_at) AS last_seen + FROM deauth_events + GROUP BY dst + ORDER BY total_frames DESC + LIMIT 10 + """) + for d in top_targeted_devices: + vendor, randomized = oui_lookup(d["dst"]) + d["vendor"] = vendor + d["randomized"] = randomized + 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, } +def build_search(q: str) -> dict: + if not q or len(q) < 2: + return {} + like = f"%{q}%" + results = {} + + # As a network — BSSID or SSID match in beacon_events + as_network = query(""" + SELECT bssid, MAX(ssid) AS ssid, + COUNT(*) AS times_seen, + MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi, + MAX(channel) AS channel, MAX(encryption) AS encryption, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, MAX(received_at) AS last_seen + FROM beacon_events + WHERE bssid LIKE ? OR ssid LIKE ? + GROUP BY bssid ORDER BY times_seen DESC LIMIT 20 + """, (like, like)) + if as_network: + results["as_network"] = as_network + + # As a client device — src_mac match in probe_events + as_client = query(""" + SELECT src_mac, + COUNT(*) AS times_seen, + MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, MAX(received_at) AS last_seen + FROM probe_events + WHERE src_mac LIKE ? + GROUP BY src_mac LIMIT 10 + """, (like,)) + if as_client: + ssid_rows = query(""" + SELECT DISTINCT src_mac, ssid FROM probe_events + WHERE src_mac LIKE ? AND ssid IS NOT NULL AND ssid != '' + """, (like,)) + ssids_by_mac: dict = {} + for s in ssid_rows: + ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"]) + for r in as_client: + vendor, randomized = oui_lookup(r["src_mac"]) + r["vendor"] = vendor + r["randomized"] = randomized + r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], []) + results["as_client"] = as_client + + # Devices probing for this SSID + probing_for = query(""" + SELECT src_mac, + COUNT(*) AS times_seen, + MAX(rssi) AS best_rssi, + MIN(received_at) AS first_seen, MAX(received_at) AS last_seen + FROM probe_events + WHERE ssid LIKE ? + GROUP BY src_mac ORDER BY times_seen DESC LIMIT 20 + """, (like,)) + if probing_for: + for r in probing_for: + vendor, randomized = oui_lookup(r["src_mac"]) + r["vendor"] = vendor + r["randomized"] = randomized + results["probing_for"] = probing_for + + # As a deauth victim — dst match + as_victim = query(""" + SELECT dst, + COUNT(*) AS total_frames, + COUNT(DISTINCT bssid) AS networks_used, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, MAX(received_at) AS last_seen + FROM deauth_events + WHERE dst LIKE ? + GROUP BY dst LIMIT 10 + """, (like,)) + if as_victim: + for r in as_victim: + vendor, randomized = oui_lookup(r["dst"]) + r["vendor"] = vendor + r["randomized"] = randomized + results["as_victim"] = as_victim + + # As an impersonated AP — bssid match in deauth_events + as_impersonated = query(""" + SELECT bssid, + COUNT(*) AS total_frames, + COUNT(DISTINCT dst) AS unique_targets, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, MAX(received_at) AS last_seen + FROM deauth_events + WHERE bssid LIKE ? + GROUP BY bssid LIMIT 10 + """, (like,)) + if as_impersonated: + results["as_impersonated"] = as_impersonated + + # Vendor name search — match query against OUI database, then find those MACs + q_lower = q.lower() + matching_prefixes = [ + prefix for prefix, vendor in _OUI_DB.items() + if q_lower in vendor.lower() + ][:60] # cap at 60 prefixes to keep query manageable + + if matching_prefixes: + ph = ",".join("?" * len(matching_prefixes)) + + vendor_clients = query(f""" + SELECT src_mac, + COUNT(*) AS times_seen, + MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, MAX(received_at) AS last_seen + FROM probe_events + WHERE substr(src_mac,1,8) IN ({ph}) + GROUP BY src_mac ORDER BY times_seen DESC LIMIT 30 + """, tuple(matching_prefixes)) + if vendor_clients: + ssid_rows = query(f""" + SELECT DISTINCT src_mac, ssid FROM probe_events + WHERE substr(src_mac,1,8) IN ({ph}) + AND ssid IS NOT NULL AND ssid != '' + """, tuple(matching_prefixes)) + ssids_by_mac: dict = {} + for s in ssid_rows: + ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"]) + for r in vendor_clients: + vendor, randomized = oui_lookup(r["src_mac"]) + r["vendor"] = vendor + r["randomized"] = randomized + r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], []) + results["vendor_clients"] = vendor_clients + + vendor_networks = query(f""" + SELECT bssid, MAX(ssid) AS ssid, + COUNT(*) AS times_seen, + MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi, + MAX(channel) AS channel, MAX(encryption) AS encryption, + COUNT(DISTINCT node_id) AS node_count, + MIN(received_at) AS first_seen, MAX(received_at) AS last_seen + FROM beacon_events + WHERE substr(bssid,1,8) IN ({ph}) + GROUP BY bssid ORDER BY times_seen DESC LIMIT 30 + """, tuple(matching_prefixes)) + if vendor_networks: + results["vendor_networks"] = vendor_networks + + if "vendor_clients" in results or "vendor_networks" in results: + results["vendor_name"] = next( + (v for k, v in _OUI_DB.items() if q_lower in v.lower()), q + ) + + return results + + def build_node_detail(node_id: str) -> dict | None: rows = query(""" SELECT @@ -567,6 +768,11 @@ async def api_rssi_history(bssid: str, hours: int = 2): return series +@app.get("/api/search") +async def api_search(q: str = ""): + return build_search(q) + + @app.get("/api/alerts") async def api_alerts(): return build_alerts() diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css index 2fd8807..d0cf57e 100644 --- a/coordinator/static/dashboard.css +++ b/coordinator/static/dashboard.css @@ -19,7 +19,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text); - font-family: 'IBM Plex Mono', monospace; + font-family: 'Roboto', sans-serif; font-size: 13px; line-height: 1.5; } @@ -83,7 +83,7 @@ html, body { } .tab-btn { - font-family: 'IBM Plex Mono', monospace; + font-family: 'Roboto', sans-serif; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; @@ -228,7 +228,7 @@ html, body { .range-btns { display: flex; gap: 4px; } .range-btn { - font-family: 'IBM Plex Mono', monospace; + font-family: 'Roboto', sans-serif; font-size: 10px; letter-spacing: 0.08em; background: none; @@ -242,7 +242,7 @@ html, body { .range-btn.active { color: var(--green-hi); border-color: var(--green-dim); } .chart-btn { - font-family: 'IBM Plex Mono', monospace; + font-family: 'Roboto', sans-serif; font-size: 10px; background: none; border: 1px solid var(--border); @@ -272,27 +272,118 @@ html, body { /* ── Node detail stats ── */ #detail-stats-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-template-columns: repeat(5, 1fr); gap: 10px; } -.stat-card { - background: var(--bg-panel); - border: 1px solid var(--border); - padding: 12px 14px; + +.stat-outer { + border-radius: 8px; + padding: 1px; + background: radial-gradient(circle 120px at 0% 0%, var(--green-mid), var(--bg)); + position: relative; + min-height: 90px; } + +.stat-dot { + width: 4px; + aspect-ratio: 1; + position: absolute; + background: var(--green-live); + box-shadow: 0 0 7px var(--green-live); + border-radius: 50%; + z-index: 2; + right: 10%; + top: 10%; + animation: statDotMove 30s linear infinite; +} +.stat-outer:nth-child(1) .stat-dot { animation-delay: 0s; } +.stat-outer:nth-child(2) .stat-dot { animation-delay: -1s; } +.stat-outer:nth-child(3) .stat-dot { animation-delay: -2s; } +.stat-outer:nth-child(4) .stat-dot { animation-delay: -3s; } +.stat-outer:nth-child(5) .stat-dot { animation-delay: -4s; } +.stat-outer:nth-child(6) .stat-dot { animation-delay: -5s; } +.stat-outer:nth-child(7) .stat-dot { animation-delay: -6s; } +.stat-outer:nth-child(8) .stat-dot { animation-delay: -7s; } +.stat-outer:nth-child(9) .stat-dot { animation-delay: -8s; } +.stat-outer:nth-child(10) .stat-dot { animation-delay: -9s; } + +@keyframes statDotMove { + 0%, 100% { top: 10%; right: 10%; } + 25% { top: 10%; right: calc(100% - 18px); } + 50% { top: calc(100% - 14px); right: calc(100% - 18px); } + 75% { top: calc(100% - 14px); right: 10%; } +} + +.stat-card { + z-index: 1; + width: 100%; + height: 100%; + border-radius: 7px; + border: 1px solid var(--border); + background: radial-gradient(circle 140px at 0% 0%, #152019, var(--bg)); + display: flex; + flex-direction: column; + justify-content: center; + padding: 12px 14px; + position: relative; + overflow: hidden; +} + +.stat-ray { + width: 100px; + height: 28px; + border-radius: 100px; + position: absolute; + background: var(--green-mid); + opacity: 0.2; + box-shadow: 0 0 40px var(--green-hi); + filter: blur(8px); + top: 0; + left: 0; + transform: rotate(35deg); + transform-origin: 10%; + pointer-events: none; +} + .stat-card .label { font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text-muted); - margin-bottom: 4px; + color: var(--text); + margin-bottom: 6px; + position: relative; + z-index: 1; } .stat-card .value { - font-size: 16px; + font-size: 20px; font-weight: 500; color: var(--green-hi); + position: relative; + z-index: 1; +} +.stat-card .value-sm { font-size: 11px; } +.stat-card .sub { font-size: 10px; color: var(--text-dim); margin-top: 3px; position: relative; z-index: 1; } + +.stat-card .line { + position: absolute; + background-color: var(--border); +} +.stat-card .topl { + top: 12%; width: 100%; height: 1px; + background: linear-gradient(90deg, var(--green-dim) 20%, transparent 80%); +} +.stat-card .bottoml { + bottom: 12%; width: 100%; height: 1px; + background: linear-gradient(90deg, transparent 20%, var(--border) 80%); +} +.stat-card .leftl { + left: 8%; width: 1px; height: 100%; + background: linear-gradient(180deg, var(--green-dim) 20%, transparent 80%); +} +.stat-card .rightl { + right: 8%; width: 1px; height: 100%; + background: linear-gradient(180deg, transparent 20%, var(--border) 80%); } -.stat-card .sub { font-size: 10px; color: var(--text-dim); margin-top: 2px; } /* ── Table ── */ .tbl-wrap { overflow-x: auto; } @@ -399,6 +490,47 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); } td.deauth-type { color: #e74c3c; } td.disassoc-type { color: var(--orange); } +/* ── Search ── */ +.search-bar { + display: flex; + gap: 8px; +} +.search-input { + flex: 1; + background: var(--bg-panel); + border: 1px solid var(--border); + color: var(--text); + font-family: 'Roboto', sans-serif; + font-size: 13px; + padding: 8px 12px; + outline: none; + transition: border-color 0.15s; +} +.search-input:focus { border-color: var(--green-dim); } +.search-input::placeholder { color: var(--text-muted); } +.search-btn { + font-family: 'Roboto', sans-serif; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + background: none; + border: 1px solid var(--green-dim); + color: var(--green-hi); + padding: 8px 20px; + cursor: pointer; + transition: background 0.15s; +} +.search-btn:hover { background: var(--bg-hover); } +.search-section { display: flex; flex-direction: column; gap: 8px; } +.search-section-label { + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} + /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar-track { background: var(--bg); } diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js index 3055e55..814ee9b 100644 --- a/coordinator/static/dashboard.js +++ b/coordinator/static/dashboard.js @@ -471,6 +471,51 @@ function renderAlertsView() { unique sources `; + // Most targeted networks + const targetsTbody = document.getElementById('targets-tbody'); + const targetsEmpty = document.getElementById('targets-empty'); + const targetsCount = document.getElementById('targets-count'); + const topTargets = a.top_targets || []; + targetsCount.textContent = `${topTargets.length}`; + if (!topTargets.length) { + targetsTbody.innerHTML = ''; + targetsEmpty.style.display = 'block'; + } else { + targetsEmpty.style.display = 'none'; + targetsTbody.innerHTML = topTargets.map((t, i) => ` + ${fmt(t.bssid)} + ${fmt(t.ssid, '')} + ${t.total_frames} + ${t.unique_srcs} + ${t.node_count} + ${shortTime(t.last_seen)} + `).join(''); + } + + // Most active attackers + const attackersTbody = document.getElementById('attackers-tbody'); + const attackersEmpty = document.getElementById('attackers-empty'); + const attackersCount = document.getElementById('attackers-count'); + const topTargetedDevices = a.top_targeted_devices || []; + attackersCount.textContent = `${topTargetedDevices.length}`; + if (!topTargetedDevices.length) { + attackersTbody.innerHTML = ''; + attackersEmpty.style.display = 'block'; + } else { + attackersEmpty.style.display = 'none'; + attackersTbody.innerHTML = topTargetedDevices.map(d => { + const vendorCls = d.randomized ? 'muted' : 'dim'; + return ` + ${fmt(d.dst)} + ${d.vendor || '—'} + ${d.total_frames} + ${d.networks_used} + ${d.node_count} + ${shortTime(d.last_seen)} + `; + }).join(''); + } + // Bursts const burstsTbody = document.getElementById('bursts-tbody'); const burstsEmpty = document.getElementById('bursts-empty'); @@ -648,6 +693,165 @@ function renderRssiChart(series) { `).join(''); } +// ── Search view ─────────────────────────────────────────────────────────────── +async function doSearch() { + const q = document.getElementById('search-input').value.trim(); + if (q.length < 2) return; + const data = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then(r => r.json()); + renderSearchResults(data, q); +} + +function renderSearchResults(data, q) { + const container = document.getElementById('search-results'); + const empty = document.getElementById('search-empty'); + container.querySelectorAll('.search-section').forEach(el => el.remove()); + + if (!Object.keys(data).length) { + empty.textContent = `No data found for "${q}".`; + empty.style.display = 'block'; + return; + } + empty.style.display = 'none'; + + // Helper to append a section + function addSection(title, html) { + const div = document.createElement('div'); + div.className = 'search-section'; + div.innerHTML = `${html}`; + container.appendChild(div); + } + + // As a network + if (data.as_network) { + const rows = data.as_network.map(r => { + const encCls = (!r.encryption || r.encryption === 'OPEN') ? 'enc-open' : 'muted'; + return ` + ${fmt(r.ssid, '')} + ${fmt(r.bssid)} + ${fmt(r.best_rssi)} dBm + ${fmt(r.channel)} + ${r.encryption || 'OPEN'} + ${fmt(r.times_seen)} + ${r.node_count} + ${shortTime(r.first_seen)} + ${shortTime(r.last_seen)} + `; + }).join(''); + addSection('As a Network', `
+ + ${rows}
SSIDBSSIDBest RSSIChEncTimes SeenNodesFirst SeenLast Seen
`); + } + + // As a client device + if (data.as_client) { + const rows = data.as_client.map(r => { + const probing = r.probed_ssids.length + ? r.probed_ssids.join(', ') + : 'wildcard only'; + return ` + ${fmt(r.src_mac)} + ${r.vendor || '—'} + ${probing} + ${fmt(r.best_rssi)} dBm + ${fmt(r.times_seen)} + ${shortTime(r.first_seen)} + ${shortTime(r.last_seen)} + `; + }).join(''); + addSection('As a Client Device (Probe Requests)', `
+ + ${rows}
MACVendorProbing ForBest RSSITimes SeenFirst SeenLast Seen
`); + } + + // Devices probing for this SSID + if (data.probing_for) { + const rows = data.probing_for.map(r => ` + ${fmt(r.src_mac)} + ${r.vendor || '—'} + ${fmt(r.best_rssi)} dBm + ${fmt(r.times_seen)} + ${shortTime(r.first_seen)} + ${shortTime(r.last_seen)} + `).join(''); + addSection('Devices Probing for This SSID', `
+ + ${rows}
MACVendorBest RSSITimes SeenFirst SeenLast Seen
`); + } + + // As a deauth victim + if (data.as_victim) { + const rows = data.as_victim.map(r => ` + ${fmt(r.dst)} + ${r.vendor || '—'} + ${r.total_frames} + ${r.networks_used} + ${r.node_count} + ${shortTime(r.first_seen)} + ${shortTime(r.last_seen)} + `).join(''); + addSection('As a Deauth Victim', `
+ + ${rows}
MACVendorFrames ReceivedNetworks UsedNodesFirst SeenLast Seen
`); + } + + // Vendor — client devices + if (data.vendor_clients) { + const label = data.vendor_name || q; + const rows = data.vendor_clients.map(r => { + const probing = r.probed_ssids.length + ? r.probed_ssids.join(', ') + : 'wildcard only'; + return ` + ${fmt(r.src_mac)} + ${r.vendor || '—'} + ${probing} + ${fmt(r.best_rssi)} dBm + ${fmt(r.times_seen)} + ${shortTime(r.last_seen)} + `; + }).join(''); + addSection(`Vendor Match — Client Devices (${label})`, `
+ + ${rows}
MACVendorProbing ForBest RSSITimes SeenLast Seen
`); + } + + // Vendor — networks / APs + if (data.vendor_networks) { + const label = data.vendor_name || q; + const rows = data.vendor_networks.map(r => { + const encCls = (!r.encryption || r.encryption === 'OPEN') ? 'enc-open' : 'muted'; + return ` + ${fmt(r.ssid, '')} + ${fmt(r.bssid)} + ${fmt(r.best_rssi)} dBm + ${fmt(r.channel)} + ${r.encryption || 'OPEN'} + ${fmt(r.times_seen)} + ${r.node_count} + ${shortTime(r.last_seen)} + `; + }).join(''); + addSection(`Vendor Match — Networks / APs (${label})`, `
+ + ${rows}
SSIDBSSIDBest RSSIChEncTimes SeenNodesLast Seen
`); + } + + // As an impersonated AP + if (data.as_impersonated) { + const rows = data.as_impersonated.map(r => ` + ${fmt(r.bssid)} + ${r.total_frames} + ${r.unique_targets} + ${r.node_count} + ${shortTime(r.first_seen)} + ${shortTime(r.last_seen)} + `).join(''); + addSection('As an Impersonated AP (Deauth Source)', `
+ + ${rows}
BSSIDFrames SentUnique TargetsNodesFirst SeenLast Seen
`); + } +} + // ── View switching ──────────────────────────────────────────────────────────── function setView(view) { currentView = view; @@ -659,12 +863,14 @@ function setView(view) { document.getElementById('view-detail').classList.toggle('active', view === 'detail'); document.getElementById('view-presence').classList.toggle('active', view === 'presence'); document.getElementById('view-alerts').classList.toggle('active', view === 'alerts'); + document.getElementById('view-search').classList.toggle('active', view === 'search'); document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail'); document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart'); document.getElementById('tab-crossnode').classList.toggle('active', view === 'crossnode'); document.getElementById('tab-clients').classList.toggle('active', view === 'clients'); document.getElementById('tab-presence').classList.toggle('active', view === 'presence'); document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts'); + document.getElementById('tab-search').classList.toggle('active', view === 'search'); } function showFeed() { @@ -685,6 +891,8 @@ async function showPresence() { setView('presence'); await fetchPresence(); } async function showAlerts() { setView('alerts'); await fetchAlerts(); } +function showSearch() { setView('search'); document.getElementById('search-input').focus(); } + async function showDetail(node_id) { currentNode = node_id; setView('detail'); diff --git a/coordinator/templates/dashboard.html b/coordinator/templates/dashboard.html index d26b482..e4b33b6 100644 --- a/coordinator/templates/dashboard.html +++ b/coordinator/templates/dashboard.html @@ -6,7 +6,7 @@ ESP32 Recon - + @@ -29,6 +29,7 @@ + @@ -84,16 +85,16 @@
-
Status
-
Total Events
-
Unique SSIDs
-
Unique BSSIDs
-
Avg RSSI
-
First Seen
-
Last Seen
-
Uptime
-
Free Heap
-
AP Signal
node → router
+
Status
+
Total Events
+
Unique SSIDs
+
Unique BSSIDs
+
Avg RSSI
+
First Seen
+
Last Seen
+
Uptime
+
Free Heap
+
AP Signal
node → router
@@ -311,6 +312,44 @@
+ +
+
+
+ Most Impersonated Networks + + all time · APs spoofed as deauth source +
+
+ + + + + + +
BSSIDSSIDFramesUnique SrcsNodesLast Seen
+ +
+
+
+
+ Most Targeted Devices + + all time · actual victim devices by dst MAC +
+
+ + + + + + +
Dst MACVendorFramesNetworks UsedNodesLast Seen
+ +
+
+
+
Detected Bursts @@ -346,6 +385,19 @@
+ + +
diff --git a/firmware/node/config.h b/firmware/node/config.h index b9b05ec..6947544 100644 --- a/firmware/node/config.h +++ b/firmware/node/config.h @@ -18,3 +18,4 @@ #define PROBE_SNIFF 1 // 1 = enabled, 0 = disabled #define PROBE_DEDUP_SECS 30 // suppress same MAC+SSID within this window (seconds) #define PROBE_QUEUE_SIZE 32 // max probe events buffered between scan cycles +#define DEAUTH_QUEUE_SIZE 32 // max deauth/disassoc events buffered between scan cycles