From 1e3c8fac3105fca9cb2cd4f907adbc829fa00123 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Tue, 21 Jul 2026 23:48:00 +0900 Subject: [PATCH 01/12] Add build-time custom control plane config with TLS transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headscale/Ionscale deployments commonly expose the control plane behind an HTTPS-only listener on port 443, which MicroLink's plain-TCP port-80 coordination transport cannot reach. This adds three Kconfig options to make custom control planes usable at build time: - CONFIG_ML_CTRL_HOST: coordination server hostname (build-time counterpart of the NVS web-UI override; useful for pre-provisioned firmware and when the config server is disabled) - CONFIG_ML_CTRL_TLS: wrap the ts2021 Noise handshake in TLS on port 443 (new src/ml_coord_tls.c, following the same mbedTLS custom-BIO pattern as the DERP connection). The server certificate is not verified; the control plane is authenticated by the pinned Noise key, the same trust model as the plain port-80 transport. Disabled by default — behavior is unchanged unless enabled. - CONFIG_ML_CTRL_NOISE_PUBKEY_HEX: pin a custom control plane Noise public key (from https:///key?v=88), replacing the built-in Tailscale key. Tested against a Headscale-based control plane (443/TLS only): TLS handshake, Noise handshake and registration path build on ESP-IDF v5.4.4 / esp32s3. --- README.md | 19 +++ components/microlink/CMakeLists.txt | 1 + components/microlink/Kconfig | 31 ++++ .../microlink/include/microlink_internal.h | 29 ++++ components/microlink/src/ml_coord.c | 123 ++++++++++---- components/microlink/src/ml_coord_tls.c | 154 ++++++++++++++++++ 6 files changed, 323 insertions(+), 34 deletions(-) create mode 100644 components/microlink/src/ml_coord_tls.c diff --git a/README.md b/README.md index 9f5c6b0..42e98a9 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,25 @@ MicroLink supports custom coordination servers like [Headscale](https://github.c **Configuration:** Set the control plane host via the HTTP config server web UI (Device Settings → Control Plane Host) or programmatically via `ctrl_host` in the config struct. +**Build-time configuration:** For pre-provisioned firmware, or when the HTTP config server is disabled, the control plane can also be set at build time: + +```ini +# sdkconfig.defaults +CONFIG_ML_CTRL_HOST="headscale.example.com" + +# Control planes that only expose an HTTPS listener (e.g. Headscale behind +# a reverse proxy / load balancer on port 443) cannot accept MicroLink's +# default plain-TCP port-80 transport. This wraps the ts2021 Noise +# handshake in TLS on port 443 instead. The server certificate is not +# verified; the control plane is authenticated by the pinned Noise public +# key below (same trust model as the plain port-80 transport). +CONFIG_ML_CTRL_TLS=y + +# The control plane's Noise public key, from +# https:///key?v=88 → "publicKey":"mkey:" (64-char hex) +CONFIG_ML_CTRL_NOISE_PUBKEY_HEX="..." +``` + **Server key:** MicroLink automatically fetches the server's Noise public key from the `/key` endpoint via HTTPS. No manual key configuration required. **Notes:** diff --git a/components/microlink/CMakeLists.txt b/components/microlink/CMakeLists.txt index 35b0bda..f4441c1 100644 --- a/components/microlink/CMakeLists.txt +++ b/components/microlink/CMakeLists.txt @@ -1,6 +1,7 @@ set(SRCS "src/microlink.c" "src/ml_coord.c" + "src/ml_coord_tls.c" "src/ml_derp.c" "src/ml_net_io.c" "src/ml_wg_mgr.c" diff --git a/components/microlink/Kconfig b/components/microlink/Kconfig index a8e0a31..a6515e1 100644 --- a/components/microlink/Kconfig +++ b/components/microlink/Kconfig @@ -287,6 +287,37 @@ menu "MicroLink V2 Configuration" endmenu + menu "Control Plane" + + config ML_CTRL_HOST + string "Control plane hostname" + default "controlplane.tailscale.com" + help + Coordination server hostname. Set to a Headscale-compatible + host (e.g. Lolipop Zero Trust Link controller) to use a + custom control plane. NVS runtime override still wins. + + config ML_CTRL_TLS + bool "Connect to control plane over TLS (port 443)" + default n + help + Wrap the ts2021 Noise handshake in TLS on port 443 instead + of plain TCP on port 80. Required for control planes that + only expose an HTTPS listener (e.g. Headscale behind an LB). + The server certificate is NOT verified; the control plane is + authenticated by the pinned Noise public key instead, same + trust model as Tailscale's plain port-80 transport. + + config ML_CTRL_NOISE_PUBKEY_HEX + string "Control plane Noise public key (hex)" + default "" + help + 64-char hex of the control plane's Noise (machine) public key, + from https:///key?v=88 → "publicKey":"mkey:". + Empty = use the built-in Tailscale key. + + endmenu + menu "HTTP Config Server" config ML_ENABLE_CONFIG_HTTPD diff --git a/components/microlink/include/microlink_internal.h b/components/microlink/include/microlink_internal.h index add69c7..481483c 100644 --- a/components/microlink/include/microlink_internal.h +++ b/components/microlink/include/microlink_internal.h @@ -79,7 +79,11 @@ extern "C" { #define ML_DERP_PORT 443 /* Tailscale control plane */ +#ifdef CONFIG_ML_CTRL_HOST +#define ML_CTRL_HOST CONFIG_ML_CTRL_HOST +#else #define ML_CTRL_HOST "controlplane.tailscale.com" +#endif #define ML_CTRL_PORT 443 #define ML_CTRL_PROTOCOL_VER 131 @@ -330,6 +334,19 @@ typedef struct { uint64_t last_recv_ms; /* For keepalive watchdog */ } ml_derp_conn_t; +/* ============================================================================ + * Control Plane TLS State (CONFIG_ML_CTRL_TLS) + * ========================================================================== */ + +typedef struct { + mbedtls_ssl_context ssl; /* Owned exclusively by coord task */ + mbedtls_ssl_config ssl_conf; + mbedtls_entropy_context entropy; + mbedtls_ctr_drbg_context ctr_drbg; + int sockfd; /* Copy of coord_sock for the BIO callbacks */ + bool active; /* TLS session established */ +} ml_coord_tls_t; + /* ============================================================================ * Main Context * ========================================================================== */ @@ -379,6 +396,9 @@ struct microlink_s { /* Coordination socket (owned exclusively by coord task) */ int coord_sock; +#ifdef CONFIG_ML_CTRL_TLS + ml_coord_tls_t coord_tls; +#endif uint32_t h2_next_stream_id; /* Next H2 stream ID for endpoint updates (odd, starts at 7) */ /* WireGuard netif (owned exclusively by wg_mgr task) */ @@ -488,6 +508,15 @@ bool ml_stun_parse_response(const uint8_t *data, size_t len, bool ml_stun_parse_response_ipv6(const uint8_t *data, size_t len, uint8_t *out_ip6, uint16_t *out_port); +/* ml_coord_tls.c (CONFIG_ML_CTRL_TLS) */ +#ifdef CONFIG_ML_CTRL_TLS +int ml_coord_tls_handshake(microlink_t *ml, const char *hostname); +int ml_coord_tls_send(microlink_t *ml, const uint8_t *data, size_t len); +int ml_coord_tls_recv(microlink_t *ml, uint8_t *buf, size_t len); +size_t ml_coord_tls_pending(microlink_t *ml); +void ml_coord_tls_free(microlink_t *ml); +#endif + /* ml_noise.c */ void ml_noise_init(ml_noise_state_t *state, const uint8_t *local_private, const uint8_t *local_public, diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index 8da8d4d..efb68aa 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -85,11 +85,40 @@ static int hex_to_bytes(const char *hex, uint8_t *bytes, size_t max_len) { * TCP I/O helpers for coord socket (owned exclusively by this task) * ========================================================================== */ +/* Single read from the coord transport (recv() semantics). + * With CONFIG_ML_CTRL_TLS the Noise byte stream runs inside TLS records; + * timeouts still surface as -1/EAGAIN via the TLS BIO. */ +static int coord_raw_recv(microlink_t *ml, uint8_t *buf, size_t len) { +#ifdef CONFIG_ML_CTRL_TLS + return ml_coord_tls_recv(ml, buf, len); +#else + return ml_recv(ml->coord_sock, buf, len, 0); +#endif +} + +/* Close the coord connection (TLS session first, then the socket) */ +static void coord_close_conn(microlink_t *ml) { +#ifdef CONFIG_ML_CTRL_TLS + ml_coord_tls_free(ml); +#endif + if (ml->coord_sock >= 0) { + ml_close_sock(ml->coord_sock); + ml->coord_sock = -1; + } +} + static int coord_send(microlink_t *ml, const uint8_t *data, size_t len) { /* Set send timeout to prevent indefinite blocking (v1 uses MSG_DONTWAIT) */ struct timeval snd_tv = { .tv_sec = 5, .tv_usec = 0 }; ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_SNDTIMEO, &snd_tv, sizeof(snd_tv)); +#ifdef CONFIG_ML_CTRL_TLS + if (ml_coord_tls_send(ml, data, len) < 0) { + ESP_LOGE(TAG, "coord_send (TLS) failed: len=%d errno=%d", (int)len, errno); + return -1; + } + return 0; +#else size_t sent = 0; while (sent < len) { int n = ml_send(ml->coord_sock, data + sent, len - sent, 0); @@ -101,13 +130,14 @@ static int coord_send(microlink_t *ml, const uint8_t *data, size_t len) { sent += n; } return 0; +#endif } static int coord_recv(microlink_t *ml, uint8_t *buf, size_t len) { size_t recvd = 0; int retries = 0; while (recvd < len) { - int n = ml_recv(ml->coord_sock, buf + recvd, len - recvd, 0); + int n = coord_raw_recv(ml, buf + recvd, len - recvd); if (n <= 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { if (recvd == 0) { @@ -227,7 +257,13 @@ static int do_tcp_connect(microlink_t *ml) { struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM }; struct addrinfo *res = NULL; - if (ml_getaddrinfo(CTRL_HOST(ml), "80", &hints, &res) != 0 || !res) { +#ifdef CONFIG_ML_CTRL_TLS + const char *ctrl_port = "443"; +#else + const char *ctrl_port = "80"; +#endif + + if (ml_getaddrinfo(CTRL_HOST(ml), ctrl_port, &hints, &res) != 0 || !res) { ESP_LOGE(TAG, "DNS resolve failed for %s", CTRL_HOST(ml)); return -1; } @@ -257,7 +293,7 @@ static int do_tcp_connect(microlink_t *ml) { ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); - ESP_LOGI(TAG, "Connecting to %s:80...", CTRL_HOST(ml)); + ESP_LOGI(TAG, "Connecting to %s:%s...", CTRL_HOST(ml), ctrl_port); if (ml_connect(sock, res->ai_addr, res->ai_addrlen) < 0) { ESP_LOGE(TAG, "TCP connect failed: %d", errno); @@ -272,6 +308,17 @@ static int do_tcp_connect(microlink_t *ml) { (t_tcp - t_dns) / 1000, (t_tcp - t_start) / 1000); ml->coord_sock = sock; + +#ifdef CONFIG_ML_CTRL_TLS + if (ml_coord_tls_handshake(ml, CTRL_HOST(ml)) < 0) { + ml_close_sock(ml->coord_sock); + ml->coord_sock = -1; + return -1; + } + int64_t t_tls = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] Control TLS handshake: %lld ms", (t_tls - t_tcp) / 1000); +#endif + return 0; } @@ -286,10 +333,24 @@ static int s_server_extra_data_len = 0; static int do_noise_handshake(microlink_t *ml, ml_noise_state_t *noise) { int64_t t_noise_start = esp_timer_get_time(); - /* Initialize Noise state with our machine key and Tailscale's server key */ + /* Initialize Noise state with our machine key and the control plane's + * server key. CONFIG_ML_CTRL_NOISE_PUBKEY_HEX overrides the built-in + * Tailscale key for Headscale-compatible control planes (value comes + * from https:///key?v=88 → "publicKey":"mkey:"). */ + const uint8_t *server_key = NULL; +#ifdef CONFIG_ML_CTRL_NOISE_PUBKEY_HEX + static uint8_t custom_server_key[32]; + if (CONFIG_ML_CTRL_NOISE_PUBKEY_HEX[0] != '\0') { + if (hex_to_bytes(CONFIG_ML_CTRL_NOISE_PUBKEY_HEX, custom_server_key, 32) == 32) { + server_key = custom_server_key; + } else { + ESP_LOGE(TAG, "Invalid ML_CTRL_NOISE_PUBKEY_HEX (need 64 hex chars) — using built-in key"); + } + } +#endif ml_noise_init(noise, ml->machine_private_key, ml->machine_public_key, - NULL); /* NULL = use default Tailscale server key */ + server_key); /* Build Noise message 1 (101 bytes) */ uint8_t msg1[128]; @@ -334,7 +395,7 @@ static int do_noise_handshake(microlink_t *ml, ml_noise_state_t *noise) { uint8_t *resp = ml_psram_malloc(2048); if (!resp) return -1; - int total = ml_recv(ml->coord_sock, resp, 2047, 0); + int total = coord_raw_recv(ml, resp, 2047); if (total <= 0) { ESP_LOGE(TAG, "Handshake recv failed: %d (errno=%d)", total, errno); free(resp); @@ -468,7 +529,7 @@ static int do_noise_handshake(microlink_t *ml, ml_noise_state_t *noise) { extra_data = ml_psram_malloc(1024); if (extra_data) { - int n = ml_recv(ml->coord_sock, extra_data, 1024, 0); + int n = coord_raw_recv(ml, extra_data, 1024); if (n > 0) { extra_len = n; ESP_LOGI(TAG, "Read %d bytes of proactive frames from socket", extra_len); @@ -1987,13 +2048,20 @@ static int do_send_endpoint_update(microlink_t *ml, ml_noise_state_t *noise) { /* Try to read one incremental MapResponse update (non-blocking) */ static int poll_map_update(microlink_t *ml, ml_noise_state_t *noise) { - /* Use select() to check if data is available before blocking in recv */ - fd_set readfds; - FD_ZERO(&readfds); - FD_SET(ml->coord_sock, &readfds); - struct timeval tv = { .tv_sec = 0, .tv_usec = 50000 }; /* 50ms */ - int sel = ml_select_fds(ml->coord_sock + 1, &readfds, NULL, NULL, &tv); - if (sel <= 0) return 0; /* No data available or error */ +#ifdef CONFIG_ML_CTRL_TLS + /* Decrypted bytes may already sit in the TLS layer with nothing left + * on the socket — select() alone would miss them. */ + if (ml_coord_tls_pending(ml) == 0) +#endif + { + /* Use select() to check if data is available before blocking in recv */ + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(ml->coord_sock, &readfds); + struct timeval tv = { .tv_sec = 0, .tv_usec = 50000 }; /* 50ms */ + int sel = ml_select_fds(ml->coord_sock + 1, &readfds, NULL, NULL, &tv); + if (sel <= 0) return 0; /* No data available or error */ + } /* Data available — set short recv timeout for partial frame safety */ struct timeval tv_recv = { .tv_sec = 2, .tv_usec = 0 }; @@ -2166,10 +2234,7 @@ void ml_coord_task(void *arg) { } break; case ML_CMD_DISCONNECT: - if (ml->coord_sock >= 0) { - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; - } + coord_close_conn(ml); state = COORD_IDLE; ml->state = ML_STATE_IDLE; break; @@ -2233,8 +2298,7 @@ void ml_coord_task(void *arg) { ml->state = ML_STATE_REGISTERING; if (do_noise_handshake(ml, &noise) < 0) { ESP_LOGE(TAG, "Noise handshake failed"); - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; + coord_close_conn(ml); state = COORD_RECONNECTING; break; } @@ -2248,8 +2312,7 @@ void ml_coord_task(void *arg) { case COORD_H2_PREFACE: if (do_h2_preface(ml, &noise) < 0) { ESP_LOGE(TAG, "H2 preface failed"); - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; + coord_close_conn(ml); state = COORD_RECONNECTING; break; } @@ -2260,8 +2323,7 @@ void ml_coord_task(void *arg) { ESP_LOGI(TAG, "Registering..."); if (do_register(ml, &noise) < 0) { ESP_LOGE(TAG, "Registration failed"); - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; + coord_close_conn(ml); state = COORD_RECONNECTING; break; } @@ -2272,8 +2334,7 @@ void ml_coord_task(void *arg) { ESP_LOGI(TAG, "Fetching peers..."); if (do_fetch_peers(ml, &noise) < 0) { ESP_LOGW(TAG, "MapRequest failed, will retry"); - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; + coord_close_conn(ml); state = COORD_RECONNECTING; break; } @@ -2567,10 +2628,7 @@ void ml_coord_task(void *arg) { reconnect_attempts++; /* Close old connection */ - if (ml->coord_sock >= 0) { - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; - } + coord_close_conn(ml); /* Reset Noise state for fresh handshake */ memset(&noise, 0, sizeof(noise)); @@ -2582,10 +2640,7 @@ void ml_coord_task(void *arg) { } /* Cleanup */ - if (ml->coord_sock >= 0) { - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; - } + coord_close_conn(ml); memset(&noise, 0, sizeof(noise)); ESP_LOGI(TAG, "Coord task exiting"); diff --git a/components/microlink/src/ml_coord_tls.c b/components/microlink/src/ml_coord_tls.c new file mode 100644 index 0000000..1a5d8bb --- /dev/null +++ b/components/microlink/src/ml_coord_tls.c @@ -0,0 +1,154 @@ +/** + * @file ml_coord_tls.c + * @brief TLS transport for the coordination connection (CONFIG_ML_CTRL_TLS) + * + * Wraps the ts2021 Noise handshake in TLS on port 443 for control planes + * that only expose an HTTPS listener (e.g. Headscale behind a load + * balancer, such as Lolipop Zero Trust Link). The server certificate is + * not verified: the control plane is authenticated by the pinned Noise + * public key, the same trust model as Tailscale's plain port-80 transport + * (and the same choice ml_derp.c makes for DERP). + * + * All functions run on the coord task only — no locking needed. + */ + +#include "sdkconfig.h" + +#ifdef CONFIG_ML_CTRL_TLS + +#include "microlink_internal.h" +#include "esp_log.h" +#include "mbedtls/error.h" +#include "mbedtls/net_sockets.h" +#include +#include + +static const char *TAG = "ml_coord_tls"; + +/* BIO callbacks route through ml_send/ml_recv so the AT-socket backend + * keeps working. SO_RCVTIMEO/SO_SNDTIMEO on the socket provide timeouts; + * a timeout surfaces as WANT_READ/WANT_WRITE and propagates out of + * mbedtls_ssl_read/write, which ml_coord_tls_recv maps back to EAGAIN. */ +static int coord_bio_send(void *ctx, const unsigned char *buf, size_t len) { + int fd = *(int *)ctx; + int n = ml_send(fd, buf, len, 0); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return MBEDTLS_ERR_SSL_WANT_WRITE; + return MBEDTLS_ERR_NET_SEND_FAILED; + } + return n; +} + +static int coord_bio_recv(void *ctx, unsigned char *buf, size_t len) { + int fd = *(int *)ctx; + int n = ml_recv(fd, buf, len, 0); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return MBEDTLS_ERR_SSL_WANT_READ; + return MBEDTLS_ERR_NET_RECV_FAILED; + } + if (n == 0) return MBEDTLS_ERR_NET_CONN_RESET; + return n; +} + +int ml_coord_tls_handshake(microlink_t *ml, const char *hostname) { + ml_coord_tls_t *t = &ml->coord_tls; + + mbedtls_ssl_init(&t->ssl); + mbedtls_ssl_config_init(&t->ssl_conf); + mbedtls_entropy_init(&t->entropy); + mbedtls_ctr_drbg_init(&t->ctr_drbg); + + int ret = mbedtls_ctr_drbg_seed(&t->ctr_drbg, mbedtls_entropy_func, + &t->entropy, NULL, 0); + if (ret != 0) goto fail; + + ret = mbedtls_ssl_config_defaults(&t->ssl_conf, + MBEDTLS_SSL_IS_CLIENT, + MBEDTLS_SSL_TRANSPORT_STREAM, + MBEDTLS_SSL_PRESET_DEFAULT); + if (ret != 0) goto fail; + + mbedtls_ssl_conf_authmode(&t->ssl_conf, MBEDTLS_SSL_VERIFY_NONE); + mbedtls_ssl_conf_rng(&t->ssl_conf, mbedtls_ctr_drbg_random, &t->ctr_drbg); + + ret = mbedtls_ssl_setup(&t->ssl, &t->ssl_conf); + if (ret != 0) goto fail; + ret = mbedtls_ssl_set_hostname(&t->ssl, hostname); + if (ret != 0) goto fail; + + t->sockfd = ml->coord_sock; + mbedtls_ssl_set_bio(&t->ssl, &t->sockfd, coord_bio_send, coord_bio_recv, NULL); + + while ((ret = mbedtls_ssl_handshake(&t->ssl)) != 0) { + if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) { + continue; /* Socket timeouts (10s) bound the total wait */ + } + goto fail; + } + + t->active = true; + ESP_LOGI(TAG, "TLS established with %s (suite: %s)", + hostname, mbedtls_ssl_get_ciphersuite(&t->ssl)); + return 0; + +fail: + { + char err_buf[128]; + mbedtls_strerror(ret, err_buf, sizeof(err_buf)); + ESP_LOGE(TAG, "TLS handshake with %s failed: -0x%04x (%s)", + hostname, (unsigned)-ret, err_buf); + } + ml_coord_tls_free(ml); + return -1; +} + +int ml_coord_tls_send(microlink_t *ml, const uint8_t *data, size_t len) { + ml_coord_tls_t *t = &ml->coord_tls; + if (!t->active) { errno = ENOTCONN; return -1; } + + size_t sent = 0; + while (sent < len) { + int n = mbedtls_ssl_write(&t->ssl, data + sent, len - sent); + if (n == MBEDTLS_ERR_SSL_WANT_READ || n == MBEDTLS_ERR_SSL_WANT_WRITE) { + errno = EAGAIN; + return -1; /* SNDTIMEO expired mid-write: caller treats as failure */ + } + if (n <= 0) { errno = EIO; return -1; } + sent += n; + } + return (int)len; +} + +/* recv() semantics: >0 bytes read, 0 peer closed, -1 error (EAGAIN on timeout) */ +int ml_coord_tls_recv(microlink_t *ml, uint8_t *buf, size_t len) { + ml_coord_tls_t *t = &ml->coord_tls; + if (!t->active) { errno = ENOTCONN; return -1; } + + int n = mbedtls_ssl_read(&t->ssl, buf, len); + if (n > 0) return n; + if (n == 0 || n == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) return 0; + if (n == MBEDTLS_ERR_SSL_WANT_READ || n == MBEDTLS_ERR_SSL_WANT_WRITE) { + errno = EAGAIN; + return -1; + } + errno = EIO; + return -1; +} + +size_t ml_coord_tls_pending(microlink_t *ml) { + ml_coord_tls_t *t = &ml->coord_tls; + if (!t->active) return 0; + return mbedtls_ssl_get_bytes_avail(&t->ssl); +} + +void ml_coord_tls_free(microlink_t *ml) { + ml_coord_tls_t *t = &ml->coord_tls; + mbedtls_ssl_free(&t->ssl); + mbedtls_ssl_config_free(&t->ssl_conf); + mbedtls_ctr_drbg_free(&t->ctr_drbg); + mbedtls_entropy_free(&t->entropy); + t->sockfd = -1; + t->active = false; +} + +#endif /* CONFIG_ML_CTRL_TLS */ From e3c3fe394657a6d8cbfa2b5efcd60c7a2328c22d Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Tue, 21 Jul 2026 23:56:56 +0900 Subject: [PATCH 02/12] Add ctrl_host / ctrl_noise_pubkey to microlink_config_t MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow supplying the custom control plane hostname and Noise public key at runtime through the config struct, in addition to the build-time Kconfig options. This enables shipping a single pre-built firmware where the control plane (and its /key-fetched Noise key) is provisioned on-device instead of baked in. Priority: config struct > Kconfig > built-in Tailscale values; the NVS web-UI host override still wins. The README already documented a ctrl_host config field — this makes it real, and extends the same idea to the Noise key. --- README.md | 2 +- components/microlink/include/microlink.h | 9 +++++++++ components/microlink/include/microlink_internal.h | 9 ++++++++- components/microlink/src/microlink.c | 10 ++++++++++ components/microlink/src/ml_coord.c | 11 +++++++---- 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 42e98a9..af08cab 100644 --- a/README.md +++ b/README.md @@ -613,7 +613,7 @@ V2 uses fully dynamic DERP discovery — no manual region configuration needed. MicroLink supports custom coordination servers like [Headscale](https://github.com/juanfont/headscale) and [Ionscale](https://github.com/jsiebens/ionscale). This allows you to run your own private Tailscale-compatible control plane. -**Configuration:** Set the control plane host via the HTTP config server web UI (Device Settings → Control Plane Host) or programmatically via `ctrl_host` in the config struct. +**Configuration:** Set the control plane host via the HTTP config server web UI (Device Settings → Control Plane Host) or programmatically via `ctrl_host` in the config struct. The server's Noise public key can likewise be supplied at runtime via `ctrl_noise_pubkey` (32 bytes) — fetch it from `https:///key?v=88` (`"publicKey":"mkey:"`) during provisioning, or pin it at build time (below). **Build-time configuration:** For pre-provisioned firmware, or when the HTTP config server is disabled, the control plane can also be set at build time: diff --git a/components/microlink/include/microlink.h b/components/microlink/include/microlink.h index 9126a53..f0628e5 100644 --- a/components/microlink/include/microlink.h +++ b/components/microlink/include/microlink.h @@ -45,6 +45,15 @@ typedef struct { uint32_t disco_heartbeat_ms; /* DISCO keepalive interval (default: 3000) */ uint32_t stun_interval_ms; /* STUN re-probe interval (default: 23000) */ uint32_t ctrl_watchdog_ms; /* Control plane watchdog timeout (default: 120000) */ + + /* Custom control plane (Headscale / Ionscale). NULL = use the + * CONFIG_ML_CTRL_HOST / CONFIG_ML_CTRL_NOISE_PUBKEY_HEX build-time + * values, falling back to Tailscale's. The NVS web-UI override, if + * present, still wins over both. */ + const char *ctrl_host; /* Coordination server hostname */ + const uint8_t *ctrl_noise_pubkey; /* Server Noise public key (32 bytes), + e.g. fetched from https:///key?v=88 + at provisioning time */ } microlink_config_t; /* Peer info (read-only snapshot) */ diff --git a/components/microlink/include/microlink_internal.h b/components/microlink/include/microlink_internal.h index 481483c..b5942d1 100644 --- a/components/microlink/include/microlink_internal.h +++ b/components/microlink/include/microlink_internal.h @@ -462,9 +462,16 @@ struct microlink_s { char nvs_device_name[48]; /* Control plane host override (empty = use ML_CTRL_HOST default). - * Set from NVS at boot for Headscale/Ionscale/custom coordinators. */ + * Set from config struct or NVS at boot for Headscale/Ionscale/custom + * coordinators. */ char ctrl_host[64]; + /* Control plane Noise public key override (from config struct; + * ctrl_noise_pubkey_set false = use CONFIG_ML_CTRL_NOISE_PUBKEY_HEX + * or the built-in Tailscale key) */ + uint8_t ctrl_noise_pubkey[32]; + bool ctrl_noise_pubkey_set; + /* Debug flags (bitmask from NVS, checked at runtime for verbose logging) */ uint8_t debug_flags; /* bit 0: DISCO, bit 1: WG, bit 2: DERP, bit 3: coord */ diff --git a/components/microlink/src/microlink.c b/components/microlink/src/microlink.c index 237f039..af8efc8 100644 --- a/components/microlink/src/microlink.c +++ b/components/microlink/src/microlink.c @@ -177,6 +177,16 @@ microlink_t *microlink_init(const microlink_config_t *config) { if (ml->config.max_peers > ML_MAX_PEERS) ml->config.max_peers = ML_MAX_PEERS; ml->config.enable_derp = true; /* Always need DERP for relay */ + /* Custom control plane from config struct (NVS override below still wins) */ + if (config->ctrl_host && config->ctrl_host[0]) { + strncpy(ml->ctrl_host, config->ctrl_host, sizeof(ml->ctrl_host) - 1); + ESP_LOGI(TAG, "Control plane from config: %s", ml->ctrl_host); + } + if (config->ctrl_noise_pubkey) { + memcpy(ml->ctrl_noise_pubkey, config->ctrl_noise_pubkey, 32); + ml->ctrl_noise_pubkey_set = true; + } + ml->state = ML_STATE_IDLE; ml->coord_sock = -1; ml->disco_sock4 = -1; diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index efb68aa..e25b975 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -334,13 +334,16 @@ static int do_noise_handshake(microlink_t *ml, ml_noise_state_t *noise) { int64_t t_noise_start = esp_timer_get_time(); /* Initialize Noise state with our machine key and the control plane's - * server key. CONFIG_ML_CTRL_NOISE_PUBKEY_HEX overrides the built-in - * Tailscale key for Headscale-compatible control planes (value comes - * from https:///key?v=88 → "publicKey":"mkey:"). */ + * server key. Priority: config-struct key (runtime, e.g. fetched from + * https:///key?v=88 at provisioning time) > + * CONFIG_ML_CTRL_NOISE_PUBKEY_HEX (build-time) > built-in Tailscale key. */ const uint8_t *server_key = NULL; + if (ml->ctrl_noise_pubkey_set) { + server_key = ml->ctrl_noise_pubkey; + } #ifdef CONFIG_ML_CTRL_NOISE_PUBKEY_HEX static uint8_t custom_server_key[32]; - if (CONFIG_ML_CTRL_NOISE_PUBKEY_HEX[0] != '\0') { + if (!server_key && CONFIG_ML_CTRL_NOISE_PUBKEY_HEX[0] != '\0') { if (hex_to_bytes(CONFIG_ML_CTRL_NOISE_PUBKEY_HEX, custom_server_key, 32) == 32) { server_key = custom_server_key; } else { From 470b53652cd62e6cde4c91f82cfcdf062f5e612d Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Wed, 22 Jul 2026 14:43:31 +0900 Subject: [PATCH 03/12] Add streaming_map_fetch for headscale controllers that ignore Stream=false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some headscale-based control planes (e.g. Lolipop Zero Trust Link) do not respond at all to the initial Stream=false MapRequest — the client sees 60 seconds of zero bytes, an empty MapResponse, and falls into a reconnect loop, never obtaining peers or the DERPMap. The native Tailscale client instead starts a single Stream=true streaming poll whose first message already carries the full netmap (peers + DERPMap). This adds a microlink_config_t.streaming_map_fetch flag (default false = unchanged behavior). When true, COORD_FETCH_PEERS starts the long-poll with OmitPeers=false, reads the initial netmap to completion, parses Node/Peers/ DERPMap, and only then connects DERP. Self-node and DERPMap parsing are factored into shared helpers so both the Stream=false and Stream=true paths populate them. --- components/microlink/include/microlink.h | 10 + components/microlink/src/ml_coord.c | 5568 ++++++++++++---------- 2 files changed, 2927 insertions(+), 2651 deletions(-) diff --git a/components/microlink/include/microlink.h b/components/microlink/include/microlink.h index f0628e5..c35a277 100644 --- a/components/microlink/include/microlink.h +++ b/components/microlink/include/microlink.h @@ -54,6 +54,16 @@ typedef struct { const uint8_t *ctrl_noise_pubkey; /* Server Noise public key (32 bytes), e.g. fetched from https:///key?v=88 at provisioning time */ + + /* Some headscale-based controllers never respond to the initial + * Stream=false MapRequest (e.g. ZTL: 60s of zero bytes -> Empty + * MapResponse -> reconnect loop). The official Tailscale client uses a + * single Stream=true streaming poll from the start, and the first stream + * message carries the full netmap (Peers + DERPMap). + * Setting this to true switches to that scheme (start with Stream=true / + * OmitPeers=false and take Peers + DERPMap from the first MapResponse). + * false (default) keeps the existing Stream=false one-shot fetch. */ + bool streaming_map_fetch; } microlink_config_t; /* Peer info (read-only snapshot) */ diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index e25b975..41657d4 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -1,2651 +1,2917 @@ -/** - * @file ml_coord.c - * @brief Coordination Task - Control Plane Client - * - * Owns ALL Noise protocol state (keys, nonces) exclusively. - * Handles registration, MapResponse parsing, endpoint updates. - * Runs as a state machine on Core 1. - * - * Protocol flow: - * 1. DNS resolve controlplane.tailscale.com - * 2. TCP connect to port 80 (NOT TLS - Noise provides encryption) - * 3. HTTP/1.1 Upgrade with Noise msg1 in X-Tailscale-Handshake header - * 4. Read Noise msg2 from upgrade response body - * 5. Derive transport keys, send H2 preface (encrypted via Noise) - * 6. Send RegisterRequest (Noise-encrypted H2 HEADERS+DATA) - * 7. Parse RegisterResponse - * 8. Send MapRequest, parse MapResponse (peers) - * 9. Long-poll for updates - * - * Reference: tailscale/control/controlclient/auto.go - * tailscale/control/controlclient/direct.go - */ - -#include "microlink_internal.h" -#include "x25519.h" -#include "esp_log.h" -#include "esp_timer.h" -#include "esp_random.h" -#include "esp_netif.h" -#include "cJSON.h" -#include "lwip/sockets.h" -#include "lwip/netdb.h" -#include "mbedtls/base64.h" -#include -#include - -static const char *TAG = "ml_coord"; - -/* Effective control plane host: NVS override or compiled default */ -#define CTRL_HOST(ml) ((ml)->ctrl_host[0] ? (ml)->ctrl_host : ML_CTRL_HOST) - -/* NodeKeyChallenge from EarlyNoise (stored between handshake and register) */ -static uint8_t s_node_key_challenge[32] = {0}; -static bool s_has_node_key_challenge = false; - -/* Coordination state machine */ -typedef enum { - COORD_IDLE, - COORD_STUN_PROBE, - COORD_DNS_RESOLVE, - COORD_TCP_CONNECT, - COORD_NOISE_HANDSHAKE, - COORD_H2_PREFACE, - COORD_REGISTER, - COORD_FETCH_PEERS, - COORD_LONG_POLL, - COORD_RECONNECTING, -} coord_state_t; - -/* ============================================================================ - * Helper: hex encoding for keys - * ========================================================================== */ - -static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex) { - static const char hextab[] = "0123456789abcdef"; - for (size_t i = 0; i < len; i++) { - hex[i * 2] = hextab[bytes[i] >> 4]; - hex[i * 2 + 1] = hextab[bytes[i] & 0x0F]; - } - hex[len * 2] = '\0'; -} - -static int hex_to_bytes(const char *hex, uint8_t *bytes, size_t max_len) { - size_t hex_len = strlen(hex); - if (hex_len % 2 != 0 || hex_len / 2 > max_len) return -1; - for (size_t i = 0; i < hex_len / 2; i++) { - unsigned int val; - if (sscanf(hex + i * 2, "%2x", &val) != 1) return -1; - bytes[i] = (uint8_t)val; - } - return hex_len / 2; -} - -/* ============================================================================ - * TCP I/O helpers for coord socket (owned exclusively by this task) - * ========================================================================== */ - -/* Single read from the coord transport (recv() semantics). - * With CONFIG_ML_CTRL_TLS the Noise byte stream runs inside TLS records; - * timeouts still surface as -1/EAGAIN via the TLS BIO. */ -static int coord_raw_recv(microlink_t *ml, uint8_t *buf, size_t len) { -#ifdef CONFIG_ML_CTRL_TLS - return ml_coord_tls_recv(ml, buf, len); -#else - return ml_recv(ml->coord_sock, buf, len, 0); -#endif -} - -/* Close the coord connection (TLS session first, then the socket) */ -static void coord_close_conn(microlink_t *ml) { -#ifdef CONFIG_ML_CTRL_TLS - ml_coord_tls_free(ml); -#endif - if (ml->coord_sock >= 0) { - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; - } -} - -static int coord_send(microlink_t *ml, const uint8_t *data, size_t len) { - /* Set send timeout to prevent indefinite blocking (v1 uses MSG_DONTWAIT) */ - struct timeval snd_tv = { .tv_sec = 5, .tv_usec = 0 }; - ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_SNDTIMEO, &snd_tv, sizeof(snd_tv)); - -#ifdef CONFIG_ML_CTRL_TLS - if (ml_coord_tls_send(ml, data, len) < 0) { - ESP_LOGE(TAG, "coord_send (TLS) failed: len=%d errno=%d", (int)len, errno); - return -1; - } - return 0; -#else - size_t sent = 0; - while (sent < len) { - int n = ml_send(ml->coord_sock, data + sent, len - sent, 0); - if (n <= 0) { - ESP_LOGE(TAG, "coord_send failed: sent=%d/%d n=%d errno=%d", - (int)sent, (int)len, n, errno); - return -1; - } - sent += n; - } - return 0; -#endif -} - -static int coord_recv(microlink_t *ml, uint8_t *buf, size_t len) { - size_t recvd = 0; - int retries = 0; - while (recvd < len) { - int n = coord_raw_recv(ml, buf + recvd, len - recvd); - if (n <= 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - if (recvd == 0) { - /* No data consumed yet — timeout is fine, caller can retry */ - return -1; - } - /* Partial data consumed — we MUST finish this read or the - * Noise frame stream will be misaligned. Retry with backoff. */ - if (++retries > 300) { /* ~3 seconds */ - ESP_LOGE(TAG, "coord_recv partial timeout: %d/%d bytes", - (int)recvd, (int)len); - return -1; - } - vTaskDelay(pdMS_TO_TICKS(10)); - continue; - } - ESP_LOGE(TAG, "coord_recv failed: %d (errno %d, recvd %d/%d)", - n, errno, (int)recvd, (int)len); - return -1; - } - recvd += n; - retries = 0; /* Reset on successful read */ - } - return 0; -} - -/* ============================================================================ - * Noise-encrypted transport I/O - * ========================================================================== */ - -/* Send a Noise transport frame: [0x04][len_hi][len_lo][encrypted_data+MAC] */ -static int noise_send(microlink_t *ml, ml_noise_state_t *noise, - const uint8_t *plaintext, size_t pt_len) { - size_t ct_len = pt_len + 16; /* ciphertext + 16-byte MAC */ - uint8_t *frame = ml_psram_malloc(3 + ct_len); - if (!frame) return -1; - - frame[0] = 0x04; /* Transport data frame type */ - frame[1] = (ct_len >> 8) & 0xFF; - frame[2] = ct_len & 0xFF; - - if (ml_noise_encrypt(noise->tx_key, noise->tx_nonce, - NULL, 0, - plaintext, pt_len, - frame + 3) != ESP_OK) { - free(frame); - return -1; - } - noise->tx_nonce++; - - int ret = coord_send(ml, frame, 3 + ct_len); - free(frame); - return ret; -} - -/* Receive and decrypt a Noise transport frame, returns plaintext length */ -static int noise_recv(microlink_t *ml, ml_noise_state_t *noise, - uint8_t *plaintext, size_t max_len) { - /* Read 3-byte frame header */ - uint8_t hdr[3]; - if (coord_recv(ml, hdr, 3) < 0) return -1; - - if (hdr[0] != 0x04) { - ESP_LOGE(TAG, "Unexpected Noise frame type: 0x%02x", hdr[0]); - return -1; - } - - uint16_t ct_len = (hdr[1] << 8) | hdr[2]; - if (ct_len < 16) return -1; - size_t pt_len = ct_len - 16; - if (pt_len > max_len) { - ESP_LOGE(TAG, "Noise frame too large: %d > %d", (int)pt_len, (int)max_len); - return -1; - } - - uint8_t *ciphertext = ml_psram_malloc(ct_len); - if (!ciphertext) return -1; - - /* Header already consumed — payload read MUST complete or stream - * alignment is permanently lost. Retry EAGAIN (coord_recv returns -1 - * with errno==EAGAIN if recvd==0 on first byte). */ - int payload_retries = 0; - while (coord_recv(ml, ciphertext, ct_len) < 0) { - if ((errno == EAGAIN || errno == EWOULDBLOCK) && ++payload_retries <= 300) { - vTaskDelay(pdMS_TO_TICKS(10)); - continue; - } - ESP_LOGE(TAG, "noise_recv payload failed: ct_len=%d retries=%d errno=%d", - ct_len, payload_retries, errno); - free(ciphertext); - return -1; - } - - if (ml_noise_decrypt(noise->rx_key, noise->rx_nonce, - NULL, 0, - ciphertext, ct_len, - plaintext) != ESP_OK) { - ESP_LOGE(TAG, "Noise decrypt failed (nonce=%llu)", (unsigned long long)noise->rx_nonce); - free(ciphertext); - return -1; - } - noise->rx_nonce++; - - free(ciphertext); - return (int)pt_len; -} - -/* ============================================================================ - * State: DNS_RESOLVE + TCP_CONNECT - * ========================================================================== */ - -static int do_tcp_connect(microlink_t *ml) { - int64_t t_start = esp_timer_get_time(); - - ESP_LOGI(TAG, "Resolving %s...", CTRL_HOST(ml)); - - struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM }; - struct addrinfo *res = NULL; - -#ifdef CONFIG_ML_CTRL_TLS - const char *ctrl_port = "443"; -#else - const char *ctrl_port = "80"; -#endif - - if (ml_getaddrinfo(CTRL_HOST(ml), ctrl_port, &hints, &res) != 0 || !res) { - ESP_LOGE(TAG, "DNS resolve failed for %s", CTRL_HOST(ml)); - return -1; - } - - int64_t t_dns = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] DNS resolve: %lld ms", (t_dns - t_start) / 1000); - - int sock = ml_socket(res->ai_family, SOCK_STREAM, IPPROTO_TCP); - if (sock < 0) { - ml_freeaddrinfo(res); - ESP_LOGE(TAG, "Socket creation failed"); - return -1; - } - - /* Socket timeouts */ - struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; - ml_setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - ml_setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); - - /* TCP keepalive (critical for NAT traversal) */ - int keepalive = 1; - int keepidle = 25; - int keepintvl = 10; - int keepcnt = 3; - ml_setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); - ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); - ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); - ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); - - ESP_LOGI(TAG, "Connecting to %s:%s...", CTRL_HOST(ml), ctrl_port); - - if (ml_connect(sock, res->ai_addr, res->ai_addrlen) < 0) { - ESP_LOGE(TAG, "TCP connect failed: %d", errno); - ml_close_sock(sock); - ml_freeaddrinfo(res); - return -1; - } - ml_freeaddrinfo(res); - - int64_t t_tcp = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] TCP connect: %lld ms (total: %lld ms)", - (t_tcp - t_dns) / 1000, (t_tcp - t_start) / 1000); - - ml->coord_sock = sock; - -#ifdef CONFIG_ML_CTRL_TLS - if (ml_coord_tls_handshake(ml, CTRL_HOST(ml)) < 0) { - ml_close_sock(ml->coord_sock); - ml->coord_sock = -1; - return -1; - } - int64_t t_tls = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] Control TLS handshake: %lld ms", (t_tls - t_tcp) / 1000); -#endif - - return 0; -} - -/* ============================================================================ - * State: NOISE_HANDSHAKE (HTTP/1.1 Upgrade with Noise msg1) - * ========================================================================== */ - -/* Server proactive data (stored between handshake and H2 preface) */ -static uint8_t *s_server_extra_data = NULL; -static int s_server_extra_data_len = 0; - -static int do_noise_handshake(microlink_t *ml, ml_noise_state_t *noise) { - int64_t t_noise_start = esp_timer_get_time(); - - /* Initialize Noise state with our machine key and the control plane's - * server key. Priority: config-struct key (runtime, e.g. fetched from - * https:///key?v=88 at provisioning time) > - * CONFIG_ML_CTRL_NOISE_PUBKEY_HEX (build-time) > built-in Tailscale key. */ - const uint8_t *server_key = NULL; - if (ml->ctrl_noise_pubkey_set) { - server_key = ml->ctrl_noise_pubkey; - } -#ifdef CONFIG_ML_CTRL_NOISE_PUBKEY_HEX - static uint8_t custom_server_key[32]; - if (!server_key && CONFIG_ML_CTRL_NOISE_PUBKEY_HEX[0] != '\0') { - if (hex_to_bytes(CONFIG_ML_CTRL_NOISE_PUBKEY_HEX, custom_server_key, 32) == 32) { - server_key = custom_server_key; - } else { - ESP_LOGE(TAG, "Invalid ML_CTRL_NOISE_PUBKEY_HEX (need 64 hex chars) — using built-in key"); - } - } -#endif - ml_noise_init(noise, - ml->machine_private_key, ml->machine_public_key, - server_key); - - /* Build Noise message 1 (101 bytes) */ - uint8_t msg1[128]; - size_t msg1_len = 0; - if (ml_noise_write_msg1(noise, msg1, &msg1_len) != ESP_OK) { - ESP_LOGE(TAG, "Failed to build Noise msg1"); - return -1; - } - - /* Base64 encode msg1 for HTTP header */ - char msg1_b64[256]; - size_t b64_len = 0; - mbedtls_base64_encode((unsigned char *)msg1_b64, sizeof(msg1_b64), &b64_len, - msg1, msg1_len); - msg1_b64[b64_len] = '\0'; - - /* Send HTTP/1.1 Upgrade request with Noise msg1 in header */ - char *http_req = ml_psram_malloc(512 + b64_len); - if (!http_req) return -1; - - int req_len = snprintf(http_req, 512 + b64_len, - "POST /ts2021 HTTP/1.1\r\n" - "Host: %s\r\n" - "Upgrade: tailscale-control-protocol\r\n" - "Connection: Upgrade\r\n" - "User-Agent: Tailscale\r\n" - "X-Tailscale-Handshake: %s\r\n" - "Content-Length: 0\r\n" - "\r\n", - CTRL_HOST(ml), msg1_b64); - - ESP_LOGI(TAG, "Sending Noise handshake (msg1=%d bytes, b64=%d chars)", (int)msg1_len, (int)b64_len); - - if (coord_send(ml, (uint8_t *)http_req, req_len) < 0) { - free(http_req); - return -1; - } - free(http_req); - - /* Read HTTP response - use large recv() because server often sends - * HTTP 101 headers + Noise msg2 + proactive frames in the same TCP segment */ - uint8_t *resp = ml_psram_malloc(2048); - if (!resp) return -1; - - int total = coord_raw_recv(ml, resp, 2047); - if (total <= 0) { - ESP_LOGE(TAG, "Handshake recv failed: %d (errno=%d)", total, errno); - free(resp); - return -1; - } - resp[total] = '\0'; - - ESP_LOGI(TAG, "HTTP response received: %d bytes", total); - - /* Verify 101 Switching Protocols */ - if (strstr((char *)resp, "101") == NULL) { - ESP_LOGE(TAG, "Noise upgrade rejected: %.200s", resp); - free(resp); - return -1; - } - ESP_LOGI(TAG, "HTTP 101 received, looking for Noise msg2..."); - - /* Find end of HTTP headers */ - uint8_t *body_start = (uint8_t *)strstr((char *)resp, "\r\n\r\n"); - if (!body_start) { - ESP_LOGE(TAG, "No header terminator found"); - free(resp); - return -1; - } - body_start += 4; /* Skip past \r\n\r\n */ - - int body_len = total - (body_start - resp); - ESP_LOGI(TAG, "Body after HTTP headers: %d bytes", body_len); - - /* Copy entire body to working buffer (may contain msg2 + extra frames) */ - uint8_t *body_buf = NULL; - int body_buf_len = 0; - - if (body_len > 0) { - body_buf = ml_psram_malloc(body_len); - if (body_buf) { - memcpy(body_buf, body_start, body_len); - body_buf_len = body_len; - } - } - free(resp); - - /* Ensure we have at least the 3-byte Noise frame header */ - if (body_buf_len < 3) { - ESP_LOGI(TAG, "Reading Noise msg2 header from socket (have %d bytes)...", body_buf_len); - /* Need to read from socket - allocate buffer if not yet */ - if (!body_buf) { - body_buf = ml_psram_malloc(512); - if (!body_buf) return -1; - } - if (coord_recv(ml, body_buf + body_buf_len, 3 - body_buf_len) < 0) { - ESP_LOGE(TAG, "Failed to read Noise msg2 header"); - free(body_buf); - return -1; - } - body_buf_len = 3; - } - - /* Parse 3-byte Noise frame header */ - uint8_t msg_type = body_buf[0]; - uint16_t payload_len = (body_buf[1] << 8) | body_buf[2]; - - ESP_LOGI(TAG, "Noise frame: type=0x%02x, payload_len=%d", msg_type, payload_len); - - if (msg_type != 0x02) { - ESP_LOGE(TAG, "Unexpected Noise msg type: 0x%02x (expected 0x02)", msg_type); - free(body_buf); - return -1; - } - - int msg2_total = 3 + payload_len; /* Total msg2 frame size */ - - /* Read remaining msg2 payload bytes from socket if needed */ - if (body_buf_len < msg2_total) { - ESP_LOGI(TAG, "Reading remaining msg2 payload: have %d, need %d", body_buf_len, msg2_total); - /* Grow buffer if needed */ - uint8_t *tmp = ml_psram_malloc(msg2_total + 512); - if (!tmp) { free(body_buf); return -1; } - memcpy(tmp, body_buf, body_buf_len); - free(body_buf); - body_buf = tmp; - - if (coord_recv(ml, body_buf + body_buf_len, msg2_total - body_buf_len) < 0) { - ESP_LOGE(TAG, "Failed to read Noise msg2 payload"); - free(body_buf); - return -1; - } - body_buf_len = msg2_total; - } - - ESP_LOGI(TAG, "Noise msg2 complete: %d bytes (payload=%d)", msg2_total, payload_len); - - /* Strip 3-byte header, pass raw payload to noise processing */ - if (ml_noise_read_msg2(noise, body_buf + 3, payload_len) != ESP_OK) { - ESP_LOGE(TAG, "Noise msg2 processing failed"); - free(body_buf); - return -1; - } - - int64_t t_noise_done = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] Noise handshake: %lld ms", (t_noise_done - t_noise_start) / 1000); - ESP_LOGI(TAG, "Noise handshake complete, transport keys derived"); - - /* Save extra data after msg2 (proactive server transport frames) - * Server sends these immediately after handshake - each one increments - * server's tx_nonce. We must process them to keep nonces in sync. - * (Matches v1 g_server_extra_data logic) - * - * On fast connections (WiFi), the proactive frames often arrive in - * the same TCP segment as the HTTP 101 + msg2. On slow connections - * (cellular PPP), they arrive in separate TCP segments. We must - * read from the socket if they weren't in the initial buffer. */ - int extra_len = body_buf_len - msg2_total; - uint8_t *extra_data = NULL; - - if (extra_len > 0) { - /* Fast path: proactive frames were in the initial recv buffer */ - ESP_LOGI(TAG, "Found %d bytes of extra data after msg2 (proactive frames)", extra_len); - extra_data = ml_psram_malloc(extra_len); - if (extra_data) { - memcpy(extra_data, body_buf + msg2_total, extra_len); - } - } else { - /* Slow path: proactive frames arrive in subsequent TCP segments. - * Set a short recv timeout and read them from the socket. - * Server sends EarlyNoise immediately after msg2, so they should - * arrive within a few hundred ms even on slow cellular links. */ - ESP_LOGI(TAG, "No extra data in initial buffer, reading proactive frames from socket..."); - struct timeval short_tv = { .tv_sec = 2, .tv_usec = 0 }; - ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &short_tv, sizeof(short_tv)); - - extra_data = ml_psram_malloc(1024); - if (extra_data) { - int n = coord_raw_recv(ml, extra_data, 1024); - if (n > 0) { - extra_len = n; - ESP_LOGI(TAG, "Read %d bytes of proactive frames from socket", extra_len); - } else { - ESP_LOGI(TAG, "No proactive frames from server (n=%d errno=%d)", n, errno); - free(extra_data); - extra_data = NULL; - extra_len = 0; - } - } - - /* Restore normal recv timeout */ - struct timeval normal_tv = { .tv_sec = 60, .tv_usec = 0 }; - ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &normal_tv, sizeof(normal_tv)); - } - - if (extra_data && extra_len > 0) { - /* Count proactive transport frames */ - int offset = 0; - int frame_count = 0; - while (offset + 3 <= extra_len) { - uint8_t ft = extra_data[offset]; - uint16_t fl = (extra_data[offset + 1] << 8) | extra_data[offset + 2]; - if (offset + 3 + fl > extra_len) { - ESP_LOGW(TAG, "Incomplete proactive frame at offset %d", offset); - break; - } - ESP_LOGI(TAG, "Proactive frame %d: type=0x%02x, len=%u", frame_count, ft, fl); - frame_count++; - offset += 3 + fl; - } - ESP_LOGI(TAG, "Server sent %d proactive transport frames", frame_count); - - /* Store for processing after handshake */ - if (s_server_extra_data) free(s_server_extra_data); - s_server_extra_data = extra_data; - s_server_extra_data_len = extra_len; - } else { - if (extra_data) free(extra_data); - } - - free(body_buf); - return 0; -} - -/* ============================================================================ - * Process proactive server frames sent after Noise handshake - * Copied from v1: decrypt EarlyNoise (frame 0) to get nodeKeyChallenge, - * then count all frames and set rx_nonce = total count. - * Frames 1-3 may not decrypt with any nonce/key combo (v1 observation). - * ========================================================================== */ - -static void process_proactive_frames(microlink_t *ml, ml_noise_state_t *noise) { - if (!s_server_extra_data || s_server_extra_data_len <= 0) { - return; - } - - ESP_LOGI(TAG, "Processing %d bytes of server proactive data", s_server_extra_data_len); - s_has_node_key_challenge = false; - - /* Decrypt ALL proactive frames and accumulate plaintext. - * EarlyNoise content (magic + length + JSON) is split across - * multiple Noise transport frames: - * Frame 0: \xff\xff\xffTS (5 bytes magic) - * Frame 1: 4 bytes (big-endian JSON length) - * Frame 2: ~95 bytes (JSON with nodeKeyChallenge) - * Frame 3: H2 SETTINGS (optional) */ - uint8_t *combined = ml_psram_malloc(1024); - size_t combined_len = 0; - int offset = 0; - int frame_count = 0; - uint64_t nonce = 0; - - while (offset + 3 <= s_server_extra_data_len) { - uint8_t ft = s_server_extra_data[offset]; - uint16_t fl = (s_server_extra_data[offset + 1] << 8) | s_server_extra_data[offset + 2]; - - if (offset + 3 + fl > s_server_extra_data_len) break; - - ESP_LOGI(TAG, "Proactive frame %d: type=0x%02x, len=%u", frame_count, ft, fl); - - if (ft == 0x04 && fl >= ML_NOISE_MAC_LEN && combined) { - size_t pt_len = fl - ML_NOISE_MAC_LEN; - uint8_t *plaintext = ml_psram_malloc(pt_len + 1); - - if (plaintext) { - esp_err_t ret = ml_noise_decrypt(noise->rx_key, nonce, - NULL, 0, - s_server_extra_data + offset + 3, fl, - plaintext); - if (ret == ESP_OK) { - ESP_LOGI(TAG, " Decrypted frame %d: %d bytes (nonce=%d)", - frame_count, (int)pt_len, (int)nonce); - if (combined_len + pt_len < 1024) { - memcpy(combined + combined_len, plaintext, pt_len); - combined_len += pt_len; - } - } else { - ESP_LOGW(TAG, " Frame %d decrypt failed (nonce=%d)", frame_count, (int)nonce); - } - free(plaintext); - } - } - - nonce++; - frame_count++; - offset += 3 + fl; - } - - ESP_LOGI(TAG, "Decrypted %d bytes from %d proactive frames", (int)combined_len, frame_count); - - /* Search combined plaintext for nodeKeyChallenge JSON */ - if (combined && combined_len > 0) { - combined[combined_len < 1024 ? combined_len : 1023] = '\0'; - - /* Find JSON start (skip magic + length prefix) */ - char *json_start = NULL; - for (int i = 0; i < (int)combined_len; i++) { - if (combined[i] == '{') { - json_start = (char *)combined + i; - break; - } - } - - if (json_start) { - ESP_LOGI(TAG, "EarlyNoise JSON: %.80s", json_start); - cJSON *early_json = cJSON_Parse(json_start); - if (early_json) { - cJSON *challenge = cJSON_GetObjectItem(early_json, "nodeKeyChallenge"); - if (challenge && cJSON_IsString(challenge)) { - const char *chal_str = challenge->valuestring; - ESP_LOGI(TAG, "Found nodeKeyChallenge: %.40s...", chal_str); - - /* Parse "chalpub:hex..." format (v1 lines 1402-1424) */ - if (strncmp(chal_str, "chalpub:", 8) == 0) { - const char *hex = chal_str + 8; - if (strlen(hex) >= 64) { - hex_to_bytes(hex, s_node_key_challenge, 32); - s_has_node_key_challenge = true; - ESP_LOGI(TAG, "NodeKeyChallenge decoded (32 bytes)"); - } - } - } - cJSON_Delete(early_json); - } - } - } - if (combined) free(combined); - - /* Set rx_nonce to skip all proactive frames */ - ESP_LOGI(TAG, "Setting rx_nonce=%d (skipping %d proactive frames)", frame_count, frame_count); - noise->rx_nonce = frame_count; - - free(s_server_extra_data); - s_server_extra_data = NULL; - s_server_extra_data_len = 0; -} - -/* ============================================================================ - * State: H2_PREFACE - Send HTTP/2 connection preface (Noise-encrypted) - * ========================================================================== */ - -static int do_h2_preface(microlink_t *ml, ml_noise_state_t *noise) { - int64_t t_h2_start = esp_timer_get_time(); - - /* 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)); - if (preface_len < 0) return -1; - pos = preface_len; - - int ack_len = ml_h2_build_settings_ack(h2_init + pos, sizeof(h2_init) - pos); - if (ack_len < 0) return -1; - pos += ack_len; - - /* Connection-level WINDOW_UPDATE (stream 0) to expand the connection window - * 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; - if (conn_window_delta > 0) { - int wu_len = ml_h2_build_window_update(h2_init + pos, sizeof(h2_init) - pos, - 0, conn_window_delta); - if (wu_len > 0) pos += wu_len; - } - - /* Encrypt and send as one Noise frame */ - if (noise_send(ml, noise, h2_init, pos) < 0) { - ESP_LOGE(TAG, "Failed to send H2 preface"); - return -1; - } - - ESP_LOGI(TAG, "H2 preface sent (%d bytes, conn window=%luKB)", - pos, (unsigned long)(ML_H2_BUFFER_SIZE / 1024)); - - /* Read and process server's response (SETTINGS, SETTINGS_ACK, WINDOW_UPDATE, etc.) */ - uint8_t recv_buf[4096]; - int recv_len = noise_recv(ml, noise, recv_buf, sizeof(recv_buf)); - if (recv_len < 0) { - ESP_LOGW(TAG, "No immediate H2 response from server (may come later)"); - /* Not fatal - server may send its frames later */ - } else { - ESP_LOGI(TAG, "Server H2 response: %d bytes", recv_len); - } - - int64_t t_h2_done = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] H2 preface: %lld ms", (t_h2_done - t_h2_start) / 1000); - - return 0; -} - -/* ============================================================================ - * State: REGISTER - Send RegisterRequest, parse RegisterResponse - * ========================================================================== */ - -static int do_register(microlink_t *ml, ml_noise_state_t *noise) { - int64_t t_reg_start = esp_timer_get_time(); - - /* Build RegisterRequest JSON */ - cJSON *root = cJSON_CreateObject(); - if (!root) return -1; - - cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); - - /* NodeKey: "nodekey:" + hex(wg_public_key) */ - char key_hex[65]; - char key_str[80]; - bytes_to_hex(ml->wg_public_key, 32, key_hex); - snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); - cJSON_AddStringToObject(root, "NodeKey", key_str); - - /* Auth - only include if auth_key is valid (matching v1 behavior) */ - if (ml->config.auth_key && strlen(ml->config.auth_key) > 0) { - cJSON *auth = cJSON_CreateObject(); - cJSON_AddStringToObject(auth, "AuthKey", ml->config.auth_key); - cJSON_AddItemToObject(root, "Auth", auth); - } - - /* Hostinfo */ - cJSON *hostinfo = cJSON_CreateObject(); - const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); - cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); - cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); - cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); - - /* NetInfo inside Hostinfo — control plane reads PreferredDERP from here - * to populate Node.HomeDERP for other peers */ - { - cJSON *netinfo = cJSON_CreateObject(); - if (netinfo) { - cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); - cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); - } - } - - cJSON_AddItemToObject(root, "Hostinfo", hostinfo); - - /* NodeKeyChallengeResponse - prove we own the WireGuard private key - * Server sends challenge public key in EarlyNoise; we respond with - * X25519(wg_private_key, challenge_public_key). (v1 lines 1754-1785) */ - if (s_has_node_key_challenge) { - ESP_LOGI(TAG, "Computing NodeKeyChallengeResponse..."); - - /* X25519(wg_private_key, challenge_public_key) */ - uint8_t challenge_pub_copy[32]; - memcpy(challenge_pub_copy, s_node_key_challenge, 32); - challenge_pub_copy[31] &= 0x7F; /* Clear high bit per RFC 7748 */ - - uint8_t challenge_response[32]; - x25519(challenge_response, ml->wg_private_key, challenge_pub_copy, 1); - - /* Encode as "chalresp:hex..." */ - char chalresp_hex[65]; - bytes_to_hex(challenge_response, 32, chalresp_hex); - char chalresp_str[80]; - snprintf(chalresp_str, sizeof(chalresp_str), "chalresp:%s", chalresp_hex); - cJSON_AddStringToObject(root, "NodeKeyChallengeResponse", chalresp_str); - ESP_LOGI(TAG, "Added NodeKeyChallengeResponse"); - } else { - ESP_LOGW(TAG, "No nodeKeyChallenge received - registration may fail!"); - } - - char *json_str = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (!json_str) return -1; - - size_t json_len = strlen(json_str); - ESP_LOGI(TAG, "RegisterRequest: %d bytes", (int)json_len); - - /* Build HTTP/2 HEADERS + DATA frames */ - uint8_t *h2_buf = ml_psram_malloc(json_len + 512); - if (!h2_buf) { free(json_str); return -1; } - - int h2_pos = 0; - - /* HEADERS frame (POST /machine/register) */ - int hdr_len = ml_h2_build_headers_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, - "POST", "/machine/register", - CTRL_HOST(ml), "application/json", - 1, false); - if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } - h2_pos += hdr_len; - - /* DATA frame (JSON body, END_STREAM) */ - int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, - (uint8_t *)json_str, json_len, - 1, true); - free(json_str); - if (data_len < 0) { free(h2_buf); return -1; } - h2_pos += data_len; - - /* Encrypt and send as one Noise frame */ - if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { - free(h2_buf); - return -1; - } - free(h2_buf); - - int64_t t_reg_sent = esp_timer_get_time(); - ESP_LOGI(TAG, "RegisterRequest sent (%d H2 bytes) [TIMING] send: %lld ms", - h2_pos, (t_reg_sent - t_reg_start) / 1000); - - /* Read RegisterResponse - accumulate all Noise frames into H2 buffer first, - * then parse H2 frames (same pattern as MapResponse). */ - uint8_t *h2_resp = ml_psram_malloc(16384); - if (!h2_resp) return -1; - size_t h2_resp_len = 0; - - uint8_t *resp_buf = ml_psram_malloc(8192); - if (!resp_buf) { free(h2_resp); return -1; } - size_t resp_total = 0; - - /* Accumulate Noise frames into H2 buffer. - * Scan each frame for H2 END_STREAM on stream 1 to break early - * instead of blocking 60s waiting for more data that won't come. */ - bool got_register_end = false; - for (int frame_count = 0; frame_count < 10 && !got_register_end; frame_count++) { - uint8_t *frame_buf = ml_psram_malloc(4096); - if (!frame_buf) break; - - int frame_len = noise_recv(ml, noise, frame_buf, 4096); - if (frame_len <= 0) { - free(frame_buf); - break; - } - - ESP_LOGI(TAG, "RegisterResponse Noise frame %d: %d bytes", frame_count, frame_len); - - if (h2_resp_len + frame_len < 16384) { - memcpy(h2_resp + h2_resp_len, frame_buf, frame_len); - h2_resp_len += frame_len; - } - free(frame_buf); - - /* Scan accumulated buffer for H2 END_STREAM on stream 1 */ - int scan = 0; - while (scan + 9 <= (int)h2_resp_len) { - uint32_t fl = (h2_resp[scan] << 16) | (h2_resp[scan+1] << 8) | h2_resp[scan+2]; - uint8_t ft = h2_resp[scan+3]; - uint8_t ff = h2_resp[scan+4]; - uint32_t fs = ((h2_resp[scan+5] & 0x7F) << 24) | (h2_resp[scan+6] << 16) | - (h2_resp[scan+7] << 8) | h2_resp[scan+8]; - if (fl > 1000000 || scan + 9 + (int)fl > (int)h2_resp_len) break; - /* END_STREAM (0x01) on stream 1 in DATA(0x00) or HEADERS(0x01) frame */ - if (fs == 1 && (ft == 0x00 || ft == 0x01) && (ff & 0x01)) { - got_register_end = true; - break; - } - scan += 9 + fl; - } - } - - /* Parse H2 frames from accumulated buffer */ - bool got_end_stream = false; - int fpos = 0; - while (fpos + 9 <= (int)h2_resp_len) { - uint32_t f_len = (h2_resp[fpos] << 16) | (h2_resp[fpos + 1] << 8) | h2_resp[fpos + 2]; - uint8_t f_type = h2_resp[fpos + 3]; - uint8_t f_flags = h2_resp[fpos + 4]; - uint32_t f_stream = ((h2_resp[fpos + 5] & 0x7F) << 24) | (h2_resp[fpos + 6] << 16) | - (h2_resp[fpos + 7] << 8) | h2_resp[fpos + 8]; - fpos += 9; - - ESP_LOGD(TAG, " Register H2 frame: type=%d flags=0x%02x len=%lu stream=%lu", - f_type, f_flags, (unsigned long)f_len, (unsigned long)f_stream); - - if (f_len > 1000000 || fpos + (int)f_len > (int)h2_resp_len) { - ESP_LOGW(TAG, " Invalid H2 frame length %lu at pos %d, stopping", (unsigned long)f_len, fpos - 9); - break; - } - - /* Only process frames on stream 1 (our RegisterResponse). - * Stream 0 = connection-level (SETTINGS, WINDOW_UPDATE, PING) */ - if (f_stream == 1) { - if (f_type == 0x00 && f_len > 0) { /* DATA frame */ - if (resp_total + f_len < 8192) { - memcpy(resp_buf + resp_total, h2_resp + fpos, f_len); - resp_total += f_len; - } - if (f_flags & 0x01) got_end_stream = true; - } - if (f_type == 0x01 && (f_flags & 0x01)) got_end_stream = true; - } - - fpos += f_len; - } - free(h2_resp); - - /* Send connection-level WINDOW_UPDATE for RegisterResponse. - * Stream 1 is closed (END_STREAM received), only update connection level. */ - if (resp_total > 0) { - uint8_t wu_buf[13]; - int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)resp_total); - noise_send(ml, noise, wu_buf, wu_len); - } - - ESP_LOGI(TAG, "RegisterResponse: %d bytes total data", (int)resp_total); - - if (resp_total == 0) { - ESP_LOGW(TAG, "No DATA frame in RegisterResponse"); - free(resp_buf); - /* Not fatal - server may just return headers-only 200 */ - return 0; - } - - uint8_t *json_data = resp_buf; - size_t json_data_len = resp_total; - - /* Hex dump first 32 bytes for debugging prefix issues */ - { - int dump = json_data_len < 32 ? (int)json_data_len : 32; - char hexbuf[97]; - for (int i = 0; i < dump; i++) { - sprintf(hexbuf + i * 3, "%02x ", json_data[i]); - } - hexbuf[dump * 3] = '\0'; - ESP_LOGI(TAG, "RegisterResponse first %d bytes (hex): %s", dump, hexbuf); - } - - /* Find start of JSON - skip any binary prefix */ - char *parse_start = (char *)json_data; - size_t parse_len = json_data_len; - int json_offset = -1; - for (int i = 0; i < 8 && i < (int)json_data_len; i++) { - if (json_data[i] == '{') { - json_offset = i; - break; - } - } - if (json_offset > 0) { - ESP_LOGI(TAG, "RegisterResponse JSON starts at offset %d", json_offset); - parse_start += json_offset; - parse_len -= json_offset; - } else if (json_offset < 0) { - ESP_LOGW(TAG, "No '{' found in RegisterResponse data"); - free(resp_buf); - return 0; - } - - /* Null-terminate for cJSON */ - char saved = parse_start[parse_len]; - parse_start[parse_len] = '\0'; - - cJSON *resp_json = cJSON_Parse(parse_start); - if (!resp_json) { - ESP_LOGW(TAG, "Failed to parse RegisterResponse JSON (len=%d)", (int)parse_len); - ESP_LOGW(TAG, "First 100 chars: %.100s", parse_start); - parse_start[parse_len] = saved; - free(resp_buf); - return 0; /* Not fatal - we'll get peers in MapResponse */ - } - parse_start[parse_len] = saved; - free(resp_buf); - - /* Extract our VPN IP from Node.Addresses */ - cJSON *node = cJSON_GetObjectItem(resp_json, "Node"); - if (node) { - cJSON *addresses = cJSON_GetObjectItem(node, "Addresses"); - if (addresses && cJSON_GetArraySize(addresses) > 0) { - const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; - if (addr) { - unsigned a, b, c, d; - if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { - ml->vpn_ip = (a << 24) | (b << 16) | (c << 8) | d; - char ip_str[16]; - microlink_ip_to_str(ml->vpn_ip, ip_str); - ESP_LOGI(TAG, "Our VPN IP: %s", ip_str); - } - } - } - /* Parse self-node DERP region — try modern HomeDERP (int) first, - * then fall back to legacy DERP string (format: "127.3.3.40:REGION") */ - cJSON *home_derp = cJSON_GetObjectItem(node, "HomeDERP"); - if (home_derp && cJSON_IsNumber(home_derp) && home_derp->valueint > 0) { - ml->derp_home_region = (uint16_t)home_derp->valueint; - ESP_LOGI(TAG, "Home DERP region: %d (from server, HomeDERP)", ml->derp_home_region); - } else { - cJSON *self_derp = cJSON_GetObjectItem(node, "DERP"); - if (self_derp && self_derp->valuestring) { - ESP_LOGI(TAG, "Self-Node DERP: %s", self_derp->valuestring); - const char *colon = strrchr(self_derp->valuestring, ':'); - if (colon) { - int region = atoi(colon + 1); - if (region > 0) { - ml->derp_home_region = (uint16_t)region; - ESP_LOGI(TAG, "Home DERP region: %d (from server, legacy DERP)", region); - } - } - } - } - /* Fallback: if server didn't assign a DERP region, use our configured default */ - if (ml->derp_home_region == 0) { - ml->derp_home_region = ML_DERP_REGION; - ESP_LOGI(TAG, "Home DERP region: %d (default)", ML_DERP_REGION); - } - cJSON *self_key = cJSON_GetObjectItem(node, "Key"); - if (self_key && self_key->valuestring) { - ESP_LOGI(TAG, "Self-Node Key (from server): %s", self_key->valuestring); - char our_key_hex[65]; - bytes_to_hex(ml->wg_public_key, 32, our_key_hex); - ESP_LOGI(TAG, "Our WG pubkey (local): nodekey:%s", our_key_hex); - } - } - - cJSON_Delete(resp_json); - - int64_t t_reg_done = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] Register total: %lld ms", (t_reg_done - t_reg_start) / 1000); - - return 0; -} - -/* ============================================================================ - * State: FETCH_PEERS - Send MapRequest, parse MapResponse - * ========================================================================== */ - -static void parse_peers_from_map_response(microlink_t *ml, cJSON *root) { - /* Try all field names used by Tailscale (copied from v1 lines 3176-3184): - * "Peers" - Full peer list (initial Stream=false response) - * "PeersChanged" - Incremental updates (Stream=true long-poll) - * "peers" - Lowercase fallback */ - cJSON *peers = cJSON_GetObjectItem(root, "Peers"); - if (!peers) { - peers = cJSON_GetObjectItem(root, "PeersChanged"); - if (peers) ESP_LOGI(TAG, "Using 'PeersChanged' field for peer list"); - } - if (!peers) { - peers = cJSON_GetObjectItem(root, "peers"); - if (peers) ESP_LOGI(TAG, "Using 'peers' (lowercase) field for peer list"); - } - if (!peers || !cJSON_IsArray(peers)) { - /* Normal for delta updates (PeersRemoved, PeersChangedPatch only) */ - goto check_removed; - } - - int count = cJSON_GetArraySize(peers); - ESP_LOGI(TAG, "MapResponse: %d peers", count); - - cJSON *peer; - cJSON_ArrayForEach(peer, peers) { - /* Allocate peer update (freed by wg_mgr after processing) */ - ml_peer_update_t *update = ml_psram_calloc(1, sizeof(ml_peer_update_t)); - if (!update) continue; - - update->action = ML_PEER_ADD; - - /* Hostname */ - cJSON *name = cJSON_GetObjectItem(peer, "Name"); - if (name && name->valuestring) { - strncpy(update->hostname, name->valuestring, sizeof(update->hostname) - 1); - /* Strip trailing dot from FQDN */ - size_t hlen = strlen(update->hostname); - if (hlen > 0 && update->hostname[hlen - 1] == '.') { - update->hostname[hlen - 1] = '\0'; - } - } - - /* NodeKey: "nodekey:HEX..." */ - cJSON *key = cJSON_GetObjectItem(peer, "Key"); - if (key && key->valuestring) { - const char *hex = key->valuestring; - if (strncmp(hex, "nodekey:", 8) == 0) hex += 8; - hex_to_bytes(hex, update->public_key, 32); - } - - /* DiscoKey: "discokey:HEX..." */ - cJSON *disco = cJSON_GetObjectItem(peer, "DiscoKey"); - if (disco && disco->valuestring) { - const char *hex = disco->valuestring; - if (strncmp(hex, "discokey:", 9) == 0) hex += 9; - hex_to_bytes(hex, update->disco_key, 32); - } - - /* VPN IP from Addresses */ - cJSON *addresses = cJSON_GetObjectItem(peer, "Addresses"); - if (addresses && cJSON_GetArraySize(addresses) > 0) { - const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; - if (addr) { - unsigned a, b, c, d; - /* Handle CIDR notation (e.g., "100.64.1.2/32") */ - if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { - update->vpn_ip = (a << 24) | (b << 16) | (c << 8) | d; - } - } - } - - /* DERP region — try modern HomeDERP (int) first, then legacy DERP string */ - cJSON *peer_home_derp = cJSON_GetObjectItem(peer, "HomeDERP"); - if (peer_home_derp && cJSON_IsNumber(peer_home_derp) && peer_home_derp->valueint > 0) { - update->derp_region = (uint16_t)peer_home_derp->valueint; - } else { - cJSON *derp = cJSON_GetObjectItem(peer, "DERP"); - if (derp && derp->valuestring) { - /* Format: "127.3.3.40:N" where N is the region number */ - unsigned dr; - if (sscanf(derp->valuestring, "127.3.3.40:%u", &dr) == 1) { - update->derp_region = (uint16_t)dr; - } - } - } - - /* Endpoints */ - cJSON *endpoints = cJSON_GetObjectItem(peer, "Endpoints"); - if (endpoints) { - int ep_count = 0; - cJSON *ep; - cJSON_ArrayForEach(ep, endpoints) { - if (ep_count >= ML_MAX_ENDPOINTS) break; - if (ep->valuestring) { - unsigned ea, eb, ec, ed, eport; - if (sscanf(ep->valuestring, "%u.%u.%u.%u:%u", - &ea, &eb, &ec, &ed, &eport) == 5) { - update->endpoints[ep_count].ip = - (ea << 24) | (eb << 16) | (ec << 8) | ed; - update->endpoints[ep_count].port = (uint16_t)eport; - update->endpoints[ep_count].is_ipv6 = false; - ep_count++; - } - } - } - update->endpoint_count = ep_count; - } - - char ip_str[16]; - microlink_ip_to_str(update->vpn_ip, ip_str); - ESP_LOGI(TAG, " Peer: %s (%s) derp=%d eps=%d", - update->hostname, ip_str, update->derp_region, update->endpoint_count); - - /* Send to wg_mgr task via queue */ - if (xQueueSend(ml->peer_update_queue, &update, pdMS_TO_TICKS(100)) != pdTRUE) { - ESP_LOGW(TAG, "Peer update queue full, dropping %s", update->hostname); - free(update); - } - } - -check_removed: - /* Handle PeersRemoved — array of nodekey strings (long-poll delta updates) */ - cJSON *removed = cJSON_GetObjectItem(root, "PeersRemoved"); - if (removed && cJSON_IsArray(removed)) { - int rm_count = cJSON_GetArraySize(removed); - ESP_LOGI(TAG, "PeersRemoved: %d peers", rm_count); - - cJSON *key_item; - cJSON_ArrayForEach(key_item, removed) { - if (!key_item->valuestring) continue; - - ml_peer_update_t *update = ml_psram_calloc(1, sizeof(ml_peer_update_t)); - if (!update) continue; - - update->action = ML_PEER_REMOVE; - - const char *hex = key_item->valuestring; - if (strncmp(hex, "nodekey:", 8) == 0) hex += 8; - hex_to_bytes(hex, update->public_key, 32); - - ESP_LOGI(TAG, " Remove peer: %02x%02x%02x%02x...", - update->public_key[0], update->public_key[1], - update->public_key[2], update->public_key[3]); - - if (xQueueSend(ml->peer_update_queue, &update, pdMS_TO_TICKS(100)) != pdTRUE) { - free(update); - } - } - } - - /* Handle PeersChangedPatch — lightweight endpoint-only updates */ - cJSON *patches = cJSON_GetObjectItem(root, "PeersChangedPatch"); - if (patches && cJSON_IsObject(patches)) { - ESP_LOGI(TAG, "PeersChangedPatch: processing lightweight updates"); - cJSON *patch = patches->child; - while (patch) { - if (patch->string && cJSON_IsObject(patch)) { - /* Key is nodekey string, value has optional fields like Endpoints, DERP */ - ml_peer_update_t *update = ml_psram_calloc(1, sizeof(ml_peer_update_t)); - if (update) { - update->action = ML_PEER_UPDATE_ENDPOINT; - - const char *hex = patch->string; - if (strncmp(hex, "nodekey:", 8) == 0) hex += 8; - hex_to_bytes(hex, update->public_key, 32); - - /* Parse DERP region if present — try DERPRegion (int) first, - * then legacy DERP string */ - cJSON *patch_derp_region = cJSON_GetObjectItem(patch, "DERPRegion"); - if (patch_derp_region && cJSON_IsNumber(patch_derp_region) && patch_derp_region->valueint > 0) { - update->derp_region = (uint16_t)patch_derp_region->valueint; - } else { - cJSON *derp = cJSON_GetObjectItem(patch, "DERP"); - if (derp && derp->valuestring) { - unsigned dr; - if (sscanf(derp->valuestring, "127.3.3.40:%u", &dr) == 1) { - update->derp_region = (uint16_t)dr; - } - } - } - - /* Parse endpoints if present */ - cJSON *endpoints = cJSON_GetObjectItem(patch, "Endpoints"); - if (endpoints) { - int ep_count = 0; - cJSON *ep; - cJSON_ArrayForEach(ep, endpoints) { - if (ep_count >= ML_MAX_ENDPOINTS) break; - if (ep->valuestring) { - unsigned ea, eb, ec, ed, eport; - if (sscanf(ep->valuestring, "%u.%u.%u.%u:%u", - &ea, &eb, &ec, &ed, &eport) == 5) { - update->endpoints[ep_count].ip = - (ea << 24) | (eb << 16) | (ec << 8) | ed; - update->endpoints[ep_count].port = (uint16_t)eport; - update->endpoints[ep_count].is_ipv6 = false; - ep_count++; - } - } - } - update->endpoint_count = ep_count; - } - - ESP_LOGI(TAG, " Patch peer: %02x%02x%02x%02x... eps=%d", - update->public_key[0], update->public_key[1], - update->public_key[2], update->public_key[3], - update->endpoint_count); - - if (xQueueSend(ml->peer_update_queue, &update, pdMS_TO_TICKS(100)) != pdTRUE) { - free(update); - } - } - } - patch = patch->next; - } - } -} - -/* Add Endpoints + EndpointTypes arrays to a MapRequest JSON object. - * Includes: WiFi LAN endpoint (type=Local), STUN IPv4 (type=STUN), - * STUN IPv6 (type=STUN). Returns number of endpoints added. */ -static int add_endpoints_to_json(microlink_t *ml, cJSON *root) { - int count = 0; - cJSON *ep_array = cJSON_AddArrayToObject(root, "Endpoints"); - cJSON *et_array = cJSON_AddArrayToObject(root, "EndpointTypes"); - if (!ep_array || !et_array) return 0; - - /* LAN endpoint (WiFi STA) */ - esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - if (sta_netif) { - esp_netif_ip_info_t ip_info; - if (esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) { - uint32_t local_ip = ntohl(ip_info.ip.addr); - char ep_str[32]; - snprintf(ep_str, sizeof(ep_str), "%lu.%lu.%lu.%lu:%u", - (unsigned long)((local_ip >> 24) & 0xFF), - (unsigned long)((local_ip >> 16) & 0xFF), - (unsigned long)((local_ip >> 8) & 0xFF), - (unsigned long)(local_ip & 0xFF), - ml->disco_local_port); - cJSON_AddItemToArray(ep_array, cJSON_CreateString(ep_str)); - cJSON_AddItemToArray(et_array, cJSON_CreateNumber(1)); /* EndpointLocal */ - count++; - } - } - - /* PPP endpoint (cellular) — use STUN-discovered public IP as our endpoint */ - /* (PPP netif doesn't have a useful local IP for peers — it's behind CGNAT) */ - - /* STUN public endpoint (IPv4) */ - if (ml->stun_public_ip != 0) { - uint32_t pub_ip = ml->stun_public_ip; - uint16_t pub_port = ml->stun_public_port ? ml->stun_public_port : ml->disco_local_port; - char ep_str[32]; - snprintf(ep_str, sizeof(ep_str), "%lu.%lu.%lu.%lu:%u", - (unsigned long)((pub_ip >> 24) & 0xFF), - (unsigned long)((pub_ip >> 16) & 0xFF), - (unsigned long)((pub_ip >> 8) & 0xFF), - (unsigned long)(pub_ip & 0xFF), - pub_port); - cJSON_AddItemToArray(ep_array, cJSON_CreateString(ep_str)); - cJSON_AddItemToArray(et_array, cJSON_CreateNumber(2)); /* EndpointSTUN */ - count++; - } - - /* STUN public endpoint (IPv6) — only include if it's a real IPv6 address, - * not an IPv4-mapped IPv6 (::ffff:x.x.x.x) which is redundant with IPv4. */ - if (ml->stun_has_ipv6) { - const uint8_t *a = ml->stun_public_ip6; - /* Check for IPv4-mapped prefix: first 10 bytes zero, bytes 10-11 = 0xff */ - static const uint8_t v4mapped_prefix[12] = {0,0,0,0, 0,0,0,0, 0,0,0xff,0xff}; - bool is_v4mapped = (memcmp(a, v4mapped_prefix, 12) == 0); - if (!is_v4mapped) { - uint16_t port6 = ml->stun_public_port6 ? ml->stun_public_port6 : ml->disco_local_port; - char ep6_str[64]; - snprintf(ep6_str, sizeof(ep6_str), - "[%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x]:%u", - a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], - a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15], - port6); - cJSON_AddItemToArray(ep_array, cJSON_CreateString(ep6_str)); - cJSON_AddItemToArray(et_array, cJSON_CreateNumber(2)); /* EndpointSTUN */ - count++; - } - } - - if (count > 0) { - ESP_LOGI(TAG, "MapRequest includes %d endpoint(s)", count); - } - return count; -} - -static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { - int64_t t_map_start = esp_timer_get_time(); - - /* Build MapRequest JSON */ - cJSON *root = cJSON_CreateObject(); - if (!root) return -1; - - cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); - - char key_hex[65]; - char key_str[80]; - bytes_to_hex(ml->wg_public_key, 32, key_hex); - snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); - cJSON_AddStringToObject(root, "NodeKey", key_str); - - /* DiscoKey */ - bytes_to_hex(ml->disco_public_key, 32, key_hex); - snprintf(key_str, sizeof(key_str), "discokey:%s", key_hex); - cJSON_AddStringToObject(root, "DiscoKey", key_str); - - /* Stream=false for initial fetch */ - cJSON_AddBoolToObject(root, "Stream", false); - cJSON_AddBoolToObject(root, "KeepAlive", true); - cJSON_AddStringToObject(root, "Compress", ""); /* Disable compression */ - - /* Hostinfo */ - cJSON *hostinfo = cJSON_CreateObject(); - const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); - cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); - cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); - cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); - cJSON_AddItemToObject(root, "Hostinfo", hostinfo); - - /* NetInfo: tell control plane our preferred DERP region and NAT type. - * MUST be inside Hostinfo — the control plane reads Hostinfo.NetInfo.PreferredDERP - * to populate Node.HomeDERP for other peers. */ - cJSON *netinfo = cJSON_CreateObject(); - if (netinfo) { - cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); - if (ml->stun_nat_checked) { - cJSON_AddBoolToObject(netinfo, "MappingVariesByDestIP", ml->nat_mapping_varies); - } - cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); - } - - /* Include endpoints if STUN has already completed (Stream=false → - * control plane processes these, unlike Stream=true with Version >= 68) */ - add_endpoints_to_json(ml, root); - - char *json_str = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (!json_str) return -1; - - size_t json_len = strlen(json_str); - ESP_LOGI(TAG, "MapRequest: %d bytes (Stream=false)", (int)json_len); - - /* Build H2 HEADERS + DATA, stream ID 3 (stream 1 was register) */ - uint8_t *h2_buf = ml_psram_malloc(json_len + 512); - if (!h2_buf) { free(json_str); return -1; } - - int h2_pos = 0; - - int hdr_len = ml_h2_build_headers_frame(h2_buf + h2_pos, json_len + 512, - "POST", "/machine/map", - CTRL_HOST(ml), "application/json", - 3, false); - if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } - h2_pos += hdr_len; - - int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, - (uint8_t *)json_str, json_len, - 3, true); - free(json_str); - if (data_len < 0) { free(h2_buf); return -1; } - h2_pos += data_len; - - if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { - free(h2_buf); - return -1; - } - free(h2_buf); - - int64_t t_map_sent = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] MapRequest send: %lld ms", (t_map_sent - t_map_start) / 1000); - - /* 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 */ - 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) */ - struct timeval rcv_tv = { .tv_sec = 60, .tv_usec = 0 }; - ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); - - uint64_t recv_start_ms = ml_get_time_ms(); - uint64_t last_progress_ms = recv_start_ms; - size_t window_consumed = 0; - - /* Read all Noise frames and accumulate decrypted H2 data. - * Scan for H2 END_STREAM flag (0x01) on DATA frames to know when the - * response is complete — without this, we wait for the full recv timeout - * (60s) before proceeding, which dominates connection time on cellular. */ - bool got_end_stream = false; - for (int read_count = 0; read_count < 200; read_count++) { - uint8_t *frame_buf = ml_psram_malloc(65536); - if (!frame_buf) break; - - int frame_len = noise_recv(ml, noise, frame_buf, 65536); - if (frame_len <= 0) { - free(frame_buf); - break; - } - - /* Append decrypted data to h2_recv */ - if (h2_total + frame_len < ML_H2_BUFFER_SIZE) { - memcpy(h2_recv + h2_total, frame_buf, frame_len); - h2_total += frame_len; - window_consumed += frame_len; - } else { - ESP_LOGW(TAG, "H2 buffer full at %dKB, truncating", (int)(h2_total / 1024)); - free(frame_buf); - break; - } - free(frame_buf); - - /* Scan newly accumulated data for H2 END_STREAM flag. - * H2 frame header: 3 bytes length + 1 byte type + 1 byte flags + 4 bytes stream ID. - * DATA frame type=0x00, END_STREAM flag=0x01. - * We scan from the start each time since frames may span Noise boundaries. */ - size_t scan_pos = 0; - while (scan_pos + 9 <= h2_total) { - uint32_t f_len = (h2_recv[scan_pos] << 16) | (h2_recv[scan_pos + 1] << 8) | h2_recv[scan_pos + 2]; - uint8_t f_type = h2_recv[scan_pos + 3]; - uint8_t f_flags = h2_recv[scan_pos + 4]; - - if (scan_pos + 9 + f_len > h2_total) break; /* Incomplete frame */ - - if (f_type == 0x00 && (f_flags & 0x01)) { - /* DATA frame with END_STREAM — response is complete */ - got_end_stream = true; - } - scan_pos += 9 + f_len; - } - - if (got_end_stream) { - ESP_LOGI(TAG, "H2 END_STREAM detected after %d Noise frames (%dKB, %lums)", - read_count + 1, (int)(h2_total / 1024), - (unsigned long)(ml_get_time_ms() - recv_start_ms)); - break; - } - - /* Progress logging every 5 seconds for large responses */ - uint64_t now = ml_get_time_ms(); - if (now - last_progress_ms > 5000) { - ESP_LOGI(TAG, "MapResponse: received %dKB so far (%d frames, %lums elapsed)", - (int)(h2_total / 1024), read_count + 1, - (unsigned long)(now - recv_start_ms)); - last_progress_ms = now; - } - - /* Proactive WINDOW_UPDATE every 32KB to keep server sending. - * Must update BOTH connection-level (stream 0) AND stream-level (stream 3) - * windows, otherwise the server stalls when either window exhausts. */ - if (window_consumed >= 32768) { - uint8_t wu_buf[26]; /* 2 WINDOW_UPDATE frames: 13 bytes each */ - int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)window_consumed); - wu_len += ml_h2_build_window_update(wu_buf + wu_len, 13, 3, (uint32_t)window_consumed); - if (wu_len > 0) { - noise_send(ml, noise, wu_buf, wu_len); - } - window_consumed = 0; - } - } - - /* Restore normal recv timeout (5 seconds for long-poll) */ - rcv_tv.tv_sec = 5; - ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); - - ESP_LOGI(TAG, "Accumulated %dKB of H2 data from Noise frames (%lums)", - (int)(h2_total / 1024), - (unsigned long)(ml_get_time_ms() - recv_start_ms)); - - /* Now parse complete H2 frames from accumulated buffer */ - 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]; - uint8_t f_type = h2_recv[fpos + 3]; - uint8_t f_flags = h2_recv[fpos + 4]; - uint32_t f_stream = ((h2_recv[fpos + 5] & 0x7F) << 24) | - (h2_recv[fpos + 6] << 16) | - (h2_recv[fpos + 7] << 8) | h2_recv[fpos + 8]; - fpos += 9; - - ESP_LOGI(TAG, " H2 frame: type=%d flags=0x%02x len=%lu stream=%lu", - f_type, f_flags, (unsigned long)f_len, (unsigned long)f_stream); - - if (fpos + (int)f_len > (int)h2_total) { - ESP_LOGW(TAG, " Incomplete H2 frame at end (need %lu, have %d)", - (unsigned long)f_len, (int)h2_total - fpos); - break; - } - - 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; - } - } - - 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. - * Without this, the server's connection-level window exhausts on large responses. */ - if (json_total > 0) { - uint8_t wu_buf[13]; - int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)json_total); - noise_send(ml, noise, wu_buf, wu_len); - ESP_LOGI(TAG, "Sent H2 WINDOW_UPDATE: %d bytes (connection level)", (int)json_total); - } - - if (json_total == 0) { - ESP_LOGW(TAG, "Empty MapResponse"); - free(resp_buf); - return -1; - } - - ESP_LOGI(TAG, "MapResponse JSON: %d bytes", (int)json_total); - - /* Hex dump first 32 bytes for debugging prefix issues */ - { - 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]); - } - 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; - 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] == '{') { - json_offset = i; - break; - } - } - if (json_offset > 0) { - ESP_LOGI(TAG, "JSON starts at offset %d (skipping %d-byte prefix)", json_offset, json_offset); - parse_start += json_offset; - parse_len -= json_offset; - } else if (json_offset < 0) { - ESP_LOGW(TAG, "No '{' found in first 8 bytes of MapResponse!"); - } - - /* Null-terminate */ - char saved = parse_start[parse_len]; - parse_start[parse_len] = '\0'; - - cJSON *map_json = cJSON_Parse(parse_start); - parse_start[parse_len] = saved; - - if (!map_json) { - const char *err = cJSON_GetErrorPtr(); - ESP_LOGE(TAG, "MapResponse JSON parse failed near: %.50s", err ? err : "unknown"); - free(resp_buf); - return -1; - } - - /* Debug: log all top-level fields in MapResponse (from v1 lines 3053-3072) */ - { - ESP_LOGI(TAG, "MapResponse top-level fields:"); - cJSON *field = NULL; - cJSON_ArrayForEach(field, map_json) { - if (field->string) { - if (cJSON_IsArray(field)) - ESP_LOGI(TAG, " - %s (array, size=%d)", field->string, cJSON_GetArraySize(field)); - else if (cJSON_IsObject(field)) - ESP_LOGI(TAG, " - %s (object)", field->string); - else if (cJSON_IsString(field)) - ESP_LOGI(TAG, " - %s (string): %.40s", field->string, field->valuestring); - else if (cJSON_IsNumber(field)) - ESP_LOGI(TAG, " - %s (number): %g", field->string, field->valuedouble); - else if (cJSON_IsBool(field)) - ESP_LOGI(TAG, " - %s (bool): %s", field->string, cJSON_IsTrue(field) ? "true" : "false"); - else - ESP_LOGI(TAG, " - %s (other)", field->string); - } - } - } - - /* Extract self-node info */ - { - cJSON *node = cJSON_GetObjectItem(map_json, "Node"); - if (node) { - /* Extract VPN IP if not already set */ - if (ml->vpn_ip == 0) { - cJSON *addresses = cJSON_GetObjectItem(node, "Addresses"); - if (addresses && cJSON_GetArraySize(addresses) > 0) { - const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; - if (addr) { - unsigned a, b, c, d; - if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { - ml->vpn_ip = (a << 24) | (b << 16) | (c << 8) | d; - char ip_str[16]; - microlink_ip_to_str(ml->vpn_ip, ip_str); - ESP_LOGI(TAG, "Our VPN IP: %s", ip_str); - } - } - } - } - /* Parse self-node DERP region — try modern HomeDERP (int) first, - * then fall back to legacy DERP string (format: "127.3.3.40:REGION") */ - cJSON *home_derp = cJSON_GetObjectItem(node, "HomeDERP"); - if (home_derp && cJSON_IsNumber(home_derp) && home_derp->valueint > 0) { - ml->derp_home_region = (uint16_t)home_derp->valueint; - ESP_LOGI(TAG, "Home DERP region: %d (from server, HomeDERP)", ml->derp_home_region); - } else { - cJSON *self_derp = cJSON_GetObjectItem(node, "DERP"); - if (self_derp && self_derp->valuestring) { - ESP_LOGI(TAG, "Self-Node DERP: %s", self_derp->valuestring); - const char *colon = strrchr(self_derp->valuestring, ':'); - if (colon) { - int region = atoi(colon + 1); - if (region > 0) { - ml->derp_home_region = (uint16_t)region; - ESP_LOGI(TAG, "Home DERP region: %d (from server, legacy DERP)", region); - } - } - } - } - /* Fallback: if server didn't assign a DERP region, use our configured default */ - if (ml->derp_home_region == 0) { - ml->derp_home_region = ML_DERP_REGION; - ESP_LOGI(TAG, "Home DERP region: %d (default)", ML_DERP_REGION); - } - cJSON *self_key = cJSON_GetObjectItem(node, "Key"); - if (self_key && self_key->valuestring) { - ESP_LOGI(TAG, "Self-Node Key (server): %.40s...", self_key->valuestring); - char our_hex[65]; - bytes_to_hex(ml->wg_public_key, 32, our_hex); - ESP_LOGI(TAG, "Our WG pubkey (local): nodekey:%.32s...", our_hex); - } - - /* Parse KeyExpiry (ISO 8601: "YYYY-MM-DDTHH:MM:SSZ") */ - cJSON *key_expiry = cJSON_GetObjectItem(node, "KeyExpiry"); - if (key_expiry && key_expiry->valuestring) { - int yr, mo, dy, hr, mn, sc; - if (sscanf(key_expiry->valuestring, "%d-%d-%dT%d:%d:%d", - &yr, &mo, &dy, &hr, &mn, &sc) >= 6) { - /* Simple epoch calculation (approximate, no leap second) */ - /* Days from year 1970 */ - int64_t days = 0; - for (int y = 1970; y < yr; y++) { - days += (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 366 : 365; - } - static const int mdays[] = {0,31,28,31,30,31,30,31,31,30,31,30,31}; - for (int m = 1; m < mo; m++) { - days += mdays[m]; - if (m == 2 && (yr % 4 == 0 && (yr % 100 != 0 || yr % 400 == 0))) days++; - } - days += dy - 1; - ml->key_expiry_epoch = days * 86400 + hr * 3600 + mn * 60 + sc; - ESP_LOGI(TAG, "Key expiry: %s (epoch: %lld)", key_expiry->valuestring, - (long long)ml->key_expiry_epoch); - } - } - - /* Check Expired flag */ - cJSON *expired = cJSON_GetObjectItem(node, "Expired"); - if (expired && cJSON_IsTrue(expired)) { - ml->key_expired = true; - ESP_LOGW(TAG, "Node key is EXPIRED — re-registration needed"); - } else { - ml->key_expired = false; - } - } - } - - /* Parse peers */ - parse_peers_from_map_response(ml, map_json); - - /* Extract DERPMap if present — parse all regions and nodes */ - cJSON *derp_map = cJSON_GetObjectItem(map_json, "DERPMap"); - if (derp_map) { - cJSON *regions = cJSON_GetObjectItem(derp_map, "Regions"); - if (regions) { - ml->derp_region_count = 0; - cJSON *region_obj; - cJSON_ArrayForEach(region_obj, regions) { - if (ml->derp_region_count >= ML_MAX_DERP_REGIONS) break; - ml_derp_region_t *r = &ml->derp_regions[ml->derp_region_count]; - memset(r, 0, sizeof(*r)); - - cJSON *rid = cJSON_GetObjectItem(region_obj, "RegionID"); - if (rid) r->region_id = (uint16_t)rid->valuedouble; - - cJSON *rcode = cJSON_GetObjectItem(region_obj, "RegionCode"); - if (rcode && rcode->valuestring) { - strncpy(r->code, rcode->valuestring, sizeof(r->code) - 1); - } - - cJSON *avoid = cJSON_GetObjectItem(region_obj, "Avoid"); - if (avoid && cJSON_IsTrue(avoid)) r->avoid = true; - - /* Parse nodes */ - cJSON *nodes = cJSON_GetObjectItem(region_obj, "Nodes"); - if (nodes) { - cJSON *node_obj; - cJSON_ArrayForEach(node_obj, nodes) { - if (r->node_count >= ML_MAX_DERP_NODES) break; - ml_derp_node_t *n = &r->nodes[r->node_count]; - memset(n, 0, sizeof(*n)); - - cJSON *hn = cJSON_GetObjectItem(node_obj, "HostName"); - if (hn && hn->valuestring) { - strncpy(n->hostname, hn->valuestring, sizeof(n->hostname) - 1); - } - - cJSON *ip4 = cJSON_GetObjectItem(node_obj, "IPv4"); - if (ip4 && ip4->valuestring) { - strncpy(n->ipv4, ip4->valuestring, sizeof(n->ipv4) - 1); - } - - cJSON *ip6 = cJSON_GetObjectItem(node_obj, "IPv6"); - if (ip6 && ip6->valuestring) { - strncpy(n->ipv6, ip6->valuestring, sizeof(n->ipv6) - 1); - } - - cJSON *sp = cJSON_GetObjectItem(node_obj, "STUNPort"); - if (sp) n->stun_port = (uint16_t)sp->valuedouble; - - cJSON *dp = cJSON_GetObjectItem(node_obj, "DERPPort"); - if (dp) n->derp_port = (uint16_t)dp->valuedouble; - - cJSON *so = cJSON_GetObjectItem(node_obj, "STUNOnly"); - if (so && cJSON_IsTrue(so)) n->stun_only = true; - - r->node_count++; - } - } - - ESP_LOGI(TAG, " DERP region %d (%s): %d nodes%s", - r->region_id, r->code, r->node_count, - r->avoid ? " [avoid]" : ""); - for (int ni = 0; ni < r->node_count; ni++) { - ESP_LOGI(TAG, " node: %s (v4=%s v6=%s stun=%d derp=%d%s)", - r->nodes[ni].hostname, - r->nodes[ni].ipv4[0] ? r->nodes[ni].ipv4 : "-", - r->nodes[ni].ipv6[0] ? r->nodes[ni].ipv6 : "-", - r->nodes[ni].stun_port ? r->nodes[ni].stun_port : 3478, - r->nodes[ni].derp_port ? r->nodes[ni].derp_port : 443, - r->nodes[ni].stun_only ? " stun-only" : ""); - } - - ml->derp_region_count++; - } - ESP_LOGI(TAG, "DERPMap: parsed %d regions", ml->derp_region_count); - } - } - - cJSON_Delete(map_json); - free(resp_buf); - - int64_t t_map_done = esp_timer_get_time(); - ESP_LOGI(TAG, "[TIMING] MapResponse recv+parse: %lld ms (total map: %lld ms, %dKB)", - (t_map_done - t_map_sent) / 1000, - (t_map_done - t_map_start) / 1000, - (int)(h2_total / 1024)); - - return 0; -} - -/* ============================================================================ - * State: LONG_POLL - Start streaming MapRequest + process incremental updates - * ========================================================================== */ - -/* Send MapRequest with Stream=true to start long-poll on H2 stream 5 */ -static int do_start_long_poll(microlink_t *ml, ml_noise_state_t *noise) { - cJSON *root = cJSON_CreateObject(); - if (!root) return -1; - - cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); - - char key_hex[65], key_str[80]; - bytes_to_hex(ml->wg_public_key, 32, key_hex); - snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); - cJSON_AddStringToObject(root, "NodeKey", key_str); - - bytes_to_hex(ml->disco_public_key, 32, key_hex); - snprintf(key_str, sizeof(key_str), "discokey:%s", key_hex); - cJSON_AddStringToObject(root, "DiscoKey", key_str); - - /* Hostinfo - REQUIRED by control plane even for Stream=true. - * V1 includes this; without it, server may not keep us "online". */ - cJSON *hostinfo = cJSON_CreateObject(); - if (hostinfo) { - const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); - cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); - cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); - cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); - cJSON_AddItemToObject(root, "Hostinfo", hostinfo); - } - - /* NetInfo: tell control plane our preferred DERP region and NAT type. - * MUST be inside Hostinfo — the control plane reads Hostinfo.NetInfo.PreferredDERP - * to populate Node.HomeDERP for other peers. */ - cJSON *netinfo = cJSON_CreateObject(); - if (netinfo) { - cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); - if (ml->stun_nat_checked) { - cJSON_AddBoolToObject(netinfo, "MappingVariesByDestIP", ml->nat_mapping_varies); - } - cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); - } - - /* Stream=true for long-poll, KeepAlive=true so server sends keepalives - * (which marks us as "online" on the control plane) */ - cJSON_AddBoolToObject(root, "Stream", true); - cJSON_AddBoolToObject(root, "KeepAlive", true); - cJSON_AddStringToObject(root, "Compress", ""); /* Disable compression */ - cJSON_AddBoolToObject(root, "OmitPeers", true); /* Already have peers */ - - /* NOTE: With Version >= 68, the control plane IGNORES Endpoints and - * Hostinfo in Stream=true MapRequests. Endpoints are sent via separate - * do_send_endpoint_update() calls (Stream=false, OmitPeers=true). */ - - char *json_str = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (!json_str) return -1; - - size_t json_len = strlen(json_str); - ESP_LOGI(TAG, "MapRequest: %d bytes (Stream=true)", (int)json_len); - - /* Build H2 frames on stream ID 5 */ - uint8_t *h2_buf = ml_psram_malloc(json_len + 512); - if (!h2_buf) { free(json_str); return -1; } - - int h2_pos = 0; - int hdr_len = ml_h2_build_headers_frame(h2_buf, json_len + 512, - "POST", "/machine/map", - CTRL_HOST(ml), "application/json", - 5, false); - if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } - h2_pos += hdr_len; - - int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, - (uint8_t *)json_str, json_len, - 5, true); - free(json_str); - if (data_len < 0) { free(h2_buf); return -1; } - h2_pos += data_len; - - if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { - free(h2_buf); - return -1; - } - free(h2_buf); - - ESP_LOGI(TAG, "Streaming MapRequest sent on stream 5"); - return 0; -} - -/* Send a "lite" endpoint update to the control plane. - * This is a non-streaming MapRequest (Stream=false, OmitPeers=true) that - * only updates our endpoints and hostinfo. Required because Version >= 68 - * means the control plane IGNORES Endpoints in streaming (Stream=true) - * MapRequests. The reference client uses a dedicated "updateRoutine" for this. - * - * Uses H2 stream 7. Response body is discarded (only HTTP status matters). - * Returns 0 on success, -1 on send failure. */ -static int do_send_endpoint_update(microlink_t *ml, ml_noise_state_t *noise) { - if (ml->stun_public_ip == 0 && !ml->stun_has_ipv6) { - ESP_LOGD(TAG, "No STUN endpoints yet, skipping endpoint update"); - return 0; /* Nothing to send */ - } - - cJSON *root = cJSON_CreateObject(); - if (!root) return -1; - - cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); - - char key_hex[65], key_str[80]; - bytes_to_hex(ml->wg_public_key, 32, key_hex); - snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); - cJSON_AddStringToObject(root, "NodeKey", key_str); - - bytes_to_hex(ml->disco_public_key, 32, key_hex); - snprintf(key_str, sizeof(key_str), "discokey:%s", key_hex); - cJSON_AddStringToObject(root, "DiscoKey", key_str); - - /* Stream=false so control plane PROCESSES our endpoints (Version >= 68) */ - cJSON_AddBoolToObject(root, "Stream", false); - cJSON_AddBoolToObject(root, "KeepAlive", true); - /* OmitPeers=true — we don't need peers back, just updating our endpoints */ - cJSON_AddBoolToObject(root, "OmitPeers", true); - cJSON_AddStringToObject(root, "Compress", ""); - - /* Hostinfo (required — control plane reads NetInfo from here) */ - cJSON *hostinfo = cJSON_CreateObject(); - if (hostinfo) { - const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); - cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); - cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); - cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); - cJSON_AddItemToObject(root, "Hostinfo", hostinfo); - - cJSON *netinfo = cJSON_CreateObject(); - if (netinfo) { - cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); - if (ml->stun_nat_checked) { - cJSON_AddBoolToObject(netinfo, "MappingVariesByDestIP", ml->nat_mapping_varies); - } - cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); - } - } - - /* Endpoints + EndpointTypes */ - int ep_count = add_endpoints_to_json(ml, root); - - char *json_str = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (!json_str) return -1; - - size_t json_len = strlen(json_str); - ESP_LOGI(TAG, "Endpoint update: %d bytes, %d endpoints (Stream=false, OmitPeers=true)", - (int)json_len, ep_count); - - /* Use incrementing odd stream IDs (H2 client-initiated = odd). - * Streams 1, 3, 5 are used by register, fetch peers, and long-poll. - * Start at 7, increment by 2 for each endpoint update. - * Reset to 7 on reconnect (h2_next_stream_id initialized in connect). */ - if (ml->h2_next_stream_id < 7) ml->h2_next_stream_id = 7; - uint32_t sid = ml->h2_next_stream_id; - ml->h2_next_stream_id += 2; - - uint8_t *h2_buf = ml_psram_malloc(json_len + 512); - if (!h2_buf) { free(json_str); return -1; } - - int h2_pos = 0; - int hdr_len = ml_h2_build_headers_frame(h2_buf, json_len + 512, - "POST", "/machine/map", - CTRL_HOST(ml), "application/json", - sid, false); - if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } - h2_pos += hdr_len; - - int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, - (uint8_t *)json_str, json_len, - sid, true); /* END_STREAM */ - free(json_str); - if (data_len < 0) { free(h2_buf); return -1; } - h2_pos += data_len; - - if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { - free(h2_buf); - ESP_LOGE(TAG, "Failed to send endpoint update"); - return -1; - } - free(h2_buf); - - ESP_LOGI(TAG, "Endpoint update sent on H2 stream %lu", (unsigned long)sid); - /* Response body is discarded — server may send empty response or - * we'll consume it in the next poll_map_update() iteration. - * Only the HTTP status code matters (200 = success). */ - return 0; -} - -/* Try to read one incremental MapResponse update (non-blocking) */ -static int poll_map_update(microlink_t *ml, ml_noise_state_t *noise) { -#ifdef CONFIG_ML_CTRL_TLS - /* Decrypted bytes may already sit in the TLS layer with nothing left - * on the socket — select() alone would miss them. */ - if (ml_coord_tls_pending(ml) == 0) -#endif - { - /* Use select() to check if data is available before blocking in recv */ - fd_set readfds; - FD_ZERO(&readfds); - FD_SET(ml->coord_sock, &readfds); - struct timeval tv = { .tv_sec = 0, .tv_usec = 50000 }; /* 50ms */ - int sel = ml_select_fds(ml->coord_sock + 1, &readfds, NULL, NULL, &tv); - if (sel <= 0) return 0; /* No data available or error */ - } - - /* Data available — set short recv timeout for partial frame safety */ - struct timeval tv_recv = { .tv_sec = 2, .tv_usec = 0 }; - ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &tv_recv, sizeof(tv_recv)); - - uint8_t *frame_buf = ml_psram_malloc(65536); - if (!frame_buf) return 0; - - int frame_len = noise_recv(ml, noise, frame_buf, 65536); - - if (frame_len <= 0) { - free(frame_buf); - int saved_errno = errno; - /* EAGAIN/EWOULDBLOCK = no data yet = not an error */ - if (saved_errno == EAGAIN || saved_errno == EWOULDBLOCK) return 0; - return frame_len; /* Real error or connection closed */ - } - - /* Extract DATA frame payload from H2 frames, track flow control */ - uint8_t *json_data = NULL; - size_t json_data_len = 0; - uint32_t total_data_bytes = 0; - uint32_t data_stream_id = 0; - int pos = 0; - - while (pos + 9 <= frame_len) { - uint32_t f_len = (frame_buf[pos] << 16) | (frame_buf[pos + 1] << 8) | frame_buf[pos + 2]; - uint8_t f_type = frame_buf[pos + 3]; - uint8_t f_flags = frame_buf[pos + 4]; - uint32_t f_stream = ((frame_buf[pos + 5] & 0x7F) << 24) | (frame_buf[pos + 6] << 16) | - (frame_buf[pos + 7] << 8) | frame_buf[pos + 8]; - pos += 9; - - if (pos + (int)f_len > frame_len) break; - - if (f_type == 0x00) { /* DATA frame */ - total_data_bytes += f_len; - if (f_stream == 5) { - /* Long-poll MapResponse data (stream 5) — parse as JSON */ - data_stream_id = f_stream; - if (f_len > 0) { - json_data = frame_buf + pos; - json_data_len = f_len; - } - } else if (f_len > 0) { - /* Endpoint update response (stream 7+) — discard body */ - ESP_LOGD(TAG, "H2 stream %lu DATA: %lu bytes (discarded)", - (unsigned long)f_stream, (unsigned long)f_len); - } - } else if (f_type == 0x06 && f_len == 8 && !(f_flags & 0x01)) { - /* HTTP/2 PING from server — respond with PONG (same payload, ACK flag) */ - uint8_t pong[17]; - pong[0] = 0x00; pong[1] = 0x00; pong[2] = 0x08; - pong[3] = 0x06; pong[4] = 0x01; - pong[5] = 0x00; pong[6] = 0x00; pong[7] = 0x00; pong[8] = 0x00; - memcpy(pong + 9, frame_buf + pos, 8); - noise_send(ml, noise, pong, sizeof(pong)); - ESP_LOGI(TAG, "Sent HTTP/2 PONG in response to server PING"); - } else if (f_type == 0x04 && !(f_flags & 0x01)) { - /* HTTP/2 SETTINGS from server — respond with SETTINGS ACK */ - uint8_t settings_ack[9] = {0x00, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00}; - noise_send(ml, noise, settings_ack, sizeof(settings_ack)); - } - pos += f_len; - } - - /* Send HTTP/2 WINDOW_UPDATE to replenish flow control after receiving DATA. - * Without this, the server's send window exhausts and the connection stalls. - * Must send for BOTH connection-level (stream 0) AND stream-level. (v1 reference) */ - if (total_data_bytes > 0) { - uint8_t wu_buf[26]; /* 2 WINDOW_UPDATE frames: 13 bytes each */ - /* Connection-level (stream 0) */ - int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, total_data_bytes); - /* Stream-level */ - if (data_stream_id > 0) { - wu_len += ml_h2_build_window_update(wu_buf + wu_len, 13, - data_stream_id, total_data_bytes); - } - noise_send(ml, noise, wu_buf, wu_len); - } - - if (!json_data || json_data_len == 0) { - /* Keepalive, SETTINGS, or PING frame - not an error */ - free(frame_buf); - return 1; /* Got data, reset watchdog */ - } - - /* Skip 4-byte length prefix if present */ - char *parse_start = (char *)json_data; - size_t parse_len = json_data_len; - if (parse_len > 4 && parse_start[4] == '{') { - parse_start += 4; - parse_len -= 4; - } - - char saved = parse_start[parse_len]; - parse_start[parse_len] = '\0'; - - cJSON *update_json = cJSON_Parse(parse_start); - parse_start[parse_len] = saved; - - if (update_json) { - ESP_LOGI(TAG, "Long-poll MapResponse update received"); - - /* Update VPN IP if present */ - cJSON *node = cJSON_GetObjectItem(update_json, "Node"); - if (node) { - cJSON *addresses = cJSON_GetObjectItem(node, "Addresses"); - if (addresses && cJSON_GetArraySize(addresses) > 0) { - const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; - if (addr) { - unsigned a, b, c, d; - if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { - uint32_t new_ip = (a << 24) | (b << 16) | (c << 8) | d; - if (new_ip != ml->vpn_ip) { - ml->vpn_ip = new_ip; - ESP_LOGI(TAG, "VPN IP updated via long-poll"); - } - } - } - } - } - - /* Parse peer updates */ - parse_peers_from_map_response(ml, update_json); - cJSON_Delete(update_json); - } - - free(frame_buf); - return 1; -} - -/* ============================================================================ - * Coordination Task Main Loop - * ========================================================================== */ - -void ml_coord_task(void *arg) { - microlink_t *ml = (microlink_t *)arg; - ESP_LOGI(TAG, "Coord task started (Core %d)", xPortGetCoreID()); - - coord_state_t state = COORD_IDLE; - ml_coord_cmd_t cmd; - uint64_t last_activity_ms = ml_get_time_ms(); - int reconnect_attempts = 0; - - /* Noise protocol state - owned exclusively by this task */ - ml_noise_state_t noise = {0}; - - /* Wait for WiFi/cellular OR shutdown */ - ESP_LOGI(TAG, "Waiting for WiFi..."); - { - EventBits_t wb = xEventGroupWaitBits(ml->events, - ML_EVT_WIFI_CONNECTED | ML_EVT_SHUTDOWN_REQUEST, - pdFALSE, pdFALSE, portMAX_DELAY); - if (wb & ML_EVT_SHUTDOWN_REQUEST) { - ESP_LOGI(TAG, "Shutdown requested before WiFi, exiting"); - vTaskDelete(NULL); - return; - } - } - - while (!(xEventGroupGetBits(ml->events) & ML_EVT_SHUTDOWN_REQUEST)) { - /* Check for commands (non-blocking) */ - if (xQueueReceive(ml->coord_cmd_queue, &cmd, 0) == pdTRUE) { - switch (cmd) { - case ML_CMD_CONNECT: - if (state == COORD_IDLE) { - state = COORD_STUN_PROBE; - ml->state = ML_STATE_CONNECTING; - } - break; - case ML_CMD_DISCONNECT: - coord_close_conn(ml); - state = COORD_IDLE; - ml->state = ML_STATE_IDLE; - break; - case ML_CMD_FORCE_RECONNECT: - state = COORD_RECONNECTING; - break; - case ML_CMD_UPDATE_ENDPOINTS: - /* TODO: Send endpoint update on existing H2 connection */ - break; - } - } - - /* DERP reconnect is now handled by the DERP I/O task itself. - * The I/O task checks ML_EVT_DERP_RECONNECT directly. */ - - switch (state) { - case COORD_IDLE: - vTaskDelay(pdMS_TO_TICKS(100)); - break; - - case COORD_STUN_PROBE: - ESP_LOGI(TAG, "Running STUN probe..."); - /* Pre-resolve STUN servers (cached, only resolves once) */ - ml_stun_resolve_servers(ml); - /* Reset retry state for fresh probe sequence */ - ml->stun_retry_count = 1; /* First probe counts as attempt 1 */ - ml->stun_using_fallback = false; - ml->stun_last_probe_ms = ml_get_time_ms(); - /* Send first probe to Tailscale STUN primary (IPv4) */ - if (ml->stun_primary_ip) { - ml_stun_send_probe_to(ml, ml->stun_primary_ip, ML_STUN_PRIMARY_PORT); - } else if (ml->stun_fallback_ip) { - ml->stun_using_fallback = true; - ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); - } else { - /* Both DNS failed, try hostname-based as last resort */ - ml_stun_send_probe(ml, ML_STUN_PRIMARY_HOST, ML_STUN_PRIMARY_PORT); - } - /* Also send IPv6 STUN probe if resolved */ - { - static const uint8_t zero16[16] = {0}; - if (memcmp(ml->stun_primary_ip6, zero16, 16) != 0) { - ml_stun_send_probe_ipv6(ml, ml->stun_primary_ip6, ML_STUN_PRIMARY_PORT); - } - } - state = COORD_DNS_RESOLVE; - break; - - case COORD_DNS_RESOLVE: - case COORD_TCP_CONNECT: - if (do_tcp_connect(ml) < 0) { - ESP_LOGE(TAG, "TCP connect failed, retrying..."); - state = COORD_RECONNECTING; - break; - } - ml->h2_next_stream_id = 7; /* Reset H2 stream counter for new connection */ - state = COORD_NOISE_HANDSHAKE; - break; - - case COORD_NOISE_HANDSHAKE: - ml->state = ML_STATE_REGISTERING; - if (do_noise_handshake(ml, &noise) < 0) { - ESP_LOGE(TAG, "Noise handshake failed"); - coord_close_conn(ml); - state = COORD_RECONNECTING; - break; - } - /* Process proactive server frames (EarlyNoise, H2 SETTINGS) - * before sending our H2 preface. Extracts nodeKeyChallenge - * and adjusts rx_nonce. */ - process_proactive_frames(ml, &noise); - state = COORD_H2_PREFACE; - break; - - case COORD_H2_PREFACE: - if (do_h2_preface(ml, &noise) < 0) { - ESP_LOGE(TAG, "H2 preface failed"); - coord_close_conn(ml); - state = COORD_RECONNECTING; - break; - } - state = COORD_REGISTER; - break; - - case COORD_REGISTER: - ESP_LOGI(TAG, "Registering..."); - if (do_register(ml, &noise) < 0) { - ESP_LOGE(TAG, "Registration failed"); - coord_close_conn(ml); - state = COORD_RECONNECTING; - break; - } - state = COORD_FETCH_PEERS; - break; - - case COORD_FETCH_PEERS: - ESP_LOGI(TAG, "Fetching peers..."); - if (do_fetch_peers(ml, &noise) < 0) { - ESP_LOGW(TAG, "MapRequest failed, will retry"); - coord_close_conn(ml); - state = COORD_RECONNECTING; - break; - } - - xEventGroupSetBits(ml->events, ML_EVT_COORD_REGISTERED); - - /* Signal DERP I/O task to connect (connection now owned by I/O task) */ - if (!ml->derp.connected) { - xEventGroupSetBits(ml->events, ML_EVT_DERP_CONNECT_REQ); - /* Wait for DERP to connect (up to 15s) before continuing */ - ESP_LOGI(TAG, "Waiting for DERP I/O task to connect..."); - xEventGroupWaitBits(ml->events, ML_EVT_DERP_CONNECTED, - pdFALSE, pdTRUE, pdMS_TO_TICKS(15000)); - } - - /* Start streaming long-poll for incremental updates */ - if (do_start_long_poll(ml, &noise) < 0) { - ESP_LOGW(TAG, "Failed to start long-poll (non-fatal)"); - } - - /* Send initial endpoint update if STUN already completed. - * (STUN runs concurrently and may have results by now.) */ - if (ml->stun_public_ip != 0 || ml->stun_has_ipv6) { - do_send_endpoint_update(ml, &noise); - } - - /* Send initial HTTP/2 PING immediately to keep connection active - * before the periodic 5s timer kicks in (server has ~20s idle timeout) */ - { - uint8_t ping_frame[17]; - ping_frame[0] = 0x00; ping_frame[1] = 0x00; ping_frame[2] = 0x08; - ping_frame[3] = 0x06; ping_frame[4] = 0x00; - ping_frame[5] = 0x00; ping_frame[6] = 0x00; - ping_frame[7] = 0x00; ping_frame[8] = 0x00; - memset(ping_frame + 9, 0x42, 8); - noise_send(ml, &noise, ping_frame, sizeof(ping_frame)); - ESP_LOGI(TAG, "Sent initial HTTP/2 PING after long-poll"); - } - - state = COORD_LONG_POLL; - ml->state = ML_STATE_CONNECTED; - reconnect_attempts = 0; - last_activity_ms = ml_get_time_ms(); - - /* Notify app */ - if (ml->state_cb) { - ml->state_cb(ml, ML_STATE_CONNECTED, ml->state_cb_data); - } - break; - - case COORD_LONG_POLL: - { - uint64_t now = ml_get_time_ms(); - - /* Check control plane watchdog (120s) */ - if (now - last_activity_ms > ml->t_ctrl_watchdog_ms) { - ESP_LOGW(TAG, "Control plane watchdog timeout"); - state = COORD_RECONNECTING; - break; - } - - /* Check for STUN response in queue (IPv4 or IPv6) */ - ml_rx_packet_t stun_pkt; - if (xQueueReceive(ml->stun_rx_queue, &stun_pkt, 0) == pdTRUE) { - uint32_t pub_ip; - uint16_t pub_port; - bool parsed = false; - - /* Try IPv4 parse first */ - if (ml_stun_parse_response(stun_pkt.data, stun_pkt.len, - &pub_ip, &pub_port)) { - if (ml->stun_public_ip == 0) { - /* First STUN result (primary server) */ - ml->stun_public_ip = pub_ip; - ml->stun_public_port = pub_port; - ml->stun_retry_count = 0; - /* Send probe to fallback for symmetric NAT check */ - if (!ml->stun_nat_checked && ml->stun_fallback_ip) { - ml_stun_send_probe_to(ml, ml->stun_fallback_ip, - ML_STUN_FALLBACK_PORT); - ESP_LOGI(TAG, "Sent STUN to fallback for NAT type detection"); - } - } else if (!ml->stun_nat_checked) { - /* Second STUN result (fallback) — compare ports */ - ml->stun_secondary_port = pub_port; - ml->stun_nat_checked = true; - if (ml->stun_public_port != pub_port) { - ml->nat_mapping_varies = true; - ESP_LOGW(TAG, "Symmetric NAT detected: port %u vs %u " - "(direct connections unlikely)", - ml->stun_public_port, pub_port); - } else { - ml->nat_mapping_varies = false; - ESP_LOGI(TAG, "Cone NAT: port %u consistent " - "(direct connections possible)", pub_port); - } - } else { - /* Periodic re-probe — update primary result */ - ml->stun_public_ip = pub_ip; - ml->stun_public_port = pub_port; - ml->stun_retry_count = 0; - } - parsed = true; - } - - /* Try IPv6 parse (response may contain IPv6 XOR-MAPPED-ADDRESS) */ - if (!parsed) { - uint8_t ip6[16]; - uint16_t port6; - if (ml_stun_parse_response_ipv6(stun_pkt.data, stun_pkt.len, - ip6, &port6)) { - memcpy(ml->stun_public_ip6, ip6, 16); - ml->stun_public_port6 = port6; - ml->stun_has_ipv6 = true; - /* Use IPv6 port for NAT detection if no IPv4 result yet */ - if (ml->stun_public_port == 0) { - ml->stun_public_port = port6; - } - ml->stun_retry_count = 0; /* Got a result, stop retrying */ - ESP_LOGI(TAG, "STUN IPv6 result obtained, port %u", port6); - /* Trigger NAT type check if not done yet */ - if (!ml->stun_nat_checked && ml->stun_fallback_ip) { - ml_stun_send_probe_to(ml, ml->stun_fallback_ip, - ML_STUN_FALLBACK_PORT); - ESP_LOGI(TAG, "Sent STUN to fallback for NAT type detection"); - } - parsed = true; - } - } - - if (parsed) { - xEventGroupSetBits(ml->events, ML_EVT_STUN_COMPLETE); - /* Send endpoint update to control plane immediately. - * With Version >= 68, Stream=true MapRequests have endpoints - * IGNORED, so we need this separate Stream=false request. */ - do_send_endpoint_update(ml, &noise); - } - free(stun_pkt.data); - } - - /* STUN retry logic: 3 attempts per server, 2s apart, then fallback */ - if (ml->stun_retry_count > 0 && ml->stun_retry_count <= ML_STUN_MAX_RETRIES) { - if (now - ml->stun_last_probe_ms > ML_STUN_RETRY_INTERVAL_MS) { - if (!ml->stun_using_fallback && ml->stun_primary_ip) { - ml_stun_send_probe_to(ml, ml->stun_primary_ip, ML_STUN_PRIMARY_PORT); - } else if (ml->stun_fallback_ip) { - ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); - } - ml->stun_last_probe_ms = now; - ml->stun_retry_count++; - if (ml->stun_retry_count > ML_STUN_MAX_RETRIES && !ml->stun_using_fallback) { - /* Primary exhausted, switch to fallback */ - ESP_LOGW(TAG, "STUN primary failed after %d retries, trying fallback", - ML_STUN_MAX_RETRIES); - ml->stun_using_fallback = true; - ml->stun_retry_count = 1; - if (ml->stun_fallback_ip) { - ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); - ml->stun_last_probe_ms = now; - } - } - } - } - - /* Periodic STUN re-probe (every 23s) — fresh probe sequence */ - static uint64_t last_stun_ms = 0; - if (now - last_stun_ms > ml->t_stun_interval_ms) { - ml->stun_retry_count = 1; - ml->stun_using_fallback = false; - ml->stun_last_probe_ms = now; - /* IPv4 probe */ - if (ml->stun_primary_ip) { - ml_stun_send_probe_to(ml, ml->stun_primary_ip, ML_STUN_PRIMARY_PORT); - } else if (ml->stun_fallback_ip) { - ml->stun_using_fallback = true; - ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); - } else { - ml_stun_send_probe(ml, ML_STUN_PRIMARY_HOST, ML_STUN_PRIMARY_PORT); - } - /* IPv6 probe (alongside IPv4) */ - { - static const uint8_t zero16[16] = {0}; - if (memcmp(ml->stun_primary_ip6, zero16, 16) != 0) { - ml_stun_send_probe_ipv6(ml, ml->stun_primary_ip6, ML_STUN_PRIMARY_PORT); - } - } - last_stun_ms = now; - } - - /* Periodic DERP NotePreferred keepalive (every 60s) */ - static uint64_t last_derp_keepalive_ms = 0; - if (ml->derp.connected && now - last_derp_keepalive_ms > 60000) { - uint8_t preferred = 0x01; - uint8_t *ka_data = malloc(1); - if (ka_data) { - *ka_data = preferred; - ml_derp_tx_item_t ka_item = { - .data = ka_data, - .len = 1, - .frame_type = 0x07, /* NotePreferred */ - }; - memset(ka_item.dest_pubkey, 0, 32); - if (xQueueSend(ml->derp_tx_queue, &ka_item, 0) != pdTRUE) { - free(ka_data); - } - } - last_derp_keepalive_ms = now; - } - - /* Key expiry check (every 60s) — re-register if expired */ - if (ml->key_expiry_epoch > 0 || ml->key_expired) { - static uint64_t last_expiry_check_ms = 0; - if (now - last_expiry_check_ms > 60000) { - last_expiry_check_ms = now; - if (ml->key_expired) { - if (ml->config.auth_key) { - ESP_LOGW(TAG, "Key expired, re-registering with auth_key..."); - state = COORD_RECONNECTING; - break; - } else { - ESP_LOGE(TAG, "Key expired but no auth_key — manual re-provisioning needed!"); - } - } - /* Warn 1 hour before expiry (rough uptime-based check) */ - /* Note: key_expiry_epoch is absolute Unix time, we compare with - * approximate boot-relative time. For accurate check we'd need - * SNTP, but this catches the Expired flag from the server. */ - } - } - - /* Send HTTP/2 PING every 5 seconds to keep control plane alive. - * The server has a ~20s idle timeout; PINGs maintain bidirectional - * activity and are what actually keep us "online". (v1 reference) */ - static uint64_t last_h2_ping_ms = 0; - if (now - last_h2_ping_ms >= 5000) { - uint8_t ping_frame[17]; - ping_frame[0] = 0x00; ping_frame[1] = 0x00; ping_frame[2] = 0x08; - ping_frame[3] = 0x06; /* Type: PING */ - ping_frame[4] = 0x00; /* Flags: none */ - ping_frame[5] = 0x00; ping_frame[6] = 0x00; - ping_frame[7] = 0x00; ping_frame[8] = 0x00; /* Stream 0 */ - /* Opaque 8-byte payload (timestamp for identification) */ - uint64_t ping_id = now; - for (int b = 0; b < 8; b++) - ping_frame[9 + b] = (ping_id >> (56 - b * 8)) & 0xFF; - int ping_ret = noise_send(ml, &noise, ping_frame, sizeof(ping_frame)); - if (ping_ret >= 0) { - last_h2_ping_ms = now; - last_activity_ms = now; - } else { - ESP_LOGW(TAG, "H2 PING send failed, reconnecting"); - state = COORD_RECONNECTING; - break; - } - } - - /* Poll for streaming MapResponse updates */ - int poll_ret = poll_map_update(ml, &noise); - if (poll_ret > 0) { - last_activity_ms = now; /* Reset watchdog */ - } else if (poll_ret < 0) { - ESP_LOGW(TAG, "Long-poll connection lost"); - state = COORD_RECONNECTING; - break; - } - - vTaskDelay(pdMS_TO_TICKS(50)); - } - break; - - case COORD_RECONNECTING: - { - uint32_t backoff_ms = 1000 << (reconnect_attempts > 4 ? 4 : reconnect_attempts); - if (backoff_ms > ML_CTRL_BACKOFF_MAX_MS) backoff_ms = ML_CTRL_BACKOFF_MAX_MS; - - ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)", - (unsigned long)backoff_ms, reconnect_attempts + 1); - - ml->state = ML_STATE_RECONNECTING; - - /* Wait on command queue with backoff timeout (interruptible!) */ - ml_coord_cmd_t wake_cmd; - if (xQueueReceive(ml->coord_cmd_queue, &wake_cmd, pdMS_TO_TICKS(backoff_ms)) == pdTRUE) { - if (wake_cmd == ML_CMD_DISCONNECT) { - state = COORD_IDLE; - ml->state = ML_STATE_IDLE; - break; - } - } - - reconnect_attempts++; - - /* Close old connection */ - coord_close_conn(ml); - - /* Reset Noise state for fresh handshake */ - memset(&noise, 0, sizeof(noise)); - - state = COORD_STUN_PROBE; - } - break; - } - } - - /* Cleanup */ - coord_close_conn(ml); - memset(&noise, 0, sizeof(noise)); - - ESP_LOGI(TAG, "Coord task exiting"); - vTaskDelete(NULL); -} +/** + * @file ml_coord.c + * @brief Coordination Task - Control Plane Client + * + * Owns ALL Noise protocol state (keys, nonces) exclusively. + * Handles registration, MapResponse parsing, endpoint updates. + * Runs as a state machine on Core 1. + * + * Protocol flow: + * 1. DNS resolve controlplane.tailscale.com + * 2. TCP connect to port 80 (NOT TLS - Noise provides encryption) + * 3. HTTP/1.1 Upgrade with Noise msg1 in X-Tailscale-Handshake header + * 4. Read Noise msg2 from upgrade response body + * 5. Derive transport keys, send H2 preface (encrypted via Noise) + * 6. Send RegisterRequest (Noise-encrypted H2 HEADERS+DATA) + * 7. Parse RegisterResponse + * 8. Send MapRequest, parse MapResponse (peers) + * 9. Long-poll for updates + * + * Reference: tailscale/control/controlclient/auto.go + * tailscale/control/controlclient/direct.go + */ + +#include "microlink_internal.h" +#include "x25519.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "esp_random.h" +#include "esp_netif.h" +#include "cJSON.h" +#include "lwip/sockets.h" +#include "lwip/netdb.h" +#include "mbedtls/base64.h" +#include +#include + +static const char *TAG = "ml_coord"; + +/* Effective control plane host: NVS override or compiled default */ +#define CTRL_HOST(ml) ((ml)->ctrl_host[0] ? (ml)->ctrl_host : ML_CTRL_HOST) + +/* NodeKeyChallenge from EarlyNoise (stored between handshake and register) */ +static uint8_t s_node_key_challenge[32] = {0}; +static bool s_has_node_key_challenge = false; + +/* Coordination state machine */ +typedef enum { + COORD_IDLE, + COORD_STUN_PROBE, + COORD_DNS_RESOLVE, + COORD_TCP_CONNECT, + COORD_NOISE_HANDSHAKE, + COORD_H2_PREFACE, + COORD_REGISTER, + COORD_FETCH_PEERS, + COORD_LONG_POLL, + COORD_RECONNECTING, +} coord_state_t; + +/* ============================================================================ + * Helper: hex encoding for keys + * ========================================================================== */ + +static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex) { + static const char hextab[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + hex[i * 2] = hextab[bytes[i] >> 4]; + hex[i * 2 + 1] = hextab[bytes[i] & 0x0F]; + } + hex[len * 2] = '\0'; +} + +static int hex_to_bytes(const char *hex, uint8_t *bytes, size_t max_len) { + size_t hex_len = strlen(hex); + if (hex_len % 2 != 0 || hex_len / 2 > max_len) return -1; + for (size_t i = 0; i < hex_len / 2; i++) { + unsigned int val; + if (sscanf(hex + i * 2, "%2x", &val) != 1) return -1; + bytes[i] = (uint8_t)val; + } + return hex_len / 2; +} + +/* ============================================================================ + * TCP I/O helpers for coord socket (owned exclusively by this task) + * ========================================================================== */ + +/* Single read from the coord transport (recv() semantics). + * With CONFIG_ML_CTRL_TLS the Noise byte stream runs inside TLS records; + * timeouts still surface as -1/EAGAIN via the TLS BIO. */ +static int coord_raw_recv(microlink_t *ml, uint8_t *buf, size_t len) { +#ifdef CONFIG_ML_CTRL_TLS + return ml_coord_tls_recv(ml, buf, len); +#else + return ml_recv(ml->coord_sock, buf, len, 0); +#endif +} + +/* Close the coord connection (TLS session first, then the socket) */ +static void coord_close_conn(microlink_t *ml) { +#ifdef CONFIG_ML_CTRL_TLS + ml_coord_tls_free(ml); +#endif + if (ml->coord_sock >= 0) { + ml_close_sock(ml->coord_sock); + ml->coord_sock = -1; + } +} + +static int coord_send(microlink_t *ml, const uint8_t *data, size_t len) { + /* Set send timeout to prevent indefinite blocking (v1 uses MSG_DONTWAIT) */ + struct timeval snd_tv = { .tv_sec = 5, .tv_usec = 0 }; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_SNDTIMEO, &snd_tv, sizeof(snd_tv)); + +#ifdef CONFIG_ML_CTRL_TLS + if (ml_coord_tls_send(ml, data, len) < 0) { + ESP_LOGE(TAG, "coord_send (TLS) failed: len=%d errno=%d", (int)len, errno); + return -1; + } + return 0; +#else + size_t sent = 0; + while (sent < len) { + int n = ml_send(ml->coord_sock, data + sent, len - sent, 0); + if (n <= 0) { + ESP_LOGE(TAG, "coord_send failed: sent=%d/%d n=%d errno=%d", + (int)sent, (int)len, n, errno); + return -1; + } + sent += n; + } + return 0; +#endif +} + +static int coord_recv(microlink_t *ml, uint8_t *buf, size_t len) { + size_t recvd = 0; + int retries = 0; + while (recvd < len) { + int n = coord_raw_recv(ml, buf + recvd, len - recvd); + if (n <= 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (recvd == 0) { + /* No data consumed yet — timeout is fine, caller can retry */ + return -1; + } + /* Partial data consumed — we MUST finish this read or the + * Noise frame stream will be misaligned. Retry with backoff. */ + if (++retries > 300) { /* ~3 seconds */ + ESP_LOGE(TAG, "coord_recv partial timeout: %d/%d bytes", + (int)recvd, (int)len); + return -1; + } + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + ESP_LOGE(TAG, "coord_recv failed: %d (errno %d, recvd %d/%d)", + n, errno, (int)recvd, (int)len); + return -1; + } + recvd += n; + retries = 0; /* Reset on successful read */ + } + return 0; +} + +/* ============================================================================ + * Noise-encrypted transport I/O + * ========================================================================== */ + +/* Send a Noise transport frame: [0x04][len_hi][len_lo][encrypted_data+MAC] */ +static int noise_send(microlink_t *ml, ml_noise_state_t *noise, + const uint8_t *plaintext, size_t pt_len) { + size_t ct_len = pt_len + 16; /* ciphertext + 16-byte MAC */ + uint8_t *frame = ml_psram_malloc(3 + ct_len); + if (!frame) return -1; + + frame[0] = 0x04; /* Transport data frame type */ + frame[1] = (ct_len >> 8) & 0xFF; + frame[2] = ct_len & 0xFF; + + if (ml_noise_encrypt(noise->tx_key, noise->tx_nonce, + NULL, 0, + plaintext, pt_len, + frame + 3) != ESP_OK) { + free(frame); + return -1; + } + noise->tx_nonce++; + + int ret = coord_send(ml, frame, 3 + ct_len); + free(frame); + return ret; +} + +/* Receive and decrypt a Noise transport frame, returns plaintext length */ +static int noise_recv(microlink_t *ml, ml_noise_state_t *noise, + uint8_t *plaintext, size_t max_len) { + /* Read 3-byte frame header */ + uint8_t hdr[3]; + if (coord_recv(ml, hdr, 3) < 0) return -1; + + if (hdr[0] != 0x04) { + ESP_LOGE(TAG, "Unexpected Noise frame type: 0x%02x", hdr[0]); + return -1; + } + + uint16_t ct_len = (hdr[1] << 8) | hdr[2]; + if (ct_len < 16) return -1; + size_t pt_len = ct_len - 16; + if (pt_len > max_len) { + ESP_LOGE(TAG, "Noise frame too large: %d > %d", (int)pt_len, (int)max_len); + return -1; + } + + uint8_t *ciphertext = ml_psram_malloc(ct_len); + if (!ciphertext) return -1; + + /* Header already consumed — payload read MUST complete or stream + * alignment is permanently lost. Retry EAGAIN (coord_recv returns -1 + * with errno==EAGAIN if recvd==0 on first byte). */ + int payload_retries = 0; + while (coord_recv(ml, ciphertext, ct_len) < 0) { + if ((errno == EAGAIN || errno == EWOULDBLOCK) && ++payload_retries <= 300) { + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + ESP_LOGE(TAG, "noise_recv payload failed: ct_len=%d retries=%d errno=%d", + ct_len, payload_retries, errno); + free(ciphertext); + return -1; + } + + if (ml_noise_decrypt(noise->rx_key, noise->rx_nonce, + NULL, 0, + ciphertext, ct_len, + plaintext) != ESP_OK) { + ESP_LOGE(TAG, "Noise decrypt failed (nonce=%llu)", (unsigned long long)noise->rx_nonce); + free(ciphertext); + return -1; + } + noise->rx_nonce++; + + free(ciphertext); + return (int)pt_len; +} + +/* ============================================================================ + * State: DNS_RESOLVE + TCP_CONNECT + * ========================================================================== */ + +static int do_tcp_connect(microlink_t *ml) { + int64_t t_start = esp_timer_get_time(); + + ESP_LOGI(TAG, "Resolving %s...", CTRL_HOST(ml)); + + struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM }; + struct addrinfo *res = NULL; + +#ifdef CONFIG_ML_CTRL_TLS + const char *ctrl_port = "443"; +#else + const char *ctrl_port = "80"; +#endif + + if (ml_getaddrinfo(CTRL_HOST(ml), ctrl_port, &hints, &res) != 0 || !res) { + ESP_LOGE(TAG, "DNS resolve failed for %s", CTRL_HOST(ml)); + return -1; + } + + int64_t t_dns = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] DNS resolve: %lld ms", (t_dns - t_start) / 1000); + + int sock = ml_socket(res->ai_family, SOCK_STREAM, IPPROTO_TCP); + if (sock < 0) { + ml_freeaddrinfo(res); + ESP_LOGE(TAG, "Socket creation failed"); + return -1; + } + + /* Socket timeouts */ + struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; + ml_setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + ml_setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + /* TCP keepalive (critical for NAT traversal) */ + int keepalive = 1; + int keepidle = 25; + int keepintvl = 10; + int keepcnt = 3; + ml_setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); + ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); + ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); + ml_setsockopt(sock, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); + + ESP_LOGI(TAG, "Connecting to %s:%s...", CTRL_HOST(ml), ctrl_port); + + if (ml_connect(sock, res->ai_addr, res->ai_addrlen) < 0) { + ESP_LOGE(TAG, "TCP connect failed: %d", errno); + ml_close_sock(sock); + ml_freeaddrinfo(res); + return -1; + } + ml_freeaddrinfo(res); + + int64_t t_tcp = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] TCP connect: %lld ms (total: %lld ms)", + (t_tcp - t_dns) / 1000, (t_tcp - t_start) / 1000); + + ml->coord_sock = sock; + +#ifdef CONFIG_ML_CTRL_TLS + if (ml_coord_tls_handshake(ml, CTRL_HOST(ml)) < 0) { + ml_close_sock(ml->coord_sock); + ml->coord_sock = -1; + return -1; + } + int64_t t_tls = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] Control TLS handshake: %lld ms", (t_tls - t_tcp) / 1000); +#endif + + return 0; +} + +/* ============================================================================ + * State: NOISE_HANDSHAKE (HTTP/1.1 Upgrade with Noise msg1) + * ========================================================================== */ + +/* Server proactive data (stored between handshake and H2 preface) */ +static uint8_t *s_server_extra_data = NULL; +static int s_server_extra_data_len = 0; + +static int do_noise_handshake(microlink_t *ml, ml_noise_state_t *noise) { + int64_t t_noise_start = esp_timer_get_time(); + + /* Initialize Noise state with our machine key and the control plane's + * server key. Priority: config-struct key (runtime, e.g. fetched from + * https:///key?v=88 at provisioning time) > + * CONFIG_ML_CTRL_NOISE_PUBKEY_HEX (build-time) > built-in Tailscale key. */ + const uint8_t *server_key = NULL; + if (ml->ctrl_noise_pubkey_set) { + server_key = ml->ctrl_noise_pubkey; + } +#ifdef CONFIG_ML_CTRL_NOISE_PUBKEY_HEX + static uint8_t custom_server_key[32]; + if (!server_key && CONFIG_ML_CTRL_NOISE_PUBKEY_HEX[0] != '\0') { + if (hex_to_bytes(CONFIG_ML_CTRL_NOISE_PUBKEY_HEX, custom_server_key, 32) == 32) { + server_key = custom_server_key; + } else { + ESP_LOGE(TAG, "Invalid ML_CTRL_NOISE_PUBKEY_HEX (need 64 hex chars) — using built-in key"); + } + } +#endif + ml_noise_init(noise, + ml->machine_private_key, ml->machine_public_key, + server_key); + + /* Build Noise message 1 (101 bytes) */ + uint8_t msg1[128]; + size_t msg1_len = 0; + if (ml_noise_write_msg1(noise, msg1, &msg1_len) != ESP_OK) { + ESP_LOGE(TAG, "Failed to build Noise msg1"); + return -1; + } + + /* Base64 encode msg1 for HTTP header */ + char msg1_b64[256]; + size_t b64_len = 0; + mbedtls_base64_encode((unsigned char *)msg1_b64, sizeof(msg1_b64), &b64_len, + msg1, msg1_len); + msg1_b64[b64_len] = '\0'; + + /* Send HTTP/1.1 Upgrade request with Noise msg1 in header */ + char *http_req = ml_psram_malloc(512 + b64_len); + if (!http_req) return -1; + + int req_len = snprintf(http_req, 512 + b64_len, + "POST /ts2021 HTTP/1.1\r\n" + "Host: %s\r\n" + "Upgrade: tailscale-control-protocol\r\n" + "Connection: Upgrade\r\n" + "User-Agent: Tailscale\r\n" + "X-Tailscale-Handshake: %s\r\n" + "Content-Length: 0\r\n" + "\r\n", + CTRL_HOST(ml), msg1_b64); + + ESP_LOGI(TAG, "Sending Noise handshake (msg1=%d bytes, b64=%d chars)", (int)msg1_len, (int)b64_len); + + if (coord_send(ml, (uint8_t *)http_req, req_len) < 0) { + free(http_req); + return -1; + } + free(http_req); + + /* Read HTTP response - use large recv() because server often sends + * HTTP 101 headers + Noise msg2 + proactive frames in the same TCP segment */ + uint8_t *resp = ml_psram_malloc(2048); + if (!resp) return -1; + + int total = coord_raw_recv(ml, resp, 2047); + if (total <= 0) { + ESP_LOGE(TAG, "Handshake recv failed: %d (errno=%d)", total, errno); + free(resp); + return -1; + } + resp[total] = '\0'; + + ESP_LOGI(TAG, "HTTP response received: %d bytes", total); + + /* Verify 101 Switching Protocols */ + if (strstr((char *)resp, "101") == NULL) { + ESP_LOGE(TAG, "Noise upgrade rejected: %.200s", resp); + free(resp); + return -1; + } + ESP_LOGI(TAG, "HTTP 101 received, looking for Noise msg2..."); + + /* Find end of HTTP headers */ + uint8_t *body_start = (uint8_t *)strstr((char *)resp, "\r\n\r\n"); + if (!body_start) { + ESP_LOGE(TAG, "No header terminator found"); + free(resp); + return -1; + } + body_start += 4; /* Skip past \r\n\r\n */ + + int body_len = total - (body_start - resp); + ESP_LOGI(TAG, "Body after HTTP headers: %d bytes", body_len); + + /* Copy entire body to working buffer (may contain msg2 + extra frames) */ + uint8_t *body_buf = NULL; + int body_buf_len = 0; + + if (body_len > 0) { + body_buf = ml_psram_malloc(body_len); + if (body_buf) { + memcpy(body_buf, body_start, body_len); + body_buf_len = body_len; + } + } + free(resp); + + /* Ensure we have at least the 3-byte Noise frame header */ + if (body_buf_len < 3) { + ESP_LOGI(TAG, "Reading Noise msg2 header from socket (have %d bytes)...", body_buf_len); + /* Need to read from socket - allocate buffer if not yet */ + if (!body_buf) { + body_buf = ml_psram_malloc(512); + if (!body_buf) return -1; + } + if (coord_recv(ml, body_buf + body_buf_len, 3 - body_buf_len) < 0) { + ESP_LOGE(TAG, "Failed to read Noise msg2 header"); + free(body_buf); + return -1; + } + body_buf_len = 3; + } + + /* Parse 3-byte Noise frame header */ + uint8_t msg_type = body_buf[0]; + uint16_t payload_len = (body_buf[1] << 8) | body_buf[2]; + + ESP_LOGI(TAG, "Noise frame: type=0x%02x, payload_len=%d", msg_type, payload_len); + + if (msg_type != 0x02) { + ESP_LOGE(TAG, "Unexpected Noise msg type: 0x%02x (expected 0x02)", msg_type); + free(body_buf); + return -1; + } + + int msg2_total = 3 + payload_len; /* Total msg2 frame size */ + + /* Read remaining msg2 payload bytes from socket if needed */ + if (body_buf_len < msg2_total) { + ESP_LOGI(TAG, "Reading remaining msg2 payload: have %d, need %d", body_buf_len, msg2_total); + /* Grow buffer if needed */ + uint8_t *tmp = ml_psram_malloc(msg2_total + 512); + if (!tmp) { free(body_buf); return -1; } + memcpy(tmp, body_buf, body_buf_len); + free(body_buf); + body_buf = tmp; + + if (coord_recv(ml, body_buf + body_buf_len, msg2_total - body_buf_len) < 0) { + ESP_LOGE(TAG, "Failed to read Noise msg2 payload"); + free(body_buf); + return -1; + } + body_buf_len = msg2_total; + } + + ESP_LOGI(TAG, "Noise msg2 complete: %d bytes (payload=%d)", msg2_total, payload_len); + + /* Strip 3-byte header, pass raw payload to noise processing */ + if (ml_noise_read_msg2(noise, body_buf + 3, payload_len) != ESP_OK) { + ESP_LOGE(TAG, "Noise msg2 processing failed"); + free(body_buf); + return -1; + } + + int64_t t_noise_done = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] Noise handshake: %lld ms", (t_noise_done - t_noise_start) / 1000); + ESP_LOGI(TAG, "Noise handshake complete, transport keys derived"); + + /* Save extra data after msg2 (proactive server transport frames) + * Server sends these immediately after handshake - each one increments + * server's tx_nonce. We must process them to keep nonces in sync. + * (Matches v1 g_server_extra_data logic) + * + * On fast connections (WiFi), the proactive frames often arrive in + * the same TCP segment as the HTTP 101 + msg2. On slow connections + * (cellular PPP), they arrive in separate TCP segments. We must + * read from the socket if they weren't in the initial buffer. */ + int extra_len = body_buf_len - msg2_total; + uint8_t *extra_data = NULL; + + if (extra_len > 0) { + /* Fast path: proactive frames were in the initial recv buffer */ + ESP_LOGI(TAG, "Found %d bytes of extra data after msg2 (proactive frames)", extra_len); + extra_data = ml_psram_malloc(extra_len); + if (extra_data) { + memcpy(extra_data, body_buf + msg2_total, extra_len); + } + } else { + /* Slow path: proactive frames arrive in subsequent TCP segments. + * Set a short recv timeout and read them from the socket. + * Server sends EarlyNoise immediately after msg2, so they should + * arrive within a few hundred ms even on slow cellular links. */ + ESP_LOGI(TAG, "No extra data in initial buffer, reading proactive frames from socket..."); + struct timeval short_tv = { .tv_sec = 2, .tv_usec = 0 }; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &short_tv, sizeof(short_tv)); + + extra_data = ml_psram_malloc(1024); + if (extra_data) { + int n = coord_raw_recv(ml, extra_data, 1024); + if (n > 0) { + extra_len = n; + ESP_LOGI(TAG, "Read %d bytes of proactive frames from socket", extra_len); + } else { + ESP_LOGI(TAG, "No proactive frames from server (n=%d errno=%d)", n, errno); + free(extra_data); + extra_data = NULL; + extra_len = 0; + } + } + + /* Restore normal recv timeout */ + struct timeval normal_tv = { .tv_sec = 60, .tv_usec = 0 }; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &normal_tv, sizeof(normal_tv)); + } + + if (extra_data && extra_len > 0) { + /* Count proactive transport frames */ + int offset = 0; + int frame_count = 0; + while (offset + 3 <= extra_len) { + uint8_t ft = extra_data[offset]; + uint16_t fl = (extra_data[offset + 1] << 8) | extra_data[offset + 2]; + if (offset + 3 + fl > extra_len) { + ESP_LOGW(TAG, "Incomplete proactive frame at offset %d", offset); + break; + } + ESP_LOGI(TAG, "Proactive frame %d: type=0x%02x, len=%u", frame_count, ft, fl); + frame_count++; + offset += 3 + fl; + } + ESP_LOGI(TAG, "Server sent %d proactive transport frames", frame_count); + + /* Store for processing after handshake */ + if (s_server_extra_data) free(s_server_extra_data); + s_server_extra_data = extra_data; + s_server_extra_data_len = extra_len; + } else { + if (extra_data) free(extra_data); + } + + free(body_buf); + return 0; +} + +/* ============================================================================ + * Process proactive server frames sent after Noise handshake + * Copied from v1: decrypt EarlyNoise (frame 0) to get nodeKeyChallenge, + * then count all frames and set rx_nonce = total count. + * Frames 1-3 may not decrypt with any nonce/key combo (v1 observation). + * ========================================================================== */ + +static void process_proactive_frames(microlink_t *ml, ml_noise_state_t *noise) { + if (!s_server_extra_data || s_server_extra_data_len <= 0) { + return; + } + + ESP_LOGI(TAG, "Processing %d bytes of server proactive data", s_server_extra_data_len); + s_has_node_key_challenge = false; + + /* Decrypt ALL proactive frames and accumulate plaintext. + * EarlyNoise content (magic + length + JSON) is split across + * multiple Noise transport frames: + * Frame 0: \xff\xff\xffTS (5 bytes magic) + * Frame 1: 4 bytes (big-endian JSON length) + * Frame 2: ~95 bytes (JSON with nodeKeyChallenge) + * Frame 3: H2 SETTINGS (optional) */ + uint8_t *combined = ml_psram_malloc(1024); + size_t combined_len = 0; + int offset = 0; + int frame_count = 0; + uint64_t nonce = 0; + + while (offset + 3 <= s_server_extra_data_len) { + uint8_t ft = s_server_extra_data[offset]; + uint16_t fl = (s_server_extra_data[offset + 1] << 8) | s_server_extra_data[offset + 2]; + + if (offset + 3 + fl > s_server_extra_data_len) break; + + ESP_LOGI(TAG, "Proactive frame %d: type=0x%02x, len=%u", frame_count, ft, fl); + + if (ft == 0x04 && fl >= ML_NOISE_MAC_LEN && combined) { + size_t pt_len = fl - ML_NOISE_MAC_LEN; + uint8_t *plaintext = ml_psram_malloc(pt_len + 1); + + if (plaintext) { + esp_err_t ret = ml_noise_decrypt(noise->rx_key, nonce, + NULL, 0, + s_server_extra_data + offset + 3, fl, + plaintext); + if (ret == ESP_OK) { + ESP_LOGI(TAG, " Decrypted frame %d: %d bytes (nonce=%d)", + frame_count, (int)pt_len, (int)nonce); + if (combined_len + pt_len < 1024) { + memcpy(combined + combined_len, plaintext, pt_len); + combined_len += pt_len; + } + } else { + ESP_LOGW(TAG, " Frame %d decrypt failed (nonce=%d)", frame_count, (int)nonce); + } + free(plaintext); + } + } + + nonce++; + frame_count++; + offset += 3 + fl; + } + + ESP_LOGI(TAG, "Decrypted %d bytes from %d proactive frames", (int)combined_len, frame_count); + + /* Search combined plaintext for nodeKeyChallenge JSON */ + if (combined && combined_len > 0) { + combined[combined_len < 1024 ? combined_len : 1023] = '\0'; + + /* Find JSON start (skip magic + length prefix) */ + char *json_start = NULL; + for (int i = 0; i < (int)combined_len; i++) { + if (combined[i] == '{') { + json_start = (char *)combined + i; + break; + } + } + + if (json_start) { + ESP_LOGI(TAG, "EarlyNoise JSON: %.80s", json_start); + cJSON *early_json = cJSON_Parse(json_start); + if (early_json) { + cJSON *challenge = cJSON_GetObjectItem(early_json, "nodeKeyChallenge"); + if (challenge && cJSON_IsString(challenge)) { + const char *chal_str = challenge->valuestring; + ESP_LOGI(TAG, "Found nodeKeyChallenge: %.40s...", chal_str); + + /* Parse "chalpub:hex..." format (v1 lines 1402-1424) */ + if (strncmp(chal_str, "chalpub:", 8) == 0) { + const char *hex = chal_str + 8; + if (strlen(hex) >= 64) { + hex_to_bytes(hex, s_node_key_challenge, 32); + s_has_node_key_challenge = true; + ESP_LOGI(TAG, "NodeKeyChallenge decoded (32 bytes)"); + } + } + } + cJSON_Delete(early_json); + } + } + } + if (combined) free(combined); + + /* Set rx_nonce to skip all proactive frames */ + ESP_LOGI(TAG, "Setting rx_nonce=%d (skipping %d proactive frames)", frame_count, frame_count); + noise->rx_nonce = frame_count; + + free(s_server_extra_data); + s_server_extra_data = NULL; + s_server_extra_data_len = 0; +} + +/* ============================================================================ + * State: H2_PREFACE - Send HTTP/2 connection preface (Noise-encrypted) + * ========================================================================== */ + +static int do_h2_preface(microlink_t *ml, ml_noise_state_t *noise) { + int64_t t_h2_start = esp_timer_get_time(); + + /* 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)); + if (preface_len < 0) return -1; + pos = preface_len; + + int ack_len = ml_h2_build_settings_ack(h2_init + pos, sizeof(h2_init) - pos); + if (ack_len < 0) return -1; + pos += ack_len; + + /* Connection-level WINDOW_UPDATE (stream 0) to expand the connection window + * 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; + if (conn_window_delta > 0) { + int wu_len = ml_h2_build_window_update(h2_init + pos, sizeof(h2_init) - pos, + 0, conn_window_delta); + if (wu_len > 0) pos += wu_len; + } + + /* Encrypt and send as one Noise frame */ + if (noise_send(ml, noise, h2_init, pos) < 0) { + ESP_LOGE(TAG, "Failed to send H2 preface"); + return -1; + } + + ESP_LOGI(TAG, "H2 preface sent (%d bytes, conn window=%luKB)", + pos, (unsigned long)(ML_H2_BUFFER_SIZE / 1024)); + + /* Read and process server's response (SETTINGS, SETTINGS_ACK, WINDOW_UPDATE, etc.) */ + uint8_t recv_buf[4096]; + int recv_len = noise_recv(ml, noise, recv_buf, sizeof(recv_buf)); + if (recv_len < 0) { + ESP_LOGW(TAG, "No immediate H2 response from server (may come later)"); + /* Not fatal - server may send its frames later */ + } else { + ESP_LOGI(TAG, "Server H2 response: %d bytes", recv_len); + } + + int64_t t_h2_done = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] H2 preface: %lld ms", (t_h2_done - t_h2_start) / 1000); + + return 0; +} + +/* ============================================================================ + * State: REGISTER - Send RegisterRequest, parse RegisterResponse + * ========================================================================== */ + +static int do_register(microlink_t *ml, ml_noise_state_t *noise) { + int64_t t_reg_start = esp_timer_get_time(); + + /* Build RegisterRequest JSON */ + cJSON *root = cJSON_CreateObject(); + if (!root) return -1; + + cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); + + /* NodeKey: "nodekey:" + hex(wg_public_key) */ + char key_hex[65]; + char key_str[80]; + bytes_to_hex(ml->wg_public_key, 32, key_hex); + snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); + cJSON_AddStringToObject(root, "NodeKey", key_str); + + /* Auth - only include if auth_key is valid (matching v1 behavior) */ + if (ml->config.auth_key && strlen(ml->config.auth_key) > 0) { + cJSON *auth = cJSON_CreateObject(); + cJSON_AddStringToObject(auth, "AuthKey", ml->config.auth_key); + cJSON_AddItemToObject(root, "Auth", auth); + } + + /* Hostinfo */ + cJSON *hostinfo = cJSON_CreateObject(); + const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); + cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); + cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); + cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); + + /* NetInfo inside Hostinfo — control plane reads PreferredDERP from here + * to populate Node.HomeDERP for other peers */ + { + cJSON *netinfo = cJSON_CreateObject(); + if (netinfo) { + cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); + cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); + } + } + + cJSON_AddItemToObject(root, "Hostinfo", hostinfo); + + /* NodeKeyChallengeResponse - prove we own the WireGuard private key + * Server sends challenge public key in EarlyNoise; we respond with + * X25519(wg_private_key, challenge_public_key). (v1 lines 1754-1785) */ + if (s_has_node_key_challenge) { + ESP_LOGI(TAG, "Computing NodeKeyChallengeResponse..."); + + /* X25519(wg_private_key, challenge_public_key) */ + uint8_t challenge_pub_copy[32]; + memcpy(challenge_pub_copy, s_node_key_challenge, 32); + challenge_pub_copy[31] &= 0x7F; /* Clear high bit per RFC 7748 */ + + uint8_t challenge_response[32]; + x25519(challenge_response, ml->wg_private_key, challenge_pub_copy, 1); + + /* Encode as "chalresp:hex..." */ + char chalresp_hex[65]; + bytes_to_hex(challenge_response, 32, chalresp_hex); + char chalresp_str[80]; + snprintf(chalresp_str, sizeof(chalresp_str), "chalresp:%s", chalresp_hex); + cJSON_AddStringToObject(root, "NodeKeyChallengeResponse", chalresp_str); + ESP_LOGI(TAG, "Added NodeKeyChallengeResponse"); + } else { + ESP_LOGW(TAG, "No nodeKeyChallenge received - registration may fail!"); + } + + char *json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json_str) return -1; + + size_t json_len = strlen(json_str); + ESP_LOGI(TAG, "RegisterRequest: %d bytes", (int)json_len); + + /* Build HTTP/2 HEADERS + DATA frames */ + uint8_t *h2_buf = ml_psram_malloc(json_len + 512); + if (!h2_buf) { free(json_str); return -1; } + + int h2_pos = 0; + + /* HEADERS frame (POST /machine/register) */ + int hdr_len = ml_h2_build_headers_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, + "POST", "/machine/register", + CTRL_HOST(ml), "application/json", + 1, false); + if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } + h2_pos += hdr_len; + + /* DATA frame (JSON body, END_STREAM) */ + int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, + (uint8_t *)json_str, json_len, + 1, true); + free(json_str); + if (data_len < 0) { free(h2_buf); return -1; } + h2_pos += data_len; + + /* Encrypt and send as one Noise frame */ + if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { + free(h2_buf); + return -1; + } + free(h2_buf); + + int64_t t_reg_sent = esp_timer_get_time(); + ESP_LOGI(TAG, "RegisterRequest sent (%d H2 bytes) [TIMING] send: %lld ms", + h2_pos, (t_reg_sent - t_reg_start) / 1000); + + /* Read RegisterResponse - accumulate all Noise frames into H2 buffer first, + * then parse H2 frames (same pattern as MapResponse). */ + uint8_t *h2_resp = ml_psram_malloc(16384); + if (!h2_resp) return -1; + size_t h2_resp_len = 0; + + uint8_t *resp_buf = ml_psram_malloc(8192); + if (!resp_buf) { free(h2_resp); return -1; } + size_t resp_total = 0; + + /* Accumulate Noise frames into H2 buffer. + * Scan each frame for H2 END_STREAM on stream 1 to break early + * instead of blocking 60s waiting for more data that won't come. */ + bool got_register_end = false; + for (int frame_count = 0; frame_count < 10 && !got_register_end; frame_count++) { + uint8_t *frame_buf = ml_psram_malloc(4096); + if (!frame_buf) break; + + int frame_len = noise_recv(ml, noise, frame_buf, 4096); + if (frame_len <= 0) { + free(frame_buf); + break; + } + + ESP_LOGI(TAG, "RegisterResponse Noise frame %d: %d bytes", frame_count, frame_len); + + if (h2_resp_len + frame_len < 16384) { + memcpy(h2_resp + h2_resp_len, frame_buf, frame_len); + h2_resp_len += frame_len; + } + free(frame_buf); + + /* Scan accumulated buffer for H2 END_STREAM on stream 1 */ + int scan = 0; + while (scan + 9 <= (int)h2_resp_len) { + uint32_t fl = (h2_resp[scan] << 16) | (h2_resp[scan+1] << 8) | h2_resp[scan+2]; + uint8_t ft = h2_resp[scan+3]; + uint8_t ff = h2_resp[scan+4]; + uint32_t fs = ((h2_resp[scan+5] & 0x7F) << 24) | (h2_resp[scan+6] << 16) | + (h2_resp[scan+7] << 8) | h2_resp[scan+8]; + if (fl > 1000000 || scan + 9 + (int)fl > (int)h2_resp_len) break; + /* END_STREAM (0x01) on stream 1 in DATA(0x00) or HEADERS(0x01) frame */ + if (fs == 1 && (ft == 0x00 || ft == 0x01) && (ff & 0x01)) { + got_register_end = true; + break; + } + scan += 9 + fl; + } + } + + /* Parse H2 frames from accumulated buffer */ + bool got_end_stream = false; + int fpos = 0; + while (fpos + 9 <= (int)h2_resp_len) { + uint32_t f_len = (h2_resp[fpos] << 16) | (h2_resp[fpos + 1] << 8) | h2_resp[fpos + 2]; + uint8_t f_type = h2_resp[fpos + 3]; + uint8_t f_flags = h2_resp[fpos + 4]; + uint32_t f_stream = ((h2_resp[fpos + 5] & 0x7F) << 24) | (h2_resp[fpos + 6] << 16) | + (h2_resp[fpos + 7] << 8) | h2_resp[fpos + 8]; + fpos += 9; + + ESP_LOGD(TAG, " Register H2 frame: type=%d flags=0x%02x len=%lu stream=%lu", + f_type, f_flags, (unsigned long)f_len, (unsigned long)f_stream); + + if (f_len > 1000000 || fpos + (int)f_len > (int)h2_resp_len) { + ESP_LOGW(TAG, " Invalid H2 frame length %lu at pos %d, stopping", (unsigned long)f_len, fpos - 9); + break; + } + + /* Only process frames on stream 1 (our RegisterResponse). + * Stream 0 = connection-level (SETTINGS, WINDOW_UPDATE, PING) */ + if (f_stream == 1) { + if (f_type == 0x00 && f_len > 0) { /* DATA frame */ + if (resp_total + f_len < 8192) { + memcpy(resp_buf + resp_total, h2_resp + fpos, f_len); + resp_total += f_len; + } + if (f_flags & 0x01) got_end_stream = true; + } + if (f_type == 0x01 && (f_flags & 0x01)) got_end_stream = true; + } + + fpos += f_len; + } + (void)got_end_stream; /* Currently unused for completion detection (suppress -Wunused-but-set-variable) */ + free(h2_resp); + + /* Send connection-level WINDOW_UPDATE for RegisterResponse. + * Stream 1 is closed (END_STREAM received), only update connection level. */ + if (resp_total > 0) { + uint8_t wu_buf[13]; + int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)resp_total); + noise_send(ml, noise, wu_buf, wu_len); + } + + ESP_LOGI(TAG, "RegisterResponse: %d bytes total data", (int)resp_total); + + if (resp_total == 0) { + ESP_LOGW(TAG, "No DATA frame in RegisterResponse"); + free(resp_buf); + /* Not fatal - server may just return headers-only 200 */ + return 0; + } + + uint8_t *json_data = resp_buf; + size_t json_data_len = resp_total; + + /* Hex dump first 32 bytes for debugging prefix issues */ + { + int dump = json_data_len < 32 ? (int)json_data_len : 32; + char hexbuf[97]; + for (int i = 0; i < dump; i++) { + sprintf(hexbuf + i * 3, "%02x ", json_data[i]); + } + hexbuf[dump * 3] = '\0'; + ESP_LOGI(TAG, "RegisterResponse first %d bytes (hex): %s", dump, hexbuf); + } + + + /* Find start of JSON - skip any binary prefix */ + char *parse_start = (char *)json_data; + size_t parse_len = json_data_len; + int json_offset = -1; + for (int i = 0; i < 8 && i < (int)json_data_len; i++) { + if (json_data[i] == '{') { + json_offset = i; + break; + } + } + if (json_offset > 0) { + ESP_LOGI(TAG, "RegisterResponse JSON starts at offset %d", json_offset); + parse_start += json_offset; + parse_len -= json_offset; + } else if (json_offset < 0) { + ESP_LOGW(TAG, "No '{' found in RegisterResponse data"); + free(resp_buf); + return 0; + } + + /* Null-terminate for cJSON */ + char saved = parse_start[parse_len]; + parse_start[parse_len] = '\0'; + + cJSON *resp_json = cJSON_Parse(parse_start); + if (!resp_json) { + ESP_LOGW(TAG, "Failed to parse RegisterResponse JSON (len=%d)", (int)parse_len); + ESP_LOGW(TAG, "First 100 chars: %.100s", parse_start); + parse_start[parse_len] = saved; + free(resp_buf); + return 0; /* Not fatal - we'll get peers in MapResponse */ + } + parse_start[parse_len] = saved; + free(resp_buf); + + /* Extract our VPN IP from Node.Addresses */ + cJSON *node = cJSON_GetObjectItem(resp_json, "Node"); + if (node) { + cJSON *addresses = cJSON_GetObjectItem(node, "Addresses"); + if (addresses && cJSON_GetArraySize(addresses) > 0) { + const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; + if (addr) { + unsigned a, b, c, d; + if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { + ml->vpn_ip = (a << 24) | (b << 16) | (c << 8) | d; + char ip_str[16]; + microlink_ip_to_str(ml->vpn_ip, ip_str); + ESP_LOGI(TAG, "Our VPN IP: %s", ip_str); + } + } + } + /* Parse self-node DERP region — try modern HomeDERP (int) first, + * then fall back to legacy DERP string (format: "127.3.3.40:REGION") */ + cJSON *home_derp = cJSON_GetObjectItem(node, "HomeDERP"); + if (home_derp && cJSON_IsNumber(home_derp) && home_derp->valueint > 0) { + ml->derp_home_region = (uint16_t)home_derp->valueint; + ESP_LOGI(TAG, "Home DERP region: %d (from server, HomeDERP)", ml->derp_home_region); + } else { + cJSON *self_derp = cJSON_GetObjectItem(node, "DERP"); + if (self_derp && self_derp->valuestring) { + ESP_LOGI(TAG, "Self-Node DERP: %s", self_derp->valuestring); + const char *colon = strrchr(self_derp->valuestring, ':'); + if (colon) { + int region = atoi(colon + 1); + if (region > 0) { + ml->derp_home_region = (uint16_t)region; + ESP_LOGI(TAG, "Home DERP region: %d (from server, legacy DERP)", region); + } + } + } + } + /* Fallback: if server didn't assign a DERP region, use our configured default */ + if (ml->derp_home_region == 0) { + ml->derp_home_region = ML_DERP_REGION; + ESP_LOGI(TAG, "Home DERP region: %d (default)", ML_DERP_REGION); + } + cJSON *self_key = cJSON_GetObjectItem(node, "Key"); + if (self_key && self_key->valuestring) { + ESP_LOGI(TAG, "Self-Node Key (from server): %s", self_key->valuestring); + char our_key_hex[65]; + bytes_to_hex(ml->wg_public_key, 32, our_key_hex); + ESP_LOGI(TAG, "Our WG pubkey (local): nodekey:%s", our_key_hex); + } + } + + cJSON_Delete(resp_json); + + int64_t t_reg_done = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] Register total: %lld ms", (t_reg_done - t_reg_start) / 1000); + + return 0; +} + +/* ============================================================================ + * State: FETCH_PEERS - Send MapRequest, parse MapResponse + * ========================================================================== */ + +static void parse_peers_from_map_response(microlink_t *ml, cJSON *root) { + /* Try all field names used by Tailscale (copied from v1 lines 3176-3184): + * "Peers" - Full peer list (initial Stream=false response) + * "PeersChanged" - Incremental updates (Stream=true long-poll) + * "peers" - Lowercase fallback */ + cJSON *peers = cJSON_GetObjectItem(root, "Peers"); + if (!peers) { + peers = cJSON_GetObjectItem(root, "PeersChanged"); + if (peers) ESP_LOGI(TAG, "Using 'PeersChanged' field for peer list"); + } + if (!peers) { + peers = cJSON_GetObjectItem(root, "peers"); + if (peers) ESP_LOGI(TAG, "Using 'peers' (lowercase) field for peer list"); + } + if (!peers || !cJSON_IsArray(peers)) { + /* Normal for delta updates (PeersRemoved, PeersChangedPatch only) */ + goto check_removed; + } + + int count = cJSON_GetArraySize(peers); + ESP_LOGI(TAG, "MapResponse: %d peers", count); + + cJSON *peer; + cJSON_ArrayForEach(peer, peers) { + /* Allocate peer update (freed by wg_mgr after processing) */ + ml_peer_update_t *update = ml_psram_calloc(1, sizeof(ml_peer_update_t)); + if (!update) continue; + + update->action = ML_PEER_ADD; + + /* Hostname */ + cJSON *name = cJSON_GetObjectItem(peer, "Name"); + if (name && name->valuestring) { + strncpy(update->hostname, name->valuestring, sizeof(update->hostname) - 1); + /* Strip trailing dot from FQDN */ + size_t hlen = strlen(update->hostname); + if (hlen > 0 && update->hostname[hlen - 1] == '.') { + update->hostname[hlen - 1] = '\0'; + } + } + + /* NodeKey: "nodekey:HEX..." */ + cJSON *key = cJSON_GetObjectItem(peer, "Key"); + if (key && key->valuestring) { + const char *hex = key->valuestring; + if (strncmp(hex, "nodekey:", 8) == 0) hex += 8; + hex_to_bytes(hex, update->public_key, 32); + } + + /* DiscoKey: "discokey:HEX..." */ + cJSON *disco = cJSON_GetObjectItem(peer, "DiscoKey"); + if (disco && disco->valuestring) { + const char *hex = disco->valuestring; + if (strncmp(hex, "discokey:", 9) == 0) hex += 9; + hex_to_bytes(hex, update->disco_key, 32); + } + + /* VPN IP from Addresses */ + cJSON *addresses = cJSON_GetObjectItem(peer, "Addresses"); + if (addresses && cJSON_GetArraySize(addresses) > 0) { + const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; + if (addr) { + unsigned a, b, c, d; + /* Handle CIDR notation (e.g., "100.64.1.2/32") */ + if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { + update->vpn_ip = (a << 24) | (b << 16) | (c << 8) | d; + } + } + } + + /* DERP region — try modern HomeDERP (int) first, then legacy DERP string */ + cJSON *peer_home_derp = cJSON_GetObjectItem(peer, "HomeDERP"); + if (peer_home_derp && cJSON_IsNumber(peer_home_derp) && peer_home_derp->valueint > 0) { + update->derp_region = (uint16_t)peer_home_derp->valueint; + } else { + cJSON *derp = cJSON_GetObjectItem(peer, "DERP"); + if (derp && derp->valuestring) { + /* Format: "127.3.3.40:N" where N is the region number */ + unsigned dr; + if (sscanf(derp->valuestring, "127.3.3.40:%u", &dr) == 1) { + update->derp_region = (uint16_t)dr; + } + } + } + + /* Endpoints */ + cJSON *endpoints = cJSON_GetObjectItem(peer, "Endpoints"); + if (endpoints) { + int ep_count = 0; + cJSON *ep; + cJSON_ArrayForEach(ep, endpoints) { + if (ep_count >= ML_MAX_ENDPOINTS) break; + if (ep->valuestring) { + unsigned ea, eb, ec, ed, eport; + if (sscanf(ep->valuestring, "%u.%u.%u.%u:%u", + &ea, &eb, &ec, &ed, &eport) == 5) { + update->endpoints[ep_count].ip = + (ea << 24) | (eb << 16) | (ec << 8) | ed; + update->endpoints[ep_count].port = (uint16_t)eport; + update->endpoints[ep_count].is_ipv6 = false; + ep_count++; + } + } + } + update->endpoint_count = ep_count; + } + + char ip_str[16]; + microlink_ip_to_str(update->vpn_ip, ip_str); + ESP_LOGI(TAG, " Peer: %s (%s) derp=%d eps=%d", + update->hostname, ip_str, update->derp_region, update->endpoint_count); + + /* Send to wg_mgr task via queue */ + if (xQueueSend(ml->peer_update_queue, &update, pdMS_TO_TICKS(100)) != pdTRUE) { + ESP_LOGW(TAG, "Peer update queue full, dropping %s", update->hostname); + free(update); + } + } + +check_removed: + /* Handle PeersRemoved — array of nodekey strings (long-poll delta updates) */ + cJSON *removed = cJSON_GetObjectItem(root, "PeersRemoved"); + if (removed && cJSON_IsArray(removed)) { + int rm_count = cJSON_GetArraySize(removed); + ESP_LOGI(TAG, "PeersRemoved: %d peers", rm_count); + + cJSON *key_item; + cJSON_ArrayForEach(key_item, removed) { + if (!key_item->valuestring) continue; + + ml_peer_update_t *update = ml_psram_calloc(1, sizeof(ml_peer_update_t)); + if (!update) continue; + + update->action = ML_PEER_REMOVE; + + const char *hex = key_item->valuestring; + if (strncmp(hex, "nodekey:", 8) == 0) hex += 8; + hex_to_bytes(hex, update->public_key, 32); + + ESP_LOGI(TAG, " Remove peer: %02x%02x%02x%02x...", + update->public_key[0], update->public_key[1], + update->public_key[2], update->public_key[3]); + + if (xQueueSend(ml->peer_update_queue, &update, pdMS_TO_TICKS(100)) != pdTRUE) { + free(update); + } + } + } + + /* Handle PeersChangedPatch — lightweight endpoint-only updates */ + cJSON *patches = cJSON_GetObjectItem(root, "PeersChangedPatch"); + if (patches && cJSON_IsObject(patches)) { + ESP_LOGI(TAG, "PeersChangedPatch: processing lightweight updates"); + cJSON *patch = patches->child; + while (patch) { + if (patch->string && cJSON_IsObject(patch)) { + /* Key is nodekey string, value has optional fields like Endpoints, DERP */ + ml_peer_update_t *update = ml_psram_calloc(1, sizeof(ml_peer_update_t)); + if (update) { + update->action = ML_PEER_UPDATE_ENDPOINT; + + const char *hex = patch->string; + if (strncmp(hex, "nodekey:", 8) == 0) hex += 8; + hex_to_bytes(hex, update->public_key, 32); + + /* Parse DERP region if present — try DERPRegion (int) first, + * then legacy DERP string */ + cJSON *patch_derp_region = cJSON_GetObjectItem(patch, "DERPRegion"); + if (patch_derp_region && cJSON_IsNumber(patch_derp_region) && patch_derp_region->valueint > 0) { + update->derp_region = (uint16_t)patch_derp_region->valueint; + } else { + cJSON *derp = cJSON_GetObjectItem(patch, "DERP"); + if (derp && derp->valuestring) { + unsigned dr; + if (sscanf(derp->valuestring, "127.3.3.40:%u", &dr) == 1) { + update->derp_region = (uint16_t)dr; + } + } + } + + /* Parse endpoints if present */ + cJSON *endpoints = cJSON_GetObjectItem(patch, "Endpoints"); + if (endpoints) { + int ep_count = 0; + cJSON *ep; + cJSON_ArrayForEach(ep, endpoints) { + if (ep_count >= ML_MAX_ENDPOINTS) break; + if (ep->valuestring) { + unsigned ea, eb, ec, ed, eport; + if (sscanf(ep->valuestring, "%u.%u.%u.%u:%u", + &ea, &eb, &ec, &ed, &eport) == 5) { + update->endpoints[ep_count].ip = + (ea << 24) | (eb << 16) | (ec << 8) | ed; + update->endpoints[ep_count].port = (uint16_t)eport; + update->endpoints[ep_count].is_ipv6 = false; + ep_count++; + } + } + } + update->endpoint_count = ep_count; + } + + ESP_LOGI(TAG, " Patch peer: %02x%02x%02x%02x... eps=%d", + update->public_key[0], update->public_key[1], + update->public_key[2], update->public_key[3], + update->endpoint_count); + + if (xQueueSend(ml->peer_update_queue, &update, pdMS_TO_TICKS(100)) != pdTRUE) { + free(update); + } + } + } + patch = patch->next; + } + } +} + +/* Add Endpoints + EndpointTypes arrays to a MapRequest JSON object. + * Includes: WiFi LAN endpoint (type=Local), STUN IPv4 (type=STUN), + * STUN IPv6 (type=STUN). Returns number of endpoints added. */ +static int add_endpoints_to_json(microlink_t *ml, cJSON *root) { + int count = 0; + cJSON *ep_array = cJSON_AddArrayToObject(root, "Endpoints"); + cJSON *et_array = cJSON_AddArrayToObject(root, "EndpointTypes"); + if (!ep_array || !et_array) return 0; + + /* LAN endpoint (WiFi STA) */ + esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (sta_netif) { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) { + uint32_t local_ip = ntohl(ip_info.ip.addr); + char ep_str[32]; + snprintf(ep_str, sizeof(ep_str), "%lu.%lu.%lu.%lu:%u", + (unsigned long)((local_ip >> 24) & 0xFF), + (unsigned long)((local_ip >> 16) & 0xFF), + (unsigned long)((local_ip >> 8) & 0xFF), + (unsigned long)(local_ip & 0xFF), + ml->disco_local_port); + cJSON_AddItemToArray(ep_array, cJSON_CreateString(ep_str)); + cJSON_AddItemToArray(et_array, cJSON_CreateNumber(1)); /* EndpointLocal */ + count++; + } + } + + /* PPP endpoint (cellular) — use STUN-discovered public IP as our endpoint */ + /* (PPP netif doesn't have a useful local IP for peers — it's behind CGNAT) */ + + /* STUN public endpoint (IPv4) */ + if (ml->stun_public_ip != 0) { + uint32_t pub_ip = ml->stun_public_ip; + uint16_t pub_port = ml->stun_public_port ? ml->stun_public_port : ml->disco_local_port; + char ep_str[32]; + snprintf(ep_str, sizeof(ep_str), "%lu.%lu.%lu.%lu:%u", + (unsigned long)((pub_ip >> 24) & 0xFF), + (unsigned long)((pub_ip >> 16) & 0xFF), + (unsigned long)((pub_ip >> 8) & 0xFF), + (unsigned long)(pub_ip & 0xFF), + pub_port); + cJSON_AddItemToArray(ep_array, cJSON_CreateString(ep_str)); + cJSON_AddItemToArray(et_array, cJSON_CreateNumber(2)); /* EndpointSTUN */ + count++; + } + + /* STUN public endpoint (IPv6) — only include if it's a real IPv6 address, + * not an IPv4-mapped IPv6 (::ffff:x.x.x.x) which is redundant with IPv4. */ + if (ml->stun_has_ipv6) { + const uint8_t *a = ml->stun_public_ip6; + /* Check for IPv4-mapped prefix: first 10 bytes zero, bytes 10-11 = 0xff */ + static const uint8_t v4mapped_prefix[12] = {0,0,0,0, 0,0,0,0, 0,0,0xff,0xff}; + bool is_v4mapped = (memcmp(a, v4mapped_prefix, 12) == 0); + if (!is_v4mapped) { + uint16_t port6 = ml->stun_public_port6 ? ml->stun_public_port6 : ml->disco_local_port; + char ep6_str[64]; + snprintf(ep6_str, sizeof(ep6_str), + "[%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x]:%u", + a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], + a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15], + port6); + cJSON_AddItemToArray(ep_array, cJSON_CreateString(ep6_str)); + cJSON_AddItemToArray(et_array, cJSON_CreateNumber(2)); /* EndpointSTUN */ + count++; + } + } + + if (count > 0) { + ESP_LOGI(TAG, "MapRequest includes %d endpoint(s)", count); + } + return count; +} + +/* Ingest the self-node info (VPN IP / HomeDERP / KeyExpiry / Expired) from + * a MapResponse. Shared parser called from both paths — the Stream=false + * one-shot fetch and the streaming_map_fetch initial netmap (extracted from + * do_fetch_peers). */ +static void parse_self_node_from_map(microlink_t *ml, cJSON *map_json) { + cJSON *node = cJSON_GetObjectItem(map_json, "Node"); + if (!node) return; + + /* Extract VPN IP if not already set */ + if (ml->vpn_ip == 0) { + cJSON *addresses = cJSON_GetObjectItem(node, "Addresses"); + if (addresses && cJSON_GetArraySize(addresses) > 0) { + const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; + if (addr) { + unsigned a, b, c, d; + if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { + ml->vpn_ip = (a << 24) | (b << 16) | (c << 8) | d; + char ip_str[16]; + microlink_ip_to_str(ml->vpn_ip, ip_str); + ESP_LOGI(TAG, "Our VPN IP: %s", ip_str); + } + } + } + } + /* Parse self-node DERP region — try modern HomeDERP (int) first, + * then fall back to legacy DERP string (format: "127.3.3.40:REGION") */ + cJSON *home_derp = cJSON_GetObjectItem(node, "HomeDERP"); + if (home_derp && cJSON_IsNumber(home_derp) && home_derp->valueint > 0) { + ml->derp_home_region = (uint16_t)home_derp->valueint; + ESP_LOGI(TAG, "Home DERP region: %d (from server, HomeDERP)", ml->derp_home_region); + } else { + cJSON *self_derp = cJSON_GetObjectItem(node, "DERP"); + if (self_derp && self_derp->valuestring) { + ESP_LOGI(TAG, "Self-Node DERP: %s", self_derp->valuestring); + const char *colon = strrchr(self_derp->valuestring, ':'); + if (colon) { + int region = atoi(colon + 1); + if (region > 0) { + ml->derp_home_region = (uint16_t)region; + ESP_LOGI(TAG, "Home DERP region: %d (from server, legacy DERP)", region); + } + } + } + } + /* Fallback: if server didn't assign a DERP region, use our configured default */ + if (ml->derp_home_region == 0) { + ml->derp_home_region = ML_DERP_REGION; + ESP_LOGI(TAG, "Home DERP region: %d (default)", ML_DERP_REGION); + } + cJSON *self_key = cJSON_GetObjectItem(node, "Key"); + if (self_key && self_key->valuestring) { + ESP_LOGI(TAG, "Self-Node Key (server): %.40s...", self_key->valuestring); + char our_hex[65]; + bytes_to_hex(ml->wg_public_key, 32, our_hex); + ESP_LOGI(TAG, "Our WG pubkey (local): nodekey:%.32s...", our_hex); + } + + /* Parse KeyExpiry (ISO 8601: "YYYY-MM-DDTHH:MM:SSZ") */ + cJSON *key_expiry = cJSON_GetObjectItem(node, "KeyExpiry"); + if (key_expiry && key_expiry->valuestring) { + int yr, mo, dy, hr, mn, sc; + if (sscanf(key_expiry->valuestring, "%d-%d-%dT%d:%d:%d", + &yr, &mo, &dy, &hr, &mn, &sc) >= 6) { + /* Simple epoch calculation (approximate, no leap second) */ + /* Days from year 1970 */ + int64_t days = 0; + for (int y = 1970; y < yr; y++) { + days += (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 366 : 365; + } + static const int mdays[] = {0,31,28,31,30,31,30,31,31,30,31,30,31}; + for (int m = 1; m < mo; m++) { + days += mdays[m]; + if (m == 2 && (yr % 4 == 0 && (yr % 100 != 0 || yr % 400 == 0))) days++; + } + days += dy - 1; + ml->key_expiry_epoch = days * 86400 + hr * 3600 + mn * 60 + sc; + ESP_LOGI(TAG, "Key expiry: %s (epoch: %lld)", key_expiry->valuestring, + (long long)ml->key_expiry_epoch); + } + } + + /* Check Expired flag */ + cJSON *expired = cJSON_GetObjectItem(node, "Expired"); + if (expired && cJSON_IsTrue(expired)) { + ml->key_expired = true; + ESP_LOGW(TAG, "Node key is EXPIRED — re-registration needed"); + } else { + ml->key_expired = false; + } +} + +/* Shared parser that ingests the DERPMap (all regions and nodes) from a + * MapResponse. Does nothing for MapResponses without a DERPMap (e.g. + * incremental updates). Called from both the Stream=false one-shot fetch + * and the streaming_map_fetch path (extracted from do_fetch_peers). */ +static void parse_derp_map_from_map(microlink_t *ml, cJSON *map_json) { + cJSON *derp_map = cJSON_GetObjectItem(map_json, "DERPMap"); + if (!derp_map) return; + cJSON *regions = cJSON_GetObjectItem(derp_map, "Regions"); + if (!regions) return; + + ml->derp_region_count = 0; + cJSON *region_obj; + cJSON_ArrayForEach(region_obj, regions) { + if (ml->derp_region_count >= ML_MAX_DERP_REGIONS) break; + ml_derp_region_t *r = &ml->derp_regions[ml->derp_region_count]; + memset(r, 0, sizeof(*r)); + + cJSON *rid = cJSON_GetObjectItem(region_obj, "RegionID"); + if (rid) r->region_id = (uint16_t)rid->valuedouble; + + cJSON *rcode = cJSON_GetObjectItem(region_obj, "RegionCode"); + if (rcode && rcode->valuestring) { + strncpy(r->code, rcode->valuestring, sizeof(r->code) - 1); + } + + cJSON *avoid = cJSON_GetObjectItem(region_obj, "Avoid"); + if (avoid && cJSON_IsTrue(avoid)) r->avoid = true; + + /* Parse nodes */ + cJSON *nodes = cJSON_GetObjectItem(region_obj, "Nodes"); + if (nodes) { + cJSON *node_obj; + cJSON_ArrayForEach(node_obj, nodes) { + if (r->node_count >= ML_MAX_DERP_NODES) break; + ml_derp_node_t *n = &r->nodes[r->node_count]; + memset(n, 0, sizeof(*n)); + + cJSON *hn = cJSON_GetObjectItem(node_obj, "HostName"); + if (hn && hn->valuestring) { + strncpy(n->hostname, hn->valuestring, sizeof(n->hostname) - 1); + } + + cJSON *ip4 = cJSON_GetObjectItem(node_obj, "IPv4"); + if (ip4 && ip4->valuestring) { + strncpy(n->ipv4, ip4->valuestring, sizeof(n->ipv4) - 1); + } + + cJSON *ip6 = cJSON_GetObjectItem(node_obj, "IPv6"); + if (ip6 && ip6->valuestring) { + strncpy(n->ipv6, ip6->valuestring, sizeof(n->ipv6) - 1); + } + + cJSON *sp = cJSON_GetObjectItem(node_obj, "STUNPort"); + if (sp) n->stun_port = (uint16_t)sp->valuedouble; + + cJSON *dp = cJSON_GetObjectItem(node_obj, "DERPPort"); + if (dp) n->derp_port = (uint16_t)dp->valuedouble; + + cJSON *so = cJSON_GetObjectItem(node_obj, "STUNOnly"); + if (so && cJSON_IsTrue(so)) n->stun_only = true; + + r->node_count++; + } + } + + ESP_LOGI(TAG, " DERP region %d (%s): %d nodes%s", + r->region_id, r->code, r->node_count, + r->avoid ? " [avoid]" : ""); + for (int ni = 0; ni < r->node_count; ni++) { + ESP_LOGI(TAG, " node: %s (v4=%s v6=%s stun=%d derp=%d%s)", + r->nodes[ni].hostname, + r->nodes[ni].ipv4[0] ? r->nodes[ni].ipv4 : "-", + r->nodes[ni].ipv6[0] ? r->nodes[ni].ipv6 : "-", + r->nodes[ni].stun_port ? r->nodes[ni].stun_port : 3478, + r->nodes[ni].derp_port ? r->nodes[ni].derp_port : 443, + r->nodes[ni].stun_only ? " stun-only" : ""); + } + + ml->derp_region_count++; + } + ESP_LOGI(TAG, "DERPMap: parsed %d regions", ml->derp_region_count); +} + +static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { + int64_t t_map_start = esp_timer_get_time(); + + /* Build MapRequest JSON */ + cJSON *root = cJSON_CreateObject(); + if (!root) return -1; + + cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); + + char key_hex[65]; + char key_str[80]; + bytes_to_hex(ml->wg_public_key, 32, key_hex); + snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); + cJSON_AddStringToObject(root, "NodeKey", key_str); + + /* DiscoKey */ + bytes_to_hex(ml->disco_public_key, 32, key_hex); + snprintf(key_str, sizeof(key_str), "discokey:%s", key_hex); + cJSON_AddStringToObject(root, "DiscoKey", key_str); + + /* Stream=false for initial fetch */ + cJSON_AddBoolToObject(root, "Stream", false); + cJSON_AddBoolToObject(root, "KeepAlive", true); + cJSON_AddStringToObject(root, "Compress", ""); /* Disable compression */ + + /* Hostinfo */ + cJSON *hostinfo = cJSON_CreateObject(); + const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); + cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); + cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); + cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); + cJSON_AddItemToObject(root, "Hostinfo", hostinfo); + + /* NetInfo: tell control plane our preferred DERP region and NAT type. + * MUST be inside Hostinfo — the control plane reads Hostinfo.NetInfo.PreferredDERP + * to populate Node.HomeDERP for other peers. */ + cJSON *netinfo = cJSON_CreateObject(); + if (netinfo) { + cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); + if (ml->stun_nat_checked) { + cJSON_AddBoolToObject(netinfo, "MappingVariesByDestIP", ml->nat_mapping_varies); + } + cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); + } + + /* Include endpoints if STUN has already completed (Stream=false → + * control plane processes these, unlike Stream=true with Version >= 68) */ + add_endpoints_to_json(ml, root); + + char *json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json_str) return -1; + + size_t json_len = strlen(json_str); + ESP_LOGI(TAG, "MapRequest: %d bytes (Stream=false)", (int)json_len); + + /* Build H2 HEADERS + DATA, stream ID 3 (stream 1 was register) */ + uint8_t *h2_buf = ml_psram_malloc(json_len + 512); + if (!h2_buf) { free(json_str); return -1; } + + int h2_pos = 0; + + int hdr_len = ml_h2_build_headers_frame(h2_buf + h2_pos, json_len + 512, + "POST", "/machine/map", + CTRL_HOST(ml), "application/json", + 3, false); + if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } + h2_pos += hdr_len; + + int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, + (uint8_t *)json_str, json_len, + 3, true); + free(json_str); + if (data_len < 0) { free(h2_buf); return -1; } + h2_pos += data_len; + + if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { + free(h2_buf); + return -1; + } + free(h2_buf); + + int64_t t_map_sent = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] MapRequest send: %lld ms", (t_map_sent - t_map_start) / 1000); + + /* 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 */ + 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) */ + struct timeval rcv_tv = { .tv_sec = 60, .tv_usec = 0 }; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); + + uint64_t recv_start_ms = ml_get_time_ms(); + uint64_t last_progress_ms = recv_start_ms; + size_t window_consumed = 0; + + /* Read all Noise frames and accumulate decrypted H2 data. + * Scan for H2 END_STREAM flag (0x01) on DATA frames to know when the + * response is complete — without this, we wait for the full recv timeout + * (60s) before proceeding, which dominates connection time on cellular. */ + bool got_end_stream = false; + for (int read_count = 0; read_count < 200; read_count++) { + uint8_t *frame_buf = ml_psram_malloc(65536); + if (!frame_buf) break; + + int frame_len = noise_recv(ml, noise, frame_buf, 65536); + if (frame_len <= 0) { + free(frame_buf); + break; + } + + /* Append decrypted data to h2_recv */ + if (h2_total + frame_len < ML_H2_BUFFER_SIZE) { + memcpy(h2_recv + h2_total, frame_buf, frame_len); + h2_total += frame_len; + window_consumed += frame_len; + } else { + ESP_LOGW(TAG, "H2 buffer full at %dKB, truncating", (int)(h2_total / 1024)); + free(frame_buf); + break; + } + free(frame_buf); + + /* Scan newly accumulated data for H2 END_STREAM flag. + * H2 frame header: 3 bytes length + 1 byte type + 1 byte flags + 4 bytes stream ID. + * DATA frame type=0x00, END_STREAM flag=0x01. + * We scan from the start each time since frames may span Noise boundaries. */ + size_t scan_pos = 0; + while (scan_pos + 9 <= h2_total) { + uint32_t f_len = (h2_recv[scan_pos] << 16) | (h2_recv[scan_pos + 1] << 8) | h2_recv[scan_pos + 2]; + uint8_t f_type = h2_recv[scan_pos + 3]; + uint8_t f_flags = h2_recv[scan_pos + 4]; + + if (scan_pos + 9 + f_len > h2_total) break; /* Incomplete frame */ + + if (f_type == 0x00 && (f_flags & 0x01)) { + /* DATA frame with END_STREAM — response is complete */ + got_end_stream = true; + } + scan_pos += 9 + f_len; + } + + if (got_end_stream) { + ESP_LOGI(TAG, "H2 END_STREAM detected after %d Noise frames (%dKB, %lums)", + read_count + 1, (int)(h2_total / 1024), + (unsigned long)(ml_get_time_ms() - recv_start_ms)); + break; + } + + /* Progress logging every 5 seconds for large responses */ + uint64_t now = ml_get_time_ms(); + if (now - last_progress_ms > 5000) { + ESP_LOGI(TAG, "MapResponse: received %dKB so far (%d frames, %lums elapsed)", + (int)(h2_total / 1024), read_count + 1, + (unsigned long)(now - recv_start_ms)); + last_progress_ms = now; + } + + /* Proactive WINDOW_UPDATE every 32KB to keep server sending. + * Must update BOTH connection-level (stream 0) AND stream-level (stream 3) + * windows, otherwise the server stalls when either window exhausts. */ + if (window_consumed >= 32768) { + uint8_t wu_buf[26]; /* 2 WINDOW_UPDATE frames: 13 bytes each */ + int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)window_consumed); + wu_len += ml_h2_build_window_update(wu_buf + wu_len, 13, 3, (uint32_t)window_consumed); + if (wu_len > 0) { + noise_send(ml, noise, wu_buf, wu_len); + } + window_consumed = 0; + } + } + + /* Restore normal recv timeout (5 seconds for long-poll) */ + rcv_tv.tv_sec = 5; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); + + ESP_LOGI(TAG, "Accumulated %dKB of H2 data from Noise frames (%lums)", + (int)(h2_total / 1024), + (unsigned long)(ml_get_time_ms() - recv_start_ms)); + + /* Now parse complete H2 frames from accumulated buffer */ + 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]; + uint8_t f_type = h2_recv[fpos + 3]; + uint8_t f_flags = h2_recv[fpos + 4]; + uint32_t f_stream = ((h2_recv[fpos + 5] & 0x7F) << 24) | + (h2_recv[fpos + 6] << 16) | + (h2_recv[fpos + 7] << 8) | h2_recv[fpos + 8]; + fpos += 9; + + ESP_LOGI(TAG, " H2 frame: type=%d flags=0x%02x len=%lu stream=%lu", + f_type, f_flags, (unsigned long)f_len, (unsigned long)f_stream); + + if (fpos + (int)f_len > (int)h2_total) { + ESP_LOGW(TAG, " Incomplete H2 frame at end (need %lu, have %d)", + (unsigned long)f_len, (int)h2_total - fpos); + break; + } + + 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; + } + } + + 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. + * Without this, the server's connection-level window exhausts on large responses. */ + if (json_total > 0) { + uint8_t wu_buf[13]; + int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)json_total); + noise_send(ml, noise, wu_buf, wu_len); + ESP_LOGI(TAG, "Sent H2 WINDOW_UPDATE: %d bytes (connection level)", (int)json_total); + } + + if (json_total == 0) { + ESP_LOGW(TAG, "Empty MapResponse"); + free(resp_buf); + return -1; + } + + ESP_LOGI(TAG, "MapResponse JSON: %d bytes", (int)json_total); + + /* Hex dump first 32 bytes for debugging prefix issues */ + { + 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]); + } + 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; + 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] == '{') { + json_offset = i; + break; + } + } + if (json_offset > 0) { + ESP_LOGI(TAG, "JSON starts at offset %d (skipping %d-byte prefix)", json_offset, json_offset); + parse_start += json_offset; + parse_len -= json_offset; + } else if (json_offset < 0) { + ESP_LOGW(TAG, "No '{' found in first 8 bytes of MapResponse!"); + } + + /* Null-terminate */ + char saved = parse_start[parse_len]; + parse_start[parse_len] = '\0'; + + cJSON *map_json = cJSON_Parse(parse_start); + parse_start[parse_len] = saved; + + if (!map_json) { + const char *err = cJSON_GetErrorPtr(); + ESP_LOGE(TAG, "MapResponse JSON parse failed near: %.50s", err ? err : "unknown"); + free(resp_buf); + return -1; + } + + /* Debug: log all top-level fields in MapResponse (from v1 lines 3053-3072) */ + { + ESP_LOGI(TAG, "MapResponse top-level fields:"); + cJSON *field = NULL; + cJSON_ArrayForEach(field, map_json) { + if (field->string) { + if (cJSON_IsArray(field)) + ESP_LOGI(TAG, " - %s (array, size=%d)", field->string, cJSON_GetArraySize(field)); + else if (cJSON_IsObject(field)) + ESP_LOGI(TAG, " - %s (object)", field->string); + else if (cJSON_IsString(field)) + ESP_LOGI(TAG, " - %s (string): %.40s", field->string, field->valuestring); + else if (cJSON_IsNumber(field)) + ESP_LOGI(TAG, " - %s (number): %g", field->string, field->valuedouble); + else if (cJSON_IsBool(field)) + ESP_LOGI(TAG, " - %s (bool): %s", field->string, cJSON_IsTrue(field) ? "true" : "false"); + else + ESP_LOGI(TAG, " - %s (other)", field->string); + } + } + } + + /* Extract self-node info */ + parse_self_node_from_map(ml, map_json); + + /* Parse peers */ + parse_peers_from_map_response(ml, map_json); + + /* Extract DERPMap if present — parse all regions and nodes */ + parse_derp_map_from_map(ml, map_json); + + cJSON_Delete(map_json); + free(resp_buf); + + int64_t t_map_done = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] MapResponse recv+parse: %lld ms (total map: %lld ms, %dKB)", + (t_map_done - t_map_sent) / 1000, + (t_map_done - t_map_start) / 1000, + (int)(h2_total / 1024)); + + return 0; +} + +/* ============================================================================ + * State: LONG_POLL - Start streaming MapRequest + process incremental updates + * ========================================================================== */ + +/* Send MapRequest with Stream=true to start long-poll on H2 stream 5. + * omit_peers=false is for streaming_map_fetch: when the initial peer fetch + * is not done via Stream=false, the first stream message must carry the + * full netmap (Peers + DERPMap), so OmitPeers is dropped. */ +static int do_start_long_poll(microlink_t *ml, ml_noise_state_t *noise, bool omit_peers) { + cJSON *root = cJSON_CreateObject(); + if (!root) return -1; + + cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); + + char key_hex[65], key_str[80]; + bytes_to_hex(ml->wg_public_key, 32, key_hex); + snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); + cJSON_AddStringToObject(root, "NodeKey", key_str); + + bytes_to_hex(ml->disco_public_key, 32, key_hex); + snprintf(key_str, sizeof(key_str), "discokey:%s", key_hex); + cJSON_AddStringToObject(root, "DiscoKey", key_str); + + /* Hostinfo - REQUIRED by control plane even for Stream=true. + * V1 includes this; without it, server may not keep us "online". */ + cJSON *hostinfo = cJSON_CreateObject(); + if (hostinfo) { + const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); + cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); + cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); + cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); + cJSON_AddItemToObject(root, "Hostinfo", hostinfo); + } + + /* NetInfo: tell control plane our preferred DERP region and NAT type. + * MUST be inside Hostinfo — the control plane reads Hostinfo.NetInfo.PreferredDERP + * to populate Node.HomeDERP for other peers. */ + cJSON *netinfo = cJSON_CreateObject(); + if (netinfo) { + cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); + if (ml->stun_nat_checked) { + cJSON_AddBoolToObject(netinfo, "MappingVariesByDestIP", ml->nat_mapping_varies); + } + cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); + } + + /* Stream=true for long-poll, KeepAlive=true so server sends keepalives + * (which marks us as "online" on the control plane) */ + cJSON_AddBoolToObject(root, "Stream", true); + cJSON_AddBoolToObject(root, "KeepAlive", true); + cJSON_AddStringToObject(root, "Compress", ""); /* Disable compression */ + /* omit_peers=true: peers were already fetched via Stream=false + * (existing path). + * omit_peers=false: streaming_map_fetch — receive all peers + DERPMap + * in the first stream message. */ + cJSON_AddBoolToObject(root, "OmitPeers", omit_peers); + + /* NOTE: With Version >= 68, the control plane IGNORES Endpoints and + * Hostinfo in Stream=true MapRequests. Endpoints are sent via separate + * do_send_endpoint_update() calls (Stream=false, OmitPeers=true). */ + + char *json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json_str) return -1; + + size_t json_len = strlen(json_str); + ESP_LOGI(TAG, "MapRequest: %d bytes (Stream=true)", (int)json_len); + + /* Build H2 frames on stream ID 5 */ + uint8_t *h2_buf = ml_psram_malloc(json_len + 512); + if (!h2_buf) { free(json_str); return -1; } + + int h2_pos = 0; + int hdr_len = ml_h2_build_headers_frame(h2_buf, json_len + 512, + "POST", "/machine/map", + CTRL_HOST(ml), "application/json", + 5, false); + if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } + h2_pos += hdr_len; + + int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, + (uint8_t *)json_str, json_len, + 5, true); + free(json_str); + if (data_len < 0) { free(h2_buf); return -1; } + h2_pos += data_len; + + if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { + free(h2_buf); + return -1; + } + free(h2_buf); + + ESP_LOGI(TAG, "Streaming MapRequest sent on stream 5"); + return 0; +} + +/* For streaming_map_fetch: blockingly read the first MapResponse (the full + * netmap) on stream 5 opened by do_start_long_poll(omit_peers=false), and + * parse Node / Peers / DERPMap from it. + * + * headscale-based controllers such as ZTL do not respond to Stream=false + * MapRequests, so this function fetches the initial netmap instead of + * do_fetch_peers. A Stream=true response keeps the stream open and never + * sends END_STREAM, so do_fetch_peers' END_STREAM detection cannot be used. + * Instead, message completion is detected via the 4-byte length prefix at + * the start of each message (the Tailscale/headscale map stream framing). + * + * The DERP connect request must be issued only after this function + * succeeds — in the reverse order we would try to connect to DERP without + * a DERPMap and fail (same symptom as getting stuck with Stream=false). */ +static int do_read_initial_netmap(microlink_t *ml, ml_noise_state_t *noise) { + int64_t t_map_start = esp_timer_get_time(); + + uint8_t *h2_recv = ml_psram_malloc(ML_H2_BUFFER_SIZE); + if (!h2_recv) return -1; + size_t h2_total = 0; + size_t fpos = 0; /* Consumed H2 frame boundary */ + + uint8_t *resp_buf = ml_psram_malloc(ML_JSON_BUFFER_SIZE); + if (!resp_buf) { free(h2_recv); return -1; } + size_t json_total = 0; /* Accumulated DATA payload of stream 5 */ + + /* The initial netmap can be large, so wait up to 60 seconds (same as do_fetch_peers) */ + struct timeval rcv_tv = { .tv_sec = 60, .tv_usec = 0 }; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); + + uint64_t recv_start_ms = ml_get_time_ms(); + uint64_t last_progress_ms = recv_start_ms; + size_t window_consumed = 0; + + size_t msg_len = 0; /* Body length from the length prefix (0 = not yet known) */ + size_t msg_off = 0; /* Body start offset (= prefix length) */ + bool msg_complete = false; + + for (int read_count = 0; read_count < 200 && !msg_complete; read_count++) { + uint8_t *frame_buf = ml_psram_malloc(65536); + if (!frame_buf) break; + + int frame_len = noise_recv(ml, noise, frame_buf, 65536); + if (frame_len <= 0) { + free(frame_buf); + break; + } + + if (h2_total + frame_len >= ML_H2_BUFFER_SIZE) { + ESP_LOGW(TAG, "H2 buffer full at %dKB, truncating", (int)(h2_total / 1024)); + free(frame_buf); + break; + } + memcpy(h2_recv + h2_total, frame_buf, frame_len); + h2_total += frame_len; + window_consumed += frame_len; + free(frame_buf); + + /* Consume only complete H2 frames, in order. An H2 frame can span + * multiple Noise frames, so keep the consumption boundary fpos and + * process incrementally. */ + while (fpos + 9 <= h2_total) { + uint32_t f_len = (h2_recv[fpos] << 16) | (h2_recv[fpos + 1] << 8) | h2_recv[fpos + 2]; + uint8_t f_type = h2_recv[fpos + 3]; + uint8_t f_flags = h2_recv[fpos + 4]; + uint32_t f_stream = ((h2_recv[fpos + 5] & 0x7F) << 24) | + (h2_recv[fpos + 6] << 16) | + (h2_recv[fpos + 7] << 8) | h2_recv[fpos + 8]; + if (fpos + 9 + f_len > h2_total) break; /* Frame tail has not arrived yet */ + + const uint8_t *payload = h2_recv + fpos + 9; + + if (f_type == 0x00 && f_stream == 5 && f_len > 0) { + /* DATA on stream 5 = the MapResponse body */ + if (json_total + f_len < ML_JSON_BUFFER_SIZE) { + memcpy(resp_buf + json_total, payload, f_len); + json_total += f_len; + } + } else if (f_type == 0x06 && f_len == 8 && !(f_flags & 0x01)) { + /* HTTP/2 PING from the server -> reply with PONG (ignoring it gets us disconnected) */ + uint8_t pong[17]; + pong[0] = 0x00; pong[1] = 0x00; pong[2] = 0x08; + pong[3] = 0x06; pong[4] = 0x01; + pong[5] = 0x00; pong[6] = 0x00; pong[7] = 0x00; pong[8] = 0x00; + memcpy(pong + 9, payload, 8); + noise_send(ml, noise, pong, sizeof(pong)); + } else if (f_type == 0x04 && !(f_flags & 0x01)) { + /* HTTP/2 SETTINGS → ACK */ + uint8_t settings_ack[9] = {0x00, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00}; + noise_send(ml, noise, settings_ack, sizeof(settings_ack)); + } + /* Skip everything else (HEADERS etc.) */ + fpos += 9 + f_len; + } + + /* Message completion check: 4-byte length prefix at the start. + * Tailscale / headscale write it little-endian, but as a safeguard + * against implementation differences, try BE if the LE value is + * implausible; raw JSON without a prefix (starting with '{') is + * considered complete once it parses fully. */ + if (json_total >= 4 && msg_len == 0 && resp_buf[0] != '{') { + size_t le = (size_t)resp_buf[0] | ((size_t)resp_buf[1] << 8) | + ((size_t)resp_buf[2] << 16) | ((size_t)resp_buf[3] << 24); + size_t be = ((size_t)resp_buf[0] << 24) | ((size_t)resp_buf[1] << 16) | + ((size_t)resp_buf[2] << 8) | (size_t)resp_buf[3]; + if (le > 0 && le < ML_JSON_BUFFER_SIZE) { + msg_len = le; + msg_off = 4; + } else if (be > 0 && be < ML_JSON_BUFFER_SIZE) { + msg_len = be; + msg_off = 4; + } + } + if (msg_len > 0 && json_total >= msg_off + msg_len) { + msg_complete = true; + ESP_LOGI(TAG, "Initial netmap complete: %d bytes (%lums)", + (int)msg_len, (unsigned long)(ml_get_time_ms() - recv_start_ms)); + } else if (msg_len == 0 && json_total > 0 && resp_buf[0] == '{') { + resp_buf[json_total] = '\0'; /* Safe: accumulation is capped at SIZE-1 */ + cJSON *probe = cJSON_Parse((char *)resp_buf); + if (probe) { + cJSON_Delete(probe); + msg_len = json_total; + msg_off = 0; + msg_complete = true; + ESP_LOGI(TAG, "Initial netmap complete (no length prefix): %d bytes", + (int)msg_len); + } + } + + uint64_t now = ml_get_time_ms(); + if (!msg_complete && now - last_progress_ms > 5000) { + ESP_LOGI(TAG, "Initial netmap: received %dKB so far (%lums elapsed)", + (int)(json_total / 1024), (unsigned long)(now - recv_start_ms)); + last_progress_ms = now; + } + + /* Every 32KB, replenish flow control with WINDOW_UPDATE (connection + * level + stream 5) so the server never stalls sending (same + * pattern as do_fetch_peers). */ + if (window_consumed >= 32768) { + uint8_t wu_buf[26]; + int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)window_consumed); + wu_len += ml_h2_build_window_update(wu_buf + wu_len, 13, 5, (uint32_t)window_consumed); + if (wu_len > 0) noise_send(ml, noise, wu_buf, wu_len); + window_consumed = 0; + } + } + + /* Restore the normal recv timeout (5 seconds) */ + rcv_tv.tv_sec = 5; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); + + free(h2_recv); + + /* Replenish the remaining flow control. Stream 5 stays open (the + * long-poll continues), so always replenish the stream level as well, + * not just the connection level. */ + if (window_consumed > 0) { + uint8_t wu_buf[26]; + int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, (uint32_t)window_consumed); + wu_len += ml_h2_build_window_update(wu_buf + wu_len, 13, 5, (uint32_t)window_consumed); + if (wu_len > 0) noise_send(ml, noise, wu_buf, wu_len); + } + + if (!msg_complete || json_total == 0) { + ESP_LOGW(TAG, "Initial netmap not received (got %d bytes, complete=%d)", + (int)json_total, msg_complete ? 1 : 0); + free(resp_buf); + return -1; + } + + /* Parse the body (skipping the prefix). Even if a fragment of the next + * message is mixed in at the tail, we cut at msg_len so it is harmless. + * The next keepalive normally arrives 60 seconds later, so in practice + * a fragment almost never gets mixed in. */ + char *parse_start = (char *)resp_buf + msg_off; + char saved = parse_start[msg_len]; + parse_start[msg_len] = '\0'; + cJSON *map_json = cJSON_Parse(parse_start); + parse_start[msg_len] = saved; + + if (!map_json) { + const char *err = cJSON_GetErrorPtr(); + ESP_LOGE(TAG, "Initial netmap JSON parse failed near: %.50s", err ? err : "unknown"); + free(resp_buf); + return -1; + } + + if (json_total > msg_off + msg_len) { + ESP_LOGD(TAG, "Initial netmap: %d trailing bytes discarded", + (int)(json_total - msg_off - msg_len)); + } + + /* Ingest self-node info / peers / DERPMap (via the same shared parsers + * as do_fetch_peers — the DERP connection relies on the DERPMap being + * reliably parsed here). */ + parse_self_node_from_map(ml, map_json); + parse_peers_from_map_response(ml, map_json); + parse_derp_map_from_map(ml, map_json); + + cJSON_Delete(map_json); + free(resp_buf); + + int64_t t_map_done = esp_timer_get_time(); + ESP_LOGI(TAG, "[TIMING] Initial netmap recv+parse: %lld ms (%dKB)", + (t_map_done - t_map_start) / 1000, (int)(json_total / 1024)); + + return 0; +} + +/* Send a "lite" endpoint update to the control plane. + * This is a non-streaming MapRequest (Stream=false, OmitPeers=true) that + * only updates our endpoints and hostinfo. Required because Version >= 68 + * means the control plane IGNORES Endpoints in streaming (Stream=true) + * MapRequests. The reference client uses a dedicated "updateRoutine" for this. + * + * Uses H2 stream 7. Response body is discarded (only HTTP status matters). + * Returns 0 on success, -1 on send failure. */ +static int do_send_endpoint_update(microlink_t *ml, ml_noise_state_t *noise) { + if (ml->stun_public_ip == 0 && !ml->stun_has_ipv6) { + ESP_LOGD(TAG, "No STUN endpoints yet, skipping endpoint update"); + return 0; /* Nothing to send */ + } + + cJSON *root = cJSON_CreateObject(); + if (!root) return -1; + + cJSON_AddNumberToObject(root, "Version", ML_CTRL_PROTOCOL_VER); + + char key_hex[65], key_str[80]; + bytes_to_hex(ml->wg_public_key, 32, key_hex); + snprintf(key_str, sizeof(key_str), "nodekey:%s", key_hex); + cJSON_AddStringToObject(root, "NodeKey", key_str); + + bytes_to_hex(ml->disco_public_key, 32, key_hex); + snprintf(key_str, sizeof(key_str), "discokey:%s", key_hex); + cJSON_AddStringToObject(root, "DiscoKey", key_str); + + /* Stream=false so control plane PROCESSES our endpoints (Version >= 68) */ + cJSON_AddBoolToObject(root, "Stream", false); + cJSON_AddBoolToObject(root, "KeepAlive", true); + /* OmitPeers=true — we don't need peers back, just updating our endpoints */ + cJSON_AddBoolToObject(root, "OmitPeers", true); + cJSON_AddStringToObject(root, "Compress", ""); + + /* Hostinfo (required — control plane reads NetInfo from here) */ + cJSON *hostinfo = cJSON_CreateObject(); + if (hostinfo) { + const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); + cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); + cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); + cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); + cJSON_AddItemToObject(root, "Hostinfo", hostinfo); + + cJSON *netinfo = cJSON_CreateObject(); + if (netinfo) { + cJSON_AddNumberToObject(netinfo, "PreferredDERP", ML_DERP_REGION); + if (ml->stun_nat_checked) { + cJSON_AddBoolToObject(netinfo, "MappingVariesByDestIP", ml->nat_mapping_varies); + } + cJSON_AddItemToObject(hostinfo, "NetInfo", netinfo); + } + } + + /* Endpoints + EndpointTypes */ + int ep_count = add_endpoints_to_json(ml, root); + + char *json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json_str) return -1; + + size_t json_len = strlen(json_str); + ESP_LOGI(TAG, "Endpoint update: %d bytes, %d endpoints (Stream=false, OmitPeers=true)", + (int)json_len, ep_count); + + /* Use incrementing odd stream IDs (H2 client-initiated = odd). + * Streams 1, 3, 5 are used by register, fetch peers, and long-poll. + * Start at 7, increment by 2 for each endpoint update. + * Reset to 7 on reconnect (h2_next_stream_id initialized in connect). */ + if (ml->h2_next_stream_id < 7) ml->h2_next_stream_id = 7; + uint32_t sid = ml->h2_next_stream_id; + ml->h2_next_stream_id += 2; + + uint8_t *h2_buf = ml_psram_malloc(json_len + 512); + if (!h2_buf) { free(json_str); return -1; } + + int h2_pos = 0; + int hdr_len = ml_h2_build_headers_frame(h2_buf, json_len + 512, + "POST", "/machine/map", + CTRL_HOST(ml), "application/json", + sid, false); + if (hdr_len < 0) { free(json_str); free(h2_buf); return -1; } + h2_pos += hdr_len; + + int data_len = ml_h2_build_data_frame(h2_buf + h2_pos, json_len + 512 - h2_pos, + (uint8_t *)json_str, json_len, + sid, true); /* END_STREAM */ + free(json_str); + if (data_len < 0) { free(h2_buf); return -1; } + h2_pos += data_len; + + if (noise_send(ml, noise, h2_buf, h2_pos) < 0) { + free(h2_buf); + ESP_LOGE(TAG, "Failed to send endpoint update"); + return -1; + } + free(h2_buf); + + ESP_LOGI(TAG, "Endpoint update sent on H2 stream %lu", (unsigned long)sid); + /* Response body is discarded — server may send empty response or + * we'll consume it in the next poll_map_update() iteration. + * Only the HTTP status code matters (200 = success). */ + return 0; +} + +/* Try to read one incremental MapResponse update (non-blocking) */ +static int poll_map_update(microlink_t *ml, ml_noise_state_t *noise) { +#ifdef CONFIG_ML_CTRL_TLS + /* Decrypted bytes may already sit in the TLS layer with nothing left + * on the socket — select() alone would miss them. */ + if (ml_coord_tls_pending(ml) == 0) +#endif + { + /* Use select() to check if data is available before blocking in recv */ + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(ml->coord_sock, &readfds); + struct timeval tv = { .tv_sec = 0, .tv_usec = 50000 }; /* 50ms */ + int sel = ml_select_fds(ml->coord_sock + 1, &readfds, NULL, NULL, &tv); + if (sel <= 0) return 0; /* No data available or error */ + } + + /* Data available — set short recv timeout for partial frame safety */ + struct timeval tv_recv = { .tv_sec = 2, .tv_usec = 0 }; + ml_setsockopt(ml->coord_sock, SOL_SOCKET, SO_RCVTIMEO, &tv_recv, sizeof(tv_recv)); + + uint8_t *frame_buf = ml_psram_malloc(65536); + if (!frame_buf) return 0; + + int frame_len = noise_recv(ml, noise, frame_buf, 65536); + + if (frame_len <= 0) { + free(frame_buf); + int saved_errno = errno; + /* EAGAIN/EWOULDBLOCK = no data yet = not an error */ + if (saved_errno == EAGAIN || saved_errno == EWOULDBLOCK) return 0; + return frame_len; /* Real error or connection closed */ + } + + /* Extract DATA frame payload from H2 frames, track flow control */ + uint8_t *json_data = NULL; + size_t json_data_len = 0; + uint32_t total_data_bytes = 0; + uint32_t data_stream_id = 0; + int pos = 0; + + while (pos + 9 <= frame_len) { + uint32_t f_len = (frame_buf[pos] << 16) | (frame_buf[pos + 1] << 8) | frame_buf[pos + 2]; + uint8_t f_type = frame_buf[pos + 3]; + uint8_t f_flags = frame_buf[pos + 4]; + uint32_t f_stream = ((frame_buf[pos + 5] & 0x7F) << 24) | (frame_buf[pos + 6] << 16) | + (frame_buf[pos + 7] << 8) | frame_buf[pos + 8]; + pos += 9; + + if (pos + (int)f_len > frame_len) break; + + if (f_type == 0x00) { /* DATA frame */ + total_data_bytes += f_len; + if (f_stream == 5) { + /* Long-poll MapResponse data (stream 5) — parse as JSON */ + data_stream_id = f_stream; + if (f_len > 0) { + json_data = frame_buf + pos; + json_data_len = f_len; + } + } else if (f_len > 0) { + /* Endpoint update response (stream 7+) — discard body */ + ESP_LOGD(TAG, "H2 stream %lu DATA: %lu bytes (discarded)", + (unsigned long)f_stream, (unsigned long)f_len); + } + } else if (f_type == 0x06 && f_len == 8 && !(f_flags & 0x01)) { + /* HTTP/2 PING from server — respond with PONG (same payload, ACK flag) */ + uint8_t pong[17]; + pong[0] = 0x00; pong[1] = 0x00; pong[2] = 0x08; + pong[3] = 0x06; pong[4] = 0x01; + pong[5] = 0x00; pong[6] = 0x00; pong[7] = 0x00; pong[8] = 0x00; + memcpy(pong + 9, frame_buf + pos, 8); + noise_send(ml, noise, pong, sizeof(pong)); + ESP_LOGI(TAG, "Sent HTTP/2 PONG in response to server PING"); + } else if (f_type == 0x04 && !(f_flags & 0x01)) { + /* HTTP/2 SETTINGS from server — respond with SETTINGS ACK */ + uint8_t settings_ack[9] = {0x00, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00}; + noise_send(ml, noise, settings_ack, sizeof(settings_ack)); + } + pos += f_len; + } + + /* Send HTTP/2 WINDOW_UPDATE to replenish flow control after receiving DATA. + * Without this, the server's send window exhausts and the connection stalls. + * Must send for BOTH connection-level (stream 0) AND stream-level. (v1 reference) */ + if (total_data_bytes > 0) { + uint8_t wu_buf[26]; /* 2 WINDOW_UPDATE frames: 13 bytes each */ + /* Connection-level (stream 0) */ + int wu_len = ml_h2_build_window_update(wu_buf, 13, 0, total_data_bytes); + /* Stream-level */ + if (data_stream_id > 0) { + wu_len += ml_h2_build_window_update(wu_buf + wu_len, 13, + data_stream_id, total_data_bytes); + } + noise_send(ml, noise, wu_buf, wu_len); + } + + if (!json_data || json_data_len == 0) { + /* Keepalive, SETTINGS, or PING frame - not an error */ + free(frame_buf); + return 1; /* Got data, reset watchdog */ + } + + /* Skip 4-byte length prefix if present */ + char *parse_start = (char *)json_data; + size_t parse_len = json_data_len; + if (parse_len > 4 && parse_start[4] == '{') { + parse_start += 4; + parse_len -= 4; + } + + char saved = parse_start[parse_len]; + parse_start[parse_len] = '\0'; + + cJSON *update_json = cJSON_Parse(parse_start); + parse_start[parse_len] = saved; + + if (update_json) { + ESP_LOGI(TAG, "Long-poll MapResponse update received"); + + /* Update VPN IP if present */ + cJSON *node = cJSON_GetObjectItem(update_json, "Node"); + if (node) { + cJSON *addresses = cJSON_GetObjectItem(node, "Addresses"); + if (addresses && cJSON_GetArraySize(addresses) > 0) { + const char *addr = cJSON_GetArrayItem(addresses, 0)->valuestring; + if (addr) { + unsigned a, b, c, d; + if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4) { + uint32_t new_ip = (a << 24) | (b << 16) | (c << 8) | d; + if (new_ip != ml->vpn_ip) { + ml->vpn_ip = new_ip; + ESP_LOGI(TAG, "VPN IP updated via long-poll"); + } + } + } + } + } + + /* Parse peer updates */ + parse_peers_from_map_response(ml, update_json); + + /* With streaming_map_fetch, DERPMap updates also arrive on this + * stream (the initial fetch uses the same stream), so ingest them. + * Behavior of the existing path (initial fetch via Stream=false) is + * unchanged. No-op for incremental updates without a DERPMap. */ + if (ml->config.streaming_map_fetch) { + parse_derp_map_from_map(ml, update_json); + } + cJSON_Delete(update_json); + } + + free(frame_buf); + return 1; +} + +/* ============================================================================ + * Coordination Task Main Loop + * ========================================================================== */ + +void ml_coord_task(void *arg) { + microlink_t *ml = (microlink_t *)arg; + ESP_LOGI(TAG, "Coord task started (Core %d)", xPortGetCoreID()); + + coord_state_t state = COORD_IDLE; + ml_coord_cmd_t cmd; + uint64_t last_activity_ms = ml_get_time_ms(); + int reconnect_attempts = 0; + + /* Noise protocol state - owned exclusively by this task */ + ml_noise_state_t noise = {0}; + + /* Wait for WiFi/cellular OR shutdown */ + ESP_LOGI(TAG, "Waiting for WiFi..."); + { + EventBits_t wb = xEventGroupWaitBits(ml->events, + ML_EVT_WIFI_CONNECTED | ML_EVT_SHUTDOWN_REQUEST, + pdFALSE, pdFALSE, portMAX_DELAY); + if (wb & ML_EVT_SHUTDOWN_REQUEST) { + ESP_LOGI(TAG, "Shutdown requested before WiFi, exiting"); + vTaskDelete(NULL); + return; + } + } + + while (!(xEventGroupGetBits(ml->events) & ML_EVT_SHUTDOWN_REQUEST)) { + /* Check for commands (non-blocking) */ + if (xQueueReceive(ml->coord_cmd_queue, &cmd, 0) == pdTRUE) { + switch (cmd) { + case ML_CMD_CONNECT: + if (state == COORD_IDLE) { + state = COORD_STUN_PROBE; + ml->state = ML_STATE_CONNECTING; + } + break; + case ML_CMD_DISCONNECT: + coord_close_conn(ml); + state = COORD_IDLE; + ml->state = ML_STATE_IDLE; + break; + case ML_CMD_FORCE_RECONNECT: + state = COORD_RECONNECTING; + break; + case ML_CMD_UPDATE_ENDPOINTS: + /* TODO: Send endpoint update on existing H2 connection */ + break; + } + } + + /* DERP reconnect is now handled by the DERP I/O task itself. + * The I/O task checks ML_EVT_DERP_RECONNECT directly. */ + + switch (state) { + case COORD_IDLE: + vTaskDelay(pdMS_TO_TICKS(100)); + break; + + case COORD_STUN_PROBE: + ESP_LOGI(TAG, "Running STUN probe..."); + /* Pre-resolve STUN servers (cached, only resolves once) */ + ml_stun_resolve_servers(ml); + /* Reset retry state for fresh probe sequence */ + ml->stun_retry_count = 1; /* First probe counts as attempt 1 */ + ml->stun_using_fallback = false; + ml->stun_last_probe_ms = ml_get_time_ms(); + /* Send first probe to Tailscale STUN primary (IPv4) */ + if (ml->stun_primary_ip) { + ml_stun_send_probe_to(ml, ml->stun_primary_ip, ML_STUN_PRIMARY_PORT); + } else if (ml->stun_fallback_ip) { + ml->stun_using_fallback = true; + ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); + } else { + /* Both DNS failed, try hostname-based as last resort */ + ml_stun_send_probe(ml, ML_STUN_PRIMARY_HOST, ML_STUN_PRIMARY_PORT); + } + /* Also send IPv6 STUN probe if resolved */ + { + static const uint8_t zero16[16] = {0}; + if (memcmp(ml->stun_primary_ip6, zero16, 16) != 0) { + ml_stun_send_probe_ipv6(ml, ml->stun_primary_ip6, ML_STUN_PRIMARY_PORT); + } + } + state = COORD_DNS_RESOLVE; + break; + + case COORD_DNS_RESOLVE: + case COORD_TCP_CONNECT: + if (do_tcp_connect(ml) < 0) { + ESP_LOGE(TAG, "TCP connect failed, retrying..."); + state = COORD_RECONNECTING; + break; + } + ml->h2_next_stream_id = 7; /* Reset H2 stream counter for new connection */ + state = COORD_NOISE_HANDSHAKE; + break; + + case COORD_NOISE_HANDSHAKE: + ml->state = ML_STATE_REGISTERING; + if (do_noise_handshake(ml, &noise) < 0) { + ESP_LOGE(TAG, "Noise handshake failed"); + coord_close_conn(ml); + state = COORD_RECONNECTING; + break; + } + /* Process proactive server frames (EarlyNoise, H2 SETTINGS) + * before sending our H2 preface. Extracts nodeKeyChallenge + * and adjusts rx_nonce. */ + process_proactive_frames(ml, &noise); + state = COORD_H2_PREFACE; + break; + + case COORD_H2_PREFACE: + if (do_h2_preface(ml, &noise) < 0) { + ESP_LOGE(TAG, "H2 preface failed"); + coord_close_conn(ml); + state = COORD_RECONNECTING; + break; + } + state = COORD_REGISTER; + break; + + case COORD_REGISTER: + ESP_LOGI(TAG, "Registering..."); + if (do_register(ml, &noise) < 0) { + ESP_LOGE(TAG, "Registration failed"); + coord_close_conn(ml); + state = COORD_RECONNECTING; + break; + } + state = COORD_FETCH_PEERS; + break; + + case COORD_FETCH_PEERS: + ESP_LOGI(TAG, "Fetching peers..."); + if (ml->config.streaming_map_fetch) { + /* headscale-based controllers such as ZTL do not respond to + * Stream=false MapRequests (60s of zero bytes -> Empty + * MapResponse -> reconnect loop). Like the official client, + * start a single Stream=true / OmitPeers=false stream from + * the beginning and take the full netmap (Peers + DERPMap) + * from the first stream message. The DERP connect request is + * issued after this point, once the DERPMap has been parsed + * (in the reverse order we would try to connect to DERP + * without a DERPMap and fail). */ + if (do_start_long_poll(ml, &noise, false) < 0 || + do_read_initial_netmap(ml, &noise) < 0) { + ESP_LOGW(TAG, "Streaming MapRequest failed, will retry"); + coord_close_conn(ml); + state = COORD_RECONNECTING; + break; + } + } else { + if (do_fetch_peers(ml, &noise) < 0) { + ESP_LOGW(TAG, "MapRequest failed, will retry"); + coord_close_conn(ml); + state = COORD_RECONNECTING; + break; + } + } + + xEventGroupSetBits(ml->events, ML_EVT_COORD_REGISTERED); + + /* Signal DERP I/O task to connect (connection now owned by I/O task) */ + if (!ml->derp.connected) { + xEventGroupSetBits(ml->events, ML_EVT_DERP_CONNECT_REQ); + /* Wait for DERP to connect (up to 15s) before continuing */ + ESP_LOGI(TAG, "Waiting for DERP I/O task to connect..."); + xEventGroupWaitBits(ml->events, ML_EVT_DERP_CONNECTED, + pdFALSE, pdTRUE, pdMS_TO_TICKS(15000)); + } + + /* Start streaming long-poll for incremental updates. + * With streaming_map_fetch, the long-poll on stream 5 was + * already started for the initial fetch, so do not start it a + * second time (reusing the same stream ID would violate the H2 + * protocol). */ + if (!ml->config.streaming_map_fetch) { + if (do_start_long_poll(ml, &noise, true) < 0) { + ESP_LOGW(TAG, "Failed to start long-poll (non-fatal)"); + } + } + + /* Send initial endpoint update if STUN already completed. + * (STUN runs concurrently and may have results by now.) */ + if (ml->stun_public_ip != 0 || ml->stun_has_ipv6) { + do_send_endpoint_update(ml, &noise); + } + + /* Send initial HTTP/2 PING immediately to keep connection active + * before the periodic 5s timer kicks in (server has ~20s idle timeout) */ + { + uint8_t ping_frame[17]; + ping_frame[0] = 0x00; ping_frame[1] = 0x00; ping_frame[2] = 0x08; + ping_frame[3] = 0x06; ping_frame[4] = 0x00; + ping_frame[5] = 0x00; ping_frame[6] = 0x00; + ping_frame[7] = 0x00; ping_frame[8] = 0x00; + memset(ping_frame + 9, 0x42, 8); + noise_send(ml, &noise, ping_frame, sizeof(ping_frame)); + ESP_LOGI(TAG, "Sent initial HTTP/2 PING after long-poll"); + } + + state = COORD_LONG_POLL; + ml->state = ML_STATE_CONNECTED; + reconnect_attempts = 0; + last_activity_ms = ml_get_time_ms(); + + /* Notify app */ + if (ml->state_cb) { + ml->state_cb(ml, ML_STATE_CONNECTED, ml->state_cb_data); + } + break; + + case COORD_LONG_POLL: + { + uint64_t now = ml_get_time_ms(); + + /* Check control plane watchdog (120s) */ + if (now - last_activity_ms > ml->t_ctrl_watchdog_ms) { + ESP_LOGW(TAG, "Control plane watchdog timeout"); + state = COORD_RECONNECTING; + break; + } + + /* Check for STUN response in queue (IPv4 or IPv6) */ + ml_rx_packet_t stun_pkt; + if (xQueueReceive(ml->stun_rx_queue, &stun_pkt, 0) == pdTRUE) { + uint32_t pub_ip; + uint16_t pub_port; + bool parsed = false; + + /* Try IPv4 parse first */ + if (ml_stun_parse_response(stun_pkt.data, stun_pkt.len, + &pub_ip, &pub_port)) { + if (ml->stun_public_ip == 0) { + /* First STUN result (primary server) */ + ml->stun_public_ip = pub_ip; + ml->stun_public_port = pub_port; + ml->stun_retry_count = 0; + /* Send probe to fallback for symmetric NAT check */ + if (!ml->stun_nat_checked && ml->stun_fallback_ip) { + ml_stun_send_probe_to(ml, ml->stun_fallback_ip, + ML_STUN_FALLBACK_PORT); + ESP_LOGI(TAG, "Sent STUN to fallback for NAT type detection"); + } + } else if (!ml->stun_nat_checked) { + /* Second STUN result (fallback) — compare ports */ + ml->stun_secondary_port = pub_port; + ml->stun_nat_checked = true; + if (ml->stun_public_port != pub_port) { + ml->nat_mapping_varies = true; + ESP_LOGW(TAG, "Symmetric NAT detected: port %u vs %u " + "(direct connections unlikely)", + ml->stun_public_port, pub_port); + } else { + ml->nat_mapping_varies = false; + ESP_LOGI(TAG, "Cone NAT: port %u consistent " + "(direct connections possible)", pub_port); + } + } else { + /* Periodic re-probe — update primary result */ + ml->stun_public_ip = pub_ip; + ml->stun_public_port = pub_port; + ml->stun_retry_count = 0; + } + parsed = true; + } + + /* Try IPv6 parse (response may contain IPv6 XOR-MAPPED-ADDRESS) */ + if (!parsed) { + uint8_t ip6[16]; + uint16_t port6; + if (ml_stun_parse_response_ipv6(stun_pkt.data, stun_pkt.len, + ip6, &port6)) { + memcpy(ml->stun_public_ip6, ip6, 16); + ml->stun_public_port6 = port6; + ml->stun_has_ipv6 = true; + /* Use IPv6 port for NAT detection if no IPv4 result yet */ + if (ml->stun_public_port == 0) { + ml->stun_public_port = port6; + } + ml->stun_retry_count = 0; /* Got a result, stop retrying */ + ESP_LOGI(TAG, "STUN IPv6 result obtained, port %u", port6); + /* Trigger NAT type check if not done yet */ + if (!ml->stun_nat_checked && ml->stun_fallback_ip) { + ml_stun_send_probe_to(ml, ml->stun_fallback_ip, + ML_STUN_FALLBACK_PORT); + ESP_LOGI(TAG, "Sent STUN to fallback for NAT type detection"); + } + parsed = true; + } + } + + if (parsed) { + xEventGroupSetBits(ml->events, ML_EVT_STUN_COMPLETE); + /* Send endpoint update to control plane immediately. + * With Version >= 68, Stream=true MapRequests have endpoints + * IGNORED, so we need this separate Stream=false request. */ + do_send_endpoint_update(ml, &noise); + } + free(stun_pkt.data); + } + + /* STUN retry logic: 3 attempts per server, 2s apart, then fallback */ + if (ml->stun_retry_count > 0 && ml->stun_retry_count <= ML_STUN_MAX_RETRIES) { + if (now - ml->stun_last_probe_ms > ML_STUN_RETRY_INTERVAL_MS) { + if (!ml->stun_using_fallback && ml->stun_primary_ip) { + ml_stun_send_probe_to(ml, ml->stun_primary_ip, ML_STUN_PRIMARY_PORT); + } else if (ml->stun_fallback_ip) { + ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); + } + ml->stun_last_probe_ms = now; + ml->stun_retry_count++; + if (ml->stun_retry_count > ML_STUN_MAX_RETRIES && !ml->stun_using_fallback) { + /* Primary exhausted, switch to fallback */ + ESP_LOGW(TAG, "STUN primary failed after %d retries, trying fallback", + ML_STUN_MAX_RETRIES); + ml->stun_using_fallback = true; + ml->stun_retry_count = 1; + if (ml->stun_fallback_ip) { + ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); + ml->stun_last_probe_ms = now; + } + } + } + } + + /* Periodic STUN re-probe (every 23s) — fresh probe sequence */ + static uint64_t last_stun_ms = 0; + if (now - last_stun_ms > ml->t_stun_interval_ms) { + ml->stun_retry_count = 1; + ml->stun_using_fallback = false; + ml->stun_last_probe_ms = now; + /* IPv4 probe */ + if (ml->stun_primary_ip) { + ml_stun_send_probe_to(ml, ml->stun_primary_ip, ML_STUN_PRIMARY_PORT); + } else if (ml->stun_fallback_ip) { + ml->stun_using_fallback = true; + ml_stun_send_probe_to(ml, ml->stun_fallback_ip, ML_STUN_FALLBACK_PORT); + } else { + ml_stun_send_probe(ml, ML_STUN_PRIMARY_HOST, ML_STUN_PRIMARY_PORT); + } + /* IPv6 probe (alongside IPv4) */ + { + static const uint8_t zero16[16] = {0}; + if (memcmp(ml->stun_primary_ip6, zero16, 16) != 0) { + ml_stun_send_probe_ipv6(ml, ml->stun_primary_ip6, ML_STUN_PRIMARY_PORT); + } + } + last_stun_ms = now; + } + + /* Periodic DERP NotePreferred keepalive (every 60s) */ + static uint64_t last_derp_keepalive_ms = 0; + if (ml->derp.connected && now - last_derp_keepalive_ms > 60000) { + uint8_t preferred = 0x01; + uint8_t *ka_data = malloc(1); + if (ka_data) { + *ka_data = preferred; + ml_derp_tx_item_t ka_item = { + .data = ka_data, + .len = 1, + .frame_type = 0x07, /* NotePreferred */ + }; + memset(ka_item.dest_pubkey, 0, 32); + if (xQueueSend(ml->derp_tx_queue, &ka_item, 0) != pdTRUE) { + free(ka_data); + } + } + last_derp_keepalive_ms = now; + } + + /* Key expiry check (every 60s) — re-register if expired */ + if (ml->key_expiry_epoch > 0 || ml->key_expired) { + static uint64_t last_expiry_check_ms = 0; + if (now - last_expiry_check_ms > 60000) { + last_expiry_check_ms = now; + if (ml->key_expired) { + if (ml->config.auth_key) { + ESP_LOGW(TAG, "Key expired, re-registering with auth_key..."); + state = COORD_RECONNECTING; + break; + } else { + ESP_LOGE(TAG, "Key expired but no auth_key — manual re-provisioning needed!"); + } + } + /* Warn 1 hour before expiry (rough uptime-based check) */ + /* Note: key_expiry_epoch is absolute Unix time, we compare with + * approximate boot-relative time. For accurate check we'd need + * SNTP, but this catches the Expired flag from the server. */ + } + } + + /* Send HTTP/2 PING every 5 seconds to keep control plane alive. + * The server has a ~20s idle timeout; PINGs maintain bidirectional + * activity and are what actually keep us "online". (v1 reference) */ + static uint64_t last_h2_ping_ms = 0; + if (now - last_h2_ping_ms >= 5000) { + uint8_t ping_frame[17]; + ping_frame[0] = 0x00; ping_frame[1] = 0x00; ping_frame[2] = 0x08; + ping_frame[3] = 0x06; /* Type: PING */ + ping_frame[4] = 0x00; /* Flags: none */ + ping_frame[5] = 0x00; ping_frame[6] = 0x00; + ping_frame[7] = 0x00; ping_frame[8] = 0x00; /* Stream 0 */ + /* Opaque 8-byte payload (timestamp for identification) */ + uint64_t ping_id = now; + for (int b = 0; b < 8; b++) + ping_frame[9 + b] = (ping_id >> (56 - b * 8)) & 0xFF; + int ping_ret = noise_send(ml, &noise, ping_frame, sizeof(ping_frame)); + if (ping_ret >= 0) { + last_h2_ping_ms = now; + last_activity_ms = now; + } else { + ESP_LOGW(TAG, "H2 PING send failed, reconnecting"); + state = COORD_RECONNECTING; + break; + } + } + + /* Poll for streaming MapResponse updates */ + int poll_ret = poll_map_update(ml, &noise); + if (poll_ret > 0) { + last_activity_ms = now; /* Reset watchdog */ + } else if (poll_ret < 0) { + ESP_LOGW(TAG, "Long-poll connection lost"); + state = COORD_RECONNECTING; + break; + } + + vTaskDelay(pdMS_TO_TICKS(50)); + } + break; + + case COORD_RECONNECTING: + { + uint32_t backoff_ms = 1000 << (reconnect_attempts > 4 ? 4 : reconnect_attempts); + if (backoff_ms > ML_CTRL_BACKOFF_MAX_MS) backoff_ms = ML_CTRL_BACKOFF_MAX_MS; + + ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)", + (unsigned long)backoff_ms, reconnect_attempts + 1); + + ml->state = ML_STATE_RECONNECTING; + + /* Wait on command queue with backoff timeout (interruptible!) */ + ml_coord_cmd_t wake_cmd; + if (xQueueReceive(ml->coord_cmd_queue, &wake_cmd, pdMS_TO_TICKS(backoff_ms)) == pdTRUE) { + if (wake_cmd == ML_CMD_DISCONNECT) { + state = COORD_IDLE; + ml->state = ML_STATE_IDLE; + break; + } + } + + reconnect_attempts++; + + /* Close old connection */ + coord_close_conn(ml); + + /* Reset Noise state for fresh handshake */ + memset(&noise, 0, sizeof(noise)); + + state = COORD_STUN_PROBE; + } + break; + } + } + + /* Cleanup */ + coord_close_conn(ml); + memset(&noise, 0, sizeof(noise)); + + ESP_LOGI(TAG, "Coord task exiting"); + vTaskDelete(NULL); +} From 125b5294056642e73f2cf166fb64e11f91b5b652 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Wed, 22 Jul 2026 14:43:52 +0900 Subject: [PATCH 04/12] Fix DERP connection for custom (non-Tailscale) control planes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes needed to reach a custom control plane's DERP relay, found while bringing up an ESP32 node on a headscale-based deployment: 1. Region fallback: when the server's HomeDERP region is not present in the DERPMap (custom control planes advertise their own region IDs, not Tailscale's region 9), fall back to the first available region in the DERPMap instead of the hardcoded Tailscale default host — otherwise the client dials derp9e.tailscale.com and fails. 2. Robust TLS setup: check the return value of every mbedTLS setup call and abort before mbedtls_ssl_handshake on failure. Previously mbedtls_ssl_setup failing (typically -0x7F00 ALLOC_FAILED under internal RAM pressure) was ignored; it NULLs ssl->conf, so handshake then returned MBEDTLS_ERR_SSL_BAD_INPUT_DATA and the real cause (OOM) was masked as "Bad input parameters". Also add derp_tls_abort() to free the mbedTLS context (and ~20KB TLS buffers) on every failure path — the old code only closed the socket, leaking on each retry and worsening the OOM over time. (With CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC the fix on the app side is CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y to allocate TLS buffers from PSRAM.) 3. Pin DERP TLS to 1.2: on ESP-IDF mbedTLS the TLS 1.3 path cannot parse an ECDSA server certificate's signature-algorithm OID ("X509 - Signature algorithm (oid) is unsupported"), even with authmode=VERIFY_NONE, because TLS 1.3 can't skip certificate processing. DERP relays accept TLS 1.2, so cap the max version at 1.2 to avoid the 1.3 certificate path. --- components/microlink/src/ml_derp.c | 208 ++++++++++++++++++++--------- 1 file changed, 148 insertions(+), 60 deletions(-) diff --git a/components/microlink/src/ml_derp.c b/components/microlink/src/ml_derp.c index c57fd0f..d860347 100644 --- a/components/microlink/src/ml_derp.c +++ b/components/microlink/src/ml_derp.c @@ -38,36 +38,33 @@ static const char *TAG = "ml_derp"; #define DERP_CONNECT_TIMEOUT_MS 10000 /* ============================================================================ - * Custom BIO callbacks for non-blocking TLS I/O + * Custom BIO callbacks for TLS I/O * - * These wrap lwIP recv/send with guaranteed timeout behavior. - * We don't trust mbedtls_net_recv_timeout on lwIP because lwIP's select() - * can sometimes block indefinitely on ESP32. + * These wrap the socket layer (ml_read_sock/ml_write_sock) so that both + * lwIP and AT socket backends work transparently. + * + * recv is provided as a blocking recv (f_recv). With the recv_timeout BIO + * (f_recv_timeout) combined with mbedtls_ssl_conf_read_timeout, the TLS 1.3 + * handshake fails with MBEDTLS_ERR_SSL_BAD_INPUT_DATA (Bad input + * parameters), so we align with the scheme that already works on the + * control plane side (coord_bio_recv in ml_coord_tls.c). Timeouts are + * enforced by SO_RCVTIMEO on the socket; a timeout surfaces as EAGAIN -> + * MBEDTLS_ERR_SSL_WANT_READ. * ========================================================================== */ /** - * Custom recv with timeout for mbedtls BIO. - * Uses SO_RCVTIMEO on the socket as the timeout mechanism (simpler than select). - * Returns bytes read, or MBEDTLS_ERR_SSL_TIMEOUT, MBEDTLS_ERR_SSL_WANT_READ. + * Custom blocking recv for mbedtls BIO (f_recv signature, no timeout arg). + * SO_RCVTIMEO on the socket bounds the wait; a timeout surfaces as + * EAGAIN/EWOULDBLOCK which we map to MBEDTLS_ERR_SSL_WANT_READ. */ -static int ml_derp_bio_recv_timeout(void *ctx, unsigned char *buf, size_t len, - uint32_t timeout) { +static int ml_derp_bio_recv(void *ctx, unsigned char *buf, size_t len) { int fd = *(int *)ctx; if (fd < 0) return MBEDTLS_ERR_NET_INVALID_CONTEXT; - /* Set SO_RCVTIMEO to the requested timeout. - * If timeout is 0 (mbedTLS default = "no timeout"), use 10s as a sane - * default to avoid indefinite blocking on AT sockets. */ - uint32_t effective_timeout = (timeout > 0) ? timeout : DERP_CONNECT_TIMEOUT_MS; - struct timeval tv; - tv.tv_sec = effective_timeout / 1000; - tv.tv_usec = (effective_timeout % 1000) * 1000; - ml_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - int ret = (int)ml_read_sock(fd, buf, len); if (ret < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { - return MBEDTLS_ERR_SSL_TIMEOUT; + return MBEDTLS_ERR_SSL_WANT_READ; } if (errno == EPIPE || errno == ECONNRESET) { return MBEDTLS_ERR_NET_CONN_RESET; @@ -77,6 +74,9 @@ static int ml_derp_bio_recv_timeout(void *ctx, unsigned char *buf, size_t len, } return MBEDTLS_ERR_NET_RECV_FAILED; } + if (ret == 0) { + return MBEDTLS_ERR_NET_CONN_RESET; + } return ret; } @@ -103,6 +103,31 @@ static int ml_derp_bio_send(void *ctx, const unsigned char *buf, size_t len) { return ret; } +/** + * Common cleanup for failure paths in ml_derp_connect() after TLS init. + * + * Previously we only closed the socket and never freed the mbedTLS + * contexts, leaking entropy/ctr_drbg/ssl_conf on every connection failure, + * and — for failures after a successful ssl_setup — the ~20KB TLS I/O + * buffers as well (internal RAM, since CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y). + * Combined with infinite retries, internal RAM shrank monotonically, + * making mbedtls_ssl_setup's ALLOC_FAILED -> handshake's Bad input + * parameters progressively worse on its own. So always free everything. + * + * mbedtls_*_free() zeroizes the structs after freeing, so a later + * ml_derp_disconnect() freeing the same contexts is not a double free. + */ +static void derp_tls_abort(microlink_t *ml, int sock) { + mbedtls_ssl_free(&ml->derp.ssl); + mbedtls_ssl_config_free(&ml->derp.ssl_conf); + mbedtls_ctr_drbg_free(&ml->derp.ctr_drbg); + mbedtls_entropy_free(&ml->derp.entropy); + if (sock >= 0) { + ml_close_sock(sock); + } + ml->derp.sockfd = -1; +} + /* DERP frame types */ #define DERP_FRAME_SERVER_KEY 0x01 #define DERP_FRAME_CLIENT_INFO 0x02 @@ -343,7 +368,8 @@ static void dispatch_derp_frame(microlink_t *ml, uint8_t frame_type, /** * Try to read one DERP frame. - * Uses mbedtls recv_timeout (100ms) so ssl_read never blocks indefinitely. + * SO_RCVTIMEO on the socket bounds each read, so ssl_read never blocks + * indefinitely (timeout surfaces as WANT_READ from the blocking-recv BIO). * Returns: 1 = frame read and dispatched, 0 = timeout (no data), <0 = error */ static int poll_derp_read(microlink_t *ml) { @@ -645,6 +671,9 @@ esp_err_t ml_derp_connect(microlink_t *ml) { * NOT on connection failure (to avoid bouncing between nodes). */ const char *derp_host = ML_DERP_HOST; int derp_port = ML_DERP_PORT; + /* Track the region actually used, for logging (differs from HomeDERP on fallback) */ + uint16_t derp_region_used = ml->derp_home_region ? ml->derp_home_region : ML_DERP_REGION; + bool node_selected = false; if (ml->derp_region_count > 0 && ml->derp_home_region > 0) { for (int i = 0; i < ml->derp_region_count; i++) { @@ -658,6 +687,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { if (ml->derp_regions[i].nodes[attempt].derp_port > 0) { derp_port = ml->derp_regions[i].nodes[attempt].derp_port; } + node_selected = true; break; } } @@ -666,10 +696,39 @@ esp_err_t ml_derp_connect(microlink_t *ml) { } } + /* Fallback for when HomeDERP is not present in the DERPMap. + * With headscale-based controllers (e.g. ZTL), HomeDERP may still point + * at an official Tailscale region (e.g. 9) while the DERPMap only + * contains their own region (e.g. 900). Falling back to the default + * ML_DERP_HOST (official) in that case is a dead end — neither auth nor + * TLS succeeds against the official DERP — so instead pick the first + * usable node (not avoid, not stun-only, has a hostname) from the + * DERPMap the controller handed out. In the normal official-Tailscale + * case, HomeDERP is in the DERPMap so node_selected is set and this + * branch is never taken; behavior is unchanged. */ + if (!node_selected && ml->derp_region_count > 0) { + for (int i = 0; i < ml->derp_region_count && !node_selected; i++) { + ml_derp_region_t *r = &ml->derp_regions[i]; + if (r->avoid) continue; + for (int j = 0; j < r->node_count; j++) { + if (!r->nodes[j].stun_only && r->nodes[j].hostname[0]) { + derp_host = r->nodes[j].hostname; + derp_port = (r->nodes[j].derp_port > 0) ? r->nodes[j].derp_port + : ML_DERP_PORT; + derp_region_used = r->region_id; + node_selected = true; + ESP_LOGW(TAG, "Home DERP region %d not in DERPMap, falling back to region %d (%s)", + ml->derp_home_region, derp_region_used, derp_host); + break; + } + } + } + } + int64_t t_derp_start = esp_timer_get_time(); ESP_LOGI(TAG, "Connecting to DERP %s:%d (region %d)", - derp_host, derp_port, ml->derp_home_region ? ml->derp_home_region : ML_DERP_REGION); + derp_host, derp_port, derp_region_used); /* DNS resolve — accept IPv4 or IPv6 (carrier may be IPv6-only) */ struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM }; @@ -714,26 +773,67 @@ esp_err_t ml_derp_connect(microlink_t *ml) { mbedtls_entropy_init(&ml->derp.entropy); mbedtls_ctr_drbg_init(&ml->derp.ctr_drbg); - mbedtls_ctr_drbg_seed(&ml->derp.ctr_drbg, mbedtls_entropy_func, + /* Always check the return values of the mbedTLS setup functions and + * abort immediately on failure instead of proceeding to the handshake. + * On failure (typically ALLOC_FAILED), mbedtls_ssl_setup resets + * ssl->conf to NULL in its error label, so ignoring the error and + * proceeding to the handshake trips the sanity check with + * MBEDTLS_ERR_SSL_BAD_INPUT_DATA — misreported as "Bad input + * parameters" when the real cause is out-of-memory. */ + int cfg_ret; + cfg_ret = mbedtls_ctr_drbg_seed(&ml->derp.ctr_drbg, mbedtls_entropy_func, &ml->derp.entropy, NULL, 0); + if (cfg_ret != 0) { + ESP_LOGE(TAG, "ctr_drbg_seed failed: -0x%04x", -cfg_ret); + derp_tls_abort(ml, sock); + return ESP_FAIL; + } - mbedtls_ssl_config_defaults(&ml->derp.ssl_conf, + cfg_ret = mbedtls_ssl_config_defaults(&ml->derp.ssl_conf, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT); + if (cfg_ret != 0) { + ESP_LOGE(TAG, "ssl_config_defaults failed: -0x%04x", -cfg_ret); + derp_tls_abort(ml, sock); + return ESP_FAIL; + } mbedtls_ssl_conf_authmode(&ml->derp.ssl_conf, MBEDTLS_SSL_VERIFY_NONE); mbedtls_ssl_conf_rng(&ml->derp.ssl_conf, mbedtls_ctr_drbg_random, &ml->derp.ctr_drbg); - mbedtls_ssl_conf_read_timeout(&ml->derp.ssl_conf, DERP_CONNECT_TIMEOUT_MS); - - mbedtls_ssl_setup(&ml->derp.ssl, &ml->derp.ssl_conf); - mbedtls_ssl_set_hostname(&ml->derp.ssl, derp_host); + /* Pin the DERP connection to TLS 1.2. When the DERP relay uses a + * Let's Encrypt ECDSA certificate, the ESP-IDF mbedTLS TLS 1.3 path + * cannot interpret the certificate's signature algorithm OID and fails + * with "X509 - Signature algorithm (oid) is unsupported" (even with + * authmode=VERIFY_NONE, TLS 1.3 cannot skip certificate processing). + * DERP also accepts TLS 1.2, so pin to 1.2 to bypass the 1.3 + * certificate path. */ + mbedtls_ssl_conf_max_tls_version(&ml->derp.ssl_conf, MBEDTLS_SSL_VERSION_TLS1_2); + + cfg_ret = mbedtls_ssl_setup(&ml->derp.ssl, &ml->derp.ssl_conf); + if (cfg_ret != 0) { + /* -0x7F00 (ALLOC_FAILED) means internal RAM exhaustion. To allocate + * the ~20KB TLS I/O buffers from PSRAM instead, set + * CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y. */ + ESP_LOGE(TAG, "mbedtls_ssl_setup failed: -0x%04x", -cfg_ret); + derp_tls_abort(ml, sock); + return ESP_FAIL; + } + cfg_ret = mbedtls_ssl_set_hostname(&ml->derp.ssl, derp_host); + if (cfg_ret != 0) { + ESP_LOGE(TAG, "ssl_set_hostname failed: -0x%04x", -cfg_ret); + derp_tls_abort(ml, sock); + return ESP_FAIL; + } /* Store socket fd BEFORE setting bio. * Use custom BIO callbacks that route through ml_read_sock/ml_write_sock, * which transparently support both lwIP and AT socket backends. * Timeout is handled via SO_RCVTIMEO. */ ml->derp.sockfd = sock; + /* Pass a blocking recv as f_recv and set f_recv_timeout to NULL + * (same scheme as the control plane; avoids the TLS 1.3 Bad input + * data failure). */ mbedtls_ssl_set_bio(&ml->derp.ssl, &ml->derp.sockfd, - ml_derp_bio_send, NULL, ml_derp_bio_recv_timeout); + ml_derp_bio_send, ml_derp_bio_recv, NULL); /* TLS handshake - socket has 10s SO_RCVTIMEO from connect phase. */ int ret; @@ -744,8 +844,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { char err_buf[128]; mbedtls_strerror(ret, err_buf, sizeof(err_buf)); ESP_LOGE(TAG, "TLS handshake failed: %s", err_buf); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -766,8 +865,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { ret = mbedtls_ssl_write(&ml->derp.ssl, (const uint8_t *)upgrade_req, strlen(upgrade_req)); if (ret < 0) { ESP_LOGE(TAG, "Failed to send HTTP upgrade"); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -782,8 +880,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { while (resp_len < (int)sizeof(resp_buf) - 1) { if (ml_get_time_ms() - http_start > DERP_CONNECT_TIMEOUT_MS) { ESP_LOGE(TAG, "HTTP upgrade response timeout"); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -795,14 +892,12 @@ esp_err_t ml_derp_connect(microlink_t *ml) { continue; } ESP_LOGE(TAG, "HTTP upgrade read failed: -0x%04x", -ret); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } if (ret == 0) { ESP_LOGE(TAG, "Connection closed during HTTP upgrade"); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } resp_len++; @@ -820,8 +915,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { if (!found_end || strstr((char *)resp_buf, "101") == NULL) { ESP_LOGE(TAG, "DERP upgrade rejected: %.100s", resp_buf); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } ESP_LOGI(TAG, "HTTP 101 Switching Protocols received"); @@ -851,16 +945,14 @@ esp_err_t ml_derp_connect(microlink_t *ml) { esp_err_t err = derp_recv_frame_header(ml, &frame_type, &frame_len, DERP_CONNECT_TIMEOUT_MS); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to read ServerKey frame header (err=%d)", err); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } if (frame_type != DERP_FRAME_SERVER_KEY || frame_len < 40) { ESP_LOGE(TAG, "Expected ServerKey frame (0x01), got 0x%02x len=%lu", frame_type, (unsigned long)frame_len); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -869,8 +961,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { static const uint8_t DERP_MAGIC[8] = {0x44, 0x45, 0x52, 0x50, 0xf0, 0x9f, 0x94, 0x91}; if (derp_tls_read_all(ml, magic, 8, DERP_CONNECT_TIMEOUT_MS) < 0) { ESP_LOGE(TAG, "Failed to read ServerKey magic"); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -878,8 +969,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { ESP_LOGE(TAG, "Invalid DERP magic: %02x%02x%02x%02x%02x%02x%02x%02x", magic[0], magic[1], magic[2], magic[3], magic[4], magic[5], magic[6], magic[7]); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } ESP_LOGI(TAG, "DERP magic verified"); @@ -888,8 +978,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { uint8_t derp_server_key[32]; if (derp_tls_read_all(ml, derp_server_key, 32, DERP_CONNECT_TIMEOUT_MS) < 0) { ESP_LOGE(TAG, "Failed to read server key"); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -922,8 +1011,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { size_t ciphertext_len = json_len + NACL_BOX_MACBYTES; uint8_t *ciphertext = malloc(ciphertext_len); if (!ciphertext) { - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -935,8 +1023,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { ) != 0) { ESP_LOGE(TAG, "NaCl box encrypt failed"); free(ciphertext); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -945,8 +1032,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { uint8_t *ci_payload = malloc(ci_payload_len); if (!ci_payload) { free(ciphertext); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } @@ -965,8 +1051,7 @@ esp_err_t ml_derp_connect(microlink_t *ml) { if (derp_write_frame(ml, DERP_FRAME_CLIENT_INFO, ci_payload, ci_payload_len) < 0) { ESP_LOGE(TAG, "Failed to send ClientInfo"); free(ci_payload); - ml_close_sock(sock); - ml->derp.sockfd = -1; + derp_tls_abort(ml, sock); return ESP_FAIL; } free(ci_payload); @@ -999,11 +1084,14 @@ esp_err_t ml_derp_connect(microlink_t *ml) { } /* Switch socket to short timeout for data phase. - * Long timeout was needed for TLS handshake, but polling must be fast. */ + * Long timeout was needed for TLS handshake, but polling must be fast. + * conf_read_timeout is deliberately not called: recv uses the f_recv + * (blocking) scheme, so only SO_RCVTIMEO takes effect (with + * f_recv_timeout unused, conf_read_timeout would be a no-op; same + * policy as the TLS 1.3 Bad input data workaround). */ { struct timeval tv = { .tv_sec = 0, .tv_usec = 200000 }; /* 200ms */ ml_setsockopt(ml->derp.sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - mbedtls_ssl_conf_read_timeout(&ml->derp.ssl_conf, 200); } ml->derp.connected = true; From 5c8d60c3591ef559f684808a434a0d29e81ca2b0 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Wed, 22 Jul 2026 16:11:59 +0900 Subject: [PATCH 05/12] Fix crash: deliver decrypted RX packets via netif->input, not ip_input wireguardif_process_data_message called ip_input() directly from the WireGuard rx task. When the inner packet is TCP, this ran tcp_input -> tcp_output on the rx task concurrently with app-side socket operations on the TCPIP thread, corrupting the PCB's unacked segment list and crashing in tcp_output (LoadProhibited, NULL useg) whenever a TCP response arrived over the tunnel. Deliver through netif->input instead (hosts set this to tcpip_input) so all TCP processing is serialized on the TCPIP thread. Fall back to ip_input when netif->input is unset. Free the pbuf if input() fails. --- .../components/wireguard_lwip/src/wireguardif.c | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/components/microlink/components/wireguard_lwip/src/wireguardif.c b/components/microlink/components/wireguard_lwip/src/wireguardif.c index 6627fd0..7597ce4 100644 --- a/components/microlink/components/wireguard_lwip/src/wireguardif.c +++ b/components/microlink/components/wireguard_lwip/src/wireguardif.c @@ -537,10 +537,21 @@ static void wireguardif_process_data_message(struct wireguard_device *device, st // 5. If the plaintext packet has not been dropped, it is inserted into the receive queue of the wg0 interface. if (dest_ok) { - // Send packet to be processed by LWIP + // Deliver through netif->input (the host sets this to + // tcpip_input) so TCP runs on the TCPIP thread, serialized + // with app-side socket operations. Calling ip_input() + // directly here processes TCP on the WireGuard rx task + // concurrently with app sockets and corrupts lwIP PCB state + // (the unacked segment list), which crashes in tcp_output. WG_DEBUG("[WG_RX_IP] Passing %u bytes to IP layer\n", (unsigned)pbuf->tot_len); - ip_input(pbuf, device->netif); - // pbuf is owned by IP layer now + if (device->netif->input != NULL) { + if (device->netif->input(pbuf, device->netif) != ERR_OK) { + pbuf_free(pbuf); + } + } else { + ip_input(pbuf, device->netif); + } + // pbuf is owned by the IP/TCPIP layer now pbuf = NULL; } else { WG_DEBUG("[WG_RX_IP] DROPPED: dest_ok=false\n"); From fcdc8d958f31343b9315edcebe218dd5556bcbe0 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Thu, 23 Jul 2026 19:08:49 +0900 Subject: [PATCH 06/12] Report OS as "esp32" in Hostinfo instead of "linux" The device is an ESP32 running ESP-IDF, not Linux. Admin dashboards display this value as the node's OS, so report it honestly. --- components/microlink/src/ml_coord.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index 41657d4..5a19ec4 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -778,7 +778,7 @@ static int do_register(microlink_t *ml, ml_noise_state_t *noise) { cJSON *hostinfo = cJSON_CreateObject(); const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OS", "esp32"); cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); @@ -1569,7 +1569,7 @@ static int do_fetch_peers(microlink_t *ml, ml_noise_state_t *noise) { cJSON *hostinfo = cJSON_CreateObject(); const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OS", "esp32"); cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); cJSON_AddItemToObject(root, "Hostinfo", hostinfo); @@ -1896,7 +1896,7 @@ static int do_start_long_poll(microlink_t *ml, ml_noise_state_t *noise, bool omi if (hostinfo) { const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OS", "esp32"); cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); cJSON_AddItemToObject(root, "Hostinfo", hostinfo); @@ -2216,7 +2216,7 @@ static int do_send_endpoint_update(microlink_t *ml, ml_noise_state_t *noise) { if (hostinfo) { const char *dev_name = (ml->config.device_name && ml->config.device_name[0]) ? ml->config.device_name : microlink_default_device_name(); cJSON_AddStringToObject(hostinfo, "Hostname", dev_name); - cJSON_AddStringToObject(hostinfo, "OS", "linux"); + cJSON_AddStringToObject(hostinfo, "OS", "esp32"); cJSON_AddStringToObject(hostinfo, "OSVersion", "ESP-IDF"); cJSON_AddStringToObject(hostinfo, "GoArch", "arm"); cJSON_AddItemToObject(root, "Hostinfo", hostinfo); From ae3d438676f7e29649db7e6c104dff7d5b5c38a5 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Fri, 24 Jul 2026 00:15:12 +0900 Subject: [PATCH 07/12] Report auth rejection via new ML_STATE_AUTH_FAILED state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control plane reports auth problems in-band (RegisterResponse Error / MachineAuthorized=false), but do_register never checked them: a rejected registration looked like success and the client spun in the reconnect loop with no way for the host app to tell the user. Headless devices have no UI to notice this — hosts need a hook to show their equivalent of a re-login prompt. - do_register returns ML_REG_AUTH_FAILED on in-band rejection - The coordinator raises ML_STATE_AUTH_FAILED through the state callback and jumps to maximum backoff (the loop keeps running in case the key is re-enabled server-side) - The key-expired-without-auth-key path raises the same state --- components/microlink/include/microlink.h | 6 +++ components/microlink/src/ml_coord.c | 50 +++++++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/components/microlink/include/microlink.h b/components/microlink/include/microlink.h index c35a277..ed586c3 100644 --- a/components/microlink/include/microlink.h +++ b/components/microlink/include/microlink.h @@ -84,6 +84,12 @@ typedef enum { ML_STATE_CONNECTED, ML_STATE_RECONNECTING, ML_STATE_ERROR, + /* Registration was rejected by the control plane (expired/revoked auth + * key, or the node awaits authorization). The stack keeps retrying at + * maximum backoff, but recovery normally needs a new auth key — hosts + * should surface this to the user as their equivalent of a "re-login" + * prompt (headless devices have no UI to notice it otherwise). */ + ML_STATE_AUTH_FAILED, } microlink_state_t; /* Callback types */ diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index 5a19ec4..c51ae3b 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -751,6 +751,9 @@ static int do_h2_preface(microlink_t *ml, ml_noise_state_t *noise) { * State: REGISTER - Send RegisterRequest, parse RegisterResponse * ========================================================================== */ +/* do_register: the control plane rejected our auth (vs. -1 = I/O error) */ +#define ML_REG_AUTH_FAILED (-2) + static int do_register(microlink_t *ml, ml_noise_state_t *noise) { int64_t t_reg_start = esp_timer_get_time(); @@ -1013,6 +1016,25 @@ static int do_register(microlink_t *ml, ml_noise_state_t *noise) { parse_start[parse_len] = saved; free(resp_buf); + /* The control plane reports auth problems in-band: a non-empty Error or + * MachineAuthorized=false means the auth key was rejected (expired, + * revoked, or pending approval). Without this check a rejected + * registration looks like success and the client spins in the reconnect + * loop with no way for the host app to tell the user. Return + * ML_REG_AUTH_FAILED so the coordinator can raise ML_STATE_AUTH_FAILED. */ + cJSON *reg_err = cJSON_GetObjectItem(resp_json, "Error"); + if (reg_err && cJSON_IsString(reg_err) && reg_err->valuestring[0]) { + ESP_LOGE(TAG, "Registration rejected by control plane: %s", reg_err->valuestring); + cJSON_Delete(resp_json); + return ML_REG_AUTH_FAILED; + } + cJSON *authorized = cJSON_GetObjectItem(resp_json, "MachineAuthorized"); + if (authorized && cJSON_IsFalse(authorized)) { + ESP_LOGE(TAG, "Registration not authorized by control plane"); + cJSON_Delete(resp_json); + return ML_REG_AUTH_FAILED; + } + /* Extract our VPN IP from Node.Addresses */ cJSON *node = cJSON_GetObjectItem(resp_json, "Node"); if (node) { @@ -2565,11 +2587,25 @@ void ml_coord_task(void *arg) { case COORD_REGISTER: ESP_LOGI(TAG, "Registering..."); - if (do_register(ml, &noise) < 0) { - ESP_LOGE(TAG, "Registration failed"); - coord_close_conn(ml); - state = COORD_RECONNECTING; - break; + { + int reg_rc = do_register(ml, &noise); + if (reg_rc < 0) { + ESP_LOGE(TAG, "Registration failed"); + if (reg_rc == ML_REG_AUTH_FAILED) { + /* Not transient: retrying with the same key will keep + * failing. Notify the host (its "re-login" prompt) and + * jump to maximum backoff — the loop keeps running in + * case the key is re-enabled server-side. */ + ml->state = ML_STATE_AUTH_FAILED; + if (ml->state_cb) { + ml->state_cb(ml, ML_STATE_AUTH_FAILED, ml->state_cb_data); + } + reconnect_attempts = 5; + } + coord_close_conn(ml); + state = COORD_RECONNECTING; + break; + } } state = COORD_FETCH_PEERS; break; @@ -2825,6 +2861,10 @@ void ml_coord_task(void *arg) { break; } else { ESP_LOGE(TAG, "Key expired but no auth_key — manual re-provisioning needed!"); + ml->state = ML_STATE_AUTH_FAILED; + if (ml->state_cb) { + ml->state_cb(ml, ML_STATE_AUTH_FAILED, ml->state_cb_data); + } } } /* Warn 1 hour before expiry (rough uptime-based check) */ From 8367c1edd5a036abf4dd6ce77bdf8e341850d305 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Tue, 28 Jul 2026 10:46:30 +0900 Subject: [PATCH 08/12] Fire state callback on ML_STATE_RECONNECTING transition The reconnect loop set ml->state = ML_STATE_RECONNECTING without invoking the state callback, unlike ML_STATE_CONNECTED and ML_STATE_AUTH_FAILED. Host apps relying on the callback for status display kept showing "connected" through a network outage (found in a live Wi-Fi outage test: the device UI stayed on the connected status while ml_coord was cycling through reconnect attempts). Fire the callback once on the transition into RECONNECTING; the loop re-enters this state on every backoff cycle, so guard on the previous state to avoid repeated callbacks. --- components/microlink/src/ml_coord.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index c51ae3b..5db37fa 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -2922,7 +2922,15 @@ void ml_coord_task(void *arg) { ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)", (unsigned long)backoff_ms, reconnect_attempts + 1); - ml->state = ML_STATE_RECONNECTING; + /* CONNECTED や AUTH_FAILED と同様にホストへ通知する (画面等の + * 状態表示が接続中のまま残るのを防ぐ)。バックオフごとに再入する + * ため、遷移した最初の1回だけ発火する */ + if (ml->state != ML_STATE_RECONNECTING) { + ml->state = ML_STATE_RECONNECTING; + if (ml->state_cb) { + ml->state_cb(ml, ML_STATE_RECONNECTING, ml->state_cb_data); + } + } /* Wait on command queue with backoff timeout (interruptible!) */ ml_coord_cmd_t wake_cmd; From 9ef10bbbb4ebe7615e820fa539b0d7af37845c2e Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Tue, 28 Jul 2026 11:01:58 +0900 Subject: [PATCH 09/12] Do not downgrade AUTH_FAILED to RECONNECTING in the reconnect loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit fired the RECONNECTING callback whenever the loop entered the reconnect state, which also fired right after a registration rejection had raised AUTH_FAILED — clobbering the host's "re-login" display with a generic "reconnecting" one (observed live: dashboard device deletion -> "authkey expired" rejection -> screen stuck on "reconnecting" instead of the re-auth prompt). AUTH_FAILED means human action is required; keep it sticky while the loop retries at maximum backoff. A successful reconnect still moves the state forward via the CONNECTED callback. --- components/microlink/src/ml_coord.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index 5db37fa..166d36d 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -2924,8 +2924,11 @@ void ml_coord_task(void *arg) { /* CONNECTED や AUTH_FAILED と同様にホストへ通知する (画面等の * 状態表示が接続中のまま残るのを防ぐ)。バックオフごとに再入する - * ため、遷移した最初の1回だけ発火する */ - if (ml->state != ML_STATE_RECONNECTING) { + * ため遷移した最初の1回だけ発火し、AUTH_FAILED 中は上書きしない + * (キー失効は「人の操作が必要」な状態で、再試行のたびに + * RECONNECTING へ落とすとホストの再ログイン表示が消えてしまう) */ + if (ml->state != ML_STATE_RECONNECTING && + ml->state != ML_STATE_AUTH_FAILED) { ml->state = ML_STATE_RECONNECTING; if (ml->state_cb) { ml->state_cb(ml, ML_STATE_RECONNECTING, ml->state_cb_data); From 2515d565d46abe1f38babd952e4a52c53e29123e Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Tue, 28 Jul 2026 15:41:15 +0900 Subject: [PATCH 10/12] Translate reconnect-notification comment to English The codebase's comments are English; keep the fork consistent (and upstreamable via PR #22). --- components/microlink/src/ml_coord.c | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/components/microlink/src/ml_coord.c b/components/microlink/src/ml_coord.c index 166d36d..aec64f3 100644 --- a/components/microlink/src/ml_coord.c +++ b/components/microlink/src/ml_coord.c @@ -2922,11 +2922,13 @@ void ml_coord_task(void *arg) { ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)", (unsigned long)backoff_ms, reconnect_attempts + 1); - /* CONNECTED や AUTH_FAILED と同様にホストへ通知する (画面等の - * 状態表示が接続中のまま残るのを防ぐ)。バックオフごとに再入する - * ため遷移した最初の1回だけ発火し、AUTH_FAILED 中は上書きしない - * (キー失効は「人の操作が必要」な状態で、再試行のたびに - * RECONNECTING へ落とすとホストの再ログイン表示が消えてしまう) */ + /* Notify the host like CONNECTED and AUTH_FAILED do, so a + * status display does not keep showing "connected" through an + * outage. The loop re-enters this state on every backoff + * cycle, so fire only on the transition — and never downgrade + * AUTH_FAILED, which requires human action: overwriting it + * with RECONNECTING on each retry would clear the host's + * re-login prompt. */ if (ml->state != ML_STATE_RECONNECTING && ml->state != ML_STATE_AUTH_FAILED) { ml->state = ML_STATE_RECONNECTING; From 6a4447c5a5a4b2bbce459953dc3e134116b16209 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Wed, 29 Jul 2026 16:05:55 +0900 Subject: [PATCH 11/12] Use wall-clock time for the WireGuard handshake timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wireguard_tai64n_now() built the TAI64N timestamp from esp_timer_get_time(), which is microseconds since boot, not wall-clock time. A peer stores the greatest timestamp it has seen from us and rejects any handshake initiation whose timestamp is not greater (replay protection, WireGuard spec 5.1). With uptime, the counter restarts near zero on every reboot, so the peer answers with "handshake replay" and drops the initiation. A device that has been up for hours therefore cannot reconnect after a reboot until its uptime passes the previous session's — in practice, never. Observed on an ESP32-S3 talking to a userspace peer: wg: ConsumeMessageInitiation: handshake replay @ 1970-01-01 09:04:41 wg: Received invalid initiation message from 8f9b9e9a... DISCO probing succeeded the whole time, so the failure looked like a routing problem rather than a rejected handshake. Use gettimeofday() instead. The system clock is expected to be set before connecting; the ztl component already syncs it over SNTP for this reason. Also drop the periodic "[TAI64N] uptime=..." printf, which was debug scaffolding and prints to the console every five seconds. --- .../src/wireguard-platform-esp32.c | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c b/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c index f974c67..5b8ef58 100644 --- a/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c +++ b/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c @@ -8,6 +8,7 @@ #include "esp_timer.h" #include "lwip/sys.h" #include +#include /* ============================================================================ * Time Functions @@ -19,18 +20,19 @@ uint32_t wireguard_sys_now() { } void wireguard_tai64n_now(uint8_t *output) { - // TAI64N format: 8 bytes seconds + 4 bytes nanoseconds - // For simplicity, use Unix epoch time - uint64_t now_us = esp_timer_get_time(); - uint64_t seconds = now_us / 1000000ULL; - uint32_t nanoseconds = (now_us % 1000000ULL) * 1000; - - // Log raw uptime before TAI offset (only every ~5s to avoid spam) - static uint64_t last_log_s = 0; - if (seconds - last_log_s >= 5) { - printf("[TAI64N] uptime=%llu s, nano=%lu\n", (unsigned long long)seconds, (unsigned long)nanoseconds); - last_log_s = seconds; - } + // TAI64N format: 8 bytes seconds + 4 bytes nanoseconds. + // + // This must be wall-clock time, not uptime. A peer keeps the greatest + // timestamp it has seen from us and rejects any handshake initiation whose + // timestamp is not greater (replay protection, WireGuard spec 5.1). With + // uptime, every reboot restarts the counter near zero, so the peer rejects + // us with "handshake replay" until our uptime passes the previous session's + // — a device that ran for hours cannot reconnect at all after a reboot. + // The system clock is expected to be set (e.g. by SNTP) before connecting. + struct timeval tv; + gettimeofday(&tv, NULL); + uint64_t seconds = (uint64_t)tv.tv_sec; + uint32_t nanoseconds = (uint32_t)tv.tv_usec * 1000; // TAI64 starts at 1970-01-01 00:00:10 TAI (Unix epoch + 10 seconds) // Add TAI offset: 2^62 + Unix time From a8d769e3ed7cf9de71e136bcfbba21909823f56f Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Wed, 29 Jul 2026 16:09:37 +0900 Subject: [PATCH 12/12] Drop the now-unused esp_timer.h include wireguard_tai64n_now() was the only user of esp_timer in this file. --- .../components/wireguard_lwip/src/wireguard-platform-esp32.c | 1 - 1 file changed, 1 deletion(-) diff --git a/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c b/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c index 5b8ef58..4a6e32b 100644 --- a/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c +++ b/components/microlink/components/wireguard_lwip/src/wireguard-platform-esp32.c @@ -5,7 +5,6 @@ #include "wireguard-platform.h" #include "esp_random.h" -#include "esp_timer.h" #include "lwip/sys.h" #include #include