430 lines
14 KiB
Python
430 lines
14 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
|
|
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
|
|
|
|
# ─── 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
|
|
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 last_beacon DESC
|
|
""")
|
|
# 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
|
|
""")
|
|
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
|
|
""")
|
|
# Attach probed SSIDs and OUI vendor info to each row
|
|
for r in rows:
|
|
ssid_rows = query("""
|
|
SELECT DISTINCT ssid FROM probe_events
|
|
WHERE src_mac = ? AND ssid IS NOT NULL AND ssid != ''
|
|
ORDER BY ssid
|
|
""", (r["src_mac"],))
|
|
r["probed_ssids"] = [s["ssid"] for s in ssid_rows]
|
|
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
|
|
bssid,
|
|
MAX(ssid) AS ssid,
|
|
MAX(channel) AS channel,
|
|
MAX(encryption) AS encryption,
|
|
node_id,
|
|
MAX(rssi) AS best_rssi,
|
|
ROUND(AVG(rssi), 1) AS avg_rssi,
|
|
COUNT(*) AS times_seen
|
|
FROM beacon_events
|
|
WHERE bssid IS NOT NULL
|
|
GROUP BY bssid, node_id
|
|
ORDER BY bssid, node_id
|
|
""")
|
|
|
|
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_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.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/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/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:
|
|
from fastapi import HTTPException
|
|
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)
|