tailscale/net/netcheck/netcheck_test.go
Jordan Whited 49de23cf1b
net/netcheck: add addReportHistoryAndSetPreferredDERP() test case (#13989)
Add an explicit case for exercising preferred DERP hysteresis around
the branch that compares latencies on a percentage basis.

Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-10-31 19:25:00 -07:00

963 lines
26 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package netcheck
import (
"bytes"
"context"
"fmt"
"maps"
"net"
"net/http"
"net/netip"
"reflect"
"slices"
"strconv"
"strings"
"testing"
"time"
"tailscale.com/net/netmon"
"tailscale.com/net/stun/stuntest"
"tailscale.com/tailcfg"
"tailscale.com/tstest/nettest"
)
func newTestClient(t testing.TB) *Client {
c := &Client{
NetMon: netmon.NewStatic(),
Logf: t.Logf,
TimeNow: func() time.Time {
return time.Unix(1729624521, 0)
},
}
return c
}
func TestBasic(t *testing.T) {
stunAddr, cleanup := stuntest.Serve(t)
defer cleanup()
c := newTestClient(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := c.Standalone(ctx, "127.0.0.1:0"); err != nil {
t.Fatal(err)
}
r, err := c.GetReport(ctx, stuntest.DERPMapOf(stunAddr.String()), nil)
if err != nil {
t.Fatal(err)
}
if !r.UDP {
t.Error("want UDP")
}
if r.Now.IsZero() {
t.Error("Now is zero")
}
if len(r.RegionLatency) != 1 {
t.Errorf("expected 1 key in DERPLatency; got %+v", r.RegionLatency)
}
if _, ok := r.RegionLatency[1]; !ok {
t.Errorf("expected key 1 in DERPLatency; got %+v", r.RegionLatency)
}
if !r.GlobalV4.IsValid() {
t.Error("expected GlobalV4 set")
}
if r.PreferredDERP != 1 {
t.Errorf("PreferredDERP = %v; want 1", r.PreferredDERP)
}
v4Addrs, _ := r.GetGlobalAddrs()
if len(v4Addrs) != 1 {
t.Error("expected one global IPv4 address")
}
if got, want := v4Addrs[0], r.GlobalV4; got != want {
t.Errorf("got %v; want %v", got, want)
}
}
func TestMultiGlobalAddressMapping(t *testing.T) {
c := &Client{
Logf: t.Logf,
}
rs := &reportState{
c: c,
start: time.Now(),
report: newReport(),
}
derpNode := &tailcfg.DERPNode{}
port1 := netip.MustParseAddrPort("127.0.0.1:1234")
port2 := netip.MustParseAddrPort("127.0.0.1:2345")
port3 := netip.MustParseAddrPort("127.0.0.1:3456")
// First report for port1
rs.addNodeLatency(derpNode, port1, 10*time.Millisecond)
// Singular report for port2
rs.addNodeLatency(derpNode, port2, 11*time.Millisecond)
// Duplicate reports for port3
rs.addNodeLatency(derpNode, port3, 12*time.Millisecond)
rs.addNodeLatency(derpNode, port3, 13*time.Millisecond)
r := rs.report
v4Addrs, _ := r.GetGlobalAddrs()
wantV4Addrs := []netip.AddrPort{port1, port3}
if !slices.Equal(v4Addrs, wantV4Addrs) {
t.Errorf("got global addresses: %v, want %v", v4Addrs, wantV4Addrs)
}
}
func TestWorksWhenUDPBlocked(t *testing.T) {
blackhole, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to open blackhole STUN listener: %v", err)
}
defer blackhole.Close()
stunAddr := blackhole.LocalAddr().String()
dm := stuntest.DERPMapOf(stunAddr)
dm.Regions[1].Nodes[0].STUNOnly = true
c := newTestClient(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r, err := c.GetReport(ctx, dm, nil)
if err != nil {
t.Fatal(err)
}
r.UPnP = ""
r.PMP = ""
r.PCP = ""
want := newReport()
// The Now field can't be compared with reflect.DeepEqual; check using
// the Equal method and then overwrite it so that the comparison below
// succeeds.
if !r.Now.Equal(c.TimeNow()) {
t.Errorf("Now = %v; want %v", r.Now, c.TimeNow())
}
want.Now = r.Now
// The IPv4CanSend flag gets set differently across platforms.
// On Windows this test detects false, while on Linux detects true.
// That's not relevant to this test, so just accept what we're
// given.
want.IPv4CanSend = r.IPv4CanSend
// OS IPv6 test is irrelevant here, accept whatever the current
// machine has.
want.OSHasIPv6 = r.OSHasIPv6
// Captive portal test is irrelevant; accept what the current report
// has.
want.CaptivePortal = r.CaptivePortal
if !reflect.DeepEqual(r, want) {
t.Errorf("mismatch\n got: %+v\nwant: %+v\n", r, want)
}
}
func TestAddReportHistoryAndSetPreferredDERP(t *testing.T) {
// report returns a *Report from (DERP host, time.Duration)+ pairs.
report := func(a ...any) *Report {
r := &Report{RegionLatency: map[int]time.Duration{}}
for i := 0; i < len(a); i += 2 {
s := a[i].(string)
if !strings.HasPrefix(s, "d") {
t.Fatalf("invalid derp server key %q", s)
}
regionID, err := strconv.Atoi(s[1:])
if err != nil {
t.Fatalf("invalid derp server key %q", s)
}
switch v := a[i+1].(type) {
case time.Duration:
r.RegionLatency[regionID] = v
case int:
r.RegionLatency[regionID] = time.Second * time.Duration(v)
default:
panic(fmt.Sprintf("unexpected type %T", v))
}
}
return r
}
mkLDAFunc := func(mm map[int]time.Time) func(int) time.Time {
return func(region int) time.Time {
return mm[region]
}
}
type step struct {
after time.Duration
r *Report
}
startTime := time.Unix(123, 0)
tests := []struct {
name string
steps []step
homeParams *tailcfg.DERPHomeParams
opts *GetReportOpts
wantDERP int // want PreferredDERP on final step
wantPrevLen int // wanted len(c.prev)
}{
{
name: "first_reading",
steps: []step{
{0, report("d1", 2, "d2", 3)},
},
wantPrevLen: 1,
wantDERP: 1,
},
{
name: "with_two",
steps: []step{
{0, report("d1", 2, "d2", 3)},
{1 * time.Second, report("d1", 4, "d2", 3)},
},
wantPrevLen: 2,
wantDERP: 1, // t0's d1 of 2 is still best
},
{
name: "but_now_d1_gone",
steps: []step{
{0, report("d1", 2, "d2", 3)},
{1 * time.Second, report("d1", 4, "d2", 3)},
{2 * time.Second, report("d2", 3)},
},
wantPrevLen: 3,
wantDERP: 2, // only option
},
{
name: "d1_is_back",
steps: []step{
{0, report("d1", 2, "d2", 3)},
{1 * time.Second, report("d1", 4, "d2", 3)},
{2 * time.Second, report("d2", 3)},
{3 * time.Second, report("d1", 4, "d2", 3)}, // same as 2 seconds ago
},
wantPrevLen: 4,
wantDERP: 1, // t0's d1 of 2 is still best
},
{
name: "things_clean_up",
steps: []step{
{0, report("d1", 1, "d2", 2)},
{1 * time.Second, report("d1", 1, "d2", 2)},
{2 * time.Second, report("d1", 1, "d2", 2)},
{3 * time.Second, report("d1", 1, "d2", 2)},
{10 * time.Minute, report("d3", 3)},
},
wantPrevLen: 1, // t=[0123]s all gone. (too old, older than 10 min)
wantDERP: 3, // only option
},
{
name: "preferred_derp_hysteresis_no_switch",
steps: []step{
{0 * time.Second, report("d1", 4, "d2", 5)},
{1 * time.Second, report("d1", 4, "d2", 3)},
},
wantPrevLen: 2,
wantDERP: 1, // 2 didn't get fast enough
},
{
name: "preferred_derp_hysteresis_no_switch_absolute",
steps: []step{
{0 * time.Second, report("d1", 4*time.Millisecond, "d2", 5*time.Millisecond)},
{1 * time.Second, report("d1", 4*time.Millisecond, "d2", 1*time.Millisecond)},
},
wantPrevLen: 2,
wantDERP: 1, // 2 is 50%+ faster, but the absolute diff is <10ms
},
{
name: "preferred_derp_hysteresis_do_switch",
steps: []step{
{0 * time.Second, report("d1", 4, "d2", 5)},
{1 * time.Second, report("d1", 4, "d2", 1)},
},
wantPrevLen: 2,
wantDERP: 2, // 2 got fast enough
},
{
name: "derp_home_params",
homeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{
1: 2.0 / 3, // 66%
},
},
steps: []step{
// We only use a single step here to avoid
// conflating DERP selection as a result of
// weight hints with the "stickiness" check
// that tries to not change the home DERP
// between steps.
{1 * time.Second, report("d1", 10, "d2", 8)},
},
wantPrevLen: 1,
wantDERP: 1, // 2 was faster, but not by 50%+
},
{
name: "derp_home_params_high_latency",
homeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{
1: 2.0 / 3, // 66%
},
},
steps: []step{
// See derp_home_params for why this is a single step.
{1 * time.Second, report("d1", 100, "d2", 10)},
},
wantPrevLen: 1,
wantDERP: 2, // 2 was faster by more than 50%
},
{
name: "derp_home_params_invalid",
homeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{
1: 0.0,
2: -1.0,
},
},
steps: []step{
{1 * time.Second, report("d1", 4, "d2", 5)},
},
wantPrevLen: 1,
wantDERP: 1,
},
{
name: "saw_derp_traffic",
steps: []step{
{0, report("d1", 2, "d2", 3)}, // (1) initially pick d1
{2 * time.Second, report("d1", 4, "d2", 3)}, // (2) still d1
{2 * time.Second, report("d2", 3)}, // (3) d1 gone, but have traffic
},
opts: &GetReportOpts{
GetLastDERPActivity: mkLDAFunc(map[int]time.Time{
1: startTime.Add(2*time.Second + PreferredDERPFrameTime/2), // within active window of step (3)
}),
},
wantPrevLen: 3,
wantDERP: 1, // still on 1 since we got traffic from it
},
{
name: "saw_derp_traffic_history",
steps: []step{
{0, report("d1", 2, "d2", 3)}, // (1) initially pick d1
{2 * time.Second, report("d1", 4, "d2", 3)}, // (2) still d1
{2 * time.Second, report("d2", 3)}, // (3) d1 gone, but have traffic
},
opts: &GetReportOpts{
GetLastDERPActivity: mkLDAFunc(map[int]time.Time{
1: startTime.Add(4*time.Second - PreferredDERPFrameTime - 1), // not within active window of (3)
}),
},
wantPrevLen: 3,
wantDERP: 2, // moved to d2 since d1 is gone
},
{
name: "preferred_derp_hysteresis_no_switch_pct",
steps: []step{
{0 * time.Second, report("d1", 34*time.Millisecond, "d2", 35*time.Millisecond)},
{1 * time.Second, report("d1", 34*time.Millisecond, "d2", 23*time.Millisecond)},
},
wantPrevLen: 2,
wantDERP: 1, // diff is 11ms, but d2 is greater than 2/3s of d1
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeTime := startTime
c := &Client{
TimeNow: func() time.Time { return fakeTime },
}
dm := &tailcfg.DERPMap{HomeParams: tt.homeParams}
rs := &reportState{
c: c,
start: fakeTime,
opts: tt.opts,
}
for _, s := range tt.steps {
fakeTime = fakeTime.Add(s.after)
rs.start = fakeTime.Add(-100 * time.Millisecond)
c.addReportHistoryAndSetPreferredDERP(rs, s.r, dm.View())
}
lastReport := tt.steps[len(tt.steps)-1].r
if got, want := len(c.prev), tt.wantPrevLen; got != want {
t.Errorf("len(prev) = %v; want %v", got, want)
}
if got, want := lastReport.PreferredDERP, tt.wantDERP; got != want {
t.Errorf("PreferredDERP = %v; want %v", got, want)
}
})
}
}
func TestMakeProbePlan(t *testing.T) {
// basicMap has 5 regions. each region has a number of nodes
// equal to the region number (1 has 1a, 2 has 2a and 2b, etc.)
basicMap := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{},
}
for rid := 1; rid <= 5; rid++ {
var nodes []*tailcfg.DERPNode
for nid := 0; nid < rid; nid++ {
nodes = append(nodes, &tailcfg.DERPNode{
Name: fmt.Sprintf("%d%c", rid, 'a'+rune(nid)),
RegionID: rid,
HostName: fmt.Sprintf("derp%d-%d", rid, nid),
IPv4: fmt.Sprintf("%d.0.0.%d", rid, nid),
IPv6: fmt.Sprintf("%d::%d", rid, nid),
})
}
basicMap.Regions[rid] = &tailcfg.DERPRegion{
RegionID: rid,
Nodes: nodes,
}
}
const ms = time.Millisecond
p := func(name string, c rune, d ...time.Duration) probe {
var proto probeProto
switch c {
case 4:
proto = probeIPv4
case 6:
proto = probeIPv6
case 'h':
proto = probeHTTPS
}
pr := probe{node: name, proto: proto}
if len(d) == 1 {
pr.delay = d[0]
} else if len(d) > 1 {
panic("too many args")
}
return pr
}
tests := []struct {
name string
dm *tailcfg.DERPMap
have6if bool
no4 bool // no IPv4
last *Report
want probePlan
}{
{
name: "initial_v6",
dm: basicMap,
have6if: true,
last: nil, // initial
want: probePlan{
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 100*ms), p("1a", 4, 200*ms)}, // all a
"region-1-v6": []probe{p("1a", 6), p("1a", 6, 100*ms), p("1a", 6, 200*ms)},
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 100*ms), p("2a", 4, 200*ms)}, // a -> b -> a
"region-2-v6": []probe{p("2a", 6), p("2b", 6, 100*ms), p("2a", 6, 200*ms)},
"region-3-v4": []probe{p("3a", 4), p("3b", 4, 100*ms), p("3c", 4, 200*ms)}, // a -> b -> c
"region-3-v6": []probe{p("3a", 6), p("3b", 6, 100*ms), p("3c", 6, 200*ms)},
"region-4-v4": []probe{p("4a", 4), p("4b", 4, 100*ms), p("4c", 4, 200*ms)},
"region-4-v6": []probe{p("4a", 6), p("4b", 6, 100*ms), p("4c", 6, 200*ms)},
"region-5-v4": []probe{p("5a", 4), p("5b", 4, 100*ms), p("5c", 4, 200*ms)},
"region-5-v6": []probe{p("5a", 6), p("5b", 6, 100*ms), p("5c", 6, 200*ms)},
},
},
{
name: "initial_no_v6",
dm: basicMap,
have6if: false,
last: nil, // initial
want: probePlan{
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 100*ms), p("1a", 4, 200*ms)}, // all a
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 100*ms), p("2a", 4, 200*ms)}, // a -> b -> a
"region-3-v4": []probe{p("3a", 4), p("3b", 4, 100*ms), p("3c", 4, 200*ms)}, // a -> b -> c
"region-4-v4": []probe{p("4a", 4), p("4b", 4, 100*ms), p("4c", 4, 200*ms)},
"region-5-v4": []probe{p("5a", 4), p("5b", 4, 100*ms), p("5c", 4, 200*ms)},
},
},
{
name: "second_v4_no_6if",
dm: basicMap,
have6if: false,
last: &Report{
RegionLatency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
// Pretend 5 is missing
},
RegionV4Latency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
},
},
want: probePlan{
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms)},
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)},
"region-3-v4": []probe{p("3a", 4)},
},
},
{
name: "second_v4_only_with_6if",
dm: basicMap,
have6if: true,
last: &Report{
RegionLatency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
// Pretend 5 is missing
},
RegionV4Latency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
},
},
want: probePlan{
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms)},
"region-1-v6": []probe{p("1a", 6)},
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)},
"region-2-v6": []probe{p("2a", 6)},
"region-3-v4": []probe{p("3a", 4)},
},
},
{
name: "second_mixed",
dm: basicMap,
have6if: true,
last: &Report{
RegionLatency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
// Pretend 5 is missing
},
RegionV4Latency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
},
RegionV6Latency: map[int]time.Duration{
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
},
},
want: probePlan{
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms)},
"region-1-v6": []probe{p("1a", 6), p("1a", 6, 12*ms)},
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)},
"region-2-v6": []probe{p("2a", 6), p("2b", 6, 24*ms)},
"region-3-v4": []probe{p("3a", 4)},
},
},
{
name: "only_v6_initial",
have6if: true,
no4: true,
dm: basicMap,
want: probePlan{
"region-1-v6": []probe{p("1a", 6), p("1a", 6, 100*ms), p("1a", 6, 200*ms)},
"region-2-v6": []probe{p("2a", 6), p("2b", 6, 100*ms), p("2a", 6, 200*ms)},
"region-3-v6": []probe{p("3a", 6), p("3b", 6, 100*ms), p("3c", 6, 200*ms)},
"region-4-v6": []probe{p("4a", 6), p("4b", 6, 100*ms), p("4c", 6, 200*ms)},
"region-5-v6": []probe{p("5a", 6), p("5b", 6, 100*ms), p("5c", 6, 200*ms)},
},
},
{
name: "try_harder_for_preferred_derp",
dm: basicMap,
have6if: true,
last: &Report{
RegionLatency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
},
RegionV4Latency: map[int]time.Duration{
1: 10 * time.Millisecond,
2: 20 * time.Millisecond,
},
RegionV6Latency: map[int]time.Duration{
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
},
PreferredDERP: 1,
},
want: probePlan{
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms), p("1a", 4, 124*ms), p("1a", 4, 186*ms)},
"region-1-v6": []probe{p("1a", 6), p("1a", 6, 12*ms), p("1a", 6, 124*ms), p("1a", 6, 186*ms)},
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)},
"region-2-v6": []probe{p("2a", 6), p("2b", 6, 24*ms)},
"region-3-v4": []probe{p("3a", 4)},
},
},
{
// #13969: ensure that the prior/current home region is always included in
// probe plans, so that we don't flap between regions due to a single major
// netcheck having excluded the home region due to a spuriously high sample.
name: "ensure_home_region_inclusion",
dm: basicMap,
have6if: true,
last: &Report{
RegionLatency: map[int]time.Duration{
1: 50 * time.Millisecond,
2: 20 * time.Millisecond,
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
},
RegionV4Latency: map[int]time.Duration{
1: 50 * time.Millisecond,
2: 20 * time.Millisecond,
},
RegionV6Latency: map[int]time.Duration{
3: 30 * time.Millisecond,
4: 40 * time.Millisecond,
},
PreferredDERP: 1,
},
want: probePlan{
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 60*ms), p("1a", 4, 220*ms), p("1a", 4, 330*ms)},
"region-1-v6": []probe{p("1a", 6), p("1a", 6, 60*ms), p("1a", 6, 220*ms), p("1a", 6, 330*ms)},
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)},
"region-2-v6": []probe{p("2a", 6), p("2b", 6, 24*ms)},
"region-3-v4": []probe{p("3a", 4), p("3b", 4, 36*ms)},
"region-3-v6": []probe{p("3a", 6), p("3b", 6, 36*ms)},
"region-4-v4": []probe{p("4a", 4)},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ifState := &netmon.State{
HaveV6: tt.have6if,
HaveV4: !tt.no4,
}
preferredDERP := 0
if tt.last != nil {
preferredDERP = tt.last.PreferredDERP
}
got := makeProbePlan(tt.dm, ifState, tt.last, preferredDERP)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("unexpected plan; got:\n%v\nwant:\n%v\n", got, tt.want)
}
})
}
}
func (plan probePlan) String() string {
var sb strings.Builder
for _, key := range slices.Sorted(maps.Keys(plan)) {
fmt.Fprintf(&sb, "[%s]", key)
pv := plan[key]
for _, p := range pv {
fmt.Fprintf(&sb, " %v", p)
}
sb.WriteByte('\n')
}
return sb.String()
}
func (p probe) String() string {
wait := ""
if p.wait > 0 {
wait = "+" + p.wait.String()
}
delay := ""
if p.delay > 0 {
delay = "@" + p.delay.String()
}
return fmt.Sprintf("%s-%s%s%s", p.node, p.proto, delay, wait)
}
func TestLogConciseReport(t *testing.T) {
dm := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: nil,
2: nil,
3: nil,
},
}
const ms = time.Millisecond
tests := []struct {
name string
r *Report
want string
}{
{
name: "no_udp",
r: &Report{},
want: "udp=false v4=false icmpv4=false v6=false mapvarydest= portmap=? derp=0",
},
{
name: "no_udp_icmp",
r: &Report{ICMPv4: true, IPv4: true},
want: "udp=false icmpv4=true v6=false mapvarydest= portmap=? derp=0",
},
{
name: "ipv4_one_region",
r: &Report{
UDP: true,
IPv4: true,
PreferredDERP: 1,
RegionLatency: map[int]time.Duration{
1: 10 * ms,
},
RegionV4Latency: map[int]time.Duration{
1: 10 * ms,
},
},
want: "udp=true v6=false mapvarydest= portmap=? derp=1 derpdist=1v4:10ms",
},
{
name: "ipv4_all_region",
r: &Report{
UDP: true,
IPv4: true,
PreferredDERP: 1,
RegionLatency: map[int]time.Duration{
1: 10 * ms,
2: 20 * ms,
3: 30 * ms,
},
RegionV4Latency: map[int]time.Duration{
1: 10 * ms,
2: 20 * ms,
3: 30 * ms,
},
},
want: "udp=true v6=false mapvarydest= portmap=? derp=1 derpdist=1v4:10ms,2v4:20ms,3v4:30ms",
},
{
name: "ipboth_all_region",
r: &Report{
UDP: true,
IPv4: true,
IPv6: true,
PreferredDERP: 1,
RegionLatency: map[int]time.Duration{
1: 10 * ms,
2: 20 * ms,
3: 30 * ms,
},
RegionV4Latency: map[int]time.Duration{
1: 10 * ms,
2: 20 * ms,
3: 30 * ms,
},
RegionV6Latency: map[int]time.Duration{
1: 10 * ms,
2: 20 * ms,
3: 30 * ms,
},
},
want: "udp=true v6=true mapvarydest= portmap=? derp=1 derpdist=1v4:10ms,1v6:10ms,2v4:20ms,2v6:20ms,3v4:30ms,3v6:30ms",
},
{
name: "portmap_all",
r: &Report{
UDP: true,
UPnP: "true",
PMP: "true",
PCP: "true",
},
want: "udp=true v4=false v6=false mapvarydest= portmap=UMC derp=0",
},
{
name: "portmap_some",
r: &Report{
UDP: true,
UPnP: "true",
PMP: "false",
PCP: "true",
},
want: "udp=true v4=false v6=false mapvarydest= portmap=UC derp=0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
c := &Client{Logf: func(f string, a ...any) { fmt.Fprintf(&buf, f, a...) }}
c.logConciseReport(tt.r, dm)
if got, ok := strings.CutPrefix(buf.String(), "[v1] report: "); !ok {
t.Errorf("unexpected result.\n got: %#q\nwant: %#q\n", got, tt.want)
}
})
}
}
func TestSortRegions(t *testing.T) {
unsortedMap := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{},
}
for rid := 1; rid <= 5; rid++ {
var nodes []*tailcfg.DERPNode
nodes = append(nodes, &tailcfg.DERPNode{
Name: fmt.Sprintf("%da", rid),
RegionID: rid,
HostName: fmt.Sprintf("derp%d-1", rid),
IPv4: fmt.Sprintf("%d.0.0.1", rid),
IPv6: fmt.Sprintf("%d::1", rid),
})
unsortedMap.Regions[rid] = &tailcfg.DERPRegion{
RegionID: rid,
Nodes: nodes,
}
}
report := newReport()
report.RegionLatency[1] = time.Second * time.Duration(5)
report.RegionLatency[2] = time.Second * time.Duration(3)
report.RegionLatency[3] = time.Second * time.Duration(6)
report.RegionLatency[4] = time.Second * time.Duration(0)
report.RegionLatency[5] = time.Second * time.Duration(2)
sortedMap := sortRegions(unsortedMap, report, 0)
// Sorting by latency this should result in rid: 5, 2, 1, 3
// rid 4 with latency 0 should be at the end
want := []int{5, 2, 1, 3, 4}
got := make([]int, len(sortedMap))
for i, r := range sortedMap {
got[i] = r.RegionID
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
type RoundTripFunc func(req *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestNodeAddrResolve(t *testing.T) {
nettest.SkipIfNoNetwork(t)
c := &Client{
Logf: t.Logf,
UseDNSCache: true,
}
dn := &tailcfg.DERPNode{
Name: "derptest1a",
RegionID: 901,
HostName: "tailscale.com",
// No IPv4 or IPv6 addrs
}
dnV4Only := &tailcfg.DERPNode{
Name: "derptest1b",
RegionID: 901,
HostName: "ipv4.google.com",
// No IPv4 or IPv6 addrs
}
// Checks whether IPv6 and IPv6 DNS resolution works on this platform.
ipv6Works := func(t *testing.T) bool {
// Verify that we can create an IPv6 socket.
ln, err := net.ListenPacket("udp6", "[::1]:0")
if err != nil {
t.Logf("IPv6 may not work on this machine: %v", err)
return false
}
ln.Close()
// Resolve a hostname that we know has an IPv6 address.
addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip6", "google.com")
if err != nil {
t.Logf("IPv6 DNS resolution error: %v", err)
return false
}
if len(addrs) == 0 {
t.Logf("IPv6 DNS resolution returned no addresses")
return false
}
return true
}
ctx := context.Background()
for _, tt := range []bool{true, false} {
t.Run(fmt.Sprintf("UseDNSCache=%v", tt), func(t *testing.T) {
c.resolver = nil
c.UseDNSCache = tt
t.Run("IPv4", func(t *testing.T) {
ap := c.nodeAddr(ctx, dn, probeIPv4)
if !ap.IsValid() {
t.Fatal("expected valid AddrPort")
}
if !ap.Addr().Is4() {
t.Fatalf("expected IPv4 addr, got: %v", ap.Addr())
}
t.Logf("got IPv4 addr: %v", ap)
})
t.Run("IPv6", func(t *testing.T) {
// Skip if IPv6 doesn't work on this machine.
if !ipv6Works(t) {
t.Skipf("IPv6 may not work on this machine")
}
ap := c.nodeAddr(ctx, dn, probeIPv6)
if !ap.IsValid() {
t.Fatal("expected valid AddrPort")
}
if !ap.Addr().Is6() {
t.Fatalf("expected IPv6 addr, got: %v", ap.Addr())
}
t.Logf("got IPv6 addr: %v", ap)
})
t.Run("IPv6 Failure", func(t *testing.T) {
ap := c.nodeAddr(ctx, dnV4Only, probeIPv6)
if ap.IsValid() {
t.Fatalf("expected no addr but got: %v", ap)
}
t.Logf("correctly got invalid addr")
})
})
}
}
func TestReportTimeouts(t *testing.T) {
if ReportTimeout < stunProbeTimeout {
t.Errorf("ReportTimeout (%v) cannot be less than stunProbeTimeout (%v)", ReportTimeout, stunProbeTimeout)
}
if ReportTimeout < icmpProbeTimeout {
t.Errorf("ReportTimeout (%v) cannot be less than icmpProbeTimeout (%v)", ReportTimeout, icmpProbeTimeout)
}
if ReportTimeout < httpsProbeTimeout {
t.Errorf("ReportTimeout (%v) cannot be less than httpsProbeTimeout (%v)", ReportTimeout, httpsProbeTimeout)
}
}
func TestNoUDPNilGetReportOpts(t *testing.T) {
blackhole, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to open blackhole STUN listener: %v", err)
}
defer blackhole.Close()
dm := stuntest.DERPMapOf(blackhole.LocalAddr().String())
for _, region := range dm.Regions {
for _, n := range region.Nodes {
n.STUNOnly = false // exercise ICMP & HTTPS probing
}
}
c := newTestClient(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r, err := c.GetReport(ctx, dm, nil)
if err != nil {
t.Fatal(err)
}
if r.UDP {
t.Fatal("unexpected working UDP")
}
}