From 8a6f48b4552fb461defbcbca2f364fde8e7760ff Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Tue, 24 Sep 2024 13:18:45 -0700 Subject: [PATCH] cli: add `tailscale dns query` (#13368) Updates tailscale/tailscale#13326 Adds a CLI subcommand to perform DNS queries using the internal DNS forwarder and observe its internals (namely, which upstream resolvers are being used). Signed-off-by: Andrea Gottardo --- client/tailscale/apitype/apitype.go | 13 ++- client/tailscale/localclient.go | 18 +++ cmd/derper/depaware.txt | 2 +- cmd/stund/depaware.txt | 2 +- cmd/tailscale/cli/dns-query.go | 163 +++++++++++++++++++++++++++ cmd/tailscale/cli/dns-status.go | 2 +- cmd/tailscale/cli/dns.go | 9 +- cmd/tailscale/depaware.txt | 2 +- ipn/ipnlocal/local.go | 45 ++++++++ ipn/localapi/localapi.go | 46 ++++++++ net/dns/resolver/forwarder.go | 11 ++ net/dns/resolver/tsdns.go | 6 + types/dnstype/messagetypes-string.go | 84 ++++++++++++++ 13 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 cmd/tailscale/cli/dns-query.go create mode 100644 types/dnstype/messagetypes-string.go diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index 81879aac3..b1c273a4f 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -4,7 +4,10 @@ // Package apitype contains types for the Tailscale LocalAPI and control plane API. package apitype -import "tailscale.com/tailcfg" +import ( + "tailscale.com/tailcfg" + "tailscale.com/types/dnstype" +) // LocalAPIHost is the Host header value used by the LocalAPI. const LocalAPIHost = "local-tailscaled.sock" @@ -65,3 +68,11 @@ type DNSOSConfig struct { SearchDomains []string MatchDomains []string } + +// DNSQueryResponse is the response to a DNS query request sent via LocalAPI. +type DNSQueryResponse struct { + // Bytes is the raw DNS response bytes. + Bytes []byte + // Resolvers is the list of resolvers that the forwarder deemed able to resolve the query. + Resolvers []*dnstype.Resolver +} diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 29e28a154..df51dc1ca 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -37,6 +37,7 @@ import ( "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/tka" + "tailscale.com/types/dnstype" "tailscale.com/types/key" "tailscale.com/types/tkatype" ) @@ -813,6 +814,8 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn return decodeJSON[*ipn.Prefs](body) } +// GetDNSOSConfig returns the system DNS configuration for the current device. +// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used. func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) { body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig") if err != nil { @@ -825,6 +828,21 @@ func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig return &osCfg, nil } +// QueryDNS executes a DNS query for a name (`google.com.`) and query type (`CNAME`). +// It returns the raw DNS response bytes and the resolvers that were used to answer the query +// (often just one, but can be more if we raced multiple resolvers). +func (lc *LocalClient) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) { + body, err := lc.get200(ctx, fmt.Sprintf("/localapi/v0/dns-query?name=%s&type=%s", url.QueryEscape(name), queryType)) + if err != nil { + return nil, nil, err + } + var res apitype.DNSQueryResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, nil, fmt.Errorf("invalid query response: %w", err) + } + return res.Bytes, res.Resolvers, nil +} + // StartLoginInteractive starts an interactive login. func (lc *LocalClient) StartLoginInteractive(ctx context.Context) error { _, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil) diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 2f6f160c8..eb9ba1619 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -128,7 +128,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/tsweb from tailscale.com/cmd/derper tailscale.com/tsweb/promvarz from tailscale.com/tsweb tailscale.com/tsweb/varz from tailscale.com/tsweb+ - tailscale.com/types/dnstype from tailscale.com/tailcfg + tailscale.com/types/dnstype from tailscale.com/tailcfg+ tailscale.com/types/empty from tailscale.com/ipn tailscale.com/types/ipproto from tailscale.com/tailcfg+ tailscale.com/types/key from tailscale.com/client/tailscale+ diff --git a/cmd/stund/depaware.txt b/cmd/stund/depaware.txt index 09540c833..a35f59516 100644 --- a/cmd/stund/depaware.txt +++ b/cmd/stund/depaware.txt @@ -91,7 +91,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ golang.org/x/crypto/sha3 from crypto/internal/mlkem768+ - golang.org/x/net/dns/dnsmessage from net + golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/http/httpguts from net/http golang.org/x/net/http/httpproxy from net/http golang.org/x/net/http2/hpack from net/http diff --git a/cmd/tailscale/cli/dns-query.go b/cmd/tailscale/cli/dns-query.go new file mode 100644 index 000000000..da2d9d2a5 --- /dev/null +++ b/cmd/tailscale/cli/dns-query.go @@ -0,0 +1,163 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "flag" + "fmt" + "net/netip" + "os" + "text/tabwriter" + + "golang.org/x/net/dns/dnsmessage" + "tailscale.com/types/dnstype" +) + +func runDNSQuery(ctx context.Context, args []string) error { + if len(args) < 1 { + return flag.ErrHelp + } + name := args[0] + queryType := "A" + if len(args) >= 2 { + queryType = args[1] + } + fmt.Printf("DNS query for %q (%s) using internal resolver:\n", name, queryType) + fmt.Println() + bytes, resolvers, err := localClient.QueryDNS(ctx, name, queryType) + if err != nil { + fmt.Printf("failed to query DNS: %v\n", err) + return nil + } + + if len(resolvers) == 1 { + fmt.Printf("Forwarding to resolver: %v\n", makeResolverString(*resolvers[0])) + } else { + fmt.Println("Multiple resolvers available:") + for _, r := range resolvers { + fmt.Printf(" - %v\n", makeResolverString(*r)) + } + } + fmt.Println() + var p dnsmessage.Parser + header, err := p.Start(bytes) + if err != nil { + fmt.Printf("failed to parse DNS response: %v\n", err) + return err + } + fmt.Printf("Response code: %v\n", header.RCode.String()) + fmt.Println() + p.SkipAllQuestions() + if header.RCode != dnsmessage.RCodeSuccess { + fmt.Println("No answers were returned.") + return nil + } + answers, err := p.AllAnswers() + if err != nil { + fmt.Printf("failed to parse DNS answers: %v\n", err) + return err + } + if len(answers) == 0 { + fmt.Println(" (no answers found)") + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "Name\tTTL\tClass\tType\tBody") + fmt.Fprintln(w, "----\t---\t-----\t----\t----") + for _, a := range answers { + fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", a.Header.Name.String(), a.Header.TTL, a.Header.Class.String(), a.Header.Type.String(), makeAnswerBody(a)) + } + w.Flush() + + fmt.Println() + return nil +} + +// makeAnswerBody returns a string with the DNS answer body in a human-readable format. +func makeAnswerBody(a dnsmessage.Resource) string { + switch a.Header.Type { + case dnsmessage.TypeA: + return makeABody(a.Body) + case dnsmessage.TypeAAAA: + return makeAAAABody(a.Body) + case dnsmessage.TypeCNAME: + return makeCNAMEBody(a.Body) + case dnsmessage.TypeMX: + return makeMXBody(a.Body) + case dnsmessage.TypeNS: + return makeNSBody(a.Body) + case dnsmessage.TypeOPT: + return makeOPTBody(a.Body) + case dnsmessage.TypePTR: + return makePTRBody(a.Body) + case dnsmessage.TypeSRV: + return makeSRVBody(a.Body) + case dnsmessage.TypeTXT: + return makeTXTBody(a.Body) + default: + return a.Body.GoString() + } +} + +func makeABody(a dnsmessage.ResourceBody) string { + if a, ok := a.(*dnsmessage.AResource); ok { + return netip.AddrFrom4(a.A).String() + } + return "" +} +func makeAAAABody(aaaa dnsmessage.ResourceBody) string { + if a, ok := aaaa.(*dnsmessage.AAAAResource); ok { + return netip.AddrFrom16(a.AAAA).String() + } + return "" +} +func makeCNAMEBody(cname dnsmessage.ResourceBody) string { + if c, ok := cname.(*dnsmessage.CNAMEResource); ok { + return c.CNAME.String() + } + return "" +} +func makeMXBody(mx dnsmessage.ResourceBody) string { + if m, ok := mx.(*dnsmessage.MXResource); ok { + return fmt.Sprintf("%s (Priority=%d)", m.MX, m.Pref) + } + return "" +} +func makeNSBody(ns dnsmessage.ResourceBody) string { + if n, ok := ns.(*dnsmessage.NSResource); ok { + return n.NS.String() + } + return "" +} +func makeOPTBody(opt dnsmessage.ResourceBody) string { + if o, ok := opt.(*dnsmessage.OPTResource); ok { + return o.GoString() + } + return "" +} +func makePTRBody(ptr dnsmessage.ResourceBody) string { + if p, ok := ptr.(*dnsmessage.PTRResource); ok { + return p.PTR.String() + } + return "" +} +func makeSRVBody(srv dnsmessage.ResourceBody) string { + if s, ok := srv.(*dnsmessage.SRVResource); ok { + return fmt.Sprintf("Target=%s, Port=%d, Priority=%d, Weight=%d", s.Target.String(), s.Port, s.Priority, s.Weight) + } + return "" +} +func makeTXTBody(txt dnsmessage.ResourceBody) string { + if t, ok := txt.(*dnsmessage.TXTResource); ok { + return fmt.Sprintf("%q", t.TXT) + } + return "" +} +func makeResolverString(r dnstype.Resolver) string { + if len(r.BootstrapResolution) > 0 { + return fmt.Sprintf("%s (bootstrap: %v)", r.Addr, r.BootstrapResolution) + } + return fmt.Sprintf("%s", r.Addr) +} diff --git a/cmd/tailscale/cli/dns-status.go b/cmd/tailscale/cli/dns-status.go index 0d59e4b91..e487c66bc 100644 --- a/cmd/tailscale/cli/dns-status.go +++ b/cmd/tailscale/cli/dns-status.go @@ -75,7 +75,7 @@ func runDNSStatus(ctx context.Context, args []string) error { fmt.Print("\n") fmt.Println("Split DNS Routes:") if len(dnsConfig.Routes) == 0 { - fmt.Println(" (no routes configured: split DNS might not be in use)") + fmt.Println(" (no routes configured: split DNS disabled)") } for _, k := range slices.Sorted(maps.Keys(dnsConfig.Routes)) { v := dnsConfig.Routes[k] diff --git a/cmd/tailscale/cli/dns.go b/cmd/tailscale/cli/dns.go index 282555695..042ce1a94 100644 --- a/cmd/tailscale/cli/dns.go +++ b/cmd/tailscale/cli/dns.go @@ -28,8 +28,13 @@ var dnsCmd = &ffcli.Command{ return fs })(), }, - - // TODO: implement `tailscale query` here + { + Name: "query", + ShortUsage: "tailscale dns query [a|aaaa|cname|mx|ns|opt|ptr|srv|txt]", + Exec: runDNSQuery, + 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 diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index d9432614f..be6f42946 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -134,7 +134,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/tstime/mono from tailscale.com/tstime/rate tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli+ tailscale.com/tsweb/varz from tailscale.com/util/usermetric - tailscale.com/types/dnstype from tailscale.com/tailcfg + tailscale.com/types/dnstype from tailscale.com/tailcfg+ tailscale.com/types/empty from tailscale.com/ipn tailscale.com/types/ipproto from tailscale.com/net/flowtrack+ tailscale.com/types/key from tailscale.com/client/tailscale+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index cd99fa351..f72470948 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -38,6 +38,7 @@ import ( "go4.org/mem" "go4.org/netipx" xmaps "golang.org/x/exp/maps" + "golang.org/x/net/dns/dnsmessage" "gvisor.dev/gvisor/pkg/tcpip" "tailscale.com/appc" "tailscale.com/client/tailscale/apitype" @@ -606,6 +607,50 @@ func (b *LocalBackend) GetDNSOSConfig() (dns.OSConfig, error) { return manager.GetBaseConfig() } +// QueryDNS performs a DNS query for name and queryType using the built-in DNS resolver, and returns +// the raw DNS response and the resolvers that are were able to handle the query (the internal forwarder +// may race multiple resolvers). +func (b *LocalBackend) QueryDNS(name string, queryType dnsmessage.Type) (res []byte, resolvers []*dnstype.Resolver, err error) { + manager, ok := b.sys.DNSManager.GetOK() + if !ok { + return nil, nil, errors.New("DNS manager not available") + } + fqdn, err := dnsname.ToFQDN(name) + if err != nil { + b.logf("DNSQuery: failed to parse FQDN %q: %v", name, err) + return nil, nil, err + } + n, err := dnsmessage.NewName(fqdn.WithTrailingDot()) + if err != nil { + b.logf("DNSQuery: failed to parse name %q: %v", name, err) + return nil, nil, err + } + from := netip.MustParseAddrPort("127.0.0.1:0") + db := dnsmessage.NewBuilder(nil, dnsmessage.Header{ + OpCode: 0, + RecursionDesired: true, + ID: 1, + }) + db.StartQuestions() + db.Question(dnsmessage.Question{ + Name: n, + Type: queryType, + Class: dnsmessage.ClassINET, + }) + q, err := db.Finish() + if err != nil { + b.logf("DNSQuery: failed to build query: %v", err) + return nil, nil, err + } + res, err = manager.Query(b.ctx, q, "tcp", from) + if err != nil { + b.logf("DNSQuery: failed to query %q: %v", name, err) + return nil, nil, err + } + rr := manager.Resolver().GetUpstreamResolvers(fqdn) + return res, rr, nil +} + // GetComponentDebugLogging gets the time that component's debug logging is // enabled until, or the zero time if component's time is not currently // enabled. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 01dc064cf..ec9d434e7 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -32,6 +32,7 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/net/dns/dnsmessage" "tailscale.com/client/tailscale/apitype" "tailscale.com/clientupdate" "tailscale.com/drive" @@ -49,6 +50,7 @@ import ( "tailscale.com/taildrop" "tailscale.com/tka" "tailscale.com/tstime" + "tailscale.com/types/dnstype" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" @@ -99,6 +101,7 @@ var handler = map[string]localAPIHandler{ "dev-set-state-store": (*Handler).serveDevSetStateStore, "dial": (*Handler).serveDial, "dns-osconfig": (*Handler).serveDNSOSConfig, + "dns-query": (*Handler).serveDNSQuery, "drive/fileserver-address": (*Handler).serveDriveServerAddr, "drive/shares": (*Handler).serveShares, "file-targets": (*Handler).serveFileTargets, @@ -2746,6 +2749,49 @@ func (h *Handler) serveDNSOSConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// serveDNSQuery provides the ability to perform DNS queries using the internal +// DNS forwarder. This is useful for debugging and testing purposes. +// URL parameters: +// - name: the domain name to query +// - type: the DNS record type to query as a number (default if empty: A = '1') +// +// The response if successful is a DNSQueryResponse JSON object. +func (h *Handler) serveDNSQuery(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) + return + } + // Require write access for privacy reasons. + if !h.PermitWrite { + http.Error(w, "dns-query access denied", http.StatusForbidden) + return + } + q := r.URL.Query() + name := q.Get("name") + queryType := q.Get("type") + qt := dnsmessage.TypeA + if queryType != "" { + t, err := dnstype.DNSMessageTypeForString(queryType) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + qt = t + } + + res, rrs, err := h.b.QueryDNS(name, qt) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&apitype.DNSQueryResponse{ + Bytes: res, + Resolvers: rrs, + }) +} + // serveDriveServerAddr handles updates of the Taildrive file server address. func (h *Handler) serveDriveServerAddr(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { diff --git a/net/dns/resolver/forwarder.go b/net/dns/resolver/forwarder.go index 7549335e6..b52fac8de 100644 --- a/net/dns/resolver/forwarder.go +++ b/net/dns/resolver/forwarder.go @@ -834,6 +834,17 @@ func (f *forwarder) resolvers(domain dnsname.FQDN) []resolverAndDelay { return cloudHostFallback // or nil if no fallback } +// GetUpstreamResolvers returns the resolvers that would be used to resolve +// the given FQDN. +func (f *forwarder) GetUpstreamResolvers(name dnsname.FQDN) []*dnstype.Resolver { + resolvers := f.resolvers(name) + upstreamResolvers := make([]*dnstype.Resolver, 0, len(resolvers)) + for _, r := range resolvers { + upstreamResolvers = append(upstreamResolvers, r.name) + } + return upstreamResolvers +} + // forwardQuery is information and state about a forwarded DNS query that's // being sent to 1 or more upstreams. // diff --git a/net/dns/resolver/tsdns.go b/net/dns/resolver/tsdns.go index 90e447020..d196ad4d6 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -337,6 +337,12 @@ func (r *Resolver) Query(ctx context.Context, bs []byte, family string, from net return out, err } +// GetUpstreamResolvers returns the resolvers that would be used to resolve +// the given FQDN. +func (r *Resolver) GetUpstreamResolvers(name dnsname.FQDN) []*dnstype.Resolver { + return r.forwarder.GetUpstreamResolvers(name) +} + // parseExitNodeQuery parses a DNS request packet. // It returns nil if it's malformed or lacking a question. func parseExitNodeQuery(q []byte) *response { diff --git a/types/dnstype/messagetypes-string.go b/types/dnstype/messagetypes-string.go new file mode 100644 index 000000000..34abea1ba --- /dev/null +++ b/types/dnstype/messagetypes-string.go @@ -0,0 +1,84 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package dnstype + +import ( + "errors" + "strings" + + "golang.org/x/net/dns/dnsmessage" +) + +// StringForType returns the string representation of a dnsmessage.Type. +// For example, StringForType(dnsmessage.TypeA) returns "A". +func StringForDNSMessageType(t dnsmessage.Type) string { + switch t { + case dnsmessage.TypeAAAA: + return "AAAA" + case dnsmessage.TypeALL: + return "ALL" + case dnsmessage.TypeA: + return "A" + case dnsmessage.TypeCNAME: + return "CNAME" + case dnsmessage.TypeHINFO: + return "HINFO" + case dnsmessage.TypeMINFO: + return "MINFO" + case dnsmessage.TypeMX: + return "MX" + case dnsmessage.TypeNS: + return "NS" + case dnsmessage.TypeOPT: + return "OPT" + case dnsmessage.TypePTR: + return "PTR" + case dnsmessage.TypeSOA: + return "SOA" + case dnsmessage.TypeSRV: + return "SRV" + case dnsmessage.TypeTXT: + return "TXT" + case dnsmessage.TypeWKS: + return "WKS" + } + return "UNKNOWN" +} + +// DNSMessageTypeForString returns the dnsmessage.Type for the given string. +// For example, DNSMessageTypeForString("A") returns dnsmessage.TypeA. +func DNSMessageTypeForString(s string) (t dnsmessage.Type, err error) { + s = strings.TrimSpace(strings.ToUpper(s)) + switch s { + case "AAAA": + return dnsmessage.TypeAAAA, nil + case "ALL": + return dnsmessage.TypeALL, nil + case "A": + return dnsmessage.TypeA, nil + case "CNAME": + return dnsmessage.TypeCNAME, nil + case "HINFO": + return dnsmessage.TypeHINFO, nil + case "MINFO": + return dnsmessage.TypeMINFO, nil + case "MX": + return dnsmessage.TypeMX, nil + case "NS": + return dnsmessage.TypeNS, nil + case "OPT": + return dnsmessage.TypeOPT, nil + case "PTR": + return dnsmessage.TypePTR, nil + case "SOA": + return dnsmessage.TypeSOA, nil + case "SRV": + return dnsmessage.TypeSRV, nil + case "TXT": + return dnsmessage.TypeTXT, nil + case "WKS": + return dnsmessage.TypeWKS, nil + } + return 0, errors.New("unknown DNS message type: " + s) +}