1343 lines
50 KiB
Python
1343 lines
50 KiB
Python
#!/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
|
||
HOT_DAYS = 7 # dashboard queries cover this many days; Search is always all-time
|
||
|
||
# ─── 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")
|
||
conn.execute("PRAGMA wal_autocheckpoint=500") # auto-checkpoint every 500 pages (~2MB)
|
||
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 _hot_cutoff() -> str:
|
||
"""ISO timestamp HOT_DAYS ago — used to scope dashboard queries."""
|
||
return (datetime.datetime.utcnow() - datetime.timedelta(days=HOT_DAYS)).isoformat(timespec="seconds")
|
||
|
||
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 ────────────────────────────────────────────────────────
|
||
|
||
# ─── BLE classification ──────────────────────────────────────────────────────
|
||
|
||
BLE_COMPANIES: dict[int, str] = {
|
||
0x004C: "Apple",
|
||
0x0075: "Samsung",
|
||
0x0006: "Microsoft",
|
||
0x00E0: "Google",
|
||
0x0059: "Nordic Semiconductor",
|
||
0x0138: "Garmin",
|
||
0x0499: "Ruuvi Innovations",
|
||
0x0157: "Polar Electro",
|
||
0x00BD: "Fitbit",
|
||
0x0171: "Amazon",
|
||
0x004F: "MediaTek",
|
||
0x001D: "Qualcomm",
|
||
}
|
||
|
||
# Apple manufacturer data: after the 2-byte company ID, byte 0 of mfr_data is the type.
|
||
APPLE_TYPES: dict[str, str] = {
|
||
"02": "iBeacon",
|
||
"05": "AirDrop",
|
||
"07": "HomeKit",
|
||
"09": "AirPods",
|
||
"0a": "AirPods Pro",
|
||
"0b": "AirPods 2nd gen",
|
||
"0c": "AirPods case / Watch",
|
||
"0f": "AirPods 3rd gen",
|
||
"12": "FindMy / AirTag",
|
||
"13": "AirPods 4th gen",
|
||
"15": "Proximity Pair",
|
||
"1e": "AirPods Pro 2nd gen",
|
||
}
|
||
|
||
|
||
def ble_device_type(mfr_id: int | None, mfr_data: str | None) -> str:
|
||
if mfr_id is None:
|
||
return "Unknown"
|
||
if mfr_id == 0x004C and mfr_data and len(mfr_data) >= 2:
|
||
return APPLE_TYPES.get(mfr_data[:2].lower(), "Apple device")
|
||
vendor = BLE_COMPANIES.get(mfr_id)
|
||
return f"{vendor} device" if vendor else f"Unknown (0x{mfr_id:04X})"
|
||
|
||
|
||
_nodes_cache: tuple[float, list] | None = None
|
||
_networks_cache: tuple[float, list] | None = None
|
||
_clients_cache: tuple[float, list] | None = None
|
||
_cross_node_cache: tuple[float, dict] | None = None
|
||
_presence_cache: tuple[float, dict] | None = None
|
||
_alerts_cache: tuple[float, dict] | None = None
|
||
_sessions_cache: tuple[float, list] | None = None
|
||
_ble_cache: tuple[float, dict] | None = None
|
||
_NODES_CACHE_TTL = 10 # seconds
|
||
_NETWORKS_CACHE_TTL = 30 # seconds
|
||
_CLIENTS_CACHE_TTL = 30 # seconds
|
||
_CROSS_NODE_CACHE_TTL = 60 # seconds
|
||
_PRESENCE_CACHE_TTL = 60 # seconds
|
||
_ALERTS_CACHE_TTL = 60 # seconds
|
||
_SESSIONS_CACHE_TTL = 60 # seconds
|
||
_BLE_CACHE_TTL = 30 # seconds
|
||
|
||
def build_nodes() -> list[dict]:
|
||
global _nodes_cache
|
||
now = datetime.datetime.utcnow().timestamp()
|
||
if _nodes_cache and (now - _nodes_cache[0]) < _NODES_CACHE_TTL:
|
||
return _nodes_cache[1]
|
||
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)
|
||
_nodes_cache = (now, rows)
|
||
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]:
|
||
global _networks_cache
|
||
now = datetime.datetime.utcnow().timestamp()
|
||
if _networks_cache and (now - _networks_cache[0]) < _NETWORKS_CACHE_TTL:
|
||
return _networks_cache[1]
|
||
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 AND received_at > ?
|
||
GROUP BY bssid
|
||
ORDER BY times_seen DESC
|
||
LIMIT 300
|
||
""", (_hot_cutoff(),))
|
||
_networks_cache = (now, rows)
|
||
return rows
|
||
|
||
|
||
def build_clients() -> list[dict]:
|
||
global _clients_cache
|
||
now = datetime.datetime.utcnow().timestamp()
|
||
if _clients_cache and (now - _clients_cache[0]) < _CLIENTS_CACHE_TTL:
|
||
return _clients_cache[1]
|
||
"""
|
||
Per unique src_mac: aggregated stats and the SSIDs they were probing for.
|
||
"""
|
||
cutoff = _hot_cutoff()
|
||
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 AND received_at > ?
|
||
GROUP BY src_mac
|
||
ORDER BY times_seen DESC
|
||
LIMIT 300
|
||
""", (cutoff,))
|
||
# 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 != ''
|
||
AND received_at > ?
|
||
ORDER BY src_mac, ssid
|
||
""", tuple(mac_list) + (cutoff,))
|
||
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
|
||
_clients_cache = (now, rows)
|
||
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}}}] }
|
||
"""
|
||
global _cross_node_cache
|
||
now = datetime.datetime.utcnow().timestamp()
|
||
if _cross_node_cache and (now - _cross_node_cache[0]) < _CROSS_NODE_CACHE_TTL:
|
||
return _cross_node_cache[1]
|
||
|
||
node_rows = query("SELECT DISTINCT node_id FROM beacon_events ORDER BY node_id")
|
||
node_ids = [r["node_id"] for r in node_rows]
|
||
|
||
cutoff = _hot_cutoff()
|
||
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.received_at > ? AND b.bssid IN (
|
||
SELECT bssid FROM beacon_events
|
||
WHERE bssid IS NOT NULL AND received_at > ?
|
||
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
|
||
""", (cutoff, cutoff))
|
||
|
||
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"])
|
||
|
||
result_dict = {"node_ids": node_ids, "networks": result}
|
||
_cross_node_cache = (now, result_dict)
|
||
return result_dict
|
||
|
||
|
||
def build_presence() -> dict:
|
||
global _presence_cache
|
||
_now_ts = datetime.datetime.utcnow().timestamp()
|
||
if _presence_cache and (_now_ts - _presence_cache[0]) < _PRESENCE_CACHE_TTL:
|
||
return _presence_cache[1]
|
||
|
||
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)
|
||
|
||
cutoff_hot = _hot_cutoff()
|
||
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 AND received_at > ?
|
||
GROUP BY bssid
|
||
HAVING MIN(received_at) > ?
|
||
ORDER BY first_seen DESC
|
||
LIMIT 100
|
||
""", (cutoff_hot, 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
|
||
""")
|
||
|
||
result = {
|
||
"present": present,
|
||
"new_macs": new_macs,
|
||
"new_networks": new_networks,
|
||
"reg_macs": reg_macs,
|
||
"reg_networks": reg_networks,
|
||
}
|
||
_presence_cache = (_now_ts, result)
|
||
return result
|
||
|
||
|
||
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"
|
||
|
||
desc, _ = deauth_reason_info(dominant)
|
||
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,
|
||
"dominant_reason_desc": desc,
|
||
"dominant_reason_class": classification,
|
||
"severity": severity,
|
||
"events": active["frames"][:50],
|
||
}
|
||
|
||
|
||
def build_sessions() -> list[dict]:
|
||
global _sessions_cache
|
||
now = datetime.datetime.utcnow().timestamp()
|
||
if _sessions_cache and (now - _sessions_cache[0]) < _SESSIONS_CACHE_TTL:
|
||
return _sessions_cache[1]
|
||
|
||
cutoff = _hot_cutoff()
|
||
|
||
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)
|
||
result = sessions[:100]
|
||
_sessions_cache = (now, result)
|
||
return result
|
||
|
||
|
||
def build_alerts() -> dict:
|
||
global _alerts_cache
|
||
_now_ts = datetime.datetime.utcnow().timestamp()
|
||
if _alerts_cache and (_now_ts - _alerts_cache[0]) < _ALERTS_CACHE_TTL:
|
||
return _alerts_cache[1]
|
||
|
||
now = datetime.datetime.utcnow()
|
||
cutoff_5m = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds")
|
||
cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds")
|
||
cutoff_7d = (now - datetime.timedelta(days=7)).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 — last 7 days
|
||
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
|
||
WHERE received_at > ?
|
||
GROUP BY bssid
|
||
ORDER BY total_frames DESC
|
||
LIMIT 10
|
||
""", (cutoff_7d,))
|
||
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}
|
||
# Dominant reason per impersonated BSSID
|
||
reason_rows_t = query(f"""
|
||
SELECT bssid, reason, COUNT(*) AS cnt
|
||
FROM deauth_events
|
||
WHERE received_at > ? AND bssid IN ({placeholders})
|
||
GROUP BY bssid, reason
|
||
ORDER BY bssid, cnt DESC
|
||
""", (cutoff_7d,) + tuple(bssid_list))
|
||
dom_reason_by_bssid: dict = {}
|
||
for r in reason_rows_t:
|
||
if r["bssid"] not in dom_reason_by_bssid:
|
||
dom_reason_by_bssid[r["bssid"]] = r["reason"]
|
||
for t in top_targets:
|
||
t["ssid"] = ssid_map.get(t["bssid"])
|
||
code = dom_reason_by_bssid.get(t["bssid"])
|
||
if code is not None:
|
||
desc, cls = deauth_reason_info(code)
|
||
t["dominant_reason"] = code
|
||
t["dominant_reason_desc"] = desc
|
||
t["dominant_reason_class"] = cls
|
||
else:
|
||
t["dominant_reason"] = None
|
||
t["dominant_reason_desc"] = None
|
||
t["dominant_reason_class"] = None
|
||
|
||
# Most targeted devices — last 7 days (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
|
||
WHERE received_at > ?
|
||
GROUP BY dst
|
||
ORDER BY total_frames DESC
|
||
LIMIT 10
|
||
""", (cutoff_7d,))
|
||
if top_targeted_devices:
|
||
dst_list = [d["dst"] for d in top_targeted_devices]
|
||
placeholders = ",".join("?" * len(dst_list))
|
||
# Dominant reason per targeted dst MAC
|
||
reason_rows_d = query(f"""
|
||
SELECT dst, reason, COUNT(*) AS cnt
|
||
FROM deauth_events
|
||
WHERE received_at > ? AND dst IN ({placeholders})
|
||
GROUP BY dst, reason
|
||
ORDER BY dst, cnt DESC
|
||
""", (cutoff_7d,) + tuple(dst_list))
|
||
dom_reason_by_dst: dict = {}
|
||
for r in reason_rows_d:
|
||
if r["dst"] not in dom_reason_by_dst:
|
||
dom_reason_by_dst[r["dst"]] = r["reason"]
|
||
else:
|
||
dom_reason_by_dst = {}
|
||
|
||
for d in top_targeted_devices:
|
||
vendor, randomized = oui_lookup(d["dst"])
|
||
d["vendor"] = vendor
|
||
d["randomized"] = randomized
|
||
code = dom_reason_by_dst.get(d["dst"])
|
||
if code is not None:
|
||
desc, cls = deauth_reason_info(code)
|
||
d["dominant_reason"] = code
|
||
d["dominant_reason_desc"] = desc
|
||
d["dominant_reason_class"] = cls
|
||
else:
|
||
d["dominant_reason"] = None
|
||
d["dominant_reason_desc"] = None
|
||
d["dominant_reason_class"] = None
|
||
|
||
# Reason code breakdown — last 7 days
|
||
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
|
||
WHERE received_at > ?
|
||
GROUP BY reason
|
||
ORDER BY total_frames DESC
|
||
""", (cutoff_7d,))
|
||
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, last 7 days
|
||
heatmap_rows = query("""
|
||
SELECT date(received_at) AS day,
|
||
CAST(strftime('%H', received_at) AS INTEGER) AS hour,
|
||
COUNT(*) AS frames
|
||
FROM deauth_events
|
||
WHERE received_at > ?
|
||
GROUP BY day, hour
|
||
ORDER BY day, hour
|
||
""", (cutoff_7d,))
|
||
|
||
result = {
|
||
"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,
|
||
}
|
||
_alerts_cache = (_now_ts, result)
|
||
return result
|
||
|
||
|
||
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_beacon_bssid_time ON beacon_events (bssid, received_at);
|
||
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_probe_src_time ON probe_events (src_mac, received_at);
|
||
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_bssid_time ON deauth_events (bssid, received_at);
|
||
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()
|
||
|
||
|
||
PRUNE_INTERVAL_SECS = 6 * 3600 # run pruning every 6 hours
|
||
CHECKPOINT_INTERVAL_SECS = 30 * 60 # force WAL checkpoint every 30 minutes
|
||
|
||
|
||
def prune_old_events() -> None:
|
||
"""Delete events older than HOT_DAYS from all event tables, then checkpoint."""
|
||
cutoff = _hot_cutoff()
|
||
tables = ["beacon_events", "probe_events", "deauth_events", "assoc_events"]
|
||
with get_conn() as conn:
|
||
for table in tables:
|
||
conn.execute(f"DELETE FROM {table} WHERE received_at < ?", (cutoff,))
|
||
conn.commit()
|
||
checkpoint_wal()
|
||
|
||
|
||
def checkpoint_wal() -> None:
|
||
"""
|
||
Force a RESTART checkpoint — writes all WAL frames to the main DB file
|
||
and resets the WAL back to the beginning. Prevents the WAL from growing
|
||
unbounded under sustained write load, which causes DB corruption over time.
|
||
RESTART blocks until all current readers finish, then checkpoints fully.
|
||
"""
|
||
with get_conn() as conn:
|
||
conn.execute("PRAGMA wal_checkpoint(RESTART)")
|
||
|
||
|
||
app = FastAPI(title="ESP32 Recon Dashboard")
|
||
ensure_schema()
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def start_background_tasks():
|
||
async def _pruner():
|
||
while True:
|
||
await asyncio.sleep(PRUNE_INTERVAL_SECS)
|
||
await asyncio.to_thread(prune_old_events)
|
||
|
||
async def _checkpointer():
|
||
while True:
|
||
await asyncio.sleep(CHECKPOINT_INTERVAL_SECS)
|
||
await asyncio.to_thread(checkpoint_wal)
|
||
|
||
asyncio.create_task(_pruner())
|
||
asyncio.create_task(_checkpointer())
|
||
|
||
|
||
@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 await asyncio.to_thread(build_nodes)
|
||
|
||
|
||
@app.get("/api/events")
|
||
async def api_events(limit: int = EVENT_CAP):
|
||
limit = min(limit, EVENT_CAP)
|
||
return await asyncio.to_thread(build_events, limit)
|
||
|
||
|
||
@app.get("/api/networks")
|
||
async def api_networks():
|
||
return await asyncio.to_thread(build_networks)
|
||
|
||
|
||
@app.get("/api/clients")
|
||
async def api_clients():
|
||
return await asyncio.to_thread(build_clients)
|
||
|
||
|
||
@app.get("/api/cross-node")
|
||
async def api_cross_node():
|
||
return await asyncio.to_thread(build_cross_node)
|
||
|
||
|
||
@app.get("/api/presence")
|
||
async def api_presence():
|
||
return await asyncio.to_thread(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")
|
||
|
||
def _fetch():
|
||
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
|
||
|
||
return await asyncio.to_thread(_fetch)
|
||
|
||
|
||
@app.get("/api/sessions")
|
||
async def api_sessions():
|
||
return await asyncio.to_thread(build_sessions)
|
||
|
||
|
||
@app.get("/api/search")
|
||
async def api_search(q: str = ""):
|
||
return await asyncio.to_thread(build_search, q)
|
||
|
||
|
||
@app.get("/api/alerts")
|
||
async def api_alerts():
|
||
return await asyncio.to_thread(build_alerts)
|
||
|
||
|
||
@app.get("/api/heartbeats")
|
||
async def api_heartbeats():
|
||
"""Latest heartbeat per node."""
|
||
return await asyncio.to_thread(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 = await asyncio.to_thread(build_node_detail, node_id)
|
||
if detail is None:
|
||
raise HTTPException(status_code=404, detail="Node not found")
|
||
return detail
|
||
|
||
|
||
@app.get("/api/ble")
|
||
async def api_ble():
|
||
data = await asyncio.to_thread(build_ble)
|
||
return data
|
||
|
||
|
||
@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 = await asyncio.to_thread(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 = await asyncio.to_thread(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",
|
||
},
|
||
)
|
||
|
||
|
||
def build_ble() -> dict:
|
||
global _ble_cache
|
||
now = datetime.datetime.utcnow().timestamp()
|
||
if _ble_cache and (now - _ble_cache[0]) < _BLE_CACHE_TTL:
|
||
return _ble_cache[1]
|
||
|
||
cutoff = _hot_cutoff()
|
||
|
||
devices = query("""
|
||
SELECT
|
||
mac, addr_type,
|
||
MAX(name) AS name,
|
||
mfr_id,
|
||
MAX(mfr_data) AS mfr_data,
|
||
MAX(rssi) AS best_rssi,
|
||
COUNT(*) AS times_seen,
|
||
MIN(received_at) AS first_seen,
|
||
MAX(received_at) AS last_seen,
|
||
COUNT(DISTINCT node_id) AS node_count
|
||
FROM ble_events
|
||
WHERE received_at >= ?
|
||
GROUP BY mac
|
||
ORDER BY times_seen DESC
|
||
LIMIT 300
|
||
""", (cutoff,))
|
||
|
||
for d in devices:
|
||
d["type"] = ble_device_type(d["mfr_id"], d["mfr_data"])
|
||
d["vendor"] = BLE_COMPANIES.get(d["mfr_id"], "Unknown") if d["mfr_id"] is not None else "Unknown"
|
||
|
||
feed = query("""
|
||
SELECT received_at, node_id, mac, addr_type, name, rssi, mfr_id, mfr_data
|
||
FROM ble_events
|
||
WHERE received_at >= ?
|
||
ORDER BY id DESC
|
||
LIMIT 100
|
||
""", (cutoff,))
|
||
|
||
for f in feed:
|
||
f["type"] = ble_device_type(f["mfr_id"], f["mfr_data"])
|
||
|
||
# Manufacturer breakdown
|
||
breakdown = query("""
|
||
SELECT mfr_id, COUNT(DISTINCT mac) AS unique_devices
|
||
FROM ble_events
|
||
WHERE received_at >= ?
|
||
GROUP BY mfr_id
|
||
ORDER BY unique_devices DESC
|
||
LIMIT 20
|
||
""", (cutoff,))
|
||
|
||
for b in breakdown:
|
||
b["vendor"] = BLE_COMPANIES.get(b["mfr_id"], "Unknown") if b["mfr_id"] is not None else "No mfr data"
|
||
|
||
result = {"devices": devices, "feed": feed, "breakdown": breakdown}
|
||
_ble_cache = (now, result)
|
||
return result
|
||
|
||
|
||
# ─── Entry point ─────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run("dashboard:app", host="0.0.0.0", port=8080, reload=False)
|