Просмотр исходного кода

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 <[email protected]>
Andrea Gottardo 1 год назад
Родитель
Сommit
8a6f48b455

+ 12 - 1
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
+}

+ 18 - 0
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)

+ 1 - 1
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+

+ 1 - 1
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

+ 163 - 0
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)
+}

+ 1 - 1
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]

+ 7 - 2
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 <name> [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
 

+ 1 - 1
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+

+ 45 - 0
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.

+ 46 - 0
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" {

+ 11 - 0
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.
 //

+ 6 - 0
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 {

+ 84 - 0
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)
+}