sligh changed to dashbaord

This commit is contained in:
bot
2026-04-05 11:29:59 +03:00
parent 5c0ec8cc5a
commit 2636ea1e11
10 changed files with 364 additions and 42 deletions
+102 -21
View File
@@ -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
+10
View File
@@ -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;
+126
View File
@@ -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]));
+32
View File
@@ -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">
+2 -1
View File
@@ -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: