cmd/tailscale/cli: add login and switch subcommands

Updates #713

Signed-off-by: Maisem Ali <maisem@tailscale.com>
This commit is contained in:
Maisem Ali 2022-11-12 16:14:22 +05:00 committed by Maisem Ali
parent ec1e67b1ab
commit f3519f7b29
7 changed files with 237 additions and 30 deletions

View File

@ -903,3 +903,43 @@ func (r jsonReader) Read(p []byte) (n int, err error) {
} }
return r.b.Read(p) 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
}

View File

@ -24,6 +24,7 @@
"text/tabwriter" "text/tabwriter"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/exp/slices"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn" "tailscale.com/ipn"
@ -35,6 +36,10 @@
var Stderr io.Writer = os.Stderr var Stderr io.Writer = os.Stderr
var Stdout io.Writer = os.Stdout var Stdout io.Writer = os.Stdout
func errf(format string, a ...any) {
fmt.Fprintf(Stderr, format, a...)
}
func printf(format string, a ...any) { func printf(format string, a ...any) {
fmt.Fprintf(Stdout, format, a...) fmt.Fprintf(Stdout, format, a...)
} }
@ -183,13 +188,20 @@ func Run(args []string) (err error) {
} }
} }
if envknob.UseWIPCode() { if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands, idTokenCmd) rootCmd.Subcommands = append(rootCmd.Subcommands,
rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd) idTokenCmd,
serveCmd,
)
} }
// Don't advertise the debug command, but it exists. // 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) 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 { if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd) 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() 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. // usageFuncNoDefaultValues is like usageFunc but doesn't print default values.
func usageFuncNoDefaultValues(c *ffcli.Command) string { func usageFuncNoDefaultValues(c *ffcli.Command) string {
return usageFuncOpt(c, false) return usageFuncOpt(c, false)

View File

@ -38,7 +38,7 @@
func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) { func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) {
for _, goos := range geese { for _, goos := range geese {
var upArgs upArgsT var upArgs upArgsT
fs := newUpFlagSet(goos, &upArgs) fs := newUpFlagSet(goos, &upArgs, "up")
fs.VisitAll(func(f *flag.Flag) { fs.VisitAll(func(f *flag.Flag) {
mp := new(ipn.MaskedPrefs) mp := new(ipn.MaskedPrefs)
updateMaskedPrefsFromUpOrSetFlag(mp, f.Name) updateMaskedPrefsFromUpOrSetFlag(mp, f.Name)
@ -488,7 +488,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
goos = tt.goos goos = tt.goos
} }
var upArgs upArgsT var upArgs upArgsT
flagSet := newUpFlagSet(goos, &upArgs) flagSet := newUpFlagSet(goos, &upArgs, "up")
flags := CleanUpArgs(tt.flags) flags := CleanUpArgs(tt.flags)
flagSet.Parse(flags) flagSet.Parse(flags)
newPrefs, err := prefsFromUpArgs(upArgs, t.Logf, new(ipnstate.Status), goos) 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) { func upArgsFromOSArgs(goos string, flagArgs ...string) (args upArgsT) {
fs := newUpFlagSet(goos, &args) fs := newUpFlagSet(goos, &args, "up")
fs.Parse(flagArgs) // populates args fs.Parse(flagArgs) // populates args
return return
} }
@ -775,7 +775,7 @@ func TestPrefFlagMapping(t *testing.T) {
func TestFlagAppliesToOS(t *testing.T) { func TestFlagAppliesToOS(t *testing.T) {
for _, goos := range geese { for _, goos := range geese {
var upArgs upArgsT var upArgs upArgsT
fs := newUpFlagSet(goos, &upArgs) fs := newUpFlagSet(goos, &upArgs, "up")
fs.VisitAll(func(f *flag.Flag) { fs.VisitAll(func(f *flag.Flag) {
if !flagAppliesToOS(f.Name, goos) { if !flagAppliesToOS(f.Name, goos) {
t.Errorf("flagAppliesToOS(%q, %q) = false but found in %s set", f.Name, goos, 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 == "" { if tt.env.goos == "" {
tt.env.goos = "linux" 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) flags := CleanUpArgs(tt.flags)
if err := tt.env.flagSet.Parse(flags); err != nil { if err := tt.env.flagSet.Parse(flags); err != nil {
t.Fatal(err) t.Fatal(err)

View File

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

122
cmd/tailscale/cli/switch.go Normal file
View File

@ -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 <name>
[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)
}
}
}

View File

@ -58,7 +58,9 @@
settings.) settings.)
`), `),
FlagSet: upFlagSet, FlagSet: upFlagSet,
Exec: runUp, Exec: func(ctx context.Context, args []string) error {
return runUp(ctx, args, upArgsGlobal)
},
} }
func effectiveGOOS() string { 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 inTest() bool { return flag.Lookup("test.v") != nil }
func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet { // newUpFlagSet returns a new flag set for the "up" and "login" commands.
upf := newFlagSet("up") 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.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.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.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication")
upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values")
upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server") 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") 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.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.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.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.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.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") 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.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") 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 return upf
} }
@ -167,7 +177,7 @@ func (a upArgsT) getAuthKey() (string, error) {
return v, nil return v, nil
} }
var upArgs upArgsT var upArgsGlobal upArgsT
// Fields output when `tailscale up --json` is used. Two JSON blocks will be output. // 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) 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 var egg bool
if len(args) > 0 { if len(args) > 0 {
egg = fmt.Sprint(args) == "[up down down left right left right b a]" 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() 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) { fs.VisitAll(func(f *flag.Flag) {
if preflessFlag(f.Name) { if preflessFlag(f.Name) {
return return

View File

@ -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/pbkdf2 from software.sslmate.com/src/go-pkcs12
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ 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/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/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http+ golang.org/x/net/http/httpguts from net/http+