#!/usr/bin/env bash # # TcpQuality 节点 TCP 丢包持续滚动监测脚本 (本地化低频看板版) # set -e # ===================== NixOS 临时运行环境 ===================== is_nixos() { [ -e /etc/NIXOS ] || { [ -r /etc/os-release ] && grep -Eq '^ID=(nixos|"nixos")$' /etc/os-release } } bootstrap_nixos_environment() { local arg need_speedtest=0 temp_script local -a nix_packages is_nixos || return 0 [ "${TCPQUALITY_NIX_BOOTSTRAPPED:-0}" -eq 1 ] && return 0 for arg in "$@"; do case "$arg" in -h|--help) return 0 ;; esac done if ! command -v nix >/dev/null 2>&1; then echo '[X] 当前系统是 NixOS,但没有找到 nix命令。' >&2 exit 1 fi if [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then echo '[X] 裸 TCP SYN 探测需要 root;请使用 root 运行,或先启用 sudo。' >&2 exit 1 fi temp_script=$(mktemp /tmp/tcpquality-nixos.XXXXXX.sh) cat "$0" > "$temp_script" chmod 0755 "$temp_script" trap 'rm -f -- "$temp_script"' EXIT nix_packages=( nixpkgs#bash nixpkgs#coreutils nixpkgs#curl nixpkgs#findutils nixpkgs#gawk nixpkgs#gnugrep nixpkgs#gnused nixpkgs#iproute2 nixpkgs#iputils nixpkgs#jq nixpkgs#kmod nixpkgs#ncurses nixpkgs#nmap nixpkgs#traceroute ) echo '[i] NixOS:正在进入临时 Nix 环境(不会修改 systemPackages)...' if [ "$(id -u)" -eq 0 ]; then exec env \ TCPQUALITY_NIX_BOOTSTRAPPED=1 \ TCPQUALITY_NIX_TEMP_SCRIPT="$temp_script" \ NIXPKGS_ALLOW_UNFREE=1 \ nix --extra-experimental-features 'nix-command flakes' \ shell --impure "${nix_packages[@]}" \ --command bash "$temp_script" "$@" fi exec env NIXPKGS_ALLOW_UNFREE=1 \ nix --extra-experimental-features 'nix-command flakes' \ shell --impure "${nix_packages[@]}" \ --command bash -c ' exec sudo env \ "PATH=$PATH" \ "TERM=${TERM:-dumb}" \ "LANG=${LANG:-C.UTF-8}" \ "GET_NODES_URL=${GET_NODES_URL:-}" \ TCPQUALITY_NIX_BOOTSTRAPPED=1 \ "TCPQUALITY_NIX_TEMP_SCRIPT=$1" \ NIXPKGS_ALLOW_UNFREE=1 \ bash "$1" "${@:2}" ' bash "$temp_script" "$@" } bootstrap_nixos_environment "$@" # ===================== 颜色与基础变量定义 ===================== RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; MAGENTA='\033[0;35m' WHITE='\033[1;37m'; BOLD='\033[1m'; DIM='\033[2m' UNDERLINE='\033[4m' NC='\033[0m' USE_SUDO="" SOURCE_MAC="" DEST_MAC="" IPV4_PUBLIC="" IPV6_PUBLIC="" IPV4_WORK=0 IPV6_WORK=0 GET_NODES_URL="${GET_NODES_URL:-https://tcpquality.ibsgss.uk/getNodes}" REMOTE_NODES_LOADED=0 REMOTE_CDN4_NODES=() REMOTE_CDN6_NODES=() REMOTE_CERNET_NODES=() REMOTE_CERNET2_NODES=() PACKETS=1 PACKET_SIZE_OVERRIDE="0" PACKET_SIZES=(40 80 160 320 640 1200) TOTAL=0 PARALLEL=16 TEST_CERNET=0 TEST_ALL=0 ONLY_IPV4=0 ONLY_IPV6=0 SELECTED_PROVINCES="" DEBUG_MODE=0 RESULT_DIR=$(mktemp -d) cleanup_result_dir() { rm -rf "$RESULT_DIR" case "${TCPQUALITY_NIX_TEMP_SCRIPT:-}" in /tmp/tcpquality-nixos.*.sh) rm -f -- "$TCPQUALITY_NIX_TEMP_SCRIPT" ;; esac } trap cleanup_result_dir EXIT # ===================== 依赖与权限、初始化检查 ===================== init_privilege() { USE_SUDO="" if [ "$(uname)" != "Darwin" ] && [ "$(id -u)" -ne 0 ]; then if command -v sudo &>/dev/null; then USE_SUDO="sudo"; fi fi } show_dependency_install_notice() { echo -ne "\r${YELLOW}[!] 检测到未安装的依赖,正在安装...${NC}"; } clear_dependency_install_notice() { printf '\r\033[2K'; } install_with_package_manager() { local dep="$1" apt_pkg="$2" dnf_pkg="$3" yum_pkg="$4" apk_pkg="$5" pacman_pkg="$6" brew_pkg="$7" if is_nixos; then return 1; fi if [ "$(uname)" != "Darwin" ] && [ "$(id -u)" -ne 0 ] && [ -z "$USE_SUDO" ]; then exit 1; fi if command -v apt-get &>/dev/null; then $USE_SUDO apt-get update -qq >/dev/null 2>&1 || true $USE_SUDO apt-get install -y -qq "$apt_pkg" >/dev/null 2>&1 || return 1 elif command -v dnf &>/dev/null; then $USE_SUDO dnf install -y -q "$dnf_pkg" >/dev/null 2>&1 || return 1 elif command -v yum &>/dev/null; then $USE_SUDO yum install -y -q "$yum_pkg" >/dev/null 2>&1 || return 1 else return 1; fi } check_command() { local cmd="$1" desc="$2" apt_pkg="$3" dnf_pkg="$4" yum_pkg="$5" apk_pkg="$6" pacman_pkg="$7" brew_pkg="$8" if command -v "$cmd" &>/dev/null; then return 0; fi show_dependency_install_notice if install_with_package_manager "$desc" "$apt_pkg" "$dnf_pkg" "$yum_pkg" "$apk_pkg" "$pacman_pkg" "$brew_pkg" && command -v "$cmd" &>/dev/null; then clear_dependency_install_notice else exit 1; fi } check_curl() { check_command curl curl curl curl curl curl curl curl; } check_nping() { if command -v nping &>/dev/null; then return 0; fi show_dependency_install_notice install_with_package_manager nping nmap nmap nmap nmap nmap nmap || true if command -v nping &>/dev/null; then clear_dependency_install_notice; else exit 1; fi } require_raw_socket_privilege() { if [ "$(id -u)" -ne 0 ]; then exit 1; fi; } # ===================== 省份与节点加载功能 ===================== province_from_code() { local code="$1" code=$(printf "%s" "$code" | tr '[:upper:]' '[:lower:]') code=${code#-} case "$code" in he|河北) echo "河北" ;; sx|山西) echo "山西" ;; ln|辽宁) echo "辽宁" ;; jl|吉林) echo "吉林" ;; hl|黑龙江) echo "黑龙江" ;; js|江苏) echo "江苏" ;; zj|浙江) echo "浙江" ;; ah|安徽) echo "安徽" ;; fj|福建) echo "福建" ;; jx|江西) echo "江西" ;; sd|山东) echo "山东" ;; ha|河南) echo "河南" ;; hb|湖北) echo "湖北" ;; hn|湖南) echo "湖南" ;; gd|广东) echo "广东" ;; hi|海南) echo "海南" ;; sc|四川) echo "四川" ;; gz|贵州) echo "贵州" ;; yn|云南) echo "云南" ;; sn|陕西) echo "陕西" ;; gs|甘肃) echo "甘肃" ;; qh|青海) echo "青海" ;; nm|内蒙古) echo "内蒙古" ;; gx|广西) echo "广西" ;; xz|西藏) echo "西藏" ;; nx|宁夏) echo "宁夏" ;; xj|新疆) echo "新疆" ;; bj|北京) echo "北京" ;; tj|天津) echo "天津" ;; sh|上海) echo "上海" ;; cq|重庆) echo "重庆" ;; *) return 1 ;; esac } add_province_filter() { local province province=$(province_from_code "$1") || return 1 case "$SELECTED_PROVINCES" in *"|$province|"*) ;; *) SELECTED_PROVINCES="${SELECTED_PROVINCES}|${province}|" ;; esac } province_selected() { [ -z "$SELECTED_PROVINCES" ] || [[ "$SELECTED_PROVINCES" == *"|$1|"* ]]; } province_filter_text() { if [ -z "$SELECTED_PROVINCES" ]; then echo "全国"; else printf "%s" "$SELECTED_PROVINCES" | sed 's/^|//; s/|$//; s/||/、/g; s/|/、/g'; fi; } count_cdn_nodes() { local family="${1:-4}" entry prov isp host fixed_ip port count=0 local -a remote_nodes=() if [ "$family" = "6" ]; then remote_nodes=("${REMOTE_CDN6_NODES[@]}"); else remote_nodes=("${REMOTE_CDN4_NODES[@]}"); fi for entry in "${remote_nodes[@]}"; do IFS='|' read -r prov isp host fixed_ip port <<< "$entry" province_selected "$prov" && count=$((count + 1)) done echo "$count" } count_cernet_nodes() { local entry prov host ip port count=0; for entry in "${REMOTE_CERNET_NODES[@]}"; do IFS='|' read -r prov host ip port <<< "$entry"; province_selected "$prov" && count=$((count + 1)); done; echo "$count"; } count_cernet2_nodes() { local entry prov host ip port count=0; for entry in "${REMOTE_CERNET2_NODES[@]}"; do IFS='|' read -r prov host ip port <<< "$entry"; province_selected "$prov" && count=$((count + 1)); done; echo "$count"; } load_remote_nodes() { local scope="${1:-cdn}" tmp url sep tmp=$(mktemp) sep="?"; [[ "$GET_NODES_URL" == *"?"* ]] && sep="&" url="${GET_NODES_URL}${sep}format=tsv&scope=${scope}" if ! curl -fsSL --connect-timeout 5 --max-time 30 "$url" > "$tmp" 2>/dev/null; then rm -f "$tmp"; return 1; fi while IFS=$'\t' read -r type family prov isp host ip port target backup_host backup_ip backup_port backup_target; do [ "$type" = "type" ] && continue [ -n "$ip" ] || continue port=${port:-80} case "$type:$family" in cdn:4) REMOTE_CDN4_NODES+=("$prov|$isp|$host|$ip|$port|$backup_host|$backup_ip|${backup_port:-80}") ;; cdn:6) REMOTE_CDN6_NODES+=("$prov|$isp|$host|$ip|$port|$backup_host|$backup_ip|${backup_port:-80}") ;; cernet:4) REMOTE_CERNET_NODES+=("$prov|$host|$ip|$port|$backup_host|$backup_ip|${backup_port:-443}") ;; cernet2:6) REMOTE_CERNET2_NODES+=("$prov|$host|$ip|$port|$backup_host|$backup_ip|${backup_port:-443}") ;; esac done < "$tmp" rm -f "$tmp" return 0 } require_remote_nodes() { load_remote_nodes "all" || exit 1; } print_cdn_entries() { if [ "$1" = "6" ]; then printf "%s\n" "${REMOTE_CDN6_NODES[@]}"; else printf "%s\n" "${REMOTE_CDN4_NODES[@]}"; fi; } print_cernet_entries() { printf "%s\n" "${REMOTE_CERNET_NODES[@]}"; } print_cernet2_entries() { printf "%s\n" "${REMOTE_CERNET2_NODES[@]}"; } # ===================== 原生表格渲染工具函数 ===================== print_header() { return 0; } loss_color() { local v=$(awk -v x="$1" 'BEGIN { printf "%d", x }' 2>/dev/null || echo 0) if [ "$v" -eq 0 ]; then echo -n "${GREEN}$1%${NC}"; elif [ "$v" -le 20 ]; then echo -n "${YELLOW}$1%${NC}"; else echo -n "${RED}$1%${NC}"; fi } show_provider_summary() { local file="$1" awk -F'|' -v green="$GREEN" -v yellow="$YELLOW" -v red="$RED" -v cyan="$CYAN" -v white="$WHITE" -v dim="$DIM" -v bold="$BOLD" -v nc="$NC" ' BEGIN { label_w = 10; route_w = 11; latency_w = 6; loss_w = 6; summary_cell_w = 25 } function display_width(text) { if (text == "黑龙江" || text == "内蒙古") return 6; return 4; } function label_cell(text, pad) { pad = label_w - display_width(text); return text sprintf("%*s", (pad<0?0:pad), ""); } function format_summary_cell(label, latency, loss, lat_c, loss_c) { return white sprintf("%*s", route_w, label) nc " " lat_c sprintf("%*s", latency_w, latency) nc " " loss_c sprintf("%*s", loss_w, loss) nc; } { status=$1; prov=$2; isp=$3; loss=$8; lat=$9; if (!(prov in seen)) { seen[prov] = 1; order[++n] = prov; } if (status != "OK") { data[prov SUBSEP isp] = format_summary_cell(isp, "failed", "failed", red, red); } else { lat_text = sprintf("%.0fms", lat); loss_text = int(loss + 0.5) "%"; lc = (loss+0 > 20)?red:((loss+0 > 0)?yellow:green); ptc = (lat+0 > 240)?red:((lat+0 > 150)?yellow:green); data[prov SUBSEP isp] = format_summary_cell(isp, lat_text, loss_text, ptc, lc); } } END { printf " %s%s%s%s %s%s%s %s/ %s%s%s %s/ %s%s%s\n", bold, cyan, label_cell("三网概览"), nc, cyan, sprintf("%*s", 17, "电信"), nc, white, cyan, sprintf("%*s", 24, "联通"), nc, white, cyan, sprintf("%*s", 24, "移动"), nc for (i = 1; i <= n; i++) { prov = order[i]; printf " %s%s%s %s %s/ %s %s/ %s\n", cyan, label_cell(prov), nc, data[prov SUBSEP "电信"], white, data[prov SUBSEP "联通"], white, data[prov SUBSEP "移动"] } printf " %s颜色: %s正常%s %s延迟>150ms或有丢包%s %s失败或大范围丢包%s\n\n", dim, green, dim, yellow, dim, red, dim }' "$file" } show_family_results() { printf " \033[1m\033[0;36m%s 统计摘要\033[0m\n\n" "$1"; show_provider_summary "$2"; } # ===================== 网络栈探测底层核心 ===================== get_ipv6_route() { local target="$1" route_info iface source_ip next_hop source_mac dest_mac if command -v ip &>/dev/null; then route_info=$(ip -6 route get "$target" 2>/dev/null | head -1) iface=$(printf "%s\n" "$route_info" | awk '{for (i=1; i<=NF; i++) if ($i=="dev") {print $(i+1); exit}}') source_ip=$(printf "%s\n" "$route_info" | awk '{for (i=1; i<=NF; i++) if ($i=="src") {print $(i+1); exit}}') next_hop=$(printf "%s\n" "$route_info" | awk '{for (i=1; i<=NF; i++) if ($i=="via") {print $(i+1); exit}}') next_hop=${next_hop:-$target} source_mac=$(ip link show dev "$iface" 2>/dev/null | awk '/link\/ether/ {print $2; exit}') dest_mac=$(ip -6 neigh show "$next_hop" dev "$iface" 2>/dev/null | awk '/lladdr/ {for (i=1; i<=NF; i++) if ($i=="lladdr") {print $(i+1); exit}}') fi if [[ "$source_ip" =~ : ]] && [ -n "$iface" ]; then printf "%s|%s|%s|%s\n" "$iface" "$source_ip" "$source_mac" "$dest_mac"; return 0; fi return 1 } is_public_ipv4() { return 0; } detect_ip_stack() { IPV4_WORK=1; IPV6_WORK=1; ipv4_available() { return 0; }; ipv6_available() { return 0; }; } ipv4_available() { [ "$IPV4_WORK" -eq 1 ]; } ipv6_available() { [ "$IPV6_WORK" -eq 1 ]; } probe_target() { local group="$1" family="$2" prov="$3" isp="$4" host="$5" ip="$6" port="${7:-80}" idx="${8:-0}" label="${9:-main}" if [ -z "$ip" ]; then echo "FAIL|$prov|$isp|$host|0|0|0|100.00|0"; return; fi local -a nping_base_args=(--tcp -p "$port" --flags syn -c 1) if [ "$family" = "6" ]; then local route_data if route_data=$(get_ipv6_route "$ip"); then IFS='|' read -r iface source_ip source_mac dest_mac <<< "$route_data" nping_base_args=(-6 -e "$iface" -S "$source_ip" --source-mac "$source_mac" --dest-mac "$dest_mac" "${nping_base_args[@]}") else nping_base_args=(-6 "${nping_base_args[@]}"); fi fi local raw=$(nping "${nping_base_args[@]}" "$ip" 2>&1 || true) local one_sent=$(printf "%s\n" "$raw" | sed -nE 's/.*sent:[[:space:]]*([0-9]+).*/\1/p' | head -1) local one_rcvd=$(printf "%s\n" "$raw" | sed -nE 's/.*Rcvd:[[:space:]]*([0-9]+).*/\1/p' | head -1) local one_rtt=$(printf "%s\n" "$raw" | sed -nE 's/.*Avg rtt:[[:space:]]*([0-9.]+).*/\1/p' | head -1) if [ "$one_rcvd" -gt 0 ] 2>/dev/null && [[ "$one_rtt" =~ ^[0-9]+([.][0-9]+)?$ ]]; then echo "OK|$prov|$isp|$host|$ip|1|1|0.00|$one_rtt" else echo "FAIL|$prov|$isp|$host|$ip|1|0|100.00|0" fi } # ===================== 参数解析模块 ===================== parse_args() { while [ $# -gt 0 ]; do case "$1" in -v4|--v4) ONLY_IPV4=1; shift ;; -v6|--v6) ONLY_IPV6=1; shift ;; --cernet) TEST_CERNET=1; shift ;; --all) TEST_ALL=1; shift ;; --province) add_province_filter "$2"; shift 2 ;; -??|-???) if add_province_filter "$1"; then shift; else shift; fi ;; *) shift ;; esac done } # ===================== 全局历史累加数据集 ===================== declare -A CUM_SENT declare -A CUM_RCVD declare -A CUM_LAT_SUM redraw_dashboard() { local snap_v4=$(mktemp) snap_v6=$(mktemp) for key in "${!CUM_SENT[@]}"; do IFS='|' read -r family f_prov f_isp <<< "$key" local sent=${CUM_SENT["$key"]} rcvd=${CUM_RCVD["$key"]} lat_sum=${CUM_LAT_SUM["$key"]:-0} local loss_pct="100.00" avg_lat="0.00" status="FAIL" if [ "$sent" -gt 0 ]; then loss_pct=$(awk -v s="$sent" -v r="$rcvd" 'BEGIN { printf "%.2f", (s - r) * 100 / s }'); fi if [ "$rcvd" -gt 0 ]; then avg_lat=$(awk -v sum="$lat_sum" -v r="$rcvd" 'BEGIN { printf "%.3f", sum / r }'); status="OK"; fi local line="$status|$f_prov|$f_isp|host|ip|$sent|$rcvd|$loss_pct|$avg_lat" if [ "$family" = "4" ]; then echo "$line" >> "$snap_v4"; else echo "$line" >> "$snap_v6"; fi done printf "\033[H\033[J" echo -e "${BOLD}${CYAN}三网云端节点 — 实时多维度历史累计监测画布 (按 Ctrl+C 退出)${NC}" echo -e "${DIM}当前统计周期: 第 $ROUND_COUNT 轮全面盘点 | 发包策略: 线性低频匀速错峰${NC}" echo "----------------------------------------------------------------------" if [ -s "$snap_v4" ]; then sort -t'|' -k2 "$snap_v4" -o "$snap_v4"; show_family_results "IPv4" "$snap_v4"; fi if [ -s "$snap_v6" ]; then sort -t'|' -k2 "$snap_v6" -o "$snap_v6"; show_family_results "IPv6" "$snap_v6"; fi rm -f "$snap_v4" "$snap_v6" } # ===================== 完美的 TUI 持续监控主流程 ===================== main() { init_privilege require_raw_socket_privilege check_curl require_remote_nodes check_nping detect_ip_stack local ipv4_enabled=0 ipv6_enabled=0 test_cdn=1 test_edu=0 want_ipv4=1 want_ipv6=1 if [ "$TEST_ALL" -eq 1 ]; then want_ipv4=1; want_ipv6=1; elif [ "$ONLY_IPV4" -eq 1 ] && [ "$ONLY_IPV6" -eq 0 ]; then want_ipv6=0; elif [ "$ONLY_IPV6" -eq 1 ] && [ "$ONLY_IPV4" -eq 0 ]; then want_ipv4=0; fi if [ "$want_ipv4" -eq 1 ] && ipv4_available; then ipv4_enabled=1; fi if [ "$want_ipv6" -eq 1 ] && ipv6_available; then ipv6_enabled=1; fi if [ "$TEST_CERNET" -eq 1 ] && [ "$TEST_ALL" -eq 0 ]; then test_cdn=0; test_edu=1; elif [ "$TEST_CERNET" -eq 1 ] || [ "$TEST_ALL" -eq 1 ]; then test_edu=1; fi local -a families=() if [ "$test_cdn" -eq 1 ]; then if [ "$ipv4_enabled" -eq 1 ]; then families+=(4); fi if [ "$ipv6_enabled" -eq 1 ]; then families+=(6); fi fi PACKETS=1 local NODE_DELAY=0.05 local -a NODES_QUEUE=() if [ "$test_cdn" -eq 1 ]; then for family in "${families[@]}"; do while IFS='|' read -r prov isp host fixed_ip port backup_host backup_ip backup_port; do province_selected "$prov" || continue NODES_QUEUE+=("$family|$prov|$isp|$host|$fixed_ip|${port:-80}") done < <(print_cdn_entries "$family") done fi if [ ${#NODES_QUEUE[@]} -eq 0 ]; then echo -e "${RED}[X] 未找到任何探测节点${NC}"; exit 1; fi ROUND_COUNT=0 while true; do ROUND_COUNT=$((ROUND_COUNT + 1)) for node_entry in "${NODES_QUEUE[@]}"; do IFS='|' read -r family prov isp host fixed_ip port <<< "$node_entry" local res=$(probe_target "cdn${family}" "$family" "$prov" "$isp" "$host" "$fixed_ip" "$port" 0 main) local status r_prov r_isp r_host r_ip r_sent r_rcvd r_loss r_lat IFS='|' read -r status r_prov r_isp r_host r_ip r_sent r_rcvd r_loss r_lat <<< "$res" if [ "$status" = "OK" ] || [ "$status" = "FAIL" ]; then local cache_key="$family|$prov|$isp" CUM_SENT["$cache_key"]=$(( CUM_SENT["$cache_key"] + r_sent )) CUM_RCVD["$cache_key"]=$(( CUM_RCVD["$cache_key"] + r_rcvd )) if [ "$status" = "OK" ] && [ "$r_rcvd" -gt 0 ]; then CUM_LAT_SUM["$cache_key"]=$(awk -v sum="${CUM_LAT_SUM["$cache_key"]:-0}" -v lat="$r_lat" 'BEGIN { print sum + lat }') fi fi redraw_dashboard sleep "$NODE_DELAY" done done } parse_args "$@" main