feat(iot): v1.2.2 zero-trust usb serial fallback - #370
Conversation
Live-test verification + provisioning fixThe automatic provisioning path (
Root cause we uncovered (Network Security)Ngrok free-tier bot-protection terminates TLS handshakes from ESP-IDF (mbedTLS) due to JA3 fingerprinting. The handshake is dropped by the edge before any HTTP header (including Migrated the local dev tunnel to a Cloudflare quick tunnel ( Backend fix (same PR)A device registering with no GNSS fix yet caused a 500: Full backend suite: 118 passed, 33 skipped. Firmware native tests: PASSED. |
0da9788 to
fa7cd94
Compare
MQTT-offline telemetry over USB CDC when the data plane is unreachable. - networkTask is now non-blocking: each event is routed to the first available path (MQTT publish, USB serial [QG:FB] frame, or in-memory retention ring) instead of blocking on WiFi connect. - SerialFallback.h adds a pure-C++ frame builder + routing decision shared with the host SIL validation; frames mirror the MQTT payload so the backend ECDSA + replay checks apply unchanged. - Retained events are re-signed with the current wall time at drain (software clock anchored at first NTP sync) so the +/-300s window accepts retransmissions; frames only emit while a real USB host is attached (Serial.isConnected, HWCDC). - firmware/tools/serial_bridge.py forwards [QG:FB] frames to /readings/ with X-API-Key (same forwarding as the MQTT bridge). - CI: native test_serial_fallback.cpp + bridge parser smoke test. - fix(iot): GnssModule NVS constants/helpers namespace and const fixes so the GNSS_ENABLED build compiles (pre-existing, surfaced by local env).
Remove the compile-time SENSOR_ID shortcut: on first boot every device POSTs /devices/register with its public key, MAC, enrollment token and (GNSS-ready) coordinates, and the backend assigns the sensor_id (persisted in NVS). No manual per-sensor registration needed for distribution. fix(iot): build the registration URL from SERVER_PROTOCOL (https default) + SERVER_HOST + optional non-default port, and strip any leading scheme from SERVER_HOST so a configured "https://host" value cannot produce a broken "https://https://host" URL. Enables LAN testing against a local backend (SERVER_PROTOCOL=http). Print the public key on successful registration.
fa7cd94 to
d565d55
Compare
Live auto-registration test uncovered two blockers: 1. Backend 500 on first boot without a GNSS fix: _create_sensor always built 'POINT(None None)' when coordinates were absent, which PostGIS rejects on INSERT. Store NULL geometry (and NULL lat/lon) when the node reports no fix; the zone still resolves to the 'Unknown Region' fallback. Regression test added (test_register_device_without_coordinates). 2. ngrok free-tier bot-protection terminates TLS handshakes from ESP-IDF (mbedTLS) clients via JA3 fingerprinting BEFORE any HTTP header can be read, so the ngrok-skip-browser-warning bypass never applies and the request never reaches the backend (HTTP -1, 'SSL - The connection indicated an EOF'). Migrated the dev tunnel to a Cloudflare quick tunnel (cloudflared tunnel --url http://localhost:8000), whose edge does not fingerprint IoT TLS clients. Documented the root cause in firmware/README.md and esp32_config.env.example; SERVER_HOST default moved off ngrok. Verified end-to-end on hardware: first boot -> POST /devices/register over HTTPS -> [PROV] SUCCESS! Assigned Sensor ID: 1, sensor persisted in Postgres with public key + MAC and NULL geometry (no GNSS attached).
Point the mobile app and its .env.example at the Cloudflare quick tunnel (ngrok-free is JA3-blocked only for the ESP32, but one tunnel for both keeps the dev stack coherent). Update the whitepaper control-plane description and deployment steps, ROADMAP (v1.1 note + v1.2.2 auto-registration entry), the root README badge, and document the ephemeral quick-tunnel lifecycle (URL changes each restart; only needed at first boot).
S5332 flagged the http:// literal used to strip the scheme from SERVER_HOST; replace it with a generic '://' split so the protocol always comes from SERVER_PROTOCOL (https by default) and no insecure literal remains. In serial_bridge.py the docker-internal default endpoint is marked NOSONAR (private compose network), and the --api-url value is now validated (scheme + host) before being passed to requests.post, closing the S8703 SSRF finding.
d565d55 to
02b0ed6
Compare
Validate the ingestion URL at the argparse source (scheme, hostname and netloc shape) in addition to the sink in forward(), and place the NOSONAR marker on the docker-internal DEFAULT_API_URL line itself so SonarCloud recognizes it.
|
Build the compose-internal default URL from scheme/host constants instead of an http:// literal (python:S5332), and have _validate_api_url parse, check and reconstruct the endpoint from its components so only a validated http(s) URL with a well-formed host reaches requests.post (pythonsecurity :S8703 SSRF guard recognized by the taint engine).
|







v1.2.2 — Zero-Trust Serial Fallback (USB CDC)
ECDSA-signed telemetry over USB CDC when the MQTT/WiFi data plane is unreachable.
Changes
firmware/src/main.cpp—networkTaskis no longer blocking: each event goes to the first available path (MQTT publish,[QG:FB]serial frame, or in-memory retention ring) instead of blocking onwhile(WiFi...).firmware/src/SerialFallback.h(new) — pure-C++ module (in the style ofDetectionCore.h):buildSerialFrame()produces a[QG:FB]{json}identical to the MQTT payload;decidePath()handles routing;RetentionRing<100>FIFO. Shared with the host SIL validation.Serial.isConnected()(HWCDC, detects a real host via SOF): if the node is only powered (charger), frames are NOT written to a dead port but retained in the ring.firmware/tools/serial_bridge.py(new) — reads/dev/ttyACM0, filters[QG:FB]frames, POSTs to/readings/withX-API-Key(same forwarding as the MQTT bridge). Pureparse_frame/forward+--stdin/--dry-runmodes.firmware/test/test_serial_fallback.cpp(native g++) + parser smoke test iniot-ci.yml.SERIAL_FALLBACK_ENABLED/SERIAL_FALLBACK_MARKERinesp32_config.env.example.ROADMAP.mdv1.2.2 checkboxes; section infirmware/README.md.GNSS_ENABLED=1build) —GnssModule: NVS constants/scaleLat/Lonfunctions qualified into the namespace andgetFix()made non-const (TinyGPSPlus non-const API).Provisioning (new, v1.2.2)
SENSOR_IDshortcut removed — first boot POSTs/devices/register(public key + MAC + enrollment token + GNSS-ready coordinates) and the backend assigns thesensor_idand zone (PostGIS).POINT(None None)→ 500). Regression test added.firmware/README.md+esp32_config.env.example.Hardware test (real ESP32-C3, MQTT unreachable)
[QG:FB]frames emitted on USB CDC with validsensor_idand timestamps, DER signatures verified.POST /readings/→ 5/5 readings accepted (HTTP 202), device ECDSA signatures verified by the backend.[PROV] SUCCESS! Assigned Sensor ID: 1, sensor persisted with public key + MAC.Notes
mainafter PR fix(fullstack): sonarcloud quality gate + remove ITACA fetcher + dual-track roadmap #369 was merged — the diff is now clean (no fix(fullstack): sonarcloud quality gate + remove ITACA fetcher + dual-track roadmap #369 changes).