From 2f0753be863b30c83957d1908ee18e628a7092b7 Mon Sep 17 00:00:00 2001 From: Joe Tsai Date: Fri, 6 Aug 2021 10:54:59 -0700 Subject: [PATCH] cmd/tailscale: add basic support for admin subcommand The admin subcommand is a thin wrapper over the REST API. It (hopefully) makes administration of tailnets easier than vanilla curl. Signed-off-by: Joe Tsai --- cmd/tailscale/cli/admin.go | 155 +++++++++++++++++++++++++++++++++++++ cmd/tailscale/cli/cli.go | 7 +- cmd/tailscale/depaware.txt | 1 + go.mod | 1 + go.sum | 2 + 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 cmd/tailscale/cli/admin.go diff --git a/cmd/tailscale/cli/admin.go b/cmd/tailscale/cli/admin.go new file mode 100644 index 000000000..823b33041 --- /dev/null +++ b/cmd/tailscale/cli/admin.go @@ -0,0 +1,155 @@ +// Copyright (c) 2021 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" + "io" + "net/http" + "os" + "strings" + + "github.com/dsnet/golib/jsonfmt" + "github.com/peterbourgon/ff/v2/ffcli" +) + +const tailscaleAPIURL = "https://api.tailscale.com/api" + +var adminCmd = &ffcli.Command{ + Name: "admin", + ShortUsage: "admin [command flags]", + ShortHelp: "Administrate a tailnet", + LongHelp: strings.TrimSpace(` +The "tailscale admin" command administrates a tailnet through the CLI. +It is a wrapper over the RESTful API served at ` + tailscaleAPIURL + `. +See https://github.com/tailscale/tailscale/blob/main/api.md for more information +about the API itself. + +In order for the "admin" command to call the API, it needs an API key, +which is specified by setting the TAILSCALE_API_KEY environment variable. +Also, to easy usage, the tailnet to administrate can be specified through the +TAILSCALE_NET_NAME environment variable, or specified with the -tailnet flag. + +Visit https://login.tailscale.com/admin/settings/authkeys in order to obtain +an API key. +`), + FlagSet: (func() *flag.FlagSet { + fs := flag.NewFlagSet("status", flag.ExitOnError) + // TODO(dsnet): Can we determine the default tailnet from what this + // device is currently part of? Alternatively, when add specific logic + // to handle auth keys, we can always associate a given key with a + // specific tailnet. + fs.StringVar(&adminArgs.tailnet, "tailnet", os.Getenv("TAILSCALE_NET_NAME"), "which tailnet to administrate") + return fs + })(), + // TODO(dsnet): Handle users, groups, dns. + Subcommands: []*ffcli.Command{{ + Name: "acl", + ShortUsage: "acl [command flags]", + ShortHelp: "Manage the ACL for a tailnet", + // TODO(dsnet): Handle preview. + Subcommands: []*ffcli.Command{{ + Name: "get", + ShortUsage: "get", + ShortHelp: "Downloads the HuJSON ACL file to stdout", + Exec: checkAdminKey(runAdminACLGet), + }, { + Name: "set", + ShortUsage: "set", + ShortHelp: "Uploads the HuJSON ACL file from stdin", + Exec: checkAdminKey(runAdminACLSet), + }}, + Exec: runHelp, + }, { + Name: "devices", + ShortUsage: "devices [command flags]", + ShortHelp: "Manage devices in a tailnet", + Subcommands: []*ffcli.Command{{ + Name: "list", + ShortUsage: "list", + ShortHelp: "List all devices in a tailnet", + Exec: checkAdminKey(runAdminDevicesList), + }, { + Name: "get", + ShortUsage: "get ", + ShortHelp: "Get information about a specific device", + Exec: checkAdminKey(runAdminDevicesGet), + }}, + Exec: runHelp, + }}, + Exec: runHelp, +} + +var adminArgs struct { + tailnet string // which tailnet to operate upon +} + +func checkAdminKey(f func(context.Context, string, []string) error) func(context.Context, []string) error { + return func(ctx context.Context, args []string) error { + // TODO(dsnet): We should have a subcommand or flag to manage keys. + // Use of an environment variable is a temporary hack. + key := os.Getenv("TAILSCALE_API_KEY") + if !strings.HasPrefix(key, "tskey-") { + return errors.New("no API key specified") + } + return f(ctx, key, args) + } +} + +func runAdminACLGet(ctx context.Context, key string, args []string) error { + if len(args) > 0 { + return flag.ErrHelp + } + return adminCallAPI(ctx, key, http.MethodGet, "/v2/tailnet/"+adminArgs.tailnet+"/acl", nil, os.Stdout) +} + +func runAdminACLSet(ctx context.Context, key string, args []string) error { + if len(args) > 0 { + return flag.ErrHelp + } + return adminCallAPI(ctx, key, http.MethodPost, "/v2/tailnet/"+adminArgs.tailnet+"/acl", os.Stdin, os.Stdout) +} + +func runAdminDevicesList(ctx context.Context, key string, args []string) error { + if len(args) > 0 { + return flag.ErrHelp + } + return adminCallAPI(ctx, key, http.MethodGet, "/v2/tailnet/"+adminArgs.tailnet+"/devices", nil, os.Stdout) +} + +func runAdminDevicesGet(ctx context.Context, key string, args []string) error { + if len(args) != 1 { + return flag.ErrHelp + } + return adminCallAPI(ctx, key, http.MethodGet, "/v2/device/"+args[0], nil, os.Stdout) +} + +func adminCallAPI(ctx context.Context, key, method, path string, in io.Reader, out io.Writer) error { + req, err := http.NewRequestWithContext(ctx, method, tailscaleAPIURL+path, in) + req.SetBasicAuth(key, "") + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send HTTP request: %w", err) + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to receive HTTP response: %w", err) + } + b, err = jsonfmt.Format(b) + if err != nil { + return fmt.Errorf("failed to format JSON response: %w", err) + } + _, err = out.Write(b) + return err + +} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index ab58eb4a3..d17284d97 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -76,6 +76,10 @@ func ActLikeCLI() bool { return false } +func runHelp(context.Context, []string) error { + return flag.ErrHelp +} + // Run runs the CLI. The args do not include the binary name. func Run(args []string) error { if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") { @@ -99,6 +103,7 @@ change in the future. upCmd, downCmd, logoutCmd, + adminCmd, netcheckCmd, ipCmd, statusCmd, @@ -109,7 +114,7 @@ change in the future. bugReportCmd, }, FlagSet: rootfs, - Exec: func(context.Context, []string) error { return flag.ErrHelp }, + Exec: runHelp, UsageFunc: usageFunc, } for _, c := range rootCmd.Subcommands { diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 5d1a82974..baf5e681f 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -3,6 +3,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate+ W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy + github.com/dsnet/golib/jsonfmt from tailscale.com/cmd/tailscale/cli github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli 💣 github.com/mitchellh/go-ps from tailscale.com/cmd/tailscale/cli github.com/peterbourgon/ff/v2 from github.com/peterbourgon/ff/v2/ffcli diff --git a/go.mod b/go.mod index cdcd59e4e..c7864e29d 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/coreos/go-iptables v0.6.0 github.com/creack/pty v1.1.9 github.com/dave/jennifer v1.4.1 + github.com/dsnet/golib/jsonfmt v1.0.0 github.com/frankban/quicktest v1.13.0 github.com/gliderlabs/ssh v0.3.2 github.com/go-multierror/multierror v1.0.2 diff --git a/go.sum b/go.sum index add86197c..417c0b888 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/denis-tingajkin/go-header v0.3.1 h1:ymEpSiFjeItCy1FOP+x0M2KdCELdEAHUs github.com/denis-tingajkin/go-header v0.3.1/go.mod h1:sq/2IxMhaZX+RRcgHfCRx/m0M5na0fBt4/CRe7Lrji0= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dsnet/golib/jsonfmt v1.0.0 h1:qrfqvbua2pQvj+dt3BcxEwwqy86F7ri2NdLQLm6g2TQ= +github.com/dsnet/golib/jsonfmt v1.0.0/go.mod h1:C0/DCakJBCSVJ3mWBjDVzym2Wf7w5hpvwgHCwI/M7/w= github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=