sligh changed to dashbaord
This commit is contained in:
@@ -21,18 +21,19 @@ This is a learning/research project covering distributed systems, event-driven a
|
||||
|
||||
## Hardware
|
||||
|
||||
- **Nodes:** ESP32-S3 N16R8 (3 active, scattered across apartment)
|
||||
- **Nodes:** ESP32-S3 N16R8 (4 active, scattered across apartment)
|
||||
- **Coordinator:** Orange Pi at `192.168.1.133`, runs 24/7 as systemd services
|
||||
- **Dev machine:** This PC at `192.168.1.101` — coding and flashing only
|
||||
- **Flashing:** Arduino CLI on this PC
|
||||
|
||||
### Active nodes
|
||||
|
||||
| node_id | MAC | Port |
|
||||
|----------|-------------------|--------------|
|
||||
| F68D6E30 | 44:1b:f6:8d:6e:30 | /dev/ttyACM0 |
|
||||
| A1D6F190 | e0:72:a1:d6:f1:90 | /dev/ttyACM1 |
|
||||
| A1D658D4 | e0:72:a1:d6:58:d4 | /dev/ttyACM2 |
|
||||
| node_id | MAC | Location / Port |
|
||||
|----------|-------------------|------------------------|
|
||||
| F68D6E30 | 44:1b:f6:8d:6e:30 | desk /dev/ttyACM0 |
|
||||
| A1D658D4 | e0:72:a1:d6:58:d4 | desk /dev/ttyACM1 |
|
||||
| A1D700C4 | e0:72:a1:d7:00:c4 | desk /dev/ttyACM2 |
|
||||
| A1D6F190 | e0:72:a1:d6:f1:90 | deployed (another room)|
|
||||
|
||||
---
|
||||
|
||||
@@ -324,13 +325,13 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa
|
||||
|
||||
- [x] Arduino CLI installed, ESP32 core configured
|
||||
- [x] Node firmware: beacon scan + probe sniffing
|
||||
- [x] Three ESP32-S3 nodes flashed and running
|
||||
- [x] Four ESP32-S3 nodes flashed and running
|
||||
- [x] UDP ingestor (`udp_ingest.py`) — beacon + probe routing with field validation
|
||||
- [x] Web dashboard: Feed, Networks, Cross-node, Clients, Node detail views
|
||||
- [x] OUI vendor lookup in Clients view
|
||||
- [x] Dashboard split into HTML / CSS / JS (no monolithic file)
|
||||
- [x] Deployed to Orange Pi as systemd services (runs 24/7)
|
||||
- [x] All three nodes sending to Orange Pi, confirmed live
|
||||
- [x] All four nodes sending to Orange Pi, confirmed live
|
||||
- [x] Node heartbeat every 10s — uptime, free heap, AP signal, drives online/offline status
|
||||
- [x] RSSI history chart per network (canvas, per-node coloured lines, 1h/2h/6h/24h range)
|
||||
- [x] Presence tab — Present Now, New Arrivals, Regulars
|
||||
|
||||
+97
-16
@@ -9,7 +9,7 @@ import sqlite3
|
||||
import datetime
|
||||
import os
|
||||
import json
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "events.db")
|
||||
@@ -20,6 +20,46 @@ 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]:
|
||||
@@ -63,6 +103,7 @@ def oui_lookup(mac: str) -> tuple[str, bool]:
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
@@ -147,6 +188,7 @@ def build_networks() -> list[dict]:
|
||||
WHERE bssid IS NOT NULL
|
||||
GROUP BY bssid
|
||||
ORDER BY times_seen DESC
|
||||
LIMIT 300
|
||||
""")
|
||||
return rows
|
||||
|
||||
@@ -169,13 +211,18 @@ def build_clients() -> list[dict]:
|
||||
WHERE src_mac IS NOT NULL
|
||||
GROUP BY src_mac
|
||||
ORDER BY times_seen DESC
|
||||
LIMIT 300
|
||||
""")
|
||||
# Fetch all probed SSIDs in one query, group in Python — avoids N+1
|
||||
ssid_rows = query("""
|
||||
# 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 IS NOT NULL AND ssid IS NOT NULL AND ssid != ''
|
||||
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"])
|
||||
@@ -199,18 +246,24 @@ def build_cross_node() -> dict:
|
||||
|
||||
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,
|
||||
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
|
||||
FROM beacon_events b
|
||||
WHERE b.bssid IN (
|
||||
SELECT bssid FROM beacon_events
|
||||
WHERE bssid IS NOT NULL
|
||||
GROUP BY bssid, node_id
|
||||
ORDER BY bssid, node_id
|
||||
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 = {}
|
||||
@@ -455,12 +508,41 @@ def build_alerts() -> dict:
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -792,7 +874,6 @@ async def api_heartbeats():
|
||||
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
|
||||
|
||||
|
||||
@@ -470,6 +470,16 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Deauth heatmap ── */
|
||||
#deauth-heatmap {
|
||||
display: block;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.reason-normal { color: var(--text-dim); }
|
||||
.reason-suspicious { color: var(--orange); }
|
||||
.reason-attack { color: #e74c3c; font-weight: 500; }
|
||||
|
||||
/* ── Alerts ── */
|
||||
.alerts-summary {
|
||||
display: flex;
|
||||
|
||||
@@ -438,6 +438,96 @@ function renderPresenceView() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deauth heatmap ────────────────────────────────────────────────────────────
|
||||
function renderDeauthHeatmap(rows) {
|
||||
const canvas = document.getElementById('deauth-heatmap');
|
||||
const empty = document.getElementById('heatmap-empty');
|
||||
const subtitle = document.getElementById('heatmap-subtitle');
|
||||
|
||||
if (!rows.length) {
|
||||
canvas.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
subtitle.textContent = '';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
empty.style.display = 'none';
|
||||
|
||||
// Build lookup
|
||||
const lookup = {};
|
||||
let maxFrames = 0;
|
||||
rows.forEach(r => {
|
||||
if (!lookup[r.day]) lookup[r.day] = {};
|
||||
lookup[r.day][r.hour] = r.frames;
|
||||
if (r.frames > maxFrames) maxFrames = r.frames;
|
||||
});
|
||||
const days = Object.keys(lookup).sort();
|
||||
subtitle.textContent = `${days.length} days`;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const padL = 100, padT = 36, padR = 16, padB = 12;
|
||||
const cellH = 42;
|
||||
|
||||
// Fill container width exactly
|
||||
const containerW = canvas.parentElement.clientWidth;
|
||||
const cellW = Math.floor((containerW - padL - padR) / 24);
|
||||
|
||||
const W = padL + 24 * cellW + padR;
|
||||
const H = padT + days.length * cellH + padB;
|
||||
|
||||
// Set physical pixel size, CSS display size
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
canvas.style.width = W + 'px';
|
||||
canvas.style.height = H + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Hour labels
|
||||
ctx.font = '11px Roboto, sans-serif';
|
||||
ctx.fillStyle = '#5a7a60';
|
||||
ctx.textAlign = 'center';
|
||||
for (let h = 0; h < 24; h++) {
|
||||
ctx.fillText(String(h).padStart(2, '0'), padL + h * cellW + cellW / 2, padT - 10);
|
||||
}
|
||||
|
||||
// Day rows
|
||||
days.forEach((day, di) => {
|
||||
const y = padT + di * cellH;
|
||||
|
||||
// Day label
|
||||
ctx.font = '11px Roboto, sans-serif';
|
||||
ctx.fillStyle = '#5a7a60';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(day.slice(5), padL - 8, y + cellH / 2 + 4);
|
||||
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const frames = (lookup[day] && lookup[day][h]) || 0;
|
||||
const x = padL + h * cellW;
|
||||
|
||||
if (frames === 0) {
|
||||
ctx.fillStyle = '#111a14';
|
||||
} else {
|
||||
const t = Math.min(frames / maxFrames, 1);
|
||||
const r = Math.round(80 + t * 151);
|
||||
const g = Math.round(20 + t * 10);
|
||||
const b = Math.round(20 + t * 10);
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
}
|
||||
ctx.fillRect(x + 1, y + 1, cellW - 2, cellH - 2);
|
||||
|
||||
if (frames > 0) {
|
||||
ctx.font = '11px Roboto, sans-serif';
|
||||
ctx.fillStyle = frames > maxFrames * 0.4 ? '#fff' : '#e74c3c';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(frames > 999 ? '999+' : frames, x + cellW / 2, y + cellH / 2 + 4);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Alerts view ───────────────────────────────────────────────────────────────
|
||||
async function fetchAlerts() {
|
||||
alertsData = await fetch('/api/alerts').then(r => r.json());
|
||||
@@ -471,6 +561,33 @@ function renderAlertsView() {
|
||||
<span>unique sources</span>
|
||||
</div>`;
|
||||
|
||||
// Reason code breakdown
|
||||
const reasonsTbody = document.getElementById('reasons-tbody');
|
||||
const reasonsEmpty = document.getElementById('reasons-empty');
|
||||
const reasonsCount = document.getElementById('reasons-count');
|
||||
const reasons = a.reason_stats || [];
|
||||
reasonsCount.textContent = `${reasons.length} codes`;
|
||||
if (!reasons.length) {
|
||||
reasonsTbody.innerHTML = '';
|
||||
reasonsEmpty.style.display = 'block';
|
||||
} else {
|
||||
reasonsEmpty.style.display = 'none';
|
||||
reasonsTbody.innerHTML = reasons.map(r => {
|
||||
const cls = `reason-${r.classification}`;
|
||||
const label = r.classification === 'attack' ? 'ATTACK'
|
||||
: r.classification === 'suspicious' ? 'SUSPICIOUS' : 'normal';
|
||||
return `<tr>
|
||||
<td class="muted">${r.reason}</td>
|
||||
<td>${r.description}</td>
|
||||
<td class="${cls}">${label}</td>
|
||||
<td class="${r.classification === 'attack' ? cls : ''}" style="${r.classification === 'attack' ? 'font-weight:500' : ''}">${r.total_frames}</td>
|
||||
<td class="muted">${r.unique_bssids}</td>
|
||||
<td class="muted">${r.unique_targets}</td>
|
||||
<td class="dim">${shortTime(r.last_seen)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Most targeted networks
|
||||
const targetsTbody = document.getElementById('targets-tbody');
|
||||
const targetsEmpty = document.getElementById('targets-empty');
|
||||
@@ -863,6 +980,7 @@ function setView(view) {
|
||||
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
|
||||
document.getElementById('view-presence').classList.toggle('active', view === 'presence');
|
||||
document.getElementById('view-alerts').classList.toggle('active', view === 'alerts');
|
||||
document.getElementById('view-analysis').classList.toggle('active', view === 'analysis');
|
||||
document.getElementById('view-search').classList.toggle('active', view === 'search');
|
||||
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
|
||||
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart');
|
||||
@@ -870,6 +988,7 @@ function setView(view) {
|
||||
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
|
||||
document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
|
||||
document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts');
|
||||
document.getElementById('tab-analysis').classList.toggle('active', view === 'analysis');
|
||||
document.getElementById('tab-search').classList.toggle('active', view === 'search');
|
||||
}
|
||||
|
||||
@@ -891,6 +1010,12 @@ async function showPresence() { setView('presence'); await fetchPresence(); }
|
||||
|
||||
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
|
||||
|
||||
async function showAnalysis() {
|
||||
setView('analysis');
|
||||
const data = await fetch('/api/alerts').then(r => r.json());
|
||||
requestAnimationFrame(() => renderDeauthHeatmap(data.heatmap || []));
|
||||
}
|
||||
|
||||
function showSearch() { setView('search'); document.getElementById('search-input').focus(); }
|
||||
|
||||
async function showDetail(node_id) {
|
||||
@@ -946,6 +1071,7 @@ function startSSE() {
|
||||
else if (currentView === 'crossnode') await fetchCrossNode();
|
||||
else if (currentView === 'presence') await fetchPresence();
|
||||
else if (currentView === 'alerts') await fetchAlerts();
|
||||
else if (currentView === 'analysis') await showAnalysis();
|
||||
|
||||
const nodes = await fetch('/api/nodes').then(r => r.json());
|
||||
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<button class="tab-btn" id="tab-clients" onclick="showClients()">Clients</button>
|
||||
<button class="tab-btn" id="tab-presence" onclick="showPresence()">Presence</button>
|
||||
<button class="tab-btn" id="tab-alerts" onclick="showAlerts()">Alerts</button>
|
||||
<button class="tab-btn" id="tab-analysis" onclick="showAnalysis()">Analysis</button>
|
||||
<button class="tab-btn" id="tab-search" onclick="showSearch()">Search</button>
|
||||
</div>
|
||||
|
||||
@@ -350,6 +351,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason code breakdown -->
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title">Reason Code Breakdown</span>
|
||||
<span class="section-count" id="reasons-count"></span>
|
||||
<span class="presence-sub">all time · normal / suspicious / attack classification</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Code</th><th>Description</th><th>Class</th>
|
||||
<th>Frames</th><th>Unique BSSIDs</th><th>Unique Targets</th><th>Last Seen</th>
|
||||
</tr></thead>
|
||||
<tbody id="reasons-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="reasons-empty" style="display:none">No data yet.</div>
|
||||
</div>
|
||||
|
||||
<!-- Detected bursts -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Detected Bursts</span>
|
||||
@@ -385,6 +403,20 @@
|
||||
|
||||
</div><!-- /#view-alerts -->
|
||||
|
||||
<!-- Analysis view -->
|
||||
<div id="view-analysis" class="view">
|
||||
|
||||
<!-- Deauth activity heatmap -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Deauth Activity Heatmap</span>
|
||||
<span class="section-count" id="heatmap-subtitle"></span>
|
||||
<span class="presence-sub">frame count per day × hour — pattern reveals manual vs automated activity</span>
|
||||
</div>
|
||||
<canvas id="deauth-heatmap"></canvas>
|
||||
<div class="empty" id="heatmap-empty" style="display:none">No deauth data yet.</div>
|
||||
|
||||
</div><!-- /#view-analysis -->
|
||||
|
||||
<!-- Search view -->
|
||||
<div id="view-search" class="view">
|
||||
<div class="search-bar">
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import sqlite3
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
|
||||
UDP_IP = "0.0.0.0"
|
||||
UDP_PORT = 5005
|
||||
@@ -19,6 +20,7 @@ DB_PATH = os.path.join(os.path.dirname(__file__), "events.db")
|
||||
|
||||
def init_db(path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(path, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS beacon_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -129,7 +131,6 @@ def store_probe(conn: sqlite3.Connection, ev: dict, received_at: str):
|
||||
|
||||
# ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
import re
|
||||
_MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
|
||||
|
||||
def _check_mac(val: str) -> bool:
|
||||
|
||||
@@ -17,5 +17,9 @@
|
||||
// ─── Probe sniffing ──────────────────────────────────────────────────────────
|
||||
#define PROBE_SNIFF 1 // 1 = enabled, 0 = disabled
|
||||
#define PROBE_DEDUP_SECS 30 // suppress same MAC+SSID within this window (seconds)
|
||||
#define PROBE_QUEUE_SIZE 32 // max probe events buffered between scan cycles
|
||||
#define DEAUTH_QUEUE_SIZE 32 // max deauth/disassoc events buffered between scan cycles
|
||||
#define DEDUP_CACHE_SIZE 64 // unique MAC+SSID pairs tracked for dedup
|
||||
#define PROBE_QUEUE_SIZE 128 // max probe events buffered between flushes
|
||||
#define DEAUTH_QUEUE_SIZE 128 // max deauth/disassoc events buffered between flushes
|
||||
|
||||
// ─── Channel hopping ─────────────────────────────────────────────────────────
|
||||
#define HOP_DWELL_MS 300 // ms to dwell on each channel (13 ch × 300ms ≈ 4s/sweep)
|
||||
|
||||
+75
-8
@@ -15,6 +15,8 @@ String nodeId;
|
||||
unsigned long lastScan = 0;
|
||||
unsigned long lastHeartbeat = 0;
|
||||
|
||||
static uint8_t homeChannel = 1; // channel of sandbox AP — updated after connect and scan
|
||||
|
||||
// ─── Probe sniffing globals ───────────────────────────────────────────────────
|
||||
|
||||
#if PROBE_SNIFF
|
||||
@@ -39,12 +41,12 @@ static QueueHandle_t deauthQueue;
|
||||
|
||||
// Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS
|
||||
struct DedupEntry { uint8_t mac[6]; char ssid[33]; uint32_t last_ms; };
|
||||
static DedupEntry dedupCache[32];
|
||||
static DedupEntry dedupCache[DEDUP_CACHE_SIZE];
|
||||
static int dedupNext = 0;
|
||||
|
||||
static bool isDuplicate(const uint8_t* mac, const char* ssid) {
|
||||
uint32_t now = millis();
|
||||
for (int i = 0; i < 32; i++) {
|
||||
for (int i = 0; i < DEDUP_CACHE_SIZE; i++) {
|
||||
if (dedupCache[i].last_ms == 0) continue;
|
||||
if (memcmp(dedupCache[i].mac, mac, 6) == 0 &&
|
||||
strcmp(dedupCache[i].ssid, ssid) == 0) {
|
||||
@@ -59,10 +61,17 @@ static bool isDuplicate(const uint8_t* mac, const char* ssid) {
|
||||
strncpy(dedupCache[dedupNext].ssid, ssid, 32);
|
||||
dedupCache[dedupNext].ssid[32] = '\0';
|
||||
dedupCache[dedupNext].last_ms = now;
|
||||
dedupNext = (dedupNext + 1) % 32;
|
||||
dedupNext = (dedupNext + 1) % DEDUP_CACHE_SIZE;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Channel hop state ───────────────────────────────────────────────────────
|
||||
|
||||
static const uint8_t HOP_LIST[] = {1,2,3,4,5,6,7,8,9,10,11,12,13};
|
||||
static const int HOP_COUNT = sizeof(HOP_LIST) / sizeof(HOP_LIST[0]);
|
||||
static int hopIdx = 0;
|
||||
static unsigned long lastHop = 0;
|
||||
|
||||
// Promiscuous callback — runs in WiFi task context, not safe to call UDP here.
|
||||
// Parse probe request, deauth, and disassoc frames and push to queues.
|
||||
static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
|
||||
@@ -153,7 +162,16 @@ void setup() {
|
||||
void loop() {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
Serial.println("[WIFI] Lost connection, reconnecting...");
|
||||
#if PROBE_SNIFF
|
||||
esp_wifi_set_promiscuous(false);
|
||||
#endif
|
||||
connectWiFi();
|
||||
#if PROBE_SNIFF
|
||||
esp_wifi_set_channel(homeChannel, WIFI_SECOND_CHAN_NONE);
|
||||
esp_wifi_set_promiscuous(true);
|
||||
hopIdx = 0;
|
||||
lastHop = millis();
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,9 +185,14 @@ void loop() {
|
||||
if (now - lastScan >= SCAN_INTERVAL_MS) {
|
||||
lastScan = now;
|
||||
scanAndSend();
|
||||
return;
|
||||
}
|
||||
|
||||
delay(100);
|
||||
#if PROBE_SNIFF
|
||||
advanceHop();
|
||||
#endif
|
||||
|
||||
delay(10);
|
||||
}
|
||||
|
||||
// ─── WiFi ────────────────────────────────────────────────────────────────────
|
||||
@@ -188,6 +211,12 @@ void connectWiFi() {
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[WIFI] Connected — IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
#if PROBE_SNIFF
|
||||
uint8_t primary; wifi_second_chan_t second;
|
||||
esp_wifi_get_channel(&primary, &second);
|
||||
if (primary > 0) homeChannel = primary;
|
||||
Serial.printf("[HOP] Home channel: %d\n", homeChannel);
|
||||
#endif
|
||||
} else {
|
||||
Serial.println("\n[WIFI] Failed to connect, will retry in loop");
|
||||
}
|
||||
@@ -196,11 +225,21 @@ void connectWiFi() {
|
||||
// ─── Scan ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void scanAndSend() {
|
||||
#if PROBE_SNIFF
|
||||
// Stop hopping and return to home channel — scan requires a stable channel to start.
|
||||
esp_wifi_set_promiscuous(false);
|
||||
esp_wifi_set_channel(homeChannel, WIFI_SECOND_CHAN_NONE);
|
||||
delay(50);
|
||||
#endif
|
||||
|
||||
Serial.println("[SCAN] Starting...");
|
||||
int n = WiFi.scanNetworks(false, true); // async=false, show_hidden=true
|
||||
|
||||
if (n == WIFI_SCAN_FAILED) {
|
||||
Serial.println("[SCAN] Failed");
|
||||
#if PROBE_SNIFF
|
||||
esp_wifi_set_promiscuous(true);
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,10 +252,19 @@ void scanAndSend() {
|
||||
WiFi.scanDelete();
|
||||
|
||||
#if PROBE_SNIFF
|
||||
// Scan may have disabled promiscuous mode internally — re-enable it.
|
||||
// Re-read home channel — scan resets the radio, AP channel may have shifted.
|
||||
uint8_t primary; wifi_second_chan_t second;
|
||||
esp_wifi_get_channel(&primary, &second);
|
||||
if (primary > 0) homeChannel = primary;
|
||||
|
||||
// Re-enable promiscuous and flush anything queued before the scan.
|
||||
esp_wifi_set_promiscuous(true);
|
||||
flushProbeQueue();
|
||||
flushDeauthQueue();
|
||||
|
||||
// Reset hop state so next cycle starts cleanly from channel 1.
|
||||
hopIdx = 0;
|
||||
lastHop = millis();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -276,12 +324,13 @@ void sendBeaconEvent(int idx) {
|
||||
// ─── Heartbeat ───────────────────────────────────────────────────────────────
|
||||
|
||||
void sendHeartbeat() {
|
||||
unsigned long now = millis();
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf),
|
||||
"{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"heartbeat\","
|
||||
"\"uptime_ms\":%lu,\"free_heap\":%lu,\"wifi_rssi\":%d}",
|
||||
nodeId.c_str(), millis(),
|
||||
millis(),
|
||||
nodeId.c_str(), now,
|
||||
now,
|
||||
(unsigned long)ESP.getFreeHeap(),
|
||||
WiFi.RSSI()
|
||||
);
|
||||
@@ -289,7 +338,7 @@ void sendHeartbeat() {
|
||||
udp.print(buf);
|
||||
udp.endPacket();
|
||||
Serial.printf("[HB] uptime=%lus heap=%luK ap=%ddBm\n",
|
||||
millis() / 1000,
|
||||
now / 1000,
|
||||
(unsigned long)ESP.getFreeHeap() / 1024,
|
||||
WiFi.RSSI()
|
||||
);
|
||||
@@ -377,4 +426,22 @@ void flushDeauthQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
// Advance to the next channel in the hop list.
|
||||
// Non-blocking — returns immediately if the dwell time hasn't elapsed.
|
||||
// Flushes queues each time we land back on homeChannel (WiFi is usable there).
|
||||
void advanceHop() {
|
||||
unsigned long now = millis();
|
||||
if (now - lastHop < HOP_DWELL_MS) return;
|
||||
lastHop = now;
|
||||
|
||||
hopIdx = (hopIdx + 1) % HOP_COUNT;
|
||||
uint8_t ch = HOP_LIST[hopIdx];
|
||||
esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);
|
||||
|
||||
if (ch == homeChannel && WiFi.status() == WL_CONNECTED) {
|
||||
flushProbeQueue();
|
||||
flushDeauthQueue();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // PROBE_SNIFF
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Reference in New Issue
Block a user