From b8a444eccb74ac0128c67e5e625e6f43f133d90e Mon Sep 17 00:00:00 2001 From: "Adrian.Nguyen-Qualgo" Date: Wed, 19 Aug 2026 10:04:00 +0700 Subject: [PATCH] fix(derp): DERP TLS session resumption, longer connect timeout, upgrade backoff Adapted from cplewes/microlink@b9636816 (not a literal cherry-pick: git apply conflicted only on microlink_internal.h's ml_derp_conn_t, which lacks the entropy/ctr_drbg fields the source commit anchors near since this fork uses PSA crypto post-mbedTLS-4.x migration -- same divergence already handled for issue #14/PR #44. ml_derp.c and ml_wg_mgr.c applied cleanly; hand-ported with identical logic/comments throughout). - DERP TLS session resumption: save the negotiated mbedTLS session after each successful handshake and resume it on the next reconnect, skipping the full ECDHE handshake (~7.5s -> sub-second). Best-effort: falls back to a full handshake transparently if the ticket expired. - DERP_CONNECT_TIMEOUT_MS 10s -> 25s: the TLS handshake was observed taking ~7.5s+ and occasionally exceeding 10s on a lossy/high-latency captive network, tripping spurious "TLS handshake failed: timed out" -> full-retry thrash. - Per-peer exponential backoff on direct-path upgrade probes: on a P2P-hostile network (blocks peer-to-peer UDP) direct-path upgrades never succeed, so re-probing every 15s forever wastes CPU + DERP TX. Doubles the interval on each unanswered probe up to a 300s cap, resets to the base interval once a direct path is established. Closes fugo101/microlink#25 Co-authored-by: Adrian.Nguyen-Qualgo Co-authored-by: Claude Sonnet 5 --- FORK_PRS.md | 2 +- .../microlink/include/microlink_internal.h | 16 ++++++++ components/microlink/src/ml_derp.c | 38 +++++++++++++++++-- components/microlink/src/ml_wg_mgr.c | 15 +++++++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/FORK_PRS.md b/FORK_PRS.md index 2449414..946de0c 100644 --- a/FORK_PRS.md +++ b/FORK_PRS.md @@ -23,7 +23,7 @@ become its own scoped PR. Issue numbers filled in once created. | 3 | ✅ [#22](https://github.com/fugo101/microlink/issues/22) (done) | [`cplewes/microlink`](https://github.com/cplewes/microlink) | `7120dfa4` | Three crash sites triggered by captive-portal DNS failures: DERP double-init dangling pointer, `ml_coord_task` touching a freed event group post-destroy, corrupt-state races during parallel teardown. Landed together with #21 in the same PR — `a415d646` (the commit for #21) supersedes most of `7120dfa4`'s NULL-guard approach with a proper liveness bitmask; only the DERP mbedTLS double-init fix from `7120dfa4` was a distinct, still-needed piece. | 1 | | 4 | [#23](https://github.com/fugo101/microlink/issues/23) | [`cplewes/microlink`](https://github.com/cplewes/microlink) | `8f2ff39b`, `5c7303b4` | Captive-portal detection (HTTP 302 / TCP close) in `ml_coord.c`, backs off 5 min instead of retrying every 16s | 1 | | 5 | ✅ [#24](https://github.com/fugo101/microlink/issues/24) (done) | [`cplewes/microlink`](https://github.com/cplewes/microlink) | `9f6af750` | Yield 1 tick per peer in `disco_periodic_probes` — prevents starving other same-core tasks on large tailnets | 1 | -| 6 | [#25](https://github.com/fugo101/microlink/issues/25) | [`cplewes/microlink`](https://github.com/cplewes/microlink) | `b9636816` | DERP TLS session resumption (skip full ECDHE handshake on reconnect, ~7.5s→sub-second), connect timeout 10s→25s, per-peer exponential backoff on direct-path upgrade probes | 1 | +| 6 | ✅ [#25](https://github.com/fugo101/microlink/issues/25) (done) | [`cplewes/microlink`](https://github.com/cplewes/microlink) | `b9636816` | DERP TLS session resumption (skip full ECDHE handshake on reconnect, ~7.5s→sub-second), connect timeout 10s→25s, per-peer exponential backoff on direct-path upgrade probes. Not a literal cherry-pick — `git apply` conflicted only on `microlink_internal.h`'s `ml_derp_conn_t`, which lacks the `entropy`/`ctr_drbg` fields the source commit anchors near (this fork uses PSA crypto, same divergence as issue #14); hand-ported with identical logic/comments, `ml_derp.c`/`ml_wg_mgr.c` applied cleanly. | 1 | | 7 | [#26](https://github.com/fugo101/microlink/issues/26) | [`antmanler/microlink`](https://github.com/antmanler/microlink) (branch `duoduo-edge`) | `2a7ba328` | `wg_udp_output_cb` thread-safety bug (raw UDP PCB called from two task contexts, corrupts heap) — same bug class as absorbed upstream PR #20, opposite direction; bundled with WG/DISCO RX queue depth increase (8→32/16) and a handshake-gating bug in `process_disco_pong` that permanently blocked sessions | 1 | | 8 | ✅ [#27](https://github.com/fugo101/microlink/issues/27) (done) | [`antmanler/microlink`](https://github.com/antmanler/microlink) | `6ef9f5a0` | New `CONFIG_ML_CONFIG_HTTPD` Kconfig toggle to skip the port-80 httpd task (~7-8KB RAM saved); peer-update queue depth fix 400→32 (was allowing an unbounded ~80KB internal-RAM burst). Not a literal cherry-pick — `git apply` failed on unrelated line drift from PR #47's teardown rewrite, so hand-ported with identical logic/comments. | 1 | | 9 | [#28](https://github.com/fugo101/microlink/issues/28) | [`antmanler/microlink`](https://github.com/antmanler/microlink) | `2a7ba328` | WG netif MTU 1420→1280 (Tailscale-standard) — needs verification against our existing `CONFIG_LWIP_IP4_FRAG`/`IP4_REASSEMBLY=y` before deciding it's still needed | 1 | diff --git a/components/microlink/include/microlink_internal.h b/components/microlink/include/microlink_internal.h index 3db2416..ae53b0b 100644 --- a/components/microlink/include/microlink_internal.h +++ b/components/microlink/include/microlink_internal.h @@ -90,6 +90,12 @@ extern "C" { #define ML_DISCO_TRUST_DURATION_MS 15000 #define ML_DISCO_PING_TIMEOUT_MS 5000 #define ML_DISCO_UPGRADE_INTERVAL_MS 15000 +/* Cap for the per-peer direct-path upgrade backoff. On a P2P-hostile network + * (e.g. healthspot blocks peer-to-peer UDP) direct-path upgrades NEVER + * succeed, so re-probing every 15s forever is pure waste (CPU + DERP TX). We + * double each peer's upgrade interval on every failed attempt up to this cap, + * resetting to the base interval when a direct path DOES establish. */ +#define ML_DISCO_UPGRADE_BACKOFF_MAX_MS 300000 #define ML_DISCO_SESSION_ACTIVE_MS 45000 /* STUN servers (Tailscale primary, Google fallback) */ @@ -279,6 +285,7 @@ typedef struct { uint64_t trust_until_ms; /* Direct path trusted until */ uint64_t last_send_ms; /* Last data sent to this peer */ uint64_t last_upgrade_ms; /* Last path upgrade attempt */ + uint32_t upgrade_interval_ms; /* Current direct-path upgrade backoff (grows on repeated failure, resets on success) */ /* Best direct path */ uint32_t best_ip; @@ -343,6 +350,15 @@ typedef struct { int sockfd; /* Raw TCP socket */ mbedtls_ssl_context ssl; /* TLS context (owned exclusively by DERP I/O task) */ mbedtls_ssl_config ssl_conf; + /* Saved TLS session for resumption across DERP reconnects. On a flaky + * network DERP drops+reconnects a lot; without this every reconnect paid + * the full ~7.5s handshake. mbedTLS client session tickets are enabled + * (CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS) and tailscale's derper + * supports resumption, so a resumed handshake skips ECDHE -> sub-second. + * Best-effort: if the ticket expired the server falls back to a full + * handshake transparently. */ + mbedtls_ssl_session saved_session; + bool have_saved_session; bool connected; uint64_t last_recv_ms; /* For keepalive watchdog */ } ml_derp_conn_t; diff --git a/components/microlink/src/ml_derp.c b/components/microlink/src/ml_derp.c index d36ac2f..2b4f8aa 100644 --- a/components/microlink/src/ml_derp.c +++ b/components/microlink/src/ml_derp.c @@ -34,8 +34,13 @@ static const char *TAG = "ml_derp"; -/* Timeout for DERP connection handshake operations */ -#define DERP_CONNECT_TIMEOUT_MS 10000 +/* Timeout for DERP connection handshake operations. Bumped 10s -> 25s: + * on a lossy/high-latency captive network (healthspot) the TLS handshake + * itself was observed taking ~7.5s and occasionally exceeding 10s under + * retransmits, tripping "TLS handshake failed: timed out" -> full-retry + * thrash. 25s lets a slow-but-valid handshake complete. Steady-state + * frame polling still uses a 200ms read timeout (set after connect). */ +#define DERP_CONNECT_TIMEOUT_MS 25000 /* ============================================================================ * Custom BIO callbacks for TLS I/O @@ -838,7 +843,19 @@ esp_err_t ml_derp_connect(microlink_t *ml) { mbedtls_ssl_set_bio(&ml->derp.ssl, &ml->derp.sockfd, ml_derp_bio_send, ml_derp_bio_recv, NULL); - /* TLS handshake - socket has 10s SO_RCVTIMEO from connect phase. */ + /* Resume a prior TLS session if we saved one from a previous DERP + * connection (saved_session survives derp_free_tls_state across a reco). + * A resumed handshake skips ECDHE -> sub-second instead of ~7.5s. */ + if (ml->derp.have_saved_session) { + int sret = mbedtls_ssl_set_session(&ml->derp.ssl, &ml->derp.saved_session); + if (sret == 0) { + ESP_LOGI(TAG, "DERP TLS: resuming saved session"); + } else { + ESP_LOGW(TAG, "DERP TLS: set_session -0x%04x, doing full handshake", -sret); + } + } + + /* TLS handshake - socket has SO_RCVTIMEO from connect phase. */ int ret; while ((ret = mbedtls_ssl_handshake(&ml->derp.ssl)) != 0) { if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) { @@ -862,6 +879,21 @@ esp_err_t ml_derp_connect(microlink_t *ml) { ESP_LOGI(TAG, "[TIMING] DERP TLS handshake: %lld ms", (t_derp_tls - t_derp_tcp) / 1000); ESP_LOGI(TAG, "TLS connected to DERP"); + /* Save the negotiated session so the NEXT reconnect can resume it and + * skip the expensive full handshake. get_session deep-copies the ticket, + * so free any prior copy first. Best-effort: on failure we just do a full + * handshake next time. */ + if (ml->derp.have_saved_session) { + mbedtls_ssl_session_free(&ml->derp.saved_session); + ml->derp.have_saved_session = false; + } + mbedtls_ssl_session_init(&ml->derp.saved_session); + if (mbedtls_ssl_get_session(&ml->derp.ssl, &ml->derp.saved_session) == 0) { + ml->derp.have_saved_session = true; + } else { + mbedtls_ssl_session_free(&ml->derp.saved_session); + } + /* HTTP Upgrade: GET /derp with Upgrade: DERP header */ char upgrade_req[256]; snprintf(upgrade_req, sizeof(upgrade_req), diff --git a/components/microlink/src/ml_wg_mgr.c b/components/microlink/src/ml_wg_mgr.c index 47d4695..2a1ef48 100644 --- a/components/microlink/src/ml_wg_mgr.c +++ b/components/microlink/src/ml_wg_mgr.c @@ -462,6 +462,7 @@ static int add_peer(microlink_t *ml, const ml_peer_update_t *update) { p->trust_until_ms = 0; p->last_send_ms = 0; p->last_upgrade_ms = 0; + p->upgrade_interval_ms = ML_DISCO_UPGRADE_INTERVAL_MS; p->has_direct_path = false; p->best_ip = 0; p->best_port = 0; @@ -934,6 +935,9 @@ static void process_disco_pong(microlink_t *ml, const ml_rx_packet_t *pkt, p->best_port = pkt->src_port; p->has_direct_path = true; p->trust_until_ms = now + ML_DISCO_TRUST_DURATION_MS; + /* Direct path established -> reset the upgrade backoff so a later + * loss + re-probe starts fast again. */ + p->upgrade_interval_ms = ML_DISCO_UPGRADE_INTERVAL_MS; /* Update WireGuard endpoint to direct path. * Always update the stored endpoint. Only force a handshake if we @@ -1475,11 +1479,20 @@ static void disco_periodic_probes(microlink_t *ml) { /* Probe for direct path upgrade (every UPGRADE_INTERVAL when on DERP). * Skip on cellular: direct paths impossible through carrier-grade NAT. * Throttled to DISCO_PROBES_PER_TICK to spread load and reduce jitter. */ + uint32_t up_interval = p->upgrade_interval_ms ? p->upgrade_interval_ms + : ML_DISCO_UPGRADE_INTERVAL_MS; if (!ml_at_socket_is_ready() && !p->has_direct_path && - now - p->last_upgrade_ms > ML_DISCO_UPGRADE_INTERVAL_MS) { + now - p->last_upgrade_ms > up_interval) { if (upgrade_probes_sent < DISCO_PROBES_PER_TICK) { disco_send_ping_to_peer(ml, i, false); p->last_upgrade_ms = now; + /* Back off: this upgrade attempt is (so far) unanswered. Double + * the interval up to the cap so a P2P-hostile network doesn't + * make us re-probe every 15s forever. A direct pong resets it + * to the base interval (see the DISCO pong handler). */ + uint32_t next = up_interval * 2; + if (next > ML_DISCO_UPGRADE_BACKOFF_MAX_MS) next = ML_DISCO_UPGRADE_BACKOFF_MAX_MS; + p->upgrade_interval_ms = next; upgrade_probes_sent++; } }