Files
esp32-cluster/coordinator/dashboard.py
T

952 lines
35 KiB
Python
Raw Normal View History

2026-04-03 14:38:38 +03:00
#!/usr/bin/env python3
"""
WiFi Recon Dashboard
Reads from events.db (written by udp_ingest.py), serves a live web UI.
"""
import asyncio
import sqlite3
import datetime
import os
import json
2026-04-05 11:29:59 +03:00
from fastapi import FastAPI, Request, HTTPException
2026-04-03 14:38:38 +03:00
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
DB_PATH = os.path.join(os.path.dirname(__file__), "events.db")
HTML_PATH = os.path.join(os.path.dirname(__file__), "templates", "dashboard.html")
CSS_PATH = os.path.join(os.path.dirname(__file__), "static", "dashboard.css")
JS_PATH = os.path.join(os.path.dirname(__file__), "static", "dashboard.js")
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
2026-04-05 11:29:59 +03:00
# ─── 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"))
2026-04-03 14:38:38 +03:00
# ─── OUI lookup ──────────────────────────────────────────────────────────────
def _load_oui(path: str) -> dict[str, str]:
db: dict[str, str] = {}
if not os.path.exists(path):
return db
with open(path, encoding="utf-8", errors="ignore") as f:
for line in f:
if "(hex)" not in line:
continue
parts = line.split("(hex)")
prefix = parts[0].strip().replace("-", ":").upper() # "AA-BB-CC" → "AA:BB:CC"
vendor = parts[1].strip()
if prefix and vendor:
db[prefix] = vendor
return db
_OUI_DB: dict[str, str] = _load_oui(OUI_PATH)
def oui_lookup(mac: str) -> tuple[str, bool]:
"""
Returns (vendor_name, is_randomized).
Randomized MACs have the locally-administered bit set (bit 1 of first byte).
OUI lookup is meaningless for randomized MACs.
"""
if not mac or len(mac) < 8:
return ("Unknown", False)
try:
first_byte = int(mac.replace(":", "").replace("-", "")[:2], 16)
randomized = bool(first_byte & 0x02)
except ValueError:
return ("Unknown", False)
if randomized:
return ("Randomized MAC", True)
prefix = mac.upper()[:8]
return (_OUI_DB.get(prefix, "Unknown"), False)
# ─── DB helpers ──────────────────────────────────────────────────────────────
def get_conn() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
2026-04-05 11:29:59 +03:00
conn.execute("PRAGMA journal_mode=WAL")
2026-04-03 14:38:38 +03:00
return conn
def query(sql: str, params: tuple = ()) -> list[dict]:
with get_conn() as conn:
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows]
def _node_status(last_seen: str | None, threshold: int = ONLINE_SECS) -> str:
if not last_seen:
return "offline"
try:
dt = datetime.datetime.fromisoformat(last_seen)
delta = (datetime.datetime.utcnow() - dt).total_seconds()
return "online" if delta <= threshold else "offline"
except ValueError:
return "offline"
# ─── API data builders ────────────────────────────────────────────────────────
def build_nodes() -> list[dict]:
rows = query("""
SELECT
node_id,
MAX(received_at) AS last_beacon,
COUNT(*) AS total_events,
MIN(received_at) AS first_seen
FROM beacon_events
GROUP BY node_id
ORDER BY node_id ASC
2026-04-03 14:38:38 +03:00
""")
# Latest heartbeat per node
hb_rows = query("""
SELECT node_id, received_at AS last_heartbeat,
uptime_ms, free_heap, wifi_rssi
FROM heartbeat_events
WHERE id IN (SELECT MAX(id) FROM heartbeat_events GROUP BY node_id)
""")
hb_map = {r["node_id"]: r for r in hb_rows}
for r in rows:
hb = hb_map.get(r["node_id"], {})
r["last_heartbeat"] = hb.get("last_heartbeat")
r["uptime_ms"] = hb.get("uptime_ms")
r["free_heap"] = hb.get("free_heap")
r["wifi_rssi"] = hb.get("wifi_rssi")
r["last_seen"] = r["last_heartbeat"] or r["last_beacon"]
# Online if heartbeat within 30s; fall back to beacon within 60s if no heartbeat yet
if r["last_heartbeat"]:
r["status"] = _node_status(r["last_heartbeat"], threshold=30)
else:
r["status"] = _node_status(r["last_beacon"], threshold=60)
return rows
def build_events(limit: int = EVENT_CAP, node_id: str | None = None) -> list[dict]:
if node_id:
return query("""
SELECT * FROM beacon_events
WHERE node_id = ?
ORDER BY id DESC LIMIT ?
""", (node_id, limit))
return query("""
SELECT * FROM beacon_events
ORDER BY id DESC LIMIT ?
""", (limit,))
def build_networks() -> list[dict]:
rows = query("""
SELECT
ssid,
bssid,
MAX(rssi) AS best_rssi,
ROUND(AVG(rssi), 1) AS avg_rssi,
channel,
encryption,
COUNT(*) AS times_seen,
COUNT(DISTINCT node_id) AS node_count,
MAX(received_at) AS last_seen
FROM beacon_events
WHERE bssid IS NOT NULL
GROUP BY bssid
ORDER BY times_seen DESC
2026-04-05 11:29:59 +03:00
LIMIT 300
2026-04-03 14:38:38 +03:00
""")
return rows
def build_clients() -> list[dict]:
"""
Per unique src_mac: aggregated stats and the SSIDs they were probing for.
"""
rows = query("""
SELECT
src_mac,
COUNT(*) AS times_seen,
COUNT(DISTINCT node_id) AS node_count,
MAX(rssi) AS best_rssi,
ROUND(AVG(rssi), 1) AS avg_rssi,
COUNT(DISTINCT NULLIF(ssid,'')) AS unique_ssids,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen
FROM probe_events
WHERE src_mac IS NOT NULL
GROUP BY src_mac
ORDER BY times_seen DESC
2026-04-05 11:29:59 +03:00
LIMIT 300
2026-04-03 14:38:38 +03:00
""")
2026-04-05 11:29:59 +03:00
# 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
2026-04-05 11:29:59 +03:00
WHERE src_mac IN ({placeholders}) AND ssid IS NOT NULL AND ssid != ''
ORDER BY src_mac, ssid
2026-04-05 11:29:59 +03:00
""", tuple(mac_list))
ssids_by_mac: dict = {}
for s in ssid_rows:
ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
2026-04-03 14:38:38 +03:00
for r in rows:
r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
2026-04-03 14:38:38 +03:00
vendor, randomized = oui_lookup(r["src_mac"])
r["vendor"] = vendor
r["randomized"] = randomized
return rows
def build_cross_node() -> dict:
"""
For every BSSID, return per-node RSSI stats side by side.
Result: { node_ids: [...], networks: [{bssid, ssid, channel, encryption,
node_count, nodes: {node_id: {best_rssi, avg_rssi, times_seen}}}] }
"""
node_rows = query("SELECT DISTINCT node_id FROM beacon_events ORDER BY node_id")
node_ids = [r["node_id"] for r in node_rows]
rows = query("""
SELECT
2026-04-05 11:29:59 +03:00
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
2026-04-03 14:38:38 +03:00
""")
networks: dict = {}
for r in rows:
bssid = r["bssid"]
if bssid not in networks:
networks[bssid] = {
"bssid": bssid,
"ssid": r["ssid"],
"channel": r["channel"],
"encryption": r["encryption"],
"nodes": {},
}
networks[bssid]["nodes"][r["node_id"]] = {
"best_rssi": r["best_rssi"],
"avg_rssi": r["avg_rssi"],
"times_seen": r["times_seen"],
}
result = sorted(
networks.values(),
key=lambda x: len(x["nodes"]),
reverse=True,
)
for net in result:
net["node_count"] = len(net["nodes"])
return {"node_ids": node_ids, "networks": result}
def build_presence() -> dict:
now = datetime.datetime.utcnow()
cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds")
cutoff_24h = (now - datetime.timedelta(hours=24)).isoformat(timespec="seconds")
# ── Present now: real MACs seen 2+ times in the last hour ────────────────
present_rows = query("""
SELECT src_mac,
COUNT(*) AS times_seen,
MAX(rssi) AS best_rssi,
ROUND(AVG(rssi), 1) AS avg_rssi,
MAX(received_at) AS last_seen,
COUNT(DISTINCT node_id) AS node_count
FROM probe_events
WHERE src_mac IS NOT NULL AND received_at > ?
GROUP BY src_mac
HAVING COUNT(*) > 1
ORDER BY last_seen DESC
""", (cutoff_1h,))
present = []
for r in present_rows:
vendor, randomized = oui_lookup(r["src_mac"])
if not randomized:
r["vendor"] = vendor
present.append(r)
# ── New arrivals: first seen in last 24h ─────────────────────────────────
new_mac_rows = query("""
SELECT src_mac,
MIN(received_at) AS first_seen,
MAX(rssi) AS best_rssi,
COUNT(*) AS times_seen,
COUNT(DISTINCT node_id) AS node_count
FROM probe_events
WHERE src_mac IS NOT NULL
GROUP BY src_mac
HAVING MIN(received_at) > ?
ORDER BY first_seen DESC
LIMIT 100
""", (cutoff_24h,))
new_macs = []
for r in new_mac_rows:
vendor, randomized = oui_lookup(r["src_mac"])
r["vendor"] = vendor
r["randomized"] = randomized
new_macs.append(r)
new_networks = query("""
SELECT bssid,
MAX(ssid) AS ssid,
MIN(received_at) AS first_seen,
MAX(rssi) AS best_rssi,
MAX(channel) AS channel,
MAX(encryption) AS encryption,
COUNT(DISTINCT node_id) AS node_count
FROM beacon_events
WHERE bssid IS NOT NULL
GROUP BY bssid
HAVING MIN(received_at) > ?
ORDER BY first_seen DESC
LIMIT 100
""", (cutoff_24h,))
# ── Regulars: seen on 2+ distinct calendar days ───────────────────────────
reg_mac_rows = query("""
SELECT src_mac,
COUNT(DISTINCT date(received_at)) AS days_seen,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen,
MAX(rssi) AS best_rssi,
COUNT(*) AS times_seen,
COUNT(DISTINCT node_id) AS node_count
FROM probe_events
WHERE src_mac IS NOT NULL
GROUP BY src_mac
HAVING COUNT(DISTINCT date(received_at)) >= 2
ORDER BY days_seen DESC, times_seen DESC
LIMIT 50
""")
reg_macs = []
for r in reg_mac_rows:
vendor, randomized = oui_lookup(r["src_mac"])
r["vendor"] = vendor
r["randomized"] = randomized
reg_macs.append(r)
reg_networks = query("""
SELECT bssid,
MAX(ssid) AS ssid,
COUNT(DISTINCT date(received_at)) AS days_seen,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen,
MAX(rssi) AS best_rssi,
MAX(channel) AS channel,
MAX(encryption) AS encryption,
COUNT(DISTINCT node_id) AS node_count
FROM beacon_events
WHERE bssid IS NOT NULL
GROUP BY bssid
HAVING COUNT(DISTINCT date(received_at)) >= 2
ORDER BY days_seen DESC, best_rssi DESC
LIMIT 50
""")
return {
"present": present,
"new_macs": new_macs,
"new_networks": new_networks,
"reg_macs": reg_macs,
"reg_networks": reg_networks,
}
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,))
# Most targeted BSSIDs — all time
top_targets = query("""
SELECT
bssid,
COUNT(*) AS total_frames,
COUNT(DISTINCT src) AS unique_srcs,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen
FROM deauth_events
GROUP BY bssid
ORDER BY total_frames DESC
LIMIT 10
""")
if top_targets:
bssid_list = [t["bssid"] for t in top_targets]
placeholders = ",".join("?" * len(bssid_list))
ssid_rows = query(f"""
SELECT bssid, MAX(ssid) AS ssid
FROM beacon_events WHERE bssid IN ({placeholders})
GROUP BY bssid
""", tuple(bssid_list))
ssid_map = {r["bssid"]: r["ssid"] for r in ssid_rows}
for t in top_targets:
t["ssid"] = ssid_map.get(t["bssid"])
# Most targeted devices — all time (grouped by dst, the actual victim)
top_targeted_devices = query("""
SELECT
dst,
COUNT(*) AS total_frames,
COUNT(DISTINCT bssid) AS networks_used,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen
FROM deauth_events
GROUP BY dst
ORDER BY total_frames DESC
LIMIT 10
""")
for d in top_targeted_devices:
vendor, randomized = oui_lookup(d["dst"])
d["vendor"] = vendor
d["randomized"] = randomized
2026-04-05 11:29:59 +03:00
# 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 {
2026-04-05 11:29:59 +03:00
"recent": recent,
"bursts": bursts,
"summary": summary[0] if summary else {},
"top_targets": top_targets,
"top_targeted_devices": top_targeted_devices,
2026-04-05 11:29:59 +03:00
"reason_stats": reason_rows,
"heatmap": heatmap_rows,
}
def build_search(q: str) -> dict:
if not q or len(q) < 2:
return {}
like = f"%{q}%"
results = {}
# As a network — BSSID or SSID match in beacon_events
as_network = query("""
SELECT bssid, MAX(ssid) AS ssid,
COUNT(*) AS times_seen,
MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
MAX(channel) AS channel, MAX(encryption) AS encryption,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
FROM beacon_events
WHERE bssid LIKE ? OR ssid LIKE ?
GROUP BY bssid ORDER BY times_seen DESC LIMIT 20
""", (like, like))
if as_network:
results["as_network"] = as_network
# As a client device — src_mac match in probe_events
as_client = query("""
SELECT src_mac,
COUNT(*) AS times_seen,
MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
FROM probe_events
WHERE src_mac LIKE ?
GROUP BY src_mac LIMIT 10
""", (like,))
if as_client:
ssid_rows = query("""
SELECT DISTINCT src_mac, ssid FROM probe_events
WHERE src_mac LIKE ? AND ssid IS NOT NULL AND ssid != ''
""", (like,))
ssids_by_mac: dict = {}
for s in ssid_rows:
ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
for r in as_client:
vendor, randomized = oui_lookup(r["src_mac"])
r["vendor"] = vendor
r["randomized"] = randomized
r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
results["as_client"] = as_client
# Devices probing for this SSID
probing_for = query("""
SELECT src_mac,
COUNT(*) AS times_seen,
MAX(rssi) AS best_rssi,
MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
FROM probe_events
WHERE ssid LIKE ?
GROUP BY src_mac ORDER BY times_seen DESC LIMIT 20
""", (like,))
if probing_for:
for r in probing_for:
vendor, randomized = oui_lookup(r["src_mac"])
r["vendor"] = vendor
r["randomized"] = randomized
results["probing_for"] = probing_for
# As a deauth victim — dst match
as_victim = query("""
SELECT dst,
COUNT(*) AS total_frames,
COUNT(DISTINCT bssid) AS networks_used,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
FROM deauth_events
WHERE dst LIKE ?
GROUP BY dst LIMIT 10
""", (like,))
if as_victim:
for r in as_victim:
vendor, randomized = oui_lookup(r["dst"])
r["vendor"] = vendor
r["randomized"] = randomized
results["as_victim"] = as_victim
# As an impersonated AP — bssid match in deauth_events
as_impersonated = query("""
SELECT bssid,
COUNT(*) AS total_frames,
COUNT(DISTINCT dst) AS unique_targets,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
FROM deauth_events
WHERE bssid LIKE ?
GROUP BY bssid LIMIT 10
""", (like,))
if as_impersonated:
results["as_impersonated"] = as_impersonated
# Vendor name search — match query against OUI database, then find those MACs
q_lower = q.lower()
matching_prefixes = [
prefix for prefix, vendor in _OUI_DB.items()
if q_lower in vendor.lower()
][:60] # cap at 60 prefixes to keep query manageable
if matching_prefixes:
ph = ",".join("?" * len(matching_prefixes))
vendor_clients = query(f"""
SELECT src_mac,
COUNT(*) AS times_seen,
MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
FROM probe_events
WHERE substr(src_mac,1,8) IN ({ph})
GROUP BY src_mac ORDER BY times_seen DESC LIMIT 30
""", tuple(matching_prefixes))
if vendor_clients:
ssid_rows = query(f"""
SELECT DISTINCT src_mac, ssid FROM probe_events
WHERE substr(src_mac,1,8) IN ({ph})
AND ssid IS NOT NULL AND ssid != ''
""", tuple(matching_prefixes))
ssids_by_mac: dict = {}
for s in ssid_rows:
ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
for r in vendor_clients:
vendor, randomized = oui_lookup(r["src_mac"])
r["vendor"] = vendor
r["randomized"] = randomized
r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
results["vendor_clients"] = vendor_clients
vendor_networks = query(f"""
SELECT bssid, MAX(ssid) AS ssid,
COUNT(*) AS times_seen,
MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
MAX(channel) AS channel, MAX(encryption) AS encryption,
COUNT(DISTINCT node_id) AS node_count,
MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
FROM beacon_events
WHERE substr(bssid,1,8) IN ({ph})
GROUP BY bssid ORDER BY times_seen DESC LIMIT 30
""", tuple(matching_prefixes))
if vendor_networks:
results["vendor_networks"] = vendor_networks
if "vendor_clients" in results or "vendor_networks" in results:
results["vendor_name"] = next(
(v for k, v in _OUI_DB.items() if q_lower in v.lower()), q
)
return results
2026-04-03 14:38:38 +03:00
def build_node_detail(node_id: str) -> dict | None:
rows = query("""
SELECT
node_id,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen,
COUNT(*) AS total_events,
COUNT(DISTINCT ssid) AS unique_ssids,
COUNT(DISTINCT bssid) AS unique_bssids,
AVG(rssi) AS avg_rssi,
MIN(rssi) AS min_rssi,
MAX(rssi) AS max_rssi
FROM beacon_events
WHERE node_id = ?
GROUP BY node_id
""", (node_id,))
if not rows:
return None
detail = rows[0]
hb_rows = query("""
SELECT received_at AS last_heartbeat, uptime_ms, free_heap, wifi_rssi
FROM heartbeat_events
WHERE node_id = ?
ORDER BY id DESC LIMIT 1
""", (node_id,))
hb = hb_rows[0] if hb_rows else {}
detail["last_heartbeat"] = hb.get("last_heartbeat")
detail["uptime_ms"] = hb.get("uptime_ms")
detail["free_heap"] = hb.get("free_heap")
detail["wifi_rssi"] = hb.get("wifi_rssi")
if detail["last_heartbeat"]:
detail["status"] = _node_status(detail["last_heartbeat"], threshold=30)
else:
detail["status"] = _node_status(detail["last_seen"], threshold=60)
detail["avg_rssi"] = round(detail["avg_rssi"], 1) if detail["avg_rssi"] else None
detail["events"] = build_events(limit=EVENT_CAP, node_id=node_id)
return detail
# ─── Routes ──────────────────────────────────────────────────────────────────
def ensure_schema():
"""Create tables if they don't exist. Runs once at startup."""
with get_conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS beacon_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL,
node_id TEXT, node_ts INTEGER, ssid TEXT, bssid TEXT,
rssi INTEGER, channel INTEGER, encryption TEXT, importance TEXT, confidence TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS probe_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL,
node_id TEXT, node_ts INTEGER, src_mac TEXT, ssid TEXT,
rssi INTEGER, importance TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS heartbeat_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL,
node_id TEXT, node_ts 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
)
""")
2026-04-05 13:06:25 +03:00
conn.execute("""
CREATE TABLE IF NOT EXISTS assoc_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL,
node_id TEXT,
node_ts INTEGER,
subtype TEXT,
src TEXT,
bssid TEXT,
ssid TEXT,
rssi INTEGER
)
""")
conn.executescript("""
CREATE INDEX IF NOT EXISTS idx_beacon_received_at ON beacon_events (received_at);
CREATE INDEX IF NOT EXISTS idx_beacon_node_id ON beacon_events (node_id);
CREATE INDEX IF NOT EXISTS idx_beacon_bssid ON beacon_events (bssid);
CREATE INDEX IF NOT EXISTS idx_probe_received_at ON probe_events (received_at);
CREATE INDEX IF NOT EXISTS idx_probe_node_id ON probe_events (node_id);
CREATE INDEX IF NOT EXISTS idx_probe_src_mac ON probe_events (src_mac);
CREATE INDEX IF NOT EXISTS idx_heartbeat_node_id ON heartbeat_events (node_id);
CREATE INDEX IF NOT EXISTS idx_deauth_received_at ON deauth_events (received_at);
CREATE INDEX IF NOT EXISTS idx_deauth_node_id ON deauth_events (node_id);
CREATE INDEX IF NOT EXISTS idx_deauth_bssid ON deauth_events (bssid);
CREATE INDEX IF NOT EXISTS idx_deauth_src ON deauth_events (src);
CREATE INDEX IF NOT EXISTS idx_deauth_dst ON deauth_events (dst);
CREATE INDEX IF NOT EXISTS idx_assoc_received_at ON assoc_events (received_at);
CREATE INDEX IF NOT EXISTS idx_assoc_node_id ON assoc_events (node_id);
CREATE INDEX IF NOT EXISTS idx_assoc_src ON assoc_events (src);
CREATE INDEX IF NOT EXISTS idx_assoc_bssid ON assoc_events (bssid);
""")
2026-04-03 14:38:38 +03:00
conn.commit()
app = FastAPI(title="ESP32 Recon Dashboard")
ensure_schema()
@app.get("/", response_class=HTMLResponse)
async def index():
return FileResponse(HTML_PATH)
@app.get("/static/dashboard.css")
async def serve_css():
return FileResponse(CSS_PATH, media_type="text/css")
@app.get("/static/dashboard.js")
async def serve_js():
return FileResponse(JS_PATH, media_type="application/javascript")
@app.get("/api/nodes")
async def api_nodes():
return build_nodes()
@app.get("/api/events")
async def api_events(limit: int = EVENT_CAP):
limit = min(limit, EVENT_CAP)
return build_events(limit=limit)
@app.get("/api/networks")
async def api_networks():
return build_networks()
@app.get("/api/clients")
async def api_clients():
return build_clients()
@app.get("/api/cross-node")
async def api_cross_node():
return build_cross_node()
@app.get("/api/presence")
async def api_presence():
return build_presence()
2026-04-03 14:38:38 +03:00
@app.get("/api/rssi-history")
async def api_rssi_history(bssid: str, hours: int = 2):
hours = min(max(hours, 1), 48)
cutoff = (datetime.datetime.utcnow() - datetime.timedelta(hours=hours)).isoformat(timespec="seconds")
rows = query("""
SELECT node_id, received_at, rssi
FROM beacon_events
WHERE bssid = ? AND received_at >= ?
ORDER BY received_at ASC
""", (bssid, cutoff))
series: dict = {}
for r in rows:
nid = r["node_id"]
if nid not in series:
series[nid] = []
series[nid].append({"t": r["received_at"], "rssi": r["rssi"]})
return series
@app.get("/api/search")
async def api_search(q: str = ""):
return build_search(q)
@app.get("/api/alerts")
async def api_alerts():
return build_alerts()
2026-04-03 14:38:38 +03:00
@app.get("/api/heartbeats")
async def api_heartbeats():
"""Latest heartbeat per node."""
return query("""
SELECT node_id, received_at, uptime_ms, free_heap, wifi_rssi
FROM heartbeat_events
WHERE id IN (SELECT MAX(id) FROM heartbeat_events GROUP BY node_id)
""")
@app.get("/api/nodes/{node_id}")
async def api_node_detail(node_id: str):
detail = build_node_detail(node_id)
if detail is None:
raise HTTPException(status_code=404, detail="Node not found")
return detail
@app.get("/api/stream")
async def api_stream():
"""SSE stream — polls DB every 2 s, pushes new events to the browser."""
async def event_generator():
try:
last_id = 0
# Start from the current latest event so we don't replay history on connect
rows = query("SELECT MAX(id) AS max_id FROM beacon_events")
if rows and rows[0]["max_id"]:
last_id = rows[0]["max_id"]
while True:
await asyncio.sleep(2)
new_rows = query("""
SELECT * FROM beacon_events
WHERE id > ?
ORDER BY id ASC
""", (last_id,))
for row in new_rows:
last_id = row["id"]
payload = json.dumps(row)
yield f"data: {payload}\n\n"
except asyncio.CancelledError:
pass # client disconnected or server shutting down — expected, not an error
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
# ─── Entry point ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run("dashboard:app", host="0.0.0.0", port=8080, reload=False)