diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index 89c7bcf91..6614b844a 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -16,6 +16,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "tailscale.com/ipn/ipnstate" "tailscale.com/paths" @@ -109,6 +110,28 @@ func Goroutines(ctx context.Context) ([]byte, error) { return body, nil } +// BugReport logs and returns a log marker that can be shared by the user with support. +func BugReport(ctx context.Context, note string) (string, error) { + u := fmt.Sprintf("http://local-tailscaled.sock/localapi/v0/bugreport?note=%s", url.QueryEscape(note)) + req, err := http.NewRequestWithContext(ctx, "POST", u, nil) + if err != nil { + return "", err + } + res, err := DoLocalRequest(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + if res.StatusCode != 200 { + return "", fmt.Errorf("HTTP %s: %s", res.Status, body) + } + return strings.TrimSpace(string(body)), nil +} + // Status returns the Tailscale daemon's status. func Status(ctx context.Context) (*ipnstate.Status, error) { return status(ctx, "") diff --git a/cmd/tailscale/cli/bugreport.go b/cmd/tailscale/cli/bugreport.go new file mode 100644 index 000000000..46c32bf96 --- /dev/null +++ b/cmd/tailscale/cli/bugreport.go @@ -0,0 +1,38 @@ +// 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" + "fmt" + + "github.com/peterbourgon/ff/v2/ffcli" + "tailscale.com/client/tailscale" +) + +var bugReportCmd = &ffcli.Command{ + Name: "bugreport", + Exec: runBugReport, + ShortHelp: "Print a shareable identifier to help diagnose issues", + ShortUsage: "bugreport [note]", +} + +func runBugReport(ctx context.Context, args []string) error { + var note string + switch len(args) { + case 0: + case 1: + note = args[0] + default: + return errors.New("unknown argumets") + } + logMarker, err := tailscale.BugReport(ctx, note) + if err != nil { + return err + } + fmt.Println(logMarker) + return nil +} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index b9d01522f..dd919c82b 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -71,6 +71,7 @@ change in the future. versionCmd, webCmd, pushCmd, + bugReportCmd, }, FlagSet: rootfs, Exec: func(context.Context, []string) error { return flag.ErrHelp }, diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 50b848506..54e094806 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -97,8 +97,9 @@ type Options struct { // server is an IPN backend and its set of 0 or more active connections // talking to an IPN backend. type server struct { - b *ipnlocal.LocalBackend - logf logger.Logf + b *ipnlocal.LocalBackend + logf logger.Logf + backendLogID string // resetOnZero is whether to call bs.Reset on transition from // 1->0 connections. That is, this is whether the backend is // being run in "client mode" that requires an active GUI @@ -610,8 +611,9 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() ( } server := &server{ - logf: logf, - resetOnZero: !opts.SurviveDisconnects, + backendLogID: logid, + logf: logf, + resetOnZero: !opts.SurviveDisconnects, } // When the context is closed or when we return, whichever is first, close our listner @@ -982,7 +984,7 @@ func (psc *protoSwitchConn) Close() error { } func (s *server) localhostHandler(ci connIdentity) http.Handler { - lah := localapi.NewHandler(s.b) + lah := localapi.NewHandler(s.b, s.logf, s.backendLogID) lah.PermitRead, lah.PermitWrite = s.localAPIPermissions(ci) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index ce257bf39..55922cb44 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -6,6 +6,8 @@ package localapi import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -14,15 +16,23 @@ import ( "runtime" "strconv" "strings" + "time" "inet.af/netaddr" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" + "tailscale.com/types/logger" ) -func NewHandler(b *ipnlocal.LocalBackend) *Handler { - return &Handler{b: b} +func randHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +func NewHandler(b *ipnlocal.LocalBackend, logf logger.Logf, logID string) *Handler { + return &Handler{b: b, logf: logf, backendLogID: logID} } type Handler struct { @@ -37,7 +47,9 @@ type Handler struct { // PermitWrite is whether mutating HTTP handlers are allowed. PermitWrite bool - b *ipnlocal.LocalBackend + b *ipnlocal.LocalBackend + logf logger.Logf + backendLogID string } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -69,11 +81,28 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveStatus(w, r) case "/localapi/v0/check-ip-forwarding": h.serveCheckIPForwarding(w, r) + case "/localapi/v0/bugreport": + h.serveBugReport(w, r) default: io.WriteString(w, "tailscaled\n") } } +func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) { + if !h.PermitRead { + http.Error(w, "bugreport access denied", http.StatusForbidden) + return + } + + logMarker := fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, time.Now().UTC().Format("20060102150405Z"), randHex(8)) + h.logf("user bugreport: %s", logMarker) + if note := r.FormValue("note"); len(note) > 0 { + h.logf("user bugreport note: %s", note) + } + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintln(w, logMarker) +} + func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "whois access denied", http.StatusForbidden)