diff --git a/config/crds/troubleshoot.sh_hostcollectors.yaml b/config/crds/troubleshoot.sh_hostcollectors.yaml index 433f5fe1..61b6907f 100644 --- a/config/crds/troubleshoot.sh_hostcollectors.yaml +++ b/config/crds/troubleshoot.sh_hostcollectors.yaml @@ -1278,6 +1278,19 @@ spec: required: - path type: object + dns: + properties: + collectorName: + type: string + exclude: + type: BoolString + hostnames: + items: + type: string + type: array + required: + - hostnames + type: object filesystemPerformance: description: |- FilesystemPerformance benchmarks sequential write latency on a single file. diff --git a/config/crds/troubleshoot.sh_hostpreflights.yaml b/config/crds/troubleshoot.sh_hostpreflights.yaml index 7039942d..907e3632 100644 --- a/config/crds/troubleshoot.sh_hostpreflights.yaml +++ b/config/crds/troubleshoot.sh_hostpreflights.yaml @@ -1278,6 +1278,19 @@ spec: required: - path type: object + dns: + properties: + collectorName: + type: string + exclude: + type: BoolString + hostnames: + items: + type: string + type: array + required: + - hostnames + type: object filesystemPerformance: description: |- FilesystemPerformance benchmarks sequential write latency on a single file. diff --git a/config/crds/troubleshoot.sh_supportbundles.yaml b/config/crds/troubleshoot.sh_supportbundles.yaml index 0a19e0a8..fe20a28d 100644 --- a/config/crds/troubleshoot.sh_supportbundles.yaml +++ b/config/crds/troubleshoot.sh_supportbundles.yaml @@ -19850,6 +19850,19 @@ spec: required: - path type: object + dns: + properties: + collectorName: + type: string + exclude: + type: BoolString + hostnames: + items: + type: string + type: array + required: + - hostnames + type: object filesystemPerformance: description: |- FilesystemPerformance benchmarks sequential write latency on a single file. diff --git a/go.mod b/go.mod index 5e237f32..2b3a704d 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/mholt/archiver/v3 v3.5.1 github.com/microsoft/go-mssqldb v1.7.2 + github.com/miekg/dns v1.1.57 github.com/opencontainers/image-spec v1.1.0 github.com/pkg/errors v0.9.1 github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851 @@ -124,6 +125,7 @@ require ( go.opentelemetry.io/otel/metric v1.30.0 // indirect go.opentelemetry.io/otel/trace v1.30.0 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index 796411d7..9d17ee47 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -218,6 +218,11 @@ type HostJournald struct { Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"` } +type HostDNS struct { + HostCollectorMeta `json:",inline" yaml:",inline"` + Hostnames []string `json:"hostnames" yaml:"hostnames"` +} + type HostCollect struct { CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` @@ -245,6 +250,7 @@ type HostCollect struct { HostKernelConfigs *HostKernelConfigs `json:"kernelConfigs,omitempty" yaml:"kernelConfigs,omitempty"` HostJournald *HostJournald `json:"journald,omitempty" yaml:"journald,omitempty"` HostCGroups *HostCGroups `json:"cgroups,omitempty" yaml:"cgroups,omitempty"` + HostDNS *HostDNS `json:"dns,omitempty" yaml:"dns,omitempty"` } // GetName gets the name of the collector diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index fb86d0e2..adb8e6be 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -2129,6 +2129,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = new(HostCGroups) (*in).DeepCopyInto(*out) } + if in.HostDNS != nil { + in, out := &in.HostDNS, &out.HostDNS + *out = new(HostDNS) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect. @@ -2288,6 +2293,27 @@ func (in *HostCopy) DeepCopy() *HostCopy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostDNS) DeepCopyInto(out *HostDNS) { + *out = *in + in.HostCollectorMeta.DeepCopyInto(&out.HostCollectorMeta) + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostDNS. +func (in *HostDNS) DeepCopy() *HostDNS { + if in == nil { + return nil + } + out := new(HostDNS) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HostHTTP) DeepCopyInto(out *HostHTTP) { *out = *in diff --git a/pkg/collect/dns.go b/pkg/collect/dns.go index 7102cc39..9a4f88d6 100644 --- a/pkg/collect/dns.go +++ b/pkg/collect/dns.go @@ -22,7 +22,7 @@ import ( const ( dnsUtilsImage = "registry.k8s.io/e2e-test-images/agnhost:2.39" - nonResolvableDomain = "non-existent-domain" + nonResolvableDomain = "*" ) type CollectDNS struct { @@ -166,7 +166,7 @@ func troubleshootDNSFromPod(client kubernetes.Interface, ctx context.Context, no echo "=== dig kubernetes ===" dig +search +short kubernetes echo "=== dig non-existent-domain ===" - dig +short %s + dig +search +short %s exit 0 `, nonResolvableDomain)} diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index aa1cdf5a..4833024a 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -67,6 +67,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect, bundlePath str return &CollectHostJournald{collector.HostJournald, bundlePath}, true case collector.HostCGroups != nil: return &CollectHostCGroups{collector.HostCGroups, bundlePath}, true + case collector.HostDNS != nil: + return &CollectHostDNS{collector.HostDNS, bundlePath}, true default: return nil, false } diff --git a/pkg/collect/host_dns.go b/pkg/collect/host_dns.go new file mode 100644 index 00000000..7f875793 --- /dev/null +++ b/pkg/collect/host_dns.go @@ -0,0 +1,205 @@ +package collect + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/miekg/dns" + "github.com/pkg/errors" + "github.com/replicatedhq/troubleshoot/internal/util" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "k8s.io/klog/v2" +) + +type CollectHostDNS struct { + hostCollector *troubleshootv1beta2.HostDNS + BundlePath string +} + +type DNSResult struct { + Query DNSQuery `json:"query"` + ResolvedFromSearch string `json:"resolvedFromSearch"` +} + +type DNSQuery map[string][]DNSEntry + +type DNSEntry struct { + Server string `json:"server"` + Search string `json:"search"` + Name string `json:"name"` + Answer string `json:"answer"` + Record string `json:"record"` +} + +const ( + HostDNSPath = "host-collectors/dns/" + resolvConf = "/etc/resolv.conf" +) + +func (c *CollectHostDNS) Title() string { + return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "dns") +} + +func (c *CollectHostDNS) IsExcluded() (bool, error) { + return isExcluded(c.hostCollector.Exclude) +} + +func (c *CollectHostDNS) Collect(progressChan chan<- interface{}) (map[string][]byte, error) { + + names := c.hostCollector.Hostnames + if len(names) == 0 { + return nil, errors.New("hostnames is required") + } + + // first, get DNS config from /etc/resolv.conf + dnsConfig, err := getDNSConfig() + if err != nil { + return nil, errors.Wrap(err, "failed to read DNS resolve config") + } + + // query DNS for each name + dnsEntries := make(map[string][]DNSEntry) + dnsResult := DNSResult{Query: dnsEntries} + allResolvedSearches := []string{} + + for _, name := range names { + entries, resolvedSearches, err := resolveName(name, dnsConfig) + if err != nil { + klog.V(2).Infof("Failed to resolve name %s: %v", name, err) + } + dnsEntries[name] = entries + allResolvedSearches = append(allResolvedSearches, resolvedSearches...) + } + + // deduplicate resolved searches + dnsResult.ResolvedFromSearch = strings.Join(util.Dedup(allResolvedSearches), ", ") + + // convert dnsResult to a JSON string + dnsResultJSON, err := json.MarshalIndent(dnsResult, "", " ") + if err != nil { + return nil, errors.Wrap(err, "failed to marshal DNS query result to JSON") + } + + output := NewResult() + outputFile := c.getOutputFilePath("result.json") + output.SaveResult(c.BundlePath, outputFile, bytes.NewBuffer(dnsResultJSON)) + + // write /etc/resolv.conf to a file + resolvConfData, err := getResolvConf() + if err != nil { + klog.V(2).Infof("failed to read DNS resolve config: %v", err) + } else { + outputFile = c.getOutputFilePath("resolv.conf") + output.SaveResult(c.BundlePath, outputFile, bytes.NewBuffer(resolvConfData)) + } + + return output, nil +} + +func (c *CollectHostDNS) getOutputFilePath(name string) string { + // normalize title to be used as a directory name, replace spaces with underscores + title := strings.ReplaceAll(c.Title(), " ", "_") + return filepath.Join(HostDNSPath, title, name) +} + +func getDNSConfig() (*dns.ClientConfig, error) { + file, err := os.Open(resolvConf) + if err != nil { + return nil, err + } + defer file.Close() + + config, err := dns.ClientConfigFromFile(file.Name()) + if err != nil { + return nil, fmt.Errorf("failed to parse resolv.conf: %v", err) + } + + return config, nil +} + +func resolveName(name string, config *dns.ClientConfig) ([]DNSEntry, []string, error) { + + results := []DNSEntry{} + resolvedSearches := []string{} + + // get a name list based on the config + queryList := config.NameList(name) + klog.V(2).Infof("DNS query list: %v", queryList) + + // for each name in the list, query all the servers + // we will query all search domains for each name + for _, query := range queryList { + for _, server := range config.Servers { + klog.V(2).Infof("Querying DNS server %s for name %s", server, query) + + entry := queryDNS(name, query, server+":"+config.Port) + results = append(results, entry) + + if entry.Search != "" { + resolvedSearches = append(resolvedSearches, entry.Search) + } + } + } + return results, resolvedSearches, nil +} + +func getResolvConf() ([]byte, error) { + data, err := os.ReadFile(resolvConf) + if err != nil { + return nil, err + } + return data, nil +} + +func queryDNS(name, query, server string) DNSEntry { + recordTypes := []uint16{dns.TypeA, dns.TypeAAAA, dns.TypeCNAME} + entry := DNSEntry{Name: query, Server: server, Answer: ""} + + for _, rec := range recordTypes { + m := &dns.Msg{} + m.SetQuestion(dns.Fqdn(query), rec) + in, err := dns.Exchange(m, server) + + if err != nil { + klog.Errorf("failed to query DNS server %s for name %s: %v", server, query, err) + continue + } + + if len(in.Answer) == 0 { + continue + } + + entry.Answer = in.Answer[0].String() + + // remember the search domain that resolved the query + // e.g. foo.test.com -> test.com + entry.Search = strings.Replace(query, name, "", 1) + + // populate record detail + switch rec { + case dns.TypeA: + record, ok := in.Answer[0].(*dns.A) + if ok { + entry.Record = record.A.String() + } + case dns.TypeAAAA: + record, ok := in.Answer[0].(*dns.AAAA) + if ok { + entry.Record = record.AAAA.String() + } + case dns.TypeCNAME: + record, ok := in.Answer[0].(*dns.CNAME) + if ok { + entry.Record = record.Target + } + } + + // break on the first successful query + break + } + return entry +} diff --git a/schemas/supportbundle-troubleshoot-v1beta2.json b/schemas/supportbundle-troubleshoot-v1beta2.json index b7f82d15..100a829c 100644 --- a/schemas/supportbundle-troubleshoot-v1beta2.json +++ b/schemas/supportbundle-troubleshoot-v1beta2.json @@ -18986,6 +18986,26 @@ } } }, + "dns": { + "type": "object", + "required": [ + "hostnames" + ], + "properties": { + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "hostnames": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "filesystemPerformance": { "description": "FilesystemPerformance benchmarks sequential write latency on a single file.\nThe optional background IOPS feature attempts to mimic real-world conditions by running read and\nwrite workloads prior to and during benchmark execution.", "type": "object",