#!/bin/bash set -e # xboard-node Multi-Node Deploy Script # Supports: Ubuntu 20+, Debian 11+, CentOS 8+, Alpine 3.18+ # # One-command deploy (non-interactive): # bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 1 # bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 2 -k xray # bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 3 --docker # # Interactive: # bash install.sh # # Management: # bash install.sh list # bash install.sh remove # bash install.sh update # bash install.sh uninstall RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' INSTALL_DIR="/usr/local/bin" CONFIG_DIR="/etc/xboard-node" SERVICE_TEMPLATE="xboard-node@.service" DOCKER_COMPOSE_FILE="${CONFIG_DIR}/docker-compose.yml" DOCKER_IMAGE="ghcr.io/cedar2025/xboard-node:latest" # Parsed parameters (populated by parse_args) PANEL_URL="" PANEL_TOKEN="" NODE_ID="" NODE_TYPE="" KERNEL_TYPE="singbox" DOCKER_MODE=0 SUBCOMMAND="" log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_step() { echo -e "${CYAN}[STEP]${NC} ${BOLD}$1${NC}"; } # ─── Argument Parsing ───────────────────────────────────────────────── parse_args() { while [ $# -gt 0 ]; do case "$1" in -a|--api) PANEL_URL="$2"; shift 2 ;; -t|--token) PANEL_TOKEN="$2"; shift 2 ;; -n|--node-id) NODE_ID="$2"; shift 2 ;; -T|--node-type) NODE_TYPE="$2"; shift 2 ;; -k|--kernel) KERNEL_TYPE="$2"; shift 2 ;; --docker) DOCKER_MODE=1; shift ;; add|remove|list|update|uninstall|help|--help|-h) if [ -z "$SUBCOMMAND" ]; then SUBCOMMAND="$1" fi shift ;; *) if [ "$SUBCOMMAND" = "remove" ] && [ -z "$NODE_ID" ]; then NODE_ID="$1" fi shift ;; esac done # Normalize kernel type case "$KERNEL_TYPE" in xray|Xray|XRAY) KERNEL_TYPE="xray" ;; *) KERNEL_TYPE="singbox" ;; esac } has_all_params() { [ -n "$PANEL_URL" ] && [ -n "$PANEL_TOKEN" ] && [ -n "$NODE_ID" ] } validate_params() { if [ -z "$PANEL_URL" ]; then log_error "Panel URL is required (-a/--api)" exit 1 fi if [ -z "$PANEL_TOKEN" ]; then log_error "Server Token is required (-t/--token)" exit 1 fi if [ -z "$NODE_ID" ]; then log_error "Node ID is required (-n/--node-id)" exit 1 fi if ! [[ "$NODE_ID" =~ ^[0-9]+$ ]]; then log_error "Node ID must be a positive integer, got: $NODE_ID" exit 1 fi } # ─── System Detection ──────────────────────────────────────────────── check_root() { if [ "$(id -u)" != "0" ]; then log_error "Please run as root (or with sudo)" exit 1 fi } detect_arch() { ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; armv7l) ARCH="armv7" ;; *) log_error "Unsupported architecture: $ARCH" exit 1 ;; esac } detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release OS=$ID elif [ -f /etc/alpine-release ]; then OS="alpine" else OS="unknown" fi } install_deps() { case "$OS" in ubuntu|debian) apt-get update -qq apt-get install -y -qq wget curl tar >/dev/null 2>&1 ;; centos|rhel|rocky|almalinux|fedora) yum install -y -q wget curl tar >/dev/null 2>&1 ;; alpine) apk add --no-cache wget curl tar >/dev/null 2>&1 ;; esac } # ─── Binary Install ────────────────────────────────────────────────── is_binary_installed() { [ -x "${INSTALL_DIR}/xboard-node" ] } install_binary() { if is_binary_installed; then log_info "xboard-node binary already installed" return fi log_step "Installing xboard-node binary..." local src="" if [ -f "./xboard-node" ]; then src="./xboard-node" elif [ -f "./xboard-node-linux-${ARCH}" ]; then src="./xboard-node-linux-${ARCH}" fi if [ -n "$src" ]; then cp "$src" "${INSTALL_DIR}/xboard-node" log_info "Installed from local file: $src" else local url="https://github.com/cedar2025/xboard-node/releases/latest/download/xboard-node-linux-${ARCH}" log_info "Downloading from GitHub releases..." if wget -q "$url" -O "${INSTALL_DIR}/xboard-node" 2>/dev/null; then log_info "Downloaded successfully" elif curl -fsSL "$url" -o "${INSTALL_DIR}/xboard-node" 2>/dev/null; then log_info "Downloaded successfully" else log_error "Failed to download. Place xboard-node binary in current directory and retry." exit 1 fi fi chmod +x "${INSTALL_DIR}/xboard-node" log_info "xboard-node installed to ${INSTALL_DIR}/xboard-node" } # ─── Systemd Template ──────────────────────────────────────────────── install_systemd_template() { if ! command -v systemctl >/dev/null 2>&1; then return fi if [ -f "/etc/systemd/system/${SERVICE_TEMPLATE}" ]; then return fi log_step "Installing systemd service template..." cat > "/etc/systemd/system/${SERVICE_TEMPLATE}" << 'UNIT' [Unit] Description=Xboard Node Backend (node %i) Documentation=https://github.com/cedar2025/xboard-node After=network.target nss-lookup.target [Service] Type=simple ExecStart=/usr/local/bin/xboard-node -c /etc/xboard-node/%i/config.yml Restart=always RestartSec=5 LimitNOFILE=1048576 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target UNIT systemctl daemon-reload log_info "Systemd template installed: ${SERVICE_TEMPLATE}" } # ─── Migrate Legacy Config ─────────────────────────────────────────── migrate_legacy_config() { if [ -f "${CONFIG_DIR}/config.yml" ] && [ ! -d "${CONFIG_DIR}/config.yml" ]; then local legacy_id legacy_id=$(grep -E '^\s*node_id:\s*' "${CONFIG_DIR}/config.yml" 2>/dev/null | head -1 | sed 's/[^0-9]*//g') if [ -z "$legacy_id" ]; then legacy_id="default" fi if [ ! -d "${CONFIG_DIR}/${legacy_id}" ]; then log_warn "Migrating legacy config to multi-node layout: node ${legacy_id}" mkdir -p "${CONFIG_DIR}/${legacy_id}" mv "${CONFIG_DIR}/config.yml" "${CONFIG_DIR}/${legacy_id}/config.yml" if command -v systemctl >/dev/null 2>&1; then systemctl stop xboard-node 2>/dev/null || true systemctl disable xboard-node 2>/dev/null || true rm -f /etc/systemd/system/xboard-node.service install_systemd_template systemctl enable "xboard-node@${legacy_id}" 2>/dev/null || true systemctl start "xboard-node@${legacy_id}" 2>/dev/null || true log_info "Migrated service: xboard-node → xboard-node@${legacy_id}" fi fi fi } # ─── Interactive Prompts ────────────────────────────────────────────── prompt_missing_params() { echo "" log_step "=== Node Configuration ===" echo "" if [ -z "$PANEL_URL" ]; then read -rp " Panel URL (e.g. https://panel.example.com): " PANEL_URL else echo -e " Panel URL: ${CYAN}${PANEL_URL}${NC}" fi if [ -z "$PANEL_TOKEN" ]; then read -rp " Server Token: " PANEL_TOKEN else echo -e " Server Token: ${CYAN}${PANEL_TOKEN:0:8}***${NC}" fi if [ -z "$NODE_ID" ]; then read -rp " Node ID: " NODE_ID else echo -e " Node ID: ${CYAN}${NODE_ID}${NC}" fi if [ -n "$NODE_TYPE" ]; then echo -e " Node Type: ${CYAN}${NODE_TYPE}${NC}" fi if [ -n "$KERNEL_TYPE" ] && [ "$KERNEL_TYPE" != "singbox" ]; then echo -e " Kernel: ${CYAN}${KERNEL_TYPE}${NC}" else echo "" echo " Kernel type:" echo " 1) singbox (default, recommended)" echo " 2) xray" read -rp " Choose [1/2]: " KERNEL_CHOICE case "$KERNEL_CHOICE" in 2) KERNEL_TYPE="xray" ;; *) KERNEL_TYPE="singbox" ;; esac fi echo "" validate_params } # ─── Node Operations ───────────────────────────────────────────────── write_node_config() { local node_id="$1" local node_dir="${CONFIG_DIR}/${node_id}" mkdir -p "$node_dir" local node_type_line="" if [ -n "$NODE_TYPE" ]; then node_type_line=" node_type: \"${NODE_TYPE}\"" fi cat > "${node_dir}/config.yml" << EOF panel: url: "${PANEL_URL}" token: "${PANEL_TOKEN}" node_id: ${node_id} ${node_type_line} node: push_interval: 0 pull_interval: 0 kernel: type: "${KERNEL_TYPE}" config_dir: "${node_dir}" log_level: "warn" log: level: "info" output: "stdout" EOF log_info "Config written: ${node_dir}/config.yml" } add_node_native() { local node_id="$1" if [ -d "${CONFIG_DIR}/${node_id}" ]; then log_error "Node ${node_id} already exists at ${CONFIG_DIR}/${node_id}/" log_info "To reconfigure, first remove it: $0 remove ${node_id}" exit 1 fi write_node_config "$node_id" if command -v systemctl >/dev/null 2>&1; then install_systemd_template systemctl enable "xboard-node@${node_id}" systemctl start "xboard-node@${node_id}" log_info "Service started: xboard-node@${node_id}" else log_warn "No systemd found. Start manually:" echo " xboard-node -c ${CONFIG_DIR}/${node_id}/config.yml" fi } add_node_docker() { local node_id="$1" if [ -d "${CONFIG_DIR}/${node_id}" ]; then log_error "Node ${node_id} already exists at ${CONFIG_DIR}/${node_id}/" log_info "To reconfigure, first remove it: $0 remove ${node_id}" exit 1 fi write_node_config "$node_id" regenerate_docker_compose log_info "Docker compose updated: ${DOCKER_COMPOSE_FILE}" if command -v docker >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1; then COMPOSE_CMD="docker compose" elif command -v docker-compose >/dev/null 2>&1; then COMPOSE_CMD="docker-compose" else log_warn "docker compose not found. Start manually:" echo " cd ${CONFIG_DIR} && docker compose up -d" return fi cd "${CONFIG_DIR}" ${COMPOSE_CMD} up -d "node-${node_id}" log_info "Container started: xboard-node-${node_id}" else log_warn "Docker not installed. Install Docker first, then run:" echo " cd ${CONFIG_DIR} && docker compose up -d" fi } regenerate_docker_compose() { local nodes=() for dir in "${CONFIG_DIR}"/*/; do [ -f "${dir}config.yml" ] || continue local nid nid=$(basename "$dir") nodes+=("$nid") done if [ ${#nodes[@]} -eq 0 ]; then rm -f "${DOCKER_COMPOSE_FILE}" return fi cat > "${DOCKER_COMPOSE_FILE}" << 'HEADER' # Auto-generated by install.sh — do not edit manually. # Regenerated each time a node is added or removed. services: HEADER for nid in "${nodes[@]}"; do cat >> "${DOCKER_COMPOSE_FILE}" << EOF node-${nid}: image: ${DOCKER_IMAGE} container_name: xboard-node-${nid} restart: always network_mode: host volumes: - ./${nid}/config.yml:/etc/xboard-node/config.yml:ro - ./${nid}:/etc/xboard-node/data logging: driver: json-file options: max-size: "10m" max-file: "3" EOF done } # ─── Deploy Node (unified entry) ───────────────────────────────────── deploy_node() { if has_all_params; then validate_params log_info "Deploying node ${NODE_ID} (${KERNEL_TYPE}) → ${PANEL_URL}" else prompt_missing_params fi if [ "$DOCKER_MODE" -eq 1 ]; then add_node_docker "$NODE_ID" else add_node_native "$NODE_ID" fi echo "" echo -e "${GREEN}=== Node ${NODE_ID} Deployed ===${NC}" echo "" if [ "$DOCKER_MODE" -eq 1 ]; then echo " Manage:" echo " Logs: docker logs -f xboard-node-${NODE_ID}" echo " Stop: cd ${CONFIG_DIR} && docker compose stop node-${NODE_ID}" echo " Restart: cd ${CONFIG_DIR} && docker compose restart node-${NODE_ID}" else echo " Manage:" echo " Status: systemctl status xboard-node@${NODE_ID}" echo " Logs: journalctl -u xboard-node@${NODE_ID} -f" echo " Stop: systemctl stop xboard-node@${NODE_ID}" echo " Restart: systemctl restart xboard-node@${NODE_ID}" fi echo "" echo " Config: ${CONFIG_DIR}/${NODE_ID}/config.yml" echo "" } # ─── Remove Node ────────────────────────────────────────────────────── remove_node() { local node_id="$1" if [ -z "$node_id" ]; then log_error "Usage: $0 remove " exit 1 fi if [ ! -d "${CONFIG_DIR}/${node_id}" ]; then log_error "Node ${node_id} not found" exit 1 fi log_step "Removing node ${node_id}..." if command -v systemctl >/dev/null 2>&1; then systemctl stop "xboard-node@${node_id}" 2>/dev/null || true systemctl disable "xboard-node@${node_id}" 2>/dev/null || true log_info "Systemd service stopped and disabled" fi if command -v docker >/dev/null 2>&1; then docker rm -f "xboard-node-${node_id}" 2>/dev/null || true fi rm -rf "${CONFIG_DIR}/${node_id}" log_info "Config removed: ${CONFIG_DIR}/${node_id}/" regenerate_docker_compose log_info "Node ${node_id} removed" } # ─── List Nodes ─────────────────────────────────────────────────────── list_nodes() { echo "" echo -e "${BOLD} Deployed Nodes${NC}" echo -e " ────────────────────────────────────────────" local found=0 for dir in "${CONFIG_DIR}"/*/; do [ -f "${dir}config.yml" ] || continue found=1 local nid nid=$(basename "$dir") local panel_url kernel_type panel_url=$(grep -E '^\s*url:' "${dir}config.yml" 2>/dev/null | head -1 | sed 's/.*url:\s*"\?\([^"]*\)"\?.*/\1/') kernel_type=$(grep -E '^\s*type:' "${dir}config.yml" 2>/dev/null | head -1 | sed 's/.*type:\s*"\?\([^"]*\)"\?.*/\1/') local status="${RED}stopped${NC}" if command -v systemctl >/dev/null 2>&1; then if systemctl is-active "xboard-node@${nid}" >/dev/null 2>&1; then status="${GREEN}running (systemd)${NC}" fi fi if command -v docker >/dev/null 2>&1; then if docker inspect -f '{{.State.Running}}' "xboard-node-${nid}" 2>/dev/null | grep -q true; then status="${GREEN}running (docker)${NC}" fi fi printf " ${BOLD}Node %-6s${NC} kernel=%-8s panel=%s\n" "$nid" "${kernel_type:-singbox}" "${panel_url:-unknown}" echo -e " status=${status}" echo "" done if [ "$found" -eq 0 ]; then echo " No nodes deployed yet." echo "" echo " Deploy your first node:" echo " $0 -a https://panel.example.com -t TOKEN -n 1" echo " $0 -a https://panel.example.com -t TOKEN -n 1 --docker" fi echo "" } # ─── Update / Uninstall ────────────────────────────────────────────── update_binary() { log_step "Updating xboard-node binary..." detect_arch local url="https://github.com/cedar2025/xboard-node/releases/latest/download/xboard-node-linux-${ARCH}" local tmp="/tmp/xboard-node-update" if wget -q "$url" -O "$tmp" 2>/dev/null || curl -fsSL "$url" -o "$tmp" 2>/dev/null; then chmod +x "$tmp" mv "$tmp" "${INSTALL_DIR}/xboard-node" log_info "Binary updated" else log_error "Failed to download update" rm -f "$tmp" exit 1 fi if command -v systemctl >/dev/null 2>&1; then for dir in "${CONFIG_DIR}"/*/; do [ -f "${dir}config.yml" ] || continue local nid nid=$(basename "$dir") if systemctl is-active "xboard-node@${nid}" >/dev/null 2>&1; then systemctl restart "xboard-node@${nid}" log_info "Restarted: xboard-node@${nid}" fi done fi if [ -f "${DOCKER_COMPOSE_FILE}" ] && command -v docker >/dev/null 2>&1; then log_info "For Docker nodes, pull the latest image and restart:" echo " cd ${CONFIG_DIR} && docker compose pull && docker compose up -d" fi } do_uninstall() { log_step "Uninstalling xboard-node..." if command -v systemctl >/dev/null 2>&1; then for dir in "${CONFIG_DIR}"/*/; do [ -f "${dir}config.yml" ] || continue local nid nid=$(basename "$dir") systemctl stop "xboard-node@${nid}" 2>/dev/null || true systemctl disable "xboard-node@${nid}" 2>/dev/null || true done systemctl stop xboard-node 2>/dev/null || true systemctl disable xboard-node 2>/dev/null || true rm -f /etc/systemd/system/xboard-node.service rm -f "/etc/systemd/system/${SERVICE_TEMPLATE}" systemctl daemon-reload fi if command -v docker >/dev/null 2>&1; then for dir in "${CONFIG_DIR}"/*/; do [ -f "${dir}config.yml" ] || continue local nid nid=$(basename "$dir") docker rm -f "xboard-node-${nid}" 2>/dev/null || true done fi rm -f "${INSTALL_DIR}/xboard-node" log_info "Binary removed" echo "" read -rp " Delete all configs? (${CONFIG_DIR}) [y/N]: " DELETE_ALL if [[ "$DELETE_ALL" =~ ^[Yy]$ ]]; then rm -rf "${CONFIG_DIR}" log_info "All configs removed" else log_info "Configs preserved at ${CONFIG_DIR}/" fi log_info "xboard-node uninstalled" } # ─── Print Help ─────────────────────────────────────────────────────── print_help() { cat << 'HELP' xboard-node Deploy Script DEPLOY A NODE (one command, repeat for each node): install.sh -a -t -n [-T ] [-k singbox|xray] [--docker] OPTIONS: -a, --api Panel URL (e.g. https://panel.example.com) -t, --token Server Token (from panel settings) -n, --node-id Node ID (positive integer) -T, --node-type Node type (optional, auto-detected from panel) -k, --kernel Kernel type (singbox or xray, default: singbox) --docker Use Docker instead of systemd EXAMPLES: # Deploy node 1 (sing-box, systemd): bash install.sh -a https://panel.example.com -t mytoken123 -n 1 # Deploy node 2 (xray, systemd): bash install.sh -a https://panel.example.com -t mytoken123 -n 2 -k xray # Deploy node 3 with explicit type (Docker): bash install.sh -a https://panel.example.com -t mytoken123 -n 3 -T shadowsocks --docker # Interactive mode (will prompt for all params): bash install.sh MANAGEMENT: bash install.sh list List all deployed nodes bash install.sh remove Remove a node bash install.sh update Update binary + restart all nodes bash install.sh uninstall Remove everything DOCKER (env-var mode, no config file needed): docker run -d --restart=always --network=host \ -e apiHost=https://panel.example.com \ -e apiKey=YOUR_TOKEN \ -e nodeID=1 \ ghcr.io/cedar2025/xboard-node:latest HELP } # ─── Main ───────────────────────────────────────────────────────────── main() { parse_args "$@" case "$SUBCOMMAND" in remove) check_root remove_node "$NODE_ID" ;; list) list_nodes ;; update) check_root update_binary ;; uninstall) check_root do_uninstall ;; help|--help|-h) print_help ;; add|"") check_root detect_arch detect_os install_deps mkdir -p "$CONFIG_DIR" migrate_legacy_config if [ "$DOCKER_MODE" -eq 0 ]; then install_binary install_systemd_template fi deploy_node ;; *) log_error "Unknown command: $SUBCOMMAND" print_help exit 1 ;; esac } main "$@"