工具脚本
IP 检测脚本
VPS / 落地节点 IP 检测:本机 NodeQuality 一体化体检 + 经代理的落地 IP 纯净度检测。
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 才会删除。