🚀 运行: ...
IP: ...
ISP: 检测中...

Debian 一键搭建 sing-box SOCKS5 双栈分流节点(附 sb 菜单管理脚本)

一、这篇讲什么

用 sing-box 搭一个带 IPv4 / IPv6 分流的 SOCKS5 代理,再配一个 sb 菜单管理脚本,一条命令呼出菜单,安装、改规则、换端口密码、看日志全在菜单里完成,不用手写 JSON 配置。

  • 双栈监听:SOCKS5 入站监听 ::,IPv4 和 IPv6 客户端都能连进来。
  • 按域名分流:Netflix、YouTube 等按域名集合强制走 IPv4 或 IPv6 出口,规则写法如 ipv4(geosite:netflix),随时增删改。
  • sb 菜单脚本:安装、卸载、启动、停止、重启、运行状态、分流设置、更改节点信息、查看日志、查看节点信息,共 10 个菜单项,彩色界面。

适用环境:Debian 系统,root 用户运行。分流功能要求 sing-box ≥ 1.12(1.12 移除了旧的 geosite 字段,本脚本改用远程 rule_set 实现,旧版本 sing-box 不可用)。

二、安装步骤

1. 把文末附录的完整脚本保存为 /root/sb.sh(nano /root/sb.sh,粘贴保存),然后运行:

bash /root/sb.sh

2. 在菜单里选 1(安装 sing-box),按提示输入:

  • 监听端口:1–65535,必须手动输入,没有默认值(下面示例用 1080,请换成你自己的端口)。
  • 用户名:默认 socks5,可直接回车沿用。
  • 密码:直接回车自动生成 16 位随机密码,或自己输入。

3. 安装过程会自动完成:安装 / 升级 sing-box(≥ 1.12)、创建 systemd 服务、停用可能冲突的旧 danted、写入默认分流规则(Netflix、YouTube 走 IPv4)、生成配置、检查配置、启动服务。

4. 装完后直接输入 sb 就能呼出菜单(快捷键装在 /usr/local/bin/sb)。新版脚本带自同步:以后直接运行脚本文件时,如果 sb 快捷键不是最新会自动同步,不会再出现新旧菜单不一致。

三、菜单一览

菜单顶端栏只显示两行:运行状态(运行中 / 已停止 / 未安装,附 sing-box 版本)和当前分流规则。端口、用户名、密码不会显示在顶端,要到「10. 查看节点信息」里看。

  • 1. 安装 sing-box:安装或升级 sing-box(≥ 1.12)、创建 systemd 服务、写入默认分流规则并启动服务。
  • 2. 卸载 sing-box:删除 sing-box 程序、二进制和 /etc/sing-box 下全部配置(含节点信息和分流规则),操作前会二次确认。
  • 3. 启动服务:systemctl start sing-box。
  • 4. 停止服务:systemctl stop sing-box。
  • 5. 重启服务:systemctl restart sing-box。
  • 6. 查看运行状态:sing-box 版本、进程 PID、端口监听情况、用户名、分流规则、最近 8 行服务日志。
  • 7. 分流设置:添加、修改、删除、清空分流规则,或查看规则说明;每次改动会自动重新生成配置、经 sing-box check 检查通过后重启生效。
  • 8. 更改节点信息:修改端口、用户名、密码;修改后自动重新生成配置并生效(密码在菜单里只显示为 ********)。
  • 9. 查看日志:三部分——当前分流规则、实时连接快照(当前经 sing-box 的对外连接统计)、最近 30 行服务日志。
  • 10. 查看节点信息:SOCKS5 类型、本机公网 IPv4 / IPv6、端口、用户名、密码、当前分流规则,以及客户端填写说明。

四、分流规则写法

格式:<出口>(<匹配目标>)

  • 出口:ipv4 表示命中的流量强制走 IPv4 出口;ipv6 表示强制走 IPv6 出口。
  • 匹配目标:
    • geosite:分类名:官方维护的域名集合,如 netflix、youtube、google(分类名不区分大小写);
    • domain:域名:指定域名,含其全部子域名;
    • 裸域名:和 domain: 一样,domain: 前缀可省略。

示例:

ipv4(geosite:netflix)      # Netflix 走 IPv4
ipv6(geosite:youtube)      # YouTube 走 IPv6
ipv4(domain:example.com)   # example.com 及其子域名走 IPv4
ipv6(bilibili.com)         # B 站走 IPv6

规则按顺序匹配,命中即走对应出口;都没命中的流量走默认直连。技术实现:geosite 走远程 rule_set(https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-<分类>.srs),分流出口是 direct-v4 / direct-v6,配合 domain_resolver 策略 ipv4_only / ipv6_only 强制解析对应版本的 IP。

五、客户端配置

  • 类型:SOCKS5。
  • 服务器:菜单第 10 项「查看节点信息」里有本机公网 IPv4 和 IPv6,填任意一个。
  • 端口 / 用户名 / 密码:同样在第 10 项里查看(示例端口 1080,换成你自己的)。
  • 必须开启「通过代理解析 DNS」(远程解析):客户端如果自己在本地把域名解析成 IP 再发给代理,服务端就看不到域名,分流规则对这类流量不生效。

用 curl 测试分流时要用 socks5h://(h 表示域名交给代理解析),而不是 socks5://:

curl -x socks5h://用户名:密码@服务器IP:1080 https://ifconfig.me
# 用户名或密码含 @ : / 等特殊字符时,需要先做 URL 编码

六、常用操作

  • 改端口 / 用户名 / 密码:菜单 8。修改后脚本自动重新生成 /etc/sing-box/config.json,经 sing-box check 检查通过后重启服务生效;若检查失败,本次修改不应用,服务保持原配置运行。
  • 改分流规则:菜单 7,添加 / 修改 / 删除 / 清空,或选 5 看规则说明。规则保存在 /etc/sing-box/shunt.rules,每改一次自动走一遍”生成配置 → 检查 → 重启生效”流程。
  • 看日志:菜单 9,一次看清当前分流规则、实时连接快照、最近 30 行服务日志,排查分流是否命中、分流是否生效先看这里。
  • 看运行状态:菜单 6,确认版本、进程、端口监听是否正常。

七、注意事项

  • geosite: 后面的分类名写错(比如拼错成 netflixx),会导致 sing-box check 配置检查失败,本次修改不生效,服务继续用旧配置运行。用菜单 9 查看日志确认服务仍在运行,然后回菜单 7 修正规则。
  • 卸载用菜单 2,会删掉 sing-box 程序、二进制和 /etc/sing-box 下的全部配置(含节点信息和分流规则),操作不可恢复,谨慎执行。
  • 关键文件位置:配置 /etc/sing-box/config.json、节点信息 /etc/sing-box/node.info(600 权限)、分流规则 /etc/sing-box/shunt.rules、快捷键 /usr/local/bin/sb。
  • 安装时如果旧版 danted 占用了端口,脚本会自动停用 danted,避免端口冲突。
  • SOCKS 入站监听 :: 即双栈;如果机器内核开了 net.ipv6.bindv6only=1,IPv4 客户端会连不上,可用 ss -tln 检查监听情况。

八、附:完整脚本 sb-socks.sh

保存为 /root/sb.sh,chmod +x /root/sb.sh 后用 bash /root/sb.sh 运行:

#!/bin/bash
# ============================================================
# sb -- sing-box SOCKS5 分流管理菜单
#
# 菜单:
#   1. 安装 sing-box        2. 卸载 sing-box
#   3. 启动服务   4. 停止服务   5. 重启服务
#   6. 查看运行状态(菜单顶端同时显示状态/分流信息)
#   7. 分流设置(格式如 ipv4(geosite:netflix))
#   8. 更改节点信息(端口 / 用户名 / 密码)
#   9. 查看日志(含分流信息)  10. 查看节点信息
#   0. 退出
#
# 安装后直接输入 sb 即可呼出本菜单。
#
# 分流规则写法:
#   ipv4(geosite:netflix)      Netflix 走 IPv4
#   ipv6(geosite:youtube)      YouTube 走 IPv6
#   ipv4(domain:example.com)   指定域名走 IPv4(含其全部子域名)
#   ipv6(example.com)          裸域名同样按子域名匹配,走 IPv6
# ============================================================

SB_DIR="/etc/sing-box"
CONF="$SB_DIR/config.json"
NODE_INFO="$SB_DIR/node.info"
RULES_FILE="$SB_DIR/shunt.rules"
GEO_BASE_URL="https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set"

DEF_PORT="1080"
DEF_USER="socks5"

# ------------------------- 颜色 -------------------------
# 终端美化:标题青色、成功绿色、错误红色、警告黄色、序号黄色
if [ -t 1 ]; then
    C_RED=$'\033[0;31m'
    C_GREEN=$'\033[0;32m'
    C_YELLOW=$'\033[0;33m'
    C_CYAN=$'\033[0;36m'
    C_BOLD=$'\033[1m'
    C_RESET=$'\033[0m'
else
    C_RED=''; C_GREEN=''; C_YELLOW=''; C_CYAN=''; C_BOLD=''; C_RESET=''
fi

# ------------------------- 基础工具 -------------------------

pause() { read -rp "按回车返回..." _; }

# JSON 字符串转义(处理 " 和 \)
json_escape() {
    printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}

trim() {
    printf '%s' "$1" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
}

# ------------------------- 节点信息 -------------------------

load_node() {
    PORT="$DEF_PORT"
    USERNAME="$DEF_USER"
    PASSWORD=""
    [ -f "$NODE_INFO" ] || return 0
    while IFS='=' read -r k v || [ -n "$k" ]; do
        case "$k" in
            PORT)     PORT="$v" ;;
            USERNAME) USERNAME="$v" ;;
            PASSWORD) PASSWORD="$v" ;;
        esac
    done < "$NODE_INFO"
}

save_node() {
    mkdir -p "$SB_DIR"
    {
        printf 'PORT=%s\n' "$PORT"
        printf 'USERNAME=%s\n' "$USERNAME"
        printf 'PASSWORD=%s\n' "$PASSWORD"
    } > "$NODE_INFO"
    chmod 600 "$NODE_INFO"
}

# ------------------------- sing-box -------------------------

sb_version() {
    command -v sing-box >/dev/null 2>&1 || return 1
    sing-box version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1
}

# ver_ge 需要版本 当前版本:当前 >= 需要 时返回 0
ver_ge() {
    [ "$1" = "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" ]
}

is_running() { pgrep -x sing-box >/dev/null 2>&1; }

need_installed() {
    if ! command -v sing-box >/dev/null 2>&1; then
        echo "sing-box 未安装,请先选「1. 安装 sing-box」"
        return 1
    fi
    return 0
}

svc_exists() {
    [ -f /etc/systemd/system/sing-box.service ] || [ -f /lib/systemd/system/sing-box.service ]
}

# ------------------------- 配置生成 -------------------------

# 根据节点信息 + 分流规则生成 $CONF,成功返回 0
build_config() {
    load_node
    [ -n "$PASSWORD" ] || { echo "错误:密码为空,请先在「8. 更改节点信息」里设置密码"; return 1; }
    if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
        echo "错误:端口无效:$PORT"; return 1
    fi

    local rule_sets="" rules=""

    if [ -f "$RULES_FILE" ]; then
        while IFS= read -r raw || [ -n "$raw" ]; do
            local line
            line="$(trim "$raw")"
            [ -z "$line" ] && continue
            case "$line" in \#*) continue ;; esac

            if [[ "$line" =~ ^(ipv4|ipv6)\((.+)\)$ ]]; then
                local ver="${BASH_REMATCH[1]}"
                local inner
                inner="$(trim "${BASH_REMATCH[2]}")"
                local outbound="direct-v4"
                [ "$ver" = "ipv6" ] && outbound="direct-v6"
                local matcher=""

                if [[ "$inner" =~ ^[Gg][Ee][Oo][Ss][Ii][Tt][Ee]:(.+)$ ]]; then
                    local cat
                    cat="$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')"
                    cat="$(trim "$cat")"
                    if [ -z "$cat" ]; then
                        echo "警告:跳过空的 geosite 规则:$line"
                        continue
                    fi
                    local tag url rs
                    tag="rs-${ver}-geosite-${cat}"
                    url="${GEO_BASE_URL}/geosite-${cat}.srs"
                    rs="$(printf '{"type": "remote", "tag": "%s", "format": "binary", "url": "%s", "download_detour": "direct"}' "$tag" "$url")"
                    if [ -z "$rule_sets" ]; then rule_sets="$rs"; else rule_sets="$rule_sets, $rs"; fi
                    matcher="$(printf '"rule_set": ["%s"]' "$tag")"
                elif [[ "$inner" =~ ^[Dd][Oo][Mm][Aa][Ii][Nn]:(.+)$ ]]; then
                    local dom
                    dom="$(json_escape "$(trim "${BASH_REMATCH[1]}")")"
                    matcher="$(printf '"domain_suffix": ["%s"]' "$dom")"
                else
                    local dom
                    dom="$(json_escape "$inner")"
                    matcher="$(printf '"domain_suffix": ["%s"]' "$dom")"
                fi

                local rule
                rule="$(printf '{"action": "route", %s, "outbound": "%s"}' "$matcher" "$outbound")"
                if [ -z "$rules" ]; then rules="$rule"; else rules="$rules, $rule"; fi
            else
                echo "警告:跳过无法识别的规则:$line"
            fi
        done < "$RULES_FILE"
    fi

    local esc_user esc_pass
    esc_user="$(json_escape "$USERNAME")"
    esc_pass="$(json_escape "$PASSWORD")"

    mkdir -p "$SB_DIR"
    {
        printf '{\n'
        printf '  "log": { "level": "info" },\n'
        printf '  "dns": {\n'
        printf '    "servers": [\n'
        printf '      { "type": "local", "tag": "dns-local" }\n'
        printf '    ]\n'
        printf '  },\n'
        printf '  "inbounds": [\n'
        printf '    {\n'
        printf '      "type": "socks",\n'
        printf '      "tag": "socks-in",\n'
        printf '      "listen": "::",\n'
        printf '      "listen_port": %s,\n' "$PORT"
        printf '      "users": [\n'
        printf '        { "username": "%s", "password": "%s" }\n' "$esc_user" "$esc_pass"
        printf '    }\n'
        printf '  ],\n'
        printf '  "outbounds": [\n'
        printf '    { "type": "direct", "tag": "direct" },\n'
        printf '    {\n'
        printf '      "type": "direct",\n'
        printf '      "tag": "direct-v4",\n'
        printf '      "domain_resolver": { "server": "dns-local", "strategy": "ipv4_only" }\n'
        printf '    },\n'
        printf '    {\n'
        printf '      "type": "direct",\n'
        printf '      "tag": "direct-v6",\n'
        printf '      "domain_resolver": { "server": "dns-local", "strategy": "ipv6_only" }\n'
        printf '    }\n'
        printf '  ],\n'
        printf '  "route": {\n'
        printf '    "rule_set": [\n'
        printf '      %s\n' "$rule_sets"
        printf '    ],\n'
        printf '    "rules": [\n'
        printf '      %s\n' "$rules"
        printf '    ],\n'
        printf '    "final": "direct",\n'
        printf '    "auto_detect_interface": true\n'
        printf '  }\n'
        printf '}\n'
    } > "$CONF"
    chmod 600 "$CONF"
    echo "配置已生成:$CONF"
}

# 生成配置 + 检查 + 生效(运行中则重启服务)
apply_config() {
    build_config || return 1
    if ! command -v sing-box >/dev/null 2>&1; then
        echo "sing-box 未安装,配置已保存,安装完成后自动生效"
        return 0
    fi
    local check_out
    if ! check_out="$(sing-box check -c "$CONF" 2>&1)"; then
        echo "配置检查失败,未应用:"
        printf '%s\n' "$check_out"
        return 1
    fi
    echo "配置检查通过"
    if is_running; then
        systemctl restart sing-box
        sleep 1
        if is_running; then
            echo "服务已重启,新配置生效"
        else
            echo "服务重启后未运行,查看日志:journalctl -u sing-box -n 30"
            return 1
        fi
    else
        echo "配置已保存(服务未运行,启动后生效)"
    fi
}

# ------------------------- 安装 / 卸载 -------------------------

ensure_service() {
    if svc_exists; then
        return 0
    fi
    local bin
    bin="$(command -v sing-box)" || { echo "找不到 sing-box 可执行文件"; return 1; }
    cat > /etc/systemd/system/sing-box.service <<EOF
[Unit]
Description=sing-box service
After=network.target

[Service]
ExecStart=${bin} run -c ${CONF}
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF
    systemctl daemon-reload
    echo "已创建 systemd 服务:sing-box.service"
}

install_sb_cmd() {
    local me
    me="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")"
    if [ "$me" != "/usr/local/bin/sb" ]; then
        cp -f "$me" /usr/local/bin/sb
        chmod +x /usr/local/bin/sb
        hash -r 2>/dev/null
        echo "快捷键已安装:以后直接输入 sb 即可呼出菜单"
    fi
}

do_install() {
    printf '%s---------- 安装 sing-box ----------%s\n' "$C_BOLD" "$C_RESET"

    command -v curl >/dev/null 2>&1 || {
        echo "正在安装 curl..."
        apt update && apt install -y curl || { echo "curl 安装失败"; return 1; }
    }

    # 1. 安装 / 升级 sing-box(分流功能需要 >= 1.12)
    local cur_ver
    cur_ver="$(sb_version || true)"
    if [ -n "$cur_ver" ]; then
        echo "检测到已安装版本:$cur_ver"
        if ver_ge "1.12.0" "$cur_ver"; then
            echo "版本满足要求(>= 1.12),跳过安装"
        else
            echo "版本过旧(需要 >= 1.12),正在升级..."
            bash <(curl -fsSL https://sing-box.app/install.sh) || { echo "升级失败,请检查网络"; return 1; }
        fi
    else
        echo "正在安装 sing-box(官方脚本)..."
        bash <(curl -fsSL https://sing-box.app/install.sh) || { echo "安装失败,请检查网络"; return 1; }
    fi
    echo "当前版本:$(sb_version)"

    # 2. 确保 systemd 服务存在
    ensure_service || return 1

    # 3. 停用旧的 danted,避免 1080 端口冲突
    if systemctl cat danted >/dev/null 2>&1; then
        systemctl stop danted 2>/dev/null
        systemctl disable danted 2>/dev/null
        echo "已停用旧的 danted(释放端口)"
    fi

    # 4. 安装 sb 快捷键
    install_sb_cmd

    # 5. 节点信息(端口必须由用户输入,不设默认值)
    load_node
    local input
    while true; do
        read -rp "监听端口(1-65535): " input
        if [[ "$input" =~ ^[0-9]+$ ]] && [ "$input" -ge 1 ] && [ "$input" -le 65535 ]; then
            PORT="$input"
            break
        fi
        echo "端口无效,请重新输入"
    done
    read -rp "用户名 [${USERNAME}]: " input
    [ -n "$input" ] && USERNAME="$input"
    read -rsp "密码(留空则自动生成强密码): " input; echo
    if [ -z "$input" ]; then
        PASSWORD="$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
        echo "已生成随机密码:$PASSWORD"
        echo "请记好,客户端连接需要用它"
    else
        PASSWORD="$input"
    fi
    save_node

    # 6. 默认分流规则
    if [ ! -s "$RULES_FILE" ]; then
        cat > "$RULES_FILE" <<'EOF'
ipv4(geosite:netflix)
ipv4(geosite:youtube)
EOF
        echo "已写入默认分流规则(Netflix / YouTube 走 IPv4),可在「分流设置」中修改"
    fi

    # 7. 生成配置、检查、启动
    apply_config || return 1
    systemctl enable sing-box >/dev/null 2>&1
    if ! is_running; then
        systemctl start sing-box
        sleep 1
    fi
    if is_running; then
        echo ""
        echo "安装完成,服务运行中"
        echo "客户端填写:服务器=本机公网IP  端口=$PORT  用户名=$USERNAME  类型=SOCKS5"
        echo "注意:客户端需开启「通过代理解析 DNS」(远程解析),否则分流规则不生效"
    else
        echo "服务启动失败,查看日志:journalctl -u sing-box -n 30"
        return 1
    fi
}

do_uninstall() {
    printf '%s---------- 卸载 sing-box ----------%s\n' "$C_BOLD" "$C_RESET"
    local c
    read -rp "将删除 sing-box 程序、二进制、全部配置,确定吗?(y/N): " c
    case "$c" in
        y|Y) ;;
        *) echo "已取消"; return 0 ;;
    esac
    systemctl stop sing-box 2>/dev/null
    systemctl disable sing-box 2>/dev/null
    rm -f /etc/systemd/system/sing-box.service /lib/systemd/system/sing-box.service
    systemctl daemon-reload 2>/dev/null
    local bin
    bin="$(command -v sing-box 2>/dev/null || true)"
    [ -n "$bin" ] && rm -f "$bin"
    rm -rf "$SB_DIR"
    rm -f /usr/local/bin/sb
    echo "卸载完成"
}

# ------------------------- 状态显示 -------------------------

list_rules() {
    if [ ! -s "$RULES_FILE" ]; then
        echo "  (无,全部直连)"
        return 0
    fi
    local n=0 line
    while IFS= read -r line || [ -n "$line" ]; do
        line="$(trim "$line")"
        [ -z "$line" ] && continue
        case "$line" in \#*) continue ;; esac
        n=$((n + 1))
        echo "  [$n] $line"
    done < "$RULES_FILE"
    [ "$n" -eq 0 ] && echo "  (无,全部直连)"
}

# 菜单顶端 compact 状态栏
show_header() {
    load_node
    local st st_color ver
    if ! command -v sing-box >/dev/null 2>&1; then
        st="未安装"; st_color="$C_YELLOW"
    elif is_running; then
        st="运行中"; st_color="$C_GREEN"
    else
        st="已停止"; st_color="$C_RED"
    fi
    ver="$(sb_version || echo "-")"
    echo "=============================================="
    printf '  %s%ssb · sing-box SOCKS5 分流管理菜单%s\n' "$C_BOLD" "$C_CYAN" "$C_RESET"
    echo "----------------------------------------------"
    printf '  状态:%s%s%s(sing-box %s)\n' "$st_color" "$st" "$C_RESET" "$ver"
    printf '  分流:'
    if [ -s "$RULES_FILE" ]; then
        local r
        r="$(grep -v '^[[:space:]]*$' "$RULES_FILE" | grep -v '^[[:space:]]*#' | tr '\n' ' ' | sed 's/ *$//')"
        if [ -n "$r" ]; then printf '%s%s%s\n' "$C_GREEN" "$r" "$C_RESET"; else echo "无(全部直连)"; fi
    else
        echo "无(全部直连)"
    fi
    echo "=============================================="
}

show_status() {
    load_node
    echo "---------- 运行状态 ----------"
    echo "sing-box 版本:$(sb_version || echo '未安装')"
    if is_running; then
        echo "服务状态:运行中(PID: $(pgrep -x sing-box | tr '\n' ' '))"
    else
        echo "服务状态:未运行"
    fi
    if ss -tln 2>/dev/null | grep -q ":${PORT} "; then
        echo "端口监听:${PORT} 正常"
    else
        echo "端口监听:${PORT} 未监听"
    fi
    echo "用户名:$USERNAME"
    echo "分流规则:"
    list_rules
    echo "---------- 最近日志 ----------"
    journalctl -u sing-box --no-pager -n 8 2>/dev/null | tail -8 || echo "(无日志)"
}

# 查看日志(含分流信息:当前规则 + 实时连接 + 服务日志)
view_logs() {
    load_node
    echo ""
    printf '%s---------- 查看日志 ----------%s\n' "$C_BOLD" "$C_RESET"
    printf '%s【当前分流规则】%s\n' "$C_CYAN" "$C_RESET"
    list_rules
    echo ""
    printf '%s【实时连接快照】%s\n' "$C_CYAN" "$C_RESET"
    if is_running; then
        local conns
        conns="$(ss -tnp 2>/dev/null | grep 'sing-box' | awk '{print $5}' | grep -v '^127\.' | grep -v '^\[::1\]' | sort | uniq -c | sort -rn | head -20)"
        if [ -n "$conns" ]; then
            printf '%s\n' "$conns" | while read -r cnt dst; do
                printf '  %sx  %s\n' "$cnt" "$dst"
            done
        else
            echo "  当前无对外活动连接"
        fi
    else
        echo "  服务未运行"
    fi
    echo ""
    printf '%s【服务日志(最近 30 行)】%s\n' "$C_CYAN" "$C_RESET"
    journalctl -u sing-box --no-pager -n 30 2>/dev/null || echo "(无日志)"
    pause
}

# 查看节点信息(连接客户端所需的全部信息)
view_node_info() {
    load_node
    echo ""
    printf '%s---------- 查看节点信息 ----------%s\n' "$C_BOLD" "$C_RESET"
    printf '%s分流规则:%s\n' "$C_CYAN" "$C_RESET"
    list_rules
    echo ""
    echo "客户端填写:类型 SOCKS5,服务器填上面任一 IP,端口/用户名/密码如上,"
    echo "并开启「通过代理解析 DNS」(远程解析),否则分流规则不生效。"
    pause
}

# ------------------------- 分流设置 -------------------------

shunt_menu() {
    while true; do
        echo ""
        printf '%s---------- 分流设置 ----------%s\n' "$C_BOLD" "$C_RESET"
        echo "当前规则:"
        list_rules
        echo "------------------------------"
        printf '%s格式:ipv4(geosite:netflix) / ipv6(domain:example.com)%s\n' "$C_CYAN" "$C_RESET"
        printf '%s      选 5 查看详细规则说明%s\n' "$C_CYAN" "$C_RESET"
        printf '  %s1.%s 添加规则\n' "$C_YELLOW" "$C_RESET"
        printf '  %s2.%s 修改规则\n' "$C_YELLOW" "$C_RESET"
        printf '  %s3.%s 删除规则\n' "$C_YELLOW" "$C_RESET"
        printf '  %s4.%s 清空规则\n' "$C_YELLOW" "$C_RESET"
        printf '  %s5.%s 规则说明\n' "$C_YELLOW" "$C_RESET"
        printf '  %s0.%s 返回\n' "$C_YELLOW" "$C_RESET"
        local c
        read -rp "请选择: " c
        case "$c" in
            1) shunt_add ;;
            2) shunt_edit ;;
            3) shunt_del ;;
            4) shunt_clear ;;
            5) shunt_help ;;
            0) return 0 ;;
            *) echo "无效选择" ;;
        esac
    done
}

shunt_add() {
    local rule
    read -rp "输入规则(如 ipv4(geosite:netflix)): " rule
    rule="$(trim "$rule")"
    if [[ "$rule" =~ ^(ipv4|ipv6)\(.+\)$ ]]; then
        mkdir -p "$SB_DIR"
        touch "$RULES_FILE"
        if grep -qxF "$rule" "$RULES_FILE" 2>/dev/null; then
            echo "规则已存在"
        else
            printf '%s\n' "$rule" >> "$RULES_FILE"
            echo "已添加:$rule"
            apply_config
        fi
    else
        echo "格式错误!应为 ipv4(...) 或 ipv6(...),例如:ipv4(geosite:netflix)"
    fi
    pause
}

shunt_edit() {
    [ -s "$RULES_FILE" ] || { echo "没有可修改的规则"; pause; return 0; }
    local -a items=()
    local line
    while IFS= read -r line || [ -n "$line" ]; do
        line="$(trim "$line")"
        [ -z "$line" ] && continue
        case "$line" in \#*) continue ;; esac
        items+=("$line")
    done < "$RULES_FILE"
    [ "${#items[@]}" -eq 0 ] && { echo "没有可修改的规则"; pause; return 0; }

    local i
    for i in "${!items[@]}"; do
        echo "  [$((i + 1))] ${items[$i]}"
    done
    local n
    read -rp "输入要修改的序号(0 取消): " n
    if [[ "$n" =~ ^[0-9]+$ ]] && [ "$n" -ge 1 ] && [ "$n" -le "${#items[@]}" ]; then
        local old="${items[$((n - 1))]}"
        echo "当前规则:$old"
        local new
        read -rp "输入新规则: " new
        new="$(trim "$new")"
        if [[ "$new" =~ ^(ipv4|ipv6)\(.+\)$ ]]; then
            if [ "$new" != "$old" ] && grep -qxF "$new" "$RULES_FILE" 2>/dev/null; then
                echo "该规则已存在"
            else
                TARGET_OLD="$old" TARGET_NEW="$new" awk '
                    BEGIN { done = 0; o = ENVIRON["TARGET_OLD"]; n = ENVIRON["TARGET_NEW"] }
                    {
                        line = $0
                        t = line
                        gsub(/^[ \t]+|[ \t]+$/, "", t)
                        if (t == o && done == 0) { done = 1; print n; next }
                        print
                    }' "$RULES_FILE" > "$RULES_FILE.tmp" && mv "$RULES_FILE.tmp" "$RULES_FILE"
                echo "已修改:$old -> $new"
                apply_config
            fi
        else
            echo "格式错误!应为 ipv4(...) 或 ipv6(...),例如:ipv4(geosite:netflix)"
        fi
    elif [ "$n" = "0" ]; then
        echo "已取消"
    else
        echo "序号无效"
    fi
    pause
}

shunt_help() {
    echo ""
    printf '%s---------- 分流规则说明 ----------%s\n' "$C_BOLD" "$C_RESET"
    echo "写法:<出口>(<匹配目标>)"
    echo ""
    printf '%s出口(流量走哪个 IP 版本出去):%s\n' "$C_CYAN" "$C_RESET"
    echo "  ipv4   命中的流量强制走 IPv4 出口"
    echo "  ipv6   命中的流量强制走 IPv6 出口"
    echo ""
    printf '%s匹配目标(哪些网站命中):%s\n' "$C_CYAN" "$C_RESET"
    echo "  geosite:分类名   官方维护的域名集合,如 netflix / youtube / google"
    echo "  domain:域名      指定域名(含其全部子域名)"
    echo "  裸域名           同 domain:,domain: 可省略"
    echo ""
    printf '%s示例:%s\n' "$C_CYAN" "$C_RESET"
    echo "  ipv4(geosite:netflix)      Netflix 走 IPv4"
    echo "  ipv6(geosite:youtube)      YouTube 走 IPv6"
    echo "  ipv4(domain:example.com)   example.com 及其子域名走 IPv4"
    echo "  ipv6(bilibili.com)         B站走 IPv6"
    echo ""
    echo "注意:"
    echo "  - 规则按顺序匹配,命中即走对应出口;都没命中的走默认直连"
    echo "  - geosite 分类名不区分大小写;写错分类名会导致配置检查失败、"
    echo "    本次修改不生效(用「查看日志」确认服务仍在运行)"
    echo "  - 客户端须开启「通过代理解析 DNS」,否则规则不生效"
    pause
}

shunt_del() {
    [ -s "$RULES_FILE" ] || { echo "没有可删除的规则"; pause; return 0; }
    local -a items=()
    local line
    while IFS= read -r line || [ -n "$line" ]; do
        line="$(trim "$line")"
        [ -z "$line" ] && continue
        case "$line" in \#*) continue ;; esac
        items+=("$line")
    done < "$RULES_FILE"
    [ "${#items[@]}" -eq 0 ] && { echo "没有可删除的规则"; pause; return 0; }

    local i
    for i in "${!items[@]}"; do
        echo "  [$((i + 1))] ${items[$i]}"
    done
    local n
    read -rp "输入要删除的序号(0 取消): " n
    if [[ "$n" =~ ^[0-9]+$ ]] && [ "$n" -ge 1 ] && [ "$n" -le "${#items[@]}" ]; then
        local target="${items[$((n - 1))]}"
        TARGET="$target" awk '
            BEGIN { done = 0; t = ENVIRON["TARGET"] }
            {
                line = $0
                gsub(/^[ \t]+|[ \t]+$/, "", line)
                if (line == t && done == 0) { done = 1; next }
                print
            }' "$RULES_FILE" > "$RULES_FILE.tmp" && mv "$RULES_FILE.tmp" "$RULES_FILE"
        echo "已删除:$target"
        apply_config
    elif [ "$n" = "0" ]; then
        echo "已取消"
    else
        echo "序号无效"
    fi
    pause
}

shunt_clear() {
    local c
    read -rp "清空全部规则(之后所有流量直连),确定吗?(y/N): " c
    case "$c" in
        y|Y)
            : > "$RULES_FILE"
            echo "已清空"
            apply_config
            ;;
        *) echo "已取消" ;;
    esac
    pause
}

# ------------------------- 节点信息 -------------------------

node_menu() {
    while true; do
        load_node
        echo ""
        printf '%s---------- 更改节点信息 ----------%s\n' "$C_BOLD" "$C_RESET"
        echo "  当前端口:$PORT"
        echo "  当前用户名:$USERNAME"
        if [ -n "$PASSWORD" ]; then
            echo "  当前密码:********(已设置)"
        else
            echo "  当前密码:(未设置)"
        fi
        echo "----------------------------------"
        echo "  1. 更改端口"
        echo "  2. 更改用户名"
        echo "  3. 更改密码"
        echo "  0. 返回"
        local c input
        read -rp "请选择: " c
        case "$c" in
            1)
                read -rp "新端口 [$PORT]: " input
                if [ -z "$input" ]; then
                    echo "未更改"
                elif [[ "$input" =~ ^[0-9]+$ ]] && [ "$input" -ge 1 ] && [ "$input" -le 65535 ]; then
                    PORT="$input"; save_node; echo "端口已改为 $PORT"; apply_config
                else
                    echo "端口无效"
                fi
                pause ;;
            2)
                read -rp "新用户名 [$USERNAME]: " input
                if [ -n "$input" ]; then
                    USERNAME="$input"; save_node; echo "用户名已改为 $USERNAME"; apply_config
                else
                    echo "未更改"
                fi
                pause ;;
            3)
                read -rsp "新密码: " input; echo
                if [ -n "$input" ]; then
                    PASSWORD="$input"; save_node; echo "密码已更新"; apply_config
                else
                    echo "密码不能为空,未更改"
                fi
                pause ;;
            0) return 0 ;;
            *) echo "无效选择"; pause ;;
        esac
    done
}

# ------------------------- 主菜单 -------------------------

main_menu() {
    while true; do
        echo ""
        show_header
        printf '  %s1.%s 安装 sing-box\n' "$C_YELLOW" "$C_RESET"
        printf '  %s2.%s 卸载 sing-box\n' "$C_YELLOW" "$C_RESET"
        printf '  %s3.%s 启动服务\n' "$C_YELLOW" "$C_RESET"
        printf '  %s4.%s 停止服务\n' "$C_YELLOW" "$C_RESET"
        printf '  %s5.%s 重启服务\n' "$C_YELLOW" "$C_RESET"
        printf '  %s6.%s 查看运行状态\n' "$C_YELLOW" "$C_RESET"
        printf '  %s7.%s 分流设置\n' "$C_YELLOW" "$C_RESET"
        printf '  %s8.%s 更改节点信息\n' "$C_YELLOW" "$C_RESET"
        printf '  %s9.%s 查看日志\n' "$C_YELLOW" "$C_RESET"
        printf '  %s10.%s 查看节点信息\n' "$C_YELLOW" "$C_RESET"
        printf '  %s0.%s 退出\n' "$C_YELLOW" "$C_RESET"
        echo "=============================================="
        local c
        read -rp "请选择: " c
        case "$c" in
            1) do_install; pause ;;
            2) do_uninstall; pause ;;
            3)
                need_installed || { pause; continue; }
                systemctl start sing-box 2>/dev/null && echo "已启动" || echo "启动失败"
                pause ;;
            4)
                need_installed || { pause; continue; }
                systemctl stop sing-box 2>/dev/null && echo "已停止" || echo "停止失败"
                pause ;;
            5)
                need_installed || { pause; continue; }
                systemctl restart sing-box 2>/dev/null && echo "已重启" || echo "重启失败"
                pause ;;
            6)
                need_installed || { pause; continue; }
                show_status; pause ;;
            7) shunt_menu ;;
            8) node_menu ;;
            9)
                need_installed || { pause; continue; }
                view_logs ;;
            10)
                need_installed || { pause; continue; }
                view_node_info ;;
            0) echo "再见"; exit 0 ;;
            *) echo "无效选择"; pause ;;
        esac
    done
}

# ------------------------- 入口 -------------------------
[ "$(id -u)" -eq 0 ] || { echo "请用 root 用户运行(或加 sudo)"; exit 1; }

# 自同步快捷键:直接运行脚本文件时,若 /usr/local/bin/sb 已存在但内容不是最新,
# 自动同步为当前版本,避免 sb 命令与脚本文件新旧不一致
sync_sb_cmd() {
    local me
    me="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")"
    if [ "$me" != "/usr/local/bin/sb" ] && [ -f /usr/local/bin/sb ]; then
        if ! cmp -s "$me" /usr/local/bin/sb 2>/dev/null; then
            cp -f "$me" /usr/local/bin/sb
            chmod +x /usr/local/bin/sb
        fi
    fi
}
sync_sb_cmd

main_menu