Skip to content

[client] Add GetSelectedClientRoutes to route manager and update DNS route check#5802

Merged
mlsmaycon merged 2 commits intomainfrom
fix/ios-route-selection
Apr 5, 2026
Merged

[client] Add GetSelectedClientRoutes to route manager and update DNS route check#5802
mlsmaycon merged 2 commits intomainfrom
fix/ios-route-selection

Conversation

@mlsmaycon
Copy link
Copy Markdown
Collaborator

@mlsmaycon mlsmaycon commented Apr 5, 2026

Describe your changes

  • DNS resolution broke after deselecting an exit node because the route checker used all client routes (including deselected ones) to decide how to forward upstream DNS
    queries
  • Added GetSelectedClientRoutes() to the route manager that filters out deselected exit nodes, and switched the DNS route checker to use it
  • Confirmed fix via device testing: after deselecting exit node, DNS queries now correctly use a regular network socket instead of binding to the utun interface

Problem

When an exit node (0.0.0.0/0) was deselected via the iOS UI, DNS resolution stopped working. All queries went through the tunnel but couldn't resolve.

Root cause: The DNS upstream resolver in engine.go called GetClientRoutes() to determine if an upstream DNS server (e.g., 8.8.8.8) should be reached through the WireGuard
tunnel. GetClientRoutes() returns all routes from the management server, including deselected ones. Since the deselected exit node's 0.0.0.0/0 matched every IP, the resolver
bound DNS sockets to the utun interface via IP_BOUND_IF — but with no WireGuard peer to forward through, every query failed.

Fix

Added GetSelectedClientRoutes() to the route manager interface, which filters routes through FilterSelectedExitNodes before returning them. The DNS route checker in engine.go
now uses this method instead of GetClientRoutes().

Verified by logs

Before fix (after deselecting exit node):
routeChecker: ip=8.8.8.8 matched by route network=0.0.0.0/0 (netID=Exit Node)
DNS exchange: upstream=8.8.8.8:53 needsPrivate=true ← bound to utun, no peer, fails

After fix:
routeChecker: ip=8.8.8.8 not matched by any selected client route
DNS exchange: upstream=8.8.8.8:53 needsPrivate=false ← regular socket, resolves

Test plan

  • Connect with exit node enabled → verify DNS works
  • Disable exit node → verify DNS continues working
  • Re-enable exit node → verify DNS still works through tunnel
  • Disable exit node with DNS route domains configured → verify domain-specific DNS routes still work
  • Select/deselect non-exit-node routes → verify no regression

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 route checking now considers only selected routes when deciding if traffic should go through the tunnel.
  • Chores

    • Internal routing manager updated to respect selected exit nodes, improving routing accuracy and testability.

@mlsmaycon mlsmaycon requested a review from lixmal April 5, 2026 11:18
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ebedbe8-0f0c-42b7-b27c-6fd8ce3063c6

📥 Commits

Reviewing files that changed from the base of the PR and between d36186f and cfcbd11.

📒 Files selected for processing (1)
  • client/internal/routemanager/mock.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/internal/routemanager/mock.go

📝 Walkthrough

Walkthrough

This change makes the engine's DNS route checker use only selected client routes (via GetSelectedClientRoutes()) instead of all client routes when deciding whether a DNS query IP falls within routed networks.

Changes

Cohort / File(s) Summary
DNS Route Checker
client/internal/engine.go
Switched DNS route checker to call GetSelectedClientRoutes() rather than GetClientRoutes() when evaluating whether DNS query IPs are within routed networks.
Route Manager Interface & Implementation
client/internal/routemanager/manager.go
Added GetSelectedClientRoutes() to Manager interface and implemented it in DefaultManager to return client routes filtered via routeSelector.FilterSelectedExitNodes(...) (excludes deselected exit nodes).
Mock Implementation
client/internal/routemanager/mock.go
Added GetSelectedClientRoutesFunc hook and GetSelectedClientRoutes() method to MockManager, following existing mock dispatch patterns.

Sequence Diagram(s)

sequenceDiagram
  participant Engine as Engine
  participant RM as RouteManager
  participant RS as RouteSelector
  participant DNS as DNSChecker

  Engine->>RM: GetSelectedClientRoutes()
  RM->>RM: lock & clone clientRoutes
  RM->>RS: FilterSelectedExitNodes(cloned clientRoutes)
  RS-->>RM: filtered routes (selected only)
  RM-->>Engine: return selected client routes
  Engine->>DNS: SetRouteChecker(using selected routes)
  DNS-->>Engine: DNS queries checked against selected routes
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Suggested reviewers

  • pappz

Poem

🐰
A hop, a sniff, a careful pace,
I check the routes within my space.
Selected paths now lead the way,
Deselected hops kept far at bay.
🎋🐇

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main changes: adding GetSelectedClientRoutes to the route manager and updating the DNS route check to use it, which directly addresses the DNS resolution issue after exit node deselection.
Description check ✅ Passed The description follows the template structure, includes a clear explanation of the problem and fix, provides verification details with logs, and specifies a comprehensive test plan. The checklist is appropriately completed as a bug fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ios-route-selection

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.

lixmal
lixmal previously approved these changes Apr 5, 2026
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 (1)
client/internal/routemanager/mock.go (1)

72-78: Consider adding a dedicated GetSelectedClientRoutesFunc for better test isolation.

Both GetClientRoutes and GetSelectedClientRoutes use the same GetClientRoutesFunc, making it impossible to test scenarios where they should return different values (e.g., testing behavior when some routes are deselected).

♻️ Suggested improvement
 type MockManager struct {
 	ClassifyRoutesFunc           func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
 	UpdateRoutesFunc             func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
 	TriggerSelectionFunc         func(haMap route.HAMap)
 	GetRouteSelectorFunc         func() *routeselector.RouteSelector
 	GetClientRoutesFunc          func() route.HAMap
+	GetSelectedClientRoutesFunc  func() route.HAMap
 	GetClientRoutesWithNetIDFunc func() map[route.NetID][]*route.Route
 	StopFunc                     func(manager *statemanager.Manager)
 }
 // GetSelectedClientRoutes mock implementation of GetSelectedClientRoutes from Manager interface
 func (m *MockManager) GetSelectedClientRoutes() route.HAMap {
-	if m.GetClientRoutesFunc != nil {
-		return m.GetClientRoutesFunc()
+	if m.GetSelectedClientRoutesFunc != nil {
+		return m.GetSelectedClientRoutesFunc()
 	}
-	return nil
+	// Fall back to GetClientRoutesFunc for backward compatibility
+	if m.GetClientRoutesFunc != nil {
+		return m.GetClientRoutesFunc()
+	}
+	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/internal/routemanager/mock.go` around lines 72 - 78, The mock
currently shares GetClientRoutesFunc for both behaviors which prevents testing
different return values; add a new field GetSelectedClientRoutesFunc to
MockManager and update the GetSelectedClientRoutes method to call
GetSelectedClientRoutesFunc when non-nil (falling back to GetClientRoutesFunc or
nil if desired), ensuring GetClientRoutes continues to call GetClientRoutesFunc
so tests can independently stub selected vs all routes.
🤖 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/mock.go`:
- Around line 72-78: The mock currently shares GetClientRoutesFunc for both
behaviors which prevents testing different return values; add a new field
GetSelectedClientRoutesFunc to MockManager and update the
GetSelectedClientRoutes method to call GetSelectedClientRoutesFunc when non-nil
(falling back to GetClientRoutesFunc or nil if desired), ensuring
GetClientRoutes continues to call GetClientRoutesFunc so tests can independently
stub selected vs all routes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a3f51b6a-3cf7-4881-a818-672bc09a42a8

📥 Commits

Reviewing files that changed from the base of the PR and between 28fbf96 and d36186f.

📒 Files selected for processing (3)
  • client/internal/engine.go
  • client/internal/routemanager/manager.go
  • client/internal/routemanager/mock.go

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 5, 2026

@mlsmaycon mlsmaycon merged commit decb5dd into main Apr 5, 2026
41 checks passed
@mlsmaycon mlsmaycon deleted the fix/ios-route-selection branch April 5, 2026 11:44
@lixmal lixmal mentioned this pull request Apr 8, 2026
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