#!/usr/bin/env bash # # wieschnell.net – Speedtest im Terminal # aepfel+birnen IT GmbH · Open Source. Open Mind. # # curl -sSL https://wieschnell.net/cli | bash # curl -sSL https://wieschnell.net/cli | bash -s -- --help # # Misst Ping, Jitter, Download und Upload gegen speed.aundb.io (LibreSpeed-Backend). # Benötigt nur bash (ab 3.2) und curl. Es werden keine Ergebnisse gespeichert. SERVER="${SPEEDTEST_SERVER:-https://speed.aundb.io}" DURATION="${SPEEDTEST_DURATION:-10}" DL_STREAMS=6 UL_STREAMS=3 DL_GRACE=1.5 UL_GRACE=3 PINGS=10 OVERHEAD=1.06 # wie im Browser-Test (LibreSpeed overheadCompensationFactor) TESTS="PDU" IPV="" JSON=0 usage() { cat <<'EOF' wieschnell.net – Speedtest im Terminal curl -sSL https://wieschnell.net/cli | bash -s -- [Optionen] Optionen -4 nur IPv4 verwenden -6 nur IPv6 verwenden --json Ergebnis als JSON ausgeben (für Skripte und Monitoring) --no-upload nur Ping und Download messen --duration N Messdauer je Richtung in Sekunden (Standard: 10) -h, --help diese Hilfe Umgebung SPEEDTEST_SERVER Gegenstelle (Standard: https://speed.aundb.io) NO_COLOR Farben abschalten EOF } while [ $# -gt 0 ]; do case "$1" in -4) IPV="-4" ;; -6) IPV="-6" ;; --json) JSON=1 ;; --no-upload) TESTS="PD" ;; --duration) shift; DURATION="${1:-}" ;; -h|--help) usage; exit 0 ;; *) echo "Unbekannte Option: $1" >&2; usage >&2; exit 2 ;; esac shift done case "$DURATION" in ''|*[!0-9]*) echo "--duration erwartet eine Zahl in Sekunden" >&2; exit 2 ;; esac [ "$DURATION" -lt 4 ] && DURATION=4 command -v curl >/dev/null 2>&1 || { echo "Fehler: curl wird benötigt." >&2; exit 1; } # ── Darstellung ────────────────────────────────────────────────────────────── FANCY=0 if [ -t 1 ] && [ "$JSON" -eq 0 ] && [ "${TERM:-dumb}" != "dumb" ]; then FANCY=1; fi UTF=0 case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) UTF=1 ;; esac [ "$(uname -s 2>/dev/null)" = "Darwin" ] && UTF=1 COLOR=$FANCY [ -n "${NO_COLOR:-}" ] && COLOR=0 TRUECOLOR=0 case "${COLORTERM:-}" in truecolor|24bit) TRUECOLOR=1 ;; esac # Rechnen/Formatieren unabhängig von der Locale (Dezimalpunkt) export LC_ALL=C ESC=$(printf '\033') B=""; D=""; R=""; YEL=""; TEAL=""; MINT=""; GRAY=""; RED=""; TRACK="" c6() { if [ "$1" -lt 48 ]; then C6=0; elif [ "$1" -lt 115 ]; then C6=1; else C6=$(( ($1 - 35) / 40 )); fi; } rgb() { if [ "$COLOR" -eq 0 ]; then REPLY=""; return; fi if [ "$TRUECOLOR" -eq 1 ]; then REPLY="${ESC}[38;2;$1;$2;$3m"; return; fi local r g; c6 "$1"; r=$C6; c6 "$2"; g=$C6; c6 "$3" REPLY="${ESC}[38;5;$(( 16 + 36 * r + 6 * g + C6 ))m" } if [ "$COLOR" -eq 1 ]; then B="${ESC}[1m"; D="${ESC}[2m"; R="${ESC}[0m" rgb 253 193 50; YEL=$REPLY rgb 35 145 177; TEAL=$REPLY rgb 99 181 161; MINT=$REPLY rgb 150 150 150; GRAY=$REPLY rgb 220 95 95; RED=$REPLY rgb 70 70 70; TRACK=$REPLY fi if [ "$UTF" -eq 1 ]; then H_FULL="━"; H_HALF="╸"; H_EMPTY="─" OK="✔"; NO="✖"; DOT="·"; DOWN="↓"; UP="↑"; PINGI="◷" SPIN="⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏" SPARKS="▁ ▂ ▃ ▄ ▅ ▆ ▇ █" BOX_TL="╭"; BOX_TR="╮"; BOX_BL="╰"; BOX_BR="╯"; BOX_H="─"; BOX_V="│" else H_FULL="="; H_HALF="-"; H_EMPTY="." OK="+"; NO="x"; DOT="-"; DOWN="v"; UP="^"; PINGI="o" SPIN="- \\ | /" SPARKS="_ . - ~ = * # @" BOX_TL="+"; BOX_TR="+"; BOX_BL="+"; BOX_BR="+"; BOX_H="-"; BOX_V="|" fi set -f; SPIN_A=($SPIN); set +f COLS=$(tput cols 2>/dev/null || echo 80) case "$COLS" in ''|*[!0-9]*) COLS=80 ;; esac W=28 [ "$COLS" -lt 76 ] && W=16 # Farbverlauf wie der Tacho im Browser: #196773 → #2391b1 → #63b5a1 → #d1dc78 GRAD=() STOPS=(25 103 115 35 145 177 99 181 161 209 220 120) for ((i = 0; i < W; i++)); do pos=$(( i * 300 / (W - 1) )); seg=$(( pos / 100 )); [ $seg -gt 2 ] && seg=2 f=$(( pos - seg * 100 )); o=$(( seg * 3 )) r=$(( STOPS[o] + (STOPS[o+3] - STOPS[o]) * f / 100 )) g=$(( STOPS[o+1] + (STOPS[o+4] - STOPS[o+1]) * f / 100 )) b=$(( STOPS[o+2] + (STOPS[o+5] - STOPS[o+2]) * f / 100 )) rgb $r $g $b; GRAD[i]=$REPLY done rep() { local s="" i; for ((i = 0; i < $2; i++)); do s="$s$1"; done; REPLY=$s; } # Balken: logarithmische Skala bis 10 Gbit/s render_bar() { local halves full half i out="" halves=$(awk -v m="$1" -v w="$W" 'BEGIN{ if (m < 0) m = 0; f = log(1 + m) / log(10001); if (f > 1) f = 1; printf "%d", f * w * 2 + 0.5 }') full=$(( halves / 2 )); half=$(( halves % 2 )) for ((i = 0; i < W; i++)); do if [ $i -lt $full ]; then out="$out${GRAD[i]}$H_FULL" elif [ $i -eq $full ] && [ $half -eq 1 ]; then out="$out${GRAD[i]}$H_HALF" else out="$out$TRACK$H_EMPTY"; fi done BAR="$out$R" } fmt() { awk -v d="$1" 'BEGIN{ if (d < 10) s = sprintf("%.2f", d); else if (d < 100) s = sprintf("%.1f", d); else s = sprintf("%.0f", d); sub(/\./, ",", s); print s }'; } sparkline() { awk -v chars="$SPARKS" -v n=18 '{ v[NR] = $1; if ($1 > mx) mx = $1 } END { split(chars, c, " "); if (NR == 0 || mx <= 0) exit k = (NR < n) ? NR : n for (i = 0; i < k; i++) { a = int(i * NR / k) + 1; b = int((i + 1) * NR / k); if (b < a) b = a s = 0; for (j = a; j <= b; j++) s += v[j]; s /= (b - a + 1) idx = int(s / mx * 7 + 0.5) + 1; out = out c[idx] } printf "%s", out }' } # ── Zeit, Prozesse, Aufräumen ──────────────────────────────────────────────── if [ -n "${EPOCHREALTIME:-}" ]; then now() { NOW=$EPOCHREALTIME; } elif command -v perl >/dev/null 2>&1; then now() { NOW=$(perl -MTime::HiRes=time -e 'printf "%.6f", time'); } else now() { NOW=$(date +%s); } fi addf() { awk -v a="$1" -v b="$2" 'BEGIN{ printf "%.6f", a + b }'; } TMP=$(mktemp -d 2>/dev/null || mktemp -d -t wieschnell) PIDS="" stop_streams() { [ -n "$PIDS" ] && kill $PIDS 2>/dev/null; PIDS=""; } cleanup() { stop_streams rm -rf "$TMP" [ "$FANCY" -eq 1 ] && printf '%s' "${ESC}[?25h" } trap cleanup EXIT trap 'printf "\n"; exit 130' INT TERM # ── Messungen ──────────────────────────────────────────────────────────────── probe_dl() { curl $IPV -s -m 15 -o /dev/null -w '%{size_download} %{time_pretransfer} %{time_total}' \ "$SERVER/backend/garbage.php?ckSize=$1&r=probe$RANDOM" 2>/dev/null } probe_ul() { curl $IPV -s -m 15 -o /dev/null -X POST -H 'Expect:' -H 'Content-Type: application/octet-stream' \ -T "$TMP/ul.bin" -w '%{size_upload} %{time_pretransfer} %{time_total}' \ "$SERVER/backend/empty.php?r=probe$RANDOM" 2>/dev/null } # Chunkgröße so wählen, dass ein Chunk pro Stream ca. 0,5 s dauert (1–$2 MB) chunk_mb() { echo "$1" | awk -v max="$2" '{ t = $3 - $2; if (t <= 0) t = 0.001; bps = $1 / t; mb = int(bps * 0.5 / 1048576 + 0.5); if (mb < 1) mb = 1; if (mb > max) mb = max; print mb }'; } # curl ab 7.63 kann die -w-Ausgabe ungepuffert auf stderr schreiben; dann läuft jeder Stream # als ein Prozess mit wiederverwendeter Verbindung, sonst als Schleife einzelner Aufrufe. CURL_V=$(curl --version 2>/dev/null | awk 'NR == 1 { split($2, v, "."); printf "%d", v[1] * 10000 + v[2] * 100 + v[3] }') [ "${CURL_V:-0}" -ge 76300 ] && [ -z "${SPEEDTEST_LEGACY:-}" ] && CURL_STDERR=1 || CURL_STDERR=0 stream_url() { if [ "$1" = dl ]; then REPLY="$SERVER/backend/garbage.php?ckSize=$2&r=$3.$RANDOM"; else REPLY="$SERVER/backend/empty.php?r=$3.$RANDOM"; fi; } curl_stream() { # $1 dl|ul $2 Format $3.. curl-Argumente local kind=$1 w=$2; shift 2 if [ "$kind" = dl ]; then curl $IPV -s -w "$w" "$@" else curl $IPV -s -X POST -H 'Expect:' -H 'Content-Type: application/octet-stream' -w "$w" "$@" fi } start_streams() { # $1 dl|ul $2 Anzahl $3 Chunk-MB local kind=$1 n=$2 mb=$3 i j args fmtw # ohne Verbindungs-Wiederverwendung größere Chunks, damit Handshake/Slow-Start weniger ins Gewicht fallen if [ "$CURL_STDERR" -eq 0 ]; then mb=$((mb * 4)); [ "$kind" = dl ] && [ $mb -gt 64 ] && mb=64; [ "$kind" = ul ] && [ $mb -gt 32 ] && mb=32; fi [ "$kind" = dl ] && fmtw='%{size_download} %{time_total}\n' || fmtw='%{size_upload} %{time_total}\n' for ((i = 1; i <= n; i++)); do now; echo "S $NOW" > "$TMP/$kind.$i" if [ "$CURL_STDERR" -eq 1 ]; then args=() for ((j = 0; j < 400; j++)); do stream_url "$kind" "$mb" "$i.$j" if [ "$kind" = dl ]; then args+=(-o /dev/null "$REPLY"); else args+=(-T "$TMP/ul.bin" -o /dev/null "$REPLY"); fi done curl_stream "$kind" "%{stderr}$fmtw" "${args[@]}" 2>> "$TMP/$kind.$i" >/dev/null & else ( j=0 while :; do j=$((j + 1)); stream_url "$kind" "$mb" "$i.$j" if [ "$kind" = dl ]; then curl_stream dl "$fmtw" -o /dev/null "$REPLY" else curl_stream ul "$fmtw" -T "$TMP/ul.bin" -o /dev/null "$REPLY"; fi done >> "$TMP/$kind.$i" 2>/dev/null ) & fi PIDS="$PIDS $!"; disown $! 2>/dev/null sleep 0.1 done } # Summe der Stream-Raten in Mbit/s. Pro Stream werden die Zeitpunkte der fertigen Chunks # rekonstruiert (Start + kumulierte time_total) und anteilig ab $2 (Grace-Ende) gezählt. calc_rate() { awk -v g="$2" -v oh="$OVERHEAD" ' function fin() { if (!have) return if (c > gi + 0.05 && bw > 0) tot += bw / (c - gi) else if (c > st && ball > 0) tot += ball / (c - st) } FNR == 1 { fin(); have = 1; st = $2; c = st; gi = (g > st) ? g : st; bw = 0; ball = 0; next } { a = c; c = a + $2; s = $1; ball += s if (c > gi && c > a) { lo = (a > gi) ? a : gi; bw += s * (c - lo) / (c - a) } } END { fin(); printf "%.3f", tot * 8 / 1e6 * oh }' "$TMP/$1".* 2>/dev/null } live_line() { # $1 Icon $2 Label $3 Mbit/s $4 Spinner $5 Restsekunden render_bar "$3" printf '\r%s %s%s%s %-9s %s %s%11s%s %s%s %ss%s' "${ESC}[K" "$TEAL" "$1" "$R" "$2" "$BAR" "$B" "$(fmt "$3") Mbit/s" "$R" "$GRAY" "$4" "$5" "$R" } final_line() { # $1 Label $2 Mbit/s $3 Sparkline render_bar "$2" printf '\r%s %s%s%s %-9s %s %s%11s%s %s%s%s\n' "${ESC}[K" "$MINT" "$OK" "$R" "$1" "$BAR" "$B" "$(fmt "$2") Mbit/s" "$R" "$TEAL" "$3" "$R" } run_transfer() { # $1 dl|ul $2 Label $3 Icon $4 Streams $5 Chunk-MB $6 Grace local kind=$1 t0 tg te rate left k=0 samples="$TMP/samples-$1" start_streams "$kind" "$4" "$5" now; t0=$NOW; tg=$(addf "$t0" "$6"); te=$(addf "$t0" "$DURATION") : > "$samples" while :; do sleep 0.25; now if awk -v n="$NOW" -v e="$te" 'BEGIN{ exit !(n >= e) }'; then break; fi if awk -v n="$NOW" -v g="$tg" 'BEGIN{ exit !(n >= g) }'; then rate=$(calc_rate "$kind" "$tg"); echo "$rate" >> "$samples" else rate=$(calc_rate "$kind" 0) fi if [ "$FANCY" -eq 1 ]; then left=$(awk -v n="$NOW" -v e="$te" 'BEGIN{ printf "%d", e - n + 0.99 }') live_line "$3" "$2" "$rate" "${SPIN_A[k % ${#SPIN_A[@]}]}" "$left" k=$((k + 1)) fi done stop_streams RESULT=$(calc_rate "$kind" "$tg") [ "$FANCY" -eq 1 ] && final_line "$2" "$RESULT" "$(sparkline < "$samples")" return 0 } ping_test() { local args=() i out for ((i = 0; i <= PINGS; i++)); do args+=(-o /dev/null "$SERVER/backend/empty.php?r=p$i.$RANDOM"); done out=$(curl $IPV -s -m 20 -w '%{time_pretransfer} %{time_starttransfer}\n' "${args[@]}" 2>/dev/null) # erste Anfrage enthält den Verbindungsaufbau und wird verworfen set -- $(echo "$out" | awk 'NR > 1 && NF == 2 { p = ($2 - $1) * 1000; if (min == "" || p < min) min = p if (n) { d = p - prev; if (d < 0) d = -d; js += d; jn++ } prev = p; n++ } END { if (min == "") min = -1; printf "%.3f %.3f", min, (jn ? js / jn : 0) }') PING=$1; JITTER=$2 } # ── Ablauf ─────────────────────────────────────────────────────────────────── code=$(curl $IPV -s -m 8 -o /dev/null -w '%{http_code}' "$SERVER/backend/empty.php" 2>/dev/null) if [ "$code" != "200" ]; then echo "Fehler: $SERVER ist nicht erreichbar (HTTP ${code:-000})." >&2 exit 1 fi CLIENT_IP=$(curl $IPV -s -m 5 "$SERVER/ip" 2>/dev/null | tr -d '\r\n ') case "$CLIENT_IP" in *:*) FAMILY="IPv6" ;; *.*) FAMILY="IPv4" ;; *) FAMILY="?" ;; esac HOST=$(echo "$SERVER" | sed -E 's#^[a-z]+://##; s#/.*##') if [ "$FANCY" -eq 1 ]; then printf '%s' "${ESC}[?25l" rep "$BOX_H" 50; line=$REPLY printf '\n %s%s%s%s%s\n' "$TEAL" "$BOX_TL" "$line" "$BOX_TR" "$R" printf ' %s%s%s %s%swieschnell.net%s %s%s%s Speedtest im Terminal %s%s%s\n' "$TEAL" "$BOX_V" "$R" "$B" "$YEL" "$R" "$GRAY" "$DOT" "$R" "$TEAL" "$BOX_V" "$R" printf ' %s%s%s %saepfel+birnen IT %s Open Source. Open Mind.%s %s%s%s\n' "$TEAL" "$BOX_V" "$R" "$GRAY" "$DOT" "$R" "$TEAL" "$BOX_V" "$R" printf ' %s%s%s%s%s\n\n' "$TEAL" "$BOX_BL" "$line" "$BOX_BR" "$R" printf ' %sServer%s %s %s%s Falkenstein (DE)%s\n' "$GRAY" "$R" "$HOST" "$GRAY" "$DOT" "$R" printf ' %sClient%s %s %s%s %s%s\n\n' "$GRAY" "$R" "${CLIENT_IP:-unbekannt}" "$GRAY" "$DOT" "$FAMILY" "$R" fi PING=""; JITTER=""; DL=""; UL="" case "$TESTS" in *P*) [ "$FANCY" -eq 1 ] && printf ' %s%s%s %-9s %smesse …%s' "$TEAL" "$PINGI" "$R" "Ping" "$GRAY" "$R" ping_test if [ "$FANCY" -eq 1 ]; then printf '\r%s %s%s%s %-9s %s%s ms%s %sJitter%s %s%s ms%s\n' "${ESC}[K" "$MINT" "$OK" "$R" "Ping" "$B" "$(fmt "$PING")" "$R" "$GRAY" "$R" "$B" "$(fmt "$JITTER")" "$R" fi ;; esac case "$TESTS" in *D*) [ "$FANCY" -eq 1 ] && printf ' %s%s%s %-9s %smesse Leitung …%s' "$TEAL" "$DOWN" "$R" "Download" "$GRAY" "$R" mb=$(chunk_mb "$(probe_dl 1)" 64) run_transfer dl "Download" "$DOWN" "$DL_STREAMS" "$mb" "$DL_GRACE" DL=$RESULT ;; esac case "$TESTS" in *U*) [ "$FANCY" -eq 1 ] && printf ' %s%s%s %-9s %smesse Leitung …%s' "$TEAL" "$UP" "$R" "Upload" "$GRAY" "$R" head -c 1048576 /dev/urandom > "$TMP/ul.bin" mb=$(chunk_mb "$(probe_ul)" 32) head -c $(( mb * 1048576 )) /dev/urandom > "$TMP/ul.bin" run_transfer ul "Upload" "$UP" "$UL_STREAMS" "$mb" "$UL_GRACE" UL=$RESULT ;; esac # ── Ergebnis ───────────────────────────────────────────────────────────────── if [ "$JSON" -eq 1 ]; then jn() { if [ -n "$1" ]; then awk -v v="$1" 'BEGIN{ printf "%.2f", v }'; else printf 'null'; fi; } printf '{"server":"%s","ip":"%s","ip_version":"%s","ping_ms":%s,"jitter_ms":%s,"download_mbps":%s,"upload_mbps":%s,"timestamp":"%s"}\n' \ "$HOST" "$CLIENT_IP" "$FAMILY" "$(jn "$PING")" "$(jn "$JITTER")" "$(jn "$DL")" "$(jn "$UL")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" exit 0 fi if [ "$FANCY" -eq 0 ]; then echo "wieschnell.net Speedtest" echo "Server: $HOST" echo "Client: ${CLIENT_IP:-unbekannt} ($FAMILY)" [ -n "$PING" ] && echo "Ping: $(fmt "$PING") ms (Jitter $(fmt "$JITTER") ms)" [ -n "$DL" ] && echo "Download: $(fmt "$DL") Mbit/s" [ -n "$UL" ] && echo "Upload: $(fmt "$UL") Mbit/s" exit 0 fi ge() { [ -n "$1" ] && awk -v a="$1" -v b="$2" 'BEGIN{ exit !(a >= b) }'; } lt() { [ -n "$1" ] && awk -v a="$1" -v b="$2" 'BEGIN{ exit !(a < b) }'; } val() { if [ -n "$1" ]; then REPLY="$(fmt "$1") $2"; else REPLY="-"; fi; } rep "$BOX_H" 21; l1=$REPLY; rep "$BOX_H" 22; l2=$REPLY; rep "$BOX_H" 53; l3=$REPLY printf '\n %s%s%s %sErgebnis%s %s%s%s%s\n' "$TEAL" "$BOX_TL" "$l1" "$YEL$B" "$R" "$TEAL" "$l2" "$BOX_TR" "$R" val "$DL" "Mbit/s"; v1=$REPLY; val "$UL" "Mbit/s"; v2=$REPLY printf ' %s%s%s %-10s%s%14s%s %-8s%s%14s%s %s%s%s\n' "$TEAL" "$BOX_V" "$R" "Download" "$B" "$v1" "$R" "Upload" "$B" "$v2" "$R" "$TEAL" "$BOX_V" "$R" val "$PING" "ms"; v1=$REPLY; val "$JITTER" "ms"; v2=$REPLY printf ' %s%s%s %-10s%s%14s%s %-8s%s%14s%s %s%s%s\n' "$TEAL" "$BOX_V" "$R" "Ping" "$B" "$v1" "$R" "Jitter" "$B" "$v2" "$R" "$TEAL" "$BOX_V" "$R" printf ' %s%s%s%s%s\n\n' "$TEAL" "$BOX_BL" "$l3" "$BOX_BR" "$R" check() { # $1 Bedingung erfüllt (0/1/-) $2 Text case "$1" in 1) REPLY="$MINT$OK$R $(printf '%-19s' "$2")" ;; 0) REPLY="$RED$NO$R $GRAY$(printf '%-19s' "$2")$R" ;; *) REPLY="$GRAY$DOT $(printf '%-19s' "$2")$R" ;; esac } yes_if() { if "$@"; then echo 1; else echo 0; fi; } c_web=$(yes_if eval 'ge "$DL" 2 && lt "$PING" 150') if ge "$DL" 25; then c_tv=1; tv="4K-Streaming"; elif ge "$DL" 8; then c_tv=1; tv="HD-Streaming"; else c_tv=0; tv="HD-Streaming"; fi if [ -n "$UL" ]; then c_video=$(yes_if eval 'ge "$DL" 4 && ge "$UL" 3 && lt "$PING" 80 && lt "$JITTER" 30') c_backup=$(yes_if ge "$UL" 40) else c_video="-"; c_backup="-" fi c_game=$(yes_if eval 'ge "$DL" 5 && lt "$PING" 35 && lt "$JITTER" 10') printf ' %sGeeignet für%s\n' "$GRAY" "$R" check "$c_web" "Surfen & E-Mail"; a=$REPLY; check "$c_tv" "$tv"; b=$REPLY printf ' %s %s\n' "$a" "$b" check "$c_video" "Videokonferenzen"; a=$REPLY; check "$c_game" "Online-Gaming"; b=$REPLY printf ' %s %s\n' "$a" "$b" check "$c_backup" "Cloud-Backup"; a=$REPLY printf ' %s\n\n' "$a" printf ' %sKeine Speicherung der Messung %s https://wieschnell.net %s --help für Optionen%s\n\n' "$GRAY" "$DOT" "$DOT" "$R"