summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrad Fitzpatrick <bradfitz@tailscale.com>2022-03-19 21:23:42 -0700
committerBrad Fitzpatrick <bradfitz@tailscale.com>2022-03-20 13:01:18 -0700
commitccdc41988c4c3113e60c2aa27aa393d3b15e4f2e (patch)
tree8840e9ea61032fd3bff3a038a1f636d107a2cc9b
parentbfb4a4d9e9b48acc3e9de8a3b2b67f1f31143b57 (diff)
downloadtailscale-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>
-rw-r--r--cmd/tailscale/cli/admin.go231
-rw-r--r--cmd/tailscale/cli/cli.go1
-rw-r--r--cmd/tailscale/cli/debug.go2
-rw-r--r--ipn/ipnlocal/local.go36
-rw-r--r--ipn/ipnserver/server.go4
5 files changed, 273 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 {
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index bcad6c64a..d235df451 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -12,6 +12,7 @@ import (
"io"
"net"
"net/http"
+ "net/url"
"os"
"os/exec"
"os/user"
@@ -3253,3 +3254,38 @@ func (b *LocalBackend) DoNoiseRequest(req *http.Request) (*http.Response, error)
}
return cc.DoNoiseRequest(req)
}
+
+// ProxyAPIRequestOverNoise sends Tailscale API request r over the
+// Noise channel, authenticated as the current node+machine key, to
+// the control plane and copies its response back to w.
+func (b *LocalBackend) ProxyAPIRequestOverNoise(w http.ResponseWriter, r *http.Request) {
+ var nodePub key.NodePublic
+ b.mu.Lock()
+ if nm := b.netMap; nm != nil {
+ nodePub = nm.NodeKey
+ }
+ b.mu.Unlock()
+ if nodePub.IsZero() {
+ http.Error(w, "no node public key", http.StatusBadGateway)
+ return
+ }
+
+ outR := r.Clone(r.Context())
+ outR.RequestURI = ""
+ outR.URL.Scheme = "https"
+ outR.URL.Host = "unused"
+
+ outR.SetBasicAuth(url.QueryEscape(nodePub.String()), "")
+ res, err := b.DoNoiseRequest(outR)
+ if err != nil {
+ http.Error(w, "failed to make backend noise request: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ for k, vv := range res.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+ w.WriteHeader(res.StatusCode)
+ io.Copy(w, res.Body)
+}
diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go
index 47b088100..db255c6e0 100644
--- a/ipn/ipnserver/server.go
+++ b/ipn/ipnserver/server.go
@@ -1049,6 +1049,10 @@ func (s *Server) localhostHandler(ci connIdentity) http.Handler {
lah.ServeHTTP(w, r)
return
}
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ s.b.ProxyAPIRequestOverNoise(w, r)
+ return
+ }
if ci.NotWindows {
io.WriteString(w, "<html><title>Tailscale</title><body><h1>Tailscale</h1>This is the local Tailscale daemon.")
return