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 <andrea@gottardo.me>
This commit is contained in:
Andrea Gottardo 2024-09-24 13:18:45 -07:00 committed by GitHub
parent a98f75b783
commit 8a6f48b455
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 396 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" {

View File

@ -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.
//

View File

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

View File

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