Add deauth/disassoc detection — firmware capture, coordinator ingestion, Alerts tab with burst detection
This commit is contained in:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user