1060 lines
30 KiB
Bash
Executable File
1060 lines
30 KiB
Bash
Executable File
#!/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}" <<EOF_UNIT
|
|
[Unit]
|
|
Description=Xboard Node Backend
|
|
Documentation=https://github.com/cedar2025/xboard-node
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
WorkingDirectory=${INSTALL_ROOT}
|
|
EnvironmentFile=-${CREDENTIALS_FILE}
|
|
ExecStart=${BINARY_PATH} -c ${CONFIG_FILE}
|
|
Restart=always
|
|
RestartSec=5
|
|
LimitNOFILE=1048576
|
|
NoNewPrivileges=true
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF_UNIT
|
|
}
|
|
|
|
backup_existing_state() {
|
|
BACKUP_PATH="${BACKUP_DIR}/$(date +%Y%m%d-%H%M%S)"
|
|
mkdir -p "$BACKUP_PATH"
|
|
if [ -x "$BINARY_PATH" ]; then
|
|
cp "$BINARY_PATH" "$BACKUP_PATH/xboard-node"
|
|
fi
|
|
if [ -x "$CLI_PATH" ]; then
|
|
cp "$CLI_PATH" "$BACKUP_PATH/xbctl"
|
|
fi
|
|
if [ -f "$CONFIG_FILE" ]; then
|
|
cp "$CONFIG_FILE" "$BACKUP_PATH/config.yml"
|
|
fi
|
|
if [ -f "$CREDENTIALS_FILE" ]; then
|
|
cp "$CREDENTIALS_FILE" "$BACKUP_PATH/credentials.env"
|
|
fi
|
|
if [ -f "$INSTALL_META" ]; then
|
|
cp "$INSTALL_META" "$BACKUP_PATH/install-meta.json"
|
|
fi
|
|
if [ -f "$SERVICE_PATH" ]; then
|
|
cp "$SERVICE_PATH" "$BACKUP_PATH/${SERVICE_NAME}"
|
|
SERVICE_EXISTED=1
|
|
else
|
|
SERVICE_EXISTED=0
|
|
fi
|
|
}
|
|
|
|
stop_existing_service() {
|
|
if [ -f "$SERVICE_PATH" ] || systemctl is-active "$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 "$@"
|