From 0fc095e91bb60024136cbe897e39205d7db70065 Mon Sep 17 00:00:00 2001 From: CValdesS Date: Mon, 6 Apr 2026 11:53:58 +0200 Subject: [PATCH 01/11] add watchdog to rp2xx0 --- src/main.cpp | 3 +++ src/main.h | 2 +- src/platform/rp2xx0/main-rp2xx0.cpp | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 979517f0a0b..15938320ebc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1146,6 +1146,9 @@ void loop() #endif #ifdef ARCH_NRF54L15 nrf54l15Loop(); +#endif +#ifdef ARCH_RP2040 + rp2040Loop(); #endif power->powerCommandsCheck(); diff --git a/src/main.h b/src/main.h index 058a2aebc70..5d1c7140d72 100644 --- a/src/main.h +++ b/src/main.h @@ -95,7 +95,7 @@ extern bool runASAP; extern bool pauseBluetoothLogging; -void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), clearBonds(), enterDfuMode(); +void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), rp2040Loop(), clearBonds(), enterDfuMode(); meshtastic_DeviceMetadata getDeviceMetadata(); #if !MESHTASTIC_EXCLUDE_I2C diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index e59b0a9cda2..ee50f4fb1c7 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -3,6 +3,7 @@ #include "hardware/xosc.h" #include #include +#include #include #include @@ -99,6 +100,10 @@ void getMacAddr(uint8_t *dmac) void rp2040Setup() { + if (watchdog_caused_reboot()) { + LOG_WARN("Rebooted by watchdog!"); + } + /* Sets a random seed to make sure we get different random numbers on each boot. */ uint32_t seed = 0; if (!HardwareRNG::seed(seed)) { @@ -128,6 +133,16 @@ void rp2040Setup() #endif } +void rp2040Loop() +{ + static bool watchdog_running = false; + if (!watchdog_running) { + watchdog_enable(8000, true); // 8s timeout; pauses during debug + watchdog_running = true; + } + watchdog_update(); +} + void enterDfuMode() { reset_usb_boot(0, 0); From b8ecb3a3bf5c5783be3122c2625b46d519d0c48a Mon Sep 17 00:00:00 2001 From: CValdesS Date: Wed, 27 May 2026 18:24:33 +0200 Subject: [PATCH 02/11] =?UTF-8?q?feat(eth-api):=20phase=200=20skeleton=20?= =?UTF-8?q?=E2=80=94=20HTTP=20API=20server=20on=20TCP/80=20(503=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of porting mesh/http/ to RP2350 + W5500 (today ESP32-only). Phase 0 stands up the listener over the existing arduino-libraries/Ethernet stack (no Mongoose — its built-in TCP/IP would conflict with EthernetServer and break OTA/MQTT/NTP). Skeleton parses request line, logs it, replies 503. Real handlers (/api/v1/{info,fromradio,toradio}) come in later phases. - New mesh/eth/ethApiServer.{h,cpp} gated by HAS_ETHERNET_API - Wired into ethClient init+loop in parallel with existing ethHttpOTA (port 4244) - Enabled in both wiznet_5500_evb_pico2_e22p and pico2_w5500_e22 variants - Build impact on wiznet: +~9 KB flash (63.0% used), <1 KB RAM --- src/mesh/eth/ethApiServer.cpp | 99 +++++++++++++++++++++++++++++++++++ src/mesh/eth/ethApiServer.h | 15 ++++++ src/mesh/eth/ethClient.cpp | 11 ++++ 3 files changed, 125 insertions(+) create mode 100644 src/mesh/eth/ethApiServer.cpp create mode 100644 src/mesh/eth/ethApiServer.h diff --git a/src/mesh/eth/ethApiServer.cpp b/src/mesh/eth/ethApiServer.cpp new file mode 100644 index 00000000000..6d9f0b56381 --- /dev/null +++ b/src/mesh/eth/ethApiServer.cpp @@ -0,0 +1,99 @@ +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_API) + +#include "ethApiServer.h" +#include + +#ifdef USE_ARDUINO_ETHERNET +#include +#else +#include +#endif + +static constexpr uint32_t HEADER_TIMEOUT_MS = 3000; +static constexpr size_t MAX_LINE_LEN = 256; + +static EthernetServer *apiServer = nullptr; + +static void send503(EthernetClient &client) +{ + static const char body[] = "ethApiServer phase 0 — handlers not yet implemented\n"; + client.print("HTTP/1.1 503 Service Unavailable\r\n"); + client.print("Content-Type: text/plain; charset=utf-8\r\n"); + client.print("Content-Length: "); + client.print((unsigned)(sizeof(body) - 1)); + client.print("\r\n"); + client.print("Connection: close\r\n\r\n"); + client.print(body); + client.flush(); +} + +// Read up to one CRLF-terminated line; returns false on timeout or oversize line. +static bool readLine(EthernetClient &client, String &out, uint32_t deadlineMs) +{ + out = ""; + while ((int32_t)(millis() - deadlineMs) < 0) { + if (!client.connected()) + return false; + if (!client.available()) { + delay(1); + continue; + } + int c = client.read(); + if (c < 0) + continue; + if (c == '\n') + return true; + if (c == '\r') + continue; + if (out.length() >= MAX_LINE_LEN) + return false; + out += (char)c; + } + return false; +} + +static void handleClient(EthernetClient &client) +{ + const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; + + String reqLine; + if (!readLine(client, reqLine, deadline) || reqLine.length() == 0) { + LOG_DEBUG("ETH API: empty/timeout request from %s", client.remoteIP().toString().c_str()); + return; + } + + // Drain remaining headers until blank line (or deadline). We don't parse + // anything yet — phase 0 just logs and replies 503. + String hdr; + while (readLine(client, hdr, deadline)) { + if (hdr.length() == 0) + break; + } + + LOG_INFO("ETH API: %s (from %s)", reqLine.c_str(), client.remoteIP().toString().c_str()); + send503(client); +} + +void initEthApiServer() +{ + if (apiServer) + return; + apiServer = new EthernetServer(ETH_API_PORT); + apiServer->begin(); + LOG_INFO("ETH API: server listening on TCP port %d (phase 0 — 503 only)", ETH_API_PORT); +} + +void ethApiServerLoop() +{ + if (!apiServer) + return; + EthernetClient client = apiServer->accept(); + if (client) { + handleClient(client); + client.stop(); + } +} + +#endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethApiServer.h b/src/mesh/eth/ethApiServer.h new file mode 100644 index 00000000000..b5a428bf55e --- /dev/null +++ b/src/mesh/eth/ethApiServer.h @@ -0,0 +1,15 @@ +#pragma once + +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_API) + +#define ETH_API_PORT 80 + +/// Initialize the Ethernet HTTP API server (call after Ethernet is connected) +void initEthApiServer(); + +/// Poll for incoming HTTP API connections (call periodically from ethClient loop) +void ethApiServerLoop(); + +#endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index a039953f4bd..fbcd7c684fe 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -6,6 +6,9 @@ #include "main.h" #include "mesh/api/ethServerAPI.h" #include "target_specific.h" +#if HAS_ETHERNET && defined(HAS_ETHERNET_API) +#include "mesh/eth/ethApiServer.h" +#endif #ifdef USE_ARDUINO_ETHERNET #include // arduino-libraries/Ethernet — supports W5100/W5200/W5500 // Shorter DHCP timeout so LoRa startup isn't blocked when no DHCP server is present. @@ -154,6 +157,10 @@ static int32_t reconnectETH() } #endif +#if HAS_ETHERNET && defined(HAS_ETHERNET_API) + initEthApiServer(); +#endif + ethStartupComplete = true; } } @@ -180,6 +187,10 @@ static int32_t reconnectETH() } #endif +#if HAS_ETHERNET && defined(HAS_ETHERNET_API) + ethApiServerLoop(); +#endif + return 5000; // every 5 seconds } From 23b975596dc936ee0d4a0c696b2876e45b95b4b5 Mon Sep 17 00:00:00 2001 From: CValdesS Date: Wed, 27 May 2026 18:52:14 +0200 Subject: [PATCH 03/11] =?UTF-8?q?feat(eth-api):=20phase=201=20=E2=80=94=20?= =?UTF-8?q?implement=20/api/v1/{fromradio,toradio}=20with=20PhoneAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace phase 0 skeleton with the real handlers that bridge the HTTP transport to PhoneAPI, mirroring mesh/http/ContentHandler (ESP32) semantics. - EthHttpAPI : public PhoneAPI (api_type=TYPE_HTTP, checkIsConnected=true), outside the MESHTASTIC_EXCLUDE_WEBSERVER gate so it builds on RP2350. - Minimal HTTP parser: method/path/query/Content-Length, no allocations beyond Arduino String, capped at 32 header lines x 256 bytes (anti-DoS). - OPTIONS preflight -> 204 with CORS + X-Protobuf-Schema. - GET /api/v1/fromradio[?all=true]: stream-write up to 64 protobufs from webAPI.getFromRadio(), Connection: close framing (HTTP/1.0 style). - PUT /api/v1/toradio: read Content-Length bytes (<=512), call webAPI.handleToRadio(), echo body back. - 404 default for unknown paths, 405 for wrong method, 400 for bad body. Validated e2e against wiznet @ 192.168.1.143: - OPTIONS /api/v1/fromradio -> 204 + correct CORS headers - PUT ToRadio{want_config_id=1} (2 bytes) -> 200 + echo - GET /api/v1/fromradio?all=true -> 200 + 3476 bytes (config+channels+nodeinfo) - GET /api/v1/nonexistent -> 404 unknown endpoint - OTA HTTP on port 4244 untouched and still responsive Build impact on wiznet_5500_evb_pico2_e22p: +2 KB flash (63.1%), +1.8 KB RAM (17.6%) vs phase 0. --- src/mesh/eth/ethApiServer.cpp | 259 ++++++++++++++++++++++++++++++---- 1 file changed, 233 insertions(+), 26 deletions(-) diff --git a/src/mesh/eth/ethApiServer.cpp b/src/mesh/eth/ethApiServer.cpp index 6d9f0b56381..53f7a461002 100644 --- a/src/mesh/eth/ethApiServer.cpp +++ b/src/mesh/eth/ethApiServer.cpp @@ -3,6 +3,7 @@ #if HAS_ETHERNET && defined(HAS_ETHERNET_API) #include "ethApiServer.h" +#include "mesh/PhoneAPI.h" #include #ifdef USE_ARDUINO_ETHERNET @@ -12,24 +13,32 @@ #endif static constexpr uint32_t HEADER_TIMEOUT_MS = 3000; +static constexpr uint32_t BODY_TIMEOUT_MS = 5000; static constexpr size_t MAX_LINE_LEN = 256; +static constexpr size_t MAX_HEADER_LINES = 32; +static constexpr const char *PROTOBUF_SCHEMA = + "https://raw.githubusercontent.com/meshtastic/protobufs/master/meshtastic/mesh.proto"; + +// PhoneAPI subclass for the Ethernet HTTP transport. Mirrors mesh/http/HttpAPI +// but lives outside the MESHTASTIC_EXCLUDE_WEBSERVER gate (which is ESP32-only). +class EthHttpAPI : public PhoneAPI +{ + public: + EthHttpAPI() { api_type = TYPE_HTTP; } + bool checkIsConnected() override { return true; } +}; static EthernetServer *apiServer = nullptr; +static EthHttpAPI webAPI; -static void send503(EthernetClient &client) -{ - static const char body[] = "ethApiServer phase 0 — handlers not yet implemented\n"; - client.print("HTTP/1.1 503 Service Unavailable\r\n"); - client.print("Content-Type: text/plain; charset=utf-8\r\n"); - client.print("Content-Length: "); - client.print((unsigned)(sizeof(body) - 1)); - client.print("\r\n"); - client.print("Connection: close\r\n\r\n"); - client.print(body); - client.flush(); -} +struct Request { + String method; + String path; + String query; // raw "key=val&..." (without leading '?'), empty if none + long contentLength = 0; +}; -// Read up to one CRLF-terminated line; returns false on timeout or oversize line. +// Read up to one CRLF-terminated line; returns false on timeout or oversize. static bool readLine(EthernetClient &client, String &out, uint32_t deadlineMs) { out = ""; @@ -54,26 +63,223 @@ static bool readLine(EthernetClient &client, String &out, uint32_t deadlineMs) return false; } -static void handleClient(EthernetClient &client) +static bool parseRequest(EthernetClient &client, Request &req, uint32_t deadlineMs) { - const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; + String line; + if (!readLine(client, line, deadlineMs) || line.length() == 0) + return false; + + // "METHOD PATH HTTP/1.1" + int sp1 = line.indexOf(' '); + int sp2 = line.indexOf(' ', sp1 + 1); + if (sp1 <= 0 || sp2 <= sp1) + return false; + req.method = line.substring(0, sp1); + String fullPath = line.substring(sp1 + 1, sp2); + + int q = fullPath.indexOf('?'); + if (q >= 0) { + req.path = fullPath.substring(0, q); + req.query = fullPath.substring(q + 1); + } else { + req.path = fullPath; + req.query.remove(0); + } + + // Headers until blank line. Only Content-Length is interesting for now. + size_t hdrCount = 0; + while (hdrCount++ < MAX_HEADER_LINES) { + if (!readLine(client, line, deadlineMs)) + return false; + if (line.length() == 0) + return true; // end of headers + if (line.length() >= 16) { + // Case-insensitive prefix match for "Content-Length:" + String head = line.substring(0, 15); + head.toLowerCase(); + if (head == "content-length:") { + String v = line.substring(15); + v.trim(); + req.contentLength = v.toInt(); + } + } + } + return false; // too many headers +} + +// Trivial query-string param lookup. `out` set to value or "" if absent. +static bool getQueryParam(const String &query, const char *key, String &out) +{ + out.remove(0); + if (query.length() == 0) + return false; + String needle = String(key) + "="; + int idx = query.indexOf(needle); + if (idx < 0) + return false; + if (idx > 0 && query.charAt(idx - 1) != '&') + return false; // false positive (e.g. "all" matching "fall") + int start = idx + needle.length(); + int end = query.indexOf('&', start); + out = (end < 0) ? query.substring(start) : query.substring(start, end); + return true; +} + +static void writeStatusLine(EthernetClient &client, int status, const char *reason) +{ + client.print("HTTP/1.1 "); + client.print(status); + client.print(' '); + client.print(reason); + client.print("\r\n"); +} + +static void writeCorsHeaders(EthernetClient &client, const char *allowMethods) +{ + client.print("Access-Control-Allow-Origin: *\r\n"); + client.print("Access-Control-Allow-Headers: Content-Type\r\n"); + client.print("Access-Control-Allow-Methods: "); + client.print(allowMethods); + client.print("\r\n"); + client.print("X-Protobuf-Schema: "); + client.print(PROTOBUF_SCHEMA); + client.print("\r\n"); +} + +static void sendPreflight(EthernetClient &client, const char *allowMethods) +{ + writeStatusLine(client, 204, "No Content"); + writeCorsHeaders(client, allowMethods); + client.print("Content-Length: 0\r\n"); + client.print("Connection: close\r\n\r\n"); + client.flush(); +} + +static void sendError(EthernetClient &client, int status, const char *reason, const char *body) +{ + writeStatusLine(client, status, reason); + client.print("Content-Type: text/plain; charset=utf-8\r\n"); + client.print("Access-Control-Allow-Origin: *\r\n"); + client.print("Content-Length: "); + client.print((unsigned)strlen(body)); + client.print("\r\n"); + client.print("Connection: close\r\n\r\n"); + client.print(body); + client.flush(); +} + +static void handleFromRadio(EthernetClient &client, const Request &req) +{ + if (req.method == "OPTIONS") { + sendPreflight(client, "GET, OPTIONS"); + return; + } + if (req.method != "GET") { + sendError(client, 405, "Method Not Allowed", "method must be GET or OPTIONS"); + return; + } + + // Drain available packets. Stream-write headers, then write each packet's + // length-delimited protobuf bytes. The client parses message-by-message. + String allParam; + bool drainAll = getQueryParam(req.query, "all", allParam) && allParam == "true"; + + // We cannot pre-compute Content-Length without buffering everything, so use + // Connection: close (HTTP/1.0 framing). All Meshtastic clients tolerate this. + writeStatusLine(client, 200, "OK"); + client.print("Content-Type: application/x-protobuf\r\n"); + writeCorsHeaders(client, "GET, OPTIONS"); + client.print("Connection: close\r\n\r\n"); - String reqLine; - if (!readLine(client, reqLine, deadline) || reqLine.length() == 0) { - LOG_DEBUG("ETH API: empty/timeout request from %s", client.remoteIP().toString().c_str()); + uint8_t txBuf[MAX_TO_FROM_RADIO_SIZE]; + size_t totalBytes = 0; + int packets = 0; + do { + size_t len = webAPI.getFromRadio(txBuf); + if (len == 0) + break; + client.write(txBuf, len); + totalBytes += len; + packets++; + } while (drainAll && packets < 64); // safety cap so a misbehaving client can't loop forever + client.flush(); + LOG_DEBUG("ETH API: fromradio sent %d packet(s), %u bytes (all=%d)", packets, + (unsigned)totalBytes, (int)drainAll); +} + +static void handleToRadio(EthernetClient &client, const Request &req) +{ + if (req.method == "OPTIONS") { + sendPreflight(client, "PUT, OPTIONS"); + return; + } + if (req.method != "PUT") { + sendError(client, 405, "Method Not Allowed", "method must be PUT or OPTIONS"); return; } - // Drain remaining headers until blank line (or deadline). We don't parse - // anything yet — phase 0 just logs and replies 503. - String hdr; - while (readLine(client, hdr, deadline)) { - if (hdr.length() == 0) + if (req.contentLength <= 0 || (size_t)req.contentLength > MAX_TO_FROM_RADIO_SIZE) { + sendError(client, 400, "Bad Request", "missing or oversized Content-Length"); + return; + } + + uint8_t buf[MAX_TO_FROM_RADIO_SIZE]; + size_t got = 0; + const uint32_t deadline = millis() + BODY_TIMEOUT_MS; + while (got < (size_t)req.contentLength) { + if (!client.connected()) break; + if ((int32_t)(millis() - deadline) >= 0) + break; + if (!client.available()) { + delay(1); + continue; + } + int n = client.read(buf + got, (size_t)req.contentLength - got); + if (n > 0) + got += n; + } + if (got != (size_t)req.contentLength) { + LOG_WARN("ETH API: toradio short read (%u/%ld)", (unsigned)got, req.contentLength); + sendError(client, 408, "Request Timeout", "body read incomplete"); + return; + } + + webAPI.handleToRadio(buf, got); + + // Echo the bytes back, matching mesh/http ESP32 semantics. + writeStatusLine(client, 200, "OK"); + client.print("Content-Type: application/x-protobuf\r\n"); + writeCorsHeaders(client, "PUT, OPTIONS"); + client.print("Content-Length: "); + client.print((unsigned)got); + client.print("\r\n"); + client.print("Connection: close\r\n\r\n"); + client.write(buf, got); + client.flush(); + LOG_DEBUG("ETH API: toradio handled %u bytes", (unsigned)got); +} + +static void handleClient(EthernetClient &client) +{ + const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; + Request req; + if (!parseRequest(client, req, deadline)) { + LOG_DEBUG("ETH API: bad/timeout request from %s", client.remoteIP().toString().c_str()); + return; } - LOG_INFO("ETH API: %s (from %s)", reqLine.c_str(), client.remoteIP().toString().c_str()); - send503(client); + LOG_INFO("ETH API: %s %s%s%s (from %s)", req.method.c_str(), req.path.c_str(), + req.query.length() ? "?" : "", req.query.c_str(), + client.remoteIP().toString().c_str()); + + if (req.path == "/api/v1/fromradio") { + handleFromRadio(client, req); + } else if (req.path == "/api/v1/toradio") { + handleToRadio(client, req); + } else { + sendError(client, 404, "Not Found", "unknown endpoint"); + } } void initEthApiServer() @@ -82,7 +288,8 @@ void initEthApiServer() return; apiServer = new EthernetServer(ETH_API_PORT); apiServer->begin(); - LOG_INFO("ETH API: server listening on TCP port %d (phase 0 — 503 only)", ETH_API_PORT); + LOG_INFO("ETH API: server listening on TCP port %d (phase 1 — fromradio/toradio live)", + ETH_API_PORT); } void ethApiServerLoop() From 6fac31267bd44abf665ff4ca409b122548704c44 Mon Sep 17 00:00:00 2001 From: CValdesS Date: Wed, 27 May 2026 19:45:59 +0200 Subject: [PATCH 04/11] fix(eth-api): move accept loop to dedicated OSThread (20ms tick) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API server was being polled from the Ethernet client Periodic which runs every 5s. Measured impact before this fix on wiznet @ 192.168.1.143: req 1: ttfb=6.458s total=6.509s (matches the 5s tick + handler overhead) req 2: connection refused (W5500 sockets exhausted) req 3: connection refused After: same hardware, same network, back-to-back requests: req 2: ttfb=0.287s req 3: ttfb=0.015s req 4: ttfb=0.022s req 5: ttfb=0.020s The web client (meshtastic/web served from localhost) was visibly stalling mid-handshake — it had pulled 109 nodes + 19 messages but channels and device info never arrived. With the periodic-only polling, every request takes 5s and the W5500's 4 hardware sockets fill up under the burst. EthApiServerThread mirrors the WebServerThread pattern from ESP32 mesh/http/WebServer.cpp: adaptive interval — 20ms when there's recent traffic, 100ms after 5s of idle, 500ms after 30s. Auto-registers with the OSThread scheduler on construction. ethHttpOTA still ticks from the periodic; left unchanged because OTA is one large transfer that tolerates the latency, and minimizing scope here to one behaviour change at a time. --- src/mesh/eth/ethApiServer.cpp | 57 +++++++++++++++++++++++++++-------- src/mesh/eth/ethApiServer.h | 8 ++--- src/mesh/eth/ethClient.cpp | 4 +-- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/mesh/eth/ethApiServer.cpp b/src/mesh/eth/ethApiServer.cpp index 53f7a461002..9049a076365 100644 --- a/src/mesh/eth/ethApiServer.cpp +++ b/src/mesh/eth/ethApiServer.cpp @@ -3,6 +3,7 @@ #if HAS_ETHERNET && defined(HAS_ETHERNET_API) #include "ethApiServer.h" +#include "concurrency/OSThread.h" #include "mesh/PhoneAPI.h" #include @@ -19,6 +20,13 @@ static constexpr size_t MAX_HEADER_LINES = 32; static constexpr const char *PROTOBUF_SCHEMA = "https://raw.githubusercontent.com/meshtastic/protobufs/master/meshtastic/mesh.proto"; +// Adaptive poll intervals (mirror mesh/http/WebServer.cpp ESP32 pattern). +static constexpr uint32_t ACTIVE_THRESHOLD_MS = 5000; +static constexpr uint32_t MEDIUM_THRESHOLD_MS = 30000; +static constexpr int32_t ACTIVE_INTERVAL_MS = 20; +static constexpr int32_t MEDIUM_INTERVAL_MS = 100; +static constexpr int32_t IDLE_INTERVAL_MS = 500; + // PhoneAPI subclass for the Ethernet HTTP transport. Mirrors mesh/http/HttpAPI // but lives outside the MESHTASTIC_EXCLUDE_WEBSERVER gate (which is ESP32-only). class EthHttpAPI : public PhoneAPI @@ -282,25 +290,50 @@ static void handleClient(EthernetClient &client) } } +// Dedicated OSThread so accept() runs on sub-second cadence. The Ethernet +// client periodic ticks every 5s which is fine for NTP/MQTT but cripples a +// chatty web client doing many small back-to-back requests (6.5s TTFB observed +// before; W5500 sockets get exhausted faster than the periodic can drain). +class EthApiServerThread : public concurrency::OSThread +{ + public: + EthApiServerThread() : concurrency::OSThread("EthApiServer") { lastActivityMs = millis(); } + + protected: + int32_t runOnce() override + { + if (apiServer) { + EthernetClient client = apiServer->accept(); + if (client) { + lastActivityMs = millis(); + handleClient(client); + client.stop(); + } + } + + uint32_t since = millis() - lastActivityMs; + if (since < ACTIVE_THRESHOLD_MS) + return ACTIVE_INTERVAL_MS; + if (since < MEDIUM_THRESHOLD_MS) + return MEDIUM_INTERVAL_MS; + return IDLE_INTERVAL_MS; + } + + private: + uint32_t lastActivityMs; +}; + +static EthApiServerThread *apiThread = nullptr; + void initEthApiServer() { if (apiServer) return; apiServer = new EthernetServer(ETH_API_PORT); apiServer->begin(); - LOG_INFO("ETH API: server listening on TCP port %d (phase 1 — fromradio/toradio live)", + apiThread = new EthApiServerThread(); // OSThread base auto-registers with the scheduler + LOG_INFO("ETH API: server listening on TCP port %d (phase 1, OSThread @ 20ms)", ETH_API_PORT); } -void ethApiServerLoop() -{ - if (!apiServer) - return; - EthernetClient client = apiServer->accept(); - if (client) { - handleClient(client); - client.stop(); - } -} - #endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethApiServer.h b/src/mesh/eth/ethApiServer.h index b5a428bf55e..d96c5c4f089 100644 --- a/src/mesh/eth/ethApiServer.h +++ b/src/mesh/eth/ethApiServer.h @@ -6,10 +6,10 @@ #define ETH_API_PORT 80 -/// Initialize the Ethernet HTTP API server (call after Ethernet is connected) +/// Initialize the Ethernet HTTP API server (call after Ethernet is connected). +/// Spawns an internal OSThread that polls accept() on a sub-second cadence, +/// independent of the 5s Ethernet client periodic — needed because the web +/// client makes many small back-to-back requests. void initEthApiServer(); -/// Poll for incoming HTTP API connections (call periodically from ethClient loop) -void ethApiServerLoop(); - #endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index fbcd7c684fe..df3d9483bf5 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -187,9 +187,7 @@ static int32_t reconnectETH() } #endif -#if HAS_ETHERNET && defined(HAS_ETHERNET_API) - ethApiServerLoop(); -#endif + // ethApiServer runs on its own OSThread (20ms ticks) — not polled here. return 5000; // every 5 seconds } From ac2da2f2ffbbf817b37cba2bc83ad4e9c07a3c9f Mon Sep 17 00:00:00 2001 From: CValdesS Date: Wed, 27 May 2026 22:50:31 +0200 Subject: [PATCH 05/11] refactor(eth-api): extract handlers to shared module via IStreamReadWrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.0 of the TLS port — separate transport from request handling so the upcoming HTTPS server can reuse the exact same parser + routing logic. - New ethApiHandlers.{h,cpp}: Request, parser, CORS helpers, fromradio/toradio handlers, EthHttpAPI : PhoneAPI subclass. All driven by a single IStreamReadWrite interface that inherits Print (so client.print(...) keeps working transparently). - ethApiServer.cpp slimmed to ~90 LOC: now just the EthernetServer(80) + OSThread + an EthernetClientStream adapter that forwards reads/writes to the underlying EthernetClient. No behaviour change. Validated on wiznet @ 192.168.1.143: 5 curl requests TTFB 18-110ms (same as pre-refactor), protobuf round-trip PUT 200 + GET ?all=true 3499 bytes. Flash impact: +200 bytes (63.2%); RAM unchanged (17.6%). --- src/mesh/eth/ethApiHandlers.cpp | 278 +++++++++++++++++++++++++++++++ src/mesh/eth/ethApiHandlers.h | 46 +++++ src/mesh/eth/ethApiServer.cpp | 286 +++----------------------------- 3 files changed, 345 insertions(+), 265 deletions(-) create mode 100644 src/mesh/eth/ethApiHandlers.cpp create mode 100644 src/mesh/eth/ethApiHandlers.h diff --git a/src/mesh/eth/ethApiHandlers.cpp b/src/mesh/eth/ethApiHandlers.cpp new file mode 100644 index 00000000000..a3d758d402e --- /dev/null +++ b/src/mesh/eth/ethApiHandlers.cpp @@ -0,0 +1,278 @@ +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_API) + +#include "ethApiHandlers.h" +#include "mesh/PhoneAPI.h" +#include + +static constexpr uint32_t HEADER_TIMEOUT_MS = 3000; +static constexpr uint32_t BODY_TIMEOUT_MS = 5000; +static constexpr size_t MAX_LINE_LEN = 256; +static constexpr size_t MAX_HEADER_LINES = 32; +static constexpr const char *PROTOBUF_SCHEMA = + "https://raw.githubusercontent.com/meshtastic/protobufs/master/meshtastic/mesh.proto"; + +// PhoneAPI subclass for the Ethernet HTTP transport. Mirrors mesh/http/HttpAPI +// but lives outside the MESHTASTIC_EXCLUDE_WEBSERVER gate (which is ESP32-only). +// A single instance is shared between the HTTP and HTTPS servers since they +// represent the same logical "phone" — same state machine, same packet queue. +class EthHttpAPI : public PhoneAPI +{ + public: + EthHttpAPI() { api_type = TYPE_HTTP; } + bool checkIsConnected() override { return true; } +}; + +static EthHttpAPI webAPI; + +struct Request { + String method; + String path; + String query; // raw "key=val&..." (without leading '?'), empty if none + long contentLength = 0; +}; + +// Read up to one CRLF-terminated line; returns false on timeout or oversize. +static bool readLine(IStreamReadWrite &client, String &out, uint32_t deadlineMs) +{ + out = ""; + while ((int32_t)(millis() - deadlineMs) < 0) { + if (!client.connected()) + return false; + if (!client.available()) { + delay(1); + continue; + } + int c = client.read(); + if (c < 0) + continue; + if (c == '\n') + return true; + if (c == '\r') + continue; + if (out.length() >= MAX_LINE_LEN) + return false; + out += (char)c; + } + return false; +} + +static bool parseRequest(IStreamReadWrite &client, Request &req, uint32_t deadlineMs) +{ + String line; + if (!readLine(client, line, deadlineMs) || line.length() == 0) + return false; + + // "METHOD PATH HTTP/1.1" + int sp1 = line.indexOf(' '); + int sp2 = line.indexOf(' ', sp1 + 1); + if (sp1 <= 0 || sp2 <= sp1) + return false; + req.method = line.substring(0, sp1); + String fullPath = line.substring(sp1 + 1, sp2); + + int q = fullPath.indexOf('?'); + if (q >= 0) { + req.path = fullPath.substring(0, q); + req.query = fullPath.substring(q + 1); + } else { + req.path = fullPath; + req.query.remove(0); + } + + // Headers until blank line. Only Content-Length is interesting for now. + size_t hdrCount = 0; + while (hdrCount++ < MAX_HEADER_LINES) { + if (!readLine(client, line, deadlineMs)) + return false; + if (line.length() == 0) + return true; // end of headers + if (line.length() >= 16) { + // Case-insensitive prefix match for "Content-Length:" + String head = line.substring(0, 15); + head.toLowerCase(); + if (head == "content-length:") { + String v = line.substring(15); + v.trim(); + req.contentLength = v.toInt(); + } + } + } + return false; // too many headers +} + +// Trivial query-string param lookup. `out` set to value or "" if absent. +static bool getQueryParam(const String &query, const char *key, String &out) +{ + out.remove(0); + if (query.length() == 0) + return false; + String needle = String(key) + "="; + int idx = query.indexOf(needle); + if (idx < 0) + return false; + if (idx > 0 && query.charAt(idx - 1) != '&') + return false; // false positive (e.g. "all" matching "fall") + int start = idx + needle.length(); + int end = query.indexOf('&', start); + out = (end < 0) ? query.substring(start) : query.substring(start, end); + return true; +} + +static void writeStatusLine(IStreamReadWrite &client, int status, const char *reason) +{ + client.print("HTTP/1.1 "); + client.print(status); + client.print(' '); + client.print(reason); + client.print("\r\n"); +} + +static void writeCorsHeaders(IStreamReadWrite &client, const char *allowMethods) +{ + client.print("Access-Control-Allow-Origin: *\r\n"); + client.print("Access-Control-Allow-Headers: Content-Type\r\n"); + client.print("Access-Control-Allow-Methods: "); + client.print(allowMethods); + client.print("\r\n"); + client.print("X-Protobuf-Schema: "); + client.print(PROTOBUF_SCHEMA); + client.print("\r\n"); +} + +static void sendPreflight(IStreamReadWrite &client, const char *allowMethods) +{ + writeStatusLine(client, 204, "No Content"); + writeCorsHeaders(client, allowMethods); + client.print("Content-Length: 0\r\n"); + client.print("Connection: close\r\n\r\n"); + client.flush(); +} + +static void sendError(IStreamReadWrite &client, int status, const char *reason, const char *body) +{ + writeStatusLine(client, status, reason); + client.print("Content-Type: text/plain; charset=utf-8\r\n"); + client.print("Access-Control-Allow-Origin: *\r\n"); + client.print("Content-Length: "); + client.print((unsigned)strlen(body)); + client.print("\r\n"); + client.print("Connection: close\r\n\r\n"); + client.print(body); + client.flush(); +} + +static void handleFromRadio(IStreamReadWrite &client, const Request &req) +{ + if (req.method == "OPTIONS") { + sendPreflight(client, "GET, OPTIONS"); + return; + } + if (req.method != "GET") { + sendError(client, 405, "Method Not Allowed", "method must be GET or OPTIONS"); + return; + } + + String allParam; + bool drainAll = getQueryParam(req.query, "all", allParam) && allParam == "true"; + + // We cannot pre-compute Content-Length without buffering everything, so use + // Connection: close (HTTP/1.0 framing). All Meshtastic clients tolerate this. + writeStatusLine(client, 200, "OK"); + client.print("Content-Type: application/x-protobuf\r\n"); + writeCorsHeaders(client, "GET, OPTIONS"); + client.print("Connection: close\r\n\r\n"); + + uint8_t txBuf[MAX_TO_FROM_RADIO_SIZE]; + size_t totalBytes = 0; + int packets = 0; + do { + size_t len = webAPI.getFromRadio(txBuf); + if (len == 0) + break; + client.write(txBuf, len); + totalBytes += len; + packets++; + } while (drainAll && packets < 64); // safety cap so a misbehaving client can't loop forever + client.flush(); + LOG_DEBUG("ETH API: fromradio sent %d packet(s), %u bytes (all=%d)", packets, + (unsigned)totalBytes, (int)drainAll); +} + +static void handleToRadio(IStreamReadWrite &client, const Request &req) +{ + if (req.method == "OPTIONS") { + sendPreflight(client, "PUT, OPTIONS"); + return; + } + if (req.method != "PUT") { + sendError(client, 405, "Method Not Allowed", "method must be PUT or OPTIONS"); + return; + } + + if (req.contentLength <= 0 || (size_t)req.contentLength > MAX_TO_FROM_RADIO_SIZE) { + sendError(client, 400, "Bad Request", "missing or oversized Content-Length"); + return; + } + + uint8_t buf[MAX_TO_FROM_RADIO_SIZE]; + size_t got = 0; + const uint32_t deadline = millis() + BODY_TIMEOUT_MS; + while (got < (size_t)req.contentLength) { + if (!client.connected()) + break; + if ((int32_t)(millis() - deadline) >= 0) + break; + if (!client.available()) { + delay(1); + continue; + } + int n = client.read(buf + got, (size_t)req.contentLength - got); + if (n > 0) + got += n; + } + if (got != (size_t)req.contentLength) { + LOG_WARN("ETH API: toradio short read (%u/%ld)", (unsigned)got, req.contentLength); + sendError(client, 408, "Request Timeout", "body read incomplete"); + return; + } + + webAPI.handleToRadio(buf, got); + + // Echo the bytes back, matching mesh/http ESP32 semantics. + writeStatusLine(client, 200, "OK"); + client.print("Content-Type: application/x-protobuf\r\n"); + writeCorsHeaders(client, "PUT, OPTIONS"); + client.print("Content-Length: "); + client.print((unsigned)got); + client.print("\r\n"); + client.print("Connection: close\r\n\r\n"); + client.write(buf, got); + client.flush(); + LOG_DEBUG("ETH API: toradio handled %u bytes", (unsigned)got); +} + +void handleApiClient(IStreamReadWrite &client) +{ + const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; + Request req; + if (!parseRequest(client, req, deadline)) { + LOG_DEBUG("ETH API: bad/timeout request from %s", client.remoteIP().toString().c_str()); + return; + } + + LOG_INFO("ETH API: %s %s%s%s (from %s)", req.method.c_str(), req.path.c_str(), + req.query.length() ? "?" : "", req.query.c_str(), + client.remoteIP().toString().c_str()); + + if (req.path == "/api/v1/fromradio") { + handleFromRadio(client, req); + } else if (req.path == "/api/v1/toradio") { + handleToRadio(client, req); + } else { + sendError(client, 404, "Not Found", "unknown endpoint"); + } +} + +#endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethApiHandlers.h b/src/mesh/eth/ethApiHandlers.h new file mode 100644 index 00000000000..815345d648c --- /dev/null +++ b/src/mesh/eth/ethApiHandlers.h @@ -0,0 +1,46 @@ +#pragma once + +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_API) + +#include +#include +#include + +// Transport-agnostic byte stream used by the HTTP request parser + handlers. +// Lets the same code drive plain TCP (EthernetClient) and TLS (mbedtls_ssl) +// transports without recompiling the handlers. +// +// Inherits Print so all `print(int)`, `print(const char *)`, `print(char)` +// helpers are available for free — the only thing implementations have to +// supply on the write side is `write(uint8_t)` + the bulk `write(buf, len)`. +class IStreamReadWrite : public Print +{ + public: + virtual ~IStreamReadWrite() = default; + + // Write side — Print pure virtual + bulk override + size_t write(uint8_t b) override = 0; + size_t write(const uint8_t *buf, size_t len) override = 0; + using Print::write; // bring in write(const char *str) and friends + + // Read side + virtual int available() = 0; + virtual int read() = 0; + virtual int read(uint8_t *buf, size_t len) = 0; + + // Status + virtual bool connected() = 0; + void flush() override = 0; // Print::flush is virtual void with empty default + + // Logging helper — used by request log line + virtual IPAddress remoteIP() = 0; +}; + +// Drive a single HTTP request → routing → response cycle on the given +// transport. Caller is responsible for closing the underlying connection +// after this returns. +void handleApiClient(IStreamReadWrite &client); + +#endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethApiServer.cpp b/src/mesh/eth/ethApiServer.cpp index 9049a076365..7a35e1e67b1 100644 --- a/src/mesh/eth/ethApiServer.cpp +++ b/src/mesh/eth/ethApiServer.cpp @@ -2,9 +2,9 @@ #if HAS_ETHERNET && defined(HAS_ETHERNET_API) +#include "ethApiHandlers.h" #include "ethApiServer.h" #include "concurrency/OSThread.h" -#include "mesh/PhoneAPI.h" #include #ifdef USE_ARDUINO_ETHERNET @@ -13,13 +13,6 @@ #include #endif -static constexpr uint32_t HEADER_TIMEOUT_MS = 3000; -static constexpr uint32_t BODY_TIMEOUT_MS = 5000; -static constexpr size_t MAX_LINE_LEN = 256; -static constexpr size_t MAX_HEADER_LINES = 32; -static constexpr const char *PROTOBUF_SCHEMA = - "https://raw.githubusercontent.com/meshtastic/protobufs/master/meshtastic/mesh.proto"; - // Adaptive poll intervals (mirror mesh/http/WebServer.cpp ESP32 pattern). static constexpr uint32_t ACTIVE_THRESHOLD_MS = 5000; static constexpr uint32_t MEDIUM_THRESHOLD_MS = 30000; @@ -27,268 +20,30 @@ static constexpr int32_t ACTIVE_INTERVAL_MS = 20; static constexpr int32_t MEDIUM_INTERVAL_MS = 100; static constexpr int32_t IDLE_INTERVAL_MS = 500; -// PhoneAPI subclass for the Ethernet HTTP transport. Mirrors mesh/http/HttpAPI -// but lives outside the MESHTASTIC_EXCLUDE_WEBSERVER gate (which is ESP32-only). -class EthHttpAPI : public PhoneAPI -{ - public: - EthHttpAPI() { api_type = TYPE_HTTP; } - bool checkIsConnected() override { return true; } -}; - static EthernetServer *apiServer = nullptr; -static EthHttpAPI webAPI; - -struct Request { - String method; - String path; - String query; // raw "key=val&..." (without leading '?'), empty if none - long contentLength = 0; -}; - -// Read up to one CRLF-terminated line; returns false on timeout or oversize. -static bool readLine(EthernetClient &client, String &out, uint32_t deadlineMs) -{ - out = ""; - while ((int32_t)(millis() - deadlineMs) < 0) { - if (!client.connected()) - return false; - if (!client.available()) { - delay(1); - continue; - } - int c = client.read(); - if (c < 0) - continue; - if (c == '\n') - return true; - if (c == '\r') - continue; - if (out.length() >= MAX_LINE_LEN) - return false; - out += (char)c; - } - return false; -} -static bool parseRequest(EthernetClient &client, Request &req, uint32_t deadlineMs) +// Adapter that exposes an EthernetClient through the transport-agnostic +// IStreamReadWrite interface so the handlers in ethApiHandlers.cpp can drive +// it the same way they drive the TLS transport. +class EthernetClientStream : public IStreamReadWrite { - String line; - if (!readLine(client, line, deadlineMs) || line.length() == 0) - return false; - - // "METHOD PATH HTTP/1.1" - int sp1 = line.indexOf(' '); - int sp2 = line.indexOf(' ', sp1 + 1); - if (sp1 <= 0 || sp2 <= sp1) - return false; - req.method = line.substring(0, sp1); - String fullPath = line.substring(sp1 + 1, sp2); - - int q = fullPath.indexOf('?'); - if (q >= 0) { - req.path = fullPath.substring(0, q); - req.query = fullPath.substring(q + 1); - } else { - req.path = fullPath; - req.query.remove(0); - } - - // Headers until blank line. Only Content-Length is interesting for now. - size_t hdrCount = 0; - while (hdrCount++ < MAX_HEADER_LINES) { - if (!readLine(client, line, deadlineMs)) - return false; - if (line.length() == 0) - return true; // end of headers - if (line.length() >= 16) { - // Case-insensitive prefix match for "Content-Length:" - String head = line.substring(0, 15); - head.toLowerCase(); - if (head == "content-length:") { - String v = line.substring(15); - v.trim(); - req.contentLength = v.toInt(); - } - } - } - return false; // too many headers -} - -// Trivial query-string param lookup. `out` set to value or "" if absent. -static bool getQueryParam(const String &query, const char *key, String &out) -{ - out.remove(0); - if (query.length() == 0) - return false; - String needle = String(key) + "="; - int idx = query.indexOf(needle); - if (idx < 0) - return false; - if (idx > 0 && query.charAt(idx - 1) != '&') - return false; // false positive (e.g. "all" matching "fall") - int start = idx + needle.length(); - int end = query.indexOf('&', start); - out = (end < 0) ? query.substring(start) : query.substring(start, end); - return true; -} - -static void writeStatusLine(EthernetClient &client, int status, const char *reason) -{ - client.print("HTTP/1.1 "); - client.print(status); - client.print(' '); - client.print(reason); - client.print("\r\n"); -} - -static void writeCorsHeaders(EthernetClient &client, const char *allowMethods) -{ - client.print("Access-Control-Allow-Origin: *\r\n"); - client.print("Access-Control-Allow-Headers: Content-Type\r\n"); - client.print("Access-Control-Allow-Methods: "); - client.print(allowMethods); - client.print("\r\n"); - client.print("X-Protobuf-Schema: "); - client.print(PROTOBUF_SCHEMA); - client.print("\r\n"); -} - -static void sendPreflight(EthernetClient &client, const char *allowMethods) -{ - writeStatusLine(client, 204, "No Content"); - writeCorsHeaders(client, allowMethods); - client.print("Content-Length: 0\r\n"); - client.print("Connection: close\r\n\r\n"); - client.flush(); -} - -static void sendError(EthernetClient &client, int status, const char *reason, const char *body) -{ - writeStatusLine(client, status, reason); - client.print("Content-Type: text/plain; charset=utf-8\r\n"); - client.print("Access-Control-Allow-Origin: *\r\n"); - client.print("Content-Length: "); - client.print((unsigned)strlen(body)); - client.print("\r\n"); - client.print("Connection: close\r\n\r\n"); - client.print(body); - client.flush(); -} - -static void handleFromRadio(EthernetClient &client, const Request &req) -{ - if (req.method == "OPTIONS") { - sendPreflight(client, "GET, OPTIONS"); - return; - } - if (req.method != "GET") { - sendError(client, 405, "Method Not Allowed", "method must be GET or OPTIONS"); - return; - } - - // Drain available packets. Stream-write headers, then write each packet's - // length-delimited protobuf bytes. The client parses message-by-message. - String allParam; - bool drainAll = getQueryParam(req.query, "all", allParam) && allParam == "true"; - - // We cannot pre-compute Content-Length without buffering everything, so use - // Connection: close (HTTP/1.0 framing). All Meshtastic clients tolerate this. - writeStatusLine(client, 200, "OK"); - client.print("Content-Type: application/x-protobuf\r\n"); - writeCorsHeaders(client, "GET, OPTIONS"); - client.print("Connection: close\r\n\r\n"); - - uint8_t txBuf[MAX_TO_FROM_RADIO_SIZE]; - size_t totalBytes = 0; - int packets = 0; - do { - size_t len = webAPI.getFromRadio(txBuf); - if (len == 0) - break; - client.write(txBuf, len); - totalBytes += len; - packets++; - } while (drainAll && packets < 64); // safety cap so a misbehaving client can't loop forever - client.flush(); - LOG_DEBUG("ETH API: fromradio sent %d packet(s), %u bytes (all=%d)", packets, - (unsigned)totalBytes, (int)drainAll); -} - -static void handleToRadio(EthernetClient &client, const Request &req) -{ - if (req.method == "OPTIONS") { - sendPreflight(client, "PUT, OPTIONS"); - return; - } - if (req.method != "PUT") { - sendError(client, 405, "Method Not Allowed", "method must be PUT or OPTIONS"); - return; - } - - if (req.contentLength <= 0 || (size_t)req.contentLength > MAX_TO_FROM_RADIO_SIZE) { - sendError(client, 400, "Bad Request", "missing or oversized Content-Length"); - return; - } + public: + explicit EthernetClientStream(EthernetClient &c) : c_(c) {} - uint8_t buf[MAX_TO_FROM_RADIO_SIZE]; - size_t got = 0; - const uint32_t deadline = millis() + BODY_TIMEOUT_MS; - while (got < (size_t)req.contentLength) { - if (!client.connected()) - break; - if ((int32_t)(millis() - deadline) >= 0) - break; - if (!client.available()) { - delay(1); - continue; - } - int n = client.read(buf + got, (size_t)req.contentLength - got); - if (n > 0) - got += n; - } - if (got != (size_t)req.contentLength) { - LOG_WARN("ETH API: toradio short read (%u/%ld)", (unsigned)got, req.contentLength); - sendError(client, 408, "Request Timeout", "body read incomplete"); - return; - } + size_t write(uint8_t b) override { return c_.write(b); } + size_t write(const uint8_t *buf, size_t len) override { return c_.write(buf, len); } - webAPI.handleToRadio(buf, got); + int available() override { return c_.available(); } + int read() override { return c_.read(); } + int read(uint8_t *buf, size_t len) override { return c_.read(buf, len); } - // Echo the bytes back, matching mesh/http ESP32 semantics. - writeStatusLine(client, 200, "OK"); - client.print("Content-Type: application/x-protobuf\r\n"); - writeCorsHeaders(client, "PUT, OPTIONS"); - client.print("Content-Length: "); - client.print((unsigned)got); - client.print("\r\n"); - client.print("Connection: close\r\n\r\n"); - client.write(buf, got); - client.flush(); - LOG_DEBUG("ETH API: toradio handled %u bytes", (unsigned)got); -} + bool connected() override { return c_.connected(); } + void flush() override { c_.flush(); } + IPAddress remoteIP() override { return c_.remoteIP(); } -static void handleClient(EthernetClient &client) -{ - const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; - Request req; - if (!parseRequest(client, req, deadline)) { - LOG_DEBUG("ETH API: bad/timeout request from %s", client.remoteIP().toString().c_str()); - return; - } - - LOG_INFO("ETH API: %s %s%s%s (from %s)", req.method.c_str(), req.path.c_str(), - req.query.length() ? "?" : "", req.query.c_str(), - client.remoteIP().toString().c_str()); - - if (req.path == "/api/v1/fromradio") { - handleFromRadio(client, req); - } else if (req.path == "/api/v1/toradio") { - handleToRadio(client, req); - } else { - sendError(client, 404, "Not Found", "unknown endpoint"); - } -} + private: + EthernetClient &c_; +}; // Dedicated OSThread so accept() runs on sub-second cadence. The Ethernet // client periodic ticks every 5s which is fine for NTP/MQTT but cripples a @@ -306,7 +61,8 @@ class EthApiServerThread : public concurrency::OSThread EthernetClient client = apiServer->accept(); if (client) { lastActivityMs = millis(); - handleClient(client); + EthernetClientStream stream(client); + handleApiClient(stream); client.stop(); } } @@ -332,7 +88,7 @@ void initEthApiServer() apiServer = new EthernetServer(ETH_API_PORT); apiServer->begin(); apiThread = new EthApiServerThread(); // OSThread base auto-registers with the scheduler - LOG_INFO("ETH API: server listening on TCP port %d (phase 1, OSThread @ 20ms)", + LOG_INFO("ETH API: server listening on TCP port %d (phase 2.0, OSThread @ 20ms)", ETH_API_PORT); } From eb417510fea09fd7720d27219b4fbd439cfb98eb Mon Sep 17 00:00:00 2001 From: CValdesS Date: Thu, 28 May 2026 00:06:34 +0200 Subject: [PATCH 06/11] feat(eth-tls): ECDSA P-256 self-signed cert generation for HTTPS API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mbedTLS-based cert generation module that produces a SAN=IP self-signed ECDSA P-256 server certificate, persisted under LittleFS so subsequent boots reuse the same key. Generation runs once on a dedicated OSThread so the ECDSA keygen path (~430 ms) never blocks the Periodic stack or the Ethernet reconnect loop. - mbedTLS 3.6.2 sources compiled in via scripts/add_mbedtls_sources.py (BuildSources of pico-sdk/lib/mbedtls/library/*.c, all 108 files). MBEDTLS_USER_CONFIG_FILE injected as CPPDEFINES tuple in the script — build_flags shell escape mangles the embedded quotes on Windows. - src/mbedtls_user_config.h undefs MBEDTLS_HAVE_TIME, HAVE_TIME_DATE, TIMING_C, NET_C, FS_IO, PSA_ITS_FILE_C, PSA_CRYPTO_STORAGE_C, and defines MBEDTLS_NO_PLATFORM_ENTROPY (entropy_poll.c uses a raw platform check, not gated by a flag). - src/mesh/eth/ethCert.{h,cpp}: ECDSA P-256 keypair + cert with SAN(IP=current) using pico-sdk get_rand_64() directly as f_rng, bypassing mbedtls_entropy. DER buffers heap-allocated to keep the OSThread stack within budget. Cert + key + ip persisted under /prefs/eth_*.der so subsequent boots reuse the same identity. - Gated by HAS_ETHERNET_TLS_API. Standalone phase: generation only; HTTPS server (TCP/443) wired up in the follow-up commit. --- scripts/add_mbedtls_sources.py | 45 +++++ src/mbedtls_user_config.h | 39 ++++ src/mesh/eth/ethCert.cpp | 342 +++++++++++++++++++++++++++++++++ src/mesh/eth/ethCert.h | 38 ++++ src/mesh/eth/ethClient.cpp | 10 + 5 files changed, 474 insertions(+) create mode 100644 scripts/add_mbedtls_sources.py create mode 100644 src/mbedtls_user_config.h create mode 100644 src/mesh/eth/ethCert.cpp create mode 100644 src/mesh/eth/ethCert.h diff --git a/scripts/add_mbedtls_sources.py b/scripts/add_mbedtls_sources.py new file mode 100644 index 00000000000..7a01e72dd15 --- /dev/null +++ b/scripts/add_mbedtls_sources.py @@ -0,0 +1,45 @@ +# Pulls all mbedTLS sources from arduino-pico's pico-sdk into the build so the +# firmware can call mbedtls_* APIs (the precompiled arduino-pico libs only +# expose BearSSL; mbedTLS is shipped as source under lib/mbedtls/library/*.c). +# +# Wired in via `extra_scripts = pre:scripts/add_mbedtls_sources.py` on the +# variants that define HAS_ETHERNET_TLS_API. POSIX-only code paths inside +# mbedtls are neutralized by src/mbedtls_user_config.h (referenced via the +# variant's -DMBEDTLS_USER_CONFIG_FILE build flag). Unused symbols are dropped +# at link time, so non-TLS envs that pull this script in pay nothing. + +Import("env") + +import glob +import os + +framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico") +if not framework_dir: + print("[add_mbedtls_sources] framework-arduinopico package not found — skipping") + Return() + +mbedtls_root = os.path.join(framework_dir, "pico-sdk", "lib", "mbedtls") +include_dir = os.path.join(mbedtls_root, "include") +src_dir = os.path.join(mbedtls_root, "library") + +if not os.path.isdir(src_dir): + print(f"[add_mbedtls_sources] mbedtls library dir not found at {src_dir}") + Return() + +# mbedtls headers + project src (where mbedtls_user_config.h lives) must be on +# the include path when the .c files compile. +env.Append(CPPPATH=[include_dir, env["PROJECT_SRC_DIR"]]) + +# Inject the user-config define through CPPDEFINES so SCons handles the +# embedded quotes correctly. The build_flags shell parser drops/corrupts the +# value when the same is expressed as -D 'MBEDTLS_USER_CONFIG_FILE="..."'. +env.Append(CPPDEFINES=[("MBEDTLS_USER_CONFIG_FILE", '\\"mbedtls_user_config.h\\"')]) + +sources = sorted(glob.glob(os.path.join(src_dir, "*.c"))) +print(f"[add_mbedtls_sources] Adding {len(sources)} mbedTLS source files from {src_dir}") + +env.BuildSources( + os.path.join("$BUILD_DIR", "mbedtls_pico"), + src_dir, + src_filter=["+<*.c>"], +) diff --git a/src/mbedtls_user_config.h b/src/mbedtls_user_config.h new file mode 100644 index 00000000000..1a1d6e14831 --- /dev/null +++ b/src/mbedtls_user_config.h @@ -0,0 +1,39 @@ +// User-provided mbedTLS config — pulled in AFTER the default mbedtls_config.h +// via -DMBEDTLS_USER_CONFIG_FILE in the variant's platformio.ini. +// +// We compile mbedtls source files straight out of pico-sdk on bare metal, so +// every option that needs POSIX (time, sockets, filesystem) is disabled here. +// Without this, sources like net_sockets.c, timing.c, platform_util.c, etc. +// abort with #error or #include . +// +// Code paths that touch these symbols are gated by the same MBEDTLS_* macros, +// so the linker simply drops the unreachable branches — no manual file +// exclusion in the build script. + +#pragma once + +// Entropy: entropy_poll.c does a hard `#error` on non-POSIX/non-Windows +// platforms. Tell it to skip the platform-specific entropy plumbing — our +// cert module passes a custom f_rng (picoRand → get_rand_64) directly into +// every mbedtls call that needs randomness, so we never invoke entropy_poll. +#define MBEDTLS_NO_PLATFORM_ENTROPY + +// Time: pico-sdk mbedtls only knows clock_gettime() (POSIX) and GetTickCount64() +// (Win32). Neither exists here. We don't need calendar time on the server side +// (cert validity check at TLS init is the only user of MBEDTLS_HAVE_TIME_DATE +// and our self-signed cert is dated 2024-2034 so the client decides validity). +#undef MBEDTLS_HAVE_TIME +#undef MBEDTLS_HAVE_TIME_DATE +#undef MBEDTLS_TIMING_C + +// Networking: net_sockets.c uses POSIX sockets. We wrap EthernetClient +// ourselves with mbedtls_ssl_set_bio() callbacks. +#undef MBEDTLS_NET_C + +// Filesystem: cert/key load happens via our own LittleFS code, not via +// mbedtls_x509_crt_parse_file()/fopen(). +#undef MBEDTLS_FS_IO + +// PSA persistent storage: requires POSIX fopen. Unused. +#undef MBEDTLS_PSA_ITS_FILE_C +#undef MBEDTLS_PSA_CRYPTO_STORAGE_C diff --git a/src/mesh/eth/ethCert.cpp b/src/mesh/eth/ethCert.cpp new file mode 100644 index 00000000000..594d96a1504 --- /dev/null +++ b/src/mesh/eth/ethCert.cpp @@ -0,0 +1,342 @@ +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) + +#include "ethCert.h" +#include "FSCommon.h" +#include "concurrency/OSThread.h" +#include +#include + +#ifdef USE_ARDUINO_ETHERNET +#include +#else +#include +#endif + +#include +#include +#include +#include +#include + +#include + +static constexpr const char *CERT_PATH = "/eth_cert.der"; +static constexpr const char *KEY_PATH = "/eth_key.der"; +static constexpr const char *IP_PATH = "/eth_cert_ip.txt"; + +// Random callback for mbedtls — sources entropy from the RP2350 ROSC TRNG via +// pico-sdk get_rand_64(). Used directly as f_rng in mbedtls calls so we don't +// have to plumb a full mbedtls_entropy_context + ctr_drbg. The hardware TRNG +// is cryptographically suitable per pico-sdk docs (ROSC + whitening). +static int picoRand(void * /*ctx*/, unsigned char *out, size_t len) +{ + while (len > 0) { + uint64_t r = get_rand_64(); + size_t to_copy = len > sizeof(r) ? sizeof(r) : len; + memcpy(out, &r, to_copy); + out += to_copy; + len -= to_copy; + } + return 0; +} + +static bool readBinary(const char *path, std::vector &out) +{ + File f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + size_t sz = f.size(); + out.clear(); + out.reserve(sz); + while (f.available()) + out.push_back((uint8_t)f.read()); + f.close(); + return out.size() == sz && sz > 0; +} + +static bool writeBinary(const char *path, const uint8_t *buf, size_t len) +{ + File f = FSCom.open(path, FILE_O_WRITE); + if (!f) + return false; + size_t w = f.write(buf, len); + f.flush(); + f.close(); + return w == len; +} + +static bool readText(const char *path, String &out) +{ + File f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + out = ""; + while (f.available()) + out += (char)f.read(); + f.close(); + return out.length() > 0; +} + +static bool writeText(const char *path, const String &s) +{ + File f = FSCom.open(path, FILE_O_WRITE); + if (!f) + return false; + size_t w = f.write((const uint8_t *)s.c_str(), s.length()); + f.flush(); + f.close(); + return w == s.length(); +} + +// Generate ECDSA P-256 key + self-signed X.509 cert with CN=, +// SAN(IP=), validity 2024-01-01 to 2034-01-01. Fills out on success. +static bool generateCert(IPAddress ip, EthCertMaterial &out) +{ + int ret; + mbedtls_pk_context pk; + mbedtls_x509write_cert crt; + + mbedtls_pk_init(&pk); + mbedtls_x509write_crt_init(&crt); + + bool ok = false; + // Buffers live on the heap so the OSThread stack stays small. Originally + // certBuf[2048] + keyBuf[1024] were locals; combined with mbedtls ECDSA's + // own deep call stack that pushed past the ~8 KB core0 stack budget on + // arduino-pico and the board reboot-looped between "starting cert + // pipeline" and the first "generated…" log. + std::vector certBuf(2048); + std::vector keyBuf(1024); + + do { + // 1. ECDSA P-256 keypair + LOG_INFO("ETH CERT: step 1/8 pk_setup"); + Serial.flush(); + ret = mbedtls_pk_setup(&pk, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)); + if (ret != 0) { + LOG_ERROR("ETH CERT: pk_setup failed -0x%04x", -ret); + break; + } + LOG_INFO("ETH CERT: step 2/8 ecp_gen_key (P-256)"); + Serial.flush(); + ret = mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(pk), picoRand, nullptr); + if (ret != 0) { + LOG_ERROR("ETH CERT: ecp_gen_key failed -0x%04x", -ret); + break; + } + + // 2. Cert fields + LOG_INFO("ETH CERT: step 3/8 subject/issuer/serial/validity/constraints"); + Serial.flush(); + String subjectName = "CN=" + ip.toString() + ",O=Meshtastic,C=US"; + ret = mbedtls_x509write_crt_set_subject_name(&crt, subjectName.c_str()); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_subject_name failed -0x%04x", -ret); + break; + } + ret = mbedtls_x509write_crt_set_issuer_name(&crt, subjectName.c_str()); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_issuer_name failed -0x%04x", -ret); + break; + } + + mbedtls_x509write_crt_set_subject_key(&crt, &pk); + mbedtls_x509write_crt_set_issuer_key(&crt, &pk); + mbedtls_x509write_crt_set_md_alg(&crt, MBEDTLS_MD_SHA256); + mbedtls_x509write_crt_set_version(&crt, MBEDTLS_X509_CRT_VERSION_3); + + unsigned char serial[16]; + picoRand(nullptr, serial, sizeof(serial)); + ret = mbedtls_x509write_crt_set_serial_raw(&crt, serial, sizeof(serial)); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_serial_raw failed -0x%04x", -ret); + break; + } + + ret = mbedtls_x509write_crt_set_validity(&crt, "20240101000000", "20340101000000"); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_validity failed -0x%04x", -ret); + break; + } + + ret = mbedtls_x509write_crt_set_basic_constraints(&crt, 0, -1); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_basic_constraints failed -0x%04x", -ret); + break; + } + + // SAN extension: IP address (4 bytes). Browsers require SAN match, + // CN alone is ignored since RFC 6125 / Chrome 58. + LOG_INFO("ETH CERT: step 4/8 SAN(IP)"); + Serial.flush(); + unsigned char ipBytes[4] = {ip[0], ip[1], ip[2], ip[3]}; + mbedtls_x509_san_list san; + san.next = nullptr; + san.node.type = MBEDTLS_X509_SAN_IP_ADDRESS; + san.node.san.unstructured_name.tag = 0; + san.node.san.unstructured_name.p = ipBytes; + san.node.san.unstructured_name.len = sizeof(ipBytes); + ret = mbedtls_x509write_crt_set_subject_alternative_name(&crt, &san); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_san failed -0x%04x", -ret); + break; + } + + // 3. Serialize cert + key to DER. mbedtls writes DER at the END of + // the buffer; ret = length, with the bytes living at (buf + size - len). + LOG_INFO("ETH CERT: step 5/8 x509write_crt_der (sign)"); + Serial.flush(); + ret = mbedtls_x509write_crt_der(&crt, certBuf.data(), certBuf.size(), picoRand, nullptr); + if (ret < 0) { + LOG_ERROR("ETH CERT: x509write_crt_der failed -0x%04x", -ret); + break; + } + size_t certLen = (size_t)ret; + LOG_INFO("ETH CERT: step 6/8 copy cert DER (%u B)", (unsigned)certLen); + Serial.flush(); + out.certDer.assign(certBuf.data() + certBuf.size() - certLen, + certBuf.data() + certBuf.size()); + + LOG_INFO("ETH CERT: step 7/8 pk_write_key_der"); + Serial.flush(); + ret = mbedtls_pk_write_key_der(&pk, keyBuf.data(), keyBuf.size()); + if (ret < 0) { + LOG_ERROR("ETH CERT: pk_write_key_der failed -0x%04x", -ret); + break; + } + size_t keyLen = (size_t)ret; + LOG_INFO("ETH CERT: step 8/8 copy key DER (%u B)", (unsigned)keyLen); + Serial.flush(); + out.keyDer.assign(keyBuf.data() + keyBuf.size() - keyLen, + keyBuf.data() + keyBuf.size()); + + ok = true; + } while (false); + + mbedtls_x509write_crt_free(&crt); + mbedtls_pk_free(&pk); + return ok; +} + +bool ensureCertForIp(IPAddress ip, EthCertMaterial &out) +{ + String ipStr = ip.toString(); + + // Try cache + String savedIp; + if (readText(IP_PATH, savedIp)) { + savedIp.trim(); + if (savedIp == ipStr && readBinary(CERT_PATH, out.certDer) && + readBinary(KEY_PATH, out.keyDer)) { + LOG_INFO("ETH CERT: loaded from FS (%u B cert + %u B key, IP %s)", + (unsigned)out.certDer.size(), (unsigned)out.keyDer.size(), ipStr.c_str()); + return true; + } + if (savedIp != ipStr) { + LOG_INFO("ETH CERT: cached IP %s != current %s, regenerating", + savedIp.c_str(), ipStr.c_str()); + } + } + + LOG_INFO("ETH CERT: generating ECDSA P-256 self-signed cert for IP %s...", ipStr.c_str()); + uint32_t t0 = millis(); + if (!generateCert(ip, out)) { + LOG_ERROR("ETH CERT: generation failed"); + return false; + } + uint32_t dt = millis() - t0; + LOG_INFO("ETH CERT: generated %u B cert + %u B key in %u ms", + (unsigned)out.certDer.size(), (unsigned)out.keyDer.size(), (unsigned)dt); + + // Persist (best-effort). Failure here means we'll regen on next boot, but + // current process still has the in-memory cert ready for use. + if (!writeBinary(CERT_PATH, out.certDer.data(), out.certDer.size()) || + !writeBinary(KEY_PATH, out.keyDer.data(), out.keyDer.size()) || + !writeText(IP_PATH, ipStr)) { + LOG_WARN("ETH CERT: persist failed — will regenerate next boot"); + } else { + LOG_INFO("ETH CERT: persisted to LittleFS"); + } + + return true; +} + +// One-shot worker that defers cert gen off the Periodic thread (which has a +// tight stack and ticks every 5s alongside reconnect / NTP / MQTT). Polls for +// a non-zero IP, then runs ensureCertForIp() once and goes idle forever. +// +// Phase 2.1-bis. The previous attempt called ensureCertForIp() inline from +// reconnectETH() and reboot-looped the board: probable stack overflow during +// ECDSA keygen + DER encoding + LittleFS write all on the Periodic stack. +class EthCertThread : public concurrency::OSThread +{ + public: + EthCertThread() : concurrency::OSThread("EthCert") {} + + protected: + int32_t runOnce() override + { + if (ready_) + return INT32_MAX; + + IPAddress ip = Ethernet.localIP(); + if (ip == IPAddress(0, 0, 0, 0)) { + // DHCP not yet bound; check again in 250 ms. + return 250; + } + + LOG_INFO("ETH CERT: thread woke, IP=%s, starting cert pipeline", ip.toString().c_str()); + Serial.flush(); + + uint32_t t0 = millis(); + bool ok = ensureCertForIp(ip, material_); + uint32_t dt = millis() - t0; + + if (ok) { + LOG_INFO("ETH CERT: pipeline OK in %u ms (%u B cert + %u B key cached)", + (unsigned)dt, (unsigned)material_.certDer.size(), + (unsigned)material_.keyDer.size()); + } else { + LOG_ERROR("ETH CERT: pipeline FAILED in %u ms — TLS server will not start", (unsigned)dt); + material_.certDer.clear(); + material_.keyDer.clear(); + } + Serial.flush(); + + ready_ = true; + return INT32_MAX; + } + + private: + bool ready_ = false; + EthCertMaterial material_; + + public: + bool isReady() const { return ready_; } + const EthCertMaterial &cert() const { return material_; } +}; + +static EthCertThread *certThread = nullptr; +static EthCertMaterial emptyMaterial; + +void initEthCertThread() +{ + if (certThread) + return; + certThread = new EthCertThread(); + LOG_INFO("ETH CERT: deferred worker scheduled (waits for DHCP, runs once)"); +} + +bool isEthCertReady() +{ + return certThread && certThread->isReady(); +} + +const EthCertMaterial &getEthCert() +{ + return (certThread && certThread->isReady()) ? certThread->cert() : emptyMaterial; +} + +#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API diff --git a/src/mesh/eth/ethCert.h b/src/mesh/eth/ethCert.h new file mode 100644 index 00000000000..abe3e7507b5 --- /dev/null +++ b/src/mesh/eth/ethCert.h @@ -0,0 +1,38 @@ +#pragma once + +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) + +#include +#include +#include +#include + +struct EthCertMaterial { + std::vector certDer; // self-signed X.509 cert in DER format + std::vector keyDer; // ECDSA P-256 private key in DER format +}; + +// Ensure we have a valid self-signed cert + ECDSA P-256 key whose CN + SAN +// match the given IP. Loads from LittleFS if cached, otherwise generates +// fresh and persists. Cert validity 2024-01-01 to 2034-01-01. +// +// Returns true on success. On false: TLS server should refuse to start (we'd +// rather fail closed than serve no encryption). +bool ensureCertForIp(IPAddress ip, EthCertMaterial &out); + +// Spawn the one-shot OSThread that drives cert gen off the Periodic stack. +// The Phase 2.1 inline call from reconnectETH() overflowed the Periodic +// thread stack on RP2350; the dedicated OSThread uses the mainController +// stack which is sized for protobuf work and comfortably handles ECDSA P-256 +// keygen + x509 sign + LittleFS write. Idempotent. +void initEthCertThread(); + +// True once the thread has finished (success or definitive failure). +bool isEthCertReady(); + +// Snapshot of the generated material once isEthCertReady(). Empty otherwise. +const EthCertMaterial &getEthCert(); + +#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index df3d9483bf5..05628ee1440 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -9,6 +9,9 @@ #if HAS_ETHERNET && defined(HAS_ETHERNET_API) #include "mesh/eth/ethApiServer.h" #endif +#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) +#include "mesh/eth/ethCert.h" +#endif #ifdef USE_ARDUINO_ETHERNET #include // arduino-libraries/Ethernet — supports W5100/W5200/W5500 // Shorter DHCP timeout so LoRa startup isn't blocked when no DHCP server is present. @@ -160,6 +163,13 @@ static int32_t reconnectETH() #if HAS_ETHERNET && defined(HAS_ETHERNET_API) initEthApiServer(); #endif +#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) + // Phase 2.1-bis — cert gen runs on its own OSThread so ECDSA keygen + // + DER encoding + LittleFS write don't share the Periodic stack + // (which overflowed in the original inline attempt). The thread + // polls for a non-zero IP itself and runs once. + initEthCertThread(); +#endif ethStartupComplete = true; } From b7a637acfd94a3458ac2701ed9763cf79e0212ad Mon Sep 17 00:00:00 2001 From: CValdesS Date: Thu, 28 May 2026 04:21:39 +0200 Subject: [PATCH 07/11] =?UTF-8?q?feat(eth-tls-api):=20phase=202.2=20?= =?UTF-8?q?=E2=80=94=20HTTPS=20server=20on=20TCP/443=20reusing=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings up an mbedTLS server on port 443 that defers to the ECDSA P-256 self-signed cert produced by [[ethCert]]. Reuses the HTTP request / response handlers from [[ethApiHandlers]] via the IStreamReadWrite interface — there is exactly one code path for routing / CORS / PhoneAPI integration regardless of whether the transport is plain TCP or TLS. EthTlsApiServerThread (OSThread): - Phase A: poll isEthCertReady() every 500 ms while the cert worker runs. Once true, parse cert chain + key, build ssl_config (TLS server, stream transport, default preset, VERIFY_NONE since we are the cert issuer), install own cert, run ssl_setup, bind tlsServer on 443. - Phase B: standard adaptive accept loop (20 / 100 / 500 ms tick), identical to the plain-HTTP server. Per-connection flow: 1. session_reset on the static ssl context (1 in-flight session — multi- session pool is Phase 3 if needed) 2. set_bio routes mbedtls I/O to two C callbacks (netSend / netRecv) that bridge to the live EthernetClient via the void* ctx 3. handshake loop (sync, blocking, with 10 s recv timeout) — mbedTLS errors are logged with mbedtls_strerror 4. wrap (ssl, client) in MbedTlsStream → handleApiClient(stream) 5. close_notify + client.stop Stack budget continues the Phase 2.1-bis discipline: every large buffer (ssl context, cert chain, pk_key, ssl_config) lives in BSS as a static global. The OSThread stack only holds the per-connection EthernetClient adapter and small mbedtls return codes. Validated on wiznet 192.168.1.143: - cert load-from-FS path: 13 ms (regen skipped on second boot) - cert gen path: 290 ms (first boot only) - ssl_setup chain: parse cert, parse key, ssl_config_defaults, conf_own_cert, ssl_setup all return 0 - end-to-end: curl -k https://192.168.1.143/api/v1/fromradio → 200 OK with application/x-protobuf, CORS, X-Protobuf-Schema headers, server initiates close_notify cleanly Footprint vs 2.0: - flash 70.5% → 87.3% (+220 KB for mbedtls_ssl + x509 server-side code) - RAM static 17.6% → 19.8% (+11 KB BSS for ssl_context + cert chain) - heap at runtime adds ~32 KB per active session (mbedtls in/out record buffers, default MBEDTLS_SSL_*_CONTENT_LEN=16384) Next: validate from Firefox direct (https://192.168.1.143 → warning accept → JSON visible), then from client.meshtastic.org hosted to confirm the mixed-content block is gone. --- src/mesh/eth/ethClient.cpp | 4 + src/mesh/eth/ethTlsApiServer.cpp | 309 +++++++++++++++++++++++++++++++ src/mesh/eth/ethTlsApiServer.h | 15 ++ 3 files changed, 328 insertions(+) create mode 100644 src/mesh/eth/ethTlsApiServer.cpp create mode 100644 src/mesh/eth/ethTlsApiServer.h diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index 05628ee1440..be14d74a168 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -11,6 +11,7 @@ #endif #if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) #include "mesh/eth/ethCert.h" +#include "mesh/eth/ethTlsApiServer.h" #endif #ifdef USE_ARDUINO_ETHERNET #include // arduino-libraries/Ethernet — supports W5100/W5200/W5500 @@ -169,6 +170,9 @@ static int32_t reconnectETH() // (which overflowed in the original inline attempt). The thread // polls for a non-zero IP itself and runs once. initEthCertThread(); + // Phase 2.2 — TLS server skeleton on TCP/443. The worker waits + // until the cert thread signals isEthCertReady() before binding. + initEthTlsApiServer(); #endif ethStartupComplete = true; diff --git a/src/mesh/eth/ethTlsApiServer.cpp b/src/mesh/eth/ethTlsApiServer.cpp new file mode 100644 index 00000000000..68a10ca5440 --- /dev/null +++ b/src/mesh/eth/ethTlsApiServer.cpp @@ -0,0 +1,309 @@ +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) + +#include "ethTlsApiServer.h" +#include "ethApiHandlers.h" +#include "ethCert.h" +#include "concurrency/OSThread.h" +#include + +#ifdef USE_ARDUINO_ETHERNET +#include +#else +#include +#endif + +#include +#include +#include +#include + +#include + +#ifndef ETH_TLS_API_PORT +#define ETH_TLS_API_PORT 443 +#endif + +// Adaptive poll intervals (mirror ethApiServer.cpp). +static constexpr uint32_t ACTIVE_THRESHOLD_MS = 5000; +static constexpr uint32_t MEDIUM_THRESHOLD_MS = 30000; +static constexpr int32_t ACTIVE_INTERVAL_MS = 20; +static constexpr int32_t MEDIUM_INTERVAL_MS = 100; +static constexpr int32_t IDLE_INTERVAL_MS = 500; +static constexpr uint32_t RECV_TIMEOUT_MS = 10000; + +// Reuse the picoRand callback semantics from ethCert.cpp. Local copy so we +// don't have to expose it through a header; the cost is two trivial functions. +static int picoRand(void * /*ctx*/, unsigned char *out, size_t len) +{ + while (len > 0) { + uint64_t r = get_rand_64(); + size_t to_copy = len > sizeof(r) ? sizeof(r) : len; + memcpy(out, &r, to_copy); + out += to_copy; + len -= to_copy; + } + return 0; +} + +// One-shot TLS context lives in BSS — keeps mbedtls allocations off the +// OSThread stack (lesson from Phase 2.1-bis: stack budget is tight on M33). +static EthernetServer *tlsServer = nullptr; +static mbedtls_x509_crt certChain; +static mbedtls_pk_context pkKey; +static mbedtls_ssl_config sslConf; +static mbedtls_ssl_context ssl; +static bool tlsReady = false; + +// Adapter: route mbedtls_ssl_set_bio() through the EthernetClient instance +// that runOnce() is currently servicing. The void* ctx we hand mbedtls is a +// pointer to the EthernetClient. +static int netSend(void *ctx, const unsigned char *buf, size_t len) +{ + auto *client = static_cast(ctx); + if (!client->connected()) + return MBEDTLS_ERR_SSL_WANT_WRITE; + size_t w = client->write(buf, len); + if (w == 0) + return MBEDTLS_ERR_SSL_WANT_WRITE; + return (int)w; +} + +static int netRecv(void *ctx, unsigned char *buf, size_t len) +{ + auto *client = static_cast(ctx); + // Block-with-timeout: spin until bytes arrive, the peer closes, or we + // exceed the per-recv budget. Pure non-blocking (return WANT_READ) would + // require mbedtls_ssl_handshake to be driven from the runOnce dispatcher + // — overkill for the Phase 2.2 skeleton with a single in-flight session. + uint32_t t0 = millis(); + while (client->available() == 0) { + if (!client->connected()) + return MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY; + if (millis() - t0 > RECV_TIMEOUT_MS) + return MBEDTLS_ERR_SSL_TIMEOUT; + delay(2); + } + int n = client->read(buf, len); + if (n <= 0) + return MBEDTLS_ERR_SSL_WANT_READ; + return n; +} + +// Bridges mbedtls_ssl_read/write to the request handlers via the same +// IStreamReadWrite interface the plain-HTTP server uses. +class MbedTlsStream : public IStreamReadWrite +{ + public: + MbedTlsStream(mbedtls_ssl_context *s, EthernetClient *c) : ssl_(s), client_(c) {} + + size_t write(uint8_t b) override + { + int r = mbedtls_ssl_write(ssl_, &b, 1); + return r > 0 ? 1 : 0; + } + size_t write(const uint8_t *buf, size_t len) override + { + size_t total = 0; + while (total < len) { + int r = mbedtls_ssl_write(ssl_, buf + total, len - total); + if (r == MBEDTLS_ERR_SSL_WANT_WRITE || r == MBEDTLS_ERR_SSL_WANT_READ) + continue; + if (r <= 0) + break; + total += (size_t)r; + } + return total; + } + + int available() override + { + // mbedtls buffers internally; ssl_get_bytes_avail reports what is + // already decoded. If 0, peek a record via read into a 1-byte buffer. + size_t pending = mbedtls_ssl_get_bytes_avail(ssl_); + if (pending > 0) + return (int)pending; + // Best-effort: report network bytes (rough proxy — handlers usually + // call read() in a loop and tolerate slow streams). + return client_->available(); + } + int read() override + { + uint8_t b; + int r = mbedtls_ssl_read(ssl_, &b, 1); + return r == 1 ? (int)b : -1; + } + int read(uint8_t *buf, size_t len) override + { + int r = mbedtls_ssl_read(ssl_, buf, len); + return r > 0 ? r : -1; + } + + bool connected() override { return client_->connected(); } + void flush() override { client_->flush(); } + IPAddress remoteIP() override { return client_->remoteIP(); } + + private: + mbedtls_ssl_context *ssl_; + EthernetClient *client_; +}; + +// Optional mbedtls debug bridge — kept disabled by default to keep COM9 +// uncluttered. Flip MBEDTLS_DEBUG_C in mbedtls_config and call +// mbedtls_ssl_conf_dbg(&sslConf, mbedtlsDebug, nullptr) to enable. +// static void mbedtlsDebug(void *ctx, int level, const char *file, int line, const char *str) { ... } + +class EthTlsApiServerThread : public concurrency::OSThread +{ + public: + EthTlsApiServerThread() : concurrency::OSThread("EthTlsApi") { lastActivityMs = millis(); } + + protected: + int32_t runOnce() override + { + // Phase A: wait for the cert worker to finish. + if (!tlsReady) { + if (!isEthCertReady()) + return 500; + if (!initTlsContext()) + return INT32_MAX; // hard fail — TLS server stays disabled + tlsReady = true; + } + + // Phase B: accept + serve one client. + if (!tlsServer) + return INT32_MAX; + + EthernetClient client = tlsServer->accept(); + if (client) { + lastActivityMs = millis(); + serveClient(client); + client.stop(); + } + + uint32_t since = millis() - lastActivityMs; + if (since < ACTIVE_THRESHOLD_MS) + return ACTIVE_INTERVAL_MS; + if (since < MEDIUM_THRESHOLD_MS) + return MEDIUM_INTERVAL_MS; + return IDLE_INTERVAL_MS; + } + + private: + uint32_t lastActivityMs; + + bool initTlsContext() + { + LOG_INFO("ETH TLS: cert is ready, initializing TLS context"); + Serial.flush(); + + const EthCertMaterial &cert = getEthCert(); + if (cert.certDer.empty() || cert.keyDer.empty()) { + LOG_ERROR("ETH TLS: cert material is empty, refusing to start TLS server"); + return false; + } + + mbedtls_x509_crt_init(&certChain); + mbedtls_pk_init(&pkKey); + mbedtls_ssl_config_init(&sslConf); + mbedtls_ssl_init(&ssl); + + int ret; + + LOG_INFO("ETH TLS: parsing cert chain (%u B DER)", (unsigned)cert.certDer.size()); + Serial.flush(); + ret = mbedtls_x509_crt_parse_der(&certChain, cert.certDer.data(), cert.certDer.size()); + if (ret != 0) { + LOG_ERROR("ETH TLS: x509_crt_parse_der failed -0x%04x", -ret); + return false; + } + + LOG_INFO("ETH TLS: parsing key (%u B DER)", (unsigned)cert.keyDer.size()); + Serial.flush(); + ret = mbedtls_pk_parse_key(&pkKey, cert.keyDer.data(), cert.keyDer.size(), nullptr, 0, + picoRand, nullptr); + if (ret != 0) { + LOG_ERROR("ETH TLS: pk_parse_key failed -0x%04x", -ret); + return false; + } + + LOG_INFO("ETH TLS: ssl_config_defaults (server, TLS, default preset)"); + Serial.flush(); + ret = mbedtls_ssl_config_defaults(&sslConf, MBEDTLS_SSL_IS_SERVER, + MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT); + if (ret != 0) { + LOG_ERROR("ETH TLS: ssl_config_defaults failed -0x%04x", -ret); + return false; + } + + mbedtls_ssl_conf_rng(&sslConf, picoRand, nullptr); + mbedtls_ssl_conf_authmode(&sslConf, MBEDTLS_SSL_VERIFY_NONE); + + LOG_INFO("ETH TLS: conf_own_cert"); + Serial.flush(); + ret = mbedtls_ssl_conf_own_cert(&sslConf, &certChain, &pkKey); + if (ret != 0) { + LOG_ERROR("ETH TLS: conf_own_cert failed -0x%04x", -ret); + return false; + } + + LOG_INFO("ETH TLS: ssl_setup"); + Serial.flush(); + ret = mbedtls_ssl_setup(&ssl, &sslConf); + if (ret != 0) { + LOG_ERROR("ETH TLS: ssl_setup failed -0x%04x", -ret); + return false; + } + + tlsServer = new EthernetServer(ETH_TLS_API_PORT); + tlsServer->begin(); + LOG_INFO("ETH TLS: server listening on TCP port %d (phase 2.2 skeleton)", + ETH_TLS_API_PORT); + Serial.flush(); + return true; + } + + void serveClient(EthernetClient &client) + { + LOG_INFO("ETH TLS: client connected from %s", client.remoteIP().toString().c_str()); + Serial.flush(); + + mbedtls_ssl_session_reset(&ssl); + mbedtls_ssl_set_bio(&ssl, &client, netSend, netRecv, nullptr); + + uint32_t t0 = millis(); + int ret; + do { + ret = mbedtls_ssl_handshake(&ssl); + } while (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE); + + if (ret != 0) { + char err[80]; + mbedtls_strerror(ret, err, sizeof(err)); + LOG_WARN("ETH TLS: handshake failed -0x%04x (%s) after %u ms", -ret, err, + (unsigned)(millis() - t0)); + return; + } + LOG_INFO("ETH TLS: handshake OK in %u ms, ciphersuite=%s", (unsigned)(millis() - t0), + mbedtls_ssl_get_ciphersuite(&ssl)); + Serial.flush(); + + MbedTlsStream stream(&ssl, &client); + handleApiClient(stream); + + mbedtls_ssl_close_notify(&ssl); + } +}; + +static EthTlsApiServerThread *tlsThread = nullptr; + +void initEthTlsApiServer() +{ + if (tlsThread) + return; + tlsThread = new EthTlsApiServerThread(); + LOG_INFO("ETH TLS: server worker scheduled (waits for cert ready)"); +} + +#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API diff --git a/src/mesh/eth/ethTlsApiServer.h b/src/mesh/eth/ethTlsApiServer.h new file mode 100644 index 00000000000..e7f55fa7cf3 --- /dev/null +++ b/src/mesh/eth/ethTlsApiServer.h @@ -0,0 +1,15 @@ +#pragma once + +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) + +// HTTPS server on TCP/443 that reuses the HTTP API handlers via the +// transport-agnostic IStreamReadWrite interface (see ethApiHandlers.h). The +// server waits for the cert produced by [[ethCert.h]] before binding the +// listening socket, so it is safe to call this from setup() / reconnectETH(). +// +// Idempotent: subsequent calls are no-ops. +void initEthTlsApiServer(); + +#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API From b2ae6de32c900c21417606d208ab9265ca976b34 Mon Sep 17 00:00:00 2001 From: CValdesS Date: Thu, 28 May 2026 04:37:58 +0200 Subject: [PATCH 08/11] fix(eth-tls): add KeyUsage + EKU serverAuth and cap TLS 1.2 for browser compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two browser-compat fixes that surfaced in Firefox validation: 1. Cert v1 only had Basic Constraints + SAN. NSS / Firefox refuse to treat a cert as a TLS server cert without an Extended Key Usage extension naming id-kp-serverAuth since 2023 — the error surfaces as a non-overridable 'Secure Connection Failed' with no 'Accept the Risk' path. Add KeyUsage(digitalSignature + keyEncipherment, critical) and ExtendedKeyUsage(serverAuth, critical). Bump cert/key/ip file paths to '_v2' so live boards regenerate on next start instead of loading a v1 cert that the browser silently refuses. 2. pico-sdk mbedtls defines MBEDTLS_SSL_PROTO_TLS1_3 in its default config, but the server-side 1.3 plumbing in this vendored build is incomplete: Firefox and openssl-3.5's s_client default to 1.3 and the handshake dies 4 ms in with MBEDTLS_ERR_ERROR_GENERIC_ERROR (-0x0001). curl/SChannel happened to default to 1.2 so it masked the issue earlier. Cap min/max_tls_version to TLS 1.2 — clients downgrade transparently and we keep the ECDHE-ECDSA + AES-GCM / CHACHA20-POLY1305 suites that already work end-to-end. Validated on wiznet 192.168.1.143: - cert v2 dumps with the 4 extensions visible (openssl x509 -text): BasicConstraints CA:FALSE, KeyUsage(critical) digitalSignature + keyEncipherment, ExtendedKeyUsage(critical) serverAuth, SAN IP. - openssl s_client (no version flag): downgrades to TLS 1.2, handshake completes, verify_return=18 (self-signed, expected). - Firefox: warning self-signed -> Advanced -> Accept Risk -> handshake OK in 632 ms with CHACHA20-POLY1305, request reaches handleApiClient and returns the protobuf body. --- src/mesh/eth/ethCert.cpp | 35 +++++++++++++++++++++++++++++--- src/mesh/eth/ethTlsApiServer.cpp | 11 ++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/mesh/eth/ethCert.cpp b/src/mesh/eth/ethCert.cpp index 594d96a1504..cea6994dec5 100644 --- a/src/mesh/eth/ethCert.cpp +++ b/src/mesh/eth/ethCert.cpp @@ -14,6 +14,7 @@ #include #endif +#include #include #include #include @@ -22,9 +23,12 @@ #include -static constexpr const char *CERT_PATH = "/eth_cert.der"; -static constexpr const char *KEY_PATH = "/eth_key.der"; -static constexpr const char *IP_PATH = "/eth_cert_ip.txt"; +// v2: cert layout now includes KeyUsage + ExtendedKeyUsage(serverAuth). +// Bumping the file names invalidates v1 caches (without EKU NSS / Firefox +// rejects the cert with a non-overridable "Secure Connection Failed"). +static constexpr const char *CERT_PATH = "/eth_cert_v2.der"; +static constexpr const char *KEY_PATH = "/eth_key_v2.der"; +static constexpr const char *IP_PATH = "/eth_cert_ip_v2.txt"; // Random callback for mbedtls — sources entropy from the RP2350 ROSC TRNG via // pico-sdk get_rand_64(). Used directly as f_rng in mbedtls calls so we don't @@ -167,6 +171,31 @@ static bool generateCert(IPAddress ip, EthCertMaterial &out) break; } + // KeyUsage: digitalSignature lets the cert sign TLS handshake + // messages (ECDHE-ECDSA key exchange). keyEncipherment is required + // by NSS/Firefox even though TLS 1.2 ECDHE doesn't actually use it. + ret = mbedtls_x509write_crt_set_key_usage( + &crt, MBEDTLS_X509_KU_DIGITAL_SIGNATURE | MBEDTLS_X509_KU_KEY_ENCIPHERMENT); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_key_usage failed -0x%04x", -ret); + break; + } + + // ExtendedKeyUsage: serverAuth. NSS / Firefox refuse to treat a cert + // as a TLS server cert without this extension since 2023 — the error + // surfaces as a non-overridable "Secure Connection Failed" with no + // "Accept the Risk" path. + mbedtls_asn1_sequence ekuSeq; + ekuSeq.buf.tag = MBEDTLS_ASN1_OID; + ekuSeq.buf.p = (unsigned char *)MBEDTLS_OID_SERVER_AUTH; + ekuSeq.buf.len = MBEDTLS_OID_SIZE(MBEDTLS_OID_SERVER_AUTH); + ekuSeq.next = nullptr; + ret = mbedtls_x509write_crt_set_ext_key_usage(&crt, &ekuSeq); + if (ret != 0) { + LOG_ERROR("ETH CERT: set_ext_key_usage failed -0x%04x", -ret); + break; + } + // SAN extension: IP address (4 bytes). Browsers require SAN match, // CN alone is ignored since RFC 6125 / Chrome 58. LOG_INFO("ETH CERT: step 4/8 SAN(IP)"); diff --git a/src/mesh/eth/ethTlsApiServer.cpp b/src/mesh/eth/ethTlsApiServer.cpp index 68a10ca5440..28cb6dacd3d 100644 --- a/src/mesh/eth/ethTlsApiServer.cpp +++ b/src/mesh/eth/ethTlsApiServer.cpp @@ -240,6 +240,17 @@ class EthTlsApiServerThread : public concurrency::OSThread mbedtls_ssl_conf_rng(&sslConf, picoRand, nullptr); mbedtls_ssl_conf_authmode(&sslConf, MBEDTLS_SSL_VERIFY_NONE); + // pico-sdk's mbedtls_config defines MBEDTLS_SSL_PROTO_TLS1_3, but the + // server-side TLS 1.3 plumbing in this vendored build is incomplete: + // Firefox / openssl s_client default to 1.3 and the handshake dies + // ~4 ms in with MBEDTLS_ERR_ERROR_GENERIC_ERROR (-0x0001). Capping + // max_version forces clients to negotiate down to 1.2 transparently, + // which is fully exercised (curl/SChannel: ECDHE-ECDSA + AES-GCM + // validated). Phase 3 can re-enable 1.3 once the missing handshake + // bits are sorted out. + mbedtls_ssl_conf_max_tls_version(&sslConf, MBEDTLS_SSL_VERSION_TLS1_2); + mbedtls_ssl_conf_min_tls_version(&sslConf, MBEDTLS_SSL_VERSION_TLS1_2); + LOG_INFO("ETH TLS: conf_own_cert"); Serial.flush(); ret = mbedtls_ssl_conf_own_cert(&sslConf, &certChain, &pkKey); From 5f70a4765c1163cea1e951d6c29eba6a732b5b4d Mon Sep 17 00:00:00 2001 From: CValdesS Date: Thu, 28 May 2026 04:51:56 +0200 Subject: [PATCH 09/11] =?UTF-8?q?perf(eth-api):=20HTTP/1.1=20keep-alive=20?= =?UTF-8?q?=E2=80=94=20one=20handshake=20per=20session,=20not=20per=20requ?= =?UTF-8?q?est?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change /fromradio used 'Connection: close' framing (HTTP/1.0 style with no Content-Length), forcing each poll to redo the full TLS handshake. client.meshtastic.org needs ~80 sequential /fromradio polls during initial sync (one per packet from the config-replay state machine: MyInfo, channels, every Config_*, every ModuleConfig_*, every NodeInfo), so the user-visible load time was ~50 s of pure handshake overhead (80 requests * ~625 ms ECDSA P-256 each). Three coordinated changes: 1. handleApiClient() now loops on the same connection until the peer closes or parseRequest hits its 3 s idle timeout. requestsServed counter keeps the 'bad/timeout request' debug log from firing on the natural idle close after a keep-alive sequence. 2. handleFromRadio() buffers all packets into a std::vector before writing, so it can emit a real Content-Length and 'Connection: keep-alive'. Buffer is dynamic — common 1-packet response only allocates ~256 B; ?all=true keeps the 64-packet cap which tops out around 16 KB. handleToRadio + sendPreflight switched to keep-alive too (they already had real Content-Length). sendError keeps close — errors are terminal. 3. ethTlsApiServer netRecv RECV_TIMEOUT_MS dropped from 10 s to 3 s so mbedtls_ssl_read can't outlast the handler's idle deadline (a quiet browser leaving the socket open would otherwise wedge the OSThread for 10 s past the natural close). Measured on wiznet 192.168.1.143 against client.meshtastic.org: - one TLS handshake (641 ms, CHACHA20-POLY1305) - ~80 requests pipelined over the same session - full config + 40 NodeInfos in ~6-8 s (vs ~50 s before) - per-request latency post-handshake: ~10-25 ms - curl -kv with two URLs: 'Reusing existing https: connection', server returns 'Connection: keep-alive' explicitly. --- src/mesh/eth/ethApiHandlers.cpp | 84 +++++++++++++++++++++----------- src/mesh/eth/ethTlsApiServer.cpp | 6 ++- 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/mesh/eth/ethApiHandlers.cpp b/src/mesh/eth/ethApiHandlers.cpp index a3d758d402e..c1f4608f3d3 100644 --- a/src/mesh/eth/ethApiHandlers.cpp +++ b/src/mesh/eth/ethApiHandlers.cpp @@ -5,7 +5,13 @@ #include "ethApiHandlers.h" #include "mesh/PhoneAPI.h" #include +#include +// HEADER_TIMEOUT_MS doubles as the keep-alive idle window: handleApiClient +// loops reading requests on the same connection, and bails out if no new +// request line arrives within this budget. 3 s is comfortable for browser +// pipelining without leaving the OSThread blocked too long after a client +// goes quiet. static constexpr uint32_t HEADER_TIMEOUT_MS = 3000; static constexpr uint32_t BODY_TIMEOUT_MS = 5000; static constexpr size_t MAX_LINE_LEN = 256; @@ -146,7 +152,7 @@ static void sendPreflight(IStreamReadWrite &client, const char *allowMethods) writeStatusLine(client, 204, "No Content"); writeCorsHeaders(client, allowMethods); client.print("Content-Length: 0\r\n"); - client.print("Connection: close\r\n\r\n"); + client.print("Connection: keep-alive\r\n\r\n"); client.flush(); } @@ -177,27 +183,37 @@ static void handleFromRadio(IStreamReadWrite &client, const Request &req) String allParam; bool drainAll = getQueryParam(req.query, "all", allParam) && allParam == "true"; - // We cannot pre-compute Content-Length without buffering everything, so use - // Connection: close (HTTP/1.0 framing). All Meshtastic clients tolerate this. - writeStatusLine(client, 200, "OK"); - client.print("Content-Type: application/x-protobuf\r\n"); - writeCorsHeaders(client, "GET, OPTIONS"); - client.print("Connection: close\r\n\r\n"); - + // Buffer all packets first so we can emit an accurate Content-Length and + // keep the connection alive. Phase 2 used Connection: close framing, which + // forced clients to redo the TLS handshake (~625 ms) for every single + // /fromradio poll — client.meshtastic.org needs dozens of those during + // initial sync, so the user-visible load time was 15-30 s of pure + // handshakes. With Content-Length + keep-alive a whole sync rides one + // handshake. Buffer is dynamic (std::vector) so the common 1-packet case + // only allocates ~256 B; the ?all=true 64-packet cap stays at ~16 KB. + std::vector body; uint8_t txBuf[MAX_TO_FROM_RADIO_SIZE]; - size_t totalBytes = 0; int packets = 0; do { size_t len = webAPI.getFromRadio(txBuf); if (len == 0) break; - client.write(txBuf, len); - totalBytes += len; + body.insert(body.end(), txBuf, txBuf + len); packets++; } while (drainAll && packets < 64); // safety cap so a misbehaving client can't loop forever + + writeStatusLine(client, 200, "OK"); + client.print("Content-Type: application/x-protobuf\r\n"); + writeCorsHeaders(client, "GET, OPTIONS"); + client.print("Content-Length: "); + client.print((unsigned)body.size()); + client.print("\r\n"); + client.print("Connection: keep-alive\r\n\r\n"); + if (!body.empty()) + client.write(body.data(), body.size()); client.flush(); LOG_DEBUG("ETH API: fromradio sent %d packet(s), %u bytes (all=%d)", packets, - (unsigned)totalBytes, (int)drainAll); + (unsigned)body.size(), (int)drainAll); } static void handleToRadio(IStreamReadWrite &client, const Request &req) @@ -247,7 +263,7 @@ static void handleToRadio(IStreamReadWrite &client, const Request &req) client.print("Content-Length: "); client.print((unsigned)got); client.print("\r\n"); - client.print("Connection: close\r\n\r\n"); + client.print("Connection: keep-alive\r\n\r\n"); client.write(buf, got); client.flush(); LOG_DEBUG("ETH API: toradio handled %u bytes", (unsigned)got); @@ -255,23 +271,35 @@ static void handleToRadio(IStreamReadWrite &client, const Request &req) void handleApiClient(IStreamReadWrite &client) { - const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; - Request req; - if (!parseRequest(client, req, deadline)) { - LOG_DEBUG("ETH API: bad/timeout request from %s", client.remoteIP().toString().c_str()); - return; - } + // HTTP/1.1 keep-alive loop. parseRequest returns false on idle timeout + // (3 s) or peer close, which is also our exit signal. Errors close the + // connection eagerly via sendError; normal responses use Content-Length + // framing + Connection: keep-alive so the loop can read the next request + // on the same TLS session. + int requestsServed = 0; + while (client.connected()) { + const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; + Request req; + if (!parseRequest(client, req, deadline)) { + if (requestsServed == 0) + LOG_DEBUG("ETH API: bad/timeout request from %s", + client.remoteIP().toString().c_str()); + return; + } - LOG_INFO("ETH API: %s %s%s%s (from %s)", req.method.c_str(), req.path.c_str(), - req.query.length() ? "?" : "", req.query.c_str(), - client.remoteIP().toString().c_str()); + LOG_INFO("ETH API: %s %s%s%s (from %s)", req.method.c_str(), req.path.c_str(), + req.query.length() ? "?" : "", req.query.c_str(), + client.remoteIP().toString().c_str()); - if (req.path == "/api/v1/fromradio") { - handleFromRadio(client, req); - } else if (req.path == "/api/v1/toradio") { - handleToRadio(client, req); - } else { - sendError(client, 404, "Not Found", "unknown endpoint"); + if (req.path == "/api/v1/fromradio") { + handleFromRadio(client, req); + } else if (req.path == "/api/v1/toradio") { + handleToRadio(client, req); + } else { + sendError(client, 404, "Not Found", "unknown endpoint"); + return; // errors are terminal — Connection: close framing + } + requestsServed++; } } diff --git a/src/mesh/eth/ethTlsApiServer.cpp b/src/mesh/eth/ethTlsApiServer.cpp index 28cb6dacd3d..a4e8f2847d3 100644 --- a/src/mesh/eth/ethTlsApiServer.cpp +++ b/src/mesh/eth/ethTlsApiServer.cpp @@ -31,7 +31,11 @@ static constexpr uint32_t MEDIUM_THRESHOLD_MS = 30000; static constexpr int32_t ACTIVE_INTERVAL_MS = 20; static constexpr int32_t MEDIUM_INTERVAL_MS = 100; static constexpr int32_t IDLE_INTERVAL_MS = 500; -static constexpr uint32_t RECV_TIMEOUT_MS = 10000; +// Matches the keep-alive idle window in ethApiHandlers — if the handler +// loop calls read() and netRecv blocked for 10 s, the 3 s idle deadline +// inside parseRequest would be irrelevant and the OSThread would stay stuck +// long after a quiet browser closed its end of the TCP socket. +static constexpr uint32_t RECV_TIMEOUT_MS = 3000; // Reuse the picoRand callback semantics from ethCert.cpp. Local copy so we // don't have to expose it through a header; the cost is two trivial functions. From 8f889e9014b39bd5d9939cfcb279f0da1d789581 Mon Sep 17 00:00:00 2001 From: CValdesS Date: Thu, 28 May 2026 05:54:42 +0200 Subject: [PATCH 10/11] =?UTF-8?q?fix(eth-tls):=20watchdog=20+=20Chrome=20c?= =?UTF-8?q?ompat=20=E2=80=94=20handshake=20stability=20across=20browsers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 keep-alive shipped a working Firefox flow but client.meshtastic.org loops + Chrome 'Test Connection' both rebooted the board. Four distinct issues; collectively they kept the OSThread inside serveClient() too long or spin-looping without yielding, and pico-sdk mbedtls' TLS 1.3 code path choked on Chrome's modern ClientHello. 1. Watchdog reset during keep-alive idle. Once a client drained the replay queue and entered 3 s poll mode, the OSThread sat inside netRecv()'s busy-wait waiting for the next request. Two consecutive 3 s waits plus prior handler time crossed the 8 s RP2350 hardware watchdog. Pet the watchdog inside netRecv()'s poll loop (every 2 ms) so a quiet client can never starve the watchdog. Same fix in the ethApiHandlers per-request yield path. 2. Cap session at 64 requests + yield() between. Defense-in-depth: a pathological client can't monopolize serveClient indefinitely; after the cap it just re-handshakes (~625 ms), still vastly cheaper than the per-request handshake we had before keep-alive. 3. TLS 1.3 code compiled out of mbedtls entirely (#undef MBEDTLS_SSL_PROTO_TLS1_3 in mbedtls_user_config). Capping max_tls_version=TLS1_2 at runtime is enough for Firefox / openssl (they downgrade cleanly), but Chrome's ClientHello carries TLS 1.3 extensions — post-quantum key shares, Encrypted ClientHello, etc. — that the vendored mbedtls 1.3 parser crashes on before the downgrade decision happens. Removing the 1.3 sources sidesteps the parser; ServerHello just announces TLS 1.2 and Chrome accepts. 4. netSend infinite WANT_WRITE spin. When W5500's TX buffer momentarily filled mid-handshake (Chrome draining slower than Firefox during ServerKeyExchange), EthernetClient::write() returned 0, our netSend returned MBEDTLS_ERR_SSL_WANT_WRITE without delay, mbedtls retried immediately, repeat at ~180k iter/sec until ... well, until the board's other threads got nothing done. Log signature: ret=-0x6880 tight-looping in the handshake iter trace. Rewrite netSend to block with delay(2) + watchdog_update() and a 3 s timeout — same shape as netRecv. Return MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY on disconnect (was incorrectly returning WANT_WRITE). Also added granular per-iter handshake logging gated on first 20 iters + every 50th after that, so any future regression localizes itself in COM9 without RTT JLink. Validated on wiznet 192.168.1.143: - Firefox: client.meshtastic.org full sync + idle poll stable (no reset during the previously-crashing 'replay drain complete' phase) - Chrome: 'Test Connection' accepts the cert prompt and connects - Edge: same as Chrome - openssl s_client default + tls1_2 forced: both negotiate TLS 1.2 with ECDHE-ECDSA + AES-GCM / CHACHA20-POLY1305, verify=18 (self- signed, expected) --- src/mbedtls_user_config.h | 14 ++++++++++ src/mesh/eth/ethApiHandlers.cpp | 34 ++++++++++++++++++++++- src/mesh/eth/ethTlsApiServer.cpp | 47 ++++++++++++++++++++++++++++---- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/mbedtls_user_config.h b/src/mbedtls_user_config.h index 1a1d6e14831..5fae49a2352 100644 --- a/src/mbedtls_user_config.h +++ b/src/mbedtls_user_config.h @@ -37,3 +37,17 @@ // PSA persistent storage: requires POSIX fopen. Unused. #undef MBEDTLS_PSA_ITS_FILE_C #undef MBEDTLS_PSA_CRYPTO_STORAGE_C + +// Compile out TLS 1.3 entirely. pico-sdk's mbedtls_config defines +// MBEDTLS_SSL_PROTO_TLS1_3 but the server-side 1.3 plumbing in this +// vendored build is fragile: capping max_tls_version=TLS1_2 at runtime +// is enough for Firefox / openssl-3 (they downgrade cleanly), but +// Chrome's ClientHello carries TLS 1.3 extensions (post-quantum key +// shares, Encrypted ClientHello, etc.) that mbedtls tries to *parse* +// during the initial ClientHello processing before deciding to +// downgrade — and that parse crashes the board (no handshake state log +// ever fires, the crash is inside the first mbedtls_ssl_handshake() +// call). Removing the 1.3 code from the build sidesteps the parsers +// entirely; mbedtls will tell Chrome "TLS 1.2 only" via the +// ServerHello and ignore the 1.3 extensions. +#undef MBEDTLS_SSL_PROTO_TLS1_3 diff --git a/src/mesh/eth/ethApiHandlers.cpp b/src/mesh/eth/ethApiHandlers.cpp index c1f4608f3d3..cd7cb8ab370 100644 --- a/src/mesh/eth/ethApiHandlers.cpp +++ b/src/mesh/eth/ethApiHandlers.cpp @@ -7,6 +7,10 @@ #include #include +#ifdef ARCH_RP2040 +#include +#endif + // HEADER_TIMEOUT_MS doubles as the keep-alive idle window: handleApiClient // loops reading requests on the same connection, and bails out if no new // request line arrives within this budget. 3 s is comfortable for browser @@ -276,8 +280,23 @@ void handleApiClient(IStreamReadWrite &client) // connection eagerly via sendError; normal responses use Content-Length // framing + Connection: keep-alive so the loop can read the next request // on the same TLS session. + // + // MAX_REQUESTS_PER_SESSION caps the loop because while we're inside it + // the parent OSThread is not returning to mainController, and the + // RP2350 hardware watchdog (8 s default in arduino-pico) only gets + // pet by the main loop. A client.meshtastic.org sync produces ~80 + // back-to-back requests over a single TLS session — well past the + // watchdog deadline. yield() between requests lets the rest of core0 + // (Periodic ticks, NTP, MQTT, LoRa packet pump) run + pets the + // watchdog; the cap puts a hard ceiling so a chatty client can never + // monopolize the server indefinitely. After the cap the client just + // re-handshakes once and continues, which is cheap (one ECDSA cost + // every 64 requests is amortized well below the per-request + // handshake we had before keep-alive). + static constexpr int MAX_REQUESTS_PER_SESSION = 64; + int requestsServed = 0; - while (client.connected()) { + while (client.connected() && requestsServed < MAX_REQUESTS_PER_SESSION) { const uint32_t deadline = millis() + HEADER_TIMEOUT_MS; Request req; if (!parseRequest(client, req, deadline)) { @@ -300,7 +319,20 @@ void handleApiClient(IStreamReadWrite &client) return; // errors are terminal — Connection: close framing } requestsServed++; +#ifdef ARCH_RP2040 + // yield() ONLY cedes to the OSThread scheduler; it does not call + // rp2040Loop() where watchdog_update() lives. While we sit inside + // serveClient() looping over keep-alive requests, main loop() is + // not running and the 8 s hardware watchdog WILL fire. Pet it + // explicitly here. + watchdog_update(); +#endif + yield(); } + + if (requestsServed >= MAX_REQUESTS_PER_SESSION) + LOG_DEBUG("ETH API: session capped at %d requests (will redo handshake on next batch)", + MAX_REQUESTS_PER_SESSION); } #endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/eth/ethTlsApiServer.cpp b/src/mesh/eth/ethTlsApiServer.cpp index a4e8f2847d3..1b8dbd0d28d 100644 --- a/src/mesh/eth/ethTlsApiServer.cpp +++ b/src/mesh/eth/ethTlsApiServer.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #ifndef ETH_TLS_API_PORT @@ -67,11 +68,26 @@ static int netSend(void *ctx, const unsigned char *buf, size_t len) { auto *client = static_cast(ctx); if (!client->connected()) - return MBEDTLS_ERR_SSL_WANT_WRITE; - size_t w = client->write(buf, len); - if (w == 0) - return MBEDTLS_ERR_SSL_WANT_WRITE; - return (int)w; + return MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY; + + // Block-with-yield until the W5500 TX buffer can absorb the chunk. + // Returning WANT_WRITE without delay made mbedtls_ssl_handshake() spin + // at ~180k iter/s when Chrome was slow to drain the socket during the + // ECDHE-ECDSA ServerKeyExchange — the original code logged exactly that + // signature (ret=-0x6880 / WANT_WRITE) tight-looping forever. Firefox + // happened to read fast enough that the buffer never filled. + uint32_t t0 = millis(); + while (true) { + if (!client->connected()) + return MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY; + size_t w = client->write(buf, len); + if (w > 0) + return (int)w; + if (millis() - t0 > RECV_TIMEOUT_MS) + return MBEDTLS_ERR_SSL_TIMEOUT; + watchdog_update(); + delay(2); + } } static int netRecv(void *ctx, unsigned char *buf, size_t len) @@ -81,12 +97,20 @@ static int netRecv(void *ctx, unsigned char *buf, size_t len) // exceed the per-recv budget. Pure non-blocking (return WANT_READ) would // require mbedtls_ssl_handshake to be driven from the runOnce dispatcher // — overkill for the Phase 2.2 skeleton with a single in-flight session. + // + // Pet the 8 s hardware watchdog from inside the poll loop. We sit here + // for up to RECV_TIMEOUT_MS waiting for the next keep-alive request, and + // a quiet client can string two such waits back-to-back (6 s) plus the + // earlier handshake/handler time — easily past the watchdog deadline. + // The main loop()'s watchdog_update() never runs while the OSThread is + // inside serveClient(), so it has to be done here. uint32_t t0 = millis(); while (client->available() == 0) { if (!client->connected()) return MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY; if (millis() - t0 > RECV_TIMEOUT_MS) return MBEDTLS_ERR_SSL_TIMEOUT; + watchdog_update(); delay(2); } int n = client->read(buf, len); @@ -289,8 +313,21 @@ class EthTlsApiServerThread : public concurrency::OSThread uint32_t t0 = millis(); int ret; + int iters = 0; do { ret = mbedtls_ssl_handshake(&ssl); + iters++; + // Log every iteration during initial handshake debugging — Chrome + // crashes inside the first mbedtls_ssl_handshake() call (no + // log appears at all), so the granular per-iter trace is what we + // need. The 'state' getter isn't in this mbedtls header set, so + // ret + iter is the best we get without enabling MBEDTLS_DEBUG_C. + if (iters <= 20 || iters % 50 == 0) { + LOG_DEBUG("ETH TLS: handshake iter=%d ret=-0x%04x", iters, + ret < 0 ? -ret : 0); + Serial.flush(); + } + watchdog_update(); } while (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE); if (ret != 0) { From 43999558371a9dd9143d9cd1a2282dfedecd07db Mon Sep 17 00:00:00 2001 From: CValdesS Date: Thu, 28 May 2026 06:06:18 +0200 Subject: [PATCH 11/11] chore(eth-tls): drop verbose debug logs from cert + TLS init paths The cert pipeline + TLS context init had step-by-step LOG_INFOs and ubiquitous Serial.flush() that were essential while diagnosing the Phase 2.1-bis stack overflow, the Chrome handshake crash, and the keep-alive watchdog reset. Once those bugs were fixed the logs just clutter COM9 on every boot. Kept on hand: - cert: 'loaded from FS', 'generating ...', 'generated N B in T ms', 'persisted to LittleFS', plus all LOG_ERROR / LOG_WARN paths - tls: 'server listening on TCP port 443', 'client connected from', 'handshake OK in N ms ciphersuite=', 'handshake failed -0xXXXX (...)', plus all init LOG_ERRORs Dropped: - cert: 'step 1/8 pk_setup' through 'step 8/8 copy key DER', 'thread woke', 'pipeline OK' (now silent on success) - tls: 'cert is ready, initializing', 'parsing cert chain', 'parsing key', 'ssl_config_defaults', 'conf_own_cert', 'ssl_setup', 'server worker scheduled', and the per-iter handshake trace - obsolete 'Optional mbedtls debug bridge' commented-out stub - all Serial.flush() that were added defensively for the debug-the-crash phase Bin shrinks ~40 KB (logs + format strings). Validated on wiznet 192.168.1.143: HTTP 200 round-trip works post-flash, no regression. --- src/mesh/eth/ethCert.cpp | 32 ++------------------- src/mesh/eth/ethTlsApiServer.cpp | 48 ++++---------------------------- 2 files changed, 7 insertions(+), 73 deletions(-) diff --git a/src/mesh/eth/ethCert.cpp b/src/mesh/eth/ethCert.cpp index cea6994dec5..9769fea00a7 100644 --- a/src/mesh/eth/ethCert.cpp +++ b/src/mesh/eth/ethCert.cpp @@ -116,15 +116,11 @@ static bool generateCert(IPAddress ip, EthCertMaterial &out) do { // 1. ECDSA P-256 keypair - LOG_INFO("ETH CERT: step 1/8 pk_setup"); - Serial.flush(); ret = mbedtls_pk_setup(&pk, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)); if (ret != 0) { LOG_ERROR("ETH CERT: pk_setup failed -0x%04x", -ret); break; } - LOG_INFO("ETH CERT: step 2/8 ecp_gen_key (P-256)"); - Serial.flush(); ret = mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(pk), picoRand, nullptr); if (ret != 0) { LOG_ERROR("ETH CERT: ecp_gen_key failed -0x%04x", -ret); @@ -132,8 +128,6 @@ static bool generateCert(IPAddress ip, EthCertMaterial &out) } // 2. Cert fields - LOG_INFO("ETH CERT: step 3/8 subject/issuer/serial/validity/constraints"); - Serial.flush(); String subjectName = "CN=" + ip.toString() + ",O=Meshtastic,C=US"; ret = mbedtls_x509write_crt_set_subject_name(&crt, subjectName.c_str()); if (ret != 0) { @@ -198,8 +192,6 @@ static bool generateCert(IPAddress ip, EthCertMaterial &out) // SAN extension: IP address (4 bytes). Browsers require SAN match, // CN alone is ignored since RFC 6125 / Chrome 58. - LOG_INFO("ETH CERT: step 4/8 SAN(IP)"); - Serial.flush(); unsigned char ipBytes[4] = {ip[0], ip[1], ip[2], ip[3]}; mbedtls_x509_san_list san; san.next = nullptr; @@ -215,29 +207,21 @@ static bool generateCert(IPAddress ip, EthCertMaterial &out) // 3. Serialize cert + key to DER. mbedtls writes DER at the END of // the buffer; ret = length, with the bytes living at (buf + size - len). - LOG_INFO("ETH CERT: step 5/8 x509write_crt_der (sign)"); - Serial.flush(); ret = mbedtls_x509write_crt_der(&crt, certBuf.data(), certBuf.size(), picoRand, nullptr); if (ret < 0) { LOG_ERROR("ETH CERT: x509write_crt_der failed -0x%04x", -ret); break; } size_t certLen = (size_t)ret; - LOG_INFO("ETH CERT: step 6/8 copy cert DER (%u B)", (unsigned)certLen); - Serial.flush(); out.certDer.assign(certBuf.data() + certBuf.size() - certLen, certBuf.data() + certBuf.size()); - LOG_INFO("ETH CERT: step 7/8 pk_write_key_der"); - Serial.flush(); ret = mbedtls_pk_write_key_der(&pk, keyBuf.data(), keyBuf.size()); if (ret < 0) { LOG_ERROR("ETH CERT: pk_write_key_der failed -0x%04x", -ret); break; } size_t keyLen = (size_t)ret; - LOG_INFO("ETH CERT: step 8/8 copy key DER (%u B)", (unsigned)keyLen); - Serial.flush(); out.keyDer.assign(keyBuf.data() + keyBuf.size() - keyLen, keyBuf.data() + keyBuf.size()); @@ -316,24 +300,12 @@ class EthCertThread : public concurrency::OSThread return 250; } - LOG_INFO("ETH CERT: thread woke, IP=%s, starting cert pipeline", ip.toString().c_str()); - Serial.flush(); - - uint32_t t0 = millis(); bool ok = ensureCertForIp(ip, material_); - uint32_t dt = millis() - t0; - - if (ok) { - LOG_INFO("ETH CERT: pipeline OK in %u ms (%u B cert + %u B key cached)", - (unsigned)dt, (unsigned)material_.certDer.size(), - (unsigned)material_.keyDer.size()); - } else { - LOG_ERROR("ETH CERT: pipeline FAILED in %u ms — TLS server will not start", (unsigned)dt); + if (!ok) { + LOG_ERROR("ETH CERT: pipeline FAILED — TLS server will not start"); material_.certDer.clear(); material_.keyDer.clear(); } - Serial.flush(); - ready_ = true; return INT32_MAX; } diff --git a/src/mesh/eth/ethTlsApiServer.cpp b/src/mesh/eth/ethTlsApiServer.cpp index 1b8dbd0d28d..ba7cfc6b6f4 100644 --- a/src/mesh/eth/ethTlsApiServer.cpp +++ b/src/mesh/eth/ethTlsApiServer.cpp @@ -177,11 +177,6 @@ class MbedTlsStream : public IStreamReadWrite EthernetClient *client_; }; -// Optional mbedtls debug bridge — kept disabled by default to keep COM9 -// uncluttered. Flip MBEDTLS_DEBUG_C in mbedtls_config and call -// mbedtls_ssl_conf_dbg(&sslConf, mbedtlsDebug, nullptr) to enable. -// static void mbedtlsDebug(void *ctx, int level, const char *file, int line, const char *str) { ... } - class EthTlsApiServerThread : public concurrency::OSThread { public: @@ -223,9 +218,6 @@ class EthTlsApiServerThread : public concurrency::OSThread bool initTlsContext() { - LOG_INFO("ETH TLS: cert is ready, initializing TLS context"); - Serial.flush(); - const EthCertMaterial &cert = getEthCert(); if (cert.certDer.empty() || cert.keyDer.empty()) { LOG_ERROR("ETH TLS: cert material is empty, refusing to start TLS server"); @@ -239,16 +231,12 @@ class EthTlsApiServerThread : public concurrency::OSThread int ret; - LOG_INFO("ETH TLS: parsing cert chain (%u B DER)", (unsigned)cert.certDer.size()); - Serial.flush(); ret = mbedtls_x509_crt_parse_der(&certChain, cert.certDer.data(), cert.certDer.size()); if (ret != 0) { LOG_ERROR("ETH TLS: x509_crt_parse_der failed -0x%04x", -ret); return false; } - LOG_INFO("ETH TLS: parsing key (%u B DER)", (unsigned)cert.keyDer.size()); - Serial.flush(); ret = mbedtls_pk_parse_key(&pkKey, cert.keyDer.data(), cert.keyDer.size(), nullptr, 0, picoRand, nullptr); if (ret != 0) { @@ -256,8 +244,6 @@ class EthTlsApiServerThread : public concurrency::OSThread return false; } - LOG_INFO("ETH TLS: ssl_config_defaults (server, TLS, default preset)"); - Serial.flush(); ret = mbedtls_ssl_config_defaults(&sslConf, MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT); if (ret != 0) { @@ -268,27 +254,19 @@ class EthTlsApiServerThread : public concurrency::OSThread mbedtls_ssl_conf_rng(&sslConf, picoRand, nullptr); mbedtls_ssl_conf_authmode(&sslConf, MBEDTLS_SSL_VERIFY_NONE); - // pico-sdk's mbedtls_config defines MBEDTLS_SSL_PROTO_TLS1_3, but the - // server-side TLS 1.3 plumbing in this vendored build is incomplete: - // Firefox / openssl s_client default to 1.3 and the handshake dies - // ~4 ms in with MBEDTLS_ERR_ERROR_GENERIC_ERROR (-0x0001). Capping - // max_version forces clients to negotiate down to 1.2 transparently, - // which is fully exercised (curl/SChannel: ECDHE-ECDSA + AES-GCM - // validated). Phase 3 can re-enable 1.3 once the missing handshake - // bits are sorted out. + // TLS 1.3 is compiled out via mbedtls_user_config.h (it would crash + // on Chrome's ClientHello extensions). These calls pin runtime to + // 1.2 too as a defense-in-depth: if a future user config flip + // re-enables 1.3 code, the config layer still won't negotiate it. mbedtls_ssl_conf_max_tls_version(&sslConf, MBEDTLS_SSL_VERSION_TLS1_2); mbedtls_ssl_conf_min_tls_version(&sslConf, MBEDTLS_SSL_VERSION_TLS1_2); - LOG_INFO("ETH TLS: conf_own_cert"); - Serial.flush(); ret = mbedtls_ssl_conf_own_cert(&sslConf, &certChain, &pkKey); if (ret != 0) { LOG_ERROR("ETH TLS: conf_own_cert failed -0x%04x", -ret); return false; } - LOG_INFO("ETH TLS: ssl_setup"); - Serial.flush(); ret = mbedtls_ssl_setup(&ssl, &sslConf); if (ret != 0) { LOG_ERROR("ETH TLS: ssl_setup failed -0x%04x", -ret); @@ -297,36 +275,21 @@ class EthTlsApiServerThread : public concurrency::OSThread tlsServer = new EthernetServer(ETH_TLS_API_PORT); tlsServer->begin(); - LOG_INFO("ETH TLS: server listening on TCP port %d (phase 2.2 skeleton)", - ETH_TLS_API_PORT); - Serial.flush(); + LOG_INFO("ETH TLS: server listening on TCP port %d", ETH_TLS_API_PORT); return true; } void serveClient(EthernetClient &client) { LOG_INFO("ETH TLS: client connected from %s", client.remoteIP().toString().c_str()); - Serial.flush(); mbedtls_ssl_session_reset(&ssl); mbedtls_ssl_set_bio(&ssl, &client, netSend, netRecv, nullptr); uint32_t t0 = millis(); int ret; - int iters = 0; do { ret = mbedtls_ssl_handshake(&ssl); - iters++; - // Log every iteration during initial handshake debugging — Chrome - // crashes inside the first mbedtls_ssl_handshake() call (no - // log appears at all), so the granular per-iter trace is what we - // need. The 'state' getter isn't in this mbedtls header set, so - // ret + iter is the best we get without enabling MBEDTLS_DEBUG_C. - if (iters <= 20 || iters % 50 == 0) { - LOG_DEBUG("ETH TLS: handshake iter=%d ret=-0x%04x", iters, - ret < 0 ? -ret : 0); - Serial.flush(); - } watchdog_update(); } while (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE); @@ -339,7 +302,6 @@ class EthTlsApiServerThread : public concurrency::OSThread } LOG_INFO("ETH TLS: handshake OK in %u ms, ciphersuite=%s", (unsigned)(millis() - t0), mbedtls_ssl_get_ciphersuite(&ssl)); - Serial.flush(); MbedTlsStream stream(&ssl, &client); handleApiClient(stream);