#!/bin/bash # Sing-Box 四協議一鍵安裝腳本 v3.4.10 — 正式發佈版 # VLESS-Reality · VMess-WS · Hysteria2 · TUIC V5 # 服務端: TCP 統一 443 · Nginx SNI 分流 · 顯式 DoT resolver + 本地 DNS 規則(非故障回退) # 客戶端模板: canonical 精確支援 1.13.21 + 1.14.0;v114 使用 1.14 原生 HTTP Client · FakeIP 雙棧 · 嚴格 DNS/QUIC/STUN 策略 · Mux # 注意: FakeIP、DNS/QUIC/STUN 策略與雙棧 DNS 為客戶端模板能力;服務端僅保留最小 DNS 依賴供自身路由解析 export LANG=en_US.UTF-8 red='\\033[0;31m' green='\\033[0;32m' yellow='\\033[0;33m' blue='\\033[0;36m' bblue='\\033[0;34m' plain='\\033[0m' UPDATE_URL="${UPDATE_URL:-}" UPDATE_SHA256="${UPDATE_SHA256:-}" UPDATE_SHA256_URL="${UPDATE_SHA256_URL:-}" SB_ALLOW_UNVERIFIED_UPDATE="${SB_ALLOW_UNVERIFIED_UPDATE:-0}" SB_BATCH_MODE="${SB_BATCH_MODE:-0}" SB_ENABLE_UFW="${SB_ENABLE_UFW:-0}" SB_ROTATE_UUID_ON_MIGRATE="${SB_ROTATE_UUID_ON_MIGRATE:-0}" SB_ROTATE_REALITY_SNI_ON_MIGRATE="${SB_ROTATE_REALITY_SNI_ON_MIGRATE:-0}" SB_ROTATE_VM_WS_PATH_ON_MIGRATE="${SB_ROTATE_VM_WS_PATH_ON_MIGRATE:-0}" # --- v3.4 可調參數 --- # 客戶端嚴格策略:port53 劫持、TCP/UDP 853、UDP/443 與 STUN 阻斷。 # SB_DNS_REJECT_BYPASS 是向後兼容的總開關;三個細分開關可單獨覆蓋。 SB_DNS_PORT53_HIJACK="${SB_DNS_PORT53_HIJACK:-1}" SB_DNS_REJECT_BYPASS="${SB_DNS_REJECT_BYPASS:-1}" SB_CLIENT_REJECT_DNS_853="${SB_CLIENT_REJECT_DNS_853:-$SB_DNS_REJECT_BYPASS}" SB_CLIENT_REJECT_UDP443="${SB_CLIENT_REJECT_UDP443:-$SB_DNS_REJECT_BYPASS}" SB_CLIENT_REJECT_STUN="${SB_CLIENT_REJECT_STUN:-$SB_DNS_REJECT_BYPASS}" # strict_proxy keeps every public resolver behind the selected proxy. # balanced_cn reaches only Alibaba/Tencent encrypted DNS directly and is not # a zero-direct-DNS profile. SB_CLIENT_DNS_PROFILE="${SB_CLIENT_DNS_PROFILE:-strict_proxy}" SB_CLIENT_DNS_CACHE_CAPACITY="${SB_CLIENT_DNS_CACHE_CAPACITY:-4096}" # 服務端 DNS 上游 (DoT, IP only) SB_SERVER_DNS_UPSTREAM="${SB_SERVER_DNS_UPSTREAM:-1.1.1.1}" # 服務端域名解析策略。空值只允許首次生成時從既有配置或網路探測取得, # 成功生成後必須持久化到 sb.user.env;auto 表示顯式選擇省略 strategy。 SB_SERVER_IP_STRATEGY_ENV="${SB_SERVER_IP_STRATEGY:-}" SB_SERVER_IP_STRATEGY="${SB_SERVER_IP_STRATEGY:-}" # normalize-v3-identity 的批次模式確認詞,僅從本次進程環境讀取。 SB_NORMALIZE_V3_CONFIRM="${SB_NORMALIZE_V3_CONFIRM:-}" # 客戶端 VMess Mux SB_CLIENT_MUX="${SB_CLIENT_MUX:-0}" SB_CLIENT_INTERRUPT_ON_SWITCH="${SB_CLIENT_INTERRUPT_ON_SWITCH:-0}" SB_CLIENT_CERT_PIN="${SB_CLIENT_CERT_PIN:-0}" # URLTest 探測參數 SB_URLTEST_INTERVAL="${SB_URLTEST_INTERVAL:-3m}" SB_URLTEST_TOLERANCE="${SB_URLTEST_TOLERANCE:-100}" # 自動測速必須使用字面 IP,避免 DNS 出站尚未建立時形成遞迴解析。 SB_URLTEST_URL="${SB_URLTEST_URL:-https://1.1.1.1/cdn-cgi/trace}" SB_URLTEST_IDLE_TIMEOUT="${SB_URLTEST_IDLE_TIMEOUT:-30m}" # TCP buffer / sysctl 調優 (BBR 之外的額外參數) SB_SYSCTL_TUNE="${SB_SYSCTL_TUNE:-1}" # HY2 預設不輸出固定帶寬,使用 sing-box/Hysteria2 的 BBR 路徑。 # 只有同時提供兩個正整數時才啟用固定 Hysteria CC 帶寬。 SB_HY2_UP_MBPS="${SB_HY2_UP_MBPS:-}" SB_HY2_DOWN_MBPS="${SB_HY2_DOWN_MBPS:-}" SB_CLIENT_TUIC_CONGESTION_CONTROL="${SB_CLIENT_TUIC_CONGESTION_CONTROL:-bbr}" ACME_SH_VERSION="${ACME_SH_VERSION:-3.1.4}" SB_CURL_CONNECT_TIMEOUT="${SB_CURL_CONNECT_TIMEOUT:-8}" SB_CURL_METADATA_MAX_TIME="${SB_CURL_METADATA_MAX_TIME:-30}" SB_CURL_ARTIFACT_MAX_TIME="${SB_CURL_ARTIFACT_MAX_TIME:-300}" SB_CURL_RETRY="${SB_CURL_RETRY:-2}" # GitHub API 不可用時,可為本次精確 SB_VERSION 顯式提供官方資產 digest。 # 此值只從進程環境讀取且不持久化,避免跨版本誤用。 SB_CORE_SHA256="${SB_CORE_SHA256:-}" SB_LEGACY_ENV_FILE="/etc/s-box/sb.env" SB_USER_ENV_FILE="/etc/s-box/sb.user.env" SB_STATE_ENV_FILE="/etc/s-box/sb.state.env" SB_ENV_FILE="$SB_STATE_ENV_FILE" SB_CERT_RENEW_SCRIPT="/etc/s-box/cert_renew.sh" SB_CERT_RENEW_STATUS="/etc/s-box/cert_renew.status" SB_CERT_RENEW_LOG="/etc/s-box/cert_renew.log" SB_CERT_RENEW_CRON_MARK="# sb-cert-renew" SB_HY2_HOP_SCRIPT="/etc/s-box/hy2_port_hop.sh" SB_HY2_HOP_SERVICE="sb-hy2-hop.service" SB_TUN_PREPARE_SERVICE="sb-tun-prepare.service" SB_NGINX_BACKUP_DIR="/etc/s-box/nginx-backups" SB_NGINX_STREAM_CONF="/etc/nginx/stream.d/sb_sni_dispatch.conf" SB_NGINX_HTTP_CONF="/etc/nginx/conf.d/sb_vmess_proxy.conf" SB_NGINX_MANIFEST="/etc/s-box/nginx_manifest.tsv" SB_SUPPORTED_CLIENT_VERSIONS="1.13.21 1.14.0" SB_EXPECTED_LATEST_STABLE="1.14.0" SB_DEFAULT_CORE_VERSION="1.14.0" SB_RULESET_REVISION="c8f7c90a1e3d834c0415202ccd85a8dfc1053165" SB_NGINX_DEFAULT_BACKEND="${SB_NGINX_DEFAULT_BACKEND:-https}" SB_ALLOW_MULTI_HTTPS="${SB_ALLOW_MULTI_HTTPS:-0}" SB_HTTP_FALLBACK_ROOT="${SB_HTTP_FALLBACK_ROOT:-}" SB_GEOSITE_GEOLOCATION_NONCN_URL="${SB_GEOSITE_GEOLOCATION_NONCN_URL:-https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geosite/geolocation-!cn.srs}" SB_GEOSITE_CN_URL="${SB_GEOSITE_CN_URL:-https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geosite/geolocation-cn.srs}" SB_GEOIP_CN_URL="${SB_GEOIP_CN_URL:-https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geoip/cn.srs}" SB_SERVER_PRIVATE_IP_POLICY="${SB_SERVER_PRIVATE_IP_POLICY:-reject}" SB_HY2_MASQUERADE_MODE="${SB_HY2_MASQUERADE_MODE:-local}" SB_REALITY_MAX_TIME_DIFFERENCE="${SB_REALITY_MAX_TIME_DIFFERENCE:-1m}" SB_TUIC_AUTH_TIMEOUT="${SB_TUIC_AUTH_TIMEOUT:-3s}" SB_SECURITY_MIN_CORE_VERSION="${SB_SECURITY_MIN_CORE_VERSION:-1.13.21}" SB_LATEST_STABLE_VERSION="" SB_LATEST_STABLE_PUBLISHED_AT="" SB_LATEST_STABLE_PROBED="0" SB_ROLLBACK_ACTIVE="0" SB_ROLLBACK_SNAPSHOT_DIR="" SB_ROLLBACK_CONTEXT="" SB_ROLLBACK_RUNNING="0" SB_ROLLBACK_FAILED_EXIT_CODE=70 SB_MIGRATE_OLD_VLESS_PORT="" SB_MIGRATE_OLD_VMESS_PORT="" SB_RESOLVED_SERVER_IP_STRATEGY="" SB_SERVER_IP_STRATEGY_SOURCE="" red(){ echo -e "\\033[31m\\033[01m$1\\033[0m";} green(){ echo -e "\\033[32m\\033[01m$1\\033[0m";} yellow(){ echo -e "\\033[33m\\033[01m$1\\033[0m";} blue(){ echo -e "\\033[36m\\033[01m$1\\033[0m";} white(){ echo -e "\\033[37m\\033[01m$1\\033[0m";} readp(){ read -rp "$(yellow "$1")" "$2";} curl_bounded(){ local max_time="${1:-$SB_CURL_METADATA_MAX_TIME}" shift local -a options=(--connect-timeout "$SB_CURL_CONNECT_TIMEOUT" --max-time "$max_time" --retry "$SB_CURL_RETRY") if curl --help all 2>/dev/null | grep -q -- '--retry-all-errors'; then options+=(--retry-all-errors) fi curl "${options[@]}" "$@" } ensure_sbox_dir(){ mkdir -p /etc/s-box chmod 700 /etc/s-box 2>/dev/null || true } read_kv_from_file(){ local file="$1" key="$2" [[ -f "$file" ]] || return 1 grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 | cut -d= -f2- } kv_exists_in_file(){ local file="$1" key="$2" [[ -f "$file" ]] || return 1 grep -q -E "^${key}=" "$file" 2>/dev/null } upsert_kv_file(){ local file="$1" key="$2" value="$3" local tmp_file touch "$file" chmod 600 "$file" 2>/dev/null || true tmp_file=$(mktemp "${file}.tmp.XXXXXX") || return 1 awk -v key="$key" -v value="$value" ' BEGIN { done = 0 } index($0, key "=") == 1 { if (!done) { printf "%s=%s\n", key, value done = 1 } next } { print } END { if (!done) printf "%s=%s\n", key, value } ' "$file" > "$tmp_file" || { rm -f "$tmp_file"; return 1; } mv "$tmp_file" "$file" chmod 600 "$file" 2>/dev/null || true } delete_kv_from_file(){ local file="$1" key="$2" tmp_file="" [[ -f "$file" ]] || return 0 tmp_file=$(mktemp "${file}.tmp.XXXXXX") || return 1 awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp_file" || { rm -f "$tmp_file"; return 1; } mv "$tmp_file" "$file" chmod 600 "$file" 2>/dev/null || true } env_file_for_key(){ local key="$1" case "$key" in SB_TELEGRAM_ENABLED|SB_TELEGRAM_BOT_TOKEN|SB_TELEGRAM_CHAT_ID|SB_TELEGRAM_THREAD_ID|SB_HY2_OBFS_ENABLED|SB_HY2_MASQUERADE_MODE|SB_HY2_MASQUERADE_URL|SB_HY2_HOP_ENABLED|SB_HY2_HOP_INTERVAL|SB_HY2_UP_MBPS|SB_HY2_DOWN_MBPS|SB_CLIENT_UTLS_ENABLED|SB_CLIENT_CERT_PIN|SB_CLIENT_REJECT_DNS_853|SB_CLIENT_REJECT_UDP443|SB_CLIENT_REJECT_STUN|SB_REALITY_UTLS_FINGERPRINT|SB_VMESS_UTLS_FINGERPRINT|SB_REALITY_SNI_CANDIDATES|SB_REALITY_MAX_TIME_DIFFERENCE|SB_TUIC_AUTH_TIMEOUT|SB_SERVER_PRIVATE_IP_POLICY|SB_SERVER_IP_STRATEGY|SB_ACME_SH_VERSION|SB_NGINX_DEFAULT_BACKEND|SB_NGINX_EXTRA_SNI_ROUTE|SB_HTTP_FALLBACK_ROOT|SB_GEOSITE_GEOLOCATION_NONCN_URL|SB_GEOSITE_CN_URL|SB_GEOIP_CN_URL) printf '%s\n' "$SB_USER_ENV_FILE" ;; *) printf '%s\n' "$SB_STATE_ENV_FILE" ;; esac } persist_env_kv(){ local key="$1" value="$2" target="" ensure_sbox_dir target="$(env_file_for_key "$key")" upsert_kv_file "$target" "$key" "$value" || return 1 if [[ "$target" != "$SB_USER_ENV_FILE" ]]; then delete_kv_from_file "$SB_USER_ENV_FILE" "$key" >/dev/null 2>&1 || true fi if [[ "$target" != "$SB_STATE_ENV_FILE" ]]; then delete_kv_from_file "$SB_STATE_ENV_FILE" "$key" >/dev/null 2>&1 || true fi } read_persisted_kv(){ local key="$1" value="" file="" local -a files=() case "$(env_file_for_key "$key")" in "$SB_USER_ENV_FILE") files=("$SB_USER_ENV_FILE" "$SB_STATE_ENV_FILE" "$SB_LEGACY_ENV_FILE") ;; *) files=("$SB_STATE_ENV_FILE" "$SB_USER_ENV_FILE" "$SB_LEGACY_ENV_FILE") ;; esac for file in "${files[@]}"; do if kv_exists_in_file "$file" "$key"; then read_kv_from_file "$file" "$key" return 0 fi done return 1 } load_runtime_env(){ local key value file SB_TELEGRAM_ENABLED=""; SB_TELEGRAM_BOT_TOKEN=""; SB_TELEGRAM_CHAT_ID=""; SB_TELEGRAM_THREAD_ID="" SB_HY2_OBFS_ENABLED=""; SB_HY2_OBFS_PASSWORD=""; SB_HY2_MASQUERADE_MODE=""; SB_HY2_MASQUERADE_URL="" SB_HY2_HOP_ENABLED=""; SB_HY2_HOP_START=""; SB_HY2_HOP_END=""; SB_HY2_HOP_INTERVAL=""; SB_HY2_HOP_TARGET_PORT="" SB_CLIENT_UTLS_ENABLED=""; SB_CLIENT_CERT_PIN="" SB_REALITY_UTLS_FINGERPRINT=""; SB_VMESS_UTLS_FINGERPRINT="" SB_NGINX_DEFAULT_BACKEND="https"; SB_NGINX_EXTRA_SNI_ROUTE=""; SB_HTTP_FALLBACK_ROOT="" SB_GEOSITE_GEOLOCATION_NONCN_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geosite/geolocation-!cn.srs"; SB_GEOSITE_CN_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geosite/geolocation-cn.srs"; SB_GEOIP_CN_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geoip/cn.srs" SB_REALITY_SNI=""; SB_REALITY_SNI_CANDIDATES=""; SB_REALITY_MAX_TIME_DIFFERENCE=""; SB_TUIC_AUTH_TIMEOUT=""; SB_SERVER_PRIVATE_IP_POLICY=""; SB_SERVER_IP_STRATEGY=""; SB_VM_WS_PATH="" INT_PORT_REALITY=""; INT_PORT_VMWS=""; INT_PORT_HTTPS_BACKEND="" for file in "$SB_LEGACY_ENV_FILE" "$SB_STATE_ENV_FILE" "$SB_USER_ENV_FILE"; do [[ -f "$file" ]] || continue while IFS='=' read -r key value; do case "$key" in SB_TELEGRAM_ENABLED|SB_TELEGRAM_BOT_TOKEN|SB_TELEGRAM_CHAT_ID|SB_TELEGRAM_THREAD_ID|SB_HY2_OBFS_ENABLED|SB_HY2_OBFS_PASSWORD|SB_HY2_MASQUERADE_MODE|SB_HY2_MASQUERADE_URL|SB_HY2_HOP_ENABLED|SB_HY2_HOP_START|SB_HY2_HOP_END|SB_HY2_HOP_INTERVAL|SB_HY2_HOP_TARGET_PORT|SB_HY2_UP_MBPS|SB_HY2_DOWN_MBPS|SB_CLIENT_UTLS_ENABLED|SB_CLIENT_CERT_PIN|SB_CLIENT_REJECT_DNS_853|SB_CLIENT_REJECT_UDP443|SB_CLIENT_REJECT_STUN|SB_REALITY_UTLS_FINGERPRINT|SB_VMESS_UTLS_FINGERPRINT|SB_NGINX_DEFAULT_BACKEND|SB_NGINX_EXTRA_SNI_ROUTE|SB_HTTP_FALLBACK_ROOT|SB_GEOSITE_GEOLOCATION_NONCN_URL|SB_GEOSITE_CN_URL|SB_GEOIP_CN_URL|SB_REALITY_SNI|SB_REALITY_SNI_CANDIDATES|SB_REALITY_MAX_TIME_DIFFERENCE|SB_TUIC_AUTH_TIMEOUT|SB_SERVER_PRIVATE_IP_POLICY|SB_SERVER_IP_STRATEGY|SB_VM_WS_PATH|INT_PORT_REALITY|INT_PORT_VMWS|INT_PORT_HTTPS_BACKEND) printf -v "$key" '%s' "$value" ;; esac done < "$file" done [[ -n "$SB_SERVER_IP_STRATEGY_ENV" ]] && SB_SERVER_IP_STRATEGY="$SB_SERVER_IP_STRATEGY_ENV" [[ -z "$SB_TELEGRAM_ENABLED" ]] && SB_TELEGRAM_ENABLED="0" [[ -z "$SB_HY2_OBFS_ENABLED" ]] && SB_HY2_OBFS_ENABLED="1" [[ -z "$SB_HY2_HOP_ENABLED" ]] && SB_HY2_HOP_ENABLED="1" [[ -z "$SB_HY2_HOP_INTERVAL" ]] && SB_HY2_HOP_INTERVAL="30s" [[ -z "$SB_HY2_MASQUERADE_MODE" ]] && SB_HY2_MASQUERADE_MODE="local" [[ -z "$SB_CLIENT_UTLS_ENABLED" ]] && SB_CLIENT_UTLS_ENABLED="0" [[ -z "$SB_CLIENT_CERT_PIN" ]] && SB_CLIENT_CERT_PIN="0" [[ -z "$SB_REALITY_UTLS_FINGERPRINT" ]] && SB_REALITY_UTLS_FINGERPRINT="chrome" [[ -z "$SB_VMESS_UTLS_FINGERPRINT" ]] && SB_VMESS_UTLS_FINGERPRINT="chrome" [[ -z "$SB_NGINX_DEFAULT_BACKEND" ]] && SB_NGINX_DEFAULT_BACKEND="https" [[ -z "$SB_REALITY_SNI" ]] && SB_REALITY_SNI="" [[ -z "$SB_REALITY_SNI_CANDIDATES" ]] && SB_REALITY_SNI_CANDIDATES="" [[ -z "$SB_REALITY_MAX_TIME_DIFFERENCE" ]] && SB_REALITY_MAX_TIME_DIFFERENCE="1m" [[ -z "$SB_TUIC_AUTH_TIMEOUT" ]] && SB_TUIC_AUTH_TIMEOUT="3s" [[ -z "$SB_SERVER_PRIVATE_IP_POLICY" ]] && SB_SERVER_PRIVATE_IP_POLICY="reject" [[ -z "$SB_VM_WS_PATH" ]] && SB_VM_WS_PATH="" export SB_TELEGRAM_ENABLED SB_TELEGRAM_BOT_TOKEN SB_TELEGRAM_CHAT_ID SB_TELEGRAM_THREAD_ID export SB_HY2_OBFS_ENABLED SB_HY2_OBFS_PASSWORD SB_HY2_MASQUERADE_MODE SB_HY2_MASQUERADE_URL export SB_HY2_HOP_ENABLED SB_HY2_HOP_START SB_HY2_HOP_END SB_HY2_HOP_INTERVAL SB_HY2_HOP_TARGET_PORT SB_CLIENT_UTLS_ENABLED SB_CLIENT_CERT_PIN export SB_REALITY_UTLS_FINGERPRINT SB_VMESS_UTLS_FINGERPRINT SB_NGINX_DEFAULT_BACKEND SB_NGINX_EXTRA_SNI_ROUTE SB_HTTP_FALLBACK_ROOT export SB_GEOSITE_GEOLOCATION_NONCN_URL SB_GEOSITE_CN_URL SB_GEOIP_CN_URL export SB_REALITY_SNI SB_REALITY_SNI_CANDIDATES SB_REALITY_MAX_TIME_DIFFERENCE SB_TUIC_AUTH_TIMEOUT SB_SERVER_PRIVATE_IP_POLICY SB_SERVER_IP_STRATEGY SB_VM_WS_PATH export INT_PORT_REALITY INT_PORT_VMWS INT_PORT_HTTPS_BACKEND } probe_latest_stable_release(){ local api_json="" if [[ "${SB_LATEST_STABLE_PROBED:-0}" == "1" && -n "${SB_LATEST_STABLE_VERSION:-}" ]]; then return 0 fi SB_LATEST_STABLE_VERSION="" SB_LATEST_STABLE_PUBLISHED_AT="" if command -v curl >/dev/null 2>&1; then api_json=$(curl_bounded "$SB_CURL_METADATA_MAX_TIME" -fsSL 'https://api.github.com/repos/SagerNet/sing-box/releases?per_page=10' 2>/dev/null || true) fi if [[ -n "$api_json" ]] && command -v jq >/dev/null 2>&1; then SB_LATEST_STABLE_VERSION=$(printf '%s' "$api_json" | jq -r '[.[] | select((.draft == false) and (.prerelease == false))][0].tag_name // empty' | sed 's/^v//') SB_LATEST_STABLE_PUBLISHED_AT=$(printf '%s' "$api_json" | jq -r '[.[] | select((.draft == false) and (.prerelease == false))][0].published_at // empty') fi if [[ -z "$SB_LATEST_STABLE_VERSION" ]] && command -v curl >/dev/null 2>&1; then SB_LATEST_STABLE_VERSION=$(curl_bounded "$SB_CURL_METADATA_MAX_TIME" -fsSL 'https://github.com/SagerNet/sing-box/releases/latest' 2>/dev/null | grep -oE 'releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | head -1 | sed 's#.*v##') fi if [[ -n "$SB_LATEST_STABLE_VERSION" ]]; then SB_LATEST_STABLE_PROBED="1" return 0 fi return 1 } build_dns_hijack_rule_json(){ if is_true "${SB_DNS_PORT53_HIJACK:-1}"; then cat <<'EOF' { "type": "logical", "mode": "or", "rules": [ { "protocol": "dns" }, { "port": 53 } ], "action": "hijack-dns" }, EOF else printf '%s\n' ' { "protocol": "dns", "action": "hijack-dns" },' fi } build_client_cn_dns_detour_json(){ case "${SB_CLIENT_DNS_PROFILE:-strict_proxy}" in strict_proxy) printf '%s' ', "detour": "select"' ;; balanced_cn) printf '%s' ', "detour": "direct"' ;; *) return 1 ;; esac } # 嚴格策略:TCP 853 阻止 DoT,UDP 853 阻止標準 DoQ;UDP 443 阻止 # HTTP/3/QUIC(包括 DoH3),STUN 則是獨立的 WebRTC/P2P 隱私策略。 # Hysteria 官方 ACL 文檔以 reject(all, udp/443) 作為「阻止 QUIC」示例; # 本項目依操作方策略預設保持全部啟用,可用三個細分開關單獨放寬。 build_dns_bypass_guard_rule_json(){ local -a rules=() local index is_true "${SB_CLIENT_REJECT_DNS_853:-1}" && rules+=('{ "port": 853 }') is_true "${SB_CLIENT_REJECT_UDP443:-1}" && rules+=('{ "network": "udp", "port": 443 }') is_true "${SB_CLIENT_REJECT_STUN:-1}" && rules+=('{ "protocol": "stun" }') (( ${#rules[@]} > 0 )) || return 0 printf '%s\n' ' { "type": "logical", "mode": "or", "rules": [' for index in "${!rules[@]}"; do if (( index + 1 < ${#rules[@]} )); then printf ' %s,\n' "${rules[$index]}" else printf ' %s\n' "${rules[$index]}" fi done printf '%s\n' ' ], "action": "reject" },' } build_hy2_bandwidth_json(){ local up="${SB_HY2_UP_MBPS:-}" down="${SB_HY2_DOWN_MBPS:-}" if [[ -z "$up" && -z "$down" ]]; then return 0 fi if [[ ! "$up" =~ ^[1-9][0-9]*$ || ! "$down" =~ ^[1-9][0-9]*$ ]]; then red "HY2 固定帶寬必須同時提供正整數 SB_HY2_UP_MBPS 與 SB_HY2_DOWN_MBPS。" >&2 return 1 fi printf ', "up_mbps": %s, "down_mbps": %s' "$up" "$down" } stable_track_label(){ printf '%s\n' "${SB_SUPPORTED_CLIENT_VERSIONS// / + }" } is_supported_client_version(){ local requested="${1:-}" [[ -n "$requested" && " $SB_SUPPORTED_CLIENT_VERSIONS " == *" $requested "* ]] } build_server_private_ip_rule_json(){ case "${SB_SERVER_PRIVATE_IP_POLICY:-reject}" in direct) printf '%s\n' ' { "ip_is_private": true, "outbound": "direct" }' ;; off|none|disabled) return 0 ;; reject|block|*) printf '%s\n' ' { "ip_is_private": true, "action": "reject" }' ;; esac } is_true(){ case "${1:-0}" in 1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;; *) return 1 ;; esac } json_bool_value(){ if is_true "${1:-0}"; then printf '%s\n' "true" else printf '%s\n' "false" fi } is_valid_client_dns_profile(){ case "${1:-}" in strict_proxy|balanced_cn) return 0 ;; *) return 1 ;; esac } is_valid_server_ip_strategy(){ case "${1:-}" in auto|prefer_ipv4|prefer_ipv6|ipv4_only|ipv6_only) return 0 ;; *) return 1 ;; esac } is_valid_go_duration(){ local value="${1:-}" digits="" [[ "$value" =~ ^(([0-9]+([.][0-9]*)?|[.][0-9]+)(ns|us|ms|s|m|h))+$ ]] || return 1 digits="${value//[!0-9]/}" [[ "$digits" == *[1-9]* ]] } is_valid_client_cache_capacity(){ [[ "${1:-}" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1024 && 10#$1 <= 65536 )) } is_valid_client_tuic_congestion_control(){ case "${1:-}" in bbr|cubic|new_reno) return 0 ;; *) return 1 ;; esac } is_literal_ip_address(){ local value="${1:-}" [[ -n "$value" ]] || return 1 command -v python3 >/dev/null 2>&1 || return 2 python3 - "$value" <<'PY' >/dev/null 2>&1 import ipaddress import sys ipaddress.ip_address(sys.argv[1]) PY } is_valid_uuid_value(){ [[ "${1:-}" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] } is_valid_reality_key(){ [[ "${1:-}" =~ ^[A-Za-z0-9_-]{43}$ ]] } is_valid_reality_short_id(){ local value="${1:-}" [[ "$value" =~ ^[0-9A-Fa-f]{2,16}$ ]] && (( ${#value} % 2 == 0 )) } is_safe_json_text(){ local value="${1:-}" [[ -n "$value" && "$value" != *\"* && "$value" != *\\* ]] || return 1 LC_ALL=C grep -Eq '^[[:print:]]+$' <<< "$value" } is_valid_ws_path(){ local value="${1:-}" is_safe_json_text "$value" || return 1 LC_ALL=C grep -Eq '^/[A-Za-z0-9._~!$&()+,;=:@%/-]*$' <<< "$value" } is_valid_tls_fingerprint(){ LC_ALL=C grep -Eq '^[A-Za-z0-9_-]+$' <<< "${1:-}" } is_valid_certificate_public_key_sha256(){ [[ "${1:-}" =~ ^[A-Za-z0-9+/]{43}=$ ]] } is_safe_https_url(){ local value="${1:-}" [[ "$value" == https://* ]] || return 1 [[ "$value" != *[[:space:]\"\\]* ]] || return 1 LC_ALL=C grep -Eq '^https://[A-Za-z0-9._~:/?#@!$&()*+,;=%-]+$' <<< "$value" } is_pinned_ruleset_url(){ local value="${1:-}" is_safe_https_url "$value" || return 1 case "$value" in "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geosite/geolocation-!cn.srs"|\ "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geosite/geolocation-cn.srs"|\ "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/${SB_RULESET_REVISION}/geo/geoip/cn.srs") return 0 ;; *) return 1 ;; esac } ensure_sysctl_conf_value(){ local key="$1" value="$2" file="/etc/sysctl.conf" tmp [[ -n "$key" ]] || return 2 [[ -f "$file" ]] || touch "$file" || return 2 tmp=$(mktemp /etc/sysctl.conf.tmp.XXXXXX) || return 2 if ! awk -v key="$key" -v value="$value" ' BEGIN { updated = 0 } { line = $0 sub(/^[[:space:]]*/, "", line) split(line, parts, "=") candidate = parts[1] sub(/[[:space:]]*$/, "", candidate) if (candidate == key) { if (!updated) { print key " = " value updated = 1 } next } print } END { if (!updated) { print key " = " value } } ' "$file" > "$tmp"; then rm -f "$tmp" return 2 fi if cmp -s "$file" "$tmp"; then rm -f "$tmp" return 1 fi mv "$tmp" "$file" || { rm -f "$tmp"; return 2; } return 0 } timestamp_utc(){ date -u +%Y%m%dT%H%M%SZ } calc_sha256(){ local file="$1" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$file" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$file" | awk '{print $1}' else return 1 fi } base64_no_wrap(){ if base64 -w 0 /dev/null >/dev/null 2>&1; then base64 -w 0 "$@" else base64 "$@" | tr -d '\r\n' fi } uri_encode(){ local value="${1:-}" if command -v jq >/dev/null 2>&1; then jq -nr --arg v "$value" '$v|@uri' else python3 - "$value" <<'PY' import sys from urllib.parse import quote print(quote(sys.argv[1], safe='')) PY fi } fetch_public_ip(){ local family="$1" url ip="" local -a urls=() case "$family" in 4) urls=("https://icanhazip.com" "https://api.ipify.org") ;; 6) urls=("https://icanhazip.com" "https://api64.ipify.org") ;; *) return 1 ;; esac for url in "${urls[@]}"; do ip=$(curl_bounded 5 -fsS -"$family" "$url" 2>/dev/null | tr -d '\r\n ' || true) case "$family" in 4) [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && { printf '%s\n' "$ip"; return 0; } ;; 6) [[ "$ip" == *:* ]] && { printf '%s\n' "$ip"; return 0; } ;; esac done return 1 } extract_sha256_from_text(){ sed -nE 's/.*([0-9a-fA-F]{64}).*/\1/p' | head -n1 | tr 'A-F' 'a-f' } verify_file_sha256(){ local file="$1" expected="$2" actual="" [[ -f "$file" ]] || return 1 expected=$(printf '%s' "$expected" | tr 'A-F' 'a-f') [[ "$expected" =~ ^[0-9a-f]{64}$ ]] || return 1 actual=$(calc_sha256 "$file" 2>/dev/null || true) [[ -n "$actual" && "$actual" == "$expected" ]] } fetch_github_release_asset_sha256(){ local owner="$1" repo="$2" tag="$3" asset_name="$4" api_json="" digest="" digest_count=0 [[ -n "$owner" && -n "$repo" && -n "$tag" && -n "$asset_name" ]] || return 1 api_json=$(curl_bounded "$SB_CURL_METADATA_MAX_TIME" -fsSL "https://api.github.com/repos/${owner}/${repo}/releases/tags/v${tag}" 2>/dev/null || true) [[ -n "$api_json" ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 digest_count=$(printf '%s' "$api_json" | jq -r --arg asset "$asset_name" '[.assets[]? | select(.name == $asset)] | length') [[ "$digest_count" == "1" ]] || return 1 digest=$(printf '%s' "$api_json" | jq -r --arg asset "$asset_name" '.assets[]? | select(.name == $asset) | .digest // empty' | sed 's/^sha256://') [[ "$digest" =~ ^[0-9a-fA-F]{64}$ ]] || return 1 printf '%s\n' "$(printf '%s' "$digest" | tr 'A-F' 'a-f')" } resolve_expected_sha256(){ local explicit_sha="$1" sha_url="$2" fetched="" if [[ -n "$explicit_sha" ]]; then printf '%s\n' "$explicit_sha" | extract_sha256_from_text return 0 fi if [[ -n "$sha_url" ]]; then fetched=$(curl_bounded "$SB_CURL_METADATA_MAX_TIME" -fsSL "$sha_url" 2>/dev/null || true) [[ -n "$fetched" ]] || return 1 printf '%s\n' "$fetched" | extract_sha256_from_text return 0 fi return 1 } record_snapshot_absent_paths(){ local dir="$1" shift local path manifest="${dir}/absent_paths.txt" : > "$manifest" for path in "$@"; do [[ -n "$path" ]] || continue [[ -e "$path" ]] || printf '%s\n' "$path" >> "$manifest" done } create_verified_snapshot_archive(){ local source="$1" destination="$2" candidate="" attempt=1 while (( attempt <= 2 )); do candidate=$(mktemp "${destination}.candidate.XXXXXX") || return 1 if tar czf "$candidate" -C / "${source#/}" >/dev/null 2>&1 \ && tar tzf "$candidate" >/dev/null 2>&1 \ && mv -f -- "$candidate" "$destination"; then return 0 fi rm -f -- "$candidate" if (( attempt == 1 )); then yellow "回滾快照打包或可讀性校驗失敗,正在重試: ${source}" >&2 fi attempt=$((attempt + 1)) done return 1 } # 建立回滾快照: 所有存在的可恢復文件必須完整備份,否則操作停止。 # 備份範圍: sing-box 配置、Nginx 配置、防火牆規則、systemd 服務、證書等。 # 已知限制: 快照只反映建立時刻的狀態;外部服務的並行變更不會被捕獲。 create_rollout_snapshot(){ local label="${1:-manual}" ts dir current_uuid="" old_umask source spec destination archive_name ts=$(timestamp_utc) dir="/root/sb2-rollout-${label}-${ts}" old_umask=$(umask) umask 077 mkdir -m 700 "$dir" || { umask "$old_umask"; return 1; } chmod 700 "$dir" || { rm -rf -- "$dir"; umask "$old_umask"; return 1; } detect_install_layout if [[ -f /etc/s-box/sb.json ]] && command -v jq >/dev/null 2>&1; then current_uuid=$(jq -r '.inbounds[]? | select(.type=="vless") | .users[0].uuid // empty' /etc/s-box/sb.json 2>/dev/null) fi { echo "CREATED_AT_UTC=$ts" echo "HOSTNAME=$(hostname 2>/dev/null || echo unknown)" echo "LAYOUT=${SB_INSTALL_LAYOUT:-unknown}" echo "DOMAIN=$(head -n1 /etc/s-box/domain.log 2>/dev/null | tr -d '\r\n ' || true)" [[ -n "$current_uuid" ]] && echo "CURRENT_UUID=$current_uuid" } > "${dir}/meta.env" || { rm -rf -- "$dir"; umask "$old_umask"; return 1; } record_snapshot_absent_paths "$dir" \ "$SB_LEGACY_ENV_FILE" \ "$SB_USER_ENV_FILE" \ "$SB_STATE_ENV_FILE" \ /etc/s-box/sb.json \ /etc/s-box/sub.txt \ /etc/s-box/domain.log \ /etc/s-box/public.key \ /etc/s-box/cert.crt \ /etc/s-box/private.key \ /etc/s-box/firewall_ports.log \ /etc/s-box/sing-box \ /etc/s-box/sing-box.tar.gz \ /etc/s-box/sbyg_update \ /etc/systemd/system/sing-box.service \ "/etc/systemd/system/${SB_HY2_HOP_SERVICE}" \ "/etc/systemd/system/${SB_TUN_PREPARE_SERVICE}" \ "$SB_HY2_HOP_SCRIPT" \ "$SB_CERT_RENEW_SCRIPT" \ "$SB_CERT_RENEW_STATUS" \ "$SB_CERT_RENEW_LOG" \ "$SB_NGINX_STREAM_CONF" \ "$SB_NGINX_HTTP_CONF" \ /usr/bin/sb \ /root/tun.sh || { rm -rf -- "$dir"; umask "$old_umask"; return 1; } for source in /etc/s-box /etc/nginx /etc/ufw; do [[ -d "$source" ]] || continue archive_name="${source#/}" archive_name="${archive_name//\//-}.tgz" if ! create_verified_snapshot_archive "$source" "${dir}/${archive_name}"; then red "回滾快照打包失敗: ${source}" rm -rf -- "$dir" umask "$old_umask" return 1 fi done for spec in \ "/etc/systemd/system/sing-box.service|sing-box.service" \ "/etc/systemd/system/${SB_HY2_HOP_SERVICE}|${SB_HY2_HOP_SERVICE}" \ "/etc/systemd/system/${SB_TUN_PREPARE_SERVICE}|${SB_TUN_PREPARE_SERVICE}" \ "/usr/bin/sb|sb.bin" \ "/etc/crontab|crontab" \ "/etc/sysctl.conf|sysctl.conf" \ "/root/tun.sh|tun.sh"; do source="${spec%%|*}" destination="${spec#*|}" [[ -f "$source" ]] || continue if ! cp -a "$source" "${dir}/${destination}" >/dev/null 2>&1; then red "回滾快照複製失敗: ${source}" rm -rf -- "$dir" umask "$old_umask" return 1 fi done ss -lntup > "${dir}/ss_lntup.txt" 2>/dev/null || true ufw status numbered > "${dir}/ufw_status.txt" 2>/dev/null || true iptables-save > "${dir}/iptables.save" 2>/dev/null || true ip6tables-save > "${dir}/ip6tables.save" 2>/dev/null || true journalctl -u sing-box -n 200 --no-pager > "${dir}/journal_sing-box.log" 2>/dev/null || true journalctl -u nginx -n 200 --no-pager > "${dir}/journal-nginx.log" 2>/dev/null || true chmod -R go-rwx "$dir" || { rm -rf -- "$dir"; umask "$old_umask"; return 1; } umask "$old_umask" printf '%s\n' "$dir" } # 從快照恢復: 文件、防火牆與服務恢復失敗會被彙總並回傳非零;非原子事務。 # 恢復順序: stop services → restore files → restore firewall → reload → restart # 已知限制: iptables-restore 全量覆蓋可能與 UFW 管理衝突; # 如恢復中途某步失敗,系統可能處於部分恢復狀態。 restore_rollout_snapshot(){ local dir="$1" restore_failed=0 spec archive target [[ -d "$dir" ]] || { red "回滾快照不存在: $dir"; return 1; } green "正在從快照回滾: $dir" systemctl stop sing-box >/dev/null 2>&1 || true systemctl stop "${SB_HY2_HOP_SERVICE}" >/dev/null 2>&1 || true for spec in \ "etc-nginx.tgz|/" \ "etc-s-box.tgz|/" \ "etc-ufw.tgz|/"; do archive="${spec%%|*}" target="${spec#*|}" [[ -f "${dir}/${archive}" ]] || continue tar xzf "${dir}/${archive}" -C "$target" >/dev/null 2>&1 || { red "回滾壓縮包恢復失敗: ${archive}" restore_failed=1 } done for spec in \ "sing-box.service|/etc/systemd/system/sing-box.service" \ "${SB_HY2_HOP_SERVICE}|/etc/systemd/system/${SB_HY2_HOP_SERVICE}" \ "${SB_TUN_PREPARE_SERVICE}|/etc/systemd/system/${SB_TUN_PREPARE_SERVICE}" \ "sb.bin|/usr/bin/sb" \ "crontab|/etc/crontab" \ "sysctl.conf|/etc/sysctl.conf" \ "tun.sh|/root/tun.sh"; do archive="${spec%%|*}" target="${spec#*|}" [[ -f "${dir}/${archive}" ]] || continue cp -a "${dir}/${archive}" "$target" >/dev/null 2>&1 || { red "回滾文件恢復失敗: ${target}" restore_failed=1 } done if [[ -f "${dir}/absent_paths.txt" ]]; then while IFS= read -r path; do [[ -n "$path" ]] || continue if [[ -d "$path" ]]; then rmdir "$path" >/dev/null 2>&1 || { red "原先不存在的目錄無法安全移除: ${path}" restore_failed=1 } else rm -f "$path" >/dev/null 2>&1 || { red "原先不存在的文件無法移除: ${path}" restore_failed=1 } fi done < "${dir}/absent_paths.txt" fi rmdir /etc/s-box >/dev/null 2>&1 || true systemctl daemon-reload >/dev/null 2>&1 || restore_failed=1 if [[ -f "${dir}/iptables.save" ]] && ! iptables-restore < "${dir}/iptables.save" >/dev/null 2>&1; then red "IPv4 防火牆回滾失敗。" restore_failed=1 fi if [[ -f "${dir}/ip6tables.save" ]] && ! ip6tables-restore < "${dir}/ip6tables.save" >/dev/null 2>&1; then red "IPv6 防火牆回滾失敗。" restore_failed=1 fi sysctl -p >/dev/null 2>&1 || yellow "sysctl 恢復後重新載入失敗,需人工核查。" >&2 if command -v ufw >/dev/null 2>&1; then ufw reload >/dev/null 2>&1 || { red "UFW 回滾後重新載入失敗。"; restore_failed=1; } fi if command -v nginx >/dev/null 2>&1; then if ! nginx -t >/dev/null 2>&1 \ || ! { systemctl reload nginx >/dev/null 2>&1 || service nginx reload >/dev/null 2>&1; }; then red "Nginx 回滾後校驗或重新載入失敗。" restore_failed=1 fi fi if [[ -f /etc/systemd/system/sing-box.service ]] && ! systemctl restart sing-box >/dev/null 2>&1; then red "sing-box 回滾後重啟失敗。" restore_failed=1 fi if [[ -f "/etc/systemd/system/${SB_HY2_HOP_SERVICE}" ]] \ && ! systemctl restart "${SB_HY2_HOP_SERVICE}" >/dev/null 2>&1; then red "HY2 端口跳躍服務回滾後重啟失敗。" restore_failed=1 fi if (( restore_failed != 0 )); then red "回滾不完整;快照保留於 ${dir},請勿宣稱已恢復。" return 1 fi green "回滾完成並通過關鍵步驟核查。" } begin_rollback_guard(){ SB_ROLLBACK_SNAPSHOT_DIR="$1" SB_ROLLBACK_CONTEXT="${2:-未命名操作}" SB_ROLLBACK_ACTIVE="1" } end_rollback_guard(){ SB_ROLLBACK_ACTIVE="0" SB_ROLLBACK_SNAPSHOT_DIR="" SB_ROLLBACK_CONTEXT="" } # 顯式失敗路徑統一消費回滾結果:原操作失敗為 1;回滾不完整提升為 70。 return_after_rollback(){ local snapshot_dir="$1" if restore_rollout_snapshot "$snapshot_dir"; then end_rollback_guard return 1 fi end_rollback_guard return "$SB_ROLLBACK_FAILED_EXIT_CODE" } # EXIT trap 自動回滾:保留原始失敗碼;回滾不完整時改以 70 退出,供 SSH 自動化停手。 rollback_on_exit_if_needed(){ local status="$?" rollback_status=0 if [[ "$SB_ROLLBACK_ACTIVE" == "1" && "$status" -ne 0 && -n "$SB_ROLLBACK_SNAPSHOT_DIR" && "${SB_ROLLBACK_RUNNING:-0}" != "1" ]]; then SB_ROLLBACK_RUNNING="1" red "${SB_ROLLBACK_CONTEXT:-本次操作} 異常退出,正在自動回滾..." restore_rollout_snapshot "$SB_ROLLBACK_SNAPSHOT_DIR" || rollback_status="$SB_ROLLBACK_FAILED_EXIT_CODE" end_rollback_guard SB_ROLLBACK_RUNNING="0" fi if (( rollback_status != 0 )); then trap - EXIT exit "$rollback_status" fi return "$status" } assert_latest_stable_supported(){ local action="${1:-本次更新}" requested_version="${SB_VERSION:-$SB_DEFAULT_CORE_VERSION}" explicit_version="0" [[ -n "${SB_VERSION:-}" ]] && explicit_version="1" if ! is_supported_client_version "$requested_version"; then red "請求版本 ${requested_version} 不在精確受支援集合中: ${SB_SUPPORTED_CLIENT_VERSIONS}。" red "請先更新單一配置模板、雙核心校驗器與回滾證據,再執行 ${action}。" return 1 fi if ! probe_latest_stable_release; then if [[ "$explicit_version" == "1" ]]; then yellow "無法探測 GitHub 最新穩定版;僅允許本次顯式指定且已審核的 SB_VERSION=${requested_version}。" return 0 fi red "無法探測 GitHub 最新穩定版,且未顯式指定受支援的 SB_VERSION。" red "${action}已 fail-closed;目前僅支援: ${SB_SUPPORTED_CLIENT_VERSIONS}。" return 1 fi green "GitHub 最新穩定版: ${SB_LATEST_STABLE_VERSION}${SB_LATEST_STABLE_PUBLISHED_AT:+ (${SB_LATEST_STABLE_PUBLISHED_AT})}" if [[ "$SB_LATEST_STABLE_VERSION" != "$SB_EXPECTED_LATEST_STABLE" ]]; then if [[ "$explicit_version" == "1" ]]; then yellow "GitHub 最新穩定版已變為 ${SB_LATEST_STABLE_VERSION};本次不追隨新版,只允許顯式且已審核的 SB_VERSION=${requested_version}。" return 0 fi red "GitHub 最新穩定版已變為 ${SB_LATEST_STABLE_VERSION},但未顯式指定已審核的 SB_VERSION。" red "${action}已 fail-closed;請顯式指定 ${SB_SUPPORTED_CLIENT_VERSIONS} 之一,或先完成新版審核。" return 1 fi return 0 } [[ $EUID -ne 0 ]] && yellow "請以root模式運行脚本" && exit detect_release_family(){ local os_id="" os_like="" if [[ -r /etc/os-release ]]; then os_id="$(. /etc/os-release && printf '%s' "${ID:-}")" os_like="$(. /etc/os-release && printf '%s' "${ID_LIKE:-}")" fi os_id="${os_id,,}" os_like="${os_like,,}" case "$os_id" in ubuntu) release="Ubuntu"; return 0 ;; debian) release="Debian"; return 0 ;; esac case " $os_like " in *" ubuntu "*) release="Ubuntu"; return 0 ;; *" debian "*) release="Debian"; return 0 ;; esac if [[ -f /etc/issue ]] && grep -q -E -i "ubuntu" /etc/issue; then release="Ubuntu"; return 0 fi if [[ -f /etc/issue ]] && grep -q -E -i "debian" /etc/issue; then release="Debian"; return 0 fi if [[ -f /proc/version ]] && grep -q -E -i "ubuntu" /proc/version; then release="Ubuntu"; return 0 fi if [[ -f /proc/version ]] && grep -q -E -i "debian" /proc/version; then release="Debian"; return 0 fi return 1 } if ! detect_release_family; then red "脚本僅支持 Debian / Ubuntu 系統。" && exit fi if ! command -v systemctl >/dev/null 2>&1; then red "錯誤:當前系統未檢測到 systemd。" red "本脚本嚴重依賴 systemd 管理服務,無法繼續。" exit 1 fi ufw_allow(){ local port="$1" proto="$2" comment="$3" if [[ -z "$comment" ]]; then ufw allow "${port}/${proto}" >/dev/null 2>&1 else if ! ufw allow "${port}/${proto}" comment "${comment}" >/dev/null 2>&1; then ufw allow "${port}/${proto}" >/dev/null 2>&1 fi fi } export sbfiles="/etc/s-box/sb.json" case $(uname -m) in armv7l) cpu=armv7;; aarch64) cpu=arm64;; x86_64) cpu=amd64;; *) red "目前脚本不支持$(uname -m)架構" && exit;; esac load_runtime_env hostname=$(hostname) reality_sni="${REALITY_SNI:-}" trap rollback_on_exit_if_needed EXIT # ==================== 基礎功能 ==================== enable_bbr(){ local needs_update=false if ensure_sysctl_conf_value "net.core.default_qdisc" "fq"; then needs_update=true elif [[ "$?" -eq 2 ]]; then yellow "寫入 /etc/sysctl.conf 失敗:net.core.default_qdisc" fi if ensure_sysctl_conf_value "net.ipv4.tcp_congestion_control" "bbr"; then needs_update=true elif [[ "$?" -eq 2 ]]; then yellow "寫入 /etc/sysctl.conf 失敗:net.ipv4.tcp_congestion_control" fi if [[ "$needs_update" == "true" ]]; then green "正在自動開啟 BBR 加速..." sysctl -p >/dev/null 2>&1 fi # TCP buffer + 網路調優 (gated by SB_SYSCTL_TUNE) if is_true "${SB_SYSCTL_TUNE:-1}"; then local tune_update=false local -A tune_params=( ["net.core.rmem_max"]="16777216" ["net.core.wmem_max"]="16777216" ["net.ipv4.tcp_rmem"]="4096 87380 16777216" ["net.ipv4.tcp_wmem"]="4096 65536 16777216" ["net.ipv4.tcp_fastopen"]="3" ["net.ipv4.tcp_slow_start_after_idle"]="0" ["net.ipv4.tcp_mtu_probing"]="1" ) for key in "${!tune_params[@]}"; do if ensure_sysctl_conf_value "$key" "${tune_params[$key]}"; then tune_update=true elif [[ "$?" -eq 2 ]]; then yellow "寫入 /etc/sysctl.conf 失敗:${key}" fi done if [[ "$tune_update" == "true" ]]; then green "正在套用 TCP buffer 調優..." sysctl -p >/dev/null 2>&1 fi fi } check_system_clock_preflight(){ local epoch="" ntp_state="" epoch=$(date +%s 2>/dev/null || true) if [[ ! "$epoch" =~ ^[0-9]+$ ]] || (( epoch < 1704067200 || epoch > 4102444800 )); then red "系統時鐘不可信;TLS、ACME 與 REALITY 1m 時差策略無法安全工作。" return 1 fi if command -v timedatectl >/dev/null 2>&1; then ntp_state=$(timedatectl show -p NTPSynchronized --value 2>/dev/null || true) if [[ "$ntp_state" != "yes" ]]; then yellow "系統時鐘目前未證明已由 NTP 同步;腳本不會自動更換時間服務,請在 canary 前核查。" fi else yellow "缺少 timedatectl,僅能確認時鐘值合理,不能證明 NTP 同步。" fi } install_depend(){ local dep_ver="6" missing=0 local required_cmds=(jq openssl ss curl wget tar python3 file nginx) for cmd in "${required_cmds[@]}"; do if ! command -v "$cmd" >/dev/null 2>&1; then missing=1 break fi done if ! dpkg -s cron >/dev/null 2>&1; then missing=1 fi if [[ "$(cat /etc/s-box/sbyg_update 2>/dev/null)" != "$dep_ver" || "$missing" == "1" ]]; then echo -e "\\033[1;36m ⏳ 安裝必要依賴...\\033[0m" DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a APT_LISTCHANGES_FRONTEND=none apt update -y || { red "apt update 失敗,無法繼續安裝依賴。" return 1 } DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a APT_LISTCHANGES_FRONTEND=none apt install -y jq openssl iproute2 iputils-ping coreutils expect git socat grep \ util-linux curl wget tar python3 cron ufw iptables file nginx libnginx-mod-stream || { red "apt install 失敗,無法繼續安裝依賴。" return 1 } mkdir -p /etc/s-box echo "$dep_ver" > /etc/s-box/sbyg_update fi } setup_tun(){ sed -i '\|^@reboot root bash /root/tun.sh >/dev/null 2>&1|d' /etc/crontab 2>/dev/null || true rm -f /root/tun.sh if [[ -c /dev/net/tun ]]; then if [[ -f "/etc/systemd/system/${SB_TUN_PREPARE_SERVICE}" ]]; then systemctl disable "${SB_TUN_PREPARE_SERVICE}" >/dev/null 2>&1 || true rm -f "/etc/systemd/system/${SB_TUN_PREPARE_SERVICE}" systemctl daemon-reload >/dev/null 2>&1 || true fi chmod 0600 /dev/net/tun >/dev/null 2>&1 || true return 0 fi cat > "/etc/systemd/system/${SB_TUN_PREPARE_SERVICE}" </dev/null 2>&1 || true systemctl enable "${SB_TUN_PREPARE_SERVICE}" >/dev/null 2>&1 || true systemctl start "${SB_TUN_PREPARE_SERVICE}" >/dev/null 2>&1 || true } _v4_cache="" _v6_cache="" v4v6(){ if [[ -z "$_v4_cache" ]]; then _v4_cache=$(fetch_public_ip 4 2>/dev/null || echo "") fi if [[ -z "$_v6_cache" ]]; then _v6_cache=$(fetch_public_ip 6 2>/dev/null || echo "") fi v4="$_v4_cache" v6="$_v6_cache" } v4v6_refresh(){ _v4_cache="" _v6_cache="" v4v6 } current_server_ip_strategy(){ local cfg="${1:-/etc/s-box/sb.json}" strategy="" command -v jq >/dev/null 2>&1 || return 1 [[ -s "$cfg" ]] || return 1 strategy=$(jq -er ' .route.default_domain_resolver as $resolver | if ($resolver | type) == "object" then ($resolver.strategy // "auto") else empty end ' "$cfg" 2>/dev/null) || return 1 is_valid_server_ip_strategy "$strategy" || return 1 printf '%s\n' "$strategy" } resolve_server_ip_strategy(){ local cfg="${1:-/etc/s-box/sb.json}" strategy="${SB_SERVER_IP_STRATEGY:-}" source="persisted" SB_RESOLVED_SERVER_IP_STRATEGY="" SB_SERVER_IP_STRATEGY_SOURCE="" export SB_RESOLVED_SERVER_IP_STRATEGY SB_SERVER_IP_STRATEGY_SOURCE if [[ -n "$strategy" ]]; then is_valid_server_ip_strategy "$strategy" || { red "SB_SERVER_IP_STRATEGY 無效;僅允許 auto/prefer_ipv4/prefer_ipv6/ipv4_only/ipv6_only。" >&2 return 1 } else strategy=$(current_server_ip_strategy "$cfg" 2>/dev/null || true) if [[ -n "$strategy" ]]; then source="current_config" else v4v6 if [[ -n "$v4" && -n "$v6" ]]; then strategy="auto" elif [[ -n "$v4" ]]; then strategy="ipv4_only" elif [[ -n "$v6" ]]; then strategy="ipv6_only" else red "首次生成無法探測公共 IPv4 或 IPv6;拒絕推導服務端 IP strategy。請顯式設置 SB_SERVER_IP_STRATEGY=auto/prefer_ipv4/prefer_ipv6/ipv4_only/ipv6_only。" >&2 return 1 fi source="first_generation_probe" fi fi SB_RESOLVED_SERVER_IP_STRATEGY="$strategy" SB_SERVER_IP_STRATEGY_SOURCE="$source" export SB_RESOLVED_SERVER_IP_STRATEGY SB_SERVER_IP_STRATEGY_SOURCE } is_valid_port_number(){ local p="$1" [[ "$p" =~ ^[0-9]+$ ]] && (( 10#$p >= 1 && 10#$p <= 65535 )) } is_valid_sni_hostname(){ local hostname="$1" label="" local -a labels=() [[ -n "$hostname" && ${#hostname} -le 253 ]] || return 1 [[ "$hostname" =~ ^[a-z0-9.-]+$ && "$hostname" == *.* && "$hostname" != .* && "$hostname" != *. ]] || return 1 local IFS='.' read -r -a labels <<< "$hostname" (( ${#labels[@]} >= 2 )) || return 1 for label in "${labels[@]}"; do [[ "$label" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || return 1 done } is_valid_hop_interval(){ local v="$1" [[ "$v" =~ ^[0-9]+[smh]$ ]] } gen_random_alnum(){ local n="${1:-20}" if command -v openssl >/dev/null 2>&1; then openssl rand -base64 $(( n * 3 )) 2>/dev/null | tr -dc 'A-Za-z0-9' | head -c "$n" else LC_ALL=C tr -dc 'A-Za-z0-9' &2 return 1 fi } format_uri_host(){ local host="$1" if [[ "$host" == *:* && "$host" != \[*\] ]]; then printf '[%s]\n' "$host" else printf '%s\n' "$host" fi } is_literal_ip_url(){ local url="$1" authority="" host="" part="" local -a parts=() [[ "$url" == http://* || "$url" == https://* ]] || return 1 authority="${url#*://}" authority="${authority%%/*}" if [[ "$authority" == \[*\]* ]]; then host="${authority#\[}" host="${host%%\]*}" [[ "$host" =~ ^[0-9A-Fa-f:]+$ && "$host" == *:* ]] return fi host="${authority%%:*}" local IFS='.' read -r -a parts <<< "$host" (( ${#parts[@]} == 4 )) || return 1 for part in "${parts[@]}"; do [[ "$part" =~ ^[0-9]+$ ]] && (( 10#$part >= 0 && 10#$part <= 255 )) || return 1 done } probe_server_dns_upstream(){ [[ -n "${SB_SERVER_DNS_UPSTREAM:-}" ]] || return 1 if command -v timeout >/dev/null 2>&1; then timeout 5 openssl s_client -connect "${SB_SERVER_DNS_UPSTREAM}:853" /dev/null 2>&1 else openssl s_client -connect "${SB_SERVER_DNS_UPSTREAM}:853" /dev/null 2>&1 fi } default_reality_sni_candidates(){ cat <<'EOF' gateway.icloud.com swdist.apple.com EOF } is_prohibited_reality_sni(){ local candidate="${1,,}" case "$candidate" in cloudflare.com|*.cloudflare.com|speedtest.net|*.speedtest.net|openai.com|*.openai.com|chatgpt.com|*.chatgpt.com) return 0 ;; esac return 1 } probe_reality_sni_candidate(){ local candidate="$1" headers status_line handshake total_ms [[ -n "$candidate" ]] || return 1 is_valid_sni_hostname "$candidate" || return 1 is_prohibited_reality_sni "$candidate" && return 1 if command -v timeout >/dev/null 2>&1; then handshake=$(timeout 6 openssl s_client -connect "${candidate}:443" -servername "$candidate" -tls1_3 -alpn h2 /dev/null || true) else handshake=$(openssl s_client -connect "${candidate}:443" -servername "$candidate" -tls1_3 -alpn h2 /dev/null || true) fi printf '%s\n' "$handshake" | grep -q "ALPN protocol: h2" || return 1 headers=$(curl_bounded 6 -fsSI --http2 -w $'\nSB_TOTAL=%{time_total}\n' "https://${candidate}/" 2>/dev/null || true) [[ -n "$headers" ]] || return 1 if printf '%s\n' "$headers" | grep -qiE '^(server:[[:space:]]*cloudflare|cf-ray:)'; then return 1 fi status_line=$(printf '%s\n' "$headers" | awk 'toupper($0) ~ /^HTTP\/[0-9.]+ [0-9][0-9][0-9]/ {print; exit}') [[ "$status_line" == HTTP/2* ]] || return 1 if printf '%s\n' "$status_line" | grep -qE '^HTTP/2 30[1278]\b'; then return 1 fi total_ms=$(printf '%s\n' "$headers" | awk -F= '/^SB_TOTAL=/{printf "%d\n", ($2 * 1000) + 0.5; exit}') [[ "$total_ms" =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "$total_ms" return 0 } resolve_reality_sni(){ local candidates_raw="" candidate="" candidate_score="" best_candidate="" best_score="" if [[ -n "${REALITY_SNI:-}" ]]; then if is_prohibited_reality_sni "$REALITY_SNI"; then red "REALITY_SNI 指向被禁止的共享 AI/CDN 前置域名: ${REALITY_SNI}" return 1 fi reality_sni="$REALITY_SNI" fi if [[ -n "${reality_sni:-}" ]]; then return 0 fi load_runtime_env if [[ -n "${SB_REALITY_SNI:-}" ]]; then if ! is_prohibited_reality_sni "$SB_REALITY_SNI"; then reality_sni="$SB_REALITY_SNI" return 0 fi yellow "已拒絕風險 Reality SNI: ${SB_REALITY_SNI}" fi candidates_raw="${SB_REALITY_SNI_CANDIDATES:-}" if [[ -z "$candidates_raw" ]]; then candidates_raw="$(default_reality_sni_candidates | paste -sd, -)" persist_env_kv "SB_REALITY_SNI_CANDIDATES" "$candidates_raw" fi local old_ifs="$IFS" IFS=',' for candidate in $candidates_raw; do candidate=$(printf '%s' "$candidate" | tr -d '[:space:]') [[ -n "$candidate" ]] || continue if candidate_score=$(probe_reality_sni_candidate "$candidate"); then if [[ -z "$best_score" || "$candidate_score" -lt "$best_score" ]]; then best_candidate="$candidate" best_score="$candidate_score" fi fi done IFS="$old_ifs" if [[ -n "$best_candidate" ]]; then reality_sni="$best_candidate" persist_env_kv "SB_REALITY_SNI" "$reality_sni" load_runtime_env green "Reality 默認 SNI 已自動選定: ${reality_sni} (${best_score}ms)" return 0 fi reality_sni="$(printf '%s\n' "$candidates_raw" | tr ',' '\n' | sed '/^[[:space:]]*$/d' | head -1)" [[ -n "$reality_sni" ]] || reality_sni="gateway.icloud.com" persist_env_kv "SB_REALITY_SNI" "$reality_sni" load_runtime_env yellow "未探測到可驗證的默認 Reality SNI,暫回退為候選池首選 ${reality_sni}" return 0 } port_in_use(){ local port="$1" ss -Htan "( sport = :${port} )" 2>/dev/null | grep -q . && return 0 ss -Huan "( sport = :${port} )" 2>/dev/null | grep -q . && return 0 return 1 } pick_unused_high_port(){ local attempts=0 port while (( attempts < 240 )); do port=$(shuf -i 10000-65535 -n 1) if ! port_in_use "$port" && [[ ! " $* " =~ " $port " ]]; then printf '%s\n' "$port" return 0 fi attempts=$((attempts + 1)) done return 1 } tcp_port_listening(){ local port="$1" ss -Htan "( sport = :${port} )" 2>/dev/null | grep -q . } udp_port_listening(){ local port="$1" ss -Huan "( sport = :${port} )" 2>/dev/null | grep -q . } find_tcp_listener_pid(){ local port="$1" line pid while IFS= read -r line; do pid=$(printf '%s\n' "$line" | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2) [[ -n "$pid" ]] && { printf '%s\n' "$pid"; return 0; } done < <(ss -Hltnp "( sport = :${port} )" 2>/dev/null || true) return 1 } detect_public_https_sites(){ # 保守策略:只計入同時滿足以下條件的 server_name: # 1. 不是 _ / localhost / 內部名 # 2. 所在 server block 同時有 listen 443 和 ssl_certificate # 3. 看起來像合法域名(含 dot) # 注意:此函數仍可能誤判 include 進但未實際啟用的配置 SB_PUBLIC_HTTPS_SITES="" command -v nginx >/dev/null 2>&1 || return 0 local dump dump=$(nginx -T 2>/dev/null || true) [[ -n "$dump" ]] || return 0 SB_PUBLIC_HTTPS_SITES=$(printf '%s\n' "$dump" | awk ' /^[[:space:]]*server[[:space:]]*\{/ { in_server=1; has_443=0; has_cert=0; delete names; name_count=0; next } in_server && /^[[:space:]]*listen[[:space:]]/ && /443/ { has_443=1 } in_server && /^[[:space:]]*ssl_certificate[[:space:]]/ { has_cert=1 } in_server && /^[[:space:]]*server_name[[:space:]]+/ { for (i = 2; i <= NF; i++) { gsub(/;/, "", $i) if ($i != "_" && $i != "localhost" && $i != "" && $i !~ /^\$/ && $i ~ /\./) { names[name_count++] = $i } } } in_server && /^[[:space:]]*\}/ { if (has_443 && has_cert && name_count > 0) { for (j = 0; j < name_count; j++) print names[j] } in_server=0 } ' | sort -u | paste -sd, -) } show_install_context(){ detect_public_https_sites local tcp443="free" udp443="free" ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE '(^|:)443$' && tcp443="busy" ss -ulnH 2>/dev/null | awk '{print $4}' | grep -qE '(^|:)443$' && udp443="busy" v4v6 echo echo -e "\\033[1;36m ┌──────────────────────────────────────────────────────────────┐\\033[0m" echo -e "\\033[1;36m │ 📋 安裝前環境預檢 │\\033[0m" echo -e "\\033[1;36m ├──────────────────────────────────────────────────────────────┤\\033[0m" printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "IPv4:" "${v4:-N/A}" printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "IPv6:" "${v6:-N/A}" printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "TCP 443:" "${tcp443}" printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "UDP 443:" "${udp443}" if [[ -n "$SB_PUBLIC_HTTPS_SITES" ]]; then printf "\\033[1;36m │\\033[0m %-12s \\033[33m%-46s\\033[0m\\033[1;36m│\\033[0m\\n" "現有站點:" "${SB_PUBLIC_HTTPS_SITES}" printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "模式:" "with_site (現有網站流量經 SNI 轉發)" else printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "現有站點:" "無" printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "模式:" "四協議 443/TCP 收斂" fi printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "客戶端版本:" "${SB_SUPPORTED_CLIENT_VERSIONS}" printf "\\033[1;36m │\\033[0m %-12s %-46s\\033[1;36m│\\033[0m\\n" "客戶端模板:" "DNS 防洩漏 + FakeIP 雙棧 (見 client-conf)" echo -e "\\033[1;36m └──────────────────────────────────────────────────────────────┘\\033[0m" echo } calc_cert_public_key_sha256(){ local cert_path="${1:-/etc/s-box/cert.crt}" [[ -s "$cert_path" ]] || return 1 command -v openssl >/dev/null 2>&1 || return 1 openssl x509 -in "$cert_path" -pubkey -noout 2>/dev/null | \ openssl pkey -pubin -outform der 2>/dev/null | \ openssl dgst -sha256 -binary 2>/dev/null | \ openssl enc -base64 2>/dev/null | tr -d '\r\n' } manifest_add_file(){ local file="$1" backup="${2:-}" ensure_sbox_dir touch "$SB_NGINX_MANIFEST" chmod 600 "$SB_NGINX_MANIFEST" 2>/dev/null || true local tmp_file tmp_file=$(mktemp "${SB_NGINX_MANIFEST}.tmp.XXXXXX") || return 1 awk -F '\t' -v file="$file" -v backup="$backup" ' BEGIN { written = 0 } $1 == file { if (!written) { if (backup != "") print file "\t" backup; else print file written = 1 } next } { print } END { if (!written) { if (backup != "") { print file "\t" backup } else { print file } } } ' "$SB_NGINX_MANIFEST" > "$tmp_file" || { rm -f "$tmp_file"; return 1; } mv "$tmp_file" "$SB_NGINX_MANIFEST" chmod 600 "$SB_NGINX_MANIFEST" 2>/dev/null || true } nginx_backup_path_for_file(){ local file="$1" safe_name="" safe_name="${file#/}" safe_name="${safe_name//\//__}" printf '%s/%s.sb_backup\n' "$SB_NGINX_BACKUP_DIR" "$safe_name" } backup_file_once(){ local file="$1" backup="" [[ -f "$file" ]] || return 1 mkdir -p "$SB_NGINX_BACKUP_DIR" || return 1 backup="$(nginx_backup_path_for_file "$file")" cp -L --preserve=mode,timestamps "$file" "$backup" || return 1 rm -f "${file}.sb_backup" manifest_add_file "$file" "$backup" } restore_recorded_backups(){ local manifest="${1:-$SB_NGINX_MANIFEST}" local file backup restore_failed=0 [[ -f "$manifest" ]] || return 0 while IFS= read -r line; do [[ -n "$line" ]] || continue file="${line%%$'\t'*}" backup="${line#*$'\t'}" [[ "$backup" == "$line" || -z "$backup" ]] && backup="$(nginx_backup_path_for_file "$file")" [[ -n "$file" ]] || continue [[ -f "$backup" ]] || backup="${file}.sb_backup" if [[ ! -f "$backup" ]]; then rm -f "$file" 2>/dev/null || restore_failed=1 continue fi if ! cp -a "$backup" "$file" 2>/dev/null && ! cat "$backup" > "$file"; then restore_failed=1 fi done < "$manifest" return "$restore_failed" } cleanup_recorded_backups(){ local manifest="${1:-$SB_NGINX_MANIFEST}" local file backup [[ -f "$manifest" ]] || return 0 while IFS= read -r line; do [[ -n "$line" ]] || continue file="${line%%$'\t'*}" backup="${line#*$'\t'}" [[ "$backup" == "$line" || -z "$backup" ]] && backup="$(nginx_backup_path_for_file "$file")" [[ -n "$file" ]] || continue rm -f "$backup" rm -f "${file}.sb_backup" done < "$manifest" rm -f "$manifest" rmdir "$SB_NGINX_BACKUP_DIR" >/dev/null 2>&1 || true } repair_nginx_backup_storage(){ ensure_sbox_dir mkdir -p "$SB_NGINX_BACKUP_DIR" || return 1 local legacy_backup orig_file safe_backup repaired=0 line tmp_backup for legacy_backup in \ /etc/nginx/nginx.conf.sb_backup \ /etc/nginx/sites-enabled/*.sb_backup \ /etc/nginx/conf.d/*.sb_backup; do [[ -f "$legacy_backup" ]] || continue orig_file="${legacy_backup%.sb_backup}" safe_backup="$(nginx_backup_path_for_file "$orig_file")" cp -L --preserve=mode,timestamps "$legacy_backup" "$safe_backup" || return 1 manifest_add_file "$orig_file" "$safe_backup" || return 1 rm -f "$legacy_backup" repaired=1 green "已遷移 Nginx 備份: $legacy_backup -> $safe_backup" done if [[ -f "$SB_NGINX_MANIFEST" ]]; then while IFS= read -r line; do [[ -n "$line" ]] || continue orig_file="${line%%$'\t'*}" safe_backup="${line#*$'\t'}" [[ "$safe_backup" == "$line" || -z "$safe_backup" ]] && safe_backup="$(nginx_backup_path_for_file "$orig_file")" [[ -L "$safe_backup" ]] || continue tmp_backup="$(mktemp "${SB_NGINX_BACKUP_DIR}/normalize.XXXXXX")" || return 1 cp -L --preserve=mode,timestamps "$safe_backup" "$tmp_backup" || { rm -f "$tmp_backup"; return 1; } mv -f "$tmp_backup" "$safe_backup" || { rm -f "$tmp_backup"; return 1; } repaired=1 green "已重寫 Nginx 備份為實體文件: $safe_backup" done < "$SB_NGINX_MANIFEST" fi [[ "$repaired" == "1" ]] || yellow "未檢測到需要遷移的舊式 Nginx 備份文件。" } rewrite_nginx_tcp_443_file(){ local file="$1" loop_port="$2" python3 - "$file" "$loop_port" <<'PY2' from pathlib import Path import re import sys path = Path(sys.argv[1]) port = sys.argv[2] text = path.read_text() lines = text.splitlines(True) out = [] changed = False have_ipv4_loopback = any(re.match(r'^\s*listen\s+127\.0\.0\.1:' + re.escape(port) + r'(\s|;)', line) for line in lines) unsupported = [] for line in lines: stripped = line.lstrip() if stripped.startswith('#'): out.append(line) continue m4 = re.match(r'^(\s*)listen\s+(?:(?:0\.0\.0\.0|\*):)?443(\s+[^;]*?)?;\s*$', line) m6 = re.match(r'^(\s*)listen\s+\[::\]:443(\s+[^;]*?)?;\s*$', line) if m4: suffix = m4.group(2) or '' if 'quic' in suffix.split(): out.append(line) continue out.append(f"{m4.group(1)}listen 127.0.0.1:{port}{suffix}\n") if not out[-1].endswith(';\n'): out[-1] = out[-1][:-1] + ';\n' have_ipv4_loopback = True changed = True continue if m6: suffix = m6.group(2) or '' if 'quic' in suffix.split(): out.append(line) continue if not have_ipv4_loopback: inserted = f"{m6.group(1)}listen 127.0.0.1:{port}{suffix}\n" if not inserted.endswith(';\n'): inserted = inserted[:-1] + ';\n' out.append(inserted) have_ipv4_loopback = True out.append(f"{m6.group(1)}# sb-managed disabled external IPv6 443: listen [::]:443{suffix};\n") changed = True continue if 'listen' in line and '443' in line and 'quic' not in line: unsupported.append(line.rstrip()) out.append(line) if unsupported: for item in unsupported: print(item, file=sys.stderr) sys.exit(4) if not changed: sys.exit(3) path.write_text(''.join(out)) PY2 } ensure_nginx_stream_include(){ local nginx_conf="${1:-/etc/nginx/nginx.conf}" python3 - "$nginx_conf" <<'PY2' from pathlib import Path import re import sys path = Path(sys.argv[1]) text = path.read_text() include_stmt = " include /etc/nginx/stream.d/*.conf;\n" stream_block = "stream {\n include /etc/nginx/stream.d/*.conf;\n}\n" if "include /etc/nginx/stream.d/*.conf;" in text: sys.exit(0) stream_match = re.search(r'(?m)^(\s*stream\s*\{\s*\n)', text) if stream_match: insert_at = stream_match.end(1) text = text[:insert_at] + include_stmt + text[insert_at:] elif re.search(r'(?m)^\s*http\s*\{', text): text = re.sub(r'(?m)^\s*http\s*\{', stream_block + "\nhttp {", text, count=1) else: if text and not text.endswith("\n"): text += "\n" text += "\n" + stream_block path.write_text(text) PY2 } maybe_align_hy2_masquerade_with_site(){ # Unauthenticated HTTP/3 traffic must never create third-party egress. [[ "${SB_HY2_MASQUERADE_MODE:-local}" == "local" ]] && return 0 [[ -n "${domain_name:-}" ]] || return 0 detect_public_https_sites [[ -n "$SB_PUBLIC_HTTPS_SITES" ]] || return 0 if printf '%s\n' "$SB_PUBLIC_HTTPS_SITES" | tr ',' '\n' | grep -Fxq "$domain_name"; then if [[ -z "${SB_HY2_MASQUERADE_URL:-}" || "${SB_HY2_MASQUERADE_URL:-}" == "https://www.cloudflare.com/" ]]; then SB_HY2_MASQUERADE_URL="https://${domain_name}/" persist_env_kv "SB_HY2_MASQUERADE_URL" "$SB_HY2_MASQUERADE_URL" load_runtime_env green "HY2 偽裝已自動對齊站點: ${SB_HY2_MASQUERADE_URL}" fi fi } detect_http_fallback_root(){ # 策略: # 1. 優先使用用戶顯式設定的 SB_HTTP_FALLBACK_ROOT # 2. 嘗試匹配 domain_name 對應 server block 的 root(需 domain_name 已設定) # 3. fallback 到 /var/www/html(不嘗試猜測其他站點的 root) # 注意:此函數不保證能正確取到多站點環境下的目標 root local dump="" root_path="" target_domain="${domain_name:-}" if [[ -n "${SB_HTTP_FALLBACK_ROOT:-}" && -d "${SB_HTTP_FALLBACK_ROOT:-}" ]]; then printf '%s\n' "$SB_HTTP_FALLBACK_ROOT" return 0 fi dump=$(nginx -T 2>/dev/null || true) if [[ -n "$dump" && -n "$target_domain" ]]; then # 只取包含我們域名的 server block 的 root root_path=$(printf '%s\n' "$dump" | awk -v domain="$target_domain" ' /^[[:space:]]*server[[:space:]]*\{/ {in_server=1; has_domain=0; root=""; next} in_server && /^[[:space:]]*server_name[[:space:]]/ { for (i=2; i<=NF; i++) { gsub(/;/,"",$i); if ($i == domain) has_domain=1 } } in_server && /^[[:space:]]*root[[:space:]]+\// { r=$2; gsub(/;/,"",r); root=r } in_server && /^[[:space:]]*\}/ { if (has_domain && root != "") { print root; exit } in_server=0 } ') fi [[ -n "$root_path" && -d "$root_path" ]] || root_path="/var/www/html" printf '%s\n' "$root_path" } select_default_sni_backend(){ case "${SB_NGINX_DEFAULT_BACKEND:-auto}" in reality|reality_backend) printf '%s\n' "reality_backend" ;; https|https_backend) printf '%s\n' "https_backend" ;; auto|"") # Unknown SNI must never enter REALITY, because invalid REALITY handshakes # are forwarded to the configured handshake destination. printf '%s\n' "https_backend" ;; *) return 1 ;; esac } detect_install_layout(){ SB_INSTALL_LAYOUT="none" if [[ -f "$SB_NGINX_STREAM_CONF" || -f "$SB_NGINX_HTTP_CONF" ]]; then SB_INSTALL_LAYOUT="v3_sni" return 0 fi if [[ -f /etc/s-box/firewall_ports.log ]]; then if grep -q '^INT_PORT_REALITY=' /etc/s-box/firewall_ports.log 2>/dev/null; then SB_INSTALL_LAYOUT="v3_sni" return 0 fi if grep -q '^VLESS_PORT=' /etc/s-box/firewall_ports.log 2>/dev/null || grep -q '^VMESS_PORT=' /etc/s-box/firewall_ports.log 2>/dev/null; then SB_INSTALL_LAYOUT="v2_split" return 0 fi fi if [[ -f /etc/s-box/sb.json ]] && command -v jq >/dev/null 2>&1; then if jq -e '.inbounds[]? | select((.type=="vless" or .type=="vmess") and .listen=="127.0.0.1")' /etc/s-box/sb.json >/dev/null 2>&1; then SB_INSTALL_LAYOUT="v3_sni" return 0 fi if jq -e '.inbounds[]? | select((.type=="vless" or .type=="vmess") and .listen != "127.0.0.1")' /etc/s-box/sb.json >/dev/null 2>&1; then SB_INSTALL_LAYOUT="v2_split" return 0 fi fi } require_v3_layout(){ local action="${1:-此操作}" detect_install_layout if [[ "$SB_INSTALL_LAYOUT" == "v3_sni" ]]; then return 0 fi if [[ "$SB_INSTALL_LAYOUT" == "v2_split" ]]; then red "當前機器仍是舊版分端口架構,${action}已暫停。" yellow "先不要把舊機器直接當作 443 / Nginx SNI 架構輸出或更新。" yellow "需先完成顯式遷移流程,再使用此版本的 v3 功能。" return 1 fi yellow "未能識別當前安裝架構,${action}已暫停。" return 1 } layout_label(){ detect_install_layout case "$SB_INSTALL_LAYOUT" in v3_sni) echo "Nginx SNI 443" ;; v2_split) echo "Legacy Split Ports" ;; *) echo "Unknown/Not Installed" ;; esac } # ==================== sing-box 安裝 ==================== resolve_core_asset_sha256(){ local version="$1" asset_name="$2" provided_sha="" official_sha="" if [[ -n "${SB_CORE_SHA256:-}" ]]; then provided_sha=$(printf '%s' "$SB_CORE_SHA256" | tr 'A-F' 'a-f') [[ "$provided_sha" =~ ^[0-9a-f]{64}$ ]] || { red "SB_CORE_SHA256 必須是本次精確 SB_VERSION 官方資產的 64 位十六進制 digest。" >&2 return 1 } fi official_sha=$(fetch_github_release_asset_sha256 "SagerNet" "sing-box" "$version" "$asset_name" 2>/dev/null || true) if [[ -n "$official_sha" ]]; then if [[ -n "$provided_sha" && "$provided_sha" != "$official_sha" ]]; then red "SB_CORE_SHA256 與官方 GitHub release digest 不一致,已中止。" >&2 return 1 fi if [[ -n "$provided_sha" ]]; then green "SB_CORE_SHA256 與官方 digest 交叉核對一致。" >&2 fi printf '%s\n' "$official_sha" return 0 fi if [[ -n "$provided_sha" ]]; then yellow "官方 GitHub digest 暫不可用;僅本次精確版本使用 SB_CORE_SHA256 備援。" >&2 printf '%s\n' "$provided_sha" return 0 fi red "未能從官方 GitHub release 取得唯一且有效的 ${asset_name} SHA256 digest。" >&2 red "為避免供應鏈校驗降級,本次安裝/更新已中止,現有二進制保持不動。" >&2 return 1 } inssb(){ local expected_sha="" downloaded_sha="" archive_candidate="" extract_candidate="" binary_candidate="" local extracted_binary="" installed_ver="" file_type="" archive_size=0 green "準備 Sing-box 內核候選文件..." mkdir -p /etc/s-box if [[ -n "${SB_VERSION:-}" ]]; then sbcore="$SB_VERSION" green "使用指定版本: $sbcore" else sbcore="$SB_DEFAULT_CORE_VERSION" green "未指定 SB_VERSION;使用保守預設核心: $sbcore" fi sbname="sing-box-$sbcore-linux-$cpu" sburl="https://github.com/SagerNet/sing-box/releases/download/v${sbcore}/${sbname}.tar.gz" expected_sha=$(resolve_core_asset_sha256 "$sbcore" "${sbname}.tar.gz") || return 1 archive_candidate=$(mktemp /etc/s-box/.sing-box.archive.XXXXXX) || { red "建立內核壓縮包候選失敗。"; return 1; } extract_candidate=$(mktemp -d /etc/s-box/.sing-box.extract.XXXXXX) || { rm -f "$archive_candidate"; red "建立內核解壓候選目錄失敗。"; return 1; } binary_candidate=$(mktemp /etc/s-box/.sing-box.candidate.XXXXXX) || { rm -f "$archive_candidate" rm -rf "$extract_candidate" red "建立內核二進制候選失敗。" return 1 } green "準備下載版本: ${sbcore} (${sbname})" if ! curl_bounded "$SB_CURL_ARTIFACT_MAX_TIME" -fL -o "$archive_candidate" -# "$sburl"; then red "下載 sing-box 內核失敗,請檢查網路或 GitHub 訪問。" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi file_type=$(file -b "$archive_candidate" 2>/dev/null) if ! echo "$file_type" | grep -qi "gzip\|tar"; then red "下載的文件不是有效的 tar.gz 格式(可能被劫持或返回了 HTML 錯誤頁)" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi archive_size=$(stat -c '%s' "$archive_candidate" 2>/dev/null || echo 0) if [[ ! "$archive_size" =~ ^[0-9]+$ ]] || (( archive_size < 1048576 || archive_size > 268435456 )); then red "sing-box 壓縮包大小不在允許範圍內。" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi if ! verify_file_sha256 "$archive_candidate" "$expected_sha"; then downloaded_sha=$(calc_sha256 "$archive_candidate" 2>/dev/null || echo "unknown") red "sing-box 下載文件 SHA256 校驗失敗。" red "期望: $expected_sha" red "實際: $downloaded_sha" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi green "sing-box 官方 SHA256 digest 校驗通過。" if ! tar --no-same-owner --no-same-permissions -xzf "$archive_candidate" -C "$extract_candidate" 2>/dev/null; then red "解壓 sing-box.tar.gz 失敗,文件可能損壞。" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi extracted_binary="${extract_candidate}/${sbname}/sing-box" if [[ ! -f "$extracted_binary" || -L "$extracted_binary" || ! -x "$extracted_binary" ]]; then red "未在解壓目錄中找到 sing-box 可執行文件,安裝中止。" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi if ! install -o root -g root -m 0755 "$extracted_binary" "$binary_candidate"; then red "建立 sing-box 可執行候選失敗。" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi installed_ver=$($binary_candidate version 2>/dev/null | head -1 | awk '{print $NF}') if [[ "$installed_ver" != "$sbcore" ]]; then red "sing-box 二進制無法執行,可能已損壞或不匹配當前架構。" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi if [[ -f /etc/s-box/sb.json ]] && ! "$binary_candidate" check -c /etc/s-box/sb.json >/dev/null 2>&1; then red "候選 sing-box 無法通過現有配置校驗;現有二進制保持不動。" rm -f "$archive_candidate" "$binary_candidate" rm -rf "$extract_candidate" return 1 fi rm -f "$archive_candidate" rm -rf "$extract_candidate" if ! mv -f -- "$binary_candidate" /etc/s-box/sing-box; then red "sing-box 候選原子替換失敗;現有二進制保持不動。" rm -f "$binary_candidate" return 1 fi green "Sing-box 內核已完成同檔案系統原子替換,版本: $installed_ver" } # ==================== 端口生成(新架構)==================== # 內部端口:Reality / VMess-WS / Nginx HTTPS backend — 監聽 127.0.0.1 # 外部端口:HY2 / TUIC — 監聽 :: (UDP) # 對外 TCP 只有 443(Nginx stream) insport(){ green "生成端口..." # 生成 5 個不衝突的高位隨機端口(3 個內部 + 2 個外部 UDP) local all_ports=() port for i in {1..5}; do port=$(pick_unused_high_port "${all_ports[@]}") || { red "高位端口生成失敗:未能在合理嘗試次數內找到空閒端口。" return 1 } all_ports+=("$port") done # 內部端口(只聽 127.0.0.1,不對外暴露) int_port_reality=${all_ports[0]} int_port_vmws=${all_ports[1]} int_port_https_backend=${all_ports[2]} # 外部端口(UDP 協議,直接對外) port_hy2=${all_ports[3]} port_tu=${all_ports[4]} # 保持向下兼容的變量名(防火牆日誌等用到) port_vl_re=$int_port_reality port_vm_ws=$int_port_vmws green " 內部端口 — Reality: $int_port_reality, VMess-WS: $int_port_vmws, HTTPS-backend: $int_port_https_backend" green " 外部端口 — HY2: $port_hy2/udp, TUIC: $port_tu/udp" green " 對外 TCP — 443 (Nginx SNI 分流)" } # ==================== HY2 端口跳躍(與原版相同)==================== pick_hy2_hop_range(){ local width=79 attempts=0 start end local in_use="" local reserved_ports=() local p in_use=$(ss -ulnH 2>/dev/null | awk '{print $5}' | sed -E 's/.*[:.]([0-9]+)$/\1/' | grep -E '^[0-9]+$' | sort -u) for p in "$port_hy2" "$port_tu" "${int_port_reality:-}" "${int_port_vmws:-}" "${int_port_https_backend:-}"; do is_valid_port_number "$p" && reserved_ports+=("$p") done while (( attempts < 120 )); do start=$(shuf -i 20000-56000 -n 1) end=$((start + width)) (( end > 65000 )) && { attempts=$((attempts + 1)); continue; } local conflict=0 for p in "${reserved_ports[@]}"; do if (( p >= start && p <= end )); then conflict=1; break; fi done (( conflict == 1 )) && { attempts=$((attempts + 1)); continue; } for p in $in_use; do if (( p >= start && p <= end )); then conflict=1; break; fi done if (( conflict == 0 )); then echo "$start $end" return 0 fi attempts=$((attempts + 1)) done echo "24000 24079" } init_hy2_transport_env(){ ensure_sbox_dir load_runtime_env local changed=0 hop_range if [[ "${SB_HY2_OBFS_ENABLED:-1}" != "0" ]]; then SB_HY2_OBFS_ENABLED="1" if [[ -z "${SB_HY2_OBFS_PASSWORD:-}" ]]; then SB_HY2_OBFS_PASSWORD="$(gen_random_alnum 20)" changed=1 fi fi if [[ "${SB_HY2_MASQUERADE_MODE:-local}" != "local" ]]; then yellow "遠端 HY2 masquerade 已停用;未授權 HTTP/3 僅返回本機固定頁面。" SB_HY2_MASQUERADE_MODE="local" changed=1 fi if [[ "${SB_HY2_HOP_ENABLED:-1}" != "0" ]]; then SB_HY2_HOP_ENABLED="1"; fi if [[ -z "${SB_HY2_HOP_INTERVAL:-}" ]] || ! is_valid_hop_interval "$SB_HY2_HOP_INTERVAL"; then SB_HY2_HOP_INTERVAL="30s"; changed=1 fi if [[ "${SB_HY2_HOP_ENABLED:-1}" == "1" ]]; then if ! is_valid_port_number "${SB_HY2_HOP_START:-}" || ! is_valid_port_number "${SB_HY2_HOP_END:-}" || (( SB_HY2_HOP_START >= SB_HY2_HOP_END )); then hop_range="$(pick_hy2_hop_range)" SB_HY2_HOP_START="${hop_range%% *}" SB_HY2_HOP_END="${hop_range##* }" changed=1 fi fi if ! is_valid_port_number "${SB_HY2_HOP_TARGET_PORT:-}" || [[ "${SB_HY2_HOP_TARGET_PORT:-}" != "$port_hy2" ]]; then SB_HY2_HOP_TARGET_PORT="$port_hy2" changed=1 fi if ! persist_env_kv "SB_HY2_OBFS_ENABLED" "${SB_HY2_OBFS_ENABLED:-1}" \ || ! persist_env_kv "SB_HY2_OBFS_PASSWORD" "${SB_HY2_OBFS_PASSWORD:-}" \ || ! persist_env_kv "SB_HY2_MASQUERADE_MODE" "local" \ || ! persist_env_kv "SB_HY2_HOP_ENABLED" "${SB_HY2_HOP_ENABLED:-1}" \ || ! persist_env_kv "SB_HY2_HOP_START" "${SB_HY2_HOP_START:-}" \ || ! persist_env_kv "SB_HY2_HOP_END" "${SB_HY2_HOP_END:-}" \ || ! persist_env_kv "SB_HY2_HOP_INTERVAL" "${SB_HY2_HOP_INTERVAL:-30s}" \ || ! persist_env_kv "SB_HY2_HOP_TARGET_PORT" "${SB_HY2_HOP_TARGET_PORT:-$port_hy2}" \ || ! persist_env_kv "SB_CLIENT_UTLS_ENABLED" "${SB_CLIENT_UTLS_ENABLED:-0}"; then red "HY2 傳輸狀態持久化失敗。" return 1 fi load_runtime_env green "HY2 抗封鎖增強: obfs=salamander, masquerade=local-static-404" if [[ "${SB_HY2_HOP_ENABLED:-1}" == "1" ]]; then green "HY2 端口跳躍: ${SB_HY2_HOP_START}:${SB_HY2_HOP_END} -> ${SB_HY2_HOP_TARGET_PORT} (${SB_HY2_HOP_INTERVAL})" else yellow "HY2 端口跳躍: 已停用" fi [[ "$changed" == "1" ]] && green "HY2 增強參數已寫入 ${SB_USER_ENV_FILE} / ${SB_STATE_ENV_FILE}" return 0 } load_hy2_runtime_from_server_files(){ load_runtime_env if [[ -z "${SB_HY2_OBFS_PASSWORD:-}" && -f /etc/s-box/sb.json ]]; then SB_HY2_OBFS_PASSWORD="$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .obfs.password // empty' /etc/s-box/sb.json 2>/dev/null)" fi if [[ -z "${SB_HY2_MASQUERADE_URL:-}" && -f /etc/s-box/sb.json ]]; then SB_HY2_MASQUERADE_URL="$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .masquerade // empty' /etc/s-box/sb.json 2>/dev/null)" fi if [[ -z "${SB_HY2_HOP_TARGET_PORT:-}" && -f /etc/s-box/sb.json ]]; then SB_HY2_HOP_TARGET_PORT="$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .listen_port // empty' /etc/s-box/sb.json 2>/dev/null)" fi if [[ -z "${SB_HY2_HOP_START:-}" || -z "${SB_HY2_HOP_END:-}" ]]; then local hop_range hop_range=$(grep '^HY2_HOP_RANGE=' /etc/s-box/firewall_ports.log 2>/dev/null | cut -d= -f2) if [[ "$hop_range" =~ ^([0-9]+):([0-9]+)$ ]]; then SB_HY2_HOP_START="${BASH_REMATCH[1]}" SB_HY2_HOP_END="${BASH_REMATCH[2]}" fi fi [[ -z "${SB_HY2_OBFS_ENABLED:-}" ]] && SB_HY2_OBFS_ENABLED="1" [[ -z "${SB_HY2_HOP_ENABLED:-}" ]] && SB_HY2_HOP_ENABLED="1" [[ -z "${SB_HY2_HOP_INTERVAL:-}" ]] && SB_HY2_HOP_INTERVAL="30s" return 0 } # ==================== ACME 證書申請 ==================== install_acme_sh_pinned(){ local acme_ref="${ACME_SH_VERSION:-3.1.4}" tmp_dir="" current_version="" if [[ -x /root/.acme.sh/acme.sh ]]; then current_version=$(/root/.acme.sh/acme.sh --version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | tail -n1 | sed 's/^v//') [[ -n "$current_version" ]] && persist_env_kv "SB_ACME_SH_VERSION" "$current_version" if [[ "$current_version" == "$acme_ref" ]]; then green "acme.sh 實際版本符合固定版本:${current_version}" else yellow "acme.sh 實際版本 ${current_version:-unknown},期望新安裝版本 ${acme_ref};既有主機不自動升級。" fi return 0 fi command -v git >/dev/null 2>&1 || { red "缺少 git,無法安裝固定版本 acme.sh"; return 1; } tmp_dir=$(mktemp -d /root/.acmesh-source.XXXXXX) || return 1 timeout "$SB_CURL_ARTIFACT_MAX_TIME" git clone --depth 1 --branch "$acme_ref" https://github.com/acmesh-official/acme.sh.git "$tmp_dir" >/dev/null 2>&1 || { rm -rf "$tmp_dir" red "下載 acme.sh 固定版本失敗:${acme_ref}" return 1 } (cd "$tmp_dir" && ./acme.sh --install --home /root/.acme.sh >/dev/null 2>&1) || { rm -rf "$tmp_dir" red "安裝 acme.sh 固定版本失敗:${acme_ref}" return 1 } rm -rf "$tmp_dir" current_version=$(/root/.acme.sh/acme.sh --version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | tail -n1 | sed 's/^v//') if [[ "$current_version" != "$acme_ref" ]]; then red "acme.sh 安裝後版本讀回不一致:${current_version:-unknown}" return 1 fi persist_env_kv "SB_ACME_SH_VERSION" "$current_version" green "acme.sh 已按固定版本安裝:${acme_ref}" } apply_acme(){ v4v6 red "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" green "必須使用真實域名進行安裝 (自動申請證書)" green "請確保您的域名已解析到本機 IP: ${v4}" domain_name="${domain_name:-${DOMAIN_NAME:-}}" if [[ -z "$domain_name" ]]; then if is_true "$SB_BATCH_MODE"; then red "批次模式未提供 DOMAIN_NAME,無法自動申請證書。" exit 1 fi readp "請輸入您的域名 (例如: example.com): " domain_name fi if [[ -z "$domain_name" ]]; then red "域名不能為空!" && exit 1 fi mkdir -p /etc/s-box green "安裝/更新 acme.sh..." if [[ ! -x /root/.acme.sh/acme.sh ]]; then install_acme_sh_pinned || exit 1 fi /root/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 || true acme_install_existing(){ local d="$1" /root/.acme.sh/acme.sh --installcert -d "$d" --fullchainpath /etc/s-box/cert.crt --keypath /etc/s-box/private.key --ecc >/dev/null 2>&1 && return 0 /root/.acme.sh/acme.sh --installcert -d "$d" --fullchainpath /etc/s-box/cert.crt --keypath /etc/s-box/private.key >/dev/null 2>&1 && return 0 return 1 } acme_webroot_for_domain(){ local d="$1" conf="" for conf in "/root/.acme.sh/${d}_ecc/${d}.conf" "/root/.acme.sh/${d}/${d}.conf"; do [[ -f "$conf" ]] || continue awk -F"'" '/^Le_Webroot=/{print $2; exit}' "$conf" return 0 done return 0 } acme_renew_and_reinstall(){ local d="$1" renew_out rc port80_pid="" p_name="" stopped_nginx=0 acme_webroot="" renew_out=$(mktemp /etc/s-box/.acme-renew-output.XXXXXX) || return 1 /root/.acme.sh/acme.sh --register-account -m "admin@$d" --server letsencrypt >/dev/null 2>&1 || true acme_webroot="$(acme_webroot_for_domain "$d")" if [[ -z "$acme_webroot" || "$acme_webroot" == "no" || "$acme_webroot" == "standalone" ]]; then port80_pid=$(find_tcp_listener_pid 80 2>/dev/null || true) if [[ -n "$port80_pid" ]]; then p_name=$(ps -p "$port80_pid" -o comm= 2>/dev/null || true) if [[ "$p_name" == "nginx" ]]; then yellow "檢測到 Nginx 佔用 80 端口,自動臨時停止..." systemctl stop nginx 2>/dev/null || service nginx stop 2>/dev/null || true stopped_nginx=1; sleep 1 fi fi fi /root/.acme.sh/acme.sh --renew -d "$d" --ecc --server letsencrypt >"$renew_out" 2>&1; rc=$? if [[ $rc -ne 0 ]]; then /root/.acme.sh/acme.sh --renew -d "$d" --server letsencrypt >>"$renew_out" 2>&1; rc=$? fi if [[ $stopped_nginx -eq 1 ]]; then systemctl start nginx 2>/dev/null || service nginx start 2>/dev/null || true fi if [[ $rc -ne 0 ]]; then yellow "acme.sh 自動續期輸出(最後 20 行):"; tail -n 20 "$renew_out" 2>/dev/null || true rm -f "$renew_out"; return 1 fi acme_install_existing "$d" || { rm -f "$renew_out"; return 1; } rm -f "$renew_out"; return 0 } if acme_install_existing "$domain_name"; then green "檢測到 acme.sh 已存在證書,已直接安裝到 /etc/s-box。" else local acme_mode="standalone" nginx_webroot="" local acme_pre_hook='if systemctl is-active --quiet nginx; then install -m 600 /dev/null /etc/s-box/.acme-nginx-was-active; systemctl stop nginx; fi' local acme_post_hook='if [ -f /etc/s-box/.acme-nginx-was-active ]; then systemctl start nginx; rm -f /etc/s-box/.acme-nginx-was-active; fi' local port80_pid=$(find_tcp_listener_pid 80 2>/dev/null || true) if [[ -n "$port80_pid" ]]; then local p_name=$(ps -p "$port80_pid" -o comm= 2>/dev/null) yellow "檢測到 80 端口已被進程 [${p_name:-未知}] (PID: $port80_pid) 佔用。" if [[ "$p_name" == "nginx" ]]; then if is_true "$SB_BATCH_MODE"; then acme_mode="${ACME_MODE:-webroot}" case "$acme_mode" in webroot) nginx_webroot="${ACME_WEBROOT:-/var/www/html}" mkdir -p "$nginx_webroot" ;; standalone) yellow "批次模式:Standalone 僅在實際簽發時通過 pre/post hook 暫停並恢復 Nginx。" ;; nginx) : ;; *) acme_mode="webroot" nginx_webroot="${ACME_WEBROOT:-/var/www/html}" mkdir -p "$nginx_webroot" ;; esac else yellow "識別到 Nginx 正在運行。" echo yellow "請選擇證書申請方式:" yellow " [1] Webroot 模式 (推薦)" yellow " [2] Nginx 模式" yellow " [3] 臨時停止 Nginx,使用 Standalone 模式" readp " 請選擇 [1/2/3]: " nginx_choice case "${nginx_choice:-1}" in 1) acme_mode="webroot" if [[ -d /var/www/html ]]; then nginx_webroot="/var/www/html" elif [[ -d /usr/share/nginx/html ]]; then nginx_webroot="/usr/share/nginx/html" else readp " 請輸入 Nginx webroot 路徑 (默認 /var/www/html): " custom_webroot nginx_webroot="${custom_webroot:-/var/www/html}"; mkdir -p "$nginx_webroot" fi green "使用 Webroot 模式,路徑: $nginx_webroot";; 2) acme_mode="nginx"; yellow "注意:Nginx 模式需要該域名已在 Nginx 配置中存在 server block。";; 3) acme_mode="standalone"; yellow "Standalone 將僅在實際簽發時通過 pre/post hook 暫停並恢復 Nginx。";; *) acme_mode="webroot"; nginx_webroot="/var/www/html"; mkdir -p "$nginx_webroot";; esac fi else red "Standalone 模式需要佔用 80 端口。請先停止該服務。"; exit 1 fi fi green "正在申請證書 (模式: $acme_mode, CA: Let's Encrypt)..." /root/.acme.sh/acme.sh --register-account -m "admin@$domain_name" --server letsencrypt >/dev/null 2>&1 || true local issue_rc=0 if [[ "$acme_mode" == "standalone" ]]; then ufw allow 80/tcp >/dev/null 2>&1 || true /root/.acme.sh/acme.sh --issue -d "$domain_name" -k ec-256 --server letsencrypt --standalone \ --pre-hook "$acme_pre_hook" --post-hook "$acme_post_hook"; issue_rc=$? elif [[ "$acme_mode" == "nginx" ]]; then /root/.acme.sh/acme.sh --issue -d "$domain_name" -k ec-256 --server letsencrypt --nginx; issue_rc=$? if [[ $issue_rc -ne 0 ]]; then yellow "Nginx 模式失敗,嘗試 Webroot 模式..." nginx_webroot="/var/www/html"; mkdir -p "$nginx_webroot" /root/.acme.sh/acme.sh --issue -d "$domain_name" -k ec-256 --server letsencrypt -w "$nginx_webroot"; issue_rc=$? fi elif [[ "$acme_mode" == "webroot" ]]; then /root/.acme.sh/acme.sh --issue -d "$domain_name" -k ec-256 --server letsencrypt -w "$nginx_webroot"; issue_rc=$? fi if [[ $issue_rc -ne 0 ]]; then if acme_install_existing "$domain_name"; then yellow "acme.sh --issue 返回非 0,但證書已存在,繼續安裝。" else red "證書申請失敗!"; exit 1 fi else acme_install_existing "$domain_name" || { red "證書安裝失敗!" && exit 1; } fi fi if [[ ! -s /etc/s-box/cert.crt || ! -s /etc/s-box/private.key ]]; then red "證書安裝失敗!" && exit 1 fi chmod 600 /etc/s-box/private.key collect_cert_expiry_info if [[ "$CERT_IS_EXPIRED" != "否" ]]; then yellow "證書狀態異常,自動續期中..." acme_renew_and_reinstall "$domain_name" || { red "證書自動續期失敗。"; exit 1; } collect_cert_expiry_info if [[ "$CERT_IS_EXPIRED" != "否" ]]; then red "續期後證書狀態仍異常。"; exit 1; fi green "證書自動續期成功。" fi if command -v openssl >/dev/null 2>&1; then if ! openssl x509 -in /etc/s-box/cert.crt -noout -text | grep -A1 "Subject Alternative Name" | grep -q "$domain_name"; then red "⚠️ 證書 SAN 不包含域名 $domain_name" else green "✅ 證書域名匹配校驗通過 ($domain_name)" fi fi echo "$domain_name" > /etc/s-box/domain.log } # 證書續期腳本 + cron(與原版相同邏輯,省略以節省篇幅——安裝時從原版複製) create_cert_renew_script(){ local renew_candidate="" ensure_sbox_dir renew_candidate=$(mktemp /etc/s-box/.cert-renew-script.candidate.XXXXXX) || return 1 cat > "$renew_candidate" <<'CERTEOF' #!/bin/bash set -u LEGACY_ENV_FILE="/etc/s-box/sb.env" USER_ENV_FILE="/etc/s-box/sb.user.env" STATE_ENV_FILE="/etc/s-box/sb.state.env" DOMAIN_FILE="/etc/s-box/domain.log" STATUS_FILE="/etc/s-box/cert_renew.status" LOG_FILE="/etc/s-box/cert_renew.log" ACME_BIN="/root/.acme.sh/acme.sh" mode="${1:-auto}" now_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)" read_env_value(){ local key="$1" file=""; for file in "$STATE_ENV_FILE" "$USER_ENV_FILE" "$LEGACY_ENV_FILE"; do [[ -f "$file" ]] || continue; if grep -q -E "^${key}=" "$file" 2>/dev/null; then grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 | cut -d= -f2-; return 0; fi; done; return 0; } acme_conf_path(){ local d="$1" conf=""; for conf in "/root/.acme.sh/${d}_ecc/${d}.conf" "/root/.acme.sh/${d}/${d}.conf"; do [[ -f "$conf" ]] && { printf '%s\n' "$conf"; return 0; }; done; return 1; } acme_conf_value(){ local conf="$1" key="$2"; awk -F"'" -v key="$key" '$0 ~ ("^" key "=") {print $2; exit}' "$conf"; } file_sha256(){ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi; } record_status(){ local result="$1" detail="$2"; { echo "LAST_RUN_AT=$now_utc"; echo "LAST_MODE=$mode"; echo "LAST_RESULT=$result"; echo "LAST_DETAIL=$detail"; } > "$STATUS_FILE"; chmod 600 "$STATUS_FILE" 2>/dev/null || true; echo "$now_utc|$mode|$result|$detail" >> "$LOG_FILE"; } send_telegram_fail(){ local msg="$1"; [[ "${SB_TELEGRAM_ENABLED:-0}" == "1" ]] || return 0; [[ -n "${SB_TELEGRAM_BOT_TOKEN:-}" && -n "${SB_TELEGRAM_CHAT_ID:-}" ]] || return 0; local api="https://api.telegram.org/bot${SB_TELEGRAM_BOT_TOKEN}/sendMessage"; local text="🔴 P1 · VPS|證書續期失敗\n資源:$(hostname) · ${domain:-unknown}\n時間(UTC):${now_utc}\n模式:${mode}\n原因:${msg}\n處置:檢查 cert_renew.status 與 cert_renew.log"; local -a curl_args=(--connect-timeout 5 --max-time 15 --retry 1 -fsS -X POST "$api" --data-urlencode "chat_id=${SB_TELEGRAM_CHAT_ID}"); [[ -n "${SB_TELEGRAM_THREAD_ID:-}" ]] && curl_args+=(--data-urlencode "message_thread_id=${SB_TELEGRAM_THREAD_ID}"); curl "${curl_args[@]}" --data-urlencode "text=${text}" >/dev/null 2>&1 || true; } SB_TELEGRAM_ENABLED="$(read_env_value SB_TELEGRAM_ENABLED)"; SB_TELEGRAM_BOT_TOKEN="$(read_env_value SB_TELEGRAM_BOT_TOKEN)"; SB_TELEGRAM_CHAT_ID="$(read_env_value SB_TELEGRAM_CHAT_ID)"; SB_TELEGRAM_THREAD_ID="$(read_env_value SB_TELEGRAM_THREAD_ID)" [[ -z "$SB_TELEGRAM_ENABLED" ]] && SB_TELEGRAM_ENABLED="0" if [[ ! -x "$ACME_BIN" ]]; then domain=""; record_status "failed" "acme_bin_missing"; send_telegram_fail "acme_bin_missing"; exit 1; fi domain="$(head -n1 "$DOMAIN_FILE" 2>/dev/null | tr -d '\r\n ')" if [[ ! "$domain" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]]; then record_status "failed" "domain_invalid"; send_telegram_fail "domain_invalid"; exit 1; fi acme_conf="$(acme_conf_path "$domain" 2>/dev/null || true)" if [[ -z "$acme_conf" ]]; then record_status "failed" "lineage_config_missing"; send_telegram_fail "lineage_config_missing"; exit 1; fi acme_webroot="$(acme_conf_value "$acme_conf" Le_Webroot)" next_renew="$(acme_conf_value "$acme_conf" Le_NextRenewTime)" now_epoch="$(date +%s)" if [[ "$mode" == "auto" ]]; then if [[ "$next_renew" =~ ^[0-9]+$ && "$now_epoch" -lt "$next_renew" ]]; then record_status "success" "not_due_next:${next_renew}"; exit 0; fi if [[ ! "$next_renew" =~ ^[0-9]+$ ]] && openssl x509 -in /etc/s-box/cert.crt -noout -checkend 2592000 >/dev/null 2>&1; then record_status "success" "not_due_cert_window"; exit 0; fi fi renew_out="$(mktemp /etc/s-box/.cert-renew-output.XXXXXX)" || exit 1 install_out="$(mktemp /etc/s-box/.cert-install-output.XXXXXX)" || { rm -f "$renew_out"; exit 1; } cert_candidate=""; key_candidate=""; cert_backup=""; key_backup=""; stopped_nginx=0 restore_nginx(){ if [[ $stopped_nginx -eq 1 ]]; then systemctl start nginx >/dev/null 2>&1 || service nginx start >/dev/null 2>&1 || return 1; stopped_nginx=0; fi; return 0; } cleanup(){ restore_nginx >/dev/null 2>&1 || true; rm -f "$renew_out" "$install_out" "$cert_candidate" "$key_candidate" "$cert_backup" "$key_backup"; } trap cleanup EXIT trap 'exit 1' HUP INT TERM "$ACME_BIN" --set-default-ca --server letsencrypt >/dev/null 2>&1 || true if [[ -z "$acme_webroot" || "$acme_webroot" == "no" || "$acme_webroot" == "standalone" ]]; then port80_pid="" while IFS= read -r line; do port80_pid=$(printf '%s\n' "$line" | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2) [[ -n "$port80_pid" ]] && break done < <(ss -Hltnp "( sport = :80 )" 2>/dev/null || true) if [[ -n "$port80_pid" ]]; then p_name=$(ps -p "$port80_pid" -o comm= 2>/dev/null || true) if [[ "$p_name" != "nginx" ]]; then record_status "failed" "port80_busy_non_nginx"; send_telegram_fail "port80_busy_non_nginx"; exit 1; fi if ! systemctl stop nginx >/dev/null 2>&1 && ! service nginx stop >/dev/null 2>&1; then record_status "failed" "nginx_stop_failed"; send_telegram_fail "nginx_stop_failed"; exit 1; fi stopped_nginx=1 sleep 1 fi fi "$ACME_BIN" --renew -d "$domain" --ecc --server letsencrypt >"$renew_out" 2>&1; renew_rc=$? if ! restore_nginx; then record_status "failed" "nginx_restore_failed"; send_telegram_fail "nginx_restore_failed"; exit 1; fi if [[ $renew_rc -ne 0 ]]; then if grep -qiE "not due|skip|domains not changed|is not due for renewal" "$renew_out"; then record_status "success" "not_due_acme"; exit 0; fi record_status "failed" "renew_failed_rc${renew_rc}"; send_telegram_fail "renew_failed_rc${renew_rc}"; exit 1 fi cert_candidate="$(mktemp /etc/s-box/.cert.candidate.XXXXXX)" || exit 1 key_candidate="$(mktemp /etc/s-box/.key.candidate.XXXXXX)" || exit 1 if ! "$ACME_BIN" --installcert -d "$domain" --fullchainpath "$cert_candidate" --keypath "$key_candidate" --reloadcmd /bin/true --ecc >"$install_out" 2>&1; then if ! "$ACME_BIN" --installcert -d "$domain" --fullchainpath "$cert_candidate" --keypath "$key_candidate" --reloadcmd /bin/true >"$install_out" 2>&1; then record_status "failed" "installcert_failed"; send_telegram_fail "installcert_failed"; exit 1; fi fi if [[ ! -s "$cert_candidate" || ! -s "$key_candidate" ]] || ! openssl x509 -in "$cert_candidate" -noout -checkhost "$domain" >/dev/null 2>&1 || ! openssl x509 -in "$cert_candidate" -noout -checkend 86400 >/dev/null 2>&1; then record_status "failed" "candidate_certificate_invalid"; send_telegram_fail "candidate_certificate_invalid"; exit 1; fi cert_pub="$(openssl x509 -in "$cert_candidate" -pubkey -noout 2>/dev/null | openssl pkey -pubin -outform DER 2>/dev/null | file_sha256 /dev/stdin 2>/dev/null || true)" key_pub="$(openssl pkey -in "$key_candidate" -pubout -outform DER 2>/dev/null | file_sha256 /dev/stdin 2>/dev/null || true)" if [[ -z "$cert_pub" || "$cert_pub" != "$key_pub" ]]; then record_status "failed" "candidate_key_mismatch"; send_telegram_fail "candidate_key_mismatch"; exit 1; fi old_cert_sha="$(file_sha256 /etc/s-box/cert.crt 2>/dev/null || true)"; old_key_sha="$(file_sha256 /etc/s-box/private.key 2>/dev/null || true)" new_cert_sha="$(file_sha256 "$cert_candidate" 2>/dev/null || true)"; new_key_sha="$(file_sha256 "$key_candidate" 2>/dev/null || true)" if [[ "$old_cert_sha" == "$new_cert_sha" && "$old_key_sha" == "$new_key_sha" ]]; then record_status "success" "unchanged"; exit 0; fi cert_backup="$(mktemp /etc/s-box/.cert.backup.XXXXXX)" || exit 1; key_backup="$(mktemp /etc/s-box/.key.backup.XXXXXX)" || exit 1 cp -p /etc/s-box/cert.crt "$cert_backup" 2>/dev/null || : > "$cert_backup"; cp -p /etc/s-box/private.key "$key_backup" 2>/dev/null || : > "$key_backup" chown root:root "$cert_candidate" "$key_candidate"; chmod 644 "$cert_candidate"; chmod 600 "$key_candidate" if ! mv -f -- "$key_candidate" /etc/s-box/private.key || ! mv -f -- "$cert_candidate" /etc/s-box/cert.crt; then cp -p "$key_backup" /etc/s-box/private.key; cp -p "$cert_backup" /etc/s-box/cert.crt; record_status "failed" "certificate_atomic_replace_failed_rolled_back"; send_telegram_fail "certificate_atomic_replace_failed_rolled_back"; exit 1; fi key_candidate=""; cert_candidate="" if ! /etc/s-box/sing-box check -c /etc/s-box/sb.json >/dev/null 2>&1 || ! nginx -t >/dev/null 2>&1; then cp -p "$key_backup" /etc/s-box/private.key; cp -p "$cert_backup" /etc/s-box/cert.crt; record_status "failed" "postinstall_config_check_failed"; send_telegram_fail "postinstall_config_check_failed"; exit 1; fi if [[ -f /etc/systemd/system/sing-box.service ]]; then if ! systemctl restart sing-box >/dev/null 2>&1 || ! systemctl is-active --quiet sing-box; then cp -p "$key_backup" /etc/s-box/private.key; cp -p "$cert_backup" /etc/s-box/cert.crt; systemctl restart sing-box >/dev/null 2>&1 || true; record_status "failed" "singbox_restart_failed_rolled_back"; send_telegram_fail "singbox_restart_failed_rolled_back"; exit 1; fi fi if pgrep -x nginx >/dev/null 2>&1; then if ! systemctl reload nginx >/dev/null 2>&1 && ! service nginx reload >/dev/null 2>&1; then cp -p "$key_backup" /etc/s-box/private.key; cp -p "$cert_backup" /etc/s-box/cert.crt systemctl restart sing-box >/dev/null 2>&1 || true systemctl reload nginx >/dev/null 2>&1 || service nginx reload >/dev/null 2>&1 || true record_status "failed" "nginx_reload_failed_rolled_back"; send_telegram_fail "nginx_reload_failed_rolled_back"; exit 1 fi fi expiry="$(openssl x509 -in /etc/s-box/cert.crt -noout -enddate 2>/dev/null | cut -d= -f2)" if [[ -n "$expiry" ]]; then record_status "success" "ok_expiry:${expiry// /_}"; else record_status "success" "ok"; fi exit 0 CERTEOF chmod 700 "$renew_candidate" if ! bash -n "$renew_candidate"; then rm -f "$renew_candidate" red "證書續期腳本候選語法校驗失敗。" return 1 fi mv -f -- "$renew_candidate" "$SB_CERT_RENEW_SCRIPT" || { rm -f "$renew_candidate"; return 1; } } install_cert_renew_jobs(){ ensure_sbox_dir; create_cert_renew_script || return 1 /root/.acme.sh/acme.sh --uninstall-cronjob >/dev/null 2>&1 || true green "已停用 acme.sh 自帶 cron,改由 sb 自管續期任務。" if [[ ! -f /etc/crontab ]]; then yellow "未找到 /etc/crontab,跳過 sb 證書續期任務寫入。"; return; fi if grep -Fq "$SB_CERT_RENEW_CRON_MARK" /etc/crontab 2>/dev/null; then sed -i "\|${SB_CERT_RENEW_CRON_MARK}|d" /etc/crontab; fi echo "17 3 * * * root ${SB_CERT_RENEW_SCRIPT} auto >/dev/null 2>&1 ${SB_CERT_RENEW_CRON_MARK}" >> /etc/crontab green "已安裝 sb 證書續期任務:每日 03:17 (UTC) 自動檢查。" } refresh_cert_renew_job(){ detect_install_layout require_v3_layout "證書續期腳本更新" || return 1 install_acme_sh_pinned || return 1 install_cert_renew_jobs || return 1 bash -n "$SB_CERT_RENEW_SCRIPT" || return 1 green "證書續期腳本已原子更新;未執行續期、未重啟 Nginx 或 sing-box。" } collect_cert_expiry_info(){ CERT_EXPIRY_DATE=""; CERT_IS_EXPIRED="未知"; CERT_DAYS_LEFT="N/A" if [[ ! -s /etc/s-box/cert.crt ]]; then CERT_IS_EXPIRED="未檢測到證書"; return; fi if ! command -v openssl >/dev/null 2>&1; then CERT_IS_EXPIRED="無法判斷 (缺少 openssl)"; return; fi CERT_EXPIRY_DATE=$(openssl x509 -in /etc/s-box/cert.crt -noout -enddate 2>/dev/null | cut -d= -f2) if [[ -z "$CERT_EXPIRY_DATE" ]]; then CERT_IS_EXPIRED="無法讀取"; return; fi local expire_epoch now_epoch diff overdue expire_epoch=$(date -d "$CERT_EXPIRY_DATE" +%s 2>/dev/null || echo "") now_epoch=$(date +%s) if [[ -z "$expire_epoch" ]]; then CERT_IS_EXPIRED="無法解析日期"; return; fi diff=$((expire_epoch - now_epoch)) if (( diff < 0 )); then overdue=$(( (-diff + 86399) / 86400 )); CERT_IS_EXPIRED="是"; CERT_DAYS_LEFT="-$overdue" else CERT_IS_EXPIRED="否"; CERT_DAYS_LEFT="$((diff / 86400))"; fi } ensure_domain_and_cert(){ local renew_jobs_done=0 if [[ -f /etc/s-box/cert.crt && -s /etc/s-box/cert.crt && -f /etc/s-box/private.key && -s /etc/s-box/private.key && -f /etc/s-box/domain.log && -s /etc/s-box/domain.log ]]; then domain_name=$(head -n1 /etc/s-box/domain.log | tr -d '\r\n ') if [[ -z "$domain_name" ]]; then yellow "域名記錄為空,將重新走證書申請流程。"; apply_acme; install_cert_renew_jobs; renew_jobs_done=1; return 0 fi collect_cert_expiry_info if [[ "$CERT_IS_EXPIRED" != "否" ]]; then yellow "歷史證書狀態異常,嘗試自動續期..." install_cert_renew_jobs; renew_jobs_done=1 "$SB_CERT_RENEW_SCRIPT" manual && green "舊證書自動續期成功。" || { red "舊證書自動續期失敗。"; exit 1; } else green "檢測到已存在有效證書與域名:${yellow}${domain_name}${plain},跳過 ACME 申請。" fi else apply_acme fi if [[ "$renew_jobs_done" != "1" ]]; then install_cert_renew_jobs; fi } # ==================== Nginx SNI 分流配置(核心新增)==================== build_nginx_http2_listen_block(){ local port="$1" version="" oldest="" if ! nginx -V 2>&1 | grep -q -- '--with-http_v2_module'; then printf ' listen 127.0.0.1:%s ssl;' "$port" return 0 fi version=$(nginx -v 2>&1 | sed -nE 's#.*nginx/([0-9]+\.[0-9]+\.[0-9]+).*#\1#p') if [[ -n "$version" ]]; then oldest=$(printf '%s\n%s\n' '1.25.1' "$version" | sort -V | head -n1) fi if [[ "$oldest" == "1.25.1" ]]; then printf ' listen 127.0.0.1:%s ssl;\n http2 on;' "$port" else printf ' listen 127.0.0.1:%s ssl http2;' "$port" fi } build_nginx_ipv6_stream_listen(){ if [[ -r /proc/sys/net/ipv6/conf/all/disable_ipv6 ]] \ && [[ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null)" == "0" ]]; then printf '%s' ' listen [::]:443 reuseport ipv6only=on;' fi } setup_nginx_sni(){ green "配置 Nginx SNI 分流 (所有 TCP 流量統一走 443)..." resolve_reality_sni load_runtime_env local tmp_stream_conf="" tmp_http_conf="" candidate_file="" fallback_root="" default_sni_backend="" nginx_main_candidate="" local http2_listen_block="" ipv6_stream_listen="" # Optional user setting: domain,backend,port. The generated upstream is always loopback-only. local extra_sni_route="${SB_NGINX_EXTRA_SNI_ROUTE:-}" extra_sni_domain="" extra_sni_backend="" extra_sni_port="" extra_sni_tail="" local extra_sni_map_line="" extra_sni_upstream_block="" local -a staged_targets=() staged_candidates=() if ! default_sni_backend="$(select_default_sni_backend)"; then red "SB_NGINX_DEFAULT_BACKEND 僅支持 reality、reality_backend、https、https_backend 或 auto。" exit 1 fi if [[ -n "$extra_sni_route" ]]; then if [[ "$extra_sni_route" != *,*,* || "$extra_sni_route" == *,*,*,* ]]; then red "SB_NGINX_EXTRA_SNI_ROUTE 格式必須為 domain,backend,port。" exit 1 fi IFS=',' read -r extra_sni_domain extra_sni_backend extra_sni_port extra_sni_tail <<< "$extra_sni_route" if [[ -z "$extra_sni_domain" || -z "$extra_sni_backend" || -z "$extra_sni_port" || -n "$extra_sni_tail" ]]; then red "SB_NGINX_EXTRA_SNI_ROUTE 必須完整提供 domain、backend 和 port。" exit 1 fi is_valid_sni_hostname "$extra_sni_domain" || { red "額外 SNI 域名格式無效。"; exit 1; } [[ "$extra_sni_backend" =~ ^[a-z_][a-z0-9_]*$ ]] || { red "額外 SNI backend 名稱格式無效。"; exit 1; } is_valid_port_number "$extra_sni_port" || { red "額外 SNI loopback 端口無效。"; exit 1; } if [[ "$extra_sni_domain" == "$reality_sni" || "$extra_sni_domain" == "$domain_name" ]]; then red "額外 SNI 域名不得覆蓋 Reality 或主 HTTPS 域名。" exit 1 fi if [[ "$extra_sni_backend" == "reality_backend" || "$extra_sni_backend" == "https_backend" ]]; then red "額外 SNI backend 不得覆蓋生成器保留名稱。" exit 1 fi if [[ "$extra_sni_port" == "$int_port_reality" || "$extra_sni_port" == "$int_port_vmws" || "$extra_sni_port" == "$int_port_https_backend" ]]; then red "額外 SNI 端口不得覆蓋 sing-box 管理的內部端口。" exit 1 fi extra_sni_map_line=" ${extra_sni_domain} ${extra_sni_backend};" printf -v extra_sni_upstream_block 'upstream %s {\n server 127.0.0.1:%s;\n}' "$extra_sni_backend" "$extra_sni_port" fi rm -f "$SB_NGINX_MANIFEST" if ! command -v nginx >/dev/null 2>&1; then green "安裝 Nginx..." apt install -y nginx libnginx-mod-stream || { red "安裝 Nginx 失敗。"; exit 1; } fi if ! nginx -V 2>&1 | grep -q "stream"; then yellow "安裝 Nginx stream 模塊..." apt install -y libnginx-mod-stream || { red "安裝 Nginx stream 模塊失敗。"; exit 1; } fi http2_listen_block="$(build_nginx_http2_listen_block "$int_port_https_backend")" || { red "無法生成 Nginx HTTP/2 監聽配置。"; exit 1; } ipv6_stream_listen="$(build_nginx_ipv6_stream_listen)" mkdir -p /etc/nginx/stream.d detect_public_https_sites if [[ -n "${SB_PUBLIC_HTTPS_SITES:-}" ]]; then local site_count=0 site_count=$(printf '%s\n' "$SB_PUBLIC_HTTPS_SITES" | tr ',' '\n' | sed '/^[[:space:]]*$/d' | wc -l | tr -d '[:space:]') if [[ "${site_count:-0}" -gt 1 ]] && ! is_true "${SB_ALLOW_MULTI_HTTPS:-0}"; then red "檢測到多個公開 HTTPS 站點: ${SB_PUBLIC_HTTPS_SITES}" red "當前 v3 SNI 接管默認只支持單一 HTTPS backend。" yellow "如確需接管多站點,請先顯式設置 SB_ALLOW_MULTI_HTTPS=1 並人工核對 nginx -T。" exit 1 elif [[ "${site_count:-0}" -gt 1 ]]; then yellow "已顯式啟用 SB_ALLOW_MULTI_HTTPS=1,但當前實現仍只提供單一 https_backend。" yellow "這只適用於人工確認後的風險操作,不代表腳本已原生支持多 HTTPS backend。" fi fi if ! grep -q "include /etc/nginx/stream.d/\*.conf;" /etc/nginx/nginx.conf 2>/dev/null; then nginx_main_candidate=$(mktemp /etc/nginx/.nginx.conf.candidate.XXXXXX) || { red "建立 nginx.conf 候選文件失敗"; exit 1; } cp -a /etc/nginx/nginx.conf "$nginx_main_candidate" || { rm -f "$nginx_main_candidate"; red "複製 nginx.conf 候選文件失敗"; exit 1; } ensure_nginx_stream_include "$nginx_main_candidate" staged_targets+=("/etc/nginx/nginx.conf") staged_candidates+=("$nginx_main_candidate") fi local nginx_conf_dirs=("/etc/nginx/sites-enabled" "/etc/nginx/conf.d") local conf_dir conf_file rewrite_rc for conf_dir in "${nginx_conf_dirs[@]}"; do [[ -d "$conf_dir" ]] || continue for conf_file in "$conf_dir"/*.conf "$conf_dir"/*; do [[ -f "$conf_file" ]] || continue [[ "$conf_file" == "$SB_NGINX_HTTP_CONF" ]] && continue [[ "$conf_file" == "$SB_NGINX_STREAM_CONF" ]] && continue if grep -qE '^\s*listen\s+((0\.0\.0\.0:|\*:)?443|\[::\]:443)(\s+[^;]*)?;' "$conf_file" 2>/dev/null; then yellow " 遷移 $conf_file 的公網 TCP 443 到 127.0.0.1:${int_port_https_backend}" candidate_file=$(mktemp "${conf_file}.candidate.XXXXXX") || { red "建立 $conf_file 候選文件失敗"; exit 1; } cp -a "$conf_file" "$candidate_file" || { rm -f "$candidate_file"; red "複製 $conf_file 候選文件失敗"; exit 1; } rewrite_nginx_tcp_443_file "$candidate_file" "$int_port_https_backend" rewrite_rc=$? if [[ "$rewrite_rc" -ne 0 ]]; then rm -f "$candidate_file" if [[ "$rewrite_rc" -eq 3 ]]; then yellow " $conf_file 未檢測到需要遷移的 TCP 443 行,保持原樣。" else red " 無法安全改寫 $conf_file 的 443 監聽行。" rm -f "${staged_candidates[@]}" "$tmp_stream_conf" "$tmp_http_conf" exit 1 fi else staged_targets+=("$conf_file") staged_candidates+=("$candidate_file") fi fi done done green "生成 Nginx stream SNI 分流配置..." tmp_stream_conf=$(mktemp /etc/nginx/stream.d/.sb_sni_dispatch.conf.candidate.XXXXXX) || { red "建立 Nginx stream 候選配置失敗"; exit 1; } cat > "$tmp_stream_conf" </dev/null) if [[ -z "$vm_ws_path" ]]; then red "未找到 VMess-WS 路徑,停止生成 Nginx 反代配置以避免路徑漂移。" rm -f "${staged_candidates[@]}" "$tmp_stream_conf" exit 1 fi fallback_root="$(detect_http_fallback_root)" tmp_http_conf=$(mktemp /etc/nginx/conf.d/.sb_vmess_proxy.conf.candidate.XXXXXX) || { red "建立 Nginx HTTP 候選配置失敗"; rm -f "${staged_candidates[@]}" "$tmp_stream_conf"; exit 1; } cat > "$tmp_http_conf" </dev/null 2>&1; then systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null green "✅ Nginx SNI 分流配置已生效" green " 443/tcp → SNI=${reality_sni} → 127.0.0.1:${int_port_reality} (Reality)" green " 443/tcp → SNI=${domain_name} → 127.0.0.1:${int_port_https_backend} (HTTPS/VMess-WS)" if [[ -n "$extra_sni_domain" ]]; then green " 443/tcp → SNI=${extra_sni_domain} → 127.0.0.1:${extra_sni_port} (${extra_sni_backend})" fi else red "❌ Nginx 配置校驗失敗,正在回滾已修改文件..." restore_recorded_backups || red "Nginx 備份恢復不完整,需要人工按快照恢復。" nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true red "Nginx 變更已回滾,請檢查現有站點配置後重試。" exit 1 fi } # ==================== sing-box 配置生成(新架構)==================== render_server_config_json(){ local output="$1" uuid_value="$2" private_key_value="$3" short_id_value="$4" reality_name="$5" local ws_path="$6" reality_port="$7" vmess_port="$8" hy2_port="$9" tuic_port="${10}" local obfs_password="${11}" strategy_json="${12}" private_rule_json="${13}" local certificate_path="${14}" key_path="${15}" local dns_upstream="${16:-}" reality_max_time_difference="${17:-}" tuic_auth_timeout="${18:-}" command -v python3 >/dev/null 2>&1 || { red "缺少 python3,無法校驗服務端 DNS 字面 IP。"; return 1; } is_literal_ip_address "$dns_upstream" || { red "服務端 DNS 上游必須是字面 IP。"; return 1; } is_valid_go_duration "$reality_max_time_difference" || { red "REALITY 時間差格式無效。"; return 1; } is_valid_go_duration "$tuic_auth_timeout" || { red "TUIC 認證超時格式無效。"; return 1; } cat > "$output" </dev/null 2>&1 || { red "缺少 jq,無法安全生成 JSON。"; return 1; } command -v python3 >/dev/null 2>&1 || { red "缺少 python3,無法校驗服務端 DNS 字面 IP。"; return 1; } is_valid_uuid_value "$uuid" || { red "UUID 格式無效,停止生成服務端配置。"; return 1; } is_valid_reality_key "$private_key_reality" || { red "REALITY 私鑰格式無效。"; return 1; } is_valid_reality_key "$public_key_reality" || { red "REALITY 公鑰格式無效。"; return 1; } is_valid_reality_short_id "$short_id" || { red "REALITY short_id 格式無效。"; return 1; } is_valid_sni_hostname "$reality_sni" || { red "REALITY SNI 格式無效。"; return 1; } is_valid_ws_path "$vm_ws_path" || { red "VMess-WS 路徑包含不安全字符。"; return 1; } is_safe_json_text "$hy2_obfs_password" || { red "HY2 obfs 密碼包含不安全 JSON 字符。"; return 1; } is_literal_ip_address "$SB_SERVER_DNS_UPSTREAM" || { red "服務端 DNS 上游必須是字面 IP。"; return 1; } is_valid_go_duration "${SB_REALITY_MAX_TIME_DIFFERENCE:-1m}" || { red "REALITY 時間差格式無效。"; return 1; } is_valid_go_duration "${SB_TUIC_AUTH_TIMEOUT:-3s}" || { red "TUIC 認證超時格式無效。"; return 1; } is_valid_port_number "$int_port_reality" || { red "REALITY 內部端口無效。"; return 1; } is_valid_port_number "$int_port_vmws" || { red "VMess 內部端口無效。"; return 1; } is_valid_port_number "$int_port_https_backend" || { red "HTTPS backend 內部端口無效。"; return 1; } is_valid_port_number "$port_hy2" || { red "HY2 端口無效。"; return 1; } is_valid_port_number "$port_tu" || { red "TUIC 端口無效。"; return 1; } resolve_server_ip_strategy || return 1 ipv="$SB_RESOLVED_SERVER_IP_STRATEGY" [[ "$ipv" == "auto" ]] && ipv="" local strategy_json="" [[ -n "$ipv" ]] && strategy_json=", \"strategy\": \"${ipv}\"" server_private_ip_rule_json="$(build_server_private_ip_rule_json)" tmp_json=$(mktemp /etc/s-box/sb.json.tmp.XXXXXX) || { red "建立臨時配置文件失敗。"; return 1; } tmp_public_key=$(mktemp /etc/s-box/public.key.tmp.XXXXXX) || { rm -f "$tmp_json"; red "建立臨時公鑰文件失敗。"; return 1; } printf '%s\n' "$public_key_reality" > "$tmp_public_key" || { rm -f "$tmp_json" "$tmp_public_key"; red "寫入臨時公鑰文件失敗。"; return 1; } chmod 600 "$tmp_public_key" 2>/dev/null || true render_server_config_json \ "$tmp_json" "$uuid" "$private_key_reality" "$short_id" "$reality_sni" "$vm_ws_path" \ "$int_port_reality" "$int_port_vmws" "$port_hy2" "$port_tu" "$hy2_obfs_password" \ "$strategy_json" "$server_private_ip_rule_json" /etc/s-box/cert.crt /etc/s-box/private.key \ "$SB_SERVER_DNS_UPSTREAM" "${SB_REALITY_MAX_TIME_DIFFERENCE:-1m}" "${SB_TUIC_AUTH_TIMEOUT:-3s}" || { rm -f "$tmp_json" "$tmp_public_key" red "渲染服務端配置失敗。" return 1 } if ! jq -e . "$tmp_json" >/dev/null 2>&1 || ! /etc/s-box/sing-box check -c "$tmp_json" >/dev/null 2>&1; then red "生成的臨時 sing-box 配置未通過校驗,已取消覆蓋正式配置。" rm -f "$tmp_json" "$tmp_public_key" return 1 fi mv "$tmp_public_key" /etc/s-box/public.key || { rm -f "$tmp_json" "$tmp_public_key"; red "覆蓋 public.key 失敗。"; return 1; } mv "$tmp_json" /etc/s-box/sb.json || { rm -f "$tmp_json"; red "覆蓋 sb.json 失敗。"; return 1; } chmod 600 /etc/s-box/public.key /etc/s-box/sb.json 2>/dev/null || true if ! persist_env_kv "SB_HY2_OBFS_PASSWORD" "$hy2_obfs_password" \ || ! persist_env_kv "SB_HY2_MASQUERADE_MODE" "local" \ || ! persist_env_kv "SB_NGINX_DEFAULT_BACKEND" "https" \ || ! persist_env_kv "SB_SERVER_PRIVATE_IP_POLICY" "reject" \ || ! persist_env_kv "SB_SERVER_IP_STRATEGY" "$SB_RESOLVED_SERVER_IP_STRATEGY" \ || ! persist_env_kv "SB_REALITY_MAX_TIME_DIFFERENCE" "${SB_REALITY_MAX_TIME_DIFFERENCE:-1m}" \ || ! persist_env_kv "SB_TUIC_AUTH_TIMEOUT" "${SB_TUIC_AUTH_TIMEOUT:-3s}" \ || ! persist_env_kv "INT_PORT_REALITY" "$int_port_reality" \ || ! persist_env_kv "INT_PORT_VMWS" "$int_port_vmws" \ || ! persist_env_kv "INT_PORT_HTTPS_BACKEND" "$int_port_https_backend" \ || ! persist_env_kv "SB_VM_WS_PATH" "$vm_ws_path" \ || ! persist_env_kv "SB_REALITY_SNI" "$reality_sni"; then red "服務端配置已生成,但狀態持久化失敗;外層交易必須回滾。" return 1 fi load_runtime_env } validate_v3_runtime_health(){ local attempt for attempt in {1..5}; do /etc/s-box/sing-box check -c /etc/s-box/sb.json >/dev/null 2>&1 || { sleep 2; continue; } systemctl is-active --quiet sing-box >/dev/null 2>&1 || { sleep 2; continue; } systemctl is-active --quiet nginx >/dev/null 2>&1 || { sleep 2; continue; } nginx -t >/dev/null 2>&1 || { sleep 2; continue; } [[ -s /etc/s-box/cert.crt && -s /etc/s-box/private.key ]] || { sleep 2; continue; } [[ -f "$SB_NGINX_STREAM_CONF" && -f "$SB_NGINX_HTTP_CONF" ]] || { sleep 2; continue; } tcp_port_listening 443 || { sleep 2; continue; } tcp_port_listening "${int_port_reality}" || { sleep 2; continue; } tcp_port_listening "${int_port_vmws}" || { sleep 2; continue; } udp_port_listening "${port_hy2}" || { sleep 2; continue; } udp_port_listening "${port_tu}" || { sleep 2; continue; } if ! probe_server_dns_upstream; then yellow "警告:未驗證到 DoT 上游 ${SB_SERVER_DNS_UPSTREAM}:853 可達;顯式 default_domain_resolver 不會自動回退到 local。" fi return 0 done return 1 } cleanup_legacy_split_firewall_rules(){ local old_vless_port="$1" old_vmess_port="$2" [[ -n "$old_vless_port" ]] && ufw delete allow "${old_vless_port}/tcp" >/dev/null 2>&1 || true [[ -n "$old_vmess_port" ]] && ufw delete allow "${old_vmess_port}/tcp" >/dev/null 2>&1 || true } preserve_external_udp_ports_for_migration(){ local keep_hy2="$1" keep_tu="$2" local all_ports=() port for i in {1..3}; do port=$(pick_unused_high_port "$keep_hy2" "$keep_tu" "${all_ports[@]}") || { red "遷移端口生成失敗:未能在合理嘗試次數內找到空閒端口。" return 1 } all_ports+=("$port") done int_port_reality=${all_ports[0]} int_port_vmws=${all_ports[1]} int_port_https_backend=${all_ports[2]} port_hy2="$keep_hy2" port_tu="$keep_tu" port_vl_re=$int_port_reality port_vm_ws=$int_port_vmws green " 保留外部 UDP 端口 — HY2: $port_hy2/udp, TUIC: $port_tu/udp" green " 新內部端口 — Reality: $int_port_reality, VMess-WS: $int_port_vmws, HTTPS-backend: $int_port_https_backend" } collect_v2_runtime_state(){ local cfg="/etc/s-box/sb.json" existing_uuid="" old_vm_ws_path="" old_reality_sni="" [[ -f "$cfg" ]] || { red "未找到舊版 /etc/s-box/sb.json"; return 1; } command -v jq >/dev/null 2>&1 || { red "缺少 jq,無法遷移舊版配置"; return 1; } existing_uuid=$(jq -r '.inbounds[]? | select(.type=="vless") | .users[0].uuid // empty' "$cfg" 2>/dev/null) private_key_reality=$(jq -r '.inbounds[]? | select(.type=="vless") | .tls.reality.private_key // empty' "$cfg" 2>/dev/null) short_id=$(jq -r '.inbounds[]? | select(.type=="vless") | .tls.reality.short_id[0] // empty' "$cfg" 2>/dev/null) old_reality_sni=$(jq -r '.inbounds[]? | select(.type=="vless") | .tls.reality.handshake.server // .tls.server_name // empty' "$cfg" 2>/dev/null) public_key_reality=$(head -n1 /etc/s-box/public.key 2>/dev/null | tr -d '\r\n ') old_vm_ws_path=$(jq -r '.inbounds[]? | select(.type=="vmess") | .transport.path // empty' "$cfg" 2>/dev/null) SB_MIGRATE_OLD_VLESS_PORT=$(jq -r '.inbounds[]? | select(.type=="vless") | .listen_port // empty' "$cfg" 2>/dev/null) SB_MIGRATE_OLD_VMESS_PORT=$(jq -r '.inbounds[]? | select(.type=="vmess") | .listen_port // empty' "$cfg" 2>/dev/null) port_hy2=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .listen_port // empty' "$cfg" 2>/dev/null) port_tu=$(jq -r '.inbounds[]? | select(.type=="tuic") | .listen_port // empty' "$cfg" 2>/dev/null) old_hy2_obfs_password=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .obfs.password // empty' "$cfg" 2>/dev/null) old_hy2_masquerade_url=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .masquerade // empty' "$cfg" 2>/dev/null) domain_name=$(head -n1 /etc/s-box/domain.log 2>/dev/null | tr -d '\r\n ') [[ -n "$existing_uuid" && -n "$private_key_reality" && -n "$short_id" && -n "$public_key_reality" && -n "$domain_name" ]] || { red "舊版配置缺少必要識別信息,無法安全遷移。" return 1 } is_valid_port_number "$port_hy2" || { red "舊版 Hysteria2 端口無效"; return 1; } is_valid_port_number "$port_tu" || { red "舊版 TUIC 端口無效"; return 1; } persist_env_kv "SB_PREVIOUS_UUID" "$existing_uuid" if is_true "$SB_ROTATE_UUID_ON_MIGRATE"; then uuid="" yellow "遷移策略:將輪換 UUID,不沿用舊 UUID。" else uuid="$existing_uuid" yellow "遷移策略:沿用舊 UUID。" fi [[ -n "$old_hy2_obfs_password" ]] && persist_env_kv "SB_HY2_OBFS_PASSWORD" "$old_hy2_obfs_password" [[ -n "$old_hy2_masquerade_url" ]] && persist_env_kv "SB_HY2_MASQUERADE_URL" "$old_hy2_masquerade_url" if [[ -n "$old_reality_sni" ]]; then persist_env_kv "SB_PREVIOUS_REALITY_SNI" "$old_reality_sni" if is_true "$SB_ROTATE_REALITY_SNI_ON_MIGRATE"; then reality_sni="" persist_env_kv "SB_REALITY_SNI" "" yellow "遷移策略:將輪換 Reality SNI,不沿用舊 SNI。" else reality_sni="$old_reality_sni" persist_env_kv "SB_REALITY_SNI" "$old_reality_sni" yellow "遷移策略:沿用舊 Reality SNI。" fi else reality_sni="" fi if [[ -n "$old_vm_ws_path" ]]; then persist_env_kv "SB_PREVIOUS_VM_WS_PATH" "$old_vm_ws_path" if is_true "$SB_ROTATE_VM_WS_PATH_ON_MIGRATE"; then persist_env_kv "SB_VM_WS_PATH" "" yellow "遷移策略:將輪換 VMess-WS 路徑,不沿用舊路徑。" else persist_env_kv "SB_VM_WS_PATH" "$old_vm_ws_path" yellow "遷移策略:沿用舊 VMess-WS 路徑。" fi fi load_runtime_env } collect_v3_runtime_state(){ local cfg="/etc/s-box/sb.json" existing_uuid="" old_vm_ws_path="" old_reality_sni="" https_backend_port="" [[ -f "$cfg" ]] || { red "未找到當前 /etc/s-box/sb.json"; return 1; } command -v jq >/dev/null 2>&1 || { red "缺少 jq,無法讀取當前 v3 配置"; return 1; } load_runtime_env existing_uuid=$(jq -r '.inbounds[]? | select(.type=="vless") | .users[0].uuid // empty' "$cfg" 2>/dev/null) private_key_reality=$(jq -r '.inbounds[]? | select(.type=="vless") | .tls.reality.private_key // empty' "$cfg" 2>/dev/null) short_id=$(jq -r '.inbounds[]? | select(.type=="vless") | .tls.reality.short_id[0] // empty' "$cfg" 2>/dev/null) old_reality_sni=$(jq -r '.inbounds[]? | select(.type=="vless") | .tls.reality.handshake.server // .tls.server_name // empty' "$cfg" 2>/dev/null) public_key_reality=$(head -n1 /etc/s-box/public.key 2>/dev/null | tr -d '\r\n ') old_vm_ws_path=$(jq -r '.inbounds[]? | select(.type=="vmess") | .transport.path // empty' "$cfg" 2>/dev/null) int_port_reality=$(jq -r '.inbounds[]? | select(.type=="vless") | .listen_port // empty' "$cfg" 2>/dev/null) int_port_vmws=$(jq -r '.inbounds[]? | select(.type=="vmess") | .listen_port // empty' "$cfg" 2>/dev/null) port_hy2=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .listen_port // empty' "$cfg" 2>/dev/null) port_tu=$(jq -r '.inbounds[]? | select(.type=="tuic") | .listen_port // empty' "$cfg" 2>/dev/null) old_hy2_obfs_password=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .obfs.password // empty' "$cfg" 2>/dev/null) old_hy2_masquerade_url=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .masquerade // empty' "$cfg" 2>/dev/null) domain_name=$(head -n1 /etc/s-box/domain.log 2>/dev/null | tr -d '\r\n ') https_backend_port="$(read_persisted_kv "INT_PORT_HTTPS_BACKEND" 2>/dev/null || true)" if ! is_valid_port_number "$https_backend_port" && [[ -f "$SB_NGINX_STREAM_CONF" ]]; then https_backend_port=$(awk '/upstream https_backend/{flag=1; next} flag && /server 127\.0\.0\.1:/{gsub(/.*127\.0\.0\.1:/, "", $0); gsub(/;.*/, "", $0); print; exit}' "$SB_NGINX_STREAM_CONF" 2>/dev/null) fi if ! is_valid_port_number "$https_backend_port" && [[ -f "$SB_NGINX_HTTP_CONF" ]]; then https_backend_port=$(awk '/listen 127\.0\.0\.1:/{gsub(/.*127\.0\.0\.1:/, "", $0); gsub(/[[:space:]]+ssl([[:space:]]+http2)?;.*/, "", $0); print; exit}' "$SB_NGINX_HTTP_CONF" 2>/dev/null) fi int_port_https_backend="$https_backend_port" [[ -n "$existing_uuid" && -n "$private_key_reality" && -n "$short_id" && -n "$public_key_reality" && -n "$domain_name" ]] || { red "當前 v3 配置缺少必要識別信息,無法安全做身份歸一化。" return 1 } is_valid_port_number "$int_port_reality" || { red "當前 Reality 內部端口無效"; return 1; } is_valid_port_number "$int_port_vmws" || { red "當前 VMess-WS 內部端口無效"; return 1; } is_valid_port_number "$int_port_https_backend" || { red "當前 HTTPS backend 內部端口無效"; return 1; } is_valid_port_number "$port_hy2" || { red "當前 Hysteria2 端口無效"; return 1; } is_valid_port_number "$port_tu" || { red "當前 TUIC 端口無效"; return 1; } uuid="$existing_uuid" [[ -n "$old_hy2_obfs_password" ]] && persist_env_kv "SB_HY2_OBFS_PASSWORD" "$old_hy2_obfs_password" [[ -n "$old_hy2_masquerade_url" ]] && persist_env_kv "SB_HY2_MASQUERADE_URL" "$old_hy2_masquerade_url" if [[ -n "$old_reality_sni" ]]; then persist_env_kv "SB_PREVIOUS_REALITY_SNI" "$old_reality_sni" fi if [[ -n "$old_vm_ws_path" ]]; then persist_env_kv "SB_PREVIOUS_VM_WS_PATH" "$old_vm_ws_path" fi reality_sni="" persist_env_kv "SB_REALITY_SNI" "" persist_env_kv "SB_VM_WS_PATH" "" yellow "身份歸一化:將重新選定 Reality SNI 並輪換 VMess-WS 路徑。" load_runtime_env } migrate_v2_to_v3(){ local snapshot_dir="" detect_install_layout if [[ "$SB_INSTALL_LAYOUT" != "v2_split" ]]; then yellow "當前不是舊版分端口架構,無需執行 v2 -> v3 遷移。" return 0 fi check_system_clock_preflight || return 1 snapshot_dir=$(create_rollout_snapshot "migrate-v2") || { red "建立遷移快照失敗。"; return 1; } green "已建立遷移快照: ${snapshot_dir}" begin_rollback_guard "$snapshot_dir" "v2 -> v3 遷移" install_depend || return 1 assert_latest_stable_supported "Sing-box 遷移" || return 1 collect_v2_runtime_state || return 1 preserve_external_udp_ports_for_migration "$port_hy2" "$port_tu" || return 1 init_hy2_transport_env || return 1 ensure_domain_and_cert maybe_align_hy2_masquerade_with_site maybe_prompt_telegram_on_install inssb || return 1 gen_config || return 1 setup_nginx_sni setup_firewall sbservice || return 1 setup_hy2_port_hopping if ! validate_v3_runtime_health; then red "遷移後健康檢查失敗,正在自動回滾。" return_after_rollback "$snapshot_dir" return $? fi cleanup_legacy_split_firewall_rules "$SB_MIGRATE_OLD_VLESS_PORT" "$SB_MIGRATE_OLD_VMESS_PORT" end_rollback_guard lnsb || yellow "快捷命令 /usr/bin/sb 更新失敗,但遷移已完成。" green "v2 -> v3 遷移完成。" yellow "快照保留於: ${snapshot_dir}" post_install_check } normalize_v3_identity_preflight(){ local current_server="unknown" current_strategy="unknown" planned_strategy="" confirmation="" command -v jq >/dev/null 2>&1 || { red "缺少 jq,無法預覽身份歸一化差異。"; return 1; } [[ -s /etc/s-box/sb.json ]] || { red "缺少現行 sb.json,無法預覽身份歸一化差異。"; return 1; } load_runtime_env current_server=$(jq -r ' .route.default_domain_resolver as $resolver | if ($resolver | type) == "object" then ($resolver.server // "unknown") else ($resolver // "unknown") end ' /etc/s-box/sb.json 2>/dev/null || printf '%s' "unknown") current_strategy=$(current_server_ip_strategy /etc/s-box/sb.json 2>/dev/null || printf '%s' "unknown") resolve_server_ip_strategy /etc/s-box/sb.json || return 1 planned_strategy="$SB_RESOLVED_SERVER_IP_STRATEGY" blue "身份歸一化 DNS 預覽:" blue " 現行 resolver=${current_server}, strategy=${current_strategy}" blue " 計畫 resolver=dns_upstream(${SB_SERVER_DNS_UPSTREAM}:853), strategy=${planned_strategy}" yellow "此命令會完整重建 sb.json、重新選定 Reality SNI、輪換 VMess-WS 路徑並重啟服務;不是只修改身份。" if is_true "$SB_BATCH_MODE"; then [[ "${SB_NORMALIZE_V3_CONFIRM:-}" == "REGENERATE" ]] || { red "批次模式必須顯式設置 SB_NORMALIZE_V3_CONFIRM=REGENERATE。" return 1 } else readp "輸入 REGENERATE 確認完整重建,其他輸入取消: " confirmation [[ "$confirmation" == "REGENERATE" ]] || { yellow "已取消身份歸一化。"; return 1; } fi } normalize_v3_identity(){ local snapshot_dir="" detect_install_layout require_v3_layout "v3 身份歸一化" || return 1 check_system_clock_preflight || return 1 normalize_v3_identity_preflight || return 1 snapshot_dir=$(create_rollout_snapshot "normalize-v3-identity") || { red "建立身份歸一化快照失敗。"; return 1; } green "已建立身份歸一化快照: ${snapshot_dir}" begin_rollback_guard "$snapshot_dir" "v3 身份歸一化" assert_latest_stable_supported "v3 身份歸一化" || return 1 collect_v3_runtime_state || return 1 init_hy2_transport_env || return 1 gen_config || return 1 setup_nginx_sni setup_firewall sbservice || return 1 setup_hy2_port_hopping if ! validate_v3_runtime_health; then red "身份歸一化後健康檢查失敗,正在自動回滾。" return_after_rollback "$snapshot_dir" return $? fi end_rollback_guard lnsb || yellow "快捷命令 /usr/bin/sb 更新失敗,但身份歸一化已完成。" green "v3 身份歸一化完成。" yellow "快照保留於: ${snapshot_dir}" post_install_check } # ==================== 服務管理 ==================== sbservice(){ cat > /etc/systemd/system/sing-box.service </dev/null 2>&1 || return 1 systemctl restart sing-box || return 1 } # ==================== HY2 端口跳躍服務 ==================== create_hy2_hop_script(){ ensure_sbox_dir cat > "$SB_HY2_HOP_SCRIPT" <<'EOF' #!/bin/bash set -u LEGACY_ENV_FILE="/etc/s-box/sb.env" USER_ENV_FILE="/etc/s-box/sb.user.env" STATE_ENV_FILE="/etc/s-box/sb.state.env" read_env(){ local key="$1" file=""; for file in "$STATE_ENV_FILE" "$USER_ENV_FILE" "$LEGACY_ENV_FILE"; do [[ -f "$file" ]] || continue; if grep -q -E "^${key}=" "$file" 2>/dev/null; then grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 | cut -d= -f2-; return 0; fi; done; return 0; } is_valid_port(){ local p="$1"; [[ "$p" =~ ^[0-9]+$ ]] && (( p >= 1 && p <= 65535 )); } clean_bin_chain(){ local bin="$1" chain="SB_HY2_HOP"; command -v "$bin" >/dev/null 2>&1 || return 0; "$bin" -t nat -F "$chain" >/dev/null 2>&1 || true; while "$bin" -t nat -C PREROUTING -j "$chain" >/dev/null 2>&1; do "$bin" -t nat -D PREROUTING -j "$chain" >/dev/null 2>&1 || break; done; while "$bin" -t nat -C OUTPUT -m addrtype --dst-type LOCAL -j "$chain" >/dev/null 2>&1; do "$bin" -t nat -D OUTPUT -m addrtype --dst-type LOCAL -j "$chain" >/dev/null 2>&1 || break; done; "$bin" -t nat -X "$chain" >/dev/null 2>&1 || true; } apply_bin_chain(){ local bin="$1" start="$2" end="$3" target="$4" chain="SB_HY2_HOP"; command -v "$bin" >/dev/null 2>&1 || return 0; "$bin" -t nat -N "$chain" >/dev/null 2>&1 || true; "$bin" -t nat -C PREROUTING -j "$chain" >/dev/null 2>&1 || "$bin" -t nat -A PREROUTING -j "$chain"; "$bin" -t nat -C OUTPUT -m addrtype --dst-type LOCAL -j "$chain" >/dev/null 2>&1 || "$bin" -t nat -A OUTPUT -m addrtype --dst-type LOCAL -j "$chain"; "$bin" -t nat -F "$chain" >/dev/null 2>&1 || true; "$bin" -t nat -A "$chain" -p udp --dport "${start}:${end}" -j REDIRECT --to-ports "$target" >/dev/null 2>&1 || true; } mode="${1:-apply}" if [[ "$mode" == "remove" ]]; then clean_bin_chain iptables; clean_bin_chain ip6tables; exit 0; fi hop_enabled="$(read_env SB_HY2_HOP_ENABLED)"; hop_start="$(read_env SB_HY2_HOP_START)"; hop_end="$(read_env SB_HY2_HOP_END)"; target_port="$(read_env SB_HY2_HOP_TARGET_PORT)" [[ -z "$hop_enabled" ]] && hop_enabled="0" if [[ "$hop_enabled" != "1" ]]; then clean_bin_chain iptables; clean_bin_chain ip6tables; exit 0; fi if ! is_valid_port "$hop_start" || ! is_valid_port "$hop_end" || ! is_valid_port "$target_port" || (( hop_start >= hop_end )); then exit 1; fi apply_bin_chain iptables "$hop_start" "$hop_end" "$target_port"; apply_bin_chain ip6tables "$hop_start" "$hop_end" "$target_port"; exit 0 EOF chmod +x "$SB_HY2_HOP_SCRIPT" } setup_hy2_port_hopping(){ load_hy2_runtime_from_server_files; create_hy2_hop_script cat > "/etc/systemd/system/${SB_HY2_HOP_SERVICE}" </dev/null 2>&1 || true systemctl restart "${SB_HY2_HOP_SERVICE}" >/dev/null 2>&1 && green "HY2 端口跳躍規則已啟用。" || yellow "HY2 端口跳躍規則啟用失敗。" else systemctl disable --now "${SB_HY2_HOP_SERVICE}" >/dev/null 2>&1 || true fi } cleanup_hy2_port_hopping(){ [[ -x "$SB_HY2_HOP_SCRIPT" ]] && "$SB_HY2_HOP_SCRIPT" remove >/dev/null 2>&1 || true systemctl disable --now "${SB_HY2_HOP_SERVICE}" >/dev/null 2>&1 || true rm -f "/etc/systemd/system/${SB_HY2_HOP_SERVICE}" "$SB_HY2_HOP_SCRIPT" systemctl daemon-reload >/dev/null 2>&1 || true } # ==================== 防火牆(精簡:只暴露 443 + UDP)==================== cleanup_managed_firewall_rules(){ local old_hy2 old_hop old_tu [[ -f /etc/s-box/firewall_ports.log ]] || return 0 old_hy2=$(grep '^HY2_PORT=' /etc/s-box/firewall_ports.log 2>/dev/null | cut -d= -f2) old_hop=$(grep '^HY2_HOP_RANGE=' /etc/s-box/firewall_ports.log 2>/dev/null | cut -d= -f2) old_tu=$(grep '^TUIC_PORT=' /etc/s-box/firewall_ports.log 2>/dev/null | cut -d= -f2) [[ -n "$old_hy2" ]] && ufw delete allow "${old_hy2}/udp" >/dev/null 2>&1 || true [[ -n "$old_hop" ]] && ufw delete allow "${old_hop}/udp" >/dev/null 2>&1 || true [[ -n "$old_tu" ]] && ufw delete allow "${old_tu}/udp" >/dev/null 2>&1 || true } setup_firewall(){ green "正在配置防火牆 (UFW - 安全模式)..." load_hy2_runtime_from_server_files local hy2_hop_range="" if [[ "${SB_HY2_HOP_ENABLED:-0}" == "1" ]] && is_valid_port_number "${SB_HY2_HOP_START:-}" && is_valid_port_number "${SB_HY2_HOP_END:-}" && (( 10#$SB_HY2_HOP_START < 10#$SB_HY2_HOP_END )); then hy2_hop_range="${SB_HY2_HOP_START}:${SB_HY2_HOP_END}" fi # 檢測 SSH 端口 local ssh_port="" if [[ -n "${SSH_CONNECTION:-}" ]]; then ssh_port=$(echo "$SSH_CONNECTION" | awk '{print $4}' 2>/dev/null) fi if [[ -z "$ssh_port" ]]; then ssh_port=$(ss -tlnp 2>/dev/null | grep -E 'sshd|"ssh"' | awk '{print $4}' | grep -oE '[0-9]+$' | head -1); fi if [[ -z "$ssh_port" ]] && command -v sshd >/dev/null 2>&1; then ssh_port=$(sshd -T 2>/dev/null | awk '/^port / {print $2; exit}'); fi if [[ -z "$ssh_port" ]]; then ssh_port=$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | tail -n 1 | awk '{print $2}'); fi [[ -z "$ssh_port" ]] && ssh_port=22 green "將放行 SSH 端口: $ssh_port" local ufw_status=$(ufw status 2>/dev/null | head -1) if echo "$ufw_status" | grep -qi "inactive"; then green "UFW 未啟用,進行首次安全配置..." ufw default deny incoming >/dev/null 2>&1 ufw default allow outgoing >/dev/null 2>&1 else green "UFW 已啟用,採用增量模式..." fi cleanup_managed_firewall_rules # 只暴露必要端口(新架構:TCP 只有 443) ufw_allow "$ssh_port" tcp "SSH" ufw_allow 80 tcp "ACME" ufw_allow 443 tcp "HTTPS+Reality" # 不再暴露 Reality/VMess 高位 TCP 端口(它們只聽 127.0.0.1) ufw_allow "$port_hy2" udp "Hysteria2" [[ -n "$hy2_hop_range" ]] && ufw_allow "$hy2_hop_range" udp "HY2-PortHop" ufw_allow "$port_tu" udp "TUIC5" cat > /etc/s-box/firewall_ports.log </dev/null 2>&1; green "UFW 已啟用。" else yellow "已跳過 UFW 啟用。手動啟用: ufw enable" fi fi green "防火牆配置完成!" echo -e " SSH: ${yellow}$ssh_port${plain}" echo -e " HTTPS+Reality: ${yellow}443/tcp${plain} (Nginx SNI 分流)" echo -e " Hysteria2: ${yellow}$port_hy2/udp${plain}" [[ -n "$hy2_hop_range" ]] && echo -e " HY2-PortHop: ${yellow}${hy2_hop_range}/udp -> $port_hy2${plain}" echo -e " TUIC5: ${yellow}$port_tu/udp${plain}" yellow " 注意:Reality 和 VMess-WS 不再暴露獨立端口,統一走 443" } # ==================== Telegram 通知 ==================== configure_telegram_notify(){ ensure_sbox_dir; load_runtime_env echo; yellow "Telegram 通知用於:證書自動續期失敗時提醒。" readp "啟用 Telegram 失敗通知?[y/N]: " tele_enable_choice if [[ ! "${tele_enable_choice:-n}" =~ ^[Yy]$ ]]; then persist_env_kv "SB_TELEGRAM_ENABLED" "0"; load_runtime_env; green "已關閉 Telegram 通知。"; return 0 fi local token_input chat_input thread_input readp "Bot Token [留空沿用]: " token_input; token_input=$(echo "$token_input" | tr -d '[:space:]'); [[ -z "$token_input" ]] && token_input="$SB_TELEGRAM_BOT_TOKEN" [[ -z "$token_input" ]] && { red "Bot Token 不能為空。"; return 1; } readp "Chat ID [留空沿用]: " chat_input; chat_input=$(echo "$chat_input" | tr -d '[:space:]'); [[ -z "$chat_input" ]] && chat_input="$SB_TELEGRAM_CHAT_ID" [[ -z "$chat_input" ]] && { red "Chat ID 不能為空。"; return 1; } readp "Thread ID (可選,none 清空): " thread_input; thread_input=$(echo "$thread_input" | tr -d '[:space:]') [[ "$thread_input" == "none" || "$thread_input" == "NONE" ]] && thread_input="" || { [[ -z "$thread_input" ]] && thread_input="$SB_TELEGRAM_THREAD_ID"; } persist_env_kv "SB_TELEGRAM_ENABLED" "1" persist_env_kv "SB_TELEGRAM_BOT_TOKEN" "$token_input" persist_env_kv "SB_TELEGRAM_CHAT_ID" "$chat_input" persist_env_kv "SB_TELEGRAM_THREAD_ID" "$thread_input" load_runtime_env; green "Telegram 參數已寫入。" } maybe_prompt_telegram_on_install(){ local tele_enabled_saved tele_enabled_saved="$(read_persisted_kv "SB_TELEGRAM_ENABLED" 2>/dev/null || true)" if [[ -n "$tele_enabled_saved" ]]; then load_runtime_env; return 0; fi if is_true "$SB_BATCH_MODE"; then persist_env_kv "SB_TELEGRAM_ENABLED" "0" load_runtime_env return 0 fi echo; yellow "可選設置:證書續期失敗 Telegram 通知。" readp "現在配置?[y/N]: " tele_init_choice if [[ "${tele_init_choice:-n}" =~ ^[Yy]$ ]]; then configure_telegram_notify else persist_env_kv "SB_TELEGRAM_ENABLED" "0"; load_runtime_env; green "已跳過。" fi } # ==================== 安裝後自檢 ==================== post_install_check(){ echo echo -e "\\033[1;36m ┌──────────────────────────────────────────────────────────────┐\\033[0m" echo -e "\\033[1;36m │ 🔍 安裝後健康自檢 │\\033[0m" echo -e "\\033[1;36m ├──────────────────────────────────────────────────────────────┤\\033[0m" local all_ok=1 if systemctl is-active --quiet nginx; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m Nginx 服務運行中 \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[31m❌\\033[0m Nginx 服務未運行 \\033[1;36m│\\033[0m"; all_ok=0; fi if systemctl is-active --quiet sing-box; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m Sing-box 服務運行中 \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[31m❌\\033[0m Sing-box 服務未運行 \\033[1;36m│\\033[0m"; all_ok=0; fi if tcp_port_listening 443; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m TCP 443 監聽中 (Nginx SNI) \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[33m⚠️\\033[0m TCP 443 未監聽 \\033[1;36m│\\033[0m"; all_ok=0; fi if tcp_port_listening "${int_port_reality}"; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m Reality 端口 ${int_port_reality} 監聽中 \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[33m⚠️\\033[0m Reality 端口 ${int_port_reality} 未監聽 \\033[1;36m│\\033[0m"; all_ok=0; fi if tcp_port_listening "${int_port_vmws}"; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m VMess-WS 端口 ${int_port_vmws} 監聽中 \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[33m⚠️\\033[0m VMess-WS 端口 ${int_port_vmws} 未監聽 \\033[1;36m│\\033[0m"; all_ok=0; fi if udp_port_listening "${port_hy2}"; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m Hysteria2 UDP ${port_hy2} 監聽中 \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[33m⚠️\\033[0m HY2 UDP ${port_hy2} 未監聽 \\033[1;36m│\\033[0m"; all_ok=0; fi if udp_port_listening "${port_tu}"; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m TUIC UDP ${port_tu} 監聽中 \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[33m⚠️\\033[0m TUIC UDP ${port_tu} 未監聽 \\033[1;36m│\\033[0m"; all_ok=0; fi if /etc/s-box/sing-box check -c /etc/s-box/sb.json >/dev/null 2>&1; then echo -e "\\033[1;36m │\\033[0m \\033[32m✅\\033[0m 配置文件校驗通過 \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[31m❌\\033[0m 配置文件校驗失敗 \\033[1;36m│\\033[0m"; all_ok=0; fi echo -e "\\033[1;36m ├──────────────────────────────────────────────────────────────┤\\033[0m" if [[ "$all_ok" == "1" ]]; then echo -e "\\033[1;36m │\\033[0m \\033[1;32m🎉 所有檢查項通過!服務已就緒。\\033[0m \\033[1;36m│\\033[0m" else echo -e "\\033[1;36m │\\033[0m \\033[1;33m⚠️ 部分項異常,請查看上方詳情。\\033[0m \\033[1;36m│\\033[0m" fi echo -e "\\033[1;36m └──────────────────────────────────────────────────────────────┘\\033[0m" } # ==================== 訂閱鏈接(端口統一 443)==================== sbshare(){ local host_uri="" require_v3_layout "節點輸出" || return 1 v4v6 domain=$(cat /etc/s-box/domain.log 2>/dev/null | head -n1 | tr -d '\r\n ') uuid=$(jq -r '.inbounds[0].users[0].uuid' /etc/s-box/sb.json 2>/dev/null) port_hy=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .listen_port' /etc/s-box/sb.json 2>/dev/null) port_tu_share=$(jq -r '.inbounds[]? | select(.type=="tuic") | .listen_port' /etc/s-box/sb.json 2>/dev/null) pk=$(cat /etc/s-box/public.key 2>/dev/null) sid=$(jq -r '.inbounds[0].tls.reality.short_id[0]' /etc/s-box/sb.json 2>/dev/null) vm_path=$(jq -r '.inbounds[]? | select(.type=="vmess") | .transport.path' /etc/s-box/sb.json 2>/dev/null) reality_sni_share=$(jq -r '.inbounds[0].tls.reality.handshake.server // .inbounds[0].tls.server_name // empty' /etc/s-box/sb.json 2>/dev/null) [[ -z "$reality_sni_share" ]] && reality_sni_share="$reality_sni" [[ -n "$domain" && -n "$uuid" && -n "$port_hy" && -n "$port_tu_share" && -n "$pk" && -n "$sid" && -n "$vm_path" && -n "$reality_sni_share" ]] || { red "節點輸出所需字段不完整,請先檢查 /etc/s-box/sb.json、/etc/s-box/public.key 和 /etc/s-box/domain.log。" return 1 } host="$(select_client_host "$domain" "$v4" "$v6")" || { red "零 DNS 洩漏策略找不到可用的字面 IP。"; return 1; } host_uri="$(format_uri_host "$host")" || return 1 load_hy2_runtime_from_server_files local domain_enc reality_sni_enc hy2_obfs_password_enc pk_enc sid_enc hostname_enc tuic_user_enc tuic_pass_enc hy2_query="security=tls&alpn=h3&insecure=0" hy2_hop_range="" domain_enc="$(uri_encode "$domain")" reality_sni_enc="$(uri_encode "$reality_sni_share")" pk_enc="$(uri_encode "$pk")" sid_enc="$(uri_encode "$sid")" hostname_enc="$(uri_encode "$hostname")" tuic_user_enc="$(uri_encode "$uuid")" tuic_pass_enc="$(uri_encode "$uuid")" hy2_query="${hy2_query}&sni=${domain_enc}" if [[ "${SB_HY2_OBFS_ENABLED:-1}" == "1" && -n "${SB_HY2_OBFS_PASSWORD:-}" ]]; then hy2_obfs_password_enc="$(uri_encode "${SB_HY2_OBFS_PASSWORD}")" hy2_query="${hy2_query}&obfs=salamander&obfs-password=${hy2_obfs_password_enc}" fi if [[ "${SB_HY2_HOP_ENABLED:-0}" == "1" ]] && is_valid_port_number "${SB_HY2_HOP_START:-}" && is_valid_port_number "${SB_HY2_HOP_END:-}" && (( 10#$SB_HY2_HOP_START < 10#$SB_HY2_HOP_END )); then hy2_hop_range="${SB_HY2_HOP_START}-${SB_HY2_HOP_END}" fi # 所有 TCP 協議端口統一為 443 vl_link="vless://$uuid@$host_uri:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=$reality_sni_enc&fp=chrome&pbk=$pk_enc&sid=$sid_enc&type=tcp&headerType=none#VL-$hostname_enc" vm_json=$(jq -n \ --arg add "$host" --arg aid "0" --arg host "$domain" --arg id "$uuid" \ --arg net "ws" --arg path "$vm_path" --arg port "443" \ --arg ps "VM-$hostname" --arg tls "tls" --arg sni "$domain" \ --arg type "none" --arg v "2" \ '{add:$add, aid:$aid, host:$host, id:$id, net:$net, path:$path, port:$port, ps:$ps, tls:$tls, sni:$sni, type:$type, v:$v}') vm_link="vmess://$(echo -n "$vm_json" | base64_no_wrap)" hy_link="hysteria2://$uuid@$host_uri:$port_hy?${hy2_query}#HY2-$hostname_enc" hy_hop_link="" # Hysteria 2 官方 URI 明確允許 authority port 使用多埠/範圍格式。 [[ -n "$hy2_hop_range" ]] && hy_hop_link="hysteria2://$uuid@$host_uri:$hy2_hop_range/?${hy2_query}#HY2-Hop-$hostname_enc" tu_link="tuic://${tuic_user_enc}:${tuic_pass_enc}@$host_uri:$port_tu_share?congestion_control=bbr&udp_relay_mode=native&alpn=h3&sni=$domain_enc&allow_insecure=0#TU5-$hostname_enc" echo "$vl_link" > /etc/s-box/sub.txt echo "$vm_link" >> /etc/s-box/sub.txt echo "$hy_link" >> /etc/s-box/sub.txt [[ -n "$hy_hop_link" ]] && echo "$hy_hop_link" >> /etc/s-box/sub.txt echo "$tu_link" >> /etc/s-box/sub.txt chmod 600 /etc/s-box/sub.txt sub_base64=$(base64_no_wrap < /etc/s-box/sub.txt) echo white "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" echo -e "域名: ${green}$domain${plain}" echo -e "UUID: ${green}$uuid${plain}" echo -e "架構: ${green}Nginx SNI 分流 (v3.2)${plain}" echo echo -e "VLESS-Reality: ${yellow}443/tcp${plain} (SNI: $reality_sni_share)" echo -e "VMess-WS-TLS: ${yellow}443/tcp${plain} (Nginx 反代)" echo -e "Hysteria2: ${yellow}$port_hy/udp${plain}" [[ -n "$hy2_hop_range" ]] && echo -e "HY2-Hop: ${yellow}${hy2_hop_range}/udp${plain}" echo -e "TUIC V5: ${yellow}$port_tu_share/udp${plain}" echo red "🚀【 聚合訂閱 (Base64) 】" echo -e "${yellow}$sub_base64${plain}" white "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" } # ==================== 客戶端配置(canonical 雙版本 + v114 原生語法)==================== validate_client_config_inputs(){ local host="$1" domain="$2" uuid_value="$3" public_key="$4" short_id_value="$5" reality_name="$6" ws_path="$7" local hy_port="$8" tuic_port="$9" hy_ports="${10}" hop_interval="${11}" obfs_password="${12}" cert_pin="${13}" label="${14}" target_track="${15:-canonical}" local range_start="" range_end="" command -v jq >/dev/null 2>&1 || { red "缺少 jq,無法校驗客戶端 JSON。" >&2; return 1; } command -v python3 >/dev/null 2>&1 || { red "缺少 python3,無法校驗客戶端字面 IP。" >&2; return 1; } is_literal_ip_address "$host" || { red "客戶端代理端點必須是字面 IP。" >&2; return 1; } is_valid_sni_hostname "$domain" || { red "客戶端 TLS 域名格式無效。" >&2; return 1; } is_valid_sni_hostname "$reality_name" || { red "客戶端 REALITY SNI 格式無效。" >&2; return 1; } is_valid_uuid_value "$uuid_value" || { red "客戶端 UUID 格式無效。" >&2; return 1; } is_valid_reality_key "$public_key" || { red "客戶端 REALITY 公鑰格式無效。" >&2; return 1; } is_valid_reality_short_id "$short_id_value" || { red "客戶端 REALITY short_id 格式無效。" >&2; return 1; } is_valid_ws_path "$ws_path" || { red "客戶端 VMess-WS 路徑包含不安全字符。" >&2; return 1; } is_valid_port_number "$hy_port" || { red "客戶端 HY2 端口無效。" >&2; return 1; } is_valid_port_number "$tuic_port" || { red "客戶端 TUIC 端口無效。" >&2; return 1; } if [[ "$hy_ports" =~ ^([0-9]+)[:-]([0-9]+)$ ]]; then range_start="${BASH_REMATCH[1]}" range_end="${BASH_REMATCH[2]}" is_valid_port_number "$range_start" && is_valid_port_number "$range_end" && (( 10#$range_start < 10#$range_end )) \ || { red "客戶端 HY2 hopping 範圍無效。" >&2; return 1; } is_valid_go_duration "$hop_interval" || { red "客戶端 HY2 hopping 間隔無效。" >&2; return 1; } else is_valid_port_number "$hy_ports" || { red "客戶端 HY2 端口選擇無效。" >&2; return 1; } fi is_safe_json_text "$obfs_password" || { red "客戶端 HY2 obfs 密碼包含不安全 JSON 字符。" >&2; return 1; } [[ -z "$cert_pin" ]] || is_valid_certificate_public_key_sha256 "$cert_pin" \ || { red "客戶端證書公鑰 pin 格式無效。" >&2; return 1; } is_safe_json_text "$label" || { red "客戶端版本標籤無效。" >&2; return 1; } is_valid_client_dns_profile "$SB_CLIENT_DNS_PROFILE" || return 1 is_valid_client_cache_capacity "$SB_CLIENT_DNS_CACHE_CAPACITY" || return 1 is_valid_client_tuic_congestion_control "$SB_CLIENT_TUIC_CONGESTION_CONTROL" || return 1 is_valid_go_duration "$SB_URLTEST_INTERVAL" || return 1 is_valid_go_duration "$SB_URLTEST_IDLE_TIMEOUT" || return 1 [[ "$SB_URLTEST_TOLERANCE" =~ ^[0-9]+$ ]] && (( 10#$SB_URLTEST_TOLERANCE <= 2000 )) || return 1 is_literal_ip_url "$SB_URLTEST_URL" || return 1 is_valid_tls_fingerprint "${SB_REALITY_UTLS_FINGERPRINT:-chrome}" || return 1 if is_true "${SB_CLIENT_UTLS_ENABLED:-0}"; then is_valid_tls_fingerprint "${SB_VMESS_UTLS_FINGERPRINT:-chrome}" || return 1 fi is_pinned_ruleset_url "$SB_GEOSITE_GEOLOCATION_NONCN_URL" || return 1 is_pinned_ruleset_url "$SB_GEOSITE_CN_URL" || return 1 is_pinned_ruleset_url "$SB_GEOIP_CN_URL" || return 1 case "$target_track" in canonical|v114) ;; *) return 1 ;; esac case "${SB_CLIENT_CONF_FORMAT:-human}" in human|json) ;; *) return 1 ;; esac } show_client_conf_latest(){ local host="$1" domain="$2" uuid="$3" pk="$4" sid="$5" reality_sni_client="$6" vm_path="$7" port_hy="$8" port_tu_client="$9" hy2_server_ports="${10}" hy2_hop_interval="${11}" hy2_obfs_password="${12}" cert_pin="${13}" local current_stable_label="${14}" target_track="${15:-canonical}" local standard_tls_pin_json="" vless_utls_json=', "utls": { "enabled": true, "fingerprint": "'"${SB_REALITY_UTLS_FINGERPRINT:-chrome}"'" }' vmess_utls_json="" hy2_server_selection_json="" hy2_bandwidth_json="" dns_hijack_rule_json="" dns_bypass_guard_rule_json="" interrupt_exist_connections_json="" cn_dns_detour_json="" json_only="0" candidate_json="" client_http_clients_json="" route_default_http_client_json="" ruleset_download_transport_json=', "download_detour": "select"' validate_client_config_inputs "$host" "$domain" "$uuid" "$pk" "$sid" "$reality_sni_client" "$vm_path" "$port_hy" "$port_tu_client" "$hy2_server_ports" "$hy2_hop_interval" "$hy2_obfs_password" "$cert_pin" "$current_stable_label" "$target_track" || { red "客戶端配置輸入校驗失敗,未輸出 JSON。" >&2 return 1 } [[ -n "$cert_pin" ]] && standard_tls_pin_json=', "certificate_public_key_sha256": ["'"$cert_pin"'"]' if [[ "${SB_CLIENT_UTLS_ENABLED:-0}" == "1" ]]; then vmess_utls_json=', "utls": { "enabled": true, "fingerprint": "'"${SB_VMESS_UTLS_FINGERPRINT:-chrome}"'" }' fi if [[ "$hy2_server_ports" =~ ^[0-9]+[:\-][0-9]+$ ]]; then hy2_server_selection_json=', "server_ports": ["'"$hy2_server_ports"'"], "hop_interval": "'"$hy2_hop_interval"'"' else hy2_server_selection_json=', "server_port": '"$port_hy" fi dns_hijack_rule_json="$(build_dns_hijack_rule_json)" dns_bypass_guard_rule_json="$(build_dns_bypass_guard_rule_json)" cn_dns_detour_json="$(build_client_cn_dns_detour_json)" || return 1 hy2_bandwidth_json="$(build_hy2_bandwidth_json)" || return 1 interrupt_exist_connections_json="$(json_bool_value "${SB_CLIENT_INTERRUPT_ON_SWITCH:-0}")" [[ "${SB_CLIENT_CONF_FORMAT:-human}" == "json" ]] && json_only="1" if [[ "$target_track" == "v114" ]]; then client_http_clients_json=' "http_clients": [ { "tag": "ruleset-download", "detour": "select" } ], ' route_default_http_client_json=' "default_http_client": "ruleset-download", ' ruleset_download_transport_json="" fi local vmess_mux_json="" if is_true "${SB_CLIENT_MUX:-0}"; then vmess_mux_json=', "multiplex": { "enabled": true, "protocol": "h2mux", "max_connections": 4, "min_streams": 4, "padding": true }' fi candidate_json=$(cat </dev/null 2>&1; then red "客戶端生成器產生了無效 JSON;未輸出候選配置。" >&2 return 1 fi if [[ "$json_only" != "1" ]]; then green "══════════════════════════════════════════════════════════════" green " 客戶端配置 — ${current_stable_label} 單一規範" green " TCP 統一 443 · TUN 模式" green " 節點: literal-ip-only (${host})" if [[ "$SB_CLIENT_DNS_PROFILE" == "strict_proxy" ]]; then green " DNS: strict_proxy — DoT/DoH 全部經代理,無直連解析" else yellow " DNS: balanced_cn — 阿里/騰訊加密 DNS 直連(非零直連 DNS)" fi green " FakeIP: v4+v6 · CN 直連 · 非 CN 走代理" yellow " Reality: uTLS 固定啟用 (${SB_REALITY_UTLS_FINGERPRINT:-chrome})" [[ "${SB_CLIENT_UTLS_ENABLED:-0}" == "1" ]] && yellow " VMess: uTLS 已啟用 (${SB_VMESS_UTLS_FINGERPRINT:-chrome})" [[ -n "$cert_pin" ]] && yellow " TLS: 證書公鑰 pin 已顯式啟用;換鑰前必須先更新客戶端" is_true "${SB_CLIENT_REJECT_DNS_853:-1}" && green " 嚴格策略: DoT/DoQ (TCP/UDP 853) 攔截已啟用" is_true "${SB_CLIENT_REJECT_UDP443:-1}" && green " 嚴格策略: HTTP/3/QUIC/DoH3 (UDP 443) 攔截已啟用" is_true "${SB_CLIENT_REJECT_STUN:-1}" && green " 隱私策略: STUN 攔截已啟用" if [[ -n "$hy2_bandwidth_json" ]]; then yellow " HY2: 固定 Hysteria CC 帶寬已顯式啟用"; else green " HY2: 未固定帶寬,使用 BBR"; fi green "══════════════════════════════════════════════════════════════" echo fi printf '%s\n' "$candidate_json" if [[ "$json_only" != "1" ]]; then echo yellow "📌 適用於: Sing-box ${current_stable_label}" fi } client_conf(){ local requested_track="${1:-${SB_CLIENT_CONF_TRACK:-canonical}}" local target_track="canonical" current_stable_label="" cert_pin="" hy2_server_ports="" hy2_hop_interval="" hy2_obfs_password="" latest_host="" case "$requested_track" in canonical|latest|stable|2|"") target_track="canonical" ;; v114|1.14|1.14.0|114) target_track="v114" ;; legacy|1|both|3) red "sing-box 1.11 legacy 軌已退役;僅輸出精確支援 1.13.21 與 1.14.0 的單一配置。" return 1 ;; *) red "未知客戶端配置軌道: ${requested_track}" yellow "可用值: canonical | v114" return 1 ;; esac require_v3_layout "客戶端配置輸出" || return 1 [[ -f /etc/s-box/sb.json ]] || { red "未找到 /etc/s-box/sb.json"; return 1; } command -v jq >/dev/null 2>&1 || { red "缺少 jq"; return 1; } is_literal_ip_url "$SB_URLTEST_URL" || { red "零 DNS 洩漏策略要求 SB_URLTEST_URL 使用字面 IP URL。"; return 1; } is_valid_client_dns_profile "$SB_CLIENT_DNS_PROFILE" || { red "SB_CLIENT_DNS_PROFILE 只能是 strict_proxy 或 balanced_cn。"; return 1; } is_valid_client_cache_capacity "$SB_CLIENT_DNS_CACHE_CAPACITY" || { red "SB_CLIENT_DNS_CACHE_CAPACITY 必須是 1024..65536 的整數。"; return 1; } is_valid_client_tuic_congestion_control "$SB_CLIENT_TUIC_CONGESTION_CONTROL" || { red "TUIC 擁塞控制只能是 bbr、cubic 或 new_reno。"; return 1; } is_valid_go_duration "$SB_URLTEST_INTERVAL" || { red "SB_URLTEST_INTERVAL 格式無效。"; return 1; } is_valid_go_duration "$SB_URLTEST_IDLE_TIMEOUT" || { red "SB_URLTEST_IDLE_TIMEOUT 格式無效。"; return 1; } [[ "$SB_URLTEST_TOLERANCE" =~ ^[0-9]+$ ]] && (( 10#$SB_URLTEST_TOLERANCE <= 2000 )) || { red "SB_URLTEST_TOLERANCE 必須是 0..2000 毫秒。"; return 1; } is_pinned_ruleset_url "$SB_GEOSITE_GEOLOCATION_NONCN_URL" || { red "非 CN geosite URL 必須固定到已審核規則集 commit。"; return 1; } is_pinned_ruleset_url "$SB_GEOSITE_CN_URL" || { red "CN geosite URL 必須固定到已審核規則集 commit。"; return 1; } is_pinned_ruleset_url "$SB_GEOIP_CN_URL" || { red "CN geoip URL 必須固定到已審核規則集 commit。"; return 1; } domain=$(head -n1 /etc/s-box/domain.log 2>/dev/null | tr -d '\r\n ') [[ -n "$domain" ]] || { red "未找到域名記錄"; return 1; } uuid=$(jq -r '.inbounds[0].users[0].uuid' /etc/s-box/sb.json 2>/dev/null) pk=$(cat /etc/s-box/public.key 2>/dev/null) sid=$(jq -r '.inbounds[0].tls.reality.short_id[0]' /etc/s-box/sb.json 2>/dev/null) vm_path=$(jq -r '.inbounds[]? | select(.type=="vmess") | .transport.path' /etc/s-box/sb.json 2>/dev/null) port_hy=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .listen_port' /etc/s-box/sb.json 2>/dev/null) port_tu_client=$(jq -r '.inbounds[]? | select(.type=="tuic") | .listen_port' /etc/s-box/sb.json 2>/dev/null) reality_sni_client=$(jq -r '.inbounds[0].tls.reality.handshake.server // empty' /etc/s-box/sb.json 2>/dev/null) [[ -n "$reality_sni_client" ]] || reality_sni_client="$reality_sni" [[ -n "$uuid" && -n "$pk" && -n "$sid" && -n "$vm_path" && -n "$port_hy" && -n "$port_tu_client" ]] || { red "服務端配置信息不完整,無法輸出客戶端配置。" return 1 } if [[ "$target_track" == "v114" ]]; then current_stable_label="1.14.0" else current_stable_label="$(stable_track_label)" fi v4v6 latest_host="$(select_client_host "$domain" "$v4" "$v6")" || { red "單一配置找不到可用的字面 IP。" return 1 } load_hy2_runtime_from_server_files hy2_server_ports="$port_hy" hy2_hop_interval="${SB_HY2_HOP_INTERVAL:-30s}" hy2_obfs_password="${SB_HY2_OBFS_PASSWORD:-}" if [[ "${SB_HY2_HOP_ENABLED:-0}" == "1" ]] && is_valid_port_number "${SB_HY2_HOP_START:-}" && is_valid_port_number "${SB_HY2_HOP_END:-}" && (( 10#$SB_HY2_HOP_START < 10#$SB_HY2_HOP_END )); then hy2_server_ports="${SB_HY2_HOP_START}:${SB_HY2_HOP_END}" fi cert_pin="" if is_true "${SB_CLIENT_CERT_PIN:-0}"; then cert_pin=$(calc_cert_public_key_sha256 /etc/s-box/cert.crt 2>/dev/null || true) is_valid_certificate_public_key_sha256 "$cert_pin" || { red "已要求證書公鑰 pin,但無法從現有證書得到有效 SHA-256;停止輸出。" return 1 } fi show_client_conf_latest "$latest_host" "$domain" "$uuid" "$pk" "$sid" "$reality_sni_client" "$vm_path" "$port_hy" "$port_tu_client" "$hy2_server_ports" "$hy2_hop_interval" "$hy2_obfs_password" "$cert_pin" "$current_stable_label" "$target_track" } # ==================== 日誌 + 重啟 + 更新 ==================== view_log(){ if command -v journalctl >/dev/null 2>&1; then green "最近 100 行 sing-box 運行日誌:"; journalctl -u sing-box --no-pager -n 100 2>/dev/null || red "未找到日誌。" else red "不支持 journalctl。"; fi } restart_singbox(){ green "正在重啟 sing-box 服務..." systemctl restart sing-box 2>/dev/null || { red "重啟失敗。"; return; } sleep 1 systemctl is-active --quiet sing-box && green "sing-box 已成功重啟。" || red "sing-box 重啟後狀態異常。" } validate_core_runtime_health(){ local attempt for attempt in {1..5}; do /etc/s-box/sing-box check -c /etc/s-box/sb.json >/dev/null 2>&1 || { sleep 2; continue; } systemctl is-active --quiet sing-box >/dev/null 2>&1 || { sleep 2; continue; } return 0 done return 1 } update_core(){ local snapshot_dir="" green "正在更新 Sing-box 內核;下載、digest、版本與配置校驗期間服務保持運行..." check_system_clock_preflight || return 1 assert_latest_stable_supported "Sing-box 內核更新" || return 1 snapshot_dir=$(create_rollout_snapshot "update-core") || { red "建立內核更新回滾快照失敗。"; return 1; } begin_rollback_guard "$snapshot_dir" "Sing-box 內核更新" inssb || { red "Sing-box 內核候選下載、校驗或原子替換失敗;運行中的舊進程未被停止。" end_rollback_guard return 1 } if ! /etc/s-box/sing-box check -c /etc/s-box/sb.json >/dev/null 2>&1; then red "內核已更新,但配置校驗失敗。" return_after_rollback "$snapshot_dir" return $? fi systemctl restart sing-box 2>/dev/null || { yellow "內核已更新,但重啟失敗。" return_after_rollback "$snapshot_dir" return $? } if ! validate_core_runtime_health; then red "內核更新後運行態校驗失敗。" return_after_rollback "$snapshot_dir" return $? fi end_rollback_guard green "Sing-box 內核已原子更新並完成一次受控重啟。" } cleanup_managed_sbox_files(){ local path local managed_paths=( /etc/s-box/sb.json /etc/s-box/sb.env /etc/s-box/sb.user.env /etc/s-box/sb.state.env /etc/s-box/sub.txt /etc/s-box/domain.log /etc/s-box/public.key /etc/s-box/cert.crt /etc/s-box/private.key /etc/s-box/firewall_ports.log /etc/s-box/sing-box /etc/s-box/sing-box.tar.gz /etc/s-box/sbyg_update "$SB_NGINX_MANIFEST" /root/geoip.db /root/geosite.db "$SB_CERT_RENEW_SCRIPT" "$SB_CERT_RENEW_STATUS" "$SB_CERT_RENEW_LOG" "$SB_HY2_HOP_SCRIPT" ) for path in "${managed_paths[@]}"; do rm -f "$path" done rm -rf "$SB_NGINX_BACKUP_DIR" rmdir /etc/s-box >/dev/null 2>&1 || true } repair_hy2_hop_defaults(){ detect_install_layout require_v3_layout "HY2 hop 默認修復" || return 1 command -v jq >/dev/null 2>&1 || { red "缺少 jq"; return 1; } port_hy2=$(jq -r '.inbounds[]? | select(.type=="hysteria2") | .listen_port // empty' /etc/s-box/sb.json 2>/dev/null) port_tu=$(jq -r '.inbounds[]? | select(.type=="tuic") | .listen_port // empty' /etc/s-box/sb.json 2>/dev/null) is_valid_port_number "$port_hy2" || { red "未找到有效的 HY2 端口"; return 1; } is_valid_port_number "$port_tu" || { red "未找到有效的 TUIC 端口"; return 1; } init_hy2_transport_env || return 1 setup_firewall setup_hy2_port_hopping green "HY2 hop 默認參數已修復。" if [[ "${SB_HY2_HOP_ENABLED:-0}" == "1" ]]; then green "當前 HY2 hop 範圍: ${SB_HY2_HOP_START}:${SB_HY2_HOP_END} -> ${SB_HY2_HOP_TARGET_PORT} (${SB_HY2_HOP_INTERVAL})" else yellow "當前 HY2 hop 仍為停用。" fi } lnsb(){ local candidate_script="" expected_sha="" downloaded_sha="" file_type="" local target="/usr/bin/sb" local backup="/usr/bin/sb.bak" local source_url="${UPDATE_URL:-}" local script_path="${BASH_SOURCE[0]:-$0}" local pseudo_source=0 [[ "$script_path" == /dev/fd/* || "$script_path" == /proc/self/fd/* ]] && pseudo_source=1 candidate_script=$(mktemp /usr/bin/.sb.candidate.XXXXXX) || { red "無法在 /usr/bin 建立隨機候選文件。"; return 1; } if [[ -z "$source_url" ]]; then if [[ -f "$script_path" ]]; then install -o root -g root -m 0755 "$script_path" "$candidate_script" || { rm -f "$candidate_script"; red "建立快捷命令候選失敗。"; return 1; } elif [[ "$pseudo_source" == 1 ]]; then rm -f "$candidate_script" yellow "當前腳本是通過 bash <(curl ...) 或類似的臨時 FD 路徑執行,無法可靠自我複製到 /usr/bin/sb。" yellow "請改用本地文件方式運行,或提供 UPDATE_URL 與精確 UPDATE_SHA256。" return 1 else rm -f "$candidate_script" yellow "本地版腳本未配置默認 UPDATE_URL,且無法從當前路徑複製自身。" yellow "請先 export UPDATE_URL=<可信腳本地址> 及 UPDATE_SHA256=<精確摘要>,或使用本地文件方式運行此腳本。" return 1 fi else expected_sha=$(resolve_expected_sha256 "${UPDATE_SHA256:-}" "${UPDATE_SHA256_URL:-}" 2>/dev/null || true) if [[ -z "$expected_sha" ]]; then rm -f "$candidate_script" red "遠程腳本更新必須提供精確 SHA256;不允許降級為未校驗更新。" is_true "$SB_ALLOW_UNVERIFIED_UPDATE" && yellow "SB_ALLOW_UNVERIFIED_UPDATE 已停用;請改用 UPDATE_SHA256 或 UPDATE_SHA256_URL。" return 1 fi if ! curl_bounded "$SB_CURL_ARTIFACT_MAX_TIME" -fsSL -o "$candidate_script" "$source_url"; then rm -f "$candidate_script" red "下載更新失敗。" return 1 fi file_type=$(file -b "$candidate_script" 2>/dev/null) if ! echo "$file_type" | grep -qi "shell\|script\|text\|ASCII"; then red "下載的文件不是有效腳本。"; rm -f "$candidate_script"; return 1; fi if ! verify_file_sha256 "$candidate_script" "$expected_sha"; then downloaded_sha=$(calc_sha256 "$candidate_script" 2>/dev/null || echo "unknown") red "更新腳本 SHA256 校驗失敗。" red "期望: $expected_sha" red "實際: $downloaded_sha" rm -f "$candidate_script" return 1 fi fi if ! bash -n "$candidate_script" 2>/dev/null; then red "腳本語法錯誤。"; rm -f "$candidate_script"; return 1; fi if ! grep -q "Sing-Box 四協議一鍵安裝腳本" "$candidate_script"; then red "腳本校驗失敗。"; rm -f "$candidate_script"; return 1; fi if [[ -f "$target" ]]; then cp -p "$target" "$backup" 2>/dev/null || { rm -f "$candidate_script"; red "建立 /usr/bin/sb.bak 失敗。"; return 1; } fi chown root:root "$candidate_script" chmod 0755 "$candidate_script" mv -f -- "$candidate_script" "$target" || { red "腳本替換失敗,已保留原文件。" rm -f "$candidate_script" return 1 } green "腳本已在目的檔案系統完成隨機候選校驗與原子替換。" } # ==================== 卸載(含 Nginx 清理)==================== unins(){ systemctl stop sing-box 2>/dev/null; systemctl disable sing-box 2>/dev/null green "清理 Nginx SNI 分流配置..." rm -f "$SB_NGINX_STREAM_CONF" "$SB_NGINX_HTTP_CONF" if [[ -f "$SB_NGINX_MANIFEST" ]]; then restore_recorded_backups else local nginx_conf_dirs=("/etc/nginx/sites-enabled" "/etc/nginx/conf.d") local conf_dir backup_file orig_file for conf_dir in "${nginx_conf_dirs[@]}"; do [[ -d "$conf_dir" ]] || continue for backup_file in "$conf_dir"/*.sb_backup; do [[ -f "$backup_file" ]] || continue orig_file="${backup_file%.sb_backup}" if [[ -f "$orig_file" || ! -e "$orig_file" ]]; then cp -a "$backup_file" "$orig_file" 2>/dev/null || true fi done done if [[ -f /etc/nginx/nginx.conf.sb_backup ]]; then cp -a /etc/nginx/nginx.conf.sb_backup /etc/nginx/nginx.conf 2>/dev/null || true; fi fi nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null || true cleanup_recorded_backups if [[ -f /etc/s-box/firewall_ports.log ]]; then green "清理防火牆規則..." local HY2_PORT=$(grep '^HY2_PORT=' /etc/s-box/firewall_ports.log 2>/dev/null | cut -d= -f2) local HY2_HOP_RANGE=$(grep '^HY2_HOP_RANGE=' /etc/s-box/firewall_ports.log 2>/dev/null | cut -d= -f2) local TUIC_PORT=$(grep '^TUIC_PORT=' /etc/s-box/firewall_ports.log 2>/dev/null | cut -d= -f2) [[ -n "$HY2_PORT" ]] && ufw delete allow "$HY2_PORT"/udp >/dev/null 2>&1 [[ -n "$HY2_HOP_RANGE" ]] && ufw delete allow "$HY2_HOP_RANGE"/udp >/dev/null 2>&1 [[ -n "$TUIC_PORT" ]] && ufw delete allow "$TUIC_PORT"/udp >/dev/null 2>&1 yellow "80/443 可能被其他服務使用,是否刪除?" if is_true "$SB_BATCH_MODE"; then del_common_ports="n" else readp " 刪除 80/443 規則?[y/N]: " del_common_ports fi [[ "$del_common_ports" =~ ^[Yy]$ ]] && { ufw delete allow 80/tcp >/dev/null 2>&1; ufw delete allow 443/tcp >/dev/null 2>&1; green "已刪除。"; } fi cleanup_hy2_port_hopping systemctl disable --now "${SB_TUN_PREPARE_SERVICE}" >/dev/null 2>&1 || true cleanup_managed_sbox_files rm -f /usr/bin/sb /etc/systemd/system/sing-box.service "/etc/systemd/system/${SB_TUN_PREPARE_SERVICE}" /root/tun.sh systemctl daemon-reload 2>/dev/null green "卸載完成 (BBR/Nginx 保留)。" } # ==================== 安裝主流程 ==================== install_singbox(){ local snapshot_dir="" if [[ -f '/etc/systemd/system/sing-box.service' ]]; then detect_install_layout if [[ "$SB_INSTALL_LAYOUT" == "v2_split" ]]; then red "已檢測到舊版分端口架構,請勿直接重裝。" yellow "請改用顯式遷移流程:sb migrate-v2" else red "已安裝 Sing-box,請先卸載。" fi return 1 fi check_system_clock_preflight || return 1 snapshot_dir=$(create_rollout_snapshot "install-v3") || { red "建立安裝快照失敗。"; return 1; } green "已建立安裝快照: ${snapshot_dir}" begin_rollback_guard "$snapshot_dir" "v3 安裝" install_depend || return 1 assert_latest_stable_supported "Sing-box 安裝" || return 1 show_install_context enable_bbr setup_tun inssb || return 1 insport || return 1 init_hy2_transport_env || return 1 ensure_domain_and_cert maybe_align_hy2_masquerade_with_site maybe_prompt_telegram_on_install gen_config || return 1 setup_nginx_sni # 核心:配置 Nginx SNI 分流 setup_firewall sbservice || return 1 setup_hy2_port_hopping if ! validate_v3_runtime_health; then red "安裝後健康檢查失敗,正在自動回滾。" return_after_rollback "$snapshot_dir" return $? fi end_rollback_guard lnsb || yellow "快捷命令 /usr/bin/sb 安裝失敗,但服務已完成部署。" green "安裝完成!" post_install_check sbshare } precheck_local_host(){ detect_install_layout v4v6 echo "HOSTNAME=$(hostname 2>/dev/null || echo unknown)" echo "IPV4=${v4:-}" echo "IPV6=${v6:-}" echo "LAYOUT=${SB_INSTALL_LAYOUT:-unknown}" echo "DOMAIN=$(head -n1 /etc/s-box/domain.log 2>/dev/null | tr -d '\r\n ' || true)" echo "SINGBOX_SERVICE=$(systemctl is-active sing-box 2>/dev/null || echo inactive)" echo "NGINX_SERVICE=$(systemctl is-active nginx 2>/dev/null || echo inactive)" echo "ACME_VERSION=$(/root/.acme.sh/acme.sh --version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | tail -n1 | sed 's/^v//' || true)" echo "ACME_NEW_INSTALL_PIN=${ACME_SH_VERSION}" echo "HTTPS_SITES=${SB_PUBLIC_HTTPS_SITES:-$(detect_public_https_sites >/dev/null 2>&1; printf '%s' "${SB_PUBLIC_HTTPS_SITES:-}")}" ss -lntup 2>/dev/null | sed 's/^/SS /' } security_audit(){ local mode="${1:-full}" pass_count=0 skip_count=0 fail_count=0 current_version="" nginx_default="" audit_pass(){ green "PASS $1"; pass_count=$((pass_count + 1)); } audit_skip(){ yellow "SKIP $1"; skip_count=$((skip_count + 1)); } audit_fail(){ red "FAIL $1"; fail_count=$((fail_count + 1)); } case "$mode" in full|config-only) ;; *) red "未知安全審核模式: ${mode}"; return 1 ;; esac [[ -x /etc/s-box/sing-box && -f /etc/s-box/sb.json ]] || { audit_fail "sing-box binary and config present" return 1 } /etc/s-box/sing-box check -c /etc/s-box/sb.json >/dev/null 2>&1 \ && audit_pass "sing-box config check" || audit_fail "sing-box config check" if [[ "$mode" == "config-only" ]]; then audit_skip "core version policy is outside config-only hardening" else current_version=$(/etc/s-box/sing-box version 2>/dev/null | head -n1 | awk '{print $NF}') if [[ -n "$current_version" && "$(printf '%s\n%s\n' "$SB_SECURITY_MIN_CORE_VERSION" "$current_version" | sort -V | head -n1)" == "$SB_SECURITY_MIN_CORE_VERSION" ]]; then audit_pass "core >= ${SB_SECURITY_MIN_CORE_VERSION}" else audit_fail "core >= ${SB_SECURITY_MIN_CORE_VERSION} (current ${current_version:-unknown})" fi fi jq -e '.inbounds[] | select(.type=="vless") | (.tls.reality.max_time_difference == "1m")' /etc/s-box/sb.json >/dev/null 2>&1 \ && audit_pass "REALITY replay window is 1m" || audit_fail "REALITY replay window is 1m" jq -e '.inbounds[] | select(.type=="hysteria2") | (.masquerade.type == "string" and .masquerade.status_code == 404)' /etc/s-box/sb.json >/dev/null 2>&1 \ && audit_pass "HY2 unauthenticated response is local static content" || audit_fail "HY2 masquerade has no remote proxy" jq -e '.inbounds[] | select(.type=="tuic") | ((.zero_rtt_handshake // false) == false and .auth_timeout == "3s")' /etc/s-box/sb.json >/dev/null 2>&1 \ && audit_pass "TUIC requires auth before relay with 3s timeout and no 0-RTT" || audit_fail "TUIC auth timeout and 0-RTT policy" jq -e '.route.rules[]? | select(.ip_is_private == true and .action == "reject")' /etc/s-box/sb.json >/dev/null 2>&1 \ && audit_pass "private destinations rejected on server" || audit_fail "private destinations rejected on server" if [[ -f "$SB_NGINX_STREAM_CONF" ]]; then nginx_default=$(awk '/map \$ssl_preread_server_name/{inmap=1;next} inmap && $1=="default" {gsub(/;/,"",$2); print $2; exit}' "$SB_NGINX_STREAM_CONF" 2>/dev/null) fi [[ "$nginx_default" == "https_backend" ]] \ && audit_pass "unknown SNI remains local" || audit_fail "unknown SNI remains local" if grep -qE 'proxy_pass[[:space:]]+\$ssl_preread_server_name' "$SB_NGINX_STREAM_CONF" 2>/dev/null; then audit_fail "no dynamic SNI proxy destination" else audit_pass "no dynamic SNI proxy destination" fi nginx -t >/dev/null 2>&1 && audit_pass "nginx config check" || audit_fail "nginx config check" [[ "$(stat -c '%a' /etc/s-box/sb.json 2>/dev/null)" == "600" ]] \ && audit_pass "server config mode 0600" || audit_fail "server config mode 0600" systemctl is-active --quiet sing-box >/dev/null 2>&1 \ && audit_pass "sing-box active" || audit_fail "sing-box active" if [[ "$(systemctl show sing-box -p NoNewPrivileges --value 2>/dev/null)" == "yes" \ && "$(systemctl show sing-box -p ProtectSystem --value 2>/dev/null)" == "strict" \ && "$(systemctl show sing-box -p PrivateTmp --value 2>/dev/null)" == "yes" ]]; then audit_pass "systemd sandbox baseline" else audit_fail "systemd sandbox baseline" fi printf 'SECURITY_AUDIT pass=%d skip=%d fail=%d mode=%s\n' "$pass_count" "$skip_count" "$fail_count" "$mode" (( fail_count == 0 )) } harden_security(){ local snapshot_dir="" tmp_json="" tmp_stream="" vless_count=0 hy2_count=0 tuic_count=0 require_v3_layout || return 1 check_system_clock_preflight || return 1 command -v jq >/dev/null 2>&1 || { red "缺少 jq,無法安全修改配置。"; return 1; } [[ -f "$SB_NGINX_STREAM_CONF" ]] || { red "缺少受管 Nginx SNI 配置。"; return 1; } vless_count=$(jq '[.inbounds[]? | select(.type=="vless")] | length' /etc/s-box/sb.json 2>/dev/null) || return 1 hy2_count=$(jq '[.inbounds[]? | select(.type=="hysteria2")] | length' /etc/s-box/sb.json 2>/dev/null) || return 1 tuic_count=$(jq '[.inbounds[]? | select(.type=="tuic")] | length' /etc/s-box/sb.json 2>/dev/null) || return 1 if [[ "$vless_count" != "1" || "$hy2_count" != "1" || "$tuic_count" != "1" ]]; then red "安全加固要求恰好一個 VLESS、HY2 與 TUIC inbound;當前為 ${vless_count}/${hy2_count}/${tuic_count}。" return 1 fi snapshot_dir=$(create_rollout_snapshot "security-hardening") || { red "建立安全加固快照失敗。"; return 1; } begin_rollback_guard "$snapshot_dir" "sb2 安全加固" tmp_json=$(mktemp /etc/s-box/sb.json.security.XXXXXX) || return 1 tmp_stream=$(mktemp /etc/nginx/stream.d/sb_sni_dispatch.conf.security.XXXXXX) || { rm -f "$tmp_json"; return 1; } if ! jq ' (.inbounds[] | select(.type=="vless") | .tls.reality.max_time_difference) = "1m" | (.inbounds[] | select(.type=="hysteria2") | .masquerade) = { "type": "string", "status_code": 404, "headers": {"content-type": ["text/plain; charset=utf-8"], "cache-control": ["no-store"]}, "content": "404 page not found\\n" } | (.inbounds[] | select(.type=="tuic") | .zero_rtt_handshake) = false | (.inbounds[] | select(.type=="tuic") | .auth_timeout) = "3s" | .route.rules = ([{"ip_is_private": true, "action": "reject"}] + ((.route.rules // []) | map(select(.ip_is_private != true)))) ' /etc/s-box/sb.json > "$tmp_json"; then rm -f "$tmp_json" "$tmp_stream" return 1 fi sed -E 's/^([[:space:]]*default[[:space:]]+)[^;]+;/\1https_backend;/' "$SB_NGINX_STREAM_CONF" > "$tmp_stream" || { rm -f "$tmp_json" "$tmp_stream" return 1 } chmod 600 "$tmp_json" "$tmp_stream" 2>/dev/null || true /etc/s-box/sing-box check -c "$tmp_json" >/dev/null 2>&1 || { red "安全加固候選 sing-box 配置校驗失敗。" rm -f "$tmp_json" "$tmp_stream" return 1 } mv "$tmp_json" /etc/s-box/sb.json || return 1 mv "$tmp_stream" "$SB_NGINX_STREAM_CONF" || return 1 chmod 600 /etc/s-box/sb.json "$SB_NGINX_STREAM_CONF" 2>/dev/null || true if ! { persist_env_kv "SB_HY2_MASQUERADE_MODE" "local" && persist_env_kv "SB_NGINX_DEFAULT_BACKEND" "https" && persist_env_kv "SB_SERVER_PRIVATE_IP_POLICY" "reject" && persist_env_kv "SB_REALITY_MAX_TIME_DIFFERENCE" "1m" && persist_env_kv "SB_TUIC_AUTH_TIMEOUT" "3s" }; then red "安全加固狀態持久化失敗,正在回滾。" return_after_rollback "$snapshot_dir" return $? fi if ! nginx -t >/dev/null 2>&1; then red "安全加固後 Nginx 配置校驗失敗,正在回滾。" return_after_rollback "$snapshot_dir" return $? fi systemctl reload nginx >/dev/null 2>&1 || { return_after_rollback "$snapshot_dir" return $? } systemctl restart sing-box >/dev/null 2>&1 || { return_after_rollback "$snapshot_dir" return $? } if ! validate_v3_runtime_health || ! security_audit config-only; then red "安全加固運行態核查失敗,正在回滾。" return_after_rollback "$snapshot_dir" return $? fi end_rollback_guard green "安全加固完成;回滾快照: ${snapshot_dir}" } print_cli_usage(){ cat <<'EOF' Usage: sb install sb migrate-v2 sb normalize-v3-identity sb rollback-snapshot /root/sb2-rollout-