diff --git a/README.md b/README.md index d182d91..d5bb661 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ * * * ## 1.更新信息 +2026.05.29 v1.3.14 Added optional custom warp route rule management, available after installation via [sb -d] without affecting the main installation flow; 增加可选的自定义 warp 路由规则管理,不影响主程序安装流程,安装后可通过 [sb -d] 按需管理 + 2026.05.18 v1.3.13 Added top-level http_clients configuration; 增加顶层的 http_clients 配置 2026.05.14 v1.3.12 1. Add Hysteria2 Realm support for machines without public inbound access, with optional WARP-assisted hole punching for strict NAT environments; 2. Realm configuration export is supported for Clash/Mihomo and sing-box clients; 3. Hysteria2 Realm can be enabled or disabled directly via sb -d; 4. Non-interactive installs support --HY2_REALM and --HY2_WARP parameters; 1. 增加 Hysteria2 Realm 支持,适用于没有公网入口的机器,并可选 WARP 辅助打洞; 2. Realm 已支持导出 Clash/Mihomo 和 sing-box 客户端配置; 3. 修改节点配置时可直接开启或关闭 Hysteria2 Realm; 4. 无交互安装支持 --HY2_REALM 与 --HY2_WARP 参数 @@ -600,6 +602,7 @@ services: | |-- 05_dns.json # DNS 规则文件 | |-- 06_ntp.json # 服务端时间同步配置文件 | |-- 07_http_clients.json # 专门给 sing-box 内部组件发 HTTP 请求配置文件 +| |-- 08_custom_route.json # 用户自定义 warp-ep 出站路由规则文件,支持 domain_suffix / rule_set 分流 | |-- 11_xtls-reality_inbounds.json # Reality vision 协议配置文件 | |-- 12_hysteria2_inbounds.json # Hysteria2 协议配置文件 | |-- 13_tuic_inbounds.json # Tuic V5 协议配置文件 # Hysteria2 协议配置文件 diff --git a/sing-box.sh b/sing-box.sh index f2dcea7..20fc0fa 100644 --- a/sing-box.sh +++ b/sing-box.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # 当前脚本版本号 -VERSION='v1.3.13 (2026.05.18)' +VERSION='v1.3.14 (2026.05.29)' # Github 反代加速代理 GITHUB_PROXY=('https://hub.glowp.xyz/' 'https://proxy.vvvv.ee/') @@ -39,8 +39,8 @@ mkdir -p "$TEMP_DIR" E[0]="Language:\n 1. English (default) \n 2. 简体中文" C[0]="${E[0]}" -E[1]="1. Added Hysteria2 Realm support for machines without public inbound access, with optional WARP-assisted hole punching; 2. Realm is exported for Clash/Mihomo and sing-box clients; 3. Hysteria2 Realm can be toggled directly in node configuration changes; 4. Unified the fixed STUN server list for Hysteria2 Realm; 5. Added top-level http_clients configuration" -C[1]="1. 增加 Hysteria2 Realm 支持,适用于没有公网入口的机器,并可选 WARP 辅助打洞; 2. Realm 已支持导出 Clash/Mihomo 和 sing-box 客户端配置; 3. 修改节点配置时可直接开启或关闭 Hysteria2 Realm; 4. 统一 Hysteria2 Realm 固定 STUN 服务器列表。 5. 增加顶层的 http_clients 配置" +E[1]="1. Added optional custom warp route rule management, available after installation via [sb -d] without affecting the main installation flow; 2. Added Hysteria2 Realm support for machines without public inbound access, with optional WARP-assisted hole punching" +C[1]="1. 增加可选的自定义 warp 路由规则管理,不影响主程序安装流程,安装后可通过 [sb -d] 按需管理; 2. 增加 Hysteria2 Realm 支持,适用于没有公网入口的机器,并可选 WARP 辅助打洞" E[2]="Downloading Sing-box. Please wait a seconds ..." C[2]="下载 Sing-box 中,请稍等 ..." E[3]="Input errors up to 5 times.The script is aborted." @@ -335,6 +335,34 @@ E[147]="Hysteria2 Realm is useful for China-back routing or machines without pub C[147]="Hysteria2 Realm 适用于回国或者没有公网入口的机器;有公网入口时不建议使用。是否启用?[y/N]:" E[148]="WARP-assisted hole punching is useful in strict NAT environments. When direct hole punching fails, Cloudflare WARP can provide a CF egress path to improve success. Enable it? [y/N]:" C[148]="WARP 辅助打洞(适用于 NAT 严格环境):当 NAT 类型较严格(如对称 NAT)导致直连打洞失败时,可借助 Cloudflare WARP 获取一个 CF 出口 IP 作为中转,提升打洞成功率。是否启用?[y/N]:" +E[150]="Custom warp-ep outbounds rules (rules: \${CUSTOM_ROUTE_COUNT:-0})" +C[150]="自定义 warp-ep 出站路由规则 (规则数: \${CUSTOM_ROUTE_COUNT:-0})" +E[151]="1. Add rule\n 2. View rules\n 3. Delete rule\n 0. Back" +C[151]="1. 添加规则\n 2. 查看规则\n 3. 删除规则\n 0. 返回" +E[152]="Select rule type:\\n 1. domain_suffix\\n 2. rule_set" +C[152]="选择规则类型:\\n 1. domain_suffix (域名后缀)\\n 2. rule_set (规则集)" +E[153]="Enter domain suffix (comma-separated, e.g. google.com,telegram.org):" +C[153]="输入域名后缀 (逗号分隔,如 google.com,telegram.org):" +E[154]="Enter rule_set name (comma-separated, e.g. geosite-google,geosite-telegram):" +C[154]="输入规则集名称 (逗号分隔,如 geosite-google,geosite-telegram):" +E[155]="Matched custom route rules will use warp-ep outbound." +C[155]="命中的自定义路由规则将使用 warp-ep 出站。" +E[156]="Rule set \"\${RULE_NAME}\" not found in SagerNet or MetaCubeX repositories. Please re-enter:" +C[156]="规则集 \"\${RULE_NAME}\" 在 SagerNet 和 MetaCubeX 仓库中均未找到,请重新输入:" +E[157]="Custom route rule added successfully." +C[157]="自定义路由规则添加成功。" +E[158]="No custom route rules configured." +C[158]="未配置自定义路由规则。" +E[159]="Enter warp-ep outbound rule number(s) to delete (comma-separated):" +C[159]="输入要删除的 warp-ep 出站规则编号 (逗号分隔):" +E[160]="Custom route rule(s) deleted." +C[160]="自定义路由规则已删除。" +E[161]="Invalid domain format: \${DOMAIN}" +C[161]="无效的域名格式: \${DOMAIN}" +E[162]="API check failed, using SagerNet URL as default. (Warning: rule_set may not exist)" +C[162]="API 校验失败,默认使用 SagerNet 地址。(警告: 规则集可能不存在)" +E[163]="Current custom route rules:" +C[163]="当前自定义路由规则:" # 自定义字体彩色,read 函数 warning() { echo -e "\033[31m\033[01m$*\033[0m"; } # 红色 @@ -630,6 +658,12 @@ change_config() { MENU_IDX+=(139) && MENU_KEY+=(hy2hopping) && MENU_VAL+=("${HY2_PORT_HOPPING_RANGE}") fi + # 自定义路由规则(仅在 warp-ep 存在时显示) + grep -q '"warp-ep"' ${WORK_DIR}/conf/02_endpoints.json 2>/dev/null && { + CUSTOM_ROUTE_COUNT=$(custom_route_count) + MENU_IDX+=(150) && MENU_KEY+=(customroute) && MENU_VAL+=("${CUSTOM_ROUTE_COUNT}") + } + [ "${#MENU_IDX[@]}" -eq 0 ] && error " $(text 110) " # 显示动态菜单 @@ -741,6 +775,9 @@ change_config() { export_list return + elif [ "$KEY" = "customroute" ]; then + custom_route_menu + return fi hint "" @@ -1144,6 +1181,406 @@ sync_hy2_warp_route() { fi } +# ===================== 自定义路由规则 ===================== + +# 统计自定义路由规则数量(按数组里的单项统计,不按整条 route rule 统计) +custom_route_count() { + local CUSTOM_FILE="${WORK_DIR}/conf/08_custom_route.json" + if [ -s "$CUSTOM_FILE" ]; then + jq_exec '[.route.rules[]? | ((.domain_suffix // []) | length) + ((.rule_set // []) | length)] | add // 0' "$CUSTOM_FILE" 2>/dev/null || echo 0 + else + echo 0 + fi +} + +# 将 warp-ep 的 domain_suffix / rule_set 合并到同一条 route rule,保持 08_custom_route.json 更简洁 +custom_route_compact_rules() { + local CUSTOM_FILE="${WORK_DIR}/conf/08_custom_route.json" + local TMP_FILE="${CUSTOM_FILE}.tmp" + [ ! -s "$CUSTOM_FILE" ] && return + + jq_exec ' + (.route.rules // []) as $rules | + ($rules | map(select((.outbound // "warp-ep") == "warp-ep") | .domain_suffix // []) | add // [] | reduce .[] as $x ([]; if index($x) then . else . + [$x] end)) as $domains | + ($rules | map(select((.outbound // "warp-ep") == "warp-ep") | .rule_set // []) | add // [] | reduce .[] as $x ([]; if index($x) then . else . + [$x] end)) as $sets | + ($rules | map(select((.outbound // "warp-ep") != "warp-ep"))) as $others | + .route.rules = ( + $others + + (if (($domains | length) + ($sets | length)) > 0 then + [((if ($sets | length) > 0 then {rule_set:$sets} else {} end) + + (if ($domains | length) > 0 then {domain_suffix:$domains} else {} end) + + {outbound:"warp-ep"})] + else [] end) + ) + ' "$CUSTOM_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$CUSTOM_FILE" +} + +# 通过 GitHub API 校验 rule_set 是否存在,返回下载 URL +# 参数: $1 = rule_set 名称(不含 .srs) +# 输出: 下载 URL 或空字符串 +check_rule_set_exists() { + local NAME="$1" + local SRS_NAME="${NAME}.srs" + local CACHE_DIR="${TEMP_DIR}/ruleset_cache" + mkdir -p "$CACHE_DIR" + + # SagerNet 源 + local SAGERNET_CACHE="${CACHE_DIR}/sagernet_tree.json" + if [ ! -s "$SAGERNET_CACHE" ]; then + curl -sL --connect-timeout 5 --max-time 15 "https://api.github.com/repos/SagerNet/sing-geosite/git/trees/rule-set?recursive=1" > "$SAGERNET_CACHE" 2>/dev/null || true + fi + + if [ -s "$SAGERNET_CACHE" ] && jq_exec -e ".tree[]? | select(.path == \"${SRS_NAME}\")" "$SAGERNET_CACHE" >/dev/null 2>&1; then + echo "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/${SRS_NAME}" + return 0 + fi + + # MetaCubeX 源 + local METACUBEX_CACHE="${CACHE_DIR}/metacubex_tree.json" + if [ ! -s "$METACUBEX_CACHE" ]; then + curl -sL --connect-timeout 5 --max-time 15 "https://api.github.com/repos/MetaCubeX/meta-rules-dat/git/trees/sing?recursive=1" > "$METACUBEX_CACHE" 2>/dev/null || true + fi + + if [ -s "$METACUBEX_CACHE" ]; then + local MATCH_PATH + MATCH_PATH=$(jq_exec -r "[.tree[]? | select(.path | endswith(\"/${SRS_NAME}\") or . == \"${SRS_NAME}\") | .path] | first // empty" "$METACUBEX_CACHE" 2>/dev/null) + if [ -n "$MATCH_PATH" ]; then + echo "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/${MATCH_PATH}" + return 0 + fi + fi + + # 两处 API 都没数据(可能是 rate limit 或网络问题) + if [ ! -s "$SAGERNET_CACHE" ] && [ ! -s "$METACUBEX_CACHE" ]; then + # API 不可用,降级使用默认 URL + warning " $(text 162) " + echo "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/${SRS_NAME}" + return 0 + fi + + # 确实在两处都没找到 + return 1 +} + +# 添加自定义路由规则 +custom_route_add() { + local CUSTOM_FILE="${WORK_DIR}/conf/08_custom_route.json" + + # 选择规则类型 + hint "\n $(text 152) " + reading " $(text 24) " RULE_TYPE_CHOICE + case "$RULE_TYPE_CHOICE" in + 1 ) local RULE_TYPE="domain_suffix" ;; + 2 ) local RULE_TYPE="rule_set" ;; + * ) info " $(text 135) " && return ;; + esac + + local VALIDATED_VALUES=() + local RULE_SET_URLS=() + + if [ "$RULE_TYPE" = "domain_suffix" ]; then + reading " $(text 153) " DOMAIN_INPUT + [ -z "$DOMAIN_INPUT" ] && info " $(text 135) " && return + + local DOMAINS=() + custom_route_csv_to_array "$DOMAIN_INPUT" DOMAINS + local DOMAIN + for DOMAIN in "${DOMAINS[@]}"; do + DOMAIN=$(sed 's/。/./g' <<< "${DOMAIN,,}") + if [[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$ ]]; then + VALIDATED_VALUES+=("$DOMAIN") + else + warning " $(text 161) " + fi + done + [ "${#VALIDATED_VALUES[@]}" -eq 0 ] && warning " $(text 135) " && return + + elif [ "$RULE_TYPE" = "rule_set" ]; then + # 输入规则集名称 + reading " $(text 154) " RULESET_INPUT + [ -z "$RULESET_INPUT" ] && info " $(text 135) " && return + + local RULESETS=() + custom_route_csv_to_array "$RULESET_INPUT" RULESETS + + local RULE_NAME RETRY URL + for RULE_NAME in "${RULESETS[@]}"; do + RULE_NAME="${RULE_NAME,,}" + RULE_NAME=$(sed -E 's#^.*/##; s/\.srs$//I' <<< "$RULE_NAME") + [[ -n "$RULE_NAME" && ! "$RULE_NAME" =~ ^geo(site|ip)- ]] && RULE_NAME="geosite-${RULE_NAME}" + [ -z "$RULE_NAME" ] && continue + + RETRY=3 + URL="" + while [ $RETRY -gt 0 ]; do + URL=$(check_rule_set_exists "$RULE_NAME") + if [ -n "$URL" ]; then + VALIDATED_VALUES+=("$RULE_NAME") + RULE_SET_URLS+=("$URL") + break + else + ((RETRY--)) + if [ $RETRY -gt 0 ]; then + warning " $(text 156) " + reading " " RULE_NAME + RULE_NAME="${RULE_NAME,,}" + RULE_NAME=$(sed -E 's#^.*/##; s/\.srs$//I' <<< "$RULE_NAME") + [[ -n "$RULE_NAME" && ! "$RULE_NAME" =~ ^geo(site|ip)- ]] && RULE_NAME="geosite-${RULE_NAME}" + else + warning " $(text 156) " + fi + fi + done + done + [ "${#VALIDATED_VALUES[@]}" -eq 0 ] && warning " $(text 135) " && return + fi + + # 自定义路由固定使用 warp-ep 出站 + local OUTBOUND="warp-ep" + hint " $(text 155) " + + # 初始化 JSON 文件(如果不存在) + if [ ! -s "$CUSTOM_FILE" ]; then + echo '{"route":{"rule_set":[],"rules":[]}}' | jq_exec '.' > "$CUSTOM_FILE" + fi + + local TMP_FILE="${CUSTOM_FILE}.tmp" + + if [ "$RULE_TYPE" = "domain_suffix" ]; then + # 构建 domain_suffix 数组 JSON,先对输入本身去重 + local DOMAINS_JSON + DOMAINS_JSON=$(printf '%s\n' "${VALIDATED_VALUES[@]}" | jq_exec -R . | jq_exec -s 'reduce .[] as $x ([]; if index($x) then . else . + [$x] end)') + + # 合并到同一条 warp-ep route rule;rule_set 和 domain_suffix 共用一条规则 + jq_exec --argjson domains "$DOMAINS_JSON" --arg out "$OUTBOUND" ' + .route.rules = (.route.rules // []) | + (.route.rules | map(select((.outbound // $out) == $out) | .domain_suffix // []) | add // []) as $old_domains | + (.route.rules | map(select((.outbound // $out) == $out) | .rule_set // []) | add // []) as $old_sets | + (.route.rules | map(select((.outbound // $out) != $out))) as $others | + (($old_domains + $domains) | reduce .[] as $x ([]; if index($x) then . else . + [$x] end)) as $new_domains | + ($old_sets | reduce .[] as $x ([]; if index($x) then . else . + [$x] end)) as $new_sets | + .route.rules = ( + $others + + (if (($new_domains | length) + ($new_sets | length)) > 0 then + [((if ($new_sets | length) > 0 then {rule_set:$new_sets} else {} end) + + (if ($new_domains | length) > 0 then {domain_suffix:$new_domains} else {} end) + + {outbound:$out})] + else [] end) + ) + ' "$CUSTOM_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$CUSTOM_FILE" + + elif [ "$RULE_TYPE" = "rule_set" ]; then + # 添加 rule_set 定义到 route.rule_set(去重) + for i in "${!VALIDATED_VALUES[@]}"; do + local RS_NAME="${VALIDATED_VALUES[$i]}" + local RS_URL="${RULE_SET_URLS[$i]}" + + jq_exec --arg tag "$RS_NAME" --arg url "$RS_URL" ' + .route.rule_set = (.route.rule_set // []) | + .route.rule_set |= ( + if any(.[]; .tag == $tag) then . + else . + [{"tag": $tag, "type": "remote", "format": "binary", "url": $url}] + end + ) + ' "$CUSTOM_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$CUSTOM_FILE" + done + + # 构建 rule_set 名称数组,先对输入本身去重 + local RS_NAMES_JSON + RS_NAMES_JSON=$(printf '%s\n' "${VALIDATED_VALUES[@]}" | jq_exec -R . | jq_exec -s 'reduce .[] as $x ([]; if index($x) then . else . + [$x] end)') + + # 合并到同一条 warp-ep route rule;rule_set 和 domain_suffix 共用一条规则 + jq_exec --argjson names "$RS_NAMES_JSON" --arg out "$OUTBOUND" ' + .route.rules = (.route.rules // []) | + (.route.rules | map(select((.outbound // $out) == $out) | .domain_suffix // []) | add // []) as $old_domains | + (.route.rules | map(select((.outbound // $out) == $out) | .rule_set // []) | add // []) as $old_sets | + (.route.rules | map(select((.outbound // $out) != $out))) as $others | + ($old_domains | reduce .[] as $x ([]; if index($x) then . else . + [$x] end)) as $new_domains | + (($old_sets + $names) | reduce .[] as $x ([]; if index($x) then . else . + [$x] end)) as $new_sets | + .route.rules = ( + $others + + (if (($new_domains | length) + ($new_sets | length)) > 0 then + [((if ($new_sets | length) > 0 then {rule_set:$new_sets} else {} end) + + (if ($new_domains | length) > 0 then {domain_suffix:$new_domains} else {} end) + + {outbound:$out})] + else [] end) + ) + ' "$CUSTOM_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$CUSTOM_FILE" + fi + + custom_route_compact_rules + + info " $(text 157) " + hint " $(text 112) " + cmd_systemctl restart sing-box + sleep 2 + cmd_systemctl status sing-box &>/dev/null && \ + info "\n Sing-box $(text 28) $(text 37) \n" || \ + warning "\n Sing-box $(text 27) $(text 38) \n" +} + +# 将逗号分隔输入转为数组:支持半角/全角逗号、顿号、分号、竖线;不使用 IFS +custom_route_csv_to_array() { + local _input="$1" + local -n _out_array="$2" + _out_array=() + + mapfile -t _out_array < <( + printf '%s\n' "$_input" | + sed 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\^\[\[[0-9;?]*[A-Za-z]//g; s/[,、;;|]/,/g; s/[[:space:]]//g; s/,/\n/g; /^$/d' + ) +} + +# 输出展开后的自定义路由项,每个 domain_suffix / rule_set 数组元素单独一行 +custom_route_items_json() { + local CUSTOM_FILE="${WORK_DIR}/conf/08_custom_route.json" + jq_exec -c ' + [.route.rules[]?] as $rules | + reduce range(0; ($rules | length)) as $i ([]; + ($rules[$i]) as $r | + . + + [($r.rule_set[]? | {rule_index:$i,type:"rule_set",match:.,outbound:($r.outbound // "warp-ep")})] + + [($r.domain_suffix[]? | {rule_index:$i,type:"domain_suffix",match:.,outbound:($r.outbound // "warp-ep")})] + + (if (($r.rule_set? == null) and ($r.domain_suffix? == null)) then [{rule_index:$i,type:"unknown",match:"N/A",outbound:($r.outbound // "warp-ep")}] else [] end) + ) | .[] + ' "$CUSTOM_FILE" 2>/dev/null +} + +# 查看自定义路由规则 +custom_route_view() { + local CUSTOM_FILE="${WORK_DIR}/conf/08_custom_route.json" + + if [ ! -s "$CUSTOM_FILE" ]; then + hint " $(text 158) " + return 1 + fi + + local ROUTE_ITEMS=() + mapfile -t ROUTE_ITEMS < <(custom_route_items_json) + + if [ "${#ROUTE_ITEMS[@]}" -eq 0 ]; then + hint " $(text 158) " + return 1 + fi + + hint "\n $(text 163) \n" + printf " %-4s %-16s %s\n" "#" "Type" "Match" + printf " %-4s %-16s %s\n" "---" "---------------" "---------------------------------------" + + local IDX=0 + local ITEM TYPE MATCH + for ITEM in "${ROUTE_ITEMS[@]}"; do + ((IDX++)) || true + TYPE=$(jq_exec -r '.type' <<< "$ITEM") + MATCH=$(jq_exec -r '.match' <<< "$ITEM") + printf " %-4s %-16s %s\n" "$IDX" "$TYPE" "$MATCH" + done + + echo "" + return 0 +} + +# 删除自定义路由规则:按展开后的单项编号删除,支持删除数组里的某个元素 +custom_route_delete() { + local CUSTOM_FILE="${WORK_DIR}/conf/08_custom_route.json" + + custom_route_view || return + + local ROUTE_ITEMS=() + mapfile -t ROUTE_ITEMS < <(custom_route_items_json) + [ "${#ROUTE_ITEMS[@]}" -eq 0 ] && info " $(text 135) " && return + + reading " $(text 159) " DELETE_INPUT + [ -z "$DELETE_INPUT" ] && info " $(text 135) " && return + + local DELETE_NUMS=() + custom_route_csv_to_array "$DELETE_INPUT" DELETE_NUMS + + local DELETE_ITEM_LINES=() + local NUM + for NUM in "${DELETE_NUMS[@]}"; do + NUM=$(sed 's/[^0-9]//g' <<< "$NUM") + if [[ "$NUM" =~ ^[0-9]+$ ]] && [ "$NUM" -ge 1 ] && [ "$NUM" -le "${#ROUTE_ITEMS[@]}" ]; then + DELETE_ITEM_LINES+=("${ROUTE_ITEMS[$((NUM - 1))]}") + fi + done + + [ "${#DELETE_ITEM_LINES[@]}" -eq 0 ] && info " $(text 135) " && return + + local DELETE_ITEMS_JSON TMP_FILE + DELETE_ITEMS_JSON=$(printf '%s\n' "${DELETE_ITEM_LINES[@]}" | jq_exec -s 'unique_by(.rule_index, .type, .match)') + TMP_FILE="${CUSTOM_FILE}.tmp" + + # 只删除被选中的数组元素;数组清空后才删除整条 route rule + jq_exec --argjson del "$DELETE_ITEMS_JSON" ' + .route.rules |= ( + [.[]?] as $rules | + reduce range(0; ($rules | length)) as $idx ([]; + ($rules[$idx]) as $rule | + ($del | map(select(.rule_index == $idx and .type == "domain_suffix") | .match)) as $remove_domains | + ($del | map(select(.rule_index == $idx and .type == "rule_set") | .match)) as $remove_sets | + ($rule + | if .domain_suffix? != null then .domain_suffix = ([.domain_suffix[]? as $v | select(($remove_domains | index($v)) | not) | $v]) else . end + | if .rule_set? != null then .rule_set = ([.rule_set[]? as $v | select(($remove_sets | index($v)) | not) | $v]) else . end + | if ((.domain_suffix // []) | length) == 0 then del(.domain_suffix) else . end + | if ((.rule_set // []) | length) == 0 then del(.rule_set) else . end + ) as $new_rule | + if (($new_rule.domain_suffix? != null) or ($new_rule.rule_set? != null)) then + . + [$new_rule] + elif ($del | any(.rule_index == $idx and .type == "unknown")) then + . + else + . + [$new_rule] + end + ) + ) + ' "$CUSTOM_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$CUSTOM_FILE" + + custom_route_compact_rules + + # 清理孤立的 rule_set 定义:只保留仍被 rules 引用的 rule_set + jq_exec ' + (.route.rules | [.[]? | .rule_set // [] | .[]] | unique) as $used | + .route.rule_set |= [.[]? | select(.tag as $t | $used | index($t) | not | not)] + ' "$CUSTOM_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$CUSTOM_FILE" + + # 如果没有任何规则了,删除文件 + local REMAINING + REMAINING=$(jq_exec '.route.rules | length' "$CUSTOM_FILE" 2>/dev/null) + if [ "${REMAINING:-0}" -eq 0 ]; then + rm -f "$CUSTOM_FILE" + fi + + info " $(text 160) " + hint " $(text 112) " + cmd_systemctl restart sing-box + sleep 2 + cmd_systemctl status sing-box &>/dev/null && \ + info "\n Sing-box $(text 28) $(text 37) \n" || \ + warning "\n Sing-box $(text 27) $(text 38) \n" +} + +# 自定义路由规则子菜单 +custom_route_menu() { + while true; do + CUSTOM_ROUTE_COUNT=$(custom_route_count) + hint "\n $(text 150) \n" + hint " $(text 151) " + hint "" + reading " $(text 24) " CUSTOM_ROUTE_CHOICE + + case "$CUSTOM_ROUTE_CHOICE" in + 1 ) custom_route_add ;; + 2 ) custom_route_view ;; + 3 ) custom_route_delete ;; + 0 ) return ;; + * ) info " $(text 135) " && return ;; + esac + done +} + +# ===================== 自定义路由规则 END ===================== + # 输入 Reality 密钥 input_reality_key() { [[ "$NONINTERACTIVE_INSTALL" != 'noninteractive_install' && "$IS_FAST_INSTALL" != 'is_fast_install' ]] && [ -z "$REALITY_PRIVATE" ] && reading "\n ${TOTAL_STEPS:+(${STEP_NUM}/${TOTAL_STEPS}) }$(text 70) " REALITY_PRIVATE