diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 00fc24296..0dcc8b463 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -157,6 +157,7 @@ change in the future. Subcommands: []*ffcli.Command{ upCmd, downCmd, + setCmd, logoutCmd, netcheckCmd, ipCmd, @@ -177,7 +178,9 @@ change in the future. UsageFunc: usageFunc, } for _, c := range rootCmd.Subcommands { - c.UsageFunc = usageFunc + if c.UsageFunc == nil { + c.UsageFunc = usageFunc + } } if envknob.UseWIPCode() { rootCmd.Subcommands = append(rootCmd.Subcommands, idTokenCmd) @@ -292,6 +295,56 @@ func strSliceContains(ss []string, s string) bool { return false } +// usageFuncNoDefaultValues is like usageFunc but doesn't print default values. +func usageFuncNoDefaultValues(c *ffcli.Command) string { + var b strings.Builder + + fmt.Fprintf(&b, "USAGE\n") + if c.ShortUsage != "" { + fmt.Fprintf(&b, " %s\n", c.ShortUsage) + } else { + fmt.Fprintf(&b, " %s\n", c.Name) + } + fmt.Fprintf(&b, "\n") + + if c.LongHelp != "" { + fmt.Fprintf(&b, "%s\n\n", c.LongHelp) + } + + if len(c.Subcommands) > 0 { + fmt.Fprintf(&b, "SUBCOMMANDS\n") + tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) + for _, subcommand := range c.Subcommands { + fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp) + } + tw.Flush() + fmt.Fprintf(&b, "\n") + } + + if countFlags(c.FlagSet) > 0 { + fmt.Fprintf(&b, "FLAGS\n") + tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) + c.FlagSet.VisitAll(func(f *flag.Flag) { + var s string + name, usage := flag.UnquoteUsage(f) + s = fmt.Sprintf(" --%s", f.Name) // Two spaces before --; see next two comments. + if len(name) > 0 { + s += " " + name + } + // Four spaces before the tab triggers good alignment + // for both 4- and 8-space tab stops. + s += "\n \t" + s += strings.ReplaceAll(usage, "\n", "\n \t") + + fmt.Fprintln(&b, s) + }) + tw.Flush() + fmt.Fprintf(&b, "\n") + } + + return strings.TrimSpace(b.String()) +} + func usageFunc(c *ffcli.Command) string { var b strings.Builder diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index 1b2371b39..a1eba8bce 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -39,7 +39,7 @@ func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) { fs := newUpFlagSet(goos, &upArgs) fs.VisitAll(func(f *flag.Flag) { mp := new(ipn.MaskedPrefs) - updateMaskedPrefsFromUpFlag(mp, f.Name) + updateMaskedPrefsFromUpOrSetFlag(mp, f.Name) got := mp.Pretty() wantEmpty := preflessFlag(f.Name) isEmpty := got == "MaskedPrefs{}" diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go new file mode 100644 index 000000000..0b289c9aa --- /dev/null +++ b/cmd/tailscale/cli/set.go @@ -0,0 +1,131 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn" + "tailscale.com/safesocket" +) + +var setCmd = &ffcli.Command{ + Name: "set", + ShortUsage: "set [flags]", + ShortHelp: "Change specified preferences", + LongHelp: `"tailscale set" allows changing specific preferences. + +Unlike "tailscale up", this command does not require the complete set of desired settings. + +Only settings explicitly mentioned will be set. There are no default values.`, + FlagSet: setFlagSet, + Exec: runSet, + UsageFunc: usageFuncNoDefaultValues, +} + +type setArgsT struct { + acceptRoutes bool + acceptDNS bool + exitNodeIP string + exitNodeAllowLANAccess bool + shieldsUp bool + runSSH bool + hostname string + advertiseRoutes string + advertiseDefaultRoute bool + opUser string + acceptedRisks string +} + +func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { + setf := newFlagSet("set") + + 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.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") + setf.BoolVar(&setArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy") + setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") + setf.StringVar(&setArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes") + setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") + if safesocket.GOOSUsesPeerCreds(goos) { + setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") + } + registerAcceptRiskFlag(setf, &setArgs.acceptedRisks) + return setf +} + +var ( + setArgs setArgsT + setFlagSet = newSetFlagSet(effectiveGOOS(), &setArgs) +) + +func runSet(ctx context.Context, args []string) (retErr error) { + if len(args) > 0 { + fatalf("too many non-flag arguments: %q", args) + } + + st, err := localClient.Status(ctx) + if err != nil { + return err + } + + routes, err := calcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute) + if err != nil { + return err + } + + maskedPrefs := &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + RouteAll: setArgs.acceptRoutes, + CorpDNS: setArgs.acceptDNS, + ExitNodeAllowLANAccess: setArgs.exitNodeAllowLANAccess, + ShieldsUp: setArgs.shieldsUp, + RunSSH: setArgs.runSSH, + Hostname: setArgs.hostname, + AdvertiseRoutes: routes, + OperatorUser: setArgs.opUser, + }, + } + + if setArgs.exitNodeIP != "" { + if err := maskedPrefs.Prefs.SetExitNodeIP(setArgs.exitNodeIP, st); err != nil { + var e ipn.ExitNodeLocalIPError + if errors.As(err, &e) { + return fmt.Errorf("%w; did you mean --advertise-exit-node?", err) + } + return err + } + } + + setFlagSet.Visit(func(f *flag.Flag) { + updateMaskedPrefsFromUpOrSetFlag(maskedPrefs, f.Name) + }) + + if maskedPrefs.IsEmpty() { + println("no flags specified") + return nil + } + + if maskedPrefs.RunSSHSet { + curPrefs, err := localClient.GetPrefs(ctx) + if err != nil { + return err + } + + wantSSH, haveSSH := maskedPrefs.RunSSH, curPrefs.RunSSH + if err := presentSSHToggleRisk(wantSSH, haveSSH, setArgs.acceptedRisks); err != nil { + return err + } + } + + _, err = localClient.EditPrefs(ctx, maskedPrefs) + return err +} diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 071ef79ea..690f6b8e4 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -380,15 +380,8 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus // Do this after validations to avoid the 5s delay if we're going to error // out anyway. wantSSH, haveSSH := env.upArgs.runSSH, curPrefs.RunSSH - if wantSSH != haveSSH && isSSHOverTailscale() { - if wantSSH { - err = presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`, env.upArgs.acceptedRisks) - } else { - err = presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`, env.upArgs.acceptedRisks) - } - if err != nil { - return false, nil, err - } + if err := presentSSHToggleRisk(wantSSH, haveSSH, env.upArgs.acceptedRisks); err != nil { + return false, nil, err } tagsChanged := !reflect.DeepEqual(curPrefs.AdvertiseTags, prefs.AdvertiseTags) @@ -413,13 +406,23 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus visitFlags = env.flagSet.VisitAll } visitFlags(func(f *flag.Flag) { - updateMaskedPrefsFromUpFlag(justEditMP, f.Name) + updateMaskedPrefsFromUpOrSetFlag(justEditMP, f.Name) }) } return simpleUp, justEditMP, nil } +func presentSSHToggleRisk(wantSSH, haveSSH bool, acceptedRisks string) error { + if !isSSHOverTailscale() || wantSSH == haveSSH { + return nil + } + if wantSSH { + return presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`, acceptedRisks) + } + return presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`, acceptedRisks) +} + func runUp(ctx context.Context, args []string) (retErr error) { var egg bool if len(args) > 0 { @@ -773,7 +776,7 @@ func preflessFlag(flagName string) bool { return false } -func updateMaskedPrefsFromUpFlag(mp *ipn.MaskedPrefs, flagName string) { +func updateMaskedPrefsFromUpOrSetFlag(mp *ipn.MaskedPrefs, flagName string) { if preflessFlag(flagName) { return } diff --git a/ipn/prefs.go b/ipn/prefs.go index 37fb712ce..6859753ec 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -244,6 +244,21 @@ func (p *Prefs) ApplyEdits(m *MaskedPrefs) { } } +// IsEmpty reports whether there are no masks set or if m is nil. +func (m *MaskedPrefs) IsEmpty() bool { + if m == nil { + return true + } + mv := reflect.ValueOf(m).Elem() + fields := mv.NumField() + for i := 1; i < fields; i++ { + if mv.Field(i).Bool() { + return false + } + } + return true +} + func (m *MaskedPrefs) Pretty() string { if m == nil { return "MaskedPrefs{}" diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 92481f02b..f362236d3 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -826,3 +826,48 @@ func TestControlURLOrDefault(t *testing.T) { t.Errorf("got %q; want %q", got, want) } } + +func TestMaskedPrefsIsEmpty(t *testing.T) { + tests := []struct { + name string + mp *MaskedPrefs + wantEmpty bool + }{ + { + name: "nil", + wantEmpty: true, + }, + { + name: "empty", + wantEmpty: true, + mp: &MaskedPrefs{}, + }, + { + name: "no-masks", + wantEmpty: true, + mp: &MaskedPrefs{ + Prefs: Prefs{ + WantRunning: true, + }, + }, + }, + { + name: "with-mask", + wantEmpty: false, + mp: &MaskedPrefs{ + Prefs: Prefs{ + WantRunning: true, + }, + WantRunningSet: true, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.mp.IsEmpty() + if got != tc.wantEmpty { + t.Fatalf("mp.IsEmpty = %t; want %t", got, tc.wantEmpty) + } + }) + } +}