#!/bin/bash

# ==================================================
# tcpo —— TCP/UDP 网络调优面板
# 一键执行: bash <(curl -sL <你的分发地址>)
# 安装后直接敲: tcpo
# ==================================================

# 版本号。启动时的版本检查按字符串不等来判「有新版」，不做大小比较——
# 语义化版本的数值比较在纯 bash 里要拆三段，而这里只需要「和线上不一样就提示」。
# [人工决策-2026-08-05 14:22:07] 只用单个整数递增，不用 semver 的三段式。
# 判据是「不等即提示」本来就不需要大小关系，三段式反而要求维护者每次判断
# 这算 minor 还是 patch。改动此处必须同步 VERSION 文件（Jenkins 门 4 会逐字校验）
SCRIPT_VERSION="4"

# --- 分发地址：改这一处即可（自建域名 / GitHub raw / jsDelivr）---
# 支持环境变量覆盖，便于本地测试：TCP_DASHBOARD_URL=http://127.0.0.1:8000/tcpo bash tcpo
# Jenkinsfile 发布到 tcp.itssx.com 并校验此处地址，改动需同步 Jenkinsfile 的 SITE_URL
SCRIPT_URL="${TCP_DASHBOARD_URL:-https://tcp.itssx.com/tcpo}"

# 版本检查候选源按顺序试，第一个成功即停：自建域名优先，GitHub raw 兜底；
# 只读文本文件、不下载脚本本体，有新版也只提示不自动更新
VERSION_URLS="https://tcp.itssx.com/VERSION
https://raw.githubusercontent.com/SunSeekerX/tcpo/main/VERSION"
# 单源超时，两源总耗时上限 6 秒，失败静默跳过
VERSION_TIMEOUT=3
# 检查结果缓存，避免每次开面板联网；文件存「时间戳 版本号」
VERSION_CACHE_TTL=86400

# 安装路径即命令名，装到 PATH 后本身即命令，不需要软链。名字取 tcp optimize；
# 不能用 tcpd（Debian tcpd/tcm 包占用 /usr/sbin|bin/tcpd，会被 PATH 优先级静默劫持）
# 或 ttcp（撞经典吞吐测试工具名）。已查过 Debian/RHEL/Alpine/Arch 四系均不冲突
SCRIPT_PATH="/usr/local/bin/tcpo"
# zz- 前缀确保字典序最后加载；多数发行版自带 99-sysctl.conf 排在前面会盖掉这里的值——
# 曾实测 somaxconn 写了 65535、实际生效仍是 1024，且无任何报错
SYSCTL_OPT="/etc/sysctl.d/zz-network-performance.conf"
LEGACY_SYSCTL_OPT="/etc/sysctl.d/99-network-performance.conf"
BBR_OPT="/etc/sysctl.d/10-bbr.conf"
# tcp_bbr 开机加载配置，集中成常量以便回退清理、演练重定向、写保护统一处理
BBR_MODLOAD="/etc/modules-load.d/bbr.conf"
LIMITS_OPT="/etc/security/limits.d/99-network-performance.conf"
# 关闭 IPv6 单独一份 drop-in：它与「网络调优」不是一回事（是关掉一整个协议族），
# 独立文件才能单独回退，也不会让菜单 5/6 重写主配置时把它带上
IPV6_OPT="/etc/sysctl.d/zz-disable-ipv6.conf"
# gai.conf 与备份，集中成常量以纳入演练模式路径重定向（曾散落七处硬编码，
# 导致 TCPO_DRYRUN=1 跑菜单 2 仍真实改机）
GAI_CONF="/etc/gai.conf"
GAI_BAK="/etc/gai.conf.bak"
# 状态标记与诊断要读真机状态，不跟演练重定向走，否则演练模式会让用户误以为真机已改；
# 写入用 $GAI_CONF（受重定向），只读探测用这个
GAI_CONF_REAL="/etc/gai.conf"
# systemd 服务的 fd 上限走 DefaultLimitNOFILE 而非 PAM/limits.d，写成 drop-in 便于整份回退
SYSTEMD_LIMITS_OPT="/etc/systemd/system.conf.d/zz-tcpo-limits.conf"

# 状态目录：原始值快照（回退用）、被接管文件清单、sysctl 应用日志
STATE_DIR="/var/lib/tcp-dashboard"
ORIG_STATE="$STATE_DIR/original-values"
TAKEOVER_LIST="$STATE_DIR/taken-over"
APPLY_LOG="$STATE_DIR/apply.log"
# 版本检查缓存，一行「时间戳 版本号」，实现每天最多查一次
VERSION_CACHE="$STATE_DIR/version-check"
# /sys 设置重启即失效，用 plan + oneshot unit 持久化，plan 与实时应用共用同一份内容
NICPLAN="$STATE_DIR/nic.plan"
# qdisc 重放单独一份 plan：格式（要跑 tc 命令、区分 mq）与 $NICPLAN 不同，混一起会互相干扰
QDISCPLAN="$STATE_DIR/qdisc.plan"
# 出向 HTB 整形（限速器拐点扫描结果）：独立脚本 + unit，可单独 shape --off 而不动基础调优
SHAPE_SCRIPT="/usr/local/bin/tcp_dashboard_shape.sh"
SHAPE_UNIT="/etc/systemd/system/tcp-dashboard-shape.service"
SHAPE_RATE="$STATE_DIR/shape.rate"
SWEEP_RESULT="$STATE_DIR/sweep.result"
# 扫描用锁：两个会话同时换 qdisc / 写快照会踩坏现场
TCPO_LOCK="$STATE_DIR/tcpo.lock"
NIC_UNIT="/etc/systemd/system/tcp-dashboard-nic.service"
NIC_APPLY="/usr/local/bin/tcp-dashboard-nic-apply.sh"
# networkd-dispatcher 钩子：网卡变 routable 时重放队列设置，只有装了 systemd-networkd 才有此目录
NIC_DISPATCH="/etc/networkd-dispatcher/routable.d/50-tcp-dashboard"
# iptables 无 drop-in 概念，comment 标记是唯一可靠的规则归属判据
MSS_TAG="tcpo-mss-clamp"
# RTT/抖动探测目标，集中一处避免探测函数与文案分叉
RTT_PROBE_TARGETS="1.1.1.1 8.8.8.8 9.9.9.9"
# RFS 全局流表单独一份 drop-in，使菜单 7 可独立于主配置文件执行
NIC_SYSCTL_OPT="/etc/sysctl.d/zz-network-rfs.conf"
# 无 systemd 环境开机不读 sysctl.d，生成重放脚本挂到自家 init；不自动改 wsl.conf/rc 目录
SYSCTL_APPLY="/usr/local/bin/tcp-dashboard-sysctl-apply.sh"
# 重放脚本每次运行记一行 uptime（非墙钟时间）：uptime 小才说明是开机早期被 init 拉起，
# 比解析启动配置猜测更可靠，也比只记「跑过」更能防手工执行的误判
SYSCTL_APPLY_STAMP="$STATE_DIR/sysctl-apply.stamp"
# 「开机早期」上限：init 拉起服务通常在开机几秒到一两分钟内，300 秒兼顾慢机器
HOOK_MAX_UPTIME=300

# ==================================================
# 演练模式（TCPO_DRYRUN=1）
# ==================================================
# 把全部写入路径重定向到临时目录，整条流程照跑但不进 /etc；真实路径与演练路径
# 跑同一份代码，不用在每个写入函数里加 if，也就不存在漏判 dry-run 的分支。
# 用途：让用户零副作用看清会写什么，也便于 CI 验证生成逻辑。
# sysctl --system/tc/iptables 这类改内核状态的动作由各函数自行判断 DRYRUN 跳过
DRYRUN=0
if [ "${TCPO_DRYRUN:-0}" = "1" ]; then
    DRYRUN=1
    DRYRUN_ROOT=$(mktemp -d 2>/dev/null) || {
        echo "演练模式无法创建临时目录，退出" >&2
        exit 1
    }
    # 目录结构照抄真实布局，方便用户直接 diff
    mkdir -p "$DRYRUN_ROOT/etc/sysctl.d" "$DRYRUN_ROOT/etc/security/limits.d" \
        "$DRYRUN_ROOT/etc/systemd/system.conf.d" "$DRYRUN_ROOT/etc/systemd/system" \
        "$DRYRUN_ROOT/etc/modules-load.d" \
        "$DRYRUN_ROOT/var/lib/tcp-dashboard" "$DRYRUN_ROOT/usr/local/bin" 2>/dev/null
    SYSCTL_OPT="$DRYRUN_ROOT$SYSCTL_OPT"
    LEGACY_SYSCTL_OPT="$DRYRUN_ROOT$LEGACY_SYSCTL_OPT"
    BBR_OPT="$DRYRUN_ROOT$BBR_OPT"
    # 新增受管文件必须同步加进这份重定向清单，否则演练模式会真的写 /etc
    BBR_MODLOAD="$DRYRUN_ROOT$BBR_MODLOAD"
    LIMITS_OPT="$DRYRUN_ROOT$LIMITS_OPT"
    IPV6_OPT="$DRYRUN_ROOT$IPV6_OPT"
    GAI_CONF="$DRYRUN_ROOT$GAI_CONF"
    GAI_BAK="$DRYRUN_ROOT$GAI_BAK"
    SYSTEMD_LIMITS_OPT="$DRYRUN_ROOT$SYSTEMD_LIMITS_OPT"
    NIC_SYSCTL_OPT="$DRYRUN_ROOT$NIC_SYSCTL_OPT"
    NIC_UNIT="$DRYRUN_ROOT$NIC_UNIT"
    NIC_APPLY="$DRYRUN_ROOT$NIC_APPLY"
    NIC_DISPATCH="$DRYRUN_ROOT$NIC_DISPATCH"
    SYSCTL_APPLY="$DRYRUN_ROOT$SYSCTL_APPLY"
    STATE_DIR="$DRYRUN_ROOT$STATE_DIR"
    ORIG_STATE="$STATE_DIR/original-values"
    TAKEOVER_LIST="$STATE_DIR/taken-over"
    APPLY_LOG="$STATE_DIR/apply.log"
    VERSION_CACHE="$STATE_DIR/version-check"
    NICPLAN="$STATE_DIR/nic.plan"
    QDISCPLAN="$STATE_DIR/qdisc.plan"
    SHAPE_SCRIPT="$DRYRUN_ROOT$SHAPE_SCRIPT"
    SHAPE_UNIT="$DRYRUN_ROOT$SHAPE_UNIT"
    SHAPE_RATE="$STATE_DIR/shape.rate"
    SWEEP_RESULT="$STATE_DIR/sweep.result"
    TCPO_LOCK="$STATE_DIR/tcpo.lock"
    SYSCTL_APPLY_STAMP="$STATE_DIR/sysctl-apply.stamp"
fi

# 演练模式下打出「将要执行但被跳过」的动作；真实模式返回 1，调用方据此决定是否真的执行
dryrun_skip() {
    [ "$DRYRUN" = "1" ] || return 1
    echo -e "  ${CYAN}[演练]${NC} 跳过: $*"
    return 0
}

# 演练结束时把生成的文件内容全部倒出来给用户看
dryrun_report() {
    [ "$DRYRUN" = "1" ] || return 0
    local f n=0
    echo
    echo -e "${YELLOW}=========== 演练结果：将写入的文件 ===========${NC}"
    # find 而不逐个变量判断，新增写入点时不用改这里
    while IFS= read -r f; do
        [ -f "$f" ] || continue
        n=$((n + 1))
        echo -e "\n${CYAN}--- ${f#$DRYRUN_ROOT} ---${NC}"
        sed 's/^/  /' "$f"
    done <<EOF
$(find "$DRYRUN_ROOT" -type f 2>/dev/null | sort)
EOF
    if [ "$n" -eq 0 ]; then
        echo -e "  ${YELLOW}（本次流程未生成任何文件）${NC}"
    fi
    echo
    echo -e "${GREEN}演练结束，系统未做任何改动。临时目录: $DRYRUN_ROOT${NC}"
    echo -e "${YELLOW}退出后可自行删除: rm -rf $DRYRUN_ROOT${NC}"
}

# 演练模式不写系统文件，所以不需要 root；非 root 下 sysctl -n 读某些 key 会失败，
# 推导值可能略有差异，但配置结构与推导链一致
if [ "$EUID" -ne 0 ] && [ "${TCPO_DRYRUN:-0}" != "1" ]; then
    echo -e "\033[0;31m错误: 必须使用 root 权限运行此脚本！\033[0m"
    echo -e "\033[1;33m提示: 只想看看会改什么？用 TCPO_DRYRUN=1 tcpo（无需 root，不写任何文件）\033[0m"
    exit 1
fi

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
NC='\033[0m'

draw_line() {
    echo -e "${YELLOW}--------------------------------------------------${NC}"
}

# 校验下载文件是否真是本脚本，首次安装与菜单 31 更新共用同一份、严格程度一致。
# 三条判据任一不过即放弃：首行是 shebang（防 HTML 错误页）、过 bash -n（防截断）、
# 含本脚本特征串（防拿到别的 shell 脚本）
validate_script() {
    local f=$1
    head -1 "$f" 2>/dev/null | grep -q '^#!/bin/bash' || {
        echo -e "  ${RED}首行不是 #!/bin/bash（拿到的可能是 HTML 错误页）${NC}" >&2
        return 1
    }
    bash -n "$f" 2>/dev/null || {
        echo -e "  ${RED}bash -n 语法检查未通过（传输可能被截断）${NC}" >&2
        return 1
    }
    grep -q '^SCRIPT_VERSION=' "$f" || {
        echo -e "  ${RED}缺少 SCRIPT_VERSION，不像是本脚本${NC}" >&2
        return 1
    }
    return 0
}

# BATCH=1 时不等待回车，供「一键全部」连续执行
BATCH=0
pause_return() {
    [ "$BATCH" = "1" ] && return
    read -p "按回车返回..."
}

# 排空 stdin 里已排队的键（不阻塞、有上限）。
# 自动选点会卡几秒，期间敲的回车躺在缓冲里；下一句 read 会瞬间吃掉当取消。
# [人工决策] -t 0 在部分 WSL/bash 上会「一直成功但不消费」→ while 死循环，
# 表现就是卡在「带宽按…对端…」后面、确认提示永远不出来。必须：短超时 + 次数上限。
drain_stdin() {
    local _z n=0
    while [ "$n" -lt 64 ]; do
        # 0.05s：没键立刻失败退出循环；有键读 1 字节再继续
        IFS= read -r -t 0.05 -n 1 _z 2>/dev/null || break
        n=$((n + 1))
    done
    return 0
}

# 交互确认统一从 /dev/tty 读，避免 stdin 被管道/残留搞乱。
# 无 tty 时退回 stdin（非交互/CI）。
read_tty() {
    # usage: read_tty "提示" varname
    local _prompt=$1 _var=$2 _val
    if [ -r /dev/tty ]; then
        read -r -p "$_prompt" _val </dev/tty || _val=""
    else
        read -r -p "$_prompt" _val || _val=""
    fi
    printf -v "$_var" '%s' "$_val"
}

# ==================================================
# --- 通用工具 ---
# ==================================================
# ==================================================
# --- 依赖安装 ---
# ==================================================
# 顺序：apt-get → dnf → yum（Fedora/RHEL9 两者共存，yum 是软链）
detect_pkg_mgr() {
    local pm
    for pm in apt-get dnf yum zypper pacman apk; do
        command -v "$pm" >/dev/null 2>&1 && { echo "$pm"; return 0; }
    done
    return 1
}

# 按命令名而非包名组织：同一命令各发行版包名不同，且 ss/nstat/tc 来自同一个包
pkg_for() {
    local tool=$1 pm=$2
    case "$tool" in
    ss | nstat | ip)
        case "$pm" in
        apt-get) echo iproute2 ;;
        dnf | yum) echo iproute ;;
        zypper) echo iproute2 ;;
        pacman) echo iproute2 ;;
        apk) echo iproute2 ;;
        esac
        ;;
    tc)
        # RHEL 系把 tc 拆成独立包 iproute-tc，装 iproute 拿不到它（实测 Fedora41/Rocky9/Alma9）
        case "$pm" in
        apt-get) echo iproute2 ;;
        dnf | yum) echo iproute-tc ;;
        zypper) echo iproute2 ;;
        pacman) echo iproute2 ;;
        apk) echo iproute2 ;;
        esac
        ;;
    ethtool) echo ethtool ;;
    iptables)
        # 全系写 iptables 即可：RHEL9+ 无 iptables 包但 iptables-nft 声明 Provides: iptables，
        # dnf install iptables 照样能装上（判包名存在要用 provides，用 info 会误判映射错误）
        echo iptables
        ;;
    ping)
        case "$pm" in
        apt-get) echo iputils-ping ;;
        dnf | yum) echo iputils ;;
        zypper) echo iputils ;;
        pacman) echo iputils ;;
        apk) echo iputils ;;
        esac
        ;;
    tracepath)
        case "$pm" in
        apt-get) echo iputils-tracepath ;;
        dnf | yum) echo iputils ;;
        zypper) echo iputils ;;
        pacman) echo iputils ;;
        apk) echo iputils ;;
        esac
        ;;
    iperf3) echo iperf3 ;;
    sysctl)
        # RHEL 系最小化镜像（实测 rockylinux:9）不自带 sysctl，procps-ng 提供它。
        # 判例：缺它时 `sysctl -n` 全部失败，本脚本把每个 key 都记成 ABSENT——
        # 快照全空、生效校验全报读不到、菜单 3 的状态判定恒假（关不掉也恢复不了）。
        # Alpine 的 sysctl 来自 busybox（已内置），仍给出 procps 映射作兜底
        case "$pm" in
        apt-get) echo procps ;;
        dnf | yum) echo procps-ng ;;
        zypper) echo procps ;;
        pacman) echo procps-ng ;;
        apk) echo procps ;;
        esac
        ;;
    *) echo "$tool" ;;
    esac
}

# apt 索引是否够新，需跨进程持久（用户可能反复进出脚本）。判据用 update-success-stamp
# 或实际索引文件，不用 lists/lock（lock 被任何 apt 操作 touch，会误判为新鲜致装包报 Unable to locate package）
apt_index_fresh() {
    local stamp age
    # apt 的 periodic 成功戳（unattended-upgrades 装了才有），存在就直接采信
    stamp=/var/lib/apt/periodic/update-success-stamp
    if [ -f "$stamp" ]; then
        age=$(($(date +%s) - $(stat -c %Y "$stamp" 2>/dev/null || echo 0)))
        [ "$age" -lt 86400 ] && return 0
    fi
    # 否则要求确实存在一天内的 Packages 索引文件。
    # find 命中即表示「索引存在且新」，两个条件一次满足
    [ -n "$(find /var/lib/apt/lists -maxdepth 1 -name '*_Packages*' -mtime -1 2>/dev/null | head -1)" ]
}

APT_UPDATED=0
# 装包套超时：软件源不可达时 apt/dnf 会挂很久（实测卡满 10 分钟拉不到索引），无超时用户没法中断
PKG_TIMEOUT=180
# 装包日志：输出不直接打印（几百行刷屏），但留档供排查
PKG_LOG="$STATE_DIR/pkg.log"

# 后台跑装包命令，前台显示转轮+已用秒数（全静默会让用户以为卡死）；无 tty 时退化为静默
run_pkg() {
    local rc pid spin='-\|/' i=0 elapsed=0
    mkdir -p "$STATE_DIR" 2>/dev/null
    printf '=== %s %s\n' "$(date '+%F %T' 2>/dev/null)" "$*" >>"$PKG_LOG" 2>/dev/null

    if command -v timeout >/dev/null 2>&1; then
        timeout "$PKG_TIMEOUT" "$@" >>"$PKG_LOG" 2>&1 &
    else
        "$@" >>"$PKG_LOG" 2>&1 &
    fi
    pid=$!

    # 非交互环境直接等，不画转轮
    if [ ! -t 2 ]; then
        wait "$pid"
        return $?
    fi

    while kill -0 "$pid" 2>/dev/null; do
        i=$(((i + 1) % 4))
        # \r 回到行首原地刷新，不新增行。\033[K 清到行尾，比补空格可靠——
        # 秒数从 9s 涨到 10s 会变宽，靠固定宽度的空格补位迟早留残影
        printf '\r    %s %ds\033[K' "${spin:$i:1}" "$elapsed" >&2
        sleep 1
        elapsed=$((elapsed + 1))
    done
    wait "$pid"
    rc=$?
    # 清掉转轮那一行，后续输出从干净的行首开始
    printf '\r\033[K' >&2
    return $rc
}

pkg_install() {
    local pm=$1 pkg=$2
    case "$pm" in
    apt-get)
        # 变量前缀对 shell 函数不起作用，必须 export
        export DEBIAN_FRONTEND=noninteractive
        if [ "$APT_UPDATED" = "0" ] && ! apt_index_fresh; then
            echo -e "  ${YELLOW}  先更新 apt 索引（首次较慢，最多等 ${PKG_TIMEOUT}s）${NC}" >&2
            run_pkg apt-get update -qq ||
                echo -e "  ${YELLOW}apt 索引更新超时，软件源可能不可达${NC}" >&2
            APT_UPDATED=1
        fi
        run_pkg apt-get install -y -qq "$pkg" && return 0
        # 装失败且未 update 过：可能误判了索引新鲜度，补一次再试，防止误判导致依赖降级
        if [ "$APT_UPDATED" = "0" ]; then
            echo -e "  ${YELLOW}$pkg 装不上，补一次 apt 索引更新后重试 ...${NC}" >&2
            run_pkg apt-get update -qq
            APT_UPDATED=1
            run_pkg apt-get install -y -qq "$pkg" && return 0
        fi
        return 1
        ;;
    dnf) run_pkg dnf install -y -q "$pkg" ;;
    yum) run_pkg yum install -y -q "$pkg" ;;
    zypper) run_pkg zypper --non-interactive --quiet install -y "$pkg" ;;
    pacman)
        # 先试不同步（-S），失败再同步（-Sy），避免每装一包都拉一遍数据库
        run_pkg pacman -S --noconfirm --needed --quiet "$pkg" ||
            run_pkg pacman -Sy --noconfirm --needed --quiet "$pkg"
        ;;
    apk) run_pkg apk add --quiet --no-cache "$pkg" ;;
    *) return 1 ;;
    esac
}

# 确保若干命令可用，缺的自动装，返回仍缺失的命令列表。用法: missing=$(ensure_tools ss nstat tc)
# 装不上只降级不报错退出，调优面板不该因一个诊断工具装不上就拒绝干活
ensure_tools() {
    local want=("$@") missing=() need_install=() t pm pkg
    for t in "${want[@]}"; do
        command -v "$t" >/dev/null 2>&1 || need_install+=("$t")
    done
    [ ${#need_install[@]} -eq 0 ] && return 0

    pm=$(detect_pkg_mgr) || {
        printf '%s\n' "${need_install[@]}"
        return 1
    }

    echo -e "  ${YELLOW}缺少 ${need_install[*]}，正在安装 ...${NC}" >&2
    # 先把包名去重（ss/nstat/tc 都来自 iproute2，只需装一次）
    local pkgs="" seen
    for t in "${need_install[@]}"; do
        pkg=$(pkg_for "$t" "$pm")
        case " $pkgs " in *" $pkg "*) continue ;; esac
        pkgs="$pkgs $pkg"
    done
    local total done_n=0
    total=$(printf '%s\n' $pkgs | wc -l | tr -d ' ')
    for pkg in $pkgs; do
        done_n=$((done_n + 1))
        [ "$total" -gt 1 ] && echo -e "  ${YELLOW}[$done_n/$total] $pkg ...${NC}" >&2
        if ! pkg_install "$pm" "$pkg"; then
            echo -e "  ${YELLOW}$pkg 安装失败，详见 $PKG_LOG${NC}" >&2
        fi
    done

    # 装完复查，仍缺的返回给调用方
    for t in "${need_install[@]}"; do
        command -v "$t" >/dev/null 2>&1 || missing+=("$t")
    done
    if [ ${#missing[@]} -gt 0 ]; then
        echo -e "  ${YELLOW}仍缺 ${missing[*]}，相关功能降级${NC}" >&2
        local hint=""
        for t in "${missing[@]}"; do
            pkg=$(pkg_for "$t" "$pm")
            case " $hint " in *" $pkg "*) continue ;; esac
            hint="$hint $pkg"
        done
        case "$pm" in
        apt-get) echo -e "  ${YELLOW}手动装: apt install -y$hint${NC}" >&2 ;;
        dnf | yum) echo -e "  ${YELLOW}手动装: $pm install -y$hint${NC}" >&2 ;;
        zypper) echo -e "  ${YELLOW}手动装: zypper install -y$hint${NC}" >&2 ;;
        pacman) echo -e "  ${YELLOW}手动装: pacman -S$hint${NC}" >&2 ;;
        apk) echo -e "  ${YELLOW}手动装: apk add$hint${NC}" >&2 ;;
        esac
        printf '%s\n' "${missing[@]}"
        return 1
    fi
    return 0
}

# 只读功能（菜单 20/21/22）专用，绝不装包，只检测报告缺什么并给手动补装命令——
# 这三个菜单对外承诺零副作用，而装包会刷新索引、改 dpkg/rpm 数据库，不算零副作用。
# 返回 0 = 全部就绪，1 = 有缺失
require_tools_readonly() {
    local want=("$@") missing=() t pm pkg hint=""
    for t in "${want[@]}"; do
        command -v "$t" >/dev/null 2>&1 || missing+=("$t")
    done
    [ ${#missing[@]} -eq 0 ] && return 0

    echo -e "  ${YELLOW}缺少 ${missing[*]}，相关指标会跳过（只读功能不会自动装包）${NC}" >&2
    if pm=$(detect_pkg_mgr); then
        for t in "${missing[@]}"; do
            pkg=$(pkg_for "$t" "$pm")
            case " $hint " in *" $pkg "*) continue ;; esac
            hint="$hint $pkg"
        done
        case "$pm" in
        apt-get) echo -e "  ${YELLOW}要补装:  apt install -y$hint${NC}" >&2 ;;
        dnf | yum) echo -e "  ${YELLOW}要补装:  $pm install -y$hint${NC}" >&2 ;;
        zypper) echo -e "  ${YELLOW}要补装:  zypper install -y$hint${NC}" >&2 ;;
        pacman) echo -e "  ${YELLOW}要补装:  pacman -S$hint${NC}" >&2 ;;
        apk) echo -e "  ${YELLOW}要补装:  apk add$hint${NC}" >&2 ;;
        esac
    fi
    return 1
}

# RHEL 系最小化安装可能没有 /etc/sysctl.d（Debian 系自带易掩盖此问题），
# 直接 cat > 会静默失败——曾实测 Rocky 9 容器里菜单 5 全程无配置写入
ensure_sysctl_dir() {
    [ -d /etc/sysctl.d ] && return 0
    mkdir -p /etc/sysctl.d 2>/dev/null && return 0
    echo -e "  ${RED}无法创建 /etc/sysctl.d（只读文件系统？），配置无法持久化${NC}"
    return 1
}

# busybox 的 sysctl 不认 --system（只有 -p <文件>）。
# [人工决策-2026-08-16 16:50:51] 判例（alpine:3.21 实测）：busybox sysctl 遇到
# --system 会打印 "unrecognized option" + 自己的帮助文本，参数一个都不应用，
# 而 apply_sysctl 的报错过滤只捞到帮助文本里的字样、判不出失败，于是一路报「已生效」。
# 后果不限于本次新增的 IPv6：菜单 4/5/6 在 Alpine 上写的参数从来就没被应用过。
# 过去没暴露是因为 distro-test 只断言配置文件内容、不查运行时值（容器里 net.core.* 本就读不到），
# 本轮新增的 IPv6 运行时断言是第一条真查生效值的。
# 判据用 `sysctl --help` 里有没有 system 字样：直接跑 --system 试探会真的改机器
sysctl_supports_system() {
    sysctl --help 2>&1 | grep -q -- '--system'
}

# 逐个应用 sysctl.d 下的配置文件，供不支持 --system 的 busybox 用。
# 顺序必须与 sysctl.d(5) 一致：所有目录的文件按文件名字典序排序、同名的 /etc 优先，
# 否则本脚本的 zz- 前缀就赢不到「最后加载」这件事（那是它整个设计的前提）
apply_sysctl_files() {
    local d f base seen="" list=""
    # 先按 basename 收集去重（同名文件 /etc 优先，与 systemd-sysctl 的覆盖规则一致）
    for d in /etc/sysctl.d /run/sysctl.d /usr/local/lib/sysctl.d /usr/lib/sysctl.d /lib/sysctl.d; do
        [ -d "$d" ] || continue
        for f in "$d"/*.conf; do
            [ -f "$f" ] || continue
            base=$(basename "$f")
            case " $seen " in *" $base "*) continue ;; esac
            seen="$seen $base"
            list="$list$base $f
"
        done
    done
    # 按 basename 排序后逐个 -p；/etc/sysctl.conf 最后应用（sysctl.d(5) 的规定）
    printf '%s' "$list" | sort | while read -r base f; do
        [ -n "$f" ] || continue
        echo "* Applying $f ..."
        sysctl -p "$f" 2>&1
    done
    [ -f /etc/sysctl.conf ] && {
        echo "* Applying /etc/sysctl.conf ..."
        sysctl -p /etc/sysctl.conf 2>&1
    }
    return 0
}

# sysctl --system 的报错不能丢，此前统一 &>/dev/null 导致排查拿不到内核给的原因
apply_sysctl() {
    mkdir -p "$STATE_DIR"
    # 只跑一次并复用输出，避免两次结果不一致
    local out errs mode
    dryrun_skip "sysctl --system（应用内核参数）" && return 0
    # [人工决策-2026-08-16 22:31:43] 缺 sysctl 命令时必须明确失败，不能继续。
    # 旧写法直接跑 `sysctl --system`，shell 报 "command not found" 但退出码/输出
    # 对不上后面的报错过滤（只 grep cannot|error|...），于是 apply 与回退都当成功，
    # 运行时值一项都没动。菜单 30 也会一路打出「回退完成」
    if ! command -v sysctl >/dev/null 2>&1; then
        ensure_tools sysctl >/dev/null
        if ! command -v sysctl >/dev/null 2>&1; then
            echo -e "  ${RED}缺 sysctl 命令且自动安装失败，无法应用/还原内核参数${NC}" >&2
            echo -e "  ${YELLOW}手动补装后重试（包名: procps-ng / procps）${NC}" >&2
            {
                echo "===== $(date '+%F %T') sysctl 不可用 ====="
                echo "command not found: sysctl"
            } >>"$APPLY_LOG"
            return 1
        fi
    fi
    if sysctl_supports_system; then
        mode="sysctl --system"
        out=$(sysctl --system 2>&1)
    else
        # busybox：逐文件 -p，顺序与 sysctl.d(5) 一致
        mode="sysctl -p 逐文件（本机 sysctl 不支持 --system）"
        out=$(apply_sysctl_files)
    fi
    {
        echo "===== $(date '+%F %T') $mode ====="
        printf '%s\n' "$out"
    } >>"$APPLY_LOG"
    # 只把真正的报错回显给用户（正常输出是一堆 "* Applying /etc/sysctl.d/xx.conf ..."）
    # 覆盖 command not found / No such file / unrecognized option（busybox）等
    errs=$(printf '%s\n' "$out" |
        grep -iE 'cannot|error|unknown|invalid|permission|not found|No such|unrecognized' |
        head -8)
    [ -z "$errs" ] && return 0

    # sysctl --system 应用系统全部 conf，报错可能不来自本脚本（如 systemd 自带的
    # 50-default.conf 通配 accept_source_route 命中伪接口 all 被拒，每次都会打印）。
    # 判据：报错行的 key 是否属于本脚本受管的 MANAGED_KEYS
    local ours="" theirs="" line key
    while IFS= read -r line; do
        [ -n "$line" ] || continue
        # sysctl 报错有三种格式都要认，第三种是路径形式，转点号 key 再比对
        key=$(printf '%s\n' "$line" | sed -n 's/.*key "\([^"]*\)".*/\1/p')
        if [ -z "$key" ]; then
            key=$(printf '%s\n' "$line" |
                sed -n 's|.*/proc/sys/\([A-Za-z0-9_/.-]*\).*|\1|p' | tr '/' '.')
        fi
        if [ -n "$key" ] && printf '%s\n' $MANAGED_KEYS | grep -qxF "$key"; then
            ours="$ours$line
"
        else
            theirs="$theirs$line
"
        fi
    done <<EOF
$errs
EOF

    if [ -n "$ours" ]; then
        echo -e "${RED}  本脚本管理的参数写入失败（完整日志见 $APPLY_LOG）:${NC}"
        printf '%s' "$ours"
    fi
    if [ -n "$theirs" ]; then
        echo -e "${YELLOW}  以下报错来自系统里其他 sysctl 配置文件，与本次调优无关（详见 $APPLY_LOG）:${NC}"
        printf '%s' "$theirs"
    fi
    return 0
}

# ==================================================
# --- 环境探测：决定「写进 /etc/sysctl.d 之后开机还会不会生效」---
# ==================================================
# 判据：本机开机时有没有人读 /etc/sysctl.d/；有 systemd 才有 systemd-sysctl
# [人工决策-2026-08-05 14:22:07] 加这一层的起因是实测判例：WSL2 默认不跑 systemd，
# 面板跑完参数确实生效，wsl --shutdown 后全部回到原值，而主菜单始终显示「已开启」、
# 菜单 5 还打印「配置持久化于 xxx」——用户以为早就调好了，实际每次重启都白调。
# 菜单 7 的网卡队列本来就查 has_systemd 并如实降级，sysctl 这条路径漏了同样的判断。
# 规则：凡宣布「持久化」前必须先探测本机开机会不会重放，禁止无条件打印

# 容器判据，原内联在 verify_sysctl_applied 里，现两处都要用故提出来
is_container() {
    [ -f /.dockerenv ] && return 0
    grep -qaE 'docker|containerd|lxc' /proc/1/cgroup 2>/dev/null
}

# 必须在 is_container 之后判：Docker Desktop 容器跑在 WSL2 内核上，/proc/version
# 同样含 microsoft，只看这一条会把普通容器误判成 WSL
is_wsl() {
    [ -e /proc/sys/fs/binfmt_misc/WSLInterop ] && return 0
    [ -d /run/WSL ] && return 0
    grep -qi microsoft /proc/version 2>/dev/null
}

# 容器与 sysvinit/openrc 机器上装 unit 是无效动作，不能装完就宣布已持久化
has_systemd() {
    command -v systemctl >/dev/null 2>&1 || return 1
    [ -d /run/systemd/system ] || return 1
    return 0
}

# /etc/wsl.conf 里已配置的 [boot] command（没有则空串），只取第一条（WSL 只认一个）
wsl_boot_command() {
    [ -f /etc/wsl.conf ] || return 0
    sed -n 's/^[[:space:]]*command[[:space:]]*=[[:space:]]*//p' /etc/wsl.conf | head -1
}

# 开机会不会重放 sysctl 配置，返回 0 表示会。判据按环境分开：WSL 必须先判
# （/etc/rcS.d/S01procps 链接在 WSL 的 /init 下不会被执行，只看 rc 链接会误报能持久化），
# 容器由宿主决定脚本管不了。与「本脚本重放脚本是否已挂进启动链」是两件事，必须分开判
# （判例见 sysctl_replay_ok）
# [人工决策-2026-08-05 21:29:17] 不再宣称「已挂进启动链」这个事实——它无法被可靠判定，
# 六轮尝试全部被推翻，两条路各有不可消除的缺陷：
#   A) 扫启动配置 grep 脚本名：静态判断任意 shell 会不会执行某脚本要解析 shell 语义，
#      grep 做不到。被纯注释、行尾注释、不可执行的 .bak、`echo 那个名字` 逐一击穿
#   B) 看运行痕迹（时间戳 / uptime）：证明得了「跑过」，证明不了「配置还在」。
#      用户跑过一次后把 rc.local 删掉，本次 boot 内会一直误报已挂；而「开机 N 秒内
#      跑的」也分不清是 init 拉起还是用户刚开机就手动执行——固定阈值不解决这个歧义
# 结论：面板改为只陈述能确证的事，把不确定的部分交给用户判断。
# 于是本函数只回答「这台机器上，本脚本是否**可能**已挂进启动链」，用于决定措辞：
#   有运行痕迹 -> 说「检测到它在本次开机早期运行过，看起来已挂好；请自行确认配置还在」
#   无运行痕迹 -> 说「开机不会自动重放」并给出挂法
# 两种措辞都不断言「已持久化」。这样最坏情况是用户多确认一次，而不是被告知一件假事
# 戳里记的那个 uptime（供提示文案引用，让用户自己判断那次运行是不是 init 拉起的）
sysctl_apply_stamp_up() {
    local b u
    [ -f "$SYSCTL_APPLY_STAMP" ] || { echo "?"; return 0; }
    read -r b u _ <"$SYSCTL_APPLY_STAMP" 2>/dev/null
    case "${u:-x}" in '' | *[!0-9]*) echo "?" ;; *) echo "$u" ;; esac
}

# 本次开机的标识，用于把「本次开机记录」与「上次的」区分开，不必靠 uptime 大小去猜。
# [人工决策-2026-08-06 01:05:22] 替代标识必须是「重启后必然变化」的东西，
# 不能是任何基于时间的近似。两版被推翻的写法：
#   1) 比 uptime 大小：分不清开机——上次留下 uptime 5，本次开机 120 秒钩子还没跑，
#      5 <= 120 且 <= 300 仍为真，旧痕迹长期伪装成本次
#   2) 开机墙钟时刻 now-uptime + ±120 秒宽容：短命开机会被合并——上一轮开机只持续
#      40 秒就重启，两次的 at: 只差 40 秒，落在宽容窗口内仍判同一次开机。
#      宽容窗口是必需的（NTP 校时、时钟漂移），所以这条路本身就自相矛盾：
#      窗口够大才能容忍漂移，够小才能区分快速重启，两者不可兼得
# 现在的替代标识：在 tmpfs 上放一个随机 token。tmpfs 重启即清空，所以
# token 还在 == 同一次开机，token 没了 == 重启过。这是精确判据不是近似。
# 目录按 /run → /dev/shm → /tmp 依次尝试（前两个必为 tmpfs；/tmp 在部分发行版
# 也是 tmpfs，不是则退化为「跨重启仍存在」，那时宁可报未挂也不误报已挂，见下）
BOOT_TOKEN_DIRS="/run /dev/shm"
boot_ident() {
    local id d f tok
    id=$(cut -c1-36 /proc/sys/kernel/random/boot_id 2>/dev/null)
    if [ -n "$id" ]; then
        printf 'id:%s' "$id"
        return 0
    fi
    # 没有 boot_id：用 tmpfs 上的 token
    for d in $BOOT_TOKEN_DIRS; do
        # 必须确认这个目录真的是 tmpfs——不是的话 token 会跨重启留下来，
        # 那就等于没有判据（旧痕迹又会伪装成本次）
        case "$(stat -f -c %T "$d" 2>/dev/null)" in
        tmpfs | ramfs) ;;
        *) continue ;;
        esac
        [ -w "$d" ] || continue
        f="$d/.tcpo-boot-token"
        if [ -f "$f" ] && [ ! -L "$f" ]; then
            read -r tok _ <"$f" 2>/dev/null
            case "${tok:-x}" in '' | *[!0-9a-f]*) tok="" ;; esac
            [ -n "$tok" ] && {
                printf 'tok:%s' "$tok"
                return 0
            }
        fi
        # 本 boot 还没有 token：生成一个。用 /proc/sys/kernel/random/uuid，
        # 拿不到就退回 $$ 与 uptime 拼（同一 boot 内只生成一次，够用）
        tok=$(tr -dc 'a-f0-9' </proc/sys/kernel/random/uuid 2>/dev/null | cut -c1-32)
        [ -n "$tok" ] || tok=$(printf '%x%x' "$$" "$(cut -d. -f1 /proc/uptime 2>/dev/null || echo 0)")
        [ -n "$tok" ] || continue
        [ -L "$f" ] && continue
        printf '%s\n' "$tok" >"$f" 2>/dev/null && {
            chmod 600 "$f" 2>/dev/null
            printf 'tok:%s' "$tok"
            return 0
        }
    done
    # 既没有 boot_id 也没有可用的 tmpfs：无法可靠区分开机，如实返回失败。
    # 调用方按「无法判定」处理——只会多提示一句挂法，不会误报已挂
    return 1
}

sysctl_apply_ran_at_boot() {
    [ -x "$SYSCTL_APPLY" ] || return 1
    # [人工决策-2026-08-05 23:05:41] 戳里记的必须是**本次开机内最早一次**运行的
    # uptime，且带 boot_id。原先每次运行都覆盖，于是钩子在开机 5 秒时成功跑过之后，
    # 用户同一 boot 内再手工跑一次，早期痕迹被覆盖成大 uptime，
    # sysctl_apply_ran_at_boot 立刻转假——一个其实挂得好好的启动链被报成
    # 「开机不会自动重放」，BBR 那边也跟着退回「重启回到 cubic」的错误结论。
    # 靠 uptime 大小区分开机新旧也不够（重启后 uptime 归零，旧记录反而更大，
    # 但同一 boot 内被覆盖的情况无从分辨），所以改为 boot_id + 只保留最早值
    local rec_ident rec_up cur_ident
    [ -f "$SYSCTL_APPLY_STAMP" ] || return 1
    # 文件格式：「<开机标识> <该 boot 内最早一次运行的 uptime 整秒> <人类可读时间>」
    # 开机标识形如 id:<boot_id> 或 at:<开机墙钟时刻>，见 boot_ident()
    read -r rec_ident rec_up _ <"$SYSCTL_APPLY_STAMP" 2>/dev/null || return 1
    case "${rec_up:-x}" in '' | *[!0-9]*) return 1 ;; esac
    # 标识取不到就是「无法判定」，按未挂处理——宁可多提示一句挂法，
    # 不可让上一次开机的痕迹伪装成本次
    cur_ident=$(boot_ident) || return 1
    [ -n "$cur_ident" ] || return 1
    boot_ident_same "$rec_ident" "$cur_ident" || return 1
    [ "$rec_up" -le "$HOOK_MAX_UPTIME" ]
}

# 逐字相等才算同一次开机，不留宽容窗口
boot_ident_same() {
    [ -n "$1" ] && [ "$1" = "$2" ]
}

# 开机会不会重新应用 /etc/sysctl.d/*.conf。只管 sysctl，不代表 BBR 模块也会加载
sysctl_replay_ok() {
    has_systemd && return 0
    is_container && return 1
    # 重放脚本有运行痕迹（迹象，非确证）
    sysctl_apply_ran_at_boot && return 0
    # WSL 的 /init 不是 sysvinit，rc 链接不会被执行
    if is_wsl; then return 1; fi
    # sysvinit：procps 的 rcS 链接
    [ -x /etc/init.d/procps ] && ls /etc/rc[S2].d/S*procps >/dev/null 2>&1 && return 0
    # openrc：判据是 runlevel 链接存在（Alpine 默认不 add）
    [ -e /etc/runlevels/boot/sysctl ] || [ -e /etc/runlevels/sysinit/sysctl ] ||
        [ -e /etc/runlevels/default/sysctl ]
}

# 开机会不会加载 tcp_bbr 模块。与 sysctl 分开判：模块没加载时内核直接拒绝 bbr
# 判例：sysvinit 上 procps 重放 sysctl 但 BBR 实际没恢复
bbr_reload_ok() {
    # 内建时不需要加载，判据是 modules.builtin
    grep -qE '(^|/)tcp_bbr\.ko' "/lib/modules/$(uname -r)/modules.builtin" 2>/dev/null && return 0
    # [人工决策-2026-08-05 16:58:33] 判据必须是「开机时谁会加载它」，不是
    # 「现在可用列表里有没有 bbr」。available 含 bbr 只说明本次会话里模块已加载
    # （可能是手工 modprobe、或本面板刚 modprobe 的），与下次开机毫无关系。
    # 旧写法在「模块已临时加载 + 没有任何持久化配置」时报「开机可恢复」，
    # 而重启后无人加载、静默回到 cubic——正是本函数要防的那个假报
    if has_systemd; then
        modules_load_has_bbr && return 0
        return 1
    fi
    grep -qE '^[[:space:]]*tcp_bbr[[:space:]]*$' /etc/modules 2>/dev/null && return 0
    # [人工决策-2026-08-05 22:14:33] 重放脚本在本次开机早期跑过也要算进来（返回 2）。
    # 上一轮把它排除是错的：那个脚本开机时会先 modprobe tcp_bbr 再 sysctl --system，
    # 所以它确实解决了模块加载。用户按提示挂好之后，面板仍报「重启后静默回到 cubic」
    # 并再次让他生成/挂载脚本——稳定的假阴性，且提示自相矛盾（那个脚本就是干这个的）。
    # 但也不能当成完全确证：痕迹证明不了那段启动配置现在还在。
    # 所以用第三种返回值：0=确证、2=有迹象但需用户核实、1=没有任何依据。
    # 调用方据此给三套措辞，而不是把「有迹象」硬归到确证或否定的任一边
    sysctl_apply_ran_at_boot && return 2
    return 1
}

# modules-load.d 里是否写着 tcp_bbr（enable_bbr 落盘后验证 + bbr_reload_ok 判开机恢复共用）
modules_load_has_bbr() {
    local f
    # [人工决策-2026-08-05 18:31:44] 只查 *.conf，不能对整个目录递归 grep：
    # systemd 明文规定「Files must have the ".conf" extension」(man 5 modules-load.d)，
    # 所以 bbr.conf.bak、.dpkg-old、编辑器临时文件里的 tcp_bbr 开机不会被读。
    # 递归 grep 会把这类备份文件当成「开机会自动加载」，于是 BBR 仍被误报为可恢复。
    # [人工决策-2026-08-05 19:20:38] 只查 /etc 与 /usr/lib，不查 /run：
    # /run 是 tmpfs（实测 findmnt -no FSTYPE /run 为 tmpfs），重启即清空。
    # 那里有配置只证明「本次 boot 有人临时放过」，不证明「下次开机还会加载」——
    # 拿它当持久化依据会把临时文件误报成 BBR 可持久恢复
    # [人工决策-2026-08-05 20:31:05] 目录清单以 systemd-modules-load 二进制里的路径
    # 字符串为准，不以 man 的 SYNOPSIS 为准——SYNOPSIS 只列了 /etc、/run、/usr/lib
    # 三个，但同一页 DESCRIPTION 正文写着「Files in /etc/ override files with the same
    # name in /run/, /usr/local/lib/, and /lib/」，而 strings 二进制确认搜索路径是五个：
    # /etc、/run、/usr/local/lib、/usr/lib、/lib。
    # 漏掉 /usr/local/lib 的后果是：tcp_bbr 由那里提供时会被误判成「开机没人加载」，
    # 于是菜单 4/5/6 报错误告警、还多生成一份不需要的重放脚本与挂载提示。
    # /lib 通常是 /usr/lib 的符号链接，但不能假定（有发行版不做 usr-merge），一并查
    for f in /etc/modules-load.d/*.conf /usr/local/lib/modules-load.d/*.conf \
        /usr/lib/modules-load.d/*.conf /lib/modules-load.d/*.conf; do
        [ -f "$f" ] || continue
        grep -qE '^[[:space:]]*tcp_bbr[[:space:]]*$' "$f" 2>/dev/null && return 0
    done
    # /etc/modules 是 Debian 系 kmod 的清单，systemd-modules-load 也会读
    grep -qE '^[[:space:]]*tcp_bbr[[:space:]]*$' /etc/modules 2>/dev/null
}

# 无 systemd 环境的开机重放脚本。
# [人工决策-2026-08-05 14:22:07] 三个方案里选「生成兜底脚本 + 告知挂法」，不选
# 「只提示不生成」也不选「新增菜单项自动挂」：
#   - 只提示的话用户得自己写这个脚本，而正确写法有坑（modprobe 要在 sysctl 之前，
#     否则 BBR 是模块时 tcp_congestion_control=bbr 会被内核直接拒绝）
#   - 自动挂要改 wsl.conf 或 rc 目录，那会改变整个发行版的启动方式，超出「网络参数」
#     范畴，也不再是「文件级可完整回退」——与项目边界冲突
# 所以只把东西备好、挂哪由用户决定。这是本项目「不越界」与「别让用户自己踩坑」的折中
write_sysctl_apply() {
    # [人工决策-2026-08-05 16:22:41] 这是安全问题而非假成功提示，优先级高于其余写入点
    refuse_if_symlink "$SYSCTL_APPLY" || return 1
    if ! { cat >"$SYSCTL_APPLY" <<APPLYEOF
#!/bin/bash
# 由 tcpo 生成：无 systemd 环境（WSL / sysvinit / 容器）里开机重放网络调优。
# systemd 机器不需要它——systemd-sysctl 会自动应用 /etc/sysctl.d/*.conf。
# 先 modprobe 再应用参数：BBR 是模块时，/etc/modules-load.d 在无 systemd 环境
# 同样没人读，不先加载模块的话 tcp_congestion_control=bbr 会被内核直接拒绝。
# 条件是「本脚本管的任一配置文件里写了 bbr」而不是「$BBR_OPT 存在」——菜单 5/6
# 把 bbr 写进主配置文件，那时 $BBR_OPT 并不存在，只看它会漏掉这条最常见的路径
for _c in $BBR_OPT $SYSCTL_OPT; do
    [ -f "\$_c" ] && grep -qE '^[[:space:]]*net\.ipv4\.tcp_congestion_control[[:space:]]*=[[:space:]]*bbr' "\$_c" 2>/dev/null && {
        modprobe tcp_bbr 2>/dev/null
        break
    }
done
# 这里不经 apply_sysctl（面板里的 sysctl --system 一律走那个入口以便回显内核报错）：
# 本脚本在开机时跑、没有终端可回显，且是独立文件调不到面板的函数。
# 输出落日志供事后排查，别丢掉。
# [人工决策-2026-08-16 16:50:51] busybox 的 sysctl 不认 --system（alpine 实测）：
# 它打印 unrecognized option + 帮助文本、参数一个都不应用，退出码仍是 0。
# 开机重放脚本不能假设宿主是 util-linux/procps 的 sysctl，必须自带降级路径——
# 与面板侧 apply_sysctl 的判据一致，但这里是独立文件，把两条路径都写进去
if sysctl --help 2>&1 | grep -q -- '--system'; then
    sysctl --system >>$APPLY_LOG 2>&1
else
    {
        _seen=""
        for _d in /etc/sysctl.d /run/sysctl.d /usr/local/lib/sysctl.d /usr/lib/sysctl.d /lib/sysctl.d; do
            [ -d "\$_d" ] || continue
            for _f in "\$_d"/*.conf; do
                [ -f "\$_f" ] || continue
                _b=\$(basename "\$_f")
                case " \$_seen " in *" \$_b "*) continue ;; esac
                _seen="\$_seen \$_b"
                echo "\$_b \$_f"
            done
        done
    } | sort | while read -r _b _f; do
        [ -n "\$_f" ] || continue
        echo "* Applying \$_f ..."
        sysctl -p "\$_f" 2>&1
    done >>$APPLY_LOG 2>&1
    [ -f /etc/sysctl.conf ] && {
        echo "* Applying /etc/sysctl.conf ..."
        sysctl -p /etc/sysctl.conf 2>&1
    } >>$APPLY_LOG 2>&1
fi
# 菜单 7 的 /sys 队列设置（RPS/RFS/XPS）也一并重放，省得再挂第二个钩子
[ -x $NIC_APPLY ] && $NIC_APPLY
# 记录「本次开机 + 该 boot 内最早一次运行的 uptime」。面板据此判断本脚本是否被挂进
# 启动链：uptime 很小 => 是 init 在开机早期自动拉起的；很大 => 是谁手工跑的。
# 关键是**只保留最早那次**：同一 boot 内已有记录就不覆盖。
# 覆盖式写法有判例——钩子在开机 5 秒时跑过，用户之后手工再跑一次，早期痕迹被覆盖成
# 大 uptime，面板于是把挂得好好的启动链报成「开机不会自动重放」，BBR 也跟着报错结论
mkdir -p $STATE_DIR 2>/dev/null
# 开机标识：优先 boot_id（id:...），读不到就用 tmpfs 上的随机 token（tok:...）。
# 与面板侧 boot_ident() 必须同格式、同来源——两边不一致的话记录永远比不上。
# 不用「开机墙钟时刻」那种近似判据：短命开机（上一轮只跑几十秒就重启）会被合并成
# 同一次，旧痕迹于是伪装成本次已挂；而 tmpfs 重启即清空，token 在不在是精确判据
_up=\$(cut -d. -f1 /proc/uptime 2>/dev/null || echo 999999)
_bid=\$(cut -c1-36 /proc/sys/kernel/random/boot_id 2>/dev/null)
_ident=""
if [ -n "\$_bid" ]; then
    _ident="id:\$_bid"
else
    for _d in /run /dev/shm; do
        case "\$(stat -f -c %T "\$_d" 2>/dev/null)" in tmpfs | ramfs) ;; *) continue ;; esac
        [ -w "\$_d" ] || continue
        _f="\$_d/.tcpo-boot-token"
        [ -L "\$_f" ] && continue
        if [ -f "\$_f" ]; then
            read -r _tok _ <"\$_f" 2>/dev/null
            case "\${_tok:-x}" in '' | *[!0-9a-f]*) _tok="" ;; esac
        else
            _tok=""
        fi
        if [ -z "\$_tok" ]; then
            _tok=\$(tr -dc 'a-f0-9' </proc/sys/kernel/random/uuid 2>/dev/null | cut -c1-32)
            [ -n "\$_tok" ] || _tok=\$(printf '%x%x' "\$\$" "\$_up")
            printf '%s\n' "\$_tok" >"\$_f" 2>/dev/null && chmod 600 "\$_f" 2>/dev/null
        fi
        [ -n "\$_tok" ] && { _ident="tok:\$_tok"; break; }
    done
fi
# 标识拿不到就不写记录：写个无法比对的标识只会让面板永远判「非本次开机」，
# 不如不写——面板那边会如实说「开机不会自动重放」并给出挂法
[ -n "\$_ident" ] || exit 0
_old_ident=""; _old_up=""
[ -f $SYSCTL_APPLY_STAMP ] && read -r _old_ident _old_up _ <$SYSCTL_APPLY_STAMP 2>/dev/null
case "\${_old_up:-x}" in '' | *[!0-9]*) _old_up="" ;; esac
# 该写还是该保留旧值：
#   没有旧记录 -> 写
#   旧记录与本次是同一次开机 -> 保留（旧的那次更早）
#   不是同一次开机 -> 写（旧痕迹必须让位，否则上次开机的 uptime 5 会长期
#   伪装成本次已挂——判例：无 boot_id 环境下靠「uptime 谁小」判断做不到这一点）
# 两种标识（id: / tok:）都是重启后必然变化的精确值，一律逐字相等才算同一次开机。
# 不留任何宽容窗口——窗口够大才能容忍时钟漂移、够小才能区分快速重启，不可兼得
_same=0
if [ -n "\$_old_up" ] && [ -n "\$_old_ident" ] && [ "\$_old_ident" = "\$_ident" ]; then
    _same=1
fi
_write=1
[ "\$_same" = 1 ] && _write=0
if [ "\$_write" = 1 ]; then
    # 原子写：先落同目录临时文件再 rename，且拒绝跟随符号链接。
    # 这个文件在开机时由 root 写，裸重定向遇到预置的符号链接会覆写链接目标；
    # 半写入还会让「保留本 boot 最早痕迹」的判据变脆（读到空/半行就当没记录）
    if [ -L $SYSCTL_APPLY_STAMP ]; then
        : # 是符号链接：不跟着写出去，也不删用户的链接，直接放弃本次记录
    else
        _tmp=\$(mktemp $STATE_DIR/.stamp.XXXXXX 2>/dev/null) && {
            printf '%s %s %s\n' "\$_ident" "\$_up" "\$(date '+%F %T' 2>/dev/null)" \\
                >"\$_tmp" 2>/dev/null &&
                chmod 644 "\$_tmp" 2>/dev/null &&
                mv -f "\$_tmp" $SYSCTL_APPLY_STAMP 2>/dev/null || rm -f "\$_tmp" 2>/dev/null
        }
    fi
fi
# 恒为 0：开机脚本失败不该拖住启动流程，最坏情况停在「参数没生效」
exit 0
APPLYEOF
    } 2>/dev/null; then
        echo -e "  ${RED}无法写入 $SYSCTL_APPLY（只读挂载 / 磁盘满 / 目录不存在）${NC}"
        return 1
    fi
    if ! chmod 755 "$SYSCTL_APPLY"; then
        echo -e "  ${YELLOW}给 $SYSCTL_APPLY 加可执行位失败，请手动 chmod 755${NC}"
        return 1
    fi
    return 0
}

# 「重放脚本挂到哪」的说明。菜单 4/5/6 与菜单 7 都要打印，提出来共用一份，
# 免得两处文案漂移（一处教对了、另一处还在教旧办法）
# 「看起来已挂好」时给出「怎么自己核实一遍」的具体命令。
# 不替用户下结论，只告诉他去看哪里——那段配置还在不在，只有看配置本身才知道
wsl_boot_hint_verify() {
    if is_wsl; then
        local cur
        cur=$(wsl_boot_command)
        if [ -n "$cur" ]; then
            echo -e "  ${CYAN}当前 [boot] command=$cur${NC}"
        fi
    fi
    echo -e "  ${CYAN}核实: 重启后回来看菜单状态即可确认${NC}"
    return 0
}

wsl_boot_hint() {
    if ! is_wsl; then
        echo -e "  ${CYAN}挂到 init: 把 $SYSCTL_APPLY 加进 /etc/rc.local 或等效位置${NC}"
        return 0
    fi
    local cur
    cur=$(wsl_boot_command)
    if [ -n "$cur" ]; then
        local esc
        esc=$(printf '%s' "$cur" | sed "s/'/'\\\\''/g")
        echo -e "  ${CYAN}WSL 只认一条 command，已有 command=$cur，改成串联:${NC}"
        echo -e "  ${CYAN}    command=/bin/sh -c '$esc; $SYSCTL_APPLY'${NC}"
    else
        echo -e "  ${CYAN}WSL 挂法: /etc/wsl.conf 加 [boot] command=$SYSCTL_APPLY${NC}"
    fi
    echo -e "  ${CYAN}改完执行 wsl --shutdown 重启${NC}"
    return 0
}

# [人工决策-2026-08-05 15:47:12] 判例：sysvinit 机器上 procps 会重放 /etc/sysctl.d，
# 早期版本据此报「已持久化」，但 BBR 实际没恢复。所以模块与参数必须分别核实、分别报
# [人工决策-2026-08-05 22:41:09] 菜单 5/6 从「只给人工命令」改为「自动补齐」：
# 它们已经在写 tcp_congestion_control=bbr 了，不配齐模块加载等于写了一个重启就失效的
# 参数——那本身就不自洽。$BBR_MODLOAD 已在菜单 30 的清理清单里，可完整回退。
# 三个判例都记在这里，别再退回裸重定向：
#   1) 只在「available 里没有 bbr」时写 —— 模块已被手工 modprobe 的机器走不到，
#      于是永远没有持久化配置，重启回 cubic
#   2) echo 重定向不查返回值 —— 只读 /etc 或磁盘满时只往 stderr 报一行、退出码被丢，
#      面板照样报「已持久化」
#   3) 裸重定向绕过 atomic_write —— 这条路径被预置成符号链接时会以 root 跟着写出去，
#      属实际的任意文件覆盖；本项目的最坏边界是「参数没生效」
write_bbr_modload() {
    dryrun_skip "写 $BBR_MODLOAD" && return 0
    if printf 'tcp_bbr\n' | atomic_write "$BBR_MODLOAD" &&
        grep -qE '^[[:space:]]*tcp_bbr[[:space:]]*$' "$BBR_MODLOAD" 2>/dev/null; then
        return 0
    fi
    return 1
}

bbr_persist_note() {
    # [人工决策-2026-08-05 16:41:07] 改为「本次写的文件或任一受管文件里含 bbr」
    if [ "$1" != "$BBR_OPT" ] && [ ! -f "$BBR_OPT" ]; then
        # 本次写的那个文件里有 bbr 吗
        [ -n "$1" ] && [ -f "$1" ] &&
            grep -qE '^[[:space:]]*net\.ipv4\.tcp_congestion_control[[:space:]]*=[[:space:]]*bbr' "$1" 2>/dev/null ||
            return 0
    fi
    # 三态：0=确证有人加载、2=有迹象（重放脚本本次开机早期跑过，它内含 modprobe）、
    # 其他=没有任何依据。「有迹象」不能报成「没人加载」——那与面板刚给的挂法自相矛盾
    bbr_reload_ok
    case $? in
    0) return 0 ;;
    2)
        echo -e "  ${CYAN}BBR 模块: 重放脚本含 modprobe，看起来可恢复${NC}"
        return 0
        ;;
    esac
    echo -e "  ${YELLOW}BBR 模块: 开机无人加载，重启后回到 cubic${NC}"

    # [人工决策-2026-08-05 21:07:44] 原先不分 systemd 与否，一律生成 $SYSCTL_APPLY
    # 并调 wsl_boot_hint。但那套「脚本 + 自己挂进启动链」只对无 systemd 的机器成立：
    # systemd 机器上没人会去执行那个脚本，提示等于让用户照着做也不生效，
    # 而真正的问题是 modules-load.d 没配好——给错方向的 remediation 比不给更糟。
    if has_systemd; then
        if write_bbr_modload; then
            echo -e "  ${GREEN}已补写 $BBR_MODLOAD${NC}（systemd-modules-load 开机加载 tcp_bbr）"
            return 0
        fi
        echo -e "  ${RED}补写 $BBR_MODLOAD 失败（只读 /etc、磁盘满，或该路径是符号链接）${NC}"
        echo -e "  ${YELLOW}手动补: echo tcp_bbr > $BBR_MODLOAD${NC}"
        return 1
    fi

    # 无 systemd：那份重放脚本才是有效手段，它会先 modprobe 再应用 sysctl
    if write_sysctl_apply; then
        echo -e "  ${GREEN}已生成 $SYSCTL_APPLY${NC}（含 modprobe tcp_bbr）"
        wsl_boot_hint
    else
        echo -e "  ${YELLOW}手动补: echo tcp_bbr >> /etc/modules${NC}"
    fi
    return 1
}

# 菜单 4/5/6 写完配置后统一调这里报持久化状态
report_sysctl_persist() {
    local conf=$1
    if has_systemd; then
        # [人工决策-2026-08-05 22:52:17] 原先先打印绿色「配置持久化于」再调
        # bbr_persist_note，于是「已持久化」和随后「重启会回到 cubic」同屏出现、自相矛盾。
        # 现在 bbr_persist_note 会自动补齐 modules-load.d，补上了才报绿；
        # 补不上就降级成黄字——结论必须与实际状态一致
        if bbr_persist_note "$conf"; then
            echo -e "  ${GREEN}已持久化 ($conf)${NC}"
        else
            echo -e "  ${YELLOW}已持久化 ($conf)，但 BBR 那一项见上${NC}"
        fi
        return 0
    fi
    # [人工决策-2026-08-05 21:29:17] 措辞必须留有余地，不能说成「已持久化」——
    # 判例：用户跑过一次后把 rc.local 里那行删掉，本次 boot 内运行痕迹还在，
    # 但下次开机不会再重放；「配置还在不在」无法从运行痕迹推出来。
    # 同样也分不清那次早期运行是 init 拉起还是用户刚开机就手动执行的。
    # 所以只陈述观测到的事实 + 让用户自己确认一句，把判断权交回去
    if sysctl_apply_ran_at_boot; then
        # 脚本仍要重写一遍：挂着的可能是旧版本，或路径被换过
        if write_sysctl_apply; then
            echo -e "  ${GREEN}本次已生效${NC} | 开机重放脚本有运行痕迹（只是运行痕迹，请重启确认）"
            wsl_boot_hint_verify
            bbr_persist_note "$conf"
            return 0
        fi
        echo -e "  ${RED}开机重放脚本 $SYSCTL_APPLY 写入失败，重启后配置会失效${NC}"
        return 1
    fi
    # 发行版自带的机制重放（procps / openrc），须与上面分开说明来源
    if sysctl_replay_ok; then
        echo -e "  ${GREEN}已持久化 ($conf)${NC}（由系统自带的 sysctl 服务重放，非本面板的脚本）"
        bbr_persist_note "$conf"
        return 0
    fi

    echo -e "  ${YELLOW}本次已生效，但重启后回到原值${NC}"
    if is_container; then
        # 措辞必须同时含「本机是容器」与「开机不会重放」：distro-test 与用户都靠这两句
        # 区分「容器限制」与「无 systemd 主机」——合并成「容器环境」后两处断言同时漂
        echo -e "  ${YELLOW}本机是容器，开机不会重放 /etc/sysctl.d/，持久化请在宿主上设置或用 docker run --sysctl${NC}"
        return 1
    fi
    echo -e "  ${YELLOW}本机没有 systemd，开机不会有人读 /etc/sysctl.d/，重启后回到原值${NC}"
    write_sysctl_apply || return 1
    echo -e "  ${GREEN}已生成 $SYSCTL_APPLY${NC}"
    wsl_boot_hint
    bbr_persist_note "$conf"
    return 1
}

# 取内核当前值与目标值中的较大者（红帽 tuned "=>" 语义，只升不降）
sysctl_ceil() {
    local key=$1 want=$2 cur
    cur=$(sysctl -n "$key" 2>/dev/null)
    case "$cur" in
    '' | *[!0-9]*) echo "$want"; return ;;
    esac
    [ "$cur" -gt "$want" ] && echo "$cur" || echo "$want"
}

# 三元组（tcp_rmem/tcp_wmem）逐字段取较大者
sysctl_ceil3() {
    local key=$1 w1=$2 w2=$3 w3=$4 cur c1 c2 c3
    cur=$(sysctl -n "$key" 2>/dev/null | tr -s ' \t' ' ')
    read -r c1 c2 c3 <<<"$cur"
    case "$c1$c2$c3" in
    '' | *[!0-9]*) echo "$w1 $w2 $w3"; return ;;
    esac
    [ "$c1" -gt "$w1" ] && w1=$c1
    [ "$c2" -gt "$w2" ] && w2=$c2
    [ "$c3" -gt "$w3" ] && w3=$c3
    echo "$w1 $w2 $w3"
}

# 回退时按此清单逐个还原运行时值（删文件+sysctl --system 不够，已删 key 的值不会被清）
MANAGED_KEYS="
net.ipv4.tcp_congestion_control
net.core.default_qdisc
net.core.somaxconn
net.core.netdev_max_backlog
net.core.netdev_budget
net.core.netdev_budget_usecs
net.core.rmem_max
net.core.wmem_max
net.core.rps_sock_flow_entries
net.ipv4.tcp_max_syn_backlog
net.ipv4.tcp_syncookies
net.ipv4.ip_local_port_range
net.ipv4.ip_local_reserved_ports
net.ipv4.tcp_rmem
net.ipv4.tcp_wmem
net.ipv4.tcp_notsent_lowat
net.ipv4.tcp_mtu_probing
net.ipv4.udp_rmem_min
net.ipv4.tcp_slow_start_after_idle
net.ipv4.tcp_no_metrics_save
net.ipv4.tcp_tw_reuse
net.ipv4.tcp_fin_timeout
net.ipv4.tcp_fastopen
net.ipv4.ip_forward
fs.nr_open
net.ipv6.conf.all.disable_ipv6
net.ipv6.conf.default.disable_ipv6
net.ipv6.conf.lo.disable_ipv6
"

# 关闭 IPv6 要写的三个 key，与 MANAGED_KEYS 里的 ipv6 三项必须是同一组。
# 单列一份是因为菜单 3 只写这三项、回退只需还原这三项，而 MANAGED_KEYS 是全量清单
IPV6_KEYS="
net.ipv6.conf.all.disable_ipv6
net.ipv6.conf.default.disable_ipv6
net.ipv6.conf.lo.disable_ipv6
"

# [人工决策-2026-08-05 13:47:12] 取「拒绝」而非「跟随」或「删除」：本项目最坏情况
# 必须停在「参数没生效」，而跟随链接写出去可能改到完全无关的文件——那超出了最坏边界
refuse_if_symlink() {
    local target=$1 dst
    [ -L "$target" ] || return 0
    dst=$(readlink -f "$target" 2>/dev/null || readlink "$target" 2>/dev/null)
    echo -e "  ${RED}拒绝写入：$target 是符号链接${NC}" >&2
    echo -e "  ${RED}  -> ${dst:-（目标无法解析，可能是悬空链接）}${NC}" >&2
    echo -e "  ${YELLOW}  本脚本不跟着链接写出去，也不删你的链接。请自行确认那个目标是不是你想改的，${NC}" >&2
    echo -e "  ${YELLOW}  确认后手动处理再重跑。本次未对该文件做任何改动${NC}" >&2
    return 1
}

# 原子写入：先落同目录临时文件再 mv（rename(2) 原子，不会有半截状态）。
# 状态文件（$ORIG_STATE/$TAKEOVER_LIST）是回退唯一依据，变空=回退能力归零
atomic_write() {
    local target=$1 dir tmp
    refuse_if_symlink "$target" || return 1
    dir=$(dirname "$target")
    mkdir -p "$dir" || return 1
    tmp=$(mktemp "$dir/.tcpo.XXXXXX") || return 1
    if ! cat >"$tmp"; then
        rm -f "$tmp"
        return 1
    fi
    # chmod 在 mv 之前：mv 之后再改会有一个短暂的宽权限窗口
    chmod 644 "$tmp" 2>/dev/null
    if ! mv -f "$tmp" "$target"; then
        rm -f "$tmp"
        return 1
    fi
    return 0
}

# 追加一行到文件，同样保证不会因中途失败留下半截文件。
# 追加本身在小于 PIPE_BUF 时基本原子，但「读旧内容 + 写新内容」的组合不是，
# 所以走同一条 atomic_write 路径：把旧内容和新行一起重写
atomic_append() {
    local target=$1 line=$2
    {
        [ -f "$target" ] && cat "$target"
        printf '%s\n' "$line"
    } | atomic_write "$target"
}

# 把网卡从 HTB/单根 qdisc 拆回「内核默认结构 + default_qdisc」。
# 多队列必须保留 mq root、只改叶子——与 apply_live_qdisc / 回退 QDISCPLAN 同一口径。
# 直接 replace root fq 会把 mq 压成单队列，属于行为回归（审查 #2）。
restore_default_qdisc() {
    # 拆 HTB/单根后回到「内核默认结构 + default_qdisc」。
    # 任一步 tc 失败都累计，最终 return 1——调用方不得把失败当成功（审查：吞错假恢复）。
    local iface=$1 want root leaf p cur fail=0 leaf_n=0 leaf_ok=0
    [ -n "$iface" ] || return 1
    command -v tc >/dev/null 2>&1 || return 1
    want=$(sysctl -n net.core.default_qdisc 2>/dev/null)
    [ -n "$want" ] || want=fq

    cur=$(tc qdisc show dev "$iface" 2>/dev/null | awk 'NR==1{print $2}')
    case "$cur" in
    htb)
        # 拆 HTB 后，多队列网卡内核会自动重建 mq root
        if ! tc qdisc del dev "$iface" root 2>/dev/null; then
            fail=1
        fi
        ;;
    mq | mqprio) ;;
    noqueue) return 0 ;;
    '')
        ;;
    *)
        # 单根 fq 等：del 让内核按硬件队列数重建（多队列会回到 mq）
        if ! tc qdisc del dev "$iface" root 2>/dev/null; then
            # 已无 root 时 del 也可能非 0，再读一次确认
            cur=$(tc qdisc show dev "$iface" 2>/dev/null | awk 'NR==1{print $2}')
            case "$cur" in
            htb | '') fail=1 ;;
            esac
        fi
        ;;
    esac

    IFS=$'\t' read -r root leaf <<<"$(qdisc_kinds "$iface")"
    case "$root" in
    mq | mqprio)
        for p in $(tc qdisc show dev "$iface" 2>/dev/null |
            awk '/parent/{for(i=1;i<=NF;i++) if($i=="parent") print $(i+1)}'); do
            leaf_n=$((leaf_n + 1))
            if tc qdisc replace dev "$iface" parent "$p" "$want" 2>/dev/null; then
                leaf_ok=$((leaf_ok + 1))
            fi
        done
        # 想换成 want 时：有叶子却一个都没换上 = 失败。
        # 部分成功（leaf_ok 在 1..n-1）也失败。但 want 在本网卡上根本装不上
        # （WSL/Hyper-V 虚拟口常见：sysctl 是 fq，运行时只能 pfifo_fast）时，
        # 叶子全失败且 root 已非 htb → 视为「尽力恢复」成功，不挡扫描。
        if [ "$leaf_n" -gt 0 ] && [ "$leaf_ok" -ne "$leaf_n" ]; then
            if [ "$leaf_ok" -eq 0 ]; then
                # 全失败：只要没停在 htb，接受现状（虚拟网卡限制）
                :
            else
                fail=1
            fi
        fi
        ;;
    noqueue) ;;
    '')
        # del 之后仍读不到 root：多半权限/设备问题
        fail=1
        ;;
    *)
        if [ "$root" != "$want" ]; then
            if ! tc qdisc replace dev "$iface" root "$want" 2>/dev/null; then
                # 换不到 want：当前已是非 htb 则接受（WSL 上 pfifo_fast 很常见）
                case "$root" in
                htb) fail=1 ;;
                esac
            fi
        fi
        ;;
    esac
    # 终态核对：仍是 htb 必失败；非 htb 即「可作基线/可当拆整形结果」
    cur=$(tc qdisc show dev "$iface" 2>/dev/null | awk 'NR==1{print $2}')
    [ "$cur" = "htb" ] && fail=1
    return "$fail"
}


# 会话互斥：shape/sweep/rollback 都会改 root qdisc 与共享状态文件。
# flock 不可用时降级为警告继续（busybox 最小环境可能没有），不硬挡。
take_tcpo_lock() {
    mkdir -p "$STATE_DIR" 2>/dev/null || true
    command -v flock >/dev/null 2>&1 || {
        echo -e "  ${YELLOW}本机无 flock，无法串行化并发会话；请勿同时开两个 tcpo 改 qdisc${NC}" >&2
        return 0
    }
    # 不能写成 exec 9>FILE 2>/dev/null —— 那个 2>/dev/null 会被 exec 永久重定向 stderr
    [ -w "$STATE_DIR" ] || return 0
    exec 9>"$TCPO_LOCK" || return 0
    if flock -n 9; then
        return 0
    fi
    echo -e "  ${RED}另一个 tcpo 会话正在改网络参数（锁 $TCPO_LOCK）${NC}" >&2
    echo -e "  ${YELLOW}等它结束，或确认无残留进程后: rm -f $TCPO_LOCK${NC}" >&2
    return 1
}

# 端口连通探测：始终带墙钟上限。缺 timeout 命令时用后台 /dev/tcp + kill 自实现。
# 裸 /dev/tcp 在被过滤地址上会无限阻塞，违反「自动联网必须带超时」。
tcp_connect_ok() {
    # 端口连通探测：始终带墙钟上限；host/port 只许主机名/IP 与数字端口，禁止进 bash -c 插值。
    local host=$1 port=$2
    # 主机：字母数字 . _ : -（IPv6 用 :）；拒绝 $ ` ; | & 空格等元字符
    case "$host" in
    '' | *[!A-Za-z0-9._:\[\]-]*) return 1 ;;
    esac
    case "$port" in
    '' | *[!0-9]*) return 1 ;;
    esac
    [ "$port" -ge 1 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null || return 1

    if command -v timeout >/dev/null 2>&1; then
        # 用位置参数传入，不用字符串拼接——内层 bash 看不到可注入文本
        timeout 2 bash -c 'echo >"/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null
        return $?
    fi
    # 无 timeout：后台连 + 轮询，最多约 2 秒。/dev/tcp 展开在本 shell，已校验过格式
    (echo >/dev/tcp/"$host"/"$port") >/dev/null 2>&1 &
    local pid=$! i=0
    while [ "$i" -lt 20 ]; do
        if ! kill -0 "$pid" 2>/dev/null; then
            wait "$pid" 2>/dev/null
            return $?
        fi
        sleep 0.1 2>/dev/null || {
            sleep 1
            i=$((i + 10))
            continue
        }
        i=$((i + 1))
    done
    kill "$pid" 2>/dev/null
    wait "$pid" 2>/dev/null
    return 1
}



# 本版本新增到 MANAGED_KEYS 的 key，供老快照补齐（见 append_missing_originals）。
# 加新参数时把它列进来，下一个版本再清空——留着不会出错（老快照补过就不再补），
# 但清空能让「哪些是本版本新增」这件事保持准确
NEW_MANAGED_KEYS="$IPV6_KEYS"

# 给「本版本新增到 MANAGED_KEYS 的 key」补一次快照。
# [人工决策-2026-08-16 16:50:51] 起因是加菜单 3（关闭 IPv6）时发现的缺陷：
# save_original_values 在快照文件已存在时整个跳过，于是老用户升级后新增 key 永远没有原值，
# restore_managed_keys 把「读不到」记为跳过——参数改得进去、回退还不回来，
# 而回退能力是本项目的最高优先级。
#
# 只补显式传入的 key，绝不遍历整个 MANAGED_KEYS。判例（本轮实测踩到）：
# 按「快照里没这一行就补」遍历全清单时，一台已经跑过 tcpo 的机器上
# netdev_budget=600、tcp_rmem 第三值 11MB 这些**已被本脚本调过的值**
# 会被当成原始值写进快照，回退于是还原到调过的值——比不还原更糟，
# 因为它看起来成功了。安全的前提是「老版本从不写这个 key，所以现值必然是原值」，
# 这个前提只对本版本新增的 key 成立，必须由调用方显式声明而不是靠推断
append_missing_originals() {
    local keys=$1 k v add=""
    [ -n "$keys" ] || return 0
    for k in $keys; do
        orig_value "$k" >/dev/null 2>&1 && continue
        if v=$(sysctl -n "$k" 2>/dev/null); then
            add="$add$k=$(printf '%s' "$v" | tr -s ' \t' ' ' | sed 's/^ //;s/ $//')
"
        else
            add="$add$k=ABSENT
"
        fi
    done
    [ -n "$add" ] || return 0
    {
        cat "$ORIG_STATE"
        printf '%s' "$add"
    } | atomic_write "$ORIG_STATE" || {
        echo -e "  ${RED}补写新增参数的原始值快照失败（磁盘满或 $STATE_DIR 不可写），中止${NC}" >&2
        echo -e "  ${RED}没有快照就无法可靠回退，不继续改动${NC}" >&2
        return 1
    }
    return 0
}

# 快照原始值。只在文件不存在时写，避免二次运行把已调过的值当原始值；
# 文件已存在时只补齐缺失的 key（见 append_missing_originals）
save_original_values() {
    mkdir -p "$STATE_DIR"
    # [人工决策-2026-08-16 16:50:51] 快照前必须确保 sysctl 命令存在，且缺了要中止。
    # 判例（rockylinux:9 最小镜像实测）：该镜像不自带 sysctl（procps-ng 未装），
    # 于是快照里每个 key 都写成 ABSENT——而 ABSENT 的语义是「本内核没这个 key，
    # 回退时跳过」。两种完全不同的情况共用一个标记，结果是整份快照变成废纸：
    # 参数照样改得进去（写文件不需要 sysctl 命令），回退时全部跳过，机器停在改后状态。
    # 这比没有快照更糟——没有快照会报警告，而这种情况回退会报「成功」。
    # 所以宁可不改：项目原则「备份失败必须放弃后续改动，可回退性优先于参数生效」
    if ! command -v sysctl >/dev/null 2>&1; then
        ensure_tools sysctl >/dev/null
        if ! command -v sysctl >/dev/null 2>&1; then
            echo -e "  ${RED}缺 sysctl 命令且自动安装失败，无法读取原始值，中止${NC}" >&2
            echo -e "  ${RED}读不到原值时快照会把全部参数记成「不存在」，回退将静默跳过一切${NC}" >&2
            echo -e "  ${YELLOW}手动补装后重试（包名: procps-ng / procps）${NC}" >&2
            return 1
        fi
    fi
    [ -f "$ORIG_STATE" ] && {
        append_missing_originals "$NEW_MANAGED_KEYS" || return 1
        return 0
    }
    {
        echo "# tcp-dashboard 首次运行前的原始值 $(date '+%F %T')"
        echo "# 格式 key=value；ABSENT 表示该 key 在本内核/命名空间不存在，回退时跳过。"
        echo "# 空值（key= 后面什么都没有）是合法原值，回退时要写回空串而不是跳过"
        local k v
        for k in $MANAGED_KEYS; do
            # 用退出码判 key 存不存在，不用值是否为空（ip_local_reserved_ports 合法值就是空串）
            if v=$(sysctl -n "$k" 2>/dev/null); then
                # 三元组等多值参数里的空白统一成单空格，便于回退时原样写回
                echo "$k=$(printf '%s' "$v" | tr -s ' \t' ' ' | sed 's/^ //;s/ $//')"
            else
                echo "$k=ABSENT"
            fi
        done
    } | atomic_write "$ORIG_STATE" || {
        # 快照写不成就不能继续改动：没有快照的回退等于猜，而猜错是把机器改成第三种状态。
        # 项目原则「备份失败必须放弃后续改动」
        echo -e "  ${RED}原始值快照写入失败（磁盘满或 $STATE_DIR 不可写），中止${NC}" >&2
        echo -e "  ${RED}没有快照就无法可靠回退，不继续改动${NC}" >&2
        return 1
    }
}

# 读回快照里的原始值，没有则返回 1
orig_value() {
    local key=$1
    [ -f "$ORIG_STATE" ] || return 1
    awk -F= -v k="$key" '$1==k {print substr($0, index($0,"=")+1); found=1} END{exit !found}' \
        "$ORIG_STATE" 2>/dev/null
}

# 把所有受管 key 的运行时值恢复到快照记录的原值
restore_managed_keys() {
    local k v restored=0 skipped=0
    if [ ! -f "$ORIG_STATE" ]; then
        echo -e "  ${YELLOW}没有原始值快照，跳过运行时值还原（配置文件已删除，重启后即为系统默认）${NC}"
        return 0
    fi
    # [人工决策-2026-08-16 22:31:43] 缺 sysctl 时必须失败返回，不能把每个 -w 计成
    # skipped 后让调用方打印「回退完成」。那是假回退：配置文件删了、运行时值全留着
    if ! command -v sysctl >/dev/null 2>&1; then
        echo -e "  ${RED}缺 sysctl 命令，无法写回任何运行时值${NC}"
        echo -e "  ${YELLOW}配置文件若已删除，重启后多数参数回系统默认；本次运行时状态未动${NC}"
        return 1
    fi
    # 整份快照都是 ABSENT 说明它是在「读不到任何 sysctl 值」的环境下建的
    # （旧版本在缺 sysctl 命令的机器上会写出这种快照，见 save_original_values 的判例）。
    # 这种快照无法自愈：参数已经被调过，现在重新采样只会把调过的值当成原值写回去，
    # 那比不还原更糟。所以只能如实告知用户，让他自己决定各项该设回什么
    local total_lines absent_lines
    total_lines=$(grep -c '^[a-z].*=' "$ORIG_STATE" 2>/dev/null)
    absent_lines=$(grep -c '=ABSENT$' "$ORIG_STATE" 2>/dev/null)
    if [ "${total_lines:-0}" -gt 0 ] && [ "$absent_lines" = "$total_lines" ]; then
        echo -e "  ${RED}快照里全部 $total_lines 项都记为「不存在」，无法还原任何运行时值${NC}"
        echo -e "  ${YELLOW}原因：这份快照是在读不到 sysctl 值的环境下建的（通常是缺 sysctl 命令）${NC}"
        echo -e "  ${YELLOW}配置文件已删除，重启后各项回到系统默认值；本次不逐项写回${NC}"
        echo -e "  ${YELLOW}（不重新采样：参数已被本脚本调过，现值不是原值，写回去等于固化调后状态）${NC}"
        return 1
    fi
    for k in $MANAGED_KEYS; do
        v=$(orig_value "$k") || { skipped=$((skipped + 1)); continue; }
        # ABSENT=本内核无此 key；空串是合法原值（ip_local_reserved_ports），不能跳
        [ "$v" = "ABSENT" ] && { skipped=$((skipped + 1)); continue; }
        if [ "$DRYRUN" = "1" ]; then
            skipped=$((skipped + 1))
            continue
        elif sysctl -w "$k=$v" &>/dev/null; then
            restored=$((restored + 1))
        else
            skipped=$((skipped + 1))
        fi
    done
    echo -e "  已还原 ${GREEN}${restored}${NC} 项运行时值（$skipped 项不可写或原本不存在，已跳过）"
    # 一项都没还原且不是演练 = 可能是假回退。快照里有非 ABSENT 项却全写失败才算失败
    if [ "$DRYRUN" != "1" ] && [ "$restored" -eq 0 ]; then
        local has_real
        has_real=$(awk -F= '
            $1 ~ /^[a-z]/ {
                v = substr($0, index($0, "=") + 1)
                if (v != "ABSENT") n++
            }
            END { print n + 0 }
        ' "$ORIG_STATE" 2>/dev/null)
        if [ "${has_real:-0}" -gt 0 ]; then
            echo -e "  ${RED}快照里有 $has_real 项可还原，但一项都没写回去${NC}"
            return 1
        fi
    fi
    return 0
}

# 累加 /proc/net/softnet_stat 指定列（十六进制），不用 awk strtonum（mawk 没有）
softnet_sum() {
    local col=$1 sum=0 v
    while read -r -a f; do
        v=${f[$((col - 1))]}
        case "$v" in
        '' | *[!0-9a-fA-F]*) continue ;;
        esac
        sum=$((sum + 16#$v))
    done </proc/net/softnet_stat 2>/dev/null
    echo "$sum"
}

# 按 comment 标记删本脚本加的 MSS clamp 规则。
# 用 iptables-save（不用 -S，后者会加载 iptable_mangle 模块）。
# 旧版本（无 comment）的规则用精确匹配兜底
del_owned_mss() {
    command -v iptables >/dev/null 2>&1 || return 0
    if [ "$DRYRUN" = "1" ]; then echo 0; return 0; fi
    local rule n=0
    if command -v iptables-save >/dev/null 2>&1; then
        # sed 只改行首的 -A，规则体原样回传给 iptables，避免自己拼参数拼错
        while IFS= read -r rule; do
            [ -n "$rule" ] || continue
            # shellcheck disable=SC2086
            if eval "iptables -t mangle $rule" 2>/dev/null; then
                n=$((n + 1))
            fi
        done <<EOF
$(iptables-save -t mangle 2>/dev/null | grep -F -- "--comment $MSS_TAG" | sed 's/^-A /-D /')
EOF
    fi
    # 兜底只在「一条带标记的都没找到」时才做。
    # [人工决策-2026-08-05 12:18:44] 兜底走的是精确文本匹配，它分不清「旧版本 tcpo 加的」
    # 和「用户自己加的同款规则」。实测：用户手加一条 + 本脚本加一条（带标记）时，
    # 无条件兜底会把用户那条也删掉。取舍是宁可留下残余也不删用户的规则——
    # 残余用户能自己看到并处理，误删则是静默破坏别人的配置。
    # 所以只在确认本脚本没留下任何带标记规则时（= 要么从没跑过，要么是旧版本装的）才兜底
    if [ "$n" -eq 0 ]; then
        iptables -t mangle -D FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null &&
            n=$((n + 1))
        iptables -t mangle -D POSTROUTING -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null &&
            n=$((n + 1))
    fi
    echo "$n"
}

# 取默认路由出口网卡。多处要用（诊断、qdisc 校验、MTU 探测、XPS）
egress_dev() {
    ip -o route get 1.1.1.1 2>/dev/null |
        awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}'
}

# 取齐全部内核网络计数器（key=value）。有 nstat 用 nstat，没有则直读 /proc
read_counters() {
    if command -v nstat >/dev/null 2>&1; then
        nstat -saz 2>/dev/null | awk 'NF>=2 {print $1"="$2}'
        return
    fi
    # /proc/net/snmp + netstat 格式：两行一组（表头行+值行），拼出的 key 与 nstat 一致
    awk '
        {
            fam = $1
            sub(/:$/, "", fam)
            if (!(fam in seen)) {
                seen[fam] = 1
                for (i = 2; i <= NF; i++) hdr[fam, i] = $i
                next
            }
            for (i = 2; i <= NF; i++)
                if (hdr[fam, i] != "") print fam hdr[fam, i] "=" $i
        }
    ' /proc/net/snmp /proc/net/netstat 2>/dev/null
}

counter_get() {
    printf '%s\n' "$2" | awk -F= -v k="$1" '$1==k {print $2; exit}'
}

# [人工决策-2026-08-05 15:12:08] 必须累加全部队列丢包，多队列网卡第一行是 mq 汇总（常为 0）
qdisc_drops() {
    local dev=$1
    [ -n "$dev" ] || { echo 0; return; }
    command -v tc >/dev/null 2>&1 || { echo 0; return; }
    tc -s qdisc show dev "$dev" 2>/dev/null |
        awk '{ while (match($0, /dropped [0-9]+/)) {
                   s += substr($0, RSTART+8, RLENGTH-8)
                   $0 = substr($0, RSTART+RLENGTH)
               } }
             END { print s+0 }'
}

# 输出 root<TAB>叶子类型。多队列网卡 root 恒为 mq，真正 qdisc 在叶子上
qdisc_kinds() {
    local dev=$1
    [ -n "$dev" ] || return 1
    command -v tc >/dev/null 2>&1 || return 1
    tc qdisc show dev "$dev" 2>/dev/null | awk '
        NR == 1 { root = $2 }
        # 叶子行形如 "qdisc fq 8001: dev eth0 parent :1 ..."，$2 是类型
        /parent/ { if (!($2 in kind)) { kind[$2] = 1; order[++n] = $2 } }
        END {
            if (root == "") exit 1
            leaves = ""
            for (i = 1; i <= n; i++) leaves = leaves (i > 1 ? " " : "") order[i]
            print root "\t" leaves
        }'
}

# ==================================================
# --- 自动安装 ---
# ==================================================
SELF=$(readlink -f "$0" 2>/dev/null || echo "$0")
# 演练模式不装自己（零写入承诺）。写成把 SELF 改成 SCRIPT_PATH 而不是在 if 上加条件——
# site-test 按 `if [ "$SELF" != "$SCRIPT_PATH" ]` 定位安装块逐条校验，改开头会让断言失效
if [ "$DRYRUN" = "1" ] && [ "$SELF" != "$SCRIPT_PATH" ]; then
    echo -e "${CYAN}[演练]${NC} 跳过自安装到 $SCRIPT_PATH"
    SELF=$SCRIPT_PATH
fi
if [ "$SELF" != "$SCRIPT_PATH" ]; then
    echo -e "${YELLOW}>>> 正在安装脚本到 $SCRIPT_PATH ...${NC}"
    mkdir -p /usr/local/bin

    if [ -f "$SELF" ]; then
        # 本地文件执行：直接复制，不联网。
        # 必须查 cp 的返回值——目标只读/磁盘满/权限异常时它只往 stderr 报一行，
        # 不查就会一路走到「已安装」并 exit 0，而文件根本没落地（实测过）
        if ! cp -f "$SELF" "$SCRIPT_PATH"; then
            echo -e "${RED}安装失败：无法写入 $SCRIPT_PATH${NC}"
            echo -e "  ${YELLOW}检查该路径是否可写（只读挂载 / 磁盘满 / 权限），或换个 PATH 目录${NC}"
            exit 1
        fi
    else
        # 管道执行：先落临时文件、校验再就位（与菜单 31 共用 validate_script）
        if ! curl -fsSL "$SCRIPT_URL" -o "$SCRIPT_PATH.tmp"; then
            rm -f "$SCRIPT_PATH.tmp"
            echo -e "${RED}下载失败，请检查网络或 SCRIPT_URL: $SCRIPT_URL${NC}"
            exit 1
        fi
        if ! validate_script "$SCRIPT_PATH.tmp"; then
            rm -f "$SCRIPT_PATH.tmp"
            echo -e "${RED}安装中止：$SCRIPT_URL 返回的内容不是有效脚本，未写入任何文件。${NC}"
            exit 1
        fi
        if ! mv "$SCRIPT_PATH.tmp" "$SCRIPT_PATH"; then
            rm -f "$SCRIPT_PATH.tmp"
            echo -e "${RED}安装失败：无法写入 $SCRIPT_PATH${NC}"
            echo -e "  ${YELLOW}检查该路径是否可写（只读挂载 / 磁盘满 / 跨设备），或换个 PATH 目录${NC}"
            exit 1
        fi
    fi
    if ! chmod +x "$SCRIPT_PATH"; then
        echo -e "${RED}安装失败：无法给 $SCRIPT_PATH 加可执行位${NC}"
        exit 1
    fi
    # 最后确认一次文件真的在位且可执行。前面每步都查了返回值，这里是兜底：
    # 漏检会让文件未落地却打印安装成功
    if [ ! -x "$SCRIPT_PATH" ]; then
        echo -e "${RED}安装失败：$SCRIPT_PATH 不存在或不可执行${NC}"
        exit 1
    fi

    echo -e "${GREEN}已安装到 $SCRIPT_PATH${NC}"

    # 管道执行时 stdin 已被脚本占用，需重新绑定 /dev/tty 才能进交互；
    # 容器/CI 可能无控制终端，先试开一次避免 ENXIO
    if [ ! -t 0 ]; then
        if { : </dev/tty; } 2>/dev/null; then
            exec bash "$SCRIPT_PATH" </dev/tty
        fi
        # 无 tty 可绑：装完即止，不进交互菜单
        echo -e "${GREEN}安装完成。当前是非交互环境，请运行 ${YELLOW}tcpo${GREEN} 打开面板。${NC}"
        exit 0
    fi
    exec bash "$SCRIPT_PATH"
fi

# ==================================================
# --- 维护：在线更新 / 卸载 ---
# ==================================================
# 版本检查：只读 VERSION 文件，发现新版只提示不自动替换。静默失败、有缓存、多源、可关
LATEST_VERSION=""
fetch_latest_version() {
    [ -n "${TCP_DASHBOARD_NO_CHECK:-}" ] && return 0

    # 缓存读取在 curl 检查之前（离线机仍能读到之前的缓存）
    local now cached_at cached_ver
    now=$(date +%s 2>/dev/null) || return 0
    if [ -f "$VERSION_CACHE" ]; then
        read -r cached_at cached_ver <"$VERSION_CACHE" 2>/dev/null
        # 时间戳必须是纯数字
        case "$cached_at" in
        '' | *[!0-9]*) cached_at=0 ;;
        esac
        if [ "$cached_at" -gt 0 ] && [ $((now - cached_at)) -lt "$VERSION_CACHE_TTL" ] &&
            [ $((now - cached_at)) -ge 0 ]; then
            LATEST_VERSION="$cached_ver"
            return 0
        fi
    fi

    # 缓存过期或没有，才需要联网。curl 缺失时直接放弃（保持 LATEST_VERSION 为空）
    command -v curl >/dev/null 2>&1 || return 0

    local url got
    for url in $VERSION_URLS; do
        # --max-time 防止源不可达时卡住启动；tr -d 清掉 CI 生成文件可能带的 \r\n
        got=$(curl -fsSL --max-time "$VERSION_TIMEOUT" "$url" 2>/dev/null |
            head -1 | tr -d ' \r\n\t')
        # 只接受形似版本号的内容，防止域名泛解析拿到 HTML 页面
        case "$got" in
        '' | *[!0-9.]*) continue ;;
        esac
        LATEST_VERSION="$got"
        break
    done

    # 结果落缓存（含「没查到」的情况，避免每次启动都重试超时）；同样拒绝跟随符号链接
    if mkdir -p "$STATE_DIR" 2>/dev/null && refuse_if_symlink "$VERSION_CACHE" 2>/dev/null; then
        printf '%s %s\n' "$now" "$LATEST_VERSION" >"$VERSION_CACHE" 2>/dev/null
    fi
    return 0
}

# 有新版时给菜单标题用的一行提示。同版本、远端更旧、检查失败都返回空串。
# 只比「不等」会把「远端 2、本地 4」误报成有新版（线上 VERSION 未跟上时实测踩过）。
version_is_newer() {
    # version_is_newer <远端> <本地> → 0 表示远端更新
    local a=$1 b=$2
    case "$a" in '' | *[!0-9.]* | .* | *..) return 1 ;; esac
    case "$b" in '' | *[!0-9.]* | .* | *..) return 1 ;; esac
    # 按 . 分段数值比较，缺段当 0（4 与 4.0 同等；4.1 > 4）
    local IFS=.
    local -a aa bb
    # shellcheck disable=SC2206
    aa=($a) bb=($b)
    local i na=${#aa[@]} nb=${#bb[@]} n max
    max=$na
    [ "$nb" -gt "$max" ] && max=$nb
    for ((i = 0; i < max; i++)); do
        na=${aa[i]:-0}
        nb=${bb[i]:-0}
        case "$na" in '' | *[!0-9]*) na=0 ;; esac
        case "$nb" in '' | *[!0-9]*) nb=0 ;; esac
        if [ "$na" -gt "$nb" ] 2>/dev/null; then return 0; fi
        if [ "$na" -lt "$nb" ] 2>/dev/null; then return 1; fi
    done
    return 1
}

version_hint() {
    [ -n "$LATEST_VERSION" ] || return 0
    [ "$LATEST_VERSION" = "$SCRIPT_VERSION" ] && return 0
    version_is_newer "$LATEST_VERSION" "$SCRIPT_VERSION" || return 0
    printf '%s' "${YELLOW}有新版 ${LATEST_VERSION}（当前 ${SCRIPT_VERSION}，菜单 31 更新）${NC}"
}

check_update() {
    echo -e "${YELLOW}正在从 $SCRIPT_URL 同步...${NC}"
    if curl -fsSL "$SCRIPT_URL" -o "$SCRIPT_PATH.tmp"; then
        # 先校验再替换，避免拉到 HTML/截断文件时把 tcpo 换坏；与首次安装共用 validate_script
        if ! validate_script "$SCRIPT_PATH.tmp"; then
            rm -f "$SCRIPT_PATH.tmp"
            echo -e "${RED}更新失败：$SCRIPT_URL 返回的内容未通过校验，已放弃，当前版本未改动。${NC}"
            pause_return
            return
        fi
        # mv/chmod 都要查返回值，否则失败时会误报「更新成功」
        if ! mv "$SCRIPT_PATH.tmp" "$SCRIPT_PATH"; then
            rm -f "$SCRIPT_PATH.tmp"
            echo -e "${RED}更新失败：无法写入 $SCRIPT_PATH（只读挂载 / 磁盘满 / 权限），当前版本未改动。${NC}"
            pause_return
            return
        fi
        if ! chmod +x "$SCRIPT_PATH" || [ ! -x "$SCRIPT_PATH" ]; then
            echo -e "${RED}更新失败：$SCRIPT_PATH 不可执行，请手动检查。${NC}"
            pause_return
            return
        fi
        # 清掉版本缓存，否则新版启动后仍会提示「有新版」
        rm -f "$VERSION_CACHE"
        echo -e "${GREEN}更新成功，正在重新载入...${NC}"
        exec bash "$SCRIPT_PATH"
    else
        rm -f "$SCRIPT_PATH.tmp"
        echo -e "${RED}更新失败，请检查网络。${NC}"
        pause_return
    fi
}

uninstall_script() {
    echo -e "\n${RED}>>> 卸载脚本并回退全部网络优化${NC}"
    read -p "确定要卸载吗？(会同时回退所有网络优化设置) [y/N]: " confirm
    if [[ "$confirm" =~ ^[Yy]$ ]]; then
        # [人工决策-2026-08-16 22:49:27] 回退失败时绝不能删脚本和状态目录。
        # 判例：rollback_tune 在缺 sysctl 时会清理配置文件但写不回运行时值并 return 1；
        # 旧写法不查返回值，接着 rm -rf $STATE_DIR（快照没了）+ rm 脚本本身——
        # 用户既丢了重试入口，又丢了唯一的原值依据，一次「未完整回退」升级成不可自救。
        # 可回退性是最高优先级：失败就停，把现场留给用户修好后重跑菜单 30 / 卸载
        if ! rollback_tune; then
            echo -e "\n  ${RED}回退未完整，已中止卸载${NC}"
            echo -e "  ${YELLOW}脚本与状态目录（含快照 $ORIG_STATE）均保留，便于修好后重试${NC}"
            echo -e "  ${YELLOW}请确认 sysctl 可用（procps-ng / procps）后：先跑菜单 30，确认回退完成再卸载${NC}"
            pause_return
            return 1
        fi
        # 状态目录里的快照/备份只在卸载时、且回退成功后才删
        rm -rf "$STATE_DIR"
        rm -f "$SCRIPT_PATH"
        echo -e "${GREEN}卸载完成，命令 tcpo 与状态目录均已移除。${NC}\n"
        exit 0
    fi
    echo -e "${GREEN}已取消。${NC}"
    sleep 1
}

# ==================================================
# --- 1. IPv4 优先解析 ---
# ==================================================
set_ipv4_priority() {
    echo -e "\n${YELLOW}>>> 设置 IPv4 优先解析${NC}"

    # 目标是符号链接时拒绝，避免跟着写到链接目标（这里改的是系统文件，非本脚本自有 drop-in）
    refuse_if_symlink "$GAI_CONF" || {
        pause_return
        return 1
    }

    # 演练模式下要先把真实文件拷进临时目录作为起点，否则会走「生成默认模板」分支，
    # 结果与真实改动不符。
    # [人工决策-2026-08-05 15:31:20] 演练必须反映真实起点：读真实文件、写临时副本。
    # 早前版本这个函数完全绕过 DRYRUN 重定向，TCPO_DRYRUN=1 跑菜单 2 会真实改机，
    # 与「零写入」承诺直接冲突
    if [ "$DRYRUN" = "1" ] && [ ! -f "$GAI_CONF" ] && [ -f /etc/gai.conf ]; then
        cp /etc/gai.conf "$GAI_CONF" 2>/dev/null &&
            echo -e "  ${CYAN}[演练]${NC} 已把真实 /etc/gai.conf 复制进演练目录作为起点"
    fi

    if [ ! -f "$GAI_CONF" ]; then
        # 再查一次符号链接：上面的 cp 操作之后状态可能已变
        refuse_if_symlink "$GAI_CONF" || {
            pause_return
            return 1
        }
        if ! cat >"$GAI_CONF" <<'EOF'
label ::1/128       0
label ::/0          1
label 2002::/16     2
label ::/96         3
label ::ffff:0:0/96 4
precedence  ::1/128       50
precedence  ::/0          40
precedence  2002::/16     30
precedence  ::/96         20
precedence  ::ffff:0:0/96 10
EOF
        then
            echo -e "  ${RED}生成 $GAI_CONF 失败（磁盘满 / 只读挂载 / 权限）${NC}"
            pause_return
            return 1
        fi
    fi

    # 备份失败必须放弃后续改动，否则没有备份可回退
    if [ ! -f "$GAI_BAK" ]; then
        if ! cp "$GAI_CONF" "$GAI_BAK"; then
            echo -e "  ${RED}备份 $GAI_CONF 失败，中止（没有备份就无法回退）${NC}"
            pause_return
            return 1
        fi
    fi

    # 两处写盘都要查返回值。
    # [人工决策-2026-08-05 16:58:19] 早前版本 sed -i 与 >> 都不查，失败后照样打印
    # 「完成」并返回 0——只读 bind mount、备份成功后磁盘写满、SELinux 拒绝这几种
    # 场景下用户会以为设好了而配置根本没落地。项目原则「落地类操作必须查返回值
    # 再宣布成功」，这里之前漏了
    if grep -q '^#precedence ::ffff:0:0/96  100' "$GAI_CONF"; then
        if ! sed -i 's|^#precedence ::ffff:0:0/96  100|precedence ::ffff:0:0/96  100|' "$GAI_CONF"; then
            echo -e "  ${RED}改写 $GAI_CONF 失败（只读挂载 / 磁盘满 / 权限），IPv4 优先未生效${NC}"
            echo -e "  ${YELLOW}备份已在 $GAI_BAK，原文件未被破坏${NC}"
            pause_return
            return 1
        fi
    elif ! grep -q '^precedence ::ffff:0:0/96  100' "$GAI_CONF"; then
        if ! echo 'precedence ::ffff:0:0/96  100' >>"$GAI_CONF"; then
            echo -e "  ${RED}追加到 $GAI_CONF 失败（只读挂载 / 磁盘满 / 权限），IPv4 优先未生效${NC}"
            echo -e "  ${YELLOW}备份已在 $GAI_BAK，原文件未被破坏${NC}"
            pause_return
            return 1
        fi
    fi

    # 兜底回读确认：某些 busybox sed 版本会「返回 0 但文件未变」
    if ! grep -q '^precedence ::ffff:0:0/96  100' "$GAI_CONF"; then
        echo -e "  ${RED}写入命令返回成功，但回读 $GAI_CONF 里没有那一行——未真正生效${NC}"
        echo -e "  ${YELLOW}请手动检查该文件；备份在 $GAI_BAK${NC}"
        pause_return
        return 1
    fi

    echo -e "${GREEN}IPv4 优先已开启${NC}"
    pause_return
}

# ==================================================
# --- i. 关闭 IPv6 ---
# ==================================================
# [人工决策-2026-08-16 16:50:51] 加这一项的判据与其他菜单不同，单独记在这里。
# 起因：某些上游（实测 grok.com）对 VPS 的 IPv6 出口直接拦，而 Node/Go 这类自带
# 解析器的程序绕过 gai.conf，菜单 2 的 IPv4 优先对它们无效——只能关掉协议族本身。
#
# 这是本项目唯一一个「最坏情况可能不止于参数没生效」的写入项：机器只有 IPv6 入口时
# 关掉它就等于自断 SSH，而本面板经 SSH 使用，失联即无法自救。所以按用户要求全关
# （all + default + lo 三项都写）的同时，必须在执行前把「会不会切断当前这条命令通路」
# 判掉——三道拒绝闸都是硬拒绝，不给 y/N 让用户手滑：
#   1) 当前 SSH 会话是 IPv6 连进来的
#   2) 有 IPv6 默认路由但没有 IPv4 默认路由（唯一出口是 v6）
#   3) 出口网卡上只有 IPv6 全局地址、没有 IPv4 地址
# 监听 :: 的服务只警告不拒绝：那是「服务可能起不来」，属可回退范畴，由用户判断。
# 归属文件独立（$IPV6_OPT）而非并进主配置：菜单 5/6 会整份重写主配置，混在一起
# 会让「关了 IPv6」被一次普通调优悄悄带走或带回。不进菜单 1——一键优化里关掉
# 一整个协议族对别人是意外的语义变更
#
# 关 lo 的 ::1 是用户明确要求的（[人工决策-2026-08-16 16:50:51]），但它会让
# 绑 ::1 的本地服务（Redis、PostgreSQL 默认配置常见）起不来，所以执行前要列出来给用户看。
# 改动此处前须与用户确认该决策是否仍有效

# 取当前会话的 SSH_CONNECTION。screen/tmux 常把环境变量丢掉，沿父进程链在
# /proc/<pid>/environ 里找；找到的值只用于判定地址族，不另作他用
resolve_ssh_connection() {
    if [ -n "${SSH_CONNECTION:-}" ]; then
        printf '%s\n' "$SSH_CONNECTION"
        return 0
    fi
    local pid=$$ envf val ppid
    # 沿父进程向上最多 32 层，防异常进程树死循环
    local n=0
    while [ "$n" -lt 32 ]; do
        n=$((n + 1))
        envf="/proc/$pid/environ"
        if [ -r "$envf" ]; then
            # environ 是 NUL 分隔；只取 SSH_CONNECTION 那一项
            val=$(tr '\0' '\n' <"$envf" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -1)
            if [ -n "$val" ]; then
                printf '%s\n' "$val"
                return 0
            fi
        fi
        # /proc/pid/stat 第 4 字段是 ppid
        ppid=$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null) || break
        case "$ppid" in '' | *[!0-9]* | 0 | 1) break ;; esac
        [ "$ppid" = "$pid" ] && break
        pid=$ppid
    done
    return 1
}

# 当前 SSH 会话是否通过 IPv6 连进来。SSH_CONNECTION 格式「客户端IP 端口 服务端IP 端口」，
# 判客户端与服务端任一是 IPv6（含冒号即 v6，IPv4 地址里不会有冒号）。
# 返回 0=是 v6、1=是 v4、2=未知（拿不到 SSH_CONNECTION）。
# [人工决策-2026-08-16 22:31:43] 未知不再当作「不是 v6」放行：本项关掉唯一管理通路
# 即失联无法自救，信息不全时必须 fail-closed。调用方对返回 2 一律拒绝或要求证明
# 存在可用的 IPv4 管理通路（见 disable_ipv6 的闸门）
ssh_session_af() {
    local conn c s
    conn=$(resolve_ssh_connection) || return 2
    c=$(printf '%s' "$conn" | awk '{print $1}')
    s=$(printf '%s' "$conn" | awk '{print $3}')
    case "$c$s" in
    *:*) return 0 ;;
    esac
    return 1
}

# 兼容旧名：只在「明确是 v6」时返回 0
ssh_over_ipv6() {
    ssh_session_af
    [ $? -eq 0 ]
}

# 是否存在已建立的 IPv6 SSH 连接。
# 用于 SSH_CONNECTION 拿不到时的兜底：双栈机器上 tmux 丢了环境、实际仍走 v6 登录
# 时，光看「有 IPv4 路由」会误放行。
# [人工决策-2026-08-16 22:49:27] 端口不能写死 22。自定义 Port 的机器（生产常见）
# 在 SSH_CONNECTION 丢失时会被漏判，随后只要有 IPv4 路由就继续关 IPv6——正是本闸
# 要防的场景。端口来源按可靠度：
#   1) ss 里 sshd 正在 listen 的端口（最准，含多 Port / 非 22）
#   2) sshd -T 的 port（配置层面，ss 读不到进程名时的兜底）
#   3) 都拿不到才退回 22
# 返回 0=有、1=没有；ss 缺失时返回 1，由调用方结合其他闸 fail-closed
has_active_ipv6_ssh() {
    command -v ss >/dev/null 2>&1 || return 1
    local ports
    # sshd 实际监听端口（ss -tlnp 的 users 字段含 "sshd"）
    ports=$(ss -tlnpH 2>/dev/null | awk '
        /"sshd"/ || /sshd,/ || /sshd\)/ {
            n = split($4, a, ":")
            p = a[n]
            gsub(/[^0-9]/, "", p)
            if (p != "") print p
        }
    ' | sort -u | tr '\n' ' ')
    # 配置层 Port（ss 在容器/权限不足时可能看不到进程名）
    if command -v sshd >/dev/null 2>&1; then
        ports="$ports $(sshd -T 2>/dev/null | awk '/^port /{print $2}' | tr '\n' ' ')"
    fi
    # 去重；仍空才退回 22
    ports=$(printf '%s\n' $ports | awk 'NF && !seen[$0]++' | tr '\n' ' ')
    [ -n "$(printf '%s' "$ports" | tr -d ' ')" ] || ports="22"

    ss -tnH state established 2>/dev/null | awk -v ps="$ports" '
        BEGIN {
            n = split(ps, a, /[ \t]+/)
            for (i = 1; i <= n; i++) if (a[i] != "") ports[a[i]] = 1
        }
        {
            # ss 对 v6 的本地/对端地址带 [ ]；无方括号的是 v4，跳过
            if ($4 !~ /\[/ && $5 !~ /\[/) next
            n = split($4, b, ":")
            lp = b[n]
            gsub(/[^0-9]/, "", lp)
            if (lp != "" && (lp in ports)) { found = 1; exit }
        }
        END { exit !found }
    '
}

# 有没有 IPv4 默认路由。
# [人工决策-2026-08-16 22:31:43] 缺 ip 命令时返回 1（当作「没有/不可用」），不再
# return 0 放行。旧写法是 fail-open：探测不了就当安全，恰恰在「仅 IPv6 管理 + 最小
# 镜像缺 iproute」这种最需要保守的场景下把唯一通路关死。信息不全 = 拒绝
has_ipv4_default_route() {
    command -v ip >/dev/null 2>&1 || return 1
    ip -4 route show default 2>/dev/null | grep -q .
}

has_ipv6_default_route() {
    command -v ip >/dev/null 2>&1 || return 1
    ip -6 route show default 2>/dev/null | grep -q .
}

# 出口网卡上有没有 IPv4 地址。缺 ip / 取不到出口 / 没有地址 → 一律返回 1（不安全）
egress_has_ipv4() {
    local dev
    command -v ip >/dev/null 2>&1 || return 1
    dev=$(egress_dev)
    [ -n "$dev" ] || return 1
    ip -4 -o addr show dev "$dev" 2>/dev/null | grep -q 'inet '
}

# 列出监听 IPv6 的服务，供关闭前警告。只读，缺 ss 就返回空
ipv6_listeners() {
    command -v ss >/dev/null 2>&1 || return 0
    # -H 去表头，取 tcp6/udp6 的监听项；进程名列可能没有（无权限或内核不报），只取有的
    ss -Htulnp6 2>/dev/null | awk '
        { for (i = 1; i <= NF; i++) if ($i ~ /users:\(\(/) { print $i; found = 1 } }
    ' | sed 's/users:((//;s/)).*//' | awk -F'"' '{print $2}' | sort -u | grep -v '^$'
}

# 关闭 IPv6 的运行时值是否已生效（三项全为 1 才算）。
# lo 那项在部分容器里读不到，读不到按「不算已开启」处理——宁可显示未开启也不假报
ipv6_disabled_now() {
    local k v n=0
    for k in $IPV6_KEYS; do
        v=$(sysctl -n "$k" 2>/dev/null) || return 1
        [ "$v" = "1" ] || return 1
        n=$((n + 1))
    done
    [ "$n" -eq 3 ]
}

disable_ipv6() {
    echo -e "\n${YELLOW}>>> 关闭 IPv6${NC}"
    echo -e "  ${CYAN}用途：上游按 IPv6 出口做拦截时（实测 grok.com 一类），关掉协议族让流量只走 IPv4。${NC}"
    echo -e "  ${CYAN}与菜单 2 的区别：菜单 2 只改解析优先级，自带解析器的程序（Node/Go）不认它。${NC}"

    # sysctl 命令本身可能缺（RHEL 系最小化镜像实测如此），它是快照、应用、校验的共同前提。
    # 缺了不能硬跑：那样三个 key 全被记成 ABSENT，写出去的文件不生效、回退也没有原值可写回，
    # 而面板会一路报成功。项目原则「缺少必需工具时不要硬跑测量」，写入路径同理
    ensure_tools sysctl >/dev/null
    if ! command -v sysctl >/dev/null 2>&1; then
        echo -e "  ${RED}缺 sysctl 命令且自动安装失败，无法关闭 IPv6${NC}"
        echo -e "  ${YELLOW}没有它就读不到原始值，改了也无法回退，因此不做改动${NC}"
        echo -e "  ${YELLOW}手动补装后重试（包名: procps-ng / procps）${NC}"
        pause_return
        return 1
    fi

    # 本内核/命名空间压根没有 IPv6 时无事可做。写个不生效的文件只会让状态位在
    # 「未关闭」上打转（判定读运行时值，而这里永远读不到），用户反复点也没反应
    if [ ! -d /proc/sys/net/ipv6 ]; then
        echo -e "\n  ${GREEN}本内核没有 IPv6 支持（/proc/sys/net/ipv6 不存在），无需关闭${NC}"
        echo -e "  ${YELLOW}未做任何改动${NC}"
        pause_return
        return 0
    fi

    # --- 失联防护闸（全部 fail-closed）---
    # [人工决策-2026-08-16 22:31:43] 信息不全时一律拒绝，不给 y/N。
    # 旧写法在「缺 ip 命令 / SSH_CONNECTION 被 tmux 丢掉」时 fail-open，
    # 恰恰是仅 IPv6 管理的机器最容易撞上的组合，会把唯一通路关死且无法自救。
    # 探测工具是硬前提：没有 ip 就证明不了「关掉后还能连上」
    if ! command -v ip >/dev/null 2>&1; then
        ensure_tools ip >/dev/null
    fi
    if ! command -v ip >/dev/null 2>&1; then
        echo -e "\n  ${RED}拒绝执行：缺 ip 命令，无法确认 IPv4 管理通路是否存在${NC}"
        echo -e "  ${YELLOW}本项关掉 IPv6 后若没有可用的 IPv4 通路即失联，探测不了就不做。${NC}"
        echo -e "  ${YELLOW}手动补装后重试（包名: iproute2 / iproute）${NC}"
        pause_return
        return 1
    fi

    # SSH 闸：明确 v6 → 拒；明确 v4 → 过；未知 → 查活跃 v6 SSH，再要求证明 IPv4 通路
    local _ssh_af
    ssh_session_af
    _ssh_af=$?
    if [ "$_ssh_af" = "0" ]; then
        local _ssh_conn
        _ssh_conn=$(resolve_ssh_connection 2>/dev/null || true)
        echo -e "\n  ${RED}拒绝执行：当前 SSH 会话是通过 IPv6 连进来的${NC}"
        [ -n "$_ssh_conn" ] && echo -e "  ${RED}  $_ssh_conn${NC}"
        echo -e "  ${YELLOW}关掉 IPv6 会立刻切断这条连接，且无法从面板恢复。${NC}"
        echo -e "  ${YELLOW}请改用 IPv4 登录后再执行本项。${NC}"
        pause_return
        return 1
    fi
    if [ "$_ssh_af" = "2" ]; then
        # 拿不到 SSH_CONNECTION（控制台 / tmux 丢环境）
        if has_active_ipv6_ssh; then
            echo -e "\n  ${RED}拒绝执行：检测到本机有已建立的 IPv6 SSH 连接${NC}"
            echo -e "  ${YELLOW}当前环境读不到 SSH_CONNECTION，但 ss 显示有人正通过 IPv6 连着 22 端口。${NC}"
            echo -e "  ${YELLOW}关掉 IPv6 会切断那些会话。请改用 IPv4 登录后再执行。${NC}"
            pause_return
            return 1
        fi
        if ! has_ipv4_default_route || ! egress_has_ipv4; then
            echo -e "\n  ${RED}拒绝执行：无法确认当前会话的管理通路${NC}"
            echo -e "  ${YELLOW}SSH_CONNECTION 为空（常见于 screen/tmux），且没有可用的 IPv4 默认路由/出口地址。${NC}"
            echo -e "  ${YELLOW}信息不全时不做这个会切断管理通路的操作。请用 IPv4 直连（不要套 tmux）再试。${NC}"
            pause_return
            return 1
        fi
    fi

    if has_ipv6_default_route && ! has_ipv4_default_route; then
        echo -e "\n  ${RED}拒绝执行：本机有 IPv6 默认路由但没有 IPv4 默认路由${NC}"
        echo -e "  ${YELLOW}关掉 IPv6 后这台机器将没有任何默认出口，等于断网。${NC}"
        echo -e "  ${YELLOW}排查: ip -4 route show default（应有一条 default via ...）${NC}"
        pause_return
        return 1
    fi
    if ! has_ipv4_default_route; then
        echo -e "\n  ${RED}拒绝执行：本机没有 IPv4 默认路由${NC}"
        echo -e "  ${YELLOW}关掉 IPv6 后可能没有任何默认出口。${NC}"
        echo -e "  ${YELLOW}排查: ip -4 route show default${NC}"
        pause_return
        return 1
    fi
    if ! egress_has_ipv4; then
        echo -e "\n  ${RED}拒绝执行：出口网卡 $(egress_dev) 上没有 IPv4 地址${NC}"
        echo -e "  ${YELLOW}关掉 IPv6 后这台机器将无法通信。${NC}"
        echo -e "  ${YELLOW}排查: ip -4 addr show dev $(egress_dev)${NC}"
        pause_return
        return 1
    fi

    # 监听 :: 的服务只警告不拒绝：属「服务可能起不来」，可回退，由用户判断。
    # 关 lo 的 ::1 是用户明确要求的，而绑 ::1 的本地服务最容易在这里出问题，所以列清楚
    local listeners
    listeners=$(ipv6_listeners)
    if [ -n "$listeners" ]; then
        echo -e "\n  ${YELLOW}以下服务当前在监听 IPv6，关闭后它们的 IPv6 监听会消失${NC}"
        echo -e "  ${YELLOW}（只监听 :: 的服务可能启动失败，绑 ::1 的本地连接会断）:${NC}"
        echo -e "${YELLOW}$(wrap_keys "$(printf '%s' "$listeners" | tr '\n' ' ')")${NC}"
    elif ! command -v ss >/dev/null 2>&1; then
        echo -e "\n  ${YELLOW}无 ss 命令，无法列出监听 IPv6 的服务（不影响关闭本身）${NC}"
    fi

    echo -e "\n  将写入 $IPV6_OPT 并立即生效："
    local k
    for k in $IPV6_KEYS; do
        echo -e "    ${CYAN}$k = 1${NC}"
    done
    echo -e "  ${CYAN}回退：再进菜单 3（关闭后该键变成「恢复 IPv6」），或菜单 30 连同全部优化一起回退${NC}"
    read -p "  确认关闭 IPv6？[y/N]: " _c
    [[ "$_c" =~ ^[Yy]$ ]] || {
        echo -e "${GREEN}已取消。${NC}"
        pause_return
        return 0
    }

    # 改运行时值前必须先快照，否则回退时没有原值可还原
    save_original_values || { pause_return; return 1; }
    ensure_sysctl_dir || { pause_return; return 1; }

    # 与菜单 5 同口径：写入前先说清哪些 key 在本环境设不了（容器命名空间外常见），
    # 只报告不阻断——配置文件仍要落地，宿主放开权限后重启即生效
    sysctl_precheck "$IPV6_KEYS"

    # 接管别处设过 disable_ipv6 的文件：只按 IPv6 那组正则接管，不牵连内核调优参数
    takeover_conflicts "$IPV6_OWNED_RE" "$IPV6_OPT"

    refuse_if_symlink "$IPV6_OPT" || { pause_return; return 1; }
    if ! cat >"$IPV6_OPT" <<EOF
# ==================================================
# 由 tcpo v${SCRIPT_VERSION} 生成于 $(date '+%F %T')
#
# 关闭 IPv6 协议族。用途：上游按 IPv6 出口做拦截时让流量只走 IPv4。
# 与 IPv4 优先解析（gai.conf）的区别：那个只改解析顺序，自带解析器的程序不认；
# 这个直接关掉协议族，对所有程序一致生效。
#
# 三项都写：all 管已存在的接口，default 管之后新建的接口（含容器 veth），
# lo 连本机 ::1 一起关。绑 ::1 的本地服务会连不上，这是关 lo 的已知代价。
# 回退: 运行 tcpo 选菜单 3（此时该键显示「恢复 IPv6」），或菜单 30 连同全部优化一起回退。
# 两者都会删除本文件并把三个 key 写回首次运行前的原始值
# ==================================================
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
    then
        echo -e "  ${RED}写入 $IPV6_OPT 失败（磁盘满 / 只读挂载 / 权限），IPv6 未关闭${NC}"
        pause_return
        return 1
    fi

    apply_sysctl

    draw_line
    local v miss=0
    for k in $IPV6_KEYS; do
        v=$(sysctl -n "$k" 2>/dev/null)
        if [ "$v" = "1" ]; then
            printf "  ${GREEN}✓${NC} %-40s = %s\n" "$k" "$v"
        else
            printf "  ${RED}✗${NC} %-40s = %s\n" "$k" "${v:-读不到}"
            miss=$((miss + 1))
        fi
    done
    draw_line

    if [ "$miss" -eq 0 ]; then
        echo -e "${GREEN}完成：IPv6 已关闭${NC}"
        # 关掉后接口上的 v6 地址会被撤掉，回读一次给用户看实际结果
        if command -v ip >/dev/null 2>&1; then
            local left
            left=$(ip -6 -o addr show scope global 2>/dev/null | wc -l | tr -d ' ')
            echo -e "  当前剩余全局 IPv6 地址: ${CYAN}${left}${NC} 个"
        fi
        report_ipv6_persist
    else
        echo -e "${RED}有 $miss 项未生效${NC}"
        if [ "$DRYRUN" = "1" ]; then
            echo -e "  ${CYAN}[演练] 未生效是因为跳过了 sysctl --system，真实执行时会生效${NC}"
        elif is_container; then
            echo -e "  ${YELLOW}容器环境: net.ipv6.conf.* 可能在命名空间外，需在宿主上设置${NC}"
        else
            echo -e "  ${YELLOW}排查: grep -rn disable_ipv6 /etc/sysctl.d/ /etc/sysctl.conf 或看 $APPLY_LOG${NC}"
        fi
    fi
    pause_return
}

# 单独还原 IPv6（菜单 3 在已开启时提供的关闭入口）。
# 与菜单 30 的差别只在范围：只删本项的文件、只写回本项那三个 key，不碰其他优化
restore_ipv6() {
    echo -e "\n${YELLOW}>>> 重新启用 IPv6${NC}"
    # 与 disable_ipv6 同一前提：没有 sysctl 就写不回运行时值，只删文件不算恢复
    # （sysctl --system 对已删除的 key 什么都不做）
    ensure_tools sysctl >/dev/null
    if ! command -v sysctl >/dev/null 2>&1; then
        echo -e "  ${RED}缺 sysctl 命令且自动安装失败，无法写回原值${NC}"
        echo -e "  ${YELLOW}只删配置文件不等于恢复，因此不做改动；手动补装后重试（procps-ng / procps）${NC}"
        pause_return
        return 1
    fi
    read -p "  确认恢复 IPv6？[y/N]: " _c
    [[ "$_c" =~ ^[Yy]$ ]] || {
        echo -e "${GREEN}已取消。${NC}"
        pause_return
        return 0
    }
    rm -f "$IPV6_OPT"
    # [人工决策-2026-08-16 22:31:43] 关闭时 takeover_conflicts 会把用户原文件里的
    # disable_ipv6 行注释掉。只删 $IPV6_OPT 不解开那些注释 = 用户原配置永久失效，
    # 「按一次 3 再按一次 3」回不到菜单 3 执行前的真实状态。
    # 只解开归属本项（owner=$IPV6_OPT）的标记，不动菜单 5/6 接管的其他参数
    # [人工决策-2026-08-16 22:49:27] 接管解开 / apply / 写回 任一失败都不能报「已恢复」。
    # 旧写法只看 ipv6_disabled_now：运行时被写成 0 就报成功，即使用户原配置行仍是
    # 注释——重启后 sysctl.d 里没有生效的 disable_ipv6=0，语义仍是坏的
    local ok=1
    restore_ipv6_takeovers || ok=0
    # 顺序与菜单 30 一致：先 --system 让还留着的/刚解开的文件重新生效，再逐个写回快照值。
    # 反过来做的话 --system 会覆盖刚写回的值
    apply_sysctl || {
        echo -e "  ${RED}应用 sysctl 失败，运行时值可能未还原${NC}"
        ok=0
    }
    restore_ipv6_keys || ok=0
    draw_line
    local k v
    for k in $IPV6_KEYS; do
        v=$(sysctl -n "$k" 2>/dev/null)
        printf "  %-40s = %s\n" "$k" "${v:-读不到}"
    done
    draw_line
    if [ "$ok" = "1" ] && ! ipv6_disabled_now; then
        echo -e "${GREEN}完成：IPv6 已恢复${NC}"
        echo -e "  ${YELLOW}接口上的 IPv6 地址由 SLAAC/DHCPv6 重新获取，可能需要几秒${NC}"
    else
        echo -e "${RED}恢复未完整${NC}"
        if ipv6_disabled_now; then
            echo -e "  ${RED}运行时 IPv6 仍处于关闭状态${NC}"
            echo -e "  ${YELLOW}排查: grep -rn disable_ipv6 /etc/sysctl.d/ /etc/sysctl.conf${NC}"
            echo -e "  ${YELLOW}（可能有别的配置文件也在设它，本面板只管自己写的那份）${NC}"
        fi
        if grep -rslF "# moved to ${IPV6_OPT} by tcp-dashboard:" /etc/sysctl.d/ /etc/sysctl.conf 2>/dev/null |
            grep -q .; then
            echo -e "  ${RED}仍有文件残留 IPv6 接管标记（用户原配置未解开）${NC}"
            echo -e "  ${YELLOW}手动: grep -rn 'moved to.*zz-disable-ipv6' /etc/sysctl.d/ /etc/sysctl.conf${NC}"
        fi
        echo -e "  ${YELLOW}修好后重跑菜单 3（恢复）${NC}"
    fi
    pause_return
    [ "$ok" = "1" ] && ! ipv6_disabled_now
}

# 解开菜单 3 关闭时留下的接管标记。
# 标记格式由 comment_out_conflicts 写：`# moved to <owner> by tcp-dashboard: <原行>`
# 只匹配 owner=$IPV6_OPT 的行，避免把菜单 5 注释掉的 somaxconn 之类一并解开。
# 用字面量前缀剥离而不是 sed 正则：owner 是路径，含 / . 等，sed 转义极易漏
# 返回 0=全部解开（或本来就没有）、1=有文件解不开/仍有残留
restore_ipv6_takeovers() {
    local f n=0 failed=0 prefix tmp line
    prefix="# moved to ${IPV6_OPT} by tcp-dashboard: "
    for f in /etc/sysctl.d/*.conf /etc/sysctl.conf; do
        [ -f "$f" ] || continue
        grep -qF "$prefix" "$f" 2>/dev/null || continue
        tmp="${f}.tcpdash.tmp"
        if ! {
            while IFS= read -r line || [ -n "$line" ]; do
                case "$line" in
                "$prefix"*) printf '%s\n' "${line#"$prefix"}" ;;
                *) printf '%s\n' "$line" ;;
                esac
            done <"$f"
        } >"$tmp" 2>/dev/null; then
            rm -f "$tmp"
            echo -e "  ${RED}解开 $f 里的 IPv6 接管标记失败（只读挂载 / 权限）${NC}"
            echo -e "  ${YELLOW}手动处理: 把该文件里「$prefix」前缀去掉${NC}"
            failed=1
            continue
        fi
        if ! cat "$tmp" >"$f" 2>/dev/null; then
            rm -f "$tmp"
            echo -e "  ${RED}写回 $f 失败（只读挂载 / 权限）${NC}"
            echo -e "  ${YELLOW}手动处理: 把该文件里「$prefix」前缀去掉${NC}"
            failed=1
            continue
        fi
        rm -f "$tmp"
        if grep -qF "$prefix" "$f" 2>/dev/null; then
            echo -e "  ${RED}写回后 $f 里仍有 IPv6 接管标记${NC}"
            echo -e "  ${YELLOW}手动处理: 把该文件里「$prefix」前缀去掉${NC}"
            failed=1
            continue
        fi
        n=$((n + 1))
        echo -e "  已解开 $f 里的 IPv6 接管标记"
    done
    [ "$n" -gt 0 ] && echo -e "  共恢复 ${GREEN}$n${NC} 个文件里被菜单 3 注释掉的原配置"
    # 兜底：任何残留都算失败（上面某条路径漏计 failed 时也能抓住）
    if grep -rslF "$prefix" /etc/sysctl.d/ /etc/sysctl.conf 2>/dev/null | grep -q .; then
        failed=1
    fi
    [ "$failed" = "0" ]
}

# 把三个 IPv6 key 写回快照原值。与 restore_managed_keys 同一套口径：
# ABSENT 表示本内核/命名空间没这个 key，跳过；读不到快照也跳过，绝不用硬编码默认值兜底
restore_ipv6_keys() {
    local k v restored=0 skipped=0
    if ! command -v sysctl >/dev/null 2>&1; then
        echo -e "  ${RED}缺 sysctl 命令，无法写回 IPv6 运行时值${NC}"
        return 1
    fi
    for k in $IPV6_KEYS; do
        v=$(orig_value "$k") || { skipped=$((skipped + 1)); continue; }
        [ "$v" = "ABSENT" ] && { skipped=$((skipped + 1)); continue; }
        if [ "$DRYRUN" = "1" ]; then
            skipped=$((skipped + 1))
        elif sysctl -w "$k=$v" &>/dev/null; then
            restored=$((restored + 1))
        else
            skipped=$((skipped + 1))
        fi
    done
    echo -e "  已还原 ${GREEN}${restored}${NC} 项运行时值（$skipped 项不可写或原本不存在，已跳过）"
    [ "$restored" -gt 0 ] || [ "$skipped" -gt 0 ]
}

# IPv6 关闭的持久化结论。复用 report_sysctl_persist 会连带打印 BBR 模块提示
# （那与本项无关，会让用户以为关 IPv6 还要管 tcp_bbr），所以单独判一次同样的三态
report_ipv6_persist() {
    if has_systemd; then
        echo -e "  ${GREEN}已持久化 ($IPV6_OPT)${NC}"
        return 0
    fi
    if sysctl_apply_ran_at_boot; then
        if write_sysctl_apply; then
            echo -e "  ${GREEN}本次已生效${NC} | 开机重放脚本有运行痕迹（只是运行痕迹，请重启确认）"
            wsl_boot_hint_verify
            return 0
        fi
        echo -e "  ${RED}开机重放脚本 $SYSCTL_APPLY 写入失败，重启后 IPv6 会恢复${NC}"
        return 1
    fi
    if sysctl_replay_ok; then
        echo -e "  ${GREEN}已持久化 ($IPV6_OPT)${NC}（由系统自带的 sysctl 服务重放，非本面板的脚本）"
        return 0
    fi
    echo -e "  ${YELLOW}本次已生效，但重启后 IPv6 会恢复${NC}"
    if is_container; then
        echo -e "  ${YELLOW}本机是容器，开机不会重放 /etc/sysctl.d/，持久化请在宿主上设置或用 docker run --sysctl${NC}"
        return 1
    fi
    echo -e "  ${YELLOW}本机没有 systemd，开机不会有人读 /etc/sysctl.d/${NC}"
    write_sysctl_apply || return 1
    echo -e "  ${GREEN}已生成 $SYSCTL_APPLY${NC}"
    wsl_boot_hint
    return 1
}

# ==================================================
# --- 2. BBR + FQ ---
# ==================================================
enable_bbr() {
    echo -e "\n${YELLOW}>>> 开启 BBR + FQ${NC}"
    # 改运行时值前必须先快照，否则回退时没有原值可还原
    save_original_values || { pause_return; return 1; }

    # 部分发行版 BBR 是模块，未加载时 available 列表里没有 bbr，直接写 sysctl 会静默失败
    if ! sysctl -n net.ipv4.tcp_available_congestion_control 2>/dev/null | grep -qw bbr; then
        echo -e "${YELLOW}    当前内核未加载 BBR 模块，尝试 modprobe tcp_bbr ...${NC}"
        dryrun_skip "modprobe tcp_bbr" || modprobe tcp_bbr 2>/dev/null
    fi
    # [人工决策-2026-08-05 16:58:33] 模块的开机加载配置无条件写，且必须查返回值。
    # 两个判例：
    #   1) 原先只在「available 里没有 bbr」时写。模块已被手工 modprobe 进来的机器
    #      走不到那个分支，于是永远没有持久化配置，重启后回到 cubic
    #   2) 原先 echo 重定向不查返回值。只读 /etc 或磁盘满时它只往 stderr 报一行、
    #      退出码被丢弃，面板照样往下报「已持久化」而文件根本没落地
    #   3) [人工决策-2026-08-05 17:44:05] 原先是裸重定向，绕过了别的关键落地路径
    #      都在走的 atomic_write（内含 refuse_if_symlink + 先临时文件再 rename）。
    #      这条路径被预置成符号链接时，脚本会以 root 跟着链接写出去、覆盖链接目标，
    #      属实际的任意文件覆盖风险；而本项目的最坏边界是「参数没生效」。
    #      统一走 atomic_write，并在末尾确认结果文件真的落地
    if ! write_bbr_modload; then
        echo -e "  ${YELLOW}写 $BBR_MODLOAD 未成功（只读 /etc、磁盘满，或该路径是符号链接）${NC}"
        echo -e "  ${YELLOW}BBR 本次仍可生效，但开机不会自动加载 tcp_bbr 模块${NC}"
    fi

    ensure_sysctl_dir || { pause_return; return 1; }
    refuse_if_symlink "$BBR_OPT" || { pause_return; return 1; }
    # 查返回值，避免写失败后仍误报「BBR 已生效」
    if ! cat >"$BBR_OPT" <<'EOF'
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
EOF
    then
        echo -e "  ${RED}写入 $BBR_OPT 失败（磁盘满 / 只读挂载 / 权限），BBR 未配置${NC}"
        pause_return
        return 1
    fi
    # 走统一入口而非裸跑 sysctl --system，以便回显内核拒绝的具体原因
    apply_sysctl

    local cur_cc cur_qdisc
    cur_cc=$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)
    cur_qdisc=$(sysctl -n net.core.default_qdisc 2>/dev/null)

    draw_line
    printf "  %-20s : %s\n" "拥塞算法" "$cur_cc"
    printf "  %-20s : %s\n" "默认队列(qdisc)" "$cur_qdisc"
    draw_line
    if [ "$cur_cc" = "bbr" ]; then
        echo -e "${GREEN}完成：BBR 已生效${NC}$(bbr_version_label)"
        # 「本次生效」与「开机还生效」分开报，无 systemd 机器重启后会回到 cubic
        report_sysctl_persist "$BBR_OPT"
    else
        echo -e "${RED}未生效：当前仍为 $cur_cc${NC}"
        local avail
        avail=$(sysctl -n net.ipv4.tcp_available_congestion_control 2>/dev/null)
        if [ -n "$avail" ] && ! printf '%s' "$avail" | grep -qw bbr; then
            echo -e "  ${YELLOW}可用算法无 bbr（需 4.9+，排查: modprobe tcp_bbr；容器内通常无法加载）${NC}"
        else
            echo -e "  ${YELLOW}bbr 在列表但被覆盖: grep -rn tcp_congestion_control /etc/sysctl.d/ /etc/sysctl.conf${NC}"
        fi
    fi
    pause_return
}

# ==================================================
# --- 3. 内核参数调优 ---
# ==================================================
# 本脚本负责的 key。zz- 前缀只赢字典序在前的文件，仍可能被 zzz-*.conf 等同名 key 覆盖，
# 需靠接管阻止（备份命中文件、注释冲突行、清单落盘供回退）
OWNED_KEYS_RE='^[[:space:]]*(net\.(core\.(default_qdisc|somaxconn|netdev_max_backlog|netdev_budget|netdev_budget_usecs|rmem_max|wmem_max|rps_sock_flow_entries)|ipv4\.(tcp_congestion_control|tcp_max_syn_backlog|tcp_syncookies|ip_local_port_range|ip_local_reserved_ports|tcp_rmem|tcp_wmem|tcp_notsent_lowat|tcp_mtu_probing|udp_rmem_min|tcp_slow_start_after_idle|tcp_no_metrics_save|tcp_tw_reuse|tcp_fin_timeout|tcp_fastopen|ip_forward))|fs\.nr_open)[[:space:]]*='

# 关闭 IPv6 的接管正则，与 $IPV6_KEYS 必须覆盖同一组参数。
# 单列一份而不是并进 OWNED_KEYS_RE：菜单 5/6 不该因为「有人在别的文件里设过 IPv6」
# 就去注释那些行——它们与内核调优无关，只有菜单 3 才有资格接管
IPV6_OWNED_RE='^[[:space:]]*net\.ipv6\.conf\.(all|default|lo)\.disable_ipv6[[:space:]]*='

# 把一个文件里命中受管 key 的行注释掉，其余行原样保留。只动冲突行不整文件禁用，
# 否则会连带停掉同文件里无关的安全参数（如 10-network-security.conf 里的 rp_filter）。
# 正则与归属文件可传入，供菜单 3 用自己那一组（默认是内核调优那一组）
comment_out_conflicts() {
    local target=$1 re=${2:-$OWNED_KEYS_RE} owner=${3:-$SYSCTL_OPT} tmp
    tmp="${target}.tcpdash.tmp"
    awk -v re="$re" -v owner="$owner" '
        $0 ~ re && $0 !~ /^[[:space:]]*#/ {
            print "# moved to " owner " by tcp-dashboard: " $0
            next
        }
        { print }
    ' "$target" >"$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
    # 保持原有权限与属主
    cat "$tmp" >"$target" 2>/dev/null && rm -f "$tmp" && return 0
    rm -f "$tmp"
    return 1
}

# 接管 /etc/sysctl.d 下与本脚本冲突的文件，以及 /etc/sysctl.conf 里的冲突行。
# 一律「逐行注释 + 备份原文件」，不移动、不禁用整个文件。
# 可传入 <正则> <归属文件> 只接管某一组参数（菜单 3 传自己那组），默认是内核调优那组
takeover_conflicts() {
    mkdir -p "$STATE_DIR"
    local re=${1:-$OWNED_KEYS_RE} owner=${2:-$SYSCTL_OPT}
    local ts backup_dir moved=0 f base
    ts=$(date '+%Y%m%d-%H%M%S')
    backup_dir="$STATE_DIR/takeover-$ts"

    for f in /etc/sysctl.d/*.conf /etc/sysctl.conf; do
        [ -f "$f" ] || continue
        base=$(basename "$f")
        # 跳过自己/BBR/RFS/IPv6 子文件及备份、禁用、说明类文件
        case "$base" in
        "$(basename "$SYSCTL_OPT")" | "$(basename "$BBR_OPT")" | "$(basename "$NIC_SYSCTL_OPT")" | "$(basename "$IPV6_OPT")") continue ;;
        *.bak | *.bak.* | *.disabled | *.disabled.* | *.old | README*) continue ;;
        esac
        grep -qE "$re" "$f" 2>/dev/null || continue

        mkdir -p "$backup_dir"
        # 备份文件名带路径信息防止撞名；备份失败就不接管，避免制造不可回退的改动
        if ! cp -a "$f" "$backup_dir/$(echo "${f#/}" | tr '/' '_')" 2>/dev/null; then
            echo -e "  ${RED}备份 $f 失败，跳过接管（不做无法回退的改动）${NC}"
            echo -e "  ${YELLOW}该文件里的同名参数可能仍会覆盖本脚本的设置${NC}"
            continue
        fi
        if comment_out_conflicts "$f" "$re" "$owner"; then
            atomic_append "$TAKEOVER_LIST" "$f"
            moved=$((moved + 1))
        else
            echo -e "  ${RED}无法改写 $f（只读挂载？），它可能仍会覆盖本脚本的参数${NC}"
        fi
    done

    # 旧版整文件改名为 .disabled-by-tcp-dashboard 的遗留，改回来后走逐行注释
    for f in /etc/sysctl.d/*.disabled-by-tcp-dashboard; do
        [ -f "$f" ] || continue
        local orig="${f%.disabled-by-tcp-dashboard}"
        if [ ! -f "$orig" ]; then
            mv "$f" "$orig" 2>/dev/null && {
                echo -e "  ${YELLOW}发现旧版整文件禁用的遗留，已还原 $orig 并改为逐行注释${NC}"
                mkdir -p "$backup_dir"
                # 同上：备份失败就不接管
                if cp -a "$orig" "$backup_dir/$(echo "${orig#/}" | tr '/' '_')" 2>/dev/null; then
                    comment_out_conflicts "$orig" "$re" "$owner" && {
                        grep -qxF "$orig" "$TAKEOVER_LIST" 2>/dev/null || atomic_append "$TAKEOVER_LIST" "$orig"
                        moved=$((moved + 1))
                    }
                else
                    echo -e "  ${RED}备份 $orig 失败，跳过接管${NC}"
                fi
            }
        fi
    done

    [ "$moved" -gt 0 ] && echo -e "  ${GREEN}接管 $moved 处冲突（备份在 $backup_dir）${NC}"

    report_vendor_conflicts "$re"
    return 0
}

# 报告 /etc 之外目录里设了同名参数的文件，只报告不接管。
# sysctl.d(5)：所有目录的配置文件统一按文件名字典序排序，最后的赢，所以本脚本的 zz-
# 前缀不会被 vendor 文件覆盖；但删掉本脚本文件后那些值会重新生效，且排查时需要完整来源，
# 故仍报告。不接管 /usr/lib 与 /lib（包管理器领地，升级会覆盖），/run 易失改它无意义
report_vendor_conflicts() {
    local re=${1:-$OWNED_KEYS_RE}
    local d f hits="" n=0
    for d in /run/sysctl.d /usr/local/lib/sysctl.d /usr/lib/sysctl.d /lib/sysctl.d; do
        [ -d "$d" ] || continue
        for f in "$d"/*.conf; do
            [ -f "$f" ] || continue
            # /lib 常是 /usr/lib 的符号链接，去重防止重复计数
            case " $hits " in *" $(readlink -f "$f" 2>/dev/null || echo "$f") "*) continue ;; esac
            grep -qE "$re" "$f" 2>/dev/null || continue
            hits="$hits $(readlink -f "$f" 2>/dev/null || echo "$f")"
            n=$((n + 1))
        done
    done
    [ "$n" -eq 0 ] && return 0
    echo -e "  ${YELLOW}另有 $n 处系统目录设了同名参数（不影响，zz- 优先）${NC}"
    return 0
}

# 扫当前监听端口，合并成区间写进 ip_local_reserved_ports，让内核分配随机源端口时
# 跳过它们，避免放宽 ip_local_port_range 后业务重启撞端口
build_reserved_ports() {
    local lo=$1 hi=$2
    if ! command -v ss >/dev/null 2>&1; then
        echo -e "  ${YELLOW}无 ss 命令，跳过监听端口保留——放宽端口范围后业务重启可能撞端口${NC}" >&2
        return 0
    fi
    ss -HtulnA all 2>/dev/null | awk -v lo="$lo" -v hi="$hi" '
        {
            # 取本地地址列，端口在最后一个冒号之后
            for (i = 1; i <= NF; i++) if ($i ~ /:[0-9]+$/) { addr = $i; break }
            if (addr == "") next
            n = split(addr, a, ":"); p = a[n] + 0
            if (p >= lo && p <= hi) seen[p] = 1
            addr = ""
        }
        END {
            cnt = 0
            for (p in seen) ports[cnt++] = p + 0
            if (cnt == 0) exit
            # 简单插入排序，端口数量级很小
            for (i = 1; i < cnt; i++) { v = ports[i]; j = i - 1
                while (j >= 0 && ports[j] > v) { ports[j+1] = ports[j]; j-- }
                ports[j+1] = v }
            # 合并连续端口成区间，最多输出 64 项（内核对该字符串长度有限制）
            out = ""; items = 0; s = ports[0]; e = ports[0]
            for (i = 1; i <= cnt; i++) {
                if (i < cnt && ports[i] == e + 1) { e = ports[i]; continue }
                seg = (s == e) ? s : s "-" e
                if (items < 64) { out = (out == "") ? seg : out "," seg; items++ }
                if (i < cnt) { s = ports[i]; e = ports[i] }
            }
            print out
        }'
}

# 按带宽 x RTT 算缓冲区上限（BDP），而非按内存百分比（同内存不同 RTT 需求差可达 20 倍）。
# 公式：BDP(bytes) = Mbps x 125 x RTT_ms，取 2.5 倍余量；同时受内存 5% 约束，下限 8MB
calc_buf_bytes() {
    local mbps=$1 rtt=$2 mem_total_kb=$3 pool_bytes=${4:-0}
    local bdp buf mem_cap pool_cap
    bdp=$((mbps * 125 * rtt))
    buf=$((bdp * 5 / 2))
    mem_cap=$((mem_total_kb * 5 / 100 * 1024))
    [ "$buf" -gt "$mem_cap" ] && buf=$mem_cap
    # 第四道约束：TCP 全局内存池（net.ipv4.tcp_mem[2]，单位页）。池打满时内核会强制收缩
    # 所有连接的缓冲，所以单条上限要压到「池/预期并发数」以内，除数 16 是跨境多连接场景的折中。
    # 池读不到（容器常见）时传 0，本约束跳过，不猜一个可能错的池大小
    if [ "${pool_bytes:-0}" -gt 0 ] 2>/dev/null; then
        pool_cap=$((pool_bytes / 16))
        [ "$buf" -gt "$pool_cap" ] && buf=$pool_cap
    fi
    [ "$buf" -lt 8388608 ] && buf=8388608
    [ "$buf" -gt 268435456 ] && buf=268435456
    # 向上取整到 MB
    echo $(((buf + 1048575) / 1048576 * 1048576))
}

# 读 tcp_mem 第三字段（max，页）换算成字节，读不到回 0。只读不写：
# 本项目故意不设 tcp_mem（社区脚本常把页当字节写出离谱大值），但读它加一道上界是安全的
tcp_mem_pool_bytes() {
    local v pg hi
    v=$(sysctl -n net.ipv4.tcp_mem 2>/dev/null) || { echo 0; return; }
    hi=$(printf '%s' "$v" | awk '{print $3}')
    case "$hi" in '' | *[!0-9]*)
        echo 0
        return
        ;;
    esac
    pg=$(getconf PAGESIZE 2>/dev/null)
    case "$pg" in '' | *[!0-9]*) pg=4096 ;; esac
    # bash 算术是 64 位，页数x页大小不会溢出
    echo $((hi * pg))
}

# 实测到公共地址的 RTT，用于 BDP 估算。三个目标取最小值（避开单点抖动），失败返回空
probe_rtt() {
    local t rtt best=""
    for t in $RTT_PROBE_TARGETS; do
        rtt=$(ping -c 3 -W 2 -q "$t" 2>/dev/null |
            awk -F'/' '/rtt|round-trip/{printf "%.0f", $5}')
        case "$rtt" in
        '' | 0 | *[!0-9]*) continue ;;
        esac
        [ -z "$best" ] && best=$rtt
        [ "$rtt" -lt "$best" ] && best=$rtt
    done
    echo "$best"
}

# 出口网卡链路速率（Mbps），读不到或不可信回 0。只用于给用户输入的带宽做上界提示，
# 不当默认值：物理口速率不等于可用带宽，拿它当默认会系统性高估。虚拟网卡常返回 -1，同样回 0
nic_link_mbps() {
    local dev=$1 v
    [ -n "$dev" ] || return 0
    v=$(cat "/sys/class/net/$dev/speed" 2>/dev/null)
    case "$v" in
    '' | -* | *[!0-9]*)
        echo 0
        return
        ;;
    esac
    [ "$v" -le 0 ] 2>/dev/null && {
        echo 0
        return
    }
    echo "$v"
}

# RTT 抖动（ping 的 mdev 字段），输出 "目标 avg mdev"。抖动比均值更能说明链路质量与
# 调优收益（nginx reuseport 案例：均值仅降 21%，标准差降 88%）。mdev 是平均绝对偏差非标准差，
# 量级相近方向一致，作抖动指标够用且零成本
probe_jitter() {
    command -v ping >/dev/null 2>&1 || return 1
    local t line avg mdev
    for t in $RTT_PROBE_TARGETS; do
        # 最后字段带单位（"0.163 ms"），用 split 去掉尾巴，直接取 $7 会带上 " ms" 导致校验失败
        line=$(ping -c 5 -W 2 -q "$t" 2>/dev/null | awk -F'/' '
            /rtt|round-trip/ {
                n = split($NF, a, " ")
                print $(NF-2) " " a[1]
                exit
            }')
        [ -n "$line" ] || continue
        avg=${line%% *}
        mdev=${line##* }
        case "$avg" in '' | *[!0-9.]*) continue ;; esac
        case "$mdev" in '' | *[!0-9.]*) continue ;; esac
        printf '%s %s %s\n' "$t" "$avg" "$mdev"
    done
}

# PROFILE: normal=普通业务（不开转发/不加 MSS clamp）  transit=中转/代理/隧道节点
PROFILE="${PROFILE:-normal}"
# TUNE_MBPS / TUNE_RTT 为空时走交互询问；非交互（一键全部）下用探测值 + 保守带宽
TUNE_MBPS="${TUNE_MBPS:-}"
TUNE_RTT="${TUNE_RTT:-}"

tune_sysctl() {
    save_original_values || { pause_return; return 1; }

    local old_cc old_somax old_rmem
    old_cc=$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)
    old_somax=$(sysctl -n net.core.somaxconn 2>/dev/null || echo "-")
    old_rmem=$(sysctl -n net.core.rmem_max 2>/dev/null || echo 212992)

    local mem_total_kb cpu_count buf_bytes mbps rtt
    mem_total_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo)
    cpu_count=$(nproc)

    echo -e "\n${YELLOW}>>> 部署内核网络参数（档位: ${CYAN}${PROFILE}${YELLOW}）${NC}"

    mbps=$TUNE_MBPS
    # 记录 RTT 来源（实测/估算），置信度不同，配置文件要留痕
    local rtt_src=""
    rtt=$TUNE_RTT
    if [ -n "$rtt" ]; then
        rtt_src="(外部指定)"
    else
        # RTT 直接决定缓冲区大小，缺 ping 时先装
        ensure_tools ping >/dev/null
        echo -e "  正在探测 RTT ..."
        rtt=$(probe_rtt)
        if [ -z "$rtt" ]; then
            rtt=150
            rtt_src="(探测失败，按跨境典型值估算)"
            echo -e "  ${YELLOW}探测失败（ICMP 可能被屏蔽），按跨境典型值 150ms 估算${NC}"
        else
            rtt_src="(实测 ${RTT_PROBE_TARGETS:-公网地址} 取最小)"
        fi
    fi
    if [ -z "$mbps" ]; then
        if [ "$BATCH" = "1" ]; then
            # 一键流程里 run_all 已 ask_bandwidth 并写入 TUNE_MBPS；此处兜底 1G
            mbps=1000
        else
            mbps=$(ask_bandwidth)
        fi
    fi
    [ "$mbps" -lt 1 ] 2>/dev/null || [ -z "$mbps" ] && mbps=1000

    local pool_bytes bdp_bytes raw_target cap_reason
    pool_bytes=$(tcp_mem_pool_bytes)
    buf_bytes=$(calc_buf_bytes "$mbps" "$rtt" "$mem_total_kb" "$pool_bytes")
    bdp_bytes=$((mbps * 125 * rtt))
    raw_target=$((bdp_bytes * 5 / 2))

    # 说清最终值被哪道约束定下来，避免用户只看到结果自己猜原因
    if [ "$buf_bytes" -eq 8388608 ] && [ "$raw_target" -lt 8388608 ]; then
        cap_reason="下限 8MB（BDP 推导值 $((raw_target / 1048576))MB 偏小）"
    elif [ "$buf_bytes" -eq 268435456 ]; then
        cap_reason="上限 256MB"
    elif [ "$pool_bytes" -gt 0 ] && [ "$raw_target" -gt $((pool_bytes / 16)) ] &&
        [ $((pool_bytes / 16)) -le $((mem_total_kb * 5 / 100 * 1024)) ]; then
        cap_reason="TCP 全局池约束（tcp_mem 上限 $((pool_bytes / 1048576))MB ÷ 16）"
    elif [ "$raw_target" -gt $((mem_total_kb * 5 / 100 * 1024)) ]; then
        cap_reason="内存 5% 约束"
    else
        cap_reason="BDP x2.5"
    fi

    # 写进配置文件头部的两段说明，与屏幕输出共用同一份数据
    local link_note="" pool_note
    local _lm
    _lm=$(nic_link_mbps "$(egress_dev)")
    [ "${_lm:-0}" -gt 0 ] 2>/dev/null && link_note="（网卡链路速率 ${_lm} Mbps）"
    if [ "$pool_bytes" -gt 0 ]; then
        pool_note="$((pool_bytes / 1048576)) MB ÷ 16 = $((pool_bytes / 16 / 1048576)) MB   （tcp_mem 上限，所有连接共用）"
    else
        pool_note="读不到（容器环境常见），本约束跳过"
    fi

    echo -e "  CPU: ${CYAN}${cpu_count}${NC} 核 | 内存: ${CYAN}$((mem_total_kb / 1024))${NC} MB"
    echo -e "  带宽: ${CYAN}${mbps}${NC} Mbps | RTT: ${CYAN}${rtt}${NC} ms ${rtt_src}"
    echo -e "  缓冲区上限: ${CYAN}$((buf_bytes / 1024 / 1024))${NC} MB"

    ensure_sysctl_dir || { pause_return; return 1; }

    # 写入前先说清哪些 key 在本环境设不了；中转档的 ip_forward 也在受管清单里一并预检
    sysctl_precheck "$MANAGED_KEYS"

    # 清掉旧版写的 99- 文件，避免两份配置按字典序互相覆盖
    rm -f "$LEGACY_SYSCTL_OPT"

    takeover_conflicts

    # 容量类参数取 max(内核现值, 目标值)，避免在内核默认值更高的机器上反向调低
    local v_somax v_backlog v_rmem_max v_wmem_max v_rmem3 v_wmem3 reserved v_nr_open
    v_somax=$(sysctl_ceil net.core.somaxconn 65535)
    v_backlog=$(sysctl_ceil net.core.netdev_max_backlog 65535)
    v_rmem_max=$(sysctl_ceil net.core.rmem_max "$buf_bytes")
    v_wmem_max=$(sysctl_ceil net.core.wmem_max "$buf_bytes")
    v_rmem3=$(sysctl_ceil3 net.ipv4.tcp_rmem 4096 131072 "$buf_bytes")
    v_wmem3=$(sysctl_ceil3 net.ipv4.tcp_wmem 4096 16384 "$buf_bytes")
    # fd 数硬顶，是 limits.d/systemd nofile 的前提，硬限超过它 setrlimit 会被拒绝
    v_nr_open=$(sysctl_ceil fs.nr_open 1048576)
    # 保留端口要靠 ss 扫监听状态，缺了会导致业务端口被随机源端口占用，需要装而非跳过
    ensure_tools ss >/dev/null
    reserved=$(build_reserved_ports 32768 65535)

    refuse_if_symlink "$SYSCTL_OPT" || { pause_return; return 1; }
    # 主配置写失败必须中止，避免后续误报持久化成功
    if ! cat >"$SYSCTL_OPT" <<EOF
# ==================================================
# 由 tcpo v${SCRIPT_VERSION} 生成于 $(date '+%F %T')，档位 ${PROFILE}
#
# 缓冲区推导链（RTT 是实测的，重跑不一定得到同样的值）：
#   带宽        ${mbps} Mbps${link_note}
#   RTT         ${rtt} ms ${rtt_src}
#   BDP         ${mbps} x 125 x ${rtt} = $((bdp_bytes / 1048576)) MB
#   x2.5 余量   $((raw_target / 1048576)) MB   （留给丢包重传期间的在途数据）
#   内存 5%     $((mem_total_kb * 5 / 100 / 1024)) MB   （本机内存 $((mem_total_kb / 1024)) MB）
#   TCP 全局池  ${pool_note}
#   最终取值    $((buf_bytes / 1048576)) MB  <- ${cap_reason}
#
# 本机: $(nproc) 核 / $((mem_total_kb / 1024)) MB 内存 / 内核 $(uname -r)
# 回退: 运行 tcpo 选菜单 30（会连同运行时值一起还原到首次运行前的真实状态）
# ==================================================

# --- 队列与拥塞算法 ---
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

# --- 连接容量（取 max(内核现值, 目标值)，不在默认值更高的机器上反向调低）---
net.core.somaxconn = ${v_somax}
net.core.netdev_max_backlog = ${v_backlog}
# 现代内核里此值不再决定 SYN 队列长度（那由 listen() backlog 经 somaxconn 钳住），
# 仅在 syncookies 关闭时保留末 1/4 队列
net.ipv4.tcp_max_syn_backlog = 16384
# SYN 洪泛降级为无状态握手，内核默认已是 1，防止被面板脚本关掉
net.ipv4.tcp_syncookies = 1

# --- 软中断收包预算（NAPI 单次轮询上限，内核默认 300 / 2000）---
# 抬高减少高 PPS 下的 squeeze，收益看 /proc/net/softnet_stat 第 3 列（诊断菜单可读）
net.core.netdev_budget = 600
net.core.netdev_budget_usecs = 8000

# 出站随机源端口范围。不从 1024 起，避开常见服务默认端口，防止占用导致重启 bind 失败。
# 下面 ip_local_reserved_ports 会排除当前监听中落在本范围内的端口
net.ipv4.ip_local_port_range = 32768 65535
$(if [ -n "$reserved" ]; then
        echo "# 自动保留：以下端口在本次调优时处于监听状态且落在临时端口范围内，"
        echo "# 内核分配随机源端口时会跳过它们，避免业务重启时 bind 失败。"
        echo "# 新增服务后重跑菜单 5 可刷新此列表。"
        echo "net.ipv4.ip_local_reserved_ports = ${reserved}"
    else
        echo "# 未检测到落在 32768-65535 内的监听端口，无需保留（ip_local_reserved_ports 留空）"
    fi)

# --- 缓冲区（第三个值是上限，内核按需增长，非预分配）---
# 中间值是新建 socket 初始缓冲：rmem 131072 是现代内核默认值（社区常抄的 87380 是老值，
# 会压低初始接收窗口）；wmem 16384 是内核/红帽 profile 默认值，发送侧有自动调优不需抬高
net.core.rmem_max = ${v_rmem_max}
net.core.wmem_max = ${v_wmem_max}
net.ipv4.tcp_rmem = ${v_rmem3}
net.ipv4.tcp_wmem = ${v_wmem3}

# --- 跨境链路针对性 ---
# 限制未发送队列积压，取 128K 而非社区常见的 16K（那是 Cloudflare 浏览器场景的值，
# 长肥网络转发写侧留太少会在高 RTT 下出现空隙）。131072 是红帽 openshift profile 的取值
net.ipv4.tcp_notsent_lowat = 131072
# MTU 探测，解决部分运营商阻断 ICMP 造成的 PMTU 黑洞
net.ipv4.tcp_mtu_probing = 1
# UDP 最小接收缓冲，缓解 QUIC/Hysteria2 高并发丢包。不设 udp_wmem_min——内核文档明写
# 该项对发送侧无效，UDP 发送侧放大靠 net.core.wmem_max + 应用自己 setsockopt(SO_SNDBUF)
net.ipv4.udp_rmem_min = 16384
# 长连接空闲后不退回慢启动（对 SSE / 长轮询 / 视频流有效）
net.ipv4.tcp_slow_start_after_idle = 0
# 不缓存 ssthresh/cwnd 进路由 metrics，避免跨境链路一次拥塞的低 ssthresh 被后续连接继承
net.ipv4.tcp_no_metrics_save = 1

# --- 连接回收 ---
# 不设 tcp_max_orphans：内核文档要求只升不降，默认值随内存自动伸缩，写死反而是降低它
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_fastopen = 3

# --- 文件句柄硬顶 ---
# 单进程 fd 数绝对上限，是 limits.d 与 systemd DefaultLimitNOFILE 的前提，同样取 max(现值,目标值)
fs.nr_open = ${v_nr_open}

$(if [ "$PROFILE" = "transit" ]; then
        echo "# --- 中转档专属：本机要为别的流量做转发 ---"
        echo "# 只在中转/代理/隧道节点开。普通业务机开了等于把自己变成路由器，"
        echo "# 配上不当的防火墙规则可能成为开放中继"
        echo "net.ipv4.ip_forward = 1"
    else
        echo "# 普通档不开 IP 转发（ip_forward 保持系统原值）。中转/代理/隧道节点请用菜单 6 的中转档"
    fi)

# --- 可选项：按需自行放开 ---
# ECN 默认值 2（被动接受）。设为 1 是主动请求，遇到老旧中间设备可能丢 SYN，故不默认开启
# net.ipv4.tcp_ecn = 1
# 重传次数默认 15（约 15 分钟）。降到 8（约 100 秒）更快失败换路，但长静默连接可能被误断
# net.ipv4.tcp_retries2 = 8

# ==================================================
# 以下是「故意不设」的参数，防止后续版本把有意的省略当成遗漏「补上」。
# ==================================================
# tcp_mem / udp_mem   单位是页非字节，社区脚本常搞错；内核按内存自动分档，ESnet 建议保持默认
# tcp_adv_win_scale   6.6 起 Obsolete，已被 per-socket scaling_ratio 取代，写了不生效
# tcp_tw_recycle      4.12 起已从内核删除，且删除前会因 NAT 环境误判丢包
# tcp_fack            5.4 起内核文档标注 legacy，无效果
# tcp_low_latency     4.14 起同上
# ipfrag_low_thresh   4.17 起标注 Obsolete
# tcp_max_tw_buckets  文档要求只升不降，社区常写的 5000 会绕过 TIME_WAIT 语义制造 RFC1337 风险
# tcp_sack / tcp_dsack / tcp_timestamps / tcp_window_scaling
#                     硬化脚本常关掉这些换性能，但长肥网络离不开 SACK/PAWS/窗口缩放，ESnet 反对关闭
# rp_filter           安全项非性能项，all=1 后单接口无法降回，多网卡/非对称路由场景会静默丢包
# tcp_rfc1337         安全项，默认 0 已有防护，设 1 会让代理机异常重置时多等一个 TIME_WAIT
# busy_poll/busy_read 用 CPU 换微秒级延迟，跨境瓶颈是 RTT 不是本机中断，红帽仅 latency profile 用
# rmem_default / wmem_default
#                     每 socket 默认值非上限，抬高对 UDP 浪费；上限交给 rmem_max/wmem_max
# optmem_max          内核默认 128KB 非社区常写的 65536，管 cmsg 辅助数据，与吞吐无关
# initcwnd / initrwnd 内核默认已是 RFC 6928 的 IW10，社区加大值无依据且会抬高丢包风险
# txqueuelen          红帽 tuned 未在任何内置 profile 用它，权威取值针对 10G 专用节点不适用本场景
EOF
    then
        echo -e "  ${RED}写入 $SYSCTL_OPT 失败（磁盘满 / 只读挂载 / 权限），内核参数未配置${NC}"
        echo -e "  ${YELLOW}已接管的冲突文件备份在 $STATE_DIR，可用菜单 30 回退${NC}"
        pause_return
        return 1
    fi

    apply_sysctl

    # 句柄上限的硬顶是 fs.nr_open，超过它 setrlimit 会被内核拒绝（实测报
    # "Operation not permitted"）。社区脚本普遍写死 1048576 而不看 nr_open，
    # 在 nr_open 较低的内核上会让 limits.conf 整行失效（PAM 报错但用户看不到）。
    #
    # [人工决策-2026-08-06 15:05:56] 处置从「取 min(想要的, nr_open) 往下砍」改为
    # 「把 nr_open 本身抬上去」。判例：Docker Desktop / WSL2 场景下 fs.nr_open 被
    # 别的东西降到 65536 时，旧写法把这个瞬时值烙进 limits.d 与 systemd drop-in，
    # 于是 DefaultLimitNOFILE 从发行版默认 1024:524288 被降成 2048:65536——
    # 本脚本成了「降低句柄上限」的那一方，而 ClickHouse 这类要求 nofile=262144 的
    # 容器直接起不来（runc 报 error setting rlimit type 7: operation not permitted）。
    # 同一台机器上另一个发行版因为运行时刻不同写出了 1048576，差 16 倍。
    #
    # 两点必须同时成立才对：
    #   1) nr_open 是可写的运行时 sysctl，抬它与抬 somaxconn 是同一类动作，
    #      归入受管清单后可被 save_original_values 快照、被菜单 30 完整还原；
    #   2) 即使抬不动（容器内 /proc/sys/fs 只读），也绝不低于机器现状——
    #      与 sysctl_ceil 的「只升不降」口径统一。旧写法在这里是单向下调，
    #      与同一个函数里 somaxconn 的处理自相矛盾。
    #
    # fs.nr_open 已写进主配置（$v_nr_open）并由上面的 apply_sysctl 生效，这里回读
    # 实际值而不是复用 $v_nr_open：抬不动时（容器内 /proc/sys/fs 只读、演练模式跳过
    # 了 sysctl --system）生效值仍是旧的，据实取才不会写出一个内核会拒绝的硬限
    local nofile_want=1048576 nr_open
    nr_open=$(sysctl -n fs.nr_open 2>/dev/null)
    case "$nr_open" in
    '' | *[!0-9]*) nr_open=$nofile_want ;;
    esac
    [ "$nofile_want" -gt "$nr_open" ] && nofile_want=$nr_open
    # 棘轮保护：不低于当前已生效的合法 systemd 硬限，本脚本只做「抬高」这一个方向
    local cur_hard
    cur_hard=$(systemctl show -p DefaultLimitNOFILE --value 2>/dev/null)
    case "$cur_hard" in
    '' | *[!0-9]*) cur_hard=0 ;;
    esac
    [ "$cur_hard" -gt "$nofile_want" ] && [ "$cur_hard" -le "$nr_open" ] && nofile_want=$cur_hard

    mkdir -p /etc/security/limits.d/
    # 句柄上限是附属项，是链接就跳过这一项、其余照做，不中止整个菜单
    if refuse_if_symlink "$LIMITS_OPT"; then
        if ! cat >"$LIMITS_OPT" <<EOF
# 由 tcpo 写入。nofile=${nofile_want}，受 fs.nr_open=${nr_open} 约束
# （硬限超过 fs.nr_open 时 setrlimit 会被内核拒绝、整行静默失效）。
# nr_open 本身也由 tcpo 抬到 1048576 并写进 $SYSCTL_OPT，不再被它往下限制
* soft nofile ${nofile_want}
* hard nofile ${nofile_want}
* soft nproc 65535
* hard nproc 65535
root soft nofile ${nofile_want}
root hard nofile ${nofile_want}
EOF
        then
            # 写不成只提示，不中止
            echo -e "  ${YELLOW}写入 $LIMITS_OPT 失败，句柄上限未改动${NC}"
        fi
    else
        echo -e "  ${YELLOW}跳过 limits.d（见上），句柄上限未改动${NC}"
    fi

    # limits.d 只走 PAM，systemd 服务的上限来自 DefaultLimitNOFILE。写法参照 CachyOS
    # 官方 10-limits.conf：软限留小避免老程序按 RLIMIT_NOFILE 预分配 fd 表吃内存，硬限受 nr_open 约束
    if has_systemd; then
        if mkdir -p "$(dirname "$SYSTEMD_LIMITS_OPT")" 2>/dev/null &&
            printf '[Manager]\nDefaultLimitNOFILE=2048:%s\n' "$nofile_want" |
            atomic_write "$SYSTEMD_LIMITS_OPT"; then
            echo -e "  systemd 全局句柄上限 ${CYAN}2048:${nofile_want}${NC}（重启或 daemon-reexec 后生效）"
        else
            echo -e "  ${YELLOW}systemd 全局句柄上限写入失败${NC}"
        fi
    fi

    # MSS clamp 只在中转档加：只对转发流量有意义，本机连接已有 tcp_mtu_probing 处理；
    # 挂 FORWARD 链而非 POSTROUTING，避免语义混到本机 OUTPUT 的包
    if [ "$PROFILE" = "transit" ]; then
        # 中转档必须有 iptables，缺了就装（多个发行版最小化安装不自带）
        if ensure_tools iptables >/dev/null; then
            # 先清掉旧规则（含无 comment 版本）再加新规则，细节见 del_owned_mss
            del_owned_mss >/dev/null
            if dryrun_skip "iptables -t mangle -A FORWARD ... TCPMSS --clamp-mss-to-pmtu"; then
                :
            elif iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
                -m comment --comment "$MSS_TAG" -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null; then
                echo -e "${GREEN}  已加 MSS Clamp 规则到 mangle FORWARD（标记 $MSS_TAG）${NC}"
                echo -e "  ${YELLOW}iptables 规则重启即失效，要持久化请装 iptables-persistent 并 netfilter-persistent save${NC}"
            elif iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
                -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null; then
                # xt_comment 缺失时降级到无标记规则，回退时只能靠精确文本匹配
                echo -e "${GREEN}  已加 MSS Clamp 规则到 mangle FORWARD${NC}"
                echo -e "  ${YELLOW}本机缺 xt_comment 模块，规则未带所有权标记；回退时若你自己也加过同样的规则，可能一并删掉${NC}"
            else
                echo -e "  ${YELLOW}MSS Clamp 规则添加失败（内核缺 xt_TCPMSS 模块或权限不足），已跳过${NC}"
            fi
        else
            echo -e "  ${YELLOW}iptables 不可用且自动安装失败，跳过 MSS Clamp。${NC}"
            echo -e "  ${YELLOW}手动补: iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu${NC}"
        fi
    fi

    echo -e "\n${GREEN}完成，前后对比：${NC}"
    draw_line
    printf "  %-12s: %-14s -> %s\n" "拥塞算法" "$old_cc" "$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)"
    printf "  %-12s: %-14s -> %s\n" "somaxconn" "$old_somax" "$(sysctl -n net.core.somaxconn 2>/dev/null)"
    # 读不到显示「不可读」而非 0MB，避免误以为参数被清零
    local new_rmem
    new_rmem=$(sysctl -n net.core.rmem_max 2>/dev/null)
    if [ -n "$new_rmem" ]; then
        printf "  %-12s: %-14s -> %s\n" "缓冲区上限" "$((old_rmem / 1024 / 1024))MB" "$((new_rmem / 1024 / 1024))MB"
    else
        printf "  %-12s: %-14s -> %s\n" "缓冲区上限" "$((old_rmem / 1024 / 1024))MB" "不可读(容器环境)，配置文件已写 $((buf_bytes / 1024 / 1024))MB"
    fi
    draw_line

    verify_sysctl_applied
    # 「本次生效」与「开机还生效」分开报，避免无 systemd 机器被误报持久化成功
    report_sysctl_persist "$SYSCTL_OPT"

    pause_return
}

# 逐项比对「配置文件里写的」与「sysctl 实际生效的」，输出 <OK|BAD|ABSENT>\t<key>\t<期望>\t<实际>。
# 分隔符用 TAB 而非空格：部分值本身带空格会被切碎。提成独立函数供主菜单共用同一份判据
sysctl_diff_lines() {
    local conf=$1 key want got line
    [ -f "$conf" ] || return 0
    while IFS= read -r line; do
        case "$line" in '' | '#'*) continue ;; esac
        key=${line%%=*}
        want=${line#*=}
        key=$(echo "$key" | tr -d ' \t')
        want=$(echo "$want" | tr -s ' \t' ' ' | sed 's/^ //;s/ $//')
        [ -z "$key" ] && continue
        # 空串是合法值（如 ip_local_reserved_ports），判「读不到」只能看退出码
        if ! got=$(sysctl -n "$key" 2>/dev/null); then
            printf 'ABSENT\t%s\t%s\t\n' "$key" "$want"
            continue
        fi
        got=$(printf '%s' "$got" | tr -s ' \t' ' ' | sed 's/^ //;s/ $//')
        if [ "$got" = "$want" ]; then
            printf 'OK\t%s\t%s\t%s\n' "$key" "$want" "$got"
        else
            printf 'BAD\t%s\t%s\t%s\n' "$key" "$want" "$got"
        fi
    done <"$conf"
}

# 配置文件里有没有「写了但没生效」的项（只数 BAD，ABSENT 属正常）。
# 主菜单用它区分「配置在且生效」与「配置在但没生效」（无 systemd 机器重启后的状态）
sysctl_conf_stale() {
    sysctl_diff_lines "$1" | grep -q '^BAD'
}

# 写入前预检 key 在本内核能不能设：MISSING 不存在（跳过）、RO 不可写、WRITABLE 可写。
# 用 /proc/sys 路径而非 sysctl -n：后者可读不代表可写
sysctl_writable() {
    local path
    # 用 tr 而非 ${1//./\/}：zsh 下反斜杠处理不一致会导致误判成 MISSING
    path="/proc/sys/$(printf '%s' "$1" | tr '.' '/')"
    if [ ! -e "$path" ]; then
        echo MISSING
    elif [ ! -w "$path" ]; then
        echo RO
    else
        echo WRITABLE
    fi
}

# 对一份 key 清单做预检并打印汇总。只报告不阻断，即使全部不可写也要把配置文件写下去
sysctl_precheck() {
    local keys=$1 k st missing="" ro="" n_ok=0
    for k in $keys; do
        st=$(sysctl_writable "$k")
        case "$st" in
        MISSING) missing="${missing} $k" ;;
        RO) ro="${ro} $k" ;;
        *) n_ok=$((n_ok + 1)) ;;
        esac
    done
    if [ -z "$missing" ] && [ -z "$ro" ]; then
        return 0
    fi
    if [ -n "$missing" ]; then
        echo -e "  ${YELLOW}本内核不存在（跳过）:${NC}"
        echo -e "${YELLOW}$(wrap_keys "$missing")${NC}"
    fi
    if [ -n "$ro" ]; then
        echo -e "  ${YELLOW}不可写（容器命名空间外）:${NC}"
        echo -e "${YELLOW}$(wrap_keys "$ro")${NC}"
    fi
    return 0
}

# 把一串 key 折行成缩进段落，不用 fmt（busybox 环境常缺）
wrap_keys() {
    printf '%s\n' $1 | awk '
        { if (length(line) + length($1) + 1 > 62) { print "      " line; line = "" }
          line = line (line == "" ? "" : " ") $1 }
        END { if (line != "") print "      " line }'
}

# 写进配置不等于生效（可能被后加载文件覆盖、内核不支持、模块未加载，均静默失败）。
# 判例：somaxconn 写了 65535，被发行版自带的 99-sysctl.conf 盖回 1024，无任何提示
verify_sysctl_applied() {
    local mismatch=0 absent=0 total=0 in_container=0 verdict key want got
    is_container && in_container=1

    # IFS 只设 TAB：值里带空格（三元组）时按空格切会把字段切碎
    while IFS=$'\t' read -r verdict key want got; do
        [ -z "$verdict" ] && continue
        total=$((total + 1))
        case "$verdict" in
        ABSENT)
            # 读不到可能是容器命名空间外（正常）或参数名写错/内核太老，不算全部生效
            absent=$((absent + 1))
            [ "$in_container" = "0" ] && echo -e "  ${YELLOW}? $key 本内核无此参数${NC}"
            ;;
        BAD)
            echo -e "  ${RED}✗ $key 期望 [$want] 实际 [$got]${NC}"
            mismatch=$((mismatch + 1))
            ;;
        esac
    done <<EOF
$(sysctl_diff_lines "$SYSCTL_OPT")
EOF

    local applied=$((total - mismatch - absent))
    if [ "$mismatch" -gt 0 ]; then
        echo -e "  ${RED}有 $mismatch 项未生效（共 $total 项）${NC}"
        if [ "$in_container" = "1" ]; then
            echo -e "  ${YELLOW}容器环境: net.core.* 在容器内只读属正常${NC}"
        else
            echo -e "  ${YELLOW}排查: grep -rn '<参数名>' /etc/sysctl.d/ /etc/sysctl.conf 或看 $APPLY_LOG${NC}"
        fi
    elif [ "$absent" -gt 0 ]; then
        if [ "$in_container" = "1" ]; then
            echo -e "  ${GREEN}✓ $applied 项已生效${NC}（$absent 项容器命名空间外）"
        else
            echo -e "  ${GREEN}✓ $applied 项已生效${NC}（$absent 项本内核不支持）"
        fi
    else
        echo -e "  ${GREEN}✓ 全部 $total 项已生效${NC}"
    fi
}

# ==================================================
# --- 4. 网卡多队列 / RPS ---
# ==================================================
# 生成 N 个 CPU 全开的掩码，逗号分隔的 32 位字、高位在前（内核 bitmap 格式）。
# 不能用 printf '%x' $(((1 << n) - 1))：n>=64 时移位溢出得 0，等于关闭 RPS
cpu_mask_all() {
    local n=$1 i out="" bits w words
    # words 需单独一行赋值，同一条 local 语句内引用刚赋值的 n 会展开不到值
    words=$(((n + 31) / 32))
    for ((i = words - 1; i >= 0; i--)); do
        bits=$((n - i * 32))
        [ "$bits" -gt 32 ] && bits=32
        if [ "$bits" -ge 32 ]; then
            w=ffffffff
        else
            w=$(printf '%08x' $(((1 << bits) - 1)))
        fi
        out="${out:+$out,}$w"
    done
    echo "$out"
}

# 掩码是否表示「已启用」：内核会重排格式，字符串比较不可靠，改判去掉 0 和逗号后是否还有内容
mask_is_set() {
    case "$(echo "$1" | tr -d '0,[:space:]')" in
    '') return 1 ;;
    *) return 0 ;;
    esac
}

# 候选物理网卡：排除回环与各类虚拟设备
phys_ifaces() {
    ls /sys/class/net 2>/dev/null | grep -vE '^(lo|docker|veth|br-|virbr|tun|tap|wg|sit|gre|vxlan|bond.*\.|any|dummy|ifb|nlmon|teql)'
}

# 网卡硬件收队列数（RSS）。ethtool -l 的 Current 段 Combined，取不到退回 rx-* 目录数
nic_hw_queues() {
    local eth=$1 n
    n=$(ethtool -l "$eth" 2>/dev/null | awk '/Current hardware settings/,0' | awk '/^Combined:/{print $2; exit}')
    case "$n" in '' | *[!0-9]*) n=$(ls -d /sys/class/net/"$eth"/queues/rx-* 2>/dev/null | wc -l) ;; esac
    echo "${n:-0}"
}

# ==================================================
# --- 4. 网卡多队列 / RPS / RFS / XPS ---
# ==================================================
tune_nic() {
    echo -e "\n${YELLOW}>>> 网卡队列与软中断分发${NC}"
    save_original_values || { pause_return; return 1; }
    # ethtool 读硬件队列数与丢包计数，缺了不致命（退回数 rx-* 目录），装上判断更准
    if ! ensure_tools ethtool >/dev/null; then
        echo -e "  ${YELLOW}ethtool 不可用，硬件队列数改用 rx-* 目录数估算，ring buffer 丢包检查跳过${NC}"
    fi

    local cpu_count mask flow_entries plan_tmp done_any=0
    cpu_count=$(nproc)
    mask=$(cpu_mask_all "$cpu_count")
    # RFS 全局表大小按核数给，钳到 [32768, 262144]
    flow_entries=$((cpu_count * 4096))
    [ "$flow_entries" -lt 32768 ] && flow_entries=32768
    [ "$flow_entries" -gt 262144 ] && flow_entries=262144

    mkdir -p "$STATE_DIR"
    plan_tmp=$(mktemp)

    # RFS 全局表必须先分配再配每队列 rps_flow_cnt，顺序反了全局表就没法用
    # 写独立 drop-in 而非追加 $SYSCTL_OPT：本菜单可单独执行，那时 $SYSCTL_OPT 可能不存在，追加会静默丢持久化
    [ "$DRYRUN" = "1" ] || sysctl -w net.core.rps_sock_flow_entries="$flow_entries" &>/dev/null
    # 写失败必须中止：全局流表没落地，重启后会出现「每队列有值、全局表是 0」的 RFS 半失效状态
    # [人工决策-2026-08-05 20:14:33] 原先写成 A && B && cat >... 的 && 链，cat 失败后
    # 函数照样往下走到 apply_sysctl 并返回 0（/dev/full 可复现）。这种 && 链形式还逃过了
    # 「无裸写 cat」的通用机检（它只匹配行首的 cat >），机检判据已一并修正
    if ! ensure_sysctl_dir || ! refuse_if_symlink "$NIC_SYSCTL_OPT" ||
        ! cat >"$NIC_SYSCTL_OPT" <<EOF
# 由 tcp-dashboard 菜单 7 写入。RFS 全局流表大小，随 CPU 核数计算。
# 每队列的 rps_flow_cnt = 本值 / 队列数，由 $NIC_APPLY 在开机时重放。
net.core.rps_sock_flow_entries = ${flow_entries}
EOF
    then
        echo -e "  ${RED}写入 $NIC_SYSCTL_OPT 失败，RFS 全局流表未持久化${NC}"
        echo -e "  ${YELLOW}继续下去会让重启后「每队列有 rps_flow_cnt、全局表是 0」——RFS 半失效${NC}"
        # 早退也要清临时文件：函数尾部那个 rm 走不到，每次命中这条路径就残留一个
        rm -f "$plan_tmp"
        pause_return
        return 1
    fi
    # 老版本可能把这一项追加在主配置文件里，清掉避免两处并存
    [ -f "$SYSCTL_OPT" ] && sed -i '/^net\.core\.rps_sock_flow_entries/d' "$SYSCTL_OPT"
    apply_sysctl

    local eth hwq nqueues per_flow
    for eth in $(phys_ifaces); do
        hwq=$(nic_hw_queues "$eth")
        nqueues=$(ls -d /sys/class/net/"$eth"/queues/rx-* 2>/dev/null | wc -l)
        [ "$nqueues" -eq 0 ] && continue

        # 硬件队列已够多时不开 RPS：RSS 已分好核，再叠软件分发只会多一次 IPI 抬高延迟（内核 scaling.rst）
        if [ "$hwq" -ge "$cpu_count" ] && [ "$hwq" -gt 1 ]; then
            echo -e "  ${CYAN}$eth${NC}: RSS 足够，跳过 RPS"
        else
            [ "$hwq" -gt 1 ] && echo -e "  ${CYAN}$eth${NC}: RPS 补足（$hwq 队列 < $cpu_count 核）"
            per_flow=$((flow_entries / nqueues))
            for f in /sys/class/net/"$eth"/queues/rx-*/rps_cpus; do
                [ -f "$f" ] || continue
                # 重定向失败的报错由 shell 打印，必须包住整个复合命令才吞得掉（只读 /sys 常见）
                if [ "$DRYRUN" = "1" ]; then
                    echo -e "$f\t$mask" >>"$plan_tmp"
                    done_any=1
                elif { echo "$mask" >"$f"; } 2>/dev/null; then
                    echo -e "$f\t$mask" >>"$plan_tmp"
                    done_any=1
                fi
            done
            # rps_flow_cnt = 全局表/队列数，非固定 4096
            for f in /sys/class/net/"$eth"/queues/rx-*/rps_flow_cnt; do
                [ -f "$f" ] || continue
                if [ "$DRYRUN" = "1" ]; then
                    echo -e "$f\t$per_flow" >>"$plan_tmp"
                elif { echo "$per_flow" >"$f"; } 2>/dev/null; then
                    echo -e "$f\t$per_flow" >>"$plan_tmp"
                fi
            done
        fi

        # XPS（发送侧）：把每个 tx 队列绑到一组 CPU，减少多核并发发送的队列锁竞争；多队列按轮转分配，单队列全核
        local txn i cpu xmask
        txn=$(ls -d /sys/class/net/"$eth"/queues/tx-* 2>/dev/null | wc -l)
        if [ "$txn" -gt 0 ]; then
            i=0
            for f in /sys/class/net/"$eth"/queues/tx-*/xps_cpus; do
                [ -f "$f" ] || continue
                if [ "$txn" -ge 2 ] && [ "$cpu_count" -ge 2 ]; then
                    cpu=$((i % cpu_count))
                    xmask=$(cpu_mask_one "$cpu_count" "$cpu")
                else
                    xmask=$mask
                fi
                if [ "$DRYRUN" = "1" ]; then
                    echo -e "$f\t$xmask" >>"$plan_tmp"
                    done_any=1
                elif { echo "$xmask" >"$f"; } 2>/dev/null; then
                    echo -e "$f\t$xmask" >>"$plan_tmp"
                    done_any=1
                fi
                i=$((i + 1))
            done
        fi

        # ring buffer 按需：拉满会抬高排队延迟、加剧 bufferbloat，先看驱动计数器有无真实丢包
        local drops
        drops=$(ethtool -S "$eth" 2>/dev/null | grep -iE 'drop|over|miss|fifo' |
            awk -F: '{gsub(/ /,"",$2); s+=$2} END{print s+0}')
        if [ "${drops:-0}" -gt 0 ]; then
            echo -e "  ${YELLOW}$eth 有 ${drops} 次丢包，可 ethtool -G $eth rx <max> 加大 ring buffer${NC}"
        fi
    done

    if [ "$done_any" = "1" ]; then
        # plan 没落地就装 unit 等于装一个开机什么都不做的服务，还报成功
        # [人工决策-2026-08-05 22:14:38] 原先是裸 `sort -u > "$NICPLAN"` 不查返回值，
        # /dev/full 复现：sort 已报 No space left on device，后续仍装 unit 并报成功、rc=0。
        # 走 atomic_write 一并拿到链接防护与原子性（写一半断电不会留半截 plan）
        if ! sort -u "$plan_tmp" | atomic_write "$NICPLAN"; then
            echo -e "  ${RED}写入 $NICPLAN 失败，开机重放清单没落地${NC}"
            echo -e "  ${YELLOW}当前会话的队列设置已生效，但重启后不会恢复；不安装开机单元${NC}"
            rm -f "$plan_tmp"
            pause_return
            return 1
        fi
        echo -e "${GREEN}完成：RPS/XPS 已配置${NC}"
        if install_nic_unit; then
            echo -e "  ${GREEN}开机单元已启用并校验通过${NC}"
        else
            echo -e "  ${YELLOW}开机单元未能启用，重启后 /sys 设置会失效${NC}"
        fi
    else
        echo -e "${YELLOW}未改动：没找到可写的网卡队列（容器或只读 /sys 环境常见）${NC}"
    fi
    rm -f "$plan_tmp"
    pause_return
}

# 单个 CPU 的掩码（供 XPS 逐队列绑核）
cpu_mask_one() {
    local n=$1 cpu=$2 i out="" w words
    words=$(((n + 31) / 32))
    for ((i = words - 1; i >= 0; i--)); do
        if [ $((cpu / 32)) -eq "$i" ]; then
            w=$(printf '%08x' $((1 << (cpu % 32))))
        else
            w=00000000
        fi
        out="${out:+$out,}$w"
    done
    echo "$out"
}

# /sys 设置重启即失效，用 oneshot unit 在开机时重放 plan；unit 不调用本脚本函数（bash 不 hoist，
# 会 command not found 却仍 exit 0），改为生成独立的只读 plan 小脚本

# 返回 0 表示 unit 确实已 enable，而非仅「unit 文件存在」——文件是无条件写的，写成功不代表 systemd 接受了它
nic_unit_enabled() {
    has_systemd || return 1
    [ -f "$NIC_UNIT" ] || return 1
    systemctl is-enabled "$(basename "$NIC_UNIT")" 2>/dev/null | grep -q '^enabled$'
}

install_nic_unit() {
    if ! has_systemd; then
        echo -e "  ${YELLOW}无 systemd，无法安装开机单元${NC}"
        # 应用脚本仍写出来方便用户自己挂，写不成要如实说
        if ! write_nic_apply; then
            echo -e "  ${RED}辅助脚本未能写出（原因见上），无法提供任何形式的开机重放${NC}"
            return 1
        fi
        # $NIC_SYSCTL_OPT 里的 RFS 全局流表是 sysctl 参数，$NIC_APPLY 不重放它，
        # 只挂 nic-apply 会导致全局表是 0、RFS 半失效，须同时备好 sysctl 重放脚本（它内部会调 nic-apply）
        if is_container; then
            echo -e "  ${YELLOW}当前设置本次有效，重启后失效。容器的持久化由宿主决定${NC}"
            return 1
        fi
        if write_sysctl_apply; then
            echo -e "  ${GREEN}已生成 $SYSCTL_APPLY${NC}"
            wsl_boot_hint
        else
            echo -e "  ${YELLOW}需手动挂 $NIC_APPLY 到 init 流程，并另行重放 $NIC_SYSCTL_OPT（RFS 全局表）${NC}"
        fi
        return 1
    fi

    # helper 没落地就装 unit，会成 status=203/EXEC 且 enable 仍报成功，等于假报持久化
    if ! write_nic_apply; then
        echo -e "  ${RED}辅助脚本写出失败，不安装开机单元${NC}"
        return 1
    fi

    refuse_if_symlink "$NIC_UNIT" || return 1
    # 写失败就不往下 enable：否则会 enable 一个内容不完整的 unit 却报成功
    if ! cat >"$NIC_UNIT" <<UNITEOF
[Unit]
Description=tcp-dashboard: apply RPS/RFS/XPS queue settings
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=$NIC_APPLY

[Install]
WantedBy=multi-user.target
UNITEOF
    then
        echo -e "  ${RED}写入 $NIC_UNIT 失败（磁盘满 / 只读挂载 / 权限），开机单元未安装${NC}"
        return 1
    fi

    local err
    if [ "$DRYRUN" = "1" ]; then
        echo -e "  ${CYAN}[演练]${NC} 跳过: systemctl daemon-reload + enable $(basename "$NIC_UNIT")"
        return 0
    fi
    if ! err=$(systemctl daemon-reload 2>&1); then
        echo -e "  ${RED}systemctl daemon-reload 失败，开机单元未生效: $err${NC}"
        return 1
    fi
    if ! err=$(systemctl enable "$(basename "$NIC_UNIT")" 2>&1); then
        echo -e "  ${RED}systemctl enable 失败，开机单元未生效: $err${NC}"
        return 1
    fi
    if ! nic_unit_enabled; then
        echo -e "  ${RED}开机单元 enable 后校验仍未通过，请手动检查 systemctl status $(basename "$NIC_UNIT")${NC}"
        return 1
    fi
    return 0
}

write_nic_apply() {
    # 本脚本是 RPS/XPS/qdisc 持久化的唯一执行者，没落地 unit 装再好也没用，写入失败必须让调用方知道
    # [人工决策-2026-08-05 15:48:02] 早前 cat 与 chmod 都不查返回值、函数恒返回 0，
    # 于是 install_nic_unit 照常建 unit 并报「开机重放已装好」。用 /dev/full 做
    # 目标可复现：shell 已报 No space left on device，函数仍返回 rc=0。
    # 项目原则「落地类操作必须查返回值再宣布成功」在这里被漏掉了
    refuse_if_symlink "$NIC_APPLY" || return 1
    if ! cat >"$NIC_APPLY" <<'APPLYEOF'
#!/bin/bash
# 由 tcp-dashboard 生成：开机重放 RPS/RFS/XPS 与 qdisc 设置。
# 两份 plan 均「不存在就跳过」；本脚本不调用主脚本函数（bash 不 hoist，会假成功）

# --- 1. /sys 队列设置（路径<TAB>值，直接写入）---
PLAN="/var/lib/tcp-dashboard/nic.plan"
if [ -f "$PLAN" ]; then
    while IFS=$'\t' read -r path value; do
        [ -n "$path" ] && [ -f "$path" ] && { echo "$value" >"$path"; } 2>/dev/null
    done <"$PLAN"
fi

# --- 2. qdisc（网卡<TAB>目标qdisc，要跑 tc 且区分多队列）---
# 多队列网卡 root 恒为 mq/mqprio，真实 qdisc 在叶子上，必须逐叶子替换，顶掉 mq 等于放弃多队列
QPLAN="/var/lib/tcp-dashboard/qdisc.plan"
if [ -f "$QPLAN" ] && command -v tc >/dev/null 2>&1; then
    while IFS=$'\t' read -r qdev qwant; do
        [ -n "$qdev" ] && [ -n "$qwant" ] || continue
        [ -d "/sys/class/net/$qdev" ] || continue
        qroot=$(tc qdisc show dev "$qdev" 2>/dev/null | awk 'NR==1{print $2}')
        case "$qroot" in
        mq | mqprio)
            # 队列号自己拼，不枚举现有 parent 行：开机时叶子可能还没建出来，枚举会拿到空集
            qh=$(tc qdisc show dev "$qdev" 2>/dev/null | awk 'NR==1{print $3}')
            [ -n "$qh" ] || continue
            qn=0
            for qq in /sys/class/net/"$qdev"/queues/tx-*; do
                [ -d "$qq" ] && qn=$((qn + 1))
            done
            qi=1
            while [ "$qi" -le "$qn" ]; do
                tc qdisc replace dev "$qdev" parent "${qh}${qi}" "$qwant" 2>/dev/null
                qi=$((qi + 1))
            done
            ;;
        '') ;;
        # noqueue 是 lo/veth 的固定行为（内核忽略 default_qdisc），不该覆盖
        noqueue) ;;
        *) tc qdisc replace dev "$qdev" root "$qwant" 2>/dev/null ;;
        esac
    done <"$QPLAN"
fi
exit 0
APPLYEOF
    then
        echo -e "  ${RED}写入 $NIC_APPLY 失败（磁盘满 / 只读挂载 / 权限）${NC}"
        echo -e "  ${RED}  开机重放脚本没落地，队列设置重启后不会恢复${NC}"
        return 1
    fi
    # 没有可执行位，ExecStart 会 Permission denied，等于持久化没做成
    if ! chmod 755 "$NIC_APPLY"; then
        echo -e "  ${RED}给 $NIC_APPLY 加可执行位失败，开机重放不会生效${NC}"
        return 1
    fi
    # 兜底确认落地结果真的存在且可执行
    if [ ! -x "$NIC_APPLY" ]; then
        echo -e "  ${RED}$NIC_APPLY 写完后仍不可执行，开机重放不会生效${NC}"
        return 1
    fi

    # 路由感知兜底：网卡热插拔/重连后 oneshot unit 不会再跑，靠 networkd-dispatcher 补一次；
    # 该目录只在装了 systemd-networkd 的机器上存在，有就加、没有不报错，exec 同一个 $NIC_APPLY
    if [ -d /etc/networkd-dispatcher/routable.d ] && refuse_if_symlink "$NIC_DISPATCH"; then
        if printf '#!/bin/sh\nexec %s\n' "$NIC_APPLY" >"$NIC_DISPATCH" 2>/dev/null &&
            chmod 755 "$NIC_DISPATCH" 2>/dev/null; then
            echo -e "  ${GREEN}已加 networkd-dispatcher 钩子（网卡重连后自动重放）${NC}"
        else
            echo -e "  ${YELLOW}networkd-dispatcher 钩子写入失败，不影响开机重放${NC}"
        fi
    fi
    return 0
}

# ==================================================
# --- c. 让 default_qdisc 立刻在已有网卡上生效 ---
# ==================================================
# 运行时改动（tc）重启即失效，切换瞬间会丢包，多数人重启一次即可
# 多队列网卡必须逐叶子替换，不能动 root（实测 replace root 会压成单队列争锁）
# [人工决策-2026-08-05 11:02:33] 社区脚本（Bud668/vps-mgr、LightKnightBBR 等）普遍
# 无条件 replace root，等于在多队列机器上主动放弃多队列。本项目按 root 类型分流
apply_live_qdisc() {
    echo -e "\n${YELLOW}>>> 让 default_qdisc 在已有网卡上立刻生效${NC}"
    # 写入类操作缺工具就装；RHEL 系把 tc 拆到独立包 iproute-tc，pkg_for 已处理映射
    if ! command -v tc >/dev/null 2>&1; then
        ensure_tools tc >/dev/null
        if ! command -v tc >/dev/null 2>&1; then
            echo -e "  ${YELLOW}tc 不可用且自动安装失败，无法继续（见上面的手动补装命令）${NC}"
            pause_return
            return 1
        fi
    fi

    local dev want root leaf
    dev=$(egress_dev)
    want=$(sysctl -n net.core.default_qdisc 2>/dev/null)
    if [ -z "$dev" ]; then
        echo -e "  ${YELLOW}识别不出出口网卡，跳过${NC}"
        pause_return
        return 1
    fi
    if [ -z "$want" ]; then
        echo -e "  ${YELLOW}读不到 net.core.default_qdisc（容器命名空间外），跳过${NC}"
        pause_return
        return 1
    fi

    IFS=$'\t' read -r root leaf <<<"$(qdisc_kinds "$dev")"
    echo -e "  出口网卡: ${CYAN}${dev}${NC}   目标 qdisc: ${CYAN}${want}${NC}"
    case "$root" in
    mq | mqprio)
        echo -e "  当前: root=${CYAN}${root}${NC}（多队列容器）叶子=${CYAN}${leaf:-未知}${NC}"
        ;;
    *)
        echo -e "  当前: root=${CYAN}${root:-未知}${NC}"
        ;;
    esac

    # noqueue 是设备类型决定的：内核文档明写 lo/veth 忽略 default_qdisc 固定用 noqueue，改它无意义
    if [ "$root" = "noqueue" ]; then
        echo -e "  ${YELLOW}本设备是 noqueue：lo / veth 这类虚拟设备按内核设计忽略 default_qdisc${NC}"
        echo -e "  ${YELLOW}容器内的出口网卡通常就是 veth。宿主机上的物理网卡才需要本项${NC}"
        pause_return
        return 0
    fi

    # 已是目标值就不动，幂等
    local effective=$root
    case "$root" in mq | mqprio) effective=$leaf ;; esac
    if [ "$effective" = "$want" ]; then
        echo -e "  ${GREEN}已经是 ${want}，无需改动${NC}"
        pause_return
        return 0
    fi

    echo -e "\n  ${YELLOW}这是运行时改动，与本面板其他改动不同：${NC}"
    case "$root" in
    mq | mqprio)
        echo -e "  ${YELLOW}  · 将逐个队列执行 tc qdisc replace dev $dev parent <N> $want${NC}"
        echo -e "  ${YELLOW}  · root 的 $root 保持不动（顶掉它等于放弃多队列）${NC}"
        ;;
    *)
        echo -e "  ${YELLOW}  · 将执行 tc qdisc replace dev $dev root $want${NC}"
        ;;
    esac
    echo -e "  ${YELLOW}  · 队列切换瞬间会丢弃已排队的包（量很小但非零）${NC}"
    echo -e "  ${YELLOW}  · 重启后由开机单元重放；回退用菜单 30（会 tc qdisc del 恢复内核默认）${NC}"
    if [ "$BATCH" != "1" ]; then
        read -p "  确认执行？[y/N]: " _c
        [[ "$_c" =~ ^[Yy]$ ]] || {
            echo -e "  ${GREEN}已取消，未做任何改动${NC}"
            pause_return
            return 0
        }
    fi

    save_original_values || { pause_return; return 1; }
    mkdir -p "$STATE_DIR"

    local done_n=0 fail_n=0 p
    if [ "$DRYRUN" = "1" ]; then
        echo -e "  ${CYAN}[演练]${NC} 跳过 tc qdisc replace，仅写 plan 文件"
        printf '%s\t%s\n' "$dev" "$want" | atomic_write "$QDISCPLAN"
        pause_return
        return 0
    fi
    case "$root" in
    mq | mqprio)
        # parent handle 取 tc 输出里 parent 后一个字段，不硬编码（主号由内核分配）
        for p in $(tc qdisc show dev "$dev" 2>/dev/null |
            awk '/parent/{for(i=1;i<=NF;i++) if($i=="parent") print $(i+1)}'); do
            if tc qdisc replace dev "$dev" parent "$p" "$want" 2>/dev/null; then
                done_n=$((done_n + 1))
            else
                fail_n=$((fail_n + 1))
            fi
        done
        ;;
    *)
        if tc qdisc replace dev "$dev" root "$want" 2>/dev/null; then
            done_n=1
        else
            fail_n=1
        fi
        ;;
    esac

    if [ "$done_n" -eq 0 ]; then
        echo -e "  ${RED}全部失败（$fail_n 项）。可能本内核缺 sch_${want} 模块，或权限不足${NC}"
        echo -e "  ${YELLOW}检查: modinfo sch_${want}${NC}"
        pause_return
        return 1
    fi

    # 回读校验：tc 返回 0 不等于真的换了
    IFS=$'\t' read -r root leaf <<<"$(qdisc_kinds "$dev")"
    local now=$root
    case "$root" in mq | mqprio) now=$leaf ;; esac
    if [ "$now" = "$want" ]; then
        echo -e "  ${GREEN}完成：$done_n 个队列已切到 $want（回读校验通过）${NC}"
        [ "$fail_n" -gt 0 ] && echo -e "  ${YELLOW}其中 $fail_n 项失败${NC}"
    else
        echo -e "  ${YELLOW}执行了 $done_n 项，但回读到的仍是「$now」而非 $want${NC}"
        echo -e "  ${YELLOW}请手动检查: tc qdisc show dev $dev${NC}"
    fi

    # 持久化写进独立的 qdisc plan（格式与 $NICPLAN 不同，不复用），走 atomic_write 防半截写入
    if ! printf '%s\t%s\n' "$dev" "$want" | atomic_write "$QDISCPLAN"; then
        echo -e "  ${YELLOW}qdisc plan 写入失败，本次改动重启后不会重放${NC}"
    fi
    if install_nic_unit; then
        echo -e "  ${GREEN}已写入开机重放（$QDISCPLAN），重启后仍生效${NC}"
    else
        echo -e "  ${YELLOW}未能启用开机单元，本次改动重启后失效（原因见上）${NC}"
    fi
    pause_return
}

# ==================================================
# --- 8. 诊断：调 sysctl 到底有没有用 ---
# ==================================================
# 只读，不改任何东西。ss -ti 暴露内核记的连接级瓶颈：sndbuf_limited/rwnd_limited/app_limited
# 分别对应本机缓冲不够、对端窗口小、应用喂不满，只有第一种调本机 sysctl 才有救
diagnose() {
    echo -e "\n${YELLOW}>>> 网络瓶颈诊断（只读，不修改任何设置）${NC}"
    # 只读功能不装包：装包会动 apt/dpkg 数据库，不是零副作用；缺工具就降级给手动补装命令
    require_tools_readonly ss nstat tc
    local dev cc qdisc live_qdisc bbr_ver
    dev=$(egress_dev)
    cc=$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)
    qdisc=$(sysctl -n net.core.default_qdisc 2>/dev/null)

    draw_line
    echo -e "  出口网卡     : ${CYAN}${dev:-未识别}${NC}"
    echo -e "  拥塞算法     : ${CYAN}${cc}${NC}$(bbr_version_label)"
    # 可用算法列表比只看当前值可靠：bbr 写入被拒时当前值不变，容易误判为「本来就不是 bbr」
    local avail_cc
    avail_cc=$(sysctl -n net.ipv4.tcp_available_congestion_control 2>/dev/null)
    if [ -n "$avail_cc" ]; then
        echo -e "  可用算法     : ${CYAN}${avail_cc}${NC}"
        case " $avail_cc " in
        *" bbr "*) ;;
        *) echo -e "    ${YELLOW}=> 列表里没有 bbr：本内核未编入或模块未加载，菜单 4 会尝试 modprobe tcp_bbr${NC}" ;;
        esac
    fi
    echo -e "  default_qdisc: ${CYAN}${qdisc}${NC}"
    # default_qdisc 只对之后新建的队列生效，须查网卡实际挂的 qdisc；多队列网卡 root 恒为
    # mq/mqprio，真实 qdisc 在叶子上（内核文档：multiqueue NICs keep mq as root）
    # [人工决策-2026-08-05 10:32:41] 早期版本只取 tc 输出第一行的 root 去比 default_qdisc，
    # 在任何多队列网卡上都必然报「不一致」——实测 root=mq / default_qdisc=fq_codel 即触发。
    # 改为 root 是 mq/mqprio 时以叶子类型为判据，且不自动执行 tc qdisc replace：
    # tc 改动是运行时的、重启即失效，要做就得配持久化，属菜单 8 的职责，诊断保持只读
    local live_root live_leaf effective
    if [ -n "$dev" ]; then
        IFS=$'\t' read -r live_root live_leaf <<<"$(qdisc_kinds "$dev")"
        case "$live_root" in
        mq | mqprio)
            # 叶子可能不止一种（部分队列被手工改过），全列出来
            echo -e "  实际 root qdisc: ${CYAN}${live_root}${NC}（多队列容器，本身不是排队算法）"
            echo -e "    叶子 qdisc   : ${CYAN}${live_leaf:-未知}${NC}"
            effective=$live_leaf
            ;;
        '')
            echo -e "  实际 root qdisc: ${YELLOW}未知（缺 tc 或读取失败）${NC}"
            effective=""
            ;;
        *)
            echo -e "  实际 root qdisc: ${CYAN}${live_root}${NC}"
            effective=$live_root
            ;;
        esac
        # noqueue 是设备类型决定的，不是配置问题，报「不一致」会误导
        if [ "$effective" = "noqueue" ]; then
            echo -e "    ${YELLOW}=> noqueue 是 lo / veth 这类虚拟设备的固定行为，按内核设计忽略 default_qdisc${NC}"
            echo -e "    ${YELLOW}   容器内出口就是 veth，这里无需也无法改动${NC}"
            effective=""
        fi
        # 叶子有多种时 effective 含空格，同样算不一致
        if [ -n "$effective" ] && [ -n "$qdisc" ] && [ "$effective" != "$qdisc" ]; then
            echo -e "    ${YELLOW}=> 与 default_qdisc（$qdisc）不一致${NC}"
            echo -e "    ${YELLOW}   default_qdisc 只影响之后新建的队列。重启即生效；${NC}"
            echo -e "    ${YELLOW}   要立刻生效用菜单 8（会逐个队列替换，不动 mq 本身）${NC}"
        elif [ -n "$effective" ]; then
            echo -e "    ${GREEN}=> 与 default_qdisc 一致${NC}"
        fi
    fi
    draw_line

    # --- 瓶颈归因 ---
    if ! command -v ss &>/dev/null; then
        echo -e "  ${YELLOW}缺少 ss 命令（iproute2），跳过连接级归因${NC}"
    else
        local stats
        # 汇总所有已建立连接的受限计时器与重传，单位微秒，取相对占比。
        # app_limited 是裸标记（无跟随数值），只能数命中的连接数，不能像其他两项算时间占比
        # [人工决策-2026-08-05 10:41:07] 实测一台真实机器：app_limited 出现 11 次、
        # rwnd_limited 11 次、sndbuf_limited 0 次。app_limited 才是现实中最常见的
        # 「其实调参没用」信号，而早期版本只在注释里提过它、代码没解析，落到 else 分支
        # 只能靠重传率反推。内核已经把结论直接给出来了，没有理由去猜
        stats=$(ss -tin 2>/dev/null | awk '
            /sndbuf_limited:/ { match($0, /sndbuf_limited:[0-9]+/); s += substr($0, RSTART+15, RLENGTH-15) }
            /rwnd_limited:/   { match($0, /rwnd_limited:[0-9]+/);   r += substr($0, RSTART+13, RLENGTH-13) }
            /busy:/           { match($0, /busy:[0-9]+/);           b += substr($0, RSTART+5,  RLENGTH-5) }
            # 锚定前后空白/行首尾，避免误伤未来可能出现的同前缀字段
            /(^|[ \t])app_limited([ \t]|$)/ { a++ }
            # [人工决策-2026-08-05 10:48:15] 早期版本用 /retrans:/ 计数，但那个字段只在
            # 连接发生过重传时才出现——实测 28 条连接里只有 7 条有 retrans:，于是
            # 「活跃连接 N 条」长期少报，且拿它当分母会让 app_limited 占比算出 116%。
            # ss -tin 的输出是「头行 + 缩进指标行」成对，数缩进行即等于连接数
            /^[ \t]+[a-z]/    { n++ }
            END { printf "%d %d %d %d %d", s+0, r+0, b+0, n+0, a+0 }')
        local snd rwnd busy nconn applim
        read -r snd rwnd busy nconn applim <<<"$stats"

        echo -e "  ${CYAN}连接级瓶颈归因${NC}（活跃连接 ${nconn:-0} 条）"
        if [ "${busy:-0}" -eq 0 ]; then
            echo -e "    当前没有足够的传输活动可供判断。请在跑业务/压测时再执行本项"
        else
            local snd_pct rwnd_pct app_pct=0
            snd_pct=$((snd * 100 / busy))
            rwnd_pct=$((rwnd * 100 / busy))
            [ "${nconn:-0}" -gt 0 ] && app_pct=$((applim * 100 / nconn))
            printf "    发送缓冲受限 (sndbuf_limited) : %s%%\n" "$snd_pct"
            printf "    对端窗口受限 (rwnd_limited)   : %s%%\n" "$rwnd_pct"
            printf "    应用喂不满 (app_limited)     : %s/%s 条连接 (%s%%)\n" \
                "${applim:-0}" "${nconn:-0}" "$app_pct"
            # 判定顺序：缓冲区受限（本机能调）优先，再对端，最后应用层
            if [ "$snd_pct" -ge 10 ]; then
                echo -e "    ${GREEN}=> 发送缓冲是瓶颈：加大 wmem 有效，本脚本菜单 5 正对这个问题${NC}"
            elif [ "$rwnd_pct" -ge 10 ]; then
                echo -e "    ${YELLOW}=> 对端接收窗口是瓶颈：本机调 sysctl 无效，要对端也调或换对端${NC}"
            elif [ "$app_pct" -ge 50 ]; then
                echo -e "    ${YELLOW}=> 过半连接是应用喂不满（内核直接给出的判定）：${NC}"
                echo -e "    ${YELLOW}   瓶颈在应用层（并发不够 / 单线程 / 上游慢），调内核参数不会有变化${NC}"
            else
                echo -e "    ${YELLOW}=> 缓冲区和应用都不是瓶颈。看下面的重传率：${NC}"
                echo -e "    ${YELLOW}   重传高 => 线路丢包，sysctl 无解，考虑换线路或上 FEC 类方案（kcptun/Hysteria）${NC}"
                echo -e "    ${YELLOW}   重传低 => 已接近本机能力上限，继续调参收益有限${NC}"
            fi
        fi
    fi
    draw_line

    # --- 重传率（区分「窗口受限」和「丢包受限」的关键）---
    # 计数器统一走 read_counters：有 nstat 用 nstat，没有则直读 /proc/net/{snmp,netstat}
    local snap_a snap_b seg_out retrans rate
    snap_a=$(read_counters)
    seg_out=$(counter_get TcpOutSegs "$snap_a")
    retrans=$(counter_get TcpRetransSegs "$snap_a")
    if [ -n "$seg_out" ] && [ "${seg_out:-0}" -gt 0 ] 2>/dev/null; then
        rate=$((retrans * 10000 / seg_out))
        echo -e "  ${CYAN}累计重传率${NC}: $((rate / 100)).$((rate % 100))%  (${retrans} / ${seg_out} 段，开机以来)"

        # 累计值会被历史流量稀释（判例：容器注入 5% 丢包，累计重传率仍只显示 0.47%），
        # 所以再取一个 5 秒窗口的增量，结论以窗口值为准
        local o2 r2 d_out d_ret wrate=""
        echo -ne "  ${CYAN}近期重传率${NC}: 采样 5 秒 ..."
        sleep 5
        snap_b=$(read_counters)
        o2=$(counter_get TcpOutSegs "$snap_b")
        r2=$(counter_get TcpRetransSegs "$snap_b")
        d_out=$((${o2:-0} - seg_out))
        d_ret=$((${r2:-0} - retrans))
        if [ "$d_out" -gt 200 ]; then
            wrate=$((d_ret * 10000 / d_out))
            echo -e "\r  ${CYAN}近期重传率${NC}: $((wrate / 100)).$((wrate % 100))%  (${d_ret} / ${d_out} 段，最近 5 秒)    "
        else
            echo -e "\r  ${CYAN}近期重传率${NC}: 本机 5 秒内只发了 ${d_out} 段，样本太少，下面按累计值判断    "
        fi

        # 有窗口值就用它，没有才退回累计值
        local judge=${wrate:-$rate}
        if [ "$judge" -ge 200 ]; then
            echo -e "    ${RED}=> 超过 2%：属于丢包受限。BBR 能缓解但治不了，sysctl 调缓冲区没用${NC}"
        elif [ "$judge" -ge 50 ]; then
            echo -e "    ${YELLOW}=> 0.5%-2%：跨境常态，BBR 正是为这个场景${NC}"
        else
            echo -e "    ${GREEN}=> 低于 0.5%：线路干净${NC}"
        fi
        if [ -n "$wrate" ] && [ "$wrate" -ge 200 ] && [ "$rate" -lt 200 ]; then
            echo -e "    ${YELLOW}（累计值被历史流量稀释了，以近期值为准）${NC}"
        fi

        # --- 重传成因分解 ---
        # 四类拆解，处置都是「sysctl 无解」但归因不同：SynRetrans 建连阶段丢包，与缓冲区无关；
        # Timeouts 硬等超时，比重传率本身严重（每次至少停一个 RTO）；SackRecovery 高是健康信号，
        # 须与 Timeouts 比占比；LostRetransmit 重传又丢，是持续劣化的硬证据
        local lostret synret timeouts sackrec
        lostret=$(counter_get TcpExtTCPLostRetransmit "$snap_b")
        [ -z "$lostret" ] && lostret=$(counter_get TcpExtTCPLostRetransmit "$snap_a")
        synret=$(counter_get TcpExtTCPSynRetrans "$snap_b")
        [ -z "$synret" ] && synret=$(counter_get TcpExtTCPSynRetrans "$snap_a")
        timeouts=$(counter_get TcpExtTCPTimeouts "$snap_b")
        [ -z "$timeouts" ] && timeouts=$(counter_get TcpExtTCPTimeouts "$snap_a")
        sackrec=$(counter_get TcpExtTCPSackRecovery "$snap_b")
        [ -z "$sackrec" ] && sackrec=$(counter_get TcpExtTCPSackRecovery "$snap_a")

        if [ -n "$lostret" ] || [ -n "$synret" ] || [ -n "$timeouts" ]; then
            echo -e "  ${CYAN}重传成因分解${NC}"
            printf "    建连丢包 (SynRetrans)      : %s\n" "${synret:--}"
            printf "    硬等超时 (Timeouts)        : %s\n" "${timeouts:--}"
            printf "    重传又丢 (LostRetransmit)  : %s\n" "${lostret:--}"
            printf "    SACK 快速恢复 (SackRecovery): %s\n" "${sackrec:--}"

            # 建连丢包单独判：与传输质量无关，容易被混为一谈
            if [ "${synret:-0}" -gt 0 ] 2>/dev/null && [ "${retrans:-0}" -gt 0 ] 2>/dev/null; then
                local sr_pct=$((synret * 100 / retrans))
                [ "$sr_pct" -ge 20 ] &&
                    echo -e "    ${YELLOW}=> 建连丢包占重传 ${sr_pct}%：握手阶段就在丢，表现为「连接建立慢」${NC}" &&
                    echo -e "    ${YELLOW}   与缓冲区无关；tcp_syn_retries 只影响放弃前重试几次，治不了丢包${NC}"
            fi
            # 恢复机制是否在正常工作：Timeouts vs SackRecovery 的相对占比
            if [ "${timeouts:-0}" -gt 0 ] 2>/dev/null; then
                local recov_total=$((timeouts + ${sackrec:-0}))
                if [ "$recov_total" -gt 0 ]; then
                    local to_pct=$((timeouts * 100 / recov_total))
                    if [ "$to_pct" -ge 50 ]; then
                        echo -e "    ${RED}=> 超时占恢复事件 ${to_pct}%：丢包没被 SACK 快速修好，在硬等 RTO${NC}"
                        echo -e "    ${RED}   这比「重传率高」严重——每次 RTO 连接至少停一个超时周期${NC}"
                    else
                        echo -e "    ${GREEN}=> 超时只占恢复事件 ${to_pct}%，SACK 快速恢复在正常工作${NC}"
                    fi
                fi
            fi
            if [ "${lostret:-0}" -gt 0 ] 2>/dev/null && [ "${retrans:-0}" -gt 0 ] 2>/dev/null; then
                local lr_pct=$((lostret * 100 / retrans))
                [ "$lr_pct" -ge 5 ] &&
                    echo -e "    ${RED}=> 重传又丢占重传 ${lr_pct}%：线路持续性丢包，调缓冲区无解${NC}"
            fi
        fi

        # --- tcp_mtu_probing 到底有没有真的触发过 ---
        # 菜单 5/22 都是主动探测，这两个计数器反映真实业务连接的情况：非零说明路径确有黑洞且参数在起作用，
        # 比 ICMP 阶梯更可信（ICMP 可能被单独屏蔽，这是 TCP 自己的数据）
        local mtup_ok mtup_fail
        mtup_ok=$(counter_get TcpExtTCPMTUPSuccess "$snap_a")
        mtup_fail=$(counter_get TcpExtTCPMTUPFail "$snap_a")
        if [ -n "$mtup_ok" ] || [ -n "$mtup_fail" ]; then
            if [ "${mtup_ok:-0}" -gt 0 ] 2>/dev/null || [ "${mtup_fail:-0}" -gt 0 ] 2>/dev/null; then
                echo -e "  ${CYAN}MTU 探测实际触发${NC}: 成功 ${mtup_ok:--} / 失败 ${mtup_fail:--}"
                echo -e "    ${GREEN}=> 非零说明路径上确有 MTU 黑洞，且 tcp_mtu_probing=1 正在起作用${NC}"
                echo -e "    ${GREEN}   这是真实业务连接的证据，比菜单 22 的 ICMP 阶梯更可信${NC}"
                [ "${mtup_fail:-0}" -gt "${mtup_ok:-0}" ] 2>/dev/null &&
                    echo -e "    ${YELLOW}   但失败多于成功：探测收敛不下去，可考虑菜单 22 找出实际 PMTU${NC}"
            else
                echo -e "  ${CYAN}MTU 探测实际触发${NC}: 0（本机连接没遇到过 MTU 黑洞）"
                echo -e "    ${GREEN}=> tcp_mtu_probing=1 是保险，没触发说明路径 MTU 干净${NC}"
            fi
        fi
    else
        echo -e "  ${YELLOW}读不到 TCP 计数器（缺 nstat 且 /proc/net/snmp 不可读），跳过重传率${NC}"
    fi
    draw_line

    # --- 队列容量证据：判断 somaxconn / netdev_max_backlog 到底有没有意义 ---
    # ListenOverflows = accept 队列满丢弃，TCPBacklogDrop = socket backlog 满丢弃；两者都为 0 说明加大属安慰剂
    if [ -n "$snap_a" ]; then
        local lisov lisdrop bkdrop ofo
        lisov=$(counter_get TcpExtListenOverflows "$snap_a")
        lisdrop=$(counter_get TcpExtListenDrops "$snap_a")
        bkdrop=$(counter_get TcpExtTCPBacklogDrop "$snap_a")
        ofo=$(counter_get TcpExtTCPOFOQueue "$snap_a")
        if [ -n "$lisov" ] || [ -n "$bkdrop" ]; then
            echo -e "  ${CYAN}队列容量证据${NC}"
            printf "    accept 队列溢出 (ListenOverflows): %s\n" "${lisov:--}"
            printf "    listen 丢弃      (ListenDrops)   : %s\n" "${lisdrop:--}"
            printf "    backlog 丢包     (TCPBacklogDrop): %s\n" "${bkdrop:--}"
            if [ "${lisov:-0}" -gt 0 ] 2>/dev/null; then
                echo -e "    ${YELLOW}=> accept 队列溢出过：somaxconn 加大有意义，但也要让应用把 listen() backlog 传大${NC}"
            elif [ "${bkdrop:-0}" -gt 0 ] 2>/dev/null; then
                echo -e "    ${YELLOW}=> backlog 有丢包：netdev_max_backlog 加大有意义${NC}"
            else
                echo -e "    ${GREEN}=> 均为 0：本机没到队列容量瓶颈，这两项属安慰剂（本脚本用 max 语义写入，不会反向调低）${NC}"
            fi
        fi
        # 乱序入队：路径多径或重排，高值拖累 SACK 恢复效率，但不是本机能调的
        if [ -n "$ofo" ] && [ "${ofo:-0}" -gt 0 ] 2>/dev/null && [ "${seg_out:-0}" -gt 0 ] 2>/dev/null; then
            echo -e "  ${CYAN}乱序入队${NC} (TCPOFOQueue): ${ofo}"
            echo -e "    ${YELLOW}=> 路径存在重排/多径。SACK 已默认开启（本脚本不关它），本机无其他可调项${NC}"
        fi
    fi

    # --- 各服务实际的 accept 队列容量：somaxconn 管得到谁 ---
    # ss -Hltn 的 Send-Q 是 LISTEN socket 实际生效的 backlog（sk_max_ack_backlog），
    # Recv-Q 是等 accept 的连接数。内核取 min(listen() 传的值, somaxconn)，Send-Q 明显小于
    # somaxconn 时瓶颈在应用自己传的值，改 sysctl 无效（nginx 要写 listen ... backlog=）
    if command -v ss >/dev/null 2>&1; then
        local somax listen_stat
        somax=$(sysctl -n net.core.somaxconn 2>/dev/null)
        if [ -n "$somax" ] && [ "${somax:-0}" -gt 0 ] 2>/dev/null; then
            # 一次 awk 出全部结论：监听总数、backlog 低于 somaxconn 的个数、排队个数、最小 backlog 值与地址
            listen_stat=$(ss -Hltn 2>/dev/null | awk -v sm="$somax" '
                {
                    n++
                    q = $3 + 0
                    if (q < sm) { low++; if (minq == 0 || q < minq) { minq = q; minaddr = $4 } }
                    if ($2 + 0 > 0) { queued++; if ($2 + 0 > maxrq) { maxrq = $2 + 0; rqaddr = $4 } }
                }
                END { printf "%d %d %d %d %s %d %s", n+0, low+0, queued+0, minq+0, (minaddr==""?"-":minaddr), maxrq+0, (rqaddr==""?"-":rqaddr) }')
            local l_n l_low l_queued l_minq l_minaddr l_maxrq l_rqaddr
            read -r l_n l_low l_queued l_minq l_minaddr l_maxrq l_rqaddr <<<"$listen_stat"
            if [ "${l_n:-0}" -gt 0 ] 2>/dev/null; then
                echo -e "  ${CYAN}监听队列容量${NC}（somaxconn=${somax}，共 ${l_n} 个监听端口）"
                if [ "${l_low:-0}" -gt 0 ] 2>/dev/null; then
                    echo -e "    ${YELLOW}${l_low} 个服务的实际 backlog 低于 somaxconn，最小的是 ${l_minq}（${l_minaddr}）${NC}"
                    echo -e "    ${YELLOW}=> 内核取 min(应用 listen() 传的值, somaxconn)。这些服务的瓶颈在应用侧，${NC}"
                    echo -e "    ${YELLOW}   改 somaxconn 对它们无效——要改应用配置（nginx 的 listen ... backlog=）${NC}"
                else
                    echo -e "    ${GREEN}=> 所有监听的 backlog 都已达到 somaxconn，sysctl 这一侧没有余量可挖${NC}"
                fi
                if [ "${l_queued:-0}" -gt 0 ] 2>/dev/null; then
                    echo -e "    ${YELLOW}当前有 ${l_queued} 个端口存在待 accept 队列，最多的 ${l_maxrq} 个（${l_rqaddr}）${NC}"
                    echo -e "    ${YELLOW}=> 应用 accept 跟不上新连接速率。加大 backlog 只是让它排更久，不解决处理能力${NC}"
                fi
            fi
        fi
    fi

    # --- 内存压力：缓冲区「开太大」的反向信号 ---
    # 方向与其他判据相反：PruneCalled/RcvPruned 是内核裁剪乱序队列腾缓冲，TCPMemoryPressures
    # 是强制收缩所有连接缓冲，非零说明总量已超机器承受能力，此时该调小而非继续加大
    if [ -n "$snap_a" ]; then
        local prune rcvpr mempr
        prune=$(counter_get TcpExtPruneCalled "$snap_a")
        rcvpr=$(counter_get TcpExtRcvPruned "$snap_a")
        mempr=$(counter_get TcpExtTCPMemoryPressures "$snap_a")
        if [ "${prune:-0}" -gt 0 ] 2>/dev/null || [ "${mempr:-0}" -gt 0 ] 2>/dev/null; then
            echo -e "  ${CYAN}TCP 内存压力${NC}"
            printf "    队列裁剪 (PruneCalled/RcvPruned): %s / %s\n" "${prune:--}" "${rcvpr:--}"
            printf "    进入压力模式 (MemoryPressures)  : %s\n" "${mempr:--}"
            echo -e "    ${RED}=> 非零是「缓冲区开太大」的信号，与其他项方向相反：${NC}"
            echo -e "    ${RED}   内核在强制收缩缓冲。此时应调小 rmem_max/wmem_max 或加内存，而不是继续加大${NC}"
        fi
    fi

    # --- UDP 侧：QUIC / Hysteria2 的关键判据 ---
    # UdpRcvbufErrors 对应 udp_rmem_min/rmem_max 该不该调；UdpInErrors 占比高不是缓冲区问题，
    # 而要怀疑 GRO 与隧道封装冲突（判例：GRE-in-UDP 上 GRO 错误合并包，关闭后 TCP 从 1Mbit 恢复到 350Mbit）
    if [ -n "$snap_a" ]; then
        local udperr udpin udprcv udpsnd
        udperr=$(counter_get UdpInErrors "$snap_a")
        udpin=$(counter_get UdpInDatagrams "$snap_a")
        udprcv=$(counter_get UdpRcvbufErrors "$snap_a")
        udpsnd=$(counter_get UdpSndbufErrors "$snap_a")
        if [ -n "$udpin" ]; then
            echo -e "  ${CYAN}UDP 侧${NC}（QUIC / Hysteria2 / WireGuard 走这里）"
            printf "    接收缓冲溢出 (UdpRcvbufErrors): %s\n" "${udprcv:--}"
            printf "    发送缓冲溢出 (UdpSndbufErrors): %s\n" "${udpsnd:--}"
            [ "${udprcv:-0}" -gt 0 ] 2>/dev/null &&
                echo -e "    ${YELLOW}=> 接收缓冲溢出过：udp_rmem_min 与 rmem_max 加大有意义（本脚本菜单 5 已设）${NC}"
            if [ "${udpin:-0}" -gt 1000 ] 2>/dev/null && [ "${udperr:-0}" -gt 0 ] 2>/dev/null; then
                local uerr_pct=$((udperr * 1000 / udpin))
                printf "    InErrors/InDatagrams         : %s.%s%%  (%s / %s)\n" \
                    "$((uerr_pct / 10))" "$((uerr_pct % 10))" "$udperr" "$udpin"
                if [ "$uerr_pct" -ge 50 ]; then
                    echo -e "    ${RED}=> 超过 5%：不像是缓冲区问题。若本机跑 GRE-in-UDP / FOU / WireGuard 隧道，${NC}"
                    echo -e "    ${RED}   优先怀疑网卡 GRO 与封装冲突（社区有实测：关掉 underlay 网卡 GRO 后 TCP 从 1Mbit 恢复到 350Mbit）${NC}"
                    echo -e "    ${RED}   验证: ethtool -K <承载隧道的物理网卡> gro off  然后重测${NC}"
                fi
            fi
        fi
    fi
    draw_line

    # --- softnet：判断 netdev_budget 那两项有没有意义 ---
    if [ -r /proc/net/softnet_stat ]; then
        local squeezed dropped
        dropped=$(softnet_sum 2)
        squeezed=$(softnet_sum 3)
        echo -e "  ${CYAN}软中断收包${NC}: backlog 丢包 ${dropped:-?} 次 | NAPI 预算耗尽 ${squeezed:-?} 次"
        if [ "${squeezed:-0}" -gt 0 ]; then
            echo -e "    ${GREEN}=> squeeze 非零，说明 netdev_budget/netdev_budget_usecs 抬高对本机有意义${NC}"
        else
            echo -e "    ${YELLOW}=> squeeze 为 0，本机收包压力不大，netdev_budget 那两项属安慰剂${NC}"
        fi
        if [ "${dropped:-0}" -gt 0 ]; then
            echo -e "    ${YELLOW}=> backlog 有丢包，netdev_max_backlog 加大有意义${NC}"
        fi
    fi

    # --- NET_RX 软中断分布：RPS 该不该开的直接证据 ---
    # 菜单 7 只按硬件队列数<核数判断能力，不代表软中断真成了瓶颈；真正证据是 NET_RX
    # 是否集中在少数核上，全核均摊时开 RPS 只会多一次 IPI 反而抬高延迟（内核 scaling.rst）
    if [ -r /proc/softirqs ]; then
        local rx_stat
        # 纯 POSIX awk：算最大核占比与核数。数值可能很大，用 awk 自己的浮点避免溢出
        rx_stat=$(awk '/NET_RX/ {
            max = 0; sum = 0; n = 0; mi = 0
            for (i = 2; i <= NF; i++) {
                v = $i + 0; sum += v; n++
                if (v > max) { max = v; mi = i - 2 }
            }
            if (sum > 0) printf "%d %d %d", n, mi, max * 100 / sum
            exit
        }' /proc/softirqs 2>/dev/null)
        if [ -n "$rx_stat" ]; then
            local rx_n rx_top rx_pct
            read -r rx_n rx_top rx_pct <<<"$rx_stat"
            echo -e "  ${CYAN}NET_RX 软中断分布${NC}: ${rx_n} 核，最忙的是 CPU${rx_top}（占 ${rx_pct}%）"
            # 阈值取均摊值的 3 倍才算倾斜；单核机器恒为 100%，单独排除
            if [ "${rx_n:-1}" -le 1 ]; then
                echo -e "    ${YELLOW}=> 单核机器，RPS 无意义（没有其他核可以分摊）${NC}"
            elif [ "$rx_pct" -ge $((300 / rx_n)) ] && [ "$rx_pct" -ge 40 ]; then
                echo -e "    ${GREEN}=> 明显集中在单核：RPS/RFS 有实际意义，菜单 7 正对这个问题${NC}"
            else
                echo -e "    ${YELLOW}=> 分布较均匀（均摊值约 $((100 / rx_n))%），软中断不是瓶颈，开 RPS 收益有限${NC}"
            fi
        fi
    fi

    # --- qdisc 丢包：区分「本地出向队列」和「路径」---
    # 必须累加所有 qdisc 行：多队列网卡第一行是 mq 汇总，实际丢包发生在叶子队列
    if [ -n "$dev" ] && command -v tc &>/dev/null; then
        local qdrop
        qdrop=$(qdisc_drops "$dev")
        if [ -n "$qdrop" ]; then
            echo -e "  ${CYAN}出向队列丢包${NC}: $qdrop（全部队列累计）"
            [ "$qdrop" -gt 0 ] 2>/dev/null &&
                echo -e "    ${YELLOW}=> 本地出向队列在丢包，是本机发送能力/整形问题，不是路径问题${NC}"
        fi
    fi

    # --- conntrack：表满会静默丢包且日志不显眼 ---
    local ct_count ct_max
    ct_count=$(sysctl -n net.netfilter.nf_conntrack_count 2>/dev/null)
    ct_max=$(sysctl -n net.netfilter.nf_conntrack_max 2>/dev/null)
    if [ -n "$ct_count" ] && [ -n "$ct_max" ] && [ "${ct_max:-0}" -gt 0 ]; then
        local ct_pct=$((ct_count * 100 / ct_max))
        echo -e "  ${CYAN}conntrack${NC}: $ct_count / $ct_max (${ct_pct}%)"
        [ "$ct_pct" -ge 80 ] &&
            echo -e "    ${RED}=> 超过 80%，表满会静默丢包。需要加大 nf_conntrack_max 并同步 hashsize${NC}"
    fi

    # --- 转发与 MSS clamp 状态（中转档的两项改动是否还在）---
    # iptables 规则重启即失效，可能出现 ip_forward 还在但规则已没了的半生效状态。
    # 查规则用 iptables-save 而非 iptables -t mangle -S：后者会加载 mangle 表模块，不是零副作用
    local fwd
    fwd=$(sysctl -n net.ipv4.ip_forward 2>/dev/null)
    if [ -n "$fwd" ]; then
        if [ "$fwd" = "1" ]; then
            echo -e "  ${CYAN}IP 转发${NC}: ${YELLOW}已开启${NC}（中转档特征；普通业务机不该开）"
            if command -v iptables-save >/dev/null 2>&1; then
                if iptables-save 2>/dev/null | grep -q 'TCPMSS'; then
                    echo -e "    MSS clamp: ${GREEN}规则在${NC}"
                else
                    echo -e "    ${YELLOW}MSS clamp: 规则不在（iptables 规则重启即失效）${NC}"
                    echo -e "    ${YELLOW}  转发已开但 clamp 没了，隧道路径可能出现 MTU 黑洞。重跑菜单 6 可补回${NC}"
                fi
            fi
        else
            echo -e "  ${CYAN}IP 转发${NC}: 关闭（普通业务机的正常状态）"
        fi
    fi
    draw_line
    pause_return
}

# BBR 版本标签。唯一可靠判据是模块自报的 version（v3 patch 有 MODULE_VERSION("3")）；
# XanMod 内置编译，很老的 kmod 读不到时报「未知」而非判失败。主线 tcp_bbr.c 从不写
# MODULE_VERSION，用 tcp_cubic 当探针区分「没报版本」与「通路不可用」，但探针证明不了
# 是主线实现，标签只陈述观察事实
# [人工决策-2026-08-04 23:07:09] 探针的结论边界：区分「模块没报版本」与「读不到」
# 成立，据此推断「是主线实现」不成立。审查指出把缺少 version 当正向证据是结论强于
# 证据，故标签退回事实陈述。未来要真正判定实现来源须另找判据（如比对模块签名/符号）
bbr_version_label() {
    local cc ver
    cc=$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)
    [ "$cc" = "bbr" ] || return 0
    ver=$(modinfo tcp_bbr 2>/dev/null | awk '/^version:/{print $2; exit}')
    case "$ver" in
    3) echo -e " ${GREEN}[BBRv3]${NC}" ;;
    '')
        if [ -n "$(modinfo tcp_cubic 2>/dev/null | awk '/^version:/{print $2; exit}')" ]; then
            echo -e " ${YELLOW}[模块未报版本]${NC}"
        else
            echo -e " ${YELLOW}[版本未知]${NC}"
        fi
        ;;
    *) echo -e " ${CYAN}[BBRv${ver}]${NC}" ;;
    esac
}

# ==================================================
# --- A. 调优前后效果测量 ---
# ==================================================
# 计数器取时间窗内的增量而非绝对值（绝对值是开机以来累计）；有 iperf3 就压测（-O 2 丢慢启动），没有就被动观测
measure() {
    echo -e "\n${YELLOW}>>> 调优效果测量${NC}"
    # 只读功能不自动装包，见 require_tools_readonly 的说明
    require_tools_readonly nstat tc
    local dev win=10 peer=""
    dev=$(egress_dev)

    if [ "$BATCH" != "1" ]; then
        read -p "  iperf3 对端地址（留空则只做被动观测）: " peer
        read -p "  观测窗口秒数（回车默认 10）: " win
        case "$win" in '' | *[!0-9]*) win=10 ;; esac
    fi

    local snap1 snap2
    snap1=$(counter_snapshot "$dev")

    # --- bufferbloat 的空载基线 ---
    # 判据是负载 RTT 相比空载涨多少，基线须在打流量前对同一目标取；被动观测模式无空载/负载边界，不做
    local bb_base="" bb_target=""
    if [ -n "$peer" ] && command -v ping >/dev/null 2>&1; then
        bb_target=$peer
        echo -e "  取空载 RTT 基线（对 $bb_target）..."
        # 5 个包够看基线，取 avg 与 mdev
        bb_base=$(ping -c 5 -W 2 -q "$bb_target" 2>/dev/null | awk -F'/' '
            /rtt|round-trip/ { n = split($NF, a, " "); print $(NF-2) " " a[1]; exit }')
        if [ -n "$bb_base" ]; then
            echo -e "    空载: ${CYAN}${bb_base%% *}${NC} ms (mdev ${bb_base##* })"
        else
            echo -e "    ${YELLOW}空载基线取不到（ICMP 可能被屏蔽），跳过 bufferbloat 判据${NC}"
        fi
    fi

    # iperf3 是主动压测工具，会真的打流量，所以问一次再装——不像 ss/nstat 那样纯读
    if [ -n "$peer" ] && ! command -v iperf3 >/dev/null 2>&1 && [ "$BATCH" != "1" ]; then
        read -p "  未装 iperf3（压测需要，会产生真实流量）。现在安装？[y/N]: " _ins
        [[ "$_ins" =~ ^[Yy]$ ]] && ensure_tools iperf3 >/dev/null
    fi

    # 负载中的 RTT 要与压测**同时**采样，所以后台起一个 ping、把结果写临时文件。
    # 不用 pkill -f 收尾：那个模式可能匹配到当前 SSH 会话的命令行（netriage 踩过这个坑），
    # 只 kill 自己记下的 PID
    local bb_load="" bb_out="" bb_pid=""
    if [ -n "$bb_base" ]; then
        bb_out=$(mktemp)
        # 比压测窗口多跑 2 秒，确保覆盖整个负载期；间隔 1 秒（默认）足够
        ping -c $((win + 2)) -W 2 -q "$bb_target" >"$bb_out" 2>/dev/null &
        bb_pid=$!
        # 用户中途 Ctrl-C 时，后台 ping 与临时文件都要收掉，否则留个孤儿进程
        # 继续 ping 到包数用完。
        # [人工决策-2026-08-05 14:05:33] 用 EXIT 而不是 INT/TERM：实测 kill -INT 发给
        # 主进程时 bash 正阻塞在 iperf3/sleep 上，信号处理要等前台命令返回，那时 ping
        # 早跑完了、trap 等于没用。EXIT 无论怎么退出（正常/中断/set -e）都会执行。
        # 只清自己起的那个 PID——不用 pkill -f，那个模式可能匹配到别的东西
        # （含当前 SSH 会话的命令行，netriage 记过这个判例）
        # shellcheck disable=SC2064
        trap "kill $bb_pid 2>/dev/null; rm -f '$bb_out'" EXIT
    fi

    if [ -n "$peer" ] && command -v iperf3 &>/dev/null; then
        echo -e "  正在对 $peer 压测（${win}s，丢弃前 2s 慢启动）..."
        iperf3 -c "$peer" -t "$win" -O 2 2>&1 | tail -6
    elif [ -n "$peer" ]; then
        echo -e "  ${YELLOW}iperf3 不可用，改为被动观测（只统计这段时间内的真实业务流量）${NC}"
        sleep "$win"
    else
        echo -e "  被动观测 ${win}s（请同时让业务跑起来）..."
        sleep "$win"
    fi

    snap2=$(counter_snapshot "$dev")

    # 收回后台 ping。wait 会等它自然结束（它的包数已经算好了），超时才 kill
    if [ -n "$bb_pid" ]; then
        # 最多再等 4 秒；ping 的总时长是 win+2 秒，正常这时已经结束或即将结束
        local _w=0
        while kill -0 "$bb_pid" 2>/dev/null && [ "$_w" -lt 4 ]; do
            sleep 1
            _w=$((_w + 1))
        done
        kill "$bb_pid" 2>/dev/null
        wait "$bb_pid" 2>/dev/null
        # 解除 trap：它只为覆盖「压测期间被中断」这个窗口，留着会在每次退出时
        # 对一个已经回收的 PID 发信号（无害但无谓），也会干扰后续可能加的 EXIT 处理
        trap - EXIT
        bb_load=$(awk -F'/' '/rtt|round-trip/ { n = split($NF, a, " "); print $(NF-2) " " a[1]; exit }' "$bb_out" 2>/dev/null)
        rm -f "$bb_out"
    fi

    echo -e "\n  ${CYAN}${win}s 窗口内的增量${NC}${YELLOW}（rx/tx_bytes 统计的是网卡 ${dev:-未识别}）${NC}"
    draw_line
    local deltas seg_delta tx_delta
    deltas=$(diff_counters "$snap1" "$snap2")
    echo "$deltas"
    # TCP 段有增长但网卡字节为 0，说明流量没走这块网卡（回环、容器内 veth、
    # 或走了另一条路由）。不解释一下会让人以为测量坏了
    seg_delta=$(echo "$deltas" | awk '$1=="TcpOutSegs"{print $2+0; exit}')
    # tx_bytes 那行是 "tx_bytes  0.00 MB"，取 $2 并去掉小数点后比较整数部分，
    # 避免因格式（前导空格、精度）导致字符串比较失败
    tx_delta=$(echo "$deltas" | awk '$1=="tx_bytes"{printf "%d", $2; exit}')
    if [ "${seg_delta:-0}" -gt 100 ] 2>/dev/null && [ "${tx_delta:-0}" -eq 0 ] 2>/dev/null; then
        echo -e "  ${YELLOW}注意: TCP 段有增长但 ${dev:-出口网卡} 字节为 0${NC}"
        echo -e "  ${YELLOW}      说明这些流量没走这块网卡（回环 / 容器内虚拟网卡 / 另一条路由）${NC}"
    fi
    draw_line

    # --- bufferbloat：负载下 RTT 涨多少 ---
    # 这是唯一能解释「带宽跑满了但延迟很差」的指标。原理是路径上某个设备（家宽网关、
    # 运营商接入层、对端网卡）的缓冲过深：满负载时包在那里排长队，RTT 被抬高几倍，
    # 而吞吐数字看起来完全正常。
    #
    # 判据取「负载 RTT / 空载 RTT ≥ 2」。这个倍数不是精确阈值——路径缓冲的绝对量
    # 才是本质，但那个量测不到，涨幅是能测到的最直接代理指标。
    # 浮点比较必须交给 awk：bash 算术只有整数，53.2 和 152.9 直接比会报错
    if [ -n "$bb_base" ] && [ -n "$bb_load" ]; then
        local bb_verdict
        bb_verdict=$(awk -v b="${bb_base%% *}" -v bm="${bb_base##* }" \
            -v l="${bb_load%% *}" -v lm="${bb_load##* }" '
            BEGIN {
                if (b <= 0) { print "skip"; exit }
                r = l / b
                # mdev 的倍数单独算：抖动放大往往比均值更明显（队列时深时浅）
                rm = (bm > 0) ? lm / bm : 0
                printf "%.1f %.0f", r, rm
            }')
        if [ "$bb_verdict" != "skip" ] && [ -n "$bb_verdict" ]; then
            local bb_ratio bb_mratio
            read -r bb_ratio bb_mratio <<<"$bb_verdict"
            echo -e "  ${CYAN}负载下的 RTT 变化${NC}（bufferbloat 判据，对 $bb_target）"
            printf "    空载  : %s ms  (mdev %s)\n" "${bb_base%% *}" "${bb_base##* }"
            printf "    负载中: %s ms  (mdev %s)\n" "${bb_load%% *}" "${bb_load##* }"
            printf "    倍数  : RTT x%s" "$bb_ratio"
            [ "${bb_mratio:-0}" -gt 1 ] 2>/dev/null && printf "，抖动 x%s" "$bb_mratio"
            printf "\n"
            # 用 awk 判阈值，同样是浮点
            case "$(awk -v r="$bb_ratio" 'BEGIN{ print (r >= 2) ? "bad" : (r >= 1.3 ? "mild" : "ok") }')" in
            bad)
                echo -e "    ${RED}=> 负载下 RTT 涨到 ${bb_ratio} 倍：路径上有过深的缓冲（bufferbloat）${NC}"
                echo -e "    ${RED}   这是中间设备的问题，本机调 sysctl 无解。BBR 比 CUBIC 在这种路径上${NC}"
                echo -e "    ${RED}   表现更好（它按实测带宽发包而不是把队列填满），本脚本已默认开 BBR + fq${NC}"
                ;;
            mild)
                echo -e "    ${YELLOW}=> 负载下 RTT 涨 ${bb_ratio} 倍：轻微排队，属正常范围${NC}"
                ;;
            *)
                echo -e "    ${GREEN}=> 负载下 RTT 基本不涨：路径缓冲健康，没有 bufferbloat${NC}"
                ;;
            esac
            draw_line
        fi
    fi

    # 抖动本身要给出数字，否则下面那句「抖动比均值更有价值」是空话。
    # 用 mdev（ping 的平均偏差）作指标，调优前后各跑一次就能看出差别
    if command -v ping >/dev/null 2>&1; then
        local jt jline
        jline=$(probe_jitter)
        if [ -n "$jline" ]; then
            echo -e "  ${CYAN}RTT 抖动${NC}（mdev，调优前后各跑一次对比）"
            printf '%s\n' "$jline" | while read -r jt javg jmdev; do
                printf "    %-14s avg %-9s mdev %s ms\n" "$jt" "${javg}ms" "$jmdev"
            done
        fi
        draw_line
    fi
    echo -e "  ${YELLOW}A/B 对比要点：同一对端、时间相邻、至少各测 3 次取中位数；${NC}"
    echo -e "  ${YELLOW}单流和多流分别测（iperf3 -P 1 / -P 4），双向都测（加 -R）。${NC}"
    echo -e "  ${YELLOW}只看平均带宽会漏掉真实收益——抖动下降往往比均值提升更有价值${NC}"
    echo -e "  ${YELLOW}（公开对照：nginx reuseport 那组数据里均值只降 21%，标准差降了 88%）${NC}"
    pause_return
}

# 一次性抓齐所有计数器，输出 key=value 便于做差
counter_snapshot() {
    local dev=$1
    if command -v nstat &>/dev/null; then
        nstat -saz 2>/dev/null | awk '/^Tcp(OutSegs|RetransSegs|InSegs)|^TcpExtTCPLoss|^UdpInErrors|^UdpInDatagrams/{print $1"="$2}'
    fi
    if [ -r /proc/net/softnet_stat ]; then
        echo "softnet_dropped=$(softnet_sum 2)"
        echo "softnet_squeezed=$(softnet_sum 3)"
    fi
    if [ -n "$dev" ] && command -v tc &>/dev/null; then
        # 走公共函数：多队列网卡上必须累加全部队列，只取首处会读成 mq 的汇总值（常为 0）
        echo "qdisc_dropped=$(qdisc_drops "$dev")"
    fi
    if [ -n "$dev" ] && [ -r "/sys/class/net/$dev/statistics/rx_bytes" ]; then
        echo "rx_bytes=$(cat "/sys/class/net/$dev/statistics/rx_bytes")"
        echo "tx_bytes=$(cat "/sys/class/net/$dev/statistics/tx_bytes")"
    fi
}

diff_counters() {
    local before=$1 after=$2
    # 两份快照拼成一个流：同一 key 第二次出现时做差。顺序保证在前的是 before
    printf '%s\n%s\n' "$before" "$after" | awk -F= '
        { if (seen[$1]++) { d = $2 - first[$1]; delta[$1] = d } else { first[$1] = $2 } }
        END {
            for (k in delta) {
                if (k == "rx_bytes" || k == "tx_bytes")
                    printf "  %-22s %s MB\n", k, sprintf("%.2f", delta[k] / 1048576)
                else
                    printf "  %-22s %s\n", k, delta[k]
            }
            if (("TcpOutSegs" in delta) && delta["TcpOutSegs"] > 0) {
                r = delta["TcpRetransSegs"] * 10000 / delta["TcpOutSegs"]
                printf "  %-22s %d.%02d%%   <-- 本窗口真实重传率\n", "retrans_rate", r / 100, r % 100
            }
        }' | sort
}

# ==================================================
# --- B. MTU / PMTU 黑洞探测 ---
# ==================================================
# 只读探测。判据链：公网口是否 1500 -> 到对端 PMTU 是否干净 -> 路径有无隧道 ->
# 真实业务是 TCP 还是 UDP/QUIC -> 有无分片/黑洞证据。干净就保持 1500 不要乱改。
# 关键：先做基线探测。busybox / BSD 的 ping 没有 -M do，那种环境下每一档都会 FAIL，
# 那是「没有数据」而不是「有黑洞」——不先说清楚会把用户引向错误结论
probe_mtu() {
    echo -e "\n${YELLOW}>>> MTU / PMTU 探测（只读）${NC}"
    # 必须是支持 -M do 的 ping（iputils）。busybox 的 ping 没这个选项，
    # 那种环境下每一档都会 FAIL——那是「没有数据」而不是「有黑洞」，会误导用户改 MTU。
    # 只读功能不自动装包，见 require_tools_readonly 的说明
    require_tools_readonly ping tracepath
    local peer dev cur_mtu
    dev=$(egress_dev)
    [ -n "$dev" ] && cur_mtu=$(cat "/sys/class/net/$dev/mtu" 2>/dev/null)
    echo -e "  出口网卡 ${CYAN}${dev:-?}${NC} 当前 MTU: ${CYAN}${cur_mtu:-?}${NC}"

    if [ "$BATCH" = "1" ]; then
        peer=1.1.1.1
    else
        read -p "  探测目标（回车默认 1.1.1.1，建议填你真实的业务对端）: " peer
        [ -z "$peer" ] && peer=1.1.1.1
    fi

    if ! ping -M do -s 56 -c 1 -W 2 "$peer" >/dev/null 2>&1; then
        echo -e "  ${YELLOW}基线探测失败：ICMP 可能被屏蔽，或本机 ping 不支持 -M do（busybox/BSD）。${NC}"
        echo -e "  ${YELLOW}这种情况下面全 FAIL 表示「拿不到数据」，不是「存在黑洞」，别据此改 MTU${NC}"
    fi

    command -v tracepath &>/dev/null && tracepath -n "$peer" 2>/dev/null | head -12

    local s mtu ok_max=0
    echo -e "\n  ${CYAN}DF 置位的载荷阶梯（载荷 + 28 = IPv4 MTU）${NC}"
    for s in 1472 1464 1452 1432 1412 1392 1372 1352 1332 1312 1292; do
        mtu=$((s + 28))
        if ping -M do -s "$s" -c 2 -W 2 "$peer" >/dev/null 2>&1; then
            echo -e "    载荷 $s  MTU $mtu  ${GREEN}OK${NC}"
            [ "$ok_max" -eq 0 ] && ok_max=$mtu
        else
            echo -e "    载荷 $s  MTU $mtu  ${RED}FAIL${NC}"
        fi
    done

    draw_line
    if [ "$ok_max" -eq 0 ]; then
        echo -e "  ${YELLOW}没有任何一档通过：优先怀疑 ICMP 被屏蔽（见上面基线提示），而不是 MTU 问题${NC}"
    elif [ "$ok_max" -ge 1500 ]; then
        echo -e "  ${GREEN}路径 MTU 1500 干净，不要改接口 MTU${NC}"
    else
        echo -e "  ${YELLOW}路径 MTU 上限约 $ok_max（低于 1500）${NC}"
        echo -e "  ${YELLOW}如果这是 TCP-only 的黑洞，本脚本已设的 tcp_mtu_probing=1 会自动收敛，无需改接口 MTU；${NC}"
        echo -e "  ${YELLOW}只有 UDP/QUIC 业务也受影响、且路径里确有隧道封装时，才考虑 ip link set $dev mtu $ok_max${NC}"
    fi
    draw_line
    pause_return
}


# ==================================================
# --- 带宽交互 / 公共测速节点 / 限速器拐点 / HTB 整形 ---
# [人工决策-2026-08-17 14:26:33] 引入 HTB 出向整形 + 近端 policer 拐点扫描。
# 依据：ports 上的突发限速器会把 BBR 打穿导致重传爆炸；限在拐点下 + fq pacing 可把
# 「不限速高吞吐高重传」变成「略低吞吐极低重传」。不设 initcwnd（仍故意不设）。
# 一键默认带扫描；公共节点全挂/用户拒装 iperf3 时只跳过整形，基础调优保留。
# 内置公共 iperf3 表 + 自动选最近 + 可手填；扫失败不回滚已写 sysctl。

# ==================================================
# 设计取自 tcpfit 的实测路径，按 tcpo 边界改写：
#   · 不设 initcwnd（故意不设，见 sysctl 注释区）
#   · 扫失败不回滚已生效的基础调优
#   · 公共节点可关可手填；装 iperf3 必问（真流量）
#   · 整形持久化走独立 unit，回退/shape off 都能清干净

# 交互选出口带宽。VPS 商家常说 100M/1G，不是「1000 Mbps」。
# 输出纯 Mbps 整数到 stdout。TUNE_MBPS 已设时调用方不应再调本函数。
# 交互选带宽。UI 一律 stderr；stdout 只吐纯数字——调用方是 bw=$(ask_bandwidth)，
# 提示进 stdout 会被吃进变量，菜单文案和数字搅在一起（WSL 实测已踩）。
ask_bandwidth() {
    local ans
    echo -e "  本机出口带宽（按 BDP 算缓冲区；商家套餐常写 100M / 1G）：" >&2
    echo -e "    ${CYAN}1${NC}) 100 Mbps" >&2
    echo -e "    ${CYAN}2${NC}) 200 Mbps" >&2
    echo -e "    ${CYAN}3${NC}) 500 Mbps" >&2
    echo -e "    ${CYAN}4${NC}) 1 Gbps     ${GREEN}[回车默认]${NC}" >&2
    echo -e "    ${CYAN}5${NC}) 2.5 Gbps" >&2
    echo -e "    ${CYAN}6${NC}) 10 Gbps" >&2
    echo -e "    ${CYAN}0${NC}) 手动输入 Mbps 数字" >&2
    read -p "  选择 [0-6，回车默认 4]: " ans || true
    case "$ans" in
    1) echo 100 ;;
    2) echo 200 ;;
    3) echo 500 ;;
    4 | '') echo 1000 ;;
    5) echo 2500 ;;
    6) echo 10000 ;;
    0)
        read -p "  请输入 Mbps 正整数: " ans || true
        case "$ans" in '' | *[!0-9]*) echo 1000 ;; *) echo "$ans" ;; esac
        ;;
    *)
        # 直接敲 Mbps 数字
        case "$ans" in
        *[!0-9]*) echo 1000 ;;
        *) echo "$ans" ;;
        esac
        ;;
    esac
}

# 公共 iperf3 节点（测的是本机端口上限，越近越准；非国内回程速度）
# 主机|城市|提供方
PEER_POOL="
speedtest.hkg12.hk.leaseweb.net|香港|Leaseweb
speedtest.sin1.sg.leaseweb.net|新加坡|Leaseweb
sgp.proof.ovh.net|新加坡|OVH
speedtest.syd12.au.leaseweb.net|悉尼|Leaseweb
speedtest.tyo11.jp.leaseweb.net|东京|Leaseweb
speedtest.fra1.de.leaseweb.net|法兰克福|Leaseweb
speedtest.ams2.nl.leaseweb.net|阿姆斯特丹|Leaseweb
ams.speedtest.clouvider.net|阿姆斯特丹|Clouvider
speedtest.lon12.uk.leaseweb.net|伦敦|Leaseweb
lon.speedtest.clouvider.net|伦敦|Clouvider
speedtest.lax12.us.leaseweb.net|洛杉矶|Leaseweb
speedtest.sfo12.us.leaseweb.net|旧金山|Leaseweb
speedtest.sea11.us.leaseweb.net|西雅图|Leaseweb
speedtest.dal13.us.leaseweb.net|达拉斯|Leaseweb
speedtest.chi11.us.leaseweb.net|芝加哥|Leaseweb
speedtest.nyc1.us.leaseweb.net|纽约|Leaseweb
speedtest.mia11.us.leaseweb.net|迈阿密|Leaseweb
speedtest.mtl2.ca.leaseweb.net|蒙特利尔|Leaseweb
"
# Leaseweb/OVH 5201-5210，Clouvider 5200-5209；5200 放末尾避免 Leaseweb 先白撞
PORT_POOL="5201 5202 5203 5204 5205 5206 5207 5208 5209 5210 5200"
PROBE_PORTS="5201 5202 5203 5200"
IP_FAMILY="${IP_FAMILY:--4}"
PEER_PORT="${PEER_PORT:-5201}"
PEER_IDEAL_RTT="${TCPO_PEER_IDEAL_RTT:-50}"
PEER_MAX_RTT="${TCPO_PEER_MAX_RTT:-100}"
SWEEP_CAP="${TCPO_SWEEP_CAP:-2500}"

# 按对端选 iperf3/ping 协议族，写入全局 IP_FAMILY（仅本轮扫描有效）。
# - 字面 IPv6（含 :）→ -6
# - 字面 IPv4 → -4
# - 主机名：解析到 A 用 -4；仅有 AAAA 用 -6；解析失败默认 -4
# 公共节点自动选点不走这里，见 auto_pick_peer 入口强制 -4。
set_ip_family_for_host() {
    local host=$1 line
    case "$host" in
    *:*)
        IP_FAMILY=-6
        return 0
        ;;
    esac
    # 点分 IPv4
    case "$host" in
    [0-9]*.[0-9]*.[0-9]*.[0-9]*)
        if printf '%s' "$host" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; then
            IP_FAMILY=-4
            return 0
        fi
        ;;
    esac
    # 主机名：优先看解析结果，避免「只填域名、仅有 AAAA」仍死磕 -4
    if command -v getent >/dev/null 2>&1; then
        if getent ahostsv4 "$host" 2>/dev/null | awk 'NF{found=1; exit} END{exit !found}'; then
            IP_FAMILY=-4
            return 0
        fi
        if getent ahostsv6 "$host" 2>/dev/null | awk 'NF{found=1; exit} END{exit !found}'; then
            IP_FAMILY=-6
            return 0
        fi
        # ahostsv4/v6 不可用时退回 ahosts 看首条
        line=$(getent ahosts "$host" 2>/dev/null | awk 'NF{print $1; exit}')
        case "$line" in
        *:*)
            IP_FAMILY=-6
            return 0
            ;;
        ?*)
            IP_FAMILY=-4
            return 0
            ;;
        esac
    fi
    IP_FAMILY=-4
}

port_order() {
    local p out="$1"
    for p in $PORT_POOL; do [ "$p" = "$1" ] || out="$out $p"; done
    echo "$out"
}

# 返回 0 并把可用端口写到 PROBE_PORT_OK。始终走 tcp_connect_ok（自带墙钟上限）。
probe_peer_port() {
    local host="$1" try
    PROBE_PORT_OK=""
    for try in $PROBE_PORTS; do
        if tcp_connect_ok "$host" "$try"; then
            PROBE_PORT_OK=$try
            return 0
        fi
    done
    return 1
}

# stdout: host:port；失败 return 1。进度打 stderr。
# 公共节点表固定 IPv4 探测，入口强制 -4，避免上次手填 IPv6 把全局 IP_FAMILY 带偏。
auto_pick_peer() {
    local cand name prov rtt tmpd sorted best="" fallback="" fallback_rtt=""
    IP_FAMILY=-4
    if ! command -v ping >/dev/null 2>&1; then
        echo -e "  ${YELLOW}本机缺 ping，无法自动选公共节点（ensure_tools 应已装；或手填对端）${NC}" >&2
        return 1
    fi
    echo -e "  ${CYAN}自动选择近端 iperf3 节点（测端口上限，越近越准）…${NC}" >&2
    tmpd=$(mktemp -d 2>/dev/null) || return 1
    while IFS='|' read -r cand name prov; do
        [ -z "$cand" ] && continue
        (
            r=$(ping $IP_FAMILY -c 2 -q -W 2 "$cand" 2>/dev/null | awk -F'/' '/rtt|round-trip/{printf "%.0f", $5}')
            [ -n "$r" ] && printf '%s %s %s %s\n' "$r" "$cand" "$name" "$prov" >"$tmpd/$cand"
        ) &
    done <<<"$PEER_POOL"
    wait
    sorted=$(cat "$tmpd"/* 2>/dev/null | sort -n)
    rm -rf "$tmpd"
    [ -n "$sorted" ] || {
        echo -e "  ${YELLOW}全部公共节点 ping 不通${NC}" >&2
        return 1
    }
    while read -r rtt cand name prov; do
        [ -z "$cand" ] && continue
        if [ "$rtt" -gt "$PEER_MAX_RTT" ] 2>/dev/null; then
            printf '    %-36s %-8s RTT %sms  太远跳过\n' "$cand" "$name" "$rtt" >&2
            continue
        fi
        printf '    %-36s %-8s RTT %sms  ' "$cand" "$name" "$rtt" >&2
        if ! probe_peer_port "$cand"; then
            echo "端口不通" >&2
            continue
        fi
        local pport="$PROBE_PORT_OK" gp="" try
        if ! command -v iperf3 >/dev/null 2>&1; then
            echo -e "${GREEN}可达 :$pport${NC}" >&2
            echo "$cand:$pport"
            return 0
        fi
        for try in $(port_order "$pport"); do
            if timeout 15 iperf3 $IP_FAMILY -c "$cand" -p "$try" -t 2 -P 1 >/dev/null 2>&1; then
                gp=$try
                break
            fi
        done
        if [ -n "$gp" ]; then
            if [ "$rtt" -le "$PEER_IDEAL_RTT" ] 2>/dev/null; then
                echo -e "${GREEN}可用 :$gp${NC}" >&2
                echo "$cand:$gp"
                return 0
            fi
            echo "可用 :$gp（偏远，作备选）" >&2
            [ -z "$fallback" ] && {
                fallback="$cand:$gp"
                fallback_rtt=$rtt
            }
        else
            echo "全端口忙" >&2
        fi
    done <<<"$sorted"
    if [ -n "$fallback" ]; then
        echo -e "  ${YELLOW}最近可用对端 RTT ${fallback_rtt}ms（理想 ≤${PEER_IDEAL_RTT}ms），拐点可能偏保守${NC}" >&2
        echo "$fallback"
        return 0
    fi
    echo -e "  ${YELLOW}没有 ${PEER_MAX_RTT}ms 内空闲的公共节点；可手填自建 iperf3 -s 地址${NC}" >&2
    return 1
}

# 问对端：回车自动选；填 IP/域名用手填。UI → stderr，stdout 只吐 host:port
ask_peer() {
    local ans picked host port bare_host=0
    echo -e "  测速对端（近端 iperf3，测本机端口上限不是回国速度）：" >&2
    echo -e "    回车 = 自动选最近公共节点" >&2
    echo -e "    或填 主机 或 主机:端口（IPv6 可用 2001:db8::1 / [2001:db8::1] / [2001:db8::1]:5201）" >&2
    read -p "  对端: " ans || true
    if [ -z "$ans" ]; then
        picked=$(auto_pick_peer) || return 1
        echo "$picked"
        return 0
    fi
    # 去掉首尾空白
    ans=${ans#"${ans%%[![:space:]]*}"}
    ans=${ans%"${ans##*[![:space:]]}"}
    case "$ans" in
    \[*\]:*)
        # [2001:db8::1]:5201
        host=${ans#\[}
        host=${host%%\]:*}
        port=${ans##*:}
        ;;
    \[*\])
        # [2001:db8::1] 无端口 → 默认/探测
        host=${ans#\[}
        host=${host%\]}
        port=5201
        bare_host=1
        ;;
    *:*)
        # 区分「hostname:port / IPv4:port」与「裸 IPv6（含多个 :）」
        case "$ans" in
        *:*:*)
            # 多个冒号：裸 IPv6 主机，默认端口
            host=$ans
            port=5201
            bare_host=1
            ;;
        *)
            host=${ans%:*}
            port=${ans##*:}
            ;;
        esac
        ;;
    *)
        host=$ans
        port=5201
        bare_host=1
        ;;
    esac
    # 格式闸：只许主机名/IP 字符集，拒绝 shell 元字符（审查：命令注入）
    # 裸 IPv6 允许冒号；方括号已在上面剥掉
    case "$host" in
    '' | *[!A-Za-z0-9._:-]*)
        echo -e "  ${RED}对端主机名不合法（只允许字母数字 . _ : -）${NC}" >&2
        return 1
        ;;
    esac
    case "$port" in
    '' | *[!0-9]*)
        echo -e "  ${RED}对端端口不合法（须为 1-65535 的数字）${NC}" >&2
        return 1
        ;;
    esac
    if [ "$port" -lt 1 ] 2>/dev/null || [ "$port" -gt 65535 ] 2>/dev/null; then
        echo -e "  ${RED}对端端口超出范围${NC}" >&2
        return 1
    fi
    # 未显式写端口：探测可用口（探测函数内部同样校验）
    if [ "$bare_host" = "1" ]; then
        if probe_peer_port "$host"; then
            port=$PROBE_PORT_OK
        fi
    fi
    echo "$host:$port"
}


# 跑 iperf3：stdout "发送Mbps 重传 接收Mbps"；失败空
run_iperf() {
    local peer="$1" dur="$2" par="$3" first="${4:-$PEER_PORT}"
    local port out tmp sg rt rg try
    command -v iperf3 >/dev/null 2>&1 || return 1
    tmp=$(mktemp 2>/dev/null) || return 1
    for try in $(port_order "$first"); do
        if timeout $((dur + 20)) iperf3 $IP_FAMILY -c "$peer" -p "$try" -t "$dur" -P "$par" -f m >"$tmp" 2>&1; then
            # sender 汇总行
            sg=$(awk '/sender$/{print $7; exit}' "$tmp")
            rt=$(awk '/sender$/{print $9; exit}' "$tmp")
            rg=$(awk '/receiver$/{print $7; exit}' "$tmp")
            [ -n "$rg" ] || rg=$sg
            # 单位可能是 Mbits/sec；-f m 保证是 Mbits
            case "$sg" in '' | *[!0-9.]*) continue ;; esac
            case "$rt" in '' | *[!0-9]*) rt=0 ;; esac
            rm -f "$tmp"
            printf '%s %s %s\n' "$sg" "$rt" "$rg"
            PEER_PORT=$try
            return 0
        fi
    done
    rm -f "$tmp"
    return 1
}

# 丢包率%（近似）：重传 / (吞吐Mbps * 秒 * 1e6/8/1500) —— 用 tcpfit 同款简化
# loss_pct <重传> <吞吐Mbps> <秒>
loss_pct() {
    awk -v rt="$1" -v mbps="$2" -v sec="$3" 'BEGIN{
        segs = mbps * 1000000 / 8 / 1500 * sec
        if (segs < 1) segs = 1
        printf "%.4f", rt * 100 / segs
    }'
}

calc_burst() {
    awk -v r="$1" 'BEGIN{v=r*500; if(v<32768)v=32768; printf "%d", v}'
}

calc_shape_margin() {
    local bw="$1"
    if [ "$bw" -le 30 ] 2>/dev/null; then echo 1
    elif [ "$bw" -le 60 ] 2>/dev/null; then echo 2
    elif [ "$bw" -le 100 ] 2>/dev/null; then echo 5
    elif [ "$bw" -le 300 ] 2>/dev/null; then echo 10
    elif [ "$bw" -le 600 ] 2>/dev/null; then echo 15
    elif [ "$bw" -le 1000 ] 2>/dev/null; then echo 25
    else echo 40
    fi
}

# 临时装 HTB 测试结构（与持久化脚本一致）
apply_test_shaper() {
    local iface="$1" rate="$2" burst
    burst=$(calc_burst "$rate")
    tc qdisc del dev "$iface" root 2>/dev/null || true
    tc qdisc add dev "$iface" root handle 1: htb default 10 2>/dev/null || return 1
    tc class add dev "$iface" parent 1: classid 1:10 htb \
        rate "${rate}mbit" ceil "${rate}mbit" burst "$burst" cburst "$burst" quantum 1514 2>/dev/null || return 1
    tc qdisc add dev "$iface" parent 1:10 handle 10: fq \
        limit 40960 flow_limit 8192 maxrate "${rate}mbit" 2>/dev/null || return 1
}

# 读 HTB class 速率为 Mbps（处理 tc 显示 Gbit）
tc_rate_mbit() {
    printf '%s' "$1" | awk '
    {
      for(i=1;i<=NF;i++) if($i=="rate"){
        v=$(i+1)
        if (v ~ /Gbit/) { gsub(/Gbit/,"",v); printf "%.0f", v*1000; exit }
        if (v ~ /Mbit/) { gsub(/Mbit/,"",v); printf "%.0f", v; exit }
        if (v ~ /Kbit/) { gsub(/Kbit/,"",v); printf "%.0f", v/1000; exit }
        if (v ~ /bit/)  { gsub(/bit/,"",v);  printf "%.0f", v/1000000; exit }
      }
    }'
}

# 写持久化 shape 脚本 + unit 并应用
shape_apply() {
    local rate="$1" iface
    take_tcpo_lock || return 1
    iface=$(egress_dev)
    [ -n "$iface" ] || {
        echo -e "  ${RED}识别不出出口网卡${NC}"
        return 1
    }
    is_posint_shape "$rate" 1 100000 || {
        echo -e "  ${RED}整形速率必须是 1-100000 的整数 Mbps${NC}"
        return 1
    }
    if dryrun_skip "写入 HTB 整形 ${rate}Mbit @ $iface"; then
        mkdir -p "$STATE_DIR"
        if ! printf '%s\n' "$rate" | atomic_write "$SHAPE_RATE"; then
            echo -e "  ${RED}演练写入 $SHAPE_RATE 失败${NC}"
            return 1
        fi
        return 0
    fi
    if ! command -v tc >/dev/null 2>&1; then
        ensure_tools tc >/dev/null
        command -v tc >/dev/null 2>&1 || {
            echo -e "  ${RED}tc 不可用，无法整形${NC}"
            return 1
        }
    fi
    mkdir -p "$(dirname "$SHAPE_SCRIPT")" "$STATE_DIR" || {
        echo -e "  ${RED}无法创建目录${NC}"
        return 1
    }
    # 走 atomic_write：拒绝符号链接、先临时文件再 rename，避免半截脚本被 enable
    if ! cat <<EOF | atomic_write "$SHAPE_SCRIPT"
#!/bin/bash
# 由 tcpo 生成：HTB 聚合限速 + fq 叶子 pacing。回退用菜单 30 或菜单 24 关整形。
IF=${iface}
RATE=\${1:-${rate}}
BURST=\$(awk -v r="\$RATE" 'BEGIN{v=r*500; if(v<32768)v=32768; printf "%d",v}')
tc qdisc del dev \$IF root 2>/dev/null || {
  tc qdisc replace dev \$IF root handle 1: mq 2>/dev/null || exit 1
  tc qdisc del dev \$IF root 2>/dev/null || exit 1
}
tc qdisc add dev \$IF root handle 1: htb default 10 || exit 1
tc class add dev \$IF parent 1: classid 1:10 htb rate \${RATE}mbit ceil \${RATE}mbit burst \${BURST} cburst \${BURST} quantum 1514 || exit 1
tc qdisc add dev \$IF parent 1:10 handle 10: fq limit 40960 flow_limit 8192 maxrate \${RATE}mbit || exit 1
EOF
    then
        echo -e "  ${RED}写入 $SHAPE_SCRIPT 失败（符号链接/磁盘满/只读）${NC}"
        return 1
    fi
    chmod +x "$SHAPE_SCRIPT" || {
        echo -e "  ${RED}chmod $SHAPE_SCRIPT 失败${NC}"
        return 1
    }
    [ -x "$SHAPE_SCRIPT" ] || {
        echo -e "  ${RED}$SHAPE_SCRIPT 未落地为可执行文件${NC}"
        return 1
    }
    if has_systemd; then
        if ! cat <<EOF | atomic_write "$SHAPE_UNIT"
[Unit]
Description=tcpo egress HTB shaper
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=$SHAPE_SCRIPT $rate
[Install]
WantedBy=multi-user.target
EOF
        then
            echo -e "  ${RED}写入 $SHAPE_UNIT 失败${NC}"
            return 1
        fi
        [ -f "$SHAPE_UNIT" ] || {
            echo -e "  ${RED}$SHAPE_UNIT 未落地${NC}"
            return 1
        }
        systemctl daemon-reload >/dev/null 2>&1
        # enable 失败仍可即时生效，但必须把「未持久化」说清楚，不能只报成功（审查：假持久化）
        local unit_ok=0
        if systemctl enable --now "$(basename "$SHAPE_UNIT")" >/dev/null 2>&1; then
            unit_ok=1
        else
            "$SHAPE_SCRIPT" "$rate" || return 1
        fi
    else
        "$SHAPE_SCRIPT" "$rate" || return 1
        local unit_ok=0
        echo -e "  ${YELLOW}无 systemd：整形已即时生效，重启后需重放 $SHAPE_SCRIPT $rate${NC}"
    fi
    local applied
    applied=$(tc_rate_mbit "$(tc class show dev "$iface" 2>/dev/null)")
    if [ -n "$applied" ] && awk -v a="$applied" -v r="$rate" 'BEGIN{exit !(a>r*0.99 && a<r*1.01)}'; then
        if ! printf '%s\n' "$rate" | atomic_write "$SHAPE_RATE"; then
            echo -e "  ${RED}写入 $SHAPE_RATE 失败（符号链接/磁盘满/只读）；运行时已整形但状态文件未落地${NC}"
            return 1
        fi
        if [ "${unit_ok:-0}" = "1" ]; then
            echo -e "  ${GREEN}已应用 HTB ${rate} Mbit + fq pacing @ ${iface}（已持久化）${NC}"
        elif has_systemd; then
            echo -e "  ${YELLOW}已应用 HTB ${rate} Mbit @ ${iface}，但 unit 未 enable 成功——重启后会丢，请检查 systemctl status $(basename "$SHAPE_UNIT")${NC}"
        else
            echo -e "  ${GREEN}已应用 HTB ${rate} Mbit + fq pacing @ ${iface}${NC}"
        fi
        return 0
    fi
    echo -e "  ${YELLOW}整形命令已执行但读回速率不符（当前 ${applied:-?} Mbit），请 tc class show dev $iface 核对${NC}"
    # 速率不符也尝试落状态，失败则明确报；不裸重定向
    if ! printf '%s\n' "$rate" | atomic_write "$SHAPE_RATE"; then
        echo -e "  ${RED}写入 $SHAPE_RATE 失败${NC}"
    fi
    return 1
}

shape_off() {
    local iface
    take_tcpo_lock || return 1
    iface=$(egress_dev)
    if dryrun_skip "移除 HTB 整形"; then
        rm -f "$SHAPE_RATE" "$SWEEP_RESULT"
        return 0
    fi
    if has_systemd && [ -f "$SHAPE_UNIT" ]; then
        systemctl disable --now "$(basename "$SHAPE_UNIT")" >/dev/null 2>&1
        rm -f "$SHAPE_UNIT"
        systemctl daemon-reload >/dev/null 2>&1
    fi
    rm -f "$SHAPE_SCRIPT" "$SHAPE_RATE" "$SWEEP_RESULT"
    [ -n "$iface" ] || {
        echo -e "  ${GREEN}整形配置已清除${NC}"
        return 0
    }
    # 拆 HTB 后走 restore_default_qdisc：多队列会回到 mq + 叶子 default_qdisc
    local restore_rc=0
    if command -v tc >/dev/null 2>&1; then
        local root
        root=$(tc qdisc show dev "$iface" 2>/dev/null | awk 'NR==1{print $2}')
        case "$root" in
        htb)
            restore_default_qdisc "$iface" || restore_rc=1
            ;;
        esac
    fi
    if [ "$restore_rc" = "0" ]; then
        echo -e "  ${GREEN}整形已移除；BBR/缓冲区等基础调优未动${NC}"
        return 0
    fi
    echo -e "  ${RED}整形配置文件已删，但 qdisc 恢复失败——请手动: tc qdisc show dev $iface${NC}"
    return 1
}


is_posint_shape() {
    case "$1" in '' | *[!0-9]*) return 1 ;; esac
    [ "$1" -ge "$2" ] 2>/dev/null && [ "$1" -le "$3" ] 2>/dev/null
}

# 菜单 24：应用/移除整形
menu_shape() {
    echo -e "\n${YELLOW}>>> 出向整形（HTB + fq pacing）${NC}"
    local cur iface
    iface=$(egress_dev)
    cur=""
    [ -n "$iface" ] && cur=$(tc_rate_mbit "$(tc class show dev "$iface" 2>/dev/null)")
    if [ -n "$cur" ]; then
        echo -e "  当前整形: ${GREEN}${cur} Mbit${NC} @ ${iface}"
    else
        echo -e "  当前整形: ${YELLOW}未设置${NC}"
    fi
    echo -e "    ${CYAN}1${NC}) 应用整形（手填 Mbps）"
    echo -e "    ${CYAN}2${NC}) 移除整形（保留基础调优）"
    echo -e "    ${CYAN}0${NC}) 返回"
    local c
    read -p "  选择: " c || true
    case "$c" in
    1)
        local r
        read -p "  整形上限 Mbps: " r || true
        shape_apply "$r"
        ;;
    2) shape_off ;;
    *) echo -e "  已取消" ;;
    esac
    pause_return
}

# 限速器拐点扫描。成功时写 $SWEEP_RESULT（KNEE/RECOMMEND/NO_KNEE 等）
# 返回 0=有推荐整形值  3=确认无拐点/超上限  1=失败
sweep_policer() {
    local peer="$1" nominal="$2" iface dur=10 par=1 thresh=0.1 GAP=3
    local res sgp rt gp lp base_loss="" last_ok="" broke_at=""
    local unshaped_mbps unshaped_loss margin rec knee
    take_tcpo_lock || return 1
    iface=$(egress_dev)
    [ -n "$iface" ] || {
        echo -e "  ${RED}无出口网卡${NC}"
        return 1
    }
    [ -n "$peer" ] || {
        echo -e "  ${RED}无对端${NC}"
        return 1
    }
    case "$nominal" in '' | *[!0-9]*) nominal=1000 ;; esac
    mkdir -p "$STATE_DIR"
    rm -f "$SWEEP_RESULT"

    if ! command -v iperf3 >/dev/null 2>&1; then
        echo -e "  ${YELLOW}拐点扫描需要 iperf3（会产生真实上行流量）${NC}"
        drain_stdin
        if [ "$BATCH" = "1" ]; then
            read -p "  现在安装 iperf3？[y/N]: " _ins || true
            [[ "$_ins" =~ ^[Yy]$ ]] || {
                echo -e "  ${YELLOW}跳过整形扫描（基础调优不受影响）${NC}"
                return 1
            }
            ensure_tools iperf3 >/dev/null
        else
            read -p "  现在安装 iperf3？[y/N]: " _ins || true
            [[ "$_ins" =~ ^[Yy]$ ]] || {
                echo -e "  ${YELLOW}已取消扫描${NC}"
                return 1
            }
            ensure_tools iperf3 >/dev/null
        fi
        command -v iperf3 >/dev/null 2>&1 || {
            echo -e "  ${RED}iperf3 不可用${NC}"
            return 1
        }
    fi
    if ! command -v tc >/dev/null 2>&1; then
        ensure_tools tc >/dev/null
        command -v tc >/dev/null 2>&1 || {
            echo -e "  ${RED}tc 不可用${NC}"
            return 1
        }
    fi

    local host=${peer%:*} pport=${peer##*:}
    [ "$host" = "$pport" ] && pport=$PEER_PORT
    PEER_PORT=$pport
    set_ip_family_for_host "$host"
    if [ "$IP_FAMILY" = "-6" ]; then
        echo -e "  ${CYAN}对端走 IPv6（iperf3 -6）${NC}"
    fi

    echo -e "  对端 ${CYAN}${host}:${PEER_PORT}${NC}  标称 ${CYAN}${nominal}${NC} Mbps  网卡 ${CYAN}${iface}${NC}"
    echo -e "  ${YELLOW}扫描会产生真实上行流量；中断(Ctrl-C)会尽量恢复 qdisc${NC}"

    # 扫描会临时换 root qdisc；退出/中断必须恢复。
    # 有持久化整形 → 重放 SHAPE_SCRIPT；否则 restore_default_qdisc（保 mq）。
    local had_shape=0
    [ -x "$SHAPE_SCRIPT" ] && [ -f "$SHAPE_RATE" ] && had_shape=1
    restore_q() {
        local rc=0
        if [ "$had_shape" = "1" ] && [ -x "$SHAPE_SCRIPT" ] && [ -f "$SHAPE_RATE" ]; then
            "$SHAPE_SCRIPT" "$(cat "$SHAPE_RATE" 2>/dev/null)" 2>/dev/null || restore_default_qdisc "$iface" || rc=1
        else
            restore_default_qdisc "$iface" || rc=1
        fi
        if [ "$rc" != "0" ]; then
            echo -e "  ${RED}qdisc 恢复失败，请检查: tc qdisc show dev $iface${NC}" >&2
        fi
        return "$rc"
    }
    trap 'echo; echo -e "  ${YELLOW}中断，恢复 qdisc…${NC}"; restore_q; trap - INT TERM; return 130' INT TERM

    # —— 1) 不限速基线 ——
    echo -e "\n  ${CYAN}[1/3] 不限速基线（判断有没有端口限速器）${NC}"
    # 基线只要「不是 HTB」：已在 pfifo_fast/fq/mq 上直接测。
    # 只有当前是 htb 才强制拆；否则 restore 会为了追 sysctl 的 fq 去 replace，
    # 在 WSL 虚拟网卡上常失败，误杀整次扫描（sysctl=fq 但运行时只能 pfifo_fast）。
    _bl_root=$(tc qdisc show dev "$iface" 2>/dev/null | awk 'NR==1{print $2}')
    if [ "$_bl_root" = "htb" ]; then
        if ! restore_default_qdisc "$iface"; then
            echo -e "  ${RED}基线前无法拆掉 HTB，中止扫描（qdisc 状态不对测拐点会失真）${NC}"
            echo -e "  ${YELLOW}可先菜单 24 关整形，或检查: tc qdisc show dev $iface${NC}"
            trap - INT TERM
            return 1
        fi
    fi
    res=$(run_iperf "$host" "$dur" 1 "$PEER_PORT") || res=""
    if [ -z "$res" ]; then
        echo -e "  ${RED}基线测速失败（对端忙或不可达）${NC}"
        restore_q
        trap - INT TERM
        return 1
    fi
    sgp=$(echo "$res" | awk '{print $1}')
    rt=$(echo "$res" | awk '{print $2}')
    gp=$(echo "$res" | awk '{print $3}')
    lp=$(loss_pct "$rt" "$sgp" "$dur")
    unshaped_mbps=$gp
    unshaped_loss=$lp
    echo -e "  不限速: 吞吐 ${CYAN}${gp}${NC} Mbps  重传 ${rt}  估丢包 ${lp}%"

    if awk -v u="$unshaped_mbps" -v c="$SWEEP_CAP" 'BEGIN{exit !(u+0 > c+0)}'; then
        echo -e "  ${GREEN}吞吐超过扫描上限 ${SWEEP_CAP} Mbit，跳过拐点扫描（通常无需整形）${NC}"
        printf 'NO_KNEE=1\nABOVE_CAP=1\nUNSHAPED=%s\nLOSS=%s\n' "$unshaped_mbps" "$unshaped_loss" >"$SWEEP_RESULT"
        restore_q
        trap - INT TERM
        return 3
    fi

    # 丢包很低 → 无限速器
    if awk -v l="$lp" -v t="$thresh" 'BEGIN{exit !(l+0 <= t+0)}'; then
        echo -e "  ${GREEN}丢包很低：端口上未见限速器，不加整形（加了只会限制自己）${NC}"
        printf 'NO_KNEE=1\nUNSHAPED=%s\nLOSS=%s\n' "$unshaped_mbps" "$unshaped_loss" >"$SWEEP_RESULT"
        restore_q
        # 若以前有整形，主动拆掉避免「结果写未设置、网上还限着」
        if [ -f "$SHAPE_RATE" ] || [ -x "$SHAPE_SCRIPT" ]; then
            echo -e "  ${CYAN}检测到旧整形，正在移除…${NC}"
            shape_off
        fi
        trap - INT TERM
        return 3
    fi

    echo -e "  ${YELLOW}丢包偏高：疑似有限速器，从实测吞吐附近往上扫拐点${NC}"

    # —— 2) 向上扫描 ——
    echo -e "\n  ${CYAN}[2/3] 拐点扫描${NC}"
    local start end step r hits j r2 s2 g2 t2 l2 min_lp
    start=$(awk -v u="$unshaped_mbps" 'BEGIN{s=int(u*0.9/10)*10; if(s<20)s=20; print s}')
    end=$(awk -v u="$unshaped_mbps" -v n="$nominal" -v c="$SWEEP_CAP" 'BEGIN{
        e=int(u*1.25/10)*10; if(n>e) e=int(n*1.1/10)*10; if(e>c) e=c; print e}')
    step=$(awk -v b="$nominal" 'BEGIN{s=int(b/30/10+0.5)*10; if(s<20)s=20; print s}')
    echo -e "  区间 ${start} → ${end}  步长 ${step}  Mbps"
    printf '  %-10s %12s %9s %8s  %s\n' "档位" "吞吐Mbps" "重传" "丢包%" "判定"

    base_loss=""
    last_ok=""
    broke_at=""
    for ((r = start; r <= end; r += step)); do
        apply_test_shaper "$iface" "$r" || {
            echo -e "  ${YELLOW}无法在 ${r}M 应用测试整形，停止${NC}"
            break
        }
        sleep 1
        res=$(run_iperf "$host" "$dur" 1 "$PEER_PORT") || res=""
        if [ -z "$res" ]; then
            printf '  %-10s %12s %9s %8s  %s\n' "$r" "-" "-" "-" "对端忙/失败"
            sleep "$GAP"
            continue
        fi
        sgp=$(echo "$res" | awk '{print $1}')
        rt=$(echo "$res" | awk '{print $2}')
        gp=$(echo "$res" | awk '{print $3}')
        lp=$(loss_pct "$rt" "$sgp" "$dur")
        if [ -z "$base_loss" ] && awk -v l="$lp" -v t="$thresh" 'BEGIN{exit !(l+0 <= t+0)}'; then
            base_loss=$lp
        fi
        # 跳变：超阈值且明显高于本底
        if awk -v l="$lp" -v t="$thresh" -v b="${base_loss:-0}" 'BEGIN{
            need=t; if(b>0 && b*5>need) need=b*5; if(need>1) need=1;
            exit !(l+0 > need)
        }'; then
            hits=1
            min_lp=$lp
            for j in 2 3; do
                sleep "$GAP"
                r2=$(run_iperf "$host" "$dur" 1 "$PEER_PORT") || r2=""
                [ -z "$r2" ] && continue
                s2=$(echo "$r2" | awk '{print $1}')
                t2=$(echo "$r2" | awk '{print $2}')
                g2=$(echo "$r2" | awk '{print $3}')
                l2=$(loss_pct "$t2" "$s2" "$dur")
                printf '  %-10s %12s %9s %8s  %s\n' "${r}(#${j})" "$g2" "$t2" "$l2" "复测"
                awk -v x="$l2" -v y="$min_lp" 'BEGIN{exit !(x<y)}' && min_lp=$l2
                if awk -v l="$l2" -v t="$thresh" -v b="${base_loss:-0}" 'BEGIN{
                    need=t; if(b>0 && b*5>need) need=b*5; if(need>1) need=1;
                    exit !(l+0 > need)
                }'; then
                    hits=$((hits + 1))
                fi
            done
            if [ "$hits" -ge 2 ]; then
                printf '  %-10s %12s %9s %8s  %s\n' "$r" "$gp" "$rt" "$lp" "丢包跳变(${hits}/3)"
                broke_at=$r
                break
            fi
            printf '  %-10s %12s %9s %8s  %s\n' "$r" "$gp" "$rt" "$lp" "瞬时抖动，继续"
            last_ok=$r
        else
            printf '  %-10s %12s %9s %8s  %s\n' "$r" "$gp" "$rt" "$lp" "ok"
            last_ok=$r
            [ -z "$base_loss" ] && base_loss=$lp
        fi
        sleep "$GAP"
    done

    # 终点必测（步长可能跳过 end）
    if [ -z "$broke_at" ] && [ -n "$last_ok" ] && [ "$last_ok" -lt "$end" ] 2>/dev/null; then
        apply_test_shaper "$iface" "$end" 2>/dev/null && {
            res=$(run_iperf "$host" "$dur" 1 "$PEER_PORT") || res=""
            if [ -n "$res" ]; then
                sgp=$(echo "$res" | awk '{print $1}')
                rt=$(echo "$res" | awk '{print $2}')
                gp=$(echo "$res" | awk '{print $3}')
                lp=$(loss_pct "$rt" "$sgp" "$dur")
                printf '  %-10s %12s %9s %8s  %s\n' "$end" "$gp" "$rt" "$lp" "终点"
                if awk -v l="$lp" -v t="$thresh" -v b="${base_loss:-0}" 'BEGIN{
                    need=t; if(b>0 && b*5>need) need=b*5; if(need>1) need=1;
                    exit !(l+0 > need)
                }'; then
                    broke_at=$end
                else
                    last_ok=$end
                fi
            fi
        }
    fi

    restore_q
    trap - INT TERM

    echo -e "\n  ${CYAN}[3/3] 结论${NC}"
    if [ -n "$broke_at" ]; then
        knee=$broke_at
        margin=$(calc_shape_margin "$nominal")
        rec=$((knee - margin))
        [ "$rec" -lt 1 ] && rec=1
        # 推荐不低于不限速吞吐的 85%（太低没意义）
        local floor
        floor=$(awk -v u="$unshaped_mbps" 'BEGIN{printf "%d", u*0.85}')
        [ "$rec" -lt "$floor" ] 2>/dev/null && rec=$floor
        echo -e "  拐点约 ${GREEN}${knee}${NC} Mbit  安全余量 ${margin}  推荐整形 ${GREEN}${rec}${NC} Mbit"
        printf 'KNEE=%s\nRECOMMEND=%s\nMARGIN=%s\nUNSHAPED=%s\nLOSS=%s\n' \
            "$knee" "$rec" "$margin" "$unshaped_mbps" "$unshaped_loss" >"$SWEEP_RESULT"
        return 0
    fi

    if [ -n "$last_ok" ]; then
        echo -e "  ${YELLOW}扫完全程未稳定复现跳变；可能无限速器或拐点在区间外${NC}"
        printf 'NO_KNEE=1\nOUT_OF_RANGE=1\nLAST_OK=%s\nUNSHAPED=%s\n' \
            "$last_ok" "$unshaped_mbps" >"$SWEEP_RESULT"
        return 3
    fi
    echo -e "  ${RED}扫描无有效样本${NC}"
    return 1
}

# 菜单 23：独立扫拐点并可选择应用
menu_sweep() {
    echo -e "\n${YELLOW}>>> 限速器拐点扫描 + 可选应用整形${NC}"
    echo -e "  原理：不限速先跑 → 丢包高则向上扫 → 在拐点下用 HTB+fq pacing 限住突发"
    echo -e "  ${YELLOW}会消耗真实上行流量（约数 GB / 数分钟，视带宽而定）${NC}"
    local peer_spec host_port bw rc rec
    bw=${TUNE_MBPS:-}
    if [ -z "$bw" ]; then
        bw=$(ask_bandwidth)
    fi
    host_port=$(ask_peer) || {
        echo -e "  ${YELLOW}无可用对端，已取消${NC}"
        pause_return
        return 1
    }
    echo -e "  带宽按 ${CYAN}${bw}${NC} Mbps  对端 ${CYAN}${host_port}${NC}"
    echo -e "  ${YELLOW}开始扫描（真实上行流量）…${NC}"
    sweep_policer "$host_port" "$bw"
    rc=$?
    if [ "$rc" = "0" ] && [ -f "$SWEEP_RESULT" ]; then
        rec=$(awk -F= '/^RECOMMEND=/{print $2}' "$SWEEP_RESULT")
        if [ -n "$rec" ]; then
            if [ "$BATCH" = "1" ]; then
                shape_apply "$rec"
            else
                read -p "  应用整形 ${rec} Mbit？[Y/n]: " _c || true
                case "$_c" in
                n | N) echo -e "  已跳过应用（结果在 $SWEEP_RESULT）" ;;
                *) shape_apply "$rec" ;;
                esac
            fi
        fi
    fi
    pause_return
    return 0
}

# 一键流程里的整形段：失败只告警
run_shape_stage() {
    local bw="$1" peer_spec rc rec
    echo -e "\n${YELLOW}>>> 阶段：限速器扫描与整形${NC}"
    peer_spec=$(ask_peer) || {
        echo -e "  ${YELLOW}整形跳过：无可用对端（基础调优已生效）${NC}"
        echo -e "  ${CYAN}稍后可用菜单 23 重试，或菜单 24 手填整形值${NC}"
        return 0
    }
    sweep_policer "$peer_spec" "$bw"
    rc=$?
    if [ "$rc" = "0" ]; then
        rec=$(awk -F= '/^RECOMMEND=/{print $2}' "$SWEEP_RESULT" 2>/dev/null)
        if [ -n "$rec" ]; then
            # 扫完冷却，避免令牌桶空窗污染「是否生效」的体感
            echo -e "  冷却 8s 后应用整形…"
            sleep 8
            shape_apply "$rec" || echo -e "  ${YELLOW}整形应用未完全成功，可菜单 24 重试${NC}"
        fi
    elif [ "$rc" = "3" ]; then
        echo -e "  ${GREEN}无需整形或未定位拐点；基础调优保留${NC}"
    else
        echo -e "  ${YELLOW}扫描失败，整形跳过；基础调优保留${NC}"
    fi
    return 0
}

# ==================================================
# --- 9. 一键全部（1+2+3+4）---
# ==================================================
run_all() {
    echo -e "
${YELLOW}>>> 一键全部优化（基础调优 + 限速器扫描/整形）${NC}"

    # 档位要用户明确选，不能默认给中转档——开 IP 转发对普通业务机是意外的语义变更
    echo -e "
  这台机器的角色："
    echo -e "    ${CYAN}1${NC}) 普通业务机 —— 保守档"
    echo -e "    ${CYAN}2${NC}) 中转/代理/隧道 —— 中转档（+IP 转发 + MSS Clamp）"
    read -p "  选择 [1/2，回车默认 1]: " role
    case "$role" in
    2) PROFILE=transit ;;
    *) PROFILE=normal ;;
    esac

    TUNE_MBPS=$(ask_bandwidth)
    case "$TUNE_MBPS" in '' | *[!0-9]*) TUNE_MBPS=1000 ;; esac

    echo -e "
  将依次：IPv4 优先 → BBR+FQ → 内核参数 → 网卡队列 → 限速扫描/整形"
    echo -e "  带宽按 ${CYAN}${TUNE_MBPS}${NC} Mbps  档位 ${CYAN}${PROFILE}${NC}"
    echo -e "  ${YELLOW}整形阶段会跑真实上行流量；公共节点不可用时只跳过整形，基础仍生效${NC}"
    echo -e "  ${YELLOW}不包含关闭 IPv6（要关请用菜单 3）${NC}"
    drain_stdin
    read -p "确认执行？[y/N]: " confirm
    [[ "$confirm" =~ ^[Yy]$ ]] || {
        echo -e "${GREEN}已取消（未输入 y）。${NC}"
        sleep 1
        return
    }

    BATCH=1
    set_ipv4_priority
    enable_bbr
    tune_sysctl
    tune_nic
    run_shape_stage "$TUNE_MBPS"
    BATCH=0
    TUNE_MBPS=""

    echo -e "
${GREEN}===== 一键优化完成 =====${NC}"
    draw_line
    printf "  %-14s : %s
" "拥塞算法" "$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)"
    printf "  %-14s : %s
" "默认队列" "$(sysctl -n net.core.default_qdisc 2>/dev/null)"
    printf "  %-14s : %s
" "somaxconn" "$(sysctl -n net.core.somaxconn 2>/dev/null)"
    printf "  %-14s : %s MB
" "缓冲区上限" "$(($(sysctl -n net.core.rmem_max 2>/dev/null || echo 0) / 1024 / 1024))"
    printf "  %-14s : %s
" "IPv4 优先" "$(grep -q '^precedence ::ffff:0:0/96  100' "$GAI_CONF" 2>/dev/null && echo 已开启 || echo 未开启)"
    printf "  %-14s : %s
" "档位" "$PROFILE"
    _sh=""
    _dev=$(egress_dev)
    [ -n "$_dev" ] && _sh=$(tc_rate_mbit "$(tc class show dev "$_dev" 2>/dev/null)")
    printf "  %-14s : %s
" "出向整形" "${_sh:+${_sh} Mbit}${_sh:-未设置}"
    draw_line
    echo -e "  ${CYAN}下一步：菜单 20 看瓶颈，菜单 21 测效果，菜单 23 可重扫拐点${NC}"
    pause_return
}


# ==================================================
# --- 5. 回退 ---
# ==================================================
rollback_tune() {
    echo -e "\n${YELLOW}>>> 回退全部优化${NC}"
    take_tcpo_lock || return 1
    local had_systemd_limits=0
    [ -f "$SYSTEMD_LIMITS_OPT" ] && had_systemd_limits=1
    # $IPV6_OPT 在这份清单里：菜单 3 关掉的 IPv6 属「本脚本改的状态」，
    # 菜单 30 承诺「还原成第一次运行前的样子」就必须连它一起还原。
    # 三个 disable_ipv6 key 已在 MANAGED_KEYS 里，运行时值由下面的 restore_managed_keys 写回
    rm -f "$SYSCTL_OPT" "$LEGACY_SYSCTL_OPT" "$NIC_SYSCTL_OPT" "$LIMITS_OPT" "$BBR_OPT" \
        "$SYSTEMD_LIMITS_OPT" \
        "$IPV6_OPT" \
        "$BBR_MODLOAD"
    # 删掉 system.conf.d drop-in 后 systemd 不会自动重读，要 daemon-reexec。
    # 它只重新执行 systemd 自身（PID 1 原地 re-exec），不影响正在跑的服务
    if [ "$had_systemd_limits" = "1" ] && has_systemd; then
        if systemctl daemon-reexec 2>/dev/null; then
            echo -e "  已移除 systemd 全局句柄上限并重载（已在运行的服务保持原上限，重启后归位）"
        else
            echo -e "  ${YELLOW}已删除 systemd 句柄上限文件，但 daemon-reexec 失败；重启后自然生效${NC}"
        fi
    fi
    # 无 systemd 环境的开机重放脚本也要删，否则回退后它仍在开机时跑 sysctl --system。
    # 那时配置文件已删，--system 不会重设本脚本的参数，但留个指向已删配置的孤儿脚本
    # 不符合「可完整回退」；用户自己挂到 wsl.conf / rc.local 的那一行删不掉，明确告知
    if [ -f "$SYSCTL_APPLY" ]; then
        rm -f "$SYSCTL_APPLY"
        # 时间戳一起删：留着会让下次调优误判「脚本还挂着」，而脚本本体已经没了
        rm -f "$SYSCTL_APPLY_STAMP"
        echo -e "  已移除开机重放脚本 $(basename "$SYSCTL_APPLY")"
        if is_wsl && [ -n "$(wsl_boot_command)" ]; then
            case "$(wsl_boot_command)" in
            *"$(basename "$SYSCTL_APPLY")"*)
                echo -e "  ${YELLOW}注意：/etc/wsl.conf 的 [boot] command 里还挂着它，请手动删掉那段${NC}"
                echo -e "  ${YELLOW}      当前: command=$(wsl_boot_command)${NC}"
                ;;
            esac
        fi
    fi

    if [ -f "$GAI_BAK" ]; then
        # 查返回值：回退失败必须让用户知道，否则 IPv4 优先会留在系统里而回退报成功。
        # 「可回退性」是本项目的最高优先级，静默失败的回退等于没有回退
        if ! mv "$GAI_BAK" "$GAI_CONF"; then
            echo -e "  ${RED}还原 $GAI_CONF 失败，IPv4 优先设置可能仍生效${NC}"
            echo -e "  ${YELLOW}手动处理: mv $GAI_BAK $GAI_CONF${NC}"
        fi
    elif [ -f "$GAI_CONF" ]; then
        # 无备份时的降级回退：把那一行注释回去。
        # [人工决策-2026-08-05 17:34:52] 这个分支原先是 sed -i ... 2>/dev/null，
        # 既不查返回值也不回读——失败后函数继续往下报「回退完成」，而 IPv4 优先
        # 实际还留在系统里。有备份的分支已经查了 mv 的返回值，这里要对齐：
        # 回退是本项目的最高优先级，静默失败的回退等于没有回退
        if ! sed -i 's|^precedence ::ffff:0:0/96  100|#precedence ::ffff:0:0/96  100|' "$GAI_CONF" 2>/dev/null; then
            echo -e "  ${RED}改回 $GAI_CONF 失败（只读挂载 / 权限），IPv4 优先仍生效${NC}"
            echo -e "  ${YELLOW}手动处理: 把 $GAI_CONF 里 precedence ::ffff:0:0/96 100 那行前面加 #${NC}"
        elif grep -q '^precedence ::ffff:0:0/96  100' "$GAI_CONF" 2>/dev/null; then
            # sed 返回 0 不等于真改了（某些 busybox sed 版本有此差异），回读确认
            echo -e "  ${RED}sed 返回成功但 $GAI_CONF 里那一行仍未被注释，IPv4 优先仍生效${NC}"
            echo -e "  ${YELLOW}手动处理: 把 $GAI_CONF 里 precedence ::ffff:0:0/96 100 那行前面加 #${NC}"
        fi
    fi

    # 还原被接管的冲突文件。接管清单在首次调优时落盘，这里逐条改回来
    if [ -f "$TAKEOVER_LIST" ]; then
        local tf bak flat
        # 从最早的备份目录取——多次调优会产生多个 takeover-*，第一次那份才是用户的原文件
        local earliest
        earliest=$(ls -d "$STATE_DIR"/takeover-* 2>/dev/null | head -1)
        while IFS= read -r tf; do
            [ -z "$tf" ] && continue
            flat=$(echo "${tf#/}" | tr '/' '_')
            bak=""
            [ -n "$earliest" ] && [ -f "$earliest/$flat" ] && bak="$earliest/$flat"
            # 兼容上一版的备份命名（直接用 basename）
            [ -z "$bak" ] && [ -n "$earliest" ] && [ -f "$earliest/$(basename "$tf")" ] &&
                bak="$earliest/$(basename "$tf")"
            if [ -n "$bak" ]; then
                cat "$bak" >"$tf" 2>/dev/null &&
                    echo -e "  已还原 $tf（从 $bak）"
            else
                # 没有备份就只把我们加的注释标记去掉，把原行恢复出来
                sed -i 's|^# moved to .* by tcp-dashboard: ||' "$tf" 2>/dev/null &&
                    echo -e "  ${YELLOW}$tf 无备份，已就地去掉接管标记恢复原行${NC}"
            fi
            # 旧版整文件禁用的遗留
            [ -f "$tf.disabled-by-tcp-dashboard" ] && [ ! -s "$tf" ] &&
                mv "$tf.disabled-by-tcp-dashboard" "$tf" 2>/dev/null
        done <"$TAKEOVER_LIST"
        rm -f "$TAKEOVER_LIST"
    fi

    # MSS clamp 规则按 comment 标记删除，只动本脚本加的那条——
    # 用户自己加过同样的规则时不会被误删（实测验证过）
    if command -v iptables &>/dev/null; then
        local _mss_n
        _mss_n=$(del_owned_mss)
        [ "${_mss_n:-0}" -gt 0 ] 2>/dev/null &&
            echo -e "  已删除 ${_mss_n} 条本脚本加的 MSS Clamp 规则"
    fi

    # 出向 HTB 整形：文件 + unit + 运行时 qdisc 一并清。
    # 菜单 30 承诺「含 HTB 整形」；只删 sysctl 会把 HTB unit 留在开机重放路径上（审查 #1）。
    if [ -f "$SHAPE_UNIT" ] || [ -x "$SHAPE_SCRIPT" ] || [ -f "$SHAPE_RATE" ]; then
        if has_systemd && [ -f "$SHAPE_UNIT" ]; then
            systemctl disable --now "$(basename "$SHAPE_UNIT")" &>/dev/null
            rm -f "$SHAPE_UNIT"
            systemctl daemon-reload &>/dev/null
        fi
        rm -f "$SHAPE_SCRIPT" "$SHAPE_RATE" "$SWEEP_RESULT"
        _sdev=$(egress_dev)
        if [ -n "$_sdev" ] && command -v tc >/dev/null 2>&1; then
            _sk=$(tc qdisc show dev "$_sdev" 2>/dev/null | awk 'NR==1{print $2}')
            if [ "$_sk" = "htb" ]; then
                if restore_default_qdisc "$_sdev"; then
                    echo -e "  已移除出向整形（HTB）并恢复 qdisc"
                else
                    echo -e "  ${RED}已删整形文件，但 qdisc 恢复失败（$_sdev 可能仍是 htb）${NC}"
                    echo -e "  ${YELLOW}手动检查: tc qdisc show dev $_sdev${NC}"
                fi
            else
                echo -e "  已移除出向整形配置文件"
            fi
        else
            echo -e "  已移除出向整形配置文件"
        fi
    fi

    # 停掉并删除 RPS/XPS 的开机应用单元
    if [ -f "$NIC_UNIT" ]; then
        systemctl disable --now "$(basename "$NIC_UNIT")" &>/dev/null
        rm -f "$NIC_UNIT"
        systemctl daemon-reload &>/dev/null
        echo -e "  已移除开机应用单元 $(basename "$NIC_UNIT")"
    fi
    # networkd-dispatcher 钩子是另一处状态，unit 不存在时也可能留着（用户只跑过菜单 8）
    [ -f "$NIC_DISPATCH" ] && {
        rm -f "$NIC_DISPATCH"
        echo -e "  已移除 networkd-dispatcher 钩子"
    }
    # [人工决策-2026-08-06 14:42:18] 重放脚本与 plan 的清理必须与 unit 解耦。
    # 原先三者一起挂在 `if [ -f "$NIC_UNIT" ]` 下，而无 systemd 机器上 install_nic_unit
    # 走降级路径：只写 nic.plan + nic-apply.sh、压根不装 unit（那个 if 恒假），
    # 于是回退后两者都遗留。且降级提示明确让用户把 nic-apply 挂进 init 流程，
    # 挂了的话下次开机还会继续重放队列设置——与「可完整回退」直接冲突。
    # 顺序在 dispatcher 钩子之后：钩子 exec 的就是这个脚本，先摘钩子再删被执行体
    if [ -f "$NIC_APPLY" ] || [ -f "$NICPLAN" ]; then
        rm -f "$NIC_APPLY" "$NICPLAN"
        echo -e "  已移除网卡重放脚本 $(basename "$NIC_APPLY") 与队列清单"
        # 用户自己挂进启动链的那一行删不掉，明确告知（与 $SYSCTL_APPLY 同一口径）。
        # WSL 的 [boot] command 能逐字读到，所以单独核一遍；其余 init 方式无法可靠判定
        if is_wsl && [ -n "$(wsl_boot_command)" ]; then
            case "$(wsl_boot_command)" in
            *"$(basename "$NIC_APPLY")"*)
                echo -e "  ${YELLOW}注意：/etc/wsl.conf 的 [boot] command 里还挂着它，请手动删掉那段${NC}"
                echo -e "  ${YELLOW}      当前: command=$(wsl_boot_command)${NC}"
                ;;
            esac
        fi
    fi

    # qdisc 的运行时改动要还原。顺序是「先删 plan 再恢复」，否则 dispatcher 钩子
    # 可能在中途被触发、把刚删掉的设置又写回来。
    # 恢复方式是 tc qdisc del 让内核按 default_qdisc 重建，而不是硬编码回落到某个值——
    # 社区脚本常见的「失败就 replace root fq」等于把用户改成另一个状态，不是回退
    if [ -f "$QDISCPLAN" ]; then
        local qdev qwant qroot
        while IFS=$'\t' read -r qdev qwant; do
            [ -n "$qdev" ] && [ -d "/sys/class/net/$qdev" ] || continue
            qroot=$(tc qdisc show dev "$qdev" 2>/dev/null | awk 'NR==1{print $2}')
            case "$qroot" in
            mq | mqprio)
                # 逐叶子 del：内核会按 default_qdisc 给每个队列重建默认 qdisc。
                # 不能对 root 做 del——那会把 mq 本身删掉
                for f in $(tc qdisc show dev "$qdev" 2>/dev/null |
                    awk '/parent/{for(i=1;i<=NF;i++) if($i=="parent") print $(i+1)}'); do
                    tc qdisc del dev "$qdev" parent "$f" 2>/dev/null
                done
                ;;
            '') ;;
            *) tc qdisc del dev "$qdev" root 2>/dev/null ;;
            esac
        done <"$QDISCPLAN"
        rm -f "$QDISCPLAN"
        echo -e "  已还原 qdisc（交回内核按 default_qdisc 重建）"
    fi

    local eth f
    for eth in $(phys_ifaces); do
        for f in /sys/class/net/"$eth"/queues/rx-*/rps_cpus; do [ -f "$f" ] && { echo 0 >"$f"; } 2>/dev/null; done
        for f in /sys/class/net/"$eth"/queues/tx-*/xps_cpus; do [ -f "$f" ] && { echo 0 >"$f"; } 2>/dev/null; done
    done

    # 顺序很关键：先让 sysctl --system 重新应用「还留在系统里的」配置文件
    # （包括刚还原回来的被接管文件），再逐个写回受管 key 的原始运行时值。
    # 反过来做的话 --system 会把刚还原的值又覆盖掉。
    # [人工决策-2026-08-16 22:31:43] 缺 sysctl 时配置文件仍清理（重启后靠内核默认
    # 自愈），但绝不报「回退完成」——运行时值一项都没写回却报成功 = 假回退
    local runtime_ok=1
    if ! apply_sysctl; then
        runtime_ok=0
    fi
    if ! restore_managed_keys; then
        runtime_ok=0
    fi

    if [ "$runtime_ok" = "1" ]; then
        echo -e "${GREEN}回退完成：配置文件已清理，受管参数已逐个还原到首次运行前的值${NC}"
    else
        echo -e "${RED}回退未完整：配置文件已清理，但运行时值未能全部写回${NC}"
        echo -e "  ${YELLOW}请确认 sysctl 可用（包名 procps-ng / procps）后重跑菜单 30，或重启让内核默认接管${NC}"
    fi
    if [ -f "$ORIG_STATE" ]; then
        echo -e "  ${YELLOW}原始值快照保留在 $ORIG_STATE（卸载面板时才删除）${NC}"
    fi
    # 运行时都写不回时，后面的遗留确认也读不到，跳过避免误导
    [ "$runtime_ok" = "1" ] || return 1
    # ip_forward 遗留是安全问题，单独确认一次
    local fw
    fw=$(sysctl -n net.ipv4.ip_forward 2>/dev/null)
    if [ "$fw" = "1" ]; then
        local ofw
        ofw=$(orig_value net.ipv4.ip_forward 2>/dev/null)
        if [ "$ofw" = "1" ]; then
            echo -e "  ${YELLOW}ip_forward 仍为 1，但快照显示本机运行前就是 1，属原状${NC}"
        else
            echo -e "  ${RED}警告: ip_forward 仍为 1 且未能还原，请手动执行 sysctl -w net.ipv4.ip_forward=0${NC}"
            echo -e "  ${RED}      并检查 /etc/sysctl.d/ 与 /etc/sysctl.conf 里是否有别的文件也在设它${NC}"
        fi
    fi
    # IPv6 遗留同样单独确认：用户可能本来就自己关着（那属原状），也可能是本面板
    # 关了却没还原成功。两者要分开说——报错方向搞反会让用户去改本来就该那样的配置
    if ipv6_disabled_now; then
        local o6
        o6=$(orig_value net.ipv6.conf.all.disable_ipv6 2>/dev/null)
        if [ "$o6" = "1" ]; then
            echo -e "  ${YELLOW}IPv6 仍是关闭状态，但快照显示本机运行前就关着，属原状${NC}"
        else
            echo -e "  ${RED}警告: IPv6 仍处于关闭状态且未能还原${NC}"
            echo -e "  ${RED}      手动恢复: sysctl -w net.ipv6.conf.all.disable_ipv6=0（default/lo 同理）${NC}"
            echo -e "  ${RED}      并检查 /etc/sysctl.d/ 与 /etc/sysctl.conf 里是否有别的文件也在设它${NC}"
        fi
    fi
}

# ==================================================
# --- 主菜单 ---
# ==================================================
# 版本检查只在进菜单前做一次（结果存 $LATEST_VERSION，循环里每次刷新直接读）。
# 放进 while 会让每次菜单重绘都走一遍缓存判断，纯属噪音
fetch_latest_version

while true; do
    if [ -f "$GAI_CONF_REAL" ] && grep -q '^precedence ::ffff:0:0/96  100' "$GAI_CONF_REAL"; then
        st_ipv4="${GREEN}[已开启]${NC}"
    else
        st_ipv4="${RED}[未开启]${NC}"
    fi
    # IPv6 三态，与 BBR 同一口径：先判运行时值，再区分「配置在但没生效」。
    # 只看文件在不在会漏掉无 systemd 机器重启后那种最误导人的状态。
    # 第三态还要区分「本来就关着」——用户自己关的不该显示成本面板关的
    # 标题跟着动作变：同一个键在已关闭时走恢复分支，写死「关闭 IPv6」会与实际行为相反
    if ipv6_disabled_now; then
        # 不用绿：绿在本面板表示「优化已开启」；关 IPv6 是关掉协议族，用黄区分
        st_ipv6="${YELLOW}[已关闭]${NC}"
        lb_ipv6="恢复 IPv6"
    elif [ -f "$IPV6_OPT" ]; then
        st_ipv6="${YELLOW}[配置未生效]${NC}"
        lb_ipv6="关闭 IPv6"
    else
        st_ipv6="${RED}[未关闭]${NC}"
        lb_ipv6="关闭 IPv6"
    fi
    # BBR 三态。只判运行时值会漏掉最误导人的那种：配置文件写着 bbr、实际跑 cubic
    # （无 systemd 机器重启后的常态）。那种情况报「未开启」也不对——用户明明设过，
    # 报「配置未生效」才能让他知道要去挂开机重放
    if [ "$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)" = "bbr" ]; then
        st_bbr="${GREEN}[已开启]${NC}"
    elif [ -f "$BBR_OPT" ]; then
        st_bbr="${YELLOW}[配置未生效]${NC}"
    else
        st_bbr="${RED}[未开启]${NC}"
    fi
    # 同理：文件存在不等于参数在跑。sysctl_conf_stale 逐条比对配置与实际生效值，
    # 有任一项不符就标黄，避免「显示已开启、实际全是原值」（判例：WSL2 重启后）
    if [ -f "$SYSCTL_OPT" ]; then
        if grep -q '^net\.ipv4\.ip_forward *= *1' "$SYSCTL_OPT" 2>/dev/null; then
            st_sysctl="${GREEN}[中转档]${NC}"
        else
            st_sysctl="${GREEN}[保守档]${NC}"
        fi
        sysctl_conf_stale "$SYSCTL_OPT" && st_sysctl="${YELLOW}[配置未生效]${NC}"
    else
        st_sysctl="${RED}[未开启]${NC}"
    fi
    # 状态判据必须读真实生效的掩码。以前判 rps_sock_flow_entries==32768：
    # 一是那个值只设了运行时、重启就丢，二是它跟 rps_cpus 到底有没有生效毫无关系
    st_nic="${RED}[未开启]${NC}"
    for _f in /sys/class/net/*/queues/rx-*/rps_cpus; do
        [ -f "$_f" ] || continue
        if mask_is_set "$(cat "$_f" 2>/dev/null)"; then
            # 判「unit 确实 enable 了」而不是「unit 文件存在」——文件是无条件写的，
            # 容器里或 systemctl enable 失败时仍会存在，据此报持久化就是假阳性
            if nic_unit_enabled; then
                st_nic="${GREEN}[已开启+持久化]${NC}"
            else
                st_nic="${YELLOW}[已开启未持久化]${NC}"
            fi
            break
        fi
    done

    # qdisc 状态：判「实际生效的」是否已等于 default_qdisc。
    # 多队列网卡要看叶子而不是 root（root 恒为 mq），否则永远显示不一致。
    # 这里是主循环而非函数体，不能用 local（会报 "local: can only be used in a function"）
    st_qdisc="${YELLOW}[未知]${NC}"
    _qdev=$(egress_dev)
    _qwant=$(sysctl -n net.core.default_qdisc 2>/dev/null)
    if [ -n "$_qdev" ] && [ -n "$_qwant" ]; then
        IFS=$'\t' read -r _qroot _qleaf <<<"$(qdisc_kinds "$_qdev")"
        _qnow=$_qroot
        case "$_qroot" in mq | mqprio) _qnow=$_qleaf ;; esac
        if [ -z "$_qroot" ]; then
            st_qdisc="${YELLOW}[缺 tc 无法判断]${NC}"
        elif [ "$_qnow" = "$_qwant" ]; then
            st_qdisc="${GREEN}[已是 ${_qwant}]${NC}"
        elif [ "$_qnow" = "noqueue" ]; then
            # lo / veth 按内核设计固定 noqueue，不算未生效（容器里出口就是 veth）
            st_qdisc="${GREEN}[noqueue 虚拟设备]${NC}"
        else
            st_qdisc="${YELLOW}[当前 ${_qnow:-?}]${NC}"
        fi
    fi

    # 无 TERM 环境（CI / 非交互）下 clear 会报错，回落到 ANSI 清屏
    clear 2>/dev/null || printf '\033[2J\033[H'
    echo -e "${YELLOW}==================================================${NC}"
    echo -e "${YELLOW}           TCP/UDP 网络调优面板                   ${NC}"
    echo -e "${GREEN}           快捷命令: tcpo   v${SCRIPT_VERSION}${NC}"
    # 有新版只在标题栏提示一行，不自动更新（这台机器可能正在跑业务）
    _hint=$(version_hint)
    [ -n "$_hint" ] && echo -e "           $_hint"
    echo -e "${YELLOW}==================================================${NC}"
    echo -e "  ${CYAN}1. 一键全部优化${NC}  （基础 + 限速扫描/整形）"
    draw_line
    echo -e "  2. IPv4 优先          $st_ipv4"
    echo -e "  3. $lb_ipv6          $st_ipv6"
    echo -e "  4. BBR + FQ           $st_bbr"
    echo -e "  5. 内核参数 · 普通档  $st_sysctl"
    echo -e "  6. 内核参数 · 中转档  $st_sysctl"
    echo -e "  7. 网卡队列/RPS/XPS   $st_nic"
    echo -e "  8. qdisc 立刻生效     $st_qdisc"
    draw_line
    echo -e "  ${CYAN}20. 瓶颈诊断${NC}"
    echo -e "  ${CYAN}21. 调优效果测量${NC}"
    echo -e "  ${CYAN}22. MTU / PMTU 探测${NC}"
    echo -e "  ${CYAN}23. 限速器拐点扫描${NC}"
    echo -e "  ${CYAN}24. 出向整形 开/关${NC}"
    draw_line
    echo -e "  30. 回退全部优化"
    echo -e "  31. 更新脚本"
    echo -e "  32. 卸载面板"
    echo -e "  0. 退出"
    draw_line
    _st_shape="无"
    _st_dev=$(egress_dev)
    if [ -n "$_st_dev" ]; then
        _st_r=$(tc_rate_mbit "$(tc class show dev "$_st_dev" 2>/dev/null)")
        [ -n "$_st_r" ] && _st_shape="${_st_r}M"
    fi
    echo -e "  当前: 算法 ${GREEN}$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)${NC}$(bbr_version_label) | 队列 ${GREEN}$(sysctl -n net.core.default_qdisc 2>/dev/null)${NC} | 整形 ${GREEN}${_st_shape}${NC} | 句柄 ${GREEN}$(ulimit -n)${NC}"
    [ "$DRYRUN" = "1" ] && echo -e "  ${CYAN}[演练模式] 不会写入任何系统文件，退出时打印将写入的内容${NC}"
    draw_line

    # read 失败 = stdin 已 EOF（无 tty 的非交互环境），必须退出，否则 case 落到 * 分支空转刷屏
    read -p "请选择: " opt || {
        echo
        exit 0
    }
    case "$opt" in
    1) run_all ;;
    2) set_ipv4_priority ;;
    # 同一个键既关又开：已关闭时给恢复入口，避免用户为了开回来必须跑菜单 30
    3)
        if ipv6_disabled_now; then
            restore_ipv6
        else
            disable_ipv6
        fi
        ;;
    4) enable_bbr ;;
    5) PROFILE=normal tune_sysctl ;;
    6)
        echo -e "\n${YELLOW}中转档会开启 IP 转发 + MSS Clamp，只有中转/代理/隧道节点需要。${NC}"
        read -p "确认使用中转档？[y/N]: " _c
        [[ "$_c" =~ ^[Yy]$ ]] && PROFILE=transit tune_sysctl || echo -e "${GREEN}已取消。${NC}"
        ;;
    7) tune_nic ;;
    8) apply_live_qdisc ;;
    20) diagnose ;;
    21) measure ;;
    22) probe_mtu ;;
    23) menu_sweep ;;
    24) menu_shape ;;
    30) rollback_tune && pause_return ;;
    31) check_update ;;
    32) uninstall_script ;;
    0)
        dryrun_report
        exit 0
        ;;
    *) echo -e "${RED}输入无效${NC}" && sleep 1 ;;
    esac
done
