Files
esp32-cluster/coordinator/dashboard.py
T
2026-04-05 20:59:22 +03:00

1058 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
from fastapi import FastAPI, Request, HTTPException
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
# ─── 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]:
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
conn.execute("PRAGMA journal_mode=WAL")
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
""")
# 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
LIMIT 300
""")
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
LIMIT 300
""")
# 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 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"])
for r in rows:
r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
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
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 = {}
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,
}
SESSION_GAP_SECS = 120 # gap > 2 min between events from same src = new session
def _finalize_session(active: dict) -> dict:
reasons = active["reasons"]
dominant = max(set(reasons), key=reasons.count) if reasons else 0
_, classification = deauth_reason_info(dominant)
rssi_vals = [r for r in active["rssi_vals"] if r is not None]
start_dt = datetime.datetime.fromisoformat(active["start"])
end_dt = datetime.datetime.fromisoformat(active["end"])
duration = int((end_dt - start_dt).total_seconds())
frames = len(active["frames"])
if classification == "attack" and frames >= 5:
severity = "attack"
elif frames >= 3 or classification in ("attack", "suspicious"):
severity = "suspicious"
else:
severity = "normal"
src_vendor, src_rand = oui_lookup(active["src"])
return {
"src": active["src"],
"src_vendor": src_vendor,
"src_randomized": src_rand,
"start": active["start"],
"end": active["end"],
"duration_secs": duration,
"frame_count": frames,
"unique_bssids": list(active["bssids"]),
"unique_targets": list(active["targets"]),
"node_count": len(active["nodes"]),
"nodes": list(active["nodes"]),
"peak_rssi": max(rssi_vals) if rssi_vals else None,
"avg_rssi": round(sum(rssi_vals) / len(rssi_vals), 1) if rssi_vals else None,
"dominant_reason": dominant,
"severity": severity,
"events": active["frames"][:50],
}
def build_sessions() -> list[dict]:
cutoff = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).isoformat(timespec="seconds")
rows = query("""
SELECT src, dst, bssid, reason, rssi, received_at, node_id, subtype
FROM deauth_events
WHERE received_at > ?
ORDER BY src, received_at
""", (cutoff,))
if not rows:
return []
sessions: list[dict] = []
active: dict | None = None
for row in rows:
src = row["src"]
ts = row["received_at"]
new_session = False
if active is None or active["src"] != src:
new_session = True
else:
last_dt = datetime.datetime.fromisoformat(active["end"])
curr_dt = datetime.datetime.fromisoformat(ts)
if (curr_dt - last_dt).total_seconds() > SESSION_GAP_SECS:
new_session = True
if new_session:
if active is not None:
sessions.append(_finalize_session(active))
active = {
"src": src,
"start": ts,
"end": ts,
"bssids": set(),
"targets": set(),
"nodes": set(),
"reasons": [],
"rssi_vals": [],
"frames": [],
}
active["end"] = ts
active["bssids"].add(row["bssid"])
active["targets"].add(row["dst"])
active["nodes"].add(row["node_id"])
active["reasons"].append(row["reason"])
active["rssi_vals"].append(row["rssi"])
active["frames"].append(row)
if active is not None:
sessions.append(_finalize_session(active))
sessions.sort(key=lambda s: s["start"], reverse=True)
return sessions[:100]
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
# 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 {},
"top_targets": top_targets,
"top_targeted_devices": top_targeted_devices,
"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
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
)
""")
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);
""")
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()
@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/sessions")
async def api_sessions():
return build_sessions()
@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()
@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)