Merge 6f679d5bc07debf38832dcdd8af990114bff02ff into b3455fa99a5e8d07133d5140017ec7c49f032a07

This commit is contained in:
Simon Law 2025-03-24 21:37:38 -07:00 committed by GitHub
commit f3368667bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 489 additions and 17 deletions

View File

@ -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,

View File

@ -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

202
cmd/tailscale/cli/get.go Normal file
View File

@ -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
}

View File

@ -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())
},
},
}
}

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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() }

View File

@ -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 {