ipn/ipnlocal: don't panic if there are no suitable exit nodes

In suggestExitNodeLocked, if no exit node candidates have a home DERP or
valid location info, `bestCandidates` is an empty slice. This slice is
passed to `selectNode` (`randomNode` in prod):

```go func randomNode(nodes views.Slice[tailcfg.NodeView], …) tailcfg.NodeView {
	…
	return nodes.At(rand.IntN(nodes.Len()))
}
```

An empty slice becomes a call to `rand.IntN(0)`, which panics.

This patch changes the behaviour, so if we've filtered out all the
candidates before calling `selectNode`, reset the list and then pick
from any of the available candidates.

This patch also updates our tests to give us more coverage of `randomNode`,
so we can spot other potential issues.

Updates #17661

Change-Id: I63eb5e4494d45a1df5b1f4b1b5c6d5576322aa72
Signed-off-by: Alex Chan <alexc@tailscale.com>
This commit is contained in:
Alex Chan
2025-11-25 10:22:08 +00:00
committed by Alex Chan
parent f4a4bab105
commit b38dd1ae06
2 changed files with 66 additions and 0 deletions

View File

@@ -7432,6 +7432,16 @@ func suggestExitNodeUsingDERP(report *netcheck.Report, nb *nodeBackend, prevSugg
}
}
bestCandidates := pickWeighted(pickFrom)
// We may have an empty list of candidates here, if none of the candidates
// have home DERP info.
//
// We know that candidates is non-empty or we'd already have returned, so if
// we've filtered everything out of bestCandidates, just use candidates.
if len(bestCandidates) == 0 {
bestCandidates = candidates
}
chosen := selectNode(views.SliceOf(bestCandidates), prevSuggestion)
if !chosen.Valid() {
return res, errors.New("chosen candidate invalid: this is a bug")

View File

@@ -4436,6 +4436,14 @@ func deterministicRegionForTest(t testing.TB, want views.Slice[int], use int) se
}
}
// deterministicNodeForTest returns a deterministic selectNodeFunc, which
// allows us to make stable assertions about which exit node will be chosen
// from a list of possible candidates.
//
// When given a list of candidates, it checks that `use` is in the list and
// returns that.
//
// It verifies that `wantLast` was passed to `selectNode(…, want)`.
func deterministicNodeForTest(t testing.TB, want views.Slice[tailcfg.StableNodeID], wantLast tailcfg.StableNodeID, use tailcfg.StableNodeID) selectNodeFunc {
t.Helper()
@@ -4444,6 +4452,16 @@ func deterministicNodeForTest(t testing.TB, want views.Slice[tailcfg.StableNodeI
}
return func(got views.Slice[tailcfg.NodeView], last tailcfg.StableNodeID) tailcfg.NodeView {
// In the tests, we choose nodes deterministically so we can get
// stable results, but in the real code, we choose nodes randomly.
//
// Call the randomNode function anyway, and ensure it returns
// a sensible result.
view := randomNode(got, last)
if !views.SliceContains(got, view) {
t.Fatalf("randomNode returns an unexpected node")
}
var ret tailcfg.NodeView
gotIDs := make([]tailcfg.StableNodeID, got.Len())
@@ -4529,6 +4547,7 @@ func TestSuggestExitNode(t *testing.T) {
Longitude: -97.3325,
Priority: 100,
}
var emptyLocation *tailcfg.Location
peer1 := makePeer(1,
withExitRoutes(),
@@ -4568,6 +4587,18 @@ func TestSuggestExitNode(t *testing.T) {
withExitRoutes(),
withSuggest(),
withLocation(fortWorthLowPriority.View()))
emptyLocationPeer9 := makePeer(9,
withoutDERP(),
withExitRoutes(),
withSuggest(),
withLocation(emptyLocation.View()),
)
emptyLocationPeer10 := makePeer(10,
withoutDERP(),
withExitRoutes(),
withSuggest(),
withLocation(emptyLocation.View()),
)
selfNode := tailcfg.Node{
Addresses: []netip.Prefix{
@@ -4898,6 +4929,31 @@ func TestSuggestExitNode(t *testing.T) {
wantName: "San Jose",
wantLocation: sanJose.View(),
},
{
// Regression test for https://github.com/tailscale/tailscale/issues/17661
name: "exit nodes with no home DERP, randomly selected",
lastReport: &netcheck.Report{
RegionLatency: map[int]time.Duration{
1: 10,
2: 20,
3: 10,
},
PreferredDERP: 1,
},
netMap: &netmap.NetworkMap{
SelfNode: selfNode.View(),
DERPMap: defaultDERPMap,
Peers: []tailcfg.NodeView{
emptyLocationPeer9,
emptyLocationPeer10,
},
},
wantRegions: []int{1, 2},
wantName: "peer9",
wantNodes: []tailcfg.StableNodeID{"stable9", "stable10"},
wantID: "stable9",
useRegion: 1,
},
}
for _, tt := range tests {