#!/usr/bin/env bash set -euo pipefail shopt -s nullglob SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)" SB_ROOT="${SING_BOX_ROOT:-$SCRIPT_DIR}" SB_BIN="${SING_BOX_BIN:-${SB_ROOT}/sing-box/sing-box}" SB_SYSTEM="${SB_ROOT}/system/config.json" SB_BYPASS="${SB_ROOT}/bypass.list" SB_PROFILES="${SB_ROOT}/profiles" SB_CURRENT="${SB_ROOT}/current.profile" SB_SETTINGS="${SB_ROOT}/settings.json" SB_RUN_DIR="${SING_BOX_RUN_DIR:-${SB_ROOT}/.runtime}" SB_CONFIG="${SB_RUN_DIR}/config.json" SB_RULES="${SB_RUN_DIR}/bypass-rules.json" SB_PID_FILE="${SB_RUN_DIR}/run.pid" SB_LOG="${SB_RUN_DIR}/sing-box.log" SB_PORT="${SING_BOX_PORT:-7890}" SB_API_PORT="${SING_BOX_API_PORT:-9090}" SB_API="http://127.0.0.1:${SB_API_PORT}" die() { printf 'sb: %s\n' "$*" >&2; exit 1; } info() { printf 'sb: %s\n' "$*"; } warn() { printf 'sb: warning: %s\n' "$*" >&2; } need() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; } init_layout() { [[ -x "$SB_BIN" ]] || die "sing-box binary is not executable: $SB_BIN" [[ -f "$SB_SYSTEM" ]] || die "system config not found: $SB_SYSTEM" [[ -f "$SB_BYPASS" ]] || die "bypass list not found: $SB_BYPASS" need jq mkdir -p "$SB_PROFILES" "$SB_RUN_DIR" if [[ ! -f "$SB_SETTINGS" ]]; then printf '%s\n' '{"mode":"rule","tun":false,"cliproxy":false,"sysproxy":false}' >"$SB_SETTINGS" fi } setting() { jq -r --arg key "$1" '.[$key]' "$SB_SETTINGS"; } set_setting() { local key="$1" value="$2" if [[ "$value" == true || "$value" == false ]]; then jq --arg key "$key" --argjson value "$value" '.[$key] = $value' "$SB_SETTINGS" >"${SB_SETTINGS}.tmp" else jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$SB_SETTINGS" >"${SB_SETTINGS}.tmp" fi mv -f "${SB_SETTINGS}.tmp" "$SB_SETTINGS" } profile_dir() { printf '%s/%s\n' "$SB_PROFILES" "$1"; } provider_file() { printf '%s/provider.json\n' "$(profile_dir "$1")"; } metadata_file() { printf '%s/source.json\n' "$(profile_dir "$1")"; } validate_name() { [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]] || die 'profile name contains unsupported characters'; } current_profile() { [[ -f "$SB_CURRENT" ]] || return 1 local name name="$(tr -d '[:space:]' <"$SB_CURRENT")" [[ -n "$name" && -f "$(provider_file "$name")" ]] || return 1 printf '%s\n' "$name" } list_profiles() { local file for file in "$SB_PROFILES"/*/provider.json; do [[ -f "$file" ]] && basename "$(dirname "$file")" done | sort } validate_provider() { jq -e 'type == "object" and (.outbounds | type == "array") and (.outbounds | length > 0)' "$1" >/dev/null \ || die "provider must be sing-box JSON with non-empty .outbounds: $1" } fetch_provider() { local src="$1" dest="$2" if [[ "$src" =~ ^https?:// ]]; then need curl curl -fL --connect-timeout 15 --max-time 90 -A 'sb/1.0 sing-box-provider' "$src" -o "$dest" elif [[ -f "$src" ]]; then cp -f -- "$src" "$dest" else die "source is neither an HTTP URL nor a file: $src" fi validate_provider "$dest" } add_cmd() { local name="${1:-}" src="${2:-}" dir tmp kind saved_src [[ -n "$name" && -n "$src" ]] || die 'usage: sb add ' validate_name "$name" dir="$(profile_dir "$name")" [[ ! -e "$dir" ]] || die "profile already exists: $name" mkdir -p "$dir" tmp="${dir}/provider.json.tmp" if [[ "$src" =~ ^https?:// ]]; then kind=http; saved_src="$src"; else kind=file; saved_src="$(readlink -f "$src")"; fi if ! fetch_provider "$src" "$tmp"; then rm -f "$tmp"; rmdir "$dir" 2>/dev/null || true; return 1; fi mv -f "$tmp" "${dir}/provider.json" jq -n --arg name "$name" --arg type "$kind" --arg source "$saved_src" --arg added_at "$(date -Iseconds)" \ '{name:$name,type:$type,source:$source,added_at:$added_at}' >"${dir}/source.json" printf '%s\n' "$name" >"$SB_CURRENT" info "added profile=${name} type=${kind}" info "run: sb start ${name}" } compile_bypass_rules() { local line value prefix : >"${SB_RULES}.ndjson" while IFS= read -r line || [[ -n "$line" ]]; do line="${line%%#*}" line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")" [[ -n "$line" ]] || continue if [[ "$line" == */* ]]; then jq -nc --arg v "$line" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson" elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then jq -nc --arg v "${line}/32" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson" elif [[ "$line" =~ ^(10|127|172|192)(\..*)?\*$ ]]; then prefix="${BASH_REMATCH[1]}" case "$prefix" in 10) value='10.0.0.0/8';; 127) value='127.0.0.0/8';; 172) value='172.16.0.0/12';; 192) value='192.168.0.0/16';; esac jq -nc --arg v "$value" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson" elif [[ "$line" == \** || "$line" == .* ]]; then value="${line#\*}"; value="${value#.}" [[ -n "$value" ]] && jq -nc --arg v "$value" '{domain_suffix:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson" else jq -nc --arg v "$line" '{domain:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson" fi done <"$SB_BYPASS" jq -s 'unique' "${SB_RULES}.ndjson" >"${SB_RULES}.tmp" mv -f "${SB_RULES}.tmp" "$SB_RULES" rm -f "${SB_RULES}.ndjson" } proxy_bypass_items() { local mode="$1" line value while IFS= read -r line || [[ -n "$line" ]]; do line="${line%%#*}" line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")" [[ -n "$line" ]] || continue value="$line" if [[ "$mode" == no_proxy && "$value" == \*.* ]]; then value=".${value#*.}" elif [[ "$mode" == gsettings && "$value" == .* ]]; then value="*${value}" fi printf '%s\n' "$value" done <"$SB_BYPASS" | awk '!seen[$0]++' } no_proxy_value() { proxy_bypass_items no_proxy | paste -sd, -; } gsettings_bypass_value() { proxy_bypass_items gsettings | jq -Rsc 'split("\n") | map(select(length > 0))'; } build_config() { local profile="$1" provider mode tun provider="$(provider_file "$profile")" [[ -f "$provider" ]] || die "profile not found: $profile" validate_provider "$provider" compile_bypass_rules mode="$(setting mode)"; tun="$(setting tun)" jq -n --slurpfile provider "$provider" --slurpfile system "$SB_SYSTEM" --slurpfile bypass "$SB_RULES" \ --arg mode "$mode" --argjson tun "$tun" --argjson port "$SB_PORT" --arg controller "127.0.0.1:${SB_API_PORT}" ' ($provider[0]) as $p | ($system[0]) as $s | ($bypass[0]) as $b | ($p.outbounds // []) as $original | ($original | map(.tag // empty)) as $tags0 | ($original + if ($tags0|index("DIRECT")) == null then [{type:"direct",tag:"DIRECT"}] else [] end) as $direct_added | ($direct_added | map(.tag // empty)) as $tags1 | ($direct_added + if ($tags1|index("Proxy")) == null then [{type:"selector",tag:"Proxy",outbounds:[$direct_added[]|select(.type!="direct" and .type!="block" and .type!="dns" and .type!="selector")|.tag]}] else [] end) as $outbounds | ($outbounds | map(.tag // empty)) as $outbound_tags | ([{type:"mixed",tag:"mixed-in",listen:"127.0.0.1",listen_port:$port,set_system_proxy:false}] + if $tun then [($p.inbounds // [] | map(select(.type=="tun")) | .[0] // {type:"tun",tag:"tun-in",address:["172.19.0.1/30","fdfe:dcba:9876::1/126"],auto_route:true,strict_route:true})] else [] end) as $inbounds | ([{action:"sniff"}] + if $tun then [{protocol:"dns",action:"hijack-dns"}] else [] end + $b) as $system_rules | (($p.route.rules // []) | map(select(.action!="sniff" and .action!="hijack-dns"))) as $provider_rules | (if $mode=="rule" then $system_rules+$provider_rules else $system_rules end) as $rules | (if $mode=="direct" then "DIRECT" elif $mode=="global" then "Proxy" elif (($p.route.final // "") as $f | ($outbound_tags|index($f)) != null) then $p.route.final else "Proxy" end) as $final | $p | .log=$s.log | .dns=$s.dns | .inbounds=$inbounds | .outbounds=$outbounds | .route=(($p.route // {}) * ($s.route // {})) | .route.auto_detect_interface=$tun | .route.rules=$rules | .route.final=$final | .experimental=(($p.experimental // {}) * ($s.experimental // {})) | .experimental.clash_api.external_controller=$controller ' >"${SB_CONFIG}.tmp" mv -f "${SB_CONFIG}.tmp" "$SB_CONFIG" } process_alive() { local pid="$1" state _ [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null || return 1 read -r _ _ state _ <"/proc/${pid}/stat" 2>/dev/null || return 1 [[ "$state" != Z ]] } is_running() { [[ -f "$SB_PID_FILE" ]] || return 1 process_alive "$(<"$SB_PID_FILE")" } port_in_use() { ss -ltnH 2>/dev/null | awk -v suffix=":$1" 'substr($4,length($4)-length(suffix)+1)==suffix{f=1} END{exit !f}' } api_curl() { local secret="$(jq -r '.experimental.clash_api.secret // empty' "$SB_CONFIG" 2>/dev/null || true)" if [[ -n "$secret" ]]; then curl -fsS --noproxy '*' -H "Authorization: Bearer ${secret}" "$@"; else curl -fsS --noproxy '*' "$@"; fi } wait_api() { local i; for i in {1..50}; do api_curl "${SB_API}/proxies" >/dev/null 2>&1 && return 0; sleep 0.1; done; return 1; } stop_cmd() { if ! is_running; then rm -f "$SB_PID_FILE"; info 'not running'; return 0; fi local pid="$(<"$SB_PID_FILE")" expected actual comm cmdline i expected="$(readlink -f "$SB_BIN")" actual="$(readlink -f "/proc/${pid}/exe" 2>/dev/null || true)" comm="$(cat "/proc/${pid}/comm" 2>/dev/null || true)" cmdline="$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true)" if [[ -z "$actual" ]] && ! process_alive "$pid"; then rm -f "$SB_PID_FILE"; info 'process already exited'; return 0 fi if [[ "$actual" != "$expected" ]]; then [[ "$comm" == "$(basename "$SB_BIN")" && "$cmdline" == "$SB_BIN run "* && "$cmdline" == *" -c $SB_CONFIG"* ]] \ || die "refusing to stop pid ${pid}: process identity does not match sb runtime" fi kill "$pid" 2>/dev/null || true for i in {1..30}; do process_alive "$pid" || break; sleep 0.1; done process_alive "$pid" && die "pid ${pid} did not stop" rm -f "$SB_PID_FILE"; info "stopped pid ${pid}" } choose_profile() { local requested="${1:-}" current pick i local -a profiles mapfile -t profiles < <(list_profiles) (( ${#profiles[@]} )) || die 'no providers; use: sb add ' current="$(current_profile 2>/dev/null || true)" if [[ -n "$requested" ]]; then [[ -f "$(provider_file "$requested")" ]] || die "profile not found: $requested"; echo "$requested"; return; fi if [[ ! -t 0 ]]; then [[ -n "$current" ]] || die 'non-interactive start requires profile name'; echo "$current"; return; fi echo 'Available providers:' >&2 for i in "${!profiles[@]}"; do [[ "${profiles[$i]}" == "$current" ]] && printf ' %d) %s (current)\n' "$((i+1))" "${profiles[$i]}" >&2 || printf ' %d) %s\n' "$((i+1))" "${profiles[$i]}" >&2 done while true; do read -r -p "Choose provider (1-${#profiles[@]}, 0 cancel): " pick [[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.' >&2; continue; } (( pick==0 )) && return 1 (( pick>=1 && pick<=${#profiles[@]} )) || { echo 'Out of range.' >&2; continue; } echo "${profiles[$((pick-1))]}"; return done } start_cmd() { local profile pid i profile="$(choose_profile "${1:-}")" || { info 'cancelled'; return; } is_running && stop_cmd build_config "$profile" "$SB_BIN" check -D "$SB_RUN_DIR" -c "$SB_CONFIG" need ss port_in_use "$SB_PORT" && die "port ${SB_PORT} is in use (stop mihomo before sb)" port_in_use "$SB_API_PORT" && die "API port ${SB_API_PORT} is in use (stop mihomo before sb)" : >"$SB_LOG" nohup "$SB_BIN" run -D "$SB_RUN_DIR" -c "$SB_CONFIG" >>"$SB_LOG" 2>&1 & pid=$! echo "$pid" >"$SB_PID_FILE"; echo "$profile" >"$SB_CURRENT" for i in {1..40}; do if ! kill -0 "$pid" 2>/dev/null; then rm -f "$SB_PID_FILE"; tail -n 50 "$SB_LOG" >&2 || true; die 'sing-box exited during startup'; fi if port_in_use "$SB_PORT" && port_in_use "$SB_API_PORT"; then info "started profile=${profile} pid=${pid} mode=$(setting mode) tun=$(setting tun)"; wait_api || warn 'API not ready'; return fi sleep 0.1 done tail -n 50 "$SB_LOG" >&2 || true; die 'startup timed out' } restart_if_running() { local profile="$(current_profile 2>/dev/null || true)" if is_running; then stop_cmd; start_cmd "$profile"; fi } require_api() { is_running || die 'sing-box is not running'; api_curl "${SB_API}/proxies" >/dev/null || die "API unavailable: $SB_API"; } urlencode() { jq -nr --arg v "$1" '$v|@uri'; } api_delay() { local node="$1" timeout="${2:-1800}" encoded url encoded="$(urlencode "$node")"; url="$(urlencode 'https://www.gstatic.com/generate_204')" api_curl "${SB_API}/proxies/${encoded}/delay?timeout=${timeout}&url=${url}" 2>/dev/null \ | jq -r 'if (.delay|type)=="number" then .delay else 999999 end' 2>/dev/null || echo 999999 } delay_text() { local delay="${1:-}" [[ "$delay" =~ ^[0-9]+$ && "$delay" != 999999 ]] && printf '%sms\n' "$delay" || printf 'timeout\n' } main_group() { jq -r 'if .proxies.Proxy.all then "Proxy" else [.proxies|to_entries[]|select(.value.all!=null)][0].key//empty end' <<<"$1"; } display_node_grid() { local -n ref=$1 local count=${#ref[@]} width cols=1 rows r c idx width="$(tput cols 2>/dev/null || echo 120)" (( count>18 && width>=110 )) && cols=2 (( count>36 && width>=170 )) && cols=3 rows=$(((count+cols-1)/cols)) for ((r=0;r}"; mapfile -t nodes <<<"$candidates" tmp="$(mktemp -d "${SB_RUN_DIR}/delay.XXXXXX")"; info "testing ${#nodes[@]} nodes (1800ms, 8 jobs)..." for i in "${!nodes[@]}"; do (api_delay "${nodes[$i]}" >"${tmp}/${i}") & running=$((running+1)) if ((running>=8)); then wait -n 2>/dev/null || wait; running=$((running-1)); fi done wait; : >"${tmp}/index" for i in "${!nodes[@]}"; do d="$(<"${tmp}/${i}")"; [[ "$d" =~ ^[0-9]+$ ]] || d=999999; printf '%s\t%s\n' "$d" "$i" >>"${tmp}/index"; done while IFS=$'\t' read -r d i; do sorted_delays+=("$d"); sorted_nodes+=("${nodes[$i]}"); done < <(sort -n "${tmp}/index") rm -rf -- "$tmp" printf 'Group: %s Current: %s' "$group" "$now"; [[ -n "$keyword" ]] && printf ' Filter: %s' "$keyword"; echo for i in "${!sorted_nodes[@]}"; do d="$(delay_text "${sorted_delays[$i]}")" [[ "${sorted_nodes[$i]}" == "$now" ]] && display+=("[$((i+1))] * ${sorted_nodes[$i]} ${d}") || display+=("[$((i+1))] ${sorted_nodes[$i]} ${d}") done display_node_grid display [[ -t 0 ]] || return while true; do read -r -p "Choose node (1-${#sorted_nodes[@]}, 0 cancel): " pick [[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.'; continue; } ((pick==0)) && { info cancelled; return; } ((pick>=1 && pick<=${#sorted_nodes[@]})) || { echo 'Out of range.'; continue; }; break done encoded="$(urlencode "$group")"; body="$(jq -nc --arg name "${sorted_nodes[$((pick-1))]}" '{name:$name}')" api_curl -X PUT -H 'Content-Type: application/json' --data "$body" "${SB_API}/proxies/${encoded}" >/dev/null info "${group} -> ${sorted_nodes[$((pick-1))]} (${sorted_delays[$((pick-1))]}ms)" } show_env() { local no_proxy no_proxy="$(no_proxy_value)" cat < 127.0.0.1:${SB_PORT}" ;; off) gsettings set org.gnome.system.proxy mode none; set_setting sysproxy false; info 'system proxy OFF' ;; status) printf 'sysproxy setting=%s gnome-mode=%s\n' "$(setting sysproxy)" "$(gsettings get org.gnome.system.proxy mode)" ;; *) die 'usage: sb set sysproxy on|off' ;; esac } default_bypass() { cat <<'EOF' # sb system bypass rules (one item per line) localhost *.local 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.2 100.64.0.0/10 ::1/128 fc00::/7 fe80::/10 EOF } rules_cmd() { case "${1:-show}" in show) echo "bypass.list: $SB_BYPASS"; nl -ba "$SB_BYPASS" ;; update) compile_bypass_rules; [[ "$(setting sysproxy)" == true ]] && sysproxy_cmd on; info "compiled $(jq length "$SB_RULES") bypass rules"; restart_if_running ;; reset) default_bypass >"${SB_BYPASS}.tmp"; mv -f "${SB_BYPASS}.tmp" "$SB_BYPASS"; compile_bypass_rules; [[ "$(setting sysproxy)" == true ]] && sysproxy_cmd on; info 'bypass.list reset'; restart_if_running ;; *) die 'usage: sb set rules show|update|reset' ;; esac } set_cmd() { local target="${1:-}" action="${2:-}" [[ -n "$target" ]] || { jq . "$SB_SETTINGS"; sysproxy_cmd status 2>/dev/null || true; return; } case "$target" in cliproxy) [[ "$action" == on || "$action" == off ]] || die 'usage: sb set cliproxy on|off' set_setting cliproxy "$([[ "$action" == on ]] && echo true || echo false)"; info "CLI proxy ${action}" [[ -n "${SB_SHELL_HOOK:-}" ]] || warn 'current shell needs one-time: eval "$(sb shell-init)"' ;; sysproxy) sysproxy_cmd "$action" ;; proxy) [[ "$action" == on || "$action" == off ]] || die 'usage: sb set proxy on|off' set_setting cliproxy "$([[ "$action" == on ]] && echo true || echo false)"; sysproxy_cmd "$action"; info "CLI + system proxy ${action}" [[ -n "${SB_SHELL_HOOK:-}" ]] || warn 'current shell needs one-time: eval "$(sb shell-init)"' ;; tun) [[ "$action" == on || "$action" == off ]] || die 'usage: sb set tun on|off' set_setting tun "$([[ "$action" == on ]] && echo true || echo false)" if [[ "$action" == on ]] && ! getcap "$SB_BIN" 2>/dev/null | grep -q cap_net_admin; then warn "TUN may need: sudo setcap cap_net_admin,cap_net_raw+ep '$SB_BIN'"; fi info "TUN ${action}"; restart_if_running ;; rules) rules_cmd "$action" ;; *) die 'usage: sb set cliproxy|sysproxy|proxy|tun|rules ...' ;; esac } update_cmd() { local profile meta source type dir tmp running=false profile="$(current_profile 2>/dev/null || true)"; [[ -n "$profile" ]] || die 'no current profile' meta="$(metadata_file "$profile")"; [[ -f "$meta" ]] || die "missing metadata: $meta" type="$(jq -r .type "$meta")"; source="$(jq -r .source "$meta")"; [[ "$type" == http ]] || die 'current provider is a local file, not HTTP' is_running && running=true; dir="$(profile_dir "$profile")"; tmp="${dir}/provider.json.tmp" fetch_provider "$source" "$tmp"; mv -f "$tmp" "${dir}/provider.json" jq --arg t "$(date -Iseconds)" '.updated_at=$t' "$meta" >"${meta}.tmp"; mv -f "${meta}.tmp" "$meta" info "updated HTTP provider: $profile"; [[ "$running" == true ]] && { stop_cmd; start_cmd "$profile"; } } mode_cmd() { local mode="${1:-}"; [[ "$mode" == rule || "$mode" == global || "$mode" == direct ]] || die 'usage: sb mode rule|global|direct' set_setting mode "$mode"; info "mode -> $mode"; restart_if_running } status_cmd() { local profile="$(current_profile 2>/dev/null || true)" now='' delay='' if is_running; then now="$(api_curl "${SB_API}/proxies/Proxy" 2>/dev/null | jq -r '.now//empty' || true)" [[ -n "$now" ]] && delay="$(delay_text "$(api_delay "$now")")" printf 'sing-box: RUNNING pid=%s\n' "$(<"$SB_PID_FILE")" else echo 'sing-box: STOPPED' fi printf 'root: %s\nprofile: %s\nmode: %s\ntun: %s\ncliproxy: %s\nsysproxy: %s\nmixed: 127.0.0.1:%s\napi: 127.0.0.1:%s\n' \ "$SB_ROOT" "${profile:-}" "$(setting mode)" "$(setting tun)" "$(setting cliproxy)" "$(setting sysproxy)" "$SB_PORT" "$SB_API_PORT" [[ -n "$now" ]] && printf 'node: %s %s\n' "$now" "$delay" } check_cmd() { local profile="${1:-}"; [[ -n "$profile" ]] || profile="$(current_profile 2>/dev/null || true)"; [[ -n "$profile" ]] || die 'no profile to check' build_config "$profile"; "$SB_BIN" check -D "$SB_RUN_DIR" -c "$SB_CONFIG"; info "configuration OK: profile=$profile" } db_ports() { echo "== listening proxy ports ==" ss -ltnp 2>/dev/null | awk -v p1=":${SB_PORT}" -v p2=":${SB_API_PORT}" 'NR==1 || index($4,p1) || index($4,p2)' if ! port_in_use "$SB_PORT" && ! port_in_use "$SB_API_PORT"; then echo 'no sb ports listening'; fi } db_process() { local pid="${1:-}" [[ -n "$pid" ]] || { [[ -f "$SB_PID_FILE" ]] && pid="$(<"$SB_PID_FILE")" || true; } [[ "$pid" =~ ^[0-9]+$ ]] || { echo 'no managed PID'; return 1; } ps -p "$pid" -o pid,ppid,user,state,etime,%cpu,%mem,cmd echo printf 'comm: '; cat "/proc/${pid}/comm" 2>/dev/null || echo '' printf 'cmdline: '; tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true; echo } db_probe_one() { local mode="$1" url="$2" case "$mode" in direct) curl -fsSIL --max-time 10 --noproxy '*' "$url" -o /dev/null ;; proxy) curl -fsSIL --max-time 10 --proxy "http://127.0.0.1:${SB_PORT}" "$url" -o /dev/null ;; esac } db_net() { local mode="${1:-proxy}" url direct_result proxy_result local -a urls=( 'https://www.google.com' 'https://www.cloudflare.com' 'https://www.github.com' 'https://www.youtube.com' 'https://www.baidu.com' 'https://www.bing.com' ) case "$mode" in proxy) is_running || die 'sing-box is not running; proxy test unavailable' echo "== proxy network test (127.0.0.1:${SB_PORT}) ==" for url in "${urls[@]}"; do db_probe_one proxy "$url" && proxy_result=OK || proxy_result=FAIL; printf '%-32s PROXY:%s\n' "$url" "$proxy_result"; done ;; direct) echo '== direct network test ==' for url in "${urls[@]}"; do db_probe_one direct "$url" && direct_result=OK || direct_result=FAIL; printf '%-32s DIRECT:%s\n' "$url" "$direct_result"; done ;; compare) is_running || die 'sing-box is not running; comparison needs the mixed proxy' echo '== DIRECT vs PROXY ==' for url in "${urls[@]}"; do db_probe_one direct "$url" && direct_result=OK || direct_result=FAIL db_probe_one proxy "$url" && proxy_result=OK || proxy_result=FAIL printf '%-32s DIRECT:%-4s PROXY:%-4s\n' "$url" "$direct_result" "$proxy_result" done ;; *) die 'usage: sb db net [proxy|direct] | sb db compare' ;; esac } db_deps() { local item path rc=0 local -a required=(jq curl ss sed sort readlink) local -a optional=(gsettings getcap ip resolvectl tput) echo '== required commands ==' for item in "${required[@]}"; do path="$(command -v "$item" 2>/dev/null || true)"; [[ -n "$path" ]] && printf 'OK %-12s %s\n' "$item" "$path" || { printf 'MISSING %s\n' "$item"; rc=1; }; done echo '== optional commands ==' for item in "${optional[@]}"; do path="$(command -v "$item" 2>/dev/null || true)"; [[ -n "$path" ]] && printf 'OK %-12s %s\n' "$item" "$path" || printf 'MISSING %s\n' "$item"; done [[ -x "$SB_BIN" ]] && echo "OK sing-box $SB_BIN" || { echo "MISSING sing-box $SB_BIN"; rc=1; } jq -e . "$SB_SYSTEM" "$SB_SETTINGS" >/dev/null && echo 'OK system/settings JSON' || rc=1 return "$rc" } db_interfaces() { echo '== interfaces ==' if command -v ip >/dev/null 2>&1; then ip -brief link show; else echo 'ip command not found'; fi echo echo '== TUN expectation ==' printf 'setting: %s\n' "$(setting tun)" if command -v ip >/dev/null 2>&1; then ip -brief link show | awk '$1 ~ /^(tun|sing|utun)/ {print}' || true; fi } db_dns() { echo '== sb DNS configuration ==' jq .dns "$SB_SYSTEM" echo echo '== host resolver ==' if command -v resolvectl >/dev/null 2>&1; then resolvectl status 2>/dev/null | sed -n '1,140p'; else cat /etc/resolv.conf; fi } db_routes() { if ! command -v ip >/dev/null 2>&1; then echo 'ip command not found'; return 1; fi echo '== routes =='; ip route show echo; echo '== policy rules =='; ip rule show echo; echo '== IPv6 routes =='; ip -6 route show 2>/dev/null || true } db_api() { local json require_api echo "== Clash API ${SB_API} ==" api_curl "${SB_API}/version" | jq . json="$(api_curl "${SB_API}/proxies")" echo; echo '== selector groups ==' jq -r '.proxies | to_entries[] | select(.value.all != null) | "\(.key) -> \(.value.now // "-") (\(.value.all|length) choices)"' <<<"$json" } db_config() { local profile profile="$(current_profile 2>/dev/null || true)" [[ -n "$profile" ]] || die 'no current profile' check_cmd "$profile" jq '{log,inbounds,outbounds:(.outbounds|map({type,tag})),dns,route:{final:.route.final,rule_count:(.route.rules|length),rule_set_count:(.route.rule_set//[]|length)},experimental}' "$SB_CONFIG" } db_log() { local lines="${1:-80}" [[ "$lines" =~ ^[0-9]+$ ]] || die 'usage: sb db log [lines]' [[ -f "$SB_LOG" ]] && tail -n "$lines" "$SB_LOG" || echo 'no log file' } db_menu() { local pick i local -a commands=(ports process net compare deps interfaces dns routes api config log) local -a descriptions=( 'proxy/API port listeners' 'managed process details' 'proxy-only network test' 'direct versus proxy network test' 'required and optional dependencies' 'network interfaces and TUN expectation' 'sing-box DNS and host resolver' 'routes and policy routing' 'Clash API and selector groups' 'merged configuration summary and check' 'recent sing-box log' ) echo '== sb db ==' for i in "${!commands[@]}"; do printf ' %2d) %-12s %s\n' "$((i+1))" "${commands[$i]}" "${descriptions[$i]}"; done [[ -t 0 ]] || return 0 while true; do read -r -p "Choose debug action (1-${#commands[@]}, 0 cancel): " pick [[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.'; continue; } (( pick==0 )) && { info cancelled; return 0; } (( pick>=1 && pick<=${#commands[@]} )) || { echo 'Out of range.'; continue; } db_cmd "${commands[$((pick-1))]}"; return done } db_cmd() { local action="${1:-menu}"; shift || true case "$action" in menu) db_menu;; ports) db_ports;; process) db_process "${1:-}";; net) db_net "${1:-proxy}";; compare) db_net compare;; deps) db_deps;; interfaces|ifaces) db_interfaces;; dns) db_dns;; routes|route) db_routes;; api) db_api;; config) db_config;; log) db_log "${1:-80}";; help|-h|--help) db_menu;; *) die 'usage: sb db [ports|process|net|compare|deps|interfaces|dns|routes|api|config|log]' ;; esac } doctor_cmd() { echo '== sb doctor =='; status_cmd; echo; "$SB_BIN" version | sed -n '1,2p'; getcap "$SB_BIN" 2>/dev/null || true echo; echo '== profiles =='; list_profiles || true; echo; echo '== config =='; check_cmd echo; echo '== log =='; [[ -f "$SB_LOG" ]] && tail -n 30 "$SB_LOG" || echo 'no log' } help_cmd() { local profile="$(current_profile 2>/dev/null || true)" count="$(list_profiles | wc -l)" cat < Add a sing-box JSON provider start [name] Choose and start a provider profile stop | end Stop managed sing-box update Update current HTTP provider -l, --list [keyword] Speed-test, sort, show, choose nodes mode rule|global|direct Set route mode and restart set cliproxy on|off Terminal proxy state (shell hook) set sysproxy on|off GNOME system proxy set proxy on|off Both terminal and system proxy set tun on|off TUN and restart set rules show|update|reset Manage root bypass.list status | check [name] Status / merged config check shell-init Print Bash integration env | noenv Print exports / unsets db [action] Debug menu: ports/process/net/DNS/routes/API/config log | doctor Log / diagnostics -h, --help Help plus concise configuration root: $SB_ROOT profiles: $count current: ${profile:-} mode: $(setting mode) tun: $(setting tun) cliproxy: $(setting cliproxy) sysproxy: $(setting sysproxy) mixed: 127.0.0.1:$SB_PORT api: 127.0.0.1:$SB_API_PORT EOF } init_layout case "${1:-status}" in add) shift; add_cmd "$@";; start) shift; start_cmd "${1:-}";; stop|end) stop_cmd;; update) update_cmd;; -l|--list|list) shift; list_nodes_cmd "${1:-}";; mode) shift; mode_cmd "${1:-}";; set) shift; set_cmd "$@";; status) status_cmd;; check) shift; check_cmd "${1:-}";; env) show_env;; noenv) show_noenv;; shell-init) shell_init_cmd;; db) shift; db_cmd "$@";; log) touch "$SB_LOG"; tail -n 80 -f "$SB_LOG";; doctor) doctor_cmd;; -h|--help|help) help_cmd;; *) help_cmd >&2; exit 2;; esac