#!/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)