From 025ceed7354a527594c3c422ab4b9e1558326323 Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Thu, 19 Sep 2024 15:52:31 -0700 Subject: [PATCH] cli: implement `tailscale dns stream` Updates tailscale/tailscale#13326 This PR adds another subcommand to `tailscale dns`, to stream queries and answers returned by the DNS forwarder as they are handled. Useful for debugging purposes, and is equivalent to setting the `TS_DEBUG_DNS_FORWARD_SEND` envknob and filtering the logs for relevant entries. This also adds a new envknob, `TS_DEBUG_DNS_INCLUDE_NAMES`, which includes the actual hostnames in the log lines (with a huge privacy warning!). This makes it easier to diagnose issues with DNS resolution. --- client/tailscale/localclient.go | 7 ++++ cmd/tailscale/cli/dns-stream.go | 72 +++++++++++++++++++++++++++++++++ cmd/tailscale/cli/dns.go | 9 ++++- ipn/localapi/localapi.go | 25 ++++++++++++ net/dns/resolver/forwarder.go | 23 ++++++++--- 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 cmd/tailscale/cli/dns-stream.go diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index df51dc1ca..73f27d50f 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1470,6 +1470,13 @@ func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode strin return decodeJSON[*ipnstate.DebugDERPRegionReport](body) } +// DebugEnvknob sets a envknob for debugging purposes. +func (lc *LocalClient) DebugEnvknob(ctx context.Context, key, value string) error { + v := url.Values{"key": {key}, "value": {value}} + _, err := lc.send(ctx, "POST", "/localapi/v0/debug-envknob?"+v.Encode(), 200, nil) + return err +} + // DebugPacketFilterRules returns the packet filter rules for the current device. func (lc *LocalClient) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) { body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil) diff --git a/cmd/tailscale/cli/dns-stream.go b/cmd/tailscale/cli/dns-stream.go new file mode 100644 index 000000000..ea5ddb2cf --- /dev/null +++ b/cmd/tailscale/cli/dns-stream.go @@ -0,0 +1,72 @@ +package cli + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" +) + +func runDNSStream(ctx context.Context, args []string) error { + fmt.Printf(`Privacy warning! To stream DNS queries, this tool will set these Tailscale debug flags, which would normally be disabled by default: + + - TS_DEBUG_DNS_FORWARD_SEND=true + - TS_DEBUG_DNS_INCLUDE_NAMES=true + +TS_DEBUG_DNS_FORWARD_SEND instructs Tailscale to log DNS queries and responses as they are handled by the internal DNS forwarder. + +TS_DEBUG_DNS_INCLUDE_NAMES instructs Tailscale to include queried and resolved DNS hostnames in the logs. + +Unless the 'TS_NO_LOGS_NO_SUPPORT' flag was previously set, logs are uploaded to Tailscale for diagnostic and debugging purposes, which can be a concern in privacy-sensitive environments. + +If you are concerned about the privacy implications of this, run this tool with the '--no-names' flag, which will avoid logging hostnames.`) + fmt.Printf("\n\n") + fmt.Println("Press Enter to start streaming DNS logs, or Ctrl+C to quit this tool.") + + buf := bufio.NewReader(os.Stdin) + _, err := buf.ReadBytes('\n') + if err != nil { + fmt.Println(err) + return nil + } + + err = localClient.DebugEnvknob(ctx, "TS_DEBUG_DNS_FORWARD_SEND", "true") + if err != nil { + fmt.Printf("failed to set TS_DEBUG_DNS_FORWARD_SEND=true: %v\n", err) + return nil + } + err = localClient.DebugEnvknob(ctx, "TS_DEBUG_DNS_INCLUDE_NAMES", "true") + if err != nil { + fmt.Printf("failed to set TS_DEBUG_DNS_INCLUDE_NAMES=true: %v\n", err) + return nil + } + + logs, err := localClient.TailDaemonLogs(ctx) + if err != nil { + return err + } + + fmt.Println("Streaming DNS logs. Press Ctrl+C to stop.") + + d := json.NewDecoder(logs) + for { + var line struct { + Text string `json:"text"` + Verbose int `json:"v"` + Time string `json:"client_time"` + } + err := d.Decode(&line) + if err != nil { + return err + } + text := strings.TrimSpace(line.Text) + dnsPrefix := "dns: resolver: forward: " + if !strings.HasPrefix(text, dnsPrefix) { + continue + } + text = strings.TrimPrefix(text, dnsPrefix) + fmt.Println(text) + } +} diff --git a/cmd/tailscale/cli/dns.go b/cmd/tailscale/cli/dns.go index 042ce1a94..18fa0f8fa 100644 --- a/cmd/tailscale/cli/dns.go +++ b/cmd/tailscale/cli/dns.go @@ -35,8 +35,13 @@ ShortHelp: "Perform a DNS query", LongHelp: "The 'tailscale dns query' subcommand performs a DNS query for the specified name using the internal DNS forwarder (100.100.100.100).\n\nIt also provides information about the resolver(s) used to resolve the query.", }, - - // TODO: implement `tailscale log` here + { + Name: "stream", + ShortUsage: "tailscale dns stream", + Exec: runDNSStream, + ShortHelp: "Stream DNS queries and responses", + LongHelp: "The 'tailscale dns stream' subcommand streams DNS queries and responses to and from the internal DNS forwarder, which is useful for debugging DNS issues.", + }, // The above work is tracked in https://github.com/tailscale/tailscale/issues/13326 }, diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index ec9d434e7..bf8cba2bc 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -92,6 +92,7 @@ "debug-capture": (*Handler).serveDebugCapture, "debug-derp-region": (*Handler).serveDebugDERPRegion, "debug-dial-types": (*Handler).serveDebugDialTypes, + "debug-envknob": (*Handler).serveDebugEnvKnob, "debug-log": (*Handler).serveDebugLog, "debug-packet-filter-matches": (*Handler).serveDebugPacketFilterMatches, "debug-packet-filter-rules": (*Handler).serveDebugPacketFilterRules, @@ -584,6 +585,30 @@ func (h *Handler) serveUserMetrics(w http.ResponseWriter, r *http.Request) { usermetric.Handler(w, r) } +// serveDebugEnvKnob allows the remote LocalAPI user to set the value of an envknob. +func (h *Handler) serveDebugEnvKnob(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "debug-envknob access denied", http.StatusForbidden) + return + } + if r.Method != "POST" { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + if k := r.FormValue("key"); k != "" { + if kv := r.FormValue("value"); kv != "" { + envknob.Setenv(k, kv) + io.WriteString(w, fmt.Sprintf("set %q to %q\n", k, kv)) + } else { + http.Error(w, fmt.Sprintf("missing envknob value for envknob key %q", k), http.StatusBadRequest) + return + } + } else { + http.Error(w, "must provide an envknob key", http.StatusBadRequest) + return + } +} + func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "debug access denied", http.StatusForbidden) diff --git a/net/dns/resolver/forwarder.go b/net/dns/resolver/forwarder.go index 5fe4cc631..4820e7743 100644 --- a/net/dns/resolver/forwarder.go +++ b/net/dns/resolver/forwarder.go @@ -486,8 +486,9 @@ func (f *forwarder) sendDoH(ctx context.Context, urlBase string, c *http.Client, } var ( - verboseDNSForward = envknob.RegisterBool("TS_DEBUG_DNS_FORWARD_SEND") - skipTCPRetry = envknob.RegisterBool("TS_DNS_FORWARD_SKIP_TCP_RETRY") + verboseDNSForward = envknob.RegisterBool("TS_DEBUG_DNS_FORWARD_SEND") + verboseDNSIncludeNames = envknob.RegisterBool("TS_DEBUG_DNS_INCLUDE_NAMES") + skipTCPRetry = envknob.RegisterBool("TS_DNS_FORWARD_SKIP_TCP_RETRY") // For correlating log messages in the send() function; only used when // verboseDNSForward() is true. @@ -501,9 +502,17 @@ func (f *forwarder) send(ctx context.Context, fq *forwardQuery, rr resolverAndDe if verboseDNSForward() { id := forwarderCount.Add(1) domain, typ, _ := nameFromQuery(fq.packet) - f.logf("forwarder.send(%q, %d, %v, %d) [%d] ...", rr.name.Addr, fq.txid, typ, len(domain), id) + if verboseDNSIncludeNames() { + f.logf("forwarder.send(%q, %d, %v, %q) [%d] ...", rr.name.Addr, fq.txid, typ, domain.WithoutTrailingDot(), id) + } else { + f.logf("forwarder.send(%q, %d, %v, %d) [%d] ...", rr.name.Addr, fq.txid, typ, len(domain), id) + } defer func() { - f.logf("forwarder.send(%q, %d, %v, %d) [%d] = %v, %v", rr.name.Addr, fq.txid, typ, len(domain), id, len(ret), err) + if verboseDNSIncludeNames() { + f.logf("forwarder.send(%q, %d, %v, %q) [%d] = %v, %v", rr.name.Addr, fq.txid, typ, domain.WithoutTrailingDot(), id, len(ret), err) + } else { + f.logf("forwarder.send(%q, %d, %v, %d) [%d] = %v, %v", rr.name.Addr, fq.txid, typ, len(domain), id, len(ret), err) + } }() } if strings.HasPrefix(rr.name.Addr, "http://") { @@ -1003,7 +1012,11 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo return fmt.Errorf("waiting to send response: %w", ctx.Err()) case responseChan <- packet{v, query.family, query.addr}: if verboseDNSForward() { - f.logf("response(%d, %v, %d) = %d, nil", fq.txid, typ, len(domain), len(v)) + if verboseDNSIncludeNames() { + f.logf("forwarder.response(%d, %v, %q) = %d, nil", fq.txid, typ, domain.WithTrailingDot(), len(v)) + } else { + f.logf("forwarder.response(%d, %v, %d) = %d, nil", fq.txid, typ, len(domain), len(v)) + } } metricDNSFwdSuccess.Add(1) f.health.SetHealthy(dnsForwarderFailing)