firmware&dashboard changes

This commit is contained in:
bot
2026-04-05 13:06:25 +03:00
parent 2636ea1e11
commit e01bbd9e56
11 changed files with 419 additions and 84 deletions
Binary file not shown.
Binary file not shown.
+31
View File
@@ -778,6 +778,37 @@ def ensure_schema():
rssi INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS assoc_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL,
node_id TEXT,
node_ts INTEGER,
subtype TEXT,
src TEXT,
bssid TEXT,
ssid TEXT,
rssi INTEGER
)
""")
conn.executescript("""
CREATE INDEX IF NOT EXISTS idx_beacon_received_at ON beacon_events (received_at);
CREATE INDEX IF NOT EXISTS idx_beacon_node_id ON beacon_events (node_id);
CREATE INDEX IF NOT EXISTS idx_beacon_bssid ON beacon_events (bssid);
CREATE INDEX IF NOT EXISTS idx_probe_received_at ON probe_events (received_at);
CREATE INDEX IF NOT EXISTS idx_probe_node_id ON probe_events (node_id);
CREATE INDEX IF NOT EXISTS idx_probe_src_mac ON probe_events (src_mac);
CREATE INDEX IF NOT EXISTS idx_heartbeat_node_id ON heartbeat_events (node_id);
CREATE INDEX IF NOT EXISTS idx_deauth_received_at ON deauth_events (received_at);
CREATE INDEX IF NOT EXISTS idx_deauth_node_id ON deauth_events (node_id);
CREATE INDEX IF NOT EXISTS idx_deauth_bssid ON deauth_events (bssid);
CREATE INDEX IF NOT EXISTS idx_deauth_src ON deauth_events (src);
CREATE INDEX IF NOT EXISTS idx_deauth_dst ON deauth_events (dst);
CREATE INDEX IF NOT EXISTS idx_assoc_received_at ON assoc_events (received_at);
CREATE INDEX IF NOT EXISTS idx_assoc_node_id ON assoc_events (node_id);
CREATE INDEX IF NOT EXISTS idx_assoc_src ON assoc_events (src);
CREATE INDEX IF NOT EXISTS idx_assoc_bssid ON assoc_events (bssid);
""")
conn.commit()
+17 -4
View File
@@ -418,10 +418,9 @@ td.dim { color: var(--text-dim); }
td.muted { color: var(--text-muted); }
/* ── Badges ── */
.badge { display: inline-block; padding: 1px 6px; font-size: 10px; letter-spacing: 0.04em; }
.badge.high { color: var(--green-live); border: 1px solid var(--green-dim); }
.badge.normal { color: var(--text-dim); border: 1px solid var(--border); }
.badge.low { color: var(--text-muted); border: 1px solid var(--text-muted); }
.imp-high { color: var(--green-live); }
.imp-normal { color: var(--text-dim); }
.imp-low { color: var(--text-muted); }
/* ── RSSI colours ── */
.rssi-hi { color: var(--green-hi); }
@@ -436,6 +435,20 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
/* ── Enc / node colours ── */
.enc-open { color: var(--orange); }
.enc-secure { color: var(--green-hi); }
tr.group-hdr td {
background: var(--bg-panel);
color: var(--green-mid);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
padding: 5px 8px;
border-top: 1px solid var(--border);
}
th.group-active { color: var(--green-hi); cursor: pointer; }
th.group-col { cursor: pointer; }
th.group-col:hover { color: var(--text); }
.nodes-multi { color: var(--green-hi); }
/* ── Cross-node cells ── */
+67 -25
View File
@@ -8,6 +8,7 @@ let networksData = [];
let netSort = { col: 'times_seen', dir: 'desc' };
let crossNodeData = { node_ids: [], networks: [] };
let clientsData = [];
let clientsGroupBy = null; // null | 'vendor' | 'ssid'
let presenceData = {};
let alertsData = {};
let chartBssid = null;
@@ -28,7 +29,13 @@ function rssiClass(rssi) {
function impBadge(imp) {
const cls = ['high', 'normal', 'low'].includes(imp) ? imp : 'normal';
return `<span class="badge ${cls}">${fmt(imp)}</span>`;
return `<span class="imp-${cls}">${fmt(imp)}</span>`;
}
function encClass(enc) {
if (!enc || enc === 'OPEN' || enc === 'None') return 'enc-open';
if (enc === 'WPA3' || enc === 'WPA2/WPA3') return 'enc-secure';
return 'muted';
}
function shortTime(iso) {
@@ -174,7 +181,7 @@ function renderNetworksTable() {
count.textContent = `${sorted.length} unique networks`;
tbody.innerHTML = sorted.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
const encCls = encClass(r.encryption);
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td>
@@ -256,7 +263,7 @@ function renderCrossNodeTable() {
tbody.innerHTML = networks.map(net => {
const seenRssis = nodeIds.filter(id => net.nodes[id]).map(id => net.nodes[id].best_rssi);
const globalBest = seenRssis.length ? Math.max(...seenRssis) : null;
const encCls = (!net.encryption || net.encryption === 'OPEN' || net.encryption === 'None') ? 'enc-open' : 'muted';
const encCls = encClass(net.encryption);
const nodeCls = net.node_count > 1 ? 'nodes-multi' : 'muted';
const nodeCells = nodeIds.map(id => {
@@ -285,10 +292,38 @@ async function fetchClients() {
renderClientsTable();
}
function clientsRow(r) {
const probing = r.probed_ssids.length
? r.probed_ssids.map(s => `<span style="color:var(--text-dim)">${s}</span>`).join(', ')
: '<span style="color:var(--text-muted)">wildcard only</span>';
const vendorCls = r.randomized ? 'muted' : 'dim';
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
return `<tr>
<td>${fmt(r.src_mac)}</td>
<td class="${vendorCls}">${r.vendor || '—'}</td>
<td style="max-width:260px;white-space:normal;line-height:1.8">${probing}</td>
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td>
<td class="${nodeCls}">${fmt(r.node_count)}</td>
<td>${fmt(r.times_seen)}</td>
<td class="dim">${shortTime(r.last_seen)}</td>
</tr>`;
}
function toggleClientsGroup(by) {
clientsGroupBy = (clientsGroupBy === by) ? null : by;
renderClientsTable();
}
function renderClientsTable() {
const tbody = document.getElementById('clients-tbody');
const empty = document.getElementById('clients-empty');
const count = document.getElementById('clients-count');
const thVendor = document.getElementById('clients-th-vendor');
const thProbing = document.getElementById('clients-th-probing');
if (thVendor) thVendor.className = clientsGroupBy === 'vendor' ? 'group-active' : 'group-col';
if (thProbing) thProbing.className = clientsGroupBy === 'ssid' ? 'group-active' : 'group-col';
if (!clientsData.length) {
tbody.innerHTML = '';
@@ -299,22 +334,29 @@ function renderClientsTable() {
empty.style.display = 'none';
count.textContent = `${clientsData.length} unique MACs`;
tbody.innerHTML = clientsData.map(r => {
const probing = r.probed_ssids.length
? r.probed_ssids.map(s => `<span style="color:var(--text-dim)">${s}</span>`).join(', ')
: '<span style="color:var(--text-muted)">wildcard only</span>';
const vendorCls = r.randomized ? 'muted' : 'dim';
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
return `<tr>
<td>${fmt(r.src_mac)}</td>
<td class="${vendorCls}">${r.vendor || '—'}</td>
<td style="max-width:260px;white-space:normal;line-height:1.8">${probing}</td>
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td>
<td class="${nodeCls}">${fmt(r.node_count)}</td>
<td>${fmt(r.times_seen)}</td>
<td class="dim">${shortTime(r.last_seen)}</td>
</tr>`;
if (!clientsGroupBy) {
tbody.innerHTML = clientsData.map(clientsRow).join('');
return;
}
// Build groups
const groups = {};
clientsData.forEach(r => {
let key;
if (clientsGroupBy === 'vendor') {
key = r.vendor || 'Unknown';
} else {
key = r.probed_ssids.length ? r.probed_ssids[0] : '(wildcard only)';
}
if (!groups[key]) groups[key] = [];
groups[key].push(r);
});
const sorted = Object.keys(groups).sort((a, b) => a.localeCompare(b));
tbody.innerHTML = sorted.map(key => {
const rows = groups[key];
const hdr = `<tr class="group-hdr"><td colspan="8">${key}${rows.length} device${rows.length > 1 ? 's' : ''}</td></tr>`;
return hdr + rows.map(clientsRow).join('');
}).join('');
}
@@ -382,7 +424,7 @@ function renderPresenceView() {
} else {
arrNetsEmpty.style.display = 'none';
arrNetsTbody.innerHTML = newNets.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
const encCls = encClass(r.encryption);
return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td>
@@ -398,8 +440,8 @@ function renderPresenceView() {
const regMacsTbody = document.getElementById('regulars-macs-tbody');
const regMacsEmpty = document.getElementById('regulars-macs-empty');
const regCount = document.getElementById('regulars-count');
const regMacs = p.regular_macs || [];
const regNets = p.regular_networks || [];
const regMacs = p.reg_macs || [];
const regNets = p.reg_networks || [];
regCount.textContent = `${regMacs.length} devices · ${regNets.length} networks`;
if (!regMacs.length) {
regMacsTbody.innerHTML = '';
@@ -425,7 +467,7 @@ function renderPresenceView() {
} else {
regNetsEmpty.style.display = 'none';
regNetsTbody.innerHTML = regNets.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
const encCls = encClass(r.encryption);
return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td>
@@ -841,7 +883,7 @@ function renderSearchResults(data, q) {
// As a network
if (data.as_network) {
const rows = data.as_network.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN') ? 'enc-open' : 'muted';
const encCls = encClass(r.encryption);
return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td>
@@ -936,7 +978,7 @@ function renderSearchResults(data, q) {
if (data.vendor_networks) {
const label = data.vendor_name || q;
const rows = data.vendor_networks.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN') ? 'enc-open' : 'muted';
const encCls = encClass(r.encryption);
return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td>
+4 -3
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESP32 Recon</title>
<title>ESP32</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230c110e'/%3E%3Ccircle cx='16' cy='23' r='2.5' fill='%234ec96a'/%3E%3Cpath d='M9 17.5 Q16 10.5 23 17.5' fill='none' stroke='%234ec96a' stroke-width='2.5' stroke-linecap='round'/%3E%3Cpath d='M4 12.5 Q16 1.5 28 12.5' fill='none' stroke='%234ec96a' stroke-width='2.5' stroke-linecap='round' opacity='0.5'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet" />
@@ -135,8 +136,8 @@
<thead>
<tr>
<th>MAC</th>
<th>Vendor</th>
<th>Probing for</th>
<th id="clients-th-vendor" class="group-col" onclick="toggleClientsGroup('vendor')" title="Group by vendor">Vendor</th>
<th id="clients-th-probing" class="group-col" onclick="toggleClientsGroup('ssid')" title="Group by probed SSID">Probing for</th>
<th>Best RSSI</th>
<th>Avg RSSI</th>
<th>Nodes</th>
+92 -10
View File
@@ -50,13 +50,16 @@ def init_db(path: str) -> sqlite3.Connection:
""")
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
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,
probe_drops INTEGER,
deauth_drops INTEGER,
assoc_drops INTEGER
)
""")
conn.execute("""
@@ -73,6 +76,38 @@ def init_db(path: str) -> sqlite3.Connection:
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
)
""")
# Indexes for common query patterns
conn.executescript("""
CREATE INDEX IF NOT EXISTS idx_beacon_received_at ON beacon_events (received_at);
CREATE INDEX IF NOT EXISTS idx_beacon_node_id ON beacon_events (node_id);
CREATE INDEX IF NOT EXISTS idx_beacon_bssid ON beacon_events (bssid);
CREATE INDEX IF NOT EXISTS idx_probe_received_at ON probe_events (received_at);
CREATE INDEX IF NOT EXISTS idx_probe_node_id ON probe_events (node_id);
CREATE INDEX IF NOT EXISTS idx_probe_src_mac ON probe_events (src_mac);
CREATE INDEX IF NOT EXISTS idx_heartbeat_node_id ON heartbeat_events (node_id);
CREATE INDEX IF NOT EXISTS idx_deauth_received_at ON deauth_events (received_at);
CREATE INDEX IF NOT EXISTS idx_deauth_node_id ON deauth_events (node_id);
CREATE INDEX IF NOT EXISTS idx_deauth_bssid ON deauth_events (bssid);
CREATE INDEX IF NOT EXISTS idx_deauth_src ON deauth_events (src);
CREATE INDEX IF NOT EXISTS idx_deauth_dst ON deauth_events (dst);
CREATE INDEX IF NOT EXISTS idx_assoc_received_at ON assoc_events (received_at);
CREATE INDEX IF NOT EXISTS idx_assoc_node_id ON assoc_events (node_id);
CREATE INDEX IF NOT EXISTS idx_assoc_src ON assoc_events (src);
CREATE INDEX IF NOT EXISTS idx_assoc_bssid ON assoc_events (bssid);
""")
conn.commit()
return conn
@@ -100,8 +135,9 @@ def store_beacon(conn: sqlite3.Connection, ev: dict, received_at: str):
def store_heartbeat(conn: sqlite3.Connection, ev: dict, received_at: str):
conn.execute("""
INSERT INTO heartbeat_events
(received_at, node_id, node_ts, uptime_ms, free_heap, wifi_rssi)
VALUES (?, ?, ?, ?, ?, ?)
(received_at, node_id, node_ts, uptime_ms, free_heap, wifi_rssi,
probe_drops, deauth_drops, assoc_drops)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
received_at,
ev.get("node_id"),
@@ -109,6 +145,9 @@ def store_heartbeat(conn: sqlite3.Connection, ev: dict, received_at: str):
ev.get("uptime_ms"),
ev.get("free_heap"),
ev.get("wifi_rssi"),
ev.get("probe_drops"),
ev.get("deauth_drops"),
ev.get("assoc_drops"),
))
conn.commit()
@@ -200,6 +239,37 @@ def validate_deauth(ev: dict) -> str | None:
return None
def store_assoc(conn: sqlite3.Connection, ev: dict, received_at: str):
conn.execute("""
INSERT INTO assoc_events
(received_at, node_id, node_ts, subtype, src, bssid, ssid, rssi)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
received_at,
ev.get("node_id"),
ev.get("ts"),
ev.get("type"),
ev.get("src"),
ev.get("bssid"),
ev.get("ssid", ""),
ev.get("rssi"),
))
conn.commit()
def validate_assoc(ev: dict) -> str | None:
for field in ("node_id", "src", "bssid", "rssi"):
if ev.get(field) is None:
return f"missing field '{field}'"
if not _check_mac(ev["src"]):
return f"bad src format: {ev['src']}"
if not _check_mac(ev["bssid"]):
return f"bad bssid format: {ev['bssid']}"
if not _check_rssi(ev["rssi"]):
return f"rssi out of range: {ev['rssi']}"
return None
def validate_probe(ev: dict) -> str | None:
"""Returns an error string if invalid, else None."""
for field in ("node_id", "src_mac", "rssi"):
@@ -233,7 +303,19 @@ def handle_packet(data: bytes, addr: tuple, conn: sqlite3.Connection):
received_at = datetime.datetime.utcnow().isoformat(timespec="seconds")
pkt_type = ev.get("type", "beacon")
if pkt_type in ("deauth", "disassoc"):
if pkt_type in ("assoc", "reassoc"):
err = validate_assoc(ev)
if err:
print(f"[DROP] {addr[0]} {pkt_type}{err}")
return
store_assoc(conn, ev, received_at)
node = ev.get("node_id", "?")
src = ev.get("src", "?")
bssid = ev.get("bssid", "?")
ssid = ev.get("ssid") or "<hidden>"
rssi = ev.get("rssi", 0)
print(f"\033[94m[{received_at}] {node} {pkt_type.upper():<8} {src}{bssid} \"{ssid}\" {rssi:>4}dBm{RESET}")
elif pkt_type in ("deauth", "disassoc"):
err = validate_deauth(ev)
if err:
print(f"[DROP] {addr[0]} {pkt_type}{err}")