From 984cd1cab0521a2f64fe6b17705f3438916857a3 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Thu, 20 Mar 2025 07:39:51 -0700 Subject: [PATCH] cmd/tailscale: add CLI debug command to do raw LocalAPI requests This adds a portable way to do a raw LocalAPI request without worrying about the Unix-vs-macOS-vs-Windows ways of hitting the LocalAPI server. (It was already possible but tedious with 'tailscale debug local-creds') Updates tailscale/corp#24690 Change-Id: I0828ca55edaedf0565c8db192c10f24bebb95f1b Signed-off-by: Brad Fitzpatrick --- cmd/tailscale/cli/debug.go | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index ce5edd8d3..9c77570d5 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -136,6 +136,17 @@ func debugCmd() *ffcli.Command { Exec: runLocalCreds, ShortHelp: "Print how to access Tailscale LocalAPI", }, + { + Name: "localapi", + ShortUsage: "tailscale debug localapi [] []", + Exec: runLocalAPI, + ShortHelp: "Call a LocalAPI method directly", + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("localapi") + fs.BoolVar(&localAPIFlags.verbose, "v", false, "verbose; dump HTTP headers") + return fs + })(), + }, { Name: "restun", ShortUsage: "tailscale debug restun", @@ -451,6 +462,81 @@ func runLocalCreds(ctx context.Context, args []string) error { return nil } +func looksLikeHTTPMethod(s string) bool { + if len(s) > len("OPTIONS") { + return false + } + for _, r := range s { + if r < 'A' || r > 'Z' { + return false + } + } + return true +} + +var localAPIFlags struct { + verbose bool +} + +func runLocalAPI(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("expected at least one argument") + } + method := "GET" + if looksLikeHTTPMethod(args[0]) { + method = args[0] + args = args[1:] + if len(args) == 0 { + return errors.New("expected at least one argument after method") + } + } + path := args[0] + if !strings.HasPrefix(path, "/localapi/") { + if !strings.Contains(path, "/") { + path = "/localapi/v0/" + path + } else { + path = "/localapi/" + path + } + } + + var body io.Reader + if len(args) > 1 { + if args[1] == "-" { + fmt.Fprintf(Stderr, "# reading request body from stdin...\n") + all, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("reading Stdin: %q", err) + } + body = bytes.NewReader(all) + } else { + body = strings.NewReader(args[1]) + } + } + req, err := http.NewRequest(method, "http://local-tailscaled.sock"+path, body) + if err != nil { + return err + } + fmt.Fprintf(Stderr, "# doing request %s %s\n", method, path) + + res, err := localClient.DoLocalRequest(req) + if err != nil { + return err + } + is2xx := res.StatusCode >= 200 && res.StatusCode <= 299 + if localAPIFlags.verbose { + res.Write(Stdout) + } else { + if !is2xx { + fmt.Fprintf(Stderr, "# Response status %s\n", res.Status) + } + io.Copy(Stdout, res.Body) + } + if is2xx { + return nil + } + return errors.New(res.Status) +} + type localClientRoundTripper struct{} func (localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {