mirror of
https://github.com/tailscale/tailscale.git
synced 2026-03-26 10:11:05 -04:00
cli: --json for tailscale dns status|query
This commit adds `--json` output mode to dns debug commands. It defines structs for the data that is returned from: `tailscale dns status` and `tailscale dns query <DOMAIN>` and populates that as it runs the diagnostics. When all the information is collected, it is serialised to JSON or string built into an output and returned to the user. The structs are defined and exported to golang consumers of this command can use them for unmarshalling. Updates #13326 Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
committed by
Kristoffer Dalby
parent
faf7f2bc45
commit
d82e478dbc
@@ -5,93 +5,165 @@
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/cmd/tailscale/cli/jsonoutput"
|
||||
)
|
||||
|
||||
var dnsQueryArgs struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
var dnsQueryCmd = &ffcli.Command{
|
||||
Name: "query",
|
||||
ShortUsage: "tailscale dns query <name> [a|aaaa|cname|mx|ns|opt|ptr|srv|txt]",
|
||||
ShortUsage: "tailscale dns query [--json] <name> [type]",
|
||||
Exec: runDNSQuery,
|
||||
ShortHelp: "Perform a DNS query",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
The 'tailscale dns query' subcommand performs a DNS query for the specified name
|
||||
using the internal DNS forwarder (100.100.100.100).
|
||||
|
||||
By default, the DNS query will request an A record. Another DNS record type can
|
||||
be specified as the second parameter.
|
||||
By default, the DNS query will request an A record. Specify the record type as
|
||||
a second argument after the name (e.g. AAAA, CNAME, MX, NS, PTR, SRV, TXT).
|
||||
|
||||
The output also provides information about the resolver(s) used to resolve the
|
||||
query.
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("query")
|
||||
fs.BoolVar(&dnsQueryArgs.json, "json", false, "output in JSON format")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
func runDNSQuery(ctx context.Context, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return flag.ErrHelp
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing required argument: name")
|
||||
}
|
||||
if len(args) > 1 {
|
||||
var flags []string
|
||||
for _, a := range args[1:] {
|
||||
if strings.HasPrefix(a, "-") {
|
||||
flags = append(flags, a)
|
||||
}
|
||||
}
|
||||
if len(flags) > 0 {
|
||||
return fmt.Errorf("unexpected flags after query name: %s; see 'tailscale dns query --help'", strings.Join(flags, ", "))
|
||||
}
|
||||
if len(args) > 2 {
|
||||
return fmt.Errorf("unexpected extra arguments: %s", strings.Join(args[2:], " "))
|
||||
}
|
||||
}
|
||||
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(args) > 1 {
|
||||
queryType = strings.ToUpper(args[1])
|
||||
}
|
||||
|
||||
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))
|
||||
rawBytes, resolvers, err := localClient.QueryDNS(ctx, name, queryType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query DNS: %w", err)
|
||||
}
|
||||
|
||||
data := &jsonoutput.DNSQueryResult{
|
||||
Name: name,
|
||||
QueryType: queryType,
|
||||
}
|
||||
|
||||
for _, r := range resolvers {
|
||||
data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r))
|
||||
}
|
||||
|
||||
var p dnsmessage.Parser
|
||||
header, err := p.Start(rawBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse DNS response: %w", err)
|
||||
}
|
||||
data.ResponseCode = header.RCode.String()
|
||||
|
||||
p.SkipAllQuestions()
|
||||
|
||||
if header.RCode == dnsmessage.RCodeSuccess {
|
||||
answers, err := p.AllAnswers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse DNS answers: %w", err)
|
||||
}
|
||||
data.Answers = make([]jsonoutput.DNSAnswer, 0, len(answers))
|
||||
for _, a := range answers {
|
||||
data.Answers = append(data.Answers, jsonoutput.DNSAnswer{
|
||||
Name: a.Header.Name.String(),
|
||||
TTL: a.Header.TTL,
|
||||
Class: a.Header.Class.String(),
|
||||
Type: a.Header.Type.String(),
|
||||
Body: makeAnswerBody(a),
|
||||
})
|
||||
}
|
||||
}
|
||||
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.")
|
||||
|
||||
if dnsQueryArgs.json {
|
||||
j, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printf("%s\n", j)
|
||||
return nil
|
||||
}
|
||||
answers, err := p.AllAnswers()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to parse DNS answers: %v\n", err)
|
||||
return err
|
||||
printf("%s", formatDNSQueryText(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDNSQueryText(data *jsonoutput.DNSQueryResult) string {
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "DNS query for %q (%s) using internal resolver:\n", data.Name, data.QueryType)
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
if len(data.Resolvers) == 1 {
|
||||
fmt.Fprintf(&sb, "Forwarding to resolver: %v\n", formatResolverString(data.Resolvers[0]))
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "Multiple resolvers available:\n")
|
||||
for _, r := range data.Resolvers {
|
||||
fmt.Fprintf(&sb, " - %v\n", formatResolverString(r))
|
||||
}
|
||||
}
|
||||
if len(answers) == 0 {
|
||||
fmt.Println(" (no answers found)")
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "Response code: %v\n", data.ResponseCode)
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
if data.Answers == nil {
|
||||
fmt.Fprintf(&sb, "No answers were returned.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
if len(data.Answers) == 0 {
|
||||
fmt.Fprintf(&sb, " (no answers found)\n")
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(&sb, 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))
|
||||
for _, a := range data.Answers {
|
||||
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", a.Name, a.TTL, a.Class, a.Type, a.Body)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatResolverString formats a jsonoutput.DNSResolverInfo for human-readable text output.
|
||||
func formatResolverString(r jsonoutput.DNSResolverInfo) string {
|
||||
if len(r.BootstrapResolution) > 0 {
|
||||
return fmt.Sprintf("%s (bootstrap: %v)", r.Addr, r.BootstrapResolution)
|
||||
}
|
||||
return r.Addr
|
||||
}
|
||||
|
||||
// makeAnswerBody returns a string with the DNS answer body in a human-readable format.
|
||||
@@ -174,9 +246,3 @@ func makeTXTBody(txt dnsmessage.ResourceBody) string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -12,13 +13,15 @@
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/cmd/tailscale/cli/jsonoutput"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
var dnsStatusCmd = &ffcli.Command{
|
||||
Name: "status",
|
||||
ShortUsage: "tailscale dns status [--all]",
|
||||
ShortUsage: "tailscale dns status [--all] [--json]",
|
||||
Exec: runDNSStatus,
|
||||
ShortHelp: "Print the current DNS status and configuration",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
@@ -72,17 +75,30 @@
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("status")
|
||||
fs.BoolVar(&dnsStatusArgs.all, "all", false, "outputs advanced debugging information")
|
||||
fs.BoolVar(&dnsStatusArgs.json, "json", false, "output in JSON format")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
// dnsStatusArgs are the arguments for the "dns status" subcommand.
|
||||
var dnsStatusArgs struct {
|
||||
all bool
|
||||
all bool
|
||||
json bool
|
||||
}
|
||||
|
||||
// makeDNSResolverInfo converts a dnstype.Resolver to a jsonoutput.DNSResolverInfo.
|
||||
func makeDNSResolverInfo(r *dnstype.Resolver) jsonoutput.DNSResolverInfo {
|
||||
info := jsonoutput.DNSResolverInfo{Addr: r.Addr}
|
||||
if r.BootstrapResolution != nil {
|
||||
info.BootstrapResolution = make([]string, 0, len(r.BootstrapResolution))
|
||||
for _, a := range r.BootstrapResolution {
|
||||
info.BootstrapResolution = append(info.BootstrapResolution, a.String())
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func runDNSStatus(ctx context.Context, args []string) error {
|
||||
all := dnsStatusArgs.all
|
||||
s, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -92,167 +108,254 @@ func runDNSStatus(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enabledStr := "disabled.\n\n(Run 'tailscale set --accept-dns=true' to start sending DNS queries to the Tailscale DNS resolver)"
|
||||
if prefs.CorpDNS {
|
||||
enabledStr = "enabled.\n\nTailscale is configured to handle DNS queries on this device.\nRun 'tailscale set --accept-dns=false' to revert to your system default DNS resolver."
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println("=== 'Use Tailscale DNS' status ===")
|
||||
fmt.Print("\n")
|
||||
fmt.Printf("Tailscale DNS: %s\n", enabledStr)
|
||||
fmt.Print("\n")
|
||||
fmt.Println("=== MagicDNS configuration ===")
|
||||
fmt.Print("\n")
|
||||
fmt.Println("This is the DNS configuration provided by the coordination server to this device.")
|
||||
fmt.Print("\n")
|
||||
if s.CurrentTailnet == nil {
|
||||
fmt.Println("No tailnet information available; make sure you're logged in to a tailnet.")
|
||||
return nil
|
||||
} else if s.CurrentTailnet.MagicDNSEnabled {
|
||||
fmt.Printf("MagicDNS: enabled tailnet-wide (suffix = %s)", s.CurrentTailnet.MagicDNSSuffix)
|
||||
fmt.Print("\n\n")
|
||||
fmt.Printf("Other devices in your tailnet can reach this device at %s\n", s.Self.DNSName)
|
||||
} else {
|
||||
fmt.Printf("MagicDNS: disabled tailnet-wide.\n")
|
||||
}
|
||||
fmt.Print("\n")
|
||||
|
||||
netMap, err := fetchNetMap()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to fetch network map: %v\n", err)
|
||||
return err
|
||||
data := &jsonoutput.DNSStatusResult{
|
||||
TailscaleDNS: prefs.CorpDNS,
|
||||
}
|
||||
dnsConfig := netMap.DNS
|
||||
fmt.Println("Resolvers (in preference order):")
|
||||
if len(dnsConfig.Resolvers) == 0 {
|
||||
fmt.Println(" (no resolvers configured, system default will be used: see 'System DNS configuration' below)")
|
||||
}
|
||||
for _, r := range dnsConfig.Resolvers {
|
||||
fmt.Printf(" - %v", r.Addr)
|
||||
if r.BootstrapResolution != nil {
|
||||
fmt.Printf(" (bootstrap: %v)", r.BootstrapResolution)
|
||||
|
||||
if s.CurrentTailnet != nil {
|
||||
data.CurrentTailnet = &jsonoutput.DNSTailnetInfo{
|
||||
MagicDNSEnabled: s.CurrentTailnet.MagicDNSEnabled,
|
||||
MagicDNSSuffix: s.CurrentTailnet.MagicDNSSuffix,
|
||||
SelfDNSName: s.Self.DNSName,
|
||||
}
|
||||
fmt.Print("\n")
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println("Split DNS Routes:")
|
||||
if len(dnsConfig.Routes) == 0 {
|
||||
fmt.Println(" (no routes configured: split DNS disabled)")
|
||||
}
|
||||
for _, k := range slices.Sorted(maps.Keys(dnsConfig.Routes)) {
|
||||
v := dnsConfig.Routes[k]
|
||||
for _, r := range v {
|
||||
fmt.Printf(" - %-30s -> %v", k, r.Addr)
|
||||
if r.BootstrapResolution != nil {
|
||||
fmt.Printf(" (bootstrap: %v)", r.BootstrapResolution)
|
||||
|
||||
netMap, err := fetchNetMap()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch network map: %w", err)
|
||||
}
|
||||
dnsConfig := netMap.DNS
|
||||
|
||||
for _, r := range dnsConfig.Resolvers {
|
||||
data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r))
|
||||
}
|
||||
|
||||
data.SplitDNSRoutes = make(map[string][]jsonoutput.DNSResolverInfo)
|
||||
for k, v := range dnsConfig.Routes {
|
||||
for _, r := range v {
|
||||
data.SplitDNSRoutes[k] = append(data.SplitDNSRoutes[k], makeDNSResolverInfo(r))
|
||||
}
|
||||
fmt.Print("\n")
|
||||
}
|
||||
}
|
||||
fmt.Print("\n")
|
||||
if all {
|
||||
fmt.Println("Fallback Resolvers:")
|
||||
if len(dnsConfig.FallbackResolvers) == 0 {
|
||||
fmt.Println(" (no fallback resolvers configured)")
|
||||
|
||||
for _, r := range dnsConfig.FallbackResolvers {
|
||||
data.FallbackResolvers = append(data.FallbackResolvers, makeDNSResolverInfo(r))
|
||||
}
|
||||
for i, r := range dnsConfig.FallbackResolvers {
|
||||
fmt.Printf(" %d: %v\n", i, r)
|
||||
}
|
||||
fmt.Print("\n")
|
||||
}
|
||||
fmt.Println("Search Domains:")
|
||||
if len(dnsConfig.Domains) == 0 {
|
||||
fmt.Println(" (no search domains configured)")
|
||||
}
|
||||
domains := dnsConfig.Domains
|
||||
slices.Sort(domains)
|
||||
for _, r := range domains {
|
||||
fmt.Printf(" - %v\n", r)
|
||||
}
|
||||
fmt.Print("\n")
|
||||
if all {
|
||||
fmt.Println("Nameservers IP Addresses:")
|
||||
if len(dnsConfig.Nameservers) == 0 {
|
||||
fmt.Println(" (none were provided)")
|
||||
}
|
||||
for _, r := range dnsConfig.Nameservers {
|
||||
fmt.Printf(" - %v\n", r)
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println("Certificate Domains:")
|
||||
if len(dnsConfig.CertDomains) == 0 {
|
||||
fmt.Println(" (no certificate domains are configured)")
|
||||
}
|
||||
for _, r := range dnsConfig.CertDomains {
|
||||
fmt.Printf(" - %v\n", r)
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println("Additional DNS Records:")
|
||||
if len(dnsConfig.ExtraRecords) == 0 {
|
||||
fmt.Println(" (no extra records are configured)")
|
||||
|
||||
domains := slices.Clone(dnsConfig.Domains)
|
||||
slices.Sort(domains)
|
||||
data.SearchDomains = domains
|
||||
|
||||
for _, a := range dnsConfig.Nameservers {
|
||||
data.Nameservers = append(data.Nameservers, a.String())
|
||||
}
|
||||
|
||||
data.CertDomains = dnsConfig.CertDomains
|
||||
|
||||
for _, er := range dnsConfig.ExtraRecords {
|
||||
if er.Type == "" {
|
||||
fmt.Printf(" - %-50s -> %v\n", er.Name, er.Value)
|
||||
data.ExtraRecords = append(data.ExtraRecords, jsonoutput.DNSExtraRecord{
|
||||
Name: er.Name,
|
||||
Type: er.Type,
|
||||
Value: er.Value,
|
||||
})
|
||||
}
|
||||
|
||||
data.ExitNodeFilteredSet = dnsConfig.ExitNodeFilteredSet
|
||||
|
||||
osCfg, err := localClient.GetDNSOSConfig(ctx)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not supported") {
|
||||
data.SystemDNSError = "not supported on this platform"
|
||||
} else {
|
||||
fmt.Printf(" - [%s] %-50s -> %v\n", er.Type, er.Name, er.Value)
|
||||
data.SystemDNSError = err.Error()
|
||||
}
|
||||
} else if osCfg != nil {
|
||||
data.SystemDNS = &jsonoutput.DNSSystemConfig{
|
||||
Nameservers: osCfg.Nameservers,
|
||||
SearchDomains: osCfg.SearchDomains,
|
||||
MatchDomains: osCfg.MatchDomains,
|
||||
}
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println("Filtered suffixes when forwarding DNS queries as an exit node:")
|
||||
if len(dnsConfig.ExitNodeFilteredSet) == 0 {
|
||||
fmt.Println(" (no suffixes are filtered)")
|
||||
}
|
||||
for _, s := range dnsConfig.ExitNodeFilteredSet {
|
||||
fmt.Printf(" - %s\n", s)
|
||||
}
|
||||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
fmt.Println("=== System DNS configuration ===")
|
||||
fmt.Print("\n")
|
||||
fmt.Println("This is the DNS configuration that Tailscale believes your operating system is using.\nTailscale may use this configuration if 'Override Local DNS' is disabled in the admin console,\nor if no resolvers are provided by the coordination server.")
|
||||
fmt.Print("\n")
|
||||
osCfg, err := localClient.GetDNSOSConfig(ctx)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not supported") {
|
||||
// avoids showing the HTTP error code which would be odd here
|
||||
fmt.Println(" (reading the system DNS configuration is not supported on this platform)")
|
||||
} else {
|
||||
fmt.Printf(" (failed to read system DNS configuration: %v)\n", err)
|
||||
if dnsStatusArgs.json {
|
||||
j, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if osCfg == nil {
|
||||
fmt.Println(" (no OS DNS configuration available)")
|
||||
printf("%s\n", j)
|
||||
return nil
|
||||
}
|
||||
printf("%s", formatDNSStatusText(data, dnsStatusArgs.all))
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDNSStatusText(data *jsonoutput.DNSStatusResult, all bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "=== 'Use Tailscale DNS' status ===\n")
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
if data.TailscaleDNS {
|
||||
fmt.Fprintf(&sb, "Tailscale DNS: enabled.\n\nTailscale is configured to handle DNS queries on this device.\nRun 'tailscale set --accept-dns=false' to revert to your system default DNS resolver.\n")
|
||||
} else {
|
||||
fmt.Println("Nameservers:")
|
||||
if len(osCfg.Nameservers) == 0 {
|
||||
fmt.Println(" (no nameservers found, DNS queries might fail\nunless the coordination server is providing a nameserver)")
|
||||
fmt.Fprintf(&sb, "Tailscale DNS: disabled.\n\n(Run 'tailscale set --accept-dns=true' to start sending DNS queries to the Tailscale DNS resolver)\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "=== MagicDNS configuration ===\n")
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "This is the DNS configuration provided by the coordination server to this device.\n")
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
if data.CurrentTailnet == nil {
|
||||
fmt.Fprintf(&sb, "No tailnet information available; make sure you're logged in to a tailnet.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
if data.CurrentTailnet.MagicDNSEnabled {
|
||||
fmt.Fprintf(&sb, "MagicDNS: enabled tailnet-wide (suffix = %s)", data.CurrentTailnet.MagicDNSSuffix)
|
||||
fmt.Fprintf(&sb, "\n\n")
|
||||
fmt.Fprintf(&sb, "Other devices in your tailnet can reach this device at %s\n", data.CurrentTailnet.SelfDNSName)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "MagicDNS: disabled tailnet-wide.\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
fmt.Fprintf(&sb, "Resolvers (in preference order):\n")
|
||||
if len(data.Resolvers) == 0 {
|
||||
fmt.Fprintf(&sb, " (no resolvers configured, system default will be used: see 'System DNS configuration' below)\n")
|
||||
}
|
||||
for _, r := range data.Resolvers {
|
||||
fmt.Fprintf(&sb, " - %v", r.Addr)
|
||||
if r.BootstrapResolution != nil {
|
||||
fmt.Fprintf(&sb, " (bootstrap: %v)", r.BootstrapResolution)
|
||||
}
|
||||
for _, ns := range osCfg.Nameservers {
|
||||
fmt.Printf(" - %v\n", ns)
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
fmt.Fprintf(&sb, "Split DNS Routes:\n")
|
||||
if len(data.SplitDNSRoutes) == 0 {
|
||||
fmt.Fprintf(&sb, " (no routes configured: split DNS disabled)\n")
|
||||
}
|
||||
for _, k := range slices.Sorted(maps.Keys(data.SplitDNSRoutes)) {
|
||||
for _, r := range data.SplitDNSRoutes[k] {
|
||||
fmt.Fprintf(&sb, " - %-30s -> %v", k, r.Addr)
|
||||
if r.BootstrapResolution != nil {
|
||||
fmt.Fprintf(&sb, " (bootstrap: %v)", r.BootstrapResolution)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println("Search domains:")
|
||||
if len(osCfg.SearchDomains) == 0 {
|
||||
fmt.Println(" (no search domains found)")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
if all {
|
||||
fmt.Fprintf(&sb, "Fallback Resolvers:\n")
|
||||
if len(data.FallbackResolvers) == 0 {
|
||||
fmt.Fprintf(&sb, " (no fallback resolvers configured)\n")
|
||||
}
|
||||
for _, sd := range osCfg.SearchDomains {
|
||||
fmt.Printf(" - %v\n", sd)
|
||||
for i, r := range data.FallbackResolvers {
|
||||
fmt.Fprintf(&sb, " %d: %v", i, r.Addr)
|
||||
if r.BootstrapResolution != nil {
|
||||
fmt.Fprintf(&sb, " (bootstrap: %v)", r.BootstrapResolution)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "Search Domains:\n")
|
||||
if len(data.SearchDomains) == 0 {
|
||||
fmt.Fprintf(&sb, " (no search domains configured)\n")
|
||||
}
|
||||
for _, r := range data.SearchDomains {
|
||||
fmt.Fprintf(&sb, " - %v\n", r)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
if all {
|
||||
fmt.Fprintf(&sb, "Nameservers IP Addresses:\n")
|
||||
if len(data.Nameservers) == 0 {
|
||||
fmt.Fprintf(&sb, " (none were provided)\n")
|
||||
}
|
||||
for _, r := range data.Nameservers {
|
||||
fmt.Fprintf(&sb, " - %v\n", r)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
fmt.Fprintf(&sb, "Certificate Domains:\n")
|
||||
if len(data.CertDomains) == 0 {
|
||||
fmt.Fprintf(&sb, " (no certificate domains are configured)\n")
|
||||
}
|
||||
for _, r := range data.CertDomains {
|
||||
fmt.Fprintf(&sb, " - %v\n", r)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
fmt.Fprintf(&sb, "Additional DNS Records:\n")
|
||||
if len(data.ExtraRecords) == 0 {
|
||||
fmt.Fprintf(&sb, " (no extra records are configured)\n")
|
||||
}
|
||||
for _, er := range data.ExtraRecords {
|
||||
if er.Type == "" {
|
||||
fmt.Fprintf(&sb, " - %-50s -> %v\n", er.Name, er.Value)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " - [%s] %-50s -> %v\n", er.Type, er.Name, er.Value)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
fmt.Fprintf(&sb, "Filtered suffixes when forwarding DNS queries as an exit node:\n")
|
||||
if len(data.ExitNodeFilteredSet) == 0 {
|
||||
fmt.Fprintf(&sb, " (no suffixes are filtered)\n")
|
||||
}
|
||||
for _, s := range data.ExitNodeFilteredSet {
|
||||
fmt.Fprintf(&sb, " - %s\n", s)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "=== System DNS configuration ===\n")
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "This is the DNS configuration that Tailscale believes your operating system is using.\nTailscale may use this configuration if 'Override Local DNS' is disabled in the admin console,\nor if no resolvers are provided by the coordination server.\n")
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
|
||||
if data.SystemDNSError != "" {
|
||||
if strings.Contains(data.SystemDNSError, "not supported") {
|
||||
fmt.Fprintf(&sb, " (reading the system DNS configuration is not supported on this platform)\n")
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " (failed to read system DNS configuration: %s)\n", data.SystemDNSError)
|
||||
}
|
||||
} else if data.SystemDNS == nil {
|
||||
fmt.Fprintf(&sb, " (no OS DNS configuration available)\n")
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "Nameservers:\n")
|
||||
if len(data.SystemDNS.Nameservers) == 0 {
|
||||
fmt.Fprintf(&sb, " (no nameservers found, DNS queries might fail\nunless the coordination server is providing a nameserver)\n")
|
||||
}
|
||||
for _, ns := range data.SystemDNS.Nameservers {
|
||||
fmt.Fprintf(&sb, " - %v\n", ns)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "Search domains:\n")
|
||||
if len(data.SystemDNS.SearchDomains) == 0 {
|
||||
fmt.Fprintf(&sb, " (no search domains found)\n")
|
||||
}
|
||||
for _, sd := range data.SystemDNS.SearchDomains {
|
||||
fmt.Fprintf(&sb, " - %v\n", sd)
|
||||
}
|
||||
if all {
|
||||
fmt.Print("\n")
|
||||
fmt.Println("Match domains:")
|
||||
if len(osCfg.MatchDomains) == 0 {
|
||||
fmt.Println(" (no match domains found)")
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "Match domains:\n")
|
||||
if len(data.SystemDNS.MatchDomains) == 0 {
|
||||
fmt.Fprintf(&sb, " (no match domains found)\n")
|
||||
}
|
||||
for _, md := range osCfg.MatchDomains {
|
||||
fmt.Printf(" - %v\n", md)
|
||||
for _, md := range data.SystemDNS.MatchDomains {
|
||||
fmt.Fprintf(&sb, " - %v\n", md)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println("[this is a preliminary version of this command; the output format may change in the future]")
|
||||
return nil
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "[this is a preliminary version of this command; the output format may change in the future]\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func fetchNetMap() (netMap *netmap.NetworkMap, err error) {
|
||||
|
||||
65
cmd/tailscale/cli/dns_test.go
Normal file
65
cmd/tailscale/cli/dns_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunDNSQueryArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no_args",
|
||||
args: []string{},
|
||||
wantErr: "missing required argument: name",
|
||||
},
|
||||
{
|
||||
name: "flag_after_name",
|
||||
args: []string{"example.com", "--json"},
|
||||
wantErr: "unexpected flags after query name: --json",
|
||||
},
|
||||
{
|
||||
name: "flag_after_name_and_type",
|
||||
args: []string{"example.com", "AAAA", "--json"},
|
||||
wantErr: "unexpected flags after query name: --json",
|
||||
},
|
||||
{
|
||||
name: "extra_args_after_type",
|
||||
args: []string{"example.com", "AAAA", "extra"},
|
||||
wantErr: "unexpected extra arguments: extra",
|
||||
},
|
||||
{
|
||||
name: "multiple_extra_args",
|
||||
args: []string{"example.com", "AAAA", "extra1", "extra2"},
|
||||
wantErr: "unexpected extra arguments: extra1 extra2",
|
||||
},
|
||||
{
|
||||
name: "non_flag_then_flag",
|
||||
args: []string{"example.com", "AAAA", "foo", "--json"},
|
||||
wantErr: "unexpected flags after query name: --json",
|
||||
},
|
||||
{
|
||||
name: "multiple_misplaced_flags",
|
||||
args: []string{"example.com", "--json", "--verbose"},
|
||||
wantErr: "unexpected flags after query name: --json, --verbose",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runDNSQuery(context.Background(), tt.args)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
cmd/tailscale/cli/jsonoutput/dns.go
Normal file
116
cmd/tailscale/cli/jsonoutput/dns.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package jsonoutput
|
||||
|
||||
// DNSResolverInfo is the JSON form of [dnstype.Resolver].
|
||||
type DNSResolverInfo struct {
|
||||
// Addr is a plain IP, IP:port, DoH URL, or HTTP-over-WireGuard URL.
|
||||
Addr string
|
||||
|
||||
// BootstrapResolution is optional pre-resolved IPs for DoT/DoH
|
||||
// resolvers whose address is not already an IP.
|
||||
BootstrapResolution []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSExtraRecord is the JSON form of [tailcfg.DNSRecord].
|
||||
type DNSExtraRecord struct {
|
||||
Name string
|
||||
Type string `json:",omitempty"` // empty means A or AAAA, depending on Value
|
||||
Value string // typically an IP address
|
||||
}
|
||||
|
||||
// DNSSystemConfig is the OS DNS configuration as observed by Tailscale,
|
||||
// mirroring [net/dns.OSConfig].
|
||||
type DNSSystemConfig struct {
|
||||
Nameservers []string `json:",omitzero"`
|
||||
SearchDomains []string `json:",omitzero"`
|
||||
|
||||
// MatchDomains are DNS suffixes restricting which queries use
|
||||
// these Nameservers. Empty means Nameservers is the primary
|
||||
// resolver.
|
||||
MatchDomains []string `json:",omitzero"`
|
||||
}
|
||||
|
||||
// DNSTailnetInfo describes MagicDNS configuration for the tailnet,
|
||||
// combining [ipnstate.TailnetStatus] and [ipnstate.PeerStatus].
|
||||
type DNSTailnetInfo struct {
|
||||
// MagicDNSEnabled is whether MagicDNS is enabled for the
|
||||
// tailnet. The device may still not use it if
|
||||
// --accept-dns=false.
|
||||
MagicDNSEnabled bool
|
||||
|
||||
// MagicDNSSuffix is the tailnet's MagicDNS suffix
|
||||
// (e.g. "tail1234.ts.net"), without surrounding dots.
|
||||
MagicDNSSuffix string `json:",omitempty"`
|
||||
|
||||
// SelfDNSName is this device's FQDN
|
||||
// (e.g. "host.tail1234.ts.net."), with trailing dot.
|
||||
SelfDNSName string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSStatusResult is the full DNS status collected from the local
|
||||
// Tailscale daemon.
|
||||
type DNSStatusResult struct {
|
||||
// TailscaleDNS is whether the Tailscale DNS configuration is
|
||||
// installed on this device (the --accept-dns setting).
|
||||
TailscaleDNS bool
|
||||
|
||||
// CurrentTailnet describes MagicDNS configuration for the tailnet.
|
||||
CurrentTailnet *DNSTailnetInfo `json:",omitzero"` // nil if not connected
|
||||
|
||||
// Resolvers are the DNS resolvers, in preference order. If
|
||||
// empty, the system defaults are used.
|
||||
Resolvers []DNSResolverInfo `json:",omitzero"`
|
||||
|
||||
// SplitDNSRoutes maps domain suffixes to dedicated resolvers.
|
||||
// An empty resolver slice means the suffix is handled by
|
||||
// Tailscale's built-in resolver (100.100.100.100).
|
||||
SplitDNSRoutes map[string][]DNSResolverInfo `json:",omitzero"`
|
||||
|
||||
// FallbackResolvers are like Resolvers but only used when
|
||||
// split DNS needs explicit default resolvers.
|
||||
FallbackResolvers []DNSResolverInfo `json:",omitzero"`
|
||||
|
||||
SearchDomains []string `json:",omitzero"`
|
||||
|
||||
// Nameservers are nameserver IPs.
|
||||
//
|
||||
// Deprecated: old protocol versions only. Use Resolvers.
|
||||
Nameservers []string `json:",omitzero"`
|
||||
|
||||
// CertDomains are FQDNs for which the coordination server
|
||||
// provisions TLS certificates via dns-01 ACME challenges.
|
||||
CertDomains []string `json:",omitzero"`
|
||||
|
||||
// ExtraRecords contains extra DNS records in the MagicDNS config.
|
||||
ExtraRecords []DNSExtraRecord `json:",omitzero"`
|
||||
|
||||
// ExitNodeFilteredSet are DNS suffixes this node won't resolve
|
||||
// when acting as an exit node DNS proxy. Period-prefixed
|
||||
// entries are suffix matches; others are exact. Always
|
||||
// lowercase, no trailing dots.
|
||||
ExitNodeFilteredSet []string `json:",omitzero"`
|
||||
|
||||
SystemDNS *DNSSystemConfig `json:",omitzero"` // nil if unavailable
|
||||
SystemDNSError string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSAnswer is a single DNS resource record from a query response.
|
||||
type DNSAnswer struct {
|
||||
Name string
|
||||
TTL uint32
|
||||
Class string // e.g. "ClassINET"
|
||||
Type string // e.g. "TypeA", "TypeAAAA"
|
||||
Body string // human-readable record data
|
||||
}
|
||||
|
||||
// DNSQueryResult is the result of a DNS query via the Tailscale
|
||||
// internal forwarder (100.100.100.100).
|
||||
type DNSQueryResult struct {
|
||||
Name string
|
||||
QueryType string // e.g. "A", "AAAA"
|
||||
Resolvers []DNSResolverInfo `json:",omitzero"`
|
||||
ResponseCode string // e.g. "RCodeSuccess", "RCodeNameError"
|
||||
Answers []DNSAnswer `json:",omitzero"`
|
||||
}
|
||||
@@ -54,6 +54,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/cmd/tailscale/cli/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
|
||||
tailscale.com/cmd/tailscale/cli/jsonoutput from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
||||
tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+
|
||||
|
||||
Reference in New Issue
Block a user