轻量级 sing-box 流量分流管理器

· 50,332 字 · 约 126 分钟
0

SBRoute — 轻量级 sing-box 流量分流管理器

熟悉 sing-box 的朋友都知道,它的功能极其强大,但全 JSON 格式的配置文件对于经常需要修改节点和路由规则的用户来说,手写体验并不友好。为了解决这个痛点,我编写/整理了 SBRoute —— 一个适用于 Debian 系统的轻量级 VPS 流量分流管理脚本。

这篇文章将带你深入拆解 sbroute.sh 的底层逻辑,看看它是如何用区区一千多行 Shell 代码,优雅地管理 sing-box 复杂路由的。

🌟 核心设计理念

SBRoute 的核心设计思想是“配置碎片化与动态编译”“服务隔离”

  1. 服务隔离:脚本并没有去动系统原有的 sing-box 服务,而是创建了一个完全独立的 Systemd 服务 sbroute.service。这意味你可以把它当作一个旁路分流工具,或者与现有的服务共存,互不干扰。
  2. 碎片化配置:抛弃了维护单一庞大 config.json 的做法。脚本在 /etc/sbroute/ 目录下维护了四个独立的小型 JSON 文件:
    • settings.json:全局设置(DNS模式、默认出站等)
    • outbounds.json:存储所有节点(Shadowsocks, VLESS, VMess 等)
    • routes.json:存储分流规则(按域名、IP、进程等)
    • rulesets.json:管理外部的 Rule Set(如 GeoIP、GeoSite 规则集)
  3. 动态编译 (generate_config):每次应用配置时,脚本会利用 jq 工具,将上述四个文件动态拼装,最终“编译”出符合 sing-box 标准的完整 config.json

🚀 核心功能代码拆解

1. 极其便利的分享链接解析 (_parse_vless_url, _parse_ss_url)

给命令行工具添加节点最烦的就是挨个输入参数。SBRoute 实现了直接解析 ss://vless:// 分享链接的功能!

在代码中,_parse_vless_url 函数通过字符串截取和解码,精妙地将分享链接拆解:

Bash/Shell
# 提取 uuid@server:port
local uuid="${body%%@*}"
local server_part="${body##*@}"

# 解析复杂的 query 参数 (传输层、TLS、Reality 等)
_parse_query "$query"
local security="${params[security]:-}"
local sni="${params[sni]:-}"
local fp="${params[fp]:-}"
local pbk="${params[pbk]:-}"
# ... 结合 jq 动态生成 VLESS outbounds JSON 对象

有了它,你可以直接运行 sbroute out url "vless://...",脚本会自动完成所有的 JSON 组装。

2. 强大的路由规则组合 (route_add)

在路由部分,脚本提供了堪比专业 GUI 客户端的灵活性。支持通过传参的方式组合规则。尤其是对 GeoIPGeoSite 的支持非常智能:

Bash/Shell
--geoip)
    shift;
    # 自动补充 srs 格式的规则集下载链接
    local geo_tag="geoip-${_geo_name}"
    local geo_url="https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-${_geo_name}.srs"
    _add_ruleset_if_missing "$geo_tag" "$geo_url" "binary"
    rule=$(echo "$rule" | jq --arg rs "$geo_tag" 'if .rule_set then .rule_set += [$rs] else . + {rule_set:[$rs]} end')
    ;;

当你输入 --geoip cn 时,脚本不仅会添加路由规则,还会自动在 rulesets.json 中配置好对应的 SagerNet/sing-geoip 远程规则集,非常省心。

3. 配置生成引擎 (generate_config)

这是整个脚本的灵魂函数。它使用 jq 的强悍能力,将各个配置块粘合在一起。

Bash/Shell
# 组装完整配置
local config
config=$(jq -n \
    --argjson dns "$dns_config" \
    --argjson inb "$inbounds" \
    --argjson out "$all_outs" \
    --argjson rules "$all_rules" \
    --argjson rsets "$rule_sets" \
    --arg final "$default_out" \
    '{
        log:{level:"warn",timestamp:true},
        dns:$dns,
        inbounds:$inb,
        outbounds:$out,
        route:{
            rules:$rules,
            rule_set:$rsets,
            final:$final,
            auto_detect_interface:true,
            default_domain_resolver:"google-dns"
        }
    }')

通过这种方式,脚本彻底隔离了人类输入和机器执行的鸿沟。无论你怎么折腾路由和节点,最终生成的 config.json 永远是严格符合规范的。

4. TUI 交互式菜单与 CLI 完美融合

脚本提供了一个美观的命令行 UI。如果你直接执行 sbroute(不带任何参数),它会进入 interactive_menu() 函数,渲染一个彩色控制台菜单。
但如果你喜欢脚本化操作,它同样支持类似于 kubectlgit 的子命令模式,非常适合集成到自动化的部署流水线中。

💡 典型使用场景示例

想要向读者展示它的便利性,看这几个典型的操作就明白了:

场景一:添加一个 VLESS 节点并作为默认出口

Bash/Shell
# 直接粘贴分享链接
sbroute out add url "vless://[email protected]:443?security=reality..."
# 假设生成的 tag 叫 node1,设置为默认出站
sbroute route default node1

场景二:设置国内外分流

Bash/Shell
# 一键应用国内直连预设(国内 IP 和域名直连,其余走默认节点)
sbroute preset cn-direct

场景三:特定域名或 IP 走直连或拦截

Bash/Shell
# 拦截所有广告
sbroute preset ads-block

# 让指定的域名和 IP 走直连 (direct)
sbroute route add direct --domain openai.com --ip-cidr 8.8.8.8/32

最后,运行 sbroute apply,脚本就会自动合并配置、校验语法的正确性,并重启服务使配置生效。

sbroute.sh
YAML
#!/bin/bash
#
# SBRoute — VPS 流量分流管理工具
# 基于 sing-box 的轻量级出站代理和路由规则管理器
# 适用于 Debian 12
#
set -euo pipefail

# ==================== 全局变量 ====================
SBROUTE_DIR="/etc/sbroute"
SINGBOX_CONFIG="$SBROUTE_DIR/config.json"
OUTBOUNDS_FILE="$SBROUTE_DIR/outbounds.json"
ROUTES_FILE="$SBROUTE_DIR/routes.json"
RULESETS_FILE="$SBROUTE_DIR/rulesets.json"
SETTINGS_FILE="$SBROUTE_DIR/settings.json"
BACKUP_DIR="$SBROUTE_DIR/backups"
SERVICE_NAME="sbroute"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
VERSION="1.1.0"

# ==================== 颜色定义 ====================
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

# ==================== 工具函数 ====================
info()  { echo -e "${GREEN}[✓]${NC} $*"; }
warn()  { echo -e "${YELLOW}[!]${NC} $*"; }
err()   { echo -e "${RED}[✗]${NC} $*"; }
die()   { err "$@"; exit 1; }
title() { echo -e "\n${BOLD}${CYAN}══ $* ══${NC}\n"; }

check_root() {
    [[ $EUID -eq 0 ]] || die "请以 root 权限运行此脚本"
}

check_jq() {
    if ! command -v jq &>/dev/null; then
        warn "jq 未安装,正在安装..."
        apt-get update -qq && apt-get install -y -qq jq >/dev/null 2>&1
        info "jq 安装完成"
    fi
}

ensure_files() {
    mkdir -p "$SBROUTE_DIR" "$BACKUP_DIR"
    [[ -f "$OUTBOUNDS_FILE" ]] || echo '[]' > "$OUTBOUNDS_FILE"
    [[ -f "$ROUTES_FILE" ]]    || echo '[]' > "$ROUTES_FILE"
    [[ -f "$RULESETS_FILE" ]]  || echo '[]' > "$RULESETS_FILE"
    [[ -f "$SETTINGS_FILE" ]]  || cat > "$SETTINGS_FILE" <<'EJSON'
{"default_outbound":"direct","dns_mode":"basic","tun_enabled":false}
EJSON
}

# ==================== 安装模块 ====================
_create_service() {
    local singbox_bin
    singbox_bin=$(command -v sing-box 2>/dev/null) || die "sing-box 二进制未找到"
    cat > "$SERVICE_FILE" <<ESERVICE
[Unit]
Description=SBRoute sing-box Service
After=network.target nss-lookup.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=${singbox_bin} run -c ${SINGBOX_CONFIG}
Restart=on-failure
RestartSec=10
LimitNOFILE=infinity

[Install]
WantedBy=multi-user.target
ESERVICE
    systemctl daemon-reload
    info "已创建独立服务: $SERVICE_NAME"
}

cmd_install() {
    title "安装 SBRoute"
    check_root; check_jq

    # 检查 sing-box 二进制
    if ! command -v sing-box &>/dev/null; then
        info "sing-box 未安装,正在安装..."
        apt-get update -qq && apt-get install -y -qq curl gpg >/dev/null 2>&1
        curl -fsSL https://sing-box.app/gpg.key | gpg --dearmor -o /usr/share/keyrings/sagernet-archive-keyring.gpg 2>/dev/null
        echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/sagernet-archive-keyring.gpg] https://deb.sagernet.org/ * *" \
            > /etc/apt/sources.list.d/sagernet.list
        apt-get update -qq && apt-get install -y -qq sing-box >/dev/null 2>&1
        command -v sing-box &>/dev/null || die "sing-box 安装失败"
    fi
    info "sing-box 版本: $(sing-box version 2>/dev/null | head -1)"

    # 检测已有 sing-box 服务
    if systemctl is-active sing-box &>/dev/null; then
        info "检测到已有 sing-box 服务正在运行(不会影响它)"
    fi

    ensure_files

    # 创建独立 systemd 服务
    _create_service
    generate_config
    systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true
    systemctl restart "$SERVICE_NAME" >/dev/null 2>&1 || true
    info "SBRoute 已启动并设为开机自启"
    info "配置文件: $SINGBOX_CONFIG"
    info "服务名称: $SERVICE_NAME (与原 sing-box 服务互不影响)"
}

cmd_uninstall() {
    title "卸载 SBRoute"
    check_root
    read -rp "确定要卸载 SBRoute 吗?(不会影响原 sing-box 服务) (y/N): " confirm
    [[ "$confirm" =~ ^[Yy]$ ]] || { warn "取消卸载"; return; }

    systemctl stop "$SERVICE_NAME" 2>/dev/null || true
    systemctl disable "$SERVICE_NAME" 2>/dev/null || true
    rm -f "$SERVICE_FILE"
    systemctl daemon-reload
    info "SBRoute 服务已移除"

    read -rp "是否同时删除 SBRoute 配置和数据? (y/N): " del_conf
    [[ "$del_conf" =~ ^[Yy]$ ]] && rm -rf "$SBROUTE_DIR" && info "配置已删除"
    info "卸载完成(原 sing-box 服务未受影响)"
}

# ==================== 出站管理 ====================
out_add() {
    local type="${1:-}"; shift 2>/dev/null || true
    case "$type" in
        ss)     out_add_ss "$@" ;;
        socks)  out_add_socks "$@" ;;
        http)   out_add_http "$@" ;;
        vless)  out_add_vless "$@" ;;
        vmess)  out_add_vmess "$@" ;;
        direct) out_add_simple "direct" "${1:-direct}" ;;
        block)  out_add_simple "block" "${1:-block}" ;;
        url)    out_add_url "$@" ;;
        *)      die "未知出站类型: $type\n支持: ss, socks, http, vless, vmess, direct, block, url" ;;
    esac
}

out_add_ss() {
    [[ $# -ge 5 ]] || die "用法: sbroute out add ss <tag> <server> <port> <method> <password>"
    local tag="$1" server="$2" port="$3" method="$4" password="$5"
    _check_tag_unique "$tag"
    local obj
    obj=$(jq -n --arg t "$tag" --arg s "$server" --argjson p "$port" \
        --arg m "$method" --arg pw "$password" \
        '{type:"shadowsocks",tag:$t,server:$s,server_port:$p,method:$m,password:$pw}')
    _append_outbound "$obj"
    info "已添加 Shadowsocks 出站: $tag ($server:$port, $method)"
}

out_add_socks() {
    [[ $# -ge 3 ]] || die "用法: sbroute out add socks <tag> <server> <port> [user] [pass]"
    local tag="$1" server="$2" port="$3" user="${4:-}" pass="${5:-}"
    _check_tag_unique "$tag"
    local obj
    obj=$(jq -n --arg t "$tag" --arg s "$server" --argjson p "$port" \
        '{type:"socks",tag:$t,server:$s,server_port:$p,version:"5"}')
    [[ -n "$user" ]] && obj=$(echo "$obj" | jq --arg u "$user" '. + {username:$u}')
    [[ -n "$pass" ]] && obj=$(echo "$obj" | jq --arg p "$pass" '. + {password:$p}')
    _append_outbound "$obj"
    info "已添加 SOCKS5 出站: $tag ($server:$port)"
}

out_add_http() {
    [[ $# -ge 3 ]] || die "用法: sbroute out add http <tag> <server> <port> [user] [pass]"
    local tag="$1" server="$2" port="$3" user="${4:-}" pass="${5:-}"
    _check_tag_unique "$tag"
    local obj
    obj=$(jq -n --arg t "$tag" --arg s "$server" --argjson p "$port" \
        '{type:"http",tag:$t,server:$s,server_port:$p}')
    [[ -n "$user" ]] && obj=$(echo "$obj" | jq --arg u "$user" '. + {username:$u}')
    [[ -n "$pass" ]] && obj=$(echo "$obj" | jq --arg p "$pass" '. + {password:$p}')
    _append_outbound "$obj"
    info "已添加 HTTP 出站: $tag ($server:$port)"
}

out_add_vless() {
    [[ $# -ge 4 ]] || die "用法: sbroute out add vless <tag> <server> <port> <uuid> [flow] [sni]"
    local tag="$1" server="$2" port="$3" uuid="$4" flow="${5:-}" sni="${6:-}"
    _check_tag_unique "$tag"
    local obj
    obj=$(jq -n --arg t "$tag" --arg s "$server" --argjson p "$port" --arg u "$uuid" \
        '{type:"vless",tag:$t,server:$s,server_port:$p,uuid:$u}')
    if [[ -n "$flow" ]]; then
        obj=$(echo "$obj" | jq --arg f "$flow" '. + {flow:$f}')
    fi
    if [[ -n "$sni" ]]; then
        obj=$(echo "$obj" | jq --arg sn "$sni" '. + {tls:{enabled:true,server_name:$sn,reality:{enabled:true}}}')
    fi
    _append_outbound "$obj"
    info "已添加 VLESS 出站: $tag ($server:$port)"
}

out_add_vmess() {
    [[ $# -ge 4 ]] || die "用法: sbroute out add vmess <tag> <server> <port> <uuid> [security]"
    local tag="$1" server="$2" port="$3" uuid="$4" security="${5:-auto}"
    _check_tag_unique "$tag"
    local obj
    obj=$(jq -n --arg t "$tag" --arg s "$server" --argjson p "$port" \
        --arg u "$uuid" --arg sec "$security" \
        '{type:"vmess",tag:$t,server:$s,server_port:$p,uuid:$u,security:$sec}')
    _append_outbound "$obj"
    info "已添加 VMess 出站: $tag ($server:$port)"
}

out_add_simple() {
    local type="$1" tag="$2"
    _check_tag_unique "$tag"
    local obj
    obj=$(jq -n --arg t "$type" --arg tag "$tag" '{type:$t,tag:$tag}')
    _append_outbound "$obj"
    info "已添加 $type 出站: $tag"
}

# ==================== 分享链接解析 ====================
_urldecode() {
    local url_encoded="${1//+/ }"
    printf '%b' "${url_encoded//%/\\x}"
}

_parse_query() {
    # 解析 query string 为关联数组,调用前需 declare -A params
    local query="$1"
    local IFS='&'
    for kv in $query; do
        local key="${kv%%=*}"
        local val="${kv#*=}"
        val=$(_urldecode "$val")
        eval "params[\"$key\"]=\"$val\""
    done
}

out_add_url() {
    [[ $# -ge 1 ]] || die "用法: sbroute out url <分享链接>\n支持: ss://, vless://"
    local url="$1"
    if [[ "$url" == ss://* ]]; then
        _parse_ss_url "$url"
    elif [[ "$url" == vless://* ]]; then
        _parse_vless_url "$url"
    elif [[ "$url" == vmess://* ]]; then
        die "VMess 分享链接暂不支持,请手动添加"
    else
        die "不支持的链接格式,目前支持: ss://, vless://"
    fi
}

_parse_ss_url() {
    local url="$1"
    # ss://base64(method:password)@server:port#tag
    # 或 ss://base64(method:password@server:port)#tag (SIP002)
    local body="${url#ss://}"

    # 提取 tag (fragment)
    local tag=""
    if [[ "$body" == *"#"* ]]; then
        tag=$(_urldecode "${body##*#}")
        body="${body%%#*}"
    fi
    [[ -n "$tag" ]] || die "SS 链接缺少 tag (# 后的名称)"

    local server port method password

    if [[ "$body" == *"@"* ]]; then
        # 格式: base64(method:password)@server:port
        local userinfo_b64="${body%%@*}"
        local server_part="${body##*@}"
        # 处理 base64 padding
        local padded="$userinfo_b64"
        local mod=$((${#padded} % 4))
        if [[ $mod -eq 2 ]]; then padded+="=="; elif [[ $mod -eq 3 ]]; then padded+="="; fi
        local userinfo
        userinfo=$(echo -n "$padded" | base64 -d 2>/dev/null) || die "SS 链接 base64 解码失败"
        method="${userinfo%%:*}"
        password="${userinfo#*:}"
        server="${server_part%%:*}"
        port="${server_part##*:}"
    else
        # 整体 base64 编码
        local padded="$body"
        local mod=$((${#padded} % 4))
        if [[ $mod -eq 2 ]]; then padded+="=="; elif [[ $mod -eq 3 ]]; then padded+="="; fi
        local decoded
        decoded=$(echo -n "$padded" | base64 -d 2>/dev/null) || die "SS 链接 base64 解码失败"
        method="${decoded%%:*}"
        local rest="${decoded#*:}"
        password="${rest%%@*}"
        local server_part="${rest##*@}"
        server="${server_part%%:*}"
        port="${server_part##*:}"
    fi

    [[ -n "$server" && -n "$port" && -n "$method" && -n "$password" ]] || die "SS 链接解析失败"
    info "解析 SS 链接: $tag ($server:$port, $method)"
    out_add_ss "$tag" "$server" "$port" "$method" "$password"
}

_parse_vless_url() {
    local url="$1"
    # vless://uuid@server:port?params#tag
    local body="${url#vless://}"

    # 提取 tag
    local tag=""
    if [[ "$body" == *"#"* ]]; then
        tag=$(_urldecode "${body##*#}")
        body="${body%%#*}"
    fi
    [[ -n "$tag" ]] || die "VLESS 链接缺少 tag (# 后的名称)"

    # 提取 query
    local query=""
    if [[ "$body" == *"?"* ]]; then
        query="${body##*?}"
        body="${body%%\?*}"
    fi

    # 提取 uuid@server:port
    local uuid="${body%%@*}"
    local server_part="${body##*@}"
    local server="${server_part%%:*}"
    local port="${server_part##*:}"

    [[ -n "$uuid" && -n "$server" && -n "$port" ]] || die "VLESS 链接解析失败"

    # 解析 query 参数
    declare -A params
    _parse_query "$query"

    local flow="${params[flow]:-}"
    local security="${params[security]:-}"
    local sni="${params[sni]:-}"
    local fp="${params[fp]:-}"
    local pbk="${params[pbk]:-}"
    local sid="${params[sid]:-}"
    local net_type="${params[type]:-tcp}"
    local alpn="${params[alpn]:-}"

    _check_tag_unique "$tag"

    # 构建基础对象
    local obj
    obj=$(jq -n --arg t "$tag" --arg s "$server" --argjson p "$port" --arg u "$uuid" \
        '{type:"vless",tag:$t,server:$s,server_port:$p,uuid:$u}')

    # flow
    [[ -n "$flow" ]] && obj=$(echo "$obj" | jq --arg f "$flow" '. + {flow:$f}')

    # TLS / Reality
    if [[ "$security" == "reality" ]]; then
        local tls_obj='{"enabled":true}'
        [[ -n "$sni" ]] && tls_obj=$(echo "$tls_obj" | jq --arg s "$sni" '. + {server_name:$s}')
        local reality_obj='{"enabled":true}'
        [[ -n "$pbk" ]] && reality_obj=$(echo "$reality_obj" | jq --arg k "$pbk" '. + {public_key:$k}')
        [[ -n "$sid" ]] && reality_obj=$(echo "$reality_obj" | jq --arg s "$sid" '. + {short_id:$s}')
        tls_obj=$(echo "$tls_obj" | jq --argjson r "$reality_obj" '. + {reality:$r}')
        [[ -n "$fp" ]] && tls_obj=$(echo "$tls_obj" | jq --arg u "$fp" '. + {utls:{enabled:true,fingerprint:$u}}')
        [[ -n "$alpn" ]] && tls_obj=$(echo "$tls_obj" | jq --arg a "$alpn" '. + {alpn:($a|split(","))}')
        obj=$(echo "$obj" | jq --argjson tls "$tls_obj" '. + {tls:$tls}')
    elif [[ "$security" == "tls" ]]; then
        local tls_obj='{"enabled":true}'
        [[ -n "$sni" ]] && tls_obj=$(echo "$tls_obj" | jq --arg s "$sni" '. + {server_name:$s}')
        [[ -n "$fp" ]] && tls_obj=$(echo "$tls_obj" | jq --arg u "$fp" '. + {utls:{enabled:true,fingerprint:$u}}')
        [[ -n "$alpn" ]] && tls_obj=$(echo "$tls_obj" | jq --arg a "$alpn" '. + {alpn:($a|split(","))}')
        obj=$(echo "$obj" | jq --argjson tls "$tls_obj" '. + {tls:$tls}')
    fi

    # transport (ws/grpc/h2)
    if [[ "$net_type" == "ws" ]]; then
        local ws_path="${params[path]:-/}"
        local ws_host="${params[host]:-}"
        local transport='{"type":"ws"}'
        transport=$(echo "$transport" | jq --arg p "$ws_path" '. + {path:$p}')
        [[ -n "$ws_host" ]] && transport=$(echo "$transport" | jq --arg h "$ws_host" '. + {headers:{Host:$h}}')
        obj=$(echo "$obj" | jq --argjson t "$transport" '. + {transport:$t}')
    elif [[ "$net_type" == "grpc" ]]; then
        local sn="${params[serviceName]:-}"
        local transport='{"type":"grpc"}'
        [[ -n "$sn" ]] && transport=$(echo "$transport" | jq --arg s "$sn" '. + {service_name:$s}')
        obj=$(echo "$obj" | jq --argjson t "$transport" '. + {transport:$t}')
    fi

    _append_outbound "$obj"
    info "已添加 VLESS 出站: $tag ($server:$port, security=$security)"
}

_check_tag_unique() {
    local tag="$1"
    local exists
    exists=$(jq -r --arg t "$tag" '[.[]|select(.tag==$t)]|length' "$OUTBOUNDS_FILE")
    [[ "$exists" -eq 0 ]] || die "Tag '$tag' 已存在,请使用其他名称"
}

_append_outbound() {
    local obj="$1"
    local tmp; tmp=$(mktemp)
    jq --argjson o "$obj" '. + [$o]' "$OUTBOUNDS_FILE" > "$tmp" && mv "$tmp" "$OUTBOUNDS_FILE"
}

out_list() {
    title "出站列表"
    local count
    count=$(jq 'length' "$OUTBOUNDS_FILE")
    if [[ "$count" -eq 0 ]]; then
        warn "暂无自定义出站(direct 和 dns 为内置出站)"
        return
    fi
    printf "${BOLD}%-4s %-20s %-12s %-30s${NC}\n" "#" "Tag" "类型" "服务器"
    echo "──────────────────────────────────────────────────────────────────"
    jq -r 'to_entries[]|[.key+1,.value.tag,.value.type,
        (if .value.server then "\(.value.server):\(.value.server_port)" else "-" end)]
        |@tsv' "$OUTBOUNDS_FILE" | while IFS=$'\t' read -r idx tag type srv; do
        printf "%-4s %-20s %-12s %-30s\n" "$idx" "$tag" "$type" "$srv"
    done
}

out_del() {
    [[ $# -ge 1 ]] || die "用法: sbroute out del <tag>"
    local tag="$1"
    local exists
    exists=$(jq -r --arg t "$tag" '[.[]|select(.tag==$t)]|length' "$OUTBOUNDS_FILE")
    [[ "$exists" -gt 0 ]] || die "出站 '$tag' 不存在"
    local tmp; tmp=$(mktemp)
    jq --arg t "$tag" '[.[]|select(.tag!=$t)]' "$OUTBOUNDS_FILE" > "$tmp" && mv "$tmp" "$OUTBOUNDS_FILE"
    # 同时清理引用该 tag 的路由规则
    tmp=$(mktemp)
    jq --arg t "$tag" '[.[]|select(.outbound!=$t)]' "$ROUTES_FILE" > "$tmp" && mv "$tmp" "$ROUTES_FILE"
    info "已删除出站: $tag(关联路由规则已清理)"
}

out_show() {
    [[ $# -ge 1 ]] || die "用法: sbroute out show <tag>"
    local tag="$1"
    jq --arg t "$tag" '.[]|select(.tag==$t)' "$OUTBOUNDS_FILE" | jq '.' || die "出站 '$tag' 不存在"
}

# ==================== 路由规则管理 ====================
route_add() {
    [[ $# -ge 2 ]] || die "用法: sbroute route add <outbound_tag> --domain/--suffix/--keyword/--geoip/--geosite/... <values>"
    local outbound="$1"; shift
    # 验证 outbound tag 存在
    local tag_ok
    tag_ok=$(jq -r --arg t "$outbound" '[.[]|select(.tag==$t)]|length' "$OUTBOUNDS_FILE")
    if [[ "$tag_ok" -eq 0 && "$outbound" != "direct" && "$outbound" != "block" ]]; then
        die "出站 '$outbound' 不存在,请先添加出站"
    fi

    local rule='{"action":"route"}'
    rule=$(echo "$rule" | jq --arg o "$outbound" '. + {outbound:$o}')

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --domain)
                shift; [[ $# -gt 0 ]] || die "--domain 需要参数"
                rule=$(echo "$rule" | jq --arg v "$1" '. + {domain:($v|split(","))}')
                ;;
            --suffix)
                shift; [[ $# -gt 0 ]] || die "--suffix 需要参数"
                rule=$(echo "$rule" | jq --arg v "$1" '. + {domain_suffix:($v|split(","))}')
                ;;
            --keyword)
                shift; [[ $# -gt 0 ]] || die "--keyword 需要参数"
                rule=$(echo "$rule" | jq --arg v "$1" '. + {domain_keyword:($v|split(","))}')
                ;;
            --regex)
                shift; [[ $# -gt 0 ]] || die "--regex 需要参数"
                rule=$(echo "$rule" | jq --arg v "$1" '. + {domain_regex:($v|split(","))}')
                ;;
            --ip-cidr)
                shift; [[ $# -gt 0 ]] || die "--ip-cidr 需要参数"
                rule=$(echo "$rule" | jq --arg v "$1" '. + {ip_cidr:($v|split(","))}')
                ;;
            --port)
                shift; [[ $# -gt 0 ]] || die "--port 需要参数"
                rule=$(echo "$rule" | jq --arg v "$1" '. + {port:($v|split(",")|map(tonumber))}')
                ;;
            --process)
                shift; [[ $# -gt 0 ]] || die "--process 需要参数"
                rule=$(echo "$rule" | jq --arg v "$1" '. + {process_name:($v|split(","))}')
                ;;
            --geoip)
                shift; [[ $# -gt 0 ]] || die "--geoip 需要参数 (如: cn, us, jp 等)"
                local IFS_OLD="$IFS"; IFS=','
                for _geo_name in $1; do
                    IFS="$IFS_OLD"
                    # 避免重复前缀: 若用户输入已含 geoip- 前缀则去掉
                    _geo_name="${_geo_name#geoip-}"
                    local geo_tag="geoip-${_geo_name}"
                    local geo_url="https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-${_geo_name}.srs"
                    _add_ruleset_if_missing "$geo_tag" "$geo_url" "binary"
                    rule=$(echo "$rule" | jq --arg rs "$geo_tag" \
                        'if .rule_set then .rule_set += [$rs] else . + {rule_set:[$rs]} end')
                done
                IFS="$IFS_OLD"
                ;;
            --geosite)
                shift; [[ $# -gt 0 ]] || die "--geosite 需要参数 (如: google, cn, netflix, category-ads-all 等)"
                local IFS_OLD="$IFS"; IFS=','
                for _geo_name in $1; do
                    IFS="$IFS_OLD"
                    # 避免重复前缀: 若用户输入已含 geosite- 前缀则去掉
                    _geo_name="${_geo_name#geosite-}"
                    local geo_tag="geosite-${_geo_name}"
                    local geo_url="https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-${_geo_name}.srs"
                    _add_ruleset_if_missing "$geo_tag" "$geo_url" "binary"
                    rule=$(echo "$rule" | jq --arg rs "$geo_tag" \
                        'if .rule_set then .rule_set += [$rs] else . + {rule_set:[$rs]} end')
                done
                IFS="$IFS_OLD"
                ;;
            --ruleset)
                shift; [[ $# -gt 0 ]] || die "--ruleset 需要参数"
                local url="$1"
                local rs_tag; rs_tag="rs-$(echo "$url" | md5sum | head -c 8)"
                # 添加到 rulesets
                local rs_exists
                rs_exists=$(jq -r --arg u "$url" '[.[]|select(.url==$u)]|length' "$RULESETS_FILE")
                if [[ "$rs_exists" -eq 0 ]]; then
                    local fmt="binary"
                    [[ "$url" == *.json ]] && fmt="source"
                    local detour; detour=$(_get_download_detour)
                    local rs_obj
                    rs_obj=$(jq -n --arg tag "$rs_tag" --arg u "$url" --arg f "$fmt" --arg d "$detour" \
                        '{type:"remote",tag:$tag,format:$f,url:$u,download_detour:$d}')
                    local tmp; tmp=$(mktemp)
                    jq --argjson o "$rs_obj" '. + [$o]' "$RULESETS_FILE" > "$tmp" && mv "$tmp" "$RULESETS_FILE"
                else
                    rs_tag=$(jq -r --arg u "$url" '.[]|select(.url==$u)|.tag' "$RULESETS_FILE")
                fi
                rule=$(echo "$rule" | jq --arg rs "$rs_tag" \
                    'if .rule_set then .rule_set += [$rs] else . + {rule_set:[$rs]} end')
                ;;
            *) die "未知路由参数: $1" ;;
        esac
        shift
    done

    local tmp; tmp=$(mktemp)
    jq --argjson r "$rule" '. + [$r]' "$ROUTES_FILE" > "$tmp" && mv "$tmp" "$ROUTES_FILE"
    info "已添加路由规则 → $outbound"
    echo "$rule" | jq '.'
}

route_list() {
    title "路由规则列表"
    local count
    count=$(jq 'length' "$ROUTES_FILE")
    if [[ "$count" -eq 0 ]]; then
        warn "暂无自定义路由规则"
        return
    fi
    printf "${BOLD}%-4s %-15s %-50s${NC}\n" "#" "出站" "匹配条件"
    echo "──────────────────────────────────────────────────────────────────────"
    jq -r 'to_entries[]|[.key+1, .value.outbound,
        ([if .value.domain then "domain:\(.value.domain|join(","))" else empty end,
          if .value.domain_suffix then "suffix:\(.value.domain_suffix|join(","))" else empty end,
          if .value.domain_keyword then "keyword:\(.value.domain_keyword|join(","))" else empty end,
          if .value.domain_regex then "regex:\(.value.domain_regex|join(","))" else empty end,
          if .value.ip_cidr then "ip:\(.value.ip_cidr|join(","))" else empty end,
          if .value.port then "port:\(.value.port|map(tostring)|join(","))" else empty end,
          if .value.process_name then "proc:\(.value.process_name|join(","))" else empty end,
          if .value.rule_set then "ruleset:\(.value.rule_set|join(","))" else empty end
        ]|join(" | "))]|@tsv' "$ROUTES_FILE" | while IFS=$'\t' read -r idx out conds; do
        printf "%-4s %-15s %-50s\n" "$idx" "$out" "$conds"
    done
}

route_del() {
    [[ $# -ge 1 ]] || die "用法: sbroute route del <index>"
    local idx="$1"
    local count; count=$(jq 'length' "$ROUTES_FILE")
    [[ "$idx" -ge 1 && "$idx" -le "$count" ]] 2>/dev/null || die "索引超出范围 (1-$count)"
    local tmp; tmp=$(mktemp)
    jq --argjson i "$((idx-1))" 'del(.[$i])' "$ROUTES_FILE" > "$tmp" && mv "$tmp" "$ROUTES_FILE"
    info "已删除路由规则 #$idx"
}

route_default() {
    [[ $# -ge 1 ]] || die "用法: sbroute route default <outbound_tag>"
    local tag="$1" tmp
    tmp=$(mktemp)
    jq --arg t "$tag" '.default_outbound=$t' "$SETTINGS_FILE" > "$tmp" && mv "$tmp" "$SETTINGS_FILE"
    info "默认出站已设置为: $tag"
}

# ==================== DNS 模块 ====================
cmd_dns() {
    local mode="${1:-}"
    case "$mode" in
        basic)
            local tmp; tmp=$(mktemp)
            jq '.dns_mode="basic"' "$SETTINGS_FILE" > "$tmp" && mv "$tmp" "$SETTINGS_FILE"
            info "DNS 模式: 基础 (Google DNS 8.8.8.8)"
            ;;
        split)
            local tmp; tmp=$(mktemp)
            jq '.dns_mode="split"' "$SETTINGS_FILE" > "$tmp" && mv "$tmp" "$SETTINGS_FILE"
            info "DNS 模式: 国内分流 (AliDNS + Google DNS)"
            ;;
        *)
            echo "用法: sbroute dns <basic|split>"
            echo "  basic  — Google DNS (8.8.8.8)"
            echo "  split  — 国内域名用 AliDNS,其余用 Google DNS"
            ;;
    esac
}

# ==================== 预设模板 ====================
cmd_preset() {
    local preset="${1:-}"
    case "$preset" in
        cn-direct)
            title "应用预设: 国内直连"
            _preset_ensure_direct
            # geoip-cn
            _add_ruleset_if_missing "geoip-cn" \
                "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" "binary"
            # geosite-cn
            _add_ruleset_if_missing "geosite-cn" \
                "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs" "binary"
            # 添加路由规则
            local rule
            rule=$(jq -n '{action:"route",outbound:"direct",rule_set:["geoip-cn","geosite-cn"]}')
            _add_route_if_missing "$rule" "geoip-cn"
            info "已添加: 国内 IP + 国内域名 → direct"
            ;;
        ads-block)
            title "应用预设: 广告拦截"
            _add_ruleset_if_missing "geosite-category-ads-all" \
                "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs" "binary"
            # 确保 block 出站存在
            local block_exists
            block_exists=$(jq '[.[]|select(.tag=="block")]|length' "$OUTBOUNDS_FILE")
            [[ "$block_exists" -gt 0 ]] || out_add_simple "block" "block"
            local rule
            rule=$(jq -n '{action:"route",outbound:"block",rule_set:["geosite-category-ads-all"]}')
            _add_route_if_missing "$rule" "geosite-category-ads-all"
            info "已添加: 广告域名 → block"
            ;;
        *)
            echo "用法: sbroute preset <cn-direct|ads-block>"
            echo "  cn-direct  — 国内 IP + 域名 直连"
            echo "  ads-block  — 广告域名 拦截"
            ;;
    esac
}

_preset_ensure_direct() {
    local exists
    exists=$(jq '[.[]|select(.tag=="direct")]|length' "$OUTBOUNDS_FILE")
    [[ "$exists" -gt 0 ]] || out_add_simple "direct" "direct"
}

# 获取 rule-set 下载使用的 detour: 优先用第一个代理出站,否则用 direct
_get_download_detour() {
    local first_proxy
    first_proxy=$(jq -r '[.[]|select(.type!="direct" and .type!="block")][0].tag // empty' "$OUTBOUNDS_FILE" 2>/dev/null)
    if [[ -n "$first_proxy" ]]; then
        echo "$first_proxy"
    else
        echo "direct"
    fi
}

_add_ruleset_if_missing() {
    local tag="$1" url="$2" fmt="$3"
    local exists
    exists=$(jq -r --arg t "$tag" '[.[]|select(.tag==$t)]|length' "$RULESETS_FILE")
    if [[ "$exists" -eq 0 ]]; then
        local detour; detour=$(_get_download_detour)
        local obj; obj=$(jq -n --arg t "$tag" --arg u "$url" --arg f "$fmt" --arg d "$detour" \
            '{type:"remote",tag:$t,format:$f,url:$u,download_detour:$d}')
        local tmp; tmp=$(mktemp)
        jq --argjson o "$obj" '. + [$o]' "$RULESETS_FILE" > "$tmp" && mv "$tmp" "$RULESETS_FILE"
    fi
}

_add_route_if_missing() {
    local rule="$1" check_key="$2"
    local exists
    exists=$(jq -r --arg k "$check_key" '[.[]|select(.rule_set? and (.rule_set[]|select(.==$k)))]|length' "$ROUTES_FILE")
    if [[ "$exists" -eq 0 ]]; then
        local tmp; tmp=$(mktemp)
        jq --argjson r "$rule" '. + [$r]' "$ROUTES_FILE" > "$tmp" && mv "$tmp" "$ROUTES_FILE"
    fi
}

# ==================== 配置生成 ====================
generate_config() {
    ensure_files
    local dns_mode default_out tun_enabled
    dns_mode=$(jq -r '.dns_mode // "basic"' "$SETTINGS_FILE")
    default_out=$(jq -r '.default_outbound // "direct"' "$SETTINGS_FILE")
    tun_enabled=$(jq -r '.tun_enabled // true' "$SETTINGS_FILE")

    local dns_config
    case "$dns_mode" in
        split)
            dns_config=$(cat <<'EDNS'
{
  "servers": [
    {"tag":"google-dns","type":"tls","server":"8.8.8.8"},
    {"tag":"ali-dns","type":"udp","server":"223.5.5.5"}
  ],
  "rules": [
    {"rule_set":["geosite-cn"],"server":"ali-dns"}
  ],
  "strategy":"ipv4_only"
}
EDNS
)
            ;;
        *)
            dns_config=$(cat <<'EDNS'
{
  "servers": [
    {"tag":"google-dns","type":"tls","server":"8.8.8.8"},
    {"tag":"ali-dns","type":"udp","server":"223.5.5.5"}
  ],
  "strategy":"ipv4_only"
}
EDNS
)
            ;;
    esac

    # 构建入站
    local inbounds='[]'
    if [[ "$tun_enabled" == "true" ]]; then
        inbounds=$(cat <<'EINB'
[{"type":"tun","tag":"tun-in","address":["172.19.0.1/30"],"auto_route":true,"strict_route":true}]
EINB
)
    fi

    # 构建出站: 内置 direct + dns + 用户自定义
    local builtin_outs='[{"type":"direct","tag":"direct"},{"type":"block","tag":"block"}]'
    local user_outs; user_outs=$(cat "$OUTBOUNDS_FILE")
    # 移除用户定义中与内置重名的
    user_outs=$(echo "$user_outs" | jq '[.[]|select(.tag!="direct" and .tag!="block" and .tag!="dns-out")]')
    local all_outs
    all_outs=$(jq -n --argjson b "$builtin_outs" --argjson u "$user_outs" '$u + $b')

    # 构建路由规则: 内置规则 + 用户规则
    local builtin_rules='[{"action":"sniff"},{"protocol":"dns","action":"hijack-dns"},{"ip_is_private":true,"action":"route","outbound":"direct"}]'
    local user_rules; user_rules=$(cat "$ROUTES_FILE")
    local all_rules
    all_rules=$(jq -n --argjson b "$builtin_rules" --argjson u "$user_rules" '$b + $u')

    # 规则集
    local rule_sets; rule_sets=$(cat "$RULESETS_FILE")

    # 更新 rule_set 中的 download_detour 为当前可用代理
    local detour; detour=$(_get_download_detour)
    rule_sets=$(echo "$rule_sets" | jq --arg d "$detour" '[.[]|.download_detour=$d]')

    # 组装完整配置
    local config
    config=$(jq -n \
        --argjson dns "$dns_config" \
        --argjson inb "$inbounds" \
        --argjson out "$all_outs" \
        --argjson rules "$all_rules" \
        --argjson rsets "$rule_sets" \
        --arg final "$default_out" \
        '{
            log:{level:"warn",timestamp:true},
            dns:$dns,
            inbounds:$inb,
            outbounds:$out,
            route:{
                rules:$rules,
                rule_set:$rsets,
                final:$final,
                auto_detect_interface:true,
                default_domain_resolver:"google-dns"
            },
            experimental:{
                cache_file:{enabled:true}
            }
        }')

    # 如果 dns_mode 是 split 但 geosite-cn 规则集不存在于 rulesets,dns 规则中移除 rule_set 引用
    if [[ "$dns_mode" == "split" ]]; then
        local has_geosite
        has_geosite=$(echo "$rule_sets" | jq '[.[]|select(.tag=="geosite-cn")]|length')
        if [[ "$has_geosite" -eq 0 ]]; then
            config=$(echo "$config" | jq '.dns.rules=[]')
        fi
    fi

    mkdir -p "$(dirname "$SINGBOX_CONFIG")"
    echo "$config" | jq '.' > "$SINGBOX_CONFIG"
}

cmd_apply() {
    title "应用配置"
    check_root
    generate_config
    info "配置已生成: $SINGBOX_CONFIG"

    # 校验
    if command -v sing-box &>/dev/null; then
        if sing-box check -c "$SINGBOX_CONFIG" 2>/dev/null; then
            info "配置校验通过 ✓"
        else
            err "配置校验失败,请检查配置"
            sing-box check -c "$SINGBOX_CONFIG" 2>&1 || true
            return 1
        fi
        # 确保服务文件存在
        [[ -f "$SERVICE_FILE" ]] || _create_service
        systemctl restart "$SERVICE_NAME" 2>/dev/null && info "$SERVICE_NAME 已重启" || warn "重启失败"
    else
        warn "sing-box 未安装,仅生成了配置文件"
    fi
}

# ==================== 备份/恢复 ====================
cmd_backup() {
    local file="${1:-$BACKUP_DIR/sbroute-$(date +%Y%m%d-%H%M%S).tar.gz}"
    tar -czf "$file" -C / "etc/sbroute" 2>/dev/null
    info "备份已保存: $file"
    echo "  大小: $(du -h "$file" | cut -f1)"
}

cmd_restore() {
    [[ $# -ge 1 ]] || die "用法: sbroute restore <file.tar.gz>"
    local file="$1"
    [[ -f "$file" ]] || die "文件不存在: $file"
    check_root
    tar -xzf "$file" -C / 2>/dev/null
    info "配置已恢复自: $file"
    warn "请运行 'sbroute apply' 使配置生效"
}

cmd_export() {
    if [[ -f "$SINGBOX_CONFIG" ]]; then
        jq '.' "$SINGBOX_CONFIG"
    else
        generate_config
        jq '.' "$SINGBOX_CONFIG"
    fi
}

cmd_import() {
    [[ $# -ge 1 ]] || die "用法: sbroute import <config.json>"
    local file="$1"
    [[ -f "$file" ]] || die "文件不存在: $file"
    check_root; ensure_files

    # 从完整配置中提取出站和路由
    local outs routes rsets
    outs=$(jq '[.outbounds[]|select(.type!="direct" and .type!="block" and .type!="dns")]' "$file" 2>/dev/null) || outs='[]'
    routes=$(jq '[.route.rules[]|select(.action=="route" and .outbound and .outbound!="direct" and .protocol==null and .ip_is_private==null)]' "$file" 2>/dev/null) || routes='[]'
    rsets=$(jq '.route.rule_set // []' "$file" 2>/dev/null) || rsets='[]'

    echo "$outs" | jq '.' > "$OUTBOUNDS_FILE"
    echo "$routes" | jq '.' > "$ROUTES_FILE"
    echo "$rsets" | jq '.' > "$RULESETS_FILE"

    local out_count route_count
    out_count=$(echo "$outs" | jq 'length')
    route_count=$(echo "$routes" | jq 'length')
    info "已导入: $out_count 个出站, $route_count 条路由规则"
    warn "请运行 'sbroute apply' 使配置生效"
}

# ==================== 服务管理 ====================
cmd_status() {
    title "SBRoute 状态"
    if command -v sing-box &>/dev/null; then
        echo -e "sing-box 版本: ${CYAN}$(sing-box version 2>/dev/null | head -1)${NC}"
    else
        warn "sing-box 未安装"
        return
    fi
    echo -e "配置文件: ${CYAN}$SINGBOX_CONFIG${NC}"
    echo -e "服务名称: ${CYAN}$SERVICE_NAME${NC}"
    echo ""
    systemctl status "$SERVICE_NAME" --no-pager -l 2>/dev/null || warn "SBRoute 服务未运行"
    # 显示原 sing-box 状态
    if systemctl is-active sing-box &>/dev/null 2>&1; then
        echo ""
        echo -e "${GREEN}[i]${NC} 原 sing-box 服务也在运行中(互不影响)"
    fi
}

cmd_log() {
    local lines="${1:-50}"
    journalctl -u "$SERVICE_NAME" -n "$lines" --no-pager 2>/dev/null || warn "无法读取日志"
}

cmd_test() {
    local tag="${1:-}" url="${2:-https://www.google.com}"
    [[ -n "$tag" ]] || die "用法: sbroute test <outbound_tag> [url]"
    info "测试出站 '$tag' 连接到 $url ..."
    # 查找出站的 server 和 port
    local out_info
    out_info=$(jq --arg t "$tag" '.[]|select(.tag==$t)' "$OUTBOUNDS_FILE")
    [[ -n "$out_info" ]] || die "出站 '$tag' 不存在"
    local type server port
    type=$(echo "$out_info" | jq -r '.type')
    server=$(echo "$out_info" | jq -r '.server // empty')
    port=$(echo "$out_info" | jq -r '.server_port // empty')

    if [[ "$type" == "socks" && -n "$server" ]]; then
        curl -x "socks5://$server:$port" -o /dev/null -s -w "HTTP %{http_code} | 耗时 %{time_total}s\n" "$url" \
            && info "连接成功" || err "连接失败"
    elif [[ "$type" == "http" && -n "$server" ]]; then
        curl -x "http://$server:$port" -o /dev/null -s -w "HTTP %{http_code} | 耗时 %{time_total}s\n" "$url" \
            && info "连接成功" || err "连接失败"
    else
        warn "仅支持 SOCKS5/HTTP 出站的直接连通性测试"
        warn "其他类型请在 apply 后检查 sing-box 日志"
    fi
}

# ==================== 交互式菜单 ====================
interactive_menu() {
    while true; do
        echo ""
        echo -e "${BOLD}${CYAN}╔══════════════════════════════════════╗${NC}"
        echo -e "${BOLD}${CYAN}║   SBRoute — VPS 流量分流管理 v${VERSION}  ║${NC}"
        echo -e "${BOLD}${CYAN}╚══════════════════════════════════════╝${NC}"
        echo ""
        echo -e "  ${BOLD}${YELLOW}▸ 出站管理${NC}"
        echo -e "  ${GREEN}1)${NC} 添加出站"
        echo -e "  ${GREEN}2)${NC} 查看出站列表"
        echo -e "  ${GREEN}3)${NC} 删除出站"
        echo ""
        echo -e "  ${BOLD}${YELLOW}▸ 路由规则${NC}"
        echo -e "  ${GREEN}4)${NC} 添加路由规则"
        echo -e "  ${GREEN}5)${NC} 查看路由规则"
        echo -e "  ${GREEN}6)${NC} 删除路由规则"
        echo -e "  ${GREEN}7)${NC} 设置默认出站"
        echo ""
        echo -e "  ${BOLD}${YELLOW}▸ 备份与配置${NC}"
        echo -e "  ${GREEN}8)${NC} 备份配置"
        echo -e "  ${GREEN}9)${NC} 恢复配置"
        echo -e "  ${GREEN}10)${NC} 导出配置 (JSON)"
        echo -e "  ${GREEN}11)${NC} 导入配置 (JSON)"
        echo ""
        echo -e "  ${BOLD}${YELLOW}▸ 系统管理${NC}"
        echo -e "  ${GREEN}12)${NC} 安装 sing-box"
        echo -e "  ${GREEN}13)${NC} DNS 设置"
        echo -e "  ${GREEN}14)${NC} 应用预设模板"
        echo -e "  ${GREEN}15)${NC} 应用配置并重启"
        echo -e "  ${GREEN}16)${NC} 查看状态"
        echo -e "  ${GREEN}17)${NC} 查看日志"
        echo -e "  ${GREEN}18)${NC} 卸载"
        echo -e "  ${GREEN}0)${NC}  退出"
        echo ""
        read -rp "请选择 [0-18]: " choice

        case "$choice" in
            1) _menu_add_outbound ;;
            2) out_list ;;
            3) _menu_del_outbound ;;
            4) _menu_add_route ;;
            5) route_list ;;
            6) _menu_del_route ;;
            7) _menu_set_default ;;
            8) cmd_backup ;;
            9) _menu_restore ;;
            10) cmd_export ;;
            11) _menu_import ;;
            12) cmd_install ;;
            13) _menu_dns ;;
            14) _menu_preset ;;
            15) cmd_apply ;;
            16) cmd_status ;;
            17) cmd_log ;;
            18) cmd_uninstall ;;
            0) echo "再见!"; exit 0 ;;
            *) warn "无效选择" ;;
        esac
        echo ""
        read -rp "按回车继续..."
    done
}

_menu_add_outbound() {
    echo ""
    echo -e "  出站类型:"
    echo -e "  ${CYAN}1)${NC} 粘贴分享链接 (ss:// / vless://)"
    echo -e "  ${CYAN}2)${NC} Shadowsocks (ss)"
    echo -e "  ${CYAN}3)${NC} SOCKS5"
    echo -e "  ${CYAN}4)${NC} HTTP"
    echo -e "  ${CYAN}5)${NC} VLESS"
    echo -e "  ${CYAN}6)${NC} VMess"
    echo -e "  ${CYAN}7)${NC} Direct"
    echo -e "  ${CYAN}8)${NC} Block"
    echo ""
    read -rp "选择类型 [1-8]: " t
    case "$t" in
        1)
            echo -e "  粘贴分享链接 (支持 ss:// 和 vless://):"
            read -rp "  链接: " share_url
            [[ -n "$share_url" ]] && out_add_url "$share_url"
            ;;
        2)
            read -rp "Tag: " tag; read -rp "Server: " srv; read -rp "Port: " port
            read -rp "Method (aes-128-gcm): " method; method=${method:-aes-128-gcm}
            read -rp "Password: " pw
            out_add_ss "$tag" "$srv" "$port" "$method" "$pw"
            ;;
        3)
            read -rp "Tag: " tag; read -rp "Server: " srv; read -rp "Port: " port
            read -rp "User (可选): " user; read -rp "Password (可选): " pw
            out_add_socks "$tag" "$srv" "$port" "$user" "$pw"
            ;;
        4)
            read -rp "Tag: " tag; read -rp "Server: " srv; read -rp "Port: " port
            read -rp "User (可选): " user; read -rp "Password (可选): " pw
            out_add_http "$tag" "$srv" "$port" "$user" "$pw"
            ;;
        5)
            read -rp "Tag: " tag; read -rp "Server: " srv; read -rp "Port: " port
            read -rp "UUID: " uuid; read -rp "Flow (可选, 如 xtls-rprx-vision): " flow
            read -rp "SNI (可选, 启用 TLS+Reality): " sni
            out_add_vless "$tag" "$srv" "$port" "$uuid" "$flow" "$sni"
            ;;
        6)
            read -rp "Tag: " tag; read -rp "Server: " srv; read -rp "Port: " port
            read -rp "UUID: " uuid; read -rp "Security (auto): " sec; sec=${sec:-auto}
            out_add_vmess "$tag" "$srv" "$port" "$uuid" "$sec"
            ;;
        7) read -rp "Tag (direct): " tag; out_add_simple "direct" "${tag:-direct}" ;;
        8) read -rp "Tag (block): " tag; out_add_simple "block" "${tag:-block}" ;;
        *) warn "无效选择" ;;
    esac
}

_menu_del_outbound() {
    out_list
    echo ""
    read -rp "输入要删除的 Tag: " tag
    [[ -n "$tag" ]] && out_del "$tag"
}

_menu_add_route() {
    out_list
    echo ""
    read -rp "目标出站 Tag: " out_tag
    [[ -n "$out_tag" ]] || return

    echo -e "\n  匹配条件 (留空跳过):"
    read -rp "  Domain (逗号分隔): " domains
    read -rp "  Domain Suffix (逗号分隔): " suffixes
    read -rp "  Domain Keyword (逗号分隔): " keywords
    read -rp "  GeoIP (逗号分隔, 如 cn,us,jp): " geoip
    read -rp "  GeoSite (逗号分隔, 如 google,cn,netflix): " geosite
    read -rp "  Rule Set URL (完整链接): " ruleset

    local args=("$out_tag")
    [[ -n "$domains" ]]  && args+=(--domain "$domains")
    [[ -n "$suffixes" ]] && args+=(--suffix "$suffixes")
    [[ -n "$keywords" ]] && args+=(--keyword "$keywords")
    [[ -n "$geoip" ]]    && args+=(--geoip "$geoip")
    [[ -n "$geosite" ]]  && args+=(--geosite "$geosite")
    [[ -n "$ruleset" ]]  && args+=(--ruleset "$ruleset")

    if [[ ${#args[@]} -le 1 ]]; then
        warn "至少需要指定一个匹配条件"
        return
    fi
    route_add "${args[@]}"
}

_menu_del_route() {
    route_list
    echo ""
    read -rp "输入要删除的序号: " idx
    [[ -n "$idx" ]] && route_del "$idx"
}

_menu_set_default() {
    out_list
    echo ""
    local cur; cur=$(jq -r '.default_outbound // "direct"' "$SETTINGS_FILE")
    echo -e "当前默认出站: ${CYAN}$cur${NC}"
    read -rp "新的默认出站 Tag: " tag
    [[ -n "$tag" ]] && route_default "$tag"
}

_menu_dns() {
    local cur; cur=$(jq -r '.dns_mode // "basic"' "$SETTINGS_FILE")
    echo -e "\n当前 DNS 模式: ${CYAN}$cur${NC}"
    echo -e "  ${CYAN}1)${NC} basic  — Google DNS"
    echo -e "  ${CYAN}2)${NC} split  — 国内分流"
    read -rp "选择 [1-2]: " c
    case "$c" in
        1) cmd_dns basic ;;
        2) cmd_dns split ;;
        *) warn "无效选择" ;;
    esac
}

_menu_preset() {
    echo -e "\n  预设模板:"
    echo -e "  ${CYAN}1)${NC} cn-direct  — 国内直连"
    echo -e "  ${CYAN}2)${NC} ads-block  — 广告拦截"
    read -rp "选择 [1-2]: " c
    case "$c" in
        1) cmd_preset cn-direct ;;
        2) cmd_preset ads-block ;;
        *) warn "无效选择" ;;
    esac
}

_menu_restore() {
    echo ""; ls -la "$BACKUP_DIR"/ 2>/dev/null || warn "无备份文件"
    echo ""
    read -rp "输入备份文件路径: " file
    [[ -n "$file" ]] && cmd_restore "$file"
}

_menu_import() {
    read -rp "输入 JSON 配置文件路径: " file
    [[ -n "$file" ]] && cmd_import "$file"
}

# ==================== 帮助信息 ====================
show_help() {
    cat <<EOF
${BOLD}SBRoute v${VERSION}${NC} — VPS 流量分流管理工具

${BOLD}用法:${NC}
  sbroute                              交互式菜单
  sbroute install                      安装 sing-box
  sbroute uninstall                    卸载

${BOLD}出站管理:${NC}
  sbroute out add ss <tag> <server> <port> <method> <password>
  sbroute out add socks <tag> <server> <port> [user] [pass]
  sbroute out add http <tag> <server> <port> [user] [pass]
  sbroute out add vless <tag> <server> <port> <uuid> [flow] [sni]
  sbroute out add vmess <tag> <server> <port> <uuid> [security]
  sbroute out add direct [tag]
  sbroute out add block [tag]
  sbroute out add url <分享链接>        解析 ss:// / vless:// 链接添加
  sbroute out list                     列出所有出站
  sbroute out del <tag>                删除出站
  sbroute out show <tag>               查看出站详情

${BOLD}路由规则:${NC}
  sbroute route add <tag> --domain/--suffix/--keyword/--regex/--ip-cidr/--port/--process <v>
  sbroute route add <tag> --geoip <codes>        GeoIP 规则集 (如 cn,us,jp)
  sbroute route add <tag> --geosite <names>      GeoSite 规则集 (如 google,cn,netflix,category-ads-all)
  sbroute route add <tag> --ruleset <url>        自定义规则集 URL
  sbroute route list                   列出路由规则
  sbroute route del <index>            删除路由规则
  sbroute route default <tag>          设置默认出站

${BOLD}DNS:${NC}
  sbroute dns basic                    Google DNS
  sbroute dns split                    国内分流 DNS

${BOLD}预设:${NC}
  sbroute preset cn-direct             国内直连规则集
  sbroute preset ads-block             广告拦截规则集

${BOLD}配置:${NC}
  sbroute apply                        生成配置并重启
  sbroute backup [file]                备份
  sbroute restore <file>               恢复
  sbroute export                       导出 JSON
  sbroute import <file>                导入 JSON

${BOLD}服务:${NC}
  sbroute status                       查看状态
  sbroute start|stop|restart           服务控制
  sbroute log [lines]                  查看日志
  sbroute test <tag> [url]             连通性测试
EOF
}

# ==================== 主入口 ====================
main() {
    local cmd="${1:-}"
    [[ -n "$cmd" ]] && shift

    # 非 help/version 命令需要 root 和初始化
    case "$cmd" in
        ""|-h|--help|help|version|-v|--version) ;;
        *) check_root; check_jq; ensure_files ;;
    esac

    case "$cmd" in
        "") interactive_menu ;;
        install)   cmd_install ;;
        uninstall) cmd_uninstall ;;
        out|outbound)
            local sub="${1:-}"; shift 2>/dev/null || true
            case "$sub" in
                add)  out_add "$@" ;;
                list) out_list ;;
                del)  out_del "$@" ;;
                show) out_show "$@" ;;
                *)    die "用法: sbroute out <add|list|del|show>" ;;
            esac
            ;;
        route)
            local sub="${1:-}"; shift 2>/dev/null || true
            case "$sub" in
                add)     route_add "$@" ;;
                list)    route_list ;;
                del)     route_del "$@" ;;
                default) route_default "$@" ;;
                *)       die "用法: sbroute route <add|list|del|default>" ;;
            esac
            ;;
        dns)    cmd_dns "$@" ;;
        preset) cmd_preset "$@" ;;
        apply)  cmd_apply ;;
        backup) cmd_backup "$@" ;;
        restore) cmd_restore "$@" ;;
        export) cmd_export ;;
        import) cmd_import "$@" ;;
        status) cmd_status ;;
        start)  check_root; [[ -f "$SERVICE_FILE" ]] || _create_service; systemctl start "$SERVICE_NAME" && info "$SERVICE_NAME 已启动" ;;
        stop)   check_root; systemctl stop "$SERVICE_NAME" && info "$SERVICE_NAME 已停止" ;;
        restart) check_root; [[ -f "$SERVICE_FILE" ]] || _create_service; systemctl restart "$SERVICE_NAME" && info "$SERVICE_NAME 已重启" ;;
        log)    cmd_log "$@" ;;
        test)   cmd_test "$@" ;;
        -h|--help|help) show_help ;;
        -v|--version|version) echo "SBRoute v${VERSION}" ;;
        *) err "未知命令: $cmd"; show_help; exit 1 ;;
    esac
}

main "$@"