#!/usr/bin/env bash
# dnsperftest - Test DNS performances
# Version: 0.0.5 -- Last update: Tue Apr 21 14:15:24 2026
# (C) 2026 F-Hauri.ch - http://www.f-hauri.ch
# Rewrite, based on https://raw.githubusercontent.com/cleanbrowsing/dnsperftest/master/dnstest.sh
# Run all test parallelized in order to speed up whole process.
# Add a very useful spinner...

shopt -s extglob

command -v bc > /dev/null || { echo "error: bc was not found. Please install bc."; exit 1; }
{ command -v drill > /dev/null && function req() { timeout 1 drill "$@" ; } ; } ||
    { command -v dig > /dev/null && function req() { dig +tries=1 +time=2 +stats "$@";};} 
export -f req &>/dev/null || { echo "error: dig was not found. Please install dnsutils."; exit 1; }

# Spinner from https://f-hauri.ch/vrac/spinnerBg.sh
# --- Minimal code for simple backgrounded spinner without json begin here! ---
declare spinnerShapes=( ⢈⡱  ⢄⡱  ⢆⡰  ⢎⡠  ⢎⡁  ⢎⠑  ⠎⠱  ⠊⡱ )
declare -i doSpinner=0  # File descriptor to address spinner as background task
declare -xi spinnerFD    # File descriptor for spinner output, in order to not
[[ -t $spinnerFD ]] || exec {spinnerFD}>&1     # use any of STDOUT nor STDERR.
spinner() { # spinner as job.  ( Use global spinnerShapes)
    local -i pnt
    while ! read -rsn 1 -t .06 _; do # while no input until delay of .06 sec
        # Save cursor position, render frame, restore cursor position
        printf >&$spinnerFD '\e7%b\e8' \
               "${spinnerShapes[pnt++%${#spinnerShapes[@]}]}"
    done
}
startSpinner() { # startSpinner          # no arguments
    printf '\e[?25l' >&$spinnerFD        # Cursor invisible
    exec {doSpinner}> >(spinner)    # Run spinner with dedicated FD to stop
}
stopSpinner() { # stopSpinner            # no arguments
    if (( doSpinner )) && echo >&"$doSpinner"; then # Echo newline in FD
        exec {doSpinner}>&-              # Close doSpinner FD
        doSpinner=0
        printf '\e[?25h\r\e[K' >&$spinnerFD  # Cursor visible
    fi
}

# Run bc as background process and set scale=2
coproc BC { bc -l ;}
echo >&${BC[1]} "scale=2"

providersV4=(
	1.1.1.1/cloudflare		4.2.2.1/level3			8.8.8.8/google 
	9.9.9.9/quad9			80.80.80.80/freenom		208.67.222.123/opendns 
	199.85.126.20/norton		185.228.168.168/cleanbrowsing	77.88.8.7/yandex 
	156.154.70.3/neustar		8.26.56.26/comodo
	45.90.28.202/nextdns
)

providersV6=(
	2606:4700:4700::1111/cloudflare-v6	2001:4860:4860::8888/google-v6
	2620:fe::fe/quad9-v6			 2620:119:35::35/opendns-v6
	2a0d:2a00:1::1/cleanbrowsing-v6		2a02:6b8::feed:0ff/yandex-v6
	2a00:5a60::ad1:0ff/adguard-v6		2610:a1:1018::3/neustar-v6
)

hasipv6() { # Testing for IPv6 (As a function, to be executed only if asked for)
    req @2a0d:2a00:1::1 www.google.com |&
       grep -q 216.239.38.120 && return 0
    return 1
}

# Read 1st command line argument
case $1 in
    ipv6 )
	if ! hasipv6; then
            echo "error: IPv6 support not found. Unable to do the ipv6 test."
	    exit 1
	fi
	providers=("${providersV6[@]}")
	;;
    all )
	providers=("${providersV4[@]}")
	hasipv6 && 
	    providers+=("${providersV6[@]}")
	;;
    * )
	providers=("${providersV4[@]}")
	;;
esac

# Domains to test. Duplicated domains are ok
domains2test=(
	www.google.com	amazon.com	facebook.com	www.youtube.com	www.reddit.com 
	wikipedia.org	twitter.com	gmail.com	www.google.com	whatsapp.com
	cartou.ch	enfi.ch		github.com	stackexchange.com
)

# Print header line and prepare some variables
declare -i totaldomains=0 domcnt=0 pids=()
printf -v headerLine "%-20s" ""
for d in "${domains2test[@]}"; do
    totaldomains+=1
    printf -v headerLine "%s%8s" "$headerLine" "test$totaldomains"
    strAvgs+=("test$totaldomains") # List of domains(tests) for averages
    declare -i "test$totaldomains" # by domain (Use bash integer variables)
done
printf -v headerLine '%s%11s' "$headerLine" "Average"

startSpinner

printf >&$spinnerFD '\rList local DNS... \e[K'
# Get all nameservers, which answer to ping, in a bash array
mapfile -t nameservers < <(. <(
	sed -ne '/^nameserver \([0-9.]\+\)$/{
	    s@@ping -nc 1 \1 \&>/dev/null \&\& echo \1 \&@p
	}' /etc/resolv.conf)
	wait
)

printf >&$spinnerFD '\rDo DNS performances tests... \e[K'
for p in "${nameservers[@]}" "${providers[@]}"; do
    IFS=/ read -r pip pname <<<"$p"
    [[ $pname ]] || pname=$pip
    exec {fd[domcnt++]}< <(
	declare -i ftime=0
	printf -v string "%-21s" "$pname" # Build string to send once to parent
	for d in "${domains2test[@]}"; do
	    ttime=$( req  @$pip $d |
			 sed -une 's/^;; Query time: \([0-9]\+\) msec/\1/p')
            if [[ -z $ttime ]]; then
	        #let's have time out be 1s = 1000ms
	        ttime=1000
	    elif [[ $ttime  == 0 ]]; then
	        ttime=1
	    fi
	    
            printf -v string "%s%4.0f ms " "$string" "$ttime"
            ftime+=ttime
	done
	echo >&${BC[1]} "$ftime/$totaldomains" # Use backgrounded BC
	read -ru ${BC[0]} avg  # to compute average
	
	printf '%s%7.2f ms\n' "$string" "$avg" # Send builded string to parent
	exit 0
    )
    pids[$!]=
done
# Wait until all subprocess finish
wait "${!pids[@]}"

declare -i testAvg  # integer variable for overall average

# Prepare output to be sorted
testResults=()
for ((i=0;i<$domcnt;i++));do
    read -ru ${fd[i]} "line"

    testResults+=("$line")

    printf -v tmpStr '%s+=%%s ' "${strAvgs[@]}" "testAvg"
    line=${line#* }
    printf -v tmpStr "$tmpStr" ${line//+( ms|.)}  # Drop radix point, multiply by 100
    eval "$tmpStr"
done
 
# Compute averages by domain
eval "echo \"${strAvgs[@]//*/\$&\/$domcnt\;}\" $testAvg/$domcnt/100 >&${BC[1]}"
mapfile -t -n $(( 1 + totaldomains )) -u ${BC[0]} Avgs

stopSpinner

echo "$headerLine"
sort -nk $((  2 * totaldomains + 2 )) < <(
    printf '%s\n' "${testResults[@]}"
)

echo "Averages by domain:"
printf -v tmpStr ' - %-9s %%-21s %%%%6.2f\n' "${strAvgs[@]}" Overall
printf -v tmpStr "$tmpStr" "${domains2test[@]}" ''
printf "$tmpStr" "${Avgs[@]}" |
    column
