DocsByRcDocsByRc
IP 检测脚本
工具脚本

IP 检测脚本

VPS / 落地节点 IP 检测:本机 NodeQuality 一体化体检 + 经代理的落地 IP 纯净度检测。

适用VPS / 落地节点
难度简单
阅读—

ip-test.sh 是 VPS / 落地节点的 IP 检测工具(Docker 版,宿主机零残留):本机跑 NodeQuality 一体化体检(硬件/IP/网络/回程路由),落地节点经代理做 IP 纯净度检测。

两大功能
  • 本机质量检测——NodeQuality 四项体检,生成可分享报告链接
  • 落地 IP 检测——经 SOCKS5/HTTP 代理检测出口 IP 质量
凭据保护:代理密码用配置文件挂载进容器,不出现在命令行;临时文件 shred 覆写删除;检测到 bash -x 直接中止。

下载脚本

交互式菜单:

BASH
curl -fsSL https://docs.232777.xyz/assets/ip-test.sh -o ip-test.sh
chmod +x ip-test.sh
./ip-test.sh

常用参数:

BASH
./ip-test.sh --purge       # 彻底清除(删镜像+依赖)
./ip-test.sh --purge -y    # 连 Docker 一并卸载
./ip-test.sh --no-install  # 缺依赖时只报错不自动装

落地检测支持多种连接字符串格式(粘贴即可自动识别):socks5://user:pass@host:port、user:pass@host:port、host:port:user:pass 四段式等。

完整脚本共 1300+ 行,按功能分段折叠展示,点击展开查看:

第 1 段:头部注释与初始化
BASH
#!/usr/bin/env bash
#
# ip-test.sh — VPS / 落地节点 IP 检测工具(Docker 版,宿主机零残留)
#
# 菜单结构:
#   1) 本机质量检测(NodeQuality 一体化体检:硬件 / IP / 网络 / 回程路由)
#        1) 完整检测模式        四项全跑,跑完给出可分享报告链接
#        2) 硬件质量体检        HardwareQuality
#        3) IP 质量体检         IPQuality
#        4) 网络质量体检        NetQuality
#        5) 回程路由检测        Backroute Trace
#   2) 落地 IP 检测(经代理,默认执行 IP 纯净度检测)
#        1) 粘贴连接字符串(自动识别格式)
#        2) 手动输入 IP / 端口 / 账号 / 密码
#   3) 彻底清除脚本(删除镜像与依赖,把宿主机恢复到未安装状态)
#   4) 退出
#
# 为什么落地检测里没有回程路由:SOCKS5/HTTP 代理只承载 TCP/UDP,不承载 ICMP。
# 路由追踪经代理时要么失败、要么走宿主机真实线路(结果无效且暴露宿主机 IP)。
#
# 本机质量检测在一次性容器里跑 NodeQuality(宿主机零污染)。注意:容器内测硬盘 I/O
# 会受 overlay 文件系统影响,仅供参考;要精确硬盘数据请直接在宿主机/VM 里跑。
#
# 镜像与依赖默认【保留】,只有走菜单第 3 项或 --purge 才会全部删除。
#
# 连接字符串支持的格式(自动识别,方括号内可省略):
#   socks5://user:pass@host:port      socks5h:// / http:// / https:// 同理
#   socks5://host:port                免认证
#   user:pass@host:port               无 scheme,默认按 socks5 处理
#   host:port@user:pass               反向 @ 写法
#   host:port:user:pass               机场/代理商常见的四段式
#   user:pass:host:port               四段式的另一种顺序
#   host:port                         免认证
#   还能自动剥掉 curl -x / --proxy / ALL_PROXY= / export 这类前缀和包裹的引号。
#   URL 形式里的 %XX 会解码;四段式里的 % 视为密码原文(不解码)。
#   密码里含 : 或 @ 也能正确切分(按最后一个 @ 和位置特征判断)。
#   ss:// vmess:// vless:// trojan:// 等不是 HTTP/SOCKS 代理,会明确拒绝。
#
# 凭据保护要点("确保信息不泄露"):
#   - 密码 / 连接字符串用 read -rs 读入,终端不回显,也不进 shell history
#   - 上游账密【绝不】出现在任何命令行参数里 —— 而是写进 gost 配置文件挂载进容器。
#     原因:容器进程在宿主机 `ps aux` 里能看到完整 cmdline,`docker inspect` 和
#     /var/lib/docker/containers/*/config.v2.json 里也会明文常驻。
#   - 配置文件放在 mktemp -d 建的 700 目录里,退出时 shred 覆写删除
#   - 传给检测容器的代理变量指向本地无认证桥接,本身不含任何密码
#   - 解析结果回显时密码只显示位数,不显示内容
#   - 检测到 set -x / bash -x 会直接中止(否则密码会被 xtrace 打到终端)
#   - 关闭 core dump,避免密码随核心转储落盘
#
# 用法:
#   ./ip-test.sh                  交互式菜单
#   ./ip-test.sh --purge          非交互:彻底清除(镜像 + 依赖)后退出;
#                                 默认不动 Docker,加 -y 才连 Docker 一并卸载
#   ./ip-test.sh --purge -y       非交互彻底清除,并自动确认卸载 Docker
#   ./ip-test.sh --no-install     缺依赖时只报错、不自动安装
#
# 上游脚本地址/参数如有变动,用环境变量覆盖即可,无需改脚本:
#   URL_IPQUALITY / NQ_URL                             下载地址(落地 IPQuality / NodeQuality)
#   IPQ_ARGS                                           传给落地 IPQuality 的额外参数
#   GOST_IMAGE / RUNNER_IMAGE                          桥接镜像 / 跑检测的基础镜像
# 例:IPQ_ARGS='-l en' ./ip-test.sh
#     NQ_URL=https://run.nodequality.com ./ip-test.sh
#
set -euo pipefail

# ── 硬性前置:xtrace 下会泄露密码,直接拒绝运行 ─────────────────────────
case $- in
  *x*) echo "检测到 set -x / bash -x:密码会被 xtrace 打印出来,已中止。" >&2; exit 1 ;;
esac

umask 077
ulimit -c 0 2>/dev/null || true   # 禁止 core dump,防止密码落盘

# ── 可覆盖的镜像 ───────────────────────────────────────────────────────
GOST_IMAGE=${GOST_IMAGE:-gogost/gost:latest}
RUNNER_IMAGE=${RUNNER_IMAGE:-debian:stable-slim}

# ── 检测脚本地址(上游改路径时改这里或用环境变量覆盖)──────────────────
URL_IPQUALITY=${URL_IPQUALITY:-https://raw.githubusercontent.com/xykt/IPQuality/main/ip.sh}
# NodeQuality 一体化体检脚本(官方一键入口,等价于 bash <(curl -sL 该地址))
NQ_URL=${NQ_URL:-https://run.nodequality.com}

RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
BLUE=$'\033[0;34m'; CYAN=$'\033[0;36m'; DIM=$'\033[2m'; NC=$'\033[0m'

NAME_PREFIX='iptest-bridge-'
BRIDGE_NAME=''
WORKDIR=''
LOCAL_PORT=''
AUTO_INSTALL=1
PURGE_ONLY=0
ASSUME_YES=0
INSTALLED_PKGS=()

# 一键导出的报告文件路径(本轮会话里生成的都记在这,脚本彻底退出时自动删除,
# 不给宿主机留任何残留;会话过程中随时可以打开/复制,退出后才清掉)。
EXPORTED_REPORTS=()

# 记录“本脚本亲手装的东西”,供「彻底清除」时精确回滚(只删我们装的,不碰你原有的)。
# 位置可用 IPTEST_STATE 覆盖(默认 /var/lib/ip-test)。
STATE_DIR=${IPTEST_STATE:-/var/lib/ip-test}
MANIFEST="$STATE_DIR/installed.list"
record_line() {
  mkdir -p "$STATE_DIR" 2>/dev/null || return 0
  if [[ -f $MANIFEST ]] && grep -qxF -- "$1" "$MANIFEST" 2>/dev/null; then
    return 0
  fi
  printf '%s\n' "$1" >> "$MANIFEST" 2>/dev/null || true
}

# 解析结果(全局,供 gost 配置生成使用)
PROXY_PROTO=''
PROXY_HOST=''
PROXY_PORT=''
PROXY_USER=''
PROXY_PASS=''

for arg in "$@"; do
  case $arg in
    --purge)      PURGE_ONLY=1 ;;
    -y|--yes)     ASSUME_YES=1 ;;
    --no-install) AUTO_INSTALL=0 ;;
    -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
    *) echo "未知参数:$arg(试试 --help)" >&2; exit 1 ;;
  esac
done

die()  { echo "${RED}[错误] $*${NC}" >&2; exit 1; }
# soft:只中止当前这一项,回到菜单,不杀掉整个脚本
soft() { echo "${RED}[错误] $*${NC}" >&2; return 1; }

# ── 每轮落地检测后的临时资源清理(凭据 + 桥接容器),镜像不动 ────────────
teardown_bridge() {
  stty echo 2>/dev/null || true
  if [[ -n $BRIDGE_NAME ]]; then
    docker rm -f "$BRIDGE_NAME" >/dev/null 2>&1 || true
    BRIDGE_NAME=''
  fi
  if [[ -n $WORKDIR && -d $WORKDIR ]]; then
    command -v shred >/dev/null 2>&1 && \
      find "$WORKDIR" -type f -exec shred -uzn1 {} + 2>/dev/null || true
    rm -rf -- "$WORKDIR" 2>/dev/null || true
    WORKDIR=''
  fi
  PROXY_PROTO=''; PROXY_HOST=''; PROXY_PORT=''; PROXY_USER=''; PROXY_PASS=''
}

CLEANED=''
cleanup() {
  local code=$?
  [[ -n $CLEANED ]] && return "$code"
  CLEANED=1
  teardown_bridge
  if (( ${#EXPORTED_REPORTS[@]} )); then
    local f deleted=0
    for f in "${EXPORTED_REPORTS[@]}"; do
      [[ -f $f ]] && rm -f -- "$f" 2>/dev/null && deleted=$((deleted+1))
    done
    (( deleted )) && echo "${DIM}已清理本次会话导出的 ${deleted} 份报告文件,宿主机不留残留。${NC}" >&2
  fi
  return "$code"
}
trap cleanup EXIT
# Ctrl-C / TERM 真正中止:只发 exit,清理交给 EXIT trap 跑一次
trap 'echo; exit 130' INT TERM
第 2 段:依赖检测与自动安装
BASH
# ══════════════════════════════════════════════════════════════════════
#  依赖检测与自动安装
第 3 段:依赖安装与 Docker 准备
BASH
# ══════════════════════════════════════════════════════════════════════
PKG_MGR=''
detect_pkg_mgr() {
  local m
  for m in apt-get dnf yum apk pacman zypper; do
    command -v "$m" >/dev/null 2>&1 && { PKG_MGR=$m; return 0; }
  done
  return 1
}

pkg_install() {
  local generic=$1 pkg
  case $PKG_MGR:$generic in
    *:curl)     pkg=curl ;;
    apt-get:iproute2|zypper:iproute2) pkg=iproute2 ;;
    *:iproute2) pkg=iproute ;;
    *:docker)   pkg=docker ;;
  esac
  echo "${BLUE}  安装 ${generic} (包名 ${pkg}) …${NC}"
  case $PKG_MGR in
    apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y "$pkg" >/dev/null ;;
    dnf)     dnf install -y "$pkg" >/dev/null ;;
    yum)     yum install -y "$pkg" >/dev/null ;;
    apk)     apk add --no-cache "$pkg" >/dev/null ;;
    pacman)  pacman -Sy --noconfirm "$pkg" >/dev/null ;;
    zypper)  zypper --non-interactive install "$pkg" >/dev/null ;;
  esac || die "自动安装 ${pkg} 失败,请手动安装后重试。"
  INSTALLED_PKGS+=("$pkg")
  record_line "pkg|$PKG_MGR|$pkg"
}

apt_updated=0
ensure_pkg() { # ensure_pkg <命令名> <通用依赖名>
  command -v "$1" >/dev/null 2>&1 && return 0
  (( AUTO_INSTALL )) || die "缺少依赖 $1,且已指定 --no-install。"
  [[ $EUID -eq 0 ]] || die "缺少依赖 $1,需要 root 才能自动安装(请用 sudo 运行,或手动安装)。"
  detect_pkg_mgr || die "缺少依赖 $1,且未识别到包管理器,请手动安装。"
  if [[ $PKG_MGR == apt-get && $apt_updated -eq 0 ]]; then
    echo "${DIM}  apt-get update …${NC}"; apt-get update -qq >/dev/null 2>&1 || true; apt_updated=1
  fi
  pkg_install "$2"
  command -v "$1" >/dev/null 2>&1 || die "安装完成但仍找不到 $1,请手动检查。"
}

install_docker() {
  echo "${YELLOW}未检测到 Docker,准备自动安装。${NC}"
  if [[ -d /etc/pve ]] || command -v pveversion >/dev/null 2>&1; then
    echo "${RED}  ⚠ 检测到这是 Proxmox VE 宿主机。官方不建议在 PVE 宿主机直接装 Docker:${NC}"
    echo "${RED}    Docker 会改写 iptables/nftables FORWARD 策略,可能干扰 PVE 桥接与 VM 网络。${NC}"
    echo "${DIM}    更稳妥的做法是在一台 LXC/VM 里跑本脚本。继续安装请在 5 秒内 Ctrl-C 取消…${NC}"
    sleep 5
  fi
  (( AUTO_INSTALL )) || die "未安装 Docker,且已指定 --no-install。"
  [[ $EUID -eq 0 ]] || die "自动安装 Docker 需要 root,请用 sudo 运行,或手动安装 Docker。"
  detect_pkg_mgr || die "未识别到包管理器,无法自动安装 Docker,请手动安装。"

  if command -v curl >/dev/null 2>&1 && [[ $PKG_MGR != apk ]]; then
    echo "${BLUE}  通过 Docker 官方脚本 get.docker.com 安装…${NC}"
    if curl -fsSL https://get.docker.com | sh >/dev/null 2>&1; then
      INSTALLED_PKGS+=("docker(get.docker.com)")
      record_line "docker|$PKG_MGR|get.docker.com"
    else
      echo "${DIM}  官方脚本失败,回退到发行版仓库…${NC}"; pkg_install docker
    fi
  else
    case $PKG_MGR in
      apk) apk add --no-cache docker docker-cli >/dev/null || die "apk 安装 docker 失败。"
           INSTALLED_PKGS+=("docker"); record_line "pkg|apk|docker"; record_line "pkg|apk|docker-cli" ;;
      *)   pkg_install docker ;;
    esac
  fi

  if command -v systemctl >/dev/null 2>&1; then
    systemctl enable --now docker >/dev/null 2>&1 || true
  elif command -v rc-service >/dev/null 2>&1; then
    rc-update add docker boot >/dev/null 2>&1 || true; rc-service docker start >/dev/null 2>&1 || true
  elif command -v service >/dev/null 2>&1; then
    service docker start >/dev/null 2>&1 || true
  fi

  local i
  for (( i=0; i<20; i++ )); do docker info >/dev/null 2>&1 && break; sleep 1; done
  docker info >/dev/null 2>&1 || die "Docker 已安装但守护进程未就绪,请手动启动后重试。"
  echo "${GREEN}  Docker 安装完成并已运行。${NC}"
}

ensure_docker() {
  if ! command -v docker >/dev/null 2>&1; then
    install_docker
  elif ! docker info >/dev/null 2>&1; then
    echo "${YELLOW}Docker 已安装但守护进程未运行,尝试启动…${NC}"
    if command -v systemctl >/dev/null 2>&1; then systemctl start docker >/dev/null 2>&1 || true
    elif command -v service >/dev/null 2>&1; then service docker start >/dev/null 2>&1 || true; fi
    local i; for (( i=0; i<15; i++ )); do docker info >/dev/null 2>&1 && break; sleep 1; done
    docker info >/dev/null 2>&1 || die "无法连接 Docker 守护进程(需要 root 或加入 docker 组)。"
  fi
}

ensure_deps() {
  ensure_pkg curl curl
  ensure_pkg ss   iproute2
  ensure_docker
}
第 4 段:工具函数
BASH
# ══════════════════════════════════════════════════════════════════════
#  工具函数
第 5 段:URL 编解码与 YAML 转义工具
BASH
# ══════════════════════════════════════════════════════════════════════
urlencode() {
  local LC_ALL=C
  local s=$1 i c h out=''
  for (( i=0; i<${#s}; i++ )); do
    c=${s:i:1}
    case $c in
      [a-zA-Z0-9.~_-]) out+=$c ;;
      *) printf -v h '%%%02X' "'$c"; out+=$h ;;
    esac
  done
  printf '%s' "$out"
}

# 只解码合法的 %XX,其余字符(包括孤立的 %)原样保留
urldecode() {
  local LC_ALL=C
  local s=$1 out='' i=0 c hex
  while (( i < ${#s} )); do
    c=${s:i:1}
    if [[ $c == '%' && ${s:i+1:2} =~ ^[0-9A-Fa-f]{2}$ ]]; then
      hex=${s:i+1:2}
      out+=$(printf '%b' "\\x${hex}")
      i=$(( i + 3 ))
    else
      out+=$c
      i=$(( i + 1 ))
    fi
  done
  printf '%s' "$out"
}

yaml_squote() { local s=${1//\'/\'\'}; printf "'%s'" "$s"; }

is_port() { [[ $1 =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 )); }

looks_like_host() {
  local h=$1
  [[ -z $h ]] && return 1
  [[ $h == localhost ]] && return 0
  [[ $h =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] && return 0          # IPv4
  [[ $h =~ ^\[[0-9A-Fa-f:.]+\]$ ]] && return 0                    # [IPv6]
  [[ $h =~ ^[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?)+$ ]] && return 0
  return 1
}

port_in_use() {
  local p=$1
  if command -v ss >/dev/null 2>&1; then
    ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$"
    return $?
  fi
  (exec 3<>/dev/tcp/127.0.0.1/"$p") 2>/dev/null && { exec 3>&- 2>/dev/null || true; return 0; }
  return 1
}

pick_port() {
  local p n
  for (( n=0; n<80; n++ )); do
    p=$(( (RANDOM % 15000) + 20000 ))
    port_in_use "$p" || { printf '%s' "$p"; return 0; }
  done
  return 1
}

ensure_image() {
  local img=$1
  docker image inspect "$img" >/dev/null 2>&1 && return 0
  echo "${BLUE}  拉取镜像 ${img} …${NC}"
  docker pull -q "$img" >/dev/null 2>&1 || { soft "镜像拉取失败:${img}(检查网络或镜像名)"; return 1; }
}

bridge_running() {
  [[ "$(docker inspect -f '{{.State.Running}}' "$BRIDGE_NAME" 2>/dev/null)" == true ]]
}

# ── 广告横幅过滤 ─────────────────────────────────────────────────────────
# NodeQuality / IPQuality 这类上游脚本内部会跑第三方检测组件(回程路由、IP
# 质量等),这些组件常年挂着"广告轮播位":赞助商会变,但版式一直是同一套——
# 连续好几行只由某个词高重复率堆出来的纯大写刷屏行,紧跟一行图文混排的推广
# 语(品牌名 + URL + 中文文案),块与块之间用空行分隔。
# 与其按具体广告商的字母表写死过滤规则(换个广告商就要重新加词),这里改成
# 识别这套通用版式:只要连续出现 2 行「高重复率大写刷屏行」,就判定进入广告
# 块,一路吞掉直到下一个空行为止(连推广语那行也一起吞,不用单独认它写的
# 是什么品牌)。真实检测数据(IP、AS 号、中文地名等)不会长这样,基本不会
# 被误伤;已知的几个广告关键词继续留作兜底。
#
# 关键点:必须按【字符】读,不能按【行】读。上游的下载/测速进度条是靠只发
# 「\r」不发「\n」来原地刷新的(比如 "# 10%\r## 50%\r######## 100%\n"),
# 之前用 `read -r line`(只认 \n)会把这一整串 \r 更新当成"还没读完的一
# 行"死死攒住,直到最后真正的 \n 出现才一次性吐出来——进度条看起来就是卡住
# 不动、最后突然跳 100%,攒起来的内容里混着好几个 \r 一次性打印还会把终端
# 搞出乱码方块。这里改成逐字符处理:遇到 \r 立刻原样吐出去(不参与广告判
# 定,反正真实的广告块都是完整整行、不会用 \r),只有遇到真正的 \n 才把攒
# 到的这一整行拿去跑广告识别逻辑,这样进度条能实时刷新,广告块也照样能拦。
#
# 这个过滤器还顺手干两件事(都因为"能实时透传 \r"之后才显出来):
#   1) 摘掉 NodeQuality 那四个交互问句(见下 strip_nq_prompts)——它们黏成一坨混进输出;
#   2) 处理 \r 进度条与其后整行内容的衔接:用「回车 + 清到行尾」(\r\033[K) 覆盖上一条
#      进度行,而不是凭空补一个 \n。补 \n 会把进度条最后一帧(如 BenchOs 99.6%→100%)
#      拆成两行;覆盖式则始终在同一行刷新,且能清掉旧帧比新内容长时残留的尾巴(那正是
#      "测试完成…"被截断、报告链接显示不全的根因)。
# 广告从「结构」上识别,不再维护广告商名字/关键词(脚本更新换广告商也不会漏)。见 filter_ads。

# ── NodeQuality 交互问句摘除 ─────────────────────────────────────────────
# NodeQuality 官方入口用 read -p 逐个问「是否跑 硬件 / IP / 网络 / 回程」四个问题,
# 答案由我们从管道喂进去。因为 stdin 不是 tty,终端不会回显换行,四个问句会
# 无换行地紧紧黏成"一整行"混进正常输出里,例如:
#   运行 HardwareQuality 测试?(回车默认 'y'…)[y/f/v/n]:运行 IPQuality 测试?…[y/n]:…
# 不能整行丢弃(问句后面可能黏着"安装前清理"这类真实状态行),要摘掉"问句本身"、
# 保留黏在末尾的真实内容。识别特征极稳定:以「运行」开头、以「[y…/n]:」收尾,中间不含
# 全角「:」——真实检测数据(IP/AS/地名)不会长这样,不会误伤。
# 用 # 作 sed 分隔符:问句里含字面 /(如 [y/f/v/n]),再用默认的 / 作分隔符会冲突。
NQ_PROMPT_SED='s#运行[^:]*\[y[^]]*\][:]##g'
strip_nq_prompts() {   # 供 EOF 收尾与单测直接调用;只有命中特征才起 sed,普通行原样返回
  case $1 in
    *运行*'[y'*) printf '%s' "$1" | sed -E "$NQ_PROMPT_SED" ;;
    *)           printf '%s' "$1" ;;
  esac
}

# ── OSC 8 超链接展开 ─────────────────────────────────────────────────────
# NodeQuality / IPQuality 的"测试完成,点击查看你的结果"是一段 OSC 8 终端超链接:
#   ESC]8;;URL ESC\  可见文字  ESC]8;; ESC\      (收尾也可能是 BEL \a)
# URL 藏在不可见的转义序列里。它常和 curl 上传进度条挤在同一行,被进度条的 \r 刷新
# 覆盖破坏 → 终端里只剩截断的可见文字、链接彻底看不到,grep 也抓不到那串 URL。
# 这里把超链接就地展开成可见的「文字 (URL)」,URL 变成普通文本:既能显示/复制,也不会
# 再被当成"看不见的转义"而随进度条一起被冲掉;两种收尾(ESC\ 和 BEL)都处理。
OSC8_SED=$'s/\x1b\\]8;[^;]*;([^\x1b\x07]*)(\x1b\\\\|\x07)([^\x1b\x07]*)\x1b\\]8;;(\x1b\\\\|\x07)/\\3 (\\1)/g'
render_osc8() {
  case $1 in
    *$'\033]8;'*) printf '%s' "$1" | sed -E "$OSC8_SED" ;;
    *)            printf '%s' "$1" ;;
  esac
}

filter_ads() {
  local buf='' ch plain nospace line last_was_cr=0 deferred=''
  local -a para=(); local emitted_any=0 pending_blank=0
  # ECR = 回车 + ANSI「清到行尾」(\033[K)。凡是"要覆盖上一条 \r 进度行"的场景都用它:
  # 先把光标退回行首、清掉整行残留字符,再写新内容。这样既不会像补 \n 那样凭空多出一行
  # (BenchOs 99.6%→100% 就是被多补的 \n 拆成两行的),也不会因为新内容比进度条短而留下
  # 旧字符的尾巴("测试完成…"被截断、报告链接显示不全,都是残留尾巴造成的)。
  local ECR=$'\r\033[K'
  # 打印一条"真正的整行内容":若紧接在 \r 进度行之后,就覆盖那一行(不另起新行)。
  emit_line() {
    if (( last_was_cr )); then printf '%s%s\n' "$ECR" "$1"; last_was_cr=0
    else printf '%s\n' "$1"; fi
  }
  # 冲刷"攒起来的完成语/报告链接":把它作为独立一行打印在最后一帧进度条的【下面】。
  # 关键:这里不能用 emit_line(那会 ECR 覆盖掉最后一帧真实进度 "100 4403…10609"),
  # 而是若光标还停在进度行上就先补一个 \n 把它挪到下一行,再打印这条完整的完成语。
  flush_deferred() {
    [[ -z $deferred ]] && return 0
    (( last_was_cr )) && { printf '\n'; last_was_cr=0; }
    # 去掉可能黏在尾部的进度残留空白(不依赖 extglob)
    deferred="${deferred%"${deferred##*[![:space:]]}"}"
    printf '%s\n' "$deferred"
    deferred=''
  }
  # ── 广告过滤(按“段落”从结构上判定,不依赖具体广告商名字/关键词)───────────
  # 第三方推广只有两种形态:① 反复堆砌品牌名的彩色 ASCII 横幅;② 几行中文文案 + 一个官网
  # http(s) 链接。真实检测报告的数据行既不是纯字母刷屏、也不含第三方外链。于是按空行分段,
  # 整段命中任一条即判为广告、【整段丢弃】(连带其前后空行一起吸收,顺便消除多余空白):
  para_is_ad() {
    local l p ns uniq has_url=0 has_art=0
    for l in "${para[@]}"; do
      p=$(printf '%s' "$l" | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g')   # 去掉 ANSI 颜色
      # 本工具自己的结果链接要保留,不算广告外链
      case $p in
        *测试完成*|*点击查看*|*报告链接*|*nodequality.com/r/*|*[Rr]eport?[Cc]heck?[Pp]lace*) continue ;;
      esac
      case $p in *http://*|*https://*) has_url=1 ;; esac
      ns=${p//[[:space:]]/}
      # 横幅行:纯拉丁字母、够长、字符种类少(真实数据行含数字/中文/标点,天然豁免);
      # 稀疏艺术行与实心横幅行同段,会被一起带走。
      if [[ ${#ns} -ge 24 && $ns =~ ^[A-Za-z]+$ ]]; then
        uniq=$(printf '%s' "$ns" | fold -w1 | sort -u | wc -l)
        (( uniq <= 12 )) && has_art=1
      fi
    done
    (( has_art )) && return 0
    # 含第三方链接且段落短(广告文案都很短;限 ≤8 行以免误伤恰好含链接的大表格)
    (( has_url )) && (( ${#para[@]} <= 8 )) && return 0
    return 1
  }
  flush_para() {
    (( ${#para[@]} == 0 )) && return 0
    if para_is_ad; then para=(); return 0; fi
    (( emitted_any )) && (( pending_blank )) && emit_line ""   # 段间保留一个空行分隔
    pending_blank=0
    local l
    for l in "${para[@]}"; do emit_line "$l"; done
    emitted_any=1
    para=()
  }
  while IFS= read -r -n1 -d '' ch || [[ -n $ch ]]; do
    if [[ $ch == $'\n' ]]; then
      # 先摘掉 NodeQuality 的交互问句(只在命中特征时起 sed,普通行零开销)
      if [[ $buf == *运行*'[y'* ]]; then
        line=$(printf '%s' "$buf" | sed -E "$NQ_PROMPT_SED")
      else
        line=$buf
      fi
      # 整行都是问句、摘完啥也不剩:直接吞掉,不留空行(真正的空行 buf 为空、会照常输出)
      if [[ -z $line && -n $buf ]]; then buf=''; continue; fi
      # 把 OSC 8 超链接就地展开成「文字 (URL)」,让报告链接以纯文本可见、可复制、可被 grep 抓到
      line=$(render_osc8 "$line")
      # 进度块已结束(收到一条真正的 \n 整行):把之前攒起来的完成语/链接先作为独立一行
      # 打印在最后一帧进度条下面(红框位置),再输出本行(如"安装后清理")。
      flush_deferred
      plain=$(printf '%s' "$line" | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g')
      nospace=${plain//[[:space:]]/}
      # 按空行分段:空行 → 结算当前段落(广告整段丢、正文整段出);正文行先攒进 para。
      if [[ -z $nospace ]]; then
        flush_para
        pending_blank=1
      else
        para+=("$line")
      fi
      buf=''
    elif [[ $ch == $'\r' ]]; then
      # \r:进度条一类的实时刷新,不参与广告判定,立刻吐出去(保证实时)。
      # 先把攒着的正文段落吐出来(进度条一起,说明上一段文字已结束),保持"文字在进度之前"的顺序。
      flush_para
      line=$(render_osc8 "$buf"); buf=''
      # 关键(本轮修):完成语/报告链接常被 curl 上传进度条的 \r 切成碎片、且黏着进度数字,
      # 若就地打印会黏进进度行、还被后一帧 100% 覆盖冲掉(截断+链接丢失)。改为攒进 deferred:
      # 命中"测试完成"就从它起头(丢掉前面的进度数字残渣),后续碎片直接续上拼成完整一句;
      # 等进度块结束(下一条 \n 整行 或 EOF)再由 flush_deferred 作为独立一行打印在最后一帧
      # 进度条【下面】(用户要求的红框位置)。期间不动 last_was_cr,进度帧照常在原地实时刷新。
      case $line in
        *http://*|*https://*|*测试完成*|*点击查看*|*报告链接*)
          if [[ $line == *测试完成* ]]; then deferred="测试完成${line#*测试完成}"
          else deferred+=$line; fi ;;
        *)
          # 普通进度帧:ECR(回车+清行)覆盖上一帧,同一行实时刷新,绝不多生成行。
          printf '%s%s' "$ECR" "$line"
          last_was_cr=1 ;;
      esac
    else
      buf+="$ch"
    fi
  done
  # ── EOF 收尾 ──────────────────────────────────────────────────────────
  if [[ -n $buf ]]; then
    line=$(strip_nq_prompts "$buf")
    line=$(render_osc8 "$line")
    buf=''
    # 收尾半行:完成语/链接碎片进 deferred 独立成行;其余正文进 para 走段落广告判定
    case $line in
      *测试完成*|*点击查看*|*报告链接*)
        if [[ $line == *测试完成* ]]; then deferred="测试完成${line#*测试完成}"
        else deferred+=$line; fi ;;
      *) nospace=${line//[[:space:]]/}; [[ -n $nospace ]] && para+=("$line") ;;
    esac
  fi
  flush_para          # 结算最后一个段落(广告丢 / 正文出)
  # 流结束:把最后攒着的完成语/报告链接作为独立一行打印在最后一帧进度条下面。
  flush_deferred
  # 若整个流最后停在 \r 进度帧上(光标在进度行末尾),补一个换行收尾,
  # 避免脚本随后 echo 的报告链接直接接在进度条尾巴上。
  (( last_was_cr )) && printf '\n'
  return 0   # 上面的 [[ ]] &&/(( )) 在"没什么要冲刷"时退出码为 1,
             # 在 set -e + pipefail 下会被当成整个函数失败,进而炸穿全脚本;
             # 显式 return 0 兜底,这里从头到尾都不算真正的错误。
}
第 6 段:连接字符串解析
BASH
# ══════════════════════════════════════════════════════════════════════
#  连接字符串解析
#  成功时填充 PROXY_PROTO / PROXY_HOST / PROXY_PORT / PROXY_USER / PROXY_PASS
第 7 段:host:port 拆分与格式判断
BASH
# ══════════════════════════════════════════════════════════════════════

# 拆 host:port,支持 [IPv6]:port
split_hostport() { # $1=串  -> 设置 _SP_HOST / _SP_PORT
  local s=$1
  _SP_HOST=''; _SP_PORT=''
  if [[ $s =~ ^(\[[0-9A-Fa-f:.]+\]):([0-9]+)$ ]]; then
    _SP_HOST=${BASH_REMATCH[1]}; _SP_PORT=${BASH_REMATCH[2]}; return 0
  fi
  [[ $s == *:* ]] || return 1
  _SP_HOST=${s%:*}; _SP_PORT=${s##*:}
  [[ -n $_SP_HOST && -n $_SP_PORT ]] || return 1
  is_port "$_SP_PORT" || return 1
  return 0
}

# 判断一个串是否形如 host:port
is_hostport() {
  local h p
  split_hostport "$1" || return 1
  h=$_SP_HOST; p=$_SP_PORT
  looks_like_host "$h" && is_port "$p"
}

parse_conn_string() {
  local raw=$1 s scheme rest userinfo hostport prev
  PROXY_PROTO=''; PROXY_HOST=''; PROXY_PORT=''; PROXY_USER=''; PROXY_PASS=''

  # 1) 反复剥掉粘贴时容易带上的外壳:空白、引号/尖括号、export、VAR=、curl -x 等。
  #    循环到不再变化为止,这样 `export ALL_PROXY="socks5://..."` 这种嵌套也能剥干净。
  #    用正则而不是 extglob:避免改动调用者的 shell 选项。
  s=$raw
  while :; do
    prev=$s
    s=${s#"${s%%[![:space:]]*}"}; s=${s%"${s##*[![:space:]]}"}
    if [[ ( ${s:0:1} == '"' && ${s: -1} == '"' && ${#s} -ge 2 ) \
       || ( ${s:0:1} == "'" && ${s: -1} == "'" && ${#s} -ge 2 ) \
       || ( ${s:0:1} == '<' && ${s: -1} == '>' && ${#s} -ge 2 ) ]]; then
      s=${s:1:${#s}-2}
    fi
    [[ $s =~ ^[Ee][Xx][Pp][Oo][Rr][Tt][[:space:]]+(.*)$ ]] && s=${BASH_REMATCH[1]}
    [[ $s =~ ^[Cc][Uu][Rr][Ll][[:space:]]+(.*)$ ]] && s=${BASH_REMATCH[1]}
    [[ $s =~ ^(-x|--proxy|--socks5|--socks5-hostname|--preproxy)[[:space:]]+(.*)$ ]] && s=${BASH_REMATCH[2]}
    [[ $s =~ ^(ALL_PROXY|all_proxy|HTTP_PROXY|http_proxy|HTTPS_PROXY|https_proxy|SOCKS_PROXY|socks_proxy)=(.*)$ ]] && s=${BASH_REMATCH[2]}
    [[ $s == "$prev" ]] && break
  done

  [[ -n $s ]] || { soft "连接字符串为空。"; return 1; }
  [[ $s == *[$'\n\r']* ]] && { soft "连接字符串含换行符,请粘贴单行内容。"; return 1; }
  [[ $s == *[[:space:]]* ]] && { soft "连接字符串中间含空格,可能是粘贴时带了多余内容。"; return 1; }

  # 2) scheme
  local decode=0
  if [[ $s =~ ^([A-Za-z][A-Za-z0-9+.-]*)://(.*)$ ]]; then
    scheme=$(printf '%s' "${BASH_REMATCH[1]}" | tr 'A-Z' 'a-z')
    rest=${BASH_REMATCH[2]}
    decode=1                     # URL 形式:userinfo 里的 %XX 要解码
    case $scheme in
      socks5|socks5h|socks|socks5t) PROXY_PROTO=socks5 ;;
      http)  PROXY_PROTO=http ;;
      https) PROXY_PROTO=https ;;
      socks4|socks4a)
        soft "socks4 上游本脚本未支持(gost 配置只写了 socks5/http/https),请换 socks5 节点。"; return 1 ;;
      ss|ssr|vmess|vless|trojan|trojan-go|hysteria|hysteria2|hy2|tuic|snell|naive|wg|wireguard|juicity|anytls)
        soft "这是 ${scheme} 节点链接,不是 HTTP/SOCKS 代理,本脚本无法直接连。请先用对应客户端把它转成本地 socks5 端口,再把那个端口填进来。"
        return 1 ;;
      *) soft "不认识的协议 ${scheme}://,支持的是 socks5 / socks5h / http / https。"; return 1 ;;
    esac
    rest=${rest%%/*}             # 去掉路径部分(URL 里密码含 / 必须写成 %2F)
    [[ -n $rest ]] || { soft "连接字符串里没有主机信息。"; return 1; }
  else
    rest=$s
  fi

  # 3) @ 形式:user:pass@host:port 或 host:port@user:pass
  if [[ $rest == *@* ]]; then
    userinfo=${rest%@*}          # 按最后一个 @ 切,允许密码里含 @
    hostport=${rest##*@}
    if is_hostport "$hostport"; then
      :                          # 正常方向
    elif is_hostport "$userinfo"; then
      local tmp=$userinfo; userinfo=$hostport; hostport=$tmp   # 反向写法
    else
      # 密码里含 @ 且主机段被切坏时,退回按第一个 @ 切一次试试
      userinfo=${rest%%@*}; hostport=${rest#*@}
      is_hostport "$hostport" || { soft "无法识别 @ 两侧哪边是主机、哪边是账密。请改用 socks5://user:pass@host:port。"; return 1; }
    fi
    split_hostport "$hostport" || { soft "主机端口部分无法解析。"; return 1; }
    PROXY_HOST=$_SP_HOST; PROXY_PORT=$_SP_PORT
    if [[ -n $userinfo ]]; then
      PROXY_USER=${userinfo%%:*}
      if [[ $userinfo == *:* ]]; then PROXY_PASS=${userinfo#*:}; else PROXY_PASS=''; fi
    fi
    (( decode )) && { PROXY_USER=$(urldecode "$PROXY_USER"); PROXY_PASS=$(urldecode "$PROXY_PASS"); }

  # 4) 免认证的 host:port(含 [IPv6]:port,必须在冒号分段之前判断)
  elif is_hostport "$rest"; then
    split_hostport "$rest" || { soft "主机端口部分无法解析。"; return 1; }
    PROXY_HOST=$_SP_HOST; PROXY_PORT=$_SP_PORT

  # 5) 纯冒号分隔的多段式
  else
    if [[ $rest == *'['* || $rest == *']'* || $rest == *'::'* ]]; then
      soft "IPv6 节点请用 URL 形式,把地址放进方括号:socks5://user:pass@[2001:db8::1]:1080"; return 1
    fi
    local -a f=()
    IFS=':' read -r -a f <<< "$rest"
    local n=${#f[@]}
    case $n in
      1) soft "只给了一段(${f[0]}),缺少端口。至少要 host:port。"; return 1 ;;
      2) soft "两段式必须是 host:port,且端口是 1-65535 的数字(当前:${f[0]} / ${f[1]})。"; return 1 ;;
      3) soft "三段式无法判断含义。请用 host:port:user:pass 或 socks5://user:pass@host:port。"; return 1 ;;
      *)
        local shape_a=0 shape_b=0            # a: host:port:user:pass   b: user:pass:host:port
        looks_like_host "${f[0]}"   && is_port "${f[1]}"   && shape_a=1
        looks_like_host "${f[n-2]}" && is_port "${f[n-1]}" && shape_b=1
        if (( shape_a && shape_b )); then
          echo "${YELLOW}      这个字符串两种解释都成立,请确认顺序:${NC}" >&2
          echo "        ${GREEN}1${NC}) host:port:user:pass  → 主机 ${f[0]}:${f[1]}" >&2
          echo "        ${GREEN}2${NC}) user:pass:host:port  → 主机 ${f[n-2]}:${f[n-1]}" >&2
          local pick=''
          read -r -p "${YELLOW}      输入 1/2 [默认 1]: ${NC}" pick || pick=''
          case ${pick:-1} in
            1) shape_b=0 ;;
            2) shape_a=0 ;;
            *) soft "无效选择:$pick"; return 1 ;;
          esac
        fi
        local j
        if (( shape_a )); then
          PROXY_HOST=${f[0]}; PROXY_PORT=${f[1]}; PROXY_USER=${f[2]}; PROXY_PASS=${f[3]}
          for (( j=4; j<n; j++ )); do PROXY_PASS+=":${f[j]}"; done   # 密码里可含 :
        elif (( shape_b )); then
          PROXY_HOST=${f[n-2]}; PROXY_PORT=${f[n-1]}; PROXY_USER=${f[0]}; PROXY_PASS=${f[1]}
          for (( j=2; j<n-2; j++ )); do PROXY_PASS+=":${f[j]}"; done
        else
          soft "四段式里既找不到 host:port 开头、也找不到 host:port 结尾,无法识别。"; return 1
        fi ;;
    esac
  fi

  # 5) 收尾校验
  [[ -z $PROXY_PROTO ]] && PROXY_PROTO=socks5          # 无 scheme 时按 socks5
  [[ -n $PROXY_HOST ]] || { soft "没解析出主机地址。"; return 1; }
  is_port "$PROXY_PORT" || { soft "端口 ${PROXY_PORT:-<空>} 不是 1-65535 的数字。"; return 1; }
  looks_like_host "$PROXY_HOST" || echo "${YELLOW}      ⚠ 主机 ${PROXY_HOST} 看起来不像 IP 或域名,仍会尝试连接。${NC}"
  if [[ -n $PROXY_USER && -z $PROXY_PASS ]]; then
    soft "解析出账号但没有密码。若该节点免认证,请只给 host:port。"; return 1
  elif [[ -z $PROXY_USER && -n $PROXY_PASS ]]; then
    soft "解析出密码但没有账号。"; return 1
  fi
  return 0
}

show_parsed() {
  local n=${#PROXY_PASS} mask='' i
  for (( i=0; i<n && i<24; i++ )); do mask+='*'; done
  echo "${GREEN}      已识别 →${NC} 协议 ${CYAN}${PROXY_PROTO}${NC}  主机 ${CYAN}${PROXY_HOST}${NC}  端口 ${CYAN}${PROXY_PORT}${NC}"
  if [[ -n $PROXY_USER ]]; then
    echo "${GREEN}                ${NC}账号 ${CYAN}${PROXY_USER}${NC}  密码 ${DIM}${mask}(${n} 位,不回显)${NC}"
  else
    echo "${GREEN}                ${NC}${DIM}免认证(无账号密码)${NC}"
  fi
}

# ── gost 配置生成(v3 YAML / v2 JSON),支持 socks5 / http / https 上游 ──
#   socks5 → connector socks5 + dialer tcp
#   http   → connector http   + dialer tcp
#   https  → connector http   + dialer tls
write_gost_v3() {
  local f=$1 conn dialer
  case $PROXY_PROTO in
    socks5) conn=socks5; dialer=tcp ;;
    http)   conn=http;   dialer=tcp ;;
    https)  conn=http;   dialer=tls ;;
  esac
  {
    printf 'services:\n'
    printf '  - name: bridge\n'
    printf '    addr: ":1080"\n'
    printf '    handler:\n      type: socks5\n      chain: up\n'
    printf '    listener:\n      type: tcp\n'
    printf 'chains:\n'
    printf '  - name: up\n    hops:\n      - name: hop-0\n        nodes:\n'
    printf '          - name: node-0\n'
    printf '            addr: %s\n' "$(yaml_squote "${PROXY_HOST}:${PROXY_PORT}")"
    printf '            connector:\n              type: %s\n' "$conn"
    if [[ -n $PROXY_USER ]]; then
      printf '              auth:\n'
      printf '                username: %s\n' "$(yaml_squote "$PROXY_USER")"
      printf '                password: %s\n' "$(yaml_squote "$PROXY_PASS")"
    fi
    printf '            dialer:\n              type: %s\n' "$dialer"
  } > "$f"
}

write_gost_v2() {
  local f=$1 auth='' scheme
  case $PROXY_PROTO in
    socks5) scheme=socks5 ;; http) scheme=http ;; https) scheme=https ;;
  esac
  [[ -n $PROXY_USER ]] && auth="$(urlencode "$PROXY_USER"):$(urlencode "$PROXY_PASS")@"
  cat > "$f" <<JSON
{
  "ServeNodes": ["socks5://:1080"],
  "ChainNodes": ["${scheme}://${auth}${PROXY_HOST}:${PROXY_PORT}"]
}
JSON
}

start_bridge() {
  local fmt=$1 ext i
  case $fmt in v3) ext=yaml ;; v2) ext=json ;; esac
  local cfg="$WORKDIR/gost.$ext"
  "write_gost_$fmt" "$cfg"
  chmod 644 "$cfg"   # 目录已 700,其他用户进不来;文件可读是为兼容非 root 镜像

  docker rm -f "$BRIDGE_NAME" >/dev/null 2>&1 || true
  docker run -d --name "$BRIDGE_NAME" \
    -p "127.0.0.1:${LOCAL_PORT}:1080" \
    -v "${cfg}:/gost.${ext}:ro" \
    "$GOST_IMAGE" -C "/gost.${ext}" >/dev/null 2>"$WORKDIR/run.err" || {
      echo "${DIM}$(tail -3 "$WORKDIR/run.err" 2>/dev/null)${NC}" >&2; return 1; }

  for (( i=0; i<25; i++ )); do
    bridge_running || return 1
    port_in_use "$LOCAL_PORT" && return 0
    sleep 0.4
  done
  return 1
}

IP_ENDPOINTS=(https://api.ipify.org https://icanhazip.com https://ipinfo.io/ip)
probe_exit_ip() {
  local socks=${1:-} ep out
  for ep in "${IP_ENDPOINTS[@]}"; do
    if [[ -n $socks ]]; then
      out=$(curl -fsS --max-time 10 --socks5-hostname "$socks" "$ep" 2>/dev/null) || continue
    else
      out=$(curl -fsS --max-time 10 "$ep" 2>/dev/null) || continue
    fi
    out=${out//[$'\r\n\t ']/}
    [[ -n $out ]] && { printf '%s' "$out"; return 0; }
  done
  return 1
}
第 8 段:检测工具执行器
BASH
# ══════════════════════════════════════════════════════════════════════
#  检测工具执行器
第 9 段:落地检测入口(容器内跑 IPQuality)
BASH
# ══════════════════════════════════════════════════════════════════════
DOCKER_TTY=(-i)
[[ -t 0 && -t 1 ]] && DOCKER_TTY=(-i -t)

# 落地检测在容器里跑 IPQuality 的入口。
#   $1 = 工具标识(目前仅 ipquality)
#   $2 = 可选的本地代理地址(给则只有"检测命令"这一步走代理)
#
# 关键设计:代理地址用非标准变量名 IPT_PROXY 传进去,容器里先用【直连】
# 装依赖、下脚本,最后才 export 标准代理变量执行检测。两个原因:
#   1. apt 只认 socks5h:// 且需要 Acquire::*::Proxy 单独配置,让它走代理纯属自找麻烦;
#   2. 装依赖和拉 GitHub 脚本本来就不该占用落地节点带宽,也不影响检测结果。
run_tool() {
  local tool=$1 proxy=${2:-}
  # 落地检测无需人工交互:容器用 -i(不带 -t),并把 `yes y` 喂进 stdin 自动确认
  # xykt IPQuality 的「Lacking necessary dependencies … Continue? (y/n): 」这类提问。
  # 不带 -t 还能让上游脚本判定为非交互、少刷一堆 tty-gated 的彩色赞助商横幅。
  local -a docker_args=(--rm -i --network host)
  local -a env_args=()
  local pkgs='curl ca-certificates' fetch='' exec_cmd='' inner

  case $tool in
    ipquality)
      pkgs='curl ca-certificates python3 jq bc'            # IPQuality 用 python3 排版
      fetch="curl -fsSL '$URL_IPQUALITY' -o /tmp/ip.sh"
      exec_cmd="bash /tmp/ip.sh ${IPQ_ARGS:-}"
      ;;
  esac

  # 代理 URL 本身不含任何凭据(指向本地无认证桥接),放命令行是安全的
  [[ -n $proxy ]] && env_args+=(-e IPT_PROXY="$proxy")

  # 注意:这段 heredoc 里【不能】用反斜杠续行。unquoted heredoc 会把
  # "\\ + 换行" 变成"裸反斜杠 + 换行被删",结果是给命令多传一个字面空格参数。
  inner=$(cat <<EOS
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1 || true
if ! apt-get install -y --no-install-recommends ${pkgs} >/dev/null 2>&1; then
  echo '容器内安装依赖失败(换个 RUNNER_IMAGE 或检查网络)' >&2; exit 1
fi
if ! ${fetch}; then
  echo '下载检测脚本失败(地址可能变了,见 --help 里的覆盖变量)' >&2; exit 1
fi
if [ -n "\${IPT_PROXY:-}" ]; then
  export ALL_PROXY="\$IPT_PROXY"   all_proxy="\$IPT_PROXY"
  export HTTP_PROXY="\$IPT_PROXY"  http_proxy="\$IPT_PROXY"
  export HTTPS_PROXY="\$IPT_PROXY" https_proxy="\$IPT_PROXY"
  export NO_PROXY= no_proxy=
fi
${exec_cmd}
EOS
)

  ensure_image "$RUNNER_IMAGE" || return 1
  echo
  echo "${DIM}(在一次性容器内安装依赖并执行,跑完即销毁)${NC}"
  echo
  local -a run_rc
  set +e
  # `< <(yes y)`:把无限个 "y" 喂给容器 stdin,自动确认上游依赖安装提问;
  # docker 退出后 yes 收到 SIGPIPE 自然结束。PIPESTATUS[0] 仍是 docker 的退出码。
  docker run "${docker_args[@]}" "${env_args[@]}" -e "TERM=${TERM:-xterm}" "$RUNNER_IMAGE" bash -c "$inner" < <(yes y) 2>&1 | filter_ads
  run_rc=("${PIPESTATUS[@]}")
  set -e
  if (( run_rc[0] != 0 )); then
    echo "${YELLOW}检测脚本执行失败或被中断。${NC}"
    echo "${DIM}若上游脚本地址/参数有变,可用环境变量覆盖,例如:${NC}"
    echo "${DIM}  URL_IPQUALITY=<新地址> IPQ_ARGS='-l en' ./ip-test.sh${NC}"
    return 1
  fi
}
第 10 段:菜单动作
BASH
# ══════════════════════════════════════════════════════════════════════
#  菜单动作
第 11 段:本机检测模式分发
BASH
# ══════════════════════════════════════════════════════════════════════
# ── 本机质量检测:一次性容器里跑 NodeQuality 一体化体检 ────────────────────
#   $1 = 模式 full|hardware|ip|net|route
#
# NodeQuality 官方入口是交互式脚本,会依次问「是否跑 硬件 / IP / 网络 / 回程」四个 y/n。
# 我们不走它默认的 y/f/v/n 快捷键,而是按主菜单选好的模式,用管道把答案喂给它 stdin:
#   完整   y y y y      硬件   y n n n      IP     n y n n
#   网络   n n y n      回程   n n n y
# 末尾再补 `yes y`,把「安装前清理 / 上传报告生成分享链接」这类后续提问一律答 y,
# 这样跑完能拿到 https://nodequality.com/r/... 的可分享报告链接。
#
# 两个受限环境下无法核实、但已收敛到这一处、便于随时调整的假设(上游若改行为就改这里):
#   1. NodeQuality 从 stdin 读答案(而非强制 /dev/tty);
#   2. 四个提问顺序为 硬件→IP→网络→回程(依据官方交互截图)。
run_nodequality() {
  local mode=$1
  local a1 a2 a3 a4 title
  case $mode in
    full)     a1=y a2=y a3=y a4=y title='完整检测模式(硬件 / IP / 网络 / 回程路由)' ;;
    hardware) a1=y a2=n a3=n a4=n title='硬件质量体检(HardwareQuality)' ;;
    ip)       a1=n a2=y a3=n a4=n title='IP 质量体检(IPQuality)' ;;
    net)      a1=n a2=n a3=y a4=n title='网络质量体检(NetQuality)' ;;
    route)    a1=n a2=n a3=n a4=y title='回程路由检测(Backroute Trace)' ;;
    *) soft "未知检测模式:$mode"; return 1 ;;
  esac

  echo
  echo "${BLUE}=== 本机质量检测 · ${title} ===${NC}"
  local ip; ip=$(probe_exit_ip) || { soft "本机无法访问外网,请先检查网络。"; return 1; }
  echo "${GREEN}本机出口 IP: ${CYAN}${ip}${NC}"
  echo "${DIM}提示:容器内测硬盘 I/O 会受 overlay 文件系统影响,仅供参考;${NC}"
  echo "${DIM}      要精确硬盘数据请直接在宿主机/VM 里跑。${NC}"

  # NodeQuality 的硬件体检(BenchOs)要 mount /proc /sys /dev 这类伪文件系统才能跑,
  # 光给 NET_RAW/NET_ADMIN 不够(会报 mount: permission denied,进而连累 /dev/fd
  # 解析失败、整体非零退出)。这里改用 --privileged:容器仍是 --rm 跑完即删,
  # 不挂任何宿主机目录,退出即销毁,不会在宿主机留下痕迹,只是放开了容器自身的权限。
  # -i 让内层管道拿到 stdin。
  local -a docker_args=(--rm -i --privileged --network host)

  # 注意:这段 heredoc 里【不能】用反斜杠续行(见文件顶部踩坑说明)。
  # printf 里的 \n 是留给 printf 自己解释的换行,heredoc 不会吃掉它(只有 \+换行才会)。
  local inner
  inner=$(cat <<EOS
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1 || true
if ! apt-get install -y --no-install-recommends curl ca-certificates wget >/dev/null 2>&1; then
  echo '容器内安装依赖失败(换个 RUNNER_IMAGE 或检查网络)' >&2; exit 1
fi
if ! curl -fsSL '${NQ_URL}' -o /tmp/nq.sh; then
  echo '下载 NodeQuality 脚本失败(地址可能变了,可用 NQ_URL 覆盖)' >&2; exit 1
fi
{ printf '%s\n' '${a1}' '${a2}' '${a3}' '${a4}'; yes y; } | bash /tmp/nq.sh
EOS
)

  ensure_image "$RUNNER_IMAGE" || return 1
  echo
  echo "${DIM}(在一次性容器内安装依赖并执行 NodeQuality,跑完即销毁)${NC}"
  echo

  local rawlog rc link
  rawlog=$(mktemp "${TMPDIR:-/tmp}/nq-raw.XXXXXX") || { soft "无法创建临时日志文件。"; return 1; }

  # -e TERM:容器没分配 tty,upstream 脚本里用到 tput 一类东西时会报
  # "TERM environment variable not set" 并且它自带的进度显示会失效,
  # 这里显式传一个,让它自己的进度提示能正常渲染。
  # 装依赖 / 下载脚本这一步全程静默、没有任何输出,先提前说明一下,免得
  # 看着像卡住了;一旦 NodeQuality 自己开始跑,filter_ads 会实时透传它的
  # 输出(包括 \r 刷新的下载进度条),不会再有延迟。
  echo "${DIM}准备阶段(装依赖、下载脚本)可能需要十几秒到几十秒,期间没有输出属于正常现象…${NC}"
  echo
  set +e
  docker run "${docker_args[@]}" -e "TERM=${TERM:-xterm}" "$RUNNER_IMAGE" bash -c "$inner" 2>&1 | filter_ads | tee "$rawlog"
  rc=${PIPESTATUS[0]}
  set -e

  # NodeQuality 自身在最后「安装后清理」阶段(卸载 BenchOs 的 proc/sys/dev、
  # 删临时目录)偶尔会返回非零退出码,但这不代表测试本身失败——只要输出里
  # 已经出现报告链接,就说明各项检测和上传都已经跑完了。所以判断顺序是:
  # 先看有没有抓到链接,链接在就按成功处理,退出码只在没链接时才当作失败依据。
  link=$(grep -oE 'https://nodequality\.com/r/[A-Za-z0-9_-]+' "$rawlog" | tail -n1 || true)

  if [[ -z $link ]] && (( rc != 0 )); then
    echo "${YELLOW}NodeQuality 执行失败或被中断。${NC}"
    echo "${DIM}若官方入口地址有变,可用环境变量覆盖,例如:${NC}"
    echo "${DIM}  NQ_URL=https://run.nodequality.com ./ip-test.sh${NC}"
    rm -f "$rawlog" 2>/dev/null || true
    return 1
  fi

  if (( rc != 0 )); then
    echo "${DIM}(提示:NodeQuality 清理阶段返回了非零状态,但报告已正常生成,可忽略这条。)${NC}"
  fi

  echo
  export_nodequality_report "$rawlog" "$mode"
  rm -f "$rawlog" 2>/dev/null || true
}

# ── 检测完成后一键导出报告:从输出里抓 https://nodequality.com/r/... 链接,
#    单独存成一份小文件放在当前目录,方便直接复制分享,不用再翻一屏输出去找。
#   $1 = 本轮原始输出日志路径   $2 = 检测模式(用于文件名)
export_nodequality_report() {
  local rawlog=$1 mode=$2 link report_file
  link=$(grep -oE 'https://nodequality\.com/r/[A-Za-z0-9_-]+' "$rawlog" | tail -n1 || true)

  if [[ -z $link ]]; then
    echo "${YELLOW}检测完成,但没有在输出里找到可分享的报告链接。${NC}"
    echo "${DIM}(NodeQuality 生成分享链接前一般需要联网上传结果;若刚才网络不稳,可重跑一次。)${NC}"
    return 0
  fi

  report_file="./nodequality-report-${mode}-$(date +%Y%m%d-%H%M%S).txt"
  if printf '%s\n' "$link" > "$report_file" 2>/dev/null; then
    local abs_path; abs_path="$(pwd)/${report_file#./}"
    EXPORTED_REPORTS+=("$abs_path")
    echo "${GREEN}检测完成,报告链接已导出:${NC}"
    echo "  ${CYAN}${link}${NC}"
    echo "${DIM}  已保存到:${abs_path}(一行纯链接,方便直接复制/分享)${NC}"
    echo "${DIM}  注:这个文件只在本次会话期间保留,脚本完全退出后会自动删除。${NC}"
  else
    echo "${GREEN}检测完成,报告链接:${NC} ${CYAN}${link}${NC}"
    echo "${YELLOW}  (当前目录不可写,保存文件失败,但链接本身有效,复制上面这行即可)${NC}"
  fi
}

action_local() {
  while true; do
    echo
    echo "${CYAN}────────── 本机质量检测(NodeQuality)──────────${NC}"
    echo "  ${GREEN}1${NC}) 完整检测模式   ${DIM}硬件 / IP / 网络 / 回程路由 全跑${NC}"
    echo "  ${GREEN}2${NC}) 硬件质量体检   ${DIM}HardwareQuality${NC}"
    echo "  ${GREEN}3${NC}) IP 质量体检    ${DIM}IPQuality${NC}"
    echo "  ${GREEN}4${NC}) 网络质量体检   ${DIM}NetQuality${NC}"
    echo "  ${GREEN}5${NC}) 回程路由检测   ${DIM}Backroute Trace${NC}"
    echo "  ${GREEN}6${NC}) 返回主菜单"
    echo "${CYAN}────────────────────────────────────────────────${NC}"
    local c; read -r -p "${YELLOW}请选择 [1-6]: ${NC}" c || { echo; return 0; }
    case $c in
      1) run_nodequality full ;;
      2) run_nodequality hardware ;;
      3) run_nodequality ip ;;
      4) run_nodequality net ;;
      5) run_nodequality route ;;
      6) return 0 ;;
      '') : ;;
      *) echo "${RED}无效选项:$c${NC}" ;;
    esac
  done
}

# 收集落地节点信息:连接字符串 或 手动输入
collect_proxy_info() {
  echo
  echo "${YELLOW}落地节点信息录入方式:${NC}"
  echo "  ${GREEN}1${NC}) 粘贴连接字符串   ${DIM}自动识别格式${NC}"
  echo "  ${GREEN}2${NC}) 手动输入         ${DIM}IP / 端口 / 账号 / 密码${NC}"
  local m; read -r -p "${YELLOW}输入 1/2 [默认 1]: ${NC}" m || return 1

  case ${m:-1} in
    1)
      echo "${DIM}  支持 socks5://user:pass@host:port、host:port:user:pass、user:pass@host:port 等,${NC}"
      echo "${DIM}  也能直接粘 ALL_PROXY=... 或 curl -x ... 。为防泄露,输入不回显。${NC}"
      local cs
      read -r -s -p "${YELLOW}  连接字符串(不回显): ${NC}" cs || return 1
      echo
      parse_conn_string "$cs" || { cs=''; return 1; }
      cs=''
      show_parsed
      ;;
    2)
      echo "${YELLOW}请选择上游代理协议:${NC}"
      echo "  ${GREEN}1${NC}) socks5   ${GREEN}2${NC}) http   ${GREEN}3${NC}) https"
      local psel; read -r -p "${YELLOW}输入 1/2/3 [默认 1]: ${NC}" psel || return 1
      case ${psel:-1} in
        1) PROXY_PROTO=socks5 ;;
        2) PROXY_PROTO=http ;;
        3) PROXY_PROTO=https ;;
        *) soft "无效协议选项:$psel"; return 1 ;;
      esac
      echo
      echo "${YELLOW}请依次输入落地节点信息(账号密码留空表示免认证):${NC}"
      read -r -p "${YELLOW}  ① IP 地址或域名 : ${NC}" PROXY_HOST || return 1
      [[ -n $PROXY_HOST ]] || { soft "地址不能为空。"; return 1; }
      read -r -p "${YELLOW}  ② 端口          : ${NC}" PROXY_PORT || return 1
      is_port "$PROXY_PORT" || { soft "端口必须是 1-65535 的数字。"; return 1; }
      read -r -p "${YELLOW}  ③ 账号(可留空): ${NC}" PROXY_USER || return 1
      read -r -s -p "${YELLOW}  ④ 密码(可留空,不回显): ${NC}" PROXY_PASS || return 1
      echo
      if [[ -n $PROXY_USER && -z $PROXY_PASS ]]; then
        soft "填了账号但没填密码。若该节点免认证,请把账号也留空。"; return 1
      elif [[ -z $PROXY_USER && -n $PROXY_PASS ]]; then
        soft "填了密码但没填账号。若该节点免认证,请把密码也留空。"; return 1
      fi
      ;;
    *) soft "无效选项:$m"; return 1 ;;
  esac

  if [[ $PROXY_USER == *[$'\n\r']* || $PROXY_PASS == *[$'\n\r']* ]]; then
    soft "账号或密码含换行符,无法处理。"; return 1
  fi
  return 0
}

# ── 落地 IP 检测:起桥接 → 默认执行 IP 纯净度检测 ──────────────────────
action_proxy() {
  collect_proxy_info || return 1

  local tmpbase=${TMPDIR:-/tmp}
  [[ -d $tmpbase && -w $tmpbase ]] || tmpbase=/tmp
  WORKDIR=$(mktemp -d "${tmpbase%/}/iptest.XXXXXXXX") || { soft "无法创建临时目录。"; return 1; }
  chmod 700 "$WORKDIR"
  BRIDGE_NAME="${NAME_PREFIX}$$"
  LOCAL_PORT=$(pick_port) || { teardown_bridge; soft "找不到可用的本地端口。"; return 1; }

  echo
  echo "${BLUE}[1/3] 启动本地无认证桥接 127.0.0.1:${LOCAL_PORT}(上游 ${PROXY_PROTO})…${NC}"
  echo "${DIM}      账密写入 700 权限配置文件挂载进容器,不经命令行,ps/inspect 均不可见。${NC}"
  ensure_image "$GOST_IMAGE" || { teardown_bridge; return 1; }

  local ok=0 fmt
  for fmt in v3 v2; do
    if start_bridge "$fmt"; then
      ok=1; echo "${GREEN}      桥接就绪(gost 配置格式:${fmt})${NC}"; break
    fi
    echo "${DIM}      ${fmt} 格式未能启动,换另一种再试…${NC}"
    docker logs "$BRIDGE_NAME" 2>&1 | tail -5 | sed "s/^/${DIM}        /;s/\$/${NC}/" || true
  done
  (( ok )) || {
    teardown_bridge
    soft "gost 桥接起不来(见上方日志,多为镜像版本与配置格式不匹配,可换 GOST_IMAGE)。"
    return 1
  }

  echo
  echo "${BLUE}[2/3] 验证连通性与落地 IP…${NC}"
  local out_ip my_ip
  if ! out_ip=$(probe_exit_ip "127.0.0.1:${LOCAL_PORT}"); then
    echo "${RED}      连不通。桥接端口是通的,问题在上游节点本身:${NC}" >&2
    echo "${RED}      地址/端口/协议写错、账号密码不对,或该节点不允许你的 IP 接入。${NC}" >&2
    echo "${DIM}      gost 日志:${NC}" >&2
    docker logs "$BRIDGE_NAME" 2>&1 | tail -15 | sed 's/^/        /' >&2 || true
    teardown_bridge; return 1
  fi
  echo "${GREEN}      连通正常,落地公网 IP: ${CYAN}${out_ip}${NC}"
  my_ip=$(probe_exit_ip || true)
  if [[ -n ${my_ip:-} && $my_ip == "$out_ip" ]]; then
    echo "${YELLOW}      ⚠ 落地 IP 与本机出口 IP 相同,代理可能未真正生效。${NC}"
  fi

  # socks5h = 由代理端做 DNS 解析(域名交给落地节点解析),避免 DNS 泄露、
  # 也让基于域名的判定更贴近节点真实环境。该 URL 指向本地无认证桥接,不含凭据。
  local proxy_url="socks5h://127.0.0.1:${LOCAL_PORT}"

  echo
  echo "${BLUE}[3/3] 对落地节点执行 IP 纯净度检测…${NC}"
  run_tool ipquality "$proxy_url" || true

  echo
  echo "${DIM}核对:上面检测报告里的 IP 应为 ${out_ip};若显示成你自己的 IP,${NC}"
  echo "${DIM}说明该工具没读代理变量,本轮结果无效。${NC}"
  teardown_bridge
}

pkg_remove() { # pkg_remove <mgr> <pkg>
  local mgr=$1 pkg=$2
  case $mgr in
    apt-get) DEBIAN_FRONTEND=noninteractive apt-get purge -y "$pkg" >/dev/null 2>&1 ;;
    dnf)     dnf remove -y "$pkg" >/dev/null 2>&1 ;;
    yum)     yum remove -y "$pkg" >/dev/null 2>&1 ;;
    apk)     apk del "$pkg" >/dev/null 2>&1 ;;
    pacman)  pacman -Rns --noconfirm "$pkg" >/dev/null 2>&1 ;;
    zypper)  zypper --non-interactive remove "$pkg" >/dev/null 2>&1 ;;
    *)       return 1 ;;
  esac
}

# 卸载 Docker(影响面大,调用方须先确认)。how = get.docker.com | 其它(走发行版包)
remove_docker() { # remove_docker <mgr> <how>
  local mgr=$1 how=$2
  if command -v systemctl >/dev/null 2>&1; then
    systemctl disable --now docker docker.socket >/dev/null 2>&1 || true
  elif command -v rc-service >/dev/null 2>&1; then
    rc-service docker stop >/dev/null 2>&1 || true; rc-update del docker >/dev/null 2>&1 || true
  elif command -v service >/dev/null 2>&1; then
    service docker stop >/dev/null 2>&1 || true
  fi
  if [[ $how == get.docker.com ]]; then
    case $mgr in
      apt-get)
        DEBIAN_FRONTEND=noninteractive apt-get purge -y \
          docker-ce docker-ce-cli containerd.io docker-buildx-plugin \
          docker-compose-plugin docker-ce-rootless-extras >/dev/null 2>&1 || true
        rm -f /etc/apt/sources.list.d/docker.list \
              /etc/apt/keyrings/docker.asc /etc/apt/keyrings/docker.gpg >/dev/null 2>&1 || true ;;
      dnf) dnf remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 || true ;;
      yum) yum remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 || true ;;
      *)   pkg_remove "$mgr" docker || true ;;
    esac
  else
    pkg_remove "$mgr" docker || true
    [[ $mgr == apk ]] && pkg_remove apk docker-cli || true
  fi
  if [[ $mgr == apt-get ]]; then
    DEBIAN_FRONTEND=noninteractive apt-get autoremove -y >/dev/null 2>&1 || true
  fi
}

# 按安装清单卸载依赖:普通包直接删;Docker 单独二次确认(默认不删)
purge_deps() {
  if [[ ! -s $MANIFEST ]]; then
    echo "${DIM}  没有记录到由本脚本安装的依赖,跳过依赖卸载。${NC}"
    return 0
  fi
  local kind a b
  local -a docker_lines=()
  while IFS='|' read -r kind a b; do
    if [[ -z ${kind:-} ]]; then continue; fi
    case $kind in
      docker) docker_lines+=("$kind|$a|$b") ;;
      pkg)
        if [[ $b == docker || $b == docker-cli ]]; then
          docker_lines+=("$kind|$a|$b")
        else
          echo "${DIM}  卸载依赖 $b(经 $a)…${NC}"
          if pkg_remove "$a" "$b"; then
            echo "${DIM}    已卸载 $b${NC}"
          else
            echo "${YELLOW}    $b 卸载失败,请手动检查${NC}"
          fi
        fi ;;
    esac
  done < "$MANIFEST"

  if (( ${#docker_lines[@]} == 0 )); then
    : > "$MANIFEST"
    return 0
  fi

  echo
  echo "${YELLOW}  本脚本当初自动安装过 Docker。卸载它会影响本机【所有】Docker 用途,不只是本工具。${NC}"
  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    local nimg ncon
    nimg=$(docker images -q 2>/dev/null | wc -l | tr -d ' ')
    ncon=$(docker ps -aq 2>/dev/null | wc -l | tr -d ' ')
    echo "${DIM}    当前本机有 ${nimg} 个镜像、${ncon} 个容器,卸载 Docker 后都将不可用。${NC}"
  fi
  local ans=''
  if (( ASSUME_YES )); then
    ans=y
  elif [[ -t 0 ]]; then
    read -r -p "${YELLOW}  确认一并卸载 Docker?[y/N]: ${NC}" ans || ans=''
  else
    ans=n
  fi
  if [[ $ans =~ ^[Yy]$ ]]; then
    local line k m h
    for line in "${docker_lines[@]}"; do
      IFS='|' read -r k m h <<< "$line"
      echo "${DIM}  卸载 Docker(经 $m)…${NC}"
      remove_docker "$m" "$h"
    done
    echo "${DIM}  注:/var/lib/docker(镜像与卷)保留,如需彻底清空请自行删除。${NC}"
    : > "$MANIFEST"
  else
    echo "${DIM}  已跳过 Docker 卸载。需要时手动卸载,或加 -y 再跑一次「彻底清除」。${NC}"
    printf '%s\n' "${docker_lines[@]}" > "$MANIFEST"
  fi
}

action_purge() {
  echo
  echo "${YELLOW}[彻底清除] 移除本工具产生的容器、镜像、依赖与临时凭据…${NC}"
  teardown_bridge
  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    local stale
    stale=$(docker ps -aq --filter "name=^${NAME_PREFIX}" 2>/dev/null || true)
    if [[ -n $stale ]]; then
      # shellcheck disable=SC2086
      docker rm -f $stale >/dev/null 2>&1 || true
      echo "${DIM}  已删除遗留桥接容器${NC}"
    fi
    local img
    for img in "$GOST_IMAGE" "$RUNNER_IMAGE"; do
      if docker image inspect "$img" >/dev/null 2>&1; then
        docker rmi -f "$img" >/dev/null 2>&1 && echo "${DIM}  已删除镜像 ${img}${NC}" || \
          echo "${YELLOW}  镜像 ${img} 删除失败(可能被其他容器占用)${NC}"
      fi
    done
  else
    echo "${DIM}  Docker 不可用,跳过容器 / 镜像清理。${NC}"
  fi
  find "${TMPDIR:-/tmp}" /tmp -maxdepth 1 -type d -name 'iptest.*' -exec rm -rf {} + 2>/dev/null || true
  purge_deps
  if [[ ! -s $MANIFEST ]]; then
    rm -rf "$STATE_DIR" 2>/dev/null || true
  fi
  echo "${GREEN}[完成] 已彻底清除:镜像、依赖与残留。${NC}"
}
第 12 段:主流程
BASH
# ══════════════════════════════════════════════════════════════════════
#  主流程
第 13 段:主菜单与入口
BASH
# ══════════════════════════════════════════════════════════════════════
echo "${CYAN}════════════════════════════════════════════════════${NC}"
echo "${GREEN}         VPS / 落地节点 IP 检测工具  (ip-test)       ${NC}"
echo "${CYAN}════════════════════════════════════════════════════${NC}"

if (( PURGE_ONLY )); then
  action_purge; exit 0
fi

echo "${DIM}检查依赖(缺失将自动安装,可用 --no-install 关闭)…${NC}"
ensure_deps
if (( ${#INSTALLED_PKGS[@]} )); then
  echo "${GREEN}依赖就绪(本次安装:${INSTALLED_PKGS[*]})${NC}"
else
  echo "${GREEN}依赖就绪。${NC}"
fi

stale=$(docker ps -aq --filter "name=^${NAME_PREFIX}" 2>/dev/null || true)
if [[ -n $stale ]]; then
  echo "${DIM}清扫上次残留的桥接容器…${NC}"
  # shellcheck disable=SC2086
  docker rm -f $stale >/dev/null 2>&1 || true
fi

while true; do
  echo
  echo "${CYAN}──────────────── 主菜单 ────────────────${NC}"
  echo "  ${GREEN}1${NC}) 本机质量检测   ${DIM}NodeQuality 综合体检${NC}"
  echo "  ${GREEN}2${NC}) 落地 IP 检测   ${DIM}经代理,默认跑纯净度${NC}"
  echo "  ${GREEN}3${NC}) 彻底清除脚本   ${DIM}删镜像 + 依赖${NC}"
  echo "  ${GREEN}4${NC}) 退出"
  echo "${CYAN}────────────────────────────────────────${NC}"
  read -r -p "${YELLOW}请选择 [1-4]: ${NC}" CHOICE || { echo; break; }
  case $CHOICE in
    1) action_local ;;
    2) action_proxy || echo "${DIM}(本轮落地检测未完成,可重试)${NC}" ;;
    3) action_purge; echo "${GREEN}已彻底清除,脚本退出。${NC}"; break ;;
    4) echo "${GREEN}再见。${NC}"; break ;;
    '') : ;;
    *) echo "${RED}无效选项:$CHOICE${NC}" ;;
  esac
done
PVE 宿主机:官方不建议在 PVE 宿主机直接装 Docker(会改写 iptables/nftables FORWARD 策略),建议在 LXC/VM 里跑本脚本。
回程路由:落地检测没有回程路由项——SOCKS5/HTTP 代理不承载 ICMP,经代理追踪要么失败要么走宿主机真实线路。
硬盘 I/O:容器内测硬盘受 overlay 文件系统影响,仅供参考;要精确数据请直接在宿主机/VM 里跑。
镜像保留:检测用的 Docker 镜像默认保留,只有走菜单第 3 项或 --purge 才会删除。