#!/usr/bin/env bash set -Eeuo pipefail RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' APP_NAME="xboard-node" INSTALL_ROOT="/etc/xboard-node" BACKUP_DIR="${INSTALL_ROOT}/backups" INSTALL_META="${INSTALL_ROOT}/install-meta.json" CONFIG_FILE="${INSTALL_ROOT}/config.yml" CREDENTIALS_FILE="${INSTALL_ROOT}/credentials.env" BINARY_PATH="/usr/local/bin/xboard-node" SERVICE_NAME="xboard-node.service" SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}" CLI_PATH="/usr/local/bin/xbctl" INSTALLER_COPY_PATH="${INSTALL_ROOT}/install.sh" CLI_BINARY_SOURCE="" DEFAULT_HEALTH_PORT=65530 DEFAULT_KERNEL="singbox" DEFAULT_MODE="node" DEFAULT_ACTION="install" DEFAULT_RELEASE_VERSION="latest" DEFAULT_LOG_LEVEL="info" DEFAULT_KERNEL_LOG_LEVEL="warn" DEFAULT_DOWNLOAD_BASE="https://github.com/cedar2025/xboard-node/releases" ACTION="${DEFAULT_ACTION}" MODE="" PANEL_URL="" TOKEN="" NODE_ID="" NODE_TYPE="" MACHINE_ID="" KERNEL_TYPE="${DEFAULT_KERNEL}" RELEASE_VERSION="${DEFAULT_RELEASE_VERSION}" HEALTH_PORT="${DEFAULT_HEALTH_PORT}" HEALTH_ENABLED=1 RUNTIME_GOMEMLIMIT="" RUNTIME_GOGC="" BINARY_SOURCE="" CLI_BINARY_SOURCE="" FORCE_RECONFIGURE=0 PURGE=0 YES=0 ARCH="" OS="" DOWNLOAD_URL="" CURRENT_STATE="fresh" TMP_DIR="" BACKUP_PATH="" SERVICE_EXISTED=0 CLEANUP_DONE=0 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}"; } cleanup_tmp() { if [ "$CLEANUP_DONE" -eq 1 ]; then return fi CLEANUP_DONE=1 if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then rm -rf "$TMP_DIR" fi } load_health_port_from_config() { local cfg_path="$1" if [ ! -f "$cfg_path" ]; then return fi local parsed parsed=$(python3 - "$cfg_path" <<'PYCFG' import sys try: import yaml except Exception: sys.exit(0) with open(sys.argv[1], 'r', encoding='utf-8') as f: data = yaml.safe_load(f) or {} port = data.get('health_port', 0) if isinstance(port, int): print(port) PYCFG ) if [ -n "$parsed" ] && [[ "$parsed" =~ ^[0-9]+$ ]]; then HEALTH_PORT="$parsed" if [ "$HEALTH_PORT" -eq 0 ]; then HEALTH_ENABLED=0 else HEALTH_ENABLED=1 fi fi } rollback_install() { log_warn "Rolling back installation" if [ -n "$BACKUP_PATH" ] && [ -d "$BACKUP_PATH" ]; then if [ -f "$BACKUP_PATH/xboard-node" ]; then install -m 755 "$BACKUP_PATH/xboard-node" "$BINARY_PATH" else rm -f "$BINARY_PATH" fi if [ -f "$BACKUP_PATH/config.yml" ]; then install -m 600 "$BACKUP_PATH/config.yml" "$CONFIG_FILE" else rm -f "$CONFIG_FILE" fi if [ -f "$BACKUP_PATH/credentials.env" ]; then install -m 600 "$BACKUP_PATH/credentials.env" "$CREDENTIALS_FILE" else rm -f "$CREDENTIALS_FILE" fi if [ -f "$BACKUP_PATH/install-meta.json" ]; then install -m 644 "$BACKUP_PATH/install-meta.json" "$INSTALL_META" else rm -f "$INSTALL_META" fi if [ -f "$BACKUP_PATH/xbctl" ]; then install -m 755 "$BACKUP_PATH/xbctl" "$CLI_PATH" else rm -f "$CLI_PATH" fi if [ -f "$BACKUP_PATH/${SERVICE_NAME}" ]; then install -m 644 "$BACKUP_PATH/${SERVICE_NAME}" "$SERVICE_PATH" else rm -f "$SERVICE_PATH" fi fi load_health_port_from_config "$CONFIG_FILE" systemctl daemon-reload || true if [ "$SERVICE_EXISTED" -eq 1 ] || [ -f "$SERVICE_PATH" ]; then systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true systemctl restart "$SERVICE_NAME" >/dev/null 2>&1 || true if ! wait_for_health; then log_error "Rollback completed but restored service did not become healthy" show_recent_logs return 1 fi else systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true fi log_warn "Rollback complete" } on_error() { local exit_code=$? local line_no=${1:-unknown} if [ "$exit_code" -ne 0 ]; then log_error "Install failed at line ${line_no} (exit=${exit_code})" if [ -n "$BACKUP_PATH" ]; then rollback_install || true fi fi cleanup_tmp exit "$exit_code" } trap 'on_error $LINENO' ERR trap cleanup_tmp EXIT usage() { cat <<'HELP' xboard-node Installer ACTIONS: install Install or reconcile the configured deployment (default) upgrade Upgrade binary and restart service uninstall Remove installed service and binary (config kept unless --purge) status Show current installation status help Show this help MODES (auto-detected from --node-id or --machine-id if omitted): --mode node Panel single-node mode (default) --mode machine Panel machine mode REQUIRED FOR NODE MODE: --panel, -a Panel URL --token, -t Panel server token --node-id, -n Node ID REQUIRED FOR MACHINE MODE: --panel, -a Panel URL --token, -t Machine token --machine-id Machine ID OPTIONAL: --node-type, -T Explicit node type for node mode --kernel, -k singbox or xray (default: singbox) --version Release version or latest (default: latest) --binary Use a local xboard-node binary path instead of downloading --xbctl-binary Use a local xbctl binary path instead of downloading --health-port Local health port (default: 65530, use 0 to disable) --gomemlimit Runtime GOMEMLIMIT value, e.g. 256MiB --gogc Runtime GOGC value, e.g. 50 --force-reconfigure Overwrite an existing install even if mode/target changed --purge With uninstall, delete /etc/xboard-node too --yes, -y Non-interactive confirmation for destructive operations EXAMPLES: sudo bash install.sh --panel https://panel.example.com --token TOKEN --node-id 1 sudo bash install.sh --panel https://panel.example.com --token TOKEN --machine-id 1 sudo bash install.sh upgrade sudo bash install.sh uninstall --purge --yes HELP } parse_args() { local positional=() while [ $# -gt 0 ]; do case "$1" in install|upgrade|uninstall|status|help) ACTION="$1" shift ;; --mode) MODE="$2" shift 2 ;; --panel|-a|--api) PANEL_URL="$2" shift 2 ;; --token|-t) TOKEN="$2" shift 2 ;; --node-id|-n) NODE_ID="$2" shift 2 ;; --node-type|-T) NODE_TYPE="$2" shift 2 ;; --machine-id) MACHINE_ID="$2" shift 2 ;; --kernel|-k) KERNEL_TYPE="$2" shift 2 ;; --version) RELEASE_VERSION="$2" shift 2 ;; --binary) BINARY_SOURCE="$2" shift 2 ;; --xbctl-binary) CLI_BINARY_SOURCE="$2" shift 2 ;; --health-port) HEALTH_PORT="$2" shift 2 ;; --gomemlimit) RUNTIME_GOMEMLIMIT="$2" shift 2 ;; --gogc) RUNTIME_GOGC="$2" shift 2 ;; --force-reconfigure) FORCE_RECONFIGURE=1 shift ;; --purge) PURGE=1 shift ;; --yes|-y) YES=1 shift ;; --help|-h) ACTION="help" shift ;; *) positional+=("$1") shift ;; esac done if [ ${#positional[@]} -gt 0 ] && [ "$ACTION" = "install" ]; then ACTION="${positional[0]}" fi case "$KERNEL_TYPE" in singbox|SingBox|SINGBOX) KERNEL_TYPE="singbox" ;; xray|Xray|XRAY) KERNEL_TYPE="xray" ;; *) ;; esac # Auto-detect mode from arguments when --mode is not specified. if [ -z "$MODE" ]; then if [ -n "$MACHINE_ID" ]; then MODE="machine" else MODE="node" fi fi case "$MODE" in node|machine) ;; *) log_error "Unsupported mode: $MODE" usage exit 1 ;; esac } check_root() { if [ "$(id -u)" -ne 0 ]; then log_error "Please run as root or with sudo" exit 1 fi } detect_arch() { local raw raw=$(uname -m) case "$raw" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) log_error "Unsupported architecture: $raw" exit 1 ;; esac } detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release OS="$ID" else OS="unknown" fi } ensure_systemd() { if ! command -v systemctl >/dev/null 2>&1; then log_error "systemd is required for this installer" exit 1 fi if [ ! -d /run/systemd/system ]; then log_error "This host does not appear to be running systemd" exit 1 fi } run_with_retry() { local attempts="$1" local delay="$2" shift 2 local i=1 while [ "$i" -le "$attempts" ]; do if "$@"; then return 0 fi if [ "$i" -lt "$attempts" ]; then log_warn "Command failed, retrying in ${delay}s: $*" sleep "$delay" fi i=$((i + 1)) done return 1 } install_dependencies() { case "$OS" in ubuntu|debian) DEBIAN_FRONTEND=noninteractive run_with_retry 10 3 apt-get update -qq DEBIAN_FRONTEND=noninteractive run_with_retry 10 3 apt-get install -y -qq curl wget ca-certificates >/dev/null 2>&1 ;; centos|rhel|rocky|almalinux|fedora) if command -v dnf >/dev/null 2>&1; then run_with_retry 5 3 dnf install -y -q curl wget ca-certificates >/dev/null 2>&1 else run_with_retry 5 3 yum install -y -q curl wget ca-certificates >/dev/null 2>&1 fi ;; *) log_warn "OS ${OS} is not in the official support set; continuing best-effort" ;; esac } ensure_dirs() { mkdir -p "$INSTALL_ROOT" "$BACKUP_DIR" chmod 700 "$INSTALL_ROOT" } validate_positive_int() { local label="$1" local value="$2" if ! [[ "$value" =~ ^[0-9]+$ ]] || [ "$value" -le 0 ]; then log_error "${label} must be a positive integer, got: ${value}" exit 1 fi } validate_install_request() { if [ -z "$PANEL_URL" ]; then log_error "Panel URL is required" exit 1 fi if [ -z "$TOKEN" ]; then log_error "Token is required" exit 1 fi if ! [[ "$HEALTH_PORT" =~ ^[0-9]+$ ]]; then log_error "health-port must be a non-negative integer" exit 1 fi if [ "$HEALTH_PORT" -eq 0 ]; then HEALTH_ENABLED=0 fi case "$KERNEL_TYPE" in singbox|xray) ;; *) log_error "Kernel must be singbox or xray" exit 1 ;; esac case "$MODE" in node) validate_positive_int "Node ID" "$NODE_ID" ;; machine) validate_positive_int "Machine ID" "$MACHINE_ID" ;; esac } json_escape() { python3 - <<'PY' "$1" import json, sys print(json.dumps(sys.argv[1])) PY } current_mode() { :; } current_target() { :; } detect_current_state() { local has_binary=0 has_config=0 has_service=0 [ -x "$BINARY_PATH" ] && has_binary=1 [ -f "$CONFIG_FILE" ] && has_config=1 [ -f "$SERVICE_PATH" ] && has_service=1 if [ "$has_binary" -eq 0 ] && [ "$has_config" -eq 0 ] && [ "$has_service" -eq 0 ]; then CURRENT_STATE="fresh" elif [ "$has_binary" -eq 1 ] && [ "$has_config" -eq 1 ] && [ "$has_service" -eq 1 ]; then CURRENT_STATE="installed" else CURRENT_STATE="partial" fi } require_reconfigure_confirmation() { return } select_binary_source() { if [ -n "$BINARY_SOURCE" ]; then if [ ! -f "$BINARY_SOURCE" ]; then log_error "Binary source not found: $BINARY_SOURCE" exit 1 fi echo "$BINARY_SOURCE" return fi if [ -f "./xboard-node" ]; then echo "./xboard-node" return fi if [ -f "./xboard-node-linux-${ARCH}" ]; then echo "./xboard-node-linux-${ARCH}" return fi echo "" } resolve_download_url() { local artifact="$1" if [ "$RELEASE_VERSION" = "latest" ]; then DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/latest/download/${artifact}" else DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/download/${RELEASE_VERSION}/${artifact}" fi } stage_binary() { local staged="$TMP_DIR/xboard-node" local local_src local_src=$(select_binary_source) if [ -n "$local_src" ]; then log_step "Using local binary: ${local_src}" cp "$local_src" "$staged" else resolve_download_url "xboard-node-linux-${ARCH}" log_step "Downloading binary: ${DOWNLOAD_URL}" if ! curl -fsSL "$DOWNLOAD_URL" -o "$staged"; then log_error "Failed to download binary from ${DOWNLOAD_URL}" exit 1 fi fi chmod +x "$staged" if ! "$staged" -v >/dev/null 2>&1; then log_error "Downloaded binary failed version check" exit 1 fi } stage_xbctl() { local staged="$TMP_DIR/xbctl" local local_src="" if [ -n "$CLI_BINARY_SOURCE" ]; then if [ ! -f "$CLI_BINARY_SOURCE" ]; then log_error "xbctl binary source not found: $CLI_BINARY_SOURCE" exit 1 fi local_src="$CLI_BINARY_SOURCE" elif [ -f "./xbctl" ]; then local_src="./xbctl" elif [ -f "./xbctl-linux-${ARCH}" ]; then local_src="./xbctl-linux-${ARCH}" fi if [ -n "$local_src" ]; then log_step "Using local xbctl binary: ${local_src}" cp "$local_src" "$staged" else resolve_download_url "xbctl-linux-${ARCH}" log_step "Downloading xbctl: ${DOWNLOAD_URL}" if ! curl -fsSL "$DOWNLOAD_URL" -o "$staged"; then log_error "Failed to download xbctl from ${DOWNLOAD_URL}" exit 1 fi fi chmod +x "$staged" if ! "$staged" version > /dev/null 2>&1; then log_error "Downloaded xbctl failed version check" exit 1 fi } render_runtime_block() { local output="" if [ -n "$RUNTIME_GOMEMLIMIT" ] || [ -n "$RUNTIME_GOGC" ]; then output+=$'runtime:\n' if [ -n "$RUNTIME_GOMEMLIMIT" ]; then output+=" gomemlimit: \"${RUNTIME_GOMEMLIMIT}\"\n" fi if [ -n "$RUNTIME_GOGC" ]; then output+=" gogc: ${RUNTIME_GOGC}\n" fi fi printf '%b' "$output" } render_config() { INSTANCE_ID=$(python3 - "$MODE" "$PANEL_URL" "$NODE_ID" "$MACHINE_ID" <<'PY_INST' import hashlib import sys from urllib.parse import urlparse mode, panel_url, node_id, machine_id = sys.argv[1:] node_id = int(node_id or 0) machine_id = int(machine_id or 0) u = urlparse((panel_url or '').strip()) if not u.scheme or not u.netloc: raise SystemExit(f"invalid panel url: {panel_url}") scheme = u.scheme.lower() host = u.netloc.lower() path = u.path.rstrip('/') normalized = f"{scheme}://{host}{path}" slug_src = f"{host}{('-' + path.strip('/')) if path.strip('/') else ''}".lower() slug = [] last_dash = False for ch in slug_src: if ch.isalnum(): slug.append(ch) last_dash = False elif not last_dash: slug.append('-') last_dash = True slug = ''.join(slug).strip('-') or 'panel' slug = (slug[:48].strip('-') or 'panel') target_value = str(machine_id) if mode == "machine" else str(node_id) target_label = f"machine-{machine_id}" if mode == "machine" else f"node-{node_id}" short = hashlib.sha1(f"{normalized}|{mode}|{target_value}".encode()).hexdigest()[:6] print(f"{slug}-{target_label}-{short}") PY_INST ) python3 - "$CONFIG_FILE" "$TMP_DIR/config.yml" "$MODE" "$PANEL_URL" "$NODE_ID" "$NODE_TYPE" "$MACHINE_ID" "$KERNEL_TYPE" "$HEALTH_PORT" "$RUNTIME_GOMEMLIMIT" "$RUNTIME_GOGC" "$INSTALL_ROOT" "$INSTANCE_ID" <<'PY_CFG' import os import sys from copy import deepcopy import yaml config_file, out_file, mode, panel_url, node_id, node_type, machine_id, kernel_type, health_port, gomemlimit, gogc, install_root, instance_id = sys.argv[1:] node_id = int(node_id or 0) machine_id = int(machine_id or 0) health_port = int(health_port or 0) if os.path.exists(config_file): with open(config_file, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) or {} else: data = {} legacy_keys = ["panel", "node", "kernel", "cert", "log", "runtime", "ws", "standalone", "health_port", "nodes", "machine"] instances = data.get("instances") if not isinstance(instances, list): instances = [] if any(k in data for k in legacy_keys): legacy = {k: deepcopy(data[k]) for k in legacy_keys if k in data} if legacy: instances.append(legacy) for k in legacy_keys: data.pop(k, None) def normalize_existing_id(inst): panel = inst.get("panel") or {} machine = inst.get("machine") or {} panel_url_existing = panel.get("url", "") from urllib.parse import urlparse u = urlparse((panel_url_existing or '').strip()) if not u.scheme or not u.netloc: return inst.get("id", "") normalized_existing = f"{u.scheme.lower()}://{u.netloc.lower()}{u.path.rstrip('/')}" if machine and machine.get("machine_id"): existing_mode = "machine" target_value = str(machine.get("machine_id")) target_label = f"machine-{machine.get('machine_id')}" else: existing_mode = "node" target_value = str(panel.get("node_id", 0)) target_label = f"node-{panel.get('node_id', 0)}" slug_src = f"{u.netloc.lower()}{('-' + u.path.rstrip('/').strip('/')) if u.path.rstrip('/').strip('/') else ''}".lower() slug = [] last_dash = False for ch in slug_src: if ch.isalnum(): slug.append(ch) last_dash = False elif not last_dash: slug.append('-') last_dash = True slug = ''.join(slug).strip('-') or 'panel' slug = (slug[:48].strip('-') or 'panel') short = __import__('hashlib').sha1(f"{normalized_existing}|{existing_mode}|{target_value}".encode()).hexdigest()[:6] return f"{slug}-{target_label}-{short}" normalized_instances = [] seen_ids = set() for inst in instances: inst = deepcopy(inst) normalized_id = normalize_existing_id(inst) if normalized_id: inst["id"] = normalized_id current_id = inst.get("id") if current_id and current_id in seen_ids: continue if current_id: seen_ids.add(current_id) normalized_instances.append(inst) instances = normalized_instances env_key = f"INSTANCE_{instance_id.upper().replace('-', '_')}_{'MACHINE_TOKEN' if mode == 'machine' else 'API_KEY'}" new_inst = { "id": instance_id, "panel": {"url": panel_url}, "kernel": { "type": kernel_type, "config_dir": f"{install_root}/instances/{instance_id}", "log_level": "warn", }, "log": {"level": "info", "output": "stdout"}, } if health_port > 0: new_inst["health_port"] = health_port if gomemlimit or gogc: new_inst["runtime"] = {} if gomemlimit: new_inst["runtime"]["gomemlimit"] = gomemlimit if gogc: new_inst["runtime"]["gogc"] = int(gogc) if mode == 'machine': new_inst["machine"] = {"machine_id": machine_id, "token_env": env_key} else: new_inst["panel"]["node_id"] = node_id new_inst["panel"]["token_env"] = env_key if node_type: new_inst["panel"]["node_type"] = node_type new_inst["node"] = {"push_interval": 0, "pull_interval": 0} replaced = False for i, inst in enumerate(instances): if inst.get("id") == instance_id: instances[i] = new_inst replaced = True break if not replaced: instances.append(new_inst) data["instances"] = instances with open(out_file, 'w', encoding='utf-8') as f: yaml.safe_dump(data, f, sort_keys=False, allow_unicode=False) PY_CFG local env_key if [ "$MODE" = "machine" ]; then env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_MACHINE_TOKEN" else env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_API_KEY" fi python3 - "$CREDENTIALS_FILE" "$TMP_DIR/credentials.env" "$env_key" "$TOKEN" <<'PY_ENV' import os import sys src, out, key, value = sys.argv[1:] entries = {} order = [] if os.path.exists(src): with open(src, 'r', encoding='utf-8') as f: for raw in f: line = raw.rstrip('\n') if not line or line.startswith('#') or '=' not in line: continue k, v = line.split('=', 1) if k not in entries: order.append(k) entries[k] = v if key not in entries: order.append(key) entries[key] = value with open(out, 'w', encoding='utf-8') as f: for k in order: f.write(f"{k}={entries[k]}\n") PY_ENV chmod 600 "$TMP_DIR/credentials.env" } render_install_meta() { python3 - "$TMP_DIR/config.yml" "$TMP_DIR/install-meta.json" "$RELEASE_VERSION" "$INSTANCE_ID" <<'PY_META' import json import sys import yaml from datetime import datetime, timezone cfg_path, out_path, version, latest_id = sys.argv[1:] with open(cfg_path, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) or {} instances = [] for inst in data.get('instances', []) or []: item = { 'id': inst.get('id', ''), 'panel_url': ((inst.get('panel') or {}).get('url', '')), 'mode': 'machine' if inst.get('machine') else ('standalone' if inst.get('standalone') else 'node'), 'node_id': ((inst.get('panel') or {}).get('node_id')), 'machine_id': ((inst.get('machine') or {}).get('machine_id')), 'health_port': inst.get('health_port', 0), } instances.append(item) meta = { 'config_mode': 'instances', 'version': version, 'latest_instance_id': latest_id, 'instance_count': len(instances), 'instances': instances, 'updated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), } with open(out_path, 'w', encoding='utf-8') as f: json.dump(meta, f, indent=2) PY_META } render_service() { cat >"$TMP_DIR/${SERVICE_NAME}" </dev/null 2>&1; then systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true fi } install_staged_files() { stop_existing_service install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH" install -m 600 "$TMP_DIR/config.yml" "$CONFIG_FILE" install -m 600 "$TMP_DIR/credentials.env" "$CREDENTIALS_FILE" install -m 644 "$TMP_DIR/install-meta.json" "$INSTALL_META" if [ "$(realpath "$0")" != "$(realpath "$INSTALLER_COPY_PATH" 2>/dev/null || echo "$INSTALLER_COPY_PATH")" ]; then install -m 755 "$0" "$INSTALLER_COPY_PATH" fi install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH" install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH" systemctl daemon-reload systemctl enable "$SERVICE_NAME" > /dev/null 2>&1 } wait_for_health() { if ! systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1; then return 1 fi if [ "$HEALTH_ENABLED" -eq 0 ]; then return 0 fi local attempt=0 local max_attempts=30 while [ "$attempt" -lt "$max_attempts" ]; do if ! systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1; then return 1 fi if curl -fsS "http://127.0.0.1:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then return 0 fi sleep 1 attempt=$((attempt + 1)) done return 1 } show_recent_logs() { if command -v journalctl >/dev/null 2>&1; then journalctl -u "$SERVICE_NAME" -n 30 --no-pager || true fi } start_service() { if systemctl is-enabled "$SERVICE_NAME" >/dev/null 2>&1; then systemctl restart "$SERVICE_NAME" else systemctl start "$SERVICE_NAME" fi if ! wait_for_health; then log_error "Service failed health check" show_recent_logs return 1 fi } perform_install() { validate_install_request detect_current_state require_reconfigure_confirmation TMP_DIR=$(mktemp -d) ensure_dirs stage_binary stage_xbctl render_config render_install_meta render_service backup_existing_state install_staged_files start_service log_info "Installation succeeded" log_info "Service: ${SERVICE_NAME}" log_info "Config: ${CONFIG_FILE}" log_info "Credentials: ${CREDENTIALS_FILE}" if [ "$HEALTH_ENABLED" -eq 1 ]; then log_info "Health: http://127.0.0.1:${HEALTH_PORT}/healthz" fi } perform_upgrade() { detect_current_state if [ "$CURRENT_STATE" = "fresh" ]; then log_warn "No existing install found; falling back to install" perform_install return fi TMP_DIR=$(mktemp -d) ensure_dirs stage_binary stage_xbctl render_service backup_existing_state install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH" install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH" install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH" systemctl daemon-reload systemctl restart "$SERVICE_NAME" if ! wait_for_health; then log_error "Upgrade health check failed" show_recent_logs return 1 fi log_info "Upgrade succeeded" } confirm_uninstall() { if [ "$YES" -eq 1 ]; then return fi echo read -r -p "Proceed with uninstall? [y/N]: " answer if ! [[ "$answer" =~ ^[Yy]$ ]]; then log_warn "Uninstall cancelled" exit 0 fi } perform_uninstall() { confirm_uninstall if [ -f "$SERVICE_PATH" ]; then systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true rm -f "$SERVICE_PATH" systemctl daemon-reload || true fi rm -f "$BINARY_PATH" rm -f "$CLI_PATH" rm -f "$INSTALL_META" if [ "$PURGE" -eq 1 ]; then rm -rf "$INSTALL_ROOT" log_info "Removed ${INSTALL_ROOT}" else rm -f "$CONFIG_FILE" "$CREDENTIALS_FILE" log_info "Config preserved under ${INSTALL_ROOT}" fi log_info "Uninstall complete" } perform_status() { detect_current_state echo echo -e "${BOLD}xboard-node install status${NC}" echo " state: ${CURRENT_STATE}" if [ -f "$INSTALL_META" ]; then echo " meta: ${INSTALL_META}" python3 - "$INSTALL_META" <<'PY' import json, sys with open(sys.argv[1]) as f: data = json.load(f) config_mode = data.get("config_mode") if config_mode: print(f" config_mode: {config_mode}") for key in ("mode", "panel_url", "kernel", "node_id", "machine_id", "health_port", "version", "updated_at", "latest_instance_id", "instance_count"): if key in data: print(f" {key}: {data[key]}") instances = data.get("instances") or [] if instances: print(" instances:") for item in instances: mode = item.get("mode", "") panel_url = item.get("panel_url", "") node_id = item.get("node_id") machine_id = item.get("machine_id") target = f"node_id={node_id}" if node_id else (f"machine_id={machine_id}" if machine_id else "") print(f" - id={item.get('id','')} mode={mode} panel_url={panel_url} {target}".rstrip()) PY fi if [ -f "$SERVICE_PATH" ]; then echo " service: ${SERVICE_NAME}" systemctl status "$SERVICE_NAME" --no-pager || true fi } main() { parse_args "$@" case "$ACTION" in help) usage exit 0 ;; status) ensure_systemd perform_status exit 0 ;; esac check_root detect_arch detect_os ensure_systemd install_dependencies case "$ACTION" in install) perform_install ;; upgrade) perform_upgrade ;; uninstall) perform_uninstall ;; *) log_error "Unknown action: $ACTION" usage exit 1 ;; esac } main "$@"