#!/usr/bin/env bash # # bench — состояние ноды: железо, сетевой тюнинг, диск, скорость, разблокировки. # wget -qO- bench.nurvps.com | bash # # Отличия от чужих bench-скриптов: # · ничего не устанавливает в систему (пакеты не ставим на боевые ноды); # · сверяет сетевой тюнинг с НАШИМ стандартом и отдельно проверяет, # переживёт ли conntrack ребут — из-за этого уже падала нода; # · dd-тест сам отказывается работать на тесном диске. # # Флаги: --no-speed · --no-unlock · --no-io · --no-tune · --iperf-static · -h # При пайпе: ... | bash -s -- --no-speed set -uo pipefail DO_SPEED=true; DO_UNLOCK=true; DO_IO=true; DO_TUNE=true IPERF_STATIC=false IO_SIZE_MB=256 # ×3 прогона IO_MIN_FREE_MB=2048 # ниже — не трогаем диск NET_TIMEOUT=7 while [[ $# -gt 0 ]]; do case "$1" in --no-speed) DO_SPEED=false ;; --no-unlock) DO_UNLOCK=false ;; --no-io) DO_IO=false ;; --no-tune) DO_TUNE=false ;; --iperf-static) IPERF_STATIC=true ;; -h|--help) sed -n '2,15p' "$0" | sed 's/^# \?//'; exit 0 ;; esac shift done if [[ -t 1 ]]; then RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m' CYAN=$'\033[36m'; DIM=$'\033[2;90m'; BOLD=$'\033[1m'; RESET=$'\033[0m' else RED=""; GREEN=""; YELLOW=""; CYAN=""; DIM=""; BOLD=""; RESET="" fi OK="${GREEN}✓${RESET}"; WARN="${YELLOW}!${RESET}"; BAD="${RED}✗${RESET}" need() { command -v "$1" >/dev/null 2>&1; } line() { echo -e "${DIM}$(printf '%.0s─' {1..66})${RESET}"; } head2() { echo; echo -e "${BOLD}$1${RESET}"; } # printf считает байты, а кириллица двухбайтовая — колонки разъезжаются. # Выкидываем continuation-байты UTF-8, чтобы получить длину в символах. vislen() { local s="${1//[$'\x80'-$'\xbf']/}"; echo "${#s}"; } row() { local pad=$(( 26 - $(vislen "$1") )) (( pad < 1 )) && pad=1 printf " %s%*s%s\n" "$1" "$pad" "" "$2" } cell() { # значение, ширина — выравнивание по правому краю с учётом кириллицы local pad=$(( $2 - $(vislen "$1") )) (( pad < 1 )) && pad=1 printf "%*s%s" "$pad" "" "$1" } human() { # КиБ -> человекочитаемое local kb="${1:-0}" awk -v k="$kb" 'BEGIN{ if (k>=1048576) printf "%.1f ГиБ", k/1048576; else if (k>=1024) printf "%.0f МиБ", k/1024; else printf "%d КиБ", k}' } TMPD=$(mktemp -d 2>/dev/null || echo /tmp/bench.$$) mkdir -p "$TMPD" cleanup() { rm -rf "$TMPD"; } trap cleanup EXIT INT TERM # ═══════════════════════════════════════════════════ система head2 "Система" OS=$( { [ -f /etc/os-release ] && . /etc/os-release && echo "$PRETTY_NAME"; } 2>/dev/null || echo "неизвестно") row "ОС" "$OS" row "Ядро" "$(uname -r) $(uname -m)" VIRT="физический / неизвестно" if need systemd-detect-virt; then v=$(systemd-detect-virt 2>/dev/null) [[ "$v" == "none" ]] && VIRT="bare metal" || VIRT="$v" fi row "Виртуализация" "$VIRT" CPU_MODEL=$(awk -F: '/model name/{gsub(/^ +/,"",$2); print $2; exit}' /proc/cpuinfo 2>/dev/null) CPU_CORES=$(nproc 2>/dev/null || echo "?") CPU_MHZ=$(awk -F: '/cpu MHz/{printf "%.0f", $2; exit}' /proc/cpuinfo 2>/dev/null) row "CPU" "${CPU_MODEL:-неизвестно}" row "Ядер / частота" "${CPU_CORES} × ${CPU_MHZ:-?} МГц" FLAGS=$(awk '/^flags/{print; exit}' /proc/cpuinfo 2>/dev/null) aes=$(grep -qw aes <<<"$FLAGS" && echo "$OK есть" || echo "$WARN нет (Reality будет дороже)") row "AES-NI" "$aes" # steal time — на KVM это главный индикатор «сосед объедает» STEAL=$(awk '/^cpu /{total=$2+$3+$4+$5+$6+$7+$8+$9; if (total>0) printf "%.1f", $9*100/total}' /proc/stat 2>/dev/null) if [[ -n "$STEAL" ]]; then if awk -v s="$STEAL" 'BEGIN{exit !(s>=5)}'; then row "Steal time" "${RED}${STEAL}%${RESET} ${DIM}— гипервизор отнимает CPU${RESET}" else row "Steal time" "${STEAL}% ${DIM}(за весь аптайм)${RESET}" fi fi MEM_T=$(awk '/MemTotal/{print $2}' /proc/meminfo) MEM_A=$(awk '/MemAvailable/{print $2}' /proc/meminfo) row "Память" "$(human "$MEM_T") всего, $(human "$MEM_A") доступно" SWAP_T=$(awk '/SwapTotal/{print $2}' /proc/meminfo) [[ "${SWAP_T:-0}" -gt 0 ]] && row "Swap" "$(human "$SWAP_T")" read -r _ DSIZE DUSED DFREE DPCT _ < <(df -k / | tail -1) row "Диск /" "$(human "$DSIZE") всего, $(human "$DFREE") свободно (занято $DPCT)" row "Аптайм" "$(uptime -p 2>/dev/null | sed 's/^up //' || echo '?')" row "Load" "$(awk '{print $1", "$2", "$3}' /proc/loadavg) ${DIM}на ${CPU_CORES} ядрах${RESET}" # ═══════════════════════════════════════════════════ сетевой тюнинг if $DO_TUNE; then head2 "Сетевой тюнинг ${DIM}(сверка с нашим стандартом)${RESET}" sq() { sysctl -n "$1" 2>/dev/null; } check_ge() { # имя, значение, минимум, подпись local label="$1" val="$2" min="$3" if [[ -z "$val" ]]; then row "$label" "$WARN не читается"; return; fi if (( val >= min )); then row "$label" "$OK $val" else row "$label" "$WARN $val ${DIM}(ждём ≥ $min)${RESET}"; fi } CC=$(sq net.ipv4.tcp_congestion_control) QD=$(sq net.core.default_qdisc) [[ "$CC" == "bbr" ]] && row "Congestion control" "$OK bbr" || row "Congestion control" "$WARN ${CC:-?} ${DIM}(ждём bbr)${RESET}" [[ "$QD" == "fq" ]] && row "Qdisc" "$OK fq" || row "Qdisc" "$WARN ${QD:-?} ${DIM}(ждём fq)${RESET}" check_ge "somaxconn" "$(sq net.core.somaxconn)" 16384 check_ge "netdev_max_backlog" "$(sq net.core.netdev_max_backlog)" 32768 check_ge "tcp_max_syn_backlog" "$(sq net.ipv4.tcp_max_syn_backlog)" 8192 FIN=$(sq net.ipv4.tcp_fin_timeout) [[ "${FIN:-0}" -le 15 ]] && row "tcp_fin_timeout" "$OK $FIN" || row "tcp_fin_timeout" "$WARN ${FIN:-?} ${DIM}(ждём 15)${RESET}" TFO=$(sq net.ipv4.tcp_fastopen) [[ "${TFO:-0}" == "3" ]] && row "tcp_fastopen" "$OK 3" || row "tcp_fastopen" "$WARN ${TFO:-?} ${DIM}(ждём 3)${RESET}" # conntrack: сколько занято и переживёт ли ребут CT_MAX=$(cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null) CT_CNT=$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null) CT_HASH=$(cat /sys/module/nf_conntrack/parameters/hashsize 2>/dev/null) CT_TO=$(cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established 2>/dev/null) if [[ -n "$CT_MAX" ]]; then pct=$(( CT_CNT * 100 / (CT_MAX > 0 ? CT_MAX : 1) )) if (( pct >= 70 )); then mark="$BAD"; elif (( pct >= 40 )); then mark="$WARN"; else mark="$OK"; fi row "conntrack" "$mark $CT_CNT из $CT_MAX ${DIM}(${pct}%)${RESET}" if (( CT_MAX < 1048576 )); then row "" "${YELLOW}лимит ниже стандарта 2097152${RESET}" fi [[ -n "$CT_HASH" ]] && { want=$(( CT_MAX / 4 )) (( CT_HASH >= want )) && row "conntrack hashsize" "$OK $CT_HASH" \ || row "conntrack hashsize" "$WARN $CT_HASH ${DIM}(ждём ${want} = max/4)${RESET}" } [[ "${CT_TO:-0}" == "86400" ]] && row "conntrack timeout" "$OK 86400" \ || row "conntrack timeout" "$WARN ${CT_TO:-?} ${DIM}(ждём 86400)${RESET}" # 🔑 из-за отсутствия persist уже падала нода после ночного ребута miss="" [[ -f /etc/modules-load.d/nf_conntrack.conf ]] || miss+="modules-load.d " [[ -f /etc/modprobe.d/nf_conntrack.conf ]] || miss+="modprobe.d " if [[ -n "$miss" ]]; then row "Переживёт ребут?" "$BAD ${RED}НЕТ${RESET} ${DIM}— нет ${miss}${RESET}" row "" "${DIM}после ребута conntrack встанет с дефолтом и нода посыпется${RESET}" else row "Переживёт ребут?" "$OK оба persist-файла на месте" fi else row "conntrack" "${DIM}модуль не загружен${RESET}" fi # RPS/XPS — размазывание softirq по ядрам if systemctl list-unit-files 2>/dev/null | grep -q '^rps-tune.service'; then st=$(systemctl is-enabled rps-tune.service 2>/dev/null) row "rps-tune.service" "$OK $st" else RXQ=$(ls -d /sys/class/net/*/queues/rx-* 2>/dev/null | grep -vE '/(lo|docker|veth|warp)' | wc -l) row "rps-tune.service" "$WARN не установлен ${DIM}(rx-очередей: ${RXQ})${RESET}" fi NOFILE=$(ulimit -n) (( NOFILE >= 65535 )) && row "ulimit -n" "$OK $NOFILE" || row "ulimit -n" "$WARN $NOFILE" fi # ═══════════════════════════════════════════════════ диск if $DO_IO; then head2 "Диск" FREE_MB=$(( DFREE / 1024 )) NEED_MB=$(( IO_SIZE_MB + 512 )) if (( FREE_MB < IO_MIN_FREE_MB || FREE_MB < NEED_MB )); then row "тест пропущен" "${YELLOW}мало места${RESET} ${DIM}(свободно ${FREE_MB} МиБ, нужно ≥ ${IO_MIN_FREE_MB})${RESET}" elif ! need dd; then row "тест пропущен" "${DIM}нет dd${RESET}" else total=0; runs=0 for i in 1 2 3; do out=$(dd if=/dev/zero of="$TMPD/io.$i" bs=1M count="$IO_SIZE_MB" conv=fdatasync 2>&1) rm -f "$TMPD/io.$i" # последняя строка dd: "... 268 MB, 256 MiB) скопировано, 0,5 s, 500 MB/s" sp=$(echo "$out" | tail -1 | grep -oE '[0-9.,]+ [kMG]B/s' | tail -1) [[ -z "$sp" ]] && continue row "прогон $i" "$sp" num=$(echo "$sp" | tr ',' '.' | awk '{print $1}') unit=$(echo "$sp" | awk '{print $2}') case "$unit" in kB/s) num=$(awk -v n="$num" 'BEGIN{print n/1000}') ;; GB/s) num=$(awk -v n="$num" 'BEGIN{print n*1000}') ;; esac total=$(awk -v t="$total" -v n="$num" 'BEGIN{print t+n}'); runs=$((runs+1)) done if (( runs > 0 )); then avg=$(awk -v t="$total" -v r="$runs" 'BEGIN{printf "%.0f", t/r}') if (( avg < 100 )); then mark="$BAD"; elif (( avg < 300 )); then mark="$WARN"; else mark="$OK"; fi row "среднее" "$mark ${avg} MB/s ${DIM}(запись, ${IO_SIZE_MB} МиБ ×${runs})${RESET}" else row "тест не удался" "${YELLOW}dd не отдал скорость${RESET}" fi fi fi # ═══════════════════════════════════════════════════ адрес и провайдер head2 "Адрес" MY_IP=$(curl -s -4 --max-time "$NET_TIMEOUT" https://api.ipify.org 2>/dev/null) if [[ -n "$MY_IP" ]]; then INFO=$(curl -s --max-time "$NET_TIMEOUT" "https://ipinfo.io/${MY_IP}/json" 2>/dev/null) getf() { echo "$INFO" | grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | cut -d'"' -f4; } row "IPv4" "${CYAN}${MY_IP}${RESET}" row "Провайдер" "$(getf org)" row "Локация" "$(getf city), $(getf region), $(getf country)" else row "IPv4" "${YELLOW}не определился${RESET}" fi IP6=$(curl -s -6 --max-time 5 https://api64.ipify.org 2>/dev/null) [[ "$IP6" == *:* ]] && row "IPv6" "$IP6" || row "IPv6" "${DIM}нет${RESET}" # все публичные адреса машины — у нод бывает routed /32 OTHERS=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -vE '^(10\.|127\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' | grep -v "^${MY_IP}$" | tr '\n' ' ') [[ -n "${OTHERS// /}" ]] && row "Другие адреса" "${YELLOW}${OTHERS}${RESET}" # ═══════════════════════════════════════════════════ скорость if $DO_SPEED; then head2 "Скорость ${DIM}(iperf3)${RESET}" IPERF="" if need iperf3; then IPERF="iperf3" elif [[ -x /tmp/iperf3 ]]; then IPERF="/tmp/iperf3" elif $IPERF_STATIC; then arch=$(uname -m); url="" [[ "$arch" == "x86_64" ]] && url="https://github.com/userdocs/iperf3-static/releases/latest/download/iperf3-amd64" [[ "$arch" == "aarch64" || "$arch" == "arm64" ]] && url="https://github.com/userdocs/iperf3-static/releases/latest/download/iperf3-arm64" if [[ -n "$url" ]] && curl -sL --max-time 60 -o /tmp/iperf3 "$url" && chmod +x /tmp/iperf3; then IPERF="/tmp/iperf3" row "iperf3" "${DIM}скачан статический бинарник в /tmp (пакет не ставился)${RESET}" fi fi if [[ -z "$IPERF" ]]; then row "тест пропущен" "${YELLOW}нет iperf3${RESET}" row "" "${DIM}пакеты на ноды не ставим; для разового замера: --iperf-static${RESET}" else printf " %s%*s%s%s%s\n" "Точка" "$(( 23 - 5 ))" "" \ "$(cell 'Отдача' 12)" "$(cell 'Приём' 12)" "$(cell 'Задержка' 10)" SERVERS=( 'spd-rudp.hostkey.ru:5201:Москва, Hostkey' 'mskst.st.mtsws.net:3333:Москва, МТС' 'st.spb.ertelecom.ru:5203:СПб, ЭР-Телеком' 'st.kzn.ertelecom.ru:5203:Казань' 'st.chel.ertelecom.ru:5203:Челябинск' 'st.krsk.ertelecom.ru:5203:Красноярск' 'iperf-ams-nl.eranium.net:5201:Амстердам' 'speedtest.fra1.de.leaseweb.net:5201:Франкфурт' ) # 🔑 У iperf3 нет таймаута подключения по умолчанию: до недоступного # сервера он висит минутами. Поэтому сначала проверяем порт, потом # ставим --connect-timeout (если версия умеет) и в любом случае timeout. IPERF_OPTS="" $IPERF --help 2>&1 | grep -q -- '--connect-timeout' && IPERF_OPTS="--connect-timeout 3000" speed_of() { # host port направление(-R или пусто) ключ_суммы # bits_per_second идёт ПЯТОЙ строкой после ключа суммы — -A4 её не захватывает timeout 25 $IPERF -c "$1" -p "$2" -P 4 -t 5 $3 $IPERF_OPTS -J 2>/dev/null | grep -A8 "\"$4\"" | grep bits_per_second | head -1 | awk -F: '{gsub(/[ \t,]/,"",$2); v=$2+0; if (v >= 1e9) printf "%.1f Гбит", v/1e9; else if (v > 0) printf "%.0f Мбит", v/1e6}' } print_row() { local lpad=$(( 23 - $(vislen "$1") )); (( lpad < 1 )) && lpad=1 printf " %s%*s%s%s%s\n" "$1" "$lpad" "" \ "$(cell "${2:-—}" 12)" "$(cell "${3:-—}" 12)" "$(cell "${4:-—}" 10)" } for entry in "${SERVERS[@]}"; do IFS=':' read -r host port label <<<"$entry" if ! timeout 4 bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null; then print_row "$label" "${DIM}недоступен${RESET}" "" "" continue fi lat=$(ping -c 2 -W 2 "$host" 2>/dev/null | tail -1 | awk -F'/' '{printf "%.0f мс", $5}') up=$(speed_of "$host" "$port" "" sum_sent) dn=$(speed_of "$host" "$port" "-R" sum_received) print_row "$label" "$up" "$dn" "$lat" done fi fi # ═══════════════════════════════════════════════════ разблокировки if $DO_UNLOCK; then head2 "Разблокировка сервисов" UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" fetch() { curl -s -4 --max-time "$NET_TIMEOUT" -A "$UA" "$@" 2>/dev/null; } # Netflix отдаёт 301 на локализованный путь (/ee/title/...), поэтому только с -L. # Локаль в итоговом URL заодно показывает, за какую страну нас считают. nf=$(curl -s -4 -o /dev/null -w '%{http_code} %{url_effective}' -L --max-time "$NET_TIMEOUT" \ -A "$UA" "https://www.netflix.com/title/70143836" 2>/dev/null) nf_code="${nf%% *}"; nf_url="${nf#* }" nf_loc=$(grep -oE 'netflix\.com/([a-z]{2})/' <<<"$nf_url" | cut -d/ -f2) case "$nf_code" in 200) row "Netflix" "$OK полная библиотека${nf_loc:+ ${DIM}(${nf_loc})${RESET}}" ;; 404) row "Netflix" "$WARN только оригиналы" ;; 403|451) row "Netflix" "$BAD заблокирован ($nf_code)" ;; 000) row "Netflix" "$BAD не отвечает" ;; *) row "Netflix" "${DIM}неясно (HTTP $nf_code)${RESET}" ;; esac # У Google регион берётся из GL. Если там RU — нода считается российской, # и это ломает Gemini/Flow независимо от того, что показывает ipinfo. yt=$(fetch "https://www.youtube.com/premium") if grep -q "Premium is not available" <<<"$yt"; then row "YouTube Premium" "$BAD недоступен в этой стране" elif [[ -n "$yt" ]]; then gl=$(grep -oE '"GL":"[A-Z]{2}"' <<<"$yt" | head -1 | cut -d'"' -f4) if [[ "$gl" == "RU" ]]; then row "YouTube Premium" "$WARN доступен, но ${RED}Google считает ноду RU${RESET}" else row "YouTube Premium" "$OK доступен${gl:+ ${DIM}(GL=${gl})${RESET}}" fi else row "YouTube Premium" "$BAD не отвечает" fi sp=$(curl -s -4 -o /dev/null -w '%{http_code}' --max-time "$NET_TIMEOUT" -A "$UA" \ "https://spclient.wg.spotify.com/signup/public/v1/account" 2>/dev/null) [[ "$sp" =~ ^(200|400)$ ]] && row "Spotify" "$OK регистрация доступна" || row "Spotify" "$WARN отдал $sp" # ChatGPT: cdn-cgi/trace отдаёт loc, а /unsupported-country — верный признак бана региона gpt=$(fetch "https://chat.openai.com/cdn-cgi/trace") gpt_loc=$(grep -oE '^loc=[A-Z]{2}' <<<"$gpt" | cut -d= -f2) gpt_url=$(curl -s -4 -o /dev/null -w '%{url_effective}' -L --max-time "$NET_TIMEOUT" -A "$UA" "https://chatgpt.com/" 2>/dev/null) if [[ "$gpt_url" == *unsupported-country* ]]; then row "ChatGPT" "$BAD регион не поддерживается${gpt_loc:+ ${DIM}(${gpt_loc})${RESET}}" elif [[ -n "$gpt_loc" ]]; then row "ChatGPT" "$OK доступен ${DIM}(${gpt_loc})${RESET}" else row "ChatGPT" "$WARN не определился" fi for pair in "Claude:https://claude.ai/" "Gemini:https://gemini.google.com/" "Steam:https://store.steampowered.com/"; do name="${pair%%:*}"; url="${pair#*:}" code=$(curl -s -4 -o /dev/null -w '%{http_code}' -L --max-time "$NET_TIMEOUT" -A "$UA" "$url" 2>/dev/null) case "$code" in 200) row "$name" "$OK доступен" ;; 403|451) row "$name" "$BAD $code — закрыт для этого IP ${DIM}(регион или дата-центр)${RESET}" ;; 000) row "$name" "$BAD не отвечает" ;; *) row "$name" "$WARN HTTP $code" ;; esac done fi echo line echo -e "${DIM}bench.nurvps.com · блокировки — censor.nurvps.com${RESET}"