Skip to content

fix(wg): a safety peer's remote re-establishes its WireGuard session on its own - #155

Closed
iliabaranov wants to merge 6 commits into
mainfrom
fix/wg-handshake-direct-leg
Closed

iliabaranov wants to merge 6 commits into
mainfrom
fix/wg-handshake-direct-leg

Conversation

@iliabaranov

@iliabaranov iliabaranov commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

The failure

A USB-tethered remote bonded to the machine node on its tether host sat unbonded for 7+ minutes (bench 2026-09-19 09:09): every BOND attempt ENOTCONN (peer known, no keypair), wg_tx_no_valid_keys climbing, disco pings flowing both ways on the direct path, no WireGuard initiation toward the host's direct endpoint, initiations relayed only. One host-initiated handshake (tailscale ping --tsmp) healed it instantly — the host's wireguard-go only initiates when it has data, and it has none while the remote is silent. Reproduced deterministically (block direct + DERP, reboot the remote mid-outage so it is keyless and the host trims it, unblock direct only, DERP dead, no TSMP): v1.3-14 never recovers (>300 s).

Root cause (ml_wg_mgr.c pong-promotion path)

The demote (wireguardif_connect_derp) sets the live endpoint peer->ip to 0.0.0.0 so initiations go via DERP. On direct regain the handler stores the address with wireguardif_update_endpoint() — which only sets connect_ip — and calls wireguardif_connect() (which re-points peer->ip and fires a handshake) only if some keypair is still valid. Without one it fell to a deliberate one-shot ("wait for them to initiate", active=false) or, for an already-known peer, to nothing. So the remote kept initiating relay-only while disco reported direct. Whether a stale keypair survived decided between seconds and never: measured 10 s, 21 s, 169 s, ∞.

Fix (three parts, safety peers only)

  1. Direct endpoint + no session → wireguardif_connect() with retries (the decisive change), paced to one connect per REKEY_TIMEOUT (5 s) per peer so pongs arriving several times a second cannot keep resetting a pending handshake. Unlimited duration is correct for the machine peer; bulk peers keep the one-shot.
  2. Handshake second leg: while DERP-only, each initiation is also sent to the peer's most recent direct disco source; a direct response re-adopts the endpoint.
  3. The disco ping fan-out includes that candidate, so the host's lazily-configured wireguard-go peer stays alive (a bare WG initiation never re-adds a trimmed peer — only an inbound disco ping does).

Diagnostics: /admin/api/peers gains wg_up/wg_active/wg_send_hs/wg_hs_pending/wg_init_age_ms/eps/best/hs_cand/ping_age_ms/backoff_ms; monitor gains wg_hs_cand_sends, disco_hs_cand_pings, wg_safety_reconnects.

Scope of the evidence

The deterministic recipe validates the package (all three changes were present in the build); change 1 is the clearest code defect and the one the recipe's stuck state exercises by construction (keyless peer, direct regained), but no change-1-only build was run. Changes 2 and 3 target the relay-up-but-slow variant and are not individually verified. My mechanism for that variant — initiations sitting in the DERP queue longer than REKEY_TIMEOUT so each response matches a superseded handshake index — is a hypothesis consistent with the measured 1.3 s-median / 13 s-max mirror lateness, not a verified fact (peer review). Both extra legs are bounded (candidate freshness 60 s, same initiation bytes, safety peers only).

Verification

Deterministic recipe above: baseline stuck >300 s; this branch bonded 22 s after the direct path returned (wg_safety_reconnects=3), no TSMP. Two Ethernet units + the USB unit are in a 4 h armed soak on this build as I write.

Also confirmed along the way: the machine side never causes a long outage (a timed-out entry is PSTOP_REMOTE_UNKNOWN, skipped by pstop_remote_get, so a re-BOND is answered at once), and priority_peer_ip is not the differentiator (recovers either way from a plain direct outage).

… source while DERP-only

Bench 2026-09-19: a USB-tethered remote sat DERP-only for 7+ minutes with
no valid keypair for the machine host — its handshake initiations went
via the relay only and never completed — while the host pinged it
DIRECTLY every few seconds (disco both ways on the tether). One
host-initiated handshake healed it instantly; an idle wireguard-go never
initiates on its own, so the remote must be able to re-establish the
session by itself.

Now the source of the most recent direct disco ping/pong from a safety
peer is kept as a handshake candidate (60 s freshness). While the peer
has no direct WG endpoint, each initiation is sent both via DERP and to
that candidate: one handshake, two legs, either response completes it,
and a direct response re-adopts the direct endpoint (update_peer_addr).
Data frames are unchanged. Diag: wg_hs_cand_sends in the monitor.
…ery diagnostics

A bare WireGuard initiation never re-adds a peer that the far side's
wireguard-go has trimmed for inactivity — only an inbound disco PING does
(Tailscale lazy peer configuration; already noted in ml_wg_mgr.c). So the
second handshake leg alone cannot recover a remote that stopped pinging
its machine host: disco_send_ping_to_peer() now also fans the ping out to
the handshake candidate (the peer's last direct disco source) for safety
peers, which keeps the host's peer configured and yields the pong the
normal direct regain needs even when the candidate table lacks it.

/admin/api/peers gains wg_up, eps, best, hs_cand, ping_age_ms,
backoff_ms per peer so the next stuck-DERP-only event is attributable
from a snapshot; monitor gains disco_hs_cand_pings.
…nects with retries

The pong-promotion path stores a regained/first direct endpoint with
wireguardif_update_endpoint() (connect_ip only) and calls
wireguardif_connect() — which re-points the LIVE endpoint peer->ip and
retries the handshake — only when some keypair is still valid. Without
one it fell to a deliberate one-shot (then active=false, 'wait for them
to initiate'), or to nothing at all for an already-known peer. Right for
bulk peers that may have trimmed us; a deadlock for the machine host,
whose wireguard-go initiates only when it has data — and it has nothing
to send while we are silent. The demote's connect_derp() had left
peer->ip at 0.0.0.0, so the remote kept initiating relay-only while disco
reported direct: indefinitely with the relay dead or slow. A stale-but-
valid keypair took the other branch and recovered in seconds — a lottery
(bench 2026-09-19: 10 s, 21 s, 169 s, never).

Safety peers now connect() with retries in that branch. Diag:
wg_safety_reconnects.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a WireGuard handshake "second leg" so an idle DERP-only peer can be healed: when a direct disco packet arrives, its source is remembered via wireguardif_set_hs_candidate, and wireguardif_peer_output mirrors handshake initiations to that fresh candidate over UDP alongside the relay, treating the send as successful if either leg lands so a direct response re-adopts the direct endpoint. Exposes the recovery state — candidate endpoint, keypair/handshake/initiation status, ping age, and upgrade backoff — through microlink_get_peer_info and the /peers and /monitor HTTP endpoints (including a wireguardif_hs_cand_sends counter) for after-the-fact path forensics.

Worth a look

  • Public peer info struct was enlarged without size negotiationcomponents/microlink/include/microlink.h:62 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Handshake initiation mirrored to unvalidated disco source enables reflection/amplification abusecomponents/microlink/components/wireguard_lwip/src/wireguardif.c:179 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Direct discovery ping source can steer WireGuard handshake candidatecomponents/microlink/src/ml_wg_mgr.c:2935 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • New peer diagnostics can be returned uninitializedcomponents/microlink/src/microlink.c:850 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Handshake candidate is read and written without synchronizationcomponents/microlink/components/wireguard_lwip/src/wireguardif.c:1039 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 414 functions depend on the 359 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: microlink_init() — 1 callers, 17 callees
  • new: disco_periodic_probes() — 1 callers, 16 callees
  • …and 39 more — each is listed as a finding

Verification — 414 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 413 function(s) in the blast radius were not formally verified this run

· 47 more finding(s) on lines outside this diff (see the check run).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding on uninitialized diagnostic fields, I also looked at the unconditional wireguardif_connect() call for safety peers with a direct endpoint but no session (ml_wg_mgr.c around line 3182): it only fires from the per-pong "no session" branch, and wireguardif_connect() re-points peer->ip/sets active=true so once a session forms it stops re-firing on this path — the retry cadence is governed by WireGuard's own REKEY_TIMEOUT handshake logic rather than pong frequency, so the higher initiation volume this introduces looks intentional rather than a bug.

Extended reasoning...

A confirmed inline finding already exists for this PR (uninitialized wg_active/wg_send_hs/wg_hs_pending/wg_init_age_ms fields in microlink_get_peer_info() when the netif/peer-index/dev checks fail), so this run does not approve or issue a plain zero-findings defer. I additionally examined the previously-flagged-and-ruled-out concern about wireguardif_connect() being called unconditionally on every safety-peer pong with a direct endpoint and no session, to see whether it independently warranted escalation. Reading the surrounding code in ml_wg_mgr.c, the call sits inside the "no session" branch only; once wireguardif_connect() establishes active=true and a handshake succeeds, subsequent pongs for that peer take the earlier is_up branch instead, and retry cadence is bounded by WireGuard's internal REKEY_TIMEOUT logic rather than being re-triggered per pong. This matches the PR's stated intent (unlimited retries for the machine peer are deliberate) and does not by itself justify a separate finding, so I'm noting it only as something checked and ruled out rather than restating it as new.

Comment thread components/microlink/src/microlink.c
Pongs arrive several times a second and wireguardif_connect() resets the
pending handshake state; without pacing a slow path could keep answering
an already-superseded initiation. One connect per 5 s per peer.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 4 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a WireGuard handshake "second leg" so a DERP-only safety peer can still complete a handshake: wireguard_peer now remembers the source of the most recent direct disco packet (hs_cand_ip/port/ms), and wireguardif_peer_output mirrors handshake initiations to that fresh candidate over the direct UDP path in addition to the relay, treating either leg succeeding as success (counted in wireguardif_hs_cand_sends, gated to a 60s freshness window). Adds wireguardif_set_hs_candidate/wireguardif_get_hs_candidate to record and read back that candidate, and paces a keyless-regain wireguardif_connect via last_safety_connect_ms. Surfaces per-peer path-recovery diagnostics (endpoint count, chosen direct endpoint, handshake candidate, ping/init ages, backoff, and the WG session flags wg_up/wg_active/wg_send_hs/wg_hs_pending) through microlink_get_peer_info and the /peers HTTP handler.

Worth a look

  • hs_cand second-leg send can direct WG initiation to unvalidated attacker-controlled endpointcomponents/microlink/components/wireguard_lwip/src/wireguardif.c:178 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • hs_cand IP passed to udp_output_fn in host order without ntohl unlike gettercomponents/microlink/components/wireguard_lwip/src/wireguardif.c:184 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Peer diagnostics read WireGuard peer internals without core lockingcomponents/microlink/src/microlink.c:871 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • New peer-info diagnostics are left uninitialized when no WG peer is availablecomponents/microlink/src/microlink.c:862 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 426 functions depend on the 364 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: noise_send() — 7 callers, 3 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: microlink_init() — 1 callers, 17 callees
  • …and 42 more — each is listed as a finding

Verification — 426 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 425 function(s) in the blast radius were not formally verified this run

· 50 more finding(s) on lines outside this diff (see the check run).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the existing inline finding, I also checked the new 5s pacing gate added in the latest commit (b11c9592a38f, process_disco_pong) for a race where a second direct pong arriving within that window could fall through to the bulk-peer one-shot branch and flip a safety peer's active flag back to false, silently reproducing the deadlock this PR fixes. It self-heals: last_safety_connect_ms is only updated by the branch that fires the real retry, so it is untouched by that stray path, and the safety peer resumes retrying within one REKEY_TIMEOUT (5s) regardless.

Extended reasoning...

This run's bug hunter reported no new findings and my own prior inline comment on microlink.c (uninitialized wg_active/wg_send_hs/wg_hs_pending/wg_init_age_ms when the peer isn't yet WG-mapped) remains unaddressed by the latest commit — I verified b11c9592a38f only touched microlink_internal.h and ml_wg_mgr.c (the pacing fix), not microlink.c, so that thread stays open and I am not restating it here. The one new thing worth recording is that I independently traced the harness's ruled-out candidate (a second pong within 5s of the safety-peer wireguardif_connect() undoing the retry by hitting the bulk one-shot branch and setting active=false) through process_disco_pong in ml_wg_mgr.c around line 3168-3207, and confirmed it is a real but transient blip, not a reproduction of the original deadlock, because the periodic 5s gate (last_safety_connect_ms) that drives ongoing retries is set only by the safety branch itself and is unaffected by the erroneous one-shot path — so retries resume automatically within one REKEY_TIMEOUT. Since an unaddressed prior-review finding still stands, I am not approving; this defer note only adds the one new, concretely-verified item beyond what's already on the thread.

@iliabaranov

Copy link
Copy Markdown
Contributor Author

Soak result (2026-09-19 12:06–15:47 PDT, 3.7 h, this build + #148#154): two Ethernet remotes + one USB-tethered remote bonded to a ROS 2 software machine on the tether host, DUT E-stop pressed/released/re-armed every 5 min (42/42 cycles, stop 0.25–0.50 s, re-arm ≤0.75 s). Zero bond drops, zero rebonds, zero resets, 100 % bonded samples on all three (12 638 one-second samples each). Reply age p99.9: 367 / 361 / 391 ms; one 1070 ms excursion on the Ethernet remote 7a60 (a ~1.2 s receive-side stall, TX unaffected — the W5500 RX class, bond held). Machine-side capture: 194 207 requests, 194 170 answered; the 37 unanswered are all late DERP-mirror copies rejected as out-of-order (median 1.3 s late), not losses.

…rite

Review: microlink_get_peer_info() left wg_active/wg_send_hs/wg_hs_pending/
wg_init_age_ms unset when there was no WG peer to read; the candidate
tuple is written from the disco task and read on the TCPIP thread — write
port last (0 first) so a reader seeing a non-zero port sees the matching
ip. Lock-free scalar reads for telemetry documented as deliberate.
@iliabaranov

Copy link
Copy Markdown
Contributor Author

Graphify escalations, addressed or answered (f0da736):

  • hs_cand IP byte order (high) — not a bug: the second leg passes ip4_addr_get_u32(&peer->hs_cand_ip) exactly as the existing direct branch passes peer->ip (network order; wg_udp_output_cb documents "already network byte order"), while the getter returns host order because ml_wg_mgr.c keeps candidate/endpoint IPs in host order (p->endpoints[].ip, disco_udp_sendto). Verified on the wire: the second-leg initiations were captured arriving at 10.42.0.1:41641.
  • Unauthenticated / attacker-steerable candidate (medium ×3) — the candidate is set only from process_disco_ping/process_disco_pong, which run on the decrypted payload (NaCl box opened with the peer's disco key; the pong additionally matches a txid we generated). Same trust as Tailscale's own candidate learning. Amplification: one 148-byte initiation per REKEY_TIMEOUT (5 s), safety peers only, candidate expires after 60 s.
  • Unsynchronised candidate (medium) — write ordered so a reader that sees a non-zero port sees the matching ip; a racing reader at worst skips one leg (f0da736).
  • Diagnostics without core lock (medium ×2) — deliberate: plain scalars for telemetry; a torn read yields one stale sample, never a fault, and locking from the HTTP task for telemetry is what stalled the safety loop before. Documented in the code.
  • Uninitialised diagnostics (medium ×2) — fixed (default-initialised).
  • Struct enlarged without size negotiation (high)microlink_peer_info_t is an in-tree snapshot struct consumed only by ml_config_httpd.c in the same build; there is no ABI boundary.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 4 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a WireGuard "handshake second leg": when a DERP-only safety peer has a fresh direct disco source recorded via wireguardif_set_hs_candidate, wireguardif_peer_output mirrors handshake initiations to that direct address as well as the relay, so an idle peer that never re-initiates can still be healed direct, with each mirrored send counted in wireguardif_hs_cand_sends. Exposes per-peer path-recovery diagnostics through microlink_get_peer_info (chosen endpoint, handshake candidate, ping/backoff/init ages, and WG session flags), reading the TCPIP-thread scalars lock-free on purpose to avoid stalling the safety loop, and surfaces them in the peers HTTP endpoint. Adds last_safety_connect_ms to pace keyless-regain wireguardif_connect() calls to the rekey timeout.

Worth a look

  • Public peer-info struct ABI expanded without size/version negotiationcomponents/microlink/include/microlink.h:65 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Handshake initiation mirrored to unvalidated disco source (spoofable direct leg)components/microlink/components/wireguard_lwip/src/wireguardif.c:179 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • hs_cand written from disco task but read on TCPIP thread without any memory barriercomponents/microlink/components/wireguard_lwip/src/wireguardif.c:178 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Non-atomic reset of hs_cand fields loses candidate when writer clears while reader readscomponents/microlink/components/wireguard_lwip/src/wireguardif.c:1050 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 426 functions depend on the 364 functions this change touches.

Health — this change adds coupling hotspots:

  • new: disco_send_ping_to_peer() — 8 callers, 8 callees
  • new: dcs_support_init() — 2 callers, 30 callees
  • new: add_peer() — 2 callers, 22 callees
  • new: wireguardif_network_rx() — 3 callers, 11 callees
  • new: disco_send_call_me_maybe() — 6 callers, 4 callees
  • new: noise_send() — 7 callers, 3 callees
  • new: neg_apply_target() — 2 callers, 10 callees
  • new: microlink_init() — 1 callers, 17 callees
  • …and 42 more — each is listed as a finding

Verification — 426 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 425 function(s) in the blast radius were not formally verified this run

· 50 more finding(s) on lines outside this diff (see the check run).

@iliabaranov

Copy link
Copy Markdown
Contributor Author

Superseded by #156 (consolidated remote-connectivity PR, same commits cherry-picked linearly; all review findings from this thread are addressed there). Closing.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I also checked whether a safety peer's second direct pong arriving within the new 5s pace window (ml_wg_mgr.c:3168) falls through to the legacy one-shot branch and clears peer->active — it does, but the next pong once the pace window elapses re-arms wireguardif_connect() with retries again, so the dip is bounded to the same 5s window rather than sticking the peer in the one-shot "wait for them to initiate" state.

Extended reasoning...

This run's inline findings (stale last_safety_connect_ms surviving slot reuse in ml_wg_mgr.c, and the racy p->wg_peer_index re-read enabling a dev->peers[-1] OOB read in microlink.c) already signal that human review is warranted, so per the narrow exception for findings-present runs I am only adding one concrete extra check that was performed and ruled out this round: the interaction between the new 5s safety-connect pacer and the pre-existing tried_initial_handshake one-shot branch at ml_wg_mgr.c:3190. Tracing the control flow confirms that a safety peer's rapid successive direct pongs can indeed fall into the legacy one-shot branch (since the safety branch never sets tried_initial_handshake), which explicitly zeroes peer->active — but because last_safety_connect_ms is only updated when the safety branch actually fires, the very next pong past the 5s pace boundary takes the safety branch again and re-enables retries, so the regression is self-limited to a sub-5s window rather than a lasting stuck state like the bug the PR fixes. This was not previously reported by me and had not surfaced in the visible prior-review text, so it is new information worth recording briefly without restating the inline findings.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 components/microlink/src/ml_wg_mgr.c — A safety peer landing in a reused peer-table slot can have its new unlimited-retry handshake connect suppressed for up to 5s right when a bond is being re-established. The new pacer field p->last_safety_connect_ms (microlink_internal.h:617) is never cleared in the !existing slot-reuse block here, unlike every sibling throttle it sits next to (direct_backoff_until, last_derp_reconnect_ms, relay_retry_next_ms, last_cmm_sent_ms) which this same block explicitly resets "on slot REUSE too" for this exact staleness hazard. …

    Extended reasoning...

    …With a stale recent timestamp inherited from the slot's prior occupant, the paced branch at ml_wg_mgr.c:3168 is skipped and control falls to the legacy one-shot branch at ml_wg_mgr.c:3190, which fires once then deliberately clears active — reproducing the stuck-DERP-only bond this PR is meant to fix, for slots reused during exactly the coord-reconnect/re-sync churn it targets. Fix: reset last_safety_connect_ms = 0 alongside the other throttle fields in this block.

    wireguardif's paced safety-connect at ml_wg_mgr.c:3168 guards with now - p->last_safety_connect_ms >= 5000u. last_safety_connect_ms lives in the fixed ml_peer_t table slot and is stamped at ml_wg_mgr.c:3186 whenever wireguardif_connect() fires for a safety peer with a direct pong and no session. When a peer is removed (wireguardif_remove_peer at ml_wg_mgr.c:2105/2172, or eviction) its slot is later reused by ML_PEER_ADD's !existing branch at ml_wg_mgr.c:2319-2345. That branch resets last_ping_sent_ms, last_pong_recv_ms, direct_backoff_until, last_derp_reconnect_ms, relay_retry_next_ms/count,…

    Verification: Severity: nit. The claim is factually correct and reachable, but bounded and non-blocking. Verified: last_safety_connect_ms (microlink_internal.h:617) is read only at ml_wg_mgr.c:3168 and written only at ml_wg_mgr.c:3186 — never cleared elsewhere (Grep: exactly those 3 sites). Peer removal/eviction/re-key-retire only set active=false (2114, 2174) and do NOT zero the slot struct, so a reused…

Comment on lines +866 to +880
if (ml->wg_netif != NULL && p->wg_peer_index >= 0) {
struct netif * netif = (struct netif *)ml->wg_netif;
u16_t cport = 0;
(void)wireguardif_get_hs_candidate(netif, (u8_t)p->wg_peer_index, &info->hs_cand_ip, &cport);
info->hs_cand_port = cport;
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];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 An HTTP client polling /peers during a concurrent peer remove/re-add can make microlink_get_peer_info() index dev->peers[-1], an out-of-bounds read, which base code never did (this whole block is new). p->wg_peer_index is re-read from the racy ml_peer_t field at lines 866, 869, 873, 879 and 880 instead of being cached once after the >=0 check at 866. If ml_wg_mgr.c:2328 sets p->wg_peer_index=-1 between those reads, the signed compare at line 879 (-1 < WIREGUARD_MAX_PEERS) still passes, and line 880 computes dev->peers[-1], reading backward into wireguard.h:218-234's private_key/cookie_secret region and reporting bits of it as wg_active/wg_send_hs/wg_hs_pending/wg_init_age_ms in the JSON response. …

Extended reasoning...

…Fix: read wg_peer_index into a local int once, validate 0<=idx<WIREGUARD_MAX_PEERS once, and use only that local for every subsequent wireguardif_* call and array index.

handler_get_peers (ml_config_httpd.c) runs on the HTTP task and calls microlink_get_peer_info() with no lock against the wg_mgr task. Step 1: line 866 reads p->wg_peer_index (say 5), passes the >= 0 guard, enters the block. Step 2: concurrently the wg_mgr task removes this peer and ml_wg_mgr.c:2328 sets p->wg_peer_index = -1. Step 3: line 879 re-reads p->wg_peer_index, now -1; -1 < WIREGUARD_MAX_PEERS (128, a plain #define) is a signed comparison and is true, so the guard does not reject it. Step 4: line 880 re-reads p->wg_peer_index again as -1 and evaluates dev->peers[p->wg_peer_index] = dev->peers[-1], stepping backward by sizeof(struct wireguard_peer) bytes from peers[0]. Step 5: struct wireguard_device (wireguard.h:218-234) places label_mac1_key, label_cookie_key, cookie_secret_millis, cookie_secret, private_key and public_key immediately before peers[], so this OOB read lands in that region. Step 6:…

Verification: nit. The scenario is real and reachable but does not fault on this platform. microlink_get_peer_info() (microlink.c:841-889) is deliberately lock-free (comment at 874-877) and re-dereferences p->wg_peer_index fresh at 866, 869, 873, 879 and 880 with no caching. wg_peer_index is a signed int (microlink_internal.h:687) that the wg_mgr task writes to -1 on peer reset/removal (ml_wg_mgr.c:1939 and…

iliabaranov added a commit that referenced this pull request Sep 20, 2026
…rd re-establishment, coord deadline (#156)

Consolidates #149, #152, #151, #155 and #153 (closed as superseded;
their review threads and bot findings were addressed there and the fixes
are included here). 12 linear commits, 409+/29−. Every commit was in the
build that passed the 3.7 h soak below.

## What is fixed
| area | commit(s) | defect |
|---|---|---|
| USB tether TX | `de335e8` `1b5fe82` | `tinyusb_net_send_sync(…, 0)`
reclaimed the packet before the equal-priority TinyUSB task ran the
deferred send — ~95 % of device→host frames silently dropped (regression
from #135). Now a 20 ms wait; TIMEOUT-after-send duplicate accepted and
documented. |
| USB tether ring | `3a8a527` | 16-slot ring lost ~1 safety frame/min on
a hub-chain host (burst-induced). 64 slots (96 KB PSRAM), 400 ms
lifetime. |
| Tether link state | `f9ec4cb` | netif never went link-down when the
host detached/suspended, so a dead tether stayed the preferred uplink.
esp_tinyusb events now drive it; device netif gets its own MAC. |
| WiFi failover crash | `9823785` | first time the failover path ever
ran it aborted: `dcs_wifi_set_enabled` reaches flash from the
supervisor's PSRAM stack. Toggle moved to an internal-stack worker. |
| WireGuard re-establishment | `0440ad2` `5b86d21` `e020c86` `cae7e94`
`54149eb` `d7a335f` | **root cause of the multi-minute/indefinite bond
drops**: on direct regain with no keypair left, the live endpoint stayed
0.0.0.0 (relay-only) and a safety peer fell into the bulk-peer
"one-shot, wait for them to initiate" — the machine host's wireguard-go
never initiates without data. Safety peers now connect with paced
retries; initiations get a second leg to the peer's authenticated direct
disco source while DERP-only; disco pings fan out to it. Per-peer WG
diagnostics in `/admin/api/peers`. |
| Coord partial frame | `8cf5104` | `coord_recv` bounded by a retry
count × 2 s socket timeout (≈10 min); now a 10 s wall-clock deadline,
`ETIMEDOUT` so callers reconnect (#127). 88-check host test. |

## Evidence
- Deterministic WG repro (block direct+DERP, reboot remote mid-outage,
unblock direct only, no host help): **v1.3-14 never recovers (>300 s);
this branch bonds in 22 s.**
- Soak 2026-09-19 12:06–15:47 PDT, 3.7 h, this code + #148/#150/#154,
two Ethernet remotes + one USB remote bonded to a ROS 2 machine on the
tether host, DUT E-stop cycled every 5 min (42/42, stop ≤0.5 s, re-arm
≤0.75 s): **zero bond drops / rebonds / resets, 100 % bonded samples on
all three** (12 638 × 1 Hz each); reply age p99.9 361–391 ms; USB ring
566 916 frames, 0 expired, 0 dropped; machine-side 194 207 requests, 194
170 answered (the rest late DERP-mirror copies, correctly rejected). One
1.07 s receive-side excursion on an Ethernet remote (W5500 RX class,
tracked separately).
- Baseline for the same class of USB remote on v1.3: 252 ring drops + 55
expiries per 4 h, and 2–7 min bond outages.

## Not in this PR
#148 (release guard), #150 (name scrub), #154 (lockstep mismatch
diagnostics — SIL comparator, reviewed separately). DERP-mirror latency
(1.3 s median) and the W5500 RX stall are follow-ups.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant