[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.
This commit is contained in:
Zoltan Papp
2026-09-05 20:31:33 +02:00
parent 932e3c263a
commit de39cad718
4 changed files with 178 additions and 6 deletions
+1
View File
@@ -257,6 +257,7 @@ type Engine struct {
fileDrop *filedrop.Manager
fileDropRunning bool
fileDropPort uint16
overlayWait overlayWaiter
statusRecorder *peer.Status
+21 -6
View File
@@ -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
}
+20
View File
@@ -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) {}
+136
View File
@@ -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
}