#!/usr/bin/env bash ############################################################################### # XGenStack Agent Installer — Phase 10 # # Installs the XGenStack agent on a target VPS, enrolls it with the platform, # configures systemd services, and verifies the agent is running. # # Supported: Ubuntu 22/24, Debian 11/12, CentOS/Rocky/Alma 8/9 # # Usage: # curl -sSL https://agent.xgenstack.com | bash -s -- \ # --api-url https://xgenstack.com/api/v1 \ # --enroll-token \ # --server-id # # Or run directly: # sudo bash install-agent.sh \ # --api-url=https://api.xgenstack.com \ # --enroll-token= \ # --server-id= ############################################################################### set -euo pipefail readonly AGENT_VERSION="0.1.0" readonly AGENT_BIN="/usr/local/bin/xgs-agent" readonly CONFIG_DIR="/etc/xgenstack" readonly DATA_DIR="/var/lib/xgs" readonly LOG_DIR="/var/log/xgenstack" readonly APP_DIR="/opt/xgs/apps" readonly SERVICE_NAME="xgs-agent" readonly SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" readonly ENV_FILE="${CONFIG_DIR}/agent.env" readonly VERSION_FILE="${CONFIG_DIR}/version" # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # No Color info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; } header() { echo -e "\n${BOLD}${CYAN}==> $*${NC}"; } # --------------------------------------------------------------------------- # Cleanup on failure # --------------------------------------------------------------------------- INSTALL_STARTED=false cleanup_on_error() { if [ "${INSTALL_STARTED}" = true ]; then error "Installation failed. Partial state may remain." error "To clean up, run: bash uninstall-agent.sh --purge" error "Check logs: journalctl -u ${SERVICE_NAME} --no-pager -n 50" fi } trap cleanup_on_error ERR # --------------------------------------------------------------------------- # Pre-flight checks # --------------------------------------------------------------------------- if [ "$(id -u)" -ne 0 ]; then error "This script must be run as root (or with sudo)." exit 1 fi # --------------------------------------------------------------------------- # Parse arguments (supports both --key=value and --key value forms) # --------------------------------------------------------------------------- API_URL="" ENROLL_TOKEN="" SERVER_ID="" SKIP_START=false FORCE=false usage() { cat < Platform API URL --enroll-token= One-time enrollment token from the platform --server-id= Server ID from the platform Optional: --skip-start Install but do not start the agent --force Re-install even if agent is already running -h, --help Show this help message EOF exit 0 } while [ $# -gt 0 ]; do case "$1" in --api-url=*) API_URL="${1#*=}"; shift ;; --api-url) API_URL="${2:-}"; shift 2 ;; --enroll-token=*) ENROLL_TOKEN="${1#*=}"; shift ;; --enroll-token) ENROLL_TOKEN="${2:-}"; shift 2 ;; --token=*) ENROLL_TOKEN="${1#*=}"; shift ;; --token) ENROLL_TOKEN="${2:-}"; shift 2 ;; --server-id=*) SERVER_ID="${1#*=}"; shift ;; --server-id) SERVER_ID="${2:-}"; shift 2 ;; --skip-start) SKIP_START=true; shift ;; --force) FORCE=true; shift ;; -h|--help) usage ;; *) warn "Unknown argument: $1 (ignoring)" shift ;; esac done # Validate required arguments MISSING=false if [ -z "${API_URL}" ]; then error "Missing required argument: --api-url" MISSING=true fi if [ -z "${ENROLL_TOKEN}" ]; then error "Missing required argument: --enroll-token" MISSING=true fi if [ -z "${SERVER_ID}" ]; then error "Missing required argument: --server-id" MISSING=true fi if [ "${MISSING}" = true ]; then echo "" usage fi # Strip trailing slash from API_URL API_URL="${API_URL%/}" # --------------------------------------------------------------------------- # Check if already installed (idempotency) # --------------------------------------------------------------------------- if [ -f "${AGENT_BIN}" ] && [ -f "${ENV_FILE}" ] && [ "${FORCE}" = false ]; then if systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then warn "XGenStack agent is already installed and running." warn "Use --force to reinstall, or run uninstall-agent.sh first." exit 0 fi fi INSTALL_STARTED=true # --------------------------------------------------------------------------- # Step 1: Detect OS and version # --------------------------------------------------------------------------- header "Detecting operating system" OS="" VERSION="" VERSION_MAJOR="" PKG_MGR="" detect_os() { if [ -f /etc/os-release ]; then # shellcheck source=/dev/null . /etc/os-release OS="${ID}" VERSION="${VERSION_ID}" VERSION_MAJOR="${VERSION%%.*}" else error "Cannot detect OS: /etc/os-release not found." exit 1 fi case "${OS}" in ubuntu) case "${VERSION}" in 22.04|24.04) PKG_MGR="apt" ;; *) error "Ubuntu ${VERSION} is not supported. Use 22.04 or 24.04." exit 1 ;; esac ;; debian) case "${VERSION_MAJOR}" in 11|12) PKG_MGR="apt" ;; *) error "Debian ${VERSION} is not supported. Use 11 or 12." exit 1 ;; esac ;; centos|rocky|almalinux|rhel) if command -v dnf &>/dev/null; then PKG_MGR="dnf" elif command -v yum &>/dev/null; then PKG_MGR="yum" else error "Neither dnf nor yum found on ${OS}." exit 1 fi ;; *) error "Unsupported OS: ${OS}" error "Supported: Ubuntu 22/24, Debian 11/12, CentOS/Rocky/Alma/RHEL 8/9" exit 1 ;; esac } detect_os success "Detected: ${OS} ${VERSION} (package manager: ${PKG_MGR})" # --------------------------------------------------------------------------- # Step 2: Install prerequisites # --------------------------------------------------------------------------- header "Installing prerequisites" PREREQS="curl jq wget" install_prereqs_apt() { info "Updating package index..." apt-get update -y -qq 2>/dev/null for pkg in ${PREREQS} ca-certificates gnupg; do if dpkg -l "${pkg}" &>/dev/null; then info "${pkg} is already installed" else info "Installing ${pkg}..." apt-get install -y -qq "${pkg}" 2>/dev/null fi done } install_prereqs_rpm() { for pkg in ${PREREQS} ca-certificates; do if rpm -q "${pkg}" &>/dev/null; then info "${pkg} is already installed" else info "Installing ${pkg}..." ${PKG_MGR} install -y -q "${pkg}" 2>/dev/null fi done } case "${PKG_MGR}" in apt) install_prereqs_apt ;; dnf|yum) install_prereqs_rpm ;; esac success "Prerequisites installed" # --------------------------------------------------------------------------- # Step 3: Create directories # --------------------------------------------------------------------------- header "Creating directories" for dir in "${CONFIG_DIR}" "${DATA_DIR}" "${LOG_DIR}" "${APP_DIR}"; do if [ ! -d "${dir}" ]; then mkdir -p "${dir}" info "Created ${dir}" else info "${dir} already exists" fi done chmod 750 "${CONFIG_DIR}" chmod 750 "${DATA_DIR}" chmod 750 "${LOG_DIR}" success "Directories ready" # --------------------------------------------------------------------------- # Step 4: Download agent binary # --------------------------------------------------------------------------- header "Installing agent binary" ARCH=$(uname -m) SYSTEM=$(uname -s | tr '[:upper:]' '[:lower:]') # Map architecture names to standard labels case "${ARCH}" in x86_64) ARCH_LABEL="amd64" ;; aarch64) ARCH_LABEL="arm64" ;; armv7l) ARCH_LABEL="armv7" ;; *) ARCH_LABEL="${ARCH}" ;; esac DOWNLOAD_URL="${API_URL}/downloads/agent/${SYSTEM}-${ARCH_LABEL}" download_binary() { local tmp_bin="/tmp/xgs-agent-download-$$" trap 'rm -f "${tmp_bin}"' RETURN info "Downloading agent binary from ${DOWNLOAD_URL}..." local http_code http_code=$(curl -sS -w '%{http_code}' -o "${tmp_bin}" \ -H "X-Enroll-Token: ${ENROLL_TOKEN}" \ "${DOWNLOAD_URL}" 2>/dev/null || echo "000") if [ "${http_code}" = "200" ] && [ -s "${tmp_bin}" ]; then mv "${tmp_bin}" "${AGENT_BIN}" chmod +x "${AGENT_BIN}" success "Agent binary installed to ${AGENT_BIN}" return 0 fi # Download endpoint not available yet — try local fallback warn "Download endpoint returned HTTP ${http_code}, trying local fallback..." return 1 } fallback_binary() { # Check common development paths local candidates=( "/usr/bin/xgs-agent" "/usr/local/bin/xgs-agent" "/opt/xgs/bin/xgs-agent" ) for candidate in "${candidates[@]}"; do if [ -x "${candidate}" ] && [ "${candidate}" != "${AGENT_BIN}" ]; then info "Found existing binary at ${candidate}, copying..." cp "${candidate}" "${AGENT_BIN}" chmod +x "${AGENT_BIN}" success "Agent binary installed to ${AGENT_BIN} (from ${candidate})" return 0 fi done # If the binary is already at the target location, nothing to do if [ -x "${AGENT_BIN}" ]; then success "Agent binary already exists at ${AGENT_BIN}" return 0 fi warn "Agent binary not available for download yet." warn "For development, build and install manually:" warn " cd /home/xgenstack.com/backend && go build -o ${AGENT_BIN} ./cmd/agent" warn "The agent service will fail to start until the binary is in place." return 0 } download_binary || fallback_binary # --------------------------------------------------------------------------- # Step 5: Enroll with the platform # --------------------------------------------------------------------------- header "Enrolling agent with the platform" HOSTNAME_VAL="$(hostname)" IP_VAL="$(curl -s --connect-timeout 5 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo 'unknown')" info "Hostname: ${HOSTNAME_VAL}" info "IP: ${IP_VAL}" info "Arch: ${ARCH}" info "Enrolling with: ${API_URL}/api/v1/agents/enroll" ENROLL_RESPONSE=$(curl -sS --connect-timeout 10 --max-time 30 \ -X POST "${API_URL}/api/v1/agents/enroll" \ -H "Content-Type: application/json" \ -d "{ \"server_id\": \"${SERVER_ID}\", \"token\": \"${ENROLL_TOKEN}\", \"hostname\": \"${HOSTNAME_VAL}\", \"ip_address\": \"${IP_VAL}\", \"os\": \"${OS}\", \"os_version\": \"${VERSION}\", \"arch\": \"${ARCH}\", \"agent_version\": \"${AGENT_VERSION}\" }") || { error "Failed to connect to the platform API at ${API_URL}" error "Please verify the API URL is correct and the server is reachable." exit 1 } # Extract fields from enrollment response NODE_ID=$(echo "${ENROLL_RESPONSE}" | jq -r '.data.node_id // empty') AGENT_KEY=$(echo "${ENROLL_RESPONSE}" | jq -r '.data.agent_key // empty') if [ -z "${NODE_ID}" ] || [ -z "${AGENT_KEY}" ]; then error "Enrollment failed. API response:" echo "${ENROLL_RESPONSE}" | jq . 2>/dev/null || echo "${ENROLL_RESPONSE}" exit 1 fi success "Enrolled successfully. Node ID: ${NODE_ID}" # Save certificates if provided CERT_PEM=$(echo "${ENROLL_RESPONSE}" | jq -r '.data.cert_pem // empty') KEY_PEM=$(echo "${ENROLL_RESPONSE}" | jq -r '.data.key_pem // empty') if [ -n "${CERT_PEM}" ] && [ -n "${KEY_PEM}" ]; then echo "${CERT_PEM}" > "${DATA_DIR}/node.crt" echo "${KEY_PEM}" > "${DATA_DIR}/node.key" chmod 600 "${DATA_DIR}/node.key" chmod 644 "${DATA_DIR}/node.crt" success "Node certificates saved" fi # --------------------------------------------------------------------------- # Step 6: Write configuration # --------------------------------------------------------------------------- header "Writing configuration" # Environment file for systemd cat > "${ENV_FILE}" < "${VERSION_FILE}" chmod 644 "${VERSION_FILE}" success "Version file written (${AGENT_VERSION})" # --------------------------------------------------------------------------- # Step 7: Install systemd service # --------------------------------------------------------------------------- header "Installing systemd service" # Stop existing service if running (idempotent reinstall) if systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then info "Stopping existing ${SERVICE_NAME} service..." systemctl stop "${SERVICE_NAME}" 2>/dev/null || true fi cat > "${SERVICE_FILE}" <<'EOF' [Unit] Description=XGenStack Agent - Autonomous VPS Runtime Agent Documentation=https://xgenstack.com/docs/agent After=network-online.target Wants=network-online.target [Service] Type=simple EnvironmentFile=/etc/xgenstack/agent.env ExecStart=/usr/local/bin/xgs-agent Restart=always RestartSec=5 StartLimitInterval=60 StartLimitBurst=5 LimitNOFILE=65536 LimitNPROC=4096 # Security hardening NoNewPrivileges=false ProtectSystem=false ProtectHome=read-only ReadWritePaths=/etc/xgenstack /var/log/xgenstack /var/lib/xgs /tmp /opt/xgs # Logging StandardOutput=journal StandardError=journal SyslogIdentifier=xgs-agent [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable "${SERVICE_NAME}" 2>/dev/null success "Systemd service installed and enabled" # --------------------------------------------------------------------------- # Step 8: Install auto-update timer (optional) # --------------------------------------------------------------------------- header "Installing auto-update timer" UPDATE_SCRIPT="/usr/local/bin/xgs-update" TIMER_SERVICE_FILE="/etc/systemd/system/xgs-update.service" TIMER_FILE="/etc/systemd/system/xgs-update.timer" # Install the update script — try local copy first, then download from platform SCRIPT_DIR="" if [ -n "${BASH_SOURCE[0]:-}" ] && [ "${BASH_SOURCE[0]:-}" != "bash" ]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" fi if [ -n "${SCRIPT_DIR}" ] && [ -f "${SCRIPT_DIR}/agent-update.sh" ]; then cp "${SCRIPT_DIR}/agent-update.sh" "${UPDATE_SCRIPT}" chmod +x "${UPDATE_SCRIPT}" info "Update script installed to ${UPDATE_SCRIPT} (local copy)" else # Download from platform when piped via curl | bash UPDATE_URL="${API_URL%/api/v1}/scripts/agent-update.sh" if curl -sSfL "${UPDATE_URL}" -o "${UPDATE_SCRIPT}" 2>/dev/null; then chmod +x "${UPDATE_SCRIPT}" info "Update script installed to ${UPDATE_SCRIPT} (downloaded)" else info "Update script not available — auto-update timer skipped" fi fi if [ -x "${UPDATE_SCRIPT}" ]; then cat > "${TIMER_SERVICE_FILE}" < "${TIMER_FILE}" </dev/null systemctl start xgs-update.timer 2>/dev/null success "Auto-update timer installed (daily at 03:00 +/- 30m jitter)" else warn "Skipping auto-update timer (update script not available)" fi # --------------------------------------------------------------------------- # Step 9: Start the agent # --------------------------------------------------------------------------- if [ "${SKIP_START}" = false ]; then header "Starting agent" if [ -x "${AGENT_BIN}" ]; then systemctl start "${SERVICE_NAME}" # Wait and verify sleep 3 if systemctl is-active --quiet "${SERVICE_NAME}"; then success "XGenStack agent is running!" else warn "Agent service failed to start." warn "Check logs: journalctl -u ${SERVICE_NAME} --no-pager -n 50" fi else warn "Agent binary not found at ${AGENT_BIN}. Service not started." warn "Install the binary and then run: systemctl start ${SERVICE_NAME}" fi else info "Skipping agent start (--skip-start specified)" fi # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- echo "" echo -e "${BOLD}${GREEN}============================================${NC}" echo -e "${BOLD}${GREEN} XGenStack Agent Installation Complete${NC}" echo -e "${BOLD}${GREEN}============================================${NC}" echo "" echo -e " ${CYAN}Node ID:${NC} ${NODE_ID}" echo -e " ${CYAN}Server ID:${NC} ${SERVER_ID}" echo -e " ${CYAN}API URL:${NC} ${API_URL}" echo -e " ${CYAN}OS:${NC} ${OS} ${VERSION}" echo -e " ${CYAN}Arch:${NC} ${ARCH} (${ARCH_LABEL})" echo -e " ${CYAN}Agent Binary:${NC} ${AGENT_BIN}" echo -e " ${CYAN}Config:${NC} ${ENV_FILE}" echo -e " ${CYAN}Logs:${NC} journalctl -u ${SERVICE_NAME}" echo -e " ${CYAN}Version:${NC} ${AGENT_VERSION}" echo "" echo -e " ${BOLD}Useful commands:${NC}" echo -e " systemctl status ${SERVICE_NAME} # Check status" echo -e " systemctl restart ${SERVICE_NAME} # Restart agent" echo -e " journalctl -u ${SERVICE_NAME} -f # Follow logs" echo "" exit 0