Files
SingboxForPanel/install.sh
2026-04-17 19:57:41 +08:00

979 lines
31 KiB
Bash

#!/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_ROUTE_FILE="$CONFIG_MERGE_DIR/route.json"
CONFIG_OUTBOUNDS_FILE="$CONFIG_MERGE_DIR/outbound.json"
LEGACY_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.3.0}"
declare -a V2BX_IMPORTED_NODE_IDS=()
declare -a EXISTING_IMPORTED_NODE_IDS=()
declare -a NODE_IDS=()
EXISTING_INSTALL=0
EXISTING_CONFIG_SOURCE=""
PROMPT_FOR_CONFIG=1
CONFIG_BACKUP_DIR=""
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/tty
else
echo -e "${RED}Interactive input requires a TTY. Please run this script in a terminal.${NC}"
exit 1
fi
else
exec 3<&0
fi
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) BINARY_ARCH="amd64" ;;
aarch64|arm64) BINARY_ARCH="arm64" ;;
armv7l|armv7) BINARY_ARCH="armv7" ;;
*)
echo -e "${RED}Unsupported architecture: $ARCH${NC}"
exit 1
;;
esac
DOWNLOAD_TARGET="${DOWNLOAD_TARGET:-linux-${BINARY_ARCH}}"
DOWNLOAD_URL="${DOWNLOAD_URL:-${RELEASE_BASE_URL}/sing-box-${DOWNLOAD_TARGET}}"
TMP_BINARY="$(mktemp)"
sanitize_value() {
printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
normalize_node_id_input() {
local value
value="$(sanitize_value "$1")"
value="${value#[}"
value="${value%]}"
value="${value//,/ }"
value="${value//;/ }"
value="${value//\"/}"
value="${value//\'/}"
value="$(printf '%s' "$value" | tr -s '[:space:]' ' ')"
sanitize_value "$value"
}
append_unique_node_id() {
local normalized_value
local node_id_part
local existing_node_id
normalized_value="$(normalize_node_id_input "$1")"
if [[ -z "$normalized_value" ]]; then
return 0
fi
read -r -a NORMALIZED_NODE_ID_PARTS <<< "$normalized_value"
for node_id_part in "${NORMALIZED_NODE_ID_PARTS[@]}"; do
if ! [[ "$node_id_part" =~ ^[0-9]+$ ]]; then
continue
fi
for existing_node_id in "${V2BX_IMPORTED_NODE_IDS[@]}"; do
if [[ "$existing_node_id" == "$node_id_part" ]]; then
node_id_part=""
break
fi
done
if [[ -n "$node_id_part" ]]; then
V2BX_IMPORTED_NODE_IDS+=("$node_id_part")
fi
done
}
find_v2bx_config() {
local candidate
for candidate in \
"/etc/V2bX/config.json" \
"/usr/local/V2bX/config.json" \
"/etc/v2bx/config.json" \
"/usr/local/etc/V2bX/config.json"
do
if [[ -f "$candidate" ]]; then
V2BX_CONFIG_PATH="$candidate"
return 0
fi
done
return 1
}
detect_v2bx() {
if command -v v2bx >/dev/null 2>&1; then
V2BX_DETECTED=1
fi
if find_v2bx_config; then
V2BX_DETECTED=1
fi
return 0
}
detect_existing_installation() {
if [[ -x "$BINARY_PATH" || -f "$SERVICE_FILE" || -f "$CONFIG_BASE_FILE" || -f "$CONFIG_ROUTE_FILE" || -f "$CONFIG_OUTBOUNDS_FILE" || -f "$LEGACY_CONFIG_OUTBOUNDS_FILE" || -f "$CONFIG_DIR/config.json" ]]; then
EXISTING_INSTALL=1
fi
if [[ -f "$CONFIG_BASE_FILE" ]]; then
EXISTING_CONFIG_SOURCE="$CONFIG_BASE_FILE"
elif [[ -f "$CONFIG_DIR/config.json" ]]; then
EXISTING_CONFIG_SOURCE="$CONFIG_DIR/config.json"
fi
if [[ "$EXISTING_INSTALL" -eq 1 ]]; then
echo -e "${YELLOW}Detected existing sing-box installation. Switching to update flow...${NC}"
if [[ -n "$EXISTING_CONFIG_SOURCE" ]]; then
echo -e "${YELLOW}Existing config source: ${EXISTING_CONFIG_SOURCE}${NC}"
fi
fi
}
load_existing_install_defaults() {
if [[ -z "$EXISTING_CONFIG_SOURCE" || ! -f "$EXISTING_CONFIG_SOURCE" ]]; then
return 1
fi
echo -e "${YELLOW}Loading defaults from existing sing-box config...${NC}"
EXISTING_IMPORTED_NODE_IDS=()
local parsed=""
if command -v python3 >/dev/null 2>&1; then
parsed="$(python3 - "$EXISTING_CONFIG_SOURCE" <<'PY'
import json
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
xboard = {}
for service in data.get("services") or []:
if isinstance(service, dict) and service.get("type") == "xboard":
xboard = service
break
print(f"PANEL_URL={xboard.get('panel_url', '')}")
print(f"PANEL_TOKEN={xboard.get('key', '')}")
node_id = xboard.get("node_id")
if node_id not in (None, ""):
print(f"NODE_ID={node_id}")
for node in xboard.get("nodes") or []:
if isinstance(node, dict):
current_node_id = node.get("node_id")
if current_node_id not in (None, ""):
print(f"NODE_ID={current_node_id}")
servers = ((data.get("dns") or {}).get("servers") or [])
dns_server = servers[0] if servers else {}
if isinstance(dns_server, dict):
print(f"DNS_MODE={dns_server.get('type', '')}")
print(f"DNS_SERVER={dns_server.get('server', '')}")
print(f"DNS_SERVER_PORT={dns_server.get('server_port', '')}")
PY
)"
elif command -v jq >/dev/null 2>&1; then
parsed="$(jq -r '
([.services[]? | select(.type == "xboard")] | .[0] // {}) as $xboard |
"PANEL_URL=" + ($xboard.panel_url // ""),
"PANEL_TOKEN=" + ($xboard.key // ""),
(if ($xboard.node_id // empty) != empty then "NODE_ID=" + ($xboard.node_id | tostring) else empty end),
(($xboard.nodes // [])[]? | "NODE_ID=" + (.node_id | tostring)),
(.dns.servers[0] // {}) as $dns |
"DNS_MODE=" + ($dns.type // ""),
"DNS_SERVER=" + ($dns.server // ""),
"DNS_SERVER_PORT=" + (($dns.server_port // "") | tostring)
' "$EXISTING_CONFIG_SOURCE" 2>/dev/null || true)"
else
echo -e "${YELLOW}Neither python3 nor jq found, unable to auto-load existing config.${NC}"
return 1
fi
if [[ -z "$parsed" ]]; then
return 1
fi
local parsed_line
while IFS= read -r parsed_line; do
parsed_line="$(sanitize_value "$parsed_line")"
case "$parsed_line" in
PANEL_URL=*)
PANEL_URL="$(sanitize_value "${parsed_line#PANEL_URL=}")"
;;
PANEL_TOKEN=*)
PANEL_TOKEN="$(sanitize_value "${parsed_line#PANEL_TOKEN=}")"
;;
NODE_ID=*)
append_unique_node_id_from_existing "${parsed_line#NODE_ID=}"
;;
DNS_MODE=*)
DNS_MODE="$(echo "${parsed_line#DNS_MODE=}" | tr '[:upper:]' '[:lower:]')"
;;
DNS_SERVER=*)
DNS_SERVER="$(sanitize_value "${parsed_line#DNS_SERVER=}")"
;;
DNS_SERVER_PORT=*)
DNS_SERVER_PORT="$(sanitize_value "${parsed_line#DNS_SERVER_PORT=}")"
;;
esac
done <<< "$parsed"
if [[ "${#EXISTING_IMPORTED_NODE_IDS[@]}" -gt 0 ]]; then
NODE_IDS=("${EXISTING_IMPORTED_NODE_IDS[@]}")
NODE_ID="${EXISTING_IMPORTED_NODE_IDS[0]}"
fi
if [[ -n "${DNS_MODE:-}" ]]; then
DNS_MODE="$(sanitize_value "$DNS_MODE")"
fi
if [[ -n "${PANEL_URL:-}" && -n "${PANEL_TOKEN:-}" && "${#NODE_IDS[@]}" -gt 0 && ( "${DNS_MODE:-}" == "local" || ( "${DNS_MODE:-}" == "udp" && -n "${DNS_SERVER:-}" && -n "${DNS_SERVER_PORT:-}" ) ) ]]; then
echo -e "${YELLOW}Loaded existing config: PanelURL=${PANEL_URL}, NodeIDs=$(IFS=,; echo "${NODE_IDS[*]}"), DNS=${DNS_MODE}${NC}"
return 0
fi
echo -e "${YELLOW}Existing config detected, but some fields are incomplete. The installer will ask for the missing values.${NC}"
return 1
}
append_unique_node_id_from_existing() {
local normalized_value
local node_id_part
local existing_node_id
normalized_value="$(normalize_node_id_input "$1")"
if [[ -z "$normalized_value" ]]; then
return 0
fi
read -r -a NORMALIZED_NODE_ID_PARTS <<< "$normalized_value"
for node_id_part in "${NORMALIZED_NODE_ID_PARTS[@]}"; do
if ! [[ "$node_id_part" =~ ^[0-9]+$ ]]; then
continue
fi
for existing_node_id in "${EXISTING_IMPORTED_NODE_IDS[@]}"; do
if [[ "$existing_node_id" == "$node_id_part" ]]; then
node_id_part=""
break
fi
done
if [[ -n "$node_id_part" ]]; then
EXISTING_IMPORTED_NODE_IDS+=("$node_id_part")
fi
done
}
ensure_config_backup_dir() {
if [[ -n "$CONFIG_BACKUP_DIR" ]]; then
return 0
fi
CONFIG_BACKUP_DIR="$CONFIG_DIR/backup/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$CONFIG_BACKUP_DIR"
echo -e "${YELLOW}Backing up existing configuration to ${CONFIG_BACKUP_DIR}${NC}"
}
backup_path_if_exists() {
local path="$1"
if [[ ! -e "$path" ]]; then
return 0
fi
ensure_config_backup_dir
cp -a "$path" "$CONFIG_BACKUP_DIR/"
}
archive_legacy_config_json() {
local legacy_config_path="$CONFIG_DIR/config.json"
if [[ ! -f "$legacy_config_path" ]]; then
return 0
fi
ensure_config_backup_dir
mv "$legacy_config_path" "$CONFIG_BACKUP_DIR/config.json.migrated"
echo -e "${YELLOW}Legacy single-file config has been archived to ${CONFIG_BACKUP_DIR}/config.json.migrated${NC}"
}
extract_legacy_config_sections() {
local source_path="$1"
local route_written=0
local outbound_written=0
if [[ ! -f "$source_path" ]]; then
return 1
fi
if command -v python3 >/dev/null 2>&1; then
local parsed
parsed="$(python3 - "$source_path" "$CONFIG_ROUTE_FILE" "$CONFIG_OUTBOUNDS_FILE" <<'PY'
import json
import sys
source_path, route_path, outbounds_path = sys.argv[1:4]
with open(source_path, "r", encoding="utf-8") as f:
data = json.load(f)
route_written = 0
if isinstance(data.get("route"), dict):
with open(route_path, "w", encoding="utf-8") as f:
json.dump({"route": data["route"]}, f, ensure_ascii=False, indent=2)
f.write("\n")
route_written = 1
outbound_written = 0
if isinstance(data.get("outbounds"), list):
with open(outbounds_path, "w", encoding="utf-8") as f:
json.dump({"outbounds": data["outbounds"]}, f, ensure_ascii=False, indent=2)
f.write("\n")
outbound_written = 1
print(f"ROUTE_WRITTEN={route_written}")
print(f"OUTBOUNDS_WRITTEN={outbound_written}")
PY
)"
while IFS= read -r parsed_line; do
case "$parsed_line" in
ROUTE_WRITTEN=1) route_written=1 ;;
OUTBOUNDS_WRITTEN=1) outbound_written=1 ;;
esac
done <<< "$parsed"
elif command -v jq >/dev/null 2>&1; then
if jq -e '.route | type == "object"' "$source_path" >/dev/null 2>&1; then
jq '{route: .route}' "$source_path" > "$CONFIG_ROUTE_FILE"
route_written=1
fi
if jq -e '.outbounds | type == "array"' "$source_path" >/dev/null 2>&1; then
jq '{outbounds: .outbounds}' "$source_path" > "$CONFIG_OUTBOUNDS_FILE"
outbound_written=1
fi
fi
if [[ "$route_written" -eq 1 || "$outbound_written" -eq 1 ]]; then
echo -e "${YELLOW}Extracted legacy config sections from ${source_path}${NC}"
return 0
fi
return 1
}
write_default_route_config() {
cat > "$CONFIG_ROUTE_FILE" <<EOF
{
"route": {
"rules": [
{
"protocol": "dns",
"action": "hijack-dns"
},
{
"network": [
"udp",
"tcp"
],
"action": "route",
"outbound": "direct"
}
],
"auto_detect_interface": true
}
}
EOF
}
write_default_outbound_config() {
cat > "$CONFIG_OUTBOUNDS_FILE" <<EOF
{
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
]
}
EOF
}
compact_default_outbound_config() {
cat <<'EOF' | tr -d '[:space:]'
{
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
]
}
EOF
}
compact_file_contents() {
local path="$1"
tr -d '[:space:]' < "$path"
}
is_default_outbound_config() {
local path="$1"
if [[ ! -f "$path" ]]; then
return 1
fi
[[ "$(compact_file_contents "$path")" == "$(compact_default_outbound_config)" ]]
}
normalize_outbound_config_layout() {
if [[ -f "$LEGACY_CONFIG_OUTBOUNDS_FILE" && ! -f "$CONFIG_OUTBOUNDS_FILE" ]]; then
mv "$LEGACY_CONFIG_OUTBOUNDS_FILE" "$CONFIG_OUTBOUNDS_FILE"
echo -e "${YELLOW}Migrated legacy outbound config to ${CONFIG_OUTBOUNDS_FILE}${NC}"
return 0
fi
if [[ ! -f "$LEGACY_CONFIG_OUTBOUNDS_FILE" || ! -f "$CONFIG_OUTBOUNDS_FILE" ]]; then
return 0
fi
if [[ "$(compact_file_contents "$LEGACY_CONFIG_OUTBOUNDS_FILE")" == "$(compact_file_contents "$CONFIG_OUTBOUNDS_FILE")" ]]; then
backup_path_if_exists "$LEGACY_CONFIG_OUTBOUNDS_FILE"
rm -f "$LEGACY_CONFIG_OUTBOUNDS_FILE"
echo -e "${YELLOW}Removed duplicate legacy outbound config: ${LEGACY_CONFIG_OUTBOUNDS_FILE}${NC}"
return 0
fi
if is_default_outbound_config "$CONFIG_OUTBOUNDS_FILE"; then
backup_path_if_exists "$CONFIG_OUTBOUNDS_FILE"
rm -f "$CONFIG_OUTBOUNDS_FILE"
mv "$LEGACY_CONFIG_OUTBOUNDS_FILE" "$CONFIG_OUTBOUNDS_FILE"
echo -e "${YELLOW}Replaced installer default outbound config with legacy custom config from ${LEGACY_CONFIG_OUTBOUNDS_FILE}${NC}"
return 0
fi
if is_default_outbound_config "$LEGACY_CONFIG_OUTBOUNDS_FILE"; then
backup_path_if_exists "$LEGACY_CONFIG_OUTBOUNDS_FILE"
rm -f "$LEGACY_CONFIG_OUTBOUNDS_FILE"
echo -e "${YELLOW}Removed legacy default outbound config to avoid duplicate outbound tags.${NC}"
return 0
fi
echo -e "${RED}Both ${CONFIG_OUTBOUNDS_FILE} and ${LEGACY_CONFIG_OUTBOUNDS_FILE} exist and contain different outbound definitions.${NC}"
echo -e "${RED}Please merge them into a single config file before rerunning the installer to avoid duplicate outbound tags.${NC}"
exit 1
}
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:-<empty>}, NodeIDs=$(IFS=,; echo "${V2BX_IMPORTED_NODE_IDS[*]}")${NC}"
else
echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-<empty>}, NodeIDs=<empty>${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
detect_existing_installation
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:-}")"
DNS_MODE="$(sanitize_value "${DNS_MODE:-}")"
DNS_SERVER="$(sanitize_value "${DNS_SERVER:-}")"
DNS_SERVER_PORT="$(sanitize_value "${DNS_SERVER_PORT:-}")"
ENABLE_PROXY_PROTOCOL_HINT="$(sanitize_value "${ENABLE_PROXY_PROTOCOL_HINT:-n}")"
if [[ "$EXISTING_INSTALL" -eq 1 ]]; then
if load_existing_install_defaults; then
PROMPT_FOR_CONFIG=0
fi
fi
download_binary
cleanup_legacy_service
if [[ "$PROMPT_FOR_CONFIG" -eq 1 ]]; then
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}}")"
NODE_IDS=()
i=1
while true; do
DEFAULT_NODE_ID=""
if [[ "$i" -le "${#EXISTING_IMPORTED_NODE_IDS[@]}" ]]; then
DEFAULT_NODE_ID="${EXISTING_IMPORTED_NODE_IDS[$((i-1))]}"
elif [[ "$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
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="$(sanitize_value "${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="$(sanitize_value "${INPUT_DNS_SERVER_PORT:-$DNS_SERVER_PORT_DEFAULT}")"
;;
local)
DNS_SERVER=""
DNS_SERVER_PORT=""
;;
*)
echo -e "${RED}Unsupported DNS mode: $DNS_MODE. Supported values: udp, local${NC}"
exit 1
;;
esac
else
echo -e "${YELLOW}Reusing existing install settings for update.${NC}"
fi
NODE_COUNT=${#NODE_IDS[@]}
case "${DNS_MODE:-udp}" in
udp)
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 <<EOF
{
"tag": "dns-upstream",
"type": "udp",
"server": "$DNS_SERVER",
"server_port": $DNS_SERVER_PORT
}
EOF
)
;;
local)
DNS_SERVER_JSON=$(cat <<EOF
{
"tag": "dns-local",
"type": "local"
}
EOF
)
;;
*)
echo -e "${RED}Unsupported DNS mode: $DNS_MODE. Supported values: udp, local${NC}"
exit 1
;;
esac
echo -e "${YELLOW}Syncing system time...${NC}"
timedatectl set-ntp true || true
if [[ -z "$PANEL_URL" || -z "$PANEL_TOKEN" ]]; then
echo -e "${RED}All fields are required!${NC}"
exit 1
fi
PANEL_URL="${PANEL_URL%/}"
SERVICE_JSON=$(cat <<EOF
{
"type": "xboard",
"panel_url": "$PANEL_URL",
"key": "$PANEL_TOKEN",
"sync_interval": "1m",
"report_interval": "1m"
EOF
)
if [[ "$NODE_COUNT" -eq 1 ]]; then
SERVICE_JSON+=$(cat <<EOF
,
"node_id": ${NODE_IDS[0]}
EOF
)
else
SERVICE_JSON+=$',
"nodes": ['
for ((i=0; i<NODE_COUNT; i++)); do
NODE_BLOCK=$(cat <<EOF
{
"node_id": ${NODE_IDS[$i]}
EOF
)
NODE_BLOCK+=$'\n }'
if [[ "$i" -gt 0 ]]; then
SERVICE_JSON+=','
fi
SERVICE_JSON+=$'\n'"$NODE_BLOCK"
done
SERVICE_JSON+=$'\n ]'
fi
SERVICE_JSON+=$'\n }'
echo -e "${YELLOW}Generating configuration...${NC}"
backup_path_if_exists "$CONFIG_BASE_FILE"
backup_path_if_exists "$CONFIG_ROUTE_FILE"
backup_path_if_exists "$CONFIG_OUTBOUNDS_FILE"
backup_path_if_exists "$LEGACY_CONFIG_OUTBOUNDS_FILE"
backup_path_if_exists "$CONFIG_DIR/config.json"
backup_path_if_exists "$SERVICE_FILE"
cat > "$CONFIG_BASE_FILE" <<EOF
{
"log": {
"level": "info",
"timestamp": true
},
"experimental": {
"cache_file": {
"enabled": true,
"path": "$WORK_DIR/cache.db"
}
},
"dns": {
"servers": [
${DNS_SERVER_JSON}
]
},
"services": [
${SERVICE_JSON}
],
"inbounds": []
}
EOF
normalize_outbound_config_layout
if [[ -f "$CONFIG_DIR/config.json" ]]; then
rm -f "$CONFIG_ROUTE_FILE" "$CONFIG_OUTBOUNDS_FILE"
if ! extract_legacy_config_sections "$CONFIG_DIR/config.json"; then
echo -e "${YELLOW}Legacy config detected but route/outbounds could not be extracted automatically. Writing default route/outbound files.${NC}"
fi
if [[ ! -f "$CONFIG_ROUTE_FILE" ]]; then
write_default_route_config
fi
if [[ ! -f "$CONFIG_OUTBOUNDS_FILE" ]]; then
write_default_outbound_config
fi
archive_legacy_config_json
else
if [[ -f "$CONFIG_ROUTE_FILE" ]]; then
echo -e "${YELLOW}Preserving existing route config: ${CONFIG_ROUTE_FILE}${NC}"
else
write_default_route_config
fi
if [[ -f "$CONFIG_OUTBOUNDS_FILE" ]]; then
echo -e "${YELLOW}Preserving existing outbound config: ${CONFIG_OUTBOUNDS_FILE}${NC}"
else
write_default_outbound_config
fi
fi
echo -e "${GREEN}Base configuration written to $CONFIG_BASE_FILE${NC}"
echo -e "${GREEN}Route configuration ready at $CONFIG_ROUTE_FILE${NC}"
echo -e "${GREEN}Outbound configuration ready at $CONFIG_OUTBOUNDS_FILE${NC}"
echo -e "${YELLOW}Edit $CONFIG_OUTBOUNDS_FILE when adding custom sing-box outbounds.${NC}"
if [[ "$ENABLE_PROXY_PROTOCOL_HINT" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then
echo -e "${YELLOW}Proxy Protocol deployment hint enabled.${NC}"
echo -e "${YELLOW}To make real client IP reporting work, your panel node config response must include:${NC}"
echo -e "${YELLOW} \"accept_proxy_protocol\": true${NC}"
echo -e "${YELLOW}Only enable this when the upstream L4 proxy or load balancer actually sends PROXY protocol headers.${NC}"
echo -e "${YELLOW}If clients connect directly without a PROXY header, connections will fail after enabling it on the panel.${NC}"
else
echo -e "${YELLOW}Proxy Protocol is not expected for this deployment.${NC}"
echo -e "${YELLOW}Keep panel field \"accept_proxy_protocol\" disabled or absent unless you are using an L4 proxy/LB that sends it.${NC}"
fi
echo -e "${YELLOW}Creating systemd service...${NC}"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=singbox service
After=network.target nss-lookup.target
[Service]
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
ExecStart=$BINARY_PATH -D $WORK_DIR -C $CONFIG_MERGE_DIR run
Restart=on-failure
RestartSec=10
LimitNOFILE=infinity
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
prompt_uninstall_v2bx
if [[ "$EXISTING_INSTALL" -eq 1 ]]; then
echo -e "${GREEN}Service updated and restarted successfully.${NC}"
echo -e "${GREEN}Configuration has been normalized to the split config.d layout.${NC}"
else
echo -e "${GREEN}Service installed and started successfully.${NC}"
fi
echo -e "${GREEN}Check status with: systemctl status ${SERVICE_NAME}${NC}"
echo -e "${GREEN}View logs with: journalctl -u ${SERVICE_NAME} -f${NC}"
echo -e "${GREEN}Panel config endpoint must control PROXY protocol via accept_proxy_protocol when needed.${NC}"