diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 3f0d9f120..3af1961f4 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -825,3 +825,31 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) { }) } } + +// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the +// tray exit-node toggle disabling every non-exit routed network. The tray used +// to Select an exit node with append=false, which the RouteSelector treats as +// "drop the whole current selection" (default-on semantics) — so enabling an +// exit node also turned off every LAN/route the user had on. The fix sends +// append=true and lets the daemon's SelectNetworks handler deselect only the +// sibling exit nodes. This test models that handler sequence against the +// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other +// exit nodes) must leave non-exit routes untouched. +func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) { + rs := routeselector.NewRouteSelector() + all := []route.NetID{"exitA", "exitB", "lan1", "lan2"} + + // User has two LAN routes on (default-on: nothing deselected => all selected). + require.True(t, rs.IsSelected("lan1")) + require.True(t, rs.IsSelected("lan2")) + + // Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true) + // then deselects sibling exit nodes (exitB), never the LAN routes. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all)) + + assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on") + assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected") + assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected") + assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected") +} diff --git a/client/ui/tray_exitnodes.go b/client/ui/tray_exitnodes.go index 53a434653..0b87fb36e 100644 --- a/client/ui/tray_exitnodes.go +++ b/client/ui/tray_exitnodes.go @@ -97,17 +97,19 @@ func (t *Tray) refreshExitNodes() { } // toggleExitNode activates or deactivates one exit node by NetID. Exit nodes -// are mutually exclusive, so Select uses append=false to clear any other -// active node before turning this one on; deselecting an active node turns -// routing off entirely. Mirrors the frontend's toggleExitNode semantics. Runs -// the RPC off the menu-click goroutine and re-fetches so the ✓ moves to the -// new selection. +// are mutually exclusive, but enforcement of that lives daemon-side: the +// SelectNetworks handler deselects every other exit node when this Select +// activates one. So Select uses append=true — append=false would tell the +// RouteSelector to drop the whole current selection (default-on semantics), +// which also turns off every non-exit routed network the user had enabled. +// Mirrors the frontend's toggleExitNode semantics. Runs the RPC off the +// menu-click goroutine and re-fetches so the ✓ moves to the new selection. func (t *Tray) toggleExitNode(id string, selected bool) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: false, All: false} + params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: true, All: false} var err error if selected { err = t.svc.Networks.Deselect(ctx, params)