You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/ipn/prefs_test.go

911 lines
19 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipn
import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"os"
"reflect"
"strings"
"testing"
"time"
"go4.org/mem"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/key"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
)
func fieldsOf(t reflect.Type) (fields []string) {
for i := 0; i < t.NumField(); i++ {
fields = append(fields, t.Field(i).Name)
}
return
}
func TestPrefsEqual(t *testing.T) {
tstest.PanicOnLog()
prefsHandles := []string{
"ControlURL",
"RouteAll",
"AllowSingleHosts",
"ExitNodeID",
"ExitNodeIP",
"ExitNodeAllowLANAccess",
"CorpDNS",
"RunSSH",
"WantRunning",
"LoggedOut",
"ShieldsUp",
"AdvertiseTags",
"Hostname",
"NotepadURLs",
"ForceDaemon",
"Egg",
"AdvertiseRoutes",
"NoSNAT",
"NetfilterMode",
"OperatorUser",
"ProfileName",
"Persist",
}
if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) {
t.Errorf("Prefs.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
have, prefsHandles)
}
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 *Prefs
want bool
}{
{
&Prefs{},
nil,
false,
},
{
nil,
&Prefs{},
false,
},
{
&Prefs{},
&Prefs{},
true,
},
{
&Prefs{ControlURL: "https://controlplane.tailscale.com"},
&Prefs{ControlURL: "https://login.private.co"},
false,
},
{
&Prefs{ControlURL: "https://controlplane.tailscale.com"},
&Prefs{ControlURL: "https://controlplane.tailscale.com"},
true,
},
{
&Prefs{RouteAll: true},
&Prefs{RouteAll: false},
false,
},
{
&Prefs{RouteAll: true},
&Prefs{RouteAll: true},
true,
},
{
&Prefs{AllowSingleHosts: true},
&Prefs{AllowSingleHosts: false},
false,
},
{
&Prefs{AllowSingleHosts: true},
&Prefs{AllowSingleHosts: true},
true,
},
{
&Prefs{ExitNodeID: "n1234"},
&Prefs{},
false,
},
{
&Prefs{ExitNodeID: "n1234"},
&Prefs{ExitNodeID: "n1234"},
true,
},
{
&Prefs{ExitNodeIP: netip.MustParseAddr("1.2.3.4")},
&Prefs{},
false,
},
{
&Prefs{ExitNodeIP: netip.MustParseAddr("1.2.3.4")},
&Prefs{ExitNodeIP: netip.MustParseAddr("1.2.3.4")},
true,
},
{
&Prefs{},
&Prefs{ExitNodeAllowLANAccess: true},
false,
},
{
&Prefs{ExitNodeAllowLANAccess: true},
&Prefs{ExitNodeAllowLANAccess: true},
true,
},
{
&Prefs{CorpDNS: true},
&Prefs{CorpDNS: false},
false,
},
{
&Prefs{CorpDNS: true},
&Prefs{CorpDNS: true},
true,
},
{
&Prefs{WantRunning: true},
&Prefs{WantRunning: false},
false,
},
{
&Prefs{WantRunning: true},
&Prefs{WantRunning: true},
true,
},
{
&Prefs{NoSNAT: true},
&Prefs{NoSNAT: false},
false,
},
{
&Prefs{NoSNAT: true},
&Prefs{NoSNAT: true},
true,
},
{
&Prefs{Hostname: "android-host01"},
&Prefs{Hostname: "android-host02"},
false,
},
{
&Prefs{Hostname: ""},
&Prefs{Hostname: ""},
true,
},
{
&Prefs{NotepadURLs: true},
&Prefs{NotepadURLs: false},
false,
},
{
&Prefs{NotepadURLs: true},
&Prefs{NotepadURLs: true},
true,
},
{
&Prefs{ShieldsUp: true},
&Prefs{ShieldsUp: false},
false,
},
{
&Prefs{ShieldsUp: true},
&Prefs{ShieldsUp: true},
true,
},
{
&Prefs{AdvertiseRoutes: nil},
&Prefs{AdvertiseRoutes: []netip.Prefix{}},
true,
},
{
&Prefs{AdvertiseRoutes: []netip.Prefix{}},
&Prefs{AdvertiseRoutes: []netip.Prefix{}},
true,
},
{
&Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")},
&Prefs{AdvertiseRoutes: nets("192.168.1.0/24", "10.2.0.0/16")},
false,
},
{
&Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")},
&Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.2.0.0/16")},
false,
},
{
&Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")},
&Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")},
true,
},
{
&Prefs{NetfilterMode: preftype.NetfilterOff},
&Prefs{NetfilterMode: preftype.NetfilterOn},
false,
},
{
&Prefs{NetfilterMode: preftype.NetfilterOn},
&Prefs{NetfilterMode: preftype.NetfilterOn},
true,
},
{
&Prefs{Persist: &persist.Persist{}},
&Prefs{Persist: &persist.Persist{
UserProfile: tailcfg.UserProfile{LoginName: "dave"},
}},
false,
},
{
&Prefs{Persist: &persist.Persist{
UserProfile: tailcfg.UserProfile{LoginName: "dave"},
}},
&Prefs{Persist: &persist.Persist{
UserProfile: tailcfg.UserProfile{LoginName: "dave"},
}},
true,
},
{
&Prefs{ProfileName: "work"},
&Prefs{ProfileName: "work"},
true,
},
{
&Prefs{ProfileName: "work"},
&Prefs{ProfileName: "home"},
false,
},
}
for i, tt := range tests {
got := tt.a.Equals(tt.b)
if got != tt.want {
t.Errorf("%d. Equal = %v; want %v", i, got, tt.want)
}
}
}
func checkPrefs(t *testing.T, p Prefs) {
var err error
var p2, p2c *Prefs
var p2b *Prefs
pp := p.Pretty()
if pp == "" {
t.Fatalf("default p.Pretty() failed\n")
}
t.Logf("\npp: %#v\n", pp)
b := p.ToBytes()
if len(b) == 0 {
t.Fatalf("default p.ToBytes() failed\n")
}
if !p.Equals(&p) {
t.Fatalf("p != p\n")
}
p2 = p.Clone()
p2.RouteAll = true
if p.Equals(p2) {
t.Fatalf("p == p2\n")
}
p2b, err = PrefsFromBytes(p2.ToBytes())
if err != nil {
t.Fatalf("PrefsFromBytes(p2) failed\n")
}
p2p := p2.Pretty()
p2bp := p2b.Pretty()
t.Logf("\np2p: %#v\np2bp: %#v\n", p2p, p2bp)
if p2p != p2bp {
t.Fatalf("p2p != p2bp\n%#v\n%#v\n", p2p, p2bp)
}
if !p2.Equals(p2b) {
t.Fatalf("p2 != p2b\n%#v\n%#v\n", p2, p2b)
}
p2c = p2.Clone()
if !p2b.Equals(p2c) {
t.Fatalf("p2b != p2c\n")
}
}
func TestBasicPrefs(t *testing.T) {
tstest.PanicOnLog()
p := Prefs{
ControlURL: "https://controlplane.tailscale.com",
}
checkPrefs(t, p)
}
func TestPrefsPersist(t *testing.T) {
tstest.PanicOnLog()
c := persist.Persist{
UserProfile: tailcfg.UserProfile{
LoginName: "test@example.com",
},
}
p := Prefs{
ControlURL: "https://controlplane.tailscale.com",
CorpDNS: true,
Persist: &c,
}
checkPrefs(t, p)
}
func TestPrefsPretty(t *testing.T) {
tests := []struct {
p Prefs
os string
want string
}{
{
Prefs{},
"linux",
"Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}",
},
{
Prefs{},
"windows",
"Prefs{ra=false mesh=false dns=false want=false Persist=nil}",
},
{
Prefs{ShieldsUp: true},
"windows",
"Prefs{ra=false mesh=false dns=false want=false shields=true Persist=nil}",
},
{
Prefs{AllowSingleHosts: true},
"windows",
"Prefs{ra=false dns=false want=false Persist=nil}",
},
{
Prefs{
NotepadURLs: true,
AllowSingleHosts: true,
},
"windows",
"Prefs{ra=false dns=false want=false notepad=true Persist=nil}",
},
{
Prefs{
AllowSingleHosts: true,
WantRunning: true,
ForceDaemon: true, // server mode
},
"windows",
"Prefs{ra=false dns=false want=true server=true Persist=nil}",
},
{
Prefs{
AllowSingleHosts: true,
WantRunning: true,
ControlURL: "http://localhost:1234",
AdvertiseTags: []string{"tag:foo", "tag:bar"},
},
"darwin",
`Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" Persist=nil}`,
},
{
Prefs{
Persist: &persist.Persist{},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist{lm=, o=, n= u=""}}`,
},
{
Prefs{
Persist: &persist.Persist{
PrivateNodeKey: key.NodePrivateFromRaw32(mem.B([]byte{1: 1, 31: 0})),
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist{lm=, o=, n=[B1VKl] u=""}}`,
},
{
Prefs{
ExitNodeIP: netip.MustParseAddr("1.2.3.4"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off Persist=nil}`,
},
{
Prefs{
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}`,
},
{
Prefs{
Hostname: "foo",
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" Persist=nil}`,
},
}
for i, tt := range tests {
got := tt.p.pretty(tt.os)
if got != tt.want {
t.Errorf("%d. wrong String:\n got: %s\nwant: %s\n", i, got, tt.want)
}
}
}
func TestLoadPrefsNotExist(t *testing.T) {
bogusFile := fmt.Sprintf("/tmp/not-exist-%d", time.Now().UnixNano())
p, err := LoadPrefs(bogusFile)
if errors.Is(err, os.ErrNotExist) {
// expected.
return
}
t.Fatalf("unexpected prefs=%#v, err=%v", p, err)
}
// TestLoadPrefsFileWithZeroInIt verifies that LoadPrefs handles corrupted input files.
// See issue #954 for details.
func TestLoadPrefsFileWithZeroInIt(t *testing.T) {
f, err := os.CreateTemp("", "TestLoadPrefsFileWithZeroInIt")
if err != nil {
t.Fatal(err)
}
path := f.Name()
if _, err := f.Write(jsonEscapedZero); err != nil {
t.Fatal(err)
}
f.Close()
defer os.Remove(path)
p, err := LoadPrefs(path)
if errors.Is(err, os.ErrNotExist) {
// expected.
return
}
t.Fatalf("unexpected prefs=%#v, err=%v", p, err)
}
func TestMaskedPrefsFields(t *testing.T) {
have := map[string]bool{}
for _, f := range fieldsOf(reflect.TypeOf(Prefs{})) {
if f == "Persist" {
// This one can't be edited.
continue
}
have[f] = true
}
for _, f := range fieldsOf(reflect.TypeOf(MaskedPrefs{})) {
if f == "Prefs" {
continue
}
if !strings.HasSuffix(f, "Set") {
t.Errorf("unexpected non-/Set$/ field %q", f)
continue
}
bare := strings.TrimSuffix(f, "Set")
_, ok := have[bare]
if !ok {
t.Errorf("no corresponding Prefs.%s field for MaskedPrefs.%s", bare, f)
continue
}
delete(have, bare)
}
for f := range have {
t.Errorf("missing MaskedPrefs.%sSet for Prefs.%s", f, f)
}
// And also make sure they line up in the right order, which
// ApplyEdits assumes.
pt := reflect.TypeOf(Prefs{})
mt := reflect.TypeOf(MaskedPrefs{})
for i := 0; i < mt.NumField(); i++ {
name := mt.Field(i).Name
if i == 0 {
if name != "Prefs" {
t.Errorf("first field of MaskedPrefs should be Prefs")
}
continue
}
prefName := pt.Field(i - 1).Name
if prefName+"Set" != name {
t.Errorf("MaskedField[%d] = %s; want %sSet", i-1, name, prefName)
}
}
}
func TestPrefsApplyEdits(t *testing.T) {
tests := []struct {
name string
prefs *Prefs
edit *MaskedPrefs
want *Prefs
}{
{
name: "no_change",
prefs: &Prefs{
Hostname: "foo",
},
edit: &MaskedPrefs{},
want: &Prefs{
Hostname: "foo",
},
},
{
name: "set1_decoy1",
prefs: &Prefs{
Hostname: "foo",
},
edit: &MaskedPrefs{
Prefs: Prefs{
Hostname: "bar",
OperatorUser: "ignore-this", // not set
},
HostnameSet: true,
},
want: &Prefs{
Hostname: "bar",
},
},
{
name: "set_several",
prefs: &Prefs{},
edit: &MaskedPrefs{
Prefs: Prefs{
Hostname: "bar",
OperatorUser: "galaxybrain",
},
HostnameSet: true,
OperatorUserSet: true,
},
want: &Prefs{
Hostname: "bar",
OperatorUser: "galaxybrain",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.prefs.Clone()
got.ApplyEdits(tt.edit)
if !got.Equals(tt.want) {
gotj, _ := json.Marshal(got)
wantj, _ := json.Marshal(tt.want)
t.Errorf("fail.\n got: %s\nwant: %s\n", gotj, wantj)
}
})
}
}
func TestMaskedPrefsPretty(t *testing.T) {
tests := []struct {
m *MaskedPrefs
want string
}{
{
m: &MaskedPrefs{},
want: "MaskedPrefs{}",
},
{
m: &MaskedPrefs{
Prefs: Prefs{
Hostname: "bar",
OperatorUser: "galaxybrain",
AllowSingleHosts: true,
RouteAll: false,
ExitNodeID: "foo",
AdvertiseTags: []string{"tag:foo", "tag:bar"},
NetfilterMode: preftype.NetfilterNoDivert,
},
RouteAllSet: true,
HostnameSet: true,
OperatorUserSet: true,
ExitNodeIDSet: true,
AdvertiseTagsSet: true,
NetfilterModeSet: true,
},
want: `MaskedPrefs{RouteAll=false ExitNodeID="foo" AdvertiseTags=["tag:foo" "tag:bar"] Hostname="bar" NetfilterMode=nodivert OperatorUser="galaxybrain"}`,
},
{
m: &MaskedPrefs{
Prefs: Prefs{
ExitNodeIP: netaddr.IPv4(100, 102, 104, 105),
},
ExitNodeIPSet: true,
},
want: `MaskedPrefs{ExitNodeIP=100.102.104.105}`,
},
}
for i, tt := range tests {
got := tt.m.Pretty()
if got != tt.want {
t.Errorf("%d.\n got: %#q\nwant: %#q\n", i, got, tt.want)
}
}
}
func TestPrefsExitNode(t *testing.T) {
var p *Prefs
if p.AdvertisesExitNode() {
t.Errorf("nil shouldn't advertise exit node")
}
p = NewPrefs()
if p.AdvertisesExitNode() {
t.Errorf("default shouldn't advertise exit node")
}
p.AdvertiseRoutes = []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/16"),
}
p.SetAdvertiseExitNode(true)
if got, want := len(p.AdvertiseRoutes), 3; got != want {
t.Errorf("routes = %d; want %d", got, want)
}
p.SetAdvertiseExitNode(true)
if got, want := len(p.AdvertiseRoutes), 3; got != want {
t.Errorf("routes = %d; want %d", got, want)
}
if !p.AdvertisesExitNode() {
t.Errorf("not advertising after enable")
}
p.SetAdvertiseExitNode(false)
if p.AdvertisesExitNode() {
t.Errorf("advertising after disable")
}
if got, want := len(p.AdvertiseRoutes), 1; got != want {
t.Errorf("routes = %d; want %d", got, want)
}
}
func TestExitNodeIPOfArg(t *testing.T) {
mustIP := netip.MustParseAddr
tests := []struct {
name string
arg string
st *ipnstate.Status
want netip.Addr
wantErr string
}{
{
name: "ip_while_stopped_okay",
arg: "1.2.3.4",
st: &ipnstate.Status{
BackendState: "Stopped",
},
want: mustIP("1.2.3.4"),
},
{
name: "ip_not_found",
arg: "1.2.3.4",
st: &ipnstate.Status{
BackendState: "Running",
},
wantErr: `no node found in netmap with IP 1.2.3.4`,
},
{
name: "ip_not_exit",
arg: "1.2.3.4",
st: &ipnstate.Status{
BackendState: "Running",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NewNode().Public(): {
TailscaleIPs: []netip.Addr{mustIP("1.2.3.4")},
},
},
},
wantErr: `node 1.2.3.4 is not advertising an exit node`,
},
{
name: "ip",
arg: "1.2.3.4",
st: &ipnstate.Status{
BackendState: "Running",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NewNode().Public(): {
TailscaleIPs: []netip.Addr{mustIP("1.2.3.4")},
ExitNodeOption: true,
},
},
},
want: mustIP("1.2.3.4"),
},
{
name: "no_match",
arg: "unknown",
st: &ipnstate.Status{MagicDNSSuffix: ".foo"},
wantErr: `invalid value "unknown" for --exit-node; must be IP or unique node name`,
},
{
name: "name",
arg: "skippy",
st: &ipnstate.Status{
MagicDNSSuffix: ".foo",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NewNode().Public(): {
DNSName: "skippy.foo.",
TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")},
ExitNodeOption: true,
},
},
},
want: mustIP("1.0.0.2"),
},
{
name: "name_not_exit",
arg: "skippy",
st: &ipnstate.Status{
MagicDNSSuffix: ".foo",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NewNode().Public(): {
DNSName: "skippy.foo.",
TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")},
},
},
},
wantErr: `node "skippy" is not advertising an exit node`,
},
{
name: "ambiguous",
arg: "skippy",
st: &ipnstate.Status{
MagicDNSSuffix: ".foo",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NewNode().Public(): {
DNSName: "skippy.foo.",
TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")},
ExitNodeOption: true,
},
key.NewNode().Public(): {
DNSName: "SKIPPY.foo.",
TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")},
ExitNodeOption: true,
},
},
},
wantErr: `ambiguous exit node name "skippy"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := exitNodeIPOfArg(tt.arg, tt.st)
if err != nil {
if err.Error() == tt.wantErr {
return
}
if tt.wantErr == "" {
t.Fatal(err)
}
t.Fatalf("error = %#q; want %#q", err, tt.wantErr)
}
if tt.wantErr != "" {
t.Fatalf("got %v; want error %#q", got, tt.wantErr)
}
if got != tt.want {
t.Fatalf("got %v; want %v", got, tt.want)
}
})
}
}
func TestControlURLOrDefault(t *testing.T) {
var p Prefs
if got, want := p.ControlURLOrDefault(), DefaultControlURL; got != want {
t.Errorf("got %q; want %q", got, want)
}
p.ControlURL = "http://foo.bar"
if got, want := p.ControlURLOrDefault(), "http://foo.bar"; got != want {
t.Errorf("got %q; want %q", got, want)
}
p.ControlURL = "https://login.tailscale.com"
if got, want := p.ControlURLOrDefault(), DefaultControlURL; got != want {
t.Errorf("got %q; want %q", got, want)
}
}
func TestMaskedPrefsIsEmpty(t *testing.T) {
tests := []struct {
name string
mp *MaskedPrefs
wantEmpty bool
}{
{
name: "nil",
wantEmpty: true,
},
{
name: "empty",
wantEmpty: true,
mp: &MaskedPrefs{},
},
{
name: "no-masks",
wantEmpty: true,
mp: &MaskedPrefs{
Prefs: Prefs{
WantRunning: true,
},
},
},
{
name: "with-mask",
wantEmpty: false,
mp: &MaskedPrefs{
Prefs: Prefs{
WantRunning: true,
},
WantRunningSet: true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.mp.IsEmpty()
if got != tc.wantEmpty {
t.Fatalf("mp.IsEmpty = %t; want %t", got, tc.wantEmpty)
}
})
}
}
func TestNotifyPrefsJSONRoundtrip(t *testing.T) {
var n Notify
if n.Prefs != nil && n.Prefs.Valid() {
t.Fatal("Prefs should not be valid at start")
}
b, err := json.Marshal(n)
if err != nil {
t.Fatal(err)
}
var n2 Notify
if err := json.Unmarshal(b, &n2); err != nil {
t.Fatal(err)
}
if n2.Prefs != nil && n2.Prefs.Valid() {
t.Fatal("Prefs should not be valid after deserialization")
}
}