├── go.mod ├── LICENSE ├── dns-tester-run.sh ├── .github └── workflows │ └── release.yaml ├── README.md └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module dns-tester 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/olekukonko/tablewriter v0.0.5 7 | github.com/schollz/progressbar/v3 v3.18.0 8 | ) 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hawshemi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dns-tester-run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # For Linux/macOS. Downloads & runs the latest release binary. 3 | 4 | # --- Configuration --- 5 | OWNER="hawshemi" 6 | REPO="dns-tester" 7 | 8 | # --- Detect OS (Linux or macOS) --- 9 | os_type=$(uname -s) 10 | if [ "$os_type" = "Darwin" ]; then 11 | platform="darwin" 12 | elif [ "$os_type" = "Linux" ]; then 13 | platform="linux" 14 | else 15 | echo "Unsupported OS: $os_type. This script only supports Linux and macOS." 16 | exit 1 17 | fi 18 | 19 | # --- Detect CPU Architecture --- 20 | cpu_arch=$(uname -m) 21 | if [ "$cpu_arch" = "x86_64" ]; then 22 | arch="amd64" 23 | elif [ "$cpu_arch" = "arm64" ] || [ "$cpu_arch" = "aarch64" ]; then 24 | arch="arm64" 25 | else 26 | echo "Unsupported architecture: $cpu_arch" 27 | exit 1 28 | fi 29 | 30 | # --- Construct Expected Asset Name --- 31 | asset_name="${REPO}-${platform}-${arch}" 32 | echo "Detected OS: $platform, Architecture: $arch" 33 | echo "Looking for asset: $asset_name" 34 | 35 | # --- Fetch Latest Release Info from GitHub --- 36 | API_URL="https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" 37 | release_json=$(curl -s "$API_URL") 38 | 39 | # --- Extract the Download URL without jq --- 40 | # This sed/grep pipeline works as follows: 41 | # 1. sed prints lines starting from the one with our asset "name" up to the first occurrence of "browser_download_url" 42 | # 2. grep filters the line containing "browser_download_url" 43 | # 3. sed extracts the URL value. 44 | download_url=$(echo "$release_json" \ 45 | | sed -n '/"name": *"'"$asset_name"'"/,/"browser_download_url"/p' \ 46 | | grep "browser_download_url" \ 47 | | head -n 1 \ 48 | | sed -E 's/.*"browser_download_url": *"([^"]+)".*/\1/') 49 | 50 | if [ -z "$download_url" ]; then 51 | echo "Error: Could not find asset \"$asset_name\" in the latest release." 52 | exit 1 53 | fi 54 | 55 | echo "Downloading $asset_name from:" 56 | echo "$download_url" 57 | curl -L -o "$asset_name" "$download_url" 58 | 59 | # --- Set Execute Permissions & Run the Binary --- 60 | chmod +x "$asset_name" 61 | echo "Running $asset_name..." 62 | ./"$asset_name" 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | BIN_PATH: /tmp/bin 10 | 11 | jobs: 12 | build: 13 | name: Build binaries 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | arch: [amd64, arm64] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: '1.24' 29 | cache: false 30 | 31 | - name: Run go mod tidy 32 | run: go mod tidy 33 | 34 | - name: Set environment variables 35 | run: | 36 | if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then 37 | echo "GOOS=linux" >> $GITHUB_ENV 38 | elif [ "${{ matrix.os }}" == "macos-latest" ]; then 39 | echo "GOOS=darwin" >> $GITHUB_ENV 40 | else 41 | echo "GOOS=windows" >> $GITHUB_ENV 42 | echo "EXT=.exe" >> $GITHUB_ENV 43 | fi 44 | 45 | - name: Build binary 46 | env: 47 | GOARCH: ${{ matrix.arch }} 48 | GOOS: ${{ env.GOOS }} 49 | EXT: ${{ env.EXT }} 50 | run: | 51 | go build -o dns-tester-${{ env.GOOS }}-${{ matrix.arch }}${{ env.EXT }} . 52 | 53 | - name: Upload binaries 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: dns-tester-${{ env.GOOS }}-${{ matrix.arch }}${{ env.EXT }} 57 | path: dns-tester-${{ env.GOOS }}-${{ matrix.arch }}${{ env.EXT }} 58 | 59 | release: 60 | name: Create GitHub Release 61 | needs: build 62 | runs-on: ubuntu-latest 63 | 64 | steps: 65 | - name: Create bin directory 66 | run: | 67 | mkdir -p ${{ env.BIN_PATH }} 68 | 69 | - name: Download binaries 70 | uses: actions/download-artifact@v4 71 | with: 72 | path: ${{ env.BIN_PATH }} 73 | pattern: dns-tester-* 74 | merge-multiple: true 75 | 76 | - name: Display structure of downloaded files 77 | run: ls -R ${{ env.BIN_PATH }} 78 | 79 | - name: Release with assets 80 | uses: softprops/action-gh-release@v2 81 | with: 82 | files: ${{ env.BIN_PATH }}/* 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DNS Tester 2 | 3 | DNS Tester is a command-line tool written in Go that benchmarks various DNS providers by measuring key performance metrics such as ping latency, jitter, DNS resolution time, and packet loss. It then computes a composite score based on these metrics and recommends the top-performing providers for fast and consistent DNS resolution. 4 | 5 | ## Features 6 | 7 | - **Comprehensive Metrics**: Computes average, median, minimum, maximum, and jitter for both ping and DNS resolution. 8 | - **Packet Loss Detection**: Measures packet loss to evaluate the reliability of each provider. 9 | - **Composite Scoring**: Uses a weighted composite score to rank DNS providers. 10 | - **Cross-Platform**: Works on Linux, macOS, and Windows. 11 | - **CI/CD Integration**: Automatically builds and releases binaries via GitHub Actions. 12 | 13 | ## Requirements 14 | 15 | - Go 1.24 or later 16 | - The following commands must be available on your system: 17 | - `ping` for latency tests 18 | - `dig` for DNS resolution tests 19 | 20 | ## Run 21 | 22 | ### Linux / MacOS: 23 | ```bash 24 | curl -L "https://raw.githubusercontent.com/hawshemi/dns-tester/main/dns-tester-run.sh" -o dns-tester-run.sh && chmod +x dns-tester-run.sh && bash dns-tester-run.sh 25 | ``` 26 | 27 | ### Windows: ([Broken for now](https://github.com/hawshemi/dns-tester/issues/4)) 28 | 29 | 1. Download from [Releases](https://github.com/hawshemi/dns-tester/releases/latest). 30 | 2. Open `CMD` or `Powershell` in the directory. 31 | 3. 32 | ``` 33 | .\dns-tester.exe 34 | ``` 35 | 36 | 37 | ## Usage 38 | 39 | **Flags:** 40 | 41 | `--output (-o): Output format (table, json, csv), default: table` 42 | 43 | `--runs (-r): Number of test runs, default: 3` 44 | 45 | `--help (-h): Show help` 46 | 47 | Examples: 48 | 49 | ```bash 50 | ./dns-tester 51 | ./dns-tester -o json -r 5 52 | ./dns-tester --output=csv --runs=2 53 | ``` 54 | 55 | ## Build 56 | 57 | #### 0. Install `golang`. 58 | ```bash 59 | wget "https://raw.githubusercontent.com/hawshemi/tools/main/go-installer/go-installer.sh" -O go-installer.sh && chmod +x go-installer.sh && bash go-installer.sh 60 | ``` 61 | 62 | #### 1. Clone the repository 63 | ```bash 64 | git clone https://github.com/hawshemi/dns-tester.git 65 | ``` 66 | 67 | #### 2. Navigate into the repository directory 68 | ```bash 69 | cd dns-tester 70 | ``` 71 | 72 | #### 3. Prepare for build 73 | ```bash 74 | go mod init dns-tester && go mod tidy 75 | ``` 76 | #### 4. Build 77 | ```bash 78 | CGO_ENABLED=0 go build 79 | ``` 80 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/csv" 7 | "encoding/json" 8 | "flag" 9 | "fmt" 10 | "math" 11 | "os" 12 | "os/exec" 13 | "sort" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/olekukonko/tablewriter" 20 | "github.com/schollz/progressbar/v3" 21 | ) 22 | 23 | type Provider struct { 24 | IP string 25 | Name string 26 | } 27 | 28 | type TestResult struct { 29 | Name string 30 | IP string 31 | // Formatted strings for display. 32 | AvgPing string 33 | MinPing string 34 | MaxPing string 35 | PingMedian string 36 | PingJitter string 37 | PingLoss string 38 | AvgResolveTime string 39 | MinResolveTime string 40 | MaxResolveTime string 41 | ResolveMedian string 42 | ResolveJitter string 43 | ResolveLoss string 44 | // Numeric values used for composite scoring. 45 | PingAvgVal float64 46 | PingMedianVal float64 47 | PingJitterVal float64 48 | PingLossVal float64 49 | ResolveAvgVal float64 50 | ResolveMedianVal float64 51 | ResolveJitterVal float64 52 | ResolveLossVal float64 53 | } 54 | 55 | const ( 56 | failMessage = "FAIL" 57 | pingCount = 6 58 | resolveCount = 6 59 | timeoutSeconds = 2 60 | maxWorkers = 8 61 | maxRetries = 3 62 | warmupRounds = 1 63 | ) 64 | 65 | // Weights for composite score (you can adjust these). 66 | const ( 67 | weightPingAvg = 0.25 68 | weightPingMedian = 0.25 69 | weightPingJitter = 0.25 70 | weightPingLoss = 0.25 71 | weightResolveAvg = 0.25 72 | weightResolveMedian = 0.25 73 | weightResolveJitter = 0.25 74 | weightResolveLoss = 0.25 75 | ) 76 | 77 | var ( 78 | providersV4 = []Provider{ 79 | {"1.1.1.1", "Cloudflare (v4)"}, 80 | {"1.1.1.2", "Cloudflare-Sec (v4)"}, 81 | {"8.8.8.8", "Google (v4)"}, 82 | {"9.9.9.9", "Quad9 (v4)"}, 83 | {"209.244.0.3", "Level3 (v4)"}, 84 | {"94.140.14.14", "Adguard (v4)"}, 85 | {"193.110.81.0", "Dns0.eu (v4)"}, 86 | {"76.76.2.2", "ControlD (v4)"}, 87 | {"95.85.95.85", "GcoreDNS (v4)"}, 88 | {"185.228.168.9", "CleanBrowsing-Sec (v4)"}, 89 | {"208.67.222.222", "OpenDNS (v4)"}, 90 | {"77.88.8.8", "Yandex (v4)"}, 91 | {"77.88.8.88", "Yandex-Safe (v4)"}, 92 | {"64.6.64.6", "UltraDNS (v4)"}, 93 | {"156.154.70.2", "UltraDNS-Sec (v4)"}, 94 | } 95 | 96 | providersV6 = []Provider{ 97 | {"2606:4700:4700::1111", "Cloudflare (v6)"}, 98 | {"2606:4700:4700::1112", "Cloudflare-Sec (v6)"}, 99 | {"2001:4860:4860::8888", "Google (v6)"}, 100 | {"2620:fe::fe", "Quad9 (v6)"}, 101 | {"2a10:50c0::ad1:ff", "Adguard (v6)"}, 102 | {"2a0f:fc80::", "Dns0.eu (v6)"}, 103 | {"2606:1a40::2", "ControlD (v6)"}, 104 | {"2a03:90c0:999d::1", "GcoreDNS (v6)"}, 105 | {"2a0d:2a00:1::2", "CleanBrowsing-Sec (v6)"}, 106 | {"2a02:6b8::feed:0ff", "Yandex (v6)"}, 107 | {"2a02:6b8::feed:bad", "Yandex-Safe (v6)"}, 108 | {"2620:74:1b::1:1", "UltraDNS (v6)"}, 109 | {"2610:a1:1018::2", "UltraDNS-Sec (v6)"}, 110 | } 111 | 112 | domains = []string{ 113 | "www.google.com", 114 | "www.speedtest.net", 115 | "i.instagram.com", 116 | } 117 | 118 | resultPool = &sync.Pool{ 119 | New: func() interface{} { 120 | return &TestResult{} 121 | }, 122 | } 123 | ) 124 | 125 | // statsDetailed computes average, min, max, median and jitter from a slice of float64. 126 | func statsDetailed(times []float64) (avg, min, max, median, jitter float64) { 127 | if len(times) == 0 { 128 | return 9999, 9999, 9999, 9999, 9999 129 | } 130 | sorted := make([]float64, len(times)) 131 | copy(sorted, times) 132 | sort.Float64s(sorted) 133 | min = sorted[0] 134 | max = sorted[len(sorted)-1] 135 | sum := 0.0 136 | for _, t := range times { 137 | sum += t 138 | } 139 | avg = sum / float64(len(times)) 140 | if len(sorted)%2 == 0 { 141 | median = (sorted[len(sorted)/2-1] + sorted[len(sorted)/2]) / 2.0 142 | } else { 143 | median = sorted[len(sorted)/2] 144 | } 145 | sumSq := 0.0 146 | for _, t := range times { 147 | diff := t - avg 148 | sumSq += diff * diff 149 | } 150 | jitter = math.Sqrt(sumSq / float64(len(times))) 151 | return 152 | } 153 | 154 | // parseMs converts strings like "12.3 ms" into a float64. 155 | func parseMs(s string) float64 { 156 | s = strings.TrimSpace(s) 157 | s = strings.TrimSuffix(s, " ms") 158 | val, err := strconv.ParseFloat(s, 64) 159 | if err != nil { 160 | return 9999 161 | } 162 | return val 163 | } 164 | 165 | // getPingStats runs the ping command, collects individual ping times, 166 | // then computes and returns avg, min, max, median, jitter and packet loss. 167 | func getPingStats(ctx context.Context, ip string) (avg, min, max, median, jitter string, loss float64, times []float64, err error) { 168 | // Warmup rounds. 169 | for i := 0; i < warmupRounds; i++ { 170 | warmupCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second) 171 | cmd := exec.CommandContext(warmupCtx, "ping", "-c", "1", "-W", strconv.Itoa(timeoutSeconds), ip) 172 | cmd.Run() 173 | cancel() 174 | time.Sleep(100 * time.Millisecond) 175 | } 176 | 177 | var output []byte 178 | for retry := 0; retry < maxRetries; retry++ { 179 | pingCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second*pingCount) 180 | cmd := exec.CommandContext(pingCtx, "ping", "-c", strconv.Itoa(pingCount), "-W", strconv.Itoa(timeoutSeconds), ip) 181 | output, err = cmd.CombinedOutput() 182 | cancel() 183 | if err == nil { 184 | break 185 | } 186 | time.Sleep(time.Duration(100*(1< 0 { 230 | loss = ((float64(pingCount) - float64(len(times))) / float64(pingCount)) * 100.0 231 | } 232 | 233 | avgVal, minVal, maxVal, medianVal, jitterVal := statsDetailed(times) 234 | avg = fmt.Sprintf("%.1f ms", avgVal) 235 | min = fmt.Sprintf("%.1f ms", minVal) 236 | max = fmt.Sprintf("%.1f ms", maxVal) 237 | median = fmt.Sprintf("%.1f ms", medianVal) 238 | jitter = fmt.Sprintf("%.1f ms", jitterVal) 239 | return 240 | } 241 | 242 | // resolveDomain performs DNS resolution using dig and returns all successful query times. 243 | func resolveDomain(ctx context.Context, domain, server string) ([]float64, int, error) { 244 | var resolveTimes []float64 245 | successes := 0 246 | for i := 0; i < resolveCount; i++ { 247 | var uniqueDomain strings.Builder 248 | uniqueDomain.WriteString(strconv.FormatInt(time.Now().UnixNano(), 16)) 249 | uniqueDomain.WriteRune('.') 250 | uniqueDomain.WriteString(domain) 251 | 252 | var output []byte 253 | var err error 254 | for retry := 0; retry < maxRetries; retry++ { 255 | cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second) 256 | cmd := exec.CommandContext(cmdCtx, "dig", "@"+server, uniqueDomain.String(), "+time="+strconv.Itoa(timeoutSeconds), "+tries=1") 257 | output, err = cmd.Output() 258 | cancel() 259 | if err == nil { 260 | break 261 | } 262 | time.Sleep(time.Duration(100*(1<= 4 { 273 | if t, err := strconv.Atoi(fields[3]); err == nil { 274 | resolveTimes = append(resolveTimes, float64(t)) 275 | successes++ 276 | } 277 | } 278 | } 279 | } 280 | time.Sleep(100 * time.Millisecond) 281 | } 282 | if len(resolveTimes) == 0 { 283 | return nil, 0, fmt.Errorf("no successful resolves") 284 | } 285 | return resolveTimes, successes, nil 286 | } 287 | 288 | func testProvider(ctx context.Context, provider Provider, runs int, resultChan chan<- TestResult, wg *sync.WaitGroup) { 289 | defer wg.Done() 290 | result := resultPool.Get().(*TestResult) 291 | result.Name = provider.Name 292 | result.IP = provider.IP 293 | 294 | // Run ping tests multiple times and collect results. 295 | var pingAvgSum, pingMinSum, pingMaxSum, pingMedianSum, pingJitterSum, pingLossSum float64 296 | var allPingTimes [][]float64 297 | pingRuns := 0 298 | for i := 0; i < runs; i++ { 299 | avgPing, minPing, maxPing, medianPing, pingJitter, pingLoss, pingTimes, err := getPingStats(ctx, provider.IP) 300 | if err == nil { 301 | pingAvgSum += parseMs(avgPing) 302 | pingMinSum += parseMs(minPing) 303 | pingMaxSum += parseMs(maxPing) 304 | pingMedianSum += parseMs(medianPing) 305 | pingJitterSum += parseMs(pingJitter) 306 | pingLossSum += pingLoss 307 | allPingTimes = append(allPingTimes, pingTimes) 308 | pingRuns++ 309 | } 310 | time.Sleep(100 * time.Millisecond) // Small delay between runs 311 | } 312 | if pingRuns > 0 { 313 | result.AvgPing = fmt.Sprintf("%.1f ms", pingAvgSum/float64(pingRuns)) 314 | result.MinPing = fmt.Sprintf("%.1f ms", pingMinSum/float64(pingRuns)) 315 | result.MaxPing = fmt.Sprintf("%.1f ms", pingMaxSum/float64(pingRuns)) 316 | result.PingMedian = fmt.Sprintf("%.1f ms", pingMedianSum/float64(pingRuns)) 317 | result.PingJitter = fmt.Sprintf("%.1f ms", pingJitterSum/float64(pingRuns)) 318 | result.PingLoss = fmt.Sprintf("%.1f%%", pingLossSum/float64(pingRuns)) 319 | result.PingAvgVal = pingAvgSum / float64(pingRuns) 320 | var combinedPingTimes []float64 321 | for _, times := range allPingTimes { 322 | combinedPingTimes = append(combinedPingTimes, times...) 323 | } 324 | _, _, _, result.PingMedianVal, result.PingJitterVal = statsDetailed(combinedPingTimes) 325 | result.PingLossVal = pingLossSum / float64(pingRuns) 326 | } else { 327 | result.AvgPing = failMessage 328 | result.MinPing = failMessage 329 | result.MaxPing = failMessage 330 | result.PingMedian = failMessage 331 | result.PingJitter = failMessage 332 | result.PingLoss = "100%" 333 | result.PingAvgVal = 9999 334 | result.PingMedianVal = 9999 335 | result.PingJitterVal = 9999 336 | result.PingLossVal = 9999 337 | } 338 | 339 | // Run DNS resolve tests multiple times across all domains. 340 | var resolveAvgSum, resolveMinSum, resolveMaxSum, resolveMedianSum, resolveJitterSum, resolveLossSum float64 341 | var allResolveTimes []float64 342 | var totalDNSAttempts, totalDNSSuccess int 343 | resolveRuns := 0 344 | for run := 0; run < runs; run++ { 345 | var resolveWG sync.WaitGroup 346 | runResolveTimes := make([][]float64, len(domains)) 347 | runDNSAttempts := len(domains) * resolveCount 348 | runDNSSuccess := 0 349 | 350 | for i, domain := range domains { 351 | resolveWG.Add(1) 352 | go func(idx int, d string) { 353 | defer resolveWG.Done() 354 | times, successes, err := resolveDomain(ctx, d, provider.IP) 355 | if err == nil { 356 | runResolveTimes[idx] = times 357 | runDNSSuccess += successes 358 | } 359 | }(i, domain) 360 | } 361 | resolveWG.Wait() 362 | 363 | var combinedRunResolve []float64 364 | for _, times := range runResolveTimes { 365 | combinedRunResolve = append(combinedRunResolve, times...) 366 | } 367 | if len(combinedRunResolve) > 0 { 368 | avgResVal, minResVal, maxResVal, medianResVal, jitterResVal := statsDetailed(combinedRunResolve) 369 | resolveAvgSum += avgResVal 370 | resolveMinSum += minResVal 371 | resolveMaxSum += maxResVal 372 | resolveMedianSum += medianResVal 373 | resolveJitterSum += jitterResVal 374 | resolveLossSum += ((float64(runDNSAttempts) - float64(runDNSSuccess)) / float64(runDNSAttempts)) * 100.0 375 | allResolveTimes = append(allResolveTimes, combinedRunResolve...) 376 | totalDNSAttempts += runDNSAttempts 377 | totalDNSSuccess += runDNSSuccess 378 | resolveRuns++ 379 | } 380 | time.Sleep(100 * time.Millisecond) // Small delay between runs 381 | } 382 | if resolveRuns > 0 { 383 | result.AvgResolveTime = fmt.Sprintf("%.1f ms", resolveAvgSum/float64(resolveRuns)) 384 | result.MinResolveTime = fmt.Sprintf("%.1f ms", resolveMinSum/float64(resolveRuns)) 385 | result.MaxResolveTime = fmt.Sprintf("%.1f ms", resolveMaxSum/float64(resolveRuns)) 386 | result.ResolveMedian = fmt.Sprintf("%.1f ms", resolveMedianSum/float64(resolveRuns)) 387 | result.ResolveJitter = fmt.Sprintf("%.1f ms", resolveJitterSum/float64(resolveRuns)) 388 | result.ResolveLoss = fmt.Sprintf("%.1f%%", resolveLossSum/float64(resolveRuns)) 389 | result.ResolveAvgVal = resolveAvgSum / float64(resolveRuns) 390 | _, _, _, result.ResolveMedianVal, result.ResolveJitterVal = statsDetailed(allResolveTimes) 391 | result.ResolveLossVal = resolveLossSum / float64(resolveRuns) 392 | } else { 393 | result.AvgResolveTime = failMessage 394 | result.MinResolveTime = failMessage 395 | result.MaxResolveTime = failMessage 396 | result.ResolveMedian = failMessage 397 | result.ResolveJitter = failMessage 398 | result.ResolveLoss = "100%" 399 | result.ResolveAvgVal = 9999 400 | result.ResolveMedianVal = 9999 401 | result.ResolveJitterVal = 9999 402 | result.ResolveLossVal = 9999 403 | } 404 | 405 | resultChan <- *result 406 | 407 | // Reset fields and return to pool. 408 | result.Name = "" 409 | result.IP = "" 410 | result.AvgPing = "" 411 | result.MinPing = "" 412 | result.MaxPing = "" 413 | result.PingMedian = "" 414 | result.PingJitter = "" 415 | result.PingLoss = "" 416 | result.AvgResolveTime = "" 417 | result.MinResolveTime = "" 418 | result.MaxResolveTime = "" 419 | result.ResolveMedian = "" 420 | result.ResolveJitter = "" 421 | result.ResolveLoss = "" 422 | result.PingAvgVal = 0 423 | result.PingMedianVal = 0 424 | result.PingJitterVal = 0 425 | result.PingLossVal = 0 426 | result.ResolveAvgVal = 0 427 | result.ResolveMedianVal = 0 428 | result.ResolveJitterVal = 0 429 | result.ResolveLossVal = 0 430 | resultPool.Put(result) 431 | } 432 | 433 | // Updated function to save JSON output to file 434 | func saveJSON(results []TestResult) { 435 | jsonData, err := json.MarshalIndent(results, "", " ") 436 | if err != nil { 437 | fmt.Printf("Error marshaling JSON: %v\n", err) 438 | return 439 | } 440 | err = os.WriteFile("dns-tester-results.json", jsonData, 0644) 441 | if err != nil { 442 | fmt.Printf("Error writing JSON to file: %v\n", err) 443 | return 444 | } 445 | fmt.Println("Results saved to dns-tester-results.json") 446 | } 447 | 448 | // Updated function to save CSV output to file 449 | func saveCSV(results []TestResult) { 450 | file, err := os.Create("dns-tester-results.csv") 451 | if err != nil { 452 | fmt.Printf("Error creating CSV file: %v\n", err) 453 | return 454 | } 455 | defer file.Close() 456 | 457 | writer := csv.NewWriter(file) 458 | writer.Write([]string{ 459 | "Provider", "IP", "Avg Ping", "Median Ping", "Ping Jitter", "Ping Loss", 460 | "Avg Resolve", "Median Resolve", "Resolve Jitter", "Resolve Loss", 461 | }) 462 | for _, res := range results { 463 | writer.Write([]string{ 464 | res.Name, 465 | res.IP, 466 | res.AvgPing, 467 | res.PingMedian, 468 | res.PingJitter, 469 | res.PingLoss, 470 | res.AvgResolveTime, 471 | res.ResolveMedian, 472 | res.ResolveJitter, 473 | res.ResolveLoss, 474 | }) 475 | } 476 | writer.Flush() 477 | if err := writer.Error(); err != nil { 478 | fmt.Printf("Error writing CSV: %v\n", err) 479 | return 480 | } 481 | fmt.Println("Results saved to dns-tester-results.csv") 482 | } 483 | 484 | // Updated printRecommendations to show top 12 485 | func printRecommendations(results []TestResult) { 486 | // Find best (lowest) values across providers for each metric. 487 | epsilon := 0.0001 488 | bestPingAvg := 99999.0 489 | bestPingMedian := 99999.0 490 | bestPingJitter := 99999.0 491 | bestPingLoss := 99999.0 492 | bestResolveAvg := 99999.0 493 | bestResolveMedian := 99999.0 494 | bestResolveJitter := 99999.0 495 | bestResolveLoss := 99999.0 496 | 497 | for _, r := range results { 498 | if r.PingAvgVal < bestPingAvg { 499 | bestPingAvg = r.PingAvgVal 500 | } 501 | if r.PingMedianVal < bestPingMedian { 502 | bestPingMedian = r.PingMedianVal 503 | } 504 | if r.PingJitterVal < bestPingJitter { 505 | bestPingJitter = r.PingJitterVal 506 | } 507 | if r.PingLossVal < bestPingLoss { 508 | bestPingLoss = r.PingLossVal 509 | } 510 | if r.ResolveAvgVal < bestResolveAvg { 511 | bestResolveAvg = r.ResolveAvgVal 512 | } 513 | if r.ResolveMedianVal < bestResolveMedian { 514 | bestResolveMedian = r.ResolveMedianVal 515 | } 516 | if r.ResolveJitterVal < bestResolveJitter { 517 | bestResolveJitter = r.ResolveJitterVal 518 | } 519 | if r.ResolveLossVal < bestResolveLoss { 520 | bestResolveLoss = r.ResolveLossVal 521 | } 522 | } 523 | 524 | // Compute composite score for each provider. 525 | type RankedResult struct { 526 | TestResult 527 | CompositeScore float64 528 | } 529 | var ranked []RankedResult 530 | for _, r := range results { 531 | // Normalize each metric by dividing by the best value (plus epsilon to avoid division by zero). 532 | pingScore := (r.PingAvgVal/(bestPingAvg+epsilon))*weightPingAvg + 533 | (r.PingMedianVal/(bestPingMedian+epsilon))*weightPingMedian + 534 | (r.PingJitterVal/(bestPingJitter+epsilon))*weightPingJitter + 535 | (r.PingLossVal/(bestPingLoss+epsilon))*weightPingLoss 536 | resolveScore := (r.ResolveAvgVal/(bestResolveAvg+epsilon))*weightResolveAvg + 537 | (r.ResolveMedianVal/(bestResolveMedian+epsilon))*weightResolveMedian + 538 | (r.ResolveJitterVal/(bestResolveJitter+epsilon))*weightResolveJitter + 539 | (r.ResolveLossVal/(bestResolveLoss+epsilon))*weightResolveLoss 540 | cs := pingScore + resolveScore 541 | ranked = append(ranked, RankedResult{r, cs}) 542 | } 543 | 544 | // Sort by composite score (lower is better). 545 | sort.Slice(ranked, func(i, j int) bool { 546 | return ranked[i].CompositeScore < ranked[j].CompositeScore 547 | }) 548 | 549 | // Show top 12 (or fewer if less than 12 results). 550 | topCount := 12 551 | if len(ranked) < topCount { 552 | topCount = len(ranked) 553 | } 554 | topResults := ranked[:topCount] 555 | 556 | table := tablewriter.NewWriter(os.Stdout) 557 | table.SetHeader([]string{"Rank", "Provider", "IP", "Composite Score", "Avg Ping", "Median Ping", "Ping Jitter", "Ping Loss", "Avg Resolve", "Median Resolve", "Resolve Jitter", "Resolve Loss"}) 558 | table.SetAlignment(tablewriter.ALIGN_CENTER) 559 | 560 | for i, rr := range topResults { 561 | table.Append([]string{ 562 | strconv.Itoa(i + 1), 563 | rr.Name, 564 | rr.IP, 565 | fmt.Sprintf("%.2f", rr.CompositeScore), 566 | rr.AvgPing, 567 | rr.PingMedian, 568 | rr.PingJitter, 569 | rr.PingLoss, 570 | rr.AvgResolveTime, 571 | rr.ResolveMedian, 572 | rr.ResolveJitter, 573 | rr.ResolveLoss, 574 | }) 575 | } 576 | fmt.Println("\nTop 12 DNS Providers (ranked by composite score):") 577 | fmt.Println() 578 | table.Render() 579 | fmt.Println() 580 | } 581 | 582 | // Updated IPv6 detection function 583 | func isIPv6Enabled() bool { 584 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 585 | defer cancel() 586 | cmd := exec.CommandContext(ctx, "dig", "-6", "@2606:4700:4700::1111", "google.com", "+time=1", "+tries=1") 587 | err := cmd.Run() 588 | return err == nil 589 | } 590 | 591 | func main() { 592 | var outputFormat string 593 | var testRuns int 594 | flag.StringVar(&outputFormat, "output", "table", "Output format: table, json, csv") 595 | flag.StringVar(&outputFormat, "o", "table", "Shorthand for --output") 596 | flag.IntVar(&testRuns, "runs", 3, "Number of test runs for averaging") 597 | flag.IntVar(&testRuns, "r", 3, "Shorthand for --runs") 598 | flag.Parse() 599 | 600 | ctx := context.Background() 601 | var providers []Provider 602 | providers = append(providers, providersV4...) 603 | if isIPv6Enabled() { 604 | providers = append(providers, providersV6...) 605 | } 606 | numProviders := len(providers) 607 | resultChan := make(chan TestResult, numProviders) 608 | var results []TestResult 609 | 610 | banner := ` 611 | +------------------------------------------------------------------------------------+ 612 | | | 613 | | ██████╗ ███╗ ██╗███████╗ ████████╗███████╗███████╗████████╗███████╗██████╗ | 614 | | ██╔══██╗████╗ ██║██╔════╝ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝██╔══██╗ | 615 | | ██║ ██║██╔██╗ ██║███████╗ ██║ █████╗ ███████╗ ██║ █████╗ ██████╔╝ | 616 | | ██║ ██║██║╚██╗██║╚════██║ ██║ ██╔══╝ ╚════██║ ██║ ██╔══╝ ██╔══██╗ | 617 | | ██████╔╝██║ ╚████║███████║ ██║ ███████╗███████║ ██║ ███████╗██║ ██║ | 618 | | ╚═════╝ ╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚══════╝╚══════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ | 619 | | | 620 | +------------------------------------------------------------------------------------+ 621 | 622 | By: github.com/hawshemi 623 | 624 | ` 625 | 626 | fmt.Println(banner) 627 | 628 | // Adjust progress bar for total tasks (providers * runs) 629 | bar := progressbar.NewOptions(numProviders*testRuns, 630 | progressbar.OptionEnableColorCodes(true), 631 | progressbar.OptionShowCount(), 632 | progressbar.OptionSetWidth(50), 633 | progressbar.OptionSetDescription(fmt.Sprintf("Testing DNS providers (%d runs each)", testRuns)), 634 | progressbar.OptionSetTheme(progressbar.Theme{ 635 | Saucer: "[green]=[reset]", 636 | SaucerHead: "[green]>[reset]", 637 | SaucerPadding: " ", 638 | BarStart: "[", 639 | BarEnd: "]", 640 | }), 641 | progressbar.OptionSetWriter(os.Stderr), // Redirect progress bar to stderr 642 | ) 643 | 644 | var wg sync.WaitGroup 645 | semaphore := make(chan struct{}, maxWorkers) 646 | for _, provider := range providers { 647 | wg.Add(1) 648 | semaphore <- struct{}{} 649 | go func(p Provider) { 650 | defer func() { <-semaphore }() 651 | testProvider(ctx, p, testRuns, resultChan, &wg) 652 | for i := 0; i < testRuns; i++ { 653 | bar.Add(1) // Increment for each run 654 | } 655 | }(provider) 656 | } 657 | go func() { 658 | wg.Wait() 659 | close(resultChan) 660 | }() 661 | for res := range resultChan { 662 | results = append(results, res) 663 | } 664 | 665 | // Handle output based on format 666 | switch outputFormat { 667 | case "json": 668 | saveJSON(results) 669 | case "csv": 670 | saveCSV(results) 671 | default: 672 | // No action for "table" since we only print recommendations 673 | } 674 | // Always print top 12 recommendations 675 | printRecommendations(results) 676 | } 677 | --------------------------------------------------------------------------------