tailscale/ipn/prefs_test.go
Alex Chan c9f214e503
ipn: warn about self as the exit node if backend is running (#17018)
Before:

    $ tailscale ip -4
    1.2.3.4

    $ tailscale set --exit-node=1.2.3.4
    no node found in netmap with IP 1.2.3.4

After:

    $ tailscale set --exit-node=1.2.3.4
    cannot use 1.2.3.4 as an exit node as it is a local IP address to this machine; did you mean --advertise-exit-node?

The new error message already existed in the code, but would only be
triggered if the backend wasn't running -- which means, in practice,
it would almost never be triggered.

The old error message is technically true, but could be confusing if you
don't know the distinction between "netmap" and "tailnet" -- it could
sound like the exit node isn't part of your tailnet. A node is never in
its own netmap, but it is part of your tailnet.

This error confused me when I was doing some local dev work, and it's
confused customers before (e.g. #7513). Using the more specific error
message should reduce confusion.

Updates #7513
Updates https://github.com/tailscale/corp/issues/23596

Signed-off-by: Alex Chan <alexc@tailscale.com>
2025-09-03 13:47:32 +01:00

1210 lines
26 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/opt"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
"tailscale.com/util/syspolicy/policyclient"
)
func fieldsOf(t reflect.Type) (fields []string) {
for i := range t.NumField() {
fields = append(fields, t.Field(i).Name)
}
return
}
func TestPrefsEqual(t *testing.T) {
tstest.PanicOnLog()
prefsHandles := []string{
"ControlURL",
"RouteAll",
"ExitNodeID",
"ExitNodeIP",
"AutoExitNode",
"InternalExitNodePrior",
"ExitNodeAllowLANAccess",
"CorpDNS",
"RunSSH",
"RunWebClient",
"WantRunning",
"LoggedOut",
"ShieldsUp",
"AdvertiseTags",
"Hostname",
"NotepadURLs",
"ForceDaemon",
"Egg",
"AdvertiseRoutes",
"AdvertiseServices",
"NoSNAT",
"NoStatefulFiltering",
"NetfilterMode",
"OperatorUser",
"ProfileName",
"AutoUpdate",
"AppConnector",
"PostureChecking",
"NetfilterKind",
"DriveShares",
"RelayServerPort",
"AllowSingleHosts",
"Persist",
}
if have := fieldsOf(reflect.TypeFor[Prefs]()); !reflect.DeepEqual(have, prefsHandles) {
t.Errorf("Prefs.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
have, prefsHandles)
}
relayServerPort := func(port int) *int {
return &port
}
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{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{AutoExitNode: ""},
&Prefs{AutoExitNode: "auto:any"},
false,
},
{
&Prefs{AutoExitNode: "auto:any"},
&Prefs{AutoExitNode: "auto:any"},
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,
},
{
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}},
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: false, Apply: opt.NewBool(false)}},
false,
},
{
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(true)}},
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}},
false,
},
{
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}},
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}},
true,
},
{
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
true,
},
{
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
&Prefs{AppConnector: AppConnectorPrefs{Advertise: false}},
false,
},
{
&Prefs{PostureChecking: true},
&Prefs{PostureChecking: true},
true,
},
{
&Prefs{PostureChecking: true},
&Prefs{PostureChecking: false},
false,
},
{
&Prefs{NetfilterKind: "iptables"},
&Prefs{NetfilterKind: "iptables"},
true,
},
{
&Prefs{NetfilterKind: "nftables"},
&Prefs{NetfilterKind: ""},
false,
},
{
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
true,
},
{
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:amelie"}},
false,
},
{
&Prefs{RelayServerPort: relayServerPort(0)},
&Prefs{RelayServerPort: nil},
false,
},
{
&Prefs{RelayServerPort: relayServerPort(0)},
&Prefs{RelayServerPort: relayServerPort(1)},
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 = new(Prefs)
err = PrefsFromBytes(p2.ToBytes(), p2b)
if err != nil {
t.Fatalf("PrefsFromBytes(p2) failed: bytes=%q; err=%v\n", p2.ToBytes(), err)
}
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 dns=false want=false routes=[] nf=off update=off Persist=nil}",
},
{
Prefs{},
"windows",
"Prefs{ra=false dns=false want=false update=off Persist=nil}",
},
{
Prefs{ShieldsUp: true},
"windows",
"Prefs{ra=false dns=false want=false shields=true update=off Persist=nil}",
},
{
Prefs{},
"windows",
"Prefs{ra=false dns=false want=false update=off Persist=nil}",
},
{
Prefs{
NotepadURLs: true,
},
"windows",
"Prefs{ra=false dns=false want=false notepad=true update=off Persist=nil}",
},
{
Prefs{
WantRunning: true,
ForceDaemon: true, // server mode
},
"windows",
"Prefs{ra=false dns=false want=true server=true update=off Persist=nil}",
},
{
Prefs{
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" update=off Persist=nil}`,
},
{
Prefs{
Persist: &persist.Persist{
PrivateNodeKey: key.NodePrivateFromRaw32(mem.B([]byte{1: 1, 31: 0})),
},
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist{o=, n=[B1VKl] u=""}}`,
},
{
Prefs{
ExitNodeIP: netip.MustParseAddr("1.2.3.4"),
},
"linux",
`Prefs{ra=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
},
"linux",
`Prefs{ra=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
Hostname: "foo",
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off host="foo" update=off Persist=nil}`,
},
{
Prefs{
AutoUpdate: AutoUpdatePrefs{
Check: true,
Apply: opt.NewBool(false),
},
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=check Persist=nil}`,
},
{
Prefs{
AutoUpdate: AutoUpdatePrefs{
Check: true,
Apply: opt.NewBool(true),
},
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=on Persist=nil}`,
},
{
Prefs{
AppConnector: AppConnectorPrefs{
Advertise: true,
},
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`,
},
{
Prefs{
AppConnector: AppConnectorPrefs{
Advertise: false,
},
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
NetfilterKind: "iptables",
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off netfilterKind=iptables update=off Persist=nil}`,
},
{
Prefs{
NetfilterKind: "",
},
"linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off 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 := LoadPrefsWindows(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 := LoadPrefsWindows(path)
if errors.Is(err, os.ErrNotExist) {
// expected.
return
}
t.Fatalf("unexpected prefs=%#v, err=%v", p, err)
}
func TestMaskedPrefsSetsInternal(t *testing.T) {
for _, f := range fieldsOf(reflect.TypeFor[MaskedPrefs]()) {
if !strings.HasSuffix(f, "Set") || !strings.HasPrefix(f, "Internal") {
continue
}
mp := new(MaskedPrefs)
reflect.ValueOf(mp).Elem().FieldByName(f).SetBool(true)
if !mp.SetsInternal() {
t.Errorf("MaskedPrefs.%sSet=true but SetsInternal=false", f)
}
}
}
func TestMaskedPrefsFields(t *testing.T) {
have := map[string]bool{}
for _, f := range fieldsOf(reflect.TypeFor[Prefs]()) {
switch f {
case "Persist", "AllowSingleHosts":
// These can't be edited.
continue
}
have[f] = true
}
for _, f := range fieldsOf(reflect.TypeFor[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.TypeFor[Prefs]()
mt := reflect.TypeFor[MaskedPrefs]()
for i := range mt.NumField() {
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",
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}`,
},
{
m: &MaskedPrefs{
Prefs: Prefs{
AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)},
},
AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: true, ApplySet: false},
},
want: `MaskedPrefs{AutoUpdate={Check=true}}`,
},
{
m: &MaskedPrefs{
Prefs: Prefs{
AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(true)},
},
AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: true, ApplySet: true},
},
want: `MaskedPrefs{AutoUpdate={Check=true Apply=true}}`,
},
{
m: &MaskedPrefs{
Prefs: Prefs{
AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)},
},
AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: false, ApplySet: true},
},
want: `MaskedPrefs{AutoUpdate={Apply=false}}`,
},
{
m: &MaskedPrefs{
Prefs: Prefs{
AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(true)},
},
AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: false, ApplySet: false},
},
want: `MaskedPrefs{}`,
},
}
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_is_self",
arg: "1.2.3.4",
st: &ipnstate.Status{
TailscaleIPs: []netip.Addr{mustIP("1.2.3.4")},
},
wantErr: "cannot use 1.2.3.4 as an exit node as it is a local IP address to this machine",
},
{
name: "ip_is_self_when_backend_running",
arg: "1.2.3.4",
st: &ipnstate.Status{
BackendState: "Running",
TailscaleIPs: []netip.Addr{mustIP("1.2.3.4")},
},
wantErr: "cannot use 1.2.3.4 as an exit node as it is a local IP address to this machine",
},
{
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_fqdn",
arg: "skippy.foo.",
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: "name_wrong_fqdn",
arg: "skippy.bar.",
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: `invalid value "skippy.bar." for --exit-node; must be IP or unique node name`,
},
{
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
polc := policyclient.NoPolicyClient{}
if got, want := p.ControlURLOrDefault(polc), DefaultControlURL; got != want {
t.Errorf("got %q; want %q", got, want)
}
p.ControlURL = "http://foo.bar"
if got, want := p.ControlURLOrDefault(polc), "http://foo.bar"; got != want {
t.Errorf("got %q; want %q", got, want)
}
p.ControlURL = "https://login.tailscale.com"
if got, want := p.ControlURLOrDefault(polc), 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")
}
}
// Verify that our Prefs type writes out an AllowSingleHosts field so we can
// downgrade to older versions that require it.
func TestPrefsDowngrade(t *testing.T) {
var p Prefs
j, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
type oldPrefs struct {
AllowSingleHosts bool
}
var op oldPrefs
if err := json.Unmarshal(j, &op); err != nil {
t.Fatal(err)
}
if !op.AllowSingleHosts {
t.Fatal("AllowSingleHosts should be true")
}
}
func TestParseAutoExitNodeString(t *testing.T) {
tests := []struct {
name string
exitNodeID string
wantOk bool
wantExpr ExitNodeExpression
}{
{
name: "empty expr",
exitNodeID: "",
wantOk: false,
wantExpr: "",
},
{
name: "no auto prefix",
exitNodeID: "foo",
wantOk: false,
wantExpr: "",
},
{
name: "auto:any",
exitNodeID: "auto:any",
wantOk: true,
wantExpr: AnyExitNode,
},
{
name: "auto:foo",
exitNodeID: "auto:foo",
wantOk: true,
wantExpr: "foo",
},
{
name: "auto prefix but empty suffix",
exitNodeID: "auto:",
wantOk: false,
wantExpr: "",
},
{
name: "auto prefix no colon",
exitNodeID: "auto",
wantOk: false,
wantExpr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExpr, gotOk := ParseAutoExitNodeString(tt.exitNodeID)
if gotOk != tt.wantOk || gotExpr != tt.wantExpr {
if tt.wantOk {
t.Fatalf("got %v (%q); want %v (%q)", gotOk, gotExpr, tt.wantOk, tt.wantExpr)
} else {
t.Fatalf("got %v (%q); want false", gotOk, gotExpr)
}
}
})
}
}