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):
|
||||
```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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+77
-6
@@ -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,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.
|
||||
// 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;
|
||||
|
||||
@@ -62,12 +72,14 @@ static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
|
||||
const uint8_t* d = pkt->payload;
|
||||
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 (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] = "";
|
||||
if (d[24] == 0x00) {
|
||||
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.ssid, ssid, 33);
|
||||
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
|
||||
@@ -110,6 +141,7 @@ void setup() {
|
||||
#if PROBE_SNIFF
|
||||
memset(dedupCache, 0, sizeof(dedupCache));
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user