#!/bin/bash set -e RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' CONFIG_DIR="/etc/sing-box" CONFIG_MERGE_DIR="$CONFIG_DIR/config.d" CONFIG_BASE_FILE="$CONFIG_MERGE_DIR/10-base.json" CONFIG_OUTBOUNDS_FILE="$CONFIG_MERGE_DIR/20-outbounds.json" WORK_DIR="/var/lib/sing-box" BINARY_PATH="/usr/local/bin/sing-box" SERVICE_NAME="singbox" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" LEGACY_SERVICE_NAMES=("ganclient" "sing-box") RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://s3.cloudyun.top/downloads/singbox}" PUBLISHED_SCRIPT_URL="${PUBLISHED_SCRIPT_URL:-https://s3.cloudyun.top/downloads/singbox/install.sh}" V2BX_DETECTED=0 V2BX_CONFIG_PATH="" UNINSTALL_V2BX_DEFAULT="${UNINSTALL_V2BX_DEFAULT:-n}" SCRIPT_VERSION="${SCRIPT_VERSION:-v1.2.4}" declare -a V2BX_IMPORTED_NODE_IDS=() echo -e "${GREEN}Welcome to singbox Release Installation Script${NC}" echo -e "${GREEN}Script version: ${SCRIPT_VERSION}${NC}" echo -e "${YELLOW}Published install script: ${PUBLISHED_SCRIPT_URL}${NC}" if [[ $EUID -ne 0 ]]; then echo -e "${RED}This script must be run as root${NC}" exit 1 fi OS="$(uname -s)" if [[ "$OS" != "Linux" ]]; then echo -e "${RED}This install script currently supports Linux only. Current OS: ${OS}${NC}" exit 1 fi if [[ ! -t 0 ]]; then if [[ -r /dev/tty ]]; then exec 3/dev/null 2>&1; then V2BX_DETECTED=1 fi if find_v2bx_config; then V2BX_DETECTED=1 fi return 0 } load_v2bx_defaults() { if [[ -z "$V2BX_CONFIG_PATH" ]] && ! find_v2bx_config; then return 1 fi echo -e "${YELLOW}Detected V2bX configuration: ${V2BX_CONFIG_PATH}${NC}" V2BX_IMPORTED_NODE_IDS=() if command -v python3 >/dev/null 2>&1; then local parsed parsed="$(python3 - "$V2BX_CONFIG_PATH" <<'PY' import json import sys path = sys.argv[1] with open(path, "r", encoding="utf-8") as f: data = json.load(f) nodes = data.get("Nodes") or [] node = nodes[0] if nodes else {} api_host = node.get("ApiHost", "") if node else "" api_key = node.get("ApiKey", "") if node else "" print(f"API_HOST={api_host or ''}") print(f"API_KEY={api_key or ''}") for entry in nodes: node_id = entry.get("NodeID", "") if node_id is None: node_id = "" print(f"NODE_ID={node_id}") PY )" if [[ -n "$parsed" ]]; then local parsed_line while IFS= read -r parsed_line; do parsed_line="$(sanitize_value "$parsed_line")" case "$parsed_line" in API_HOST=*) if [[ -z "${PANEL_URL:-}" ]]; then PANEL_URL="$(sanitize_value "${parsed_line#API_HOST=}")" fi ;; API_KEY=*) if [[ -z "${PANEL_TOKEN:-}" ]]; then PANEL_TOKEN="$(sanitize_value "${parsed_line#API_KEY=}")" fi ;; NODE_ID=*) append_unique_node_id "${parsed_line#NODE_ID=}" ;; esac done <<< "$parsed" fi elif command -v jq >/dev/null 2>&1; then local parsed parsed="$(jq -r '(.Nodes[0].ApiHost // "" | "API_HOST=" + .), (.Nodes[0].ApiKey // "" | "API_KEY=" + .), (.Nodes[]?.NodeID // "" | tostring | "NODE_ID=" + .)' "$V2BX_CONFIG_PATH" 2>/dev/null || true)" if [[ -n "$parsed" ]]; then local parsed_line while IFS= read -r parsed_line; do parsed_line="$(sanitize_value "$parsed_line")" case "$parsed_line" in API_HOST=*) if [[ -z "${PANEL_URL:-}" ]]; then PANEL_URL="$(sanitize_value "${parsed_line#API_HOST=}")" fi ;; API_KEY=*) if [[ -z "${PANEL_TOKEN:-}" ]]; then PANEL_TOKEN="$(sanitize_value "${parsed_line#API_KEY=}")" fi ;; NODE_ID=*) append_unique_node_id "${parsed_line#NODE_ID=}" ;; esac done <<< "$parsed" fi else echo -e "${YELLOW}Neither python3 nor jq found, skipping automatic V2bX config import.${NC}" fi if [[ -z "${NODE_ID:-}" && "${#V2BX_IMPORTED_NODE_IDS[@]}" -gt 0 ]]; then NODE_ID="${V2BX_IMPORTED_NODE_IDS[0]}" fi if [[ "${#V2BX_IMPORTED_NODE_IDS[@]}" -gt 0 ]]; then echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-}, NodeIDs=$(IFS=,; echo "${V2BX_IMPORTED_NODE_IDS[*]}")${NC}" else echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-}, NodeIDs=${NC}" fi return 0 } stop_v2bx_if_present() { if [[ "$V2BX_DETECTED" -ne 1 ]]; then return 0 fi if ! command -v v2bx >/dev/null 2>&1; then echo -e "${YELLOW}V2bX config detected but 'v2bx' command not found, skipping stop/disable.${NC}" return 0 fi echo -e "${YELLOW}Detected V2bX, stopping and disabling it before continuing...${NC}" v2bx stop || true v2bx disable || true } prompt_uninstall_v2bx() { if [[ "$V2BX_DETECTED" -ne 1 ]]; then return 0 fi if ! command -v v2bx >/dev/null 2>&1; then return 0 fi read -u 3 -p "Detected V2bX. Uninstall it now? [${UNINSTALL_V2BX_DEFAULT}]: " INPUT_UNINSTALL_V2BX local uninstall_v2bx_answer uninstall_v2bx_answer=${INPUT_UNINSTALL_V2BX:-$UNINSTALL_V2BX_DEFAULT} if [[ "$uninstall_v2bx_answer" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then echo -e "${YELLOW}Running: v2bx uninstall${NC}" v2bx uninstall else echo -e "${YELLOW}Keeping existing V2bX installation.${NC}" fi } download_binary() { echo -e "${YELLOW}Downloading sing-box release binary...${NC}" echo -e "${YELLOW}Target: ${DOWNLOAD_TARGET}${NC}" echo -e "${YELLOW}URL: ${DOWNLOAD_URL}${NC}" if command -v curl >/dev/null 2>&1; then if ! curl -fL "${DOWNLOAD_URL}" -o "${TMP_BINARY}"; then echo -e "${RED}Failed to download release binary with curl.${NC}" rm -f "${TMP_BINARY}" exit 1 fi elif command -v wget >/dev/null 2>&1; then if ! wget -O "${TMP_BINARY}" "${DOWNLOAD_URL}"; then echo -e "${RED}Failed to download release binary with wget.${NC}" rm -f "${TMP_BINARY}" exit 1 fi else echo -e "${RED}Neither curl nor wget is installed.${NC}" rm -f "${TMP_BINARY}" exit 1 fi install -m 0755 "${TMP_BINARY}" "${BINARY_PATH}" rm -f "${TMP_BINARY}" if [[ ! -x "${BINARY_PATH}" ]]; then echo -e "${RED}Binary install failed: ${BINARY_PATH} not executable.${NC}" exit 1 fi echo -e "${GREEN}sing-box downloaded and installed to ${BINARY_PATH}${NC}" } cleanup_legacy_service() { echo -e "${YELLOW}Cleaning up legacy services if present...${NC}" for legacy_service_name in "${LEGACY_SERVICE_NAMES[@]}"; do if [[ "$legacy_service_name" == "$SERVICE_NAME" ]]; then continue fi legacy_service_file="/etc/systemd/system/${legacy_service_name}.service" if systemctl list-unit-files | grep -q "^${legacy_service_name}\.service"; then systemctl stop "${legacy_service_name}" 2>/dev/null || true systemctl disable "${legacy_service_name}" 2>/dev/null || true fi if [[ -f "$legacy_service_file" ]]; then rm -f "$legacy_service_file" fi if [[ -L "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" ]]; then rm -f "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" fi done } detect_v2bx stop_v2bx_if_present mkdir -p "$CONFIG_DIR" mkdir -p "$CONFIG_MERGE_DIR" mkdir -p "$WORK_DIR" if [[ -f ".env" ]]; then echo -e "${YELLOW}Loading configuration from .env...${NC}" source .env fi load_v2bx_defaults || true PANEL_URL="$(sanitize_value "${PANEL_URL:-}")" PANEL_TOKEN="$(sanitize_value "${PANEL_TOKEN:-}")" NODE_ID="$(sanitize_value "${NODE_ID:-}")" ENABLE_PROXY_PROTOCOL_HINT="$(sanitize_value "${ENABLE_PROXY_PROTOCOL_HINT:-n}")" download_binary cleanup_legacy_service read -u 3 -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL PANEL_URL="$(sanitize_value "${INPUT_URL:-$PANEL_URL}")" read -u 3 -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN PANEL_TOKEN="$(sanitize_value "${INPUT_TOKEN:-$PANEL_TOKEN}")" read -u 3 -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL ENABLE_PROXY_PROTOCOL_HINT="$(sanitize_value "${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}}")" declare -a NODE_IDS i=1 while true; do DEFAULT_NODE_ID="" if [[ "$i" -le "${#V2BX_IMPORTED_NODE_IDS[@]}" ]]; then DEFAULT_NODE_ID="${V2BX_IMPORTED_NODE_IDS[$((i-1))]}" elif [[ "$i" -eq 1 && -n "${NODE_ID:-}" ]]; then DEFAULT_NODE_ID="$NODE_ID" fi if [[ -n "$DEFAULT_NODE_ID" ]]; then read -u 3 -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type N/NO to finish): " INPUT_ID else read -u 3 -p "Enter Node ID for node #$i (press Enter or type N/NO to finish): " INPUT_ID fi CURRENT_NODE_ID="$(sanitize_value "${INPUT_ID:-$DEFAULT_NODE_ID}")" if [[ -z "$DEFAULT_NODE_ID" && -z "$CURRENT_NODE_ID" && "${#NODE_IDS[@]}" -gt 0 ]]; then break fi if [[ "$CURRENT_NODE_ID" =~ ^([nN]|[nN][oO])$ ]]; then if [[ "${#NODE_IDS[@]}" -eq 0 ]]; then echo -e "${RED}At least one Node ID is required${NC}" exit 1 fi break fi CURRENT_NODE_ID="$(normalize_node_id_input "$CURRENT_NODE_ID")" if [[ -z "$CURRENT_NODE_ID" ]]; then echo -e "${RED}Node ID is required for node #$i${NC}" exit 1 fi read -r -a CURRENT_NODE_ID_PARTS <<< "$CURRENT_NODE_ID" if [[ "${#CURRENT_NODE_ID_PARTS[@]}" -eq 0 ]]; then echo -e "${RED}Node ID is required for node #$i${NC}" exit 1 fi for CURRENT_NODE_ID_PART in "${CURRENT_NODE_ID_PARTS[@]}"; do if ! [[ "$CURRENT_NODE_ID_PART" =~ ^[0-9]+$ ]]; then echo -e "${RED}Node ID must be a positive integer, got: ${CURRENT_NODE_ID_PART}${NC}" exit 1 fi NODE_IDS+=("$CURRENT_NODE_ID_PART") done ((i++)) done NODE_COUNT=${#NODE_IDS[@]} DNS_MODE_DEFAULT=${DNS_MODE:-udp} read -u 3 -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE DNS_MODE=$(echo "${INPUT_DNS_MODE:-$DNS_MODE_DEFAULT}" | tr '[:upper:]' '[:lower:]') case "$DNS_MODE" in udp) DNS_SERVER_DEFAULT=${DNS_SERVER:-1.1.1.1} DNS_SERVER_PORT_DEFAULT=${DNS_SERVER_PORT:-53} read -u 3 -p "Enter DNS server [${DNS_SERVER_DEFAULT}]: " INPUT_DNS_SERVER DNS_SERVER=${INPUT_DNS_SERVER:-$DNS_SERVER_DEFAULT} read -u 3 -p "Enter DNS server port [${DNS_SERVER_PORT_DEFAULT}]: " INPUT_DNS_SERVER_PORT DNS_SERVER_PORT=${INPUT_DNS_SERVER_PORT:-$DNS_SERVER_PORT_DEFAULT} if [[ -z "$DNS_SERVER" ]]; then echo -e "${RED}DNS server is required in udp mode${NC}" exit 1 fi if ! [[ "$DNS_SERVER_PORT" =~ ^[0-9]+$ ]] || [[ "$DNS_SERVER_PORT" -lt 1 ]] || [[ "$DNS_SERVER_PORT" -gt 65535 ]]; then echo -e "${RED}DNS server port must be an integer between 1 and 65535${NC}" exit 1 fi DNS_SERVER_JSON=$(cat < "$CONFIG_BASE_FILE" < "$CONFIG_OUTBOUNDS_FILE" < "$SERVICE_FILE" <