cmd/tailscale/cli: add "tailscale get" command
This adds @alexwlchan's proposed "tailscale get" command that reads current preference values, complementing "tailscale set". It uses the same flag names as set. tailscale get # show all settings as a table tailscale get all # same tailscale get accept-dns # show a single value tailscale get --json # output as JSON object tailscale get --set-flags # output as tailscale set argv Fixes #11389 Fixes tailscale/corp#38702 Change-Id: Ie366f27f11ccc56c76fff9a94ed8a9de9c835bd0 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
2d6844c565
commit
0d92a69259
@@ -275,6 +275,7 @@ change in the future.
|
||||
upCmd,
|
||||
downCmd,
|
||||
setCmd,
|
||||
getCmd,
|
||||
loginCmd,
|
||||
logoutCmd,
|
||||
switchCmd,
|
||||
|
||||
+14
-14
@@ -620,10 +620,10 @@ var fileGetCmd = &ffcli.Command{
|
||||
Exec: runFileGet,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("get")
|
||||
fs.BoolVar(&getArgs.wait, "wait", false, "wait for a file to arrive if inbox is empty")
|
||||
fs.BoolVar(&getArgs.loop, "loop", false, "run get in a loop, receiving files as they come in")
|
||||
fs.BoolVar(&getArgs.verbose, "verbose", false, "verbose output")
|
||||
fs.Var(&getArgs.conflict, "conflict", "`behavior`"+` when a conflicting (same-named) file already exists in the target directory.
|
||||
fs.BoolVar(&fileGetArgs.wait, "wait", false, "wait for a file to arrive if inbox is empty")
|
||||
fs.BoolVar(&fileGetArgs.loop, "loop", false, "run get in a loop, receiving files as they come in")
|
||||
fs.BoolVar(&fileGetArgs.verbose, "verbose", false, "verbose output")
|
||||
fs.Var(&fileGetArgs.conflict, "conflict", "`behavior`"+` when a conflicting (same-named) file already exists in the target directory.
|
||||
skip: skip conflicting files: leave them in the taildrop inbox and print an error. get any non-conflicting files
|
||||
overwrite: overwrite existing file
|
||||
rename: write to a new number-suffixed filename`)
|
||||
@@ -632,7 +632,7 @@ var fileGetCmd = &ffcli.Command{
|
||||
})(),
|
||||
}
|
||||
|
||||
var getArgs = struct {
|
||||
var fileGetArgs = struct {
|
||||
wait bool
|
||||
loop bool
|
||||
verbose bool
|
||||
@@ -694,7 +694,7 @@ func receiveFile(ctx context.Context, wf apitype.WaitingFile, dir string) (targe
|
||||
return "", 0, fmt.Errorf("opening inbox file %q: %w", wf.Name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
f, err := openFileOrSubstitute(dir, wf.Name, getArgs.conflict)
|
||||
f, err := openFileOrSubstitute(dir, wf.Name, fileGetArgs.conflict)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
@@ -720,10 +720,10 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
|
||||
errs = append(errs, fmt.Errorf("getting WaitingFiles: %w", err))
|
||||
break
|
||||
}
|
||||
if len(wfs) != 0 || !(getArgs.wait || getArgs.loop) {
|
||||
if len(wfs) != 0 || !(fileGetArgs.wait || fileGetArgs.loop) {
|
||||
break
|
||||
}
|
||||
if getArgs.verbose {
|
||||
if fileGetArgs.verbose {
|
||||
printf("waiting for file...")
|
||||
}
|
||||
if err := waitForFile(ctx); err != nil {
|
||||
@@ -744,7 +744,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
if getArgs.verbose {
|
||||
if fileGetArgs.verbose {
|
||||
printf("wrote %v as %v (%d bytes)\n", wf.Name, writtenFile, size)
|
||||
}
|
||||
if err = localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
|
||||
@@ -756,7 +756,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
|
||||
if deleted == 0 && len(wfs) > 0 {
|
||||
// persistently stuck files are basically an error
|
||||
errs = append(errs, fmt.Errorf("moved %d/%d files", deleted, len(wfs)))
|
||||
} else if getArgs.verbose {
|
||||
} else if fileGetArgs.verbose {
|
||||
printf("moved %d/%d files\n", deleted, len(wfs))
|
||||
}
|
||||
return errs
|
||||
@@ -776,7 +776,7 @@ func runFileGet(ctx context.Context, args []string) error {
|
||||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||||
return fmt.Errorf("%q is not a directory", dir)
|
||||
}
|
||||
if getArgs.loop {
|
||||
if fileGetArgs.loop {
|
||||
for {
|
||||
errs := runFileGetOneBatch(ctx, dir)
|
||||
for _, err := range errs {
|
||||
@@ -808,7 +808,7 @@ func runFileGet(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
func wipeInbox(ctx context.Context) error {
|
||||
if getArgs.wait {
|
||||
if fileGetArgs.wait {
|
||||
return errors.New("can't use --wait with /dev/null target")
|
||||
}
|
||||
wfs, err := localClient.WaitingFiles(ctx)
|
||||
@@ -817,7 +817,7 @@ func wipeInbox(ctx context.Context) error {
|
||||
}
|
||||
deleted := 0
|
||||
for _, wf := range wfs {
|
||||
if getArgs.verbose {
|
||||
if fileGetArgs.verbose {
|
||||
log.Printf("deleting %v ...", wf.Name)
|
||||
}
|
||||
if err := localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
|
||||
@@ -825,7 +825,7 @@ func wipeInbox(ctx context.Context) error {
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
if getArgs.verbose {
|
||||
if fileGetArgs.verbose {
|
||||
log.Printf("deleted %d files", deleted)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
var getCmd = &ffcli.Command{
|
||||
Name: "get",
|
||||
ShortUsage: "tailscale get [flags] [setting-name | all]",
|
||||
ShortHelp: "Show current preference values",
|
||||
LongHelp: `"tailscale get" shows the current value of one or all preferences.
|
||||
|
||||
With no argument or "all", all preferences are shown.
|
||||
With a specific setting name, only that value is shown.
|
||||
|
||||
The setting names are the same flag names accepted by "tailscale set".`,
|
||||
FlagSet: getFlags,
|
||||
Exec: runGet,
|
||||
}
|
||||
|
||||
type getArgsT struct {
|
||||
json bool
|
||||
setFlags bool
|
||||
}
|
||||
|
||||
var getArgs getArgsT
|
||||
|
||||
var getFlags = newGetFlagSet(&getArgs)
|
||||
|
||||
func newGetFlagSet(args *getArgsT) *flag.FlagSet {
|
||||
fs := newFlagSet("get")
|
||||
fs.BoolVar(&args.json, "json", false, "output as JSON")
|
||||
fs.BoolVar(&args.setFlags, "set-flags", false, "output as \"tailscale set\" flag arguments")
|
||||
return fs
|
||||
}
|
||||
|
||||
// getSetting is a single preference name-value pair.
|
||||
type getSetting struct {
|
||||
name string
|
||||
value any
|
||||
}
|
||||
|
||||
func runGet(ctx context.Context, args []string) error {
|
||||
prefs, err := localClient.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, wantAll, err := selectSettings(prefs, st, effectiveGOOS(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case getArgs.json:
|
||||
return getOutputJSON(settings)
|
||||
case getArgs.setFlags:
|
||||
return getOutputSetFlags(settings)
|
||||
case !wantAll:
|
||||
// Single value: just print the raw value.
|
||||
outln(fmt.Sprint(settings[0].value))
|
||||
return nil
|
||||
default:
|
||||
return getOutputTable(settings)
|
||||
}
|
||||
}
|
||||
|
||||
// selectSettings validates args and returns the settings to display.
|
||||
// wantAll reports whether the caller asked for all settings (no arg or "all").
|
||||
func selectSettings(prefs *ipn.Prefs, st *ipnstate.Status, goos string, args []string) (settings []getSetting, wantAll bool, err error) {
|
||||
if len(args) > 1 {
|
||||
return nil, false, fmt.Errorf("too many arguments: %q", args)
|
||||
}
|
||||
wantAll = len(args) == 0 || args[0] == "all"
|
||||
if wantAll {
|
||||
return getSettingsFromPrefs(prefs, st, goos, false), true, nil
|
||||
}
|
||||
wantName := args[0]
|
||||
// When querying a specific name, include hidden flags.
|
||||
for _, s := range getSettingsFromPrefs(prefs, st, goos, true) {
|
||||
if s.name == wantName {
|
||||
return []getSetting{s}, false, nil
|
||||
}
|
||||
}
|
||||
return nil, false, fmt.Errorf("unknown setting %q; see \"tailscale set --help\" for valid settings", wantName)
|
||||
}
|
||||
|
||||
// getSettingsFromPrefs returns get-able settings derived from prefs,
|
||||
// using the same flag names as "tailscale set".
|
||||
// If includeHidden is false, flags with hidden usage strings are omitted.
|
||||
func getSettingsFromPrefs(prefs *ipn.Prefs, st *ipnstate.Status, goos string, includeHidden bool) []getSetting {
|
||||
// Use the set command's flag set to get the canonical ordered list
|
||||
// of flag names and to determine OS applicability.
|
||||
var dummy setArgsT
|
||||
fs := newSetFlagSet(goos, &dummy)
|
||||
|
||||
var settings []getSetting
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
if preflessFlag(f.Name) {
|
||||
return
|
||||
}
|
||||
if !includeHidden && strings.HasPrefix(f.Usage, hidden) {
|
||||
return
|
||||
}
|
||||
v := prefValue(f.Name, prefs, st)
|
||||
settings = append(settings, getSetting{name: f.Name, value: v})
|
||||
})
|
||||
return settings
|
||||
}
|
||||
|
||||
// prefValue returns the current value of the preference corresponding to
|
||||
// the given "tailscale set" flag name.
|
||||
func prefValue(flagName string, prefs *ipn.Prefs, st *ipnstate.Status) any {
|
||||
switch flagName {
|
||||
case "accept-routes":
|
||||
return prefs.RouteAll
|
||||
case "accept-dns":
|
||||
return prefs.CorpDNS
|
||||
case "exit-node":
|
||||
if prefs.AutoExitNode.IsSet() {
|
||||
return ipn.AutoExitNodePrefix + string(prefs.AutoExitNode)
|
||||
}
|
||||
ip := exitNodeIP(prefs, st)
|
||||
if ip.IsValid() {
|
||||
return ip.String()
|
||||
}
|
||||
return ""
|
||||
case "exit-node-allow-lan-access":
|
||||
return prefs.ExitNodeAllowLANAccess
|
||||
case "shields-up":
|
||||
return prefs.ShieldsUp
|
||||
case "ssh":
|
||||
return prefs.RunSSH
|
||||
case "hostname":
|
||||
return prefs.Hostname
|
||||
case "advertise-routes":
|
||||
var sb strings.Builder
|
||||
for i, r := range tsaddr.WithoutExitRoutes(views.SliceOf(prefs.AdvertiseRoutes)).All() {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteString(r.String())
|
||||
}
|
||||
return sb.String()
|
||||
case "advertise-exit-node":
|
||||
return tsaddr.ContainsExitRoutes(views.SliceOf(prefs.AdvertiseRoutes))
|
||||
case "advertise-connector":
|
||||
return prefs.AppConnector.Advertise
|
||||
case "nickname":
|
||||
return prefs.ProfileName
|
||||
case "update-check":
|
||||
return prefs.AutoUpdate.Check
|
||||
case "auto-update":
|
||||
return prefs.AutoUpdate.Apply.EqualBool(true)
|
||||
case "report-posture":
|
||||
return prefs.PostureChecking
|
||||
case "webclient":
|
||||
return prefs.RunWebClient
|
||||
case "operator":
|
||||
return prefs.OperatorUser
|
||||
case "snat-subnet-routes":
|
||||
return !prefs.NoSNAT
|
||||
case "stateful-filtering":
|
||||
val, ok := prefs.NoStatefulFiltering.Get()
|
||||
if ok && val {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case "netfilter-mode":
|
||||
return prefs.NetfilterMode.String()
|
||||
case "unattended":
|
||||
return prefs.ForceDaemon
|
||||
case "sync":
|
||||
return prefs.Sync.EqualBool(true)
|
||||
case "relay-server-port":
|
||||
if prefs.RelayServerPort != nil {
|
||||
return fmt.Sprint(*prefs.RelayServerPort)
|
||||
}
|
||||
return ""
|
||||
case "relay-server-static-endpoints":
|
||||
parts := make([]string, len(prefs.RelayServerStaticEndpoints))
|
||||
for i, ep := range prefs.RelayServerStaticEndpoints {
|
||||
parts[i] = ep.String()
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getOutputTable(settings []getSetting) error {
|
||||
w := tabwriter.NewWriter(Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "NAME\tVALUE\n")
|
||||
for _, s := range settings {
|
||||
fmt.Fprintf(w, "%s\t%v\n", s.name, s.value)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func getOutputJSON(settings []getSetting) error {
|
||||
m := make(map[string]any, len(settings))
|
||||
for _, s := range settings {
|
||||
m[s.name] = s.value
|
||||
}
|
||||
j, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outln(string(j))
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOutputSetFlags(settings []getSetting) error {
|
||||
var parts []string
|
||||
for _, s := range settings {
|
||||
parts = append(parts, fmtFlagValueArg(s.name, s.value))
|
||||
}
|
||||
outln(strings.Join(parts, " "))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/preftype"
|
||||
)
|
||||
|
||||
func TestPrefValue(t *testing.T) {
|
||||
port := uint16(41641)
|
||||
peerKey := key.NewNode().Public()
|
||||
exitPeerID := tailcfg.StableNodeID("exit-peer")
|
||||
exitPeerIP := netip.MustParseAddr("100.64.0.5")
|
||||
|
||||
stWithExitPeer := &ipnstate.Status{
|
||||
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||
peerKey: {
|
||||
ID: exitPeerID,
|
||||
TailscaleIPs: []netip.Addr{exitPeerIP},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
prefs *ipn.Prefs
|
||||
st *ipnstate.Status
|
||||
want any
|
||||
}{
|
||||
// Simple boolean prefs.
|
||||
{
|
||||
name: "accept-routes-true",
|
||||
flag: "accept-routes",
|
||||
prefs: &ipn.Prefs{RouteAll: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "accept-routes-false",
|
||||
flag: "accept-routes",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "accept-dns",
|
||||
flag: "accept-dns",
|
||||
prefs: &ipn.Prefs{CorpDNS: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exit-node-allow-lan-access",
|
||||
flag: "exit-node-allow-lan-access",
|
||||
prefs: &ipn.Prefs{ExitNodeAllowLANAccess: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "shields-up",
|
||||
flag: "shields-up",
|
||||
prefs: &ipn.Prefs{ShieldsUp: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ssh",
|
||||
flag: "ssh",
|
||||
prefs: &ipn.Prefs{RunSSH: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "advertise-connector",
|
||||
flag: "advertise-connector",
|
||||
prefs: &ipn.Prefs{AppConnector: ipn.AppConnectorPrefs{Advertise: true}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "update-check",
|
||||
flag: "update-check",
|
||||
prefs: &ipn.Prefs{AutoUpdate: ipn.AutoUpdatePrefs{Check: true}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "report-posture",
|
||||
flag: "report-posture",
|
||||
prefs: &ipn.Prefs{PostureChecking: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "webclient",
|
||||
flag: "webclient",
|
||||
prefs: &ipn.Prefs{RunWebClient: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unattended",
|
||||
flag: "unattended",
|
||||
prefs: &ipn.Prefs{ForceDaemon: true},
|
||||
want: true,
|
||||
},
|
||||
|
||||
// Simple string prefs.
|
||||
{
|
||||
name: "hostname",
|
||||
flag: "hostname",
|
||||
prefs: &ipn.Prefs{Hostname: "myhost"},
|
||||
want: "myhost",
|
||||
},
|
||||
{
|
||||
name: "nickname",
|
||||
flag: "nickname",
|
||||
prefs: &ipn.Prefs{ProfileName: "work"},
|
||||
want: "work",
|
||||
},
|
||||
{
|
||||
name: "operator",
|
||||
flag: "operator",
|
||||
prefs: &ipn.Prefs{OperatorUser: "alice"},
|
||||
want: "alice",
|
||||
},
|
||||
|
||||
// exit-node has three branches.
|
||||
{
|
||||
name: "exit-node/auto",
|
||||
flag: "exit-node",
|
||||
prefs: &ipn.Prefs{AutoExitNode: ipn.AnyExitNode},
|
||||
want: "auto:any",
|
||||
},
|
||||
{
|
||||
name: "exit-node/by-ip",
|
||||
flag: "exit-node",
|
||||
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("100.64.0.1")},
|
||||
want: "100.64.0.1",
|
||||
},
|
||||
{
|
||||
name: "exit-node/by-id-resolves-via-status",
|
||||
flag: "exit-node",
|
||||
prefs: &ipn.Prefs{ExitNodeID: exitPeerID},
|
||||
st: stWithExitPeer,
|
||||
want: exitPeerIP.String(),
|
||||
},
|
||||
{
|
||||
name: "exit-node/empty",
|
||||
flag: "exit-node",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: "",
|
||||
},
|
||||
|
||||
// advertise-routes filters out exit routes, comma-joins.
|
||||
{
|
||||
name: "advertise-routes/multiple",
|
||||
flag: "advertise-routes",
|
||||
prefs: &ipn.Prefs{AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
}},
|
||||
want: "10.0.0.0/24,192.168.0.0/16",
|
||||
},
|
||||
{
|
||||
name: "advertise-routes/excludes-exit-routes",
|
||||
flag: "advertise-routes",
|
||||
prefs: &ipn.Prefs{AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/24"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
}},
|
||||
want: "10.0.0.0/24",
|
||||
},
|
||||
{
|
||||
name: "advertise-routes/empty",
|
||||
flag: "advertise-routes",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: "",
|
||||
},
|
||||
|
||||
// advertise-exit-node derives from AdvertiseRoutes.
|
||||
{
|
||||
name: "advertise-exit-node/true",
|
||||
flag: "advertise-exit-node",
|
||||
prefs: &ipn.Prefs{AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "advertise-exit-node/false-empty",
|
||||
flag: "advertise-exit-node",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "advertise-exit-node/false-only-subnet",
|
||||
flag: "advertise-exit-node",
|
||||
prefs: &ipn.Prefs{AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/24"),
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
|
||||
// auto-update and sync use opt.Bool.EqualBool(true).
|
||||
{
|
||||
name: "auto-update/unset-is-false",
|
||||
flag: "auto-update",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "auto-update/explicit-true",
|
||||
flag: "auto-update",
|
||||
prefs: &ipn.Prefs{AutoUpdate: ipn.AutoUpdatePrefs{Apply: opt.NewBool(true)}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auto-update/explicit-false",
|
||||
flag: "auto-update",
|
||||
prefs: &ipn.Prefs{AutoUpdate: ipn.AutoUpdatePrefs{Apply: opt.NewBool(false)}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "sync/unset-is-false",
|
||||
flag: "sync",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "sync/explicit-true",
|
||||
flag: "sync",
|
||||
prefs: &ipn.Prefs{Sync: opt.NewBool(true)},
|
||||
want: true,
|
||||
},
|
||||
|
||||
// snat-subnet-routes is inverted.
|
||||
{
|
||||
name: "snat-subnet-routes/default-true",
|
||||
flag: "snat-subnet-routes",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "snat-subnet-routes/false-when-no-snat",
|
||||
flag: "snat-subnet-routes",
|
||||
prefs: &ipn.Prefs{NoSNAT: true},
|
||||
want: false,
|
||||
},
|
||||
|
||||
// stateful-filtering: the inversion of NoStatefulFiltering, defaulting on.
|
||||
{
|
||||
name: "stateful-filtering/unset-is-true",
|
||||
flag: "stateful-filtering",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stateful-filtering/explicit-disabled-no-stateful",
|
||||
flag: "stateful-filtering",
|
||||
prefs: &ipn.Prefs{NoStatefulFiltering: opt.NewBool(true)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "stateful-filtering/explicit-enabled-no-stateful",
|
||||
flag: "stateful-filtering",
|
||||
prefs: &ipn.Prefs{NoStatefulFiltering: opt.NewBool(false)},
|
||||
want: true,
|
||||
},
|
||||
|
||||
// netfilter-mode renders via String().
|
||||
{
|
||||
name: "netfilter-mode/off",
|
||||
flag: "netfilter-mode",
|
||||
prefs: &ipn.Prefs{NetfilterMode: preftype.NetfilterOff},
|
||||
want: "off",
|
||||
},
|
||||
{
|
||||
name: "netfilter-mode/on",
|
||||
flag: "netfilter-mode",
|
||||
prefs: &ipn.Prefs{NetfilterMode: preftype.NetfilterOn},
|
||||
want: "on",
|
||||
},
|
||||
|
||||
// relay-server-port: nil pointer vs explicit.
|
||||
{
|
||||
name: "relay-server-port/unset",
|
||||
flag: "relay-server-port",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "relay-server-port/set",
|
||||
flag: "relay-server-port",
|
||||
prefs: &ipn.Prefs{RelayServerPort: &port},
|
||||
want: "41641",
|
||||
},
|
||||
|
||||
// relay-server-static-endpoints: empty vs joined.
|
||||
{
|
||||
name: "relay-server-static-endpoints/empty",
|
||||
flag: "relay-server-static-endpoints",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "relay-server-static-endpoints/multiple",
|
||||
flag: "relay-server-static-endpoints",
|
||||
prefs: &ipn.Prefs{RelayServerStaticEndpoints: []netip.AddrPort{
|
||||
netip.MustParseAddrPort("192.0.2.1:40000"),
|
||||
netip.MustParseAddrPort("[2001:db8::1]:40000"),
|
||||
}},
|
||||
want: "192.0.2.1:40000,[2001:db8::1]:40000",
|
||||
},
|
||||
|
||||
// Unknown flag returns nil. This guards against the default branch
|
||||
// silently producing nil for a flag that should have been wired up.
|
||||
{
|
||||
name: "unknown-flag",
|
||||
flag: "no-such-flag",
|
||||
prefs: &ipn.Prefs{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
st := tt.st
|
||||
if st == nil {
|
||||
st = &ipnstate.Status{}
|
||||
}
|
||||
got := prefValue(tt.flag, tt.prefs, st)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("prefValue(%q) = %v (%T), want %v (%T)",
|
||||
tt.flag, got, got, tt.want, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrefValueCoversAllSetFlags is the load-bearing guard: every flag
|
||||
// that "tailscale set" exposes must have a corresponding prefValue case,
|
||||
// or "tailscale get" silently returns nil for it. It iterates the set
|
||||
// command's flag set across the platforms whose flag sets differ, so
|
||||
// OS-conditional flags (snat-subnet-routes, netfilter-mode, unattended,
|
||||
// operator, ...) are all covered.
|
||||
func TestPrefValueCoversAllSetFlags(t *testing.T) {
|
||||
for _, goos := range []string{"linux", "darwin", "windows"} {
|
||||
t.Run(goos, func(t *testing.T) {
|
||||
var dummy setArgsT
|
||||
fs := newSetFlagSet(goos, &dummy)
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
if preflessFlag(f.Name) {
|
||||
return
|
||||
}
|
||||
if got := prefValue(f.Name, &ipn.Prefs{}, &ipnstate.Status{}); got == nil {
|
||||
t.Errorf("prefValue(%q) returned nil; add a case for it in prefValue", f.Name)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSettingsFromPrefsHiddenFlag(t *testing.T) {
|
||||
prefs := &ipn.Prefs{}
|
||||
st := &ipnstate.Status{}
|
||||
|
||||
visible := getSettingsFromPrefs(prefs, st, "linux", false)
|
||||
if containsSetting(visible, "sync") {
|
||||
t.Error("expected hidden flag --sync to be excluded when includeHidden=false")
|
||||
}
|
||||
if !containsSetting(visible, "accept-dns") {
|
||||
t.Error("expected visible flag --accept-dns to be included")
|
||||
}
|
||||
|
||||
withHidden := getSettingsFromPrefs(prefs, st, "linux", true)
|
||||
if !containsSetting(withHidden, "sync") {
|
||||
t.Error("expected hidden flag --sync to be included when includeHidden=true")
|
||||
}
|
||||
|
||||
// Ordering must match the set flag set's VisitAll order.
|
||||
var wantOrder []string
|
||||
var dummy setArgsT
|
||||
newSetFlagSet("linux", &dummy).VisitAll(func(f *flag.Flag) {
|
||||
if preflessFlag(f.Name) {
|
||||
return
|
||||
}
|
||||
wantOrder = append(wantOrder, f.Name)
|
||||
})
|
||||
var gotOrder []string
|
||||
for _, s := range withHidden {
|
||||
gotOrder = append(gotOrder, s.name)
|
||||
}
|
||||
if !reflect.DeepEqual(gotOrder, wantOrder) {
|
||||
t.Errorf("setting order = %v, want %v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectSettings(t *testing.T) {
|
||||
prefs := &ipn.Prefs{Hostname: "h", CorpDNS: true}
|
||||
st := &ipnstate.Status{}
|
||||
const goos = "linux"
|
||||
|
||||
t.Run("empty-args-returns-all-visible", func(t *testing.T) {
|
||||
got, wantAll, err := selectSettings(prefs, st, goos, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wantAll {
|
||||
t.Error("wantAll = false; want true")
|
||||
}
|
||||
if containsSetting(got, "sync") {
|
||||
t.Error("hidden flag --sync leaked into all-settings result")
|
||||
}
|
||||
if !containsSetting(got, "hostname") {
|
||||
t.Error("missing --hostname in all-settings result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all-arg-same-as-empty", func(t *testing.T) {
|
||||
empty, _, err := selectSettings(prefs, st, goos, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allArg, wantAll, err := selectSettings(prefs, st, goos, []string{"all"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wantAll {
|
||||
t.Error("wantAll = false; want true for explicit \"all\"")
|
||||
}
|
||||
if !reflect.DeepEqual(empty, allArg) {
|
||||
t.Errorf("\"all\" produced %v, empty produced %v", allArg, empty)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("specific-visible-flag", func(t *testing.T) {
|
||||
got, wantAll, err := selectSettings(prefs, st, goos, []string{"hostname"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wantAll {
|
||||
t.Error("wantAll = true; want false for specific name")
|
||||
}
|
||||
if len(got) != 1 || got[0].name != "hostname" || got[0].value != "h" {
|
||||
t.Errorf("got %+v, want [{hostname h}]", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("specific-hidden-flag", func(t *testing.T) {
|
||||
// Hidden flags must be reachable by exact name.
|
||||
got, _, err := selectSettings(prefs, st, goos, []string{"sync"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].name != "sync" {
|
||||
t.Errorf("got %+v, want [{sync ...}]", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown-flag-errors", func(t *testing.T) {
|
||||
_, _, err := selectSettings(prefs, st, goos, []string{"no-such-flag"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown setting") || !strings.Contains(err.Error(), "no-such-flag") {
|
||||
t.Errorf("error %q missing expected substrings", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too-many-args-errors", func(t *testing.T) {
|
||||
_, _, err := selectSettings(prefs, st, goos, []string{"hostname", "ssh"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many arguments") {
|
||||
t.Errorf("error %q missing \"too many arguments\"", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("os-conditional-flag-on-wrong-goos", func(t *testing.T) {
|
||||
// "netfilter-mode" is registered only on linux. Asking for it
|
||||
// on darwin should produce an "unknown setting" error.
|
||||
_, _, err := selectSettings(prefs, st, "darwin", []string{"netfilter-mode"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown setting") {
|
||||
t.Errorf("got err=%v, want \"unknown setting\"", err)
|
||||
}
|
||||
// And operator is peer-creds-only.
|
||||
if safesocket.GOOSUsesPeerCreds("windows") {
|
||||
t.Skip("operator is exposed on windows")
|
||||
}
|
||||
_, _, err = selectSettings(prefs, st, "windows", []string{"operator"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown setting") {
|
||||
t.Errorf("got err=%v, want \"unknown setting\"", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetOutputJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tstest.Replace[io.Writer](t, &Stdout, &buf)
|
||||
|
||||
settings := []getSetting{
|
||||
{name: "accept-dns", value: true},
|
||||
{name: "hostname", value: "myhost"},
|
||||
{name: "advertise-routes", value: "10.0.0.0/24"},
|
||||
{name: "shields-up", value: false},
|
||||
}
|
||||
if err := getOutputJSON(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, buf.String())
|
||||
}
|
||||
want := map[string]any{
|
||||
"accept-dns": true,
|
||||
"hostname": "myhost",
|
||||
"advertise-routes": "10.0.0.0/24",
|
||||
"shields-up": false,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOutputTable(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tstest.Replace[io.Writer](t, &Stdout, &buf)
|
||||
|
||||
settings := []getSetting{
|
||||
{name: "accept-dns", value: true},
|
||||
{name: "hostname", value: "myhost"},
|
||||
}
|
||||
if err := getOutputTable(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("got %d lines, want 3:\n%s", len(lines), out)
|
||||
}
|
||||
if !strings.HasPrefix(lines[0], "NAME") || !strings.Contains(lines[0], "VALUE") {
|
||||
t.Errorf("header line = %q, want NAME ... VALUE", lines[0])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], "accept-dns") || !strings.HasSuffix(lines[1], "true") {
|
||||
t.Errorf("row 1 = %q", lines[1])
|
||||
}
|
||||
if !strings.HasPrefix(lines[2], "hostname") || !strings.HasSuffix(lines[2], "myhost") {
|
||||
t.Errorf("row 2 = %q", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOutputSetFlags(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tstest.Replace[io.Writer](t, &Stdout, &buf)
|
||||
|
||||
settings := []getSetting{
|
||||
{name: "ssh", value: true},
|
||||
{name: "shields-up", value: false},
|
||||
{name: "hostname", value: "myhost"},
|
||||
{name: "advertise-routes", value: ""},
|
||||
}
|
||||
if err := getOutputSetFlags(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := strings.TrimSpace(buf.String())
|
||||
// true → bare flag; false → --flag=false; empty string → --flag=; other → --flag=value
|
||||
want := "--ssh --shields-up=false --hostname=myhost --advertise-routes="
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// containsSetting reports whether settings contains a setting with the given name.
|
||||
func containsSetting(settings []getSetting, name string) bool {
|
||||
for _, s := range settings {
|
||||
if s.name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user