[PR #47] fix: recover a black-holed tunnel by restarting for a new source port #46

Open
opened 2026-10-13 21:08:54 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/amnezia-vpn/amneziawg-apple/pull/47
Author: @shadypayload
Created: 9/15/2026
Status: 🔄 Open

Base: master ← Head: fix/ne-wedge-watchdog


📝 Commits (2)

  • 23a4e0f fix: recover a black-holed tunnel by restarting for a new source port
  • 9d70274 fix: decode wgGetConfig's C string in the wedge watchdog

📊 Changes

1 file changed (+111 additions, -0 deletions)

View changed files

📝 Sources/WireGuardKit/WireGuardAdapter.swift (+111 -0)

📄 Description

Symptom

After some network path changes the tunnel goes permanently dead while the UI still shows
Connected: no pages load, and nothing recovers it except toggling the tunnel off and on by hand.
It can persist for minutes or hours. Reported independently in amnezia-client#2149 ("Transitioning
between LTE and Wi-Fi on iOS AmneziaWG corrupts connection... VPN indicator still shows on top of the
screen and connection toggle is still ON, but in fact the VPN connection is not working at all"),
where the reporter also had On-Demand enabled — On-Demand does not help, for the reason below.

I captured both ends of several of these. Server-side, during the dead window the server receives
zero packets from the client from any address or port, while the client log shows it sending a
handshake initiation every ~5 s the whole time. The instant the tunnel is toggled, the very first
packet arrives from a new source port and the handshake completes in about 1 ms.

Root cause

On a path change we only ever bump the socket, and a bump deliberately reuses the same local UDP
port. A new port is only ever allocated by a full backend start:

  • WireGuardAdapter.didReceivePathUpdate (iOS branch): for a satisfiable path we call
    wgSetConfig + wgDisableSomeRoamingForBrokenMobileSemantics + wgBumpSockets. The
    .temporaryShutdown → resume branch (the only one that calls startWireGuardBackend) is reached
    only when the path goes .unsatisfied.
  • wgBumpSockets (Sources/WireGuardKitGo/api-apple.go) → dev.BindUpdate().
  • BindUpdate (amneziawg-go device/device.go) → netc.bind.Open(netc.port) — it passes the
    previously bound port back in, and closeBindLocked never clears netc.port.
  • StdNetBind.Open (conn/bind_std.go) binds that specific port; it only lets the OS choose a fresh
    ephemeral port when the requested port is 0.
  • wgTurnOn → device.NewDevice(..., conn.NewStdNetBind(), ...) starts with net.port == 0, and a
    client config carries no listen_port, so a full start always yields a new source port.

So when a path change invalidates the old flow's NAT/CGNAT mapping, every subsequent packet —
handshake initiations and the keepalives wgBumpSockets sends on success alike — leaves from the same
now-dead 4-tuple. WireGuard retries indefinitely and the tunnel never recovers on its own. A manual
toggle works purely because it is a full restart, i.e. a new source port.

This behavior is inherited from upstream wireguard-apple / wireguard-go, so it is not an Amnezia
regression — but it is very visible on mobile, where the path changes constantly.

Why the existing handshake-freshness logic does not catch it: it lives in TunnelsManager, i.e.
the app process, which iOS suspends while the app is backgrounded — exactly when this happens —
and it only evaluates around activation, not for the lifetime of an established tunnel.

The fix

A small watchdog in WireGuardAdapter (the network extension, which stays alive for as long as the
tunnel is up). Every 15 s it reads the runtime config and looks for the black-hole signature:

  • tx is advancing (we are sending — handshake retries and/or PersistentKeepalive), and
  • rx is not advancing (nothing is coming back), and
  • the newest handshake is older than 60 s (past the point it would normally have been refreshed).

After 3 consecutive strikes (~45 s) it performs a full backend restart — wgTurnOff then
startWireGuardBackend with the existing settings generator — which creates a fresh device and
therefore a fresh source port. This is exactly what a manual toggle does, and it reuses the same
teardown/restart pair the .temporaryShutdown → resume path already uses.

Deliberately conservative:

  • It only runs after the tunnel has had at least one successful handshake (everHadHandshake), so a
    tunnel that never worked is left alone.
  • A benign NAT re-map recovers within seconds (rx resumes), so the strike counter resets and nothing
    is restarted. In my captures ordinary cellular NAT churn happens ~12–20×/hour and never trips it.
  • If the path goes genuinely offline, NWPathMonitor moves the state to .temporaryShutdown and the
    watchdog skips (it requires .started), so it will not restart in a loop while offline.
  • It does not re-apply network settings; the utun and routes are intact during this failure, only the
    UDP socket is dead.

Tunables at the top of WireGuardAdapter: wedgeTickSeconds (15), wedgeHandshakeStaleSeconds (60),
wedgeStrikesToRestart (3).

Testing

  • The port behavior underpinning this is covered by a direct test of the Go bind layer: an initial
    Open(0) took port 52427; closing and re-opening with the stored port (what BindUpdate does)
    returned the same 52427; a fresh NewStdNetBind().Open(0) (what a restart does) returned a
    different port. That is the whole mechanism in two calls.
  • The log lines in the captured failures (Network change detected with satisfied route and interface order [...], UDP bind has been updated) map line-for-line onto the bump-not-restart path above.
  • The detection logic is validated against a real wedge (2026-09-15, iOS 26.6.2, AmneziaWG 3.1.4,
    ordinary tunnel, cellular). Last good handshake 00:47:12; the tunnel then sat dead until a manual
    toggle at 02:41:25 — 114 minutes. Over the 84 minutes the app's log ring still covered:
    965 x Sending handshake initiation, 0 receives of any kind, and 9 x
    Network change detected ... -> UDP bind has been updated — nine rebinds, not one of which
    recovered it, which is the bug in one line. Runtime config read at 02:37:44 while still dead:
    last_handshake_time_sec age 6632 s, rx_bytes=5227148, tx_bytes=3267683. Server-side over a
    full 60 s sample during the same window: zero bytes received from this peer, despite
    PersistentKeepalive = 25. The toggle at 02:41:25 produced Received handshake response at
    02:41:27 — two seconds.
    So throughout that window all three of the watchdog's conditions held continuously (tx advancing, rx
    flat, handshake far past stale). It would have restarted the backend roughly 45–60 s into the
    wedge instead of 114 minutes.
  • ⚠️ Not yet built on-device: this patch was authored and reviewed against current master but
    I do not have a macOS/Xcode environment to build it (and the Network Extension entitlement requires
    a paid Apple Developer account). I am raising it as a proposed fix for review rather than claiming a
    device-tested change — happy to iterate, and glad to hand over the captures if useful.

Disclosure: the source trace and this patch were produced with AI assistance (the commit carries a
Co-Authored-By trailer). The packet captures, client logs and the reproduction are my own, and every
claim above is cited to a specific source file so it can be verified independently.

Refs: amnezia-client#2149


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/amnezia-vpn/amneziawg-apple/pull/47 **Author:** [@shadypayload](https://github.com/shadypayload) **Created:** 9/15/2026 **Status:** 🔄 Open **Base:** `master` ← **Head:** `fix/ne-wedge-watchdog` --- ### 📝 Commits (2) - [`23a4e0f`](https://github.com/amnezia-vpn/amneziawg-apple/commit/23a4e0ff060279d85c867382c9662d92e48473fb) fix: recover a black-holed tunnel by restarting for a new source port - [`9d70274`](https://github.com/amnezia-vpn/amneziawg-apple/commit/9d70274c6c4c37ea0ece842963988d727c770429) fix: decode wgGetConfig's C string in the wedge watchdog ### 📊 Changes **1 file changed** (+111 additions, -0 deletions) <details> <summary>View changed files</summary> 📝 `Sources/WireGuardKit/WireGuardAdapter.swift` (+111 -0) </details> ### 📄 Description ## Symptom After some network path changes the tunnel goes permanently dead while the UI still shows **Connected**: no pages load, and nothing recovers it except toggling the tunnel off and on by hand. It can persist for minutes or hours. Reported independently in amnezia-client#2149 ("Transitioning between LTE and Wi-Fi on iOS AmneziaWG corrupts connection... VPN indicator still shows on top of the screen and connection toggle is still ON, but in fact the VPN connection is not working at all"), where the reporter also had On-Demand enabled — On-Demand does not help, for the reason below. I captured both ends of several of these. Server-side, during the dead window the server receives **zero** packets from the client from any address or port, while the client log shows it sending a handshake initiation every ~5 s the whole time. The instant the tunnel is toggled, the very first packet arrives **from a new source port** and the handshake completes in about 1 ms. ## Root cause On a path change we only ever *bump* the socket, and a bump deliberately reuses the same local UDP port. A new port is only ever allocated by a full backend start: - `WireGuardAdapter.didReceivePathUpdate` (iOS branch): for a satisfiable path we call `wgSetConfig` + `wgDisableSomeRoamingForBrokenMobileSemantics` + `wgBumpSockets`. The `.temporaryShutdown` → resume branch (the only one that calls `startWireGuardBackend`) is reached **only when the path goes `.unsatisfied`**. - `wgBumpSockets` (`Sources/WireGuardKitGo/api-apple.go`) → `dev.BindUpdate()`. - `BindUpdate` (amneziawg-go `device/device.go`) → `netc.bind.Open(netc.port)` — it passes the previously bound port back in, and `closeBindLocked` never clears `netc.port`. - `StdNetBind.Open` (`conn/bind_std.go`) binds that specific port; it only lets the OS choose a fresh ephemeral port when the requested port is `0`. - `wgTurnOn` → `device.NewDevice(..., conn.NewStdNetBind(), ...)` starts with `net.port == 0`, and a client config carries no `listen_port`, so a full start always yields a **new** source port. So when a path change invalidates the old flow's NAT/CGNAT mapping, every subsequent packet — handshake initiations and the keepalives `wgBumpSockets` sends on success alike — leaves from the same now-dead 4-tuple. WireGuard retries indefinitely and the tunnel never recovers on its own. A manual toggle works purely because it is a full restart, i.e. a new source port. This behavior is inherited from upstream wireguard-apple / wireguard-go, so it is not an Amnezia regression — but it is very visible on mobile, where the path changes constantly. **Why the existing handshake-freshness logic does not catch it:** it lives in `TunnelsManager`, i.e. the **app** process, which iOS suspends while the app is backgrounded — exactly when this happens — and it only evaluates around activation, not for the lifetime of an established tunnel. ## The fix A small watchdog in `WireGuardAdapter` (the network extension, which stays alive for as long as the tunnel is up). Every 15 s it reads the runtime config and looks for the black-hole signature: - tx is advancing (we are sending — handshake retries and/or `PersistentKeepalive`), **and** - rx is not advancing (nothing is coming back), **and** - the newest handshake is older than 60 s (past the point it would normally have been refreshed). After 3 consecutive strikes (~45 s) it performs a full backend restart — `wgTurnOff` then `startWireGuardBackend` with the existing settings generator — which creates a fresh device and therefore a fresh source port. This is exactly what a manual toggle does, and it reuses the same teardown/restart pair the `.temporaryShutdown` → resume path already uses. Deliberately conservative: - It only runs after the tunnel has had at least one successful handshake (`everHadHandshake`), so a tunnel that never worked is left alone. - A benign NAT re-map recovers within seconds (rx resumes), so the strike counter resets and nothing is restarted. In my captures ordinary cellular NAT churn happens ~12–20×/hour and never trips it. - If the path goes genuinely offline, `NWPathMonitor` moves the state to `.temporaryShutdown` and the watchdog skips (it requires `.started`), so it will not restart in a loop while offline. - It does not re-apply network settings; the utun and routes are intact during this failure, only the UDP socket is dead. Tunables at the top of `WireGuardAdapter`: `wedgeTickSeconds` (15), `wedgeHandshakeStaleSeconds` (60), `wedgeStrikesToRestart` (3). ## Testing - The port behavior underpinning this is covered by a direct test of the Go bind layer: an initial `Open(0)` took port 52427; closing and re-opening with the stored port (what `BindUpdate` does) returned **the same** 52427; a fresh `NewStdNetBind().Open(0)` (what a restart does) returned a different port. That is the whole mechanism in two calls. - The log lines in the captured failures (`Network change detected with satisfied route and interface order [...]`, `UDP bind has been updated`) map line-for-line onto the bump-not-restart path above. - **The detection logic is validated against a real wedge** (2026-09-15, iOS 26.6.2, AmneziaWG 3.1.4, ordinary tunnel, cellular). Last good handshake 00:47:12; the tunnel then sat dead until a manual toggle at 02:41:25 — **114 minutes**. Over the 84 minutes the app's log ring still covered: `965` x `Sending handshake initiation`, `0` receives of any kind, and `9` x `Network change detected ... -> UDP bind has been updated` — nine rebinds, not one of which recovered it, which is the bug in one line. Runtime config read at 02:37:44 while still dead: `last_handshake_time_sec` age **6632 s**, `rx_bytes=5227148`, `tx_bytes=3267683`. Server-side over a full 60 s sample during the same window: **zero** bytes received from this peer, despite `PersistentKeepalive = 25`. The toggle at 02:41:25 produced `Received handshake response` at **02:41:27** — two seconds. So throughout that window all three of the watchdog's conditions held continuously (tx advancing, rx flat, handshake far past stale). It would have restarted the backend roughly **45–60 s** into the wedge instead of 114 minutes. - ⚠️ **Not yet built on-device:** this patch was authored and reviewed against current `master` but I do not have a macOS/Xcode environment to build it (and the Network Extension entitlement requires a paid Apple Developer account). I am raising it as a proposed fix for review rather than claiming a device-tested change — happy to iterate, and glad to hand over the captures if useful. *Disclosure: the source trace and this patch were produced with AI assistance (the commit carries a `Co-Authored-By` trailer). The packet captures, client logs and the reproduction are my own, and every claim above is cited to a specific source file so it can be verified independently.* Refs: amnezia-client#2149 --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amneziawg-apple#46
No description provided.