summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSimon Law <sfllaw@sfllaw.ca>2025-03-14 11:47:57 -0700
committerMario Minardi <mario@tailscale.com>2025-04-07 16:10:20 -0600
commitb13343e37e4e140d6144ac29b87654daeb509a54 (patch)
treeef856ca3b7c17eebb6bc0cda871d8c2e17b95a0a
parent0655dd7b3da74697e190d67e95c6dbef5ad01060 (diff)
downloadtailscale-mpminardi/temp.tar.xz
tailscale-mpminardi/temp.zip
cmd/tailscale: get command for printing settingsmpminardi/temp
For symmetry, `tailscale get` is the complement of `tailscale set`. For every `tailscale set --SETTING`, there is now a corresponding `tailscale get SETTING`. While users were able to use `tailscale debug --prefs | jq .SETTING` to extract their settings, this requires an external tool. To add insult to injury, the names of the settings don’t always match the keys in the JSON. For example, the `accept-dns` setting is called `.CorpDNS`. And `advertise-exit-node` is just user-hostile. This patch also contains tests that try to keep `getSettings` aligned with the `setFlagSet` and `upFlagSet` flags for the `set` and `up` commands, respectively. As a happy side-effect, this also checks that the default values of these flags are consistent with the actual default settings. Closes: #2130 Signed-off-by: Simon Law <sfllaw@sfllaw.ca>
-rw-r--r--cmd/tailscale/cli/cli.go3
-rw-r--r--cmd/tailscale/cli/funnel.go2
-rw-r--r--cmd/tailscale/cli/get.go212
-rw-r--r--cmd/tailscale/cli/get_test.go244
-rw-r--r--cmd/tailscale/cli/serve_legacy.go2
-rw-r--r--cmd/tailscale/cli/web.go2
-rw-r--r--types/opt/bool.go25
7 files changed, 486 insertions, 4 deletions
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..3bca5345e
--- /dev/null
+++ b/cmd/tailscale/cli/get.go
@@ -0,0 +1,212 @@
+// 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) (v reflect.Value, err 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)
+ }
+
+ defer func() {
+ switch r := recover().(type) {
+ case nil: // noop
+ case error:
+ err = fmt.Errorf("bad pref flag %q for %s: %w", prefs, name, r)
+ default:
+ err = fmt.Errorf("bad pref flag %q for %s: %v", prefs, name, r)
+ }
+ }()
+ 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..da7c70e4d
--- /dev/null
+++ b/cmd/tailscale/cli/get_test.go
@@ -0,0 +1,244 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package cli
+
+import (
+ "bytes"
+ "context"
+ "flag"
+ "io"
+ "net"
+ "net/http"
+ "path/filepath"
+ "runtime"
+ "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 TestGetSettingsWillRoundtrip(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 {
+ if runtime.GOOS == "windows" {
+ // Connect over a Unix domain socket for admin access,
+ // which keeps ipnauth_notwindows happy, but ipnauth_windows
+ // wants a different guarantee on Windows.
+ t.Skip("newLocalClient doesn't know to authorize with safesocket.WindowsClientConn")
+ }
+
+ 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/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/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 {