Skip to content

Commit c3ab378

Browse files
committed
Refactor code structure for improved readability and maintainability
1 parent 147ee82 commit c3ab378

3 files changed

Lines changed: 1062 additions & 8 deletions

File tree

content/wardrive.js

Lines changed: 219 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,39 @@ const MIN_PAUSE_THRESHOLD_MS = 1000; // Minimum timer value (1 second)
6464
const MAX_REASONABLE_TIMER_MS = 5 * 60 * 1000; // Maximum reasonable timer value (5 minutes) to handle clock skew
6565
const RX_LOG_LISTEN_WINDOW_MS = 6000; // Listen window for repeater echoes (6 seconds)
6666
const CHANNEL_GROUP_TEXT_HEADER = 0x15; // Header byte for Meshtastic GroupText packets (0x15) - used exclusively for Session Log echo detection
67+
const ADVERT_HEADER = 0x11; // Header byte for ADVERT packets (0x11)
68+
69+
// RX Packet Filter Configuration
70+
const MAX_RX_PATH_LENGTH = 9; // Maximum path length for RX packets (drop if exceeded to filter corrupted packets)
71+
const RX_ALLOWED_CHANNELS = ['#wardriving', '#public', '#testing', '#ottawa']; // Allowed channels for RX wardriving
72+
const RX_PRINTABLE_THRESHOLD = 0.80; // Minimum printable character ratio for GRP_TXT (80%)
6773

6874
// Pre-computed channel hash and key for the wardriving channel
6975
// These will be computed once at startup and used for message correlation and decryption
7076
let WARDRIVING_CHANNEL_HASH = null;
7177
let WARDRIVING_CHANNEL_KEY = null;
7278

73-
// Initialize the wardriving channel hash and key at startup
79+
// Pre-computed channel hashes and keys for all allowed RX channels
80+
const RX_CHANNEL_MAP = new Map(); // Map<channelHash, {name, key}>
81+
82+
// Initialize channel hashes and keys at startup
7483
(async function initializeChannelHash() {
7584
try {
85+
// Initialize wardriving channel (for TX tracking)
7686
WARDRIVING_CHANNEL_KEY = await deriveChannelKey(CHANNEL_NAME);
7787
WARDRIVING_CHANNEL_HASH = await computeChannelHash(WARDRIVING_CHANNEL_KEY);
7888
debugLog(`[INIT] Wardriving channel hash pre-computed at startup: 0x${WARDRIVING_CHANNEL_HASH.toString(16).padStart(2, '0')}`);
7989
debugLog(`[INIT] Wardriving channel key cached for message decryption (${WARDRIVING_CHANNEL_KEY.length} bytes)`);
90+
91+
// Initialize all allowed RX channels
92+
debugLog(`[INIT] Pre-computing hashes/keys for ${RX_ALLOWED_CHANNELS.length} allowed RX channels...`);
93+
for (const channelName of RX_ALLOWED_CHANNELS) {
94+
const key = await deriveChannelKey(channelName);
95+
const hash = await computeChannelHash(key);
96+
RX_CHANNEL_MAP.set(hash, { name: channelName, key: key });
97+
debugLog(`[INIT] ${channelName} -> hash=0x${hash.toString(16).padStart(2, '0')}`);
98+
}
99+
debugLog(`[INIT] ✅ All RX channel hashes/keys initialized successfully`);
80100
} catch (error) {
81101
debugError(`[INIT] CRITICAL: Failed to pre-compute channel hash/key: ${error.message}`);
82102
debugError(`[INIT] Repeater echo tracking will be disabled. Please reload the page.`);
@@ -199,6 +219,7 @@ const txLogState = {
199219
// RX log state (passive observations)
200220
const rxLogState = {
201221
entries: [], // Array of parsed RX log entries
222+
dropCount: 0, // Count of dropped/filtered packets
202223
isExpanded: false,
203224
autoScroll: true,
204225
maxEntries: 100 // Limit to prevent memory issues
@@ -2004,6 +2025,177 @@ async function decryptGroupTextPayload(payload, channelKey) {
20042025
}
20052026
}
20062027

2028+
/**
2029+
* Check if a string is printable ASCII (basic ASCII only, no extended chars)
2030+
* @param {string} str - String to check
2031+
* @returns {boolean} True if all characters are printable ASCII (32-126)
2032+
*/
2033+
function isStrictAscii(str) {
2034+
for (let i = 0; i < str.length; i++) {
2035+
const code = str.charCodeAt(i);
2036+
if (code < 32 || code > 126) {
2037+
return false;
2038+
}
2039+
}
2040+
return true;
2041+
}
2042+
2043+
/**
2044+
* Calculate ratio of printable characters in a string
2045+
* @param {string} str - String to analyze
2046+
* @returns {number} Ratio of printable chars (0.0 to 1.0)
2047+
*/
2048+
function getPrintableRatio(str) {
2049+
if (str.length === 0) return 0;
2050+
let printableCount = 0;
2051+
for (let i = 0; i < str.length; i++) {
2052+
const code = str.charCodeAt(i);
2053+
// Printable: ASCII 32-126 or common whitespace (9=tab, 10=newline, 13=CR)
2054+
if ((code >= 32 && code <= 126) || code === 9 || code === 10 || code === 13) {
2055+
printableCount++;
2056+
}
2057+
}
2058+
return printableCount / str.length;
2059+
}
2060+
2061+
/**
2062+
* Parse and validate ADVERT packet name field
2063+
* @param {Uint8Array} payload - Encrypted payload from metadata
2064+
* @returns {Object} {valid: boolean, name: string, reason: string}
2065+
*/
2066+
function parseAdvertName(payload) {
2067+
try {
2068+
// ADVERT structure: [32 bytes pubkey][4 bytes timestamp][64 bytes signature][1 byte flags][name...]
2069+
const PUBKEY_SIZE = 32;
2070+
const TIMESTAMP_SIZE = 4;
2071+
const SIGNATURE_SIZE = 64;
2072+
const FLAGS_SIZE = 1;
2073+
const NAME_OFFSET = PUBKEY_SIZE + TIMESTAMP_SIZE + SIGNATURE_SIZE + FLAGS_SIZE;
2074+
2075+
if (payload.length <= NAME_OFFSET) {
2076+
return { valid: false, name: '', reason: 'payload too short for name' };
2077+
}
2078+
2079+
const nameBytes = payload.slice(NAME_OFFSET);
2080+
const decoder = new TextDecoder('utf-8', { fatal: false });
2081+
const name = decoder.decode(nameBytes).replace(/\0+$/, '').trim();
2082+
2083+
debugLog(`[RX FILTER] ADVERT name extracted: "${name}" (${name.length} chars)`);
2084+
2085+
if (name.length === 0) {
2086+
return { valid: false, name: '', reason: 'name empty' };
2087+
}
2088+
2089+
// Check if name is printable
2090+
const printableRatio = getPrintableRatio(name);
2091+
debugLog(`[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%`);
2092+
2093+
if (printableRatio < 0.9) {
2094+
return { valid: false, name: name, reason: 'name not printable' };
2095+
}
2096+
2097+
// Check strict ASCII (no extended characters)
2098+
if (!isStrictAscii(name)) {
2099+
return { valid: false, name: name, reason: 'name contains non-ASCII chars' };
2100+
}
2101+
2102+
return { valid: true, name: name, reason: 'kept' };
2103+
2104+
} catch (error) {
2105+
debugError(`[RX FILTER] Error parsing ADVERT name: ${error.message}`);
2106+
return { valid: false, name: '', reason: 'parse error' };
2107+
}
2108+
}
2109+
2110+
/**
2111+
* Validate RX packet for wardriving logging
2112+
* @param {Object} metadata - Parsed metadata from parseRxPacketMetadata()
2113+
* @returns {Promise<Object>} {valid: boolean, reason: string, channelName?: string, plaintext?: string}
2114+
*/
2115+
async function validateRxPacket(metadata) {
2116+
try {
2117+
// Log raw packet for debugging
2118+
const rawHex = Array.from(metadata.raw).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
2119+
debugLog(`[RX FILTER] ========== VALIDATING PACKET ==========`);
2120+
debugLog(`[RX FILTER] Raw packet (${metadata.raw.length} bytes): ${rawHex}`);
2121+
debugLog(`[RX FILTER] Header: 0x${metadata.header.toString(16).padStart(2, '0')} | PathLength: ${metadata.pathLength} | SNR: ${metadata.snr}`);
2122+
2123+
// VALIDATION 1: Check path length
2124+
if (metadata.pathLength > MAX_RX_PATH_LENGTH) {
2125+
debugLog(`[RX FILTER] ❌ DROPPED: pathLen>${MAX_RX_PATH_LENGTH} (${metadata.pathLength} hops)`);
2126+
return { valid: false, reason: `pathLen>${MAX_RX_PATH_LENGTH}` };
2127+
}
2128+
debugLog(`[RX FILTER] ✓ Path length OK (${metadata.pathLength}${MAX_RX_PATH_LENGTH})`);
2129+
2130+
// VALIDATION 2: Check packet type (only ADVERT and GRP_TXT)
2131+
if (metadata.header === CHANNEL_GROUP_TEXT_HEADER) {
2132+
debugLog(`[RX FILTER] Packet type: GRP_TXT (0x15)`);
2133+
2134+
// GRP_TXT validation
2135+
if (metadata.encryptedPayload.length < 3) {
2136+
debugLog(`[RX FILTER] ❌ DROPPED: GRP_TXT payload too short (${metadata.encryptedPayload.length} bytes)`);
2137+
return { valid: false, reason: 'payload too short' };
2138+
}
2139+
2140+
const channelHash = metadata.encryptedPayload[0];
2141+
debugLog(`[RX FILTER] Channel hash: 0x${channelHash.toString(16).padStart(2, '0')}`);
2142+
2143+
// Check if channel is in allowed list
2144+
const channelInfo = RX_CHANNEL_MAP.get(channelHash);
2145+
if (!channelInfo) {
2146+
debugLog(`[RX FILTER] ❌ DROPPED: Unknown channel hash 0x${channelHash.toString(16).padStart(2, '0')}`);
2147+
return { valid: false, reason: 'unknown channel hash' };
2148+
}
2149+
2150+
debugLog(`[RX FILTER] ✓ Channel matched: ${channelInfo.name}`);
2151+
2152+
// Decrypt message
2153+
const plaintext = await decryptGroupTextPayload(metadata.encryptedPayload, channelInfo.key);
2154+
if (!plaintext) {
2155+
debugLog(`[RX FILTER] ❌ DROPPED: Decryption failed`);
2156+
return { valid: false, reason: 'decrypt failed' };
2157+
}
2158+
2159+
debugLog(`[RX FILTER] Decrypted message (${plaintext.length} chars): "${plaintext.substring(0, 60)}${plaintext.length > 60 ? '...' : ''}"}`);
2160+
2161+
// Check printable ratio
2162+
const printableRatio = getPrintableRatio(plaintext);
2163+
debugLog(`[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% (threshold: ${(RX_PRINTABLE_THRESHOLD * 100).toFixed(1)}%)`);
2164+
2165+
if (printableRatio < RX_PRINTABLE_THRESHOLD) {
2166+
debugLog(`[RX FILTER] ❌ DROPPED: plaintext not printable`);
2167+
return { valid: false, reason: 'plaintext not printable' };
2168+
}
2169+
2170+
debugLog(`[RX FILTER] ✅ KEPT: GRP_TXT passed all validations`);
2171+
return { valid: true, reason: 'kept', channelName: channelInfo.name, plaintext: plaintext };
2172+
2173+
} else if (metadata.header === ADVERT_HEADER) {
2174+
debugLog(`[RX FILTER] Packet type: ADVERT (0x11)`);
2175+
2176+
// ADVERT validation
2177+
const nameResult = parseAdvertName(metadata.encryptedPayload);
2178+
2179+
if (!nameResult.valid) {
2180+
debugLog(`[RX FILTER] ❌ DROPPED: ${nameResult.reason}`);
2181+
return { valid: false, reason: nameResult.reason };
2182+
}
2183+
2184+
debugLog(`[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")`);
2185+
return { valid: true, reason: 'kept' };
2186+
2187+
} else {
2188+
// Unsupported packet type
2189+
debugLog(`[RX FILTER] ❌ DROPPED: unsupported ptype (header=0x${metadata.header.toString(16).padStart(2, '0')})`);
2190+
return { valid: false, reason: 'unsupported ptype' };
2191+
}
2192+
2193+
} catch (error) {
2194+
debugError(`[RX FILTER] ❌ Validation error: ${error.message}`);
2195+
return { valid: false, reason: 'validation error' };
2196+
}
2197+
}
2198+
20072199
/**
20082200
* Start listening for repeater echoes via rx_log
20092201
* Uses the pre-computed WARDRIVING_CHANNEL_HASH for message correlation
@@ -2289,21 +2481,37 @@ async function handleRxLogging(metadata, data) {
22892481
// Packets with no path are direct transmissions (node-to-node) and don't provide
22902482
// information about repeater coverage, so we skip them for RX wardriving purposes.
22912483
if (metadata.pathLength === 0) {
2484+
rxLogState.dropCount++;
2485+
updateRxLogSummary();
22922486
debugLog(`[RX LOG] Ignoring: no path (direct transmission, not via repeater)`);
22932487
return;
22942488
}
22952489

2490+
// Get current GPS location (must have GPS before further validation)
2491+
if (!state.lastFix) {
2492+
rxLogState.dropCount++;
2493+
updateRxLogSummary();
2494+
debugLog(`[RX LOG] No GPS fix available, skipping entry`);
2495+
return;
2496+
}
2497+
2498+
// PACKET FILTER: Validate packet before logging
2499+
const validation = await validateRxPacket(metadata);
2500+
if (!validation.valid) {
2501+
rxLogState.dropCount++;
2502+
updateRxLogSummary();
2503+
const rawHex = Array.from(metadata.raw).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
2504+
debugLog(`[RX LOG] ❌ Packet dropped: ${validation.reason}`);
2505+
debugLog(`[RX LOG] Dropped packet hex: ${rawHex}`);
2506+
return;
2507+
}
2508+
22962509
// Extract LAST hop from path (the repeater that directly delivered to us)
22972510
const lastHopId = metadata.lastHop;
22982511
const repeaterId = lastHopId.toString(16).padStart(2, '0');
22992512

23002513
debugLog(`[RX LOG] Packet heard via last hop: ${repeaterId}, SNR=${metadata.snr}, path_length=${metadata.pathLength}`);
2301-
2302-
// Get current GPS location
2303-
if (!state.lastFix) {
2304-
debugLog(`[RX LOG] No GPS fix available, skipping entry`);
2305-
return;
2306-
}
2514+
debugLog(`[RX LOG] ✅ Packet validated and passed filter`);
23072515

23082516
const lat = state.lastFix.lat;
23092517
const lon = state.lastFix.lon;
@@ -2945,7 +3153,9 @@ function updateRxLogSummary() {
29453153
if (!rxLogCount || !rxLogLastTime || !rxLogLastRepeater) return;
29463154

29473155
const count = rxLogState.entries.length;
2948-
rxLogCount.textContent = count === 1 ? '1 observation' : `${count} observations`;
3156+
const dropText = `${rxLogState.dropCount} dropped`;
3157+
const obsText = count === 1 ? '1 observation' : `${count} observations`;
3158+
rxLogCount.textContent = `${obsText}, ${dropText}`;
29493159

29503160
if (count === 0) {
29513161
rxLogLastTime.textContent = 'No data';
@@ -4167,6 +4377,7 @@ async function connect() {
41674377
updateTxLogSummary();
41684378

41694379
rxLogState.entries = [];
4380+
rxLogState.dropCount = 0;
41704381
renderRxLogEntries(true);
41714382
updateRxLogSummary();
41724383

docs/zOther/javascript/packet_decode_test.js

Whitespace-only changes.

0 commit comments

Comments
 (0)