sligh changed to dashbaord

This commit is contained in:
bot
2026-04-05 11:29:59 +03:00
parent 5c0ec8cc5a
commit 2636ea1e11
10 changed files with 364 additions and 42 deletions
+9 -8
View File
@@ -21,18 +21,19 @@ This is a learning/research project covering distributed systems, event-driven a
## Hardware ## 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 - **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 - **Dev machine:** This PC at `192.168.1.101` — coding and flashing only
- **Flashing:** Arduino CLI on this PC - **Flashing:** Arduino CLI on this PC
### Active nodes ### Active nodes
| node_id | MAC | Port | | node_id | MAC | Location / Port |
|----------|-------------------|--------------| |----------|-------------------|------------------------|
| F68D6E30 | 44:1b:f6:8d:6e:30 | /dev/ttyACM0 | | F68D6E30 | 44:1b:f6:8d:6e:30 | desk /dev/ttyACM0 |
| A1D6F190 | e0:72:a1:d6:f1:90 | /dev/ttyACM1 | | A1D658D4 | e0:72:a1:d6:58:d4 | desk /dev/ttyACM1 |
| A1D658D4 | e0:72:a1:d6:58:d4 | /dev/ttyACM2 | | 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] Arduino CLI installed, ESP32 core configured
- [x] Node firmware: beacon scan + probe sniffing - [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] UDP ingestor (`udp_ingest.py`) — beacon + probe routing with field validation
- [x] Web dashboard: Feed, Networks, Cross-node, Clients, Node detail views - [x] Web dashboard: Feed, Networks, Cross-node, Clients, Node detail views
- [x] OUI vendor lookup in Clients view - [x] OUI vendor lookup in Clients view
- [x] Dashboard split into HTML / CSS / JS (no monolithic file) - [x] Dashboard split into HTML / CSS / JS (no monolithic file)
- [x] Deployed to Orange Pi as systemd services (runs 24/7) - [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] 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] RSSI history chart per network (canvas, per-node coloured lines, 1h/2h/6h/24h range)
- [x] Presence tab — Present Now, New Arrivals, Regulars - [x] Presence tab — Present Now, New Arrivals, Regulars
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+102 -21
View File
@@ -9,7 +9,7 @@ import sqlite3
import datetime import datetime
import os import os
import json import json
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
DB_PATH = os.path.join(os.path.dirname(__file__), "events.db") 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 ONLINE_SECS = 30 # node considered online if heartbeat within this many seconds
EVENT_CAP = 100 # max events returned / shown in dashboard 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 ────────────────────────────────────────────────────────────── # ─── OUI lookup ──────────────────────────────────────────────────────────────
def _load_oui(path: str) -> dict[str, str]: 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: def get_conn() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn return conn
@@ -147,6 +188,7 @@ def build_networks() -> list[dict]:
WHERE bssid IS NOT NULL WHERE bssid IS NOT NULL
GROUP BY bssid GROUP BY bssid
ORDER BY times_seen DESC ORDER BY times_seen DESC
LIMIT 300
""") """)
return rows return rows
@@ -169,13 +211,18 @@ def build_clients() -> list[dict]:
WHERE src_mac IS NOT NULL WHERE src_mac IS NOT NULL
GROUP BY src_mac GROUP BY src_mac
ORDER BY times_seen DESC ORDER BY times_seen DESC
LIMIT 300
""") """)
# Fetch all probed SSIDs in one query, group in Python — avoids N+1 # Fetch SSIDs only for the MACs we actually returned
ssid_rows = query(""" 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 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 ORDER BY src_mac, ssid
""") """, tuple(mac_list))
ssids_by_mac: dict = {} ssids_by_mac: dict = {}
for s in ssid_rows: for s in ssid_rows:
ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"]) ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
@@ -199,18 +246,24 @@ def build_cross_node() -> dict:
rows = query(""" rows = query("""
SELECT SELECT
bssid, b.bssid,
MAX(ssid) AS ssid, MAX(b.ssid) AS ssid,
MAX(channel) AS channel, MAX(b.channel) AS channel,
MAX(encryption) AS encryption, MAX(b.encryption) AS encryption,
node_id, b.node_id,
MAX(rssi) AS best_rssi, MAX(b.rssi) AS best_rssi,
ROUND(AVG(rssi), 1) AS avg_rssi, ROUND(AVG(b.rssi), 1) AS avg_rssi,
COUNT(*) AS times_seen COUNT(*) AS times_seen
FROM beacon_events FROM beacon_events b
WHERE bssid IS NOT NULL WHERE b.bssid IN (
GROUP BY bssid, node_id SELECT bssid FROM beacon_events
ORDER BY bssid, node_id 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 = {} networks: dict = {}
@@ -455,12 +508,41 @@ def build_alerts() -> dict:
d["vendor"] = vendor d["vendor"] = vendor
d["randomized"] = randomized 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 { return {
"recent": recent, "recent": recent,
"bursts": bursts, "bursts": bursts,
"summary": summary[0] if summary else {}, "summary": summary[0] if summary else {},
"top_targets": top_targets, "top_targets": top_targets,
"top_targeted_devices": top_targeted_devices, "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): async def api_node_detail(node_id: str):
detail = build_node_detail(node_id) detail = build_node_detail(node_id)
if detail is None: if detail is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Node not found") raise HTTPException(status_code=404, detail="Node not found")
return detail return detail
+10
View File
@@ -470,6 +470,16 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
margin-bottom: 6px; 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 ── */
.alerts-summary { .alerts-summary {
display: flex; display: flex;
+126
View File
@@ -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 ─────────────────────────────────────────────────────────────── // ── Alerts view ───────────────────────────────────────────────────────────────
async function fetchAlerts() { async function fetchAlerts() {
alertsData = await fetch('/api/alerts').then(r => r.json()); alertsData = await fetch('/api/alerts').then(r => r.json());
@@ -471,6 +561,33 @@ function renderAlertsView() {
<span>unique sources</span> <span>unique sources</span>
</div>`; </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 // Most targeted networks
const targetsTbody = document.getElementById('targets-tbody'); const targetsTbody = document.getElementById('targets-tbody');
const targetsEmpty = document.getElementById('targets-empty'); 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-detail').classList.toggle('active', view === 'detail');
document.getElementById('view-presence').classList.toggle('active', view === 'presence'); document.getElementById('view-presence').classList.toggle('active', view === 'presence');
document.getElementById('view-alerts').classList.toggle('active', view === 'alerts'); 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('view-search').classList.toggle('active', view === 'search');
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail'); document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart'); 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-clients').classList.toggle('active', view === 'clients');
document.getElementById('tab-presence').classList.toggle('active', view === 'presence'); document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts'); 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'); 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 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(); } function showSearch() { setView('search'); document.getElementById('search-input').focus(); }
async function showDetail(node_id) { async function showDetail(node_id) {
@@ -946,6 +1071,7 @@ function startSSE() {
else if (currentView === 'crossnode') await fetchCrossNode(); else if (currentView === 'crossnode') await fetchCrossNode();
else if (currentView === 'presence') await fetchPresence(); else if (currentView === 'presence') await fetchPresence();
else if (currentView === 'alerts') await fetchAlerts(); else if (currentView === 'alerts') await fetchAlerts();
else if (currentView === 'analysis') await showAnalysis();
const nodes = await fetch('/api/nodes').then(r => r.json()); const nodes = await fetch('/api/nodes').then(r => r.json());
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n])); nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
+32
View File
@@ -29,6 +29,7 @@
<button class="tab-btn" id="tab-clients" onclick="showClients()">Clients</button> <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-presence" onclick="showPresence()">Presence</button>
<button class="tab-btn" id="tab-alerts" onclick="showAlerts()">Alerts</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> <button class="tab-btn" id="tab-search" onclick="showSearch()">Search</button>
</div> </div>
@@ -350,6 +351,23 @@
</div> </div>
</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 --> <!-- Detected bursts -->
<div class="section-header"> <div class="section-header">
<span class="section-title">Detected Bursts</span> <span class="section-title">Detected Bursts</span>
@@ -385,6 +403,20 @@
</div><!-- /#view-alerts --> </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 --> <!-- Search view -->
<div id="view-search" class="view"> <div id="view-search" class="view">
<div class="search-bar"> <div class="search-bar">
+2 -1
View File
@@ -10,6 +10,7 @@ import json
import sqlite3 import sqlite3
import datetime import datetime
import os import os
import re
UDP_IP = "0.0.0.0" UDP_IP = "0.0.0.0"
UDP_PORT = 5005 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: def init_db(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path, check_same_thread=False) conn = sqlite3.connect(path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS beacon_events ( CREATE TABLE IF NOT EXISTS beacon_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -129,7 +131,6 @@ def store_probe(conn: sqlite3.Connection, ev: dict, received_at: str):
# ─── Validation ────────────────────────────────────────────────────────────── # ─── Validation ──────────────────────────────────────────────────────────────
import re
_MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$') _MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
def _check_mac(val: str) -> bool: def _check_mac(val: str) -> bool:
+8 -4
View File
@@ -15,7 +15,11 @@
#define HEARTBEAT_INTERVAL_MS 10000 // how often to send a heartbeat (ms) #define HEARTBEAT_INTERVAL_MS 10000 // how often to send a heartbeat (ms)
// ─── Probe sniffing ────────────────────────────────────────────────────────── // ─── Probe sniffing ──────────────────────────────────────────────────────────
#define PROBE_SNIFF 1 // 1 = enabled, 0 = disabled #define PROBE_SNIFF 1 // 1 = enabled, 0 = disabled
#define PROBE_DEDUP_SECS 30 // suppress same MAC+SSID within this window (seconds) #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 DEDUP_CACHE_SIZE 64 // unique MAC+SSID pairs tracked for dedup
#define DEAUTH_QUEUE_SIZE 32 // max deauth/disassoc events buffered between scan cycles #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
View File
@@ -15,6 +15,8 @@ String nodeId;
unsigned long lastScan = 0; unsigned long lastScan = 0;
unsigned long lastHeartbeat = 0; unsigned long lastHeartbeat = 0;
static uint8_t homeChannel = 1; // channel of sandbox AP — updated after connect and scan
// ─── Probe sniffing globals ─────────────────────────────────────────────────── // ─── Probe sniffing globals ───────────────────────────────────────────────────
#if PROBE_SNIFF #if PROBE_SNIFF
@@ -39,12 +41,12 @@ static QueueHandle_t deauthQueue;
// Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS // Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS
struct DedupEntry { uint8_t mac[6]; char ssid[33]; uint32_t last_ms; }; 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 int dedupNext = 0;
static bool isDuplicate(const uint8_t* mac, const char* ssid) { static bool isDuplicate(const uint8_t* mac, const char* ssid) {
uint32_t now = millis(); 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 (dedupCache[i].last_ms == 0) continue;
if (memcmp(dedupCache[i].mac, mac, 6) == 0 && if (memcmp(dedupCache[i].mac, mac, 6) == 0 &&
strcmp(dedupCache[i].ssid, ssid) == 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); strncpy(dedupCache[dedupNext].ssid, ssid, 32);
dedupCache[dedupNext].ssid[32] = '\0'; dedupCache[dedupNext].ssid[32] = '\0';
dedupCache[dedupNext].last_ms = now; dedupCache[dedupNext].last_ms = now;
dedupNext = (dedupNext + 1) % 32; dedupNext = (dedupNext + 1) % DEDUP_CACHE_SIZE;
return false; 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. // Promiscuous callback — runs in WiFi task context, not safe to call UDP here.
// Parse probe request, deauth, and disassoc frames and push to queues. // Parse probe request, deauth, and disassoc frames and push to queues.
static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) { static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
@@ -153,7 +162,16 @@ void setup() {
void loop() { void loop() {
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
Serial.println("[WIFI] Lost connection, reconnecting..."); Serial.println("[WIFI] Lost connection, reconnecting...");
#if PROBE_SNIFF
esp_wifi_set_promiscuous(false);
#endif
connectWiFi(); connectWiFi();
#if PROBE_SNIFF
esp_wifi_set_channel(homeChannel, WIFI_SECOND_CHAN_NONE);
esp_wifi_set_promiscuous(true);
hopIdx = 0;
lastHop = millis();
#endif
return; return;
} }
@@ -167,9 +185,14 @@ void loop() {
if (now - lastScan >= SCAN_INTERVAL_MS) { if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now; lastScan = now;
scanAndSend(); scanAndSend();
return;
} }
delay(100); #if PROBE_SNIFF
advanceHop();
#endif
delay(10);
} }
// ─── WiFi ──────────────────────────────────────────────────────────────────── // ─── WiFi ────────────────────────────────────────────────────────────────────
@@ -188,6 +211,12 @@ void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected — IP: %s\n", WiFi.localIP().toString().c_str()); 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 { } else {
Serial.println("\n[WIFI] Failed to connect, will retry in loop"); Serial.println("\n[WIFI] Failed to connect, will retry in loop");
} }
@@ -196,11 +225,21 @@ void connectWiFi() {
// ─── Scan ──────────────────────────────────────────────────────────────────── // ─── Scan ────────────────────────────────────────────────────────────────────
void scanAndSend() { 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..."); Serial.println("[SCAN] Starting...");
int n = WiFi.scanNetworks(false, true); // async=false, show_hidden=true int n = WiFi.scanNetworks(false, true); // async=false, show_hidden=true
if (n == WIFI_SCAN_FAILED) { if (n == WIFI_SCAN_FAILED) {
Serial.println("[SCAN] Failed"); Serial.println("[SCAN] Failed");
#if PROBE_SNIFF
esp_wifi_set_promiscuous(true);
#endif
return; return;
} }
@@ -213,10 +252,19 @@ void scanAndSend() {
WiFi.scanDelete(); WiFi.scanDelete();
#if PROBE_SNIFF #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); esp_wifi_set_promiscuous(true);
flushProbeQueue(); flushProbeQueue();
flushDeauthQueue(); flushDeauthQueue();
// Reset hop state so next cycle starts cleanly from channel 1.
hopIdx = 0;
lastHop = millis();
#endif #endif
} }
@@ -276,12 +324,13 @@ void sendBeaconEvent(int idx) {
// ─── Heartbeat ─────────────────────────────────────────────────────────────── // ─── Heartbeat ───────────────────────────────────────────────────────────────
void sendHeartbeat() { void sendHeartbeat() {
unsigned long now = millis();
char buf[256]; char buf[256];
snprintf(buf, sizeof(buf), snprintf(buf, sizeof(buf),
"{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"heartbeat\"," "{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"heartbeat\","
"\"uptime_ms\":%lu,\"free_heap\":%lu,\"wifi_rssi\":%d}", "\"uptime_ms\":%lu,\"free_heap\":%lu,\"wifi_rssi\":%d}",
nodeId.c_str(), millis(), nodeId.c_str(), now,
millis(), now,
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getFreeHeap(),
WiFi.RSSI() WiFi.RSSI()
); );
@@ -289,7 +338,7 @@ void sendHeartbeat() {
udp.print(buf); udp.print(buf);
udp.endPacket(); udp.endPacket();
Serial.printf("[HB] uptime=%lus heap=%luK ap=%ddBm\n", Serial.printf("[HB] uptime=%lus heap=%luK ap=%ddBm\n",
millis() / 1000, now / 1000,
(unsigned long)ESP.getFreeHeap() / 1024, (unsigned long)ESP.getFreeHeap() / 1024,
WiFi.RSSI() 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 #endif // PROBE_SNIFF
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB