Skip to content

[ios] Ensure route settlement on iOS before handling DNS responses#5360

Merged
pappz merged 7 commits intomainfrom
fix/ios-dns-first-response
Feb 19, 2026
Merged

[ios] Ensure route settlement on iOS before handling DNS responses#5360
pappz merged 7 commits intomainfrom
fix/ios-dns-first-response

Conversation

@pappz
Copy link
Copy Markdown
Collaborator

@pappz pappz commented Feb 17, 2026

Ensure route settlement on iOS before handling DNS responses to prevent bypassing the tunnel.

Describe your changes

Issue ticket number and link

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

Summary by CodeRabbit

  • Bug Fixes
    • DNS responses now wait briefly for route configuration changes to settle before replying, improving reliability on iOS where network setting updates are asynchronous.
    • Non-iOS platforms include a no-op placeholder so behavior remains unchanged on other platforms.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

Adds a platform-specific delay before replacing IPs in DNS responses: after updating domain prefixes the handler calls waitForRouteSettlement(logger) so route changes (notably iOS tunnel settings) have time to apply before the DNS reply is modified and sent.

Changes

Cohort / File(s) Summary
DNS interceptor handler
client/internal/routemanager/dnsinterceptor/handler.go
Calls waitForRouteSettlement(logger) after updating domain prefixes and before replacing IPs in DNS responses.
iOS route settlement
client/internal/routemanager/dnsinterceptor/handler_ios.go
New iOS-only file (//go:build ios) implementing waitForRouteSettlement(logger *log.Entry) which logs and sleeps for 500ms to allow async tunnel network settings to apply.
Non-iOS stub
client/internal/routemanager/dnsinterceptor/handler_nonios.go
New non-iOS file (//go:build !ios) providing a no-op waitForRouteSettlement(_ *log.Entry) so other platforms are unaffected.
Imports reorder
client/internal/engine.go
Minor import reordering (nbnetstack alias moved within import block).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • #5046: Modifies DnsInterceptor.writeMsg behavior/logging in the same handler, creating a direct code-level intersection with this timing change.

Suggested reviewers

  • pascal-fischer

Poem

🐰
I paused my hop for half a beat,
Let routes settle beneath my feet.
A sleepy wait, then DNS flies—
Smooth responses, no surprise. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding route settlement delay on iOS before DNS response handling.
Description check ✅ Passed The description follows the template structure with most sections filled, including change type (bug fix) and documentation justification, though the 'Describe your changes' and 'Issue ticket number' sections are empty.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/ios-dns-first-response

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
client/internal/routemanager/dnsinterceptor/handler.go (1)

354-358: The delay fires even when no routes actually changed.

waitForRouteSettlement is called whenever newPrefixes is non-empty, but updateDomainPrefixes may determine that all prefixes already exist (i.e., toAdd is empty), meaning no route changes were triggered. On iOS, this adds an unnecessary 500ms penalty to every repeated DNS lookup for an already-resolved domain.

Consider having updateDomainPrefixes return a boolean indicating whether routes actually changed, and only sleep when they did:

♻️ Suggested approach
-		if err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes, logger); err != nil {
+		changed, err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes, logger)
+		if err != nil {
 			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)
+		if changed {
+			// Allow time for route changes to be applied before sending
+			// the DNS response (relevant on iOS where setTunnelNetworkSettings
+			// is asynchronous).
+			waitForRouteSettlement(logger)
+		}

And in updateDomainPrefixes, return len(toAdd) > 0 || len(toRemove) > 0 as the changed indicator (adjusting the signature to (bool, error)).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/internal/routemanager/dnsinterceptor/handler.go` around lines 354 -
358, The current code always calls waitForRouteSettlement(logger) whenever
newPrefixes is non-empty, causing an unnecessary 500ms delay even if
updateDomainPrefixes made no changes; modify updateDomainPrefixes to return
(bool, error) where the bool is changed := (len(toAdd) > 0 || len(toRemove) >
0), then in the caller check that changed is true before calling
waitForRouteSettlement(logger) so the sleep only runs when routes were actually
modified.
client/internal/routemanager/dnsinterceptor/handler_ios.go (1)

11-19: Pragmatic workaround — consider documenting the 500ms rationale.

The fixed 500ms delay is a reasonable heuristic, but it's worth noting that under heavy load or on older devices, setTunnelNetworkSettings may take longer, while on fast paths it wastes time. If there's empirical data or Apple documentation backing this value, a brief comment on the constant would help future maintainers understand the trade-off.

Otherwise, the implementation is clean and well-commented.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/internal/routemanager/dnsinterceptor/handler_ios.go` around lines 11 -
19, Add documentation explaining why routeSettleDelay is 500ms and any
assumptions or evidence backing it: update the comment above the constant
routeSettleDelay and the waitForRouteSettlement function to note whether the
value is empirical or conservative, mention if it targets specific iOS
versions/devices or observed latency ranges, and indicate that it can be tuned
or converted to a dynamic/backoff strategy later; reference the routeSettleDelay
constant and waitForRouteSettlement(logger *log.Entry) so maintainers know where
to change or experiment with the value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/internal/routemanager/dnsinterceptor/handler_ios.go`:
- Around line 11-19: Add documentation explaining why routeSettleDelay is 500ms
and any assumptions or evidence backing it: update the comment above the
constant routeSettleDelay and the waitForRouteSettlement function to note
whether the value is empirical or conservative, mention if it targets specific
iOS versions/devices or observed latency ranges, and indicate that it can be
tuned or converted to a dynamic/backoff strategy later; reference the
routeSettleDelay constant and waitForRouteSettlement(logger *log.Entry) so
maintainers know where to change or experiment with the value.

In `@client/internal/routemanager/dnsinterceptor/handler.go`:
- Around line 354-358: The current code always calls
waitForRouteSettlement(logger) whenever newPrefixes is non-empty, causing an
unnecessary 500ms delay even if updateDomainPrefixes made no changes; modify
updateDomainPrefixes to return (bool, error) where the bool is changed :=
(len(toAdd) > 0 || len(toRemove) > 0), then in the caller check that changed is
true before calling waitForRouteSettlement(logger) so the sleep only runs when
routes were actually modified.

mlsmaycon
mlsmaycon previously approved these changes Feb 19, 2026
- Switch iOS route settlement log level from Debug to Trace for finer control.
- Add clarifying comments for `waitForRouteSettlement` on non-iOS platforms.
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/internal/routemanager/dnsinterceptor/handler_nonios.go`:
- Around line 7-12: The no-op waitForRouteSettlement in handler_nonios.go is
used on Android due to the !ios build tag, but Android's VpnService.establish()
applies routes asynchronously like iOS; either add an Android-specific
implementation or verify it's unnecessary. Create handler_android.go that
implements waitForRouteSettlement with the same short sleep/delay logic as
handler_ios.go (so the unconditional call from handler.go still waits), or if
you confirm Android timing is safe, document that and remove the
Android-specific sleep path by updating waitForRouteSettlement behavior
accordingly; reference the waitForRouteSettlement function, handler_ios.go
implementation, and the call site in handler.go for locating the change.

Comment on lines +7 to +12
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.
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for handler_*.go files in dnsinterceptor
echo "=== Handler files in dnsinterceptor ==="
fd -g 'handler_*.go' client/internal/routemanager/dnsinterceptor/

# Check for android references in dnsinterceptor
echo -e "\n=== Android references in dnsinterceptor ==="
rg -i 'android' client/internal/routemanager/dnsinterceptor/

# Check handler.go for build constraints and waitForRouteSettlement calls
echo -e "\n=== Build constraints and relevant code in handler.go ==="
rg -n '//go:build|//\+build|waitForRouteSettlement' client/internal/routemanager/dnsinterceptor/handler.go

# Check build tags in handler_nonios.go
echo -e "\n=== Build tags in handler_nonios.go ==="
head -10 client/internal/routemanager/dnsinterceptor/handler_nonios.go

# Look for Android target support at repo level
echo -e "\n=== Android GOOS references in root/build files ==="
fd -g 'Makefile|*.mk|*.sh|go.mod|go.sum' | head -5 | xargs rg -l 'android' 2>/dev/null || echo "No android refs in common build files"

# Check if there's any reference to Android vpn service or Android-specific route handling elsewhere
echo -e "\n=== Android VpnService or Android route handling in codebase ==="
rg -i 'vpnservice|android.*route|route.*android' client/ | head -20

Repository: netbirdio/netbird

Length of output: 6892


🏁 Script executed:

#!/bin/bash
# Check the iOS implementation
echo "=== handler_ios.go implementation ==="
cat -n client/internal/routemanager/dnsinterceptor/handler_ios.go

# Check context around line 357 in handler.go to see if there's Android gating
echo -e "\n=== Context around waitForRouteSettlement call in handler.go (line 357) ==="
sed -n '350,365p' client/internal/routemanager/dnsinterceptor/handler.go

# Check if there's any condition checking for android before calling waitForRouteSettlement
echo -e "\n=== Full context for waitForRouteSettlement usage ==="
rg -B5 -A5 'waitForRouteSettlement' client/internal/routemanager/dnsinterceptor/handler.go

# Check if there's Android-specific route settlement elsewhere
echo -e "\n=== Android route settlement or similar in Android-specific files ==="
rg -i 'settlement|delay.*route' client/android/ client/internal/routemanager/systemops/systemops_android.go client/internal/routemanager/notifier/notifier_android.go 2>/dev/null

Repository: netbirdio/netbird

Length of output: 1784


Android also executes handler_nonios.go's no-op — add Android-specific route settlement delay or verify it's not needed.

Android is a supported platform in NetBird, and because no handler_android.go exists, the //go:build !ios constraint on handler_nonios.go causes Android to use the no-op implementation. The unconditional call to waitForRouteSettlement at line 357 in handler.go will execute the no-op on Android despite Android's VpnService.establish() also applying tunnel settings asynchronously—the same async behavior that necessitates the 500ms settlement delay on iOS. Without this delay on Android, the DNS-bypass bug this PR fixes on iOS can occur on Android as well.

Either create handler_android.go with the same sleep implementation as handler_ios.go, or confirm Android's route handling does not exhibit the same async timing issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/internal/routemanager/dnsinterceptor/handler_nonios.go` around lines 7
- 12, The no-op waitForRouteSettlement in handler_nonios.go is used on Android
due to the !ios build tag, but Android's VpnService.establish() applies routes
asynchronously like iOS; either add an Android-specific implementation or verify
it's unnecessary. Create handler_android.go that implements
waitForRouteSettlement with the same short sleep/delay logic as handler_ios.go
(so the unconditional call from handler.go still waits), or if you confirm
Android timing is safe, document that and remove the Android-specific sleep path
by updating waitForRouteSettlement behavior accordingly; reference the
waitForRouteSettlement function, handler_ios.go implementation, and the call
site in handler.go for locating the change.

@pappz pappz merged commit fc6b93a into main Feb 19, 2026
40 of 41 checks passed
@pappz pappz deleted the fix/ios-dns-first-response branch February 19, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants