From d1ddef9afe8b280e7330acdeccf12dd4f354aca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 17 Feb 2026 10:44:16 +0100 Subject: [PATCH 1/5] Ensure route settlement on iOS before handling DNS responses to prevent bypassing the tunnel. --- .../routemanager/dnsinterceptor/handler.go | 5 +++++ .../dnsinterceptor/handler_ios.go | 20 +++++++++++++++++++ .../dnsinterceptor/handler_nonios.go | 7 +++++++ 3 files changed, 32 insertions(+) create mode 100644 client/internal/routemanager/dnsinterceptor/handler_ios.go create mode 100644 client/internal/routemanager/dnsinterceptor/handler_nonios.go diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 12c9ff4af38..4bf0d547679 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -351,6 +351,11 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg, logger *log. logger.Errorf("failed to update domain prefixes: %v", err) } + // Allow time for route changes to be applied before sending + // the DNS response (relevant on iOS where setTunnelNetworkSettings + // is asynchronous). + waitForRouteSettlement(logger) + d.replaceIPsInDNSResponse(r, newPrefixes, logger) } } diff --git a/client/internal/routemanager/dnsinterceptor/handler_ios.go b/client/internal/routemanager/dnsinterceptor/handler_ios.go new file mode 100644 index 00000000000..ad4058e89c2 --- /dev/null +++ b/client/internal/routemanager/dnsinterceptor/handler_ios.go @@ -0,0 +1,20 @@ +//go:build ios + +package dnsinterceptor + +import ( + "time" + + log "github.com/sirupsen/logrus" +) + +const routeSettleDelay = 500 * time.Millisecond + +// waitForRouteSettlement introduces a short delay on iOS to allow +// setTunnelNetworkSettings to apply route changes before the DNS +// response reaches the application. Without this, the first request +// to a newly resolved domain may bypass the tunnel. +func waitForRouteSettlement(logger *log.Entry) { + logger.Debugf("waiting %v for iOS route settlement", routeSettleDelay) + time.Sleep(routeSettleDelay) +} diff --git a/client/internal/routemanager/dnsinterceptor/handler_nonios.go b/client/internal/routemanager/dnsinterceptor/handler_nonios.go new file mode 100644 index 00000000000..6621144ae3e --- /dev/null +++ b/client/internal/routemanager/dnsinterceptor/handler_nonios.go @@ -0,0 +1,7 @@ +//go:build !ios + +package dnsinterceptor + +import log "github.com/sirupsen/logrus" + +func waitForRouteSettlement(_ *log.Entry) {} From 4163012a732e4eaf083aec6a85bc6ec6fc16664f Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Wed, 18 Feb 2026 17:46:18 +0100 Subject: [PATCH 2/5] add more logs --- client/internal/engine.go | 15 +++++++++-- client/internal/peer/notifier.go | 25 +++++++++++++++++++ .../routemanager/dnsinterceptor/handler.go | 1 + .../routemanager/notifier/notifier_ios.go | 3 +++ .../routemanager/systemops/systemops_ios.go | 4 +++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index 4f3cf0998a2..bec7233a96e 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -28,8 +28,8 @@ import ( "github.com/netbirdio/netbird/client/firewall" firewallManager "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" - nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/device" + nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/internal/acl" "github.com/netbirdio/netbird/client/internal/debug" @@ -224,6 +224,7 @@ type Engine struct { jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup + runID int } // Peer is an instance of the Connection Peer @@ -276,6 +277,10 @@ func NewEngine( } func (e *Engine) Stop() error { + start := time.Now() + defer func() { + log.Infof("Engine run %d stop took %s", e.runID, time.Since(start)) + }() if e == nil { // this seems to be a very odd case but there was the possibility if the netbird down command comes before the engine is fully started log.Debugf("tried stopping engine that is nil") @@ -408,6 +413,12 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error { // Connections to remote peers are not established here. // However, they will be established once an event with a list of peers to connect to will be received from Management Service func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error { + start := time.Now() + e.runID++ + defer func() { + log.Infof("Engine run %d start took %s", e.runID, time.Since(start)) + }() + e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() @@ -832,7 +843,7 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { started := time.Now() defer func() { - log.Infof("sync finished in %s", time.Since(started)) + log.Infof("sync for runID %d finished in %s", e.runID, time.Since(started)) }() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 8d1954fe5ab..8071e5f4bc2 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -2,6 +2,8 @@ package peer import ( "sync" + + log "github.com/sirupsen/logrus" ) const ( @@ -56,6 +58,8 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { calculatedState := n.calculateState(mgmState, signalState) if !n.isServerStateChanged(calculatedState) { + log.Infof("--- dnsdebug -- updateServerStates didn't change = %s", stateToString(calculatedState)) + n.serverStateLock.Unlock() return } @@ -63,6 +67,8 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { n.lastNotification = calculatedState n.serverStateLock.Unlock() + log.Infof("--- dnsdebug -- updateServerStates changed = %s", stateToString(calculatedState)) + n.notify(calculatedState) } @@ -71,6 +77,7 @@ func (n *notifier) clientStart() { n.currentClientState = true n.lastNotification = stateConnecting n.serverStateLock.Unlock() + log.Info("--- dnsdebug -- clientStart") n.notify(stateConnecting) } @@ -80,6 +87,7 @@ func (n *notifier) clientStop() { n.currentClientState = false n.lastNotification = stateDisconnected n.serverStateLock.Unlock() + log.Info("--- dnsdebug -- clientStop") n.notify(stateDisconnected) } @@ -90,6 +98,8 @@ func (n *notifier) clientTearDown() { n.lastNotification = stateDisconnecting n.serverStateLock.Unlock() + log.Info("--- dnsdebug -- clientTearDown") + n.notify(stateDisconnecting) } @@ -125,6 +135,21 @@ func (n *notifier) calculateState(managementConn, signalConn bool) int { return stateConnecting } +func stateToString(state int) string { + switch state { + case stateConnected: + return "connected" + case stateDisconnected: + return "disconnected" + case stateConnecting: + return "connecting" + case stateDisconnecting: + return "disconnecting" + default: + return "unknown" + } +} + func (n *notifier) peerListChanged(numOfPeers int) { n.serverStateLock.Lock() n.lastNumberOfPeers = numOfPeers diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 4bf0d547679..a7d25540d31 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -150,6 +150,7 @@ func (d *DnsInterceptor) addAllowedIPForPrefix(realPrefix netip.Prefix, peerKey // addRouteAndAllowedIP handles both route and AllowedIPs addition for a prefix func (d *DnsInterceptor) addRouteAndAllowedIP(realPrefix netip.Prefix, domain domain.Domain) error { // Routes use fake IPs (so traffic to fake IPs gets routed to interface) + log.Infof("--- dnsdebug -- addRouteAndAllowedIP - domain: %s , net: %s", domain.SafeString(), realPrefix.Addr()) routePrefix := d.transformRealToFakePrefix(realPrefix) if _, err := d.routeRefCounter.Increment(routePrefix, struct{}{}); err != nil { return fmt.Errorf("add route for IP %s: %v", routePrefix, err) diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index bb125cfa4e4..97e51264ec3 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -11,6 +11,7 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/route" + log "github.com/sirupsen/logrus" ) type Notifier struct { @@ -47,10 +48,12 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { sort.Strings(newNets) if slices.Equal(n.currentPrefixes, newNets) { + log.Infof("--- dnsdebug -- OnNewPrefixes skipped: %s", newNets) return } n.currentPrefixes = newNets + log.Infof("--- dnsdebug -- OnNewPrefixes sent: %s", newNets) n.notify() } diff --git a/client/internal/routemanager/systemops/systemops_ios.go b/client/internal/routemanager/systemops/systemops_ios.go index 99a363371ac..49ae6139408 100644 --- a/client/internal/routemanager/systemops/systemops_ios.go +++ b/client/internal/routemanager/systemops/systemops_ios.go @@ -32,6 +32,8 @@ func (r *SysOps) AddVPNRoute(prefix netip.Prefix, _ *net.Interface) error { r.mu.Lock() defer r.mu.Unlock() + log.Infof("--- dnsdebug -- Adding VPN route prefix %s", prefix) + r.prefixes[prefix] = struct{}{} r.notify() return nil @@ -41,6 +43,8 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, _ *net.Interface) error { r.mu.Lock() defer r.mu.Unlock() + log.Infof("--- dnsdebug -- Removing VPN route prefix %s", prefix) + delete(r.prefixes, prefix) r.notify() return nil From 8c08d4915251d7f6919daa5ec7863f99a6c17fdb Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Thu, 19 Feb 2026 14:01:28 +0100 Subject: [PATCH 3/5] rollback debug changes --- client/internal/engine.go | 15 ----------- client/internal/peer/notifier.go | 27 ------------------- .../routemanager/dnsinterceptor/handler.go | 1 - .../routemanager/notifier/notifier_ios.go | 3 --- .../routemanager/systemops/systemops_ios.go | 4 --- 5 files changed, 50 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index bec7233a96e..5ebd719648f 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -224,7 +224,6 @@ type Engine struct { jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup - runID int } // Peer is an instance of the Connection Peer @@ -277,10 +276,6 @@ func NewEngine( } func (e *Engine) Stop() error { - start := time.Now() - defer func() { - log.Infof("Engine run %d stop took %s", e.runID, time.Since(start)) - }() if e == nil { // this seems to be a very odd case but there was the possibility if the netbird down command comes before the engine is fully started log.Debugf("tried stopping engine that is nil") @@ -413,12 +408,6 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error { // Connections to remote peers are not established here. // However, they will be established once an event with a list of peers to connect to will be received from Management Service func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error { - start := time.Now() - e.runID++ - defer func() { - log.Infof("Engine run %d start took %s", e.runID, time.Since(start)) - }() - e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() @@ -841,10 +830,6 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate } func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { - started := time.Now() - defer func() { - log.Infof("sync for runID %d finished in %s", e.runID, time.Since(started)) - }() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 8071e5f4bc2..5b500936d46 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -2,8 +2,6 @@ package peer import ( "sync" - - log "github.com/sirupsen/logrus" ) const ( @@ -58,8 +56,6 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { calculatedState := n.calculateState(mgmState, signalState) if !n.isServerStateChanged(calculatedState) { - log.Infof("--- dnsdebug -- updateServerStates didn't change = %s", stateToString(calculatedState)) - n.serverStateLock.Unlock() return } @@ -67,8 +63,6 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { n.lastNotification = calculatedState n.serverStateLock.Unlock() - log.Infof("--- dnsdebug -- updateServerStates changed = %s", stateToString(calculatedState)) - n.notify(calculatedState) } @@ -77,8 +71,6 @@ func (n *notifier) clientStart() { n.currentClientState = true n.lastNotification = stateConnecting n.serverStateLock.Unlock() - log.Info("--- dnsdebug -- clientStart") - n.notify(stateConnecting) } @@ -87,7 +79,6 @@ func (n *notifier) clientStop() { n.currentClientState = false n.lastNotification = stateDisconnected n.serverStateLock.Unlock() - log.Info("--- dnsdebug -- clientStop") n.notify(stateDisconnected) } @@ -98,8 +89,6 @@ func (n *notifier) clientTearDown() { n.lastNotification = stateDisconnecting n.serverStateLock.Unlock() - log.Info("--- dnsdebug -- clientTearDown") - n.notify(stateDisconnecting) } @@ -134,22 +123,6 @@ func (n *notifier) calculateState(managementConn, signalConn bool) int { return stateConnecting } - -func stateToString(state int) string { - switch state { - case stateConnected: - return "connected" - case stateDisconnected: - return "disconnected" - case stateConnecting: - return "connecting" - case stateDisconnecting: - return "disconnecting" - default: - return "unknown" - } -} - func (n *notifier) peerListChanged(numOfPeers int) { n.serverStateLock.Lock() n.lastNumberOfPeers = numOfPeers diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index a7d25540d31..4bf0d547679 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -150,7 +150,6 @@ func (d *DnsInterceptor) addAllowedIPForPrefix(realPrefix netip.Prefix, peerKey // addRouteAndAllowedIP handles both route and AllowedIPs addition for a prefix func (d *DnsInterceptor) addRouteAndAllowedIP(realPrefix netip.Prefix, domain domain.Domain) error { // Routes use fake IPs (so traffic to fake IPs gets routed to interface) - log.Infof("--- dnsdebug -- addRouteAndAllowedIP - domain: %s , net: %s", domain.SafeString(), realPrefix.Addr()) routePrefix := d.transformRealToFakePrefix(realPrefix) if _, err := d.routeRefCounter.Increment(routePrefix, struct{}{}); err != nil { return fmt.Errorf("add route for IP %s: %v", routePrefix, err) diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 97e51264ec3..bb125cfa4e4 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -11,7 +11,6 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/route" - log "github.com/sirupsen/logrus" ) type Notifier struct { @@ -48,12 +47,10 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { sort.Strings(newNets) if slices.Equal(n.currentPrefixes, newNets) { - log.Infof("--- dnsdebug -- OnNewPrefixes skipped: %s", newNets) return } n.currentPrefixes = newNets - log.Infof("--- dnsdebug -- OnNewPrefixes sent: %s", newNets) n.notify() } diff --git a/client/internal/routemanager/systemops/systemops_ios.go b/client/internal/routemanager/systemops/systemops_ios.go index 49ae6139408..99a363371ac 100644 --- a/client/internal/routemanager/systemops/systemops_ios.go +++ b/client/internal/routemanager/systemops/systemops_ios.go @@ -32,8 +32,6 @@ func (r *SysOps) AddVPNRoute(prefix netip.Prefix, _ *net.Interface) error { r.mu.Lock() defer r.mu.Unlock() - log.Infof("--- dnsdebug -- Adding VPN route prefix %s", prefix) - r.prefixes[prefix] = struct{}{} r.notify() return nil @@ -43,8 +41,6 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, _ *net.Interface) error { r.mu.Lock() defer r.mu.Unlock() - log.Infof("--- dnsdebug -- Removing VPN route prefix %s", prefix) - delete(r.prefixes, prefix) r.notify() return nil From bbfbc26087a22a3019ced9adc99084627687599d Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Thu, 19 Feb 2026 14:18:23 +0100 Subject: [PATCH 4/5] rollback changes --- client/internal/engine.go | 4 ++++ client/internal/peer/notifier.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/client/internal/engine.go b/client/internal/engine.go index 5ebd719648f..beb2a411c82 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -830,6 +830,10 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate } func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { + started := time.Now() + defer func() { + log.Infof("sync finished in %s", time.Since(started)) + }() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 5b500936d46..8d1954fe5ab 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -71,6 +71,7 @@ func (n *notifier) clientStart() { n.currentClientState = true n.lastNotification = stateConnecting n.serverStateLock.Unlock() + n.notify(stateConnecting) } @@ -123,6 +124,7 @@ func (n *notifier) calculateState(managementConn, signalConn bool) int { return stateConnecting } + func (n *notifier) peerListChanged(numOfPeers int) { n.serverStateLock.Lock() n.lastNumberOfPeers = numOfPeers From f33020e3127965e075efca20e49c1c12058e233a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 19 Feb 2026 18:06:24 +0100 Subject: [PATCH 5/5] [client] Improve logging and add comments for iOS route settlement logic - Switch iOS route settlement log level from Debug to Trace for finer control. - Add clarifying comments for `waitForRouteSettlement` on non-iOS platforms. --- client/internal/routemanager/dnsinterceptor/handler_ios.go | 2 +- .../internal/routemanager/dnsinterceptor/handler_nonios.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/client/internal/routemanager/dnsinterceptor/handler_ios.go b/client/internal/routemanager/dnsinterceptor/handler_ios.go index ad4058e89c2..4cf80eb164a 100644 --- a/client/internal/routemanager/dnsinterceptor/handler_ios.go +++ b/client/internal/routemanager/dnsinterceptor/handler_ios.go @@ -15,6 +15,6 @@ const routeSettleDelay = 500 * time.Millisecond // response reaches the application. Without this, the first request // to a newly resolved domain may bypass the tunnel. func waitForRouteSettlement(logger *log.Entry) { - logger.Debugf("waiting %v for iOS route settlement", routeSettleDelay) + logger.Tracef("waiting %v for iOS route settlement", routeSettleDelay) time.Sleep(routeSettleDelay) } diff --git a/client/internal/routemanager/dnsinterceptor/handler_nonios.go b/client/internal/routemanager/dnsinterceptor/handler_nonios.go index 6621144ae3e..68cd7330bbc 100644 --- a/client/internal/routemanager/dnsinterceptor/handler_nonios.go +++ b/client/internal/routemanager/dnsinterceptor/handler_nonios.go @@ -4,4 +4,9 @@ package dnsinterceptor import log "github.com/sirupsen/logrus" -func waitForRouteSettlement(_ *log.Entry) {} +func waitForRouteSettlement(_ *log.Entry) { + // No-op on non-iOS platforms: route changes are applied synchronously by + // the kernel, so no settlement delay is needed before the DNS response + // reaches the application. The delay is only required on iOS where + // setTunnelNetworkSettings applies routes asynchronously. +}