mirror of
https://github.com/amnezia-vpn/amneziawg-apple.git
synced 2026-10-13 21:34:11 +00:00
[PR #47] fix: recover a black-holed tunnel by restarting for a new source port #46
Labels
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
amnezia-vpn/amneziawg-apple#46
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 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)
23a4e0ffix: recover a black-holed tunnel by restarting for a new source port9d70274fix: 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 callwgSetConfig+wgDisableSomeRoamingForBrokenMobileSemantics+wgBumpSockets. The.temporaryShutdown→ resume branch (the only one that callsstartWireGuardBackend) is reachedonly when the path goes
.unsatisfied.wgBumpSockets(Sources/WireGuardKitGo/api-apple.go) →dev.BindUpdate().BindUpdate(amneziawg-godevice/device.go) →netc.bind.Open(netc.port)— it passes thepreviously bound port back in, and
closeBindLockednever clearsnetc.port.StdNetBind.Open(conn/bind_std.go) binds that specific port; it only lets the OS choose a freshephemeral port when the requested port is
0.wgTurnOn→device.NewDevice(..., conn.NewStdNetBind(), ...)starts withnet.port == 0, and aclient 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
wgBumpSocketssends on success alike — leaves from the samenow-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 thetunnel is up). Every 15 s it reads the runtime config and looks for the black-hole signature:
PersistentKeepalive), andAfter 3 consecutive strikes (~45 s) it performs a full backend restart —
wgTurnOffthenstartWireGuardBackendwith the existing settings generator — which creates a fresh device andtherefore 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:
everHadHandshake), so atunnel that never worked is left alone.
is restarted. In my captures ordinary cellular NAT churn happens ~12–20×/hour and never trips it.
NWPathMonitormoves the state to.temporaryShutdownand thewatchdog skips (it requires
.started), so it will not restart in a loop while offline.UDP socket is dead.
Tunables at the top of
WireGuardAdapter:wedgeTickSeconds(15),wedgeHandshakeStaleSeconds(60),wedgeStrikesToRestart(3).Testing
Open(0)took port 52427; closing and re-opening with the stored port (whatBindUpdatedoes)returned the same 52427; a fresh
NewStdNetBind().Open(0)(what a restart does) returned adifferent port. That is the whole mechanism in two calls.
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.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:
965xSending handshake initiation,0receives of any kind, and9xNetwork change detected ... -> UDP bind has been updated— nine rebinds, not one of whichrecovered it, which is the bug in one line. Runtime config read at 02:37:44 while still dead:
last_handshake_time_secage 6632 s,rx_bytes=5227148,tx_bytes=3267683. Server-side over afull 60 s sample during the same window: zero bytes received from this peer, despite
PersistentKeepalive = 25. The toggle at 02:41:25 producedReceived handshake responseat02: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.
masterbutI 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-Bytrailer). The packet captures, client logs and the reproduction are my own, and everyclaim 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.