mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-28 23:04:10 +00:00
cmd/tailscale: get command for printing settings
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>
This commit is contained in:
parent
0655dd7b3d
commit
b13343e37e
@ -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,
|
||||
|
@ -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
|
||||
|
212
cmd/tailscale/cli/get.go
Normal file
212
cmd/tailscale/cli/get.go
Normal file
@ -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
|
||||
}
|
244
cmd/tailscale/cli/get_test.go
Normal file
244
cmd/tailscale/cli/get_test.go
Normal file
@ -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())
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user