From 7f702133cc9181d1788052bd2caf101e30b41c9b Mon Sep 17 00:00:00 2001 From: bot Date: Tue, 7 Apr 2026 09:51:46 +0300 Subject: [PATCH] Fix SSE reconnect storm and document node dropout findings - Fix stale EventSource accumulation: onerror handler now closes the existing EventSource before creating a new one, preventing multiple live instances from stacking up and flooding /api/nodes on reconnect. - Add in-flight guard on /api/nodes fetch so concurrent requests are dropped if one is already in progress. - Add dominant reason code column to Alerts tab (Most Impersonated Networks and Most Targeted Devices tables) and Sessions tab rows. - README: document node dropout root cause (AP overwhelm on staggered power cycle), overnight stability confirmation, and dedicated AP todo. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 14 ++++++++++ coordinator/static/dashboard.js | 47 ++++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c3782b8..75e54aa 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,16 @@ The **Bursts** section is the anomaly detector. A single unique source MAC gener 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. +### Node dropout — AP overwhelm on simultaneous reconnect + +Observed 2026-04-06: three of four nodes dropped off the dashboard after being power cycled individually at different times. Symptoms included reason-15 deauth floods (`4-way handshake timeout`) from the affected nodes captured by the surviving node, and nodes getting stuck in a reconnect loop without ever successfully sending data to the coordinator. + +**Root cause:** the sandbox AP was overwhelmed by multiple nodes power cycling at slightly different times and hammering it with simultaneous association requests. Each node hops channels every ~4 seconds, briefly dropping and reconnecting to sandbox on every cycle. When several nodes do this at the same time after a staggered power cycle, the AP's association table fills with stale entries and stops completing WPA2 handshakes for new clients. + +**Confirmed not a firmware issue:** all four nodes ran for 21 hours continuously without a single drop when power cycled simultaneously (clean start, all connect at once). Heap stayed stable across all nodes throughout (234K–247K, no drift). + +**Mitigation:** power cycle all nodes at the same time rather than individually, so they all connect fresh together rather than in a staggered loop. Long-term fix is a dedicated AP for the nodes only, so their reconnect churn is isolated from regular network traffic and the AP is never competing with other clients. + ### Our own networks To avoid investigating our own infrastructure, these SSIDs and BSSIDs are ours: @@ -409,8 +419,12 @@ Broadcasting an open unauthenticated hotspot. Next step: connect via Parrot lapt - [x] Sessions tab — deauth events grouped into sessions by source MAC + 2-min gap, sortable, expandable rows - [x] Dashboard performance overhaul — fixed tab switch latency and Networks tab failing to load (see below) - [x] DB pruning / retention policy — background task deletes events older than HOT_DAYS every 6 hours +- [x] Alerts tab — dominant reason code added to Most Impersonated Networks and Most Targeted Devices tables +- [x] Sessions tab — dominant reason code + description shown per session row and in expanded detail panel +- [x] SSE reconnect bug fix — multiple stale EventSource instances were accumulating on reconnect, flooding /api/nodes on tab re-open; fixed with proper close-before-reconnect and in-flight guard - [ ] Surface assoc events in dashboard (Search results or dedicated view) - [ ] Scan interval control from dashboard +- [ ] Dedicated AP for nodes to isolate reconnect churn from regular network traffic --- diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js index 61ffdd0..f47ae95 100644 --- a/coordinator/static/dashboard.js +++ b/coordinator/static/dashboard.js @@ -34,6 +34,13 @@ function impBadge(imp) { return `${fmt(imp)}`; } +function reasonCell(code, desc, cls) { + if (code == null) return ''; + const color = cls === 'attack' ? '#e74c3c' : cls === 'suspicious' ? '#e67e22' : 'var(--text-muted)'; + return `${code}` + + `${desc || ''}`; +} + function encClass(enc) { if (!enc || enc === 'OPEN' || enc === 'None') return 'enc-open'; if (enc === 'WPA3' || enc === 'WPA2/WPA3') return 'enc-secure'; @@ -652,6 +659,7 @@ function renderSessionsView() { ${s.unique_targets.length} ${s.node_count} ${s.peak_rssi != null ? s.peak_rssi + ' dBm' : '—'} + ${reasonCell(s.dominant_reason, s.dominant_reason_desc, s.dominant_reason_class)} ${s.severity} `; tbody.appendChild(mainRow); @@ -683,14 +691,14 @@ function renderSessionsView() { : ''; detailRow.innerHTML = ` - +
BSSIDs: ${bssidList} Targets: ${targetList} Nodes: ${nodeList} Avg RSSI: ${s.avg_rssi != null ? s.avg_rssi + ' dBm' : '—'} - Reason: ${s.dominant_reason} + Dominant Reason: ${reasonCell(s.dominant_reason, s.dominant_reason_desc, s.dominant_reason_class)}
@@ -791,6 +799,7 @@ function renderAlertsView() { + @@ -814,6 +823,7 @@ function renderAlertsView() { + @@ -1234,8 +1244,14 @@ async function init() { } // ── SSE ─────────────────────────────────────────────────────────────────────── +let _currentES = null; // track active EventSource so we can close it on reconnect +let _nodesFetching = false; // in-flight guard — prevents concurrent /api/nodes requests + function startSSE() { + if (_currentES) { _currentES.close(); _currentES = null; } + const es = new EventSource('/api/stream'); + _currentES = es; const dot = document.getElementById('live-dot'); let sseTimer = null; @@ -1265,18 +1281,29 @@ function startSSE() { else if (currentView === 'alerts') await fetchAlerts(); else if (currentView === 'analysis') await showAnalysis(); - const nodes = await fetch('/api/nodes').then(r => r.json()); - nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n])); - renderSidebar(nodes); - - if (currentView === 'detail' && currentNode) { - const data = await fetch(`/api/nodes/${currentNode}`).then(r => r.json()); - renderDetail(data); + if (!_nodesFetching) { + _nodesFetching = true; + try { + const nodes = await fetch('/api/nodes').then(r => r.json()); + nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n])); + renderSidebar(nodes); + if (currentView === 'detail' && currentNode) { + const data = await fetch(`/api/nodes/${currentNode}`).then(r => r.json()); + renderDetail(data); + } + } finally { + _nodesFetching = false; + } } }, 2000); }; - es.onerror = () => { dot.classList.remove('alive'); setTimeout(startSSE, 3000); }; + es.onerror = () => { + dot.classList.remove('alive'); + es.close(); + _currentES = null; + setTimeout(startSSE, 3000); + }; } // ── Boot ──────────────────────────────────────────────────────────────────────
${fmt(t.bssid)} ${fmt(t.ssid, '')} ${t.total_frames}${reasonCell(t.dominant_reason, t.dominant_reason_desc, t.dominant_reason_class)} ${t.unique_srcs} ${t.node_count} ${shortTime(t.last_seen)}${fmt(d.dst)} ${d.vendor || '—'} ${d.total_frames}${reasonCell(d.dominant_reason, d.dominant_reason_desc, d.dominant_reason_class)} ${d.networks_used} ${d.node_count} ${shortTime(d.last_seen)}