mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-14 23:11:39 -04:00
Package tailcfg defines the types and constants used by the Tailscale protocol, but since everything is all in one package, it’s difficult to sift through the docs: https://pkg.go.dev/tailscale.com/tailcfg We define and enumerate capabilities as string constants for tailcfg.NodeCapability and tailcfg.PeerCapability. This PR extracts them into their own packages: - tailcfg.CapabilityFileSharing becomes nodecap.FileSharing - tailcfg.NodeAttrOnlyTCP443 becomes nodecap.OnlyTCP443 - tailcfg.PeerCapabilityTaildrive becomes peercap.Taildrive We originally intended for CapabilityFoo to grant an entitlement or permission for Foo, and for NodeAttrBar to configure Bar in the nodeAttrs section of the policy file. However, there was no technical enforcement of this convention, so new capabilities have used the NodeAttr prefix regardless of meaning. Therefore, this PR unifies tailcfg.CapabilityFoo and tailcfg.NodeAttrBar into a single package as nodecap.Foo and nodecap.Bar. Ran `go fix -inline ./...` and committed the changes that replaced uses of the tailcfg aliases with the authoritative ones. Updates #20259 Change-Id: Ieb7e7e6c8247c39faf42fdf15c68cdc7c621c730 Signed-off-by: Simon Law <sfllaw@tailscale.com>
1341 lines
28 KiB
Go
1341 lines
28 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package tailcfg_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/netip"
|
|
"os"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
. "tailscale.com/tailcfg"
|
|
"tailscale.com/tailcfg/peercap"
|
|
"tailscale.com/tstest/deptest"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/opt"
|
|
"tailscale.com/util/must"
|
|
)
|
|
|
|
func fieldsOf(t reflect.Type) (fields []string) {
|
|
for field := range t.Fields() {
|
|
fields = append(fields, field.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",
|
|
"RemoteConfig",
|
|
"WireIngress",
|
|
"IngressEnabled",
|
|
"AllowsUpdate",
|
|
"Machine",
|
|
"GoArch",
|
|
"GoArchVar",
|
|
"GoVersion",
|
|
"RoutableIPs",
|
|
"RequestTags",
|
|
"WoLMACs",
|
|
"Services",
|
|
"NetInfo",
|
|
"SSH_HostKeys",
|
|
"Cloud",
|
|
"Userspace",
|
|
"UserspaceRouter",
|
|
"AppConnector",
|
|
"ServicesHash",
|
|
"PeerRelay",
|
|
"ExitNodeID",
|
|
"Location",
|
|
"TPM",
|
|
"StateEncrypted",
|
|
}
|
|
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,
|
|
},
|
|
{
|
|
&Hostinfo{PeerRelay: true},
|
|
&Hostinfo{PeerRelay: true},
|
|
true,
|
|
},
|
|
{
|
|
&Hostinfo{PeerRelay: true},
|
|
&Hostinfo{PeerRelay: false},
|
|
false,
|
|
},
|
|
{
|
|
&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
|
|
&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
|
|
true,
|
|
},
|
|
{
|
|
&Hostinfo{ServicesHash: "084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0"},
|
|
&Hostinfo{},
|
|
false,
|
|
},
|
|
{
|
|
&Hostinfo{IngressEnabled: true},
|
|
&Hostinfo{},
|
|
false,
|
|
},
|
|
{
|
|
&Hostinfo{IngressEnabled: true},
|
|
&Hostinfo{IngressEnabled: true},
|
|
true,
|
|
},
|
|
{
|
|
&Hostinfo{IngressEnabled: false},
|
|
&Hostinfo{},
|
|
true,
|
|
},
|
|
{
|
|
&Hostinfo{IngressEnabled: false},
|
|
&Hostinfo{IngressEnabled: true},
|
|
false,
|
|
},
|
|
{
|
|
&Hostinfo{ExitNodeID: "stable-exit"},
|
|
&Hostinfo{ExitNodeID: "stable-exit"},
|
|
true,
|
|
},
|
|
{
|
|
&Hostinfo{ExitNodeID: ""},
|
|
&Hostinfo{},
|
|
true,
|
|
},
|
|
{
|
|
&Hostinfo{ExitNodeID: ""},
|
|
&Hostinfo{ExitNodeID: "stable-exit"},
|
|
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 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", "LegacyDERPString", "HomeDERP", "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{LegacyDERPString: "foo"},
|
|
&Node{LegacyDERPString: "bar"},
|
|
false,
|
|
},
|
|
{
|
|
&Node{HomeDERP: 1},
|
|
&Node{HomeDERP: 2},
|
|
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: new(netip.MustParseAddr("100.64.0.1"))},
|
|
false,
|
|
},
|
|
{
|
|
&Node{SelfNodeV4MasqAddrForThisPeer: new(netip.MustParseAddr("100.64.0.1"))},
|
|
&Node{SelfNodeV4MasqAddrForThisPeer: new(netip.MustParseAddr("100.64.0.1"))},
|
|
true,
|
|
},
|
|
{
|
|
&Node{},
|
|
&Node{SelfNodeV6MasqAddrForThisPeer: new(netip.MustParseAddr("2001::3456"))},
|
|
false,
|
|
},
|
|
{
|
|
&Node{SelfNodeV6MasqAddrForThisPeer: new(netip.MustParseAddr("2001::3456"))},
|
|
&Node{SelfNodeV6MasqAddrForThisPeer: new(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)
|
|
}
|
|
}
|
|
}
|
|
|
|
var nodeIsRouterCases = []struct {
|
|
name string
|
|
node Node
|
|
want bool
|
|
}{
|
|
{
|
|
name: "empty",
|
|
node: Node{},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "too-few-allowedips",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "duplicates",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "plain-ipv4",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "plain-ipv6",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "plain-ipv4-ipv6",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "exit-node-ipv4",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "exit-node-ipv6",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "exit-node-ipv4-ipv6",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subnet-router-ipv4",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("192.0.2.0/24"),
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subnet-router-ipv6",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
netip.MustParsePrefix("2001:db8::/32"),
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subnet-router-ipv4-ipv6",
|
|
node: Node{
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
},
|
|
AllowedIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
|
netip.MustParsePrefix("192.0.2.0/24"),
|
|
netip.MustParsePrefix("2001:db8::/32"),
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
func TestNodeIsRouter(t *testing.T) {
|
|
for _, tc := range nodeIsRouterCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := tc.node.IsRouter(); got != tc.want {
|
|
t.Errorf("node: got %t, want %t", got, tc.want)
|
|
}
|
|
|
|
nv := tc.node.View()
|
|
if got := nv.IsRouter(); got != tc.want {
|
|
t.Errorf("view: got %t, want %t", got, tc.want)
|
|
}
|
|
|
|
// Check that [ipnstate.PeerStatus.IsRouter] matches.
|
|
ps := peerStatusFromNode(nv)
|
|
if got := ps.IsRouter(); got != tc.want {
|
|
t.Errorf("peer status: got %t, want %t", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func FuzzNodeIsRouter(f *testing.F) {
|
|
encodePrefixes := func(f *testing.F, prefixes ...netip.Prefix) string {
|
|
f.Helper()
|
|
out := make([]string, len(prefixes))
|
|
for i, p := range prefixes {
|
|
out[i] = p.String()
|
|
}
|
|
return strings.Join(out, " ")
|
|
}
|
|
decodePrefixes := func(t *testing.T, prefixes string) []netip.Prefix {
|
|
t.Helper()
|
|
var out []netip.Prefix
|
|
for p := range strings.FieldsSeq(prefixes) {
|
|
pfx, err := netip.ParsePrefix(p)
|
|
if err != nil {
|
|
log.Printf("skipping %q: %v", prefixes, err)
|
|
t.Skipf("%q: %v", prefixes, err)
|
|
}
|
|
out = append(out, pfx)
|
|
}
|
|
return out
|
|
}
|
|
|
|
for _, tc := range nodeIsRouterCases {
|
|
addresses := encodePrefixes(f, tc.node.Addresses...)
|
|
allowedIPs := encodePrefixes(f, tc.node.AllowedIPs...)
|
|
f.Logf("addresses=%q allowedIPs=%q", addresses, allowedIPs)
|
|
f.Add(addresses, allowedIPs)
|
|
}
|
|
f.Fuzz(func(t *testing.T, addresses, allowedIPs string) {
|
|
n := Node{
|
|
Addresses: decodePrefixes(t, addresses),
|
|
AllowedIPs: decodePrefixes(t, allowedIPs),
|
|
}
|
|
ps := peerStatusFromNode(n.View())
|
|
t.Logf("%v %v", n.Addresses, n.AllowedIPs)
|
|
|
|
if len(n.Addresses) != len(ps.TailscaleIPs) ||
|
|
len(n.AllowedIPs) != ps.AllowedIPs.Len() {
|
|
t.Skip("n and ps are not equivalent")
|
|
}
|
|
|
|
gotN := n.IsRouter()
|
|
gotPS := ps.IsRouter()
|
|
if gotN != gotPS {
|
|
t.Errorf("mismatched node %t, peer status %t; addresses=%q allowedIPs=%q",
|
|
gotN, gotPS, addresses, allowedIPs)
|
|
}
|
|
})
|
|
}
|
|
|
|
func peerStatusFromNode(n NodeView) *ipnstate.PeerStatus {
|
|
ps := &ipnstate.PeerStatus{
|
|
ID: n.StableID(),
|
|
NodeID: n.ID(),
|
|
PublicKey: n.Key(),
|
|
DNSName: n.Name(),
|
|
}
|
|
for _, p := range n.Addresses().All() {
|
|
if p.IsSingleIP() {
|
|
ps.TailscaleIPs = append(ps.TailscaleIPs, p.Addr())
|
|
}
|
|
}
|
|
ps.AllowedIPs = new(n.AllowedIPs())
|
|
return ps
|
|
}
|
|
|
|
func TestNetInfoFields(t *testing.T) {
|
|
handled := []string{
|
|
"MappingVariesByDestIP",
|
|
"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{}},
|
|
}
|
|
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 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 TestMarshalToRawMessageAndBack(t *testing.T) {
|
|
type inner struct {
|
|
Groups []string `json:"groups,omitempty"`
|
|
}
|
|
testip := netip.MustParseAddrPort("1.2.3.4:80")
|
|
type testRule struct {
|
|
Ports []int `json:"ports,omitempty"`
|
|
ToggleOn bool `json:"toggleOn,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Groups inner `json:"groups"`
|
|
Addrs []netip.AddrPort `json:"addrs"`
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
capType peercap.Cap
|
|
val testRule
|
|
}{
|
|
{
|
|
name: "empty",
|
|
val: testRule{},
|
|
capType: peercap.Cap("foo"),
|
|
},
|
|
{
|
|
name: "some-values",
|
|
val: testRule{Ports: []int{80, 443}, Name: "foo"},
|
|
capType: peercap.Cap("foo"),
|
|
},
|
|
{
|
|
name: "all-values",
|
|
val: testRule{Ports: []int{80, 443}, Name: "foo", ToggleOn: true, Groups: inner{Groups: []string{"foo", "bar"}}, Addrs: []netip.AddrPort{testip}},
|
|
capType: peercap.Cap("foo"),
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
raw, err := MarshalCapJSON(tc.val)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error marshalling raw message: %v", err)
|
|
}
|
|
cap := PeerCapMap{tc.capType: []RawMessage{raw}}
|
|
after, err := UnmarshalCapJSON[testRule](cap, tc.capType)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error unmarshaling raw message: %v", err)
|
|
}
|
|
if !reflect.DeepEqual([]testRule{tc.val}, after) {
|
|
t.Errorf("got %#v; want %#v", after, []testRule{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
|
|
"testing": "do not use testing package in production code",
|
|
"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)
|
|
}
|
|
|
|
func TestCheckTag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tag string
|
|
want bool
|
|
}{
|
|
{"empty", "", false},
|
|
{"good", "tag:foo", true},
|
|
{"bad", "tag:", false},
|
|
{"no_leading_num", "tag:1foo", false},
|
|
{"no_punctuation", "tag:foa@bar", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := CheckTag(tt.tag)
|
|
if err == nil && !tt.want {
|
|
t.Errorf("got nil; want error")
|
|
} else if err != nil && tt.want {
|
|
t.Errorf("got %v; want nil", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDisplayMessageEqual(t *testing.T) {
|
|
type test struct {
|
|
name string
|
|
value1 DisplayMessage
|
|
value2 DisplayMessage
|
|
wantEqual bool
|
|
}
|
|
|
|
for _, test := range []test{
|
|
{
|
|
name: "same",
|
|
value1: DisplayMessage{
|
|
Title: "title",
|
|
Text: "text",
|
|
Severity: SeverityHigh,
|
|
ImpactsConnectivity: false,
|
|
PrimaryAction: &DisplayMessageAction{
|
|
URL: "https://example.com",
|
|
Label: "Open",
|
|
},
|
|
},
|
|
value2: DisplayMessage{
|
|
Title: "title",
|
|
Text: "text",
|
|
Severity: SeverityHigh,
|
|
ImpactsConnectivity: false,
|
|
PrimaryAction: &DisplayMessageAction{
|
|
URL: "https://example.com",
|
|
Label: "Open",
|
|
},
|
|
},
|
|
wantEqual: true,
|
|
},
|
|
{
|
|
name: "different-title",
|
|
value1: DisplayMessage{
|
|
Title: "title",
|
|
},
|
|
value2: DisplayMessage{
|
|
Title: "different title",
|
|
},
|
|
wantEqual: false,
|
|
},
|
|
{
|
|
name: "different-text",
|
|
value1: DisplayMessage{
|
|
Text: "some text",
|
|
},
|
|
value2: DisplayMessage{
|
|
Text: "different text",
|
|
},
|
|
wantEqual: false,
|
|
},
|
|
{
|
|
name: "different-severity",
|
|
value1: DisplayMessage{
|
|
Severity: SeverityHigh,
|
|
},
|
|
value2: DisplayMessage{
|
|
Severity: SeverityMedium,
|
|
},
|
|
wantEqual: false,
|
|
},
|
|
{
|
|
name: "different-impactsConnectivity",
|
|
value1: DisplayMessage{
|
|
ImpactsConnectivity: true,
|
|
},
|
|
value2: DisplayMessage{
|
|
ImpactsConnectivity: false,
|
|
},
|
|
wantEqual: false,
|
|
},
|
|
{
|
|
name: "different-primaryAction-nil-non-nil",
|
|
value1: DisplayMessage{},
|
|
value2: DisplayMessage{
|
|
PrimaryAction: &DisplayMessageAction{
|
|
URL: "https://example.com",
|
|
Label: "Open",
|
|
},
|
|
},
|
|
wantEqual: false,
|
|
},
|
|
{
|
|
name: "different-primaryAction-url",
|
|
value1: DisplayMessage{
|
|
PrimaryAction: &DisplayMessageAction{
|
|
URL: "https://example.com",
|
|
Label: "Open",
|
|
},
|
|
},
|
|
value2: DisplayMessage{
|
|
PrimaryAction: &DisplayMessageAction{
|
|
URL: "https://zombo.com",
|
|
Label: "Open",
|
|
},
|
|
},
|
|
wantEqual: false,
|
|
},
|
|
{
|
|
name: "different-primaryAction-label",
|
|
value1: DisplayMessage{
|
|
PrimaryAction: &DisplayMessageAction{
|
|
URL: "https://example.com",
|
|
Label: "Open",
|
|
},
|
|
},
|
|
value2: DisplayMessage{
|
|
PrimaryAction: &DisplayMessageAction{
|
|
URL: "https://example.com",
|
|
Label: "Learn more",
|
|
},
|
|
},
|
|
wantEqual: false,
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got := test.value1.Equal(test.value2)
|
|
|
|
if got != test.wantEqual {
|
|
value1 := must.Get(json.MarshalIndent(test.value1, "", " "))
|
|
value2 := must.Get(json.MarshalIndent(test.value2, "", " "))
|
|
t.Errorf("value1.Equal(value2): got %t, want %t\nvalue1:\n%s\nvalue2:\n%s", got, test.wantEqual, value1, value2)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceActionTypeValid(t *testing.T) {
|
|
tests := []struct {
|
|
typ ServiceActionType
|
|
want bool
|
|
}{
|
|
{ServiceActionTypeAWSS3, true},
|
|
{ServiceActionTypeCockroachDB, true},
|
|
{ServiceActionTypeElasticSearch, true},
|
|
{ServiceActionTypeHTTP, true},
|
|
{ServiceActionTypeKubernetes, true},
|
|
{ServiceActionTypeMongoDB, true},
|
|
{ServiceActionTypeMSSQL, true},
|
|
{ServiceActionTypeMySQL, true},
|
|
{ServiceActionTypePostgreSQL, true},
|
|
{ServiceActionTypeRDP, true},
|
|
{ServiceActionTypeVNC, true},
|
|
{ServiceActionTypeSSH, true},
|
|
{ServiceActionTypeTCP, true},
|
|
{"ftp", false},
|
|
{"", false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := tt.typ.Valid(); got != tt.want {
|
|
t.Errorf("ServiceActionType(%q).Valid() = %v, want %v", tt.typ, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSSHActionJSON verifies that SSHAction round-trips through
|
|
// encoding/json with SessionDuration encoded as int64 nanoseconds.
|
|
// It notably guards against jsonv2 `format` tag options in struct
|
|
// tags, which Go 1.27's encoding/json rejects at runtime.
|
|
// See https://github.com/tailscale/tailscale/issues/20528.
|
|
func TestSSHActionJSON(t *testing.T) {
|
|
a := SSHAction{
|
|
Accept: true,
|
|
SessionDuration: 5 * time.Second,
|
|
}
|
|
got, err := json.Marshal(a)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
const want = `{"accept":true,"sessionDuration":5000000000}`
|
|
if string(got) != want {
|
|
t.Errorf("Marshal = %s; want %s", got, want)
|
|
}
|
|
var back SSHAction
|
|
if err := json.Unmarshal(got, &back); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(back, a) {
|
|
t.Errorf("round trip = %+v; want %+v", back, a)
|
|
}
|
|
}
|