cmd/tailscale/cli: add beginnings of tailscale set

Signed-off-by: Maisem Ali <maisem@tailscale.com>
This commit is contained in:
Maisem Ali 2022-10-25 18:02:58 -07:00 committed by Brad Fitzpatrick
parent a471681e28
commit 19b5586573
6 changed files with 260 additions and 13 deletions

View File

@ -157,6 +157,7 @@ func Run(args []string) (err error) {
Subcommands: []*ffcli.Command{ Subcommands: []*ffcli.Command{
upCmd, upCmd,
downCmd, downCmd,
setCmd,
logoutCmd, logoutCmd,
netcheckCmd, netcheckCmd,
ipCmd, ipCmd,
@ -177,7 +178,9 @@ func Run(args []string) (err error) {
UsageFunc: usageFunc, UsageFunc: usageFunc,
} }
for _, c := range rootCmd.Subcommands { for _, c := range rootCmd.Subcommands {
c.UsageFunc = usageFunc if c.UsageFunc == nil {
c.UsageFunc = usageFunc
}
} }
if envknob.UseWIPCode() { if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands, idTokenCmd) rootCmd.Subcommands = append(rootCmd.Subcommands, idTokenCmd)
@ -292,6 +295,56 @@ func strSliceContains(ss []string, s string) bool {
return false 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 { func usageFunc(c *ffcli.Command) string {
var b strings.Builder var b strings.Builder

View File

@ -39,7 +39,7 @@ func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) {
fs := newUpFlagSet(goos, &upArgs) fs := newUpFlagSet(goos, &upArgs)
fs.VisitAll(func(f *flag.Flag) { fs.VisitAll(func(f *flag.Flag) {
mp := new(ipn.MaskedPrefs) mp := new(ipn.MaskedPrefs)
updateMaskedPrefsFromUpFlag(mp, f.Name) updateMaskedPrefsFromUpOrSetFlag(mp, f.Name)
got := mp.Pretty() got := mp.Pretty()
wantEmpty := preflessFlag(f.Name) wantEmpty := preflessFlag(f.Name)
isEmpty := got == "MaskedPrefs{}" isEmpty := got == "MaskedPrefs{}"

131
cmd/tailscale/cli/set.go Normal file
View File

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

View File

@ -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 // Do this after validations to avoid the 5s delay if we're going to error
// out anyway. // out anyway.
wantSSH, haveSSH := env.upArgs.runSSH, curPrefs.RunSSH wantSSH, haveSSH := env.upArgs.runSSH, curPrefs.RunSSH
if wantSSH != haveSSH && isSSHOverTailscale() { if err := presentSSHToggleRisk(wantSSH, haveSSH, env.upArgs.acceptedRisks); err != nil {
if wantSSH { return false, nil, err
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
}
} }
tagsChanged := !reflect.DeepEqual(curPrefs.AdvertiseTags, prefs.AdvertiseTags) 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 = env.flagSet.VisitAll
} }
visitFlags(func(f *flag.Flag) { visitFlags(func(f *flag.Flag) {
updateMaskedPrefsFromUpFlag(justEditMP, f.Name) updateMaskedPrefsFromUpOrSetFlag(justEditMP, f.Name)
}) })
} }
return simpleUp, justEditMP, nil 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) { func runUp(ctx context.Context, args []string) (retErr error) {
var egg bool var egg bool
if len(args) > 0 { if len(args) > 0 {
@ -773,7 +776,7 @@ func preflessFlag(flagName string) bool {
return false return false
} }
func updateMaskedPrefsFromUpFlag(mp *ipn.MaskedPrefs, flagName string) { func updateMaskedPrefsFromUpOrSetFlag(mp *ipn.MaskedPrefs, flagName string) {
if preflessFlag(flagName) { if preflessFlag(flagName) {
return return
} }

View File

@ -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 { func (m *MaskedPrefs) Pretty() string {
if m == nil { if m == nil {
return "MaskedPrefs{<nil>}" return "MaskedPrefs{<nil>}"

View File

@ -826,3 +826,48 @@ func TestControlURLOrDefault(t *testing.T) {
t.Errorf("got %q; want %q", got, want) 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)
}
})
}
}