feat(eth): port mesh/http to RP2350 + W5500 (HTTP/80 + HTTPS/443) (#10573)

* add watchdog to rp2xx0

* feat(eth-api): phase 0 skeleton — HTTP API server on TCP/80 (503 only)

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

* feat(eth-api): phase 1 — implement /api/v1/{fromradio,toradio} with PhoneAPI

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.

* fix(eth-api): move accept loop to dedicated OSThread (20ms tick)

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.

* refactor(eth-api): extract handlers to shared module via IStreamReadWrite

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%).

* feat(eth-tls): ECDSA P-256 self-signed cert generation for HTTPS API

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.

* feat(eth-tls-api): phase 2.2 — HTTPS server on TCP/443 reusing handlers

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.

* fix(eth-tls): add KeyUsage + EKU serverAuth and cap TLS 1.2 for browser compat

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.

* perf(eth-api): HTTP/1.1 keep-alive — one handshake per session, not per request

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.

* fix(eth-tls): watchdog + Chrome compat — handshake stability across browsers

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)

* 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.

* fix(eth): harden API/TLS build guards (review #10573)

- ethApiHandlers.{h,cpp}: gate on (HAS_ETHERNET_API || HAS_ETHERNET_TLS_API)
  so the shared handleApiClient/IStreamReadWrite still compile for an
  HTTPS-only variant (the TLS server depends on them).
- ethTlsApiServer.{cpp,h}, ethCert.{cpp,h}: require ARCH_RP2040 (they use Pico
  SDK get_rand_64()/watchdog_update()); the ethClient.cpp TLS include + call
  site are tightened to match, so enabling the flag on a non-RP2040 Ethernet
  target is a clean no-op instead of a cryptic build break.
- clang-format (16) the touched eth files to satisfy Trunk Check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(eth): address review — listener/cert recovery + keep-alive framing

- Rebind HTTP/80 and HTTPS/443 after a W5500 chip reset. reconnectETH() now
  tears the API/TLS servers down (deInitEthApiServer/deInitEthTlsApiServer) so
  the restart path recreates them; the singleton guards previously left both
  bound to dead sockets until reboot. The workers are kept (only the listener
  is dropped) so nothing is deleted from another thread's runOnce.

- Stop the keep-alive loop after a handler error. /fromradio and /toradio
  return whether the connection may stay open; a 400/405/408 response
  (Connection: close) now ends the loop so unread body bytes can't be parsed
  as the next request.

- Validate the cached cert/key before trusting it (parse both DERs), and clear
  the IP commit-marker before rewriting the pair and write it last, so a reset
  mid-persist regenerates instead of handing the TLS server a truncated or
  mismatched pair (which would hard-disable HTTPS).

- Track a cert generation counter so the TLS server reloads and rebinds when a
  DHCP lease change regenerates the cert for a new IP (the SAN must follow, or
  browsers reject the new address). The cert worker is no longer one-shot.

- Silence the SCons F821/E402 lint on add_mbedtls_sources.py to match the other
  extra_scripts.

Validated on a W5500 board: a forced chip reset rebinds 80 and 443 without an
MCU reboot; a truncated cached cert regenerates and HTTPS recovers; a simulated
lease change reissues the cert with the new IP in the SAN.

* fix(eth): clear cert readiness on regen failure + verify cached pair

Follow-up to the review:
- Reset ready_/certIp_ when ensureCertForIp() fails, so isReady() no longer
  reports true with empty material — otherwise a later TLS reload fails
  initTlsContext() and stays disabled instead of retrying on the next poll.
- certKeyParse() now also checks the cached cert and key form a matching pair
  (mbedtls_pk_check_pair), rejecting an independently-parseable but mismatched
  cert/key (e.g. if the IP commit-marker survived a failed clear).

Validated on a W5500 board: a valid cached pair still loads from FS, and a
regenerated cert reloads cleanly.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Valdes
2026-07-01 05:51:14 -05:00
committed by GitHub
co-authored by GitHub Ben Meadors Claude Opus 4.8
parent cfc2e457a9
commit e64d20548c
13 changed files with 1470 additions and 1 deletions
+49
View File
@@ -0,0 +1,49 @@
# 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.
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): Import/env/Return are SCons-injected globals
# trunk-ignore-all(ruff/E402)
# trunk-ignore-all(flake8/E402): stdlib imports must follow Import("env")
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>"],
)
+3
View File
@@ -1325,6 +1325,9 @@ void loop()
#endif
#ifdef ARCH_NRF54L15
nrf54l15Loop();
#endif
#ifdef ARCH_RP2040
rp2040Loop();
#endif
power->powerCommandsCheck();
+1 -1
View File
@@ -113,7 +113,7 @@ extern bool runASAP;
extern bool pauseBluetoothLogging;
void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), clearBonds(), enterDfuMode();
void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), rp2040Loop(), clearBonds(), enterDfuMode();
#ifdef ARCH_ESP32
void esp32ReleaseBluetoothMemoryIfUnused();
#endif
+53
View File
@@ -0,0 +1,53 @@
// 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 <sys/socket.h>.
//
// 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
// 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
+346
View File
@@ -0,0 +1,346 @@
#include "configuration.h"
#if HAS_ETHERNET && (defined(HAS_ETHERNET_API) || defined(HAS_ETHERNET_TLS_API))
#include "ethApiHandlers.h"
#include "mesh/PhoneAPI.h"
#include <Arduino.h>
#include <vector>
#ifdef ARCH_RP2040
#include <hardware/watchdog.h>
#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
// 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;
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: keep-alive\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();
}
// Returns true if the connection may stay open (keep-alive), false if the
// handler emitted an error response with Connection: close framing.
static bool handleFromRadio(IStreamReadWrite &client, const Request &req)
{
if (req.method == "OPTIONS") {
sendPreflight(client, "GET, OPTIONS");
return true;
}
if (req.method != "GET") {
sendError(client, 405, "Method Not Allowed", "method must be GET or OPTIONS");
return false;
}
String allParam;
bool drainAll = getQueryParam(req.query, "all", allParam) && allParam == "true";
// 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<uint8_t> body;
uint8_t txBuf[MAX_TO_FROM_RADIO_SIZE];
int packets = 0;
do {
size_t len = webAPI.getFromRadio(txBuf);
if (len == 0)
break;
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)body.size(), (int)drainAll);
return true;
}
// Returns true if the connection may stay open (keep-alive), false if the
// handler emitted an error response with Connection: close framing.
static bool handleToRadio(IStreamReadWrite &client, const Request &req)
{
if (req.method == "OPTIONS") {
sendPreflight(client, "PUT, OPTIONS");
return true;
}
if (req.method != "PUT") {
sendError(client, 405, "Method Not Allowed", "method must be PUT or OPTIONS");
return false;
}
if (req.contentLength <= 0 || (size_t)req.contentLength > MAX_TO_FROM_RADIO_SIZE) {
sendError(client, 400, "Bad Request", "missing or oversized Content-Length");
return false;
}
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 false;
}
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: keep-alive\r\n\r\n");
client.write(buf, got);
client.flush();
LOG_DEBUG("ETH API: toradio handled %u bytes", (unsigned)got);
return true;
}
void handleApiClient(IStreamReadWrite &client)
{
// 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.
//
// 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() && requestsServed < MAX_REQUESTS_PER_SESSION) {
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());
bool keepAlive = true;
if (req.path == "/api/v1/fromradio") {
keepAlive = handleFromRadio(client, req);
} else if (req.path == "/api/v1/toradio") {
keepAlive = handleToRadio(client, req);
} else {
sendError(client, 404, "Not Found", "unknown endpoint");
return; // errors are terminal — Connection: close framing
}
// A handler that emitted an error advertised Connection: close. Stop the
// keep-alive loop so any unread/leftover body bytes (e.g. after a 408
// short read or a 400 oversized PUT) aren't misparsed as the next request.
if (!keepAlive)
return;
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 || HAS_ETHERNET_TLS_API)
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "configuration.h"
#if HAS_ETHERNET && (defined(HAS_ETHERNET_API) || defined(HAS_ETHERNET_TLS_API))
#include <Arduino.h>
#include <IPAddress.h>
#include <Print.h>
// 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 || HAS_ETHERNET_TLS_API)
+112
View File
@@ -0,0 +1,112 @@
#include "configuration.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_API)
#include "concurrency/OSThread.h"
#include "ethApiHandlers.h"
#include "ethApiServer.h"
#include <Arduino.h>
#ifdef USE_ARDUINO_ETHERNET
#include <Ethernet.h>
#else
#include <RAK13800_W5100S.h>
#endif
// 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;
static EthernetServer *apiServer = nullptr;
// 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
{
public:
explicit EthernetClientStream(EthernetClient &c) : c_(c) {}
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); }
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); }
bool connected() override { return c_.connected(); }
void flush() override { c_.flush(); }
IPAddress remoteIP() override { return c_.remoteIP(); }
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
// 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();
EthernetClientStream stream(client);
handleApiClient(stream);
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()
{
// Bind the listener (idempotent — deInitEthApiServer() drops apiServer on a
// W5500 reset, and this rebinds it on the restart path).
if (!apiServer) {
apiServer = new EthernetServer(ETH_API_PORT);
apiServer->begin();
LOG_INFO("ETH API: server listening on TCP port %d (phase 2.0, OSThread @ 20ms)", ETH_API_PORT);
}
// The worker is created once and kept for the lifetime of the process. It
// idles harmlessly while apiServer is null (runOnce guards on it), so we
// never delete it from another thread's runOnce — that would corrupt the
// scheduler's thread list mid-iteration.
if (!apiThread)
apiThread = new EthApiServerThread(); // OSThread base auto-registers with the scheduler
}
void deInitEthApiServer()
{
// A W5500 chip reset wipes the hardware socket table, so the listener is now
// bound to a dead socket. Drop it (the worker stays alive and idles) so the
// next initEthApiServer() from reconnectETH's restart path rebinds TCP/80.
if (apiServer) {
delete apiServer;
apiServer = nullptr;
}
}
#endif // HAS_ETHERNET && HAS_ETHERNET_API
+20
View File
@@ -0,0 +1,20 @@
#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).
/// 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();
/// Drop the HTTP listener (keeps the worker) so a later initEthApiServer()
/// rebinds TCP/80. Called by reconnectETH() after a W5500 chip reset, whose
/// register wipe leaves the old socket dead.
void deInitEthApiServer();
#endif // HAS_ETHERNET && HAS_ETHERNET_API
+396
View File
@@ -0,0 +1,396 @@
#include "configuration.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) && defined(ARCH_RP2040)
#include "FSCommon.h"
#include "concurrency/OSThread.h"
#include "ethCert.h"
#include <Arduino.h>
#include <string.h>
#ifdef USE_ARDUINO_ETHERNET
#include <Ethernet.h>
#else
#include <RAK13800_W5100S.h>
#endif
#include <mbedtls/asn1.h>
#include <mbedtls/ecp.h>
#include <mbedtls/error.h>
#include <mbedtls/oid.h>
#include <mbedtls/pk.h>
#include <mbedtls/x509_crt.h>
#include <pico/rand.h>
// 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
// 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<uint8_t> &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=<ip>,
// SAN(IP=<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<unsigned char> certBuf(2048);
std::vector<unsigned char> keyBuf(1024);
do {
// 1. ECDSA P-256 keypair
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;
}
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
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;
}
// 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.
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).
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;
out.certDer.assign(certBuf.data() + certBuf.size() - certLen, certBuf.data() + certBuf.size());
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;
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;
}
// Sanity-check a cached cert/key pair before trusting it. A reset between the
// three persist writes in ensureCertForIp() can leave a truncated DER on the FS;
// parsing both here catches that so we regenerate instead of handing the TLS
// server a bad pair (which then hard-disables HTTPS until the files change).
static bool certKeyParse(const std::vector<uint8_t> &certDer, const std::vector<uint8_t> &keyDer)
{
if (certDer.empty() || keyDer.empty())
return false;
mbedtls_x509_crt crt;
mbedtls_pk_context pk;
mbedtls_x509_crt_init(&crt);
mbedtls_pk_init(&pk);
// Both DERs must parse AND form a matching pair: parsing alone would accept a
// truncation-free but mismatched cert/key (e.g. a new cert written over an old
// key when the IP commit-marker survived a failed clear), which TLS would then
// reject at handshake time.
bool ok = mbedtls_x509_crt_parse_der(&crt, certDer.data(), certDer.size()) == 0 &&
mbedtls_pk_parse_key(&pk, keyDer.data(), keyDer.size(), nullptr, 0, picoRand, nullptr) == 0 &&
mbedtls_pk_check_pair(&crt.pk, &pk, picoRand, nullptr) == 0;
mbedtls_pk_free(&pk);
mbedtls_x509_crt_free(&crt);
return ok;
}
bool ensureCertForIp(IPAddress ip, EthCertMaterial &out)
{
String ipStr = ip.toString();
// Try cache. The IP file is a commit-marker written last (and cleared first)
// by the persist step below, so a present matching marker means cert+key were
// both fully written; certKeyParse() is belt-and-suspenders against truncation.
String savedIp;
if (readText(IP_PATH, savedIp)) {
savedIp.trim();
if (savedIp == ipStr && readBinary(CERT_PATH, out.certDer) && readBinary(KEY_PATH, out.keyDer)) {
if (certKeyParse(out.certDer, 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;
}
LOG_WARN("ETH CERT: cached cert/key failed to parse (partial write?), regenerating");
out.certDer.clear();
out.keyDer.clear();
}
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 the
// current process still has the in-memory cert ready for use.
//
// Clear the IP commit-marker FIRST so a reset mid-write can't leave the marker
// pointing at a half-written (or stale-paired) cert/key — the load path only
// trusts the cache when the marker matches. Writing the marker LAST commits the
// new pair atomically w.r.t. the loader.
writeText(IP_PATH, "");
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;
}
// Worker that defers cert gen off the Periodic thread (which has a tight stack
// and ticks every 5s alongside reconnect / NTP / MQTT). Waits for a non-zero IP,
// generates/loads the cert for it, then keeps polling at CERT_RECHECK_MS so a
// DHCP lease change to a new IP regenerates the cert — the SAN must track the
// current address or browsers reject the new one. The steady-state poll is just
// localIP() + compare; ECDSA keygen only reruns when the IP actually changes,
// and it runs on this thread's own stack (not the Periodic's).
//
// 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
{
IPAddress ip = Ethernet.localIP();
if (ip == IPAddress(0, 0, 0, 0))
return 250; // DHCP not yet bound; check again in 250 ms
if (ready_ && ip == certIp_)
return CERT_RECHECK_MS; // cert already matches the current IP
// First cert, or the lease moved us to a new IP. ensureCertForIp()
// regenerates whenever its saved IP != ip, so the cert SAN follows.
bool ok = ensureCertForIp(ip, material_);
if (!ok) {
LOG_ERROR("ETH CERT: pipeline FAILED — TLS server will not start");
// Don't leave isReady() reporting true with empty material: a later TLS
// teardown (e.g. a W5500 reset) would then fail initTlsContext() and stay
// disabled. Clear readiness so the TLS worker waits and the next poll
// retries from scratch.
ready_ = false;
certIp_ = IPAddress(0, 0, 0, 0);
material_.certDer.clear();
material_.keyDer.clear();
return CERT_RECHECK_MS; // retry on the next poll
}
certIp_ = ip;
ready_ = true;
certGeneration_++; // bump so the TLS worker reloads + rebinds with the new cert
return CERT_RECHECK_MS;
}
private:
static constexpr uint32_t CERT_RECHECK_MS = 30000; // re-poll IP to catch DHCP lease changes
bool ready_ = false;
IPAddress certIp_ = IPAddress(0, 0, 0, 0);
uint32_t certGeneration_ = 0;
EthCertMaterial material_;
public:
bool isReady() const { return ready_; }
uint32_t generation() const { return certGeneration_; }
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;
}
uint32_t getEthCertGeneration()
{
return certThread ? certThread->generation() : 0;
}
#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API && ARCH_RP2040
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "configuration.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) && defined(ARCH_RP2040)
#include <IPAddress.h>
#include <stddef.h>
#include <stdint.h>
#include <vector>
struct EthCertMaterial {
std::vector<uint8_t> certDer; // self-signed X.509 cert in DER format
std::vector<uint8_t> 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();
// Monotonic counter bumped each time the cert is (re)generated — e.g. when a DHCP
// lease change moves us to a new IP. The TLS server reloads when this changes.
uint32_t getEthCertGeneration();
#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API && ARCH_RP2040
+31
View File
@@ -9,6 +9,13 @@
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
#include "mesh/eth/ethOTA.h"
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_API)
#include "mesh/eth/ethApiServer.h"
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) && defined(ARCH_RP2040)
#include "mesh/eth/ethCert.h"
#include "mesh/eth/ethTlsApiServer.h"
#endif
#ifdef USE_ARDUINO_ETHERNET
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5100/W5200/W5500
// Shorter DHCP timeout so LoRa startup isn't blocked when no DHCP server is present.
@@ -59,6 +66,16 @@ static int32_t reconnectETH()
#if !MESHTASTIC_EXCLUDE_SOCKETAPI
deInitApiServer();
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_API)
// Drop the HTTP/80 listener; the restart path below rebinds it. Without
// this the singleton guard makes initEthApiServer() a no-op and the
// phone API stays dead on a stale socket until reboot.
deInitEthApiServer();
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) && defined(ARCH_RP2040)
// Same for HTTPS/443 + its mbedTLS context.
deInitEthTlsApiServer();
#endif
#if HAS_UDP_MULTICAST
if (udpHandler) {
udpHandler->stop();
@@ -160,6 +177,19 @@ static int32_t reconnectETH()
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
initEthOTA();
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_API)
initEthApiServer();
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) && defined(ARCH_RP2040)
// 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();
// Phase 2.2 — TLS server skeleton on TCP/443. The worker waits
// until the cert thread signals isEthCertReady() before binding.
initEthTlsApiServer();
#endif
ethStartupComplete = true;
}
@@ -190,6 +220,7 @@ static int32_t reconnectETH()
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
ethOTALoop();
#endif
// ethApiServer runs on its own OSThread (20ms ticks) — not polled here.
return 5000; // every 5 seconds
}
+351
View File
@@ -0,0 +1,351 @@
#include "configuration.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) && defined(ARCH_RP2040)
#include "concurrency/OSThread.h"
#include "ethApiHandlers.h"
#include "ethCert.h"
#include "ethTlsApiServer.h"
#include <Arduino.h>
#ifdef USE_ARDUINO_ETHERNET
#include <Ethernet.h>
#else
#include <RAK13800_W5100S.h>
#endif
#include <mbedtls/error.h>
#include <mbedtls/pk.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
#include <hardware/watchdog.h>
#include <pico/rand.h>
#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;
// 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.
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<EthernetClient *>(ctx);
if (!client->connected())
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)
{
auto *client = static_cast<EthernetClient *>(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.
//
// 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);
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_;
};
class EthTlsApiServerThread : public concurrency::OSThread
{
public:
EthTlsApiServerThread() : concurrency::OSThread("EthTlsApi") { lastActivityMs = millis(); }
protected:
int32_t runOnce() override
{
// Phase A: wait for the cert worker, then rebuild if the cert was
// regenerated (a DHCP lease change to a new IP bumps the generation, and
// the SAN must follow or browsers reject the new address).
if (tlsReady && getEthCertGeneration() != loadedCertGen_) {
LOG_INFO("ETH TLS: cert regenerated (gen %u->%u), reloading + rebinding", (unsigned)loadedCertGen_,
(unsigned)getEthCertGeneration());
deInitEthTlsApiServer(); // frees ctx, drops the listener, clears tlsReady
}
if (!tlsReady) {
if (!isEthCertReady())
return 500;
if (!initTlsContext())
return INT32_MAX; // hard fail — TLS server stays disabled
loadedCertGen_ = getEthCertGeneration();
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;
uint32_t loadedCertGen_ = 0; // cert generation the current TLS context was built from
bool initTlsContext()
{
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;
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;
}
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;
}
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);
// 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);
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;
}
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", ETH_TLS_API_PORT);
return true;
}
void serveClient(EthernetClient &client)
{
LOG_INFO("ETH TLS: client connected from %s", client.remoteIP().toString().c_str());
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);
watchdog_update();
} 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));
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)");
}
void deInitEthTlsApiServer()
{
// A W5500 chip reset leaves tlsServer bound to a dead socket and the cached
// mbedTLS context stale. Reset the worker back to Phase A (free the context,
// drop the listener, clear tlsReady) WITHOUT deleting the OSThread — its next
// runOnce re-waits for isEthCertReady() and rebuilds the context + rebinds
// TCP/443. Safe to free here: this runs in reconnectETH (ethConnect thread),
// and the cooperative scheduler guarantees tlsThread is not mid-runOnce, so
// nothing is using these contexts right now.
if (tlsServer) {
delete tlsServer;
tlsServer = nullptr;
}
if (tlsReady) {
mbedtls_ssl_free(&ssl);
mbedtls_ssl_config_free(&sslConf);
mbedtls_pk_free(&pkKey);
mbedtls_x509_crt_free(&certChain);
tlsReady = false;
}
}
#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API && ARCH_RP2040
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "configuration.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_TLS_API) && defined(ARCH_RP2040)
// 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();
/// Reset the TLS server to its pre-bind state (frees the mbedTLS context, drops
/// the TCP/443 listener, clears the ready flag) so the worker rebuilds and
/// rebinds on its next tick. Called by reconnectETH() after a W5500 chip reset.
void deInitEthTlsApiServer();
#endif // HAS_ETHERNET && HAS_ETHERNET_TLS_API && ARCH_RP2040