Add deauth/disassoc detection — firmware capture, coordinator ingestion, Alerts tab with burst detection

This commit is contained in:
bot
2026-04-03 22:24:25 +03:00
parent cbef4143ee
commit fbf5b5c738
7 changed files with 426 additions and 23 deletions
+31 -1
View File
@@ -151,6 +151,24 @@ Each node:
}
```
**Deauth / Disassoc event** (management frame captured in promiscuous mode):
```json
{
"node_id": "F68D6E30",
"ts": 12345,
"type": "deauth",
"src": "AA:BB:CC:DD:EE:FF",
"dst": "11:22:33:44:55:66",
"bssid": "AA:BB:CC:DD:EE:FF",
"reason": 7,
"rssi": -61
}
```
- `type` is `deauth` (0xC0) or `disassoc` (0xA0)
- `dst = FF:FF:FF:FF:FF:FF` means broadcast deauth — a classic deauth flood signature
- `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
**Heartbeat event** (node health, sent every 10 seconds):
```json
{
@@ -231,6 +249,9 @@ Open `http://192.168.1.133:8080` in your browser.
- **RSSI history chart** — opens from any Networks row. Shows signal strength over time per node (1h / 2h / 6h / 24h selectable). Each node gets its own coloured line. Useful for spotting interference patterns, seeing how signal fluctuates by time of day, and comparing which node consistently hears a given AP better. Updates live as new scans arrive.
- **Cross-node** — side-by-side per-node RSSI for every AP. Shows which node is physically closer to each network. Filter to multi-node only to focus on confirmed cross-node observations.
- **Clients** — probe request data: unique MACs, vendor (OUI lookup), what SSIDs they're searching for, signal, which nodes saw them.
- **Alerts** — deauth/disassoc frame detection with two sections:
- *Detected Bursts* — fires when ≥10 deauth or disassoc frames are seen for the same BSSID within a 5-minute window. Highlighted red when confirmed by 2+ nodes (high confidence, source is physically nearby). Burst rows include BSSID, SSID, frame count, unique source MACs, and node count.
- *Raw Feed* — last 100 deauth/disassoc events with src, dst, BSSID, reason code, and RSSI. Useful for inspecting specific events. `dst = FF:FF:FF:FF:FF:FF` (broadcast deauth) is a classic attack tool signature.
- **Presence** — three sections driven entirely by probe data:
- *Present Now* — real (non-randomized) MACs seen 2+ times in the last hour. Filters out the city-center noise of transient devices.
- *New Arrivals* — devices and networks first seen in the last 24 hours, split into two side-by-side tables.
@@ -244,7 +265,7 @@ A node is considered **online** if a heartbeat was received within the last 30 s
### Live updates
SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node, presence) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests.
SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node, presence, alerts) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests.
---
@@ -263,6 +284,14 @@ What probe data tells you:
- **Wildcard probes** (empty SSID): the device is searching for any available network
- Probe data maps **client devices**, not infrastructure — complementary to beacon scan data
### Deauth frame noise vs real attacks
The raw deauth feed in the Alerts tab will fill up constantly — deauth and disassoc frames are a normal part of WiFi operation (devices disconnecting, APs doing band steering, power saving). This is expected and not cause for concern.
The **Bursts** section is the anomaly detector. A single unique source MAC generating 10+ deauth frames for the same BSSID within 5 minutes is not normal operation — it indicates a deauth flood, the standard precursor to a WPA2 handshake capture attack. The attacker forces connected clients to disconnect, captures the 4-way handshake when they reconnect, and then cracks it offline.
Multi-node confirmation (`node_count > 1`, highlighted red) significantly raises confidence — it means the source is physically close and strong, not a distant weak signal.
### OUI lookup
The `oui.txt` file is the IEEE public OUI database (39,171 entries as of download). It maps the first 3 bytes of a real MAC to a manufacturer name. Used in the Clients tab. Refresh it occasionally by re-running `deploy.sh` after downloading a fresh copy from `https://standards-oui.ieee.org/oui/oui.txt`.
@@ -283,6 +312,7 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa
- [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] Deauth/disassoc frame detection — burst detection with multi-node confirmation, Alerts tab
- [ ] Scan interval control from dashboard
- [ ] DB pruning / retention policy (events.db grows indefinitely)
+82
View File
@@ -354,6 +354,69 @@ def build_presence() -> dict:
}
def build_alerts() -> dict:
now = datetime.datetime.utcnow()
cutoff_5m = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds")
cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds")
# Recent raw deauth/disassoc events
recent = query("""
SELECT * FROM deauth_events
ORDER BY id DESC LIMIT 100
""")
# Burst detection: >= 10 frames for the same BSSID within the last 5 minutes.
# node_count > 1 means multiple sensors confirmed the burst — much higher confidence.
bursts = query("""
SELECT
bssid,
subtype,
COUNT(*) AS count,
COUNT(DISTINCT node_id) AS node_count,
COUNT(DISTINCT src) AS unique_srcs,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen
FROM deauth_events
WHERE received_at > ?
GROUP BY bssid, subtype
HAVING COUNT(*) >= 10
ORDER BY count DESC
""", (cutoff_5m,))
# Correlate burst BSSIDs with known network SSIDs
if bursts:
bssid_list = [b["bssid"] for b in bursts]
placeholders = ",".join("?" * len(bssid_list))
ssid_rows = query(f"""
SELECT bssid, MAX(ssid) AS ssid, MAX(encryption) AS encryption
FROM beacon_events WHERE bssid IN ({placeholders})
GROUP BY bssid
""", tuple(bssid_list))
ssid_map = {r["bssid"]: r for r in ssid_rows}
for b in bursts:
net = ssid_map.get(b["bssid"], {})
b["ssid"] = net.get("ssid")
b["encryption"] = net.get("encryption")
# Summary counts for the last hour
summary = query("""
SELECT
COUNT(*) AS total,
COUNT(DISTINCT bssid) AS unique_bssids,
COUNT(DISTINCT src) AS unique_srcs,
SUM(CASE WHEN subtype='deauth' THEN 1 ELSE 0 END) AS deauth_count,
SUM(CASE WHEN subtype='disassoc' THEN 1 ELSE 0 END) AS disassoc_count
FROM deauth_events
WHERE received_at > ?
""", (cutoff_1h,))
return {
"recent": recent,
"bursts": bursts,
"summary": summary[0] if summary else {},
}
def build_node_detail(node_id: str) -> dict | None:
rows = query("""
SELECT
@@ -418,6 +481,20 @@ def ensure_schema():
uptime_ms INTEGER, free_heap INTEGER, wifi_rssi INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS deauth_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL,
node_id TEXT,
node_ts INTEGER,
subtype TEXT,
src TEXT,
dst TEXT,
bssid TEXT,
reason INTEGER,
rssi INTEGER
)
""")
conn.commit()
@@ -490,6 +567,11 @@ async def api_rssi_history(bssid: str, hours: int = 2):
return series
@app.get("/api/alerts")
async def api_alerts():
return build_alerts()
@app.get("/api/heartbeats")
async def api_heartbeats():
"""Latest heartbeat per node."""
+20
View File
@@ -379,6 +379,26 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
margin-bottom: 6px;
}
/* ── Alerts ── */
.alerts-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
}
.alerts-summary .stat { display: flex; flex-direction: column; gap: 2px; }
.alerts-summary .stat .val { font-size: 18px; font-weight: 500; color: var(--text); }
.alerts-summary .stat.alert .val { color: #e74c3c; }
.burst-row { background: rgba(231,76,60,0.07); }
.burst-row:hover { background: rgba(231,76,60,0.14) !important; }
.burst-multi { color: #e74c3c; font-weight: 500; }
td.deauth-type { color: #e74c3c; }
td.disassoc-type { color: var(--orange); }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--bg); }
+95
View File
@@ -9,6 +9,7 @@ let netSort = { col: 'times_seen', dir: 'desc' };
let crossNodeData = { node_ids: [], networks: [] };
let clientsData = [];
let presenceData = {};
let alertsData = {};
let chartBssid = null;
let chartHours = 2;
@@ -437,6 +438,95 @@ function renderPresenceView() {
}
}
// ── Alerts view ───────────────────────────────────────────────────────────────
async function fetchAlerts() {
alertsData = await fetch('/api/alerts').then(r => r.json());
renderAlertsView();
}
function renderAlertsView() {
const a = alertsData;
const sum = a.summary || {};
// Summary strip
document.getElementById('alerts-summary').innerHTML = `
<div class="stat ${sum.total > 0 ? 'alert' : ''}">
<span class="val">${sum.total ?? 0}</span>
<span>events (last 1h)</span>
</div>
<div class="stat">
<span class="val">${sum.deauth_count ?? 0}</span>
<span>deauth</span>
</div>
<div class="stat">
<span class="val">${sum.disassoc_count ?? 0}</span>
<span>disassoc</span>
</div>
<div class="stat">
<span class="val">${sum.unique_bssids ?? 0}</span>
<span>unique BSSIDs</span>
</div>
<div class="stat">
<span class="val">${sum.unique_srcs ?? 0}</span>
<span>unique sources</span>
</div>`;
// Bursts
const burstsTbody = document.getElementById('bursts-tbody');
const burstsEmpty = document.getElementById('bursts-empty');
const burstsCount = document.getElementById('bursts-count');
const bursts = a.bursts || [];
burstsCount.textContent = bursts.length ? `${bursts.length} active` : '';
if (!bursts.length) {
burstsTbody.innerHTML = '';
burstsEmpty.style.display = 'block';
} else {
burstsEmpty.style.display = 'none';
burstsTbody.innerHTML = bursts.map(b => {
const multiNode = b.node_count > 1;
const rowCls = multiNode ? 'burst-row burst-multi' : 'burst-row';
const nodeCls = multiNode ? 'burst-multi' : 'muted';
const typeCls = b.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type';
return `<tr class="${rowCls}">
<td class="dim">${fmt(b.bssid)}</td>
<td>${fmt(b.ssid, '<hidden>')}</td>
<td class="${typeCls}">${b.subtype}</td>
<td style="color:#e74c3c;font-weight:500">${b.count}</td>
<td class="muted">${b.unique_srcs}</td>
<td class="${nodeCls}">${b.node_count}</td>
<td class="dim">${shortTime(b.first_seen)}</td>
<td class="dim">${shortTime(b.last_seen)}</td>
</tr>`;
}).join('');
}
// Raw deauth feed
const deauthTbody = document.getElementById('deauth-tbody');
const deauthEmpty = document.getElementById('deauth-empty');
const deauthCount = document.getElementById('deauth-count');
const recent = a.recent || [];
deauthCount.textContent = `${recent.length} events`;
if (!recent.length) {
deauthTbody.innerHTML = '';
deauthEmpty.style.display = 'block';
} else {
deauthEmpty.style.display = 'none';
deauthTbody.innerHTML = recent.map(r => {
const typeCls = r.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type';
return `<tr>
<td class="dim">${shortTime(r.received_at)}</td>
<td class="muted">${fmt(r.node_id)}</td>
<td class="${typeCls}">${r.subtype}</td>
<td class="dim">${fmt(r.src)}</td>
<td class="dim">${fmt(r.dst)}</td>
<td class="dim">${fmt(r.bssid)}</td>
<td class="muted">${fmt(r.reason)}</td>
<td class="${rssiClass(r.rssi)}">${fmt(r.rssi)} dBm</td>
</tr>`;
}).join('');
}
}
// ── RSSI chart ────────────────────────────────────────────────────────────────
async function showNetworkChart(bssid) {
chartBssid = bssid;
@@ -568,11 +658,13 @@ function setView(view) {
document.getElementById('view-clients').classList.toggle('active', view === 'clients');
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
document.getElementById('view-presence').classList.toggle('active', view === 'presence');
document.getElementById('view-alerts').classList.toggle('active', view === 'alerts');
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart');
document.getElementById('tab-crossnode').classList.toggle('active', view === 'crossnode');
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts');
}
function showFeed() {
@@ -591,6 +683,8 @@ async function showClients() { setView('clients'); await fetchClients(); }
async function showPresence() { setView('presence'); await fetchPresence(); }
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
async function showDetail(node_id) {
currentNode = node_id;
setView('detail');
@@ -643,6 +737,7 @@ function startSSE() {
else if (currentView === 'network-chart') await loadAndRenderChart();
else if (currentView === 'crossnode') await fetchCrossNode();
else if (currentView === 'presence') await fetchPresence();
else if (currentView === 'alerts') await fetchAlerts();
const nodes = await fetch('/api/nodes').then(r => r.json());
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
+42
View File
@@ -28,6 +28,7 @@
<button class="tab-btn" id="tab-crossnode" onclick="showCrossNode()">Cross-node</button>
<button class="tab-btn" id="tab-clients" onclick="showClients()">Clients</button>
<button class="tab-btn" id="tab-presence" onclick="showPresence()">Presence</button>
<button class="tab-btn" id="tab-alerts" onclick="showAlerts()">Alerts</button>
</div>
<!-- Sidebar -->
@@ -304,6 +305,47 @@
</div><!-- /#view-presence -->
<!-- Alerts view -->
<div id="view-alerts" class="view">
<!-- Summary strip -->
<div id="alerts-summary" class="alerts-summary"></div>
<!-- Detected bursts -->
<div class="section-header">
<span class="section-title">Detected Bursts</span>
<span class="section-count" id="bursts-count"></span>
<span class="presence-sub">≥10 deauth/disassoc frames for same BSSID in last 5 min</span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>BSSID</th><th>SSID</th><th>Type</th><th>Frames</th>
<th>Unique Srcs</th><th>Nodes</th><th>First</th><th>Last</th>
</tr></thead>
<tbody id="bursts-tbody"></tbody>
</table>
<div class="empty" id="bursts-empty" style="display:none">No bursts detected in last 5 minutes.</div>
</div>
<!-- Raw deauth feed -->
<div class="section-header" style="margin-top:8px">
<span class="section-title">Recent Deauth / Disassoc Events</span>
<span class="section-count" id="deauth-count"></span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>Time</th><th>Node</th><th>Type</th><th>Src</th>
<th>Dst</th><th>BSSID</th><th>Reason</th><th>RSSI</th>
</tr></thead>
<tbody id="deauth-tbody"></tbody>
</table>
<div class="empty" id="deauth-empty" style="display:none">No deauth events yet. Nodes need to be reflashed with deauth detection enabled.</div>
</div>
</div><!-- /#view-alerts -->
</div><!-- /#main -->
</div><!-- /#app -->
+64 -1
View File
@@ -57,6 +57,20 @@ def init_db(path: str) -> sqlite3.Connection:
wifi_rssi INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS deauth_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL,
node_id TEXT,
node_ts INTEGER,
subtype TEXT,
src TEXT,
dst TEXT,
bssid TEXT,
reason INTEGER,
rssi INTEGER
)
""")
conn.commit()
return conn
@@ -149,6 +163,42 @@ def validate_heartbeat(ev: dict) -> str | None:
return f"wifi_rssi out of range: {ev['wifi_rssi']}"
return None
def store_deauth(conn: sqlite3.Connection, ev: dict, received_at: str):
conn.execute("""
INSERT INTO deauth_events
(received_at, node_id, node_ts, subtype, src, dst, bssid, reason, rssi)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
received_at,
ev.get("node_id"),
ev.get("ts"),
ev.get("type"),
ev.get("src"),
ev.get("dst"),
ev.get("bssid"),
ev.get("reason"),
ev.get("rssi"),
))
conn.commit()
def validate_deauth(ev: dict) -> str | None:
for field in ("node_id", "src", "dst", "bssid", "reason", "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["dst"]):
return f"bad dst format: {ev['dst']}"
if not _check_mac(ev["bssid"]):
return f"bad bssid format: {ev['bssid']}"
if not isinstance(ev["reason"], int) or not (0 <= ev["reason"] <= 65535):
return f"invalid reason code: {ev['reason']}"
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"):
@@ -182,7 +232,20 @@ 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 == "heartbeat":
if pkt_type in ("deauth", "disassoc"):
err = validate_deauth(ev)
if err:
print(f"[DROP] {addr[0]} {pkt_type}{err}")
return
store_deauth(conn, ev, received_at)
node = ev.get("node_id", "?")
src = ev.get("src", "?")
dst = ev.get("dst", "?")
bssid = ev.get("bssid", "?")
reason = ev.get("reason", 0)
rssi = ev.get("rssi", 0)
print(f"\033[91m[{received_at}] {node} {pkt_type.upper():<8} {src}{dst} bssid={bssid} reason={reason} {rssi:>4}dBm{RESET}")
elif pkt_type == "heartbeat":
err = validate_heartbeat(ev)
if err:
print(f"[DROP] {addr[0]} heartbeat — {err}")
+92 -21
View File
@@ -25,7 +25,17 @@ struct ProbeEvent {
int8_t rssi;
};
struct DeauthEvent {
uint8_t src[6];
uint8_t dst[6];
uint8_t bssid[6];
uint8_t subtype; // 0x0C = deauth, 0x0A = disassoc
uint16_t reason;
int8_t rssi;
};
static QueueHandle_t probeQueue;
static QueueHandle_t deauthQueue;
// Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS
struct DedupEntry { uint8_t mac[6]; char ssid[33]; uint32_t last_ms; };
@@ -54,39 +64,60 @@ static bool isDuplicate(const uint8_t* mac, const char* ssid) {
}
// Promiscuous callback — runs in WiFi task context, not safe to call UDP here.
// Parse probe request frames and push to queue for the main loop to send.
// Parse probe request, deauth, and disassoc frames and push to queues.
static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
if (type != WIFI_PKT_MGMT) return;
const wifi_promiscuous_pkt_t* pkt = (const wifi_promiscuous_pkt_t*)buf;
const uint8_t* d = pkt->payload;
const uint8_t* d = pkt->payload;
uint16_t len = pkt->rx_ctrl.sig_len;
if (len < 28) return;
if (d[0] != 0x40) return; // byte 0 = 0x40 → management, probe request subtype
if (len < 24) return;
uint8_t subtype = d[0];
const uint8_t* src = d + 10; // source address at bytes 1015
// ── Probe request (0x40) ─────────────────────────────────────────────────
if (subtype == 0x40) {
if (len < 28) return;
const uint8_t* src = d + 10;
// Parse SSID tag (tag 0) from frame body starting at byte 24
char ssid[33] = "";
if (d[24] == 0x00) {
uint8_t slen = d[25];
if (slen > 0 && slen <= 32 && (26 + slen) <= len) {
bool printable = true;
for (uint8_t i = 0; i < slen; i++) {
if (d[26 + i] < 32 || d[26 + i] > 126) { printable = false; break; }
char ssid[33] = "";
if (d[24] == 0x00) {
uint8_t slen = d[25];
if (slen > 0 && slen <= 32 && (26 + slen) <= len) {
bool printable = true;
for (uint8_t i = 0; i < slen; i++) {
if (d[26 + i] < 32 || d[26 + i] > 126) { printable = false; break; }
}
if (printable) { memcpy(ssid, d + 26, slen); ssid[slen] = '\0'; }
}
if (printable) { memcpy(ssid, d + 26, slen); ssid[slen] = '\0'; }
}
if (isDuplicate(src, ssid)) return;
ProbeEvent ev;
memcpy(ev.src_mac, src, 6);
memcpy(ev.ssid, ssid, 33);
ev.rssi = (int8_t)pkt->rx_ctrl.rssi;
xQueueSend(probeQueue, &ev, 0);
return;
}
if (isDuplicate(src, ssid)) return;
// ── Deauth (0xC0) and Disassoc (0xA0) ───────────────────────────────────
if (subtype == 0xC0 || subtype == 0xA0) {
// 802.11 frame header: addr1 (dst) @ 4, addr2 (src) @ 10, addr3 (bssid) @ 16
// Frame body starts at byte 24: 2-byte reason code
if (len < 26) return;
ProbeEvent ev;
memcpy(ev.src_mac, src, 6);
memcpy(ev.ssid, ssid, 33);
ev.rssi = (int8_t)pkt->rx_ctrl.rssi;
xQueueSend(probeQueue, &ev, 0); // 0 timeout: drop if queue full
DeauthEvent ev;
memcpy(ev.dst, d + 4, 6);
memcpy(ev.src, d + 10, 6);
memcpy(ev.bssid, d + 16, 6);
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);
return;
}
}
#endif // PROBE_SNIFF
@@ -109,7 +140,8 @@ 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));
esp_wifi_set_promiscuous_rx_cb(promiscuous_rx_cb);
esp_wifi_set_promiscuous(true);
Serial.println("[PROBE] Promiscuous mode enabled");
@@ -184,6 +216,7 @@ void scanAndSend() {
// Scan may have disabled promiscuous mode internally — re-enable it.
esp_wifi_set_promiscuous(true);
flushProbeQueue();
flushDeauthQueue();
#endif
}
@@ -306,4 +339,42 @@ void flushProbeQueue() {
}
}
void sendDeauthEvent(const DeauthEvent& 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], dst[18], bssid[18];
macStr(ev.src, src);
macStr(ev.dst, dst);
macStr(ev.bssid, bssid);
const char* stype = (ev.subtype == 0xC0) ? "deauth" : "disassoc";
char buf[384];
snprintf(buf, sizeof(buf),
"{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"%s\","
"\"src\":\"%s\",\"dst\":\"%s\",\"bssid\":\"%s\","
"\"reason\":%u,\"rssi\":%d}",
nodeId.c_str(), millis(), stype,
src, dst, bssid,
(unsigned)ev.reason, (int)ev.rssi
);
udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT);
udp.print(buf);
udp.endPacket();
Serial.printf(" [%s] %s → %s bssid=%s reason=%u %ddBm\n",
stype, src, dst, bssid, (unsigned)ev.reason, (int)ev.rssi);
}
void flushDeauthQueue() {
if (WiFi.status() != WL_CONNECTED) return;
DeauthEvent ev;
while (xQueueReceive(deauthQueue, &ev, 0) == pdTRUE) {
sendDeauthEvent(ev);
}
}
#endif // PROBE_SNIFF