When parsing the `tailscale up --exit-node=ARG` argument, we try to resolve hostnames by searching the list of peers. However, at startup, the peer list is empty, causing hostname lookups to trivially fail with an unhelpful "invalid value" erorr. Improve the error message when the peer list is empty to inform the user that hostnames cannot be resolved during startup, and advise them to use the exit node's Tailscale IP address instead. Also, clarify that hostnames must be peer hostnames, not arbitrary hostnames. Fixes #19882 Change-Id: I9390a427c2863d657cf46c5e33b43cb3c5363764 Signed-off-by: Alex Chan <alexc@tailscale.com>
1868 lines
56 KiB
Go
1868 lines
56 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
stdcmp "cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
qt "github.com/frankban/quicktest"
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/peterbourgon/ff/v3/ffcli"
|
|
"tailscale.com/envknob"
|
|
"tailscale.com/health/healthmsg"
|
|
"tailscale.com/internal/client/tailscale"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/ipnstate"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tka"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/tstest/deptest"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/types/opt"
|
|
"tailscale.com/types/persist"
|
|
"tailscale.com/types/preftype"
|
|
"tailscale.com/util/set"
|
|
"tailscale.com/version/distro"
|
|
)
|
|
|
|
func TestPanicIfAnyEnvCheckedInInit(t *testing.T) {
|
|
envknob.PanicIfAnyEnvCheckedInInit()
|
|
}
|
|
|
|
func TestShortUsage(t *testing.T) {
|
|
t.Setenv("TAILSCALE_USE_WIP_CODE", "1")
|
|
if !envknob.UseWIPCode() {
|
|
t.Fatal("expected envknob.UseWIPCode() to be true")
|
|
}
|
|
|
|
walkCommands(newRootCmd(), func(w cmdWalk) bool {
|
|
c, parents := w.Command, w.parents
|
|
|
|
// Words that we expect to be in the usage.
|
|
words := make([]string, len(parents)+1)
|
|
for i, parent := range parents {
|
|
words[i] = parent.Name
|
|
}
|
|
words[len(parents)] = c.Name
|
|
|
|
// Check the ShortHelp starts with a capital letter.
|
|
if prefix, help := trimPrefixes(c.ShortHelp, "HIDDEN: ", "[ALPHA] ", "[BETA] "); help != "" {
|
|
if 'a' <= help[0] && help[0] <= 'z' {
|
|
if len(help) > 20 {
|
|
help = help[:20] + "…"
|
|
}
|
|
caphelp := string(help[0]-'a'+'A') + help[1:]
|
|
t.Errorf("command: %s: ShortHelp %q should start with a capital letter %q", strings.Join(words, " "), prefix+help, prefix+caphelp)
|
|
}
|
|
}
|
|
|
|
// Check all words appear in the usage.
|
|
usage := c.ShortUsage
|
|
for _, word := range words {
|
|
var ok bool
|
|
usage, ok = cutWord(usage, word)
|
|
if !ok {
|
|
full := strings.Join(words, " ")
|
|
t.Errorf("command: %s: usage %q should contain the full path %q", full, c.ShortUsage, full)
|
|
return true
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func trimPrefixes(full string, prefixes ...string) (trimmed, remaining string) {
|
|
s := full
|
|
start:
|
|
for _, p := range prefixes {
|
|
var ok bool
|
|
s, ok = strings.CutPrefix(s, p)
|
|
if ok {
|
|
goto start
|
|
}
|
|
}
|
|
return full[:len(full)-len(s)], s
|
|
}
|
|
|
|
// cutWord("tailscale debug scale 123", "scale") returns (" 123", true).
|
|
func cutWord(s, w string) (after string, ok bool) {
|
|
var p string
|
|
for {
|
|
p, s, ok = strings.Cut(s, w)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
if p != "" && isWordChar(p[len(p)-1]) {
|
|
continue
|
|
}
|
|
if s != "" && isWordChar(s[0]) {
|
|
continue
|
|
}
|
|
return s, true
|
|
}
|
|
}
|
|
|
|
func isWordChar(r byte) bool {
|
|
return r == '_' ||
|
|
('0' <= r && r <= '9') ||
|
|
('A' <= r && r <= 'Z') ||
|
|
('a' <= r && r <= 'z')
|
|
}
|
|
|
|
func TestCutWord(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
word string
|
|
out string
|
|
ok bool
|
|
}{
|
|
{"tailscale debug", "debug", "", true},
|
|
{"tailscale debug", "bug", "", false},
|
|
{"tailscale debug", "tail", "", false},
|
|
{"tailscale debug scaley scale 123", "scale", " 123", true},
|
|
}
|
|
for _, test := range tests {
|
|
out, ok := cutWord(test.in, test.word)
|
|
if out != test.out || ok != test.ok {
|
|
t.Errorf("cutWord(%q, %q) = (%q, %t), wanted (%q, %t)", test.in, test.word, out, ok, test.out, test.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// geese is a collection of gooses. It need not be complete.
|
|
// But it should include anything handled specially (e.g. linux, windows)
|
|
// and at least one thing that's not (darwin, freebsd).
|
|
var geese = []string{"linux", "darwin", "windows", "freebsd"}
|
|
|
|
// Test that checkForAccidentalSettingReverts's updateMaskedPrefsFromUpFlag can handle
|
|
// all flags. This will panic if a new flag creeps in that's unhandled.
|
|
//
|
|
// Also, issue 1880: advertise-exit-node was being ignored. Verify that all flags cause an edit.
|
|
func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) {
|
|
for _, goos := range geese {
|
|
var upArgs upArgsT
|
|
fs := newUpFlagSet(goos, &upArgs, "up")
|
|
fs.VisitAll(func(f *flag.Flag) {
|
|
mp := new(ipn.MaskedPrefs)
|
|
updateMaskedPrefsFromUpOrSetFlag(mp, f.Name)
|
|
got := mp.Pretty()
|
|
wantEmpty := preflessFlag(f.Name)
|
|
isEmpty := got == "MaskedPrefs{}"
|
|
if isEmpty != wantEmpty {
|
|
t.Errorf("flag %q created MaskedPrefs %s; want empty=%v", f.Name, got, wantEmpty)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
flags []string // argv to be parsed by FlagSet
|
|
curPrefs *ipn.Prefs
|
|
|
|
curExitNodeIP netip.Addr
|
|
curUser string // os.Getenv("USER") on the client side
|
|
goos string // empty means "linux"
|
|
distro distro.Distro
|
|
backendState string // empty means "Running"
|
|
|
|
want string
|
|
}{
|
|
{
|
|
name: "bare_up_means_up",
|
|
flags: []string{},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: false,
|
|
Hostname: "foo",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "bare_up_needs_login_default_prefs",
|
|
flags: []string{},
|
|
curPrefs: ipn.NewPrefs(),
|
|
backendState: ipn.NeedsLogin.String(),
|
|
want: "",
|
|
},
|
|
{
|
|
name: "bare_up_needs_login_losing_prefs",
|
|
flags: []string{},
|
|
curPrefs: &ipn.Prefs{
|
|
// defaults:
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: false,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
// non-default:
|
|
CorpDNS: false,
|
|
},
|
|
backendState: ipn.NeedsLogin.String(),
|
|
want: accidentalUpPrefix + " --accept-dns=false",
|
|
},
|
|
{
|
|
name: "losing_hostname",
|
|
flags: []string{"--accept-dns"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: false,
|
|
Hostname: "foo",
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --accept-dns --hostname=foo",
|
|
},
|
|
{
|
|
name: "hostname_changing_explicitly",
|
|
flags: []string{"--hostname=bar"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
Hostname: "foo",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "hostname_changing_empty_explicitly",
|
|
flags: []string{"--hostname="},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
Hostname: "foo",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
// Issue 1725: "tailscale up --authkey=..." (or other non-empty flags) works from
|
|
// a fresh server's initial prefs.
|
|
name: "up_with_default_prefs",
|
|
flags: []string{"--authkey=foosdlkfjskdljf"},
|
|
curPrefs: ipn.NewPrefs(),
|
|
want: "",
|
|
},
|
|
{
|
|
name: "implicit_operator_change",
|
|
flags: []string{"--hostname=foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
OperatorUser: "alice",
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
curUser: "eve",
|
|
want: accidentalUpPrefix + " --hostname=foo --operator=alice",
|
|
},
|
|
{
|
|
name: "implicit_operator_matches_shell_user",
|
|
flags: []string{"--hostname=foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
OperatorUser: "alice",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
curUser: "alice",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "error_advertised_routes_exit_node_removed",
|
|
flags: []string{"--advertise-routes=10.0.42.0/24"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.42.0/24"),
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --advertise-routes=10.0.42.0/24 --advertise-exit-node",
|
|
},
|
|
{
|
|
name: "advertised_routes_exit_node_removed_explicit",
|
|
flags: []string{"--advertise-routes=10.0.42.0/24", "--advertise-exit-node=false"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.42.0/24"),
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "advertised_routes_includes_the_0_routes", // but no --advertise-exit-node
|
|
flags: []string{"--advertise-routes=11.1.43.0/24,0.0.0.0/0,::/0"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.42.0/24"),
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "advertise_exit_node", // Issue 1859
|
|
flags: []string{"--advertise-exit-node"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "advertise_exit_node_over_existing_routes",
|
|
flags: []string{"--advertise-exit-node"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("1.2.0.0/16"),
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
|
|
},
|
|
{
|
|
name: "advertise_exit_node_over_existing_routes_and_exit_node",
|
|
flags: []string{"--advertise-exit-node"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
netip.MustParsePrefix("1.2.0.0/16"),
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
|
|
},
|
|
{
|
|
name: "exit_node_clearing", // Issue 1777
|
|
flags: []string{"--exit-node="},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
|
|
ExitNodeID: "fooID",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "remove_all_implicit",
|
|
flags: []string{"--force-reauth"},
|
|
curPrefs: &ipn.Prefs{
|
|
WantRunning: true,
|
|
ControlURL: ipn.DefaultControlURL,
|
|
RouteAll: true,
|
|
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
|
|
CorpDNS: false,
|
|
ShieldsUp: true,
|
|
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
|
Hostname: "myhostname",
|
|
ForceDaemon: true,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/16"),
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
NetfilterMode: preftype.NetfilterNoDivert,
|
|
OperatorUser: "alice",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
curUser: "eve",
|
|
want: accidentalUpPrefix + " --force-reauth --accept-dns=false --accept-routes --advertise-exit-node --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --hostname=myhostname --netfilter-mode=nodivert --operator=alice --shields-up",
|
|
},
|
|
{
|
|
name: "remove_all_implicit_except_hostname",
|
|
flags: []string{"--hostname=newhostname"},
|
|
curPrefs: &ipn.Prefs{
|
|
WantRunning: true,
|
|
ControlURL: ipn.DefaultControlURL,
|
|
RouteAll: true,
|
|
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
|
|
CorpDNS: false,
|
|
ShieldsUp: true,
|
|
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
|
Hostname: "myhostname",
|
|
ForceDaemon: true,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/16"),
|
|
},
|
|
NetfilterMode: preftype.NetfilterNoDivert,
|
|
OperatorUser: "alice",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
curUser: "eve",
|
|
want: accidentalUpPrefix + " --hostname=newhostname --accept-dns=false --accept-routes --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --netfilter-mode=nodivert --operator=alice --shields-up",
|
|
},
|
|
{
|
|
name: "loggedout_is_implicit",
|
|
flags: []string{"--hostname=foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
LoggedOut: true,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "", // not an error. LoggedOut is implicit.
|
|
},
|
|
{
|
|
// Test that a pre-1.8 version of Tailscale with bogus NoSNAT pref
|
|
// values is able to enable exit nodes without warnings.
|
|
name: "make_windows_exit_node",
|
|
flags: []string{"--advertise-exit-node"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
RouteAll: true,
|
|
|
|
// And assume this no-op accidental pre-1.8 value:
|
|
NoSNAT: true,
|
|
},
|
|
goos: "windows",
|
|
want: "", // not an error
|
|
},
|
|
{
|
|
name: "ignore_netfilter_change_non_linux",
|
|
flags: []string{"--accept-dns"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
|
|
NetfilterMode: preftype.NetfilterNoDivert, // we never had this bug, but pretend it got set non-zero on Windows somehow
|
|
},
|
|
goos: "openbsd",
|
|
want: "", // not an error
|
|
},
|
|
{
|
|
name: "operator_losing_routes_step1", // https://twitter.com/EXPbits/status/1390418145047887877
|
|
flags: []string{"--operator=expbits"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
netip.MustParsePrefix("1.2.0.0/16"),
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --operator=expbits --advertise-exit-node --advertise-routes=1.2.0.0/16",
|
|
},
|
|
{
|
|
name: "operator_losing_routes_step2", // https://twitter.com/EXPbits/status/1390418145047887877
|
|
flags: []string{"--operator=expbits", "--advertise-routes=1.2.0.0/16"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
netip.MustParsePrefix("1.2.0.0/16"),
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --advertise-routes=1.2.0.0/16 --operator=expbits --advertise-exit-node",
|
|
},
|
|
{
|
|
name: "errors_preserve_explicit_flags",
|
|
flags: []string{"--reset", "--force-reauth=false", "--authkey=secretrand"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: false,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
|
|
Hostname: "foo",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --auth-key=secretrand --force-reauth=false --reset --hostname=foo",
|
|
},
|
|
{
|
|
name: "error_exit_node_omit_with_ip_pref",
|
|
flags: []string{"--hostname=foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
|
|
ExitNodeIP: netip.MustParseAddr("100.64.5.4"),
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.4",
|
|
},
|
|
{
|
|
name: "error_exit_node_omit_with_id_pref",
|
|
flags: []string{"--hostname=foo"},
|
|
curExitNodeIP: netip.MustParseAddr("100.64.5.7"),
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
|
|
ExitNodeID: "some_stable_id",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.7",
|
|
},
|
|
{
|
|
name: "error_exit_node_and_allow_lan_omit_with_id_pref", // Issue 3480
|
|
flags: []string{"--hostname=foo"},
|
|
curExitNodeIP: netip.MustParseAddr("100.2.3.4"),
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
|
|
ExitNodeAllowLANAccess: true,
|
|
ExitNodeID: "some_stable_id",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --hostname=foo --exit-node-allow-lan-access --exit-node=100.2.3.4",
|
|
},
|
|
{
|
|
name: "ignore_login_server_synonym",
|
|
flags: []string{"--login-server=https://controlplane.tailscale.com"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: "", // not an error
|
|
},
|
|
{
|
|
name: "ignore_login_server_synonym_on_other_change",
|
|
flags: []string{"--netfilter-mode=off"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: false,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --netfilter-mode=off --accept-dns=false",
|
|
},
|
|
{
|
|
// Issue 3176: on Synology, don't require --accept-routes=false because user
|
|
// might've had an old install, and we don't support --accept-routes anyway.
|
|
name: "synology_permit_omit_accept_routes",
|
|
flags: []string{"--hostname=foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: true,
|
|
RouteAll: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
goos: "linux",
|
|
distro: distro.Synology,
|
|
want: "",
|
|
},
|
|
{
|
|
// Same test case as "synology_permit_omit_accept_routes" above, but
|
|
// on non-Synology distro.
|
|
name: "not_synology_dont_permit_omit_accept_routes",
|
|
flags: []string{"--hostname=foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: true,
|
|
RouteAll: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
goos: "linux",
|
|
distro: "", // not Synology
|
|
want: accidentalUpPrefix + " --hostname=foo --accept-routes",
|
|
},
|
|
{
|
|
name: "profile_name_ignored_in_up",
|
|
flags: []string{"--hostname=foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
ProfileName: "foo",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
goos: "linux",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "losing_report_posture",
|
|
flags: []string{"--accept-dns"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: false,
|
|
CorpDNS: true,
|
|
PostureChecking: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
want: accidentalUpPrefix + " --accept-dns --report-posture",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
goos := stdcmp.Or(tt.goos, "linux")
|
|
backendState := stdcmp.Or(tt.backendState, ipn.Running.String())
|
|
// Needs to match the other conditions in checkForAccidentalSettingReverts
|
|
tt.curPrefs.Persist = &persist.Persist{
|
|
UserProfile: tailcfg.UserProfile{
|
|
LoginName: "janet",
|
|
},
|
|
}
|
|
var upArgs upArgsT
|
|
flagSet := newUpFlagSet(goos, &upArgs, "up")
|
|
flags := CleanUpArgs(tt.flags)
|
|
flagSet.Parse(flags)
|
|
newPrefs, err := prefsFromUpArgs(upArgs, t.Logf, new(ipnstate.Status), goos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upEnv := upCheckEnv{
|
|
goos: goos,
|
|
flagSet: flagSet,
|
|
curExitNodeIP: tt.curExitNodeIP,
|
|
distro: tt.distro,
|
|
user: tt.curUser,
|
|
backendState: backendState,
|
|
}
|
|
applyImplicitPrefs(newPrefs, tt.curPrefs, upEnv)
|
|
var got string
|
|
if _, err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upEnv); err != nil {
|
|
got = err.Error()
|
|
}
|
|
if strings.TrimSpace(got) != tt.want {
|
|
t.Errorf("unexpected result\n got: %s\nwant: %s\n", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func upArgsFromOSArgs(goos string, flagArgs ...string) (args upArgsT) {
|
|
fs := newUpFlagSet(goos, &args, "up")
|
|
fs.Parse(flagArgs) // populates args
|
|
return
|
|
}
|
|
|
|
func TestPrefsFromUpArgs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args upArgsT
|
|
goos string // runtime.GOOS; empty means linux
|
|
st *ipnstate.Status // or nil
|
|
want *ipn.Prefs
|
|
wantErr string
|
|
wantWarn string
|
|
}{
|
|
{
|
|
name: "default_linux",
|
|
goos: "linux",
|
|
args: upArgsFromOSArgs("linux"),
|
|
want: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: true,
|
|
NoSNAT: false,
|
|
NoStatefulFiltering: "true",
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
CorpDNS: true,
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "default_windows",
|
|
goos: "windows",
|
|
args: upArgsFromOSArgs("windows"),
|
|
want: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: true,
|
|
CorpDNS: true,
|
|
RouteAll: true,
|
|
NoSNAT: false,
|
|
NoStatefulFiltering: "true",
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "advertise_default_route",
|
|
args: upArgsFromOSArgs("linux", "--advertise-exit-node"),
|
|
want: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: true,
|
|
CorpDNS: true,
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
NoStatefulFiltering: "true",
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "error_advertise_route_invalid_ip",
|
|
args: upArgsT{
|
|
advertiseRoutes: "foo",
|
|
},
|
|
wantErr: `"foo" is not a valid IP address or CIDR prefix`,
|
|
},
|
|
{
|
|
name: "error_advertise_route_unmasked_bits",
|
|
args: upArgsT{
|
|
advertiseRoutes: "1.2.3.4/16",
|
|
},
|
|
wantErr: `1.2.3.4/16 has non-address bits set; expected 1.2.0.0/16`,
|
|
},
|
|
{
|
|
name: "error_exit_node_bad_ip",
|
|
args: upArgsT{
|
|
exitNodeIP: "foo",
|
|
},
|
|
st: &ipnstate.Status{
|
|
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
|
key.NewNode().Public(): {
|
|
DNSName: "example.com.",
|
|
TailscaleIPs: []netip.Addr{netip.MustParseAddr("1.0.0.2")},
|
|
},
|
|
},
|
|
},
|
|
wantErr: `invalid value "foo" for --exit-node; must be IP or peer hostname`,
|
|
},
|
|
{
|
|
name: "error_exit_node_not_started",
|
|
args: upArgsT{
|
|
exitNodeIP: "foo",
|
|
},
|
|
wantErr: `cannot resolve exit node by hostname while Tailscale is starting up; please use its Tailscale IP address instead`,
|
|
},
|
|
{
|
|
name: "error_exit_node_allow_lan_without_exit_node",
|
|
args: upArgsT{
|
|
exitNodeAllowLANAccess: true,
|
|
},
|
|
wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`,
|
|
},
|
|
{
|
|
name: "error_tag_bad_prefix",
|
|
args: upArgsT{
|
|
advertiseTags: "notatag:foo",
|
|
},
|
|
wantErr: `tag: "notatag:foo": tags must start with 'tag:'`,
|
|
},
|
|
{
|
|
name: "tag_auto_prefix",
|
|
args: upArgsFromOSArgs("linux", "--advertise-tags=foo,bar"),
|
|
want: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: true,
|
|
CorpDNS: true,
|
|
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
|
NoSNAT: false,
|
|
NoStatefulFiltering: "true",
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tag_mixed_prefix",
|
|
args: upArgsFromOSArgs("linux", "--advertise-tags=tag:foo,bar"),
|
|
want: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: true,
|
|
CorpDNS: true,
|
|
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
|
NoSNAT: false,
|
|
NoStatefulFiltering: "true",
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "error_long_hostname",
|
|
args: upArgsT{
|
|
hostname: strings.Repeat(strings.Repeat("a", 63)+".", 4),
|
|
},
|
|
wantErr: `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" is too long to be a DNS name`,
|
|
},
|
|
{
|
|
name: "error_long_label",
|
|
args: upArgsT{
|
|
hostname: strings.Repeat("a", 64) + ".example.com",
|
|
},
|
|
wantErr: `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" is not a valid DNS label`,
|
|
},
|
|
{
|
|
name: "error_linux_netfilter_empty",
|
|
args: upArgsT{
|
|
netfilterMode: "",
|
|
},
|
|
wantErr: `invalid value --netfilter-mode=""`,
|
|
},
|
|
{
|
|
name: "error_linux_netfilter_bogus",
|
|
args: upArgsT{
|
|
netfilterMode: "bogus",
|
|
},
|
|
wantErr: `invalid value --netfilter-mode="bogus"`,
|
|
},
|
|
{
|
|
name: "error_exit_node_ip_is_self_ip",
|
|
args: upArgsT{
|
|
exitNodeIP: "100.105.106.107",
|
|
},
|
|
st: &ipnstate.Status{
|
|
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.105.106.107")},
|
|
},
|
|
wantErr: `cannot use 100.105.106.107 as an exit node as it is a local IP address to this machine; did you mean --advertise-exit-node?`,
|
|
},
|
|
{
|
|
name: "warn_linux_netfilter_nodivert",
|
|
goos: "linux",
|
|
args: upArgsT{
|
|
netfilterMode: "nodivert",
|
|
},
|
|
wantWarn: "netfilter=nodivert; add iptables calls to ts-* chains manually.",
|
|
want: &ipn.Prefs{
|
|
WantRunning: true,
|
|
NetfilterMode: preftype.NetfilterNoDivert,
|
|
NoSNAT: true,
|
|
NoStatefulFiltering: "true",
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "warn_linux_netfilter_off",
|
|
goos: "linux",
|
|
args: upArgsT{
|
|
netfilterMode: "off",
|
|
},
|
|
wantWarn: "netfilter=off; configure iptables yourself.",
|
|
want: &ipn.Prefs{
|
|
WantRunning: true,
|
|
NetfilterMode: preftype.NetfilterOff,
|
|
NoSNAT: true,
|
|
NoStatefulFiltering: "true",
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "via_route_good",
|
|
goos: "linux",
|
|
args: upArgsT{
|
|
advertiseRoutes: "fd7a:115c:a1e0:b1a::bb:10.0.0.0/112",
|
|
netfilterMode: "off",
|
|
},
|
|
want: &ipn.Prefs{
|
|
WantRunning: true,
|
|
NoSNAT: true,
|
|
NoStatefulFiltering: "true",
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
|
|
},
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "via_route_good_16_bit",
|
|
goos: "linux",
|
|
args: upArgsT{
|
|
advertiseRoutes: "fd7a:115c:a1e0:b1a::aabb:10.0.0.0/112",
|
|
netfilterMode: "off",
|
|
},
|
|
want: &ipn.Prefs{
|
|
WantRunning: true,
|
|
NoSNAT: true,
|
|
NoStatefulFiltering: "true",
|
|
AdvertiseRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::aabb:10.0.0.0/112"),
|
|
},
|
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
|
Check: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "via_route_short_prefix",
|
|
goos: "linux",
|
|
args: upArgsT{
|
|
advertiseRoutes: "fd7a:115c:a1e0:b1a::/64",
|
|
netfilterMode: "off",
|
|
},
|
|
wantErr: "fd7a:115c:a1e0:b1a::/64 4-in-6 prefix must be at least a /96",
|
|
},
|
|
{
|
|
name: "via_route_short_reserved_siteid",
|
|
goos: "linux",
|
|
args: upArgsT{
|
|
advertiseRoutes: "fd7a:115c:a1e0:b1a:1234:5678::/112",
|
|
netfilterMode: "off",
|
|
},
|
|
wantErr: "route fd7a:115c:a1e0:b1a:1234:5678::/112 contains invalid site ID 12345678; must be 0xffff or less",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var warnBuf tstest.MemLogger
|
|
goos := stdcmp.Or(tt.goos, "linux")
|
|
st := tt.st
|
|
if st == nil {
|
|
st = new(ipnstate.Status)
|
|
}
|
|
got, err := prefsFromUpArgs(tt.args, warnBuf.Logf, st, goos)
|
|
gotErr := fmt.Sprint(err)
|
|
if tt.wantErr != "" {
|
|
if tt.wantErr != gotErr {
|
|
t.Errorf("wrong error.\n got error: %v\nwant error: %v\n", gotErr, tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tt.want == nil {
|
|
t.Fatal("tt.want is nil")
|
|
}
|
|
if !got.Equals(tt.want) {
|
|
jgot, _ := json.MarshalIndent(got, "", "\t")
|
|
jwant, _ := json.MarshalIndent(tt.want, "", "\t")
|
|
if bytes.Equal(jgot, jwant) {
|
|
t.Logf("prefs differ only in non-JSON-visible ways (nil/non-nil zero-length arrays)")
|
|
}
|
|
t.Errorf("wrong prefs\n got: %s\nwant: %s\n\ngot: %s\nwant: %s\n",
|
|
got.Pretty(), tt.want.Pretty(),
|
|
jgot, jwant,
|
|
)
|
|
|
|
}
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
func TestPrefFlagMapping(t *testing.T) {
|
|
prefHasFlag := map[string]bool{}
|
|
for _, pv := range prefsOfFlag {
|
|
for _, pref := range pv {
|
|
prefHasFlag[strings.Split(pref, ".")[0]] = true
|
|
}
|
|
}
|
|
|
|
prefType := reflect.TypeFor[ipn.Prefs]()
|
|
for field := range prefType.Fields() {
|
|
prefName := field.Name
|
|
if prefHasFlag[prefName] {
|
|
continue
|
|
}
|
|
switch prefName {
|
|
case "AllowSingleHosts":
|
|
// Fake pref for downgrade compat. See #12058.
|
|
continue
|
|
case "WantRunning", "Persist", "LoggedOut":
|
|
// All explicitly handled (ignored) by checkForAccidentalSettingReverts.
|
|
continue
|
|
case "OSVersion", "DeviceModel":
|
|
// Only used by Android, which doesn't have a CLI mode anyway, so
|
|
// fine to not map.
|
|
continue
|
|
case "NotepadURLs":
|
|
// TODO(bradfitz): https://github.com/tailscale/tailscale/issues/1830
|
|
continue
|
|
case "Egg":
|
|
// Not applicable.
|
|
continue
|
|
case "RunWebClient":
|
|
// TODO(tailscale/corp#14335): Currently behind a feature flag.
|
|
continue
|
|
case "NetfilterKind":
|
|
// Handled by TS_DEBUG_FIREWALL_MODE env var, we don't want to have
|
|
// a CLI flag for this. The Pref is used by c2n.
|
|
continue
|
|
case "DriveShares":
|
|
// Handled by the tailscale share subcommand, we don't want a CLI
|
|
// flag for this.
|
|
continue
|
|
case "AdvertiseServices":
|
|
// Handled by the tailscale serve subcommand, we don't want a
|
|
// CLI flag for this.
|
|
continue
|
|
case "InternalExitNodePrior":
|
|
// Used internally by LocalBackend as part of exit node usage toggling.
|
|
// No CLI flag for this.
|
|
continue
|
|
case "AutoExitNode":
|
|
// Handled by tailscale {set,up} --exit-node=auto:any.
|
|
continue
|
|
}
|
|
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
|
|
}
|
|
}
|
|
|
|
func TestFlagAppliesToOS(t *testing.T) {
|
|
for _, goos := range geese {
|
|
var upArgs upArgsT
|
|
fs := newUpFlagSet(goos, &upArgs, "up")
|
|
fs.VisitAll(func(f *flag.Flag) {
|
|
if !flagAppliesToOS(f.Name, goos) {
|
|
t.Errorf("flagAppliesToOS(%q, %q) = false but found in %s set", f.Name, goos, goos)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdatePrefs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
flags []string // argv to be parsed into env.flagSet and env.upArgs
|
|
curPrefs *ipn.Prefs
|
|
env upCheckEnv // empty goos means "linux"
|
|
|
|
// sshOverTailscale specifies if the cmd being run over SSH over Tailscale.
|
|
// It is used to test the --accept-risks flag.
|
|
sshOverTailscale bool
|
|
|
|
// checkUpdatePrefsMutations, if non-nil, is run with the new prefs after
|
|
// updatePrefs might've mutated them (from applyImplicitPrefs).
|
|
checkUpdatePrefsMutations func(t *testing.T, newPrefs *ipn.Prefs)
|
|
|
|
wantSimpleUp bool
|
|
wantJustEditMP *ipn.MaskedPrefs
|
|
wantErrSubtr string
|
|
}{
|
|
{
|
|
name: "bare_up_means_up",
|
|
flags: []string{},
|
|
curPrefs: ipn.NewPrefs(),
|
|
wantSimpleUp: false, // user profile not set, so no simple up
|
|
},
|
|
{
|
|
name: "just_up",
|
|
flags: []string{},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
},
|
|
env: upCheckEnv{
|
|
backendState: "Stopped",
|
|
},
|
|
wantSimpleUp: true,
|
|
},
|
|
{
|
|
name: "just_up_needs_login_default_prefs",
|
|
flags: []string{},
|
|
curPrefs: ipn.NewPrefs(),
|
|
env: upCheckEnv{
|
|
backendState: "NeedsLogin",
|
|
},
|
|
wantSimpleUp: false,
|
|
},
|
|
{
|
|
name: "just_up_needs_login_losing_prefs",
|
|
flags: []string{},
|
|
curPrefs: &ipn.Prefs{
|
|
// defaults:
|
|
ControlURL: ipn.DefaultControlURL,
|
|
WantRunning: false,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
// non-default:
|
|
CorpDNS: false,
|
|
},
|
|
env: upCheckEnv{
|
|
backendState: "NeedsLogin",
|
|
},
|
|
wantSimpleUp: false,
|
|
wantErrSubtr: "tailscale up --accept-dns=false",
|
|
},
|
|
{
|
|
name: "just_edit",
|
|
flags: []string{},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
wantSimpleUp: true,
|
|
wantJustEditMP: &ipn.MaskedPrefs{WantRunningSet: true},
|
|
},
|
|
{
|
|
name: "just_edit_reset",
|
|
flags: []string{"--reset"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
AdvertiseRoutesSet: true,
|
|
AdvertiseTagsSet: true,
|
|
AppConnectorSet: true,
|
|
ControlURLSet: true,
|
|
CorpDNSSet: true,
|
|
ExitNodeAllowLANAccessSet: true,
|
|
ExitNodeIDSet: true,
|
|
ExitNodeIPSet: true,
|
|
HostnameSet: true,
|
|
NetfilterModeSet: true,
|
|
NoSNATSet: true,
|
|
NoStatefulFilteringSet: true,
|
|
OperatorUserSet: true,
|
|
PostureCheckingSet: true,
|
|
RouteAllSet: true,
|
|
RunSSHSet: true,
|
|
ShieldsUpSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
},
|
|
{
|
|
name: "control_synonym",
|
|
flags: []string{},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
wantSimpleUp: true,
|
|
wantJustEditMP: &ipn.MaskedPrefs{WantRunningSet: true},
|
|
},
|
|
{
|
|
name: "change_login_server",
|
|
flags: []string{"--login-server=https://localhost:1000"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
wantSimpleUp: true,
|
|
wantJustEditMP: &ipn.MaskedPrefs{WantRunningSet: true},
|
|
wantErrSubtr: "can't change --login-server without --force-reauth",
|
|
},
|
|
{
|
|
name: "change_tags",
|
|
flags: []string{"--advertise-tags=tag:foo"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
},
|
|
{
|
|
// Issue 3808: explicitly empty --operator= should clear value.
|
|
name: "explicit_empty_operator",
|
|
flags: []string{"--operator="},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
OperatorUser: "somebody",
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
env: upCheckEnv{user: "somebody", backendState: "Running"},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
OperatorUserSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
checkUpdatePrefsMutations: func(t *testing.T, prefs *ipn.Prefs) {
|
|
if prefs.OperatorUser != "" {
|
|
t.Errorf("operator sent to backend should be empty; got %q", prefs.OperatorUser)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "enable_ssh",
|
|
flags: []string{"--ssh"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
RunSSHSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if !newPrefs.RunSSH {
|
|
t.Errorf("RunSSH not set to true")
|
|
}
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
},
|
|
{
|
|
name: "disable_ssh",
|
|
flags: []string{"--ssh=false"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
RunSSH: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
RunSSHSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if newPrefs.RunSSH {
|
|
t.Errorf("RunSSH not set to false")
|
|
}
|
|
},
|
|
env: upCheckEnv{backendState: "Running", upArgs: upArgsT{
|
|
runSSH: true,
|
|
}},
|
|
},
|
|
{
|
|
name: "disable_ssh_over_ssh_no_risk",
|
|
flags: []string{"--ssh=false"},
|
|
sshOverTailscale: true,
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
RunSSH: true,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
RunSSHSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if !newPrefs.RunSSH {
|
|
t.Errorf("RunSSH not set to true")
|
|
}
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
wantErrSubtr: "aborted, no changes made",
|
|
},
|
|
{
|
|
name: "enable_ssh_over_ssh_no_risk",
|
|
flags: []string{"--ssh=true"},
|
|
sshOverTailscale: true,
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
RunSSHSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if !newPrefs.RunSSH {
|
|
t.Errorf("RunSSH not set to true")
|
|
}
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
wantErrSubtr: "aborted, no changes made",
|
|
},
|
|
{
|
|
name: "enable_ssh_over_ssh",
|
|
flags: []string{"--ssh=true", "--accept-risk=lose-ssh"},
|
|
sshOverTailscale: true,
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
RunSSHSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if !newPrefs.RunSSH {
|
|
t.Errorf("RunSSH not set to true")
|
|
}
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
},
|
|
{
|
|
name: "disable_ssh_over_ssh",
|
|
flags: []string{"--ssh=false", "--accept-risk=lose-ssh"},
|
|
sshOverTailscale: true,
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
|
CorpDNS: true,
|
|
RunSSH: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
RunSSHSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if newPrefs.RunSSH {
|
|
t.Errorf("RunSSH not set to false")
|
|
}
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
},
|
|
{
|
|
name: "force_reauth_over_ssh_no_risk",
|
|
flags: []string{"--force-reauth"},
|
|
sshOverTailscale: true,
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
wantErrSubtr: "aborted, no changes made",
|
|
},
|
|
{
|
|
name: "force_reauth_over_ssh",
|
|
flags: []string{"--force-reauth", "--accept-risk=lose-ssh"},
|
|
sshOverTailscale: true,
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: "https://login.tailscale.com",
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: nil,
|
|
env: upCheckEnv{backendState: "Running"},
|
|
},
|
|
{
|
|
name: "advertise_connector",
|
|
flags: []string{"--advertise-connector"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
AppConnectorSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if !newPrefs.AppConnector.Advertise {
|
|
t.Errorf("prefs.AppConnector.Advertise not set")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "no_advertise_connector",
|
|
flags: []string{"--advertise-connector=false"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true,
|
|
NetfilterMode: preftype.NetfilterOn,
|
|
AppConnector: ipn.AppConnectorPrefs{
|
|
Advertise: true,
|
|
},
|
|
NoStatefulFiltering: opt.NewBool(true),
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
AppConnectorSet: true,
|
|
WantRunningSet: true,
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if newPrefs.AppConnector.Advertise {
|
|
t.Errorf("prefs.AppConnector.Advertise not unset")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "auto_exit_node",
|
|
flags: []string{"--exit-node=auto:any"},
|
|
curPrefs: &ipn.Prefs{
|
|
ControlURL: ipn.DefaultControlURL,
|
|
CorpDNS: true, // enabled by [ipn.NewPrefs] by default
|
|
NetfilterMode: preftype.NetfilterOn, // enabled by [ipn.NewPrefs] by default
|
|
},
|
|
wantJustEditMP: &ipn.MaskedPrefs{
|
|
WantRunningSet: true, // enabled by default for tailscale up
|
|
AutoExitNodeSet: true,
|
|
ExitNodeIDSet: true, // we want ExitNodeID cleared
|
|
ExitNodeIPSet: true, // same for ExitNodeIP
|
|
},
|
|
env: upCheckEnv{backendState: "Running"},
|
|
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
|
if newPrefs.AutoExitNode != ipn.AnyExitNode {
|
|
t.Errorf("AutoExitNode: got %q; want %q", newPrefs.AutoExitNode, ipn.AnyExitNode)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.sshOverTailscale {
|
|
tstest.Replace(t, &getSSHClientEnvVar, func() string { return "100.100.100.100 1 1" })
|
|
} else if isSSHOverTailscale() {
|
|
// The test is being executed over a "real" tailscale SSH
|
|
// session, but sshOverTailscale is unset. Make the test appear
|
|
// as if it's not over tailscale SSH.
|
|
tstest.Replace(t, &getSSHClientEnvVar, func() string { return "" })
|
|
}
|
|
if tt.env.goos == "" {
|
|
tt.env.goos = "linux"
|
|
}
|
|
tt.env.flagSet = newUpFlagSet(tt.env.goos, &tt.env.upArgs, "up")
|
|
flags := CleanUpArgs(tt.flags)
|
|
if err := tt.env.flagSet.Parse(flags); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
newPrefs, err := prefsFromUpArgs(tt.env.upArgs, t.Logf, new(ipnstate.Status), tt.env.goos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
simpleUp, justEditMP, err := updatePrefs(newPrefs, tt.curPrefs, tt.env)
|
|
if err != nil {
|
|
if tt.wantErrSubtr != "" {
|
|
if !strings.Contains(err.Error(), tt.wantErrSubtr) {
|
|
t.Fatalf("want error %q, got: %v", tt.wantErrSubtr, err)
|
|
}
|
|
return
|
|
}
|
|
t.Fatal(err)
|
|
} else if tt.wantErrSubtr != "" {
|
|
t.Fatalf("want error %q, got nil", tt.wantErrSubtr)
|
|
}
|
|
if tt.checkUpdatePrefsMutations != nil {
|
|
tt.checkUpdatePrefsMutations(t, newPrefs)
|
|
}
|
|
if simpleUp != tt.wantSimpleUp {
|
|
t.Fatalf("simpleUp=%v, want %v", simpleUp, tt.wantSimpleUp)
|
|
}
|
|
var oldEditPrefs ipn.Prefs
|
|
if justEditMP != nil {
|
|
oldEditPrefs = justEditMP.Prefs
|
|
justEditMP.Prefs = ipn.Prefs{} // uninteresting
|
|
}
|
|
if !reflect.DeepEqual(justEditMP, tt.wantJustEditMP) {
|
|
t.Logf("justEditMP != wantJustEditMP; following diff omits the Prefs field, which was \n%v", logger.AsJSON(oldEditPrefs))
|
|
t.Fatalf("justEditMP: %v\n\n: ", cmp.Diff(justEditMP, tt.wantJustEditMP, cmpIP))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
var cmpIP = cmp.Comparer(func(a, b netip.Addr) bool {
|
|
return a == b
|
|
})
|
|
|
|
func TestCleanUpArgs(t *testing.T) {
|
|
type S = []string
|
|
c := qt.New(t)
|
|
tests := []struct {
|
|
in []string
|
|
want []string
|
|
}{
|
|
{in: S{"something"}, want: S{"something"}},
|
|
{in: S{}, want: S{}},
|
|
{in: S{"--authkey=0"}, want: S{"--auth-key=0"}},
|
|
{in: S{"a", "--authkey=1", "b"}, want: S{"a", "--auth-key=1", "b"}},
|
|
{in: S{"a", "--auth-key=2", "b"}, want: S{"a", "--auth-key=2", "b"}},
|
|
{in: S{"a", "-authkey=3", "b"}, want: S{"a", "--auth-key=3", "b"}},
|
|
{in: S{"a", "-auth-key=4", "b"}, want: S{"a", "-auth-key=4", "b"}},
|
|
{in: S{"a", "--authkey", "5", "b"}, want: S{"a", "--auth-key", "5", "b"}},
|
|
{in: S{"a", "-authkey", "6", "b"}, want: S{"a", "--auth-key", "6", "b"}},
|
|
{in: S{"a", "authkey", "7", "b"}, want: S{"a", "authkey", "7", "b"}},
|
|
{in: S{"--authkeyexpiry", "8"}, want: S{"--authkeyexpiry", "8"}},
|
|
{in: S{"--auth-key-expiry", "9"}, want: S{"--auth-key-expiry", "9"}},
|
|
|
|
{in: S{"--posture-checking"}, want: S{"--report-posture"}},
|
|
{in: S{"-posture-checking"}, want: S{"--report-posture"}},
|
|
{in: S{"--posture-checking=nein"}, want: S{"--report-posture=nein"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := CleanUpArgs(tt.in)
|
|
c.Assert(got, qt.DeepEquals, tt.want)
|
|
}
|
|
}
|
|
|
|
func TestUpWorthWarning(t *testing.T) {
|
|
if !upWorthyWarning(healthmsg.WarnAcceptRoutesOff) {
|
|
t.Errorf("WarnAcceptRoutesOff of %q should be worth warning", healthmsg.WarnAcceptRoutesOff)
|
|
}
|
|
if !upWorthyWarning(healthmsg.TailscaleSSHOnBut + "some problem") {
|
|
t.Errorf("want true for SSH problems")
|
|
}
|
|
if upWorthyWarning("not in map poll") {
|
|
t.Errorf("want false for other misc errors")
|
|
}
|
|
}
|
|
|
|
func TestParseNLArgs(t *testing.T) {
|
|
tcs := []struct {
|
|
name string
|
|
input []string
|
|
parseKeys bool
|
|
parseDisablements bool
|
|
|
|
wantErr error
|
|
wantKeys []tka.Key
|
|
wantDisablements [][]byte
|
|
}{
|
|
{
|
|
name: "empty",
|
|
input: nil,
|
|
parseKeys: true,
|
|
parseDisablements: true,
|
|
},
|
|
{
|
|
name: "key-no-votes",
|
|
input: []string{"nlpub:" + strings.Repeat("00", 32)},
|
|
parseKeys: true,
|
|
wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 1, Public: bytes.Repeat([]byte{0}, 32)}},
|
|
},
|
|
{
|
|
name: "key-with-votes",
|
|
input: []string{"nlpub:" + strings.Repeat("01", 32) + "?5"},
|
|
parseKeys: true,
|
|
wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 5, Public: bytes.Repeat([]byte{1}, 32)}},
|
|
},
|
|
{
|
|
name: "disablements",
|
|
input: []string{"disablement:" + strings.Repeat("02", 32), "disablement-secret:" + strings.Repeat("03", 32)},
|
|
parseDisablements: true,
|
|
wantDisablements: [][]byte{bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, 32)},
|
|
},
|
|
{
|
|
name: "disablements-not-allowed",
|
|
input: []string{"disablement:" + strings.Repeat("02", 32)},
|
|
parseKeys: true,
|
|
wantErr: fmt.Errorf("parsing key 1: key hex string doesn't have expected type prefix tlpub:"),
|
|
},
|
|
{
|
|
name: "keys-not-allowed",
|
|
input: []string{"nlpub:" + strings.Repeat("02", 32)},
|
|
parseDisablements: true,
|
|
wantErr: fmt.Errorf("parsing argument 1: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", "nlpub:0202020202020202020202020202020202020202020202020202020202020202"),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tcs {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
keys, disablements, err := parseNLArgs(tc.input, tc.parseKeys, tc.parseDisablements)
|
|
if (tc.wantErr == nil && err != nil) ||
|
|
(tc.wantErr != nil && err == nil) ||
|
|
(tc.wantErr != nil && err != nil && tc.wantErr.Error() != err.Error()) {
|
|
t.Fatalf("parseNLArgs(%v).err = %v, want %v", tc.input, err, tc.wantErr)
|
|
}
|
|
|
|
if !reflect.DeepEqual(keys, tc.wantKeys) {
|
|
t.Errorf("keys = %v, want %v", keys, tc.wantKeys)
|
|
}
|
|
if !reflect.DeepEqual(disablements, tc.wantDisablements) {
|
|
t.Errorf("disablements = %v, want %v", disablements, tc.wantDisablements)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// makeQuietContinueOnError modifies c recursively to make all the
|
|
// flagsets have error mode flag.ContinueOnError and not
|
|
// spew all over stderr.
|
|
func makeQuietContinueOnError(c *ffcli.Command) {
|
|
if c.FlagSet != nil {
|
|
c.FlagSet.Init(c.Name, flag.ContinueOnError)
|
|
c.FlagSet.Usage = func() {}
|
|
c.FlagSet.SetOutput(io.Discard)
|
|
}
|
|
c.UsageFunc = func(*ffcli.Command) string { return "" }
|
|
for _, sub := range c.Subcommands {
|
|
makeQuietContinueOnError(sub)
|
|
}
|
|
}
|
|
|
|
// see tailscale/tailscale#6813
|
|
func TestNoDups(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{
|
|
name: "dup-boolean",
|
|
args: []string{"up", "--json", "--json"},
|
|
want: "error parsing commandline arguments: invalid boolean flag json: flag provided multiple times",
|
|
},
|
|
{
|
|
name: "dup-string",
|
|
args: []string{"up", "--hostname=foo", "--hostname=bar"},
|
|
want: "error parsing commandline arguments: invalid value \"bar\" for flag -hostname: flag provided multiple times",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := newRootCmd(t)
|
|
makeQuietContinueOnError(cmd)
|
|
err := cmd.Parse(tt.args)
|
|
if got := fmt.Sprint(err); got != tt.want {
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHelpAlias(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
tstest.Replace[io.Writer](t, &Stdout, &stdout)
|
|
tstest.Replace[io.Writer](t, &Stderr, &stderr)
|
|
|
|
gotExit0 := false
|
|
defer func() {
|
|
if !gotExit0 {
|
|
t.Error("expected os.Exit(0) to be called")
|
|
return
|
|
}
|
|
if !strings.Contains(stderr.String(), "SUBCOMMANDS") {
|
|
t.Errorf("expected help output to contain SUBCOMMANDS; got stderr=%q; stdout=%q", stderr.String(), stdout.String())
|
|
}
|
|
}()
|
|
defer func() {
|
|
if e := recover(); e != nil {
|
|
if strings.Contains(fmt.Sprint(e), "unexpected call to os.Exit(0)") {
|
|
gotExit0 = true
|
|
} else {
|
|
t.Errorf("unexpected panic: %v", e)
|
|
}
|
|
}
|
|
}()
|
|
err := Run([]string{"help"})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDocs(t *testing.T) {
|
|
root := newRootCmd()
|
|
check := func(t *testing.T, c *ffcli.Command) {
|
|
shortVerb, _, ok := strings.Cut(c.ShortHelp, " ")
|
|
if !ok || shortVerb == "" {
|
|
t.Errorf("couldn't find verb+space in ShortHelp")
|
|
} else {
|
|
if strings.HasSuffix(shortVerb, ".") {
|
|
t.Errorf("ShortHelp shouldn't end in period; got %q", c.ShortHelp)
|
|
}
|
|
if b := shortVerb[0]; b >= 'a' && b <= 'z' {
|
|
t.Errorf("ShortHelp should start with upper-case letter; got %q", c.ShortHelp)
|
|
}
|
|
if strings.HasSuffix(shortVerb, "s") && shortVerb != "Does" {
|
|
t.Errorf("verb %q ending in 's' is unexpected, from %q", shortVerb, c.ShortHelp)
|
|
}
|
|
}
|
|
|
|
name := t.Name()
|
|
wantPfx := strings.ReplaceAll(strings.TrimPrefix(name, "TestDocs/"), "/", " ")
|
|
switch name {
|
|
case "TestDocs/tailscale/completion/bash",
|
|
"TestDocs/tailscale/completion/zsh":
|
|
wantPfx = "" // special-case exceptions
|
|
}
|
|
if !strings.HasPrefix(c.ShortUsage, wantPfx) {
|
|
t.Errorf("ShortUsage should start with %q; got %q", wantPfx, c.ShortUsage)
|
|
}
|
|
}
|
|
|
|
var walk func(t *testing.T, c *ffcli.Command)
|
|
walk = func(t *testing.T, c *ffcli.Command) {
|
|
t.Run(c.Name, func(t *testing.T) {
|
|
check(t, c)
|
|
for _, sub := range c.Subcommands {
|
|
walk(t, sub)
|
|
}
|
|
})
|
|
}
|
|
walk(t, root)
|
|
}
|
|
|
|
func TestUpResolves(t *testing.T) {
|
|
const testARN = "arn:aws:ssm:us-east-1:123456789012:parameter/my-parameter"
|
|
undo := tailscale.HookResolveValueFromParameterStore.SetForTest(func(_ context.Context, valueOrARN string) (string, error) {
|
|
if valueOrARN == testARN {
|
|
return "resolved-value", nil
|
|
}
|
|
return valueOrARN, nil
|
|
})
|
|
defer undo()
|
|
|
|
const content = "file-content"
|
|
fpath := filepath.Join(t.TempDir(), "testfile")
|
|
if err := os.WriteFile(fpath, []byte(content), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
arg string
|
|
want string
|
|
}{
|
|
{"parameter_store", testARN, "resolved-value"},
|
|
{"file", "file:" + fpath, "file-content"},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name+"_auth_key", func(t *testing.T) {
|
|
args := upArgsT{authKeyOrFile: tt.arg}
|
|
got, err := args.getAuthKey(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
|
|
t.Run(tt.name+"_client_secret", func(t *testing.T) {
|
|
args := upArgsT{clientSecretOrFile: tt.arg}
|
|
got, err := args.getClientSecret(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
|
|
t.Run(tt.name+"_id_token", func(t *testing.T) {
|
|
args := upArgsT{idTokenOrFile: tt.arg}
|
|
got, err := args.getIDToken(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("passthrough", func(t *testing.T) {
|
|
args := upArgsT{authKeyOrFile: "tskey-abcd1234"}
|
|
got, err := args.getAuthKey(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "tskey-abcd1234" {
|
|
t.Errorf("got %q, want %q", got, "tskey-abcd1234")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDeps(t *testing.T) {
|
|
deptest.DepChecker{
|
|
GOOS: "linux",
|
|
GOARCH: "arm64",
|
|
WantDeps: set.Of(
|
|
"tailscale.com/feature/capture/dissector", // want the Lua by default
|
|
),
|
|
BadDeps: map[string]string{
|
|
"tailscale.com/feature/capture": "don't link capture code",
|
|
"tailscale.com/net/packet": "why we passing packets in the CLI?",
|
|
"tailscale.com/net/flowtrack": "why we tracking flows in the CLI?",
|
|
},
|
|
}.Check(t)
|
|
}
|
|
|
|
func TestDepsNoCapture(t *testing.T) {
|
|
deptest.DepChecker{
|
|
GOOS: "linux",
|
|
GOARCH: "arm64",
|
|
Tags: "ts_omit_capture",
|
|
BadDeps: map[string]string{
|
|
"tailscale.com/feature/capture": "don't link capture code",
|
|
"tailscale.com/feature/capture/dissector": "don't like the Lua",
|
|
},
|
|
}.Check(t)
|
|
|
|
}
|
|
|
|
func TestSanitizeWriter(t *testing.T) {
|
|
buf := new(bytes.Buffer)
|
|
w := sanitizeOutput(buf)
|
|
|
|
in := []byte(`my auth key is tskey-auth-abc123-def456 and tskey-foo, what's yours?`)
|
|
want := []byte(`my auth key is tskey-XXXXXXXXXXXXXXXXXX and tskey-XXX, what's yours?`)
|
|
n, err := w.Write(in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != len(in) {
|
|
t.Errorf("unexpected write length %d, want %d", n, len(in))
|
|
}
|
|
if got := buf.Bytes(); !bytes.Equal(got, want) {
|
|
t.Errorf("unexpected sanitized content\ngot: %q\nwant: %q", got, want)
|
|
}
|
|
}
|