diff --git a/README.md b/README.md index 489e92c..ba909d0 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@ This is a learning/research project covering distributed systems, event-driven a ### Active nodes -| 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)| +| node_id | MAC | Location | +|----------|-------------------|-------------------------| +| F68D6E30 | 44:1b:f6:8d:6e:30 | room 1 (permanent) | +| A1D658D4 | e0:72:a1:d6:58:d4 | room 2 (permanent) | +| A1D700C4 | e0:72:a1:d7:00:c4 | dev machine /dev/ttyACM2| +| A1D6F190 | e0:72:a1:d6:f1:90 | dev machine /dev/ttyACM1| --- @@ -124,10 +124,15 @@ esp32_cluster/ Each node: - Connects to WiFi `sandbox` - Scans for nearby APs every 15 seconds (active scan, includes hidden SSIDs) +- Hops across all 13 2.4 GHz channels (300ms dwell per channel) while sniffing - Captures probe request frames passively (promiscuous mode) between scans -- Sends a heartbeat packet every 10 seconds (uptime, free heap, WiFi RSSI to router) -- Sends beacon, probe, and heartbeat events as UDP JSON packets to the coordinator +- Captures deauth and disassoc frames (0xC0 / 0xA0) +- Captures association and reassociation request frames (0x00 / 0x20) with SSID IE parsing +- Sends a heartbeat packet every 10 seconds (uptime, free heap, WiFi RSSI to router, queue drop counters) +- Sends all events as UDP JSON packets to the coordinator - `node_id` is derived from bytes 2–5 of the ESP32's base MAC address +- Events are buffered in FreeRTOS queues and flushed once per scan cycle when back on the home channel +- If a deauth with reason code 2 is seen at RSSI ≥ -60 dBm, an alert flag triggers an immediate flush on the home channel without waiting for the next scan ### Event formats @@ -179,17 +184,37 @@ Each node: - `reason` is the 802.11 reason code (7 = class 3 frame received from non-associated station, common in attack tools) - Captured passively in the same promiscuous callback as probe requests, flushed to coordinator after each scan cycle +**Assoc / Reassoc event** (client joining a network): +```json +{ + "node_id": "F68D6E30", + "ts": 12345, + "type": "assoc", + "src": "AA:BB:CC:DD:EE:FF", + "bssid": "11:22:33:44:55:66", + "ssid": "NetworkName", + "rssi": -55 +} +``` +- `type` is `assoc` (0x00) or `reassoc` (0x20) +- SSID is parsed from the first Information Element in the frame body (IE tag 0x00) +- Captures real client→AP join events, not just passive probe searches + **Heartbeat event** (node health, sent every 10 seconds): ```json { - "node_id": "F68D6E30", - "ts": 12345, - "type": "heartbeat", - "uptime_ms": 123456, - "free_heap": 245000, - "wifi_rssi": -62 + "node_id": "F68D6E30", + "ts": 12345, + "type": "heartbeat", + "uptime_ms": 123456, + "free_heap": 245000, + "wifi_rssi": -62, + "probe_drops": 0, + "deauth_drops": 0, + "assoc_drops": 0 } ``` +- `probe_drops`, `deauth_drops`, `assoc_drops` count events lost due to queue overflow since boot. Non-zero values indicate the node is seeing more traffic than the queue sizes can absorb. - `imp`: `high` if RSSI >= -50, `low` if <= -80, else `normal` - `ssid` in probe events is the network the device is searching for — empty means wildcard (any network) @@ -216,16 +241,21 @@ 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 | -| `DEAUTH_QUEUE_SIZE`| `32` | Max deauth/disassoc events buffered | +| 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) | +| `HEARTBEAT_INTERVAL_MS` | `10000` | How often to send a heartbeat (ms) | +| `PROBE_SNIFF` | `1` | Enable/disable probe/deauth/assoc sniffing (1/0) | +| `PROBE_DEDUP_SECS` | `30` | Suppress duplicate probe MAC+SSID (secs) | +| `DEDUP_CACHE_SIZE` | `64` | Number of MAC+SSID pairs tracked for dedup | +| `PROBE_QUEUE_SIZE` | `128` | Max probe events buffered between flushes | +| `DEAUTH_QUEUE_SIZE` | `128` | Max deauth/disassoc events buffered | +| `ASSOC_QUEUE_SIZE` | `32` | Max assoc/reassoc events buffered | +| `HOP_DWELL_MS` | `300` | Ms to dwell on each channel while hopping (13ch × 300ms ≈ 4s) | +| `RSSI_ALERT_THRESHOLD` | `-60` | RSSI floor for immediate-flush deauth alert (dBm) | --- @@ -327,21 +357,28 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa - [x] Node firmware: beacon scan + probe sniffing - [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] Web dashboard: Feed, Networks, Cross-node, Clients, Alerts, Search, Presence, Node detail +- [x] OUI vendor lookup in Clients view (vendor grouping + randomized MAC detection) - [x] Dashboard split into HTML / CSS / JS (no monolithic file) - [x] Deployed to Orange Pi as systemd services (runs 24/7) - [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 +- [x] Presence tab — Present Now, New Arrivals, Regulars (fixed key mismatch bug) - [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 +- [x] Channel hopping — 13 channels at 300ms dwell, flush on homeChannel pass +- [x] Assoc/reassoc frame capture — client join events with SSID IE parsing +- [x] RSSI-triggered alert flush — immediate homeChannel flush on close-range deauth attack signature +- [x] Queue drop counters — tracked per queue in firmware, reported in heartbeat, stored in DB +- [x] SQLite indexes — received_at, node_id, bssid, src_mac, src, dst across all event tables +- [x] Dashboard query caps — 300-row limits on Clients, Networks, Cross-node to keep UI responsive - [ ] Attack session reconstruction — group deauth events into discrete sessions by source/time -- [ ] Scan interval control from dashboard +- [ ] Surface assoc events in dashboard (Sessions tab or Search results) - [ ] DB pruning / retention policy (events.db grows indefinitely) +- [ ] Scan interval control from dashboard --- diff --git a/coordinator/__pycache__/dashboard.cpython-314.pyc b/coordinator/__pycache__/dashboard.cpython-314.pyc index b44fa1a..be954bc 100644 Binary files a/coordinator/__pycache__/dashboard.cpython-314.pyc and b/coordinator/__pycache__/dashboard.cpython-314.pyc differ diff --git a/coordinator/__pycache__/udp_ingest.cpython-314.pyc b/coordinator/__pycache__/udp_ingest.cpython-314.pyc index e8c0c4d..91d86b4 100644 Binary files a/coordinator/__pycache__/udp_ingest.cpython-314.pyc and b/coordinator/__pycache__/udp_ingest.cpython-314.pyc differ diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py index f10731e..db56b71 100644 --- a/coordinator/dashboard.py +++ b/coordinator/dashboard.py @@ -778,6 +778,37 @@ def ensure_schema(): rssi INTEGER ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS assoc_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + received_at TEXT NOT NULL, + node_id TEXT, + node_ts INTEGER, + subtype TEXT, + src TEXT, + bssid TEXT, + ssid TEXT, + rssi INTEGER + ) + """) + conn.executescript(""" + CREATE INDEX IF NOT EXISTS idx_beacon_received_at ON beacon_events (received_at); + CREATE INDEX IF NOT EXISTS idx_beacon_node_id ON beacon_events (node_id); + CREATE INDEX IF NOT EXISTS idx_beacon_bssid ON beacon_events (bssid); + CREATE INDEX IF NOT EXISTS idx_probe_received_at ON probe_events (received_at); + CREATE INDEX IF NOT EXISTS idx_probe_node_id ON probe_events (node_id); + CREATE INDEX IF NOT EXISTS idx_probe_src_mac ON probe_events (src_mac); + CREATE INDEX IF NOT EXISTS idx_heartbeat_node_id ON heartbeat_events (node_id); + CREATE INDEX IF NOT EXISTS idx_deauth_received_at ON deauth_events (received_at); + CREATE INDEX IF NOT EXISTS idx_deauth_node_id ON deauth_events (node_id); + CREATE INDEX IF NOT EXISTS idx_deauth_bssid ON deauth_events (bssid); + CREATE INDEX IF NOT EXISTS idx_deauth_src ON deauth_events (src); + CREATE INDEX IF NOT EXISTS idx_deauth_dst ON deauth_events (dst); + CREATE INDEX IF NOT EXISTS idx_assoc_received_at ON assoc_events (received_at); + CREATE INDEX IF NOT EXISTS idx_assoc_node_id ON assoc_events (node_id); + CREATE INDEX IF NOT EXISTS idx_assoc_src ON assoc_events (src); + CREATE INDEX IF NOT EXISTS idx_assoc_bssid ON assoc_events (bssid); + """) conn.commit() diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css index 0672b7a..5134932 100644 --- a/coordinator/static/dashboard.css +++ b/coordinator/static/dashboard.css @@ -418,10 +418,9 @@ td.dim { color: var(--text-dim); } td.muted { color: var(--text-muted); } /* ── Badges ── */ -.badge { display: inline-block; padding: 1px 6px; font-size: 10px; letter-spacing: 0.04em; } -.badge.high { color: var(--green-live); border: 1px solid var(--green-dim); } -.badge.normal { color: var(--text-dim); border: 1px solid var(--border); } -.badge.low { color: var(--text-muted); border: 1px solid var(--text-muted); } +.imp-high { color: var(--green-live); } +.imp-normal { color: var(--text-dim); } +.imp-low { color: var(--text-muted); } /* ── RSSI colours ── */ .rssi-hi { color: var(--green-hi); } @@ -436,6 +435,20 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); } /* ── Enc / node colours ── */ .enc-open { color: var(--orange); } +.enc-secure { color: var(--green-hi); } + +tr.group-hdr td { + background: var(--bg-panel); + color: var(--green-mid); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.06em; + padding: 5px 8px; + border-top: 1px solid var(--border); +} +th.group-active { color: var(--green-hi); cursor: pointer; } +th.group-col { cursor: pointer; } +th.group-col:hover { color: var(--text); } .nodes-multi { color: var(--green-hi); } /* ── Cross-node cells ── */ diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js index 21be0a3..49dd31b 100644 --- a/coordinator/static/dashboard.js +++ b/coordinator/static/dashboard.js @@ -8,6 +8,7 @@ let networksData = []; let netSort = { col: 'times_seen', dir: 'desc' }; let crossNodeData = { node_ids: [], networks: [] }; let clientsData = []; +let clientsGroupBy = null; // null | 'vendor' | 'ssid' let presenceData = {}; let alertsData = {}; let chartBssid = null; @@ -28,7 +29,13 @@ function rssiClass(rssi) { function impBadge(imp) { const cls = ['high', 'normal', 'low'].includes(imp) ? imp : 'normal'; - return `${fmt(imp)}`; + return `${fmt(imp)}`; +} + +function encClass(enc) { + if (!enc || enc === 'OPEN' || enc === 'None') return 'enc-open'; + if (enc === 'WPA3' || enc === 'WPA2/WPA3') return 'enc-secure'; + return 'muted'; } function shortTime(iso) { @@ -174,7 +181,7 @@ function renderNetworksTable() { count.textContent = `${sorted.length} unique networks`; tbody.innerHTML = sorted.map(r => { - const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted'; + const encCls = encClass(r.encryption); const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted'; return ` ${fmt(r.ssid, '')} @@ -256,7 +263,7 @@ function renderCrossNodeTable() { tbody.innerHTML = networks.map(net => { const seenRssis = nodeIds.filter(id => net.nodes[id]).map(id => net.nodes[id].best_rssi); const globalBest = seenRssis.length ? Math.max(...seenRssis) : null; - const encCls = (!net.encryption || net.encryption === 'OPEN' || net.encryption === 'None') ? 'enc-open' : 'muted'; + const encCls = encClass(net.encryption); const nodeCls = net.node_count > 1 ? 'nodes-multi' : 'muted'; const nodeCells = nodeIds.map(id => { @@ -285,10 +292,38 @@ async function fetchClients() { renderClientsTable(); } +function clientsRow(r) { + const probing = r.probed_ssids.length + ? r.probed_ssids.map(s => `${s}`).join(', ') + : 'wildcard only'; + const vendorCls = r.randomized ? 'muted' : 'dim'; + const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted'; + return ` + ${fmt(r.src_mac)} + ${r.vendor || '—'} + ${probing} + ${fmt(r.best_rssi)} dBm + ${fmt(r.avg_rssi)} dBm + ${fmt(r.node_count)} + ${fmt(r.times_seen)} + ${shortTime(r.last_seen)} + `; +} + +function toggleClientsGroup(by) { + clientsGroupBy = (clientsGroupBy === by) ? null : by; + renderClientsTable(); +} + function renderClientsTable() { const tbody = document.getElementById('clients-tbody'); const empty = document.getElementById('clients-empty'); const count = document.getElementById('clients-count'); + const thVendor = document.getElementById('clients-th-vendor'); + const thProbing = document.getElementById('clients-th-probing'); + + if (thVendor) thVendor.className = clientsGroupBy === 'vendor' ? 'group-active' : 'group-col'; + if (thProbing) thProbing.className = clientsGroupBy === 'ssid' ? 'group-active' : 'group-col'; if (!clientsData.length) { tbody.innerHTML = ''; @@ -299,22 +334,29 @@ function renderClientsTable() { empty.style.display = 'none'; count.textContent = `${clientsData.length} unique MACs`; - tbody.innerHTML = clientsData.map(r => { - const probing = r.probed_ssids.length - ? r.probed_ssids.map(s => `${s}`).join(', ') - : 'wildcard only'; - const vendorCls = r.randomized ? 'muted' : 'dim'; - const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted'; - return ` - ${fmt(r.src_mac)} - ${r.vendor || '—'} - ${probing} - ${fmt(r.best_rssi)} dBm - ${fmt(r.avg_rssi)} dBm - ${fmt(r.node_count)} - ${fmt(r.times_seen)} - ${shortTime(r.last_seen)} - `; + if (!clientsGroupBy) { + tbody.innerHTML = clientsData.map(clientsRow).join(''); + return; + } + + // Build groups + const groups = {}; + clientsData.forEach(r => { + let key; + if (clientsGroupBy === 'vendor') { + key = r.vendor || 'Unknown'; + } else { + key = r.probed_ssids.length ? r.probed_ssids[0] : '(wildcard only)'; + } + if (!groups[key]) groups[key] = []; + groups[key].push(r); + }); + + const sorted = Object.keys(groups).sort((a, b) => a.localeCompare(b)); + tbody.innerHTML = sorted.map(key => { + const rows = groups[key]; + const hdr = `${key} — ${rows.length} device${rows.length > 1 ? 's' : ''}`; + return hdr + rows.map(clientsRow).join(''); }).join(''); } @@ -382,7 +424,7 @@ function renderPresenceView() { } else { arrNetsEmpty.style.display = 'none'; arrNetsTbody.innerHTML = newNets.map(r => { - const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted'; + const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} @@ -398,8 +440,8 @@ function renderPresenceView() { const regMacsTbody = document.getElementById('regulars-macs-tbody'); const regMacsEmpty = document.getElementById('regulars-macs-empty'); const regCount = document.getElementById('regulars-count'); - const regMacs = p.regular_macs || []; - const regNets = p.regular_networks || []; + const regMacs = p.reg_macs || []; + const regNets = p.reg_networks || []; regCount.textContent = `${regMacs.length} devices · ${regNets.length} networks`; if (!regMacs.length) { regMacsTbody.innerHTML = ''; @@ -425,7 +467,7 @@ function renderPresenceView() { } else { regNetsEmpty.style.display = 'none'; regNetsTbody.innerHTML = regNets.map(r => { - const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted'; + const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} @@ -841,7 +883,7 @@ function renderSearchResults(data, q) { // As a network if (data.as_network) { const rows = data.as_network.map(r => { - const encCls = (!r.encryption || r.encryption === 'OPEN') ? 'enc-open' : 'muted'; + const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} @@ -936,7 +978,7 @@ function renderSearchResults(data, q) { 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'; + const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} diff --git a/coordinator/templates/dashboard.html b/coordinator/templates/dashboard.html index 59a3156..26f1d36 100644 --- a/coordinator/templates/dashboard.html +++ b/coordinator/templates/dashboard.html @@ -3,7 +3,8 @@ - ESP32 Recon + ESP32 + @@ -135,8 +136,8 @@ MAC - Vendor - Probing for + Vendor ⊞ + Probing for ⊞ Best RSSI Avg RSSI Nodes diff --git a/coordinator/udp_ingest.py b/coordinator/udp_ingest.py index f989347..0af2bc6 100644 --- a/coordinator/udp_ingest.py +++ b/coordinator/udp_ingest.py @@ -50,13 +50,16 @@ def init_db(path: str) -> sqlite3.Connection: """) conn.execute(""" CREATE TABLE IF NOT EXISTS heartbeat_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - received_at TEXT NOT NULL, - node_id TEXT, - node_ts INTEGER, - uptime_ms INTEGER, - free_heap INTEGER, - wifi_rssi INTEGER + id INTEGER PRIMARY KEY AUTOINCREMENT, + received_at TEXT NOT NULL, + node_id TEXT, + node_ts INTEGER, + uptime_ms INTEGER, + free_heap INTEGER, + wifi_rssi INTEGER, + probe_drops INTEGER, + deauth_drops INTEGER, + assoc_drops INTEGER ) """) conn.execute(""" @@ -73,6 +76,38 @@ def init_db(path: str) -> sqlite3.Connection: rssi INTEGER ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS assoc_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + received_at TEXT NOT NULL, + node_id TEXT, + node_ts INTEGER, + subtype TEXT, + src TEXT, + bssid TEXT, + ssid TEXT, + rssi INTEGER + ) + """) + # Indexes for common query patterns + conn.executescript(""" + CREATE INDEX IF NOT EXISTS idx_beacon_received_at ON beacon_events (received_at); + CREATE INDEX IF NOT EXISTS idx_beacon_node_id ON beacon_events (node_id); + CREATE INDEX IF NOT EXISTS idx_beacon_bssid ON beacon_events (bssid); + CREATE INDEX IF NOT EXISTS idx_probe_received_at ON probe_events (received_at); + CREATE INDEX IF NOT EXISTS idx_probe_node_id ON probe_events (node_id); + CREATE INDEX IF NOT EXISTS idx_probe_src_mac ON probe_events (src_mac); + CREATE INDEX IF NOT EXISTS idx_heartbeat_node_id ON heartbeat_events (node_id); + CREATE INDEX IF NOT EXISTS idx_deauth_received_at ON deauth_events (received_at); + CREATE INDEX IF NOT EXISTS idx_deauth_node_id ON deauth_events (node_id); + CREATE INDEX IF NOT EXISTS idx_deauth_bssid ON deauth_events (bssid); + CREATE INDEX IF NOT EXISTS idx_deauth_src ON deauth_events (src); + CREATE INDEX IF NOT EXISTS idx_deauth_dst ON deauth_events (dst); + CREATE INDEX IF NOT EXISTS idx_assoc_received_at ON assoc_events (received_at); + CREATE INDEX IF NOT EXISTS idx_assoc_node_id ON assoc_events (node_id); + CREATE INDEX IF NOT EXISTS idx_assoc_src ON assoc_events (src); + CREATE INDEX IF NOT EXISTS idx_assoc_bssid ON assoc_events (bssid); + """) conn.commit() return conn @@ -100,8 +135,9 @@ def store_beacon(conn: sqlite3.Connection, ev: dict, received_at: str): def store_heartbeat(conn: sqlite3.Connection, ev: dict, received_at: str): conn.execute(""" INSERT INTO heartbeat_events - (received_at, node_id, node_ts, uptime_ms, free_heap, wifi_rssi) - VALUES (?, ?, ?, ?, ?, ?) + (received_at, node_id, node_ts, uptime_ms, free_heap, wifi_rssi, + probe_drops, deauth_drops, assoc_drops) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( received_at, ev.get("node_id"), @@ -109,6 +145,9 @@ def store_heartbeat(conn: sqlite3.Connection, ev: dict, received_at: str): ev.get("uptime_ms"), ev.get("free_heap"), ev.get("wifi_rssi"), + ev.get("probe_drops"), + ev.get("deauth_drops"), + ev.get("assoc_drops"), )) conn.commit() @@ -200,6 +239,37 @@ def validate_deauth(ev: dict) -> str | None: return None +def store_assoc(conn: sqlite3.Connection, ev: dict, received_at: str): + conn.execute(""" + INSERT INTO assoc_events + (received_at, node_id, node_ts, subtype, src, bssid, ssid, rssi) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + received_at, + ev.get("node_id"), + ev.get("ts"), + ev.get("type"), + ev.get("src"), + ev.get("bssid"), + ev.get("ssid", ""), + ev.get("rssi"), + )) + conn.commit() + + +def validate_assoc(ev: dict) -> str | None: + for field in ("node_id", "src", "bssid", "rssi"): + if ev.get(field) is None: + return f"missing field '{field}'" + if not _check_mac(ev["src"]): + return f"bad src format: {ev['src']}" + if not _check_mac(ev["bssid"]): + return f"bad bssid format: {ev['bssid']}" + if not _check_rssi(ev["rssi"]): + return f"rssi out of range: {ev['rssi']}" + return None + + def validate_probe(ev: dict) -> str | None: """Returns an error string if invalid, else None.""" for field in ("node_id", "src_mac", "rssi"): @@ -233,7 +303,19 @@ def handle_packet(data: bytes, addr: tuple, conn: sqlite3.Connection): received_at = datetime.datetime.utcnow().isoformat(timespec="seconds") pkt_type = ev.get("type", "beacon") - if pkt_type in ("deauth", "disassoc"): + if pkt_type in ("assoc", "reassoc"): + err = validate_assoc(ev) + if err: + print(f"[DROP] {addr[0]} {pkt_type} — {err}") + return + store_assoc(conn, ev, received_at) + node = ev.get("node_id", "?") + src = ev.get("src", "?") + bssid = ev.get("bssid", "?") + ssid = ev.get("ssid") or "" + rssi = ev.get("rssi", 0) + print(f"\033[94m[{received_at}] {node} {pkt_type.upper():<8} {src} → {bssid} \"{ssid}\" {rssi:>4}dBm{RESET}") + elif pkt_type in ("deauth", "disassoc"): err = validate_deauth(ev) if err: print(f"[DROP] {addr[0]} {pkt_type} — {err}") diff --git a/firmware/node/config.h b/firmware/node/config.h index 06399e8..cbf3417 100644 --- a/firmware/node/config.h +++ b/firmware/node/config.h @@ -21,5 +21,11 @@ #define PROBE_QUEUE_SIZE 128 // max probe events buffered between flushes #define DEAUTH_QUEUE_SIZE 128 // max deauth/disassoc events buffered between flushes +// ─── Association capture ───────────────────────────────────────────────────── +#define ASSOC_QUEUE_SIZE 32 // max assoc/reassoc events buffered between flushes + +// ─── RSSI alert threshold ──────────────────────────────────────────────────── +#define RSSI_ALERT_THRESHOLD -60 // flush immediately if attack-strength deauth seen (dBm) + // ─── Channel hopping ───────────────────────────────────────────────────────── #define HOP_DWELL_MS 300 // ms to dwell on each channel (13 ch × 300ms ≈ 4s/sweep) diff --git a/firmware/node/node.ino b/firmware/node/node.ino index 516de18..69eed41 100644 --- a/firmware/node/node.ino +++ b/firmware/node/node.ino @@ -28,16 +28,33 @@ struct ProbeEvent { }; struct DeauthEvent { - uint8_t src[6]; - uint8_t dst[6]; - uint8_t bssid[6]; - uint8_t subtype; // 0x0C = deauth, 0x0A = disassoc + uint8_t src[6]; + uint8_t dst[6]; + uint8_t bssid[6]; + uint8_t subtype; // 0xC0 = deauth, 0xA0 = disassoc uint16_t reason; + int8_t rssi; +}; + +struct AssocEvent { + uint8_t src[6]; // client MAC + uint8_t bssid[6]; // AP BSSID + char ssid[33]; // SSID the client is associating to + uint8_t subtype; // 0x00 = assoc req, 0x20 = reassoc req int8_t rssi; }; static QueueHandle_t probeQueue; static QueueHandle_t deauthQueue; +static QueueHandle_t assocQueue; + +static volatile uint32_t probeDrops = 0; +static volatile uint32_t deauthDrops = 0; +static volatile uint32_t assocDrops = 0; + +// Set in promiscuous callback when a close-range attack-signature deauth is seen. +// Checked in loop() to trigger an immediate flush rather than waiting for next hop cycle. +static volatile bool alertPending = false; // Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS struct DedupEntry { uint8_t mac[6]; char ssid[33]; uint32_t last_ms; }; @@ -107,7 +124,7 @@ static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) { memcpy(ev.src_mac, src, 6); memcpy(ev.ssid, ssid, 33); ev.rssi = (int8_t)pkt->rx_ctrl.rssi; - xQueueSend(probeQueue, &ev, 0); + if (xQueueSend(probeQueue, &ev, 0) != pdTRUE) probeDrops++; return; } @@ -124,7 +141,47 @@ static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) { ev.subtype = subtype; ev.reason = (uint16_t)d[24] | ((uint16_t)d[25] << 8); ev.rssi = (int8_t)pkt->rx_ctrl.rssi; - xQueueSend(deauthQueue, &ev, 0); + if (xQueueSend(deauthQueue, &ev, 0) != pdTRUE) deauthDrops++; + + // Strong close-range attack signature — request an immediate flush + if (ev.reason == 2 && ev.rssi >= RSSI_ALERT_THRESHOLD) { + alertPending = true; + } + return; + } + + // ── Association request (0x00) and Reassociation request (0x20) ───────── + // Association req body: capability(2) + listen_interval(2) + IEs + // Reassociation req body: capability(2) + listen_interval(2) + current_AP(6) + IEs + // SSID IE is always first IE: tag(1) + length(1) + ssid(n) + if (subtype == 0x00 || subtype == 0x20) { + uint16_t ie_offset = (subtype == 0x00) ? 28 : 34; // body start + fixed fields + if (len < ie_offset + 2) return; + + AssocEvent ev; + memcpy(ev.src, d + 10, 6); + memcpy(ev.bssid, d + 16, 6); + ev.subtype = subtype; + ev.rssi = (int8_t)pkt->rx_ctrl.rssi; + ev.ssid[0] = '\0'; + + if (d[ie_offset] == 0x00) { // SSID IE tag + uint8_t slen = d[ie_offset + 1]; + if (slen > 0 && slen <= 32 && (ie_offset + 2 + slen) <= len) { + bool printable = true; + for (uint8_t i = 0; i < slen; i++) { + if (d[ie_offset + 2 + i] < 32 || d[ie_offset + 2 + i] > 126) { + printable = false; break; + } + } + if (printable) { + memcpy(ev.ssid, d + ie_offset + 2, slen); + ev.ssid[slen] = '\0'; + } + } + } + + if (xQueueSend(assocQueue, &ev, 0) != pdTRUE) assocDrops++; return; } } @@ -149,8 +206,9 @@ void setup() { #if PROBE_SNIFF memset(dedupCache, 0, sizeof(dedupCache)); - probeQueue = xQueueCreate(PROBE_QUEUE_SIZE, sizeof(ProbeEvent)); + probeQueue = xQueueCreate(PROBE_QUEUE_SIZE, sizeof(ProbeEvent)); deauthQueue = xQueueCreate(DEAUTH_QUEUE_SIZE, sizeof(DeauthEvent)); + assocQueue = xQueueCreate(ASSOC_QUEUE_SIZE, sizeof(AssocEvent)); esp_wifi_set_promiscuous_rx_cb(promiscuous_rx_cb); esp_wifi_set_promiscuous(true); Serial.println("[PROBE] Promiscuous mode enabled"); @@ -189,7 +247,18 @@ void loop() { } #if PROBE_SNIFF - advanceHop(); + // Alert path — close-range attack deauth detected; flush immediately + // rather than waiting up to 4s for the next natural homeChannel pass. + if (alertPending && WiFi.status() == WL_CONNECTED) { + alertPending = false; + esp_wifi_set_channel(homeChannel, WIFI_SECOND_CHAN_NONE); + flushDeauthQueue(); + flushProbeQueue(); + flushAssocQueue(); + lastHop = millis(); // avoid an immediate hop right after flushing + } else { + advanceHop(); + } #endif delay(10); @@ -261,6 +330,7 @@ void scanAndSend() { esp_wifi_set_promiscuous(true); flushProbeQueue(); flushDeauthQueue(); + flushAssocQueue(); // Reset hop state so next cycle starts cleanly from channel 1. hopIdx = 0; @@ -293,7 +363,8 @@ const char* importance(int rssi) { void sendBeaconEvent(int idx) { String ssid = WiFi.SSID(idx); - ssid.replace("\"", "\\\""); // escape quotes for JSON + ssid.replace("\\", "\\\\"); // escape backslashes first, then quotes + ssid.replace("\"", "\\\""); String bssid = WiFi.BSSIDstr(idx); int rssi = WiFi.RSSI(idx); @@ -325,22 +396,29 @@ void sendBeaconEvent(int idx) { void sendHeartbeat() { unsigned long now = millis(); - char buf[256]; + char buf[320]; snprintf(buf, sizeof(buf), "{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"heartbeat\"," - "\"uptime_ms\":%lu,\"free_heap\":%lu,\"wifi_rssi\":%d}", + "\"uptime_ms\":%lu,\"free_heap\":%lu,\"wifi_rssi\":%d," + "\"probe_drops\":%lu,\"deauth_drops\":%lu,\"assoc_drops\":%lu}", nodeId.c_str(), now, now, (unsigned long)ESP.getFreeHeap(), - WiFi.RSSI() + WiFi.RSSI(), + (unsigned long)probeDrops, + (unsigned long)deauthDrops, + (unsigned long)assocDrops ); udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT); udp.print(buf); udp.endPacket(); - Serial.printf("[HB] uptime=%lus heap=%luK ap=%ddBm\n", + Serial.printf("[HB] uptime=%lus heap=%luK ap=%ddBm drops(p/d/a)=%lu/%lu/%lu\n", now / 1000, (unsigned long)ESP.getFreeHeap() / 1024, - WiFi.RSSI() + WiFi.RSSI(), + (unsigned long)probeDrops, + (unsigned long)deauthDrops, + (unsigned long)assocDrops ); } @@ -426,6 +504,50 @@ void flushDeauthQueue() { } } +void sendAssocEvent(const AssocEvent& ev) { + auto macStr = [](const uint8_t* m, char* out) { + snprintf(out, 18, "%02X:%02X:%02X:%02X:%02X:%02X", + m[0], m[1], m[2], m[3], m[4], m[5]); + }; + char src[18], bssid[18]; + macStr(ev.src, src); + macStr(ev.bssid, bssid); + + const char* stype = (ev.subtype == 0x00) ? "assoc" : "reassoc"; + + // Escape quotes in SSID + char ssidEsc[66]; + int j = 0; + for (int i = 0; ev.ssid[i] && j < 64; i++) { + if (ev.ssid[i] == '"' || ev.ssid[i] == '\\') ssidEsc[j++] = '\\'; + ssidEsc[j++] = ev.ssid[i]; + } + ssidEsc[j] = '\0'; + + char buf[320]; + snprintf(buf, sizeof(buf), + "{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"%s\"," + "\"src\":\"%s\",\"bssid\":\"%s\",\"ssid\":\"%s\",\"rssi\":%d}", + nodeId.c_str(), millis(), stype, + src, bssid, ssidEsc, (int)ev.rssi + ); + + udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT); + udp.print(buf); + udp.endPacket(); + + Serial.printf(" [%s] %s → %s \"%s\" %ddBm\n", + stype, src, bssid, ev.ssid[0] ? ev.ssid : "", (int)ev.rssi); +} + +void flushAssocQueue() { + if (WiFi.status() != WL_CONNECTED) return; + AssocEvent ev; + while (xQueueReceive(assocQueue, &ev, 0) == pdTRUE) { + sendAssocEvent(ev); + } +} + // Advance to the next channel in the hop list. // Non-blocking — returns immediately if the dwell time hasn't elapsed. // Flushes queues each time we land back on homeChannel (WiFi is usable there). @@ -441,6 +563,7 @@ void advanceHop() { if (ch == homeChannel && WiFi.status() == WL_CONNECTED) { flushProbeQueue(); flushDeauthQueue(); + flushAssocQueue(); } } diff --git a/heatmap.png b/heatmap.png deleted file mode 100644 index 8df50a3..0000000 Binary files a/heatmap.png and /dev/null differ