From d060b3fa02f50b439489d6b7c833425625079c0f Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Wed, 4 Sep 2024 12:43:55 -0700 Subject: [PATCH] cli: implement `tailscale dns status` (#13353) Updates tailscale/tailscale#13326 This PR begins implementing a `tailscale dns` command group in the Tailscale CLI. It provides an initial implementation of `tailscale dns status` which dumps the state of the internal DNS forwarder. Two new endpoints were added in LocalAPI to support the CLI functionality: - `/netmap`: dumps a copy of the last received network map (because the CLI shouldn't have to listen to the ipn bus for a copy) - `/dns-osconfig`: dumps the OS DNS configuration (this will be very handy for the UI clients as well, as they currently do not display this information) My plan is to implement other subcommands mentioned in tailscale/tailscale#13326, such as `query`, in later PRs. Signed-off-by: Andrea Gottardo --- client/tailscale/apitype/apitype.go | 8 + client/tailscale/localclient.go | 12 ++ cmd/tailscale/cli/cli.go | 1 + cmd/tailscale/cli/dns-status.go | 246 ++++++++++++++++++++++++++++ cmd/tailscale/cli/dns.go | 44 +++++ cmd/tailscale/depaware.txt | 2 +- ipn/ipnlocal/local.go | 9 + ipn/localapi/localapi.go | 39 +++++ net/dns/manager.go | 5 + 9 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 cmd/tailscale/cli/dns-status.go create mode 100644 cmd/tailscale/cli/dns.go diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index 1fcd70a51..81879aac3 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -57,3 +57,11 @@ type ExitNodeSuggestionResponse struct { Name string Location tailcfg.LocationView `json:",omitempty"` } + +// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package +// into the CLI. +type DNSOSConfig struct { + Nameservers []string + SearchDomains []string + MatchDomains []string +} diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 98371393d..29e28a154 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -813,6 +813,18 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn return decodeJSON[*ipn.Prefs](body) } +func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) { + body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig") + if err != nil { + return nil, err + } + var osCfg apitype.DNSOSConfig + if err := json.Unmarshal(body, &osCfg); err != nil { + return nil, fmt.Errorf("invalid dns.OSConfig: %w", err) + } + return &osCfg, 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/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index efbdd3e40..864cf6903 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -187,6 +187,7 @@ func newRootCmd() *ffcli.Command { configureCmd, netcheckCmd, ipCmd, + dnsCmd, statusCmd, pingCmd, ncCmd, diff --git a/cmd/tailscale/cli/dns-status.go b/cmd/tailscale/cli/dns-status.go new file mode 100644 index 000000000..f412f6c31 --- /dev/null +++ b/cmd/tailscale/cli/dns-status.go @@ -0,0 +1,246 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "fmt" + "slices" + "strings" + + "tailscale.com/ipn" + "tailscale.com/types/netmap" +) + +// dnsStatusArgs are the arguments for the "dns status" subcommand. +var dnsStatusArgs struct { + all bool +} + +func runDNSStatus(ctx context.Context, args []string) error { + all := dnsStatusArgs.all + s, err := localClient.Status(ctx) + if err != nil { + return err + } + + prefs, err := localClient.GetPrefs(ctx) + 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 into 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 + } + 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) + } + fmt.Print("\n") + } + 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)") + } + routesKeys := make([]string, 0, len(dnsConfig.Routes)) + for k := range dnsConfig.Routes { + routesKeys = append(routesKeys, k) + } + slices.Sort(routesKeys) + for _, k := range routesKeys { + 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) + } + fmt.Print("\n") + } + } + fmt.Print("\n") + if all { + fmt.Println("Fallback Resolvers:") + if len(dnsConfig.FallbackResolvers) == 0 { + fmt.Println(" (no fallback resolvers configured)") + } + 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)") + } + for _, er := range dnsConfig.ExtraRecords { + if er.Type == "" { + fmt.Printf(" - %-50s -> %v\n", er.Name, er.Value) + } else { + fmt.Printf(" - [%s] %-50s -> %v\n", er.Type, er.Name, er.Value) + } + } + 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) + } + } else if osCfg == nil { + fmt.Println(" (no OS DNS configuration available)") + } 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)") + } + for _, ns := range osCfg.Nameservers { + fmt.Printf(" - %v\n", ns) + } + fmt.Print("\n") + fmt.Println("Search domains:") + if len(osCfg.SearchDomains) == 0 { + fmt.Println(" (no search domains found)") + } + for _, sd := range osCfg.SearchDomains { + fmt.Printf(" - %v\n", sd) + } + if all { + fmt.Print("\n") + fmt.Println("Match domains:") + if len(osCfg.MatchDomains) == 0 { + fmt.Println(" (no match domains found)") + } + for _, md := range osCfg.MatchDomains { + fmt.Printf(" - %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 +} + +func fetchNetMap() (netMap *netmap.NetworkMap, err error) { + w, err := localClient.WatchIPNBus(context.Background(), ipn.NotifyInitialNetMap) + if err != nil { + return nil, err + } + defer w.Close() + notify, err := w.Next() + if err != nil { + return nil, err + } + if notify.NetMap == nil { + return nil, fmt.Errorf("no network map yet available, please try again later") + } + return notify.NetMap, nil +} + +func dnsStatusLongHelp() string { + return `The 'tailscale dns status' subcommand prints the current DNS status and configuration, including: + +- Whether the built-in DNS forwarder is enabled. +- The MagicDNS configuration provided by the coordination server. +- Details on which resolver(s) Tailscale believes the system is using by default. + +The --all flag can be used to output advanced debugging information, including fallback resolvers, nameservers, certificate domains, extra records, and the exit node filtered set. + +=== Contents of the MagicDNS configuration === + +The MagicDNS configuration is provided by the coordination server to the client and includes the following components: + +- MagicDNS enablement status: Indicates whether MagicDNS is enabled across the entire tailnet. + +- MagicDNS Suffix: The DNS suffix used for devices within your tailnet. + +- DNS Name: The DNS name that other devices in the tailnet can use to reach this device. + +- Resolvers: The preferred DNS resolver(s) to be used for resolving queries, in order of preference. If no resolvers are listed here, the system defaults are used. + +- Split DNS Routes: Custom DNS resolvers may be used to resolve hostnames in specific domains, this is also known as a 'Split DNS' configuration. The mapping of domains to their respective resolvers is provided here. + +- Certificate Domains: The DNS names for which the coordination server will assist in provisioning TLS certificates. + +- Extra Records: Additional DNS records that the coordination server might provide to the internal DNS resolver. + +- Exit Node Filtered Set: DNS suffixes that the node, when acting as an exit node DNS proxy, will not answer. + +For more information about the DNS functionality built into Tailscale, refer to https://tailscale.com/kb/1054/dns.` +} diff --git a/cmd/tailscale/cli/dns.go b/cmd/tailscale/cli/dns.go new file mode 100644 index 000000000..282555695 --- /dev/null +++ b/cmd/tailscale/cli/dns.go @@ -0,0 +1,44 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "flag" + + "github.com/peterbourgon/ff/v3/ffcli" +) + +var dnsCmd = &ffcli.Command{ + Name: "dns", + ShortHelp: "Diagnose the internal DNS forwarder", + LongHelp: dnsCmdLongHelp(), + ShortUsage: "tailscale dns [flags]", + UsageFunc: usageFuncNoDefaultValues, + Subcommands: []*ffcli.Command{ + { + Name: "status", + ShortUsage: "tailscale dns status [--all]", + Exec: runDNSStatus, + ShortHelp: "Prints the current DNS status and configuration", + LongHelp: dnsStatusLongHelp(), + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("status") + fs.BoolVar(&dnsStatusArgs.all, "all", false, "outputs advanced debugging information (fallback resolvers, nameservers, cert domains, extra records, and exit node filtered set)") + return fs + })(), + }, + + // TODO: implement `tailscale query` here + + // TODO: implement `tailscale log` here + + // The above work is tracked in https://github.com/tailscale/tailscale/issues/13326 + }, +} + +func dnsCmdLongHelp() string { + return `The 'tailscale dns' subcommand provides tools for diagnosing the internal DNS forwarder (100.100.100.100). + +For more information about the DNS functionality built into Tailscale, refer to https://tailscale.com/kb/1054/dns.` +} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index b121ee019..b5d5735b7 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -139,7 +139,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/types/key from tailscale.com/client/tailscale+ tailscale.com/types/lazy from tailscale.com/util/testenv+ tailscale.com/types/logger from tailscale.com/client/web+ - tailscale.com/types/netmap from tailscale.com/ipn + tailscale.com/types/netmap from tailscale.com/ipn+ tailscale.com/types/nettype from tailscale.com/net/netcheck+ tailscale.com/types/opt from tailscale.com/client/tailscale+ tailscale.com/types/persist from tailscale.com/ipn diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 897539600..9a57776a0 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -597,6 +597,15 @@ func (b *LocalBackend) SetComponentDebugLogging(component string, until time.Tim return nil } +// GetDNSOSConfig returns the base OS DNS configuration, as seen by the DNS manager. +func (b *LocalBackend) GetDNSOSConfig() (dns.OSConfig, error) { + manager, ok := b.sys.DNSManager.GetOK() + if !ok { + return dns.OSConfig{}, errors.New("DNS manager not available") + } + return manager.GetBaseConfig() +} + // 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 1902a967c..01dc064cf 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -98,6 +98,7 @@ "derpmap": (*Handler).serveDERPMap, "dev-set-state-store": (*Handler).serveDevSetStateStore, "dial": (*Handler).serveDial, + "dns-osconfig": (*Handler).serveDNSOSConfig, "drive/fileserver-address": (*Handler).serveDriveServerAddr, "drive/shares": (*Handler).serveShares, "file-targets": (*Handler).serveFileTargets, @@ -2707,6 +2708,44 @@ func (h *Handler) serveUpdateProgress(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ups) } +// serveDNSOSConfig serves the current system DNS configuration as a JSON object, if +// supported by the OS. +func (h *Handler) serveDNSOSConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != httpm.GET { + http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) + return + } + // Require write access for privacy reasons. + if !h.PermitWrite { + http.Error(w, "dns-osconfig dump access denied", http.StatusForbidden) + return + } + bCfg, err := h.b.GetDNSOSConfig() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + nameservers := make([]string, 0, len(bCfg.Nameservers)) + for _, ns := range bCfg.Nameservers { + nameservers = append(nameservers, ns.String()) + } + searchDomains := make([]string, 0, len(bCfg.SearchDomains)) + for _, sd := range bCfg.SearchDomains { + searchDomains = append(searchDomains, sd.WithoutTrailingDot()) + } + matchDomains := make([]string, 0, len(bCfg.MatchDomains)) + for _, md := range bCfg.MatchDomains { + matchDomains = append(matchDomains, md.WithoutTrailingDot()) + } + response := apitype.DNSOSConfig{ + Nameservers: nameservers, + SearchDomains: searchDomains, + MatchDomains: matchDomains, + } + json.NewEncoder(w).Encode(response) +} + // 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/manager.go b/net/dns/manager.go index dfce5b2ac..51a0fa12c 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -122,6 +122,11 @@ func (m *Manager) Set(cfg Config) error { return m.setLocked(cfg) } +// GetBaseConfig returns the current base OS DNS configuration as provided by the OSConfigurator. +func (m *Manager) GetBaseConfig() (OSConfig, error) { + return m.os.GetBaseConfig() +} + // setLocked sets the DNS configuration. // // m.mu must be held.