Add deauth/disassoc detection — firmware capture, coordinator ingestion, Alerts tab with burst detection
This commit is contained in:
@@ -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):
|
**Heartbeat event** (node health, sent every 10 seconds):
|
||||||
```json
|
```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.
|
- **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.
|
- **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.
|
- **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:
|
- **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.
|
- *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.
|
- *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
|
### 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
|
- **Wildcard probes** (empty SSID): the device is searching for any available network
|
||||||
- Probe data maps **client devices**, not infrastructure — complementary to beacon scan data
|
- 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
|
### 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`.
|
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] 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
|
||||||
|
- [x] Deauth/disassoc frame detection — burst detection with multi-node confirmation, Alerts tab
|
||||||
- [ ] Scan interval control from dashboard
|
- [ ] Scan interval control from dashboard
|
||||||
- [ ] DB pruning / retention policy (events.db grows indefinitely)
|
- [ ] DB pruning / retention policy (events.db grows indefinitely)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
def build_node_detail(node_id: str) -> dict | None:
|
||||||
rows = query("""
|
rows = query("""
|
||||||
SELECT
|
SELECT
|
||||||
@@ -418,6 +481,20 @@ def ensure_schema():
|
|||||||
uptime_ms INTEGER, free_heap INTEGER, wifi_rssi INTEGER
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -490,6 +567,11 @@ async def api_rssi_history(bssid: str, hours: int = 2):
|
|||||||
return series
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/alerts")
|
||||||
|
async def api_alerts():
|
||||||
|
return build_alerts()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/heartbeats")
|
@app.get("/api/heartbeats")
|
||||||
async def api_heartbeats():
|
async def api_heartbeats():
|
||||||
"""Latest heartbeat per node."""
|
"""Latest heartbeat per node."""
|
||||||
|
|||||||
@@ -379,6 +379,26 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
|
|||||||
margin-bottom: 6px;
|
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 ── */
|
/* ── Scrollbar ── */
|
||||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
::-webkit-scrollbar-track { background: var(--bg); }
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ let netSort = { col: 'times_seen', dir: 'desc' };
|
|||||||
let crossNodeData = { node_ids: [], networks: [] };
|
let crossNodeData = { node_ids: [], networks: [] };
|
||||||
let clientsData = [];
|
let clientsData = [];
|
||||||
let presenceData = {};
|
let presenceData = {};
|
||||||
|
let alertsData = {};
|
||||||
let chartBssid = null;
|
let chartBssid = null;
|
||||||
let chartHours = 2;
|
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 ────────────────────────────────────────────────────────────────
|
// ── RSSI chart ────────────────────────────────────────────────────────────────
|
||||||
async function showNetworkChart(bssid) {
|
async function showNetworkChart(bssid) {
|
||||||
chartBssid = bssid;
|
chartBssid = bssid;
|
||||||
@@ -568,11 +658,13 @@ function setView(view) {
|
|||||||
document.getElementById('view-clients').classList.toggle('active', view === 'clients');
|
document.getElementById('view-clients').classList.toggle('active', view === 'clients');
|
||||||
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
|
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
|
||||||
document.getElementById('view-presence').classList.toggle('active', view === 'presence');
|
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-feed').classList.toggle('active', view === 'feed' || view === 'detail');
|
||||||
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart');
|
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart');
|
||||||
document.getElementById('tab-crossnode').classList.toggle('active', view === 'crossnode');
|
document.getElementById('tab-crossnode').classList.toggle('active', view === 'crossnode');
|
||||||
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
|
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
|
||||||
document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
|
document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
|
||||||
|
document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFeed() {
|
function showFeed() {
|
||||||
@@ -591,6 +683,8 @@ async function showClients() { setView('clients'); await fetchClients(); }
|
|||||||
|
|
||||||
async function showPresence() { setView('presence'); await fetchPresence(); }
|
async function showPresence() { setView('presence'); await fetchPresence(); }
|
||||||
|
|
||||||
|
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
|
||||||
|
|
||||||
async function showDetail(node_id) {
|
async function showDetail(node_id) {
|
||||||
currentNode = node_id;
|
currentNode = node_id;
|
||||||
setView('detail');
|
setView('detail');
|
||||||
@@ -643,6 +737,7 @@ function startSSE() {
|
|||||||
else if (currentView === 'network-chart') await loadAndRenderChart();
|
else if (currentView === 'network-chart') await loadAndRenderChart();
|
||||||
else if (currentView === 'crossnode') await fetchCrossNode();
|
else if (currentView === 'crossnode') await fetchCrossNode();
|
||||||
else if (currentView === 'presence') await fetchPresence();
|
else if (currentView === 'presence') await fetchPresence();
|
||||||
|
else if (currentView === 'alerts') await fetchAlerts();
|
||||||
|
|
||||||
const nodes = await fetch('/api/nodes').then(r => r.json());
|
const nodes = await fetch('/api/nodes').then(r => r.json());
|
||||||
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
|
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<button class="tab-btn" id="tab-crossnode" onclick="showCrossNode()">Cross-node</button>
|
<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-clients" onclick="showClients()">Clients</button>
|
||||||
<button class="tab-btn" id="tab-presence" onclick="showPresence()">Presence</button>
|
<button class="tab-btn" id="tab-presence" onclick="showPresence()">Presence</button>
|
||||||
|
<button class="tab-btn" id="tab-alerts" onclick="showAlerts()">Alerts</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
@@ -304,6 +305,47 @@
|
|||||||
|
|
||||||
</div><!-- /#view-presence -->
|
</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><!-- /#main -->
|
||||||
</div><!-- /#app -->
|
</div><!-- /#app -->
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ def init_db(path: str) -> sqlite3.Connection:
|
|||||||
wifi_rssi 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()
|
conn.commit()
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -149,6 +163,42 @@ def validate_heartbeat(ev: dict) -> str | None:
|
|||||||
return f"wifi_rssi out of range: {ev['wifi_rssi']}"
|
return f"wifi_rssi out of range: {ev['wifi_rssi']}"
|
||||||
return None
|
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:
|
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"):
|
||||||
@@ -182,7 +232,20 @@ 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 == "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)
|
err = validate_heartbeat(ev)
|
||||||
if err:
|
if err:
|
||||||
print(f"[DROP] {addr[0]} heartbeat — {err}")
|
print(f"[DROP] {addr[0]} heartbeat — {err}")
|
||||||
|
|||||||
+77
-6
@@ -25,7 +25,17 @@ struct ProbeEvent {
|
|||||||
int8_t rssi;
|
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 probeQueue;
|
||||||
|
static QueueHandle_t deauthQueue;
|
||||||
|
|
||||||
// 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; };
|
||||||
@@ -54,7 +64,7 @@ static bool isDuplicate(const uint8_t* mac, const char* ssid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Promiscuous callback — runs in WiFi task context, not safe to call UDP here.
|
// 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) {
|
static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
|
||||||
if (type != WIFI_PKT_MGMT) return;
|
if (type != WIFI_PKT_MGMT) return;
|
||||||
|
|
||||||
@@ -62,12 +72,14 @@ static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
|
|||||||
const uint8_t* d = pkt->payload;
|
const uint8_t* d = pkt->payload;
|
||||||
uint16_t len = pkt->rx_ctrl.sig_len;
|
uint16_t len = pkt->rx_ctrl.sig_len;
|
||||||
|
|
||||||
|
if (len < 24) return;
|
||||||
|
uint8_t subtype = d[0];
|
||||||
|
|
||||||
|
// ── Probe request (0x40) ─────────────────────────────────────────────────
|
||||||
|
if (subtype == 0x40) {
|
||||||
if (len < 28) return;
|
if (len < 28) return;
|
||||||
if (d[0] != 0x40) return; // byte 0 = 0x40 → management, probe request subtype
|
const uint8_t* src = d + 10;
|
||||||
|
|
||||||
const uint8_t* src = d + 10; // source address at bytes 10–15
|
|
||||||
|
|
||||||
// Parse SSID tag (tag 0) from frame body starting at byte 24
|
|
||||||
char ssid[33] = "";
|
char ssid[33] = "";
|
||||||
if (d[24] == 0x00) {
|
if (d[24] == 0x00) {
|
||||||
uint8_t slen = d[25];
|
uint8_t slen = d[25];
|
||||||
@@ -86,7 +98,26 @@ 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); // 0 timeout: drop if queue full
|
xQueueSend(probeQueue, &ev, 0);
|
||||||
|
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;
|
||||||
|
|
||||||
|
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
|
#endif // PROBE_SNIFF
|
||||||
@@ -110,6 +141,7 @@ 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));
|
||||||
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");
|
||||||
@@ -184,6 +216,7 @@ void scanAndSend() {
|
|||||||
// Scan may have disabled promiscuous mode internally — re-enable it.
|
// Scan may have disabled promiscuous mode internally — re-enable it.
|
||||||
esp_wifi_set_promiscuous(true);
|
esp_wifi_set_promiscuous(true);
|
||||||
flushProbeQueue();
|
flushProbeQueue();
|
||||||
|
flushDeauthQueue();
|
||||||
#endif
|
#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
|
#endif // PROBE_SNIFF
|
||||||
|
|||||||
Reference in New Issue
Block a user