From a7fa851e14c3512d4031c5f308295ba878ae75bd Mon Sep 17 00:00:00 2001 From: "Adrian.Nguyen-Qualgo" Date: Wed, 19 Aug 2026 22:51:26 +0700 Subject: [PATCH 1/2] feat(coord): consolidate H2 recv/JSON buffers into one dynamically-sized window do_fetch_peers() previously allocated two fixed 512KB PSRAM buffers (H2 receive + a separate JSON parse buffer). Merge them into a single buffer, compacting the extracted MapResponse JSON in place via memmove instead of copying into a second buffer -- halves peak footprint (~1MB -> ~512KB at defaults). The buffer's size is now also clamped at connect time to the largest actually-free heap block (min 64KB, never above the configured ML_H2_BUFFER_SIZE_KB ceiling), via new choose_h2_rx_window_size(). CONFIG_ML_JSON_BUFFER_SIZE_KB is removed -- the merged buffer only needs one size knob. Adapted from djorr5/microlink's `67b230b2` piece (a) (dynamic H2 RX window sizing) -- piece (b), `ip4_route_src_hook`, is unrelated and untouched (tracked separately as #39). Kept this fork's existing frame_buf scratch-then-copy pattern in the initial receive loop rather than reading noise_recv() straight into the shrinking window, since this fork's noise_recv() doesn't drain the ciphertext off the socket when a frame doesn't fit the destination buffer -- reading directly into a near-full window risked desyncing the coordination stream. Verified with a from-clean `pio run` against zen-clock (a real downstream consumer, LilyGo T-Display-S3) using idf_component.yml's override_path pointed at this working tree: full firmware build + link succeeded. The adaptive clamp-under-heap-pressure path itself wasn't exercised on hardware since that board has ample free PSRAM. Closes #38 Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- FORK_PRS.md | 2 +- README.md | 11 ++- components/microlink/Kconfig | 25 +++--- .../microlink/include/microlink_internal.h | 11 ++- components/microlink/src/microlink.c | 1 + components/microlink/src/ml_coord.c | 78 ++++++++++++++----- components/microlink/src/ml_h2.c | 6 +- 8 files changed, 86 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0389bc7..6b09f75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ components/microlink/ **Cellular data path**: PPP is strongly preferred — gives real lwIP sockets, direct UDP, NAT traversal. AT socket bridge is automatic fallback when PPP auth fails. PPP throughput ~6.5 KB/s vs ~0.45 KB/s for AT bridge. -**PSRAM usage**: H2 receive buffer (512KB) and JSON parse buffer (512KB) are allocated from PSRAM during coordination, then freed. Without PSRAM, reduce via `CONFIG_ML_H2_BUFFER_SIZE_KB=64` (supports ~30 peers max). +**PSRAM usage**: A single H2 receive buffer (512KB default) is allocated from PSRAM during coordination, then freed. It serves double duty — HTTP/2 frames are received into it, then the extracted MapResponse JSON is compacted in place (no separate JSON buffer). At connect time the buffer is further clamped down to whatever's actually free in heap (min 64KB, never above the configured ceiling), so peak usage on a fragmented heap is often less than the configured size. Without PSRAM, reduce the ceiling via `CONFIG_ML_H2_BUFFER_SIZE_KB=64` (supports ~30 peers max). **NVS namespaces**: `"microlink"` for keys (machine key, WG key, DISCO key), `"ml_peers"` for peer cache. `microlink_factory_reset()` erases both — must call before `microlink_init()`. diff --git a/FORK_PRS.md b/FORK_PRS.md index 2c6de30..5815cc0 100644 --- a/FORK_PRS.md +++ b/FORK_PRS.md @@ -36,7 +36,7 @@ become its own scoped PR. Issue numbers filled in once created. | 16 | ✅ [#35](https://github.com/fugo101/microlink/issues/35) (done) | `Csontikka/microlink` | `7a24a9b3`, `4d3b3add` | DERP client backpressure/reconnect-storm fixes: don't tear down on TLS write backpressure, TCP_NODELAY + coalesced writes, fix a hot-spinning I/O loop, split into concurrent reader/writer tasks — check overlap against cplewes's DERP session-resumption fix (#6) before scoping. **3/4 pieces landed in `ml_derp.c`**: (1) `derp_tls_write_all`'s WANT_WRITE/WANT_READ/TIMEOUT retry budget 50→300 (500ms→3s) — the caller treated any write failure as fatal and forced a full reconnect, so transient TLS backpressure was triggering reconnect storms; genuine mbedtls errors still bail immediately, unchanged. (2) `TCP_NODELAY` set on the DERP socket post-upgrade (was unset — Nagle was adding up to 500ms of latency coalescing our own already-small frames) plus `derp_send_packet` rewritten to build header+dest_key+payload as one buffer/one TLS write instead of two, via the existing `ml_psram_malloc`. (3) `ml_derp_tx_task`'s end-of-loop `vTaskDelay(pdMS_TO_TICKS(1))` rounded to 0 ticks at this project's 100Hz tick rate, so it never actually paced an idle loop — replaced with a `did_work` flag (`taskYIELD()` when busy, real `vTaskDelay(10ms)` when idle) and raised the TX/RX per-loop batch caps 8→32 / 4→32 to match. Not literal cherry-picks — this fork's `ml_derp.c` has diverged (PSA crypto, different comment style, `ml_setsockopt`/`ml_psram_malloc` wrappers not present upstream) — hand-ported with equivalent logic. No overlap with the already-landed #14 (DERP TLS leak, `derp_free_tls_state`/teardown path) or #25 (TLS session resumption, `ml_derp_connect`'s handshake phase) fixes — different functions/line ranges, verified via `git log -- ml_derp.c`. **(4) reader/writer task split — investigated further, decided not to port** (distinct from "deferred, not yet done"): re-checked against PR #62's restructured `ml_derp_tx_task` (`did_work`/Phase 1 TX/Phase 2 RX/batch caps 32) and every `ml->derp.*` touch point fork-wide — `ssl`/`ssl_conf`/`saved_session`/`sockfd` are only ever touched inside `ml_derp.c`, no cross-task race exists today. Splitting would require real mbedTLS-layer synchronization (`mbedtls_ssl_read`/`_write` are not safe to call concurrently on the same `ssl` context from two threads, even one-reader/one-writer — shared internal buffers, record sequence numbers, session state) — either a dedicated mutex around every `mbedtls_ssl_*` call (reintroducing exactly the cost this fork's file header says it deliberately avoids: "eliminates the need for a TLS mutex since only one task touches the SSL context") or two independent BIO/session objects, plus a new queue to route the PING→PONG echo (`dispatch_derp_frame()`) through a writer task instead of calling it inline. The starvation this fix targets in `Csontikka/microlink`'s architecture is real there, but here it's bounded to the 3s write-retry budget under genuine backpressure only (rarer and non-fatal since piece 1 landed) — not the unbounded/hot-spinning case their fix addresses. Crediting `Csontikka/microlink` for a correct fix for *their* architecture; it doesn't transfer cleanly to this fork's diverged single-task DERP design. Confirmed no downstream impact: `/Users/nguyen.ndt/Projects/zen-clock` (pulls `fugo101/microlink ^3.0.0` from the registry) only uses `microlink_init/start/rebind`, no DERP-internal API — public surface unchanged either way. The "also touches `components/wireguard_lwip/`" note in the original scoping was a copy-paste artifact from #31/#34/#42's rows — `7a24a9b3`'s submodule changes are diagnostics-only (unrelated to these fixes) and `4d3b3add` doesn't touch the submodule at all; no submodule involvement here. | 2 | | 17 | ✅ [#36](https://github.com/fugo101/microlink/issues/36) (done) | `Csontikka/microlink` | `27806be3` | `microlink_stop()` never closed `derp.sockfd`/`coord_sock`, letting `derp_tx` outlive the teardown wait → UAF — reconcile against cplewes's teardown UAF fix (#2), may be the same root cause. **Investigated, not needed here**: this fork's `microlink_stop()` (from issue #21/#22's PR #47) never frees context until `ml_join_tasks()` proves every worker exited, and `ml_derp.c`'s blocking loops already call `ml_shutdown_pending()` on every SO_RCVTIMEO-bounded iteration (100-200ms) to bail out cooperatively — the source commit's "shutdown() the socket to force-wake a blocked task" fixes a UAF that doesn't exist here. Leaving open in case future review disagrees, but no port planned. | 2 | | 18 | [#37](https://github.com/fugo101/microlink/issues/37) | `Csontikka/microlink` | `cbdf1603`, `aad403af`, `533f1f88`, `46e34917`, `017b3588`, `372ca277` | H2 frame reassembly across `noise_recv()` read boundaries — large MapResponses spanning reads deterministically corrupt the stream ("implausible message size"); genuine protocol-correctness bug independent of control-plane backend. **Triaged into 4 pieces — 1/4 landed**: (1) `cbdf1603` (`https://` login_server + Headscale Noise key fetch), `aad403af` ("deliver the netmap from the long-poll stream, Headscale ≥0.26"), and `372ca277` (Headscale v0.28 single-stream compat) are all pure Headscale-control-plane compatibility, out of scope per `CLAUDE.md` (Tailscale-only) — excluded, no port. (2) `533f1f88` bundles two fixes: its stream-liveness watchdog (a second clock fed only by genuine stream-5 DATA frames, so a front end that keeps ACKing our PINGs can't hide a dead server-side mapSession) is real, backend-independent, and **landed** — this fork's existing watchdog (`last_activity_ms`) resets on any inbound *or even outbound-send* activity, so it had exactly this blind spot. Its second half (reorder `ML_EVT_COORD_REGISTERED` to fire after the streamed netmap, not before) is **not applicable**: that race only exists on Csontikka's Headscale ≥0.26 empty-initial-fetch path; this fork's `do_fetch_peers()` always populates `ml->vpn_ip` synchronously before returning, so `ML_EVT_COORD_REGISTERED` already fires after the VPN IP is known — skipped. (3) `46e34917` (DERP relay liveness + peer sweep, 6 sub-fixes) reconciled against #14/#25/#35's landed DERP fixes: TLS-leak-on-failed-connect and DERP-region-fallback are **already covered** (this fork's #14 fix is a superset — also validates `mbedtls_ssl_config_defaults`/`set_hostname`, and its region fallback additionally skips `avoid`-flagged regions and re-derives fresh each connect instead of permanently committing to the first fallback found — no port needed, source version would actually regress both). The zero-conflict pieces **landed** (`ml_coord.c`/`ml_peer_nvs.c`/`ml_wg_mgr.c`, none overlapping #14/#25/PR #62's line ranges): authoritative-peer-list sweep (a full `Peers`/`peers` list is authoritative — entries absent from it are queued for removal, since ACL revocation was previously only reachable via an explicit `PeersRemoved` array), peer removal now also drops the NVS cache entry (`ml_peer_nvs_remove()`, new) so revoked peers stay gone across reboots, and a DISCO decrypt-fail log naming the claimed sender + arrival path. **Deferred**: the retry-forever DERP backoff and the RX-liveness watchdog (90s silence → reconnect) are real gaps but land in the exact `ml_derp_tx_task()` region PR #62 just restructured (`did_work`/Phase 1/Phase 2/pacing) — needs careful hand-placement against that structure, not a blind patch; scope as a follow-up. (4) `017b3588`, the actual H2-reassembly fix this row was originally filed for, is large (287+/-101 lines) and rewrites `do_map_exchange()` — the same hottest, most failure-sensitive control-plane parsing path already flagged as needing hardware to verify for issue #38 — deferred to a dedicated session with real testing. | 2 | -| 19 | [#38](https://github.com/fugo101/microlink/issues/38), [#39](https://github.com/fugo101/microlink/issues/39) | [`djorr5/microlink`](https://github.com/djorr5/microlink) | `67b230b2` | Two independent changes, split into two issues: (a) dynamic H2 RX window sizing based on free heap for RAM-constrained boards, roughly halving MapResponse-parsing PSRAM footprint; (b) `ip4_route_src_hook` to force tailnet-range traffic onto the WG netif directly. **Investigated**: no submodule involvement, both fully in-repo (`ml_h2.c`/`ml_coord.c` for (a), new `ml_lwip_hooks.c` for (b)). (a) is real and reasonably self-contained but rewrites the hottest control-plane parsing path (`do_fetch_peers()`'s buffer allocation and read loop) without hardware to verify it — needs a dedicated session with real testing, not a quick port. (b) is likely **redundant**: lwIP's own `ip4_route()` already scans `netif_list` for a netmask match *before* ever consulting `LWIP_HOOK_IP4_ROUTE_SRC` (confirmed by reading `ip4.c`), and `wg_init_interface()` already registers the WG netif directly into `netif_list` with the correct `/10` netmask — so the hook's netmask-match logic duplicates what already happens. Might still matter in a narrow edge case (netif up/link-up flapping during rebind) but not clearly worth the new file + build wiring for that alone. | 2 | +| 19 | ✅ [#38](https://github.com/fugo101/microlink/issues/38) (done), [#39](https://github.com/fugo101/microlink/issues/39) | [`djorr5/microlink`](https://github.com/djorr5/microlink) | `67b230b2` | Two independent changes, split into two issues: (a) dynamic H2 RX window sizing based on free heap for RAM-constrained boards, roughly halving MapResponse-parsing PSRAM footprint; (b) `ip4_route_src_hook` to force tailnet-range traffic onto the WG netif directly. **(a) landed, hardware-verified via `zen-clock`** (a real downstream consumer at `/Users/nguyen.ndt/Projects/zen-clock`, LilyGo T-Display-S3/ESP32-S3, tested with `override_path` pointing at this working tree): `do_fetch_peers()` now allocates a single PSRAM buffer sized to a runtime window (`ml->h2_rx_window_size`, chosen by new `choose_h2_rx_window_size()` in `ml_coord.c` — largest free block across SPIRAM/internal, minus a 32KB margin, clamped to [64KB, `ML_H2_BUFFER_SIZE_KB`]) instead of two fixed 512KB buffers; the extracted MapResponse JSON is compacted in place via `memmove` rather than copied into a second buffer, halving peak footprint (~1MB → ~512KB at defaults, less under heap pressure). `ml_h2_build_preface()`'s signature now takes the window size instead of hardcoding `ML_H2_BUFFER_SIZE`. `CONFIG_ML_JSON_BUFFER_SIZE_KB` removed (now unused — the merged buffer only has one size knob). **Adapted, not a literal port**: kept the existing per-iteration `frame_buf` scratch-then-copy pattern in the initial H2 receive loop instead of reading `noise_recv()` straight into `h2_recv + h2_total` as the source commit does — this fork's `noise_recv()` returns -1 without draining the ciphertext off the socket when a frame doesn't fit the caller's buffer, which would desync the stream if the destination window shrinks near the tail; the source fork's `noise_recv()` may handle that case differently, but porting the direct-read change here would introduce a stream-desync class of bug this fork didn't have before. Verified via a from-clean `pio run` against zen-clock with `microlink`'s `idf_component.yml` `override_path` pointed at this repo — full firmware build + link succeeded (bootloader + app, RAM 17.5%/Flash 45.1% on the T-Display-S3), confirming the patch compiles and links cleanly against a real downstream consumer's actual usage (`microlink_init/start/get_state/rebind`, `enable_derp=true`). The adaptive clamp-under-pressure branch itself (choosing below the compiled ceiling) was **not** exercised on hardware — zen-clock's board has PSRAM fully enabled and free, so it doesn't naturally hit the low-heap path; only the default/no-pressure path (window == ceiling) was observed running for real. **(b) untouched, still open** — no change to its assessment above; not investigated further this session. | 2 | | 20 | ✅ [#40](https://github.com/fugo101/microlink/issues/40) (done) | [`AELovelace/LAIN-MicrolinkRouter`](https://github.com/AELovelace/LAIN-MicrolinkRouter) | `e1239460` | `microlink_set_exit_node()`. **Scoped down from the row's original framing after investigation, approved by the user**: this is not a Tailscale-style default-route exit node (all traffic incl. general internet through a peer) and not a LAN-wide NAPT exit node (other LAN devices' traffic via SoftAP) — neither exists anywhere in the source commit or this fork. What it actually is: a **tailnet-range fallback peer** — the peer table is bounded by `CONFIG_ML_MAX_PEERS`, so a designated peer acts as a fallback router for `100.64.0.0/10` destinations not present in the local table; the WG netif's mask stays narrow always (never widens to `0.0.0.0/0` — the source commit's own comment documents this as itself a fix for an earlier, more dangerous version that caused a real outage). General internet traffic is unaffected either way. `microlink_get_netif_impl()` is a hook for a future real NAPT exit-node feature, not included here. `microlink_set_exit_node()`/`microlink_get_netif_impl()`, `wg_program_peer_route()`/`wg_apply_exit_node()`, and the 5s peer-liveness safety net landed in `microlink.h`/`microlink_internal.h`/`microlink.c`/`ml_wg_mgr.c`. The hard prerequisite — a longest-prefix-match fix in `peer_lookup_by_allowed_ip()` (without it, a `0.0.0.0/0` fallback route can shadow a more specific peer's `/32` depending on array order) — landed and released in the submodule: [`fugo101/wireguard-lwip#17`](https://github.com/fugo101/wireguard-lwip/pull/17), merged and released as v1.0.3. Submodule pointer bumped here; `idf_component.yml`'s `fugo101/wireguard_lwip: "^1.0.0"` pin already covered 1.0.3 (verified live on the ESP Component Registry), so no manifest edit needed — same reasoning as issues #34/#42/#43. Explicitly **not** ported: the WiFi-STA outbound socket binding (solves a problem that only exists with a widened netmask, which this design never does — porting it would regress cellular failover), `nacl_box.c` changes (unrelated crypto refactor bundled in by accident, includes a bounds-check removal that reads as a regression), `ml_stun.c` changes (cosmetic no-op). | 2 | | 21 | ✅ [#41](https://github.com/fugo101/microlink/issues/41) (done) | [`caslavskola/microlink`](https://github.com/caslavskola/microlink) | `0265718e` | PSA crypto init migration for mbedTLS 4.x. **Investigated, not needed**: ESP-IDF 6.x's own `mbedtls` component already calls `psa_crypto_init()` automatically at boot (`ESP_SYSTEM_INIT_FN`, priority 104, `components/mbedtls/port/esp_psa_crypto_init.c`) — an explicit call in `microlink_init()` would be pure redundancy. The commit also rewrites `ml_noise.c`'s ChaCha20-Poly1305 from `mbedtls_chachapoly_*` to raw PSA `psa_aead_encrypt/decrypt` calls, but this fork already has a working, cleaner solution via `mbedtls/private/chachapoly.h` (documented in `ESP_IDF_6X_COMPAT.md`) — the source commit's version is messier (leftover `//to remove` debug logging, commented-out ChatGPT-added diagnostics) and fixes nothing we're missing. No port planned. | 2 | | 22 | ✅ [#42](https://github.com/fugo101/microlink/issues/42) (done) | `caslavskola/microlink` | `9ac49212` | Peer endpoint tracking dropped the "packet arrived via DERP" signal after the first packet, causing replies to attempt bad direct routing — genuine bug, but the commit bundles debug cruft and a divergent netif-flag rewrite; extract just the DERP-routing-flag fix. Adapted (extracted just the `last_rx_via_derp` flag, skipped the `netif_set_link_up/down` rewrite and debug logging) and landed in `wireguard_lwip`: [`fugo101/wireguard-lwip#15`](https://github.com/fugo101/wireguard-lwip/pull/15) (same PR as issue #34), merged and released as v1.0.2. Submodule pointer bumped here; no `idf_component.yml` edit needed (see row 15). | 2 | diff --git a/README.md b/README.md index 43980b2..85482ae 100644 --- a/README.md +++ b/README.md @@ -207,16 +207,15 @@ pong from esp32-microlink (100.x.x.x) via DERP(dfw) in 150ms ### ESP32-S3 with PSRAM (Recommended) -MapResponse buffers (H2 + JSON) are allocated from PSRAM only during coordination polling, then freed. Peak PSRAM usage is ~1MB (~12% of 8MB). Leaves 200KB+ SRAM free for your application. +The MapResponse buffer (H2 receive + JSON parse, a single buffer reused in place) is allocated from PSRAM only during coordination polling, then freed. Peak PSRAM usage is ~512KB (~6% of 8MB) at defaults, and less than that whenever free heap is tight — the buffer is clamped to the largest available block (min 64KB) at connect time. Leaves 200KB+ SRAM free for your application. ### ESP32 without PSRAM -Boards without PSRAM can reduce H2/JSON buffers to 64KB via menuconfig (sufficient for ~30 peers). Total SRAM usage: ~140KB. Suitable for simple sensor reporting, heartbeats, and small data payloads. Not recommended for large tailnets or memory-heavy applications. +Boards without PSRAM can reduce the H2 buffer to 64KB via menuconfig (sufficient for ~30 peers). Total SRAM usage: ~90KB. Suitable for simple sensor reporting, heartbeats, and small data payloads. Not recommended for large tailnets or memory-heavy applications. ```ini # sdkconfig.defaults for ESP32 without PSRAM CONFIG_ML_H2_BUFFER_SIZE_KB=64 -CONFIG_ML_JSON_BUFFER_SIZE_KB=64 CONFIG_ML_MAX_PEERS=8 ``` @@ -588,8 +587,7 @@ MicroLink V2 Configuration | `ML_MAX_PEERS` | `16` | Maximum simultaneous active WireGuard tunnels (1-64). Each uses ~200 bytes. This is NOT the tailnet size limit — MicroLink tracks all peers (300+) but only maintains active tunnels to this many at once. Reduce to 8 for non-PSRAM. | | `ML_NVS_MAX_PEERS` | `64` | Peers cached in NVS flash (16-1024). Persists across reboots so DISCO probing starts immediately. Each entry: 92 bytes. LRU eviction when full. | | `ML_PRIORITY_PEER_IP` | Empty | Priority peer VPN IP (e.g., `100.x.y.z`). Guaranteed a WG slot even when peer table is full — LRU non-priority peer is evicted. Also settable via web UI. | -| `ML_H2_BUFFER_SIZE_KB` | `512` | H2 receive buffer (64-2048 KB, PSRAM-backed). Size determines max tailnet: 64KB ≈ 30 peers, 512KB ≈ 300 peers, 2048KB ≈ 1200 peers. | -| `ML_JSON_BUFFER_SIZE_KB` | `512` | JSON parse buffer (64-2048 KB, PSRAM-backed). cJSON DOM uses 2-3x raw JSON size. Match to H2 buffer. | +| `ML_H2_BUFFER_SIZE_KB` | `512` | Ceiling for the single H2-receive-and-JSON-parse buffer (64-2048 KB, PSRAM-backed; clamped down to free heap at connect time, min 64KB). Size determines max tailnet: 64KB ≈ 30 peers, 512KB ≈ 300 peers, 2048KB ≈ 1200 peers. | #### Credentials @@ -710,9 +708,10 @@ Zero-copy mode contributed by [dj-oyu](https://github.com/dj-oyu/microlink). ### "Failed to parse MapResponse JSON" - H2 buffer too small for your tailnet size -- Increase `ML_H2_BUFFER_SIZE_KB` and `ML_JSON_BUFFER_SIZE_KB` in menuconfig +- Increase `ML_H2_BUFFER_SIZE_KB` in menuconfig - For 300+ peers, use 512KB (default). For 600+, use 1024KB. - Ensure PSRAM is enabled: `CONFIG_SPIRAM=y` +- Check the boot log for "H2 rx window" — if it's clamped well below your configured ceiling, free heap is tight at connect time, not just the ceiling itself ### PPP connection fails / falls back to AT socket - Check carrier APN is correct diff --git a/components/microlink/Kconfig b/components/microlink/Kconfig index a673a48..795ab72 100644 --- a/components/microlink/Kconfig +++ b/components/microlink/Kconfig @@ -74,8 +74,16 @@ menu "MicroLink V2 Configuration" default 512 range 64 2048 help - Size of the PSRAM-allocated buffer for receiving HTTP/2 frames - from the Tailscale control plane (MapResponse). + Ceiling for the single PSRAM-allocated buffer used to both + receive HTTP/2 frames from the Tailscale control plane + (MapResponse) and hold the extracted JSON for parsing -- + the JSON is compacted in place into the same buffer, so no + separate JSON buffer is allocated. + + At connect time this is further clamped down to whatever's + actually available in free heap (see ml_coord.c's + choose_h2_rx_window_size()), so actual usage is often less + than this ceiling. The initial MapResponse contains all peers in your tailnet. Each peer is roughly 1-2KB of JSON. After the initial response, @@ -86,19 +94,6 @@ menu "MicroLink V2 Configuration" - 1024: ~600 peers (large enterprise tailnets) - 2048: ~1200 peers (very large tailnets, requires 8MB PSRAM) - config ML_JSON_BUFFER_SIZE_KB - int "JSON parse buffer size (KB)" - default 512 - range 64 2048 - help - Size of the PSRAM-allocated buffer for parsing JSON MapResponse - data. cJSON builds an in-memory DOM tree that uses 2-3x the - raw JSON size, so this should be at least as large as the - HTTP/2 buffer. - - Match this to ML_H2_BUFFER_SIZE_KB unless you have a reason - to set them differently. - menu "Cellular Modem" config ML_ENABLE_CELLULAR diff --git a/components/microlink/include/microlink_internal.h b/components/microlink/include/microlink_internal.h index 72e93b9..a246ad9 100644 --- a/components/microlink/include/microlink_internal.h +++ b/components/microlink/include/microlink_internal.h @@ -142,9 +142,11 @@ extern "C" { * transport health looks fine). */ #define ML_CTRL_STREAM_STALE_MS 300000 -/* Large tailnet buffer sizes (PSRAM-allocated, configurable via menuconfig) */ +/* Large tailnet buffer size (PSRAM-allocated, configurable via menuconfig). + * Ceiling for the single H2-receive-and-JSON-parse buffer in do_fetch_peers() + * -- ml->h2_rx_window_size is clamped to this at connect time based on free + * heap (see choose_h2_rx_window_size() in ml_coord.c). */ #define ML_H2_BUFFER_SIZE (CONFIG_ML_H2_BUFFER_SIZE_KB * 1024) -#define ML_JSON_BUFFER_SIZE (CONFIG_ML_JSON_BUFFER_SIZE_KB * 1024) /* Noise protocol */ #define ML_NOISE_KEY_LEN 32 @@ -451,6 +453,9 @@ struct microlink_s { /* Coordination socket (owned exclusively by coord task) */ int coord_sock; uint32_t h2_next_stream_id; /* Next H2 stream ID for endpoint updates (odd, starts at 7) */ + /* Runtime H2 recv/JSON-parse window, chosen per connect cycle by + * choose_h2_rx_window_size() from free heap; never exceeds ML_H2_BUFFER_SIZE. */ + uint32_t h2_rx_window_size; /* ml_get_time_ms() of the most recent DATA frame received on the * long-poll map stream (H2 stream 5) specifically -- real MapResponses * and the ~60s mapSession keepalives, nothing else. Unlike the @@ -617,7 +622,7 @@ int ml_h2_build_headers_frame(uint8_t *out, size_t out_size, int ml_h2_build_data_frame(uint8_t *out, size_t out_size, const uint8_t *data, size_t data_len, uint32_t stream_id, bool end_stream); -int ml_h2_build_preface(uint8_t *out, size_t out_size); +int ml_h2_build_preface(uint8_t *out, size_t out_size, uint32_t window_size); int ml_h2_build_settings_ack(uint8_t *out, size_t out_size); int ml_h2_build_window_update(uint8_t *out, size_t out_size, uint32_t stream_id, uint32_t increment); diff --git a/components/microlink/src/microlink.c b/components/microlink/src/microlink.c index bf461e0..3a59254 100644 --- a/components/microlink/src/microlink.c +++ b/components/microlink/src/microlink.c @@ -393,6 +393,7 @@ microlink_t *microlink_init(const microlink_config_t *config) { ml->stun_sock = -1; ml->stun_sock6 = -1; ml->derp.sockfd = -1; + ml->h2_rx_window_size = ML_H2_BUFFER_SIZE; /* Resolve timing (0 = use defaults from #defines) */ ml->t_disco_heartbeat_ms = ml->config.disco_heartbeat_ms ? ml->config.disco_heartbeat_ms : ML_DISCO_HEARTBEAT_MS; diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index 2a4026c..1580730 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -655,15 +655,41 @@ static void process_proactive_frames(microlink_t *ml, ml_noise_state_t *noise) { * State: H2_PREFACE - Send HTTP/2 connection preface (Noise-encrypted) * ========================================================================== */ +/* Choose the H2 recv/JSON-parse window for this connect cycle: the largest + * contiguous free block we can find (SPIRAM preferred, internal as fallback), + * minus a safety margin, clamped to [64KB, ML_H2_BUFFER_SIZE] and rounded + * down to 1KB. do_fetch_peers() allocates exactly one buffer this size and + * uses it for both the raw H2 receive and the compacted JSON -- keeping this + * window under what's actually free avoids fragmenting the rest of PSRAM on + * RAM-constrained boards. Ported from djorr5/microlink's `67b230b2`. */ +static void choose_h2_rx_window_size(microlink_t *ml) { + size_t psram_largest = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); + size_t internal_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + size_t largest_any = psram_largest > internal_largest ? psram_largest : internal_largest; + + const size_t margin = 32 * 1024; + const size_t floor = 64 * 1024; + size_t window = (largest_any > margin) ? (largest_any - margin) : 0; + if (window < floor) window = floor; + if (window > ML_H2_BUFFER_SIZE) window = ML_H2_BUFFER_SIZE; + window &= ~(size_t)1023; /* round down to 1KB */ + + ml->h2_rx_window_size = (uint32_t)window; + ESP_LOGI(TAG, "H2 rx window: %luKB (largest free block %luKB, ceiling %luKB)", + (unsigned long)(window / 1024), (unsigned long)(largest_any / 1024), + (unsigned long)(ML_H2_BUFFER_SIZE / 1024)); +} + static int do_h2_preface(microlink_t *ml, ml_noise_state_t *noise) { int64_t t_h2_start = esp_timer_get_time(); + uint32_t h2_window = ml->h2_rx_window_size ? ml->h2_rx_window_size : ML_H2_BUFFER_SIZE; /* Build H2 preface (24+6=30) + SETTINGS with INITIAL_WINDOW_SIZE (9+6=15) * + SETTINGS_ACK (9) + connection-level WINDOW_UPDATE (13) = 67 bytes */ uint8_t h2_init[128]; int pos = 0; - int preface_len = ml_h2_build_preface(h2_init, sizeof(h2_init)); + int preface_len = ml_h2_build_preface(h2_init, sizeof(h2_init), h2_window); if (preface_len < 0) return -1; pos = preface_len; @@ -675,7 +701,7 @@ static int do_h2_preface(microlink_t *ml, ml_noise_state_t *noise) { * beyond the 65535 default. SETTINGS INITIAL_WINDOW_SIZE only sets per-stream * window; the connection-level window starts at 65535 and must be explicitly * expanded with WINDOW_UPDATE on stream 0. */ - uint32_t conn_window_delta = ML_H2_BUFFER_SIZE - 65535; + uint32_t conn_window_delta = h2_window - 65535; if (conn_window_delta > 0) { int wu_len = ml_h2_build_window_update(h2_init + pos, sizeof(h2_init) - pos, 0, conn_window_delta); @@ -689,7 +715,7 @@ static int do_h2_preface(microlink_t *ml, ml_noise_state_t *noise) { } ESP_LOGI(TAG, "H2 preface sent (%d bytes, conn window=%luKB)", - pos, (unsigned long)(ML_H2_BUFFER_SIZE / 1024)); + pos, (unsigned long)(h2_window / 1024)); /* Read and process server's response (SETTINGS, SETTINGS_ACK, WINDOW_UPDATE, etc.) */ uint8_t recv_buf[4096]; @@ -1495,13 +1521,22 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { /* Read MapResponse - accumulate ALL decrypted Noise frames first, then parse H2. * This is critical because a single H2 frame can span multiple Noise frames * (v1 does the same with h2_buffer). - * Smart timeout: extend to 60s for large tailnets (300+ peers = 240KB+). */ - uint8_t *h2_recv = ml_psram_malloc(ML_H2_BUFFER_SIZE); /* 512KB for 300+ peer tailnets */ + * Smart timeout: extend to 60s for large tailnets (300+ peers = 240KB+). + * + * Single PSRAM buffer for both the raw H2 receive and the compacted JSON -- + * once all frames are accumulated, DATA payloads are extracted in place via + * memmove() (see below) instead of copying into a second buffer, halving + * peak footprint. Sized to the runtime window choose_h2_rx_window_size() + * picked for this connect cycle (<= ML_H2_BUFFER_SIZE). Ported from + * djorr5/microlink's `67b230b2` piece (a); adapted to keep the per-iteration + * frame_buf scratch read (below) instead of reading straight into h2_recv -- + * this fork's noise_recv() returns -1 without draining the ciphertext off + * the socket when the frame doesn't fit the caller's buffer, which would + * desync the stream if the destination window shrinks near the tail end. */ + uint32_t h2_window = ml->h2_rx_window_size ? ml->h2_rx_window_size : ML_H2_BUFFER_SIZE; + uint8_t *h2_recv = ml_psram_malloc(h2_window); if (!h2_recv) return -1; size_t h2_total = 0; - - uint8_t *resp_buf = ml_psram_malloc(ML_JSON_BUFFER_SIZE); - if (!resp_buf) { free(h2_recv); return -1; } size_t json_total = 0; /* Set extended recv timeout for large MapResponse (60 seconds) */ @@ -1528,7 +1563,7 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { } /* Append decrypted data to h2_recv */ - if (h2_total + frame_len < ML_H2_BUFFER_SIZE) { + if (h2_total + frame_len < h2_window) { memcpy(h2_recv + h2_total, frame_buf, frame_len); h2_total += frame_len; window_consumed += frame_len; @@ -1596,7 +1631,10 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { (int)(h2_total / 1024), (unsigned long)(ml_get_time_ms() - recv_start_ms)); - /* Now parse complete H2 frames from accumulated buffer */ + /* Now parse complete H2 frames from accumulated buffer, compacting DATA + * payloads into the front of the SAME buffer (json_total <= fpos always, + * since fpos also advances past 9-byte frame headers and non-DATA frames + * that json_total never counts -- memmove handles the overlap safely). */ int fpos = 0; while (fpos + 9 <= (int)h2_total) { uint32_t f_len = (h2_recv[fpos] << 16) | (h2_recv[fpos + 1] << 8) | h2_recv[fpos + 2]; @@ -1617,15 +1655,12 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { } if (f_type == 0x00 && f_len > 0) { /* DATA frame */ - if (json_total + f_len < ML_JSON_BUFFER_SIZE) { - memcpy(resp_buf + json_total, h2_recv + fpos, f_len); - json_total += f_len; - } + memmove(h2_recv + json_total, h2_recv + fpos, f_len); + json_total += f_len; } fpos += f_len; } - free(h2_recv); /* Send connection-level WINDOW_UPDATE to replenish HTTP/2 flow control. * Stream 3 is already closed (END_STREAM received), so only update stream 0. @@ -1639,7 +1674,7 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { if (json_total == 0) { ESP_LOGW(TAG, "Empty MapResponse"); - free(resp_buf); + free(h2_recv); return -1; } @@ -1650,20 +1685,20 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { int dump = json_total < 32 ? (int)json_total : 32; char hexbuf[97]; for (int i = 0; i < dump; i++) { - sprintf(hexbuf + i * 3, "%02x ", resp_buf[i]); + sprintf(hexbuf + i * 3, "%02x ", h2_recv[i]); } hexbuf[dump * 3] = '\0'; ESP_LOGI(TAG, "MapResponse first %d bytes (hex): %s", dump, hexbuf); } /* Check for length prefix (Tailscale binary framing: 4-byte big-endian length before JSON) */ - char *parse_start = (char *)resp_buf; + char *parse_start = (char *)h2_recv; size_t parse_len = json_total; /* Find the start of JSON - look for '{' in first 8 bytes */ int json_offset = -1; for (int i = 0; i < 8 && i < (int)json_total; i++) { - if (resp_buf[i] == '{') { + if (h2_recv[i] == '{') { json_offset = i; break; } @@ -1686,7 +1721,7 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { if (!map_json) { const char *err = cJSON_GetErrorPtr(); ESP_LOGE(TAG, "MapResponse JSON parse failed near: %.50s", err ? err : "unknown"); - free(resp_buf); + free(h2_recv); return -1; } @@ -1884,7 +1919,7 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { } cJSON_Delete(map_json); - free(resp_buf); + free(h2_recv); int64_t t_map_done = esp_timer_get_time(); ESP_LOGI(TAG, "[TIMING] MapResponse recv+parse: %lld ms (total map: %lld ms, %dKB)", @@ -2354,6 +2389,7 @@ void ml_coord_task(void *arg) { * before sending our H2 preface. Extracts nodeKeyChallenge * and adjusts rx_nonce. */ process_proactive_frames(ml, &noise); + choose_h2_rx_window_size(ml); state = COORD_H2_PREFACE; break; diff --git a/components/microlink/src/ml_h2.c b/components/microlink/src/ml_h2.c index 0c7a229..d40810b 100644 --- a/components/microlink/src/ml_h2.c +++ b/components/microlink/src/ml_h2.c @@ -121,7 +121,7 @@ static int hpack_literal_new(uint8_t *out, const char *name, const char *value) * * Returns total bytes written, or -1 on error. */ -int ml_h2_build_preface(uint8_t *out, size_t out_size) { +int ml_h2_build_preface(uint8_t *out, size_t out_size, uint32_t window_size) { /* Preface (24) + SETTINGS frame header (9) + INITIAL_WINDOW_SIZE setting (6) = 39 bytes */ if (out_size < H2_PREFACE_LEN + 9 + 6) return -1; @@ -131,11 +131,11 @@ int ml_h2_build_preface(uint8_t *out, size_t out_size) { memcpy(out, H2_CONNECTION_PREFACE, H2_PREFACE_LEN); pos += H2_PREFACE_LEN; - /* SETTINGS frame with INITIAL_WINDOW_SIZE = ML_H2_BUFFER_SIZE + /* SETTINGS frame with INITIAL_WINDOW_SIZE = window_size (the caller's + * runtime-chosen H2 recv window, <= ML_H2_BUFFER_SIZE) * Each setting is 6 bytes: 2-byte ID + 4-byte value (RFC 7540 Section 6.5.1) * Without this, the server uses the HTTP/2 default of 65535 bytes (64KB), * which is too small for large MapResponses (100KB+ on 60+ peer tailnets). */ - uint32_t window_size = ML_H2_BUFFER_SIZE; pos += write_frame_header(out + pos, 6, H2_FRAME_SETTINGS, 0, 0); /* INITIAL_WINDOW_SIZE (0x04) */ From b31a34df2d4d7de4df906620c5c2984c097816b0 Mon Sep 17 00:00:00 2001 From: "Adrian.Nguyen-Qualgo" Date: Wed, 19 Aug 2026 22:59:46 +0700 Subject: [PATCH 2/2] docs(fork-prs): upgrade #38's H2 window fix to real-hardware verified Board became available mid-session -- re-flashed zen-clock (LilyGo T-Display-S3) against a real tailnet and captured the actual boot log through do_fetch_peers(): H2 rx window computed correctly (128KB, clamped to the configured ceiling since free heap was ample), a real 22KB/13-frame MapResponse reassembled and JSON-compacted in place without truncation or corruption, and all 5 real tailnet peers completed WireGuard handshakes normally afterward. Upgrades the earlier compile-only verification note. Co-Authored-By: Claude Sonnet 5 --- FORK_PRS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK_PRS.md b/FORK_PRS.md index 5815cc0..3225396 100644 --- a/FORK_PRS.md +++ b/FORK_PRS.md @@ -36,7 +36,7 @@ become its own scoped PR. Issue numbers filled in once created. | 16 | ✅ [#35](https://github.com/fugo101/microlink/issues/35) (done) | `Csontikka/microlink` | `7a24a9b3`, `4d3b3add` | DERP client backpressure/reconnect-storm fixes: don't tear down on TLS write backpressure, TCP_NODELAY + coalesced writes, fix a hot-spinning I/O loop, split into concurrent reader/writer tasks — check overlap against cplewes's DERP session-resumption fix (#6) before scoping. **3/4 pieces landed in `ml_derp.c`**: (1) `derp_tls_write_all`'s WANT_WRITE/WANT_READ/TIMEOUT retry budget 50→300 (500ms→3s) — the caller treated any write failure as fatal and forced a full reconnect, so transient TLS backpressure was triggering reconnect storms; genuine mbedtls errors still bail immediately, unchanged. (2) `TCP_NODELAY` set on the DERP socket post-upgrade (was unset — Nagle was adding up to 500ms of latency coalescing our own already-small frames) plus `derp_send_packet` rewritten to build header+dest_key+payload as one buffer/one TLS write instead of two, via the existing `ml_psram_malloc`. (3) `ml_derp_tx_task`'s end-of-loop `vTaskDelay(pdMS_TO_TICKS(1))` rounded to 0 ticks at this project's 100Hz tick rate, so it never actually paced an idle loop — replaced with a `did_work` flag (`taskYIELD()` when busy, real `vTaskDelay(10ms)` when idle) and raised the TX/RX per-loop batch caps 8→32 / 4→32 to match. Not literal cherry-picks — this fork's `ml_derp.c` has diverged (PSA crypto, different comment style, `ml_setsockopt`/`ml_psram_malloc` wrappers not present upstream) — hand-ported with equivalent logic. No overlap with the already-landed #14 (DERP TLS leak, `derp_free_tls_state`/teardown path) or #25 (TLS session resumption, `ml_derp_connect`'s handshake phase) fixes — different functions/line ranges, verified via `git log -- ml_derp.c`. **(4) reader/writer task split — investigated further, decided not to port** (distinct from "deferred, not yet done"): re-checked against PR #62's restructured `ml_derp_tx_task` (`did_work`/Phase 1 TX/Phase 2 RX/batch caps 32) and every `ml->derp.*` touch point fork-wide — `ssl`/`ssl_conf`/`saved_session`/`sockfd` are only ever touched inside `ml_derp.c`, no cross-task race exists today. Splitting would require real mbedTLS-layer synchronization (`mbedtls_ssl_read`/`_write` are not safe to call concurrently on the same `ssl` context from two threads, even one-reader/one-writer — shared internal buffers, record sequence numbers, session state) — either a dedicated mutex around every `mbedtls_ssl_*` call (reintroducing exactly the cost this fork's file header says it deliberately avoids: "eliminates the need for a TLS mutex since only one task touches the SSL context") or two independent BIO/session objects, plus a new queue to route the PING→PONG echo (`dispatch_derp_frame()`) through a writer task instead of calling it inline. The starvation this fix targets in `Csontikka/microlink`'s architecture is real there, but here it's bounded to the 3s write-retry budget under genuine backpressure only (rarer and non-fatal since piece 1 landed) — not the unbounded/hot-spinning case their fix addresses. Crediting `Csontikka/microlink` for a correct fix for *their* architecture; it doesn't transfer cleanly to this fork's diverged single-task DERP design. Confirmed no downstream impact: `/Users/nguyen.ndt/Projects/zen-clock` (pulls `fugo101/microlink ^3.0.0` from the registry) only uses `microlink_init/start/rebind`, no DERP-internal API — public surface unchanged either way. The "also touches `components/wireguard_lwip/`" note in the original scoping was a copy-paste artifact from #31/#34/#42's rows — `7a24a9b3`'s submodule changes are diagnostics-only (unrelated to these fixes) and `4d3b3add` doesn't touch the submodule at all; no submodule involvement here. | 2 | | 17 | ✅ [#36](https://github.com/fugo101/microlink/issues/36) (done) | `Csontikka/microlink` | `27806be3` | `microlink_stop()` never closed `derp.sockfd`/`coord_sock`, letting `derp_tx` outlive the teardown wait → UAF — reconcile against cplewes's teardown UAF fix (#2), may be the same root cause. **Investigated, not needed here**: this fork's `microlink_stop()` (from issue #21/#22's PR #47) never frees context until `ml_join_tasks()` proves every worker exited, and `ml_derp.c`'s blocking loops already call `ml_shutdown_pending()` on every SO_RCVTIMEO-bounded iteration (100-200ms) to bail out cooperatively — the source commit's "shutdown() the socket to force-wake a blocked task" fixes a UAF that doesn't exist here. Leaving open in case future review disagrees, but no port planned. | 2 | | 18 | [#37](https://github.com/fugo101/microlink/issues/37) | `Csontikka/microlink` | `cbdf1603`, `aad403af`, `533f1f88`, `46e34917`, `017b3588`, `372ca277` | H2 frame reassembly across `noise_recv()` read boundaries — large MapResponses spanning reads deterministically corrupt the stream ("implausible message size"); genuine protocol-correctness bug independent of control-plane backend. **Triaged into 4 pieces — 1/4 landed**: (1) `cbdf1603` (`https://` login_server + Headscale Noise key fetch), `aad403af` ("deliver the netmap from the long-poll stream, Headscale ≥0.26"), and `372ca277` (Headscale v0.28 single-stream compat) are all pure Headscale-control-plane compatibility, out of scope per `CLAUDE.md` (Tailscale-only) — excluded, no port. (2) `533f1f88` bundles two fixes: its stream-liveness watchdog (a second clock fed only by genuine stream-5 DATA frames, so a front end that keeps ACKing our PINGs can't hide a dead server-side mapSession) is real, backend-independent, and **landed** — this fork's existing watchdog (`last_activity_ms`) resets on any inbound *or even outbound-send* activity, so it had exactly this blind spot. Its second half (reorder `ML_EVT_COORD_REGISTERED` to fire after the streamed netmap, not before) is **not applicable**: that race only exists on Csontikka's Headscale ≥0.26 empty-initial-fetch path; this fork's `do_fetch_peers()` always populates `ml->vpn_ip` synchronously before returning, so `ML_EVT_COORD_REGISTERED` already fires after the VPN IP is known — skipped. (3) `46e34917` (DERP relay liveness + peer sweep, 6 sub-fixes) reconciled against #14/#25/#35's landed DERP fixes: TLS-leak-on-failed-connect and DERP-region-fallback are **already covered** (this fork's #14 fix is a superset — also validates `mbedtls_ssl_config_defaults`/`set_hostname`, and its region fallback additionally skips `avoid`-flagged regions and re-derives fresh each connect instead of permanently committing to the first fallback found — no port needed, source version would actually regress both). The zero-conflict pieces **landed** (`ml_coord.c`/`ml_peer_nvs.c`/`ml_wg_mgr.c`, none overlapping #14/#25/PR #62's line ranges): authoritative-peer-list sweep (a full `Peers`/`peers` list is authoritative — entries absent from it are queued for removal, since ACL revocation was previously only reachable via an explicit `PeersRemoved` array), peer removal now also drops the NVS cache entry (`ml_peer_nvs_remove()`, new) so revoked peers stay gone across reboots, and a DISCO decrypt-fail log naming the claimed sender + arrival path. **Deferred**: the retry-forever DERP backoff and the RX-liveness watchdog (90s silence → reconnect) are real gaps but land in the exact `ml_derp_tx_task()` region PR #62 just restructured (`did_work`/Phase 1/Phase 2/pacing) — needs careful hand-placement against that structure, not a blind patch; scope as a follow-up. (4) `017b3588`, the actual H2-reassembly fix this row was originally filed for, is large (287+/-101 lines) and rewrites `do_map_exchange()` — the same hottest, most failure-sensitive control-plane parsing path already flagged as needing hardware to verify for issue #38 — deferred to a dedicated session with real testing. | 2 | -| 19 | ✅ [#38](https://github.com/fugo101/microlink/issues/38) (done), [#39](https://github.com/fugo101/microlink/issues/39) | [`djorr5/microlink`](https://github.com/djorr5/microlink) | `67b230b2` | Two independent changes, split into two issues: (a) dynamic H2 RX window sizing based on free heap for RAM-constrained boards, roughly halving MapResponse-parsing PSRAM footprint; (b) `ip4_route_src_hook` to force tailnet-range traffic onto the WG netif directly. **(a) landed, hardware-verified via `zen-clock`** (a real downstream consumer at `/Users/nguyen.ndt/Projects/zen-clock`, LilyGo T-Display-S3/ESP32-S3, tested with `override_path` pointing at this working tree): `do_fetch_peers()` now allocates a single PSRAM buffer sized to a runtime window (`ml->h2_rx_window_size`, chosen by new `choose_h2_rx_window_size()` in `ml_coord.c` — largest free block across SPIRAM/internal, minus a 32KB margin, clamped to [64KB, `ML_H2_BUFFER_SIZE_KB`]) instead of two fixed 512KB buffers; the extracted MapResponse JSON is compacted in place via `memmove` rather than copied into a second buffer, halving peak footprint (~1MB → ~512KB at defaults, less under heap pressure). `ml_h2_build_preface()`'s signature now takes the window size instead of hardcoding `ML_H2_BUFFER_SIZE`. `CONFIG_ML_JSON_BUFFER_SIZE_KB` removed (now unused — the merged buffer only has one size knob). **Adapted, not a literal port**: kept the existing per-iteration `frame_buf` scratch-then-copy pattern in the initial H2 receive loop instead of reading `noise_recv()` straight into `h2_recv + h2_total` as the source commit does — this fork's `noise_recv()` returns -1 without draining the ciphertext off the socket when a frame doesn't fit the caller's buffer, which would desync the stream if the destination window shrinks near the tail; the source fork's `noise_recv()` may handle that case differently, but porting the direct-read change here would introduce a stream-desync class of bug this fork didn't have before. Verified via a from-clean `pio run` against zen-clock with `microlink`'s `idf_component.yml` `override_path` pointed at this repo — full firmware build + link succeeded (bootloader + app, RAM 17.5%/Flash 45.1% on the T-Display-S3), confirming the patch compiles and links cleanly against a real downstream consumer's actual usage (`microlink_init/start/get_state/rebind`, `enable_derp=true`). The adaptive clamp-under-pressure branch itself (choosing below the compiled ceiling) was **not** exercised on hardware — zen-clock's board has PSRAM fully enabled and free, so it doesn't naturally hit the low-heap path; only the default/no-pressure path (window == ceiling) was observed running for real. **(b) untouched, still open** — no change to its assessment above; not investigated further this session. | 2 | +| 19 | ✅ [#38](https://github.com/fugo101/microlink/issues/38) (done), [#39](https://github.com/fugo101/microlink/issues/39) | [`djorr5/microlink`](https://github.com/djorr5/microlink) | `67b230b2` | Two independent changes, split into two issues: (a) dynamic H2 RX window sizing based on free heap for RAM-constrained boards, roughly halving MapResponse-parsing PSRAM footprint; (b) `ip4_route_src_hook` to force tailnet-range traffic onto the WG netif directly. **(a) landed, hardware-verified via `zen-clock`** (a real downstream consumer at `/Users/nguyen.ndt/Projects/zen-clock`, LilyGo T-Display-S3/ESP32-S3, tested with `override_path` pointing at this working tree): `do_fetch_peers()` now allocates a single PSRAM buffer sized to a runtime window (`ml->h2_rx_window_size`, chosen by new `choose_h2_rx_window_size()` in `ml_coord.c` — largest free block across SPIRAM/internal, minus a 32KB margin, clamped to [64KB, `ML_H2_BUFFER_SIZE_KB`]) instead of two fixed 512KB buffers; the extracted MapResponse JSON is compacted in place via `memmove` rather than copied into a second buffer, halving peak footprint (~1MB → ~512KB at defaults, less under heap pressure). `ml_h2_build_preface()`'s signature now takes the window size instead of hardcoding `ML_H2_BUFFER_SIZE`. `CONFIG_ML_JSON_BUFFER_SIZE_KB` removed (now unused — the merged buffer only has one size knob). **Adapted, not a literal port**: kept the existing per-iteration `frame_buf` scratch-then-copy pattern in the initial H2 receive loop instead of reading `noise_recv()` straight into `h2_recv + h2_total` as the source commit does — this fork's `noise_recv()` returns -1 without draining the ciphertext off the socket when a frame doesn't fit the caller's buffer, which would desync the stream if the destination window shrinks near the tail; the source fork's `noise_recv()` may handle that case differently, but porting the direct-read change here would introduce a stream-desync class of bug this fork didn't have before. **Flashed and confirmed working on real hardware** (LilyGo T-Display-S3, real tailnet `husky-firefighter.ts.net`) via `pio run -t upload` against zen-clock with `override_path` pointed at this working tree. Boot log against a real, non-trivial MapResponse (5 peers, spanning 13 Noise frames): `H2 rx window: 128KB (largest free block 6144KB, ceiling 128KB)` → `H2 preface sent (61 bytes, conn window=128KB)` → `H2 END_STREAM detected after 13 Noise frames (22KB, 1189ms)` → `Accumulated 22KB of H2 data from Noise frames` → `MapResponse JSON: 22592 bytes` → JSON parsed cleanly, `Peers (array, size=5)` — no truncation warnings, no crashes, no heap corruption. All 5 real tailnet peers then completed WireGuard handshakes (direct + DERP) normally, confirming the consolidated single-buffer + in-place `memmove` compaction didn't corrupt the accumulated H2/JSON data. The adaptive clamp-under-pressure branch itself (choosing *below* the compiled ceiling) was **not** exercised — this board's free PSRAM (6144KB largest block) is far above the 128KB ceiling it's configured with, so the window always resolved to the full ceiling; only the default/no-pressure path was observed running for real. **(b) untouched, still open** — no change to its assessment above; not investigated further this session. | 2 | | 20 | ✅ [#40](https://github.com/fugo101/microlink/issues/40) (done) | [`AELovelace/LAIN-MicrolinkRouter`](https://github.com/AELovelace/LAIN-MicrolinkRouter) | `e1239460` | `microlink_set_exit_node()`. **Scoped down from the row's original framing after investigation, approved by the user**: this is not a Tailscale-style default-route exit node (all traffic incl. general internet through a peer) and not a LAN-wide NAPT exit node (other LAN devices' traffic via SoftAP) — neither exists anywhere in the source commit or this fork. What it actually is: a **tailnet-range fallback peer** — the peer table is bounded by `CONFIG_ML_MAX_PEERS`, so a designated peer acts as a fallback router for `100.64.0.0/10` destinations not present in the local table; the WG netif's mask stays narrow always (never widens to `0.0.0.0/0` — the source commit's own comment documents this as itself a fix for an earlier, more dangerous version that caused a real outage). General internet traffic is unaffected either way. `microlink_get_netif_impl()` is a hook for a future real NAPT exit-node feature, not included here. `microlink_set_exit_node()`/`microlink_get_netif_impl()`, `wg_program_peer_route()`/`wg_apply_exit_node()`, and the 5s peer-liveness safety net landed in `microlink.h`/`microlink_internal.h`/`microlink.c`/`ml_wg_mgr.c`. The hard prerequisite — a longest-prefix-match fix in `peer_lookup_by_allowed_ip()` (without it, a `0.0.0.0/0` fallback route can shadow a more specific peer's `/32` depending on array order) — landed and released in the submodule: [`fugo101/wireguard-lwip#17`](https://github.com/fugo101/wireguard-lwip/pull/17), merged and released as v1.0.3. Submodule pointer bumped here; `idf_component.yml`'s `fugo101/wireguard_lwip: "^1.0.0"` pin already covered 1.0.3 (verified live on the ESP Component Registry), so no manifest edit needed — same reasoning as issues #34/#42/#43. Explicitly **not** ported: the WiFi-STA outbound socket binding (solves a problem that only exists with a widened netmask, which this design never does — porting it would regress cellular failover), `nacl_box.c` changes (unrelated crypto refactor bundled in by accident, includes a bounds-check removal that reads as a regression), `ml_stun.c` changes (cosmetic no-op). | 2 | | 21 | ✅ [#41](https://github.com/fugo101/microlink/issues/41) (done) | [`caslavskola/microlink`](https://github.com/caslavskola/microlink) | `0265718e` | PSA crypto init migration for mbedTLS 4.x. **Investigated, not needed**: ESP-IDF 6.x's own `mbedtls` component already calls `psa_crypto_init()` automatically at boot (`ESP_SYSTEM_INIT_FN`, priority 104, `components/mbedtls/port/esp_psa_crypto_init.c`) — an explicit call in `microlink_init()` would be pure redundancy. The commit also rewrites `ml_noise.c`'s ChaCha20-Poly1305 from `mbedtls_chachapoly_*` to raw PSA `psa_aead_encrypt/decrypt` calls, but this fork already has a working, cleaner solution via `mbedtls/private/chachapoly.h` (documented in `ESP_IDF_6X_COMPAT.md`) — the source commit's version is messier (leftover `//to remove` debug logging, commented-out ChatGPT-added diagnostics) and fixes nothing we're missing. No port planned. | 2 | | 22 | ✅ [#42](https://github.com/fugo101/microlink/issues/42) (done) | `caslavskola/microlink` | `9ac49212` | Peer endpoint tracking dropped the "packet arrived via DERP" signal after the first packet, causing replies to attempt bad direct routing — genuine bug, but the commit bundles debug cruft and a divergent netif-flag rewrite; extract just the DERP-routing-flag fix. Adapted (extracted just the `last_rx_via_derp` flag, skipped the `netif_set_link_up/down` rewrite and debug logging) and landed in `wireguard_lwip`: [`fugo101/wireguard-lwip#15`](https://github.com/fugo101/wireguard-lwip/pull/15) (same PR as issue #34), merged and released as v1.0.2. Submodule pointer bumped here; no `idf_component.yml` edit needed (see row 15). | 2 |