diff options
| author | Brad Fitzpatrick <bradfitz@tailscale.com> | 2022-03-19 21:23:42 -0700 |
|---|---|---|
| committer | Brad Fitzpatrick <bradfitz@tailscale.com> | 2022-03-20 13:01:18 -0700 |
| commit | ccdc41988c4c3113e60c2aa27aa393d3b15e4f2e (patch) | |
| tree | 8840e9ea61032fd3bff3a038a1f636d107a2cc9b /cmd | |
| parent | bfb4a4d9e9b48acc3e9de8a3b2b67f1f31143b57 (diff) | |
| download | tailscale-bradfitz/cli_admin.tar.xz tailscale-bradfitz/cli_admin.zip | |
cmd/tailscale, ipn/ipn{local,server}: add start of CLI admin API + over Noisebradfitz/cli_admin
Change-Id: I2936f6baf50e7eeac7190051adba493d4245b3ea
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/tailscale/cli/admin.go | 231 | ||||
| -rw-r--r-- | cmd/tailscale/cli/cli.go | 1 | ||||
| -rw-r--r-- | cmd/tailscale/cli/debug.go | 2 |
3 files changed, 233 insertions, 1 deletions
diff --git a/cmd/tailscale/cli/admin.go b/cmd/tailscale/cli/admin.go new file mode 100644 index 000000000..77c6e524a --- /dev/null +++ b/cmd/tailscale/cli/admin.go @@ -0,0 +1,231 @@ +// 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. + +// Admin commands. + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/client/tailscale" +) + +var adminCmd = &ffcli.Command{ + Name: "admin", + Exec: runAdmin, + LongHelp: `"tailscale admin" contains admin commands to manage a Tailscale network.`, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("admin") + fs.StringVar(&adminArgs.apiBase, "api-server", "https://api.tailscale.com", "which Tailscale server instance to use. Ignored when --token-file is empty.") + fs.StringVar(&adminArgs.tokenFile, "token-file", "", "if non-empty, filename containing API token to use. If empty, authentication is done via the active Tailscale control plane connection.") + fs.StringVar(&adminArgs.tailnet, "tailnet", "", "Tailnet to query or edit. Required if token-file is used. Must be blank if token-file is blank, in which case the tailnet used is the same as the active tailnet.") + return fs + })(), + Subcommands: []*ffcli.Command{ + newTailnetACLGetCmd(), + newTailnetDeviceListCmd(), + newTailnetKeyListCmd(), + }, +} + +var adminArgs struct { + tokenFile string + tailnet string + apiBase string +} + +func runAdmin(ctx context.Context, args []string) error { + if len(args) > 0 { + return errors.New("unknown command; see 'tailscale admin --help'") + } + return errors.New("see 'tailscale admin --help'") +} + +type adminClient struct { + apiBase string // e.g. "https://api.tailscale.com" + token string // non-empty if using token-based auth + hc *http.Client + tailnet string // always non-empty +} + +func getAdminHTTPClient() (*adminClient, error) { + tokenFile := adminArgs.tokenFile + tailnet := adminArgs.tailnet + apiBase := adminArgs.apiBase + if (tokenFile != "") != (tailnet != "") { + return nil, errors.New("--token-file and --tailnet must both be blank or both be specified") + } + if tailnet == "" { + st, err := tailscale.StatusWithoutPeers(context.Background()) + if err != nil { + return nil, err + } + if st.BackendState != "Running" { + return nil, fmt.Errorf("Tailscale must be running; currently in state %q", st.BackendState) + } + if st.CurrentTailnet == nil { + return nil, fmt.Errorf("no CurrentTailnet in status") + } + tailnet = st.CurrentTailnet.Name + // TODO(bradfitz): put apiBase in *ipnstate.TailnetStatus? update apiBase here? + } + ac := &adminClient{ + tailnet: tailnet, + apiBase: apiBase, + } + + if tokenFile != "" { + v, err := os.ReadFile(tokenFile) + if err != nil { + return nil, err + } + token := strings.TrimSpace(string(v)) + if token == "" || strings.Contains(token, "\n") { + return nil, fmt.Errorf("expect exactly 1 line in API token file %v", tokenFile) + } + ac.token = token + ac.hc = http.DefaultClient + } else { + // Otherwise, proxy via the local tailscaled and use its identity. + ac.hc = &http.Client{Transport: apiViaTailscaledTransport{}} + ac.apiBase = "http://local-tailscaled.sock" + } + return ac, nil +} + +func newTailnetDeviceListCmd() *ffcli.Command { + var fields string + const sub = "tailnet-device-list" + fs := newFlagSet(sub) + fs.StringVar(&fields, "fields", "default", "comma-separated fields to include in response or 'default', 'all'") + return &ffcli.Command{ + Name: sub, + ShortHelp: "list devices", + FlagSet: fs, + Exec: func(ctx context.Context, args []string) error { + ac, err := getAdminHTTPClient() + if err != nil { + return err + } + q := url.Values{"fields": []string{fields}} + return writeResJSON(ac.hc.Get(ac.apiBase + "/api/v2/tailnet/" + ac.tailnet + "/devices?" + q.Encode())) + }, + } +} + +func newTailnetKeyListCmd() *ffcli.Command { + const sub = "tailnet-key-list" + return &ffcli.Command{ + Name: sub, + ShortHelp: "list keys or specific key (with keyID as argument)", + Exec: func(ctx context.Context, args []string) error { + var suf string + if len(args) == 1 { + suf = "/" + args[0] + } else if len(args) > 1 { + return errors.New("too many arguments") + } + ac, err := getAdminHTTPClient() + if err != nil { + return err + } + return writeResJSON(ac.hc.Get(ac.apiBase + "/api/v2/tailnet/" + ac.tailnet + "/keys" + suf)) + }, + } +} + +func newTailnetACLGetCmd() *ffcli.Command { + var asJSON bool // true is JSON, false is HuJSON + const sub = "tailnet-acl-get" + fs := newFlagSet(sub) + fs.BoolVar(&asJSON, "json", false, "if true, return ACL is JSON format. The default of false means to use the original HuJSON JSON superset form that allows comments and trailing commas.") + return &ffcli.Command{ + Name: sub, + ShortHelp: "list Tailnet ACL/config policy", + FlagSet: fs, + Exec: func(ctx context.Context, args []string) error { + ac, err := getAdminHTTPClient() + if err != nil { + return err + } + req, err := http.NewRequest("GET", ac.apiBase+"/api/v2/tailnet/"+ac.tailnet+"/acl", nil) + if err != nil { + return err + } + if asJSON { + req.Header.Set("Accept", "application/json") + } + res, err := ac.hc.Do(req) + if err != nil { + return err + } + if asJSON { + return writeResJSON(res, err) + } + defer res.Body.Close() + if res.StatusCode != 200 { + body, _ := io.ReadAll(res.Body) + return fmt.Errorf("%v: %s", res.Status, body) + } + all, err := io.ReadAll(res.Body) + if err != nil { + return err + } + var buf bytes.Buffer + buf.Write(all) + ensureTrailingNewline(&buf) + os.Stdout.Write(buf.Bytes()) + return nil + }, + } +} + +// apiViaTailscaledTransport is an http.RoundTripper that makes +// Tailscale API HTTP requests via the localapi to tailscaled, +// which then forwards them on over Noise. +type apiViaTailscaledTransport struct{} + +func (apiViaTailscaledTransport) RoundTrip(r *http.Request) (*http.Response, error) { + return tailscale.DoLocalRequest(r) +} + +func ensureTrailingNewline(buf *bytes.Buffer) { + if buf.Len() > 0 && buf.Bytes()[buf.Len()-1] != '\n' { + buf.WriteByte('\n') + } +} + +func writeResJSON(res *http.Response, err error) error { + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != 200 { + body, _ := io.ReadAll(res.Body) + return fmt.Errorf("%v: %s", res.Status, body) + } + all, err := io.ReadAll(res.Body) + if err != nil { + return err + } + var buf bytes.Buffer + if err := json.Indent(&buf, all, "", "\t"); err != nil { + return err + } + ensureTrailingNewline(&buf) + os.Stdout.Write(buf.Bytes()) + return nil +} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 93ea0c60e..7fc44a3d9 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -175,6 +175,7 @@ change in the future. fileCmd, bugReportCmd, certCmd, + adminCmd, }, FlagSet: rootfs, Exec: func(context.Context, []string) error { return flag.ErrHelp }, diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index 0d11e165a..44fa97ef7 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -192,7 +192,7 @@ func runDebug(ctx context.Context, args []string) error { // to subcommands. return nil } - return errors.New("see 'tailscale debug --help") + return errors.New("see 'tailscale debug --help'") } func runLocalCreds(ctx context.Context, args []string) error { |
