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 <noreply@anthropic.com>
This commit is contained in:
bot
2026-04-07 09:51:46 +03:00
parent 36c7248d0e
commit 7f702133cc
2 changed files with 51 additions and 10 deletions
+14
View File
@@ -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. 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 (234K247K, 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 ### Our own networks
To avoid investigating our own infrastructure, these SSIDs and BSSIDs are ours: 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] 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] 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] 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) - [ ] Surface assoc events in dashboard (Search results or dedicated view)
- [ ] Scan interval control from dashboard - [ ] Scan interval control from dashboard
- [ ] Dedicated AP for nodes to isolate reconnect churn from regular network traffic
--- ---
+37 -10
View File
@@ -34,6 +34,13 @@ function impBadge(imp) {
return `<span class="imp-${cls}">${fmt(imp)}</span>`; return `<span class="imp-${cls}">${fmt(imp)}</span>`;
} }
function reasonCell(code, desc, cls) {
if (code == null) return '<span class="muted">—</span>';
const color = cls === 'attack' ? '#e74c3c' : cls === 'suspicious' ? '#e67e22' : 'var(--text-muted)';
return `<span style="color:${color};font-weight:${cls === 'attack' ? 500 : 400}">${code}</span>`
+ `<span class="muted" style="font-size:10px;margin-left:4px">${desc || ''}</span>`;
}
function encClass(enc) { function encClass(enc) {
if (!enc || enc === 'OPEN' || enc === 'None') return 'enc-open'; if (!enc || enc === 'OPEN' || enc === 'None') return 'enc-open';
if (enc === 'WPA3' || enc === 'WPA2/WPA3') return 'enc-secure'; if (enc === 'WPA3' || enc === 'WPA2/WPA3') return 'enc-secure';
@@ -652,6 +659,7 @@ function renderSessionsView() {
<td>${s.unique_targets.length}</td> <td>${s.unique_targets.length}</td>
<td>${s.node_count}</td> <td>${s.node_count}</td>
<td>${s.peak_rssi != null ? s.peak_rssi + ' dBm' : '—'}</td> <td>${s.peak_rssi != null ? s.peak_rssi + ' dBm' : '—'}</td>
<td>${reasonCell(s.dominant_reason, s.dominant_reason_desc, s.dominant_reason_class)}</td>
<td><span class="sev-label ${sevClass}">${s.severity}</span></td> <td><span class="sev-label ${sevClass}">${s.severity}</span></td>
`; `;
tbody.appendChild(mainRow); tbody.appendChild(mainRow);
@@ -683,14 +691,14 @@ function renderSessionsView() {
: ''; : '';
detailRow.innerHTML = ` detailRow.innerHTML = `
<td colspan="11" class="session-detail-cell"> <td colspan="12" class="session-detail-cell">
<div class="session-detail-inner"> <div class="session-detail-inner">
<div class="session-meta"> <div class="session-meta">
<span><strong>BSSIDs:</strong> ${bssidList}</span> <span><strong>BSSIDs:</strong> ${bssidList}</span>
<span><strong>Targets:</strong> ${targetList}</span> <span><strong>Targets:</strong> ${targetList}</span>
<span><strong>Nodes:</strong> ${nodeList}</span> <span><strong>Nodes:</strong> ${nodeList}</span>
<span><strong>Avg RSSI:</strong> ${s.avg_rssi != null ? s.avg_rssi + ' dBm' : '—'}</span> <span><strong>Avg RSSI:</strong> ${s.avg_rssi != null ? s.avg_rssi + ' dBm' : '—'}</span>
<span><strong>Reason:</strong> ${s.dominant_reason}</span> <span><strong>Dominant Reason:</strong> ${reasonCell(s.dominant_reason, s.dominant_reason_desc, s.dominant_reason_class)}</span>
</div> </div>
<table class="session-events-table"> <table class="session-events-table">
<thead><tr> <thead><tr>
@@ -791,6 +799,7 @@ function renderAlertsView() {
<td class="dim">${fmt(t.bssid)}</td> <td class="dim">${fmt(t.bssid)}</td>
<td>${fmt(t.ssid, '<hidden>')}</td> <td>${fmt(t.ssid, '<hidden>')}</td>
<td style="color:#e74c3c;font-weight:500">${t.total_frames}</td> <td style="color:#e74c3c;font-weight:500">${t.total_frames}</td>
<td>${reasonCell(t.dominant_reason, t.dominant_reason_desc, t.dominant_reason_class)}</td>
<td class="muted">${t.unique_srcs}</td> <td class="muted">${t.unique_srcs}</td>
<td class="${t.node_count > 1 ? 'nodes-multi' : 'muted'}">${t.node_count}</td> <td class="${t.node_count > 1 ? 'nodes-multi' : 'muted'}">${t.node_count}</td>
<td class="dim">${shortTime(t.last_seen)}</td> <td class="dim">${shortTime(t.last_seen)}</td>
@@ -814,6 +823,7 @@ function renderAlertsView() {
<td class="dim">${fmt(d.dst)}</td> <td class="dim">${fmt(d.dst)}</td>
<td class="${vendorCls}">${d.vendor || '—'}</td> <td class="${vendorCls}">${d.vendor || '—'}</td>
<td style="color:#e74c3c;font-weight:500">${d.total_frames}</td> <td style="color:#e74c3c;font-weight:500">${d.total_frames}</td>
<td>${reasonCell(d.dominant_reason, d.dominant_reason_desc, d.dominant_reason_class)}</td>
<td class="muted">${d.networks_used}</td> <td class="muted">${d.networks_used}</td>
<td class="${d.node_count > 1 ? 'nodes-multi' : 'muted'}">${d.node_count}</td> <td class="${d.node_count > 1 ? 'nodes-multi' : 'muted'}">${d.node_count}</td>
<td class="dim">${shortTime(d.last_seen)}</td> <td class="dim">${shortTime(d.last_seen)}</td>
@@ -1234,8 +1244,14 @@ async function init() {
} }
// ── SSE ─────────────────────────────────────────────────────────────────────── // ── 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() { function startSSE() {
if (_currentES) { _currentES.close(); _currentES = null; }
const es = new EventSource('/api/stream'); const es = new EventSource('/api/stream');
_currentES = es;
const dot = document.getElementById('live-dot'); const dot = document.getElementById('live-dot');
let sseTimer = null; let sseTimer = null;
@@ -1265,18 +1281,29 @@ function startSSE() {
else if (currentView === 'alerts') await fetchAlerts(); else if (currentView === 'alerts') await fetchAlerts();
else if (currentView === 'analysis') await showAnalysis(); else if (currentView === 'analysis') await showAnalysis();
const nodes = await fetch('/api/nodes').then(r => r.json()); if (!_nodesFetching) {
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n])); _nodesFetching = true;
renderSidebar(nodes); try {
const nodes = await fetch('/api/nodes').then(r => r.json());
if (currentView === 'detail' && currentNode) { nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
const data = await fetch(`/api/nodes/${currentNode}`).then(r => r.json()); renderSidebar(nodes);
renderDetail(data); if (currentView === 'detail' && currentNode) {
const data = await fetch(`/api/nodes/${currentNode}`).then(r => r.json());
renderDetail(data);
}
} finally {
_nodesFetching = false;
}
} }
}, 2000); }, 2000);
}; };
es.onerror = () => { dot.classList.remove('alive'); setTimeout(startSSE, 3000); }; es.onerror = () => {
dot.classList.remove('alive');
es.close();
_currentES = null;
setTimeout(startSSE, 3000);
};
} }
// ── Boot ────────────────────────────────────────────────────────────────────── // ── Boot ──────────────────────────────────────────────────────────────────────