diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 06a877874..27e7bd2a2 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -275,6 +275,7 @@ change in the future. upCmd, downCmd, setCmd, + getCmd, loginCmd, logoutCmd, switchCmd, diff --git a/cmd/tailscale/cli/file.go b/cmd/tailscale/cli/file.go index 489c83deb..946e5f2cf 100644 --- a/cmd/tailscale/cli/file.go +++ b/cmd/tailscale/cli/file.go @@ -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 diff --git a/cmd/tailscale/cli/get.go b/cmd/tailscale/cli/get.go new file mode 100644 index 000000000..f9cf3b1c3 --- /dev/null +++ b/cmd/tailscale/cli/get.go @@ -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 +} diff --git a/cmd/tailscale/cli/get_test.go b/cmd/tailscale/cli/get_test.go new file mode 100644 index 000000000..7b9f13ea3 --- /dev/null +++ b/cmd/tailscale/cli/get_test.go @@ -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 +}