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, "