firmware&dashboard changes

This commit is contained in:
bot
2026-04-05 13:06:25 +03:00
parent 2636ea1e11
commit e01bbd9e56
11 changed files with 419 additions and 84 deletions
+65 -28
View File
@@ -28,12 +28,12 @@ This is a learning/research project covering distributed systems, event-driven a
### Active nodes ### Active nodes
| node_id | MAC | Location / Port | | node_id | MAC | Location |
|----------|-------------------|------------------------| |----------|-------------------|-------------------------|
| F68D6E30 | 44:1b:f6:8d:6e:30 | desk /dev/ttyACM0 | | F68D6E30 | 44:1b:f6:8d:6e:30 | room 1 (permanent) |
| A1D658D4 | e0:72:a1:d6:58:d4 | desk /dev/ttyACM1 | | A1D658D4 | e0:72:a1:d6:58:d4 | room 2 (permanent) |
| A1D700C4 | e0:72:a1:d7:00:c4 | desk /dev/ttyACM2 | | A1D700C4 | e0:72:a1:d7:00:c4 | dev machine /dev/ttyACM2|
| A1D6F190 | e0:72:a1:d6:f1:90 | deployed (another room)| | A1D6F190 | e0:72:a1:d6:f1:90 | dev machine /dev/ttyACM1|
--- ---
@@ -124,10 +124,15 @@ esp32_cluster/
Each node: Each node:
- Connects to WiFi `sandbox` - Connects to WiFi `sandbox`
- Scans for nearby APs every 15 seconds (active scan, includes hidden SSIDs) - 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 - Captures probe request frames passively (promiscuous mode) between scans
- Sends a heartbeat packet every 10 seconds (uptime, free heap, WiFi RSSI to router) - Captures deauth and disassoc frames (0xC0 / 0xA0)
- Sends beacon, probe, and heartbeat events as UDP JSON packets to the coordinator - 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 25 of the ESP32's base MAC address - `node_id` is derived from bytes 25 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 ### 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) - `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 - 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): **Heartbeat event** (node health, sent every 10 seconds):
```json ```json
{ {
"node_id": "F68D6E30", "node_id": "F68D6E30",
"ts": 12345, "ts": 12345,
"type": "heartbeat", "type": "heartbeat",
"uptime_ms": 123456, "uptime_ms": 123456,
"free_heap": 245000, "free_heap": 245000,
"wifi_rssi": -62 "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` - `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) - `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 ### config.h reference
| Constant | Default | Purpose | | Constant | Default | Purpose |
|--------------------|------------------|-------------------------------------------| |-------------------------|-----------|----------------------------------------------------------------|
| `WIFI_SSID` | `sandbox` | WiFi network to connect to | | `WIFI_SSID` | `sandbox` | WiFi network to connect to |
| `COORDINATOR_IP` | `192.168.1.133` | Orange Pi address | | `COORDINATOR_IP` | `192.168.1.133` | Orange Pi address |
| `COORDINATOR_PORT` | `5005` | UDP port | | `COORDINATOR_PORT` | `5005` | UDP port |
| `SCAN_INTERVAL_MS` | `15000` | How often to run a beacon scan (ms) | | `SCAN_INTERVAL_MS` | `15000` | How often to run a beacon scan (ms) |
| `PROBE_SNIFF` | `1` | Enable/disable probe sniffing (1/0) | | `HEARTBEAT_INTERVAL_MS` | `10000` | How often to send a heartbeat (ms) |
| `PROBE_DEDUP_SECS` | `30` | Suppress duplicate probe MAC+SSID (secs) | | `PROBE_SNIFF` | `1` | Enable/disable probe/deauth/assoc sniffing (1/0) |
| `PROBE_QUEUE_SIZE` | `32` | Max probe events buffered between scans | | `PROBE_DEDUP_SECS` | `30` | Suppress duplicate probe MAC+SSID (secs) |
| `DEAUTH_QUEUE_SIZE`| `32` | Max deauth/disassoc events buffered | | `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] Node firmware: beacon scan + probe sniffing
- [x] Four 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] UDP ingestor (`udp_ingest.py`) — beacon + probe routing with field validation
- [x] Web dashboard: Feed, Networks, Cross-node, Clients, Node detail views - [x] Web dashboard: Feed, Networks, Cross-node, Clients, Alerts, Search, Presence, Node detail
- [x] OUI vendor lookup in Clients view - [x] OUI vendor lookup in Clients view (vendor grouping + randomized MAC detection)
- [x] Dashboard split into HTML / CSS / JS (no monolithic file) - [x] Dashboard split into HTML / CSS / JS (no monolithic file)
- [x] Deployed to Orange Pi as systemd services (runs 24/7) - [x] Deployed to Orange Pi as systemd services (runs 24/7)
- [x] All four 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] 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] 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] 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] 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] 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] 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 - [ ] 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) - [ ] DB pruning / retention policy (events.db grows indefinitely)
- [ ] Scan interval control from dashboard
--- ---
Binary file not shown.
Binary file not shown.
+31
View File
@@ -778,6 +778,37 @@ def ensure_schema():
rssi INTEGER 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() conn.commit()
+17 -4
View File
@@ -418,10 +418,9 @@ td.dim { color: var(--text-dim); }
td.muted { color: var(--text-muted); } td.muted { color: var(--text-muted); }
/* ── Badges ── */ /* ── Badges ── */
.badge { display: inline-block; padding: 1px 6px; font-size: 10px; letter-spacing: 0.04em; } .imp-high { color: var(--green-live); }
.badge.high { color: var(--green-live); border: 1px solid var(--green-dim); } .imp-normal { color: var(--text-dim); }
.badge.normal { color: var(--text-dim); border: 1px solid var(--border); } .imp-low { color: var(--text-muted); }
.badge.low { color: var(--text-muted); border: 1px solid var(--text-muted); }
/* ── RSSI colours ── */ /* ── RSSI colours ── */
.rssi-hi { color: var(--green-hi); } .rssi-hi { color: var(--green-hi); }
@@ -436,6 +435,20 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
/* ── Enc / node colours ── */ /* ── Enc / node colours ── */
.enc-open { color: var(--orange); } .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); } .nodes-multi { color: var(--green-hi); }
/* ── Cross-node cells ── */ /* ── Cross-node cells ── */
+67 -25
View File
@@ -8,6 +8,7 @@ let networksData = [];
let netSort = { col: 'times_seen', dir: 'desc' }; let netSort = { col: 'times_seen', dir: 'desc' };
let crossNodeData = { node_ids: [], networks: [] }; let crossNodeData = { node_ids: [], networks: [] };
let clientsData = []; let clientsData = [];
let clientsGroupBy = null; // null | 'vendor' | 'ssid'
let presenceData = {}; let presenceData = {};
let alertsData = {}; let alertsData = {};
let chartBssid = null; let chartBssid = null;
@@ -28,7 +29,13 @@ function rssiClass(rssi) {
function impBadge(imp) { function impBadge(imp) {
const cls = ['high', 'normal', 'low'].includes(imp) ? imp : 'normal'; const cls = ['high', 'normal', 'low'].includes(imp) ? imp : 'normal';
return `<span class="badge ${cls}">${fmt(imp)}</span>`; return `<span class="imp-${cls}">${fmt(imp)}</span>`;
}
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) { function shortTime(iso) {
@@ -174,7 +181,7 @@ function renderNetworksTable() {
count.textContent = `${sorted.length} unique networks`; count.textContent = `${sorted.length} unique networks`;
tbody.innerHTML = sorted.map(r => { 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'; const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
return `<tr> return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td> <td>${fmt(r.ssid, '<hidden>')}</td>
@@ -256,7 +263,7 @@ function renderCrossNodeTable() {
tbody.innerHTML = networks.map(net => { tbody.innerHTML = networks.map(net => {
const seenRssis = nodeIds.filter(id => net.nodes[id]).map(id => net.nodes[id].best_rssi); const seenRssis = nodeIds.filter(id => net.nodes[id]).map(id => net.nodes[id].best_rssi);
const globalBest = seenRssis.length ? Math.max(...seenRssis) : null; 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 nodeCls = net.node_count > 1 ? 'nodes-multi' : 'muted';
const nodeCells = nodeIds.map(id => { const nodeCells = nodeIds.map(id => {
@@ -285,10 +292,38 @@ async function fetchClients() {
renderClientsTable(); renderClientsTable();
} }
function clientsRow(r) {
const probing = r.probed_ssids.length
? r.probed_ssids.map(s => `<span style="color:var(--text-dim)">${s}</span>`).join(', ')
: '<span style="color:var(--text-muted)">wildcard only</span>';
const vendorCls = r.randomized ? 'muted' : 'dim';
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
return `<tr>
<td>${fmt(r.src_mac)}</td>
<td class="${vendorCls}">${r.vendor || '—'}</td>
<td style="max-width:260px;white-space:normal;line-height:1.8">${probing}</td>
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td>
<td class="${nodeCls}">${fmt(r.node_count)}</td>
<td>${fmt(r.times_seen)}</td>
<td class="dim">${shortTime(r.last_seen)}</td>
</tr>`;
}
function toggleClientsGroup(by) {
clientsGroupBy = (clientsGroupBy === by) ? null : by;
renderClientsTable();
}
function renderClientsTable() { function renderClientsTable() {
const tbody = document.getElementById('clients-tbody'); const tbody = document.getElementById('clients-tbody');
const empty = document.getElementById('clients-empty'); const empty = document.getElementById('clients-empty');
const count = document.getElementById('clients-count'); 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) { if (!clientsData.length) {
tbody.innerHTML = ''; tbody.innerHTML = '';
@@ -299,22 +334,29 @@ function renderClientsTable() {
empty.style.display = 'none'; empty.style.display = 'none';
count.textContent = `${clientsData.length} unique MACs`; count.textContent = `${clientsData.length} unique MACs`;
tbody.innerHTML = clientsData.map(r => { if (!clientsGroupBy) {
const probing = r.probed_ssids.length tbody.innerHTML = clientsData.map(clientsRow).join('');
? r.probed_ssids.map(s => `<span style="color:var(--text-dim)">${s}</span>`).join(', ') return;
: '<span style="color:var(--text-muted)">wildcard only</span>'; }
const vendorCls = r.randomized ? 'muted' : 'dim';
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted'; // Build groups
return `<tr> const groups = {};
<td>${fmt(r.src_mac)}</td> clientsData.forEach(r => {
<td class="${vendorCls}">${r.vendor || '—'}</td> let key;
<td style="max-width:260px;white-space:normal;line-height:1.8">${probing}</td> if (clientsGroupBy === 'vendor') {
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td> key = r.vendor || 'Unknown';
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td> } else {
<td class="${nodeCls}">${fmt(r.node_count)}</td> key = r.probed_ssids.length ? r.probed_ssids[0] : '(wildcard only)';
<td>${fmt(r.times_seen)}</td> }
<td class="dim">${shortTime(r.last_seen)}</td> if (!groups[key]) groups[key] = [];
</tr>`; 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 = `<tr class="group-hdr"><td colspan="8">${key}${rows.length} device${rows.length > 1 ? 's' : ''}</td></tr>`;
return hdr + rows.map(clientsRow).join('');
}).join(''); }).join('');
} }
@@ -382,7 +424,7 @@ function renderPresenceView() {
} else { } else {
arrNetsEmpty.style.display = 'none'; arrNetsEmpty.style.display = 'none';
arrNetsTbody.innerHTML = newNets.map(r => { arrNetsTbody.innerHTML = newNets.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted'; const encCls = encClass(r.encryption);
return `<tr> return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td> <td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td> <td class="dim">${fmt(r.bssid)}</td>
@@ -398,8 +440,8 @@ function renderPresenceView() {
const regMacsTbody = document.getElementById('regulars-macs-tbody'); const regMacsTbody = document.getElementById('regulars-macs-tbody');
const regMacsEmpty = document.getElementById('regulars-macs-empty'); const regMacsEmpty = document.getElementById('regulars-macs-empty');
const regCount = document.getElementById('regulars-count'); const regCount = document.getElementById('regulars-count');
const regMacs = p.regular_macs || []; const regMacs = p.reg_macs || [];
const regNets = p.regular_networks || []; const regNets = p.reg_networks || [];
regCount.textContent = `${regMacs.length} devices · ${regNets.length} networks`; regCount.textContent = `${regMacs.length} devices · ${regNets.length} networks`;
if (!regMacs.length) { if (!regMacs.length) {
regMacsTbody.innerHTML = ''; regMacsTbody.innerHTML = '';
@@ -425,7 +467,7 @@ function renderPresenceView() {
} else { } else {
regNetsEmpty.style.display = 'none'; regNetsEmpty.style.display = 'none';
regNetsTbody.innerHTML = regNets.map(r => { regNetsTbody.innerHTML = regNets.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted'; const encCls = encClass(r.encryption);
return `<tr> return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td> <td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td> <td class="dim">${fmt(r.bssid)}</td>
@@ -841,7 +883,7 @@ function renderSearchResults(data, q) {
// As a network // As a network
if (data.as_network) { if (data.as_network) {
const rows = data.as_network.map(r => { const rows = data.as_network.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN') ? 'enc-open' : 'muted'; const encCls = encClass(r.encryption);
return `<tr> return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td> <td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td> <td class="dim">${fmt(r.bssid)}</td>
@@ -936,7 +978,7 @@ function renderSearchResults(data, q) {
if (data.vendor_networks) { if (data.vendor_networks) {
const label = data.vendor_name || q; const label = data.vendor_name || q;
const rows = data.vendor_networks.map(r => { const rows = data.vendor_networks.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN') ? 'enc-open' : 'muted'; const encCls = encClass(r.encryption);
return `<tr> return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td> <td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td> <td class="dim">${fmt(r.bssid)}</td>
+4 -3
View File
@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESP32 Recon</title> <title>ESP32</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230c110e'/%3E%3Ccircle cx='16' cy='23' r='2.5' fill='%234ec96a'/%3E%3Cpath d='M9 17.5 Q16 10.5 23 17.5' fill='none' stroke='%234ec96a' stroke-width='2.5' stroke-linecap='round'/%3E%3Cpath d='M4 12.5 Q16 1.5 28 12.5' fill='none' stroke='%234ec96a' stroke-width='2.5' stroke-linecap='round' opacity='0.5'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet" />
@@ -135,8 +136,8 @@
<thead> <thead>
<tr> <tr>
<th>MAC</th> <th>MAC</th>
<th>Vendor</th> <th id="clients-th-vendor" class="group-col" onclick="toggleClientsGroup('vendor')" title="Group by vendor">Vendor</th>
<th>Probing for</th> <th id="clients-th-probing" class="group-col" onclick="toggleClientsGroup('ssid')" title="Group by probed SSID">Probing for</th>
<th>Best RSSI</th> <th>Best RSSI</th>
<th>Avg RSSI</th> <th>Avg RSSI</th>
<th>Nodes</th> <th>Nodes</th>
+92 -10
View File
@@ -50,13 +50,16 @@ def init_db(path: str) -> sqlite3.Connection:
""") """)
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS heartbeat_events ( CREATE TABLE IF NOT EXISTS heartbeat_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL, received_at TEXT NOT NULL,
node_id TEXT, node_id TEXT,
node_ts INTEGER, node_ts INTEGER,
uptime_ms INTEGER, uptime_ms INTEGER,
free_heap INTEGER, free_heap INTEGER,
wifi_rssi INTEGER wifi_rssi INTEGER,
probe_drops INTEGER,
deauth_drops INTEGER,
assoc_drops INTEGER
) )
""") """)
conn.execute(""" conn.execute("""
@@ -73,6 +76,38 @@ def init_db(path: str) -> sqlite3.Connection:
rssi INTEGER 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() conn.commit()
return conn 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): def store_heartbeat(conn: sqlite3.Connection, ev: dict, received_at: str):
conn.execute(""" conn.execute("""
INSERT INTO heartbeat_events INSERT INTO heartbeat_events
(received_at, node_id, node_ts, uptime_ms, free_heap, wifi_rssi) (received_at, node_id, node_ts, uptime_ms, free_heap, wifi_rssi,
VALUES (?, ?, ?, ?, ?, ?) probe_drops, deauth_drops, assoc_drops)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
received_at, received_at,
ev.get("node_id"), 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("uptime_ms"),
ev.get("free_heap"), ev.get("free_heap"),
ev.get("wifi_rssi"), ev.get("wifi_rssi"),
ev.get("probe_drops"),
ev.get("deauth_drops"),
ev.get("assoc_drops"),
)) ))
conn.commit() conn.commit()
@@ -200,6 +239,37 @@ def validate_deauth(ev: dict) -> str | None:
return 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: def validate_probe(ev: dict) -> str | None:
"""Returns an error string if invalid, else None.""" """Returns an error string if invalid, else None."""
for field in ("node_id", "src_mac", "rssi"): 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") received_at = datetime.datetime.utcnow().isoformat(timespec="seconds")
pkt_type = ev.get("type", "beacon") 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 "<hidden>"
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) err = validate_deauth(ev)
if err: if err:
print(f"[DROP] {addr[0]} {pkt_type}{err}") print(f"[DROP] {addr[0]} {pkt_type}{err}")
+6
View File
@@ -21,5 +21,11 @@
#define PROBE_QUEUE_SIZE 128 // max probe events buffered between flushes #define PROBE_QUEUE_SIZE 128 // max probe events buffered between flushes
#define DEAUTH_QUEUE_SIZE 128 // max deauth/disassoc 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 ───────────────────────────────────────────────────────── // ─── Channel hopping ─────────────────────────────────────────────────────────
#define HOP_DWELL_MS 300 // ms to dwell on each channel (13 ch × 300ms ≈ 4s/sweep) #define HOP_DWELL_MS 300 // ms to dwell on each channel (13 ch × 300ms ≈ 4s/sweep)
+137 -14
View File
@@ -28,16 +28,33 @@ struct ProbeEvent {
}; };
struct DeauthEvent { struct DeauthEvent {
uint8_t src[6]; uint8_t src[6];
uint8_t dst[6]; uint8_t dst[6];
uint8_t bssid[6]; uint8_t bssid[6];
uint8_t subtype; // 0x0C = deauth, 0x0A = disassoc uint8_t subtype; // 0xC0 = deauth, 0xA0 = disassoc
uint16_t reason; 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; int8_t rssi;
}; };
static QueueHandle_t probeQueue; static QueueHandle_t probeQueue;
static QueueHandle_t deauthQueue; 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 // Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS
struct DedupEntry { uint8_t mac[6]; char ssid[33]; uint32_t last_ms; }; 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.src_mac, src, 6);
memcpy(ev.ssid, ssid, 33); memcpy(ev.ssid, ssid, 33);
ev.rssi = (int8_t)pkt->rx_ctrl.rssi; ev.rssi = (int8_t)pkt->rx_ctrl.rssi;
xQueueSend(probeQueue, &ev, 0); if (xQueueSend(probeQueue, &ev, 0) != pdTRUE) probeDrops++;
return; return;
} }
@@ -124,7 +141,47 @@ static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
ev.subtype = subtype; ev.subtype = subtype;
ev.reason = (uint16_t)d[24] | ((uint16_t)d[25] << 8); ev.reason = (uint16_t)d[24] | ((uint16_t)d[25] << 8);
ev.rssi = (int8_t)pkt->rx_ctrl.rssi; 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; return;
} }
} }
@@ -149,8 +206,9 @@ void setup() {
#if PROBE_SNIFF #if PROBE_SNIFF
memset(dedupCache, 0, sizeof(dedupCache)); 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)); 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_rx_cb(promiscuous_rx_cb);
esp_wifi_set_promiscuous(true); esp_wifi_set_promiscuous(true);
Serial.println("[PROBE] Promiscuous mode enabled"); Serial.println("[PROBE] Promiscuous mode enabled");
@@ -189,7 +247,18 @@ void loop() {
} }
#if PROBE_SNIFF #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 #endif
delay(10); delay(10);
@@ -261,6 +330,7 @@ void scanAndSend() {
esp_wifi_set_promiscuous(true); esp_wifi_set_promiscuous(true);
flushProbeQueue(); flushProbeQueue();
flushDeauthQueue(); flushDeauthQueue();
flushAssocQueue();
// Reset hop state so next cycle starts cleanly from channel 1. // Reset hop state so next cycle starts cleanly from channel 1.
hopIdx = 0; hopIdx = 0;
@@ -293,7 +363,8 @@ const char* importance(int rssi) {
void sendBeaconEvent(int idx) { void sendBeaconEvent(int idx) {
String ssid = WiFi.SSID(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); String bssid = WiFi.BSSIDstr(idx);
int rssi = WiFi.RSSI(idx); int rssi = WiFi.RSSI(idx);
@@ -325,22 +396,29 @@ void sendBeaconEvent(int idx) {
void sendHeartbeat() { void sendHeartbeat() {
unsigned long now = millis(); unsigned long now = millis();
char buf[256]; char buf[320];
snprintf(buf, sizeof(buf), snprintf(buf, sizeof(buf),
"{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"heartbeat\"," "{\"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, nodeId.c_str(), now,
now, now,
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getFreeHeap(),
WiFi.RSSI() WiFi.RSSI(),
(unsigned long)probeDrops,
(unsigned long)deauthDrops,
(unsigned long)assocDrops
); );
udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT); udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT);
udp.print(buf); udp.print(buf);
udp.endPacket(); 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, now / 1000,
(unsigned long)ESP.getFreeHeap() / 1024, (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 : "<hidden>", (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. // Advance to the next channel in the hop list.
// Non-blocking — returns immediately if the dwell time hasn't elapsed. // Non-blocking — returns immediately if the dwell time hasn't elapsed.
// Flushes queues each time we land back on homeChannel (WiFi is usable there). // 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) { if (ch == homeChannel && WiFi.status() == WL_CONNECTED) {
flushProbeQueue(); flushProbeQueue();
flushDeauthQueue(); flushDeauthQueue();
flushAssocQueue();
} }
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB