diff --git a/promiscuis-flock-you/README.md b/promiscuis-flock-you/README.md new file mode 100644 index 0000000..10bf0c9 --- /dev/null +++ b/promiscuis-flock-you/README.md @@ -0,0 +1,220 @@ +# promiscuis-flock-you + +WiFi-side companion to [flock-you](https://github.com/colonelpanichacks/flock-you). Passively detects Flock Safety cameras, Raven gunshot detectors, and related surveillance infrastructure by sniffing 2.4 GHz management and data frames in promiscuous mode. Emits Flask-compatible JSON over USB for live dashboard ingestion, stores detections on-device in SPIFFS so it works standalone too. + +--- + +## Credits + +All OUI research, the promiscuous-mode detection strategy, and the original firmware this is modded from: **ØяĐöØцяöЪöяцฐ @NitekryDPaul**. The core discovery — that Flock stations with randomised transmitter addresses still show up as the *destination* of probe responses and data frames during their burst-sleep duty cycle — is his. The 30-OUI target list below is his research. This fork adds Flask-app integration and on-device persistence on top of his work. + +--- + +## What it does + +- Sets the WiFi radio to `WIFI_MODE_NULL` and enables promiscuous sniffing +- Hops channels (default 1 / 6 / 11, 350 ms dwell) +- Inspects every 802.11 management and data frame +- Flags any frame whose `addr2` (transmitter) **or** `addr1` (receiver) matches a known Flock / Raven / SoundThinking OUI +- Skips multicast (`addr1` broadcast frames) and randomised MACs (locally-administered bit set) before matching +- Beeps the buzzer and flashes the onboard LED on every new detection +- Emits one JSON line per detection over USB CDC in the exact schema the Flask dashboard expects +- Stores detections in SPIFFS with an atomic CRC-checked envelope, so nothing is lost across power cycles + +Runs with or without USB attached. No AP, no web server — the radio stays dedicated to sniffing, channel hopping is preserved. + +--- + +## Why `addr1` matters + +Most WiFi sniffers only check the transmitter address (`addr2`). Flock infrastructure often goes long windows without transmitting — it sleeps, wakes in bursts, uploads, sleeps again. During that silence it may still show up on the air as the **destination** of probe responses or data frames from nearby APs. + +Checking `addr1` (receiver) picks those up. It requires a multicast guard (`addr1` is `ff:ff:ff:ff:ff:ff` in beacons and broadcasts) and a randomised-MAC guard, both of which are done at the top of the match function. + +This is the key insight from @NitekryDPaul's research. + +--- + +## Hardware + +- **Board**: Seeed Studio XIAO ESP32-S3 +- **Buzzer**: piezo on GPIO3 +- **LED**: onboard user LED on GPIO21 (active low) +- **Serial mirror**: TX-only on GPIO43 at 115200 baud (for attaching a second logger or a secondary device) + +--- + +## OUI target list + +All lowercase, colon-separated. From @NitekryDPaul's research: + +``` +70:c9:4e 3c:91:80 d8:f3:bc 80:30:49 b8:35:32 +14:5a:fc 74:4c:a1 08:3a:88 9c:2f:9d c0:35:32 +94:08:53 e4:aa:ea f4:6a:dd f8:a2:d6 24:b2:b9 +00:f4:8d d0:39:57 e8:d0:fc e0:4f:43 b8:1e:a4 +70:08:94 58:8e:81 ec:1b:bd 3c:71:bf 58:00:e3 +90:35:ea 5c:93:a2 64:6e:69 48:27:ea a4:cf:12 +``` + +Pre-compiled into a byte table in `setup()` so the matcher stays entirely in IRAM with no flash-resident lookups during callback execution. + +--- + +## Architecture + +``` + [2.4GHz air] + │ + ▼ + wifiSniffer() ← IRAM promiscuous callback (WiFi task) + │ fast match, no Serial, no malloc + ▼ + alertQueue[32] ← lock-free ring buffer (ISR-safe mux) + │ + ▼ + drainAlertQueue() ← loop() context + │ + ├─► fyAddDetection() ← always, every hit + │ │ + │ ▼ + │ fyDet[200] ← unique-by-MAC table + │ │ + │ ▼ + │ autosaveTick() ← every 60s when dirty + │ │ + │ ▼ + │ fySaveSession() ← atomic CRC-envelope write to SPIFFS + │ + ├─► shouldSuppressDuplicate() ← 5s per-MAC cooldown + │ + └─► emitDetectionJSON() ← USB CDC line for Flask + buzzerBeep() + ledFlash() +``` + +The split between callback and loop is deliberate: the WiFi task has hard real-time constraints and can't call `Serial.print` or `malloc` safely. The callback writes only to the ring buffer; `loop()` does all the heavy work. + +--- + +## SPIFFS wire format + +File layout on flash (atomic, crash-safe): + +``` +Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"} +Line 2: [{"mac":"...","method":"...","rssi":...,...},...] +``` + +Save procedure: + +1. Compute CRC32 + byte count over the serialised payload +2. Write envelope header + payload to `/session.tmp` +3. Re-read and re-validate `/session.tmp` (CRC check) +4. Remove `/session.json` +5. Atomic rename `/session.tmp` → `/session.json` (copy+delete fallback) + +Boot recovery: + +1. If `/session.json` validates, promote it to `/prev_session.json` +2. Otherwise try `/session.tmp` (interrupted save) +3. Delete both working files, start with an empty live table +4. `/prev_session.json` stays around for inspection + +CRC32 uses the standard `0xEDB88320` polynomial so the same file can be verified on a host with any off-the-shelf CRC tool. + +--- + +## Flask dashboard integration + +This firmware emits the same JSON schema as the BLE `flock-you` firmware, so the Flask app (`api/flockyou.py` in [colonelpanichacks/flock-you](https://github.com/colonelpanichacks/flock-you)) ingests it with no changes. + +Per-detection JSON line: + +```json +{"event":"detection","detection_method":"wifi_oui_addr2","protocol":"wifi_2_4ghz","mac_address":"aa:bb:cc:dd:ee:ff","oui":"aa:bb:cc","device_name":"","rssi":-62,"channel":6,"frequency":2437,"ssid":""} +``` + +`detection_method` values: + +- `wifi_oui_addr2` — transmitter-address OUI match +- `wifi_oui_addr1` — receiver-address OUI match (the @NitekryDPaul technique) +- `wifi_oui_addr3` — BSSID-address OUI match (management frames only, disabled by default) +- `wifi_ssid` — SSID keyword match (disabled by default) + +### GPS wardriving + +No AP, no on-device GPS — GPS is handled Flask-side. Plug a USB NMEA puck into the host running Flask, or open the Flask dashboard in a phone browser and let it post browser-geolocation updates. Flask does a temporal match between the detection timestamp and the GPS timeline, and exports JSON / CSV / KML for Google Earth. + +### Running Flask + +```bash +cd flock-you/api +pip install -r requirements.txt +python flockyou.py +``` + +Open `http://localhost:5000`, connect your serial port from the UI, and detections start showing up live. + +--- + +## Build and flash + +PlatformIO config for the XIAO ESP32-S3: + +```ini +[env:xiao_esp32s3] +platform = espressif32@^6.3.0 +board = seeed_xiao_esp32s3 +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 + +build_flags = + -DCORE_DEBUG_LEVEL=0 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + +board_build.arduino.memory_type = qio_opi +board_build.partitions = partitions.csv +board_build.filesystem = spiffs +``` + +`partitions.csv` is included — 1.9 MB SPIFFS partition, 6 MB app. + +No extra libraries needed. `SPIFFS.h` ships with Arduino-ESP32 core. + +--- + +## Config cheatsheet (top of `main.cpp`) + +| Define | Default | Notes | +|---|---|---| +| `CHANNEL_MODE` | `CHANNEL_MODE_CUSTOM` | `CUSTOM` (1/6/11), `FULL_HOP` (1-11), or `SINGLE` | +| `CHANNEL_DWELL_MS` | 350 | Time on each channel before hop | +| `RSSI_MIN` | -95 | Drop frames weaker than this | +| `ALERT_COOLDOWN_MS` | 5000 | Per-MAC serial-emit rate limit | +| `CHECK_ADDR1` | 1 | The @NitekryDPaul receiver-side technique | +| `CHECK_ADDR3` | 0 | BSSID fallback (mgmt frames only) | +| `ENABLE_SSID_MATCH` | 0 | Substring match against `target_ssid_keywords[]` | +| `PROCESS_MGMT_FRAMES` | 1 | Beacons, probe req/resp, etc. | +| `PROCESS_DATA_FRAMES` | 1 | Data frames (where addr1 catch shines) | +| `MAX_DETECTIONS` | 200 | On-device table cap | +| `AUTOSAVE_INTERVAL_MS` | 60000 | SPIFFS save cadence | +| `LED_PIN` | 21 | Onboard user LED | +| `BUZZER_PIN` | 3 | Piezo | + +--- + +## Standalone vs connected operation + +**Without USB:** device boots, beeps, starts scanning, stores every unique detection to SPIFFS, flashes the onboard LED on each hit. Plug in later, the prior session is sitting in `/prev_session.json`. + +**With USB + Flask running:** same thing, plus every detection streams live to the dashboard as a JSON line. Flask adds GPS (if configured) and deduplicates across MAC, building the wardriving map as you move. + +Both modes work simultaneously — the SPIFFS write path doesn't care if a host is listening. + +--- + +## Legal / intended use + +Passive reception of publicly-broadcast 802.11 frames and public BLE advertisements. Privacy research, surveillance auditing, education. The device does not transmit and does not attempt to authenticate to any network. diff --git a/promiscuis-flock-you/main.cpp b/promiscuis-flock-you/main.cpp new file mode 100644 index 0000000..b6585fc --- /dev/null +++ b/promiscuis-flock-you/main.cpp @@ -0,0 +1,980 @@ +#include +#include +#include "esp_wifi.h" +#include +#include +#include + +// ============================================================ +// CONFIG +// ============================================================ + +#define BUZZER_PIN 3 +#define USE_BUZZER 1 + +// Onboard user LED on Seeed XIAO ESP32-S3 is GPIO21 and is ACTIVE LOW +// (driving the pin LOW lights the LED). +#define LED_PIN 21 +#define USE_LED 1 +#define LED_ACTIVE_HIGH 0 +#define LED_FLASH_MS 120 + +#define MIRROR_SERIAL 1 +#define MIRROR_TX_PIN 43 +#define MIRROR_BAUD 115200 + +#define CHANNEL_MODE_FULL_HOP 0 +#define CHANNEL_MODE_CUSTOM 1 +#define CHANNEL_MODE_SINGLE 2 + +#define CHANNEL_MODE CHANNEL_MODE_CUSTOM +#define CHANNEL_DWELL_MS 350 +#define SINGLE_CHANNEL 1 + +static const uint8_t customChannels[] = {1, 6, 11}; +static const size_t customChannelCount = sizeof(customChannels) / sizeof(customChannels[0]); + +static const uint8_t fullHopChannels[] = {1,2,3,4,5,6,7,8,9,10,11}; +static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(fullHopChannels[0]); + +#define HEARTBEAT_MS 30000 +#define RSSI_MIN -95 +#define ALERT_COOLDOWN_MS 5000 + +#define ENABLE_SSID_MATCH 0 +#define CHECK_ADDR1 1 // dst/rx — catches Flock STAs receiving probe responses +#define CHECK_ADDR3 0 // bssid fallback for randomised addr2 +static const char* target_ssid_keywords[] = { "flock" }; +static const size_t SSID_KEYWORD_COUNT = sizeof(target_ssid_keywords) / sizeof(target_ssid_keywords[0]); + +#define STOP_ON_SSID_HIT 0 +#define STOP_ON_OUI_HIT 0 +#define PROCESS_MGMT_FRAMES 1 +#define PROCESS_DATA_FRAMES 1 + +// Persistence +#define MAX_DETECTIONS 200 +#define FY_SESSION_FILE "/session.json" +#define FY_SESSION_TMP "/session.tmp" +#define FY_PREV_FILE "/prev_session.json" +#define AUTOSAVE_INTERVAL_MS 60000 + +// ============================================================ +// TARGET OUI LIST (all lowercase, colons only) +// ============================================================ + +static const char* target_ouis[] = { + "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "b8:35:32", + "14:5a:fc", "74:4c:a1", "08:3a:88", "9c:2f:9d", "c0:35:32", + "94:08:53", "e4:aa:ea", "f4:6a:dd", "f8:a2:d6", "24:b2:b9", + "00:f4:8d", "d0:39:57", "e8:d0:fc", "e0:4f:43", "b8:1e:a4", + "70:08:94", "58:8e:81", "ec:1b:bd", "3c:71:bf", "58:00:e3", + "90:35:ea", "5c:93:a2", "64:6e:69", "48:27:ea", "a4:cf:12" + +}; +static const size_t OUI_COUNT = sizeof(target_ouis) / sizeof(target_ouis[0]); + +// Pre-compiled byte table — populated once in setup(), never touched again. +// Keeps matchOuiRaw entirely in IRAM with no flash-resident function calls. +static uint8_t oui_bytes[OUI_COUNT][3]; + +// ============================================================ +// ALERT QUEUE (callback → loop, avoids Serial in WiFi task) +// ============================================================ + +#define ALERT_QUEUE_SIZE 32 + +typedef enum : uint8_t { + ALERT_OUI_ADDR2 = 0, + ALERT_OUI_ADDR1 = 1, + ALERT_OUI_ADDR3 = 2, + ALERT_SSID = 3, +} AlertType; + +typedef struct { + AlertType type; + uint8_t mac[6]; + int8_t rssi; + uint8_t channel; + char ssid[33]; // populated for SSID hits + char frameKind[12]; +} AlertEntry; + +static volatile AlertEntry alertQueue[ALERT_QUEUE_SIZE]; +static volatile size_t alertHead = 0; // written by callback +static volatile size_t alertTail = 0; // read by loop() +static portMUX_TYPE queueMux = portMUX_INITIALIZER_UNLOCKED; + +static void IRAM_ATTR enqueueAlert(AlertType type, const uint8_t* mac, int8_t rssi, + uint8_t ch, const char* ssid, const char* kind) { + portENTER_CRITICAL_ISR(&queueMux); + size_t next = (alertHead + 1) % ALERT_QUEUE_SIZE; + if (next == alertTail) { // drop if full — loop() is behind + portEXIT_CRITICAL_ISR(&queueMux); + return; + } + + AlertEntry* e = (AlertEntry*)&alertQueue[alertHead]; + e->type = type; + e->rssi = rssi; + e->channel = ch; + memcpy((void*)e->mac, mac, 6); + + if (ssid) { strncpy((char*)e->ssid, ssid, 32); ((char*)e->ssid)[32] = '\0'; } + else { ((char*)e->ssid)[0] = '\0'; } + + if (kind) { strncpy((char*)e->frameKind, kind, 11); ((char*)e->frameKind)[11] = '\0'; } + else { ((char*)e->frameKind)[0] = '\0'; } + + alertHead = next; + portEXIT_CRITICAL_ISR(&queueMux); +} + +// ============================================================ +// DETECTION TABLE (on-device storage, persisted to SPIFFS) +// ============================================================ +// +// Single-threaded: only touched from loop() — drainAlertQueue() adds, and +// fySaveSession() reads. No mutex needed. The WiFi-task callback never +// touches this table; it only writes to the lock-free alert ring buffer. + +typedef struct { + char mac[18]; + char method[16]; // "oui_addr2" / "oui_addr1" / "oui_addr3" / "ssid" + int8_t rssi; + uint8_t channel; + uint32_t firstSeen; // millis() at first hit + uint32_t lastSeen; // millis() at latest hit + uint16_t count; + char ssid[33]; // "" unless an SSID hit populated it +} FYDetection; + +static FYDetection fyDet[MAX_DETECTIONS]; +static int fyDetCount = 0; +static bool fySpiffsReady = false; +static bool fyDirty = false; +static unsigned long fyLastSaveAt = 0; +static int fyLastSaveCount = 0; + +// ============================================================ +// STATE +// ============================================================ + +static uint8_t currentChannel = 1; +static size_t customChannelIndex = 0; +static size_t fullHopIndex = 0; +static unsigned long lastHop = 0; +static unsigned long lastHeartbeat = 0; +static volatile bool sniffingStopped = false; + +// Dedupe table (small circular, avoids single-slot eviction bug). +// This is the *serial-rate-limit* dedup — it suppresses beep + emit within +// ALERT_COOLDOWN_MS of a prior hit on the same MAC. The detection table +// (above) still counts every hit regardless of this suppression. +#define DEDUPE_SLOTS 8 +static struct { + char mac[18]; + unsigned long ts; +} dedupeTable[DEDUPE_SLOTS]; +static size_t dedupeIdx = 0; + +// LED one-shot pulse timer +static volatile unsigned long ledOffAt = 0; + +// ============================================================ +// 802.11 HEADER +// ============================================================ + +typedef struct __attribute__((packed)) { + uint16_t frame_ctrl; + uint16_t duration; + uint8_t addr1[6]; + uint8_t addr2[6]; + uint8_t addr3[6]; + uint16_t seq_ctrl; +} wifi_ieee80211_mac_hdr_t; + +// ============================================================ +// HELPERS +// ============================================================ + +// Dual-output: prints to both Serial (USB) and Serial1 (GPIO43) +static char _dualBuf[384]; + +static void dualPrintf(const char* fmt, ...) __attribute__((format(printf, 1, 2))); +static void dualPrintf(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + int n = vsnprintf(_dualBuf, sizeof(_dualBuf), fmt, args); + va_end(args); + if (n > 0) { + Serial.write(_dualBuf, n); +#if MIRROR_SERIAL + Serial1.write(_dualBuf, n); +#endif + } +} + +static void dualPrintln(const char* str) { + Serial.println(str); +#if MIRROR_SERIAL + Serial1.println(str); +#endif +} + +static inline void ledSet(bool on) { +#if USE_LED +#if LED_ACTIVE_HIGH + digitalWrite(LED_PIN, on ? HIGH : LOW); +#else + digitalWrite(LED_PIN, on ? LOW : HIGH); +#endif +#endif +} + +static void ledFlash(unsigned ms) { +#if USE_LED + ledSet(true); + ledOffAt = millis() + ms; + if (ledOffAt == 0) ledOffAt = 1; // avoid the "off" sentinel +#endif +} + +static void ledTick() { +#if USE_LED + if (ledOffAt && (long)(millis() - ledOffAt) >= 0) { + ledSet(false); + ledOffAt = 0; + } +#endif +} + +static void buzzerBeep(unsigned int ms) { +#if USE_BUZZER + digitalWrite(BUZZER_PIN, HIGH); delay(ms); digitalWrite(BUZZER_PIN, LOW); +#endif +} +static void startupBeep() { +#if USE_BUZZER + // First 6 notes of SMB World 1-2 (underground). Koji Kondo's descending + // pattern: C5 → C4 → A4 → A3 → G#4 → G#3 (alternating-octave pairs). + static const uint16_t notes[6] = { 523, 262, 440, 220, 415, 208 }; + for (int i = 0; i < 6; i++) { + tone(BUZZER_PIN, notes[i]); + delay((i == 5) ? 160 : 95); + noTone(BUZZER_PIN); + if (i < 5) delay(22); + } +#endif +} + +static void macToStr(const uint8_t* mac, char* buf, size_t len) { + snprintf(buf, len, "%02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} +static void ouiFromMac(const uint8_t* mac, char* buf, size_t len) { + snprintf(buf, len, "%02x:%02x:%02x", mac[0], mac[1], mac[2]); +} + +static void precompileOuis() { + for (size_t i = 0; i < OUI_COUNT; i++) { + const char* o = target_ouis[i]; + oui_bytes[i][0] = (uint8_t)strtol(o, nullptr, 16); + oui_bytes[i][1] = (uint8_t)strtol(o + 3, nullptr, 16); + oui_bytes[i][2] = (uint8_t)strtol(o + 6, nullptr, 16); + } +} + +// Bit 0 of byte 0 set = multicast/broadcast — never a real device transmitter or receiver +// we care about. Guards addr1 checks against 01:xx, 33:33:xx, ff:ff:ff:ff:ff:ff etc. +static inline bool IRAM_ATTR isMulticast(const uint8_t* mac) { + return mac[0] & 0x01; +} + +static bool IRAM_ATTR matchOuiRaw(const uint8_t* mac) { + // Locally-administered (randomised) MACs have bit 1 of byte 0 set. + // Fixed infrastructure devices never use them — skip immediately. + if (mac[0] & 0x02) return false; + + for (size_t i = 0; i < OUI_COUNT; i++) { + if (mac[0] == oui_bytes[i][0] && + mac[1] == oui_bytes[i][1] && + mac[2] == oui_bytes[i][2]) return true; + } + return false; +} + +static char* strcasestr_local(const char* haystack, const char* needle) { + if (!*needle) return (char*)haystack; + for (; *haystack; ++haystack) { + const char* h = haystack; const char* n = needle; + while (*h && *n && tolower((unsigned char)*h) == tolower((unsigned char)*n)) { ++h; ++n; } + if (!*n) return (char*)haystack; + } + return nullptr; +} +static bool matchSsidKeyword(const char* ssid) { + for (size_t i = 0; i < SSID_KEYWORD_COUNT; i++) + if (strcasestr_local(ssid, target_ssid_keywords[i])) return true; + return false; +} + +static const char* channelModeName() { + switch (CHANNEL_MODE) { + case CHANNEL_MODE_FULL_HOP: return "FULL_HOP"; + case CHANNEL_MODE_CUSTOM: return "CUSTOM"; + case CHANNEL_MODE_SINGLE: return "SINGLE"; + default: return "UNKNOWN"; + } +} + +static inline uint16_t channelFreqMhz(uint8_t ch) { + return (ch >= 1 && ch <= 14) ? (uint16_t)(2407 + 5 * ch) : 0; +} + +static bool shouldSuppressDuplicate(const char* macStr) { + unsigned long now = millis(); + for (size_t i = 0; i < DEDUPE_SLOTS; i++) { + if (strcmp(dedupeTable[i].mac, macStr) == 0) { + if ((now - dedupeTable[i].ts) < ALERT_COOLDOWN_MS) return true; + dedupeTable[i].ts = now; + return false; + } + } + // Not found — insert into next slot + strlcpy(dedupeTable[dedupeIdx].mac, macStr, 18); + dedupeTable[dedupeIdx].ts = now; + dedupeIdx = (dedupeIdx + 1) % DEDUPE_SLOTS; + return false; +} + +static void stopSniffing(const char* reason) { + if (sniffingStopped) return; + sniffingStopped = true; + esp_wifi_set_promiscuous(false); + dualPrintf("[flockyou] sniffing stopped: %s\n", reason); +} + +static void applyInitialChannel() { +#if CHANNEL_MODE == CHANNEL_MODE_SINGLE + currentChannel = SINGLE_CHANNEL; +#elif CHANNEL_MODE == CHANNEL_MODE_CUSTOM + currentChannel = customChannels[0]; +#else + currentChannel = fullHopChannels[0]; +#endif + esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); + lastHop = millis(); // start dwell timer precisely when channel is first set +} + +static void updateChannelMode() { + if (sniffingStopped) return; +#if CHANNEL_MODE == CHANNEL_MODE_SINGLE + if (currentChannel != SINGLE_CHANNEL) { + currentChannel = SINGLE_CHANNEL; + esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); + } + return; +#else + if (millis() - lastHop < CHANNEL_DWELL_MS) return; + #if CHANNEL_MODE == CHANNEL_MODE_CUSTOM + customChannelIndex = (customChannelIndex + 1) % customChannelCount; + currentChannel = customChannels[customChannelIndex]; + #else + fullHopIndex = (fullHopIndex + 1) % fullHopChannelCount; + currentChannel = fullHopChannels[fullHopIndex]; + #endif + esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); + lastHop = millis(); +#endif +} + +static void printHeartbeat() { + if (millis() - lastHeartbeat >= HEARTBEAT_MS) { + dualPrintf("[flockyou] scanning (ch=%u mode=%s det=%d)\n", + currentChannel, channelModeName(), fyDetCount); + lastHeartbeat = millis(); + } +} + +// ============================================================ +// DETECTION TABLE OPS +// ============================================================ + +static const char* alertTypeToMethod(AlertType t) { + switch (t) { + case ALERT_OUI_ADDR2: return "oui_addr2"; + case ALERT_OUI_ADDR1: return "oui_addr1"; + case ALERT_OUI_ADDR3: return "oui_addr3"; + case ALERT_SSID: return "ssid"; + default: return "unknown"; + } +} + +// Returns index of entry (new or updated), or -1 if table is full. +static int fyAddDetection(const char* mac, const char* method, + int8_t rssi, uint8_t ch, const char* ssid) { + for (int i = 0; i < fyDetCount; i++) { + if (strcasecmp(fyDet[i].mac, mac) == 0) { + if (fyDet[i].count < 0xFFFF) fyDet[i].count++; + fyDet[i].lastSeen = millis(); + fyDet[i].rssi = rssi; + fyDet[i].channel = ch; + if (ssid && ssid[0] && !fyDet[i].ssid[0]) { + strlcpy(fyDet[i].ssid, ssid, sizeof(fyDet[i].ssid)); + } + fyDirty = true; + return i; + } + } + if (fyDetCount >= MAX_DETECTIONS) return -1; + FYDetection& d = fyDet[fyDetCount]; + strlcpy(d.mac, mac, sizeof(d.mac)); + strlcpy(d.method, method ? method : "", sizeof(d.method)); + d.rssi = rssi; + d.channel = ch; + d.firstSeen = millis(); + d.lastSeen = d.firstSeen; + d.count = 1; + if (ssid && ssid[0]) strlcpy(d.ssid, ssid, sizeof(d.ssid)); + else d.ssid[0] = '\0'; + fyDetCount++; + fyDirty = true; + return fyDetCount - 1; +} + +// ============================================================ +// JSON ESCAPE — only needed for SSIDs (user-controlled bytes) +// ============================================================ + +static size_t jsonEscape(char* dst, size_t cap, const char* src) { + size_t o = 0; + if (cap == 0) return 0; + for (size_t i = 0; src[i]; i++) { + char c = src[i]; + if (c == '"' || c == '\\') { + if (o + 2 >= cap) break; + dst[o++] = '\\'; dst[o++] = c; + } else if ((unsigned char)c < 0x20) { + if (o + 6 >= cap) break; + int n = snprintf(dst + o, cap - o, "\\u%04x", (unsigned)(unsigned char)c); + if (n <= 0 || (size_t)n >= cap - o) break; + o += (size_t)n; + } else { + if (o + 1 >= cap) break; + dst[o++] = c; + } + } + dst[o] = '\0'; + return o; +} + +// ============================================================ +// CRC32 (zlib / SPIFFS-tool compatible polynomial 0xEDB88320) +// ============================================================ + +static uint32_t fyCRC32Update(uint32_t crc, const uint8_t* data, size_t len) { + crc = ~crc; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & -(int32_t)(crc & 1)); + } + return ~crc; +} + +// ============================================================ +// SPIFFS SESSION PERSISTENCE — bulletproof envelope format +// ============================================================ +// +// Wire format on disk: +// Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"}\n +// Line 2+: [{"mac":...},...] (exactly B bytes, CRC32 == X) +// +// Atomic write procedure: +// 1. Compute payload size + CRC (pass 1) +// 2. Write envelope + payload to /session.tmp (pass 2) +// 3. Re-validate /session.tmp from disk +// 4. Remove /session.json, rename tmp → main (with copy+delete fallback) +// +// Boot-time recovery: +// - Try /session.json. If missing or CRC-invalid, try /session.tmp. +// - Copy whichever validates to /prev_session.json, then delete both. + +static size_t fySerializeDet(const FYDetection& d, char* dst, size_t cap) { + char ssidEsc[sizeof(d.ssid) * 6 + 1]; + jsonEscape(ssidEsc, sizeof(ssidEsc), d.ssid); + int n = snprintf(dst, cap, + "{\"mac\":\"%s\",\"method\":\"%s\",\"rssi\":%d,\"channel\":%u," + "\"first\":%lu,\"last\":%lu,\"count\":%u,\"ssid\":\"%s\"}", + d.mac, d.method, d.rssi, (unsigned)d.channel, + (unsigned long)d.firstSeen, (unsigned long)d.lastSeen, (unsigned)d.count, + ssidEsc); + return (n > 0 && (size_t)n < cap) ? (size_t)n : 0; +} + +static uint32_t fyComputePayloadCRC(size_t& outBytes) { + char line[384]; + uint32_t crc = 0; + outBytes = 0; + crc = fyCRC32Update(crc, (const uint8_t*)"[", 1); outBytes += 1; + for (int i = 0; i < fyDetCount; i++) { + if (i > 0) { crc = fyCRC32Update(crc, (const uint8_t*)",", 1); outBytes += 1; } + size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); + if (n == 0) continue; + crc = fyCRC32Update(crc, (const uint8_t*)line, n); + outBytes += n; + } + crc = fyCRC32Update(crc, (const uint8_t*)"]", 1); outBytes += 1; + return crc; +} + +// Minimal envelope parser: pulls bytes + crc fields by substring search. +// Robust to field reordering; rejects anything without both required keys. +static bool fyParseEnvelope(const char* hdr, size_t& outBytes, uint32_t& outCrc) { + const char* b = strstr(hdr, "\"bytes\":"); + const char* c = strstr(hdr, "\"crc\":\"0x"); + if (!b || !c) return false; + b += 8; + long long bv = 0; + if (sscanf(b, "%lld", &bv) != 1 || bv < 0) return false; + c += 9; + unsigned cv = 0; + if (sscanf(c, "%x", &cv) != 1) return false; + outBytes = (size_t)bv; + outCrc = (uint32_t)cv; + return true; +} + +static bool fyValidateSessionFile(const char* path) { + if (!SPIFFS.exists(path)) return false; + File f = SPIFFS.open(path, "r"); + if (!f) return false; + + String hdr = f.readStringUntil('\n'); + if (hdr.length() < 10 || hdr[0] != '{') { f.close(); return false; } + + size_t expectedBytes = 0; + uint32_t expectedCRC = 0; + if (!fyParseEnvelope(hdr.c_str(), expectedBytes, expectedCRC)) { + f.close(); return false; + } + + size_t bodyOffset = hdr.length() + 1; + size_t fileSize = f.size(); + if (fileSize < bodyOffset + expectedBytes) { f.close(); return false; } + if ((fileSize - bodyOffset) != expectedBytes) { f.close(); return false; } + + uint8_t buf[256]; + uint32_t crc = 0; + size_t remaining = expectedBytes; + while (remaining > 0) { + int n = f.read(buf, remaining < sizeof(buf) ? remaining : sizeof(buf)); + if (n <= 0) break; + crc = fyCRC32Update(crc, buf, (size_t)n); + remaining -= (size_t)n; + } + f.close(); + return (remaining == 0 && crc == expectedCRC); +} + +static bool fySpiffsCopy(const char* src, const char* dst) { + File s = SPIFFS.open(src, "r"); + if (!s) return false; + File d = SPIFFS.open(dst, "w"); + if (!d) { s.close(); return false; } + uint8_t buf[256]; + int n; + bool ok = true; + while ((n = s.read(buf, sizeof(buf))) > 0) { + if (d.write(buf, (size_t)n) != (size_t)n) { ok = false; break; } + } + s.close(); + d.close(); + return ok; +} + +static bool fyAtomicPromote(const char* src, const char* dst) { + if (SPIFFS.rename(src, dst)) return true; + if (!fySpiffsCopy(src, dst)) return false; + SPIFFS.remove(src); + return true; +} + +static void fySaveSession() { + if (!fySpiffsReady) return; + if (!fyDirty && fyDetCount == fyLastSaveCount) return; + + size_t payloadBytes = 0; + uint32_t crc = fyComputePayloadCRC(payloadBytes); + int savedCount = fyDetCount; + + File f = SPIFFS.open(FY_SESSION_TMP, "w"); + if (!f) { + dualPrintf("[flockyou] save failed: cannot open %s\n", FY_SESSION_TMP); + return; + } + f.printf("{\"v\":1,\"count\":%d,\"bytes\":%u,\"crc\":\"0x%08lX\"}\n", + savedCount, (unsigned)payloadBytes, (unsigned long)crc); + + char line[384]; + size_t wrote = 0; + f.write((uint8_t*)"[", 1); wrote++; + for (int i = 0; i < fyDetCount; i++) { + if (i > 0) { f.write((uint8_t*)",", 1); wrote++; } + size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); + if (n == 0) continue; + f.write((uint8_t*)line, n); + wrote += n; + } + f.write((uint8_t*)"]", 1); wrote++; + f.close(); + + if (wrote != payloadBytes) { + dualPrintf("[flockyou] save WARNING: wrote %u expected %u — aborting\n", + (unsigned)wrote, (unsigned)payloadBytes); + return; + } + + if (!fyValidateSessionFile(FY_SESSION_TMP)) { + dualPrintf("[flockyou] save verify FAILED — old session preserved\n"); + return; + } + + SPIFFS.remove(FY_SESSION_FILE); + if (!fyAtomicPromote(FY_SESSION_TMP, FY_SESSION_FILE)) { + dualPrintf("[flockyou] promote FAILED — data in %s for recovery\n", FY_SESSION_TMP); + return; + } + + fyLastSaveAt = millis(); + fyLastSaveCount = savedCount; + fyDirty = false; + dualPrintf("[flockyou] session saved: %d det, %u bytes, crc=0x%08lX\n", + savedCount, (unsigned)payloadBytes, (unsigned long)crc); +} + +// Promote any valid session file from last boot into /prev_session.json, then +// start this boot with a fresh empty table. Preserves history across power cycles. +static void fyPromotePrevSession() { + if (!fySpiffsReady) return; + + const char* source = nullptr; + if (fyValidateSessionFile(FY_SESSION_FILE)) source = FY_SESSION_FILE; + else if (fyValidateSessionFile(FY_SESSION_TMP)) source = FY_SESSION_TMP; + + if (!source) { + if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); + if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); + dualPrintln("[flockyou] no valid prior session to promote"); + return; + } + + if (!fySpiffsCopy(source, FY_PREV_FILE)) { + dualPrintf("[flockyou] failed to promote %s → %s\n", source, FY_PREV_FILE); + return; + } + if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); + if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); + + File v = SPIFFS.open(FY_PREV_FILE, "r"); + size_t sz = v ? v.size() : 0; + if (v) v.close(); + dualPrintf("[flockyou] prior session promoted from %s (%u bytes)\n", + source, (unsigned)sz); +} + +// ============================================================ +// FLASK-COMPATIBLE JSON EMISSION +// ============================================================ +// +// The Flask app (flock-you/api/flockyou.py) reads one JSON object per line +// from the USB CDC serial port. It filters by presence of `detection_method` +// and extracts these fields: mac_address, rssi, channel, frequency, ssid, +// device_name, gps.latitude, gps.longitude, gps.accuracy. +// +// GPS is handled Flask-side via its own USB NMEA puck or browser geolocation; +// we don't embed GPS here because there's no on-device AP / phone link. + +static void emitDetectionJSON(const char* mac, const char* method, + int8_t rssi, uint8_t ch, const char* ssid) { + char ssidEsc[sizeof(((FYDetection*)0)->ssid) * 6 + 1]; + jsonEscape(ssidEsc, sizeof(ssidEsc), ssid ? ssid : ""); + char oui[9]; + uint8_t mbytes[6] = {0}; + sscanf(mac, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mbytes[0], &mbytes[1], &mbytes[2], &mbytes[3], &mbytes[4], &mbytes[5]); + ouiFromMac(mbytes, oui, sizeof(oui)); + + dualPrintf( + "{\"event\":\"detection\"," + "\"detection_method\":\"wifi_%s\"," + "\"protocol\":\"wifi_2_4ghz\"," + "\"mac_address\":\"%s\"," + "\"oui\":\"%s\"," + "\"device_name\":\"\"," + "\"rssi\":%d," + "\"channel\":%u," + "\"frequency\":%u," + "\"ssid\":\"%s\"}\n", + method, mac, oui, rssi, + (unsigned)ch, (unsigned)channelFreqMhz(ch), ssidEsc); +} + +// ============================================================ +// PROMISCUOUS CALLBACK — keep it fast, no Serial, no malloc +// ============================================================ + +static bool IRAM_ATTR extractSsidFromMgmtBody(const uint8_t* body, int len, + char* outSsid, size_t outLen) { + if (!body || len <= 0 || !outSsid || outLen == 0) return false; + while (len >= 2) { + uint8_t id = body[0], elen = body[1]; + if ((int)elen + 2 > len) break; + if (id == 0) { + size_t n = (elen < (outLen - 1)) ? elen : (outLen - 1); + memcpy(outSsid, body + 2, n); + outSsid[n] = '\0'; + return true; + } + body += elen + 2; len -= elen + 2; + } + return false; +} + +static void IRAM_ATTR wifiSniffer(void* buf, wifi_promiscuous_pkt_type_t type) { + if (!buf || sniffingStopped) return; + +#if PROCESS_MGMT_FRAMES && PROCESS_DATA_FRAMES + if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return; +#elif PROCESS_MGMT_FRAMES + if (type != WIFI_PKT_MGMT) return; +#elif PROCESS_DATA_FRAMES + if (type != WIFI_PKT_DATA) return; +#else + return; // nothing configured to process +#endif + + wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf; + if (pkt->rx_ctrl.sig_len < sizeof(wifi_ieee80211_mac_hdr_t)) return; + wifi_ieee80211_mac_hdr_t* hdr = (wifi_ieee80211_mac_hdr_t*)pkt->payload; + int8_t rssi = pkt->rx_ctrl.rssi; + + if (rssi < RSSI_MIN) return; + + uint8_t ch = (uint8_t)pkt->rx_ctrl.channel; // actual rx channel from driver + + // --- OUI check: addr2 (transmitter/source) --- + if (matchOuiRaw(hdr->addr2)) { + enqueueAlert(ALERT_OUI_ADDR2, hdr->addr2, rssi, ch, nullptr, "addr2"); + } + +#if CHECK_ADDR1 + // addr1 (receiver/destination): catches Flock STAs that appear only as the + // dst of probe responses and data frames — never transmitting in the capture + // window due to their burst-sleep duty cycle. Multicast guard is mandatory + // here since addr1 is broadcast (ff:ff:ff:ff:ff:ff) in beacons/broadcasts. + if (!isMulticast(hdr->addr1) && matchOuiRaw(hdr->addr1)) { + enqueueAlert(ALERT_OUI_ADDR1, hdr->addr1, rssi, ch, nullptr, "addr1"); + } +#endif + +#if CHECK_ADDR3 + // addr3 fallback: catches cases where addr2 is randomised but addr3 + // carries the real BSSID OUI (management frames only). + if (type == WIFI_PKT_MGMT && matchOuiRaw(hdr->addr3)) { + enqueueAlert(ALERT_OUI_ADDR3, hdr->addr3, rssi, ch, nullptr, "addr3"); + } +#endif + +#if ENABLE_SSID_MATCH + if (type == WIFI_PKT_MGMT) { + uint8_t fc0 = hdr->frame_ctrl & 0xFF; + uint8_t subtype = (fc0 >> 4) & 0x0F; + uint8_t ftype = (fc0 >> 2) & 0x03; + + if (ftype == 0) { + int sigLen = pkt->rx_ctrl.sig_len - 4; // strip 4-byte FCS + if (sigLen < (int)sizeof(wifi_ieee80211_mac_hdr_t)) return; + + const uint8_t* mgmtBody = nullptr; + int mgmtBodyLen = 0; + const char* frameKind = nullptr; + + if (subtype == 8 || subtype == 5) { + // Beacon / Probe Response: fixed params = 12 bytes after MAC hdr + int off = sizeof(wifi_ieee80211_mac_hdr_t) + 12; + if (sigLen > off) { + frameKind = (subtype == 8) ? "beacon" : "probe_resp"; + mgmtBody = pkt->payload + off; + mgmtBodyLen = sigLen - off; + } + } else if (subtype == 4) { + // Probe Request: IEs follow directly after MAC hdr + int off = sizeof(wifi_ieee80211_mac_hdr_t); + if (sigLen > off) { + frameKind = "probe_req"; + mgmtBody = pkt->payload + off; + mgmtBodyLen = sigLen - off; + } + } + + if (mgmtBody && mgmtBodyLen > 0) { + char ssid[33] = {0}; + if (extractSsidFromMgmtBody(mgmtBody, mgmtBodyLen, ssid, sizeof(ssid))) { + if (matchSsidKeyword(ssid)) { + enqueueAlert(ALERT_SSID, hdr->addr2, rssi, ch, ssid, frameKind); + } + } + } + } + } +#endif +} + +// ============================================================ +// DRAIN QUEUE — called from loop(), safe to Serial.print here +// ============================================================ + +static void drainAlertQueue() { + while (true) { + portENTER_CRITICAL(&queueMux); + if (alertTail == alertHead) { portEXIT_CRITICAL(&queueMux); break; } + AlertEntry e; + memcpy(&e, (const void*)&alertQueue[alertTail], sizeof(AlertEntry)); + alertTail = (alertTail + 1) % ALERT_QUEUE_SIZE; + portEXIT_CRITICAL(&queueMux); + + char macStr[18]; + macToStr(e.mac, macStr, sizeof(macStr)); + const char* method = alertTypeToMethod(e.type); + + // Always update the on-device detection table (survives reboot via SPIFFS). + int idx = fyAddDetection(macStr, method, e.rssi, e.channel, + (e.type == ALERT_SSID) ? e.ssid : nullptr); + + // Serial-rate-limit: suppress emit/beep/flash within ALERT_COOLDOWN_MS. + if (shouldSuppressDuplicate(macStr)) continue; + + // Human-readable line (for serial terminal / mirror). + char oui[9]; + ouiFromMac(e.mac, oui, sizeof(oui)); + if (e.type == ALERT_SSID) { + dualPrintf("[flockyou] DETECT-SSID type=%s mac=%s ssid=\"%s\" rssi=%d ch=%u count=%d\n", + e.frameKind, macStr, e.ssid, e.rssi, e.channel, + (idx >= 0) ? (int)fyDet[idx].count : 0); + } else { + dualPrintf("[flockyou] DETECT-OUI mac=%s oui=%s rssi=%d ch=%u addr=%s count=%d\n", + macStr, oui, e.rssi, e.channel, + e.frameKind[0] ? e.frameKind : "addr2", + (idx >= 0) ? (int)fyDet[idx].count : 0); + } + + // Flask-compatible JSON line (parsed by api/flockyou.py over USB CDC). + emitDetectionJSON(macStr, method, e.rssi, e.channel, + (e.type == ALERT_SSID) ? e.ssid : ""); + + // Beep + LED flash on every emitted detection. + buzzerBeep(60); + ledFlash(LED_FLASH_MS); + +#if STOP_ON_OUI_HIT + if (e.type != ALERT_SSID) stopSniffing("OUI hit"); +#endif +#if STOP_ON_SSID_HIT + if (e.type == ALERT_SSID) stopSniffing("SSID hit"); +#endif + } +} + +// ============================================================ +// AUTOSAVE +// ============================================================ + +static void autosaveTick() { + if (!fySpiffsReady || !fyDirty) return; + if (millis() - fyLastSaveAt < AUTOSAVE_INTERVAL_MS) return; + fySaveSession(); +} + +// ============================================================ +// SETUP / LOOP +// ============================================================ + +void setup() { + Serial.begin(115200); + // Crucial for USB-optional operation: without this, Serial.write() will + // block indefinitely on an ESP32-S3 USB-CDC port when no host is attached. + Serial.setTxTimeoutMs(0); + delay(300); + +#if MIRROR_SERIAL + Serial1.begin(MIRROR_BAUD, SERIAL_8N1, -1, MIRROR_TX_PIN); // TX-only on GPIO43 +#endif + +#if USE_BUZZER + pinMode(BUZZER_PIN, OUTPUT); + digitalWrite(BUZZER_PIN, LOW); +#endif + +#if USE_LED + pinMode(LED_PIN, OUTPUT); + ledSet(false); +#endif + + startupBeep(); +#if USE_LED + ledFlash(200); +#endif + + precompileOuis(); + memset(dedupeTable, 0, sizeof(dedupeTable)); + + // SPIFFS — format on first boot if missing. Non-fatal if it fails. + if (SPIFFS.begin(true)) { + fySpiffsReady = true; + dualPrintln("[flockyou] SPIFFS ready"); + fyPromotePrevSession(); + } else { + dualPrintln("[flockyou] SPIFFS init FAILED — running without persistence"); + } + + WiFi.mode(WIFI_MODE_NULL); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_wifi_init(&cfg); + esp_wifi_set_storage(WIFI_STORAGE_RAM); + esp_wifi_set_mode(WIFI_MODE_NULL); + esp_wifi_start(); + + applyInitialChannel(); + + wifi_promiscuous_filter_t filt = { + .filter_mask = 0 +#if PROCESS_MGMT_FRAMES + | WIFI_PROMIS_FILTER_MASK_MGMT +#endif +#if PROCESS_DATA_FRAMES + | WIFI_PROMIS_FILTER_MASK_DATA +#endif + }; + esp_wifi_set_promiscuous_filter(&filt); + esp_wifi_set_promiscuous_rx_cb(&wifiSniffer); + esp_wifi_set_promiscuous(true); + + dualPrintln("[flockyou] merged WiFi detector started"); + dualPrintf("[flockyou] mode=%s dwell_ms=%u start_channel=%u rssi_min=%d spiffs=%d\n", + channelModeName(), CHANNEL_DWELL_MS, currentChannel, + RSSI_MIN, fySpiffsReady ? 1 : 0); + + lastHeartbeat = millis(); + fyLastSaveAt = millis(); +} + +void loop() { + updateChannelMode(); + drainAlertQueue(); // Serial.printf happens here, not in callback + autosaveTick(); // periodic SPIFFS write if dirty + ledTick(); // turn off LED after LED_FLASH_MS + printHeartbeat(); + delay(1); +} diff --git a/promiscuis-flock-you/partitions.csv b/promiscuis-flock-you/partitions.csv new file mode 100644 index 0000000..b3ec3c3 --- /dev/null +++ b/promiscuis-flock-you/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x600000, +spiffs, data, spiffs, 0x610000, 0x1F0000, diff --git a/promiscuis-flock-you/platformio.ini b/promiscuis-flock-you/platformio.ini new file mode 100644 index 0000000..2b3da17 --- /dev/null +++ b/promiscuis-flock-you/platformio.ini @@ -0,0 +1,20 @@ +[platformio] +src_dir = . + +[env:xiao_esp32s3] +platform = espressif32@^6.3.0 +board = seeed_xiao_esp32s3 +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 + +build_flags = + -DCORE_DEBUG_LEVEL=0 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + +build_src_filter = + + +board_build.arduino.memory_type = qio_opi +board_build.partitions = partitions.csv +board_build.filesystem = spiffs