diff --git a/README.md b/README.md
index 0adf230..876a7f6 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py
index 5b28884..be1767a 100644
--- a/coordinator/dashboard.py
+++ b/coordinator/dashboard.py
@@ -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."""
diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css
index 1e87e2d..2fd8807 100644
--- a/coordinator/static/dashboard.css
+++ b/coordinator/static/dashboard.css
@@ -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); }
diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js
index 53c6c34..3055e55 100644
--- a/coordinator/static/dashboard.js
+++ b/coordinator/static/dashboard.js
@@ -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 = `
+