#!/usr/bin/env bash
# qbench.sh - quick UK-focused server benchmark (AlmaLinux / Rocky / RHEL 8-10, also Debian/Ubuntu)
#
# Installs nothing and downloads no third-party code. Uses only tools already on a
# minimal AlmaLinux install: bash, awk, curl, dd, openssl, ping, lscpu, free, df, lsblk.
#
# Usage (host this file yourself):
#   curl -sL https://YOUR-HOST/qbench.sh | bash
#   curl -sL https://YOUR-HOST/qbench.sh | bash -s -- --no-disk --no-net
#
# Options:
#   --no-disk   skip disk tests        --no-net   skip network tests
#   --label X   name to store with the result (default: hostname)
#   QB_NO_LOOKUP=1  skip the public IP / provider lookup (ipinfo.io)
#
# Runtime: about 2-3 minutes.

set -u
export LC_ALL=C

# ------------------------------------------------------------------
# Config - edit these for your own setup
# ------------------------------------------------------------------
# Download tests: "Name|URL". Each runs for at most NET_SECONDS.
DL_TARGETS=(
  "Clouvider London|https://lon.speedtest.clouvider.net/1g.bin"
  "Linode London|https://speedtest.london.linode.com/100MB-london.bin"
  "Vultr London|https://lon-gb-ping.vultr.com/vultr.com.100MB.bin"
  "Vultr Manchester|https://man-uk-ping.vultr.com/vultr.com.100MB.bin"
)
# Optional: your own receiver (qbench-receiver.php) for the upload test and result history.
# Leave empty to skip. Must NOT be behind Cloudflare (it caps uploads and skews speed).
RECEIVER_URL="https://qbench.hostxnow.com/qbench.sh"
RECEIVER_TOKEN="dhdhfkk445k5k333ndsssd"
NET_SECONDS=10
DISK_MB=1024          # size of each disk write test
# ------------------------------------------------------------------

DO_DISK=1; DO_NET=1; LABEL="$(hostname -s 2>/dev/null || hostname)"
while [ $# -gt 0 ]; do
  case "$1" in
    --no-disk) DO_DISK=0 ;;
    --no-net)  DO_NET=0 ;;
    --label)   LABEL="${2:-$LABEL}"; shift ;;
  esac
  shift
done

B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
line() { printf '%s\n' "----------------------------------------------------------------------"; }
row()  { printf ' %-22s: %s\n' "$1" "$2"; }
have() { command -v "$1" >/dev/null 2>&1; }

START=$(date +%s)
FQDN=$(hostname -f 2>/dev/null || hostname)
LOCAL_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="src") {print $(i+1); exit}}')
# Public IP + network owner. This is a plain JSON data lookup (no code is downloaded or run).
# Set QB_NO_LOOKUP=1 to skip it.
PUB_IP=""; PROVIDER=""; GEO=""; RDNS=""
if [ "${QB_NO_LOOKUP:-0}" != "1" ]; then
  INFO=$(curl -4 -fsS --max-time 5 https://ipinfo.io/json 2>/dev/null)
  jget() { printf '%s' "$INFO" | sed -n "s/.*\"$1\": *\"\([^\"]*\)\".*/\1/p" | head -1; }
  PUB_IP=$(jget ip); PROVIDER=$(jget org); RDNS=$(jget hostname)
  GEO="$(jget city), $(jget country)"; [ "$GEO" = ", " ] && GEO=""
fi
echo
line; printf ' %sqbench%s  %s  (%s)\n' "$B" "$N" "$LABEL" "$(date -u '+%Y-%m-%d %H:%M UTC')"; line
row "Hostname" "$FQDN"
row "Public IP" "${PUB_IP:-${LOCAL_IP:-unknown}}${RDNS:+  (rDNS $RDNS)}"
[ -n "$LOCAL_IP" ] && [ -n "$PUB_IP" ] && [ "$LOCAL_IP" != "$PUB_IP" ] && row "Local IP" "$LOCAL_IP  ${D}(behind NAT)${N}"
row "Provider / network" "${PROVIDER:-unknown}${GEO:+  -  $GEO}"

# ---------------- System ----------------
CPU_MODEL=$(awk -F: '/model name/ {gsub(/^ +/,"",$2); print $2; exit}' /proc/cpuinfo)
[ -z "$CPU_MODEL" ] && CPU_MODEL=$(lscpu 2>/dev/null | awk -F: '/Model name/ {gsub(/^ +/,"",$2); print $2; exit}')
CORES=$(nproc)
MHZ=$(awk -F: '/cpu MHz/ {printf "%.0f", $2; exit}' /proc/cpuinfo)
MAXMHZ=$(lscpu 2>/dev/null | awk -F: '/CPU max MHz/ {gsub(/ /,"",$2); printf "%.0f", $2; exit}')
L3=$(lscpu 2>/dev/null | awk -F: '/L3 cache/ {gsub(/^ +/,"",$2); print $2; exit}')
AVX512=$(grep -qw avx512f /proc/cpuinfo && echo yes || echo no)
AES=$(grep -qw aes /proc/cpuinfo && echo yes || echo no)
VIRT=$( (have systemd-detect-virt && systemd-detect-virt) 2>/dev/null || echo unknown)
RAM=$(free -m | awk '/^Mem:/ {printf "%.1f GiB", $2/1024}')
SWAP=$(free -m | awk '/^Swap:/ {printf "%.1f GiB", $2/1024}')
OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")
ROOTDISK=$(df -h / | awk 'NR==2 {print $2" total, "$4" free"}')
DISKTYPE=$(lsblk -dno NAME,ROTA,TRAN,MODEL 2>/dev/null | awk -v v="$VIRT" '$1!~/^(loop|sr|zram)/ {t=($2==0?"SSD":"HDD"); if($1~/^nvme/)t="NVMe"; if(v!="none" && $1!~/^nvme/)t="virtual disk"; printf "%s %s; ", $1, t}')

row "OS" "$OS"
row "Kernel" "$(uname -r)"
row "Virtualisation" "$VIRT"
row "CPU" "$CPU_MODEL"
row "Cores / threads" "$CORES  (current ${MHZ:-?} MHz${MAXMHZ:+, max $MAXMHZ MHz}${L3:+, L3 $L3})"
row "AES-NI / AVX-512" "$AES / $AVX512"
row "RAM / swap" "$RAM / $SWAP"
if [ "$VIRT" != "none" ]; then
  row "Memory type" "hidden by the host (only visible on dedicated servers)"
elif [ "$(id -u)" -eq 0 ] && have dmidecode; then
  MEMTYPE=$(dmidecode -t memory 2>/dev/null | awk -F: '
    /^\tType:/ && $2!~/Unknown|Other/ {t=$2}
    /^\tConfigured Memory Speed:/ && $2!~/Unknown/ {s=$2}
    /^\tSize:/ && $2!~/No Module/ {n++}
    END {if(t!="") printf "%s,%s (%d modules)", t, s, n}')
  ECC=$(dmidecode -t 16 2>/dev/null | awk -F: '/Error Correction Type/ {gsub(/^ /,"",$2); print $2; exit}')
  [ -n "$MEMTYPE" ] && row "Memory type" "$MEMTYPE${ECC:+, ECC: $ECC}"
fi
row "Root disk" "$ROOTDISK"
row "Disks" "${DISKTYPE:-unknown}"
row "Uptime / load" "$(uptime -p 2>/dev/null) / $(awk '{print $1,$2,$3}' /proc/loadavg)"

# ---------------- Steal time ----------------
ST=$(vmstat 1 6 2>/dev/null | awk 'NR>3 {s+=$NF; n++} END {if(n) printf "%.1f", s/n}')
row "CPU steal (5s avg)" "${ST:-?}%  ${D}(above 1-2% = noisy neighbours)${N}"
line

# ---------------- CPU ----------------
rsa_speed() { # $1 = threads; prints RSA-2048 signatures per second
  # RSA signing is plain integer maths with no special CPU instruction behind it, so it
  # compares fairly across Intel and AMD (SHA256 doesn't: AMD has SHA hardware, old Xeons don't).
  local multi=""; [ "$1" -gt 1 ] && multi="-multi $1"
  # shellcheck disable=SC2086
  # -mr gives machine-readable output whose layout is stable across OpenSSL 3.0 / 3.2 / 3.5:
  # +F2:<index>:<bits>:<sign/s>:<verify/s>[:...]
  openssl speed -mr $multi -seconds 4 rsa2048 2>/dev/null | awk -F: '$1=="+F2" && $3==2048 {v=$4} END {if (v>0) printf "%.0f", v}'
}
script_speed() { # single-thread interpreted loop, similar in spirit to PHP work; prints ms
  local t0 t1
  t0=$(date +%s%N)
  awk 'BEGIN { s=0; for (i=0; i<3000000; i++) { s += i % 7; x = sprintf("%d", i); h[i % 1000] = x } }'
  t1=$(date +%s%N)
  echo $(( (t1 - t0) / 1000000 ))
}
printf ' %sCPU%s\n' "$B" "$N"
CPU1=$(rsa_speed 1)
row "RSA-2048 1 thread" "${CPU1:-?} signs/s  ${D}(higher is better)${N}"
CPUN=$(rsa_speed "$CORES")
row "RSA-2048 $CORES threads" "${CPUN:-?} signs/s"
SCRIPT_MS=$(script_speed)
row "Script loop 1 thread" "${SCRIPT_MS} ms  ${D}(lower is better, closest to PHP)${N}"
line

# ---------------- Disk ----------------
DISK_W="" DISK_R="" DISK_SYNC=""
if [ $DO_DISK -eq 1 ]; then
  printf ' %sDisk%s  %s(direct I/O, bypasses RAM cache)%s\n' "$B" "$N" "$D" "$N"
  TESTDIR=$(df -P / | awk 'NR==2 {print $6}'); [ -w "$TESTDIR" ] || TESTDIR=/tmp
  TF="$TESTDIR/.qbench.$$"
  FREE_MB=$(df -Pm "$TESTDIR" | awk 'NR==2 {print $4}')
  [ "$FREE_MB" -lt $((DISK_MB * 2)) ] && DISK_MB=$((FREE_MB / 4))
  trap 'rm -f "$TF"' EXIT

  dd_speed() { awk '/copied/ {for(i=1;i<=NF;i++) if($i ~ /\/s$/) {u=$i; v=$(i-1)}; if(u=="GB/s")v*=1000; if(u=="kB/s")v/=1000; printf "%.0f", v}'; }
  total=0
  for i in 1 2 3; do
    s=$(dd if=/dev/zero of="$TF" bs=1M count="$DISK_MB" oflag=direct conv=fdatasync 2>&1 | dd_speed)
    total=$((total + ${s:-0})); printf -v r$i '%s' "$s"
  done
  DISK_W=$((total / 3))
  row "Seq write 1M x${DISK_MB}MB" "${r1} / ${r2} / ${r3} MB/s  (avg ${DISK_W} MB/s)"
  sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null
  DISK_R=$(dd if="$TF" of=/dev/null bs=1M iflag=direct 2>&1 | dd_speed)
  row "Seq read 1M" "${DISK_R} MB/s"
  # 4k synchronous writes: each write waits for the disk, like database commits/fsync
  t0=$(date +%s%N)
  dd if=/dev/zero of="$TF" bs=4k count=2000 oflag=direct,dsync 2>/dev/null
  t1=$(date +%s%N)
  DISK_SYNC=$(awk -v a="$t0" -v b="$t1" 'BEGIN {printf "%.0f", 2000 / ((b-a)/1e9)}')
  SYNC_LAT=$(awk -v a="$t0" -v b="$t1" 'BEGIN {printf "%.3f", ((b-a)/1e6)/2000}')
  row "4k sync writes" "${DISK_SYNC} IOPS  (${SYNC_LAT} ms each - lower is better for MySQL)"
  rm -f "$TF"
  line
fi

# ---------------- Network ----------------
NET_SUMMARY=""
if [ $DO_NET -eq 1 ]; then
  printf ' %sNetwork%s  %s(UK, %ss per test, single connection)%s\n' "$B" "$N" "$D" "$NET_SECONDS" "$N"
  for t in "${DL_TARGETS[@]}"; do
    name=${t%%|*}; url=${t#*|}
    host=$(echo "$url" | awk -F/ '{print $3}')
    ping_ms=$(ping -c 4 -W 2 -q "$host" 2>/dev/null | awk -F/ '/^rtt|^round-trip/ {printf "%.1f ms", $5}')
    sp=$(curl -s -o /dev/null --max-time "$NET_SECONDS" -w '%{speed_download}' "$url" 2>/dev/null)
    mbit=$(awk -v s="${sp:-0}" 'BEGIN {printf "%.0f", s*8/1000000}')
    row "$name" "down ${mbit} Mbit/s   ping ${ping_ms:-n/a}"
    NET_SUMMARY="${NET_SUMMARY}${name}=${mbit};"
  done
  if [ -n "$RECEIVER_URL" ]; then
    # Pre-generate the payload so the test measures the network, not /dev/urandom.
    UPF=$(mktemp /dev/shm/qbench-up.XXXXXX 2>/dev/null || mktemp)
    head -c 50000000 /dev/urandom > "$UPF"
    res=$(curl -s -o /dev/null --max-time 30 -D - -H "X-Qbench-Token: $RECEIVER_TOKEN" \
          -H 'Content-Type: application/octet-stream' --data-binary @"$UPF" \
          -w 'QB %{http_code} %{speed_upload}' "$RECEIVER_URL?upload=1" 2>/dev/null)
    rm -f "$UPF"
    code=$(echo "$res" | awk '/^QB/ {print $2}'); sp=$(echo "$res" | awk '/^QB/ {print $3}')
    up=$(awk -v s="${sp:-0}" 'BEGIN {printf "%.0f", s*8/1000000}')
    if [ "$code" = "200" ]; then
      row "Upload to your server" "up ${up} Mbit/s  ${D}(50MB)${N}"
    else
      why="HTTP ${code:-none}"
      echo "$res" | grep -qi '^server: *cloudflare' && why="$why - receiver is behind Cloudflare, use a non-proxied hostname"
      [ "$code" = "413" ] && why="$why - web server upload size limit"
      [ "$code" = "403" ] && why="$why - token mismatch"
      [ "$code" = "501" ] && why="$why - web server rejected the request method"
      row "Upload to your server" "failed ($why)"; up=0
    fi
    NET_SUMMARY="${NET_SUMMARY}upload=${up};"
  fi
  line
fi

# ---------------- History ----------------
ELAPSED=$(( $(date +%s) - START ))
CSV_HEAD="date,label,hostname,ip,provider,cpu,cores,virt,steal,rsa_1t,rsa_all,script_ms,disk_write_mbs,disk_read_mbs,sync4k_iops,network"
CSV_ROW="$(date -u +%FT%TZ),\"$LABEL\",\"$FQDN\",${PUB_IP:-$LOCAL_IP},\"$PROVIDER\",\"$CPU_MODEL\",$CORES,$VIRT,${ST:-},${CPU1:-},${CPUN:-},${SCRIPT_MS:-},${DISK_W:-},${DISK_R:-},${DISK_SYNC:-},\"$NET_SUMMARY\""
HIST="$HOME/qbench-history.csv"
[ -f "$HIST" ] || echo "$CSV_HEAD" > "$HIST"
echo "$CSV_ROW" >> "$HIST"
if [ -n "$RECEIVER_URL" ]; then
  curl -s -o /dev/null --max-time 10 -H "X-Qbench-Token: $RECEIVER_TOKEN" \
       --data-urlencode "row=$CSV_ROW" "$RECEIVER_URL?save=1" && SAVED=" and sent to your receiver"
fi
printf ' Finished in %ss. Result saved to %s%s\n' "$ELAPSED" "$HIST" "${SAVED:-}"
line
