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.
angott/dns-cli-stream
Andrea Gottardo 1 month ago
parent 7c02dcf93a
commit 025ceed735

@ -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)

@ -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)
}
}

@ -35,8 +35,13 @@ var dnsCmd = &ffcli.Command{
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
},

@ -92,6 +92,7 @@ var handler = map[string]localAPIHandler{
"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)

@ -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)

Loading…
Cancel
Save