firmware&dashboard changes
This commit is contained in:
@@ -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 2–5 of the ESP32's base MAC address
|
- `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
|
### 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.
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 ── */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
Reference in New Issue
Block a user