-
Notifications
You must be signed in to change notification settings - Fork 1
fix: remote connectivity — USB tether TX, bus-state failover, WireGuard re-establishment, coord deadline #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
de335e8
1b5fe82
3a8a527
f9ec4cb
9823785
0440ad2
5b86d21
e020c86
cae7e94
54149eb
d7a335f
8cf5104
0625bc7
dc2c6cc
07e5d05
99f5708
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| // SPDX-FileCopyrightText: 2026 Polymath Robotics | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /* Wall-clock budget for finishing ONE partial coordination (Noise) frame — | ||
| * pure, host-testable (host/test_coord_frame_budget.c). Used by | ||
| * ml_coord.c coord_recv_ex(). | ||
| * | ||
| * Issue #127: the old bound was a retry COUNT (300 x 10 ms, "~3 s"), but each | ||
| * retry first blocked for the socket's SO_RCVTIMEO — 2 s in long-poll, 60 s | ||
| * while fetching the MapResponse — so a stalled-but-open peer held the coord | ||
| * task for 300 x 2 s = 10 min, starving the ML_CTRL_WATCHDOG_MS (120 s) check | ||
| * that only runs between reads. | ||
| * | ||
| * Two limits, both counted from the FIRST byte of the frame: | ||
| * - NO-PROGRESS window (10 s): every byte received restarts it. A stalled | ||
| * peer (TCP black hole, dead relay) is abandoned 10 s after its last byte — | ||
| * the #127 case — while a slow-but-live link keeps its frame as long as | ||
| * bytes keep arriving. Bytes arriving is exactly the liveness the control | ||
| * watchdog wants to see, so extending on progress does not hide a dead | ||
| * control plane from it. | ||
| * - HARD CAP scaled by frame size: max(10 s, frame_len / 1 KiB/s). Control | ||
| * frames (<= 4 KB) get the 10 s floor; a full 64 KB MapResponse frame gets | ||
| * 64 s, i.e. a link must sustain ~8 kbit/s to keep it. Below that the | ||
| * 5 Hz safety heartbeat is marginal anyway, and the cap bounds how long a | ||
| * trickling peer can hold the coord task (previously unbounded). | ||
| * - Not armed (no byte consumed yet) = an idle stream, not a partial frame: | ||
| * the caller keeps using the socket timeout and may simply retry later. | ||
| * - Once armed, one blocking wait may last at most the time to the nearer of | ||
| * the two deadlines — never the socket's own timeout. | ||
| */ | ||
|
|
||
| #pragma once | ||
|
|
||
| #include <stdbool.h> | ||
| #include <stddef.h> | ||
| #include <stdint.h> | ||
|
|
||
| #define ML_COORD_FRAME_NO_PROGRESS_MS 10000u /* abandon after this long without a byte */ | ||
| #define ML_COORD_FRAME_MIN_BUDGET_MS 10000u /* hard-cap floor (control frames <= 4 KB) */ | ||
| #define ML_COORD_FRAME_MIN_RATE_BPS 1024u /* bytes/s a live link must sustain for the hard cap */ | ||
|
|
||
| typedef struct | ||
| { | ||
| bool armed; | ||
| int64_t total_us; /* hard cap length for this frame (from frame_len) */ | ||
| int64_t first_byte_us; /* when the frame started (arming instant) */ | ||
| int64_t hard_deadline_us; /* first byte + total_us */ | ||
| int64_t progress_deadline_us; /* last byte + NO_PROGRESS */ | ||
| } ml_coord_frame_budget_t; | ||
|
|
||
| /* Hard cap for a frame of frame_len bytes, in ms. */ | ||
| static inline uint32_t ml_coord_frame_budget_total_ms(size_t frame_len) | ||
| { | ||
| uint64_t by_size = ((uint64_t)frame_len * 1000u) / ML_COORD_FRAME_MIN_RATE_BPS; | ||
| return (by_size > ML_COORD_FRAME_MIN_BUDGET_MS) ? (uint32_t)by_size : ML_COORD_FRAME_MIN_BUDGET_MS; | ||
| } | ||
|
|
||
| /* frame_len = total bytes this read must complete (the caller's len). */ | ||
| static inline void ml_coord_frame_budget_init(ml_coord_frame_budget_t * b, size_t frame_len) | ||
| { | ||
| b->armed = false; | ||
| b->total_us = (int64_t)ml_coord_frame_budget_total_ms(frame_len) * 1000LL; | ||
| b->first_byte_us = 0; | ||
| b->hard_deadline_us = 0; | ||
| b->progress_deadline_us = 0; | ||
| } | ||
|
|
||
| /* A frame's length is often known only after its header: re-size the hard cap | ||
| * for the WHOLE frame (header + payload) while keeping the first-byte instant, | ||
| * so header and payload run under ONE budget, not two. Never shrinks below the | ||
| * length the budget was initialised with. */ | ||
| static inline void ml_coord_frame_budget_set_frame_len(ml_coord_frame_budget_t * b, size_t frame_len) | ||
| { | ||
| int64_t total = (int64_t)ml_coord_frame_budget_total_ms(frame_len) * 1000LL; | ||
| if (total > b->total_us) b->total_us = total; | ||
| if (b->armed) b->hard_deadline_us = b->first_byte_us + b->total_us; | ||
| } | ||
|
|
||
| /* Call after every read that consumed n > 0 bytes: arms on the first byte, | ||
| * restarts the no-progress window on every byte, never moves the hard cap. */ | ||
| static inline void ml_coord_frame_budget_on_bytes(ml_coord_frame_budget_t * b, int64_t now_us) | ||
| { | ||
| if (!b->armed) { | ||
| b->armed = true; | ||
| b->first_byte_us = now_us; | ||
| b->hard_deadline_us = now_us + b->total_us; | ||
| } | ||
| b->progress_deadline_us = now_us + (int64_t)ML_COORD_FRAME_NO_PROGRESS_MS * 1000LL; | ||
| } | ||
|
|
||
| static inline bool ml_coord_frame_budget_armed(const ml_coord_frame_budget_t * b) | ||
| { | ||
| return b->armed; | ||
| } | ||
|
|
||
| static inline int64_t ml_coord_frame_budget_deadline_us(const ml_coord_frame_budget_t * b) | ||
| { | ||
| return (b->progress_deadline_us < b->hard_deadline_us) ? b->progress_deadline_us : b->hard_deadline_us; | ||
| } | ||
|
|
||
| /* True once an armed budget has run out (either limit): the frame must be abandoned. */ | ||
| static inline bool ml_coord_frame_budget_expired(const ml_coord_frame_budget_t * b, int64_t now_us) | ||
| { | ||
| return b->armed && now_us >= ml_coord_frame_budget_deadline_us(b); | ||
| } | ||
|
|
||
| /* Longest one blocking wait for more bytes may last. 0 when not armed or expired. */ | ||
| static inline int64_t ml_coord_frame_budget_wait_us(const ml_coord_frame_budget_t * b, int64_t now_us) | ||
| { | ||
| if (!b->armed) return 0; | ||
| int64_t rem = ml_coord_frame_budget_deadline_us(b) - now_us; | ||
| return rem > 0 ? rem : 0; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| // SPDX-FileCopyrightText: 2026 Polymath Robotics | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /* WireGuard re-establishment decision on a direct-path (re)gain — pure, | ||
| * host-testable (host/test_wg_regain_policy.c). | ||
| * | ||
| * Context (ml_wg_mgr.c process_disco_pong): a direct pong just installed an | ||
| * endpoint for a peer that has NO valid WireGuard session. Two kinds of peer | ||
| * want two different reactions: | ||
| * | ||
| * - SAFETY peer (the machine host): connect() with WireGuard's own | ||
| * REKEY_TIMEOUT retries. Its wireguard-go initiates only when it has data, | ||
| * and it has nothing to send while we are silent — a one-shot that then | ||
| * waits for the peer to initiate deadlocks (bench 2026-09-19: relay dead, | ||
| * stuck indefinitely). Paced to REKEY_TIMEOUT: pongs arrive several times a | ||
| * second and every connect() resets the pending handshake, so a slow path | ||
| * could otherwise keep answering an already-superseded initiation. Inside | ||
| * the pace window the answer is NONE — never the one-shot, which clears | ||
| * peer->active and would cancel the retries just armed. | ||
| * | ||
| * - BULK tailnet peer: one initiation, active cleared. If it has us trimmed | ||
| * it will never answer and retries would run forever. | ||
| */ | ||
|
|
||
| #pragma once | ||
|
|
||
| #include <stdbool.h> | ||
| #include <stdint.h> | ||
|
|
||
| #define ML_WG_SAFETY_CONNECT_PACE_MS 5000u /* = WireGuard REKEY_TIMEOUT */ | ||
|
|
||
| typedef enum | ||
| { | ||
| ML_WG_REGAIN_NONE = 0, /* leave the pending handshake alone */ | ||
| ML_WG_REGAIN_CONNECT_RETRYING, /* wireguardif_connect(): active, auto-retries */ | ||
| ML_WG_REGAIN_ONE_SHOT_INIT, /* single initiation, active cleared afterwards */ | ||
| } ml_wg_regain_action_t; | ||
|
|
||
| /* last_safety_connect_ms == 0 means "never connected". */ | ||
| static inline ml_wg_regain_action_t ml_wg_regain_action( | ||
| bool is_safety_peer, bool tried_initial_handshake, uint64_t now_ms, uint64_t last_safety_connect_ms) | ||
| { | ||
| if (is_safety_peer) { | ||
| if (last_safety_connect_ms == 0u || (now_ms - last_safety_connect_ms) >= ML_WG_SAFETY_CONNECT_PACE_MS) { | ||
| return ML_WG_REGAIN_CONNECT_RETRYING; | ||
| } | ||
| return ML_WG_REGAIN_NONE; | ||
| } | ||
| return tried_initial_handshake ? ML_WG_REGAIN_NONE : ML_WG_REGAIN_ONE_SHOT_INIT; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,9 +21,11 @@ | |
| #include "esp_timer.h" | ||
| #include "esp_wifi.h" | ||
| #include "lwip/sockets.h" | ||
| #include "lwip/sys.h" | ||
| #include "microlink_internal.h" | ||
| #include "nvs.h" | ||
| #include "nvs_flash.h" | ||
| #include "wireguardif.h" /* peer path-recovery diagnostics in microlink_get_peer_info */ | ||
|
|
||
| static const char * TAG = "microlink"; | ||
|
|
||
|
|
@@ -848,6 +850,48 @@ esp_err_t microlink_get_peer_info(const microlink_t * ml, int index, microlink_p | |
| info->online = p->active; | ||
| info->direct_path = p->has_direct_path; | ||
| info->derp_region = p->derp_region; | ||
| info->endpoint_count = p->endpoint_count; | ||
| info->best_ip = p->best_ip; | ||
| info->best_port = (uint16_t)p->best_port; | ||
| info->hs_cand_ip = 0; | ||
| info->hs_cand_port = 0; | ||
| info->wg_up = false; | ||
| info->wg_active = false; | ||
| info->wg_send_hs = false; | ||
| info->wg_hs_pending = false; | ||
| info->wg_init_age_ms = 0xFFFFFFFFu; /* "never" until a WG peer says otherwise */ | ||
| uint64_t now = ml_get_time_ms(); | ||
| info->ping_age_ms = (p->last_ping_sent_ms != 0) ? (uint32_t)(now - p->last_ping_sent_ms) : 0xFFFFFFFFu; | ||
| info->backoff_ms = (p->direct_backoff_until > now) ? (uint32_t)(p->direct_backoff_until - now) : 0u; | ||
| if (ml->wg_netif != NULL && p->wg_peer_index >= 0) { | ||
| struct netif * netif = (struct netif *)ml->wg_netif; | ||
| ip_addr_t cur_ip; | ||
| u16_t cur_port; | ||
| info->wg_up = (wireguardif_peer_is_up(netif, (u8_t)p->wg_peer_index, &cur_ip, &cur_port) == ERR_OK); | ||
| /* Diagnostic snapshot of plain scalars owned by the TCPIP thread, read | ||
| * without the core lock on purpose: a torn read can only yield a stale | ||
| * bool or age for one JSON sample, never a fault, and taking the lock from | ||
| * the HTTP task for telemetry is what stalled the safety loop before. */ | ||
| struct wireguard_device * dev = (struct wireguard_device *)netif->state; | ||
| if (dev != NULL && p->wg_peer_index < WIREGUARD_MAX_PEERS) { | ||
| const struct wireguard_peer * wp = &dev->peers[p->wg_peer_index]; | ||
| info->wg_active = wp->active; | ||
| info->wg_send_hs = wp->send_handshake; | ||
| info->wg_hs_pending = wp->handshake.valid; | ||
| /* Handshake second-leg candidate: same lock-free telemetry snapshot (the | ||
| * locked wireguardif_get_hs_candidate() is for the disco task's decisions, | ||
| * not for the HTTP task — see the comment above). */ | ||
| if ( | ||
| !ip_addr_isany(&wp->hs_cand_ip) && wp->hs_cand_port != 0 && | ||
| !wireguard_expired(wp->hs_cand_ms, HS_CAND_FRESH_MS / 1000)) | ||
| { | ||
| info->hs_cand_ip = lwip_ntohl(ip4_addr_get_u32(ip_2_ip4(&wp->hs_cand_ip))); | ||
| info->hs_cand_port = wp->hs_cand_port; | ||
| } | ||
| uint32_t wg_now = sys_now(); /* wireguardif stamps with lwIP sys_now(), not esp_timer */ | ||
| info->wg_init_age_ms = (wp->last_initiation_tx != 0) ? (wg_now - wp->last_initiation_tx) : 0xFFFFFFFFu; | ||
|
Comment on lines
+866
to
+892
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) After merging, an admin can see /admin/api/peers show a retired or LRU-evicted peer row (active:false) carrying a completely different peer's live WireGuard diagnostics (wg_up, wg_active, hs_cand, wg_init_age_ms) - forensics data this diff adds and that is now wrong for exactly the kind of investigation it exists for. ml_wg_mgr.c's re-key retire (ml_wg_mgr.c:2105-2115) and LRU evict (ml_wg_mgr.c:2172-2176) paths call wireguardif_remove_peer() but clear only p->active, never p->wg_peer_index or p->vpn_ip. wireguardif_add_peer()'s peer_alloc() can hand that freed WG slot to an unrelated peer. microlink_get_peer_info() (microlink.c:866-892) then reads dev->peers[p->wg_peer_index] whenever the stale index is >=0, without checking p->active or matching the slot's key/vpn_ip to this peer. … Extended reasoning...…Fix: clear wg_peer_index (and vpn_ip) at retire/evict time, or verify the WG slot still belongs to this peer (matching public_key) before exposing its state. ml_wg_mgr.c:2105-2115 retires a re-keyed peer: wireguardif_remove_peer(netif, p->wg_peer_index) then only ml->peers[idx].active=false; wg_peer_index and vpn_ip are left as-is. Same gap at the LRU-evict path, ml_wg_mgr.c:2172-2176. wireguardif_remove_peer (wireguardif.c:1219-1228) sets peer->valid=false on that WG slot. A later wireguardif_add_peer() for a DIFFERENT peer calls peer_alloc(), which scans for the first invalid slot and can return this same just-freed index. If ml_wg_mgr.c's own free-slot scan (2120-2125) picks an earlier already-inactive ml->peers[] slot for the new peer instead of idx, the retired entry at idx stays indefinitely with active=false, vpn_ip nonzero, wg_peer_index pointing at the now-reused slot. handler_get_peers (ml_config_httpd.c:701-704) only skips vpn_ip==0, so it emits this row. microlink_get_peer_info tests p->wg_peer_index>=0 (true) and reads dev->peers[index] (microlink.c:875-892):… Verification: nit. Real but low-severity diagnostic-accuracy defect newly introduced by this diff (the WG diagnostics block at microlink.c:866-893 is added here; the base get_peer_info never touched wg_peer_index). Mechanism (retire path only): ml_wg_mgr.c:2105-2115 removes the WG slot (wireguardif_remove_peer, 2106; the slot is zeroed and valid=false per wireguardif.c:1223-1224) but clears only… | nit. Real… |
||
| } | ||
| } | ||
| return ESP_OK; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 An attacker who replays one authenticated disco ping with a spoofed source IP makes the device repeatedly send real WireGuard handshake-init packets to that address for 60s, not just the single PONG the base branch reflects. ml_wg_mgr.c:2935-2942 sets peer->hs_cand from pkt->src_ip/src_port on an unvalidated inbound ping, before the pong that would confirm it. wireguardif.c:180-189 then mirrors every paced (5s, ML_WG_SAFETY_CONNECT_PACE_MS) safety-peer handshake initiation there while HS_CAND_FRESH_MS=60s holds. Fix: gate mirroring on a validated pong / has_direct_path, not the raw inbound ping.
Extended reasoning...
Disco pings are nacl_box_open-authenticated (ml_wg_mgr.c:3430) so only the true peer's disco key can produce valid content, but the UDP source IP/port used as pkt->src_ip/src_port is not part of that authenticated payload. An attacker who observes one genuine ping (network tap/MITM) can resend the identical ciphertext with a forged source IP=victim; nacl_box_open still succeeds. Handler at ml_wg_mgr.c:2935 sees !pkt->via_derp, is_safety_peer true, calls wireguardif_set_hs_candidate(peer_index, victim_ip, victim_port) immediately - the very next comment says reachability is only proven later by a pong that flips has_direct_path, which never comes for a spoofed address. Safety-peer regain policy (ml_wg_regain_policy.h) paces handshake retries every ML_WG_SAFETY_CONNECT_PACE_MS=5000ms while DERP-only. Each retry goes through wireguardif_peer_output; since hs_cand is still fresh (HS_CAND_FRESH_MS=60000), wireguardif.c:180-189 sends a second copy via udp_output_fn straight to victim_ip:victim_port. Over the 60s window that is up to ~12 reflected packets from one injected ping, versus the…
Verification: Severity: normal (security-relevant; newly introduced by this change, modest amplification, gated by an outage window + on-path capture). The mechanism is real and reachable. Disco pings are authenticated only over the nacl box ciphertext (ml_wg_mgr.c:3430 nacl_box_open uses sender_disco_key + ml->disco_private_key); the UDP transport 5-tuple (pkt->src_ip/src_port) is NOT covered by that box,…