summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJoe Tsai <joetsai@digital-static.net>2021-08-06 10:54:59 -0700
committerJoe Tsai <joetsai@digital-static.net>2021-08-06 11:08:21 -0700
commit2f0753be863b30c83957d1908ee18e628a7092b7 (patch)
treea5eab48c3d9fc6f611f4031fc10fa739e501c1d9
parent360223fccbbf66c0fa36783ecaf813939b0ed188 (diff)
downloadtailscale-dsnet/admin-cli.tar.xz
tailscale-dsnet/admin-cli.zip
cmd/tailscale: add basic support for admin subcommanddsnet/admin-cli
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 <joetsai@digital-static.net>
-rw-r--r--cmd/tailscale/cli/admin.go155
-rw-r--r--cmd/tailscale/cli/cli.go7
-rw-r--r--cmd/tailscale/depaware.txt1
-rw-r--r--go.mod1
-rw-r--r--go.sum2
5 files changed, 165 insertions, 1 deletions
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 <subcommand> [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 <subcommand> [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 <subcommand> [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 <id>",
+ 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=