// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package tailcfg_test import ( "encoding/json" "net/netip" "os" "reflect" "regexp" "strconv" "strings" "testing" "time" . "tailscale.com/tailcfg" "tailscale.com/tstest/deptest" "tailscale.com/types/key" "tailscale.com/types/opt" "tailscale.com/types/ptr" "tailscale.com/util/must" ) func fieldsOf(t reflect.Type) (fields []string) { for i := range t.NumField() { fields = append(fields, t.Field(i).Name) } return } func TestHostinfoEqual(t *testing.T) { hiHandles := []string{ "IPNVersion", "FrontendLogID", "BackendLogID", "OS", "OSVersion", "Container", "Env", "Distro", "DistroVersion", "DistroCodeName", "App", "Desktop", "Package", "DeviceModel", "PushDeviceToken", "Hostname", "ShieldsUp", "ShareeNode", "NoLogsNoSupport", "WireIngress", "AllowsUpdate", "Machine", "GoArch", "GoArchVar", "GoVersion", "RoutableIPs", "RequestTags", "WoLMACs", "Services", "NetInfo", "SSH_HostKeys", "Cloud", "Userspace", "UserspaceRouter", "AppConnector", "Location", } if have := fieldsOf(reflect.TypeFor[Hostinfo]()); !reflect.DeepEqual(have, hiHandles) { t.Errorf("Hostinfo.Equal check might be out of sync\nfields: %q\nhandled: %q\n", have, hiHandles) } nets := func(strs ...string) (ns []netip.Prefix) { for _, s := range strs { n, err := netip.ParsePrefix(s) if err != nil { panic(err) } ns = append(ns, n) } return ns } tests := []struct { a, b *Hostinfo want bool }{ { nil, nil, true, }, { &Hostinfo{}, nil, false, }, { nil, &Hostinfo{}, false, }, { &Hostinfo{}, &Hostinfo{}, true, }, { &Hostinfo{IPNVersion: "1"}, &Hostinfo{IPNVersion: "2"}, false, }, { &Hostinfo{IPNVersion: "2"}, &Hostinfo{IPNVersion: "2"}, true, }, { &Hostinfo{FrontendLogID: "1"}, &Hostinfo{FrontendLogID: "2"}, false, }, { &Hostinfo{FrontendLogID: "2"}, &Hostinfo{FrontendLogID: "2"}, true, }, { &Hostinfo{BackendLogID: "1"}, &Hostinfo{BackendLogID: "2"}, false, }, { &Hostinfo{BackendLogID: "2"}, &Hostinfo{BackendLogID: "2"}, true, }, { &Hostinfo{OS: "windows"}, &Hostinfo{OS: "linux"}, false, }, { &Hostinfo{OS: "windows"}, &Hostinfo{OS: "windows"}, true, }, { &Hostinfo{Hostname: "vega"}, &Hostinfo{Hostname: "iris"}, false, }, { &Hostinfo{Hostname: "vega"}, &Hostinfo{Hostname: "vega"}, true, }, { &Hostinfo{RoutableIPs: nil}, &Hostinfo{RoutableIPs: nets("10.0.0.0/16")}, false, }, { &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, &Hostinfo{RoutableIPs: nets("10.2.0.0/16", "192.168.2.0/24")}, false, }, { &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.2.0/24")}, false, }, { &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, true, }, { &Hostinfo{RequestTags: []string{"abc", "def"}}, &Hostinfo{RequestTags: []string{"abc", "def"}}, true, }, { &Hostinfo{RequestTags: []string{"abc", "def"}}, &Hostinfo{RequestTags: []string{"abc", "123"}}, false, }, { &Hostinfo{RequestTags: []string{}}, &Hostinfo{RequestTags: []string{"abc"}}, false, }, { &Hostinfo{Services: []Service{{Proto: TCP, Port: 1234, Description: "foo"}}}, &Hostinfo{Services: []Service{{Proto: UDP, Port: 2345, Description: "bar"}}}, false, }, { &Hostinfo{Services: []Service{{Proto: TCP, Port: 1234, Description: "foo"}}}, &Hostinfo{Services: []Service{{Proto: TCP, Port: 1234, Description: "foo"}}}, true, }, { &Hostinfo{ShareeNode: true}, &Hostinfo{}, false, }, { &Hostinfo{SSH_HostKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO.... root@bar"}}, &Hostinfo{}, false, }, { &Hostinfo{App: "golink"}, &Hostinfo{App: "abc"}, false, }, { &Hostinfo{App: "golink"}, &Hostinfo{App: "golink"}, true, }, { &Hostinfo{AppConnector: opt.Bool("true")}, &Hostinfo{AppConnector: opt.Bool("true")}, true, }, { &Hostinfo{AppConnector: opt.Bool("true")}, &Hostinfo{AppConnector: opt.Bool("false")}, false, }, } for i, tt := range tests { got := tt.a.Equal(tt.b) if got != tt.want { t.Errorf("%d. Equal = %v; want %v", i, got, tt.want) } } } func TestHostinfoHowEqual(t *testing.T) { tests := []struct { a, b *Hostinfo want []string }{ { a: nil, b: nil, want: nil, }, { a: new(Hostinfo), b: nil, want: []string{"nil"}, }, { a: nil, b: new(Hostinfo), want: []string{"nil"}, }, { a: new(Hostinfo), b: new(Hostinfo), want: nil, }, { a: &Hostinfo{ IPNVersion: "1", ShieldsUp: false, RoutableIPs: []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}, }, b: &Hostinfo{ IPNVersion: "2", ShieldsUp: true, RoutableIPs: []netip.Prefix{netip.MustParsePrefix("1.2.3.0/25")}, }, want: []string{"IPNVersion", "ShieldsUp", "RoutableIPs"}, }, { a: &Hostinfo{ IPNVersion: "1", }, b: &Hostinfo{ IPNVersion: "2", NetInfo: new(NetInfo), }, want: []string{"IPNVersion", "NetInfo.nil"}, }, { a: &Hostinfo{ IPNVersion: "1", NetInfo: &NetInfo{ WorkingIPv6: "true", HavePortMap: true, LinkType: "foo", PreferredDERP: 123, DERPLatency: map[string]float64{ "foo": 1.0, }, }, }, b: &Hostinfo{ IPNVersion: "2", NetInfo: &NetInfo{}, }, want: []string{"IPNVersion", "NetInfo.WorkingIPv6", "NetInfo.HavePortMap", "NetInfo.PreferredDERP", "NetInfo.LinkType", "NetInfo.DERPLatency"}, }, } for i, tt := range tests { got := tt.a.HowUnequal(tt.b) if !reflect.DeepEqual(got, tt.want) { t.Errorf("%d. got %q; want %q", i, got, tt.want) } } } func TestHostinfoTailscaleSSHEnabled(t *testing.T) { tests := []struct { hi *Hostinfo want bool }{ { nil, false, }, { &Hostinfo{}, false, }, { &Hostinfo{SSH_HostKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO.... root@bar"}}, true, }, } for i, tt := range tests { got := tt.hi.TailscaleSSHEnabled() if got != tt.want { t.Errorf("%d. got %v; want %v", i, got, tt.want) } } } func TestNodeEqual(t *testing.T) { nodeHandles := []string{ "ID", "StableID", "Name", "User", "Sharer", "Key", "KeyExpiry", "KeySignature", "Machine", "DiscoKey", "Addresses", "AllowedIPs", "Endpoints", "DERP", "Hostinfo", "Created", "Cap", "Tags", "PrimaryRoutes", "LastSeen", "Online", "MachineAuthorized", "Capabilities", "CapMap", "UnsignedPeerAPIOnly", "ComputedName", "computedHostIfDifferent", "ComputedNameWithHost", "DataPlaneAuditLogID", "Expired", "SelfNodeV4MasqAddrForThisPeer", "SelfNodeV6MasqAddrForThisPeer", "IsWireGuardOnly", "IsJailed", "ExitNodeDNSResolvers", } if have := fieldsOf(reflect.TypeFor[Node]()); !reflect.DeepEqual(have, nodeHandles) { t.Errorf("Node.Equal check might be out of sync\nfields: %q\nhandled: %q\n", have, nodeHandles) } n1 := key.NewNode().Public() m1 := key.NewMachine().Public() now := time.Now() tests := []struct { a, b *Node want bool }{ { &Node{}, nil, false, }, { nil, &Node{}, false, }, { &Node{}, &Node{}, true, }, { &Node{}, &Node{}, true, }, { &Node{ID: 1}, &Node{}, false, }, { &Node{ID: 1}, &Node{ID: 1}, true, }, { &Node{StableID: "node-abcd"}, &Node{}, false, }, { &Node{StableID: "node-abcd"}, &Node{StableID: "node-abcd"}, true, }, { &Node{User: 0}, &Node{User: 1}, false, }, { &Node{User: 1}, &Node{User: 1}, true, }, { &Node{Key: n1}, &Node{Key: key.NewNode().Public()}, false, }, { &Node{Key: n1}, &Node{Key: n1}, true, }, { &Node{KeyExpiry: now}, &Node{KeyExpiry: now.Add(60 * time.Second)}, false, }, { &Node{KeyExpiry: now}, &Node{KeyExpiry: now}, true, }, { &Node{Machine: m1}, &Node{Machine: key.NewMachine().Public()}, false, }, { &Node{Machine: m1}, &Node{Machine: m1}, true, }, { &Node{Addresses: []netip.Prefix{}}, &Node{Addresses: nil}, false, }, { &Node{Addresses: []netip.Prefix{}}, &Node{Addresses: []netip.Prefix{}}, true, }, { &Node{AllowedIPs: []netip.Prefix{}}, &Node{AllowedIPs: nil}, false, }, { &Node{Addresses: []netip.Prefix{}}, &Node{Addresses: []netip.Prefix{}}, true, }, { &Node{Endpoints: []netip.AddrPort{}}, &Node{Endpoints: nil}, false, }, { &Node{Endpoints: []netip.AddrPort{}}, &Node{Endpoints: []netip.AddrPort{}}, true, }, { &Node{Hostinfo: (&Hostinfo{Hostname: "alice"}).View()}, &Node{Hostinfo: (&Hostinfo{Hostname: "bob"}).View()}, false, }, { &Node{Hostinfo: (&Hostinfo{}).View()}, &Node{Hostinfo: (&Hostinfo{}).View()}, true, }, { &Node{Created: now}, &Node{Created: now.Add(60 * time.Second)}, false, }, { &Node{Created: now}, &Node{Created: now}, true, }, { &Node{LastSeen: &now}, &Node{LastSeen: nil}, false, }, { &Node{LastSeen: &now}, &Node{LastSeen: &now}, true, }, { &Node{DERP: "foo"}, &Node{DERP: "bar"}, false, }, { &Node{Tags: []string{"tag:foo"}}, &Node{Tags: []string{"tag:foo"}}, true, }, { &Node{Tags: []string{"tag:foo", "tag:bar"}}, &Node{Tags: []string{"tag:bar"}}, false, }, { &Node{Tags: []string{"tag:foo"}}, &Node{Tags: []string{"tag:bar"}}, false, }, { &Node{Tags: []string{"tag:foo"}}, &Node{}, false, }, { &Node{Expired: true}, &Node{}, false, }, { &Node{}, &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, false, }, { &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, true, }, { &Node{}, &Node{SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))}, false, }, { &Node{SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))}, &Node{SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))}, true, }, { &Node{ CapMap: NodeCapMap{ "foo": []RawMessage{`"foo"`}, }, }, &Node{ CapMap: NodeCapMap{ "foo": []RawMessage{`"foo"`}, }, }, true, }, { &Node{ CapMap: NodeCapMap{ "bar": []RawMessage{`"foo"`}, }, }, &Node{ CapMap: NodeCapMap{ "foo": []RawMessage{`"bar"`}, }, }, false, }, { &Node{ CapMap: NodeCapMap{ "foo": nil, }, }, &Node{ CapMap: NodeCapMap{ "foo": []RawMessage{`"bar"`}, }, }, false, }, { &Node{IsJailed: true}, &Node{IsJailed: true}, true, }, { &Node{IsJailed: false}, &Node{IsJailed: true}, false, }, } for i, tt := range tests { got := tt.a.Equal(tt.b) if got != tt.want { t.Errorf("%d. Equal = %v; want %v", i, got, tt.want) } } } func TestNetInfoFields(t *testing.T) { handled := []string{ "MappingVariesByDestIP", "HairPinning", "WorkingIPv6", "OSHasIPv6", "WorkingUDP", "WorkingICMPv4", "HavePortMap", "UPnP", "PMP", "PCP", "PreferredDERP", "LinkType", "DERPLatency", "FirewallMode", } if have := fieldsOf(reflect.TypeFor[NetInfo]()); !reflect.DeepEqual(have, handled) { t.Errorf("NetInfo.Clone/BasicallyEqually check might be out of sync\nfields: %q\nhandled: %q\n", have, handled) } } func TestCloneUser(t *testing.T) { tests := []struct { name string u *User }{ {"nil_logins", &User{}}, {"zero_logins", &User{Logins: make([]LoginID, 0)}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u2 := tt.u.Clone() if !reflect.DeepEqual(tt.u, u2) { t.Errorf("not equal") } }) } } func TestCloneNode(t *testing.T) { tests := []struct { name string v *Node }{ {"nil_fields", &Node{}}, {"zero_fields", &Node{ Addresses: make([]netip.Prefix, 0), AllowedIPs: make([]netip.Prefix, 0), Endpoints: make([]netip.AddrPort, 0), }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v2 := tt.v.Clone() if !reflect.DeepEqual(tt.v, v2) { t.Errorf("not equal") } }) } } func TestUserProfileJSONMarshalForMac(t *testing.T) { // Old macOS clients had a bug where they required // UserProfile.Roles to be non-null. Lock that in // 1.0.x/1.2.x clients are gone in the wild. // See mac commit 0242c08a2ca496958027db1208f44251bff8488b (Sep 30). // It was fixed in at least 1.4.x, and perhaps 1.2.x. j, err := json.Marshal(UserProfile{}) if err != nil { t.Fatal(err) } const wantSub = `"Roles":[]` if !strings.Contains(string(j), wantSub) { t.Fatalf("didn't contain %#q; got: %s", wantSub, j) } // And back: var up UserProfile if err := json.Unmarshal(j, &up); err != nil { t.Fatalf("Unmarshal: %v", err) } } func TestEndpointTypeMarshal(t *testing.T) { eps := []EndpointType{ EndpointUnknownType, EndpointLocal, EndpointSTUN, EndpointPortmapped, EndpointSTUN4LocalPort, } got, err := json.Marshal(eps) if err != nil { t.Fatal(err) } const want = `[0,1,2,3,4]` if string(got) != want { t.Errorf("got %s; want %s", got, want) } } func TestRegisterRequestNilClone(t *testing.T) { var nilReq *RegisterRequest got := nilReq.Clone() if got != nil { t.Errorf("got = %v; want nil", got) } } // Tests that CurrentCapabilityVersion is bumped when the comment block above it gets bumped. // We've screwed this up several times. func TestCurrentCapabilityVersion(t *testing.T) { f := must.Get(os.ReadFile("tailcfg.go")) matches := regexp.MustCompile(`(?m)^//[\s-]+(\d+): \d\d\d\d-\d\d-\d\d: `).FindAllStringSubmatch(string(f), -1) max := 0 for _, m := range matches { n := must.Get(strconv.Atoi(m[1])) if n > max { max = n } } if CapabilityVersion(max) != CurrentCapabilityVersion { t.Errorf("CurrentCapabilityVersion = %d; want %d", CurrentCapabilityVersion, max) } } func TestUnmarshalHealth(t *testing.T) { tests := []struct { in string // MapResponse JSON want []string // MapResponse.Health wanted value post-unmarshal }{ {in: `{}`}, {in: `{"Health":null}`}, {in: `{"Health":[]}`, want: []string{}}, {in: `{"Health":["bad"]}`, want: []string{"bad"}}, } for _, tt := range tests { var mr MapResponse if err := json.Unmarshal([]byte(tt.in), &mr); err != nil { t.Fatal(err) } if !reflect.DeepEqual(mr.Health, tt.want) { t.Errorf("for %#q: got %v; want %v", tt.in, mr.Health, tt.want) } } } func TestRawMessage(t *testing.T) { // Create a few types of json.RawMessages and then marshal them back and // forth to make sure they round-trip. type rule struct { Ports []int `json:",omitempty"` } tests := []struct { name string val map[string][]rule wire map[string][]RawMessage }{ { name: "nil", val: nil, wire: nil, }, { name: "empty", val: map[string][]rule{}, wire: map[string][]RawMessage{}, }, { name: "one", val: map[string][]rule{ "foo": {{Ports: []int{1, 2, 3}}}, }, wire: map[string][]RawMessage{ "foo": { `{"Ports":[1,2,3]}`, }, }, }, { name: "many", val: map[string][]rule{ "foo": {{Ports: []int{1, 2, 3}}}, "bar": {{Ports: []int{4, 5, 6}}, {Ports: []int{7, 8, 9}}}, "baz": nil, "abc": {}, "def": {{}}, }, wire: map[string][]RawMessage{ "foo": { `{"Ports":[1,2,3]}`, }, "bar": { `{"Ports":[4,5,6]}`, `{"Ports":[7,8,9]}`, }, "baz": nil, "abc": {}, "def": {"{}"}, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { j := must.Get(json.Marshal(tc.val)) var gotWire map[string][]RawMessage if err := json.Unmarshal(j, &gotWire); err != nil { t.Fatalf("unmarshal: %v", err) } if !reflect.DeepEqual(gotWire, tc.wire) { t.Errorf("got %#v; want %#v", gotWire, tc.wire) } j = must.Get(json.Marshal(tc.wire)) var gotVal map[string][]rule if err := json.Unmarshal(j, &gotVal); err != nil { t.Fatalf("unmarshal: %v", err) } if !reflect.DeepEqual(gotVal, tc.val) { t.Errorf("got %#v; want %#v", gotVal, tc.val) } }) } } func TestDeps(t *testing.T) { deptest.DepChecker{ BadDeps: map[string]string{ // Make sure we don't again accidentally bring in a dependency on // drive or its transitive dependencies "tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631", "github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631", }, }.Check(t) }