// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package ipnlocal import ( "context" "encoding/json" "errors" "fmt" "math" "math/rand" "net" "net/http" "net/netip" "os" "reflect" "slices" "sync" "testing" "time" "github.com/google/go-cmp/cmp" "go4.org/netipx" "golang.org/x/net/dns/dnsmessage" "tailscale.com/appc" "tailscale.com/appc/appctest" "tailscale.com/control/controlclient" "tailscale.com/drive" "tailscale.com/drive/driveimpl" "tailscale.com/ipn" "tailscale.com/ipn/store/mem" "tailscale.com/net/interfaces" "tailscale.com/net/netcheck" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/tsd" "tailscale.com/tstest" "tailscale.com/types/dnstype" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/types/netmap" "tailscale.com/types/opt" "tailscale.com/types/ptr" "tailscale.com/types/views" "tailscale.com/util/dnsname" "tailscale.com/util/mak" "tailscale.com/util/must" "tailscale.com/util/syspolicy" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" "tailscale.com/wgengine/wgcfg" ) func inRemove(ip netip.Addr) bool { for _, pfx := range removeFromDefaultRoute { if pfx.Contains(ip) { return true } } return false } func TestShrinkDefaultRoute(t *testing.T) { tests := []struct { route string in []string out []string localIPFn func(netip.Addr) bool // true if this machine's local IP address should be "in" after shrinking. }{ { route: "0.0.0.0/0", in: []string{"1.2.3.4", "25.0.0.1"}, out: []string{ "10.0.0.1", "10.255.255.255", "192.168.0.1", "192.168.255.255", "172.16.0.1", "172.31.255.255", "100.101.102.103", "224.0.0.1", "169.254.169.254", // Some random IPv6 stuff that shouldn't be in a v4 // default route. "fe80::", "2601::1", }, localIPFn: func(ip netip.Addr) bool { return !inRemove(ip) && ip.Is4() }, }, { route: "::/0", in: []string{"::1", "2601::1"}, out: []string{ "fe80::1", "ff00::1", tsaddr.TailscaleULARange().Addr().String(), }, localIPFn: func(ip netip.Addr) bool { return !inRemove(ip) && ip.Is6() }, }, } // Construct a fake local network environment to make this test hermetic. // localInterfaceRoutes and hostIPs would normally come from calling interfaceRoutes, // and localAddresses would normally come from calling interfaces.LocalAddresses. var b netipx.IPSetBuilder for _, c := range []string{"127.0.0.0/8", "192.168.9.0/24", "fe80::/32"} { p := netip.MustParsePrefix(c) b.AddPrefix(p) } localInterfaceRoutes, err := b.IPSet() if err != nil { t.Fatal(err) } hostIPs := []netip.Addr{ netip.MustParseAddr("127.0.0.1"), netip.MustParseAddr("192.168.9.39"), netip.MustParseAddr("fe80::1"), netip.MustParseAddr("fe80::437d:feff:feca:49a7"), } localAddresses := []netip.Addr{ netip.MustParseAddr("192.168.9.39"), } for _, test := range tests { def := netip.MustParsePrefix(test.route) got, err := shrinkDefaultRoute(def, localInterfaceRoutes, hostIPs) if err != nil { t.Fatalf("shrinkDefaultRoute(%q): %v", test.route, err) } for _, ip := range test.in { if !got.Contains(netip.MustParseAddr(ip)) { t.Errorf("shrink(%q).Contains(%v) = false, want true", test.route, ip) } } for _, ip := range test.out { if got.Contains(netip.MustParseAddr(ip)) { t.Errorf("shrink(%q).Contains(%v) = true, want false", test.route, ip) } } for _, ip := range localAddresses { want := test.localIPFn(ip) if gotContains := got.Contains(ip); gotContains != want { t.Errorf("shrink(%q).Contains(%v) = %v, want %v", test.route, ip, gotContains, want) } } } } func TestPeerRoutes(t *testing.T) { pp := netip.MustParsePrefix tests := []struct { name string peers []wgcfg.Peer want []netip.Prefix }{ { name: "small_v4", peers: []wgcfg.Peer{ { AllowedIPs: []netip.Prefix{ pp("100.101.102.103/32"), }, }, }, want: []netip.Prefix{ pp("100.101.102.103/32"), }, }, { name: "big_v4", peers: []wgcfg.Peer{ { AllowedIPs: []netip.Prefix{ pp("100.101.102.103/32"), pp("100.101.102.104/32"), pp("100.101.102.105/32"), }, }, }, want: []netip.Prefix{ pp("100.64.0.0/10"), }, }, { name: "has_1_v6", peers: []wgcfg.Peer{ { AllowedIPs: []netip.Prefix{ pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b240/128"), }, }, }, want: []netip.Prefix{ pp("fd7a:115c:a1e0::/48"), }, }, { name: "has_2_v6", peers: []wgcfg.Peer{ { AllowedIPs: []netip.Prefix{ pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b240/128"), pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b241/128"), }, }, }, want: []netip.Prefix{ pp("fd7a:115c:a1e0::/48"), }, }, { name: "big_v4_big_v6", peers: []wgcfg.Peer{ { AllowedIPs: []netip.Prefix{ pp("100.101.102.103/32"), pp("100.101.102.104/32"), pp("100.101.102.105/32"), pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b240/128"), pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b241/128"), }, }, }, want: []netip.Prefix{ pp("100.64.0.0/10"), pp("fd7a:115c:a1e0::/48"), }, }, { name: "output-should-be-sorted", peers: []wgcfg.Peer{ { AllowedIPs: []netip.Prefix{ pp("100.64.0.2/32"), pp("10.0.0.0/16"), }, }, { AllowedIPs: []netip.Prefix{ pp("100.64.0.1/32"), pp("10.0.0.0/8"), }, }, }, want: []netip.Prefix{ pp("10.0.0.0/8"), pp("10.0.0.0/16"), pp("100.64.0.1/32"), pp("100.64.0.2/32"), }, }, { name: "skip-unmasked-prefixes", peers: []wgcfg.Peer{ { PublicKey: key.NewNode().Public(), AllowedIPs: []netip.Prefix{ pp("100.64.0.2/32"), pp("10.0.0.100/16"), }, }, }, want: []netip.Prefix{ pp("100.64.0.2/32"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := peerRoutes(t.Logf, tt.peers, 2) if !reflect.DeepEqual(got, tt.want) { t.Errorf("got = %v; want %v", got, tt.want) } }) } } func TestPeerAPIBase(t *testing.T) { tests := []struct { name string nm *netmap.NetworkMap peer *tailcfg.Node want string }{ { name: "nil_netmap", peer: new(tailcfg.Node), want: "", }, { name: "nil_peer", nm: new(netmap.NetworkMap), want: "", }, { name: "self_only_4_them_both", nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), }, }).View(), }, peer: &tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.2/32"), netip.MustParsePrefix("fe70::2/128"), }, Hostinfo: (&tailcfg.Hostinfo{ Services: []tailcfg.Service{ {Proto: "peerapi4", Port: 444}, {Proto: "peerapi6", Port: 666}, }, }).View(), }, want: "http://100.64.1.2:444", }, { name: "self_only_6_them_both", nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("fe70::1/128"), }, }).View(), }, peer: &tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.2/32"), netip.MustParsePrefix("fe70::2/128"), }, Hostinfo: (&tailcfg.Hostinfo{ Services: []tailcfg.Service{ {Proto: "peerapi4", Port: 444}, {Proto: "peerapi6", Port: 666}, }, }).View(), }, want: "http://[fe70::2]:666", }, { name: "self_both_them_only_4", nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), }, peer: &tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.2/32"), netip.MustParsePrefix("fe70::2/128"), }, Hostinfo: (&tailcfg.Hostinfo{ Services: []tailcfg.Service{ {Proto: "peerapi4", Port: 444}, }, }).View(), }, want: "http://100.64.1.2:444", }, { name: "self_both_them_only_6", nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), }, peer: &tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.2/32"), netip.MustParsePrefix("fe70::2/128"), }, Hostinfo: (&tailcfg.Hostinfo{ Services: []tailcfg.Service{ {Proto: "peerapi6", Port: 666}, }, }).View(), }, want: "http://[fe70::2]:666", }, { name: "self_both_them_no_peerapi_service", nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), }, peer: &tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.2/32"), netip.MustParsePrefix("fe70::2/128"), }, }, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := peerAPIBase(tt.nm, tt.peer.View()) if got != tt.want { t.Errorf("got %q; want %q", got, tt.want) } }) } } type panicOnUseTransport struct{} func (panicOnUseTransport) RoundTrip(*http.Request) (*http.Response, error) { panic("unexpected HTTP request") } func newTestLocalBackend(t testing.TB) *LocalBackend { var logf logger.Logf = logger.Discard sys := new(tsd.System) store := new(mem.Store) sys.Set(store) eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set) if err != nil { t.Fatalf("NewFakeUserspaceEngine: %v", err) } t.Cleanup(eng.Close) sys.Set(eng) lb, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) if err != nil { t.Fatalf("NewLocalBackend: %v", err) } return lb } // Issue 1573: don't generate a machine key if we don't want to be running. func TestLazyMachineKeyGeneration(t *testing.T) { tstest.Replace(t, &panicOnMachineKeyGeneration, func() bool { return true }) lb := newTestLocalBackend(t) lb.SetHTTPTestClient(&http.Client{ Transport: panicOnUseTransport{}, // validate we don't send HTTP requests }) if err := lb.Start(ipn.Options{}); err != nil { t.Fatalf("Start: %v", err) } // Give the controlclient package goroutines (if they're // accidentally started) extra time to schedule and run (and thus // hit panicOnUseTransport). time.Sleep(500 * time.Millisecond) } func TestZeroExitNodeViaLocalAPI(t *testing.T) { lb := newTestLocalBackend(t) // Give it an initial exit node in use. if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ ExitNodeIDSet: true, Prefs: ipn.Prefs{ ExitNodeID: "foo", }, }); err != nil { t.Fatalf("enabling first exit node: %v", err) } // SetUseExitNodeEnabled(false) "remembers" the prior exit node. if _, err := lb.SetUseExitNodeEnabled(false); err != nil { t.Fatal("expected failure") } // Zero the exit node pv, err := lb.EditPrefs(&ipn.MaskedPrefs{ ExitNodeIDSet: true, Prefs: ipn.Prefs{ ExitNodeID: "", }, }) if err != nil { t.Fatalf("enabling first exit node: %v", err) } // We just set the internal exit node to the empty string, so InternalExitNodePrior should // also be zero'd if got, want := pv.InternalExitNodePrior(), tailcfg.StableNodeID(""); got != want { t.Fatalf("unexpected InternalExitNodePrior %q, want: %q", got, want) } } func TestSetUseExitNodeEnabled(t *testing.T) { lb := newTestLocalBackend(t) // Can't turn it on if it never had an old value. if _, err := lb.SetUseExitNodeEnabled(true); err == nil { t.Fatal("expected success") } // But we can turn it off when it's already off. if _, err := lb.SetUseExitNodeEnabled(false); err != nil { t.Fatal("expected failure") } // Give it an initial exit node in use. if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ ExitNodeIDSet: true, Prefs: ipn.Prefs{ ExitNodeID: "foo", }, }); err != nil { t.Fatalf("enabling first exit node: %v", err) } // Now turn off that exit node. if prefs, err := lb.SetUseExitNodeEnabled(false); err != nil { t.Fatal("expected failure") } else { if g, w := prefs.ExitNodeID(), tailcfg.StableNodeID(""); g != w { t.Fatalf("unexpected exit node ID %q; want %q", g, w) } if g, w := prefs.InternalExitNodePrior(), tailcfg.StableNodeID("foo"); g != w { t.Fatalf("unexpected exit node prior %q; want %q", g, w) } } // And turn it back on. if prefs, err := lb.SetUseExitNodeEnabled(true); err != nil { t.Fatal("expected failure") } else { if g, w := prefs.ExitNodeID(), tailcfg.StableNodeID("foo"); g != w { t.Fatalf("unexpected exit node ID %q; want %q", g, w) } if g, w := prefs.InternalExitNodePrior(), tailcfg.StableNodeID("foo"); g != w { t.Fatalf("unexpected exit node prior %q; want %q", g, w) } } // Verify we block setting an Internal field. if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ InternalExitNodePriorSet: true, }); err == nil { t.Fatalf("unexpected success; want an error trying to set an internal field") } } func TestFileTargets(t *testing.T) { b := new(LocalBackend) _, err := b.FileTargets() if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { t.Errorf("before connect: got %q; want %q", got, want) } b.netMap = new(netmap.NetworkMap) _, err = b.FileTargets() if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { t.Errorf("non-running netmap: got %q; want %q", got, want) } b.state = ipn.Running _, err = b.FileTargets() if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want { t.Errorf("without cap: got %q; want %q", got, want) } b.capFileSharing = true got, err := b.FileTargets() if err != nil { t.Fatal(err) } if len(got) != 0 { t.Fatalf("unexpected %d peers", len(got)) } var peerMap map[tailcfg.NodeID]tailcfg.NodeView mak.NonNil(&peerMap) var nodeID tailcfg.NodeID nodeID = 1234 peer := &tailcfg.Node{ ID: 1234, Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(), } peerMap[nodeID] = peer.View() b.peers = peerMap got, err = b.FileTargets() if err != nil { t.Fatal(err) } if len(got) != 0 { t.Fatalf("unexpected %d peers", len(got)) } // (other cases handled by TestPeerAPIBase above) } func TestInternalAndExternalInterfaces(t *testing.T) { type interfacePrefix struct { i interfaces.Interface pfx netip.Prefix } masked := func(ips ...interfacePrefix) (pfxs []netip.Prefix) { for _, ip := range ips { pfxs = append(pfxs, ip.pfx.Masked()) } return pfxs } iList := func(ips ...interfacePrefix) (il interfaces.List) { for _, ip := range ips { il = append(il, ip.i) } return il } newInterface := func(name, pfx string, wsl2, loopback bool) interfacePrefix { ippfx := netip.MustParsePrefix(pfx) ip := interfaces.Interface{ Interface: &net.Interface{}, AltAddrs: []net.Addr{ netipx.PrefixIPNet(ippfx), }, } if loopback { ip.Flags = net.FlagLoopback } if wsl2 { ip.HardwareAddr = []byte{0x00, 0x15, 0x5d, 0x00, 0x00, 0x00} } return interfacePrefix{i: ip, pfx: ippfx} } var ( en0 = newInterface("en0", "10.20.2.5/16", false, false) en1 = newInterface("en1", "192.168.1.237/24", false, false) wsl = newInterface("wsl", "192.168.5.34/24", true, false) loopback = newInterface("lo0", "127.0.0.1/8", false, true) ) tests := []struct { name string goos string il interfaces.List wantInt []netip.Prefix wantExt []netip.Prefix }{ { name: "single-interface", goos: "linux", il: iList( en0, loopback, ), wantInt: masked(loopback), wantExt: masked(en0), }, { name: "multiple-interfaces", goos: "linux", il: iList( en0, en1, wsl, loopback, ), wantInt: masked(loopback), wantExt: masked(en0, en1, wsl), }, { name: "wsl2", goos: "windows", il: iList( en0, en1, wsl, loopback, ), wantInt: masked(loopback, wsl), wantExt: masked(en0, en1), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gotInt, gotExt, err := internalAndExternalInterfacesFrom(tc.il, tc.goos) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(gotInt, tc.wantInt) { t.Errorf("unexpected internal prefixes\ngot %v\nwant %v", gotInt, tc.wantInt) } if !reflect.DeepEqual(gotExt, tc.wantExt) { t.Errorf("unexpected external prefixes\ngot %v\nwant %v", gotExt, tc.wantExt) } }) } } func TestPacketFilterPermitsUnlockedNodes(t *testing.T) { tests := []struct { name string peers []*tailcfg.Node filter []filter.Match want bool }{ { name: "empty", want: false, }, { name: "no-unsigned", peers: []*tailcfg.Node{ {ID: 1}, }, want: false, }, { name: "unsigned-good", peers: []*tailcfg.Node{ {ID: 1, UnsignedPeerAPIOnly: true}, }, want: false, }, { name: "unsigned-bad", peers: []*tailcfg.Node{ { ID: 1, UnsignedPeerAPIOnly: true, AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("100.64.0.0/32"), }, }, }, filter: []filter.Match{ { Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/32")}, Dsts: []filter.NetPortRange{ { Net: netip.MustParsePrefix("100.99.0.0/32"), }, }, }, }, want: true, }, { name: "unsigned-bad-src-is-superset", peers: []*tailcfg.Node{ { ID: 1, UnsignedPeerAPIOnly: true, AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("100.64.0.0/32"), }, }, }, filter: []filter.Match{ { Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/24")}, Dsts: []filter.NetPortRange{ { Net: netip.MustParsePrefix("100.99.0.0/32"), }, }, }, }, want: true, }, { name: "unsigned-okay-because-no-dsts", peers: []*tailcfg.Node{ { ID: 1, UnsignedPeerAPIOnly: true, AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("100.64.0.0/32"), }, }, }, filter: []filter.Match{ { Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/32")}, Caps: []filter.CapMatch{ { Dst: netip.MustParsePrefix("100.99.0.0/32"), Cap: "foo", }, }, }, }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := packetFilterPermitsUnlockedNodes(peersMap(nodeViews(tt.peers)), tt.filter); got != tt.want { t.Errorf("got %v, want %v", got, tt.want) } }) } } func TestStatusPeerCapabilities(t *testing.T) { tests := []struct { name string peers []tailcfg.NodeView expectedPeerCapabilities map[tailcfg.StableNodeID][]tailcfg.NodeCapability expectedPeerCapMap map[tailcfg.StableNodeID]tailcfg.NodeCapMap }{ { name: "peers-with-capabilities", peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 1, StableID: "foo", IsWireGuardOnly: true, Hostinfo: (&tailcfg.Hostinfo{}).View(), Capabilities: []tailcfg.NodeCapability{tailcfg.CapabilitySSH}, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.CapabilitySSH: nil, }), }).View(), (&tailcfg.Node{ ID: 2, StableID: "bar", Hostinfo: (&tailcfg.Hostinfo{}).View(), Capabilities: []tailcfg.NodeCapability{tailcfg.CapabilityAdmin}, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.CapabilityAdmin: {`{"test": "true}`}, }), }).View(), }, expectedPeerCapabilities: map[tailcfg.StableNodeID][]tailcfg.NodeCapability{ tailcfg.StableNodeID("foo"): {tailcfg.CapabilitySSH}, tailcfg.StableNodeID("bar"): {tailcfg.CapabilityAdmin}, }, expectedPeerCapMap: map[tailcfg.StableNodeID]tailcfg.NodeCapMap{ tailcfg.StableNodeID("foo"): (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.CapabilitySSH: nil, }), tailcfg.StableNodeID("bar"): (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.CapabilityAdmin: {`{"test": "true}`}, }), }, }, { name: "peers-without-capabilities", peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 1, StableID: "foo", IsWireGuardOnly: true, Hostinfo: (&tailcfg.Hostinfo{}).View(), }).View(), (&tailcfg.Node{ ID: 2, StableID: "bar", Hostinfo: (&tailcfg.Hostinfo{}).View(), }).View(), }, }, } b := newTestLocalBackend(t) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { b.setNetMapLocked(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ MachineAuthorized: true, Addresses: ipps("100.101.101.101"), }).View(), Peers: tt.peers, }) got := b.Status() for _, peer := range got.Peer { if !reflect.DeepEqual(peer.Capabilities, tt.expectedPeerCapabilities[peer.ID]) { t.Errorf("peer capabilities: expected %v got %v", tt.expectedPeerCapabilities, peer.Capabilities) } if !reflect.DeepEqual(peer.CapMap, tt.expectedPeerCapMap[peer.ID]) { t.Errorf("peer capmap: expected %v got %v", tt.expectedPeerCapMap, peer.CapMap) } } }) } } // legacyBackend was the interface between Tailscale frontends // (e.g. cmd/tailscale, iOS/MacOS/Windows GUIs) and the tailscale // backend (e.g. cmd/tailscaled) running on the same machine. // (It has nothing to do with the interface between the backends // and the cloud control plane.) type legacyBackend interface { // SetNotifyCallback sets the callback to be called on updates // from the backend to the client. SetNotifyCallback(func(ipn.Notify)) // Start starts or restarts the backend, typically when a // frontend client connects. Start(ipn.Options) error } // Verify that LocalBackend still implements the legacyBackend interface // for now, at least until the macOS and iOS clients move off of it. var _ legacyBackend = (*LocalBackend)(nil) func TestWatchNotificationsCallbacks(t *testing.T) { b := new(LocalBackend) n := new(ipn.Notify) b.WatchNotifications(context.Background(), 0, func() { b.mu.Lock() defer b.mu.Unlock() // Ensure a watcher has been installed. if len(b.notifyWatchers) != 1 { t.Fatalf("unexpected number of watchers in new LocalBackend, want: 1 got: %v", len(b.notifyWatchers)) } // Send a notification. Range over notifyWatchers to get the channel // because WatchNotifications doesn't expose the handle for it. for _, sess := range b.notifyWatchers { select { case sess.ch <- n: default: t.Fatalf("could not send notification") } } }, func(roNotify *ipn.Notify) bool { if roNotify != n { t.Fatalf("unexpected notification received. want: %v got: %v", n, roNotify) } return false }) // Ensure watchers have been cleaned up. b.mu.Lock() defer b.mu.Unlock() if len(b.notifyWatchers) != 0 { t.Fatalf("unexpected number of watchers in new LocalBackend, want: 0 got: %v", len(b.notifyWatchers)) } } // tests LocalBackend.updateNetmapDeltaLocked func TestUpdateNetmapDelta(t *testing.T) { b := newTestLocalBackend(t) if b.updateNetmapDeltaLocked(nil) { t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap") } b.netMap = &netmap.NetworkMap{} for i := range 5 { b.netMap.Peers = append(b.netMap.Peers, (&tailcfg.Node{ID: (tailcfg.NodeID(i) + 1)}).View()) } b.updatePeersFromNetmapLocked(b.netMap) someTime := time.Unix(123, 0) muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{ PeersChangedPatch: []*tailcfg.PeerChange{ { NodeID: 1, DERPRegion: 1, }, { NodeID: 2, Online: ptr.To(true), }, { NodeID: 3, Online: ptr.To(false), }, { NodeID: 4, LastSeen: ptr.To(someTime), }, }, }, someTime) if !ok { t.Fatal("netmap.MutationsFromMapResponse failed") } if !b.updateNetmapDeltaLocked(muts) { t.Fatalf("updateNetmapDeltaLocked() = false, want true with new netmap") } wants := []*tailcfg.Node{ { ID: 1, DERP: "127.3.3.40:1", }, { ID: 2, Online: ptr.To(true), }, { ID: 3, Online: ptr.To(false), }, { ID: 4, LastSeen: ptr.To(someTime), }, } for _, want := range wants { gotv, ok := b.peers[want.ID] if !ok { t.Errorf("netmap.Peer %v missing from b.peers", want.ID) continue } got := gotv.AsStruct() if !reflect.DeepEqual(got, want) { t.Errorf("netmap.Peer %v wrong.\n got: %v\nwant: %v", want.ID, logger.AsJSON(got), logger.AsJSON(want)) } } } // tests WhoIs and indirectly that setNetMapLocked updates b.nodeByAddr correctly. func TestWhoIs(t *testing.T) { b := newTestLocalBackend(t) b.setNetMapLocked(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ ID: 1, User: 10, Addresses: []netip.Prefix{netip.MustParsePrefix("100.101.102.103/32")}, }).View(), Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, User: 20, Addresses: []netip.Prefix{netip.MustParsePrefix("100.200.200.200/32")}, }).View(), }, UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{ 10: { DisplayName: "Myself", }, 20: { DisplayName: "Peer", }, }, }) tests := []struct { q string want tailcfg.NodeID // 0 means want ok=false wantName string }{ {"100.101.102.103:0", 1, "Myself"}, {"100.101.102.103:123", 1, "Myself"}, {"100.200.200.200:0", 2, "Peer"}, {"100.200.200.200:123", 2, "Peer"}, {"100.4.0.4:404", 0, ""}, } for _, tt := range tests { t.Run(tt.q, func(t *testing.T) { nv, up, ok := b.WhoIs(netip.MustParseAddrPort(tt.q)) var got tailcfg.NodeID if ok { got = nv.ID() } if got != tt.want { t.Errorf("got nodeID %v; want %v", got, tt.want) } if up.DisplayName != tt.wantName { t.Errorf("got name %q; want %q", up.DisplayName, tt.wantName) } }) } } func TestWireguardExitNodeDNSResolvers(t *testing.T) { type tc struct { name string id tailcfg.StableNodeID peers []*tailcfg.Node wantOK bool wantResolvers []*dnstype.Resolver } tests := []tc{ { name: "no peers", id: "1", wantOK: false, wantResolvers: nil, }, { name: "non wireguard peer", id: "1", peers: []*tailcfg.Node{ { ID: 1, StableID: "1", IsWireGuardOnly: false, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, }, }, wantOK: false, wantResolvers: nil, }, { name: "no matching IDs", id: "2", peers: []*tailcfg.Node{ { ID: 1, StableID: "1", IsWireGuardOnly: true, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, }, }, wantOK: false, wantResolvers: nil, }, { name: "wireguard peer", id: "1", peers: []*tailcfg.Node{ { ID: 1, StableID: "1", IsWireGuardOnly: true, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, }, }, wantOK: true, wantResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, }, } for _, tc := range tests { peers := peersMap(nodeViews(tc.peers)) nm := &netmap.NetworkMap{} gotResolvers, gotOK := wireguardExitNodeDNSResolvers(nm, peers, tc.id) if gotOK != tc.wantOK || !resolversEqual(t, gotResolvers, tc.wantResolvers) { t.Errorf("case: %s: got %v, %v, want %v, %v", tc.name, gotOK, gotResolvers, tc.wantOK, tc.wantResolvers) } } } func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { type tc struct { name string exitNode tailcfg.StableNodeID peers []tailcfg.NodeView dnsConfig *tailcfg.DNSConfig wantDefaultResolvers []*dnstype.Resolver wantRoutes map[dnsname.FQDN][]*dnstype.Resolver } defaultResolvers := []*dnstype.Resolver{{Addr: "default.example.com"}} wgResolvers := []*dnstype.Resolver{{Addr: "wg.example.com"}} peers := []tailcfg.NodeView{ (&tailcfg.Node{ ID: 1, StableID: "wg", IsWireGuardOnly: true, ExitNodeDNSResolvers: wgResolvers, Hostinfo: (&tailcfg.Hostinfo{}).View(), }).View(), // regular tailscale exit node with DNS capabilities (&tailcfg.Node{ Cap: 26, ID: 2, StableID: "ts", Hostinfo: (&tailcfg.Hostinfo{}).View(), }).View(), } exitDOH := peerAPIBase(&netmap.NetworkMap{Peers: peers}, peers[0]) + "/dns-query" routes := map[dnsname.FQDN][]*dnstype.Resolver{ "route.example.com.": {{Addr: "route.example.com"}}, } stringifyRoutes := func(routes map[dnsname.FQDN][]*dnstype.Resolver) map[string][]*dnstype.Resolver { if routes == nil { return nil } m := make(map[string][]*dnstype.Resolver) for k, v := range routes { m[string(k)] = v } return m } tests := []tc{ { name: "noExit/noRoutes/noResolver", exitNode: "", peers: peers, dnsConfig: &tailcfg.DNSConfig{}, wantDefaultResolvers: nil, wantRoutes: nil, }, { name: "tsExit/noRoutes/noResolver", exitNode: "ts", peers: peers, dnsConfig: &tailcfg.DNSConfig{}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantRoutes: nil, }, { name: "tsExit/noRoutes/defaultResolver", exitNode: "ts", peers: peers, dnsConfig: &tailcfg.DNSConfig{Resolvers: defaultResolvers}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantRoutes: nil, }, // The following two cases may need to be revisited. For a shared-in // exit node split-DNS may effectively break, furthermore in the future // if different nodes observe different DNS configurations, even a // tailnet local exit node may present a different DNS configuration, // which may not meet expectations in some use cases. // In the case where a default resolver is set, the default resolver // should also perhaps take precedence also. { name: "tsExit/routes/noResolver", exitNode: "ts", peers: peers, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantRoutes: nil, }, { name: "tsExit/routes/defaultResolver", exitNode: "ts", peers: peers, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantRoutes: nil, }, // WireGuard exit nodes with DNS capabilities provide a "fallback" type // behavior, they have a lower precedence than a default resolver, but // otherwise allow split-DNS to operate as normal, and are used when // there is no default resolver. { name: "wgExit/noRoutes/noResolver", exitNode: "wg", peers: peers, dnsConfig: &tailcfg.DNSConfig{}, wantDefaultResolvers: wgResolvers, wantRoutes: nil, }, { name: "wgExit/noRoutes/defaultResolver", exitNode: "wg", peers: peers, dnsConfig: &tailcfg.DNSConfig{Resolvers: defaultResolvers}, wantDefaultResolvers: defaultResolvers, wantRoutes: nil, }, { name: "wgExit/routes/defaultResolver", exitNode: "wg", peers: peers, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, wantDefaultResolvers: defaultResolvers, wantRoutes: routes, }, { name: "wgExit/routes/noResolver", exitNode: "wg", peers: peers, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, wantDefaultResolvers: wgResolvers, wantRoutes: routes, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { nm := &netmap.NetworkMap{ Peers: tc.peers, DNS: *tc.dnsConfig, } prefs := &ipn.Prefs{ExitNodeID: tc.exitNode, CorpDNS: true} got := dnsConfigForNetmap(nm, peersMap(tc.peers), prefs.View(), t.Logf, "") if !resolversEqual(t, got.DefaultResolvers, tc.wantDefaultResolvers) { t.Errorf("DefaultResolvers: got %#v, want %#v", got.DefaultResolvers, tc.wantDefaultResolvers) } if !routesEqual(t, got.Routes, tc.wantRoutes) { t.Errorf("Routes: got %#v, want %#v", got.Routes, tc.wantRoutes) } }) } } func TestOfferingAppConnector(t *testing.T) { b := newTestBackend(t) if b.OfferingAppConnector() { t.Fatal("unexpected offering app connector") } b.appConnector = appc.NewAppConnector(t.Logf, nil) if !b.OfferingAppConnector() { t.Fatal("unexpected not offering app connector") } } func TestRouteAdvertiser(t *testing.T) { b := newTestBackend(t) testPrefix := netip.MustParsePrefix("192.0.0.8/32") ra := appc.RouteAdvertiser(b) must.Do(ra.AdvertiseRoute(testPrefix)) routes := b.Prefs().AdvertiseRoutes() if routes.Len() != 1 || routes.At(0) != testPrefix { t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix}) } must.Do(ra.UnadvertiseRoute(testPrefix)) routes = b.Prefs().AdvertiseRoutes() if routes.Len() != 0 { t.Fatalf("got routes %v, want none", routes) } } func TestRouterAdvertiserIgnoresContainedRoutes(t *testing.T) { b := newTestBackend(t) testPrefix := netip.MustParsePrefix("192.0.0.0/24") ra := appc.RouteAdvertiser(b) must.Do(ra.AdvertiseRoute(testPrefix)) routes := b.Prefs().AdvertiseRoutes() if routes.Len() != 1 || routes.At(0) != testPrefix { t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix}) } must.Do(ra.AdvertiseRoute(netip.MustParsePrefix("192.0.0.8/32"))) // the above /32 is not added as it is contained within the /24 routes = b.Prefs().AdvertiseRoutes() if routes.Len() != 1 || routes.At(0) != testPrefix { t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix}) } } func TestObserveDNSResponse(t *testing.T) { b := newTestBackend(t) // ensure no error when no app connector is configured b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) rc := &appctest.RouteCollector{} b.appConnector = appc.NewAppConnector(t.Logf, rc) b.appConnector.UpdateDomains([]string{"example.com"}) b.appConnector.Wait(context.Background()) b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) b.appConnector.Wait(context.Background()) wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} if !slices.Equal(rc.Routes(), wantRoutes) { t.Fatalf("got routes %v, want %v", rc.Routes(), wantRoutes) } } func TestCoveredRouteRangeNoDefault(t *testing.T) { tests := []struct { existingRoute netip.Prefix newRoute netip.Prefix want bool }{ { existingRoute: netip.MustParsePrefix("192.0.0.1/32"), newRoute: netip.MustParsePrefix("192.0.0.1/32"), want: true, }, { existingRoute: netip.MustParsePrefix("192.0.0.1/32"), newRoute: netip.MustParsePrefix("192.0.0.2/32"), want: false, }, { existingRoute: netip.MustParsePrefix("192.0.0.0/24"), newRoute: netip.MustParsePrefix("192.0.0.1/32"), want: true, }, { existingRoute: netip.MustParsePrefix("192.0.0.0/16"), newRoute: netip.MustParsePrefix("192.0.0.0/24"), want: true, }, { existingRoute: netip.MustParsePrefix("0.0.0.0/0"), newRoute: netip.MustParsePrefix("192.0.0.0/24"), want: false, }, { existingRoute: netip.MustParsePrefix("::/0"), newRoute: netip.MustParsePrefix("2001:db8::/32"), want: false, }, } for _, tt := range tests { got := coveredRouteRangeNoDefault([]netip.Prefix{tt.existingRoute}, tt.newRoute) if got != tt.want { t.Errorf("coveredRouteRange(%v, %v) = %v, want %v", tt.existingRoute, tt.newRoute, got, tt.want) } } } func TestReconfigureAppConnector(t *testing.T) { b := newTestBackend(t) b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) if b.appConnector != nil { t.Fatal("unexpected app connector") } b.EditPrefs(&ipn.MaskedPrefs{ Prefs: ipn.Prefs{ AppConnector: ipn.AppConnectorPrefs{ Advertise: true, }, }, AppConnectorSet: true, }) b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) if b.appConnector == nil { t.Fatal("expected app connector") } appCfg := `{ "name": "example", "domains": ["example.com"], "connectors": ["tag:example"] }` b.netMap.SelfNode = (&tailcfg.Node{ Name: "example.ts.net", Tags: []string{"tag:example"}, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ "tailscale.com/app-connectors": {tailcfg.RawMessage(appCfg)}, }), }).View() b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) b.appConnector.Wait(context.Background()) want := []string{"example.com"} if !slices.Equal(b.appConnector.Domains().AsSlice(), want) { t.Fatalf("got domains %v, want %v", b.appConnector.Domains(), want) } if v, _ := b.hostinfo.AppConnector.Get(); !v { t.Fatalf("expected app connector service") } // disable the connector in order to assert that the service is removed b.EditPrefs(&ipn.MaskedPrefs{ Prefs: ipn.Prefs{ AppConnector: ipn.AppConnectorPrefs{ Advertise: false, }, }, AppConnectorSet: true, }) b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) if b.appConnector != nil { t.Fatal("expected no app connector") } if v, _ := b.hostinfo.AppConnector.Get(); v { t.Fatalf("expected no app connector service") } } func resolversEqual(t *testing.T, a, b []*dnstype.Resolver) bool { if a == nil && b == nil { return true } if a == nil || b == nil { t.Errorf("resolversEqual: a == nil || b == nil : %#v != %#v", a, b) return false } if len(a) != len(b) { t.Errorf("resolversEqual: len(a) != len(b) : %#v != %#v", a, b) return false } for i := range a { if !a[i].Equal(b[i]) { t.Errorf("resolversEqual: a != b [%d]: %v != %v", i, *a[i], *b[i]) return false } } return true } func routesEqual(t *testing.T, a, b map[dnsname.FQDN][]*dnstype.Resolver) bool { if len(a) != len(b) { t.Logf("routes: len(a) != len(b): %d != %d", len(a), len(b)) return false } for name := range a { if !resolversEqual(t, a[name], b[name]) { t.Logf("routes: a != b [%s]: %v != %v", name, a[name], b[name]) return false } } return true } // dnsResponse is a test helper that creates a DNS response buffer for the given domain and address func dnsResponse(domain, address string) []byte { addr := netip.MustParseAddr(address) b := dnsmessage.NewBuilder(nil, dnsmessage.Header{}) b.EnableCompression() b.StartAnswers() switch addr.BitLen() { case 32: b.AResource( dnsmessage.ResourceHeader{ Name: dnsmessage.MustNewName(domain), Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 0, }, dnsmessage.AResource{ A: addr.As4(), }, ) case 128: b.AAAAResource( dnsmessage.ResourceHeader{ Name: dnsmessage.MustNewName(domain), Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 0, }, dnsmessage.AAAAResource{ AAAA: addr.As16(), }, ) default: panic("invalid address length") } return must.Get(b.Finish()) } type errorSyspolicyHandler struct { t *testing.T err error key syspolicy.Key allowKeys map[syspolicy.Key]*string } func (h *errorSyspolicyHandler) ReadString(key string) (string, error) { sk := syspolicy.Key(key) if _, ok := h.allowKeys[sk]; !ok { h.t.Errorf("ReadString: %q is not in list of permitted keys", h.key) } if sk == h.key { return "", h.err } return "", syspolicy.ErrNoSuchKey } func (h *errorSyspolicyHandler) ReadUInt64(key string) (uint64, error) { h.t.Errorf("ReadUInt64(%q) unexpectedly called", key) return 0, syspolicy.ErrNoSuchKey } func (h *errorSyspolicyHandler) ReadBoolean(key string) (bool, error) { h.t.Errorf("ReadBoolean(%q) unexpectedly called", key) return false, syspolicy.ErrNoSuchKey } type mockSyspolicyHandler struct { t *testing.T // stringPolicies is the collection of policies that we expect to see // queried by the current test. If the policy is expected but unset, then // use nil, otherwise use a string equal to the policy's desired value. stringPolicies map[syspolicy.Key]*string // failUnknownPolicies is set if policies other than those in stringPolicies // (uint64 or bool policies are not supported by mockSyspolicyHandler yet) // should be considered a test failure if they are queried. failUnknownPolicies bool } func (h *mockSyspolicyHandler) ReadString(key string) (string, error) { if s, ok := h.stringPolicies[syspolicy.Key(key)]; ok { if s == nil { return "", syspolicy.ErrNoSuchKey } return *s, nil } if h.failUnknownPolicies { h.t.Errorf("ReadString(%q) unexpectedly called", key) } return "", syspolicy.ErrNoSuchKey } func (h *mockSyspolicyHandler) ReadUInt64(key string) (uint64, error) { if h.failUnknownPolicies { h.t.Errorf("ReadUInt64(%q) unexpectedly called", key) } return 0, syspolicy.ErrNoSuchKey } func (h *mockSyspolicyHandler) ReadBoolean(key string) (bool, error) { if h.failUnknownPolicies { h.t.Errorf("ReadBoolean(%q) unexpectedly called", key) } return false, syspolicy.ErrNoSuchKey } func TestSetExitNodeIDPolicy(t *testing.T) { pfx := netip.MustParsePrefix tests := []struct { name string exitNodeIPKey bool exitNodeIDKey bool exitNodeID string exitNodeIP string prefs *ipn.Prefs exitNodeIPWant string exitNodeIDWant string prefsChanged bool nm *netmap.NetworkMap }{ { name: "ExitNodeID key is set", exitNodeIDKey: true, exitNodeID: "123", exitNodeIDWant: "123", prefsChanged: true, }, { name: "ExitNodeID key not set", exitNodeIDKey: true, exitNodeIDWant: "", prefsChanged: false, }, { name: "ExitNodeID key set, ExitNodeIP preference set", exitNodeIDKey: true, exitNodeID: "123", prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, exitNodeIDWant: "123", prefsChanged: true, }, { name: "ExitNodeID key not set, ExitNodeIP key set", exitNodeIPKey: true, exitNodeIP: "127.0.0.1", prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, exitNodeIPWant: "127.0.0.1", prefsChanged: false, }, { name: "ExitNodeIP key set, existing ExitNodeIP pref", exitNodeIPKey: true, exitNodeIP: "127.0.0.1", prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, exitNodeIPWant: "127.0.0.1", prefsChanged: false, }, { name: "existing preferences match policy", exitNodeIDKey: true, exitNodeID: "123", prefs: &ipn.Prefs{ExitNodeID: tailcfg.StableNodeID("123")}, exitNodeIDWant: "123", prefsChanged: false, }, { name: "ExitNodeIP set if net map does not have corresponding node", exitNodeIPKey: true, prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, exitNodeIP: "127.0.0.1", exitNodeIPWant: "127.0.0.1", prefsChanged: false, nm: &netmap.NetworkMap{ Name: "foo.tailnet", SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ pfx("100.102.103.104/32"), pfx("100::123/128"), }, }).View(), Peers: []tailcfg.NodeView{ (&tailcfg.Node{ Name: "a.tailnet", Addresses: []netip.Prefix{ pfx("100.0.0.201/32"), pfx("100::201/128"), }, }).View(), (&tailcfg.Node{ Name: "b.tailnet", Addresses: []netip.Prefix{ pfx("100::202/128"), }, }).View(), }, }, }, { name: "ExitNodeIP cleared if net map has corresponding node - policy matches prefs", prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, exitNodeIPKey: true, exitNodeIP: "127.0.0.1", exitNodeIPWant: "", exitNodeIDWant: "123", prefsChanged: true, nm: &netmap.NetworkMap{ Name: "foo.tailnet", SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ pfx("100.102.103.104/32"), pfx("100::123/128"), }, }).View(), Peers: []tailcfg.NodeView{ (&tailcfg.Node{ Name: "a.tailnet", StableID: tailcfg.StableNodeID("123"), Addresses: []netip.Prefix{ pfx("127.0.0.1/32"), pfx("100::201/128"), }, }).View(), (&tailcfg.Node{ Name: "b.tailnet", Addresses: []netip.Prefix{ pfx("100::202/128"), }, }).View(), }, }, }, { name: "ExitNodeIP cleared if net map has corresponding node - no policy set", prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, exitNodeIPWant: "", exitNodeIDWant: "123", prefsChanged: true, nm: &netmap.NetworkMap{ Name: "foo.tailnet", SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ pfx("100.102.103.104/32"), pfx("100::123/128"), }, }).View(), Peers: []tailcfg.NodeView{ (&tailcfg.Node{ Name: "a.tailnet", StableID: tailcfg.StableNodeID("123"), Addresses: []netip.Prefix{ pfx("127.0.0.1/32"), pfx("100::201/128"), }, }).View(), (&tailcfg.Node{ Name: "b.tailnet", Addresses: []netip.Prefix{ pfx("100::202/128"), }, }).View(), }, }, }, { name: "ExitNodeIP cleared if net map has corresponding node - different exit node IP in policy", exitNodeIPKey: true, prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, exitNodeIP: "100.64.5.6", exitNodeIPWant: "", exitNodeIDWant: "123", prefsChanged: true, nm: &netmap.NetworkMap{ Name: "foo.tailnet", SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ pfx("100.102.103.104/32"), pfx("100::123/128"), }, }).View(), Peers: []tailcfg.NodeView{ (&tailcfg.Node{ Name: "a.tailnet", StableID: tailcfg.StableNodeID("123"), Addresses: []netip.Prefix{ pfx("100.64.5.6/32"), pfx("100::201/128"), }, }).View(), (&tailcfg.Node{ Name: "b.tailnet", Addresses: []netip.Prefix{ pfx("100::202/128"), }, }).View(), }, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { b := newTestBackend(t) msh := &mockSyspolicyHandler{ t: t, stringPolicies: map[syspolicy.Key]*string{ syspolicy.ExitNodeID: nil, syspolicy.ExitNodeIP: nil, }, } if test.exitNodeIDKey { msh.stringPolicies[syspolicy.ExitNodeID] = &test.exitNodeID } if test.exitNodeIPKey { msh.stringPolicies[syspolicy.ExitNodeIP] = &test.exitNodeIP } syspolicy.SetHandlerForTest(t, msh) if test.nm == nil { test.nm = new(netmap.NetworkMap) } if test.prefs == nil { test.prefs = ipn.NewPrefs() } pm := must.Get(newProfileManager(new(mem.Store), t.Logf)) pm.prefs = test.prefs.View() b.netMap = test.nm b.pm = pm changed := setExitNodeID(b.pm.prefs.AsStruct(), test.nm) b.SetPrefsForTest(pm.CurrentPrefs().AsStruct()) if got := b.pm.prefs.ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) { t.Errorf("got %v want %v", got, test.exitNodeIDWant) } if got := b.pm.prefs.ExitNodeIP(); test.exitNodeIPWant == "" { if got.String() != "invalid IP" { t.Errorf("got %v want invalid IP", got) } } else if got.String() != test.exitNodeIPWant { t.Errorf("got %v want %v", got, test.exitNodeIPWant) } if changed != test.prefsChanged { t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed) } }) } } func TestApplySysPolicy(t *testing.T) { tests := []struct { name string prefs ipn.Prefs wantPrefs ipn.Prefs wantAnyChange bool stringPolicies map[syspolicy.Key]string }{ { name: "empty prefs without policies", }, { name: "prefs set without policies", prefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, ExitNodeAllowLANAccess: true, CorpDNS: true, RouteAll: true, }, wantPrefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, ExitNodeAllowLANAccess: true, CorpDNS: true, RouteAll: true, }, }, { name: "empty prefs with policies", wantPrefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, ExitNodeAllowLANAccess: true, CorpDNS: true, RouteAll: true, }, wantAnyChange: true, stringPolicies: map[syspolicy.Key]string{ syspolicy.ControlURL: "1", syspolicy.EnableIncomingConnections: "never", syspolicy.EnableServerMode: "always", syspolicy.ExitNodeAllowLANAccess: "always", syspolicy.EnableTailscaleDNS: "always", syspolicy.EnableTailscaleSubnets: "always", }, }, { name: "prefs set with matching policies", prefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, }, wantPrefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, }, stringPolicies: map[syspolicy.Key]string{ syspolicy.ControlURL: "1", syspolicy.EnableIncomingConnections: "never", syspolicy.EnableServerMode: "always", syspolicy.ExitNodeAllowLANAccess: "never", syspolicy.EnableTailscaleDNS: "never", syspolicy.EnableTailscaleSubnets: "never", }, }, { name: "prefs set with conflicting policies", prefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, ExitNodeAllowLANAccess: false, CorpDNS: true, RouteAll: false, }, wantPrefs: ipn.Prefs{ ControlURL: "2", ShieldsUp: false, ForceDaemon: false, ExitNodeAllowLANAccess: true, CorpDNS: false, RouteAll: true, }, wantAnyChange: true, stringPolicies: map[syspolicy.Key]string{ syspolicy.ControlURL: "2", syspolicy.EnableIncomingConnections: "always", syspolicy.EnableServerMode: "never", syspolicy.ExitNodeAllowLANAccess: "always", syspolicy.EnableTailscaleDNS: "never", syspolicy.EnableTailscaleSubnets: "always", }, }, { name: "prefs set with neutral policies", prefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, ExitNodeAllowLANAccess: false, CorpDNS: true, RouteAll: true, }, wantPrefs: ipn.Prefs{ ControlURL: "1", ShieldsUp: true, ForceDaemon: true, ExitNodeAllowLANAccess: false, CorpDNS: true, RouteAll: true, }, stringPolicies: map[syspolicy.Key]string{ syspolicy.EnableIncomingConnections: "user-decides", syspolicy.EnableServerMode: "user-decides", syspolicy.ExitNodeAllowLANAccess: "user-decides", syspolicy.EnableTailscaleDNS: "user-decides", syspolicy.EnableTailscaleSubnets: "user-decides", }, }, { name: "ControlURL", wantPrefs: ipn.Prefs{ ControlURL: "set", }, wantAnyChange: true, stringPolicies: map[syspolicy.Key]string{ syspolicy.ControlURL: "set", }, }, { name: "enable AutoUpdate apply does not unset check", prefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: true, Apply: opt.NewBool(false), }, }, wantPrefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: true, Apply: opt.NewBool(true), }, }, wantAnyChange: true, stringPolicies: map[syspolicy.Key]string{ syspolicy.ApplyUpdates: "always", }, }, { name: "disable AutoUpdate apply does not unset check", prefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: true, Apply: opt.NewBool(true), }, }, wantPrefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: true, Apply: opt.NewBool(false), }, }, wantAnyChange: true, stringPolicies: map[syspolicy.Key]string{ syspolicy.ApplyUpdates: "never", }, }, { name: "enable AutoUpdate check does not unset apply", prefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: false, Apply: opt.NewBool(true), }, }, wantPrefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: true, Apply: opt.NewBool(true), }, }, wantAnyChange: true, stringPolicies: map[syspolicy.Key]string{ syspolicy.CheckUpdates: "always", }, }, { name: "disable AutoUpdate check does not unset apply", prefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: true, Apply: opt.NewBool(true), }, }, wantPrefs: ipn.Prefs{ AutoUpdate: ipn.AutoUpdatePrefs{ Check: false, Apply: opt.NewBool(true), }, }, wantAnyChange: true, stringPolicies: map[syspolicy.Key]string{ syspolicy.CheckUpdates: "never", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { msh := &mockSyspolicyHandler{ t: t, stringPolicies: make(map[syspolicy.Key]*string, len(tt.stringPolicies)), } for p, v := range tt.stringPolicies { v := v // construct a unique pointer for each policy value msh.stringPolicies[p] = &v } syspolicy.SetHandlerForTest(t, msh) t.Run("unit", func(t *testing.T) { prefs := tt.prefs.Clone() gotAnyChange := applySysPolicy(prefs) if gotAnyChange && prefs.Equals(&tt.prefs) { t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty()) } if !gotAnyChange && !prefs.Equals(&tt.prefs) { t.Errorf("!anyChange but prefs changed from %v to %v", tt.prefs.Pretty(), prefs.Pretty()) } if gotAnyChange != tt.wantAnyChange { t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantAnyChange) } if !prefs.Equals(&tt.wantPrefs) { t.Errorf("prefs=%v, want %v", prefs.Pretty(), tt.wantPrefs.Pretty()) } }) t.Run("status update", func(t *testing.T) { // Profile manager fills in blank ControlURL but it's not set // in most test cases to avoid cluttering them, so adjust for // that. usePrefs := tt.prefs.Clone() if usePrefs.ControlURL == "" { usePrefs.ControlURL = ipn.DefaultControlURL } wantPrefs := tt.wantPrefs.Clone() if wantPrefs.ControlURL == "" { wantPrefs.ControlURL = ipn.DefaultControlURL } pm := must.Get(newProfileManager(new(mem.Store), t.Logf)) pm.prefs = usePrefs.View() b := newTestBackend(t) b.mu.Lock() b.pm = pm b.mu.Unlock() b.SetControlClientStatus(b.cc, controlclient.Status{}) if !b.Prefs().Equals(wantPrefs.View()) { t.Errorf("prefs=%v, want %v", b.Prefs().Pretty(), wantPrefs.Pretty()) } }) }) } } func TestPreferencePolicyInfo(t *testing.T) { tests := []struct { name string initialValue bool wantValue bool wantChange bool policyValue string policyError error }{ { name: "force enable modify", initialValue: false, wantValue: true, wantChange: true, policyValue: "always", }, { name: "force enable unchanged", initialValue: true, wantValue: true, policyValue: "always", }, { name: "force disable modify", initialValue: true, wantValue: false, wantChange: true, policyValue: "never", }, { name: "force disable unchanged", initialValue: false, wantValue: false, policyValue: "never", }, { name: "unforced enabled", initialValue: true, wantValue: true, policyValue: "user-decides", }, { name: "unforced disabled", initialValue: false, wantValue: false, policyValue: "user-decides", }, { name: "blank enabled", initialValue: true, wantValue: true, policyValue: "", }, { name: "blank disabled", initialValue: false, wantValue: false, policyValue: "", }, { name: "unset enabled", initialValue: true, wantValue: true, policyError: syspolicy.ErrNoSuchKey, }, { name: "unset disabled", initialValue: false, wantValue: false, policyError: syspolicy.ErrNoSuchKey, }, { name: "error enabled", initialValue: true, wantValue: true, policyError: errors.New("test error"), }, { name: "error disabled", initialValue: false, wantValue: false, policyError: errors.New("test error"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { for _, pp := range preferencePolicies { t.Run(string(pp.key), func(t *testing.T) { var h syspolicy.Handler allPolicies := make(map[syspolicy.Key]*string, len(preferencePolicies)+1) allPolicies[syspolicy.ControlURL] = nil for _, pp := range preferencePolicies { allPolicies[pp.key] = nil } if tt.policyError != nil { h = &errorSyspolicyHandler{ t: t, err: tt.policyError, key: pp.key, allowKeys: allPolicies, } } else { msh := &mockSyspolicyHandler{ t: t, stringPolicies: allPolicies, failUnknownPolicies: true, } msh.stringPolicies[pp.key] = &tt.policyValue h = msh } syspolicy.SetHandlerForTest(t, h) prefs := defaultPrefs.AsStruct() pp.set(prefs, tt.initialValue) gotAnyChange := applySysPolicy(prefs) if gotAnyChange != tt.wantChange { t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantChange) } got := pp.get(prefs.View()) if got != tt.wantValue { t.Errorf("pref=%v, want %v", got, tt.wantValue) } }) } }) } } func TestOnTailnetDefaultAutoUpdate(t *testing.T) { tests := []struct { desc string before, after opt.Bool tailnetDefault bool }{ { before: opt.Bool(""), tailnetDefault: true, after: opt.NewBool(true), }, { before: opt.Bool(""), tailnetDefault: false, after: opt.NewBool(false), }, { before: opt.Bool("unset"), tailnetDefault: true, after: opt.NewBool(true), }, { before: opt.Bool("unset"), tailnetDefault: false, after: opt.NewBool(false), }, { before: opt.NewBool(false), tailnetDefault: true, after: opt.NewBool(false), }, { before: opt.NewBool(true), tailnetDefault: false, after: opt.NewBool(true), }, } for _, tt := range tests { t.Run(fmt.Sprintf("before=%s after=%s", tt.before, tt.after), func(t *testing.T) { b := newTestBackend(t) p := ipn.NewPrefs() p.AutoUpdate.Apply = tt.before if err := b.pm.setPrefsLocked(p.View()); err != nil { t.Fatal(err) } b.onTailnetDefaultAutoUpdate(tt.tailnetDefault) if want, got := tt.after, b.pm.CurrentPrefs().AutoUpdate().Apply; got != want { t.Errorf("got: %q, want %q", got, want) } }) } } func TestTCPHandlerForDst(t *testing.T) { b := newTestBackend(t) tests := []struct { desc string dst string intercept bool }{ { desc: "intercept port 80 (Web UI) on quad100 IPv4", dst: "100.100.100.100:80", intercept: true, }, { desc: "intercept port 80 (Web UI) on quad100 IPv6", dst: "[fd7a:115c:a1e0::53]:80", intercept: true, }, { desc: "don't intercept port 80 on local ip", dst: "100.100.103.100:80", intercept: false, }, { desc: "intercept port 8080 (Taildrive) on quad100 IPv4", dst: "100.100.100.100:8080", intercept: true, }, { desc: "intercept port 8080 (Taildrive) on quad100 IPv6", dst: "[fd7a:115c:a1e0::53]:8080", intercept: true, }, { desc: "don't intercept port 8080 on local ip", dst: "100.100.103.100:8080", intercept: false, }, { desc: "don't intercept port 9080 on quad100 IPv4", dst: "100.100.100.100:9080", intercept: false, }, { desc: "don't intercept port 9080 on quad100 IPv6", dst: "[fd7a:115c:a1e0::53]:9080", intercept: false, }, { desc: "don't intercept port 9080 on local ip", dst: "100.100.103.100:9080", intercept: false, }, } for _, tt := range tests { t.Run(tt.dst, func(t *testing.T) { t.Log(tt.desc) src := netip.MustParseAddrPort("100.100.102.100:51234") h, _ := b.TCPHandlerForDst(src, netip.MustParseAddrPort(tt.dst)) if !tt.intercept && h != nil { t.Error("intercepted traffic we shouldn't have") } else if tt.intercept && h == nil { t.Error("failed to intercept traffic we should have") } }) } } func TestDriveManageShares(t *testing.T) { tests := []struct { name string disabled bool existing []*drive.Share add *drive.Share remove string rename [2]string expect any }{ { name: "append", existing: []*drive.Share{ {Name: "b"}, {Name: "d"}, }, add: &drive.Share{Name: " E "}, expect: []*drive.Share{ {Name: "b"}, {Name: "d"}, {Name: "e"}, }, }, { name: "prepend", existing: []*drive.Share{ {Name: "b"}, {Name: "d"}, }, add: &drive.Share{Name: " A "}, expect: []*drive.Share{ {Name: "a"}, {Name: "b"}, {Name: "d"}, }, }, { name: "insert", existing: []*drive.Share{ {Name: "b"}, {Name: "d"}, }, add: &drive.Share{Name: " C "}, expect: []*drive.Share{ {Name: "b"}, {Name: "c"}, {Name: "d"}, }, }, { name: "replace", existing: []*drive.Share{ {Name: "b", Path: "i"}, {Name: "d"}, }, add: &drive.Share{Name: " B ", Path: "ii"}, expect: []*drive.Share{ {Name: "b", Path: "ii"}, {Name: "d"}, }, }, { name: "add_bad_name", add: &drive.Share{Name: "$"}, expect: drive.ErrInvalidShareName, }, { name: "add_disabled", disabled: true, add: &drive.Share{Name: "a"}, expect: drive.ErrDriveNotEnabled, }, { name: "remove", existing: []*drive.Share{ {Name: "a"}, {Name: "b"}, {Name: "c"}, }, remove: "b", expect: []*drive.Share{ {Name: "a"}, {Name: "c"}, }, }, { name: "remove_non_existing", existing: []*drive.Share{ {Name: "a"}, {Name: "b"}, {Name: "c"}, }, remove: "D", expect: os.ErrNotExist, }, { name: "remove_disabled", disabled: true, remove: "b", expect: drive.ErrDriveNotEnabled, }, { name: "rename", existing: []*drive.Share{ {Name: "a"}, {Name: "b"}, }, rename: [2]string{"a", " C "}, expect: []*drive.Share{ {Name: "b"}, {Name: "c"}, }, }, { name: "rename_not_exist", existing: []*drive.Share{ {Name: "a"}, {Name: "b"}, }, rename: [2]string{"d", "c"}, expect: os.ErrNotExist, }, { name: "rename_exists", existing: []*drive.Share{ {Name: "a"}, {Name: "b"}, }, rename: [2]string{"a", "b"}, expect: os.ErrExist, }, { name: "rename_bad_name", rename: [2]string{"a", "$"}, expect: drive.ErrInvalidShareName, }, { name: "rename_disabled", disabled: true, rename: [2]string{"a", "c"}, expect: drive.ErrDriveNotEnabled, }, } drive.DisallowShareAs = true t.Cleanup(func() { drive.DisallowShareAs = false }) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { b := newTestBackend(t) b.mu.Lock() if tt.existing != nil { b.driveSetSharesLocked(tt.existing) } if !tt.disabled { self := b.netMap.SelfNode.AsStruct() self.CapMap = tailcfg.NodeCapMap{tailcfg.NodeAttrsTaildriveShare: nil} b.netMap.SelfNode = self.View() b.sys.Set(driveimpl.NewFileSystemForRemote(b.logf)) } b.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) t.Cleanup(cancel) result := make(chan views.SliceView[*drive.Share, drive.ShareView], 1) var wg sync.WaitGroup wg.Add(1) go b.WatchNotifications( ctx, 0, func() { wg.Done() }, func(n *ipn.Notify) bool { select { case result <- n.DriveShares: default: // } return false }, ) wg.Wait() var err error switch { case tt.add != nil: err = b.DriveSetShare(tt.add) case tt.remove != "": err = b.DriveRemoveShare(tt.remove) default: err = b.DriveRenameShare(tt.rename[0], tt.rename[1]) } switch e := tt.expect.(type) { case error: if !errors.Is(err, e) { t.Errorf("expected error, want: %v got: %v", e, err) } case []*drive.Share: if err != nil { t.Errorf("unexpected error: %v", err) } else { r := <-result got, err := json.MarshalIndent(r, "", " ") if err != nil { t.Fatalf("can't marshal got: %v", err) } want, err := json.MarshalIndent(e, "", " ") if err != nil { t.Fatalf("can't marshal want: %v", err) } if diff := cmp.Diff(string(got), string(want)); diff != "" { t.Errorf("wrong shares; (-got+want):%v", diff) } } } }) } } func TestValidPopBrowserURL(t *testing.T) { b := newTestBackend(t) tests := []struct { desc string controlURL string popBrowserURL string want bool }{ {"saas_login", "https://login.tailscale.com", "https://login.tailscale.com/a/foo", true}, {"saas_controlplane", "https://controlplane.tailscale.com", "https://controlplane.tailscale.com/a/foo", true}, {"saas_root", "https://login.tailscale.com", "https://tailscale.com/", true}, {"saas_bad_hostname", "https://login.tailscale.com", "https://example.com/a/foo", false}, {"localhost", "http://localhost", "http://localhost/a/foo", true}, {"custom_control_url_https", "https://example.com", "https://example.com/a/foo", true}, {"custom_control_url_https_diff_domain", "https://example.com", "https://other.com/a/foo", true}, {"custom_control_url_http", "http://example.com", "http://example.com/a/foo", true}, {"custom_control_url_http_diff_domain", "http://example.com", "http://other.com/a/foo", true}, {"bad_scheme", "https://example.com", "http://example.com/a/foo", false}, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { if _, err := b.EditPrefs(&ipn.MaskedPrefs{ ControlURLSet: true, Prefs: ipn.Prefs{ ControlURL: tt.controlURL, }, }); err != nil { t.Fatal(err) } got := b.validPopBrowserURL(tt.popBrowserURL) if got != tt.want { t.Errorf("got %v, want %v", got, tt.want) } }) } } func TestRoundTraffic(t *testing.T) { tests := []struct { name string bytes int64 want float64 }{ {name: "under 5 bytes", bytes: 4, want: 4}, {name: "under 1000 bytes", bytes: 987, want: 990}, {name: "under 10_000 bytes", bytes: 8875, want: 8900}, {name: "under 100_000 bytes", bytes: 77777, want: 78000}, {name: "under 1_000_000 bytes", bytes: 666523, want: 670000}, {name: "under 10_000_000 bytes", bytes: 22556677, want: 23000000}, {name: "under 1_000_000_000 bytes", bytes: 1234234234, want: 1200000000}, {name: "under 1_000_000_000 bytes", bytes: 123423423499, want: 123400000000}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if result := roundTraffic(tt.bytes); result != tt.want { t.Errorf("unexpected rounding got %v want %v", result, tt.want) } }) } } func (b *LocalBackend) SetPrefsForTest(newp *ipn.Prefs) { if newp == nil { panic("SetPrefsForTest got nil prefs") } unlock := b.lockAndGetUnlock() defer unlock() b.setPrefsLockedOnEntry(newp, unlock) } func TestSuggestExitNode(t *testing.T) { tests := []struct { name string lastReport netcheck.Report netMap netmap.NetworkMap wantID tailcfg.StableNodeID wantName string wantLocation tailcfg.LocationView wantError error }{ { name: "2 exit nodes in same region", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 10 * time.Millisecond, 2: 20 * time.Millisecond, 3: 30 * time.Millisecond, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: {}, 2: {}, 3: {}, }, }, Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, Name: "2", StableID: "2", DERP: "127.3.3.40:1", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), (&tailcfg.Node{ ID: 3, Name: "3", StableID: "3", DERP: "127.3.3.40:1", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), }, }, wantName: "3", wantID: tailcfg.StableNodeID("3"), }, { name: "2 derp based exit nodes, different regions, no latency measurements", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 0, 2: 0, 3: 0, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: {}, 2: {}, 3: {}, }, }, Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", Name: "2", DERP: "127.3.3.40:2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), (&tailcfg.Node{ ID: 3, StableID: "3", Name: "3", DERP: "127.3.3.40:3", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), }, }, wantName: "3", wantID: tailcfg.StableNodeID("3"), }, { name: "2 derp based exit nodes, different regions, same latency", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 10, 2: 10, 3: 0, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: {}, 2: {}, 3: {}, }, }, Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", Name: "2", DERP: "127.3.3.40:1", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), (&tailcfg.Node{ ID: 3, StableID: "3", Name: "3", DERP: "127.3.3.40:2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), }, }, wantName: "2", wantID: tailcfg.StableNodeID("2"), }, { name: "mullvad nodes, no derp based exit nodes", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 0, 2: 0, 3: 0, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { Latitude: 40.73061, Longitude: -73.935242, }, 2: {}, 3: {}, }, }, Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Name: "Dallas", Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Latitude: 32.89748, Longitude: -97.040443, Priority: 100, }, }).View(), CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), (&tailcfg.Node{ ID: 3, StableID: "3", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Name: "San Jose", Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Latitude: 37.3382082, Longitude: -121.8863286, Priority: 20, }, }).View(), CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), }, }, wantID: tailcfg.StableNodeID("2"), wantLocation: (&tailcfg.Location{ Latitude: 32.89748, Longitude: -97.040443, Priority: 100, }).View(), wantName: "Dallas", }, { name: "mullvad nodes close to each other, different priorities", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 0, 2: 0, 3: 0, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { Latitude: 40.73061, Longitude: -73.935242, }, 2: {}, 3: {}, }, }, Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Name: "Dallas", Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Latitude: 32.89748, Longitude: -97.040443, Priority: 10, }, }).View(), CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), (&tailcfg.Node{ ID: 3, StableID: "3", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Name: "Fort Worth", Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Latitude: 37.768799, Longitude: -97.309341, Priority: 50, }, }).View(), CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), }, }, wantID: tailcfg.StableNodeID("3"), wantLocation: (&tailcfg.Location{ Latitude: 37.768799, Longitude: -97.309341, Priority: 50, }).View(), wantName: "Fort Worth", }, { name: "mullvad nodes, no preferred derp region exit nodes", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 0, 2: 0, 3: 0, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { Latitude: 40.73061, Longitude: -73.935242, }, 2: {}, 3: {}, }, }, Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Name: "Dallas", Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Latitude: 32.89748, Longitude: -97.040443, Priority: 20, }, }).View(), CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), (&tailcfg.Node{ ID: 3, StableID: "3", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Name: "San Jose", Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Latitude: 37.3382082, Longitude: -121.8863286, Priority: 30, }, }).View(), CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), (&tailcfg.Node{ ID: 3, StableID: "3", Name: "3", DERP: "127.3.3.40:2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ tailcfg.NodeAttrSuggestExitNode: {}, }), }).View(), }, }, wantID: tailcfg.StableNodeID("3"), wantName: "3", }, { name: "no mullvad nodes; no derp nodes", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 0, 2: 0, 3: 0, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: {}, 2: {}, 3: {}, }, }, }, }, { name: "no preferred derp region", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 0, 2: -1, 3: 0, }, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: {}, 2: {}, 3: {}, }, }, }, wantError: ErrNoPreferredDERP, }, { name: "derp exit node and mullvad exit node both with no suggest exit node attribute", lastReport: netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 0, 2: 0, 3: 0, }, PreferredDERP: 1, }, netMap: netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Addresses: []netip.Prefix{ netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("fe70::1/128"), }, }).View(), DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: {}, 2: {}, 3: {}, }, }, Peers: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", Name: "2", DERP: "127.3.3.40:1", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, }).View(), (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Name: "Dallas", Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Latitude: 32.89748, Longitude: -97.040443, Priority: 30, }, }).View(), }).View(), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := rand.New(rand.NewSource(100)) got, err := suggestExitNode(&tt.lastReport, &tt.netMap, r) if got.Name != tt.wantName { t.Errorf("name=%v, want %v", got.Name, tt.wantName) } if got.ID != tt.wantID { t.Errorf("ID=%v, want %v", got.ID, tt.wantID) } if tt.wantError == nil && err != nil { t.Errorf("err=%v, want no error", err) } if tt.wantError != nil && !errors.Is(err, tt.wantError) { t.Errorf("err=%v, want %v", err, tt.wantError) } if !reflect.DeepEqual(got.Location, tt.wantLocation) { t.Errorf("location=%v, want %v", got.Location, tt.wantLocation) } }) } } func TestSuggestExitNodePickWeighted(t *testing.T) { tests := []struct { name string candidates []tailcfg.NodeView wantValue tailcfg.NodeView wantValid bool }{ { name: ">1 candidates", candidates: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Priority: 20, }, }).View(), }).View(), (&tailcfg.Node{ ID: 3, StableID: "3", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Priority: 10, }, }).View(), }).View(), }, wantValue: (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Priority: 20, }, }).View(), }).View(), wantValid: true, }, { name: "<1 candidates", candidates: []tailcfg.NodeView{}, wantValid: false, }, { name: "1 candidate", candidates: []tailcfg.NodeView{ (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Priority: 20, }, }).View(), }).View(), }, wantValue: (&tailcfg.Node{ ID: 2, StableID: "2", AllowedIPs: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, Hostinfo: (&tailcfg.Hostinfo{ Location: &tailcfg.Location{ Priority: 20, }, }).View(), }).View(), wantValid: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := pickWeighted(tt.candidates) if !reflect.DeepEqual(got, tt.wantValue) { t.Errorf("got value %v want %v", got, tt.wantValue) if tt.wantValid != got.Valid() { t.Errorf("got invalid candidate expected valid") } if tt.wantValid { if !reflect.DeepEqual(got, tt.wantValue) { t.Errorf("got value %v want %v", got, tt.wantValue) } } } }) } } func TestSuggestExitNodeLongLatDistance(t *testing.T) { tests := []struct { name string fromLat float64 fromLong float64 toLat float64 toLong float64 want float64 }{ { name: "zero values", fromLat: 0, fromLong: 0, toLat: 0, toLong: 0, want: 0, }, { name: "valid values", fromLat: 40.73061, fromLong: -73.935242, toLat: 37.3382082, toLong: -121.8863286, want: 4117266.873301274, }, { name: "valid values, locations in north and south of equator", fromLat: 40.73061, fromLong: -73.935242, toLat: -33.861481, toLong: 151.205475, want: 15994089.144368416, }, } // The wanted values are computed using a more precise algorithm using the WGS84 model but // longLatDistance uses a spherical approximation for simplicity. To account for this, we allow for // 10km of error. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := longLatDistance(tt.fromLat, tt.fromLong, tt.toLat, tt.toLong) const maxError = 10000 // 10km if math.Abs(got-tt.want) > maxError { t.Errorf("distance=%vm, want within %vm of %vm", got, maxError, tt.want) } }) } } func TestMinLatencyDERPregion(t *testing.T) { tests := []struct { name string regions []int report *netcheck.Report wantRegion int }{ { name: "regions, no latency values", regions: []int{1, 2, 3}, wantRegion: 0, report: &netcheck.Report{}, }, { name: "regions, different latency values", regions: []int{1, 2, 3}, wantRegion: 2, report: &netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 10 * time.Millisecond, 2: 5 * time.Millisecond, 3: 30 * time.Millisecond, }, }, }, { name: "regions, same values", regions: []int{1, 2, 3}, wantRegion: 1, report: &netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 10 * time.Millisecond, 2: 10 * time.Millisecond, 3: 10 * time.Millisecond, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := minLatencyDERPRegion(tt.regions, tt.report) if got != tt.wantRegion { t.Errorf("got region %v want region %v", got, tt.wantRegion) } }) } }