From e97e3146d50b4cf07e46d3d5a92d10e9819d16e1 Mon Sep 17 00:00:00 2001 From: Aiden Smith <29802327+DevVoxel@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:51:29 -0400 Subject: [PATCH 1/2] Add compare and benchmark commands, expand resolver list --- README.md | 39 ++++++- cmd/vdns/cmd/benchmark.go | 107 ++++++++++++++++++ cmd/vdns/cmd/compare.go | 228 ++++++++++++++++++++++++++++++++++++++ cmd/vdns/cmd/helpers.go | 13 +++ cmd/vdns/cmd/lookup.go | 7 +- internal/dns/resolver.go | 73 ++++++++++++ 6 files changed, 458 insertions(+), 9 deletions(-) create mode 100644 cmd/vdns/cmd/benchmark.go create mode 100644 cmd/vdns/cmd/compare.go diff --git a/README.md b/README.md index 9315b2d..de5abaf 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ vdns lookup example.com google.com github.com # Propagation check vdns prop cloudflare.com +# Compare two domains +vdns compare google.com cloudflare.com + +# Benchmark resolver speed +vdns benchmark + # Watch mode vdns lookup example.com --watch vdns prop example.com --until-consistent @@ -74,6 +80,8 @@ vdns lookup example.com # DNS lookup with DNSSEC validation | `vdns lookup [domain...]` | DNS record lookup | Local / Remote | | `vdns a/aaaa/mx/ns/txt/cname/soa/caa/srv ` | Shorthand lookups | Local / Remote | | `vdns prop [domain...]` | DNS propagation check | Local / Remote | +| `vdns compare ` | Side-by-side DNS comparison | Local / Remote | +| `vdns benchmark [domain]` | Measure resolver response times | Local | | `vdns whois ` | WHOIS/RDAP data | Remote | | `vdns avail ` | Domain availability | Remote | | `vdns ssl ` | SSL/TLS certificate | Remote | @@ -128,6 +136,28 @@ vdns prop example.com google.com Results are displayed sequentially with clear separation. +## Compare + +Compare DNS records between two domains side by side: + +```bash +vdns compare google.com cloudflare.com +vdns compare example.com example.org -t A,MX,NS +``` + +Matching records are shown plain, differences are highlighted. Works in both local and remote mode. + +## Benchmark + +Measure response times for all public resolvers from your machine: + +```bash +vdns benchmark # Query google.com by default +vdns benchmark example.com # Query a specific domain +``` + +Results are sorted by latency and color-coded. Also shows your detected system resolver. + ## Local History Lookup results are stored locally by default. Browse, search, export, or clear: @@ -209,13 +239,16 @@ vdns config remove-profile work ## Propagation Resolvers -Propagation checks query these public resolvers: +Propagation checks and benchmarks query these public resolvers: - Google DNS (8.8.8.8, 8.8.4.4) - Cloudflare DNS (1.1.1.1, 1.0.0.1) -- Quad9 (9.9.9.9) +- Quad9 (9.9.9.9, 149.112.112.112) - OpenDNS (208.67.222.222, 208.67.220.220) -- Control D (76.76.2.0) +- Control D (76.76.2.0, 76.76.10.0) +- AdGuard DNS (94.140.14.14, 94.140.15.15) +- CleanBrowsing (185.228.168.9, 185.228.169.9) +- Level3 (4.2.2.1) ## Shell Completions diff --git a/cmd/vdns/cmd/benchmark.go b/cmd/vdns/cmd/benchmark.go new file mode 100644 index 0000000..8620561 --- /dev/null +++ b/cmd/vdns/cmd/benchmark.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "net" + "os" + "sort" + "strings" + + "github.com/Vector-DNS/vdns-cli/internal/dns" + "github.com/Vector-DNS/vdns-cli/internal/output" + "github.com/spf13/cobra" +) + +var benchmarkCmd = &cobra.Command{ + Use: "benchmark [domain]", + Short: "Measure DNS resolver response times", + Long: "Benchmarks all public resolvers by querying a domain and measuring response time. Defaults to querying google.com.", + Aliases: []string{"bench"}, + Args: cobra.MaximumNArgs(1), + RunE: runBenchmark, +} + +func init() { + rootCmd.AddCommand(benchmarkCmd) +} + +func runBenchmark(cmd *cobra.Command, args []string) error { + pr := output.NewPrinter(jsonOutput, noColor) + + domain := "google.com" + if len(args) == 1 { + domain = args[0] + } + + results := dns.Benchmark(domain, dns.DefaultResolvers) + + if pr.IsJSON() { + return pr.JSON(results) + } + + fmt.Println() + fmt.Fprintf(os.Stdout, " %s\n", pr.Bold("DNS Resolver Benchmark")) + fmt.Fprintf(os.Stdout, " %s %s\n", pr.Dim("Query:"), domain) + + sysResolver := dns.GetSystemResolver() + if dns.IsDefaultResolver() { + fmt.Fprintf(os.Stdout, " %s %s %s\n", pr.Dim("Resolver:"), sysResolver, pr.Dim("(default fallback)")) + } else { + fmt.Fprintf(os.Stdout, " %s %s\n", pr.Dim("System resolver:"), sysResolver) + } + fmt.Println() + + sort.Slice(results, func(i, j int) bool { + if results[i].Success != results[j].Success { + return results[i].Success + } + return results[i].LatencyMs < results[j].LatencyMs + }) + + nameW := len("Resolver") + for _, r := range results { + name := fmt.Sprintf("%s (%s)", r.Resolver.Name, resolverIP(r.Resolver)) + if len(name) > nameW { + nameW = len(name) + } + } + + header := "Resolver" + strings.Repeat(" ", nameW-len("Resolver")) + fmt.Fprintf(os.Stdout, " %s %s\n", pr.Dim(header), pr.Dim("Latency")) + fmt.Fprintf(os.Stdout, " %s\n", pr.Dim(strings.Repeat("-", nameW+12))) + + for _, r := range results { + name := fmt.Sprintf("%s (%s)", r.Resolver.Name, resolverIP(r.Resolver)) + padded := name + strings.Repeat(" ", nameW-len(name)) + + if !r.Success { + fmt.Fprintf(os.Stdout, " %s %s\n", + padded, + pr.Color(output.Red, "FAILED: "+r.Error)) + continue + } + + color := output.Green + if r.LatencyMs > 100 { + color = output.Yellow + } + if r.LatencyMs > 300 { + color = output.Red + } + + fmt.Fprintf(os.Stdout, " %s %s\n", + padded, + pr.Color(color, fmt.Sprintf("%dms", r.LatencyMs))) + } + + fmt.Println() + return nil +} + +func resolverIP(r dns.Resolver) string { + host, _, err := net.SplitHostPort(r.Address) + if err != nil { + return r.Address + } + return host +} diff --git a/cmd/vdns/cmd/compare.go b/cmd/vdns/cmd/compare.go new file mode 100644 index 0000000..b9ba740 --- /dev/null +++ b/cmd/vdns/cmd/compare.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "sync" + + "github.com/Vector-DNS/vdns-cli/internal/client" + "github.com/Vector-DNS/vdns-cli/internal/config" + "github.com/Vector-DNS/vdns-cli/internal/dns" + "github.com/Vector-DNS/vdns-cli/internal/output" + "github.com/Vector-DNS/vdns-cli/internal/validate" + "github.com/spf13/cobra" +) + +var compareTypes string + +var compareCmd = &cobra.Command{ + Use: "compare ", + Short: "Compare DNS records between two domains", + Long: "Performs DNS lookups on two domains and displays their records side by side, highlighting differences.", + Args: cobra.ExactArgs(2), + RunE: runCompare, +} + +func init() { + compareCmd.Flags().StringVarP(&compareTypes, "type", "t", "", "Record types to compare (comma-separated, e.g. A,MX,NS)") + rootCmd.AddCommand(compareCmd) +} + +type domainRecords struct { + domain string + records map[string][]printableRecord +} + +func runCompare(cmd *cobra.Command, args []string) error { + pr := output.NewPrinter(jsonOutput, noColor) + + vr1, err := validate.Domain(args[0]) + if err != nil { + pr.Error(err.Error()) + return err + } + vr2, err := validate.Domain(args[1]) + if err != nil { + pr.Error(err.Error()) + return err + } + + types := parseTypeFlag(compareTypes) + + cfg, err := config.Load() + if err != nil { + pr.Error(fmt.Sprintf("could not load config: %s", err)) + return err + } + + useRemote := !localMode && cfg.ShouldUseRemote() + + var d1, d2 domainRecords + var err1, err2 error + + var wg sync.WaitGroup + wg.Add(2) + + if useRemote { + c := client.New(cfg.ActiveServer(), cfg.ActiveAPIKey()) + go func() { + defer wg.Done() + d1, err1 = fetchRemoteRecords(c, vr1.ASCII, types) + }() + go func() { + defer wg.Done() + d2, err2 = fetchRemoteRecords(c, vr2.ASCII, types) + }() + wg.Wait() + if c.LastRateLimit != nil { + pr.RateLimitWarning(c.LastRateLimit.Remaining, c.LastRateLimit.Limit, verbose) + } + } else { + go func() { + defer wg.Done() + d1, err1 = fetchLocalRecords(vr1.ASCII, types) + }() + go func() { + defer wg.Done() + d2, err2 = fetchLocalRecords(vr2.ASCII, types) + }() + wg.Wait() + } + + if err1 != nil { + pr.Error(fmt.Sprintf("%s: %s", vr1.ASCII, err1)) + return err1 + } + if err2 != nil { + pr.Error(fmt.Sprintf("%s: %s", vr2.ASCII, err2)) + return err2 + } + + if pr.IsJSON() { + return pr.JSON(map[string]any{ + d1.domain: d1.records, + d2.domain: d2.records, + }) + } + + printComparison(pr, d1, d2) + + if useRemote { + pr.PoweredBy() + } + + return nil +} + +func fetchLocalRecords(domain string, types []string) (domainRecords, error) { + result, err := dns.Lookup(domain, types) + if err != nil { + return domainRecords{}, err + } + return domainRecords{domain: domain, records: localToRecords(result.Records)}, nil +} + +func fetchRemoteRecords(c *client.Client, domain string, types []string) (domainRecords, error) { + resp, err := c.DNSLookup(client.DNSLookupRequest{Domain: domain, Types: types}) + if err != nil { + return domainRecords{}, err + } + return domainRecords{domain: domain, records: remoteToRecords(resp.Records)}, nil +} + +func printComparison(pr *output.Printer, d1, d2 domainRecords) { + fmt.Println() + fmt.Fprintf(os.Stdout, " %s\n", + pr.Bold(fmt.Sprintf("%s vs %s", d1.domain, d2.domain))) + fmt.Println() + + allTypes := collectTypes(d1.records, d2.records) + colWidth := 35 + + for _, rtype := range dns.SupportedTypes { + if !allTypes[rtype] { + continue + } + + recs1 := d1.records[rtype] + recs2 := d2.records[rtype] + + color := pr.RecordColor(rtype) + pr.Section(pr.Color(color, rtype+" Records")) + + vals1 := recordValues(recs1) + vals2 := recordValues(recs2) + + maxLen := len(vals1) + if len(vals2) > maxLen { + maxLen = len(vals2) + } + + // Pad plain text before wrapping with ANSI codes to keep alignment. + leftHeader := padRight(d1.domain, colWidth) + fmt.Fprintf(os.Stdout, " %s %s\n", + pr.Dim(leftHeader), pr.Dim(d2.domain)) + + for i := range maxLen { + left := "" + right := "" + if i < len(vals1) { + left = vals1[i] + } + if i < len(vals2) { + right = vals2[i] + } + + if left == right { + fmt.Fprintf(os.Stdout, " %s %s\n", padRight(left, colWidth), right) + } else { + leftPadded := padRight(left, colWidth) + if left == "" { + leftPadded = padRight("-", colWidth) + leftPadded = pr.Dim(leftPadded) + } else { + leftPadded = pr.Color(output.Yellow, leftPadded) + } + rightStr := right + if right == "" { + rightStr = pr.Dim("-") + } else { + rightStr = pr.Color(output.Yellow, right) + } + fmt.Fprintf(os.Stdout, " %s %s\n", leftPadded, rightStr) + } + } + fmt.Println() + } +} + +func padRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} + +func collectTypes(a, b map[string][]printableRecord) map[string]bool { + types := make(map[string]bool) + for k := range a { + types[k] = true + } + for k := range b { + types[k] = true + } + return types +} + +func recordValues(recs []printableRecord) []string { + vals := make([]string, len(recs)) + for i, r := range recs { + if r.Priority != nil { + vals[i] = fmt.Sprintf("%d %s", *r.Priority, r.Value) + } else { + vals[i] = r.Value + } + } + return vals +} diff --git a/cmd/vdns/cmd/helpers.go b/cmd/vdns/cmd/helpers.go index a98bf25..d214a81 100644 --- a/cmd/vdns/cmd/helpers.go +++ b/cmd/vdns/cmd/helpers.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strings" "time" "github.com/Vector-DNS/vdns-cli/internal/client" @@ -95,6 +96,18 @@ func printDNSRecords(pr *output.Printer, records map[string][]printableRecord) { } } +// parseTypeFlag splits a comma-separated type flag into a slice. +func parseTypeFlag(s string) []string { + if s == "" { + return nil + } + var types []string + for _, t := range strings.Split(s, ",") { + types = append(types, strings.TrimSpace(t)) + } + return types +} + // formatTTL converts an optional TTL pointer to a display string. func formatTTL[T ~int | ~uint32](ttl *T) string { if ttl == nil { diff --git a/cmd/vdns/cmd/lookup.go b/cmd/vdns/cmd/lookup.go index 808b5a2..713d167 100644 --- a/cmd/vdns/cmd/lookup.go +++ b/cmd/vdns/cmd/lookup.go @@ -72,12 +72,7 @@ func runLookup(cmd *cobra.Command, args []string) error { return fmt.Errorf("no valid domains provided") } - var types []string - if lookupTypes != "" { - for _, t := range strings.Split(lookupTypes, ",") { - types = append(types, strings.TrimSpace(t)) - } - } + types := parseTypeFlag(lookupTypes) cfg, err := config.Load() if err != nil { diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go index 5690bfc..606da73 100644 --- a/internal/dns/resolver.go +++ b/internal/dns/resolver.go @@ -18,9 +18,16 @@ var DefaultResolvers = []Resolver{ {Address: "1.1.1.1:53", Name: "Cloudflare DNS"}, {Address: "1.0.0.1:53", Name: "Cloudflare DNS Secondary"}, {Address: "9.9.9.9:53", Name: "Quad9"}, + {Address: "149.112.112.112:53", Name: "Quad9 Secondary"}, {Address: "208.67.222.222:53", Name: "OpenDNS"}, {Address: "208.67.220.220:53", Name: "OpenDNS Secondary"}, {Address: "76.76.2.0:53", Name: "Control D"}, + {Address: "76.76.10.0:53", Name: "Control D Secondary"}, + {Address: "94.140.14.14:53", Name: "AdGuard DNS"}, + {Address: "94.140.15.15:53", Name: "AdGuard DNS Secondary"}, + {Address: "185.228.168.9:53", Name: "CleanBrowsing"}, + {Address: "185.228.169.9:53", Name: "CleanBrowsing Secondary"}, + {Address: "4.2.2.1:53", Name: "Level3"}, } // DefaultResolverIPs returns just the IP addresses from DefaultResolvers. @@ -199,6 +206,71 @@ func CheckPropagation(domain string, recordType string, resolvers []Resolver) ([ return results, checkConsistency(results) } +// BenchmarkResult holds timing data for a single resolver. +type BenchmarkResult struct { + Resolver Resolver + LatencyMs int64 + Success bool + Error string +} + +// Benchmark measures response time for each resolver by querying a domain. +func Benchmark(domain string, resolvers []Resolver) []BenchmarkResult { + if len(resolvers) == 0 { + resolvers = DefaultResolvers + } + + results := make([]BenchmarkResult, len(resolvers)) + var wg sync.WaitGroup + + c := new(mdns.Client) + c.Timeout = 5 * time.Second + + for i, res := range resolvers { + results[i] = BenchmarkResult{Resolver: res} + + wg.Add(1) + go func(idx int, resolver Resolver) { + defer wg.Done() + + m := new(mdns.Msg) + m.SetQuestion(mdns.Fqdn(domain), mdns.TypeA) + m.RecursionDesired = true + + start := time.Now() + r, _, err := c.Exchange(m, resolver.Address) + results[idx].LatencyMs = time.Since(start).Milliseconds() + + if err != nil { + results[idx].Error = err.Error() + return + } + if r == nil || r.Rcode != mdns.RcodeSuccess { + results[idx].Error = "no valid response" + return + } + results[idx].Success = true + }(i, res) + } + + wg.Wait() + return results +} + +var isDefaultResolver bool + +// GetSystemResolver returns the detected system resolver address. +func GetSystemResolver() string { + return getSystemResolver() +} + +// IsDefaultResolver returns true if the system resolver could not be detected +// and the fallback (Google DNS) is being used instead. +func IsDefaultResolver() bool { + getSystemResolver() + return isDefaultResolver +} + func getSystemResolver() string { cachedResolverOnce.Do(func() { config, err := mdns.ClientConfigFromFile("/etc/resolv.conf") @@ -206,6 +278,7 @@ func getSystemResolver() string { cachedResolver = net.JoinHostPort(config.Servers[0], config.Port) } else { cachedResolver = "8.8.8.8:53" + isDefaultResolver = true } }) return cachedResolver From 9a06cf9dab6b03f5fbf97a571ae8777181c293af Mon Sep 17 00:00:00 2001 From: Aiden Smith <29802327+DevVoxel@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:09:28 -0400 Subject: [PATCH 2/2] Add domain validation to benchmark and compare commands --- cmd/vdns/cmd/benchmark.go | 8 +++++++- cmd/vdns/cmd/compare.go | 17 +++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cmd/vdns/cmd/benchmark.go b/cmd/vdns/cmd/benchmark.go index 8620561..1ef9476 100644 --- a/cmd/vdns/cmd/benchmark.go +++ b/cmd/vdns/cmd/benchmark.go @@ -9,6 +9,7 @@ import ( "github.com/Vector-DNS/vdns-cli/internal/dns" "github.com/Vector-DNS/vdns-cli/internal/output" + "github.com/Vector-DNS/vdns-cli/internal/validate" "github.com/spf13/cobra" ) @@ -30,7 +31,12 @@ func runBenchmark(cmd *cobra.Command, args []string) error { domain := "google.com" if len(args) == 1 { - domain = args[0] + vr, err := validate.Domain(args[0]) + if err != nil { + pr.Error(err.Error()) + return err + } + domain = vr.ASCII } results := dns.Benchmark(domain, dns.DefaultResolvers) diff --git a/cmd/vdns/cmd/compare.go b/cmd/vdns/cmd/compare.go index b9ba740..0c0fe60 100644 --- a/cmd/vdns/cmd/compare.go +++ b/cmd/vdns/cmd/compare.go @@ -37,16 +37,17 @@ type domainRecords struct { func runCompare(cmd *cobra.Command, args []string) error { pr := output.NewPrinter(jsonOutput, noColor) - vr1, err := validate.Domain(args[0]) - if err != nil { - pr.Error(err.Error()) - return err + validResults, validationErrs := validate.Domains(args) + if len(validationErrs) > 0 { + for _, e := range validationErrs { + pr.Error(e.Error()) + } + return fmt.Errorf("invalid domains") } - vr2, err := validate.Domain(args[1]) - if err != nil { - pr.Error(err.Error()) - return err + if len(validResults) != 2 { + return fmt.Errorf("two valid domains required") } + vr1, vr2 := validResults[0], validResults[1] types := parseTypeFlag(compareTypes)