[GH-ISSUE #2149] Transitioning between LTE and Wi-Fi on iOS AmneziaWG corrupts connection to WARP #953

Open
opened 2026-10-13 20:31:38 +00:00 by bsakaguchi · 2 comments
Owner

Originally created by @DmitriyGo7 on GitHub (Jan 23, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/2149

In AmneziaWG I have on-demand activation turned on for both Wi-Fi and cellular, for any SSID. I'm on iOS 15.

When my iPhone autoconnects from LTE Internet to Wi-Fi Internet (when I come home), VPN indicator still shows on top of the screen and connection toggle in AmneziaWG is still ON (green, "Active (On-Demand)"), but in fact the VPN connection is not working at all, no webpages load. I'm connecting to Cloudflare WARP free VPN (in Russia). Toggling VPN OFF and back ON in AmneziaWG solves the problem, VPN starts working after that.

The same problem manifests when transitioning from Wi-Fi to LTE, and toggling VPN OFF and back ON helps in that case as well.

Expected behavior is smooth transition between LTE and Wi-Fi, with continuously working VPN, not having to go to AmneziaWG app every time I come home or go outside.

My device is iPhone 7 Plus on iOS 15.8.5
AmneziaWG 2.0.0 (11)

Server is free Cloudflare WARP using AmneziaWG profile generated by button "AWG 1.5 (2 вариант)" in https://warp-generator.github.io/warp/

Originally created by @DmitriyGo7 on GitHub (Jan 23, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/2149 In AmneziaWG I have on-demand activation turned on for both Wi-Fi and cellular, for any SSID. I'm on iOS 15. When my iPhone autoconnects from LTE Internet to Wi-Fi Internet (when I come home), VPN indicator still shows on top of the screen and connection toggle in AmneziaWG is still ON (green, "Active (On-Demand)"), but in fact the VPN connection is not working at all, no webpages load. I'm connecting to Cloudflare WARP free VPN (in Russia). Toggling VPN OFF and back ON in AmneziaWG solves the problem, VPN starts working after that. The same problem manifests when transitioning from Wi-Fi to LTE, and toggling VPN OFF and back ON helps in that case as well. Expected behavior is smooth transition between LTE and Wi-Fi, with continuously working VPN, not having to go to AmneziaWG app every time I come home or go outside. My device is iPhone 7 Plus on iOS 15.8.5 AmneziaWG 2.0.0 (11) Server is free Cloudflare WARP using AmneziaWG profile generated by button "AWG 1.5 (2 вариант)" in https://warp-generator.github.io/warp/
Author
Owner

@kda2495 commented on GitHub (Feb 16, 2026):

PersistentKeepalive = 15 (or 10, 5) doesn't help. We need reconnect feature if connection is lost or changed.

<!-- gh-comment-id:3908385576 --> @kda2495 commented on GitHub (Feb 16, 2026): PersistentKeepalive = 15 (or 10, 5) doesn't help. We need reconnect feature if connection is lost or changed.
Author
Owner

@shadypayload commented on GitHub (Sep 15, 2026):

I think I have the root cause for this, and a proposed patch. I hit the same thing on iOS 26.6.2
with AmneziaWG 3.1.4 and instrumented both ends of it for several days.

What the captures show. 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 entire time. The moment the tunnel is toggled by hand, the first packet arrives from
a new source port
and the handshake completes in ~1 ms.

My clearest specimen (2026-09-15, iOS 26.6.2, AmneziaWG 3.1.4): last good handshake 00:47:12, dead
until I toggled at 02:41:25 — 114 minutes. In the 84 minutes the log ring still covered: 965
Sending handshake initiation, zero receives, and nine Network change detected ... -> UDP bind has been updated — nine rebinds, none of which recovered it. Server-side during the same
window: zero bytes received from the peer across a full 60 s sample, despite
PersistentKeepalive = 25. The toggle produced Received handshake response two seconds later.

Why. On a path change the extension only bumps the socket, and a bump reuses the same local UDP
port:

  • WireGuardAdapter.didReceivePathUpdate (iOS): a satisfiable path takes the
    wgSetConfig + wgBumpSockets branch. The branch that actually restarts the backend is only
    reached when the path goes .unsatisfied.
  • wgBumpSockets → dev.BindUpdate() → netc.bind.Open(netc.port), which passes the previously
    bound port back in; StdNetBind.Open only picks a fresh ephemeral port when asked for port 0.
  • Only a full start (wgTurnOn → NewDevice → NewStdNetBind → Open(0)) gets a new port — which
    is precisely why the manual toggle is the one thing that works.

So once a path change invalidates the old flow's NAT/CGNAT mapping, every packet afterwards leaves
from the same dead 4-tuple and WireGuard retries forever. An LTE↔Wi-Fi transition is exactly this
case, which matches the original report here.

Why On-Demand doesn't save it (you mentioned having it on, and so did I): the extension stays
.started and the path stays satisfied throughout, so there is nothing for On-Demand to act on —
it governs when a tunnel is started, not whether a running one is still passing traffic.

Why the handshake-freshness check added in 3.1.x doesn't save it either: 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 life of the tunnel.

I've opened a PR against amneziawg-apple with a small watchdog in the network extension (which
stays alive) that detects the signature — tx advancing, rx flat, handshake stale — and does a full
backend restart to get a fresh source port, i.e. what the manual toggle does, automatically:
https://github.com/amnezia-vpn/amneziawg-apple/pull/47

Fair warning on that PR: I can't build it on-device (no paid Apple Developer account, and the
Network Extension entitlement needs one), so it is a proposed fix for review rather than a
device-tested one. Happy to share the packet captures and client logs if they'd help.

Disclosure: the source trace and patch were produced with AI assistance; the captures, logs and
reproduction are my own, and the reasoning is cited to source so it can be checked.

<!-- gh-comment-id:5678672188 --> @shadypayload commented on GitHub (Sep 15, 2026): I think I have the root cause for this, and a proposed patch. I hit the same thing on iOS 26.6.2 with AmneziaWG 3.1.4 and instrumented both ends of it for several days. **What the captures show.** 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 entire time. The moment the tunnel is toggled by hand, the first packet arrives **from a new source port** and the handshake completes in ~1 ms. My clearest specimen (2026-09-15, iOS 26.6.2, AmneziaWG 3.1.4): last good handshake 00:47:12, dead until I toggled at 02:41:25 — **114 minutes**. In the 84 minutes the log ring still covered: 965 `Sending handshake initiation`, **zero** receives, and **nine** `Network change detected ... -> UDP bind has been updated` — nine rebinds, none of which recovered it. Server-side during the same window: zero bytes received from the peer across a full 60 s sample, despite `PersistentKeepalive = 25`. The toggle produced `Received handshake response` two seconds later. **Why.** On a path change the extension only *bumps* the socket, and a bump reuses the same local UDP port: - `WireGuardAdapter.didReceivePathUpdate` (iOS): a **satisfiable** path takes the `wgSetConfig` + `wgBumpSockets` branch. The branch that actually restarts the backend is only reached when the path goes `.unsatisfied`. - `wgBumpSockets` → `dev.BindUpdate()` → `netc.bind.Open(netc.port)`, which passes the previously bound port back in; `StdNetBind.Open` only picks a fresh ephemeral port when asked for port `0`. - Only a full start (`wgTurnOn` → `NewDevice` → `NewStdNetBind` → `Open(0)`) gets a new port — which is precisely why the manual toggle is the one thing that works. So once a path change invalidates the old flow's NAT/CGNAT mapping, every packet afterwards leaves from the same dead 4-tuple and WireGuard retries forever. An LTE↔Wi-Fi transition is exactly this case, which matches the original report here. **Why On-Demand doesn't save it** (you mentioned having it on, and so did I): the extension stays `.started` and the path stays *satisfied* throughout, so there is nothing for On-Demand to act on — it governs when a tunnel is *started*, not whether a running one is still passing traffic. **Why the handshake-freshness check added in 3.1.x doesn't save it either:** 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 life of the tunnel. I've opened a PR against amneziawg-apple with a small watchdog in the **network extension** (which stays alive) that detects the signature — tx advancing, rx flat, handshake stale — and does a full backend restart to get a fresh source port, i.e. what the manual toggle does, automatically: https://github.com/amnezia-vpn/amneziawg-apple/pull/47 Fair warning on that PR: I can't build it on-device (no paid Apple Developer account, and the Network Extension entitlement needs one), so it is a proposed fix for review rather than a device-tested one. Happy to share the packet captures and client logs if they'd help. *Disclosure: the source trace and patch were produced with AI assistance; the captures, logs and reproduction are my own, and the reasoning is cited to source so it can be checked.*
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/amnezia-client#953
No description provided.