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:
@@ -7432,6 +7432,16 @@ func suggestExitNodeUsingDERP(report *netcheck.Report, nb *nodeBackend, prevSugg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bestCandidates := pickWeighted(pickFrom)
|
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)
|
chosen := selectNode(views.SliceOf(bestCandidates), prevSuggestion)
|
||||||
if !chosen.Valid() {
|
if !chosen.Valid() {
|
||||||
return res, errors.New("chosen candidate invalid: this is a bug")
|
return res, errors.New("chosen candidate invalid: this is a bug")
|
||||||
|
|||||||
@@ -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 {
|
func deterministicNodeForTest(t testing.TB, want views.Slice[tailcfg.StableNodeID], wantLast tailcfg.StableNodeID, use tailcfg.StableNodeID) selectNodeFunc {
|
||||||
t.Helper()
|
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 {
|
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
|
var ret tailcfg.NodeView
|
||||||
|
|
||||||
gotIDs := make([]tailcfg.StableNodeID, got.Len())
|
gotIDs := make([]tailcfg.StableNodeID, got.Len())
|
||||||
@@ -4529,6 +4547,7 @@ func TestSuggestExitNode(t *testing.T) {
|
|||||||
Longitude: -97.3325,
|
Longitude: -97.3325,
|
||||||
Priority: 100,
|
Priority: 100,
|
||||||
}
|
}
|
||||||
|
var emptyLocation *tailcfg.Location
|
||||||
|
|
||||||
peer1 := makePeer(1,
|
peer1 := makePeer(1,
|
||||||
withExitRoutes(),
|
withExitRoutes(),
|
||||||
@@ -4568,6 +4587,18 @@ func TestSuggestExitNode(t *testing.T) {
|
|||||||
withExitRoutes(),
|
withExitRoutes(),
|
||||||
withSuggest(),
|
withSuggest(),
|
||||||
withLocation(fortWorthLowPriority.View()))
|
withLocation(fortWorthLowPriority.View()))
|
||||||
|
emptyLocationPeer9 := makePeer(9,
|
||||||
|
withoutDERP(),
|
||||||
|
withExitRoutes(),
|
||||||
|
withSuggest(),
|
||||||
|
withLocation(emptyLocation.View()),
|
||||||
|
)
|
||||||
|
emptyLocationPeer10 := makePeer(10,
|
||||||
|
withoutDERP(),
|
||||||
|
withExitRoutes(),
|
||||||
|
withSuggest(),
|
||||||
|
withLocation(emptyLocation.View()),
|
||||||
|
)
|
||||||
|
|
||||||
selfNode := tailcfg.Node{
|
selfNode := tailcfg.Node{
|
||||||
Addresses: []netip.Prefix{
|
Addresses: []netip.Prefix{
|
||||||
@@ -4898,6 +4929,31 @@ func TestSuggestExitNode(t *testing.T) {
|
|||||||
wantName: "San Jose",
|
wantName: "San Jose",
|
||||||
wantLocation: sanJose.View(),
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user