cmd/tailscale/tsdnsjsonv0: extract a new package for tailscale dns --json (#20017)

This patch extracts all the DNS related JSON handling from the
cmd/tailscale/jsonoutput package into a new tsdnsjsonv0 package.

It adds package documentation for tsdnsjsonv0 with a big WARNING that
this is an unstable format with no backwards compatibility guarantees.
When we stabilize this format, we should spin off a new tsdnsjsonv1
package that uses jsonoutput.ResponseEnvelope to declare version 1.

Updates #13326
Updates #18750

Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
Simon Law
2026-07-27 13:59:29 -07:00
committed by GitHub
parent 358c975aea
commit f3ec43d7dd
9 changed files with 93 additions and 74 deletions
+7 -7
View File
@@ -16,7 +16,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/net/dns/dnsmessage" "golang.org/x/net/dns/dnsmessage"
"tailscale.com/cmd/tailscale/jsonoutput" "tailscale.com/cmd/tailscale/tsdnsjsonv0"
) )
var dnsQueryArgs struct { var dnsQueryArgs struct {
@@ -74,7 +74,7 @@ func runDNSQuery(ctx context.Context, args []string) error {
return fmt.Errorf("failed to query DNS: %w", err) return fmt.Errorf("failed to query DNS: %w", err)
} }
data := &jsonoutput.DNSQueryResult{ data := &tsdnsjsonv0.QueryResponse{
Name: name, Name: name,
QueryType: queryType, QueryType: queryType,
} }
@@ -97,9 +97,9 @@ func runDNSQuery(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to parse DNS answers: %w", err) return fmt.Errorf("failed to parse DNS answers: %w", err)
} }
data.Answers = make([]jsonoutput.DNSAnswer, 0, len(answers)) data.Answers = make([]tsdnsjsonv0.Answer, 0, len(answers))
for _, a := range answers { for _, a := range answers {
data.Answers = append(data.Answers, jsonoutput.DNSAnswer{ data.Answers = append(data.Answers, tsdnsjsonv0.Answer{
Name: a.Header.Name.String(), Name: a.Header.Name.String(),
TTL: a.Header.TTL, TTL: a.Header.TTL,
Class: a.Header.Class.String(), Class: a.Header.Class.String(),
@@ -121,7 +121,7 @@ func runDNSQuery(ctx context.Context, args []string) error {
return nil return nil
} }
func formatDNSQueryText(data *jsonoutput.DNSQueryResult) string { func formatDNSQueryText(data *tsdnsjsonv0.QueryResponse) string {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "DNS query for %q (%s) using internal resolver:\n", data.Name, data.QueryType) fmt.Fprintf(&sb, "DNS query for %q (%s) using internal resolver:\n", data.Name, data.QueryType)
@@ -159,8 +159,8 @@ func formatDNSQueryText(data *jsonoutput.DNSQueryResult) string {
return sb.String() return sb.String()
} }
// formatResolverString formats a jsonoutput.DNSResolverInfo for human-readable text output. // formatResolverString formats a [tsdnsjsonv0.ResolverInfo] for human-readable text output.
func formatResolverString(r jsonoutput.DNSResolverInfo) string { func formatResolverString(r tsdnsjsonv0.ResolverInfo) string {
if len(r.BootstrapResolution) > 0 { if len(r.BootstrapResolution) > 0 {
return fmt.Sprintf("%s (bootstrap: %v)", r.Addr, r.BootstrapResolution) return fmt.Sprintf("%s (bootstrap: %v)", r.Addr, r.BootstrapResolution)
} }
+10 -10
View File
@@ -14,7 +14,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/jsonoutput" "tailscale.com/cmd/tailscale/tsdnsjsonv0"
"tailscale.com/types/dnstype" "tailscale.com/types/dnstype"
) )
@@ -85,9 +85,9 @@ var dnsStatusArgs struct {
json bool json bool
} }
// makeDNSResolverInfo converts a dnstype.Resolver to a jsonoutput.DNSResolverInfo. // makeDNSResolverInfo converts a [dnstype.Resolver] to a [tsdnsjsonv0.ResolverInfo].
func makeDNSResolverInfo(r *dnstype.Resolver) jsonoutput.DNSResolverInfo { func makeDNSResolverInfo(r *dnstype.Resolver) tsdnsjsonv0.ResolverInfo {
info := jsonoutput.DNSResolverInfo{Addr: r.Addr} info := tsdnsjsonv0.ResolverInfo{Addr: r.Addr}
if r.BootstrapResolution != nil { if r.BootstrapResolution != nil {
info.BootstrapResolution = make([]string, 0, len(r.BootstrapResolution)) info.BootstrapResolution = make([]string, 0, len(r.BootstrapResolution))
for _, a := range r.BootstrapResolution { for _, a := range r.BootstrapResolution {
@@ -108,12 +108,12 @@ func runDNSStatus(ctx context.Context, args []string) error {
return err return err
} }
data := &jsonoutput.DNSStatusResult{ data := &tsdnsjsonv0.StatusResponse{
TailscaleDNS: prefs.CorpDNS, TailscaleDNS: prefs.CorpDNS,
} }
if s.CurrentTailnet != nil { if s.CurrentTailnet != nil {
data.CurrentTailnet = &jsonoutput.DNSTailnetInfo{ data.CurrentTailnet = &tsdnsjsonv0.TailnetInfo{
MagicDNSEnabled: s.CurrentTailnet.MagicDNSEnabled, MagicDNSEnabled: s.CurrentTailnet.MagicDNSEnabled,
MagicDNSSuffix: s.CurrentTailnet.MagicDNSSuffix, MagicDNSSuffix: s.CurrentTailnet.MagicDNSSuffix,
SelfDNSName: s.Self.DNSName, SelfDNSName: s.Self.DNSName,
@@ -128,7 +128,7 @@ func runDNSStatus(ctx context.Context, args []string) error {
data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r)) data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r))
} }
data.SplitDNSRoutes = make(map[string][]jsonoutput.DNSResolverInfo) data.SplitDNSRoutes = make(map[string][]tsdnsjsonv0.ResolverInfo)
for k, v := range dnsConfig.Routes { for k, v := range dnsConfig.Routes {
for _, r := range v { for _, r := range v {
data.SplitDNSRoutes[k] = append(data.SplitDNSRoutes[k], makeDNSResolverInfo(r)) data.SplitDNSRoutes[k] = append(data.SplitDNSRoutes[k], makeDNSResolverInfo(r))
@@ -150,7 +150,7 @@ func runDNSStatus(ctx context.Context, args []string) error {
data.CertDomains = dnsConfig.CertDomains data.CertDomains = dnsConfig.CertDomains
for _, er := range dnsConfig.ExtraRecords { for _, er := range dnsConfig.ExtraRecords {
data.ExtraRecords = append(data.ExtraRecords, jsonoutput.DNSExtraRecord{ data.ExtraRecords = append(data.ExtraRecords, tsdnsjsonv0.ExtraRecord{
Name: er.Name, Name: er.Name,
Type: er.Type, Type: er.Type,
Value: er.Value, Value: er.Value,
@@ -167,7 +167,7 @@ func runDNSStatus(ctx context.Context, args []string) error {
data.SystemDNSError = err.Error() data.SystemDNSError = err.Error()
} }
} else if osCfg != nil { } else if osCfg != nil {
data.SystemDNS = &jsonoutput.DNSSystemConfig{ data.SystemDNS = &tsdnsjsonv0.SystemConfig{
Nameservers: osCfg.Nameservers, Nameservers: osCfg.Nameservers,
SearchDomains: osCfg.SearchDomains, SearchDomains: osCfg.SearchDomains,
MatchDomains: osCfg.MatchDomains, MatchDomains: osCfg.MatchDomains,
@@ -187,7 +187,7 @@ func runDNSStatus(ctx context.Context, args []string) error {
return nil return nil
} }
func formatDNSStatusText(data *jsonoutput.DNSStatusResult, all bool) string { func formatDNSStatusText(data *tsdnsjsonv0.StatusResponse, all bool) string {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "\n") fmt.Fprintf(&sb, "\n")
+1
View File
@@ -187,6 +187,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli 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/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
tailscale.com/cmd/tailscale/jsonoutput from tailscale.com/cmd/tailscale/cli tailscale.com/cmd/tailscale/jsonoutput from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscale/tsdnsjsonv0 from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscale/tsroutecheckjsonv0 from tailscale.com/cmd/tailscale/cli tailscale.com/cmd/tailscale/tsroutecheckjsonv0 from tailscale.com/cmd/tailscale/cli
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+ tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
tailscale.com/control/controlhttp from tailscale.com/control/ts2021 tailscale.com/control/controlhttp from tailscale.com/control/ts2021
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package tsdnsjsonv0 provides types for unmarshalling the JSON output of the
// "tailscale dns --json" command:
//
// - [QueryResponse] will unmarshal the output of "tailscale dns query --json=1"
// - [StatusResponse] will unmarshal the output of "tailscale dns status --json=1".
//
// # WARNING: unstable
//
// Format is "v0" and is subject to change.
// There is no guarantee of backwards or forwards compatibility.
package tsdnsjsonv0
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors // Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package jsonoutput_test package tsdnsjsonv0_test
import ( import (
"encoding/json" "encoding/json"
@@ -10,10 +10,10 @@ import (
"os" "os"
"os/exec" "os/exec"
"tailscale.com/cmd/tailscale/jsonoutput" "tailscale.com/cmd/tailscale/tsdnsjsonv0"
) )
func ExampleDNSQueryResult() { func ExampleQueryResponse() {
cmd := exec.Command("tailscale", "dns", "query", "--json", "hello.ts.net") cmd := exec.Command("tailscale", "dns", "query", "--json", "hello.ts.net")
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
@@ -23,9 +23,9 @@ func ExampleDNSQueryResult() {
panic(err) panic(err)
} }
var dnsQuery jsonoutput.DNSQueryResult var resp tsdnsjsonv0.QueryResponse
if err := json.Unmarshal(out, &dnsQuery); err != nil { if err := json.Unmarshal(out, &resp); err != nil {
panic(err) panic(err)
} }
fmt.Printf("{type: %s, name: %q}\n", dnsQuery.QueryType, dnsQuery.Name) fmt.Printf("{type: %s, name: %q}\n", resp.QueryType, resp.Name)
} }
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors // Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package jsonoutput_test package tsdnsjsonv0_test
import ( import (
"encoding/json" "encoding/json"
@@ -10,10 +10,10 @@ import (
"os" "os"
"os/exec" "os/exec"
"tailscale.com/cmd/tailscale/jsonoutput" "tailscale.com/cmd/tailscale/tsdnsjsonv0"
) )
func ExampleDNSStatusResult() { func ExampleStatusResponse() {
cmd := exec.Command("tailscale", "dns", "status", "--json") cmd := exec.Command("tailscale", "dns", "status", "--json")
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
@@ -23,9 +23,9 @@ func ExampleDNSStatusResult() {
panic(err) panic(err)
} }
var dnsStatus jsonoutput.DNSStatusResult var resp tsdnsjsonv0.StatusResponse
if err := json.Unmarshal(out, &dnsStatus); err != nil { if err := json.Unmarshal(out, &resp); err != nil {
panic(err) panic(err)
} }
fmt.Printf("{accept-dns: %t, resolvers: %q}\n", dnsStatus.TailscaleDNS, dnsStatus.Resolvers) fmt.Printf("{accept-dns: %t, resolvers: %q}\n", resp.TailscaleDNS, resp.Resolvers)
} }
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsdnsjsonv0
// Answer is a single DNS resource record from a query response.
type Answer struct {
Name string
TTL uint32
Class string // e.g. "ClassINET"
Type string // e.g. "TypeA", "TypeAAAA"
Body string // human-readable record data
}
// QueryResponse is the result of a DNS query via the Tailscale
// internal forwarder (100.100.100.100). It is the output of:
//
// $ tailscale dns query --json NAME
type QueryResponse struct {
Name string
QueryType string // e.g. "A", "AAAA"
Resolvers []ResolverInfo `json:",omitzero"`
ResponseCode string // e.g. "RCodeSuccess", "RCodeNameError"
Answers []Answer `json:",omitzero"`
}
@@ -1,10 +1,17 @@
// Copyright (c) Tailscale Inc & contributors // Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package jsonoutput package tsdnsjsonv0
// DNSResolverInfo is the JSON form of [tailscale.com/types/dnstype.Resolver]. // ExtraRecord is the JSON form of [tailscale.com/tailcfg.DNSRecord].
type DNSResolverInfo struct { type ExtraRecord struct {
Name string
Type string `json:",omitempty"` // empty means A or AAAA, depending on Value
Value string // typically an IP address
}
// ResolverInfo is the JSON form of [tailscale.com/types/dnstype.Resolver].
type ResolverInfo struct {
// Addr is a plain IP, IP:port, DoH URL, or HTTP-over-WireGuard URL. // Addr is a plain IP, IP:port, DoH URL, or HTTP-over-WireGuard URL.
Addr string Addr string
@@ -13,16 +20,9 @@ type DNSResolverInfo struct {
BootstrapResolution []string `json:",omitempty"` BootstrapResolution []string `json:",omitempty"`
} }
// DNSExtraRecord is the JSON form of [tailscale.com/tailcfg.DNSRecord]. // SystemConfig is the OS DNS configuration as observed by Tailscale,
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 [tailscale.com/net/dns.OSConfig]. // mirroring [tailscale.com/net/dns.OSConfig].
type DNSSystemConfig struct { type SystemConfig struct {
Nameservers []string `json:",omitzero"` Nameservers []string `json:",omitzero"`
SearchDomains []string `json:",omitzero"` SearchDomains []string `json:",omitzero"`
@@ -32,10 +32,10 @@ type DNSSystemConfig struct {
MatchDomains []string `json:",omitzero"` MatchDomains []string `json:",omitzero"`
} }
// DNSTailnetInfo describes MagicDNS configuration for the tailnet, // TailnetInfo describes MagicDNS configuration for the tailnet,
// combining [tailscale.com/ipn/ipnstate.TailnetStatus] // combining [tailscale.com/ipn/ipnstate.TailnetStatus]
// and [tailscale.com/ipn/ipnstate.PeerStatus]. // and [tailscale.com/ipn/ipnstate.PeerStatus].
type DNSTailnetInfo struct { type TailnetInfo struct {
// MagicDNSEnabled is whether MagicDNS is enabled for the // MagicDNSEnabled is whether MagicDNS is enabled for the
// tailnet. The device may still not use it if // tailnet. The device may still not use it if
// --accept-dns=false. // --accept-dns=false.
@@ -50,30 +50,30 @@ type DNSTailnetInfo struct {
SelfDNSName string `json:",omitempty"` SelfDNSName string `json:",omitempty"`
} }
// DNSStatusResult is the full DNS status collected from the local // StatusResponse is the full DNS status collected from the local
// Tailscale daemon. It is the output of: // Tailscale daemon. It is the output of:
// //
// $ tailscale dns status --json // $ tailscale dns status --json
type DNSStatusResult struct { type StatusResponse struct {
// TailscaleDNS is whether the Tailscale DNS configuration is // TailscaleDNS is whether the Tailscale DNS configuration is
// installed on this device (the --accept-dns setting). // installed on this device (the --accept-dns setting).
TailscaleDNS bool TailscaleDNS bool
// CurrentTailnet describes MagicDNS configuration for the tailnet. // CurrentTailnet describes MagicDNS configuration for the tailnet.
CurrentTailnet *DNSTailnetInfo `json:",omitzero"` // nil if not connected CurrentTailnet *TailnetInfo `json:",omitzero"` // nil if not connected
// Resolvers are the DNS resolvers, in preference order. If // Resolvers are the DNS resolvers, in preference order. If
// empty, the system defaults are used. // empty, the system defaults are used.
Resolvers []DNSResolverInfo `json:",omitzero"` Resolvers []ResolverInfo `json:",omitzero"`
// SplitDNSRoutes maps domain suffixes to dedicated resolvers. // SplitDNSRoutes maps domain suffixes to dedicated resolvers.
// An empty resolver slice means the suffix is handled by // An empty resolver slice means the suffix is handled by
// Tailscale's built-in resolver (100.100.100.100). // Tailscale's built-in resolver (100.100.100.100).
SplitDNSRoutes map[string][]DNSResolverInfo `json:",omitzero"` SplitDNSRoutes map[string][]ResolverInfo `json:",omitzero"`
// FallbackResolvers are like Resolvers but only used when // FallbackResolvers are like Resolvers but only used when
// split DNS needs explicit default resolvers. // split DNS needs explicit default resolvers.
FallbackResolvers []DNSResolverInfo `json:",omitzero"` FallbackResolvers []ResolverInfo `json:",omitzero"`
SearchDomains []string `json:",omitzero"` SearchDomains []string `json:",omitzero"`
@@ -87,7 +87,7 @@ type DNSStatusResult struct {
CertDomains []string `json:",omitzero"` CertDomains []string `json:",omitzero"`
// ExtraRecords contains extra DNS records in the MagicDNS config. // ExtraRecords contains extra DNS records in the MagicDNS config.
ExtraRecords []DNSExtraRecord `json:",omitzero"` ExtraRecords []ExtraRecord `json:",omitzero"`
// ExitNodeFilteredSet are DNS suffixes this node won't resolve // ExitNodeFilteredSet are DNS suffixes this node won't resolve
// when acting as an exit node DNS proxy. Period-prefixed // when acting as an exit node DNS proxy. Period-prefixed
@@ -95,27 +95,6 @@ type DNSStatusResult struct {
// lowercase, no trailing dots. // lowercase, no trailing dots.
ExitNodeFilteredSet []string `json:",omitzero"` ExitNodeFilteredSet []string `json:",omitzero"`
SystemDNS *DNSSystemConfig `json:",omitzero"` // nil if unavailable SystemDNS *SystemConfig `json:",omitzero"` // nil if unavailable
SystemDNSError string `json:",omitempty"` 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). It is the output of:
//
// $ tailscale dns query --json NAME
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"`
}
+1 -1
View File
@@ -55,7 +55,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 from tailscale.com/cmd/tailscaled
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli 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/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
tailscale.com/cmd/tailscale/jsonoutput from tailscale.com/cmd/tailscale/cli tailscale.com/cmd/tailscale/tsdnsjsonv0 from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+ tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+ tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+