v1.3.19 feat: add real-time traffic stats and speed up CDN probe

- add real-time traffic stats via clash_api: total in/out shown in `sb -n` output and main menu status line
- inject `experimental.clash_api` into the base config, with a fallback patch plus SIGHUP reload for legacy configs and cleanup of stale `v2ray_api`
- add shared `find_free_port()` for both the nginx default port and the clash_api port, probing for unused ports within the script port range
- add `format_traffic()` and `ensure_stats_data()` helpers, fetching `/connections` with curl-first, wget fallback
- speed up the CDN status probe with HEAD requests and explicit timeouts in `get_code()`

v1.3.19 feat: 新增实时流量统计,加速优选地址探测

- 新增基于 clash_api 的实时流量统计:`sb -n` 输出与主菜单状态行展示总入站/出站流量
- 基础配置注入 `experimental.clash_api`,旧配置自动补全并 SIGHUP 热加载,同时清理残留的 `v2ray_api`
- 新增公共 `find_free_port()`,nginx 默认端口与 clash_api 端口共用,在脚本端口范围内探测未被占用的端口
- 新增 `format_traffic()` 与 `ensure_stats_data()` 辅助函数,优先 curl、wget 兜底拉取 `/connections` 数据
- 加速优选地址状态探测:`get_code()` 改用 HEAD 请求并显式设置超时
This commit is contained in:
fscarmen
2026-07-31 15:38:40 +00:00
parent eb5c5a32c7
commit 09036481c9
2 changed files with 262 additions and 91 deletions
+258 -89
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# 当前脚本版本号
VERSION='v1.3.18 (2026.07.28)'
VERSION='v1.3.19 (2026.07.31)'
# Github 反代加速代理
GITHUB_PROXY=('https://hub.glowp.xyz/' 'https://proxy.vvvv.ee/')
@@ -40,8 +40,8 @@ mkdir -p "$TEMP_DIR"
E[0]="Language:\n 1. English (default) \n 2. 简体中文"
C[0]="${E[0]}"
E[1]="1. Add bind_interface option in sb -d menu to bind outbound traffic to a specific NIC; 2. Add enable/disable subscriptions option in sb -d menu 2. Change v2rayN Hysteria2 Realm config from Finalmask field to ProtoExtraObj; 3. add SIGHUP hot-reload support, replace restart sequences with reload; 4. force base-config regeneration on upgrade to guarantee sing-box check passes"
C[1]="1. sb -d 菜单新增「指定网络出口」选项,可为出站流量绑定特定网卡; 2. sb -d 菜单新增 「订阅开关」,可随时开启关闭订阅; 3. v2rayN 的 Hysteria2 Realm 配置从 Finalmask 字段改为 ProtoExtraObj; 4. 新增 SIGHUP 热更支持,用 reload 替换重启流程; 4. 新增 SIGHUP 热更支持,用 reload 替换重启流程; 5. 升级时强制重置基础配置至新版格式,保证兼容性"
E[1]="1. Add bind_interface option in sb -d menu to bind outbound traffic to a specific NIC; 2. Add enable/disable subscriptions option in sb -d menu 3. Change v2rayN Hysteria2 Realm config from Finalmask field to ProtoExtraObj; 4. add SIGHUP hot-reload support, replace restart sequences with reload; 5. force base-config regeneration on upgrade to guarantee sing-box check passes; 6. Add real-time traffic stats (-n / main menu)"
C[1]="1. sb -d 菜单新增「指定网络出口」选项,可为出站流量绑定特定网卡; 2. sb -d 菜单新增 「订阅开关」,可随时开启关闭订阅; 3. v2rayN 的 Hysteria2 Realm 配置从 Finalmask 字段改为 ProtoExtraObj; 4. 新增 SIGHUP 热更支持,用 reload 替换重启流程; 4. 新增 SIGHUP 热更支持,用 reload 替换重启流程; 5. 升级时强制重置基础配置至新版格式,保证兼容性; 6. 添加实时流量统计 (-n / 主菜单)"
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."
@@ -98,8 +98,8 @@ E[28]="open"
C[28]="开启"
E[29]="View links (sb -n)"
C[29]="查看节点信息 (sb -n)"
E[30]="Listen ports (current: \${_val})"
C[30]="监听端口 (当前: \${_val})"
E[30]="Listen ports (current: \${VAL_ITEM})"
C[30]="监听端口 (当前: \${VAL_ITEM})"
E[31]="Sync Sing-box to the latest version (sb -v)"
C[31]="同步 Sing-box 至最新版本 (sb -v)"
E[32]="Upgrade kernel, turn on BBR, change Linux system (sb -b)"
@@ -134,8 +134,8 @@ E[46]="Warp / warp-go was detected to be running. Please enter the correct serve
C[46]="检测到 warp / warp-go 正在运行,请输入确认的服务器 IP:"
E[47]="No server ip, script exits. Feedback:[https://github.com/fscarmen/sing-box/issues]"
C[47]="没有 server ip,脚本退出,问题反馈:[https://github.com/fscarmen/sing-box/issues]"
E[48]="Client Fingerprint (current: \${_val})"
C[48]="客户端指纹 (当前: \${_val})"
E[48]="Client Fingerprint (current: \${VAL_ITEM})"
C[48]="客户端指纹 (当前: \${VAL_ITEM})"
E[49]="Select more protocols to install (e.g. hgbd). The order of the port numbers of the protocols is related to the ordering of the multiple choices:\n a. all (default)"
C[49]="多选需要安装协议(比如 hgbd),协议的端口号次序与多选的排序有关:\n a. all (默认)"
E[50]="Please enter the \$TYPE domain name:"
@@ -172,8 +172,8 @@ E[65]="Open Realm"
C[65]="开启 Realm"
E[66]="Please select the protocols to be added (multiple choices possible. Press Enter to skip):"
C[66]="请选择需要增加的协议(可以多选,回车跳过):"
E[67]="Bind network interface (current: \${_val:-default})"
C[67]="指定网络出口 (当前: \${_val:-默认})"
E[67]="Bind network interface (current: \${VAL_ITEM:-default})"
C[67]="指定网络出口 (当前: \${VAL_ITEM:-默认})"
E[68]="Press [n] if there is an error, other keys to continue:"
C[68]="如有错误请按 [n],其他键继续:"
E[69]="Install sba scripts (argo + sing-box) [https://github.com/fscarmen/sba]"
@@ -294,16 +294,16 @@ E[126]="Network request URL structure is wrong. Missing Zone ID"
C[126]="网络请求地址(URL)结构不对,缺少 Zone ID"
E[127]="Please select what to modify:"
C[127]="请选择修改项目:"
E[128]="Preferred CDN (current: \${_val})"
C[128]="优选域名/IP (当前: \${_val})"
E[129]="Reality SNI (current: \${_val})"
C[129]="Reality SNI (当前: \${_val})"
E[130]="Node name (current: \${_val})"
C[130]="节点名称 (当前: \${_val})"
E[131]="UUID / Password (current: \${_val})"
C[131]="UUID / 密码 (当前: \${_val})"
E[132]="Server IP (current: \${_val})"
C[132]="服务器 IP (当前: \${_val})"
E[128]="Preferred CDN (current: \${VAL_ITEM})"
C[128]="优选域名/IP (当前: \${VAL_ITEM})"
E[129]="Reality SNI (current: \${VAL_ITEM})"
C[129]="Reality SNI (当前: \${VAL_ITEM})"
E[130]="Node name (current: \${VAL_ITEM})"
C[130]="节点名称 (当前: \${VAL_ITEM})"
E[131]="UUID / Password (current: \${VAL_ITEM})"
C[131]="UUID / 密码 (当前: \${VAL_ITEM})"
E[132]="Server IP (current: \${VAL_ITEM})"
C[132]="服务器 IP (当前: \${VAL_ITEM})"
E[133]="Invalid IP address format"
C[133]="IP 地址格式错误"
E[134]="Please enter new value (press Enter to skip):"
@@ -370,44 +370,44 @@ reading() { read -rp "$(info "$1")" "$2"; }
# 预处理:扫描 E/C 数组,把含 $ 的条目下标记录到关联数组,避免 text() 每次调用都启动 grep 子进程
declare -A TEXT_NEEDS_EVAL
for _text_i in "${!E[@]}"; do
[[ "${E[${_text_i}]}" == *'$'* || "${C[${_text_i}]}" == *'$'* ]] && TEXT_NEEDS_EVAL[${_text_i}]=1
for TEXT_I in "${!E[@]}"; do
[[ "${E[${TEXT_I}]}" == *'$'* || "${C[${TEXT_I}]}" == *'$'* ]] && TEXT_NEEDS_EVAL[${TEXT_I}]=1
done
unset _text_i
unset TEXT_I
# text <index>:输出当前语言对应的字符串,含 $ 变量的条目用 eval 展开,其余直接 printf
text() {
local -n _text_arr="${L}" # nameref 指向 E 或 C,零子进程
local _text_val="${_text_arr[$*]}"
local -n TEXT_ARR="${L}" # nameref 指向 E 或 C,零子进程
local TEXT_VAL="${TEXT_ARR[$*]}"
if [[ -n "${TEXT_NEEDS_EVAL[$*]}" ]]; then
eval "printf '%s' \"${_text_val}\""
eval "printf '%s' \"${TEXT_VAL}\""
else
printf '%s' "${_text_val}"
printf '%s' "${TEXT_VAL}"
fi
}
# 根据 INSTALL_PROTOCOLS 计算安装流程总步骤数
# sing-box 协议分类:Reality 类 (b/j/k)、Hysteria2(c)、WS 类 (h/i)
calc_install_steps() {
local _total=5 # 固定步骤:协议选择、起始端口、VPS IP、UUID、节点名
local STEP_TOTAL=5 # 固定步骤:协议选择、起始端口、VPS IP、UUID、节点名
local HAS_REALITY=false HAS_WS=false HAS_HY2=false
for _P in "${INSTALL_PROTOCOLS[@]}"; do
[[ "$_P" =~ ^[bjk]$ ]] && HAS_REALITY=true
[[ "$_P" =~ ^[hi]$ ]] && HAS_WS=true
[[ "$_P" == 'c' ]] && HAS_HY2=true
for PROTO in "${INSTALL_PROTOCOLS[@]}"; do
[[ "$PROTO" =~ ^[bjk]$ ]] && HAS_REALITY=true
[[ "$PROTO" =~ ^[hi]$ ]] && HAS_WS=true
[[ "$PROTO" == 'c' ]] && HAS_HY2=true
done
[[ "$IS_SUB" = 'is_sub' || "$IS_ARGO" = 'is_argo' ]] && (( _total++ )) # nginx 端口
$HAS_REALITY && (( _total++ )) # Reality 私钥
$HAS_WS && (( _total++ )) # CDN / 域名
[[ "$IS_SUB" = 'is_sub' || "$IS_ARGO" = 'is_argo' ]] && (( STEP_TOTAL++ )) # nginx 端口
$HAS_REALITY && (( STEP_TOTAL++ )) # Reality 私钥
$HAS_WS && (( STEP_TOTAL++ )) # CDN / 域名
# Hysteria2 Realm / WARP / Port Hopping are protocol sub-options and are not counted as install steps.
[ "$IS_ARGO" = 'is_argo' ] && (( _total++ )) # Argo 域名
TOTAL_STEPS=$_total
[ "$IS_ARGO" = 'is_argo' ] && (( STEP_TOTAL++ )) # Argo 域名
TOTAL_STEPS=$STEP_TOTAL
}
# 检测是否需要启用 Github CDN,如能直接连通 api.github.com,则不使用
check_cdn() {
local PROXY CODE PID CMD
local _WAIT_COUNT=40
local WAIT_COUNT=40
local PIDS=()
local API_URL='https://api.github.com/repos/SagerNet/sing-box/releases'
@@ -421,13 +421,13 @@ check_cdn() {
return
fi
# 获取 HTTP 状态码
# 获取 HTTP 状态码(HEAD 探测:只取响应头,不下载 body;--tries=1 避免被墙时 wget 默认 20 次重试放大延迟)
get_code() {
local url=$1
if [ "$CMD" = 'wget' ]; then
wget -qT5 -O /dev/null --server-response "$url" 2>&1 | awk '/HTTP\//{code=$2} END{print code}'
wget -q --spider --tries=1 -T5 -O /dev/null --server-response "$url" 2>&1 | awk '/HTTP\//{code=$2} END{print code}'
else
curl -skL -w "%{http_code}" "$url" -o /dev/null
curl -skL -I --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code}' "$url"
fi
}
@@ -448,9 +448,9 @@ check_cdn() {
done
# 等第一个返回 200 的代理,超时则回退为直连,避免无限等待卡死
while [ ! -e "${TEMP_DIR}/cdn_proxy" ] && [ "$_WAIT_COUNT" -gt 0 ]; do
while [ ! -e "${TEMP_DIR}/cdn_proxy" ] && [ "$WAIT_COUNT" -gt 0 ]; do
sleep 0.05
(( _WAIT_COUNT-- )) || true
(( WAIT_COUNT-- )) || true
done
[ -e "${TEMP_DIR}/cdn_proxy" ] && GH_PROXY=$(cat "${TEMP_DIR}/cdn_proxy") || GH_PROXY=''
@@ -704,11 +704,11 @@ change_config() {
# 显示动态菜单
hint "\n $(text 127)\n"
for _i in "${!MENU_IDX[@]}"; do
local _val="${MENU_VAL[_i]}"
local _raw
eval "_raw=\"\${${L}[${MENU_IDX[_i]}]}\""
eval "hint \" $(printf '%2d' $(( _i+1 ))). ${_raw}\""
for MENU_INDEX in "${!MENU_IDX[@]}"; do
local VAL_ITEM="${MENU_VAL[MENU_INDEX]}"
local RAW_ITEM
eval "RAW_ITEM=\"\${${L}[${MENU_IDX[MENU_INDEX]}]}\""
eval "hint \" $(printf '%2d' $(( MENU_INDEX+1 ))). ${RAW_ITEM}\""
done
hint ""
reading " $(text 24) " CHOOSE_NODE_INFO
@@ -864,9 +864,9 @@ change_config() {
[ "$iface" != "lo" ] && IFACE_LIST+=("$iface")
done < <(ifconfig -a 2>/dev/null | awk '/^[a-zA-Z]/')
else
for _if in /sys/class/net/*; do
_if="${_if##*/}"
[ "$_if" != "lo" ] && IFACE_LIST+=("$_if")
for IFACE_ITEM in /sys/class/net/*; do
IFACE_ITEM="${IFACE_ITEM##*/}"
[ "$IFACE_ITEM" != "lo" ] && IFACE_LIST+=("$IFACE_ITEM")
done
fi
mapfile -t IFACE_LIST < <(printf '%s\n' "${IFACE_LIST[@]}" | sort -u)
@@ -874,8 +874,8 @@ change_config() {
hint "\n $(text 77) \n"
hint " $(text 78) "
for _if in "${IFACE_LIST[@]}"; do
hint " $IDX. $_if"
for IFACE_ITEM in "${IFACE_LIST[@]}"; do
hint " $IDX. $IFACE_ITEM"
((IDX++))
done
hint " 0. $(text 35)"
@@ -1181,8 +1181,8 @@ create_argo_tunnel() {
input_nginx_port() {
local NUM=$1
local PORT_ERROR_TIME=6
# 生成 1000 - 65535 随机默认端口数
local PORT_NGINX_DEFAULT=$(shuf -i ${MIN_PORT}-${MAX_PORT} -n 1)
# 在脚本端口范围(MIN_PORT-MAX_PORT)内随机生成一个未被系统占用的默认端口(与 clash_api 共用 find_free_port
local PORT_NGINX_DEFAULT=$(find_free_port)
[[ "$IS_FAST_INSTALL" = 'is_fast_install' && -z "$PORT_NGINX" ]] && PORT_NGINX="$PORT_NGINX_DEFAULT"
while true; do
[[ "$PORT_ERROR_TIME" > 1 && "$PORT_ERROR_TIME" < 6 ]] && unset IN_USED PORT_NGINX
@@ -1590,12 +1590,12 @@ custom_route_add() {
# 将逗号分隔输入转为数组:支持半角/全角逗号、顿号、分号、竖线;不使用 IFS
custom_route_csv_to_array() {
local _input="$1"
local -n _out_array="$2"
_out_array=()
local INPUT_CSV="$1"
local -n OUT_ARRAY="$2"
OUT_ARRAY=()
mapfile -t _out_array < <(
printf '%s\n' "$_input" |
mapfile -t OUT_ARRAY < <(
printf '%s\n' "$INPUT_CSV" |
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'
)
}
@@ -2127,13 +2127,9 @@ cmd_systemctl() {
rc-service "$2" restart >/dev/null 2>&1
;;
reload )
rc-service "$2" reload >/dev/null 2>&1 || rc-service "$2" restart >/dev/null 2>&1
local MAINPID=$(cat /var/run/sing-box.pid 2>/dev/null)
if [ -n "$MAINPID" ] && kill -0 "$MAINPID" 2>/dev/null; then
kill -HUP "$MAINPID" 2>/dev/null
info "\n $(text 95) \n"
else
rc-service "$2" restart >/dev/null 2>&1
fi
[ -n "$MAINPID" ] && info "\n $(text 95) \n"
;;
status )
rc-service "$2" status
@@ -2158,13 +2154,9 @@ cmd_systemctl() {
[ "$IS_CENTOS" = 'CentOS7' ] && [ "$2" = 'sing-box' ] && [ -s $WORK_DIR/nginx.conf ] && nginx_run
;;
reload )
systemctl reload sing-box >/dev/null 2>&1 || systemctl restart sing-box >/dev/null 2>&1
local MAINPID=$(systemctl show -p MainPID sing-box 2>/dev/null | awk -F= '{print $2}')
if [ -n "$MAINPID" ] && [ "$MAINPID" -gt 0 ] 2>/dev/null; then
systemctl kill -s HUP sing-box >/dev/null 2>&1
info "\n $(text 95) \n"
else
systemctl restart sing-box >/dev/null 2>&1
fi
[ -n "$MAINPID" ] && [ "$MAINPID" -gt 0 ] 2>/dev/null && info "\n $(text 95) \n"
;;
status )
systemctl is-active "$2"
@@ -2474,10 +2466,10 @@ input_start_port() {
sing-box_variables() {
STEP_NUM=0
# 预先用全选协议计算最大总步骤数,用于协议选择提示时显示 (1/?)
local _saved_protocols=("${INSTALL_PROTOCOLS[@]}")
local SAVED_PROTOCOLS=("${INSTALL_PROTOCOLS[@]}")
INSTALL_PROTOCOLS=(b c d e f g h i j k l m)
calc_install_steps
INSTALL_PROTOCOLS=("${_saved_protocols[@]}")
INSTALL_PROTOCOLS=("${SAVED_PROTOCOLS[@]}")
if grep -qi 'cloudflare' <<< "$ASNORG4$ASNORG6"; then
if grep -qi 'cloudflare' <<< "$ASNORG6" && [ -n "$WAN4" ] && ! grep -qi 'cloudflare' <<< "$ASNORG4"; then
@@ -3495,6 +3487,76 @@ http {
echo "$NGINX_CONF" > ${WORK_DIR}/nginx.conf
}
# ==================== 流量统计 ====================
# 流量与单位换算:四舍五入保留 1 位小数
format_traffic() {
local BYTES=$1
[ "$BYTES" -lt 1024 ] && { echo "${BYTES} B"; return; }
local DIV UNIT
if [ "$BYTES" -lt $((1024 * 1024)) ]; then
DIV=1024; UNIT=KB
elif [ "$BYTES" -lt $((1024 * 1024 * 1024)) ]; then
DIV=$((1024 * 1024)); UNIT=MB
elif [ "$BYTES" -lt $((1024 * 1024 * 1024 * 1024)) ]; then
DIV=$((1024 * 1024 * 1024)); UNIT=GB
else
DIV=$((1024 * 1024 * 1024 * 1024)); UNIT=TB
fi
local IDX=$((BYTES / DIV))
local REM=$(( ((BYTES % DIV) * 10 + DIV / 2) / DIV ))
[ "$REM" -ge 10 ] && { IDX=$((IDX + 1)); REM=0; }
echo "${IDX}.${REM} ${UNIT}"
}
# 在脚本限制的端口范围内(MIN_PORT-MAX_PORT)随机找一个未被系统占用的端口。
# 逻辑统一为数组承载候选端口:一次性随机生成 16 个端口装入数组,
# 逐个用 ss -nltp 探测占用情况,返回第一个空闲端口;全部占用则返回 1。
# 用两次 RANDOM 组合成 0-65535 的随机值再取模,避免单次 RANDOM(0-32767) 无法覆盖完整范围。
# 供 nginx 默认端口(input_nginx_port)与 clash_api 端口(find_free_api_port)共用。
find_free_port() {
local CAND=() SPAN=$((MAX_PORT - MIN_PORT + 1)) IDX PORT
for IDX in $(seq 1 16); do
CAND+=("$((MIN_PORT + ((RANDOM * 2) + (RANDOM % 2)) % SPAN))")
done
for PORT in "${CAND[@]}"; do
if ! ss -nltp 2>/dev/null | grep -q ":${PORT} "; then
echo "$PORT"; return 0
fi
done
return 1
}
# 在脚本限制的端口范围内随机找一个未被占用的空闲端口,供 clash_api 监听(复用 find_free_port)。
find_free_api_port() {
local PORT
PORT=$(find_free_port) || PORT=10000 # 探测失败则回退默认值
echo "$PORT"
}
# 获取 /connections 流量数据并缓存到全局变量 STATS_JSON(静默降级:任一前置不满足即返回 1)
# clash_api 由官方二进制默认编译(with_clash_api),无需版本门控;
# /connections 返回 { downloadTotal, uploadTotal, connections: [...] }
# downloadTotal / uploadTotal 为进程生命周期累计值
ensure_stats_data() {
[ -n "$STATS_JSON" ] && return 0
[ "${STATUS[0]}" != "$(text 28)" ] && return 1 # Sing-box 未运行
[ ! -x "$WORK_DIR/sing-box" ] && return 1
local API_PORT
# 从 04_experimental.json 解析 clash_api 监听端口(external_controller 形如 127.0.0.1:<port>)。
# 单条 sed 正则提取(排除 // 注释行),不依赖 jq / 多段管道。
API_PORT=$(sed -n '/^[[:space:]]*\/\//!s/.*"external_controller"[[:space:]]*:[[:space:]]*"127\.0\.0\.1:\([0-9][0-9]*\)".*/\1/p' "$WORK_DIR/conf/04_experimental.json" 2>/dev/null | head -1)
[ -z "$API_PORT" ] && return 1
# curl 优先,wget 兜底(脚本安装时已依赖 wget,必存在)
if command -v curl >/dev/null 2>&1; then
STATS_JSON=$(curl -fsS --max-time 3 "http://127.0.0.1:${API_PORT}/connections" 2>/dev/null) || return 1
else
STATS_JSON=$(wget -qO- --timeout=3 "http://127.0.0.1:${API_PORT}/connections" 2>/dev/null) || return 1
fi
[ -n "$STATS_JSON" ] || return 1
}
# 生成 sing-box 基础配置
generate_sing_box_base_conf() {
# 生成 log 配置
@@ -3601,13 +3663,21 @@ EOF
}
EOF
# 生成缓存文件
# 生成缓存文件 + clash_api 流量统计。clash_api 是官方二进制默认编译功能
# with_clash_api 在 DEFAULT_BUILD_TAGS 中),无版本门控;无需枚举
# inbound/outbound tagclash_api 默认统计所有流量)。
# 新装场景(generate_sing_box_base_conf 由 sing-box_json 首次调用)inbound 尚未生成
# 也不影响 clash_api 注入,此处统一直接写入。
CLASH_API_PORT=$(find_free_api_port)
cat > ${WORK_DIR}/conf/04_experimental.json << EOF
{
"experimental": {
"cache_file": {
"enabled": true,
"path": "${WORK_DIR}/cache.db"
},
"clash_api": {
"external_controller": "127.0.0.1:${CLASH_API_PORT}"
}
}
}
@@ -4495,6 +4565,25 @@ fetch_nodes_value() {
# 获取 naive key-value
[ -s ${WORK_DIR}/conf/*_${NODE_TAG[11]}_inbounds.json ] && local JSON=$(cat ${WORK_DIR}/conf/*_${NODE_TAG[11]}_inbounds.json) && NODE_NAME[22]=$(sed -n "s/.*\"tag\":\"\(.*\) ${NODE_TAG[11]}.*/\1/p" <<< "$JSON") && PORT_NAIVE=$(sed -n 's/.*"listen_port":\([0-9]\+\),/\1/gp' <<< "$JSON") && UUID[22]=$(awk -F '"' '/"username"/{print $4; exit}' <<< "$JSON")
# 兜底:极早期版本 04_experimental.json 无 clash_apicache_file-only),补全注入。
# 同时剥离可能残留的 v2ray_api(官方 release 二进制默认不编译该功能,
# 若旧版脚本已注入会在启动时报 "v2ray api is not included in this build")。
# 升级/change 场景 generate_sing_box_base_conf 已统一写入 clash_api,这里无需重复。
if ! grep -q 'clash_api' ${WORK_DIR}/conf/04_experimental.json 2>/dev/null; then
CLASH_API_PORT=$(find_free_api_port)
local EXP_JSON=$(cat ${WORK_DIR}/conf/04_experimental.json)
printf '%s\n' "$EXP_JSON" | "$DIR/jq" 'del(.experimental.v2ray_api) | .experimental += {
"clash_api": {
"external_controller": "127.0.0.1:'"$CLASH_API_PORT"'"
}
}' > ${WORK_DIR}/conf/04_experimental.json
# 补全后立即热加载(SIGHUP)使 clash_api 监听马上生效;
# 前台同步执行确保信号送达(SIGHUP reload 不断连 SSH);仅当 sing-box 运行中才 reload
if pgrep -x sing-box >/dev/null 2>&1; then
cmd_systemctl reload sing-box
fi
fi
}
# 获取 Argo 临时隧道域名
@@ -5295,6 +5384,29 @@ $(hint "$(text 82) 2:")
$(${WORK_DIR}/qrencode $SUBSCRIBE_ADDRESS/${UUID_CONFIRM}/auto2)
"
# === 流量统计块(仅 clash_api 可用时显示;流量为 0 时显示 0 B) ===
# downloadTotal / uploadTotal 为 clash_api 提供的进程生命周期累计值
STATS_JSON='' # 每次调用 export_list 都是独立的用户请求,强制刷新获取实时数据
if ensure_stats_data; then
local IN_SUM OUT_SUM
IN_SUM=$(echo "$STATS_JSON" | $WORK_DIR/jq '.downloadTotal // 0' 2>/dev/null)
OUT_SUM=$(echo "$STATS_JSON" | $WORK_DIR/jq '.uploadTotal // 0' 2>/dev/null)
EXPORT_LIST_FILE="${EXPORT_LIST_FILE}
*******************************************
┌────────────────┐
│ │
$(warning "Traffic Stats")
│ │
└────────────────┘
---------------------------
$(info "⬇ Inbound (total): $(format_traffic $IN_SUM)")
$(hint "⬆ Outbound (total): $(format_traffic $OUT_SUM)")
"
fi
# === 结束 ===
# 生成并显示节点信息
echo "$EXPORT_LIST_FILE" > ${WORK_DIR}/list
cat ${WORK_DIR}/list
@@ -5327,7 +5439,7 @@ change_protocols() {
INSTALLED_PROTOCOLS_LIST=$(awk -F '"' '/"tag":/{print $4}' ${WORK_DIR}/conf/*_inbounds.json | grep -v 'shadowtls-in' | awk '{print $NF}')
for f in ${!NODE_TAG[@]}; do [[ $INSTALLED_PROTOCOLS_LIST =~ "${NODE_TAG[f]}" ]] && EXISTED_PROTOCOLS+=("${PROTOCOL_LIST[f]}") || NOT_EXISTED_PROTOCOLS+=("${PROTOCOL_LIST[f]}"); done
# 列出已安装协议
# 列出已安装协议(保持原有样式,仅显示协议名;F2 流量显示已移除,见需求文档 3.3 节)
hint "\n $(text 136) (${#EXISTED_PROTOCOLS[@]})"
for h in "${!EXISTED_PROTOCOLS[@]}"; do
hint " $(asc $(( h+97 ))). ${EXISTED_PROTOCOLS[h]} "
@@ -5857,10 +5969,22 @@ menu() {
printf '%-11s' "$s"
fi
}
local _SBV; printf -v _SBV '%-26s' "$SING_BOX_VERSION"
local _AV; printf -v _AV '%-26s' "$ARGO_VERSION"
local _NV; printf -v _NV '%-26s' "$NGINX_VERSION"
info "\t Sing-box: $(_sv "${STATUS[0]}") ${_SBV}${SING_BOX_MEMORY_USAGE}\n\t Argo: $(_sv "${STATUS[1]}") ${_AV}${ARGO_MEMORY_USAGE}\n\t Nginx: $(_sv "${STATUS[2]}") ${_NV}${NGINX_MEMORY_USAGE}"
local SBV; printf -v SBV '%-26s' "$SING_BOX_VERSION"
local AV; printf -v AV '%-26s' "$ARGO_VERSION"
local NV; printf -v NV '%-26s' "$NGINX_VERSION"
# === 计算 Sing-box 行流量(仅数据可用时显示;流量为 0 时显示 0 B) ===
# downloadTotal / uploadTotal 为 clash_api 提供的进程生命周期累计值
local SB_TRAFFIC=""
if ensure_stats_data 2>/dev/null; then
local IN_SUM OUT_SUM
IN_SUM=$(echo "$STATS_JSON" | $WORK_DIR/jq '.downloadTotal // 0' 2>/dev/null)
OUT_SUM=$(echo "$STATS_JSON" | $WORK_DIR/jq '.uploadTotal // 0' 2>/dev/null)
SB_TRAFFIC="$(format_traffic $IN_SUM)$(format_traffic $OUT_SUM)"
fi
# === 结束 ===
info "\t Sing-box: $(_sv "${STATUS[0]}") ${SBV}${SING_BOX_MEMORY_USAGE}${SB_TRAFFIC}"
info "\t Argo: $(_sv "${STATUS[1]}") ${AV}${ARGO_MEMORY_USAGE}"
info "\t Nginx: $(_sv "${STATUS[2]}") ${NV}${NGINX_MEMORY_USAGE}"
echo -e "\n======================================================================================================================\n"
for ((b=1;b<=${#OPTION[*]};b++)); do [ "$b" = "${#OPTION[*]}" ] && hint " ${OPTION[0]} " || hint " ${OPTION[b]} "; done
reading "\n $(text 24) " CHOOSE
@@ -5876,6 +6000,51 @@ menu() {
check_cdn
statistics_of_run_times update sing-box.sh 2>/dev/null
###### 为了给旧版本 04_experimental.json 补全 clash_api 配置并剥离 v2ray_api,将于 2026年12月31日移除
if [ -x "$WORK_DIR/jq" ] && [ -s "$WORK_DIR/conf/04_experimental.json" ] && [ -x "$WORK_DIR/sing-box" ] && [[ "$(date +%Y%m%d)" < "20261231" ]]; then
# 旧版本 04_experimental.json 可能缺 clash_api、或含 v2ray_api(旧版脚本注入的)。
# clash_api 是官方 release 二进制默认编译功能(with_clash_api),直接补全;
# v2ray_api 官方 release 二进制默认不编译,残留会导致启动失败,必须剥离。
if ! grep -q 'clash_api' "$WORK_DIR/conf/04_experimental.json" || grep -q 'v2ray_api' "$WORK_DIR/conf/04_experimental.json"; then
API_PORT=$(find_free_api_port)
if grep -q 'clash_api' "$WORK_DIR/conf/04_experimental.json"; then
# 已有 clash_api(可能由新版脚本生成),仅剥离残留的 v2ray_api
grep -v '^//' "$WORK_DIR/conf/04_experimental.json" | $WORK_DIR/jq 'del(.experimental.v2ray_api)' > "$TEMP_DIR/exp_clash_api_tmp.json" 2>/dev/null
else
# 缺失 clash_api,剥离 v2ray_api 并补全 clash_api
grep -v '^//' "$WORK_DIR/conf/04_experimental.json" | $WORK_DIR/jq --arg ec "127.0.0.1:${API_PORT}" '
del(.experimental.v2ray_api) | .experimental += {
"clash_api": { "external_controller": $ec }
}
' > "$TEMP_DIR/exp_clash_api_tmp.json" 2>/dev/null
fi
[ -s "$TEMP_DIR/exp_clash_api_tmp.json" ] && mv "$TEMP_DIR/exp_clash_api_tmp.json" "$WORK_DIR/conf/04_experimental.json" && {
# 修改了 experimental 配置,用 SIGHUP 热加载使 API 监听生效(PID 不变,SSH 连接不断)。
# 此处在 check_system_info() 之前(SYSTEM 未设置)且 select_language() 之前(L 未设置),
# 注意:不能调用 cmd_systemctl reload——其成功分支会调用 info/textnameref 依赖 L),
# 与 Alpine 分支直接 kill -HUP 对称。前台同步执行确保信号送达(SIGHUP 不断连 SSH)。
if [ -d /run/openrc ] || command -v rc-service >/dev/null 2>&1; then
# Alpinekill -HUP 主进程(与 cmd_systemctl reload 的 Alpine 分支一致);PID 不存在时降级 restart。
SB_PID=$(cat /var/run/sing-box.pid 2>/dev/null)
if [ -n "$SB_PID" ] && kill -0 "$SB_PID" 2>/dev/null; then
kill -HUP "$SB_PID" 2>/dev/null
else
rc-service sing-box restart >/dev/null 2>&1
fi
else
# systemd 等:复刻 cmd_systemctl reload 的 systemd 分支(SIGHUP 热加载);PID 不存在时降级 restart。
SB_MAINPID=$(systemctl show -p MainPID sing-box 2>/dev/null | awk -F= '{print $2}')
if [ -n "$SB_MAINPID" ] && [ "$SB_MAINPID" -gt 0 ] 2>/dev/null; then
systemctl kill -s HUP sing-box >/dev/null 2>&1
else
systemctl restart sing-box >/dev/null 2>&1
fi
unset SB_MAINPID
fi
}
fi
fi
###### 为了把原来的 nekobox 换成 Throne 做的处理,将于 2026年9月30日移除
if [ -s $WORK_DIR/nginx.conf ] && grep -q 'Neko|Throne' $WORK_DIR/nginx.conf; then
sed -i 's@~\*Neko|Throne.*@~*Throne|Neko /throne; # 匹配 Throne / Neko 客户端@g' "$WORK_DIR/nginx.conf"
@@ -5888,21 +6057,21 @@ fi
[[ "${*^^}" =~ '-E'|'-K' ]] && L=E
[[ "${*^^}" =~ '-C'|'-B'|'-L' ]] && L=C
# 支持在 select_language 前识别 --LANGUAGE,避免 KV 无交互安装仍弹出语言选择。
for ((_param_i=1; _param_i<=$#; _param_i++)); do
eval "_param_v=\${${_param_i}}"
case "${_param_v^^}" in
for ((PARAM_I=1; PARAM_I<=$#; PARAM_I++)); do
eval "PARAM_V=\${${PARAM_I}}"
case "${PARAM_V^^}" in
--LANGUAGE )
_param_n=$((_param_i+1))
eval "_param_lang=\${${_param_n}}"
[[ "${_param_lang^^}" =~ ^C ]] && L=C || L=E
PARAM_N=$((PARAM_I+1))
eval "PARAM_LANG=\${${PARAM_N}}"
[[ "${PARAM_LANG^^}" =~ ^C ]] && L=C || L=E
;;
--LANGUAGE=* )
_param_lang="${_param_v#*=}"
[[ "${_param_lang^^}" =~ ^C ]] && L=C || L=E
PARAM_LANG="${PARAM_V#*=}"
[[ "${PARAM_LANG^^}" =~ ^C ]] && L=C || L=E
;;
esac
done
unset _param_i _param_v _param_n _param_lang
unset PARAM_I PARAM_V PARAM_N PARAM_LANG
# 获取 -F 参数的值
CONFIG_FILE=$(awk '-F[ =]' 'tolower($1) ~ /^-f$/{print $2}' <<< "$*")
@@ -6081,4 +6250,4 @@ elif [ "$IS_FAST_INSTALL" = 'is_fast_install' ]; then
else
menu_setting
menu
fi
fi