From de39cad71801934cbea7d1d61073db64c35d3e35 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sat, 5 Sep 2026 20:31:33 +0200 Subject: [PATCH] [client] Wait for the overlay address before binding the file drop receiver iOS applies the tunnel address out of band, after handing the engine the tun fd, so the receiver bound to that address a moment too early and failed with EADDRNOTAVAIL. Nothing retried it, leaving the peer able to send but never to receive for the life of the connection. Wait for the address to appear and bind then, and let a rebind start a receiver that is down rather than skipping it. Only the subsystems that asked to be bound are bound, so the SSH sessions and DNS queries that other listeners are carrying are left alone. The wait itself is iOS-only: every other platform assigns the address in the call chain that creates the interface, or is handed one that already carries it, so there it compiles down to nothing. --- client/internal/engine.go | 1 + client/internal/engine_filedrop.go | 27 +++- client/internal/engine_overlay_wait.go | 20 +++ client/internal/engine_overlay_wait_ios.go | 136 +++++++++++++++++++++ 4 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 client/internal/engine_overlay_wait.go create mode 100644 client/internal/engine_overlay_wait_ios.go diff --git a/client/internal/engine.go b/client/internal/engine.go index f96451e54..f93d54772 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -257,6 +257,7 @@ type Engine struct { fileDrop *filedrop.Manager fileDropRunning bool fileDropPort uint16 + overlayWait overlayWaiter statusRecorder *peer.Status diff --git a/client/internal/engine_filedrop.go b/client/internal/engine_filedrop.go index a7ebd698c..81d94c887 100644 --- a/client/internal/engine_filedrop.go +++ b/client/internal/engine_filedrop.go @@ -15,6 +15,8 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" ) +const fileDropWatchName = "the file drop receiver" + type filedropResolver struct { status *peer.Status } @@ -45,6 +47,12 @@ func (e *Engine) startFileDrop() { } wgAddr := e.wgInterface.Address() + if !e.overlayAddrReady(wgAddr.IP) { + log.Infof("file drop receiver waits for the overlay address %s", wgAddr.IP) + e.armOverlayWatch(fileDropWatchName, e.restartFileDrop) + return + } + addr := netip.AddrPortFrom(wgAddr.IP, fileDropListenPort()) resolver := filedropResolver{status: e.statusRecorder} @@ -63,6 +71,7 @@ func (e *Engine) startFileDrop() { if v6 := wgAddr.IPv6; v6.IsValid() { if err := e.fileDrop.AddReceiverListener(e.ctx, netip.AddrPortFrom(v6, bound)); err != nil { log.Warnf("failed to add IPv6 file drop listener: %v", err) + e.armOverlayWatch(fileDropWatchName, e.restartFileDrop) } } @@ -142,13 +151,19 @@ func (e *Engine) setFileDropTunnel() { e.fileDrop.SetTunnel(dial, e.statusRecorder.GetLocalPeerState().FQDN) } -// restartFileDrop rebinds the receiver after the platform replaced the tunnel -// device. The listeners are bound to the overlay address of the interface being -// swapped out and do not survive it: Android renews the tun on every route -// change, which leaves the IPv4 listener dead with accept4: invalid argument. -// No-op when it is not running. See Engine.rebindOverlayListeners. +// restartFileDrop gives the receiver listeners on the interface as it is now. +// The listeners are bound to the overlay address of the interface being swapped +// out and do not survive it: Android renews the tun on every route change, which +// leaves the IPv4 listener dead with accept4: invalid argument. A receiver that +// is not running is started rather than skipped, since the reason it is down may +// be the very bind this rebind can now make. See Engine.rebindOverlayListeners. func (e *Engine) restartFileDrop() error { - if e.fileDrop == nil || !e.fileDropRunning || e.wgInterface == nil { + if e.fileDrop == nil || e.wgInterface == nil { + return nil + } + + if !e.fileDropRunning { + e.startFileDrop() return nil } diff --git a/client/internal/engine_overlay_wait.go b/client/internal/engine_overlay_wait.go new file mode 100644 index 000000000..285ed3736 --- /dev/null +++ b/client/internal/engine_overlay_wait.go @@ -0,0 +1,20 @@ +//go:build !ios + +package internal + +import "net/netip" + +// overlayWaiter carries no state off iOS. Every other platform assigns the +// overlay address in the same call chain that creates the interface, or hands +// the engine an interface that already carries it, so a listener bound right +// after has nothing to wait for. See the iOS variant for what the wait is. +type overlayWaiter struct{} + +// overlayAddrReady reports whether ip can be bound. Always true here. +func (e *Engine) overlayAddrReady(netip.Addr) bool { + return true +} + +// armOverlayWatch has nothing to watch here and is never reached, since +// overlayAddrReady never refuses. +func (e *Engine) armOverlayWatch(string, overlayRebind) {} diff --git a/client/internal/engine_overlay_wait_ios.go b/client/internal/engine_overlay_wait_ios.go new file mode 100644 index 000000000..f13bceea2 --- /dev/null +++ b/client/internal/engine_overlay_wait_ios.go @@ -0,0 +1,136 @@ +//go:build ios + +package internal + +import ( + "maps" + "net" + "net/netip" + "slices" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + overlayWatchInterval = 200 * time.Millisecond + overlayWatchTimeout = time.Minute +) + +// overlayWaiter holds what waits for the overlay address, keyed by name so a +// second arming of the same subsystem replaces it. Guarded by syncMsgMux. +type overlayWaiter struct { + watching bool + waiters map[string]overlayRebind +} + +// overlayAddrReady reports whether ip is on the tunnel interface as it is now. +// +// The engine learning its overlay address is not the same as the address being +// usable. iOS does not let the client touch the interface: it hands the engine +// a tun fd and applies the address itself, out of band and without telling the +// engine when it lands. A bind placed in between fails with EADDRNOTAVAIL, and +// the listener has no way back on its own. The caller must hold syncMsgMux. +func (e *Engine) overlayAddrReady(ip netip.Addr) bool { + if e.wgInterface == nil || !ip.IsValid() { + return false + } + + iface, err := net.InterfaceByName(e.wgInterface.Name()) + if err != nil { + log.Debugf("look up tunnel interface %s: %v", e.wgInterface.Name(), err) + return false + } + addrs, err := iface.Addrs() + if err != nil { + log.Debugf("list addresses of %s: %v", e.wgInterface.Name(), err) + return false + } + + for _, addr := range addrs { + prefix, err := netip.ParsePrefix(addr.String()) + if err != nil { + continue + } + if prefix.Addr().Unmap() == ip.Unmap() { + return true + } + } + return false +} + +// armOverlayWatch waits for the overlay address to appear and then binds what +// asked to be bound. +// +// Only the subsystems that armed the watch are bound. This is deliberately +// narrower than rebindOverlayListeners: an address that was missing says +// nothing about the listeners that did come up, and rebuilding those would drop +// the SSH sessions and DNS queries they are carrying. The caller must hold +// syncMsgMux. +func (e *Engine) armOverlayWatch(name string, ensure overlayRebind) { + if e.ctx.Err() != nil { + return + } + + if e.overlayWait.waiters == nil { + e.overlayWait.waiters = make(map[string]overlayRebind) + } + e.overlayWait.waiters[name] = ensure + + if e.overlayWait.watching { + return + } + e.overlayWait.watching = true + + e.shutdownWg.Add(1) + go func() { + defer e.shutdownWg.Done() + + ticker := time.NewTicker(overlayWatchInterval) + defer ticker.Stop() + deadline := time.NewTimer(overlayWatchTimeout) + defer deadline.Stop() + + for { + select { + case <-e.ctx.Done(): + return + case <-deadline.C: + e.syncMsgMux.Lock() + waiting := slices.Sorted(maps.Keys(e.overlayWait.waiters)) + e.overlayWait = overlayWaiter{} + e.syncMsgMux.Unlock() + log.Errorf("overlay address did not appear within %s, still down: %s", + overlayWatchTimeout, strings.Join(waiting, ", ")) + return + case <-ticker.C: + if e.bindOverlayWaiters() { + return + } + } + } + }() +} + +// bindOverlayWaiters runs what the watch collected once the overlay address is +// up, reporting whether the wait is over. +func (e *Engine) bindOverlayWaiters() bool { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + if e.wgInterface == nil || !e.overlayAddrReady(e.wgInterface.Address().IP) { + return false + } + + waiters := e.overlayWait.waiters + e.overlayWait = overlayWaiter{} + + for name, ensure := range waiters { + log.Infof("overlay address is up, binding %s", name) + if err := ensure(); err != nil { + log.Errorf("bind %s after the overlay address came up: %v", name, err) + } + } + return true +}