mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-27 18:57:35 +00:00
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 <joetsai@digital-static.net>
This commit is contained in:
parent
360223fccb
commit
2f0753be86
155
cmd/tailscale/cli/admin.go
Normal file
155
cmd/tailscale/cli/admin.go
Normal file
@ -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
|
||||||
|
|
||||||
|
}
|
@ -76,6 +76,10 @@ func ActLikeCLI() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runHelp(context.Context, []string) error {
|
||||||
|
return flag.ErrHelp
|
||||||
|
}
|
||||||
|
|
||||||
// Run runs the CLI. The args do not include the binary name.
|
// Run runs the CLI. The args do not include the binary name.
|
||||||
func Run(args []string) error {
|
func Run(args []string) error {
|
||||||
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
|
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
|
||||||
@ -99,6 +103,7 @@ change in the future.
|
|||||||
upCmd,
|
upCmd,
|
||||||
downCmd,
|
downCmd,
|
||||||
logoutCmd,
|
logoutCmd,
|
||||||
|
adminCmd,
|
||||||
netcheckCmd,
|
netcheckCmd,
|
||||||
ipCmd,
|
ipCmd,
|
||||||
statusCmd,
|
statusCmd,
|
||||||
@ -109,7 +114,7 @@ change in the future.
|
|||||||
bugReportCmd,
|
bugReportCmd,
|
||||||
},
|
},
|
||||||
FlagSet: rootfs,
|
FlagSet: rootfs,
|
||||||
Exec: func(context.Context, []string) error { return flag.ErrHelp },
|
Exec: runHelp,
|
||||||
UsageFunc: usageFunc,
|
UsageFunc: usageFunc,
|
||||||
}
|
}
|
||||||
for _, c := range rootCmd.Subcommands {
|
for _, c := range rootCmd.Subcommands {
|
||||||
|
@ -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 from github.com/alexbrainman/sspi/negotiate+
|
||||||
W github.com/alexbrainman/sspi/internal/common 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
|
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/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli
|
||||||
💣 github.com/mitchellh/go-ps 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
|
github.com/peterbourgon/ff/v2 from github.com/peterbourgon/ff/v2/ffcli
|
||||||
|
1
go.mod
1
go.mod
@ -9,6 +9,7 @@ require (
|
|||||||
github.com/coreos/go-iptables v0.6.0
|
github.com/coreos/go-iptables v0.6.0
|
||||||
github.com/creack/pty v1.1.9
|
github.com/creack/pty v1.1.9
|
||||||
github.com/dave/jennifer v1.4.1
|
github.com/dave/jennifer v1.4.1
|
||||||
|
github.com/dsnet/golib/jsonfmt v1.0.0
|
||||||
github.com/frankban/quicktest v1.13.0
|
github.com/frankban/quicktest v1.13.0
|
||||||
github.com/gliderlabs/ssh v0.3.2
|
github.com/gliderlabs/ssh v0.3.2
|
||||||
github.com/go-multierror/multierror v1.0.2
|
github.com/go-multierror/multierror v1.0.2
|
||||||
|
2
go.sum
2
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/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/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/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/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 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||||
|
Loading…
x
Reference in New Issue
Block a user