diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 2a532f9d7..2924ac462 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -80,7 +80,7 @@ func CleanUpArgs(args []string) []string { return out } -var localClient = local.Client{ +var localClient = &local.Client{ Socket: paths.DefaultTailscaledSocket(), } @@ -188,6 +188,7 @@ change in the future. upCmd, downCmd, setCmd, + getCmd, loginCmd, logoutCmd, switchCmd, diff --git a/cmd/tailscale/cli/funnel.go b/cmd/tailscale/cli/funnel.go index f4a1c6bfd..8cd82677f 100644 --- a/cmd/tailscale/cli/funnel.go +++ b/cmd/tailscale/cli/funnel.go @@ -17,7 +17,7 @@ import ( ) var funnelCmd = func() *ffcli.Command { - se := &serveEnv{lc: &localClient} + se := &serveEnv{lc: localClient} // previously used to serve legacy newFunnelCommand unless useWIPCode is true // change is limited to make a revert easier and full cleanup to come after the release. // TODO(tylersmalley): cleanup and removal of newFunnelCommand as of 2023-10-16 diff --git a/cmd/tailscale/cli/get.go b/cmd/tailscale/cli/get.go new file mode 100644 index 000000000..79b7322f7 --- /dev/null +++ b/cmd/tailscale/cli/get.go @@ -0,0 +1,202 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "flag" + "fmt" + "maps" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/types/opt" +) + +var getCmd = &ffcli.Command{ + Name: "get", + ShortUsage: "tailscale get setting", + ShortHelp: "Print specified settings", + LongHelp: `"tailscale get" prints a specific setting. + +Only one setting will be printed. + +SETTINGS +` + getSettings.Settings(), + FlagSet: newFlagSet("get"), + Exec: runGet, + UsageFunc: usageFuncNoDefaultValues, +} + +type getSettingsT map[string]string + +// makeGetSettingsT returns a [getSettingsT] with all of the settings controlled +// by the given flagsets. Each setting gets its help text from its flag's Usage. +func makeGetSettingsT(flagsets ...*flag.FlagSet) getSettingsT { + settings := make(getSettingsT) + for _, fs := range flagsets { + fs.VisitAll(func(f *flag.Flag) { + if preflessFlag(f.Name) { + return + } + if _, ok := settings[f.Name]; ok { + return + } + + settings[f.Name] = f.Usage + }) + } + return settings +} + +// Settings returns a string of all the settings known to the get command. +// The result is formatted for use in help text. +func (s getSettingsT) Settings() string { + var b strings.Builder + names := slices.Sorted(maps.Keys(s)) + for _, name := range names { + usage := s.Usage(name) + if strings.HasPrefix(usage, hidden) { + continue + } + b.WriteString(" ") + b.WriteString(name) + b.WriteString("\n ") + b.WriteString(usage) + b.WriteString("\n") + } + return b.String() +} + +func lookupPrefOfFlag(p *ipn.Prefs, name string) (reflect.Value, error) { + prefs, ok := prefsOfFlag[name] + if !ok { + return reflect.Value{}, fmt.Errorf("missing pref flag mapping for %s", name) + } + if len(prefs) != 1 { + return reflect.Value{}, fmt.Errorf("expected only one pref flag mapping for %s, not %q", name, prefs) + } + v := reflect.ValueOf(p).Elem() + for _, n := range strings.Split(prefs[0], ".") { + v = v.FieldByName(n) + } + return v, nil +} + +// Lookup returns a function that can be used to look up the associated +// preference for a given flag name. +func (s getSettingsT) Lookup(name string) func(*ipn.Prefs, *ipnstate.Status) string { + if _, ok := s[name]; !ok { + return nil + } + + switch name { + case "advertise-connector": + return func(p *ipn.Prefs, st *ipnstate.Status) string { + value, err := lookupPrefOfFlag(p, name) + if err != nil { + panic(err) + } + return fmt.Sprintf("%v", value.FieldByName("Advertise")) + } + case "advertise-exit-node": + return func(p *ipn.Prefs, st *ipnstate.Status) string { + return strconv.FormatBool(p.AdvertisesExitNode()) + } + case "advertise-tags": + return func(p *ipn.Prefs, st *ipnstate.Status) string { + value, err := lookupPrefOfFlag(p, name) + if err != nil { + panic(err) + } + v := value.Interface().([]string) + return strings.Join(v, ",") + } + case "advertise-routes": + return func(p *ipn.Prefs, st *ipnstate.Status) string { + var b strings.Builder + for i, r := range p.AdvertiseRoutes { + if i > 0 { + b.WriteRune(',') + } + b.WriteString(r.String()) + } + return b.String() + } + case "exit-node": + return func(p *ipn.Prefs, st *ipnstate.Status) string { + ip := exitNodeIP(p, st) + if ip.IsValid() { + return ip.String() + } + return "" + } + case "snat-subnet-routes": + return func(p *ipn.Prefs, st *ipnstate.Status) string { + value, err := lookupPrefOfFlag(p, name) + if err != nil { + panic(err) + } + return fmt.Sprintf("%t", !value.Bool()) + } + case "stateful-filtering": + return func(p *ipn.Prefs, st *ipnstate.Status) string { + value, err := lookupPrefOfFlag(p, name) + if err != nil { + panic(err) + } + v := value.Interface().(opt.Bool) + return v.Not().String() + } + default: + return func(p *ipn.Prefs, st *ipnstate.Status) string { + value, err := lookupPrefOfFlag(p, name) + if err != nil { + panic(err) + } + return fmt.Sprintf("%v", value) // fmt prints the concrete value + } + } +} + +// Usage returns the usage string for a given flag name. +func (s getSettingsT) Usage(name string) string { + usage, ok := s[name] + if !ok { + panic("unknown setting: " + name) + } + return usage +} + +var getSettings = makeGetSettingsT(setFlagSet, upFlagSet) + +func runGet(ctx context.Context, args []string) (retErr error) { + if len(args) != 1 { + fatalf("must provide only one non-flag argument: %q", args) + } + + setting := args[0] + lookup := getSettings.Lookup(setting) + if lookup == nil { + fatalf("unknown setting: %s", setting) + } + + prefs, err := localClient.GetPrefs(ctx) + if err != nil { + return err + } + + status, err := localClient.Status(ctx) + if err != nil { + return err + } + + outln(lookup(prefs, status)) + return nil +} diff --git a/cmd/tailscale/cli/get_test.go b/cmd/tailscale/cli/get_test.go new file mode 100644 index 000000000..c108b9b15 --- /dev/null +++ b/cmd/tailscale/cli/get_test.go @@ -0,0 +1,236 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "bytes" + "context" + "flag" + "io" + "net" + "net/http" + "path/filepath" + "testing" + + "tailscale.com/client/local" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/ipnserver" + "tailscale.com/ipn/store/mem" + "tailscale.com/tsd" + "tailscale.com/tstest" + "tailscale.com/types/logger" + "tailscale.com/types/logid" + "tailscale.com/wgengine" +) + +func TestGetSettingsArePairedWithPrefFlags(t *testing.T) { + // Every get setting should have a corresponding prefsOfFlag. + // Some prefsOfFlag might not be in getSettings because it is either + // a prefless flag or it doesn't apply to this operating system. + for name, _ := range getSettings { + if _, ok := prefsOfFlag[name]; !ok { + t.Errorf("mismatched getter: %s", name) + } + } +} + +func TestGetSettingsArePairedWithSetFlags(t *testing.T) { + // Every set flag should have a corresponding get setting, + // except for prefless flags, which don't have get settings. + setFlagSet.VisitAll(func(f *flag.Flag) { + if preflessFlag(f.Name) { + return + } + if _, ok := getSettings[f.Name]; !ok { + t.Errorf("missing set flag: %s", f.Name) + } + }) +} + +func TestGetSettingsArePairedWithUpFlags(t *testing.T) { + // Every up flag should have a corresponding get setting, + // except for prefless flags, which don't have get settings. + upFlagSet.VisitAll(func(f *flag.Flag) { + if preflessFlag(f.Name) { + return + } + if _, ok := getSettings[f.Name]; !ok { + t.Errorf("missing up flag: %s", f.Name) + } + }) +} + +func TestGetSettingsWithFakeServer(t *testing.T) { + for _, tt := range []struct{ flag, value string }{ + // --nickname is at the top-level in .ProfileName + {"nickname", "home"}, + {"nickname", "work"}, + // --update-check is nested in .AutoUpdate.Check + {"update-check", "false"}, + {"update-check", "true"}, + } { + name := tt.flag + "=" + tt.value + t.Run(name, func(t *testing.T) { + // Capture outln calls + var stdout bytes.Buffer + tstest.Replace[io.Writer](t, &Stdout, &stdout) + + // Use a fake localClient that processes settings updates + lc := newLocalClient(t) + tstest.Replace(t, &localClient, lc) + + // setCmd.FlagSet must be reset to parse arguments + cmd := *setCmd + cmd.FlagSet = newSetFlagSet(effectiveGOOS(), &setArgs) + tstest.Replace(t, &setCmd, &cmd) + tstest.Replace(t, &setFlagSet, cmd.FlagSet) + + // Capture errors from setCmd + cmd.FlagSet.Init(cmd.FlagSet.Name(), flag.PanicOnError) + defer func() { + if r := recover(); r != nil { + t.Fatal(r) + } + }() + + // Capture errors from getCmd + tstest.Replace(t, &Fatalf, t.Fatalf) + + arg := "--" + tt.flag + "=" + tt.value + t.Logf("tailscale set %s", arg) + if err := setCmd.ParseAndRun(t.Context(), []string{arg}); err != nil { + t.Fatal(err) + } + + stdout.Reset() + arg = tt.flag + t.Logf("tailscale get %s", arg) + if err := runGet(t.Context(), []string{arg}); err != nil { + t.Fatal(err) + } + + got := stdout.String() + want := tt.value + "\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + } +} + +func TestGetDefaultSettings(t *testing.T) { + // Fetch the default settings from all of the flags + for _, fs := range []*flag.FlagSet{setFlagSet, upFlagSet} { + fs.VisitAll(func(f *flag.Flag) { + if preflessFlag(f.Name) { + return + } + + t.Run(f.Name, func(t *testing.T) { + // Capture outln calls + var stdout bytes.Buffer + tstest.Replace[io.Writer](t, &Stdout, &stdout) + + // Use a fake localClient that processes settings updates + lc := newLocalClient(t) + tstest.Replace(t, &localClient, lc) + + if err := runGet(t.Context(), []string{f.Name}); err != nil { + t.Fatal(err) + } + + want := f.DefValue + switch f.Name { + case "auto-update": + // Unset by tailscale up. + want = "unset" + case "login-server": + // The default settings is empty, + // but tailscale up sets it on start. + want = "" + } + want += "\n" + + got := stdout.String() + if got != want { + t.Errorf("tailscale get %s: got %q, want %q", f.Name, got, want) + } + }) + }) + } + setFlagSet.VisitAll(func(f *flag.Flag) { + if preflessFlag(f.Name) { + return + } + if _, ok := getSettings[f.Name]; !ok { + t.Errorf("missing set flag: %s", f.Name) + } + }) +} + +func newLocalListener(t testing.TB) net.Listener { + sock := filepath.Join(t.TempDir(), "sock") + l, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + return l +} + +func newLocalBackend(t testing.TB, logID logid.PublicID) *ipnlocal.LocalBackend { + var logf logger.Logf = func(_ string, _ ...any) {} + if testing.Verbose() { + logf = tstest.WhileTestRunningLogger(t) + } + + sys := new(tsd.System) + if _, ok := sys.StateStore.GetOK(); !ok { + sys.Set(new(mem.Store)) + } + if _, ok := sys.Engine.GetOK(); !ok { + eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(eng.Close) + + sys.Set(eng) + } + + lb, err := ipnlocal.NewLocalBackend(logf, logID, sys, 0) + if err != nil { + t.Fatal(err) + } + return lb +} + +func newLocalClient(t testing.TB) *local.Client { + var logf logger.Logf = func(_ string, _ ...any) {} + if testing.Verbose() { + logf = tstest.WhileTestRunningLogger(t) + } + + logID := logid.PublicID{} + + lb := newLocalBackend(t, logID) + t.Cleanup(lb.Shutdown) + + // Connect over Unix domain socket for admin access. + l := newLocalListener(t) + t.Cleanup(func() { l.Close() }) + + srv := ipnserver.New(logf, logID, lb.NetMon()) + srv.SetLocalBackend(lb) + + go srv.Run(t.Context(), l) + + return &local.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var std net.Dialer + return std.DialContext(ctx, "unix", l.Addr().String()) + }, + }, + } +} diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go index 96629b5ad..a0460f9f9 100644 --- a/cmd/tailscale/cli/serve_legacy.go +++ b/cmd/tailscale/cli/serve_legacy.go @@ -32,7 +32,7 @@ import ( ) var serveCmd = func() *ffcli.Command { - se := &serveEnv{lc: &localClient} + se := &serveEnv{lc: localClient} // previously used to serve legacy newFunnelCommand unless useWIPCode is true // change is limited to make a revert easier and full cleanup to come after the relase. // TODO(tylersmalley): cleanup and removal of newServeLegacyCommand as of 2023-10-16 diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index e8e5f0c51..07b3fe9ce 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -68,8 +68,8 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { setf := newFlagSet("set") setf.StringVar(&setArgs.profileName, "nickname", "", "nickname for the current account") - setf.BoolVar(&setArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes") - setf.BoolVar(&setArgs.acceptDNS, "accept-dns", false, "accept DNS configuration from the admin panel") + setf.BoolVar(&setArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes") + setf.BoolVar(&setArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel") setf.StringVar(&setArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node") setf.BoolVar(&setArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node") setf.BoolVar(&setArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 31f7eb956..26db85f13 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -39,7 +39,6 @@ import ( "tailscale.com/types/preftype" "tailscale.com/types/views" "tailscale.com/util/dnsname" - "tailscale.com/version" "tailscale.com/version/distro" ) @@ -79,14 +78,8 @@ func effectiveGOOS() string { // acceptRouteDefault returns the CLI's default value of --accept-routes as // a function of the platform it's running on. func acceptRouteDefault(goos string) bool { - switch goos { - case "windows": - return true - case "darwin": - return version.IsSandboxedMacOS() - default: - return false - } + var p *ipn.Prefs + return p.DefaultRouteAll(goos) } var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up") diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index e209d388e..c53284d31 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -110,7 +110,7 @@ func runWeb(ctx context.Context, args []string) error { Mode: web.LoginServerMode, CGIMode: webArgs.cgi, PathPrefix: webArgs.prefix, - LocalClient: &localClient, + LocalClient: localClient, } if webArgs.readonly { opts.Mode = web.ReadOnlyServerMode diff --git a/ipn/prefs.go b/ipn/prefs.go index f5406f3b7..98f04dfa9 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -29,6 +29,7 @@ import ( "tailscale.com/types/views" "tailscale.com/util/dnsname" "tailscale.com/util/syspolicy" + "tailscale.com/version" ) // DefaultControlURL is the URL base of the control plane @@ -664,7 +665,7 @@ func NewPrefs() *Prefs { // Provide default values for options which might be missing // from the json data for any reason. The json can still // override them to false. - return &Prefs{ + p := &Prefs{ // ControlURL is explicitly not set to signal that // it's not yet configured, which relaxes the CLI "up" // safety net features. It will get set to DefaultControlURL @@ -672,7 +673,6 @@ func NewPrefs() *Prefs { // later anyway. ControlURL: "", - RouteAll: true, CorpDNS: true, WantRunning: false, NetfilterMode: preftype.NetfilterOn, @@ -682,6 +682,8 @@ func NewPrefs() *Prefs { Apply: opt.Bool("unset"), }, } + p.RouteAll = p.DefaultRouteAll(runtime.GOOS) + return p } // ControlURLOrDefault returns the coordination server's URL base. @@ -711,6 +713,19 @@ func (p *Prefs) ControlURLOrDefault() string { return DefaultControlURL } +// DefaultRouteAll returns the default value of [Prefs.RouteAll] as a function +// of the platform it's running on. +func (p *Prefs) DefaultRouteAll(goos string) bool { + switch goos { + case "windows": + return true + case "darwin": + return version.IsSandboxedMacOS() + default: + return false + } +} + // AdminPageURL returns the admin web site URL for the current ControlURL. func (p PrefsView) AdminPageURL() string { return p.ж.AdminPageURL() } diff --git a/types/opt/bool.go b/types/opt/bool.go index 0a3ee67ad..09deacf99 100644 --- a/types/opt/bool.go +++ b/types/opt/bool.go @@ -24,6 +24,18 @@ func NewBool(b bool) Bool { return Bool(strconv.FormatBool(b)) } +// String implements the [fmt.Stringer] interface. +// +// It never returns an empty string, since it is easier to read "unset". +func (b Bool) String() string { + switch b { + case "": + return "unset" + default: + return string(b) + } +} + func (b *Bool) Set(v bool) { *b = Bool(strconv.FormatBool(v)) } @@ -41,6 +53,19 @@ func (b Bool) Get() (v bool, ok bool) { } } +// Not returns the inverse of b, i.e. Bool("true") swapped with Bool("false"). +// However, b is returned unchanged if it was unset. +func (b Bool) Not() Bool { + switch b { + case "true": + return Bool("false") + case "false": + return Bool("true") + default: + return b + } +} + // Scan implements database/sql.Scanner. func (b *Bool) Scan(src any) error { if src == nil {