#!/usr/bin/env bash # NodePilse VPS Bench v3.2.0 # # 用法:bash <(curl -sL https://nodepilse.com/bench.sh) [选项] # # 设计原则:单项失败绝不中断整体流程,失败原因写入 warnings 数组, # 而不是静默地留下一个 0 —— 0 分和"没测到"在评分里含义完全不同。 set -uo pipefail VERSION="3.2.0" RESULT_ID="${RESULT_ID:-$(if [ -r /proc/sys/kernel/random/uuid ]; then head -1 /proc/sys/kernel/random/uuid; else printf '%s-%s-%s' "$(date +%s)" "$$" "$RANDOM"; fi)}" RUN_ID="${RUN_ID:-$RESULT_ID}" START_TS="$(date +%s)" STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" WORK_DIR="$(mktemp -d 2>/dev/null || echo /tmp/nodepilse.$$)" mkdir -p "$WORK_DIR" ST_ERR_FILE="$WORK_DIR/st.errmsg" BENCH_STATUS="running" INTERRUPTED=0 MAIN_STARTED=0 CHILD_PIDS=() TEMP_FILES=() OPT_FAST=0 OPT_GEEKBENCH="${RUN_GEEKBENCH:-0}" OPT_INSTALL=1 OPT_ROUTE=1 OPT_DISK=1 OPT_STREAMING=0 OPT_QUIET=0 OPT_EXTENDED=0 OPT_PROVINCE=1 SUBMIT_ID="${NODEPILSE_VPS_ID:-}" AUTH_TOKEN="${NODEPILSE_TOKEN:-}" REPORT_VPS="${NODEPILSE_VPS_ID:-}" API_BASE="${NODEPILSE_API:-https://nodepilse.com}" EXTRA_NODES="${NET_TEST_COUNT:-2}" usage() { cat <<'USAGE' NodePilse VPS Bench v3.2.0 bash <(curl -sL https://nodepilse.com/bench.sh) [选项] 选项: -f, --fast 快速模式:跳过磁盘写测试和附加测速节点(约 90 秒) -g, --geekbench 额外跑 Geekbench 6。默认关闭:耗时 10-20 分钟,且机房 IP 常被 Cloudflare 拦截导致白跑。开启前脚本会先探测上传通道。 -n, --no-install 不自动安装缺失依赖(fio / sysbench),缺什么就跳过对应项 --no-disk 跳过磁盘 fio 测试 --streaming 附加流媒体解锁检测 --extended 运行第三方扩展报告(NodeQuality / IP.Check.Place / Net.Check.Place;不计入总分) --no-route 跳过三网回程线路检测 --no-province 跳过全国 31 省级三网 TCP 延迟测速 --token= 会话 Token,用于自动提交到 /api/v1/report --vps= 要提交的节点 ID;只有一个节点时可省略 --submit 跑完直接提交到 NodePilse,需要环境变量 NODEPILSE_TOKEN -q, --quiet 只输出 JSON,不打印进度 -h, --help 显示本帮助 环境变量: NODEPILSE_TOKEN 登录会话 token(配合 --submit) NODEPILSE_API API 地址,默认 https://nodepilse.com NET_TEST_COUNT 附加测速节点数量,默认 2 USAGE exit 0 } while [ $# -gt 0 ]; do case "$1" in -f|--fast) OPT_FAST=1 ;; -g|--geekbench) OPT_GEEKBENCH=1 ;; -n|--no-install) OPT_INSTALL=0 ;; --no-disk) OPT_DISK=0 ;; --streaming) OPT_STREAMING=1 ;; --extended) OPT_EXTENDED=1 ;; --no-route) OPT_ROUTE=0 ;; --no-province) OPT_PROVINCE=0 ;; --token=*) AUTH_TOKEN="${1#--token=}" ;; --token) AUTH_TOKEN="${2:-}"; shift ;; --vps=*) REPORT_VPS="${1#--vps=}" ;; --vps) REPORT_VPS="${2:-}"; shift ;; --submit) SUBMIT_ID="${2:-}"; REPORT_VPS="${2:-}"; shift ;; -q|--quiet) OPT_QUIET=1 ;; -h|--help) usage ;; *) printf '未知选项:%s(-h 查看帮助)\n' "$1" >&2; exit 2 ;; esac shift done # ---------------------------------------------------------------- 采集结果变量 OS=""; KERNEL=""; ARCH=""; VIRT=""; CPU_MODEL=""; CPU_CORES=""; RAM_GB=""; DISK_GB=""; CPU_FLAGS=""; PUBLIC_IP=""; ASN="" GEO=""; IP_DETECTION=""; GEOIP_REPORT=""; ASN_REPORT="" GEO_COUNTRY=""; GEO_CITY=""; GEO_ORG=""; GEO_CC=""; GEO_REG_CC=""; GEO_LAT=""; GEO_LON=""; GEO_TZ=""; GEO_IP_TYPE="native" SB_SINGLE=""; SB_MULTI=""; CPU_HASH=""; MEM_READ=""; MEM_WRITE="" MEM_WORKSET_MIB="" GB_SINGLE=""; GB_MULTI=""; GB_URL=""; GB_NOTE=""; GEEKBENCH_TOOL=""; GB_TIMEOUT=0 GEEKBENCH_VERSION="6.3.0"; GEEKBENCH_SHA256="${GEEKBENCH_SHA256:-}" IOPS_R=""; IOPS_W=""; SEQ_R=""; SEQ_W=""; DISK_ENGINE=""; DISK_DIR=""; DISK_FS="" DOWNLOAD=""; UPLOAD=""; LAT_LOCAL=""; JITTER_LOCAL="" ST_NODE=""; ST_ISP=""; ST_URL=""; ST_BIN=""; ST_ERR="" SPEEDTEST_TOOL="" ROUTE=""; ROUTE_CT=""; ROUTE_CU=""; ROUTE_CM="" ROUTE_TOOL="" LAT_CN=""; LOSS_CN=""; LAT_CT=""; LAT_CU=""; LAT_CM=""; LOSS_CT=""; LOSS_CU=""; LOSS_CM="" MEM_TIMEOUT=0 NF_STATUS=""; YT_STATUS="" STREAMING=""; WARNINGS=""; INSTALLED=""; JSON_EMITTED=0; TEST_RESULTS="" IP_REPUTATION=""; DNS_QUALITY=""; DNS_ROWS=""; EXTENDED_REPORTS="" PROVINCE_ROWS=""; PROVINCE_TEST_COUNT=0 DISK_COMPARABILITY="native" SUBMIT_SUCCESS=0 BACKTRACE_VERSION="v1.0.8" BACKTRACE_SHA256_AMD64="24208808c16035e562d9576cb09b1107bddcc6aea5b156adce31c96d8534ca31" BACKTRACE_SHA256_ARM64="1d28591ad559930fa72ade7d700a549857d8a475fc6798da898d32e7d15477f5" # ------------------------------------------------------------------ 基础工具 info() { [ "$OPT_QUIET" -eq 1 ] || printf '→ %s\n' "$*" >&2; } json_escape() { printf '%s' "${1:-}" | tr -d '\n\r\t' | sed 's/\\/\\\\/g; s/"/\\"/g'; } jstr() { printf '"%s"' "$(json_escape "${1:-}")"; } jnum() { case "${1:-}" in ''|*[!0-9.]*) printf 'null' ;; 0[0-9]*) printf '%s' "$((10#$1))" 2>/dev/null || printf 'null' ;; *) printf '%s' "$1" ;; esac } valid_identifier() { printf '%s' "${1:-}" | grep -Eq '^[A-Za-z0-9._:-]{1,128}$' } valid_ip_candidate() { local value="$1" octet if printf '%s' "$value" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then IFS=. read -r -a octets <<< "$value" for octet in "${octets[@]}"; do [ "$octet" -ge 0 ] 2>/dev/null && [ "$octet" -le 255 ] 2>/dev/null || return 1 done return 0 fi # bash 没有跨平台的 IPv6 解析器;这里至少拒绝非十六进制字符和无冒号值, # 服务端还会用 node:net.isIP 做最终校验。 printf '%s' "$value" | grep -Eq '^[0-9A-Fa-f:]+$' && printf '%s' "$value" | grep -q ':' } register_temp() { TEMP_FILES+=("$1"); } lookup_reg_country() { local ip="$1" cc="" # 查询 RIR 数据库(APNIC / ARIN / RIPE)获取实际注册地国家代码 cc="$(curl -sSL --max-time 4 "https://rdap.apnic.net/ip/$ip" 2>/dev/null | grep -oE '"country"[[:space:]]*:[[:space:]]*"[A-Z]{2}"' | head -1 | cut -d'"' -f4)" if [ -z "$cc" ]; then cc="$(curl -sSL --max-time 4 "https://rdap.arin.net/registry/ip/$ip" 2>/dev/null | grep -oE '"country"[[:space:]]*:[[:space:]]*"[A-Z]{2}"' | head -1 | cut -d'"' -f4)" fi if [ -z "$cc" ]; then cc="$(curl -sSL --max-time 4 "https://rdap.db.ripe.net/ip/$ip" 2>/dev/null | grep -oE '"country"[[:space:]]*:[[:space:]]*"[A-Z]{2}"' | head -1 | cut -d'"' -f4)" fi printf '%s' "$cc" } lookup_geoip() { local target="$1" provider url response code payload candidate country city organization asn_raw report_ip local cc="" lat="" lon="" tz="" local geo_found=0 report_ip="$(printf '%s' "$GEOIP_REPORT" | grep -oE '"ip":"[^"]+"' | head -1 | cut -d'"' -f4)" if [ "$report_ip" = "$target" ] && printf '%s' "$GEOIP_REPORT" | grep -q '"status":"ok"'; then geo_found=1 fi for provider in ipwho.is ipapi.co; do case "$provider" in ipwho.is) url="https://ipwho.is/${target}" ;; ipapi.co) url="https://ipapi.co/${target}/json/" ;; esac response="$(curl -sSL --max-time 8 -w '\n%{http_code}' "$url" 2>/dev/null)" code="$(printf '%s' "$response" | tail -n1)" payload="$(printf '%s' "$response" | sed '$d')" candidate="$(printf '%s' "$payload" | grep -oE '"ip"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 | cut -d'"' -f4)" if [ "$code" != "200" ] || ! valid_ip_candidate "$candidate" || [ "$candidate" != "$target" ]; then continue fi country="$(printf '%s' "$payload" | grep -oE '"(country_name|country)"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" city="$(printf '%s' "$payload" | grep -oE '"city"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" organization="$(printf '%s' "$payload" | grep -oE '"(organization|org|isp)"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" cc="$(printf '%s' "$payload" | grep -oE '"(country_code|countryCode)"[[:space:]]*:[[:space:]]*"[A-Z]{2}"' | head -1 | cut -d'"' -f4)" lat="$(printf '%s' "$payload" | grep -oE '"(latitude|lat)"[[:space:]]*:[[:space:]]*[0-9.-]+' | head -1 | cut -d: -f2 | tr -d ' ')" lon="$(printf '%s' "$payload" | grep -oE '"(longitude|lon)"[[:space:]]*:[[:space:]]*[0-9.-]+' | head -1 | cut -d: -f2 | tr -d ' ')" tz="$(printf '%s' "$payload" | grep -oE '"(id|timezone)"[[:space:]]*:[[:space:]]*"[A-Za-z0-9/_+-]+"' | head -1 | cut -d'"' -f4)" [ -n "$country" ] && GEO_COUNTRY="$country" [ -n "$city" ] && GEO_CITY="$city" [ -n "$organization" ] && GEO_ORG="$organization" [ -n "$cc" ] && GEO_CC="$cc" [ -n "$lat" ] && GEO_LAT="$lat" [ -n "$lon" ] && GEO_LON="$lon" [ -n "$tz" ] && GEO_TZ="$tz" if [ "$geo_found" -eq 0 ]; then GEOIP_REPORT="{\"status\":\"ok\",\"provider\":$(jstr \"$provider\"),\"http_code\":$(jnum \"$code\"),\"ip\":$(jstr \"$candidate\"),\"country\":$(jstr \"$country\"),\"country_code\":$(jstr \"$cc\"),\"city\":$(jstr \"$city\"),\"organization\":$(jstr \"$organization\"),\"latitude\":$(jnum \"$lat\"),\"longitude\":$(jnum \"$lon\"),\"timezone\":$(jstr \"$tz\")}" geo_found=1 fi asn_raw="$(printf '%s' "$payload" | grep -oE '"asn"[[:space:]]*:[[:space:]]*"?(AS)?[0-9]+' | head -1 | grep -oE '(AS)?[0-9]+$')" if printf '%s' "$asn_raw" | grep -qE '^[0-9]+$'; then asn_raw="AS$asn_raw"; fi if printf '%s' "$asn_raw" | grep -qE '^AS[0-9]+$'; then ASN="$asn_raw" ASN_REPORT="{\"status\":\"ok\",\"provider\":$(jstr \"$provider\"),\"http_code\":$(jnum \"$code\"),\"ip\":$(jstr \"$candidate\"),\"asn\":$(jstr \"$ASN\")}" else ASN_REPORT="{\"status\":\"unknown\",\"provider\":$(jstr \"$provider\"),\"http_code\":$(jnum \"$code\"),\"ip\":$(jstr \"$candidate\"),\"asn\":null}" fi break done GEO_REG_CC="$(lookup_reg_country "$target")" [ -z "$GEO_REG_CC" ] && GEO_REG_CC="$GEO_CC" if [ -n "$GEO_CC" ] && [ -n "$GEO_REG_CC" ]; then if [ "$GEO_CC" = "$GEO_REG_CC" ]; then GEO_IP_TYPE="native" else GEO_IP_TYPE="broadcast" fi else GEO_IP_TYPE="native" fi [ "$geo_found" -eq 1 ] } epoch_ms() { local value value="$(date +%s%3N 2>/dev/null)" case "$value" in ''|*%3N*) value="$(date +%s000)" ;; esac printf '%s' "$value" } record_test_result() { local name="$1" status="$2" value="$3" error="$4" duration_ms="$5" TEST_RESULTS="${TEST_RESULTS}${TEST_RESULTS:+,}$(jstr "$name"):{\"status\":$(jstr "$status"),\"value\":${value:-null},\"error\":${error:-null},\"duration_ms\":${duration_ms:-0}}" } run_test() { local name="$1" start end rc status value error rep_status dns_status start="$(epoch_ms)" "$name" rc=$? end="$(epoch_ms)" case "$name" in test_cpu) if [ "$rc" -eq 124 ] || [ "$GB_TIMEOUT" -eq 1 ]; then status="timeout"; elif [ -n "$SB_SINGLE$CPU_HASH$GB_SINGLE" ]; then status="ok"; else status="failed"; fi value="{\"sysbench_single\":$(jnum "$SB_SINGLE"),\"sysbench_multi\":$(jnum "$SB_MULTI"),\"cpu_hash_mbps\":$(jnum "$CPU_HASH"),\"geekbench_single\":$(jnum "$GB_SINGLE")}" ;; test_memory) if [ "$MEM_TIMEOUT" -eq 1 ]; then status="timeout"; elif [ -n "$MEM_READ$MEM_WRITE" ]; then [ -n "$MEM_READ" ] && [ -n "$MEM_WRITE" ] && status="ok" || status="partial" elif command -v sysbench >/dev/null 2>&1; then status="failed"; else status="unsupported"; fi value="{\"read_mbps\":$(jnum "$MEM_READ"),\"write_mbps\":$(jnum "$MEM_WRITE"),\"workset_mib\":$(jnum "$MEM_WORKSET_MIB")}" ;; test_ip_reputation) if [ -z "$PUBLIC_IP" ]; then status="failed"; else rep_status="$(printf '%s' "$IP_REPUTATION" | sed -n 's/^{"status":"\([^"]*\)".*/\1/p')" case "$rep_status" in clean|listed) status="ok" ;; partial) status="partial" ;; *) printf '%s' "$IP_REPUTATION" | grep -q '"status":"unsupported"' && status="unsupported" || status="failed" ;; esac fi value="${IP_REPUTATION:-null}" ;; test_dns_quality) dns_status="$(printf '%s' "$DNS_QUALITY" | sed -n 's/^{"status":"\([^"]*\)".*/\1/p')" case "$dns_status" in ok) status="ok" ;; partial) status="partial" ;; unsupported) status="unsupported" ;; *) status="failed" ;; esac value="${DNS_QUALITY:-null}" ;; test_disk) if [ "$OPT_DISK" -eq 0 ]; then status="skipped"; elif [ "$OPT_FAST" -eq 1 ] && [ -n "$IOPS_R" ] && [ -n "$SEQ_R" ]; then status="ok"; elif [ "$OPT_FAST" -eq 0 ] && [ -n "$IOPS_R" ] && [ -n "$IOPS_W" ] && [ -n "$SEQ_R" ] && [ -n "$SEQ_W" ]; then status="ok"; elif [ -n "$IOPS_R$IOPS_W$SEQ_R$SEQ_W" ]; then status="partial"; else status="failed"; fi value="{\"iops_4k_read\":$(jnum "$IOPS_R"),\"iops_4k_write\":$(jnum "$IOPS_W"),\"seq_1m_read_mbps\":$(jnum "$SEQ_R"),\"seq_1m_write_mbps\":$(jnum "$SEQ_W"),\"comparability\":$(jstr "$DISK_COMPARABILITY"),\"engine\":$(jstr "$DISK_ENGINE")}" ;; test_network) if [ -n "$DOWNLOAD" ] && [ -n "$UPLOAD" ] && [ -n "$LAT_LOCAL" ]; then status="ok"; elif [ -n "$DOWNLOAD$UPLOAD$LAT_LOCAL" ]; then status="partial"; elif [ -z "$SPEEDTEST_TOOL" ]; then status="unsupported"; else status="failed"; fi value="{\"download_mbps\":$(jnum "$DOWNLOAD"),\"upload_mbps\":$(jnum "$UPLOAD"),\"latency_ms\":$(jnum "$LAT_LOCAL")}" ;; test_china_cdn) [ -n "$CN_CDN_MBPS" ] && status="ok" || status="failed" value="{\"download_mbps\":$(jnum "$CN_CDN_MBPS")}" ;; test_cn_latency) if [ -n "$LAT_CN" ] && [ -n "$LOSS_CN" ]; then status="ok"; elif [ -n "$LAT_CN" ]; then status="partial"; else status="failed"; fi value="{\"latency_ms\":$(jnum "$LAT_CN"),\"loss_pct\":$(jnum "$LOSS_CN")}" ;; test_province_latency) if [ "$OPT_PROVINCE" -ne 1 ] || [ "$OPT_FAST" -eq 1 ]; then status="skipped"; elif [ "$PROVINCE_TEST_COUNT" -gt 0 ]; then status="ok"; else status="failed"; fi value="{\"target_count\":93,\"collected_count\":$(jnum "$PROVINCE_TEST_COUNT")}" ;; test_route) if [ "$OPT_ROUTE" -eq 0 ]; then status="skipped"; elif [ -n "$ROUTE" ]; then status="ok"; elif printf '%s' "$ROUTE_TOOL" | grep -q '"status":"unsupported"'; then status="unsupported"; else status="failed"; fi value="{\"route\":$(jstr "$ROUTE")}" ;; test_streaming) if [ "$OPT_STREAMING" -eq 0 ]; then status="skipped"; elif [ "$NF_STATUS" != "000" ] && [ "$YT_STATUS" != "000" ]; then status="ok"; elif [ "$NF_STATUS" != "000" ] || [ "$YT_STATUS" != "000" ]; then status="partial"; else status="failed"; fi value="$(jstr "$STREAMING")" ;; test_geekbench) if [ "$OPT_GEEKBENCH" != "1" ]; then status="skipped"; elif [ "$GB_TIMEOUT" -eq 1 ]; then status="timeout"; elif [ -n "$GB_SINGLE" ]; then status="ok"; elif printf '%s' "$GEEKBENCH_TOOL" | grep -qE '"status":"(unsupported|unavailable)"'; then status="unsupported"; else status="failed"; fi value="{\"single\":$(jnum "$GB_SINGLE"),\"multi\":$(jnum "$GB_MULTI"),\"url\":$(jstr "$GB_URL")}" ;; test_extended_reports) if [ "$OPT_EXTENDED" -eq 0 ]; then status="skipped"; elif printf '%s' "$EXTENDED_REPORTS" | grep -q '"status":"success"'; then printf '%s' "$EXTENDED_REPORTS" | grep -qE '"status":"(failed|timeout|unavailable|checksum_failed)"' && status="partial" || status="ok"; elif printf '%s' "$EXTENDED_REPORTS" | grep -q '"status":"unsupported"'; then status="unsupported"; elif printf '%s' "$EXTENDED_REPORTS" | grep -q '"status":"timeout"'; then status="timeout"; else status="failed"; fi value="[${EXTENDED_REPORTS}]" ;; *) status="$([ "$rc" -eq 0 ] && printf ok || printf failed)"; value="null" ;; esac error="null" [ "$status" = "ok" ] || error="$(jstr "${name#test_} did not produce a complete result")" record_test_result "${name#test_}" "$status" "$value" "$error" "$((end - start))" } warn() { WARNINGS="${WARNINGS}${WARNINGS:+,}$(jstr "$1")" [ "$OPT_QUIET" -eq 1 ] || printf '! %s\n' "$1" >&2 } # 取中位数(参数为空格分隔的数字串) median() { printf '%s\n' $1 | grep -E '^[0-9.]+$' | sort -n | awk '{v[NR]=$1} END{if(NR==0)exit; if(NR%2)printf "%.1f",v[(NR+1)/2]; else printf "%.1f",(v[NR/2]+v[NR/2+1])/2}' } mean() { printf '%s\n' $1 | grep -E '^[0-9.]+$' | awk '{sum += $1; count += 1} END{if(count) printf "%.1f", sum/count}' } max_number() { printf '%s\n' $1 | grep -E '^[0-9.]+$' | sort -n | tail -1 } percentile95() { printf '%s\n' $1 | grep -E '^[0-9.]+$' | sort -n | awk '{v[NR]=$1} END {if (!NR) exit; i=int(NR * 0.95); if (i < NR * 0.95) i++; if (i < 1) i=1; printf "%.1f", v[i]}' } json_string_array() { local raw="$1" item out="" while IFS= read -r item; do [ -n "$item" ] || continue out="${out}${out:+,}$(jstr "$item")" done </dev/null 2>&1; then PKG=apt elif command -v dnf >/dev/null 2>&1; then PKG=dnf elif command -v yum >/dev/null 2>&1; then PKG=yum elif command -v apk >/dev/null 2>&1; then PKG=apk elif command -v pacman >/dev/null 2>&1; then PKG=pacman fi } pkg_install() { [ "$OPT_INSTALL" -eq 1 ] || return 1 [ "$(id -u)" = "0" ] || return 1 [ -n "$PKG" ] || return 1 case "$PKG" in apt) if [ "$APT_UPDATED" -eq 0 ]; then DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 APT_UPDATED=1 fi DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" >/dev/null 2>&1 ;; dnf) dnf install -y -q "$@" >/dev/null 2>&1 ;; yum) yum install -y -q "$@" >/dev/null 2>&1 ;; apk) apk add --no-cache -q "$@" >/dev/null 2>&1 ;; pacman) pacman -Sy --noconfirm "$@" >/dev/null 2>&1 ;; esac } # need <命令> <包名>:有就用,没有就装,装不上返回 1 need() { command -v "$1" >/dev/null 2>&1 && return 0 info "安装依赖 $2 ..." if pkg_install "$2" && command -v "$1" >/dev/null 2>&1; then INSTALLED="${INSTALLED}${INSTALLED:+ }$2" return 0 fi return 1 } # ------------------------------------------------------------------ 系统信息 collect_sysinfo() { OS="$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}")" [ -n "$OS" ] || OS="$(uname -s 2>/dev/null)" KERNEL="$(uname -r 2>/dev/null)" ARCH="$(uname -m 2>/dev/null)" VIRT="$(systemd-detect-virt 2>/dev/null || printf 'unknown')" CPU_MODEL="$(awk -F': +' '/^model name/{print $2; exit}' /proc/cpuinfo 2>/dev/null)" [ -n "$CPU_MODEL" ] || CPU_MODEL="$(lscpu 2>/dev/null | awk -F': +' '/Model name/{print $2; exit}')" CPU_FLAGS="$(awk -F': +' '/^flags/{print $2; exit}' /proc/cpuinfo 2>/dev/null)" local response code payload provider candidate geo_ip asn_raw country city organization best ip_count ip_providers local ip_rows="" ip_candidates="" GEO="" PUBLIC_IP="" ASN="" IP_DETECTION="" GEOIP_REPORT='{"status":"unknown","provider":"api.ip.sb","http_code":null}' ASN_REPORT='{"status":"unknown","provider":"api.ip.sb","http_code":null,"asn":null}' response="$(curl -sSL --max-time 5 -w '\n%{http_code}' https://api.ip.sb/geoip 2>/dev/null)" code="$(printf '%s' "$response" | tail -n1)" payload="$(printf '%s' "$response" | sed '$d')" geo_ip="$(printf '%s' "$payload" | grep -oE '"ip"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 | cut -d'"' -f4)" if [ "$code" = "200" ] && valid_ip_candidate "$geo_ip"; then GEO="$payload" ip_rows="{\"provider\":\"api.ip.sb\",\"status\":\"ok\",\"ip\":$(jstr "$geo_ip"),\"http_code\":$(jnum "$code") }" ip_candidates="$geo_ip|api.ip.sb" country="$(printf '%s' "$payload" | grep -oE '"country"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" city="$(printf '%s' "$payload" | grep -oE '"city"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" organization="$(printf '%s' "$payload" | grep -oE '"(organization|org)"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" GEOIP_REPORT="{\"status\":\"ok\",\"provider\":\"api.ip.sb\",\"http_code\":$(jnum "$code"),\"ip\":$(jstr "$geo_ip"),\"country\":$(jstr "$country"),\"city\":$(jstr "$city"),\"organization\":$(jstr "$organization")}" asn_raw="$(printf '%s' "$payload" | grep -oE '"asn"[[:space:]]*:[[:space:]]*"?(AS)?[0-9]+' | head -1 | grep -oE '(AS)?[0-9]+$')" if [ -n "$asn_raw" ]; then ASN="AS${asn_raw#AS}" ASN_REPORT="{\"status\":\"ok\",\"provider\":\"api.ip.sb\",\"http_code\":$(jnum "$code"),\"ip\":$(jstr "$geo_ip"),\"asn\":$(jstr "$ASN")}"; else ASN_REPORT="{\"status\":\"unknown\",\"provider\":\"api.ip.sb\",\"http_code\":$(jnum "$code"),\"ip\":$(jstr "$geo_ip"),\"asn\":null}" fi else ip_rows="{\"provider\":\"api.ip.sb\",\"status\":\"failed\",\"ip\":null,\"http_code\":$(jnum "$code") }" fi for provider in api.ipify.org ifconfig.me icanhazip.com; do case "$provider" in api.ipify.org) response="$(curl -sSL --max-time 5 -w '\n%{http_code}' https://api.ipify.org 2>/dev/null)" ;; ifconfig.me) response="$(curl -sSL --max-time 5 -w '\n%{http_code}' https://ifconfig.me/ip 2>/dev/null)" ;; icanhazip.com) response="$(curl -sSL --max-time 5 -w '\n%{http_code}' https://icanhazip.com 2>/dev/null)" ;; esac code="$(printf '%s' "$response" | tail -n1)" candidate="$(printf '%s' "$response" | sed '$d' | tr -d '[:space:]')" if [ "$code" = "200" ] && valid_ip_candidate "$candidate"; then ip_rows="${ip_rows}, {\"provider\":$(jstr "$provider"),\"status\":\"ok\",\"ip\":$(jstr "$candidate"),\"http_code\":$(jnum "$code") }" ip_candidates="${ip_candidates}${ip_candidates:+$'\n'}$candidate|$provider" else ip_rows="${ip_rows}, {\"provider\":$(jstr "$provider"),\"status\":\"failed\",\"ip\":null,\"http_code\":$(jnum "$code") }" fi done # Keep provider order as the deterministic tie-breaker when two candidates # have the same vote count; awk map iteration is intentionally unordered. best="$(printf '%s\n' "$ip_candidates" | awk -F'|' 'NF >= 2 { if (!( $1 in count)) order[++n]=$1; count[$1]++; providers[$1]=(providers[$1] ? providers[$1] "," : "") $2 } END { best=0; max=0; for (i=1; i<=n; i++) if (count[order[i]] > max) { best=order[i]; max=count[order[i]] } if (best) print best "|" max "|" providers[best] }')" if [ -n "$best" ]; then PUBLIC_IP="${best%%|*}" ip_count="${best#*|}"; ip_count="${ip_count%%|*}" ip_providers="${best##*|}" IP_DETECTION="{\"status\":$(jstr "$([ "$ip_count" -ge 2 ] && printf verified || printf unverified)"),\"public_ip\":$(jstr "$PUBLIC_IP"),\"consensus_count\":$(jnum "$ip_count"),\"providers\":[$ip_rows]}" else IP_DETECTION="{\"status\":\"unknown\",\"public_ip\":null,\"consensus_count\":0,\"providers\":[$ip_rows]}" fi if [ -n "$PUBLIC_IP" ]; then local report_ip report_ip="$(printf '%s' "$GEOIP_REPORT" | grep -oE '"ip":"[^"]+"' | head -1 | cut -d'"' -f4)" if [ "$report_ip" != "$PUBLIC_IP" ] || ! printf '%s' "$GEOIP_REPORT" | grep -q '\"status\":\"ok\"' || [ -z "$ASN" ]; then lookup_geoip "$PUBLIC_IP" || warn "GeoIP / ASN 回退接口均不可用(公网 IP 仍可独立使用)" fi fi [ -n "$PUBLIC_IP" ] || warn "公网 IP 识别失败(四个服务均不可用)" [ -n "$ASN" ] || warn "ASN 识别失败(公网 IP 仍可独立使用)" CPU_CORES="$(nproc 2>/dev/null || printf '1')" RAM_GB="$(awk '/MemTotal/{printf "%.1f", $2/1048576}' /proc/meminfo 2>/dev/null)" DISK_GB="$(df -BG / 2>/dev/null | awk 'NR==2{sub("G","",$2); print $2}')" } # IP reputation is an independent report and never changes the benchmark score. # DNSBL 反向查询名:IPv4 三段倒序(1.2.3.4 → 4.3.2.1) rev4() { printf '%s' "$1" | awk -F. '{print $4"."$3"."$2"."$1}' } # IPv6 按 RFC 3596 展开为点分半字节反向名(2400:c620::a → a.0.0....0.2.6.c.0.2.6.4.2) rev6() { local addr="$1" left="" right="" has_ellipsis=0 local l_groups=() r_groups=() all=() fill g i nibbles reversed out sep case "$addr" in *::*) has_ellipsis=1; left="${addr%%::*}"; right="${addr##*::}" ;; *) left="$addr" ;; esac [ -n "$left" ] && IFS=':' read -ra l_groups <<< "$left" [ -n "$right" ] && IFS=':' read -ra r_groups <<< "$right" fill=$(( 8 - ${#l_groups[@]} - ${#r_groups[@]} )) { [ "$has_ellipsis" -eq 1 ] && [ "$fill" -lt 0 ]; } && return 1 { [ "$has_ellipsis" -eq 0 ] && [ "${#l_groups[@]}" -ne 8 ]; } && return 1 for g in ${l_groups[@]+"${l_groups[@]}"}; do all+=("$g"); done if [ "$has_ellipsis" -eq 1 ]; then for ((i = 0; i < fill; i++)); do all+=("0"); done fi for g in ${r_groups[@]+"${r_groups[@]}"}; do all+=("$g"); done [ "${#all[@]}" -eq 8 ] || return 1 nibbles="" for g in "${all[@]}"; do nibbles+="$(printf '%04x' "0x${g:-0}" 2>/dev/null)" || return 1 done [ "${#nibbles}" -eq 32 ] || return 1 reversed="" for ((i = ${#nibbles} - 1; i >= 0; i--)); do reversed+="${nibbles:$i:1}"; done out=""; sep="" for ((i = 0; i < ${#reversed}; i++)); do out+="$sep${reversed:$i:1}"; sep="."; done printf '%s' "$out" } # dnsbl_query <反向名> :输出 clean / listed / unknown dnsbl_query() { local query="$1.$2" answer rcode answer_line if command -v dig >/dev/null 2>&1; then answer="$(dig +time=3 +tries=1 +noall +answer +comments "$query" 2>/dev/null)" rcode="$(printf '%s' "$answer" | grep -oE 'status: [A-Z]+' | head -1 | cut -d' ' -f2)" answer_line="$(printf '%s' "$answer" | grep -v '^;' | grep -v '^[[:space:]]*$' | head -1)" if [ "$rcode" = "NXDOMAIN" ]; then printf 'clean' elif [ "$rcode" = "NOERROR" ] && [ -n "$answer_line" ]; then if printf '%s' "$answer_line" | grep -qE '127\.255\.255\.'; then printf 'unknown'; else printf 'listed'; fi else printf 'unknown' fi elif command -v getent >/dev/null 2>&1; then answer="$(getent hosts "$query" 2>/dev/null | head -1)" [ -n "$answer" ] && printf 'listed' || printf 'unknown' else printf 'unknown' fi } dnsbl_accumulate() { # $1=status $2=zone;结果累计进 listed_count/clean_count/unknown_count/dnsbl_rows case "$1" in listed) listed_count=$((listed_count + 1)) ;; clean) clean_count=$((clean_count + 1)) ;; *) unknown_count=$((unknown_count + 1)) ;; esac dnsbl_rows="${dnsbl_rows}${dnsbl_rows:+,}{\"provider\":$(jstr "$2"),\"status\":$(jstr "$1")}" } detect_usage_type() { local org="$1" local text="$(printf '%s' "$org" | tr '[:upper:]' '[:lower:]')" if printf '%s' "$text" | grep -Eq 'broadband|telecom|unicom|mobile|residential|comcast|charter|verizon|at&t|cox|spectrum|frontier|bell|rogers|shaw|vodafone|bt|virgin|orange|deutsche telekom|hkt|pccw'; then printf '家宽' elif printf '%s' "$text" | grep -Eq 'cloud|hosting|server|datacenter|data center|vps|ovh|hetzner|digitalocean|vultr|linode|aws|amazon|google|azure|microsoft|oracle|alibaba|tencent|ucloud|choopa|leaseweb|quadranet|cogent|colocrossing|landups|liberally'; then printf '机房' else printf '商业' fi } detect_media_unlock() { local ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" local cc="${GEO_CC:-${GEO_COUNTRY_CODE:-US}}" [ -n "$cc" ] || cc="US" local cg_code cg_st="failed" cg_code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 -A "$ua" https://chatgpt.com/ 2>/dev/null)" if [ "$cg_code" = "200" ] || [ "$cg_code" = "307" ] || [ "$cg_code" = "302" ]; then cg_st="unlock"; fi local nf_code nf_st="failed" nf_code="$(curl -sL -o /dev/null -w "%{http_code}" --max-time 5 https://www.netflix.com/title/81280792 2>/dev/null)" if [ "$nf_code" = "200" ]; then nf_st="unlock"; fi local yt_code yt_st="failed" yt_code="$(curl -sL -o /dev/null -w "%{http_code}" --max-time 5 https://www.youtube.com/premium 2>/dev/null)" if [ "$yt_code" = "200" ] || [ "$yt_code" = "302" ]; then yt_st="unlock"; fi local dp_code dp_st="failed" dp_code="$(curl -sL -o /dev/null -w "%{http_code}" --max-time 5 -A "$ua" https://www.disneyplus.com/ 2>/dev/null)" if [ "$dp_code" = "200" ] || [ "$dp_code" = "301" ] || [ "$dp_code" = "302" ]; then dp_st="unlock"; fi local tt_code tt_st="failed" tt_code="$(curl -sL -o /dev/null -w "%{http_code}" --max-time 5 -A "$ua" https://www.tiktok.com/ 2>/dev/null)" if [ "$tt_code" = "200" ] || [ "$tt_code" = "301" ] || [ "$tt_code" = "302" ]; then tt_st="unlock"; fi local rd_code rd_st="failed" rd_code="$(curl -sL -o /dev/null -w "%{http_code}" --max-time 5 -A "$ua" https://www.reddit.com/ 2>/dev/null)" if [ "$rd_code" = "200" ] || [ "$rd_code" = "301" ] || [ "$rd_code" = "302" ]; then rd_st="unlock"; fi local ap_code ap_st="failed" ap_code="$(curl -sL -o /dev/null -w "%{http_code}" --max-time 5 -A "$ua" https://www.primevideo.com/ 2>/dev/null)" if [ "$ap_code" = "200" ] || [ "$ap_code" = "301" ] || [ "$ap_code" = "302" ]; then ap_st="unlock"; fi printf '[{"service":"TikTok","status":%s,"region":%s,"type":"原生"},{"service":"Disney+","status":%s,"region":%s,"type":"原生"},{"service":"Netflix","status":%s,"region":%s,"type":"原生"},{"service":"YouTube","status":%s,"region":%s,"type":"原生"},{"service":"AmazonPV","status":%s,"region":%s,"type":"原生"},{"service":"Reddit","status":%s,"region":%s,"type":"原生"},{"service":"ChatGPT","status":%s,"region":%s,"type":"原生"}]' \ "$(jstr "$tt_st")" "$(jstr "$cc")" \ "$(jstr "$dp_st")" "$(jstr "$cc")" \ "$(jstr "$nf_st")" "$(jstr "$cc")" \ "$(jstr "$yt_st")" "$(jstr "$cc")" \ "$(jstr "$ap_st")" "$(jstr "$cc")" \ "$(jstr "$rd_st")" "$(jstr "$cc")" \ "$(jstr "$cg_st")" "$(jstr "$cc")" } test_ip_reputation() { info "IP 质量与信誉体检(独立报告,不影响总分)..." [ -n "$PUBLIC_IP" ] || { IP_REPUTATION='{"status":"unknown","reason":"public IP unavailable"}'; warn "IP 信誉检查跳过:公网 IP 不可用"; return; } local ptr="" rev="" dnsbl_rows="" zone dnsbl_status smtp_status overall_status="unknown" local clean_count=0 listed_count=0 unknown_count=0 local rep_org="${GEO_ORG}" rep_country="${GEO_COUNTRY}" rep_city="${GEO_CITY}" ip_version="" local rep_cc="${GEO_CC}" rep_reg_cc="${GEO_REG_CC}" ip_type="${GEO_IP_TYPE:-native}" local loc_str="" tz_str="${GEO_TZ}" map_url="" usage_type="" risk_level="very_low" risk_score=3 risk_level_text="极低风险" local media_json="" if [ -n "$GEO_LAT" ] && [ -n "$GEO_LON" ]; then loc_str="${GEO_LAT}, ${GEO_LON}" map_url="https://www.openstreetmap.org/?mlat=${GEO_LAT}&mlon=${GEO_LON}" fi [ -n "$rep_country" ] || rep_country="$(printf '%s' "$GEOIP_REPORT" | grep -oE '"country"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" [ -n "$rep_city" ] || rep_city="$(printf '%s' "$GEOIP_REPORT" | grep -oE '"city"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" [ -n "$rep_org" ] || rep_org="$(printf '%s' "$GEOIP_REPORT" | grep -oE '"organization"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" [ -n "$rep_cc" ] || rep_cc="$(printf '%s' "$GEOIP_REPORT" | grep -oE '"country_code"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" [ -n "$rep_reg_cc" ] && [ "$rep_reg_cc" != "$rep_cc" ] && ip_type="broadcast" usage_type="$(detect_usage_type "$rep_org" "$rep_org")" if command -v getent >/dev/null 2>&1; then ptr="$(getent hosts "$PUBLIC_IP" 2>/dev/null | awk 'NR==1{print $2}')"; fi case "$PUBLIC_IP" in *:*) ip_version="6" ;; *.*) ip_version="4" ;; esac if [ "$ip_version" = "4" ]; then rev="$(rev4 "$PUBLIC_IP")" for zone in zen.spamhaus.org bl.spamcop.net b.barracudacentral.org dnsbl.sorbs.net bl.mailspike.net; do dnsbl_accumulate "$(dnsbl_query "$rev" "$zone")" "$zone" done elif [ "$ip_version" = "6" ]; then if rev="$(rev6 "$PUBLIC_IP")"; then for zone in zen.spamhaus.org dnsbl.dronebl.org; do dnsbl_accumulate "$(dnsbl_query "$rev" "$zone")" "$zone" done else dnsbl_rows='{"provider":"public DNSBL","status":"unsupported","detail":"IPv6 address could not be expanded"}' unknown_count=$((unknown_count + 1)) fi else dnsbl_rows='{"provider":"public DNSBL","status":"unknown","detail":"invalid public IP"}' unknown_count=$((unknown_count + 1)) fi if [ "$listed_count" -gt 0 ]; then overall_status="listed" risk_level="high" risk_score=75 risk_level_text="高风险" elif [ "$clean_count" -gt 0 ] && [ "$unknown_count" -gt 0 ]; then overall_status="partial" risk_level="low" risk_score=15 risk_level_text="低风险" elif [ "$clean_count" -gt 0 ]; then overall_status="clean" risk_level="very_low" risk_score=3 risk_level_text="极低风险" fi smtp_status="blocked" if command -v timeout >/dev/null 2>&1; then timeout 5 bash -c 'cat /dev/tcp/gmail-smtp-in.l.google.com/25' >/dev/null 2>&1 && smtp_status="open" || smtp_status="blocked" fi media_json="$(detect_media_unlock)" IP_REPUTATION="$(cat </dev/null 2>&1; then a_records="$(dig +time=3 +tries=1 +short A "$host" 2>/dev/null)" aaaa_records="$(dig +time=3 +tries=1 +short AAAA "$host" 2>/dev/null)" [ -n "$a_records" ] && a_status="ok" [ -n "$aaaa_records" ] && aaaa_status="ok" elif command -v getent >/dev/null 2>&1; then a_records="$(getent ahostsv4 "$host" 2>/dev/null | awk '{print $1}' | sort -u)" aaaa_records="$(getent ahostsv6 "$host" 2>/dev/null | awk '{print $1}' | sort -u)" [ -n "$a_records" ] && a_status="ok" [ -n "$aaaa_records" ] && aaaa_status="ok" fi dns_values=""; connect_values=""; tls_values=""; ttfb_values=""; codes=""; samples="" for _sample in 1 2 3; do result="$(curl -sS -o /dev/null -w '%{http_code}|%{time_namelookup}|%{time_connect}|%{time_appconnect}|%{time_starttransfer}' --connect-timeout 5 --max-time 10 "$url" 2>/dev/null)" curl_rc=$? code="${result%%|*}" if [ -n "$result" ] && [ "$code" != "000" ]; then dns_ms="$(printf '%s' "$result" | cut -d'|' -f2 | awk '{print $1*1000}')" connect_ms="$(printf '%s' "$result" | cut -d'|' -f3 | awk '{print $1*1000}')" tls_ms="$(printf '%s' "$result" | cut -d'|' -f4 | awk '{print $1*1000}')" ttfb_ms="$(printf '%s' "$result" | cut -d'|' -f5 | awk '{print $1*1000}')" dns_values="${dns_values}${dns_values:+ }$dns_ms" connect_values="${connect_values}${connect_values:+ }$connect_ms" tls_values="${tls_values}${tls_values:+ }$tls_ms" ttfb_values="${ttfb_values}${ttfb_values:+ }$ttfb_ms" codes="${codes}${codes:+,}$(jstr "$code")" target_success=$((target_success + 1)) samples="${samples}${samples:+,}{\"status\":\"ok\",\"http_code\":$(jstr "$code"),\"dns_ms\":$(jnum "$dns_ms"),\"connect_ms\":$(jnum "$connect_ms"),\"tls_ms\":$(jnum "$tls_ms"),\"ttfb_ms\":$(jnum "$ttfb_ms") }" else sample_status="failed" [ "$curl_rc" -eq 28 ] && sample_status="timeout" samples="${samples}${samples:+,}{\"status\":$(jstr "$sample_status"),\"http_code\":null,\"curl_exit\":$curl_rc}" fi done if [ "$target_success" -eq 3 ]; then full_targets=$((full_targets + 1)) DNS_ROWS="${DNS_ROWS}${DNS_ROWS:+,}{\"host\":$(jstr "$host"),\"status\":\"ok\",\"a_status\":$(jstr "$a_status"),\"aaaa_status\":$(jstr "$aaaa_status"),\"a\":$(json_string_array "$a_records"),\"aaaa\":$(json_string_array "$aaaa_records"),\"http_code\":[$codes],\"dns_ms\":$(jnum "$(median "$dns_values")"),\"connect_ms\":$(jnum "$(median "$connect_values")"),\"tls_ms\":$(jnum "$(median "$tls_values")"),\"ttfb_ms\":$(jnum "$(median "$ttfb_values")"),\"samples\":[$samples]}" elif [ "$target_success" -gt 0 ]; then partial_targets=$((partial_targets + 1)) DNS_ROWS="${DNS_ROWS}${DNS_ROWS:+,}{\"host\":$(jstr "$host"),\"status\":\"partial\",\"a_status\":$(jstr "$a_status"),\"aaaa_status\":$(jstr "$aaaa_status"),\"a\":$(json_string_array "$a_records"),\"aaaa\":$(json_string_array "$aaaa_records"),\"http_code\":[$codes],\"dns_ms\":$(jnum "$(median "$dns_values")"),\"connect_ms\":$(jnum "$(median "$connect_values")"),\"tls_ms\":$(jnum "$(median "$tls_values")"),\"ttfb_ms\":$(jnum "$(median "$ttfb_values")"),\"samples\":[$samples]}" else failed_targets=$((failed_targets + 1)) DNS_ROWS="${DNS_ROWS}${DNS_ROWS:+,}{\"host\":$(jstr "$host"),\"status\":\"failed\",\"a_status\":$(jstr "$a_status"),\"aaaa_status\":$(jstr "$aaaa_status"),\"a\":$(json_string_array "$a_records"),\"aaaa\":$(json_string_array "$aaaa_records"),\"samples\":[$samples]}" fi target_success=0 done [ "$partial_targets" -eq 0 ] && [ "$failed_targets" -eq 0 ] || overall_status="partial" [ "$full_targets" -eq 0 ] && [ "$partial_targets" -eq 0 ] && overall_status="failed" DNS_QUALITY="{\"status\":$(jstr "$overall_status"),\"sample_count\":3,\"target_count\":$target_total,\"targets\":[$DNS_ROWS]}" [ "$overall_status" = "ok" ] || warn "部分 DNS / HTTPS 目标检查失败" return 0 } run_external_report() { local name="$1" url="$2" args="${3:-}" script="$WORK_DIR/ext-$1.sh" out="$WORK_DIR/ext-$1.out" sandbox="$WORK_DIR/ext-$1-work" rc body tool_sha expected_sha env_key tool_version isolation report_status truncated env_key="NODEPILSE_EXTENDED_SHA256_${name^^}" env_key="${env_key//[^A-Z0-9_]/_}" expected_sha="${!env_key:-}" if ! printf '%s' "$expected_sha" | grep -Eq '^[A-Fa-f0-9]{64}$'; then EXTENDED_REPORTS="${EXTENDED_REPORTS}${EXTENDED_REPORTS:+,}{\"name\":$(jstr "$name"),\"status\":\"unsupported\",\"tool_version\":null,\"sha256\":null,\"reason\":\"missing pinned SHA-256\",\"isolation\":\"none\",\"truncated\":false,\"output\":\"\"}" warn "扩展报告 $name 已跳过:未配置固定 SHA-256(环境变量 $env_key)" return fi tool_version="sha256:$expected_sha" if ! curl -fsSL --max-time 30 "$url" -o "$script" 2>/dev/null; then EXTENDED_REPORTS="${EXTENDED_REPORTS}${EXTENDED_REPORTS:+,}{\"name\":$(jstr "$name"),\"status\":\"unavailable\",\"tool_version\":$(jstr "$tool_version"),\"sha256\":$(jstr "$expected_sha"),\"isolation\":\"none\",\"truncated\":false,\"output\":\"\"}" warn "扩展报告 $name 下载失败" return fi if ! printf '%s %s\n' "$expected_sha" "$script" | sha256sum -c - >/dev/null 2>&1; then EXTENDED_REPORTS="${EXTENDED_REPORTS}${EXTENDED_REPORTS:+,}{\"name\":$(jstr "$name"),\"status\":\"checksum_failed\",\"tool_version\":$(jstr "$tool_version"),\"sha256\":$(jstr "$expected_sha"),\"isolation\":\"none\",\"truncated\":false,\"output\":\"\"}" warn "扩展报告 $name 校验和不匹配,已拒绝执行" return fi if ! command -v runuser >/dev/null 2>&1 || ! id nobody >/dev/null 2>&1; then EXTENDED_REPORTS="${EXTENDED_REPORTS}${EXTENDED_REPORTS:+,}{\"name\":$(jstr "$name"),\"status\":\"unsupported\",\"tool_version\":$(jstr "$tool_version"),\"sha256\":$(jstr "$expected_sha"),\"isolation\":\"none\",\"truncated\":false,\"output\":\"\"}" warn "扩展报告 $name 已跳过:系统没有低权限执行环境" return fi chmod 755 "$WORK_DIR" "$script" if ! mkdir -p "$sandbox" || ! chown nobody "$sandbox" 2>/dev/null; then EXTENDED_REPORTS="${EXTENDED_REPORTS}${EXTENDED_REPORTS:+,}{\"name\":$(jstr "$name"),\"status\":\"unsupported\",\"tool_version\":$(jstr "$tool_version"),\"sha256\":$(jstr "$expected_sha"),\"reason\":\"failed to prepare low-privilege workspace\",\"isolation\":\"none\",\"truncated\":false,\"output\":\"\"}" warn "扩展报告 $name 无法创建低权限隔离目录" return fi : > "$out" chmod 666 "$out" isolation="nobody+ulimit+timeout" tool_sha="$(sha256sum "$script" 2>/dev/null | awk '{print $1}')" runuser -u nobody -- env HOME="$sandbox" sh -c 'cd "$1" || exit 1; shift; umask 077; ulimit -t 180; ulimit -v 524288; ulimit -f 65536; ulimit -u 64 2>/dev/null || true; exec timeout -k 5 180 bash "$@"' -- "$sandbox" "$script" $args >"$out" 2>&1 rc=$? body="$(tail -c 32768 "$out" 2>/dev/null)" truncated=false [ "$(wc -c <"$out" 2>/dev/null || printf 0)" -gt 32768 ] && truncated=true if [ "$rc" -eq 0 ]; then report_status="success"; elif [ "$rc" -eq 124 ]; then report_status="timeout"; else report_status="failed"; fi EXTENDED_REPORTS="${EXTENDED_REPORTS}${EXTENDED_REPORTS:+,}{\"name\":$(jstr "$name"),\"status\":$(jstr "$report_status"),\"tool_version\":$(jstr "$tool_version"),\"sha256\":$(jstr "$tool_sha"),\"exit_code\":$rc,\"isolation\":$(jstr "$isolation"),\"truncated\":$truncated,\"output\":$(jstr "$body")}" [ "$rc" -eq 0 ] || warn "扩展报告 $name 执行失败或超时" } test_extended_reports() { [ "$OPT_EXTENDED" -eq 1 ] || return info "第三方扩展报告(不计入总分)..." run_external_report "IP.Check.Place" "https://IP.Check.Place" "-j -E" run_external_report "Net.Check.Place" "https://Net.Check.Place" "-j -E" run_external_report "NodeQuality" "https://run.NodeQuality.com" "" } # ---------------------------------------------------------------------- CPU # sysbench 是主指标:纯本地计算,不依赖任何外网,任何机房都能出分。 test_cpu() { if need sysbench sysbench; then info "CPU 单核(sysbench,8 秒)..." SB_SINGLE="$(timeout 30 sysbench cpu --threads=1 --time=8 --cpu-max-prime=10000 run 2>/dev/null \ | awk '/events per second/{printf "%.0f", $4}')" info "CPU 多核(sysbench,${CPU_CORES} 线程,8 秒)..." SB_MULTI="$(timeout 30 sysbench cpu --threads="${CPU_CORES:-1}" --time=8 --cpu-max-prime=10000 run 2>/dev/null \ | awk '/events per second/{printf "%.0f", $4}')" [ -n "$SB_SINGLE" ] || warn "sysbench 已安装但未产出成绩" else warn "sysbench 不可用(未安装且无法自动安装),CPU 只剩 sha256 兜底指标" fi # sha256 吞吐:sysbench 也拿不到时的最后兜底,永远可用 if command -v sha256sum >/dev/null 2>&1; then info "CPU sha256 吞吐(单核 512MB)..." local t0 t1 ns t0="$(date +%s%N)" dd if=/dev/zero bs=1M count=512 2>/dev/null | sha256sum >/dev/null 2>&1 t1="$(date +%s%N)" ns=$((t1 - t0)) [ "$ns" -gt 0 ] 2>/dev/null && CPU_HASH=$(( 512 * 1000000000 / ns )) fi } test_memory() { if ! command -v sysbench >/dev/null 2>&1; then warn "sysbench 不可用,内存吞吐无数据" return fi local ram_mib="$(awk '/MemAvailable/{available=$2} /MemTotal/{total=$2} END {value=available ? available : total; if (value) printf "%d", value/1024}' /proc/meminfo 2>/dev/null)" if [ "${ram_mib:-0}" -lt 1536 ]; then MEM_WORKSET_MIB=128 elif [ "${ram_mib:-0}" -le 4096 ]; then MEM_WORKSET_MIB=512 else MEM_WORKSET_MIB=1024 fi info "内存吞吐(sysbench,${MEM_WORKSET_MIB} MiB,最长 60 秒)..." local mem_out mem_rc mem_out="$(timeout 60 sysbench memory --memory-block-size=1M --memory-total-size="${MEM_WORKSET_MIB}M" --memory-oper=read run 2>/dev/null)" mem_rc=$? [ "$mem_rc" -eq 124 ] && MEM_TIMEOUT=1 MEM_READ="$(printf '%s' "$mem_out" | awk -F'[()]' '/MiB transferred/{gsub(/[^0-9.]/,"",$2); print $2; exit}')" mem_out="$(timeout 60 sysbench memory --memory-block-size=1M --memory-total-size="${MEM_WORKSET_MIB}M" --memory-oper=write run 2>/dev/null)" mem_rc=$? [ "$mem_rc" -eq 124 ] && MEM_TIMEOUT=1 MEM_WRITE="$(printf '%s' "$mem_out" | awk -F'[()]' '/MiB transferred/{gsub(/[^0-9.]/,"",$2); print $2; exit}')" [ -n "$MEM_READ" ] || warn "内存读吞吐未产出成绩" [ -n "$MEM_WRITE" ] || warn "内存写吞吐未产出成绩" } # Geekbench 只在显式开启时跑,且先探测上传通道 —— 免费版分数只存在于上传后的 # 结果页,通道不通就是纯浪费 10-20 分钟。 test_geekbench() { if [ "$OPT_GEEKBENCH" != "1" ]; then info "跳过 Geekbench(需要时加 --geekbench)" return fi local code GEEKBENCH_TOOL="{\"tool\":\"Geekbench\",\"version\":$(jstr "$GEEKBENCH_VERSION"),\"sha256\":$(jstr "$GEEKBENCH_SHA256"),\"status\":\"configured\"}" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://browser.geekbench.com/ 2>/dev/null)" if [ "$code" != "200" ]; then GB_NOTE="browser.geekbench.com 返回 HTTP ${code:-000},本机 IP 无法上传结果,免费版拿不到分数,已跳过(sysbench 成绩不受影响)" GEEKBENCH_TOOL="{\"tool\":\"Geekbench\",\"version\":$(jstr "$GEEKBENCH_VERSION"),\"sha256\":null,\"status\":\"unavailable\"}" warn "$GB_NOTE" return fi if [ -z "$GEEKBENCH_SHA256" ] || ! command -v sha256sum >/dev/null 2>&1; then GB_NOTE="Geekbench ${GEEKBENCH_VERSION} 缺少固定 SHA-256,已跳过以保证测试可复现" GEEKBENCH_TOOL="{\"tool\":\"Geekbench\",\"version\":$(jstr "$GEEKBENCH_VERSION"),\"sha256\":null,\"status\":\"unsupported\"}" warn "$GB_NOTE" return fi info "Geekbench 6 CPU 跑分(10-20 分钟,结果会公开上传到 Geekbench Browser)..." curl -fsSL --max-time 300 "https://cdn.geekbench.com/Geekbench-${GEEKBENCH_VERSION}-Linux.tar.gz" \ -o "$WORK_DIR/gb.tgz" 2>/dev/null || { GB_NOTE="Geekbench 下载失败"; warn "$GB_NOTE"; return; } if ! printf '%s %s\n' "$GEEKBENCH_SHA256" "$WORK_DIR/gb.tgz" | sha256sum -c - >/dev/null 2>&1; then GB_NOTE="Geekbench SHA-256 校验失败,已拒绝执行" GEEKBENCH_TOOL="{\"tool\":\"Geekbench\",\"version\":$(jstr "$GEEKBENCH_VERSION"),\"sha256\":$(jstr "$GEEKBENCH_SHA256"),\"status\":\"checksum_failed\"}" warn "$GB_NOTE" return fi GEEKBENCH_TOOL="{\"tool\":\"Geekbench\",\"version\":$(jstr "$GEEKBENCH_VERSION"),\"sha256\":$(jstr "$GEEKBENCH_SHA256"),\"status\":\"verified\"}" tar -xzf "$WORK_DIR/gb.tgz" -C "$WORK_DIR" 2>/dev/null local gbdir="$WORK_DIR/Geekbench-${GEEKBENCH_VERSION}-Linux" if [ ! -x "$gbdir/geekbench6" ]; then GB_NOTE="Geekbench 下载或解压失败" warn "$GB_NOTE" return fi local out out="$(cd "$gbdir" && timeout -k 10 1500 ./geekbench6 2>&1)" local gb_rc=$? [ "$gb_rc" -eq 124 ] && GB_TIMEOUT=1 GB_SINGLE="$(printf '%s' "$out" | awk '/Single-Core Score/{print $NF; exit}' | tr -d ',')" GB_MULTI="$(printf '%s' "$out" | awk '/Multi-Core Score/{print $NF; exit}' | tr -d ',')" GB_URL="$(printf '%s' "$out" | grep -oE 'https://browser\.geekbench\.com/v6/cpu/[0-9]+' | tail -1)" # 终端不打印分数时,从结果页的 csv 视图取 if [ -z "$GB_SINGLE" ] && [ -n "$GB_URL" ]; then local csv csv="$(curl -sL --max-time 25 "${GB_URL}.csv" 2>/dev/null)" GB_SINGLE="$(printf '%s' "$csv" | awk -F, '/^Single-Core,/{print $2; exit}')" GB_MULTI="$(printf '%s' "$csv" | awk -F, '/^Multi-Core,/{print $2; exit}')" fi if [ -z "$GB_SINGLE" ]; then GB_NOTE="Geekbench 跑完但未取到分数(上传或结果页读取失败)" warn "$GB_NOTE" fi } # --------------------------------------------------------------------- 磁盘 to_iops() { case "$1" in *k|*K) awk "BEGIN{printf \"%d\", ${1%[kK]} * 1000}" ;; *m|*M) awk "BEGIN{printf \"%d\", ${1%[mM]} * 1000000}" ;; *) awk "BEGIN{printf \"%d\", ${1:-0}}" ;; esac } to_mbps() { case "$1" in *GiB/s) awk "BEGIN{printf \"%.1f\", ${1%GiB/s} * 1073.741824}" ;; *MiB/s) awk "BEGIN{printf \"%.1f\", ${1%MiB/s} * 1.048576}" ;; *KiB/s) awk "BEGIN{printf \"%.1f\", ${1%KiB/s} / 976.5625}" ;; *GB/s) awk "BEGIN{printf \"%.1f\", ${1%GB/s} * 1000}" ;; *MB/s) awk "BEGIN{printf \"%.1f\", ${1%MB/s}}" ;; *KB/s) awk "BEGIN{printf \"%.1f\", ${1%KB/s} / 1000}" ;; *) printf '' ;; esac } # 选测试目录:绝不能落在 tmpfs 上。Debian 13 / Ubuntu 24.04 默认把 /tmp 挂成 # tmpfs,在那里跑 fio 量的是内存 —— 本机实测 tmpfs 上 4K 随机读 284k IOPS, # 而真实 ext4 根盘是 28k,差一个数量级。 pick_disk_dir() { local cand fs avail for cand in "${BENCH_DISK_DIR:-}" "$PWD" /var/tmp /root /; do [ -n "$cand" ] || continue [ -d "$cand" ] && [ -w "$cand" ] || continue fs="$(findmnt -no FSTYPE --target "$cand" 2>/dev/null)" [ -n "$fs" ] || fs="$(stat -f -c %T "$cand" 2>/dev/null)" case "$fs" in tmpfs|ramfs) continue ;; esac avail="$(df -BM "$cand" 2>/dev/null | awk 'NR==2{gsub(/[^0-9]/,"",$4); print $4}')" [ -n "$avail" ] && [ "$avail" -lt 1600 ] 2>/dev/null && continue DISK_DIR="$cand"; DISK_FS="${fs:-unknown}" return 0 done return 1 } # fio_run <输出文件>:libaio 不可用时自动退回 psync # size 固定 1G:工作集太小会整体落进宿主机缓存,测出来的是缓存不是盘。 fio_run() { local out="$3" fio --name=np --filename="$DISK_DIR/.nodepilse-fio.dat" --ioengine=libaio --iodepth=16 \ --rw="$1" --bs="$2" --direct=1 --size=1G --numjobs=1 --runtime=15 \ --time_based --group_reporting --output="$out" >/dev/null 2>&1 if ! grep -q 'IOPS=' "$out" 2>/dev/null; then DISK_ENGINE="psync" DISK_COMPARABILITY="degraded" fio --name=np --filename="$DISK_DIR/.nodepilse-fio.dat" --ioengine=psync \ --rw="$1" --bs="$2" --direct=1 --size=1G --numjobs=1 --runtime=15 \ --time_based --group_reporting --output="$out" >/dev/null 2>&1 fi } fio_iops() { local v v="$(grep -oE 'IOPS=[0-9.]+[kKmM]?' "$1" 2>/dev/null | head -1 | cut -d= -f2)" [ -n "$v" ] && to_iops "$v" } fio_bw() { local v v="$(grep -oE 'BW=[0-9.]+[KMG]i?B/s' "$1" 2>/dev/null | head -1 | cut -d= -f2)" [ -n "$v" ] && to_mbps "$v" } test_disk() { if [ "$OPT_DISK" -eq 0 ]; then info "跳过磁盘测试" return fi if ! pick_disk_dir; then warn "找不到可用于磁盘测试的非 tmpfs 目录(或剩余空间不足 1.6G),跳过磁盘测试" return fi register_temp "$DISK_DIR/.nodepilse-fio.dat" register_temp "$DISK_DIR/.nodepilse-dd.dat" if ! need fio fio; then warn "fio 不可用,磁盘测试降级为 dd 顺序写" local out out="$(dd if=/dev/zero of="$DISK_DIR/.nodepilse-dd.dat" bs=1M count=512 oflag=direct 2>&1 \ || dd if=/dev/zero of="$DISK_DIR/.nodepilse-dd.dat" bs=1M count=512 conv=fdatasync 2>&1)" SEQ_W="$(printf '%s' "$out" | grep -oE '[0-9.]+ [MG]B/s' | tail -1 \ | awk '{if($2=="GB/s") printf "%.1f", $1*1000; else printf "%.1f", $1}')" DISK_ENGINE="dd" DISK_COMPARABILITY="degraded" rm -f "$DISK_DIR/.nodepilse-dd.dat" return fi DISK_ENGINE="libaio" info "磁盘测试目录 ${DISK_DIR}(${DISK_FS})" info "磁盘 4K 随机读 ..." fio_run randread 4k "$WORK_DIR/r4k.out"; IOPS_R="$(fio_iops "$WORK_DIR/r4k.out")" info "磁盘 1M 顺序读 ..." fio_run read 1m "$WORK_DIR/r1m.out"; SEQ_R="$(fio_bw "$WORK_DIR/r1m.out")" if [ "$OPT_FAST" -eq 0 ]; then info "磁盘 4K 随机写 ..." fio_run randwrite 4k "$WORK_DIR/w4k.out"; IOPS_W="$(fio_iops "$WORK_DIR/w4k.out")" info "磁盘 1M 顺序写 ..." fio_run write 1m "$WORK_DIR/w1m.out"; SEQ_W="$(fio_bw "$WORK_DIR/w1m.out")" else info "快速模式:跳过磁盘写测试" fi rm -f "$DISK_DIR/.nodepilse-fio.dat" [ -n "$IOPS_R" ] || warn "fio 未产出 4K 随机读成绩" } test_streaming() { if [ "$OPT_STREAMING" -eq 0 ]; then return fi info "流媒体解锁检测 ..." NF_STATUS="$(curl -sL --max-time 8 -o /dev/null -w '%{http_code}' https://www.netflix.com/title/81280792 2>/dev/null)" YT_STATUS="$(curl -sL --max-time 8 -o /dev/null -w '%{http_code}' https://www.youtube.com/premium 2>/dev/null)" STREAMING="netflix:${NF_STATUS:-unknown},youtube:${YT_STATUS:-unknown}" } # --------------------------------------------------------------------- 网络 # v2 的 URL 写成了 ooklaspeedtest-cli-linux-*.tgz(实际返回 403),于是永远静默 # 退回 python 版 speedtest-cli,再由它挑到烂节点。这里用官方正确的文件名。 get_speedtest() { # Ookla CLI 读 $HOME 存许可状态,HOME 为空时直接抛 C++ 异常退出。cron 和 # systemd-run 下 HOME 经常是空的,所以这里先兜住。 if [ -z "${HOME:-}" ]; then export HOME="$WORK_DIR" info "HOME 未设置,临时指向 $WORK_DIR(Ookla CLI 需要)" fi local a="" case "$ARCH" in x86_64|amd64) a=x86_64 ;; aarch64|arm64) a=aarch64 ;; armv7l|armhf) a=armhf ;; i386|i686) a=i386 ;; esac [ -n "$a" ] || return 1 command -v sha256sum >/dev/null 2>&1 || return 1 local expected_sha="" case "$a" in x86_64) expected_sha="5690596c54ff9bed63fa3732f818a05dbc2db19ad36ed68f21ca5f64d5cfeeb7" ;; aarch64) expected_sha="3953d231da3783e2bf8904b6dd72767c5c6e533e163d3742fd0437affa431bd3" ;; armhf) expected_sha="e45fcdebbd8a185553535533dd032d6b10bc8c64eee4139b1147b9c09835d08d" ;; i386) expected_sha="9ff7e18dbae7ee0e03c66108445a2fb6ceea6c86f66482e1392f55881b772fe8" ;; esac info "下载官方 Ookla Speedtest CLI($a)..." curl -sL --max-time 120 \ "https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-${a}.tgz" \ -o "$WORK_DIR/st.tgz" 2>/dev/null || return 1 if [ -z "$expected_sha" ] || ! printf '%s %s\n' "$expected_sha" "$WORK_DIR/st.tgz" | sha256sum -c - >/dev/null 2>&1; then warn "Ookla Speedtest CLI 校验和不匹配,已拒绝执行" return 1 fi tar -xzf "$WORK_DIR/st.tgz" -C "$WORK_DIR" 2>/dev/null || return 1 [ -x "$WORK_DIR/speedtest" ] || return 1 ST_BIN="$WORK_DIR/speedtest" SPEEDTEST_TOOL="{\"tool\":\"Ookla Speedtest CLI\",\"version\":\"1.2.0\",\"sha256\":$(jstr "$expected_sha"),\"status\":\"verified\"}" } j_num() { printf '%s' "$1" | grep -oE "\"$2\":[0-9.]+" | head -1 | cut -d: -f2; } j_str() { printf '%s' "$1" | grep -oE "\"$2\":\"[^\"]*\"" | head -1 | cut -d'"' -f4; } # st_measure [节点ID]:成功时输出 "标签|ISP|延迟|抖动|下载Mbps|上传Mbps|结果页" # 失败原因写进文件而不是变量:调用方是 line="$(st_measure ...)",命令替换是子 # shell,在里面赋值的全局变量出了子 shell 就丢了,warnings 里只会剩一句 # "测速失败",等于把 stderr 丢进 /dev/null 白改一遍。 st_err_read() { cat "$ST_ERR_FILE" 2>/dev/null; } st_measure() { local outf="$WORK_DIR/st.out" errf="$WORK_DIR/st.err" : > "$ST_ERR_FILE" if [ -n "${1:-}" ]; then timeout -k 5 120 "$ST_BIN" --accept-license --accept-gdpr -f json --server-id="$1" >"$outf" 2>"$errf" else timeout -k 5 120 "$ST_BIN" --accept-license --accept-gdpr -f json >"$outf" 2>"$errf" fi local raw raw="$(grep '"type":"result"' "$outf" 2>/dev/null | head -1)" if [ -z "$raw" ]; then # Ookla 有时把 JSON 错误行写到 stderr,所以两个文件都找 message;找不到才退回 # 非 JSON 的原始 stderr(例如 HOME 缺失时的 C++ terminate 信息) { grep -hoE '"message":"[^"]*"' "$outf" "$errf" 2>/dev/null | head -1 | cut -d'"' -f4 grep -hvE '^[[:space:]]*\{' "$errf" 2>/dev/null | head -1; } \ | tr '\n' ' ' | cut -c1-160 | sed 's/[[:space:]]\{1,\}$//' > "$ST_ERR_FILE" return 1 fi local pchunk schunk name loc isp ping jit dbytes ubytes down up url label pchunk="$(printf '%s' "$raw" | grep -oE '"ping":\{[^}]*\}')" schunk="$(printf '%s' "$raw" | grep -oE '"server":\{[^}]*\}')" ping="$(j_num "$pchunk" latency)" jit="$(j_num "$pchunk" jitter)" dbytes="$(printf '%s' "$raw" | grep -oE '"download":\{"bandwidth":[0-9]+' | head -1 | grep -oE '[0-9]+$')" ubytes="$(printf '%s' "$raw" | grep -oE '"upload":\{"bandwidth":[0-9]+' | head -1 | grep -oE '[0-9]+$')" isp="$(j_str "$raw" isp)" name="$(j_str "$schunk" name)" loc="$(j_str "$schunk" location)" url="$(printf '%s' "$raw" | grep -oE 'https://www\.speedtest\.net/result/c/[0-9a-f-]+' | head -1)" [ -n "$dbytes" ] || { ST_ERR="结果 JSON 里没有 download.bandwidth"; return 1; } down="$(awk "BEGIN{printf \"%.1f\", $dbytes * 8 / 1000000}")" up="$(awk "BEGIN{printf \"%.1f\", ${ubytes:-0} * 8 / 1000000}")" label="${loc:-未知}${name:+ · $name}" printf '%s|%s|%s|%s|%s|%s|%s\n' "$label" "$isp" "${ping:-}" "${jit:-}" "$down" "$up" "$url" } NET_ROWS="$WORK_DIR/net.tsv" : > "$NET_ROWS" net_record() { # net_record <是否代表值 1/0> local canon="$1" line="$2" local label isp ping jit down up url IFS='|' read -r label isp ping jit down up url <> "$NET_ROWS" if [ "$canon" = "1" ]; then DOWNLOAD="$down"; UPLOAD="$up"; LAT_LOCAL="$ping"; JITTER_LOCAL="$jit" ST_NODE="$label"; ST_ISP="$isp"; ST_URL="$url" fi info " ${label}:下载 ${down} Mbps / 上传 ${up} Mbps / 延迟 ${ping:-?} ms" } test_network() { if ! get_speedtest; then warn "官方 Ookla CLI 不可用(架构 ${ARCH:-未知} 或下载失败),跳过测速" return fi # 代表值用 Ookla 自己按延迟选出的最优节点,而不是"列表里第一个能跑通的" info "测速:自动选择最优节点 ..." local line if line="$(st_measure '')"; then net_record 1 "$line" else ST_ERR="$(st_err_read)" warn "自动选节点测速失败${ST_ERR:+:$ST_ERR},改为逐个尝试就近节点" fi if [ "$OPT_FAST" -eq 1 ]; then info "快速模式:跳过附加测速节点" elif [ "${EXTRA_NODES:-0}" -gt 0 ] 2>/dev/null; then local ids id ids="$("$ST_BIN" -L --accept-license --accept-gdpr 2>/dev/null \ | awk '/^[[:space:]]*[0-9]+/{print $1}' | head -"$EXTRA_NODES")" for id in $ids; do info "测速:节点 #${id} ..." if line="$(st_measure "$id")"; then # 自动选点失败时先记录所有备用节点,最后统一选吞吐最佳者。 net_record 0 "$line" else ST_ERR="$(st_err_read)" warn "测速节点 #${id} 无响应${ST_ERR:+:$ST_ERR},已跳过(不计入结果)" fi done fi # 仍然没有代表值:从已记录的行里取下载最高的一条 if [ -z "$DOWNLOAD" ] && [ -s "$NET_ROWS" ]; then local best best="$(sort -t"$(printf '\t')" -k3,3 -rn "$NET_ROWS" | head -1)" if [ -n "$best" ]; then ST_NODE="$(printf '%s' "$best" | cut -f1)" LAT_LOCAL="$(printf '%s' "$best" | cut -f2)" DOWNLOAD="$(printf '%s' "$best" | cut -f3)" UPLOAD="$(printf '%s' "$best" | cut -f4)" fi fi [ -n "$DOWNLOAD" ] || warn "所有测速节点均失败,网络吞吐无数据" } # ----------------------------------------------------------- 国内 CDN 测速 # zstaticcdn 是国内 CDN 边缘节点,页面文件只有几十 KB,单次下载无法反映带宽。 # 用多线程反复下载固定时长,以累计字节数 / 秒数得到聚合下载速率,作为 # "VPS 到国内 CDN" 的参考值。不参与 Ookla 代表值和总分。 CN_CDN_URL="${NODEPILSE_CN_CDN_URL:-https://lf3-ips.zstaticcdn.com/}" CN_CDN_THREADS="${NODEPILSE_CN_CDN_THREADS:-8}" CN_CDN_DURATION="${NODEPILSE_CN_CDN_DURATION:-10}" CN_CDN_MBPS="" CN_CDN_ERROR="" test_china_cdn() { info "国内 CDN 测速:$CN_CDN_URL ..." local code code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$CN_CDN_URL" 2>/dev/null)" if [ "$code" != "200" ]; then CN_CDN_ERROR="HTTP ${code:-000}" warn "国内 CDN 不可达($CN_CDN_ERROR),跳过" return fi local workdir="$WORK_DIR/cn_cdn" mkdir -p "$workdir" local pids=() i outfile chunk v bytes total end_ts end_ts=$(( $(date +%s) + CN_CDN_DURATION )) for i in $(seq 1 "$CN_CDN_THREADS"); do outfile="$workdir/thread_$i" : > "$outfile" ( bytes=0 while [ "$(date +%s)" -lt "$end_ts" ]; do chunk="$(curl -sL --compressed --max-time 5 -o /dev/null -w '%{size_download}' "$CN_CDN_URL" 2>/dev/null || echo 0)" bytes=$(( bytes + ${chunk:-0} )) done echo "$bytes" > "$outfile" ) & pids+=($!) CHILD_PIDS+=($!) done for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null; done CHILD_PIDS=() total=0 for i in $(seq 1 "$CN_CDN_THREADS"); do v="$(cat "$workdir/thread_$i" 2>/dev/null || echo 0)" total=$(( total + ${v:-0} )) done rm -rf "$workdir" if [ "$total" -le 0 ]; then CN_CDN_ERROR="0 bytes in ${CN_CDN_DURATION}s" warn "国内 CDN 测速失败($CN_CDN_ERROR),跳过" return fi CN_CDN_MBPS="$(awk "BEGIN{printf \"%.1f\", $total * 8 / $CN_CDN_DURATION / 1000000}")" info " 国内 CDN 下载:${CN_CDN_MBPS} Mbps" } # ------------------------------------------------------- 三网延迟 / 丢包 # v2 只 ping 223.5.5.5(AliDNS 是 anycast)。实测本机到它 49ms,而到真实单播 # 的上海电信 167ms、上海联通 209ms、上海移动 230ms —— anycast 会就近落到境外 # POP,量出来的根本不是到中国的延迟。这里改用真实单播三网 IP。 CN_TARGETS_FULL="电信:上海:202.96.209.133 电信:成都:61.139.2.69 联通:上海:210.22.97.1 联通:北京:202.106.50.1 移动:上海:211.136.112.200 移动:成都:211.137.96.205" CN_TARGETS_FAST="电信:上海:202.96.209.133 联通:上海:210.22.97.1 移动:上海:211.136.112.200" CN_ROWS="" LAT_CN_METHOD="" test_cn_latency() { local list="$CN_TARGETS_FULL" [ "$OPT_FAST" -eq 1 ] && list="$CN_TARGETS_FAST" info "三网延迟 / 丢包(真实单播 IP)..." local rtts="" fallback_rtts="" losses="" ct="" cu="" cm="" lct="" lcu="" lcm="" t carrier city ip out rtt loss method local raw_rtts stats_rtts rtt_min rtt_p95 fallback_ms fallback_result sample_count stats_sample_count connect_ms for t in $list; do carrier="${t%%:*}" city="$(printf '%s' "$t" | cut -d: -f2)" ip="${t##*:}" method="icmp" rtt="" loss="" rtt_min="" rtt_p95="" connect_ms="" out="$(LC_ALL=C ping -c 10 -W 2 -w 15 "$ip" 2>/dev/null)" raw_rtts="$(printf '%s' "$out" | grep -oE 'time[=<][0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+(\.[0-9]+)?$')" sample_count="$(printf '%s\n' "$raw_rtts" | grep -E '^[0-9.]+$' | wc -l | tr -d ' ')" stats_sample_count="$sample_count" stats_rtts="$raw_rtts" if [ "${sample_count:-0}" -gt 1 ]; then stats_rtts="$(printf '%s\n' "$raw_rtts" | sed '1d' | tr '\n' ' ')" stats_sample_count=$((sample_count - 1)) fi rtt="$(median "$stats_rtts")" rtt_min="$(printf '%s\n' $stats_rtts | grep -E '^[0-9.]+$' | sort -n | head -1)" rtt_p95="$(percentile95 "$stats_rtts")" loss="$(printf '%s' "$out" | grep -oE '[0-9.]+% packet loss' | grep -oE '^[0-9.]+' | head -1)" if [ -z "$rtt" ]; then fallback_result="$(curl -sS -o /dev/null -w '%{time_connect}' --connect-timeout 2 --max-time 3 "telnet://$ip:53" 2>/dev/null)" fallback_ms="$(printf '%s' "$fallback_result" | awk '{if ($1 > 0) printf "%.1f", $1*1000}')" if [ -n "$fallback_ms" ] && [ "$fallback_ms" != "0.0" ]; then method="tcp_53" connect_ms="$fallback_ms" fallback_rtts="$fallback_rtts $fallback_ms" loss="" fi fi if [ -n "$rtt" ]; then rtts="$rtts $rtt" case "$carrier" in 电信) ct="$ct $rtt" ;; 联通) cu="$cu $rtt" ;; 移动) cm="$cm $rtt" ;; esac info " ${carrier}${city}:${rtt} ms / 丢包 ${loss:-?}%(${method})" elif [ -n "$connect_ms" ]; then info " ${carrier}${city}:TCP/53 建连 ${connect_ms} ms(ICMP fallback)" else info " ${carrier}${city}:超时" fi if [ -n "$loss" ]; then losses="$losses $loss" case "$carrier" in 电信) lct="$lct $loss" ;; 联通) lcu="$lcu $loss" ;; 移动) lcm="$lcm $loss" ;; esac fi CN_ROWS="${CN_ROWS}${CN_ROWS:+,}{\"carrier\":$(jstr "$carrier"),\"city\":$(jstr "$city"),\"ip\":$(jstr "$ip"),\"method\":$(jstr "$method"),\"requested_samples\":10,\"raw_samples\":$(jnum "$sample_count"),\"samples\":$(jnum "$stats_sample_count"),\"rtt_ms\":$(jnum "$rtt"),\"rtt_min_ms\":$(jnum "$rtt_min"),\"rtt_p95_ms\":$(jnum "$rtt_p95"),\"tcp_connect_ms\":$(jnum "$connect_ms"),\"loss_pct\":$(jnum "$loss")}" done LAT_CN="$(median "$rtts")" LAT_CN_METHOD="icmp" if [ -z "$LAT_CN" ] && [ -n "$fallback_rtts" ]; then LAT_CN="$(median "$fallback_rtts")" LAT_CN_METHOD="tcp_53" fi LOSS_CT="$(mean "$lct")" LOSS_CU="$(mean "$lcu")" LOSS_CM="$(mean "$lcm")" # 取各运营商平均丢包中的最大值,避免某一条线路严重丢包被其它 0% 样本掩盖。 LOSS_CN="$(max_number "$LOSS_CT $LOSS_CU $LOSS_CM")" # ping 被防火墙直接拦截时既没有 RTT 也没有丢包样本;此时 0% 不是实测值 if [ -z "$rtts" ] && [ -z "$losses" ]; then LOSS_CN="" fi # 每家运营商取最优值(同运营商多城市里最快的一条) LAT_CT="$(printf '%s\n' $ct | grep -E '^[0-9.]+$' | sort -n | head -1)" LAT_CU="$(printf '%s\n' $cu | grep -E '^[0-9.]+$' | sort -n | head -1)" LAT_CM="$(printf '%s\n' $cm | grep -E '^[0-9.]+$' | sort -n | head -1)" [ -n "$LAT_CN" ] || warn "三网 IP 全部超时(ICMP 可能被封),延迟无数据" } # ------------------------------------------------ 全国 31 省级三网 TCP 延迟 CN_PROVINCES="北京:bj 天津:tj 河北:he 山西:sx 内蒙古:nm 辽宁:ln 吉林:jl 黑龙江:hl 上海:sh 江苏:js 浙江:zj 安徽:ah 福建:fj 江西:jx 山东:sd 河南:ha 湖北:hb 湖南:hn 广东:gd 广西:gx 海南:hi 重庆:cq 四川:sc 贵州:gz 云南:yn 西藏:xz 陕西:sn 甘肃:gs 青海:qh 宁夏:nx 新疆:xj" test_province_latency() { if [ "$OPT_PROVINCE" -ne 1 ] || [ "$OPT_FAST" -eq 1 ]; then info "跳过全国 31 省级三网 TCP 延迟测速" return 0 fi info "全国 31 省级三网节点 TCP 延迟测速(高并发探测)..." local workdir="$WORK_DIR/prov_lat" mkdir -p "$workdir" local batch=() local idx=0 local prov_name prov_code c_key c_name target_host for p in $CN_PROVINCES; do prov_name="${p%%:*}" prov_code="${p##*:}" for c_item in "cm:移动" "cu:联通" "ct:电信"; do c_key="${c_item%%:*}" c_name="${c_item##*:}" target_host="${prov_code}-${c_key}-v4.ip.zstaticcdn.com" idx=$((idx + 1)) ( connect_res="$(curl -sS -o /dev/null -w '%{time_connect}' --connect-timeout 2 --max-time 3 "http://${target_host}:80/" 2>/dev/null || echo 0)" ms="$(awk -v t="$connect_res" 'BEGIN{if (t > 0) printf "%.1f", t*1000; else print ""}')" printf '{"province":%s,"code":%s,"carrier":%s,"carrier_key":%s,"host":%s,"port":80,"tcp_ms":%s}\n' \ "$(jstr "$prov_name")" "$(jstr "$prov_code")" "$(jstr "$c_name")" "$(jstr "$c_key")" "$(jstr "$target_host")" "${ms:-null}" > "$workdir/$idx" ) & batch+=($!) CHILD_PIDS+=($!) if [ "${#batch[@]}" -ge 16 ]; then for pid in "${batch[@]}"; do wait "$pid" 2>/dev/null || true; done batch=() fi done done for pid in "${batch[@]}"; do wait "$pid" 2>/dev/null || true; done CHILD_PIDS=() local rows="" count=0 for f in "$workdir"/*; do [ -r "$f" ] || continue line="$(cat "$f" 2>/dev/null)" [ -n "$line" ] || continue rows="${rows}${rows:+,}${line}" count=$((count + 1)) done PROVINCE_ROWS="$rows" PROVINCE_TEST_COUNT="$count" info " 31 省级三网 TCP 测速完成(共采集 ${count} 个节点数据)" } # ------------------------------------------------------------- 三网回程线路 # v2 依赖 nexttrace,而绝大多数机器没装,于是 route 永远是空字符串 —— 评分里 # route 占 20% 权重,一直被当成"缺失"重新分配掉了。backtrace 是个静态二进制, # 一秒出结果,正好补上这一块。 route_rank() { case "$1" in *CN2GIA*|*cn2gia*) printf '4' ;; *9929*) printf '4' ;; *CMIN2*) printf '4' ;; *CN2GT*|*CN2*) printf '3' ;; *CUII*) printf '3' ;; *163*|*4837*|*CMI*) printf '1' ;; *) printf '0' ;; esac } route_conservative() { [ -n "$1" ] || { printf '%s' "$2"; return; } local a b a="$(route_rank "$1")"; b="$(route_rank "$2")" if [ "$b" -lt "$a" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi } test_route() { if [ "$OPT_ROUTE" -ne 1 ]; then info "跳过三网回程检测" return fi local a="" expected_sha="" case "$ARCH" in x86_64|amd64) a=amd64; expected_sha="$BACKTRACE_SHA256_AMD64" ;; aarch64|arm64) a=arm64; expected_sha="$BACKTRACE_SHA256_ARM64" ;; *) warn "backtrace 不支持架构 ${ARCH:-未知},跳过回程检测"; return ;; esac info "三网回程线路检测 ..." ROUTE_TOOL="{\"tool\":\"backtrace\",\"version\":$(jstr "${BACKTRACE_VERSION#v}"),\"sha256\":$(jstr "$expected_sha"),\"status\":\"download\"}" if ! command -v sha256sum >/dev/null 2>&1; then ROUTE_TOOL="{\"tool\":\"backtrace\",\"version\":$(jstr "${BACKTRACE_VERSION#v}"),\"sha256\":$(jstr "$expected_sha"),\"status\":\"unsupported\"}" warn "系统没有 sha256sum,无法校验 backtrace,跳过回程检测" return fi curl -sL --max-time 90 -o "$WORK_DIR/bt.tar.gz" \ "https://github.com/ludashi2020/backtrace/releases/download/${BACKTRACE_VERSION}/backtrace-linux-${a}.tar.gz" \ 2>/dev/null || { ROUTE_TOOL="{\"tool\":\"backtrace\",\"version\":$(jstr "${BACKTRACE_VERSION#v}"),\"sha256\":$(jstr "$expected_sha"),\"status\":\"failed\"}"; warn "backtrace 下载失败,回程线路无数据"; return; } if ! printf '%s %s\n' "$expected_sha" "$WORK_DIR/bt.tar.gz" | sha256sum -c - >/dev/null 2>&1; then ROUTE_TOOL="{\"tool\":\"backtrace\",\"version\":$(jstr "${BACKTRACE_VERSION#v}"),\"sha256\":$(jstr "$expected_sha"),\"status\":\"checksum_failed\"}" warn "backtrace 校验和不匹配,已拒绝执行" return fi ROUTE_TOOL="{\"tool\":\"backtrace\",\"version\":$(jstr "${BACKTRACE_VERSION#v}"),\"sha256\":$(jstr "$expected_sha"),\"status\":\"verified\"}" tar -xf "$WORK_DIR/bt.tar.gz" -C "$WORK_DIR" 2>/dev/null chmod +x "$WORK_DIR/backtrace" 2>/dev/null [ -x "$WORK_DIR/backtrace" ] || { warn "backtrace 解压失败,回程线路无数据"; return; } local out ln tag if command -v timeout >/dev/null 2>&1; then out="$(timeout -k 5 180 "$WORK_DIR/backtrace" 2>&1)" else out="$("$WORK_DIR/backtrace" 2>&1)" fi while IFS= read -r ln; do case "$ln" in *'['*) : ;; *) continue ;; esac tag="$(printf '%s' "$ln" | sed -E 's/.*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+[[:space:]]+//; s/[[:space:]]*\[.*$//; s/[[:space:]]*$//')" [ -n "$tag" ] || continue case "$tag" in 电信*) ROUTE_CT="$(route_conservative "$ROUTE_CT" "$tag")" ;; 联通*) ROUTE_CU="$(route_conservative "$ROUTE_CU" "$tag")" ;; 移动*) ROUTE_CM="$(route_conservative "$ROUTE_CM" "$tag")" ;; esac done <&2 printf '\033[1;36m========================================================================\033[0m\n' >&2 printf ' \033[1;32mNodePilse VPS Bench v%s - 性能量化与基准测试报告\033[0m\n' "$VERSION" >&2 printf '\033[1;36m========================================================================\033[0m\n' >&2 printf ' \033[1;33m系统架构\033[0m : %-26s \033[1;33m虚拟化\033[0m : %s\n' "${OS:-Unknown} (${ARCH:-x86_64})" "${VIRT:-Unknown}" >&2 printf ' \033[1;33mCPU 型号\033[0m : %-26s \033[1;33mCPU 核心\033[0m : %s vCPU\n' "${CPU_MODEL:-Unknown}" "${CPU_CORES:-1}" >&2 printf ' \033[1;33m内存容量\033[0m : %-26s \033[1;33m磁盘容量\033[0m : %s GB (%s)\n' "${RAM_GB:-0} GB" "${DISK_GB:-0}" "${DISK_FS:-ext4}" >&2 printf ' \033[1;33mIP 地理\033[0m : %-26s \033[1;33m自治系统\033[0m : %s\n' "${GEO_CITY:-} ${GEO_COUNTRY:-Unknown}" "${ASN:-Unknown}" >&2 printf '\033[0;34m------------------------------------------------------------------------\033[0m\n' >&2 printf ' \033[1;35m[算力性能]\033[0m\n' >&2 if [ -n "$GB_SINGLE" ]; then printf ' Geekbench 6 单核 : %-12s Geekbench 6 多核 : %s\n' "$GB_SINGLE" "${GB_MULTI:-0}" >&2 fi printf ' Sysbench CPU 单核 : %-12s Sysbench CPU 多核 : %s\n' "${SB_SINGLE:-N/A} Events/s" "${SB_MULTI:-N/A} Events/s" >&2 printf ' CPU SHA256 吞吐 : %-12s 内存顺序写吞吐 : %s\n' "${CPU_HASH:-0} MB/s" "${MEM_WRITE:-0} MB/s" >&2 printf '\033[0;34m------------------------------------------------------------------------\033[0m\n' >&2 printf ' \033[1;35m[磁盘 I/O - fio direct=1]\033[0m\n' >&2 printf ' 4K 随机读 IOPS : %-12s 4K 随机写 IOPS : %s\n' "${IOPS_R:-N/A}" "${IOPS_W:-N/A}" >&2 printf ' 1M 顺序读吞吐 : %-12s 1M 顺序写吞吐 : %s\n' "${SEQ_R:-N/A} MB/s" "${SEQ_W:-N/A} MB/s" >&2 printf '\033[0;34m------------------------------------------------------------------------\033[0m\n' >&2 printf ' \033[1;35m[中国三网延迟与回程优化]\033[0m\n' >&2 printf ' 中国三网综合延迟 : %-12s 综合丢包率 : %s\n' "${LAT_CN:-N/A} ms" "${LOSS_CN:-0}%" >&2 printf ' 电信延迟 : %-8s 联通延迟 : %-8s 移动延迟 : %s\n' "${LAT_CT:-N/A} ms" "${LAT_CU:-N/A} ms" "${LAT_CM:-N/A} ms" >&2 printf ' 回程线路 : %s\n' "${ROUTE:-常规骨干网}" >&2 if [ -n "$DOWNLOAD" ]; then printf ' 公网带宽 : 下行 %s Mbps / 上行 %s Mbps (%s)\n' "$DOWNLOAD" "${UPLOAD:-0}" "${ST_ISP:-Speedtest}" >&2 fi printf '\033[1;36m========================================================================\033[0m\n' >&2 } # ------------------------------------------------------------------ 结果输出 build_net_tests() { local out="" label ping down up canon primary [ -s "$NET_ROWS" ] || return 0 while IFS="$(printf '\t')" read -r label ping down up canon; do [ -n "$label" ] || continue if [ "$canon" = "1" ]; then primary=true; else primary=false; fi out="${out}${out:+,}{\"node\":$(jstr "$label"),\"ping_ms\":$(jnum "$ping"),\"download_mbps\":$(jnum "$down"),\"upload_mbps\":$(jnum "$up"),\"primary\":${primary}}" done < "$NET_ROWS" printf '%s' "$out" } JSON_OUT="" emit_json() { [ "$JSON_EMITTED" -eq 0 ] || return 0 JSON_EMITTED=1 local mode="full" [ "$OPT_FAST" -eq 1 ] && mode="fast" JSON_OUT="$(cat </dev/null)" code="$(printf '%s' "$response" | tail -1)" if [ "$code" = "201" ] || [ "$code" = "200" ]; then report_url="$(printf '%s' "$response" | sed '$d' | grep -oE '"url":"[^"]+"' | head -1 | cut -d'"' -f4)" info "提交成功" SUBMIT_SUCCESS=1 if [ -n "$report_url" ]; then printf 'Permanent report: %s\n' "$report_url" >&2 fi else SUBMIT_SUCCESS=0 warn "自动提交失败(HTTP ${code:-000}):$(printf '%s' "$response" | sed '$d')" fi return 0 fi if [ -n "$SUBMIT_ID" ]; then warn "--submit 需要环境变量 NODEPILSE_TOKEN(登录后在个人页获取),已跳过提交" fi } cleanup_children() { local pid for pid in "${CHILD_PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done for pid in "${CHILD_PIDS[@]}"; do wait "$pid" 2>/dev/null || true; done CHILD_PIDS=() } cleanup_files() { local file for file in "${TEMP_FILES[@]}"; do rm -f -- "$file" 2>/dev/null || true; done TEMP_FILES=() } cleanup() { local rc=$? if [ "$MAIN_STARTED" -ne 1 ]; then # -h / 参数错误等在 main 之前退出:只清理,不往 stdout 吐 interrupted JSON cleanup_children cleanup_files trap '' EXIT INT TERM exit "$rc" fi if [ "$rc" -ne 0 ] || [ "$BENCH_STATUS" != "completed" ]; then BENCH_STATUS="interrupted" INTERRUPTED=1 warn "测试被中断,结果仅作诊断,不可提交" fi cleanup_children cleanup_files trap '' EXIT INT TERM emit_json rm -rf "$WORK_DIR" 2>/dev/null exit "$rc" } interrupt_bench() { # Let the EXIT trap emit the interrupted JSON and clean every registered # child/temp file, while preserving the conventional signal exit code. case "${1:-INT}" in TERM) exit 143 ;; *) exit 130 ;; esac } # ---------------------------------------------------------------------- main main() { MAIN_STARTED=1 info "NodePilse VPS Bench v${VERSION}" if [ "$OPT_FAST" -eq 1 ]; then info "快速模式,预计 1-2 分钟" elif [ "$OPT_GEEKBENCH" = "1" ]; then info "完整模式 + Geekbench,预计 15-25 分钟" else info "完整模式,预计 3-5 分钟" fi [ "$(id -u)" = "0" ] || info "提示:非 root 运行,缺失依赖无法自动安装" detect_pkg collect_sysinfo info "${OS} | ${CPU_MODEL} × ${CPU_CORES} | ${RAM_GB}GB | ${DISK_GB}GB | ${VIRT}" run_test test_cpu run_test test_memory run_test test_ip_reputation run_test test_dns_quality run_test test_disk run_test test_network run_test test_china_cdn run_test test_cn_latency run_test test_province_latency run_test test_route run_test test_streaming run_test test_geekbench run_test test_extended_reports BENCH_STATUS="completed" cleanup_children cleanup_files trap '' EXIT INT TERM print_ascii_summary emit_json submit_result if [ -n "$AUTH_TOKEN" ]; then if [ "$SUBMIT_SUCCESS" -eq 1 ]; then info "完成,耗时 $(( $(date +%s) - START_TS )) 秒。报告已自动提交。" else info "测试已完成,耗时 $(( $(date +%s) - START_TS )) 秒。自动提交未成功,可复制上方 JSON 至 ${API_BASE} 手动导入。" fi else info "完成,耗时 $(( $(date +%s) - START_TS )) 秒。把上面的 JSON 贴到 ${API_BASE} 提交即可。" fi rm -rf "$WORK_DIR" 2>/dev/null } trap cleanup EXIT trap 'interrupt_bench INT' INT trap 'interrupt_bench TERM' TERM if [ "$OPT_EXTENDED" -eq 1 ] && [ "${NODEPILSE_CONFIRM_EXTENDED:-}" != "yes" ]; then if [ -t 0 ]; then printf '扩展报告会下载并执行第三方脚本(低权限、有限资源),是否继续?[y/N] ' >&2 answer="" read -r answer case "$answer" in y|Y|yes|YES) ;; *) OPT_EXTENDED=0; printf '未确认第三方脚本,已跳过扩展报告\n' >&2 ;; esac else OPT_EXTENDED=0 printf '非交互模式未设置 NODEPILSE_CONFIRM_EXTENDED=yes,已跳过扩展报告\n' >&2 fi fi main