sligh changed to dashbaord
This commit is contained in:
+102
-21
@@ -9,7 +9,7 @@ import sqlite3
|
||||
import datetime
|
||||
import os
|
||||
import json
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "events.db")
|
||||
@@ -20,6 +20,46 @@ OUI_PATH = os.path.join(os.path.dirname(__file__), "oui.txt")
|
||||
ONLINE_SECS = 30 # node considered online if heartbeat within this many seconds
|
||||
EVENT_CAP = 100 # max events returned / shown in dashboard
|
||||
|
||||
# ─── Deauth reason codes ─────────────────────────────────────────────────────
|
||||
|
||||
# classification: 'normal', 'suspicious', 'attack'
|
||||
DEAUTH_REASONS: dict[int, tuple[str, str]] = {
|
||||
0: ("Reserved", "normal"),
|
||||
1: ("Unspecified", "normal"),
|
||||
2: ("Previous auth no longer valid", "attack"),
|
||||
3: ("Station leaving BSS", "normal"),
|
||||
4: ("Inactivity / idle timeout", "normal"),
|
||||
5: ("Too many associated stations", "normal"),
|
||||
6: ("Class 2 frame from non-authenticated station","attack"),
|
||||
7: ("Class 3 frame from non-associated station", "attack"),
|
||||
8: ("Station leaving BSS (disassoc)", "normal"),
|
||||
9: ("Station not authenticated", "suspicious"),
|
||||
10: ("Power capability unacceptable", "normal"),
|
||||
11: ("Supported channels unacceptable", "normal"),
|
||||
13: ("Invalid information element", "suspicious"),
|
||||
14: ("MIC failure", "suspicious"),
|
||||
15: ("4-way handshake timeout", "normal"),
|
||||
16: ("Group key handshake timeout", "normal"),
|
||||
17: ("IE in 4-way handshake differs", "suspicious"),
|
||||
18: ("Invalid group cipher", "suspicious"),
|
||||
19: ("Invalid pairwise cipher", "suspicious"),
|
||||
20: ("Invalid AKMP", "suspicious"),
|
||||
21: ("Unsupported RSNE version", "suspicious"),
|
||||
22: ("Invalid RSNE capabilities", "suspicious"),
|
||||
23: ("802.1X authentication failed", "suspicious"),
|
||||
24: ("Cipher suite rejected by policy", "suspicious"),
|
||||
34: ("Low ACK", "normal"),
|
||||
39: ("BSS transition management request", "normal"),
|
||||
45: ("Peer stakey negotiation failed", "suspicious"),
|
||||
47: ("Authorized access limit reached", "normal"),
|
||||
99: ("Peer abandoned session", "normal"),
|
||||
103: ("Disassociated — excessive frames", "suspicious"),
|
||||
147: ("Vendor-specific", "normal"),
|
||||
}
|
||||
|
||||
def deauth_reason_info(code: int) -> tuple[str, str]:
|
||||
return DEAUTH_REASONS.get(code, ("Unknown / vendor-specific", "normal"))
|
||||
|
||||
# ─── OUI lookup ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_oui(path: str) -> dict[str, str]:
|
||||
@@ -63,6 +103,7 @@ def oui_lookup(mac: str) -> tuple[str, bool]:
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
@@ -147,6 +188,7 @@ def build_networks() -> list[dict]:
|
||||
WHERE bssid IS NOT NULL
|
||||
GROUP BY bssid
|
||||
ORDER BY times_seen DESC
|
||||
LIMIT 300
|
||||
""")
|
||||
return rows
|
||||
|
||||
@@ -169,13 +211,18 @@ def build_clients() -> list[dict]:
|
||||
WHERE src_mac IS NOT NULL
|
||||
GROUP BY src_mac
|
||||
ORDER BY times_seen DESC
|
||||
LIMIT 300
|
||||
""")
|
||||
# Fetch all probed SSIDs in one query, group in Python — avoids N+1
|
||||
ssid_rows = query("""
|
||||
# Fetch SSIDs only for the MACs we actually returned
|
||||
if not rows:
|
||||
return []
|
||||
mac_list = [r["src_mac"] for r in rows]
|
||||
placeholders = ",".join("?" * len(mac_list))
|
||||
ssid_rows = query(f"""
|
||||
SELECT DISTINCT src_mac, ssid FROM probe_events
|
||||
WHERE src_mac IS NOT NULL AND ssid IS NOT NULL AND ssid != ''
|
||||
WHERE src_mac IN ({placeholders}) AND ssid IS NOT NULL AND ssid != ''
|
||||
ORDER BY src_mac, ssid
|
||||
""")
|
||||
""", tuple(mac_list))
|
||||
ssids_by_mac: dict = {}
|
||||
for s in ssid_rows:
|
||||
ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
|
||||
@@ -199,18 +246,24 @@ def build_cross_node() -> dict:
|
||||
|
||||
rows = query("""
|
||||
SELECT
|
||||
bssid,
|
||||
MAX(ssid) AS ssid,
|
||||
MAX(channel) AS channel,
|
||||
MAX(encryption) AS encryption,
|
||||
node_id,
|
||||
MAX(rssi) AS best_rssi,
|
||||
ROUND(AVG(rssi), 1) AS avg_rssi,
|
||||
COUNT(*) AS times_seen
|
||||
FROM beacon_events
|
||||
WHERE bssid IS NOT NULL
|
||||
GROUP BY bssid, node_id
|
||||
ORDER BY bssid, node_id
|
||||
b.bssid,
|
||||
MAX(b.ssid) AS ssid,
|
||||
MAX(b.channel) AS channel,
|
||||
MAX(b.encryption) AS encryption,
|
||||
b.node_id,
|
||||
MAX(b.rssi) AS best_rssi,
|
||||
ROUND(AVG(b.rssi), 1) AS avg_rssi,
|
||||
COUNT(*) AS times_seen
|
||||
FROM beacon_events b
|
||||
WHERE b.bssid IN (
|
||||
SELECT bssid FROM beacon_events
|
||||
WHERE bssid IS NOT NULL
|
||||
GROUP BY bssid
|
||||
ORDER BY COUNT(DISTINCT node_id) DESC, COUNT(*) DESC
|
||||
LIMIT 300
|
||||
)
|
||||
GROUP BY b.bssid, b.node_id
|
||||
ORDER BY b.bssid, b.node_id
|
||||
""")
|
||||
|
||||
networks: dict = {}
|
||||
@@ -455,12 +508,41 @@ def build_alerts() -> dict:
|
||||
d["vendor"] = vendor
|
||||
d["randomized"] = randomized
|
||||
|
||||
# Reason code breakdown — all time
|
||||
reason_rows = query("""
|
||||
SELECT reason,
|
||||
COUNT(*) AS total_frames,
|
||||
COUNT(DISTINCT bssid) AS unique_bssids,
|
||||
COUNT(DISTINCT dst) AS unique_targets,
|
||||
MIN(received_at) AS first_seen,
|
||||
MAX(received_at) AS last_seen
|
||||
FROM deauth_events
|
||||
GROUP BY reason
|
||||
ORDER BY total_frames DESC
|
||||
""")
|
||||
for r in reason_rows:
|
||||
desc, classification = deauth_reason_info(r["reason"])
|
||||
r["description"] = desc
|
||||
r["classification"] = classification
|
||||
|
||||
# Activity heatmap — frame count per day × hour
|
||||
heatmap_rows = query("""
|
||||
SELECT date(received_at) AS day,
|
||||
CAST(strftime('%H', received_at) AS INTEGER) AS hour,
|
||||
COUNT(*) AS frames
|
||||
FROM deauth_events
|
||||
GROUP BY day, hour
|
||||
ORDER BY day, hour
|
||||
""")
|
||||
|
||||
return {
|
||||
"recent": recent,
|
||||
"bursts": bursts,
|
||||
"summary": summary[0] if summary else {},
|
||||
"recent": recent,
|
||||
"bursts": bursts,
|
||||
"summary": summary[0] if summary else {},
|
||||
"top_targets": top_targets,
|
||||
"top_targeted_devices": top_targeted_devices,
|
||||
"reason_stats": reason_rows,
|
||||
"heatmap": heatmap_rows,
|
||||
}
|
||||
|
||||
|
||||
@@ -792,7 +874,6 @@ async def api_heartbeats():
|
||||
async def api_node_detail(node_id: str):
|
||||
detail = build_node_detail(node_id)
|
||||
if detail is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
return detail
|
||||
|
||||
|
||||
@@ -470,6 +470,16 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Deauth heatmap ── */
|
||||
#deauth-heatmap {
|
||||
display: block;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.reason-normal { color: var(--text-dim); }
|
||||
.reason-suspicious { color: var(--orange); }
|
||||
.reason-attack { color: #e74c3c; font-weight: 500; }
|
||||
|
||||
/* ── Alerts ── */
|
||||
.alerts-summary {
|
||||
display: flex;
|
||||
|
||||
@@ -438,6 +438,96 @@ function renderPresenceView() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deauth heatmap ────────────────────────────────────────────────────────────
|
||||
function renderDeauthHeatmap(rows) {
|
||||
const canvas = document.getElementById('deauth-heatmap');
|
||||
const empty = document.getElementById('heatmap-empty');
|
||||
const subtitle = document.getElementById('heatmap-subtitle');
|
||||
|
||||
if (!rows.length) {
|
||||
canvas.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
subtitle.textContent = '';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
empty.style.display = 'none';
|
||||
|
||||
// Build lookup
|
||||
const lookup = {};
|
||||
let maxFrames = 0;
|
||||
rows.forEach(r => {
|
||||
if (!lookup[r.day]) lookup[r.day] = {};
|
||||
lookup[r.day][r.hour] = r.frames;
|
||||
if (r.frames > maxFrames) maxFrames = r.frames;
|
||||
});
|
||||
const days = Object.keys(lookup).sort();
|
||||
subtitle.textContent = `${days.length} days`;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const padL = 100, padT = 36, padR = 16, padB = 12;
|
||||
const cellH = 42;
|
||||
|
||||
// Fill container width exactly
|
||||
const containerW = canvas.parentElement.clientWidth;
|
||||
const cellW = Math.floor((containerW - padL - padR) / 24);
|
||||
|
||||
const W = padL + 24 * cellW + padR;
|
||||
const H = padT + days.length * cellH + padB;
|
||||
|
||||
// Set physical pixel size, CSS display size
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
canvas.style.width = W + 'px';
|
||||
canvas.style.height = H + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Hour labels
|
||||
ctx.font = '11px Roboto, sans-serif';
|
||||
ctx.fillStyle = '#5a7a60';
|
||||
ctx.textAlign = 'center';
|
||||
for (let h = 0; h < 24; h++) {
|
||||
ctx.fillText(String(h).padStart(2, '0'), padL + h * cellW + cellW / 2, padT - 10);
|
||||
}
|
||||
|
||||
// Day rows
|
||||
days.forEach((day, di) => {
|
||||
const y = padT + di * cellH;
|
||||
|
||||
// Day label
|
||||
ctx.font = '11px Roboto, sans-serif';
|
||||
ctx.fillStyle = '#5a7a60';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(day.slice(5), padL - 8, y + cellH / 2 + 4);
|
||||
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const frames = (lookup[day] && lookup[day][h]) || 0;
|
||||
const x = padL + h * cellW;
|
||||
|
||||
if (frames === 0) {
|
||||
ctx.fillStyle = '#111a14';
|
||||
} else {
|
||||
const t = Math.min(frames / maxFrames, 1);
|
||||
const r = Math.round(80 + t * 151);
|
||||
const g = Math.round(20 + t * 10);
|
||||
const b = Math.round(20 + t * 10);
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
}
|
||||
ctx.fillRect(x + 1, y + 1, cellW - 2, cellH - 2);
|
||||
|
||||
if (frames > 0) {
|
||||
ctx.font = '11px Roboto, sans-serif';
|
||||
ctx.fillStyle = frames > maxFrames * 0.4 ? '#fff' : '#e74c3c';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(frames > 999 ? '999+' : frames, x + cellW / 2, y + cellH / 2 + 4);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Alerts view ───────────────────────────────────────────────────────────────
|
||||
async function fetchAlerts() {
|
||||
alertsData = await fetch('/api/alerts').then(r => r.json());
|
||||
@@ -471,6 +561,33 @@ function renderAlertsView() {
|
||||
<span>unique sources</span>
|
||||
</div>`;
|
||||
|
||||
// Reason code breakdown
|
||||
const reasonsTbody = document.getElementById('reasons-tbody');
|
||||
const reasonsEmpty = document.getElementById('reasons-empty');
|
||||
const reasonsCount = document.getElementById('reasons-count');
|
||||
const reasons = a.reason_stats || [];
|
||||
reasonsCount.textContent = `${reasons.length} codes`;
|
||||
if (!reasons.length) {
|
||||
reasonsTbody.innerHTML = '';
|
||||
reasonsEmpty.style.display = 'block';
|
||||
} else {
|
||||
reasonsEmpty.style.display = 'none';
|
||||
reasonsTbody.innerHTML = reasons.map(r => {
|
||||
const cls = `reason-${r.classification}`;
|
||||
const label = r.classification === 'attack' ? 'ATTACK'
|
||||
: r.classification === 'suspicious' ? 'SUSPICIOUS' : 'normal';
|
||||
return `<tr>
|
||||
<td class="muted">${r.reason}</td>
|
||||
<td>${r.description}</td>
|
||||
<td class="${cls}">${label}</td>
|
||||
<td class="${r.classification === 'attack' ? cls : ''}" style="${r.classification === 'attack' ? 'font-weight:500' : ''}">${r.total_frames}</td>
|
||||
<td class="muted">${r.unique_bssids}</td>
|
||||
<td class="muted">${r.unique_targets}</td>
|
||||
<td class="dim">${shortTime(r.last_seen)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Most targeted networks
|
||||
const targetsTbody = document.getElementById('targets-tbody');
|
||||
const targetsEmpty = document.getElementById('targets-empty');
|
||||
@@ -863,6 +980,7 @@ function setView(view) {
|
||||
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('view-analysis').classList.toggle('active', view === 'analysis');
|
||||
document.getElementById('view-search').classList.toggle('active', view === 'search');
|
||||
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
|
||||
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart');
|
||||
@@ -870,6 +988,7 @@ function setView(view) {
|
||||
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');
|
||||
document.getElementById('tab-analysis').classList.toggle('active', view === 'analysis');
|
||||
document.getElementById('tab-search').classList.toggle('active', view === 'search');
|
||||
}
|
||||
|
||||
@@ -891,6 +1010,12 @@ async function showPresence() { setView('presence'); await fetchPresence(); }
|
||||
|
||||
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
|
||||
|
||||
async function showAnalysis() {
|
||||
setView('analysis');
|
||||
const data = await fetch('/api/alerts').then(r => r.json());
|
||||
requestAnimationFrame(() => renderDeauthHeatmap(data.heatmap || []));
|
||||
}
|
||||
|
||||
function showSearch() { setView('search'); document.getElementById('search-input').focus(); }
|
||||
|
||||
async function showDetail(node_id) {
|
||||
@@ -946,6 +1071,7 @@ function startSSE() {
|
||||
else if (currentView === 'crossnode') await fetchCrossNode();
|
||||
else if (currentView === 'presence') await fetchPresence();
|
||||
else if (currentView === 'alerts') await fetchAlerts();
|
||||
else if (currentView === 'analysis') await showAnalysis();
|
||||
|
||||
const nodes = await fetch('/api/nodes').then(r => r.json());
|
||||
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<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>
|
||||
<button class="tab-btn" id="tab-analysis" onclick="showAnalysis()">Analysis</button>
|
||||
<button class="tab-btn" id="tab-search" onclick="showSearch()">Search</button>
|
||||
</div>
|
||||
|
||||
@@ -350,6 +351,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason code breakdown -->
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title">Reason Code Breakdown</span>
|
||||
<span class="section-count" id="reasons-count"></span>
|
||||
<span class="presence-sub">all time · normal / suspicious / attack classification</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Code</th><th>Description</th><th>Class</th>
|
||||
<th>Frames</th><th>Unique BSSIDs</th><th>Unique Targets</th><th>Last Seen</th>
|
||||
</tr></thead>
|
||||
<tbody id="reasons-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="reasons-empty" style="display:none">No data yet.</div>
|
||||
</div>
|
||||
|
||||
<!-- Detected bursts -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Detected Bursts</span>
|
||||
@@ -385,6 +403,20 @@
|
||||
|
||||
</div><!-- /#view-alerts -->
|
||||
|
||||
<!-- Analysis view -->
|
||||
<div id="view-analysis" class="view">
|
||||
|
||||
<!-- Deauth activity heatmap -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Deauth Activity Heatmap</span>
|
||||
<span class="section-count" id="heatmap-subtitle"></span>
|
||||
<span class="presence-sub">frame count per day × hour — pattern reveals manual vs automated activity</span>
|
||||
</div>
|
||||
<canvas id="deauth-heatmap"></canvas>
|
||||
<div class="empty" id="heatmap-empty" style="display:none">No deauth data yet.</div>
|
||||
|
||||
</div><!-- /#view-analysis -->
|
||||
|
||||
<!-- Search view -->
|
||||
<div id="view-search" class="view">
|
||||
<div class="search-bar">
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import sqlite3
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
|
||||
UDP_IP = "0.0.0.0"
|
||||
UDP_PORT = 5005
|
||||
@@ -19,6 +20,7 @@ DB_PATH = os.path.join(os.path.dirname(__file__), "events.db")
|
||||
|
||||
def init_db(path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(path, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS beacon_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -129,7 +131,6 @@ def store_probe(conn: sqlite3.Connection, ev: dict, received_at: str):
|
||||
|
||||
# ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
import re
|
||||
_MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
|
||||
|
||||
def _check_mac(val: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user