diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 2e09750a8..ac5dc80b6 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -903,3 +903,43 @@ func (r jsonReader) Read(p []byte) (n int, err error) { } return r.b.Read(p) } + +// ProfileStatus returns the current profile and the list of all profiles. +func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProfile, all []ipn.LoginProfile, err error) { + body, err := lc.send(ctx, "GET", "/localapi/v0/profiles/current", 200, nil) + if err != nil { + return + } + current, err = decodeJSON[ipn.LoginProfile](body) + if err != nil { + return + } + body, err = lc.send(ctx, "GET", "/localapi/v0/profiles/", 200, nil) + if err != nil { + return + } + all, err = decodeJSON[[]ipn.LoginProfile](body) + return current, all, err +} + +// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new +// profile is not assigned an ID until it is persisted after a successful login. +// In order to login to the new profile, the user must call LoginInteractive. +func (lc *LocalClient) SwitchToEmptyProfile(ctx context.Context) error { + _, err := lc.send(ctx, "PUT", "/localapi/v0/profiles/", http.StatusCreated, nil) + return err +} + +// SwitchProfile switches to the given profile. +func (lc *LocalClient) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error { + _, err := lc.send(ctx, "POST", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), 204, nil) + return err +} + +// DeleteProfile removes the profile with the given ID. +// If the profile is the current profile, an empty profile +// will be selected as if SwitchToEmptyProfile was called. +func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID) error { + _, err := lc.send(ctx, "DELETE", "/localapi/v0/profiles"+url.PathEscape(string(profile)), http.StatusNoContent, nil) + return err +} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 93c198b5e..866036df0 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -24,6 +24,7 @@ "text/tabwriter" "github.com/peterbourgon/ff/v3/ffcli" + "golang.org/x/exp/slices" "tailscale.com/client/tailscale" "tailscale.com/envknob" "tailscale.com/ipn" @@ -35,6 +36,10 @@ var Stderr io.Writer = os.Stderr var Stdout io.Writer = os.Stdout +func errf(format string, a ...any) { + fmt.Fprintf(Stderr, format, a...) +} + func printf(format string, a ...any) { fmt.Fprintf(Stdout, format, a...) } @@ -183,13 +188,20 @@ func Run(args []string) (err error) { } } if envknob.UseWIPCode() { - rootCmd.Subcommands = append(rootCmd.Subcommands, idTokenCmd) - rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd) + rootCmd.Subcommands = append(rootCmd.Subcommands, + idTokenCmd, + serveCmd, + ) } // Don't advertise the debug command, but it exists. - if strSliceContains(args, "debug") { + switch { + case slices.Contains(args, "debug"): rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd) + case slices.Contains(args, "login"): + rootCmd.Subcommands = append(rootCmd.Subcommands, loginCmd) + case slices.Contains(args, "switch"): + rootCmd.Subcommands = append(rootCmd.Subcommands, switchCmd) } if runtime.GOOS == "linux" && distro.Get() == distro.Synology { rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd) @@ -287,15 +299,6 @@ func pump(ctx context.Context, bc *ipn.BackendClient, conn net.Conn) error { return ctx.Err() } -func strSliceContains(ss []string, s string) bool { - for _, v := range ss { - if v == s { - return true - } - } - return false -} - // usageFuncNoDefaultValues is like usageFunc but doesn't print default values. func usageFuncNoDefaultValues(c *ffcli.Command) string { return usageFuncOpt(c, false) diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index c5eab17b9..682e5b86e 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -38,7 +38,7 @@ func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) { for _, goos := range geese { var upArgs upArgsT - fs := newUpFlagSet(goos, &upArgs) + fs := newUpFlagSet(goos, &upArgs, "up") fs.VisitAll(func(f *flag.Flag) { mp := new(ipn.MaskedPrefs) updateMaskedPrefsFromUpOrSetFlag(mp, f.Name) @@ -488,7 +488,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) { goos = tt.goos } var upArgs upArgsT - flagSet := newUpFlagSet(goos, &upArgs) + flagSet := newUpFlagSet(goos, &upArgs, "up") flags := CleanUpArgs(tt.flags) flagSet.Parse(flags) newPrefs, err := prefsFromUpArgs(upArgs, t.Logf, new(ipnstate.Status), goos) @@ -515,7 +515,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) { } func upArgsFromOSArgs(goos string, flagArgs ...string) (args upArgsT) { - fs := newUpFlagSet(goos, &args) + fs := newUpFlagSet(goos, &args, "up") fs.Parse(flagArgs) // populates args return } @@ -775,7 +775,7 @@ func TestPrefFlagMapping(t *testing.T) { func TestFlagAppliesToOS(t *testing.T) { for _, goos := range geese { var upArgs upArgsT - fs := newUpFlagSet(goos, &upArgs) + fs := newUpFlagSet(goos, &upArgs, "up") fs.VisitAll(func(f *flag.Flag) { if !flagAppliesToOS(f.Name, goos) { t.Errorf("flagAppliesToOS(%q, %q) = false but found in %s set", f.Name, goos, goos) @@ -1070,7 +1070,7 @@ func TestUpdatePrefs(t *testing.T) { if tt.env.goos == "" { tt.env.goos = "linux" } - tt.env.flagSet = newUpFlagSet(tt.env.goos, &tt.env.upArgs) + tt.env.flagSet = newUpFlagSet(tt.env.goos, &tt.env.upArgs, "up") flags := CleanUpArgs(tt.flags) if err := tt.env.flagSet.Parse(flags); err != nil { t.Fatal(err) diff --git a/cmd/tailscale/cli/login.go b/cmd/tailscale/cli/login.go new file mode 100644 index 000000000..2ff2c161b --- /dev/null +++ b/cmd/tailscale/cli/login.go @@ -0,0 +1,32 @@ +// 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" + "flag" + + "github.com/peterbourgon/ff/v3/ffcli" +) + +var loginArgs upArgsT + +var loginCmd = &ffcli.Command{ + Name: "login", + ShortUsage: "[ALPHA] login [flags]", + ShortHelp: "Log in to a Tailscale account", + LongHelp: `"tailscale login" logs this machine in to your Tailscale network. +This command is currently in alpha and may change in the future.`, + UsageFunc: usageFunc, + FlagSet: func() *flag.FlagSet { + return newUpFlagSet(effectiveGOOS(), &loginArgs, "login") + }(), + Exec: func(ctx context.Context, args []string) error { + if err := localClient.SwitchToEmptyProfile(ctx); err != nil { + return err + } + return runUp(ctx, args, loginArgs) + }, +} diff --git a/cmd/tailscale/cli/switch.go b/cmd/tailscale/cli/switch.go new file mode 100644 index 000000000..fc17adb13 --- /dev/null +++ b/cmd/tailscale/cli/switch.go @@ -0,0 +1,122 @@ +// 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" + "flag" + "fmt" + "os" + "time" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn" +) + +var switchCmd = &ffcli.Command{ + Name: "switch", + ShortHelp: "Switches to a different Tailscale profile", + FlagSet: func() *flag.FlagSet { + fs := flag.NewFlagSet("switch", flag.ExitOnError) + fs.BoolVar(&switchArgs.list, "list", false, "list available profiles") + return fs + }(), + Exec: switchProfile, + UsageFunc: func(*ffcli.Command) string { + return `USAGE + [ALPHA] switch + [ALPHA] switch --list + +"tailscale switch" switches between logged in profiles. +This command is currently in alpha and may change in the future.` + }, +} + +var switchArgs struct { + list bool +} + +func listProfiles(ctx context.Context) error { + curP, all, err := localClient.ProfileStatus(ctx) + if err != nil { + return err + } + for _, prof := range all { + if prof.ID == curP.ID { + fmt.Printf("%s *\n", prof.Name) + } else { + fmt.Println(prof.Name) + } + } + return nil +} + +func switchProfile(ctx context.Context, args []string) error { + if switchArgs.list { + return listProfiles(ctx) + } + if len(args) != 1 { + outln("usage: tailscale profile switch NAME") + os.Exit(1) + } + cp, all, err := localClient.ProfileStatus(ctx) + if err != nil { + errf("Failed to switch to profile: %v\n", err) + os.Exit(1) + } + var profID ipn.ProfileID + for _, p := range all { + if p.Name == args[0] { + profID = p.ID + break + } + } + if profID == "" { + errf("No profile named %q\n", args[0]) + os.Exit(1) + } + if profID == cp.ID { + printf("Already on profile %q\n", args[0]) + os.Exit(0) + } + if err := localClient.SwitchProfile(ctx, profID); err != nil { + errf("Failed to switch to profile: %v\n", err) + os.Exit(1) + } + printf("Switching to profile %q\n", args[0]) + for { + select { + case <-ctx.Done(): + errf("Timed out waiting for switch to complete.") + os.Exit(1) + default: + } + st, err := localClient.StatusWithoutPeers(ctx) + if err != nil { + errf("Error getting status: %v", err) + os.Exit(1) + } + switch st.BackendState { + case "NoState", "Starting": + // TODO(maisem): maybe add a way to subscribe to state changes to + // LocalClient. + time.Sleep(100 * time.Millisecond) + continue + case "NeedsLogin": + outln("Logged out.") + outln("To log in, run:") + outln(" tailscale up") + return nil + case "Running": + outln("Success.") + return nil + } + // For all other states, use the default error message. + if msg, ok := isRunningOrStarting(st); !ok { + outln(msg) + os.Exit(1) + } + } +} diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 1b2076a2c..f8d2ec10a 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -58,7 +58,9 @@ settings.) `), FlagSet: upFlagSet, - Exec: runUp, + Exec: func(ctx context.Context, args []string) error { + return runUp(ctx, args, upArgsGlobal) + }, } func effectiveGOOS() string { @@ -81,17 +83,19 @@ func acceptRouteDefault(goos string) bool { } } -var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgs) +var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up") func inTest() bool { return flag.Lookup("test.v") != nil } -func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet { - upf := newFlagSet("up") +// newUpFlagSet returns a new flag set for the "up" and "login" commands. +func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet { + if cmd != "up" && cmd != "login" { + panic("cmd must be up or login") + } + upf := newFlagSet(cmd) upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs") - upf.BoolVar(&upArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") - upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication") - upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values") + upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`) upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server") upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes") @@ -102,7 +106,6 @@ func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet { upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy") upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")") - upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`) upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") upf.StringVar(&upArgs.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") upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") @@ -117,7 +120,14 @@ func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet { upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)") } upf.DurationVar(&upArgs.timeout, "timeout", 0, "maximum amount of time to wait for tailscaled to enter a Running state; default (0s) blocks forever") - registerAcceptRiskFlag(upf, &upArgs.acceptedRisks) + + if cmd == "up" { + // Some flags are only for "up", not "login". + upf.BoolVar(&upArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") + upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values") + upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication") + registerAcceptRiskFlag(upf, &upArgs.acceptedRisks) + } return upf } @@ -167,7 +177,7 @@ func (a upArgsT) getAuthKey() (string, error) { return v, nil } -var upArgs upArgsT +var upArgsGlobal upArgsT // Fields output when `tailscale up --json` is used. Two JSON blocks will be output. // @@ -427,7 +437,7 @@ func presentSSHToggleRisk(wantSSH, haveSSH bool, acceptedRisks string) error { 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, upArgs upArgsT) (retErr error) { var egg bool if len(args) > 0 { egg = fmt.Sprint(args) == "[up down down left right left right b a]" @@ -936,7 +946,7 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) { return env.curExitNodeIP.String() } - fs := newUpFlagSet(env.goos, new(upArgsT) /* dummy */) + fs := newUpFlagSet(env.goos, new(upArgsT) /* dummy */, "up") fs.VisitAll(func(f *flag.Flag) { if preflessFlag(f.Name) { return diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index a66ce3297..3fa91bafc 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -125,7 +125,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12 golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ golang.org/x/exp/constraints from golang.org/x/exp/slices - golang.org/x/exp/slices from tailscale.com/net/tsaddr + golang.org/x/exp/slices from tailscale.com/net/tsaddr+ golang.org/x/net/bpf from github.com/mdlayher/netlink+ golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/http/httpguts from net/http+