mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
cmd/tailscale/cli: add login and switch subcommands
Updates #713 Signed-off-by: Maisem Ali <maisem@tailscale.com>
This commit is contained in:
parent
ec1e67b1ab
commit
f3519f7b29
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
32
cmd/tailscale/cli/login.go
Normal file
32
cmd/tailscale/cli/login.go
Normal 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
122
cmd/tailscale/cli/switch.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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+
|
||||
|
Loading…
Reference in New Issue
Block a user