mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-10-25 17:13:47 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			2709 lines
		
	
	
		
			91 KiB
		
	
	
	
		
			Bash
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			2709 lines
		
	
	
		
			91 KiB
		
	
	
	
		
			Bash
		
	
	
		
			Executable File
		
	
	
	
	
| #!/bin/bash
 | ||
| # PatchMon Self-Hosting Installation Script
 | ||
| # Automated deployment script for self-hosted PatchMon instances
 | ||
| # Usage: ./self-hosting-install.sh
 | ||
| # Interactive self-hosting installation script
 | ||
| 
 | ||
| set -e
 | ||
| 
 | ||
| # Create main installation log file
 | ||
| INSTALL_LOG="/var/log/patchmon-install.log"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] === PatchMon Self-Hosting Installation Started ===" >> "$INSTALL_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script PID: $$" >> "$INSTALL_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Running as user: $(whoami)" >> "$INSTALL_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Current directory: $(pwd)" >> "$INSTALL_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script arguments: $@" >> "$INSTALL_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script path: $0" >> "$INSTALL_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] ======================================" >> "$INSTALL_LOG"
 | ||
| 
 | ||
| # Create immediate debug log for troubleshooting
 | ||
| DEBUG_LOG="/tmp/patchmon_debug_$(date +%Y%m%d_%H%M%S).log"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] === PatchMon Script Started ===" >> "$DEBUG_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script PID: $$" >> "$DEBUG_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Running as user: $(whoami)" >> "$DEBUG_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Current directory: $(pwd)" >> "$DEBUG_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script arguments: $@" >> "$DEBUG_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script path: $0" >> "$DEBUG_LOG"
 | ||
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] ======================================" >> "$DEBUG_LOG"
 | ||
| 
 | ||
| # Colors for output
 | ||
| RED='\033[0;31m'
 | ||
| GREEN='\033[0;32m'
 | ||
| YELLOW='\033[1;33m'
 | ||
| BLUE='\033[0;34m'
 | ||
| NC='\033[0m' # No Color
 | ||
| 
 | ||
| # Global variables
 | ||
| SCRIPT_VERSION="self-hosting-install.sh v1.3.0-selfhost-2025-10-19-1"
 | ||
| DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
 | ||
| FQDN=""
 | ||
| CUSTOM_FQDN=""
 | ||
| EMAIL=""
 | ||
| 
 | ||
| # Logging function
 | ||
| function log_message() {
 | ||
|     local message="$1"
 | ||
|     local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
 | ||
|     local log_file="/var/log/patchmon-install.log"
 | ||
|     
 | ||
|     echo "[${timestamp}] ${message}" >> "$log_file"
 | ||
|     echo "[${timestamp}] ${message}"
 | ||
| }
 | ||
| DEPLOYMENT_BRANCH="main"
 | ||
| GITHUB_REPO=""
 | ||
| DB_SAFE_NAME=""
 | ||
| DB_PASS=""
 | ||
| JWT_SECRET=""
 | ||
| BACKEND_PORT=""
 | ||
| APP_DIR=""
 | ||
| USE_LETSENCRYPT="false"  # Will be set based on user input
 | ||
| SERVER_PROTOCOL_SEL="https"
 | ||
| SERVER_PORT_SEL=""  # Will be set to BACKEND_PORT in init_instance_vars
 | ||
| SETUP_NGINX="true"
 | ||
| UPDATE_MODE="false"
 | ||
| SELECTED_INSTANCE=""
 | ||
| SELECTED_SERVICE_NAME=""
 | ||
| 
 | ||
| # Functions
 | ||
| print_status() {
 | ||
|     echo -e "${GREEN}✅ $1${NC}"
 | ||
| }
 | ||
| 
 | ||
| print_info() {
 | ||
|     echo -e "${BLUE}ℹ️  $1${NC}"
 | ||
| }
 | ||
| 
 | ||
| print_error() {
 | ||
|     echo -e "${RED}❌ $1${NC}"
 | ||
| }
 | ||
| 
 | ||
| print_warning() {
 | ||
|     echo -e "${YELLOW}⚠️  $1${NC}"
 | ||
| }
 | ||
| 
 | ||
| print_question() {
 | ||
|     echo -e "${BLUE}❓ $1${NC}"
 | ||
| }
 | ||
| 
 | ||
| print_success() {
 | ||
|     echo -e "${GREEN}🎉 $1${NC}"
 | ||
| }
 | ||
| 
 | ||
| # Interactive input functions
 | ||
| read_input() {
 | ||
|     local prompt="$1"
 | ||
|     local var_name="$2"
 | ||
|     local default_value="$3"
 | ||
|     
 | ||
|     if [ -n "$default_value" ]; then
 | ||
|         echo -n -e "${BLUE}$prompt${NC} [${YELLOW}$default_value${NC}]: "
 | ||
|     else
 | ||
|         echo -n -e "${BLUE}$prompt${NC}: "
 | ||
|     fi
 | ||
|     
 | ||
|     read -r input
 | ||
|     if [ -z "$input" ] && [ -n "$default_value" ]; then
 | ||
|         eval "$var_name='$default_value'"
 | ||
|     else
 | ||
|         eval "$var_name='$input'"
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| read_yes_no() {
 | ||
|     local prompt="$1"
 | ||
|     local var_name="$2"
 | ||
|     local default_value="$3"
 | ||
|     
 | ||
|     while true; do
 | ||
|         if [ -n "$default_value" ]; then
 | ||
|             echo -n -e "${BLUE}$prompt${NC} [${YELLOW}$default_value${NC}]: "
 | ||
|         else
 | ||
|             echo -n -e "${BLUE}$prompt${NC} (y/n): "
 | ||
|         fi
 | ||
|         read -r input
 | ||
|         
 | ||
|         if [ -z "$input" ] && [ -n "$default_value" ]; then
 | ||
|             input="$default_value"
 | ||
|         fi
 | ||
|         
 | ||
|         case $input in
 | ||
|             [Yy]|[Yy][Ee][Ss])
 | ||
|                 eval "$var_name='y'"
 | ||
|                 break
 | ||
|                 ;;
 | ||
|             [Nn]|[Nn][Oo])
 | ||
|                 eval "$var_name='n'"
 | ||
|                 break
 | ||
|                 ;;
 | ||
|             *)
 | ||
|                 print_error "Please answer yes (y) or no (n)"
 | ||
|                 ;;
 | ||
|         esac
 | ||
|     done
 | ||
| }
 | ||
| 
 | ||
| print_banner() {
 | ||
|     echo -e "${BLUE}====================================================${NC}"
 | ||
|     echo -e "${BLUE}        PatchMon Self-Hosting Installation${NC}"
 | ||
|     echo -e "${BLUE}Running: $SCRIPT_VERSION${NC}"
 | ||
|     echo -e "${BLUE}====================================================${NC}"
 | ||
| }
 | ||
| 
 | ||
| # Interactive setup functions
 | ||
| check_timezone() {
 | ||
|     print_info "Checking current timezone..."
 | ||
|     current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
 | ||
|     
 | ||
|     if [ "$current_tz" != "Unknown" ]; then
 | ||
|         current_datetime=$(date)
 | ||
|         print_info "Current timezone: $current_tz"
 | ||
|         print_info "Current date/time: $current_datetime"
 | ||
|         read_yes_no "Is this timezone and date/time correct?" TIMEZONE_CORRECT "y"
 | ||
|         
 | ||
|         if [ "$TIMEZONE_CORRECT" = "n" ]; then
 | ||
|             print_info "Available timezones:"
 | ||
|             timedatectl list-timezones | head -20
 | ||
|             print_warning "Showing first 20 timezones. Use 'timedatectl list-timezones' to see all."
 | ||
|             read_input "Enter your timezone (e.g., America/New_York, Europe/London)" NEW_TIMEZONE
 | ||
|             
 | ||
|             if [ -n "$NEW_TIMEZONE" ]; then
 | ||
|                 print_info "Setting timezone to $NEW_TIMEZONE..."
 | ||
|                 timedatectl set-timezone "$NEW_TIMEZONE"
 | ||
|                 print_status "Timezone updated to $NEW_TIMEZONE"
 | ||
|                 
 | ||
|                 # Show updated date/time
 | ||
|                 updated_datetime=$(date)
 | ||
|                 print_info "Updated date/time: $updated_datetime"
 | ||
|             fi
 | ||
|         fi
 | ||
|     else
 | ||
|         print_warning "Could not detect timezone. Please set it manually if needed."
 | ||
|         current_datetime=$(date)
 | ||
|         print_info "Current date/time: $current_datetime"
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| check_root() {
 | ||
|     if [[ $EUID -ne 0 ]]; then
 | ||
|         print_error "This script must be run as root"
 | ||
|         print_info "Please run: sudo $0"
 | ||
|         exit 1
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Function to run commands as a specific user with better error handling
 | ||
| run_as_user() {
 | ||
|     local user="$1"
 | ||
|     local command="$2"
 | ||
|     
 | ||
|     if ! command -v sudo >/dev/null 2>&1; then
 | ||
|         print_error "sudo is required but not installed. Please install sudo first."
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     if ! id "$user" &>/dev/null; then
 | ||
|         print_error "User '$user' does not exist"
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     sudo -u "$user" bash -c "$command"
 | ||
| }
 | ||
| 
 | ||
| # Detect and use the best available package manager
 | ||
| detect_package_manager() {
 | ||
|     # Prefer apt over apt-get for modern Debian/Ubuntu systems
 | ||
|     if command -v apt >/dev/null 2>&1; then
 | ||
|         PKG_MANAGER="apt"
 | ||
|         PKG_UPDATE="apt update"
 | ||
|         PKG_UPGRADE="apt upgrade -y"
 | ||
|         PKG_INSTALL="apt install -y"
 | ||
|     elif command -v apt-get >/dev/null 2>&1; then
 | ||
|         PKG_MANAGER="apt-get"
 | ||
|         PKG_UPDATE="apt-get update"
 | ||
|         PKG_UPGRADE="apt-get upgrade -y"
 | ||
|         PKG_INSTALL="apt-get install -y"
 | ||
|     else
 | ||
|         print_error "No supported package manager found (apt or apt-get required)"
 | ||
|         print_info "This script requires a Debian/Ubuntu-based system"
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     print_info "Using package manager: $PKG_MANAGER"
 | ||
| }
 | ||
| 
 | ||
| check_prerequisites() {
 | ||
|     print_info "Running and checking prerequisites..."
 | ||
|     
 | ||
|     # Check if running as root
 | ||
|     check_root
 | ||
|     
 | ||
|     # Detect package manager
 | ||
|     detect_package_manager
 | ||
|     
 | ||
|     print_info "Installing updates..."
 | ||
|     $PKG_UPDATE -y
 | ||
|     $PKG_UPGRADE
 | ||
|     
 | ||
|     print_info "Installing prerequisite applications..."
 | ||
|     # Install sudo if not present (needed for user switching)
 | ||
|     if ! command -v sudo >/dev/null 2>&1; then
 | ||
|         print_info "Installing sudo (required for user switching)..."
 | ||
|         $PKG_INSTALL sudo
 | ||
|     fi
 | ||
|     
 | ||
|     $PKG_INSTALL wget curl jq git netcat-openbsd
 | ||
|     
 | ||
|     print_status "Prerequisites installed successfully"
 | ||
| }
 | ||
| 
 | ||
| select_branch() {
 | ||
|     print_info "Fetching available releases from GitHub repository..."
 | ||
|     
 | ||
|     # Create temporary directory for git operations
 | ||
|     TEMP_DIR="/tmp/patchmon_branches_$$"
 | ||
|     mkdir -p "$TEMP_DIR"
 | ||
|     cd "$TEMP_DIR"
 | ||
|     
 | ||
|     # Try to clone the repository normally
 | ||
|     if git clone "$DEFAULT_GITHUB_REPO" . 2>/dev/null; then
 | ||
|         # Get list of tags sorted by version (semantic versioning)
 | ||
|         # Using git tag with version sorting
 | ||
|         tags=$(git tag -l --sort=-v:refname 2>/dev/null | head -3)
 | ||
|         
 | ||
|         if [ -n "$tags" ]; then
 | ||
|             print_info "Available releases and branches:"
 | ||
|             echo ""
 | ||
|             
 | ||
|             # Display last 3 release tags
 | ||
|             option_count=1
 | ||
|             declare -A options_map
 | ||
|             
 | ||
|             while IFS= read -r tag; do
 | ||
|                 if [ -n "$tag" ]; then
 | ||
|                     # Get tag date and commit info
 | ||
|                     tag_date=$(git log -1 --format="%ci" "$tag" 2>/dev/null || echo "Unknown")
 | ||
|                     
 | ||
|                     # Format the date
 | ||
|                     if [ "$tag_date" != "Unknown" ]; then
 | ||
|                         formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
 | ||
|                     else
 | ||
|                         formatted_date="Unknown"
 | ||
|                     fi
 | ||
|                     
 | ||
|                     # Mark the first one as latest
 | ||
|                     if [ $option_count -eq 1 ]; then
 | ||
|                         printf "%2d. %-20s (Latest Release - %s)\n" "$option_count" "$tag" "$formatted_date"
 | ||
|                     else
 | ||
|                         printf "%2d. %-20s (Release - %s)\n" "$option_count" "$tag" "$formatted_date"
 | ||
|                     fi
 | ||
|                     
 | ||
|                     # Store the tag for later selection
 | ||
|                     options_map[$option_count]="$tag"
 | ||
|                     option_count=$((option_count + 1))
 | ||
|                 fi
 | ||
|             done <<< "$tags"
 | ||
|             
 | ||
|             # Add main branch as an option
 | ||
|             main_commit=$(git log -1 --format="%ci" "origin/main" 2>/dev/null || echo "Unknown")
 | ||
|             if [ "$main_commit" != "Unknown" ]; then
 | ||
|                 formatted_main_date=$(date -d "$main_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$main_commit")
 | ||
|             else
 | ||
|                 formatted_main_date="Unknown"
 | ||
|             fi
 | ||
|             printf "%2d. %-20s (Development Branch - %s)\n" "$option_count" "main" "$formatted_main_date"
 | ||
|             options_map[$option_count]="main"
 | ||
|             
 | ||
|             echo ""
 | ||
|             
 | ||
|             # Default to option 1 (latest release tag)
 | ||
|             default_option=1
 | ||
|             
 | ||
|             while true; do
 | ||
|                 read_input "Select version/branch number" SELECTION_NUMBER "$default_option"
 | ||
|                 
 | ||
|                 if [[ "$SELECTION_NUMBER" =~ ^[0-9]+$ ]]; then
 | ||
|                     selected_option="${options_map[$SELECTION_NUMBER]}"
 | ||
|                     if [ -n "$selected_option" ]; then
 | ||
|                         DEPLOYMENT_BRANCH="$selected_option"
 | ||
|                         
 | ||
|                         # Show confirmation
 | ||
|                         if [ "$selected_option" = "main" ]; then
 | ||
|                             print_status "Selected branch: main (latest development code)"
 | ||
|                             print_info "Last commit: $formatted_main_date"
 | ||
|                         else
 | ||
|                             print_status "Selected release: $selected_option"
 | ||
|                             tag_date=$(git log -1 --format="%ci" "$selected_option" 2>/dev/null || echo "Unknown")
 | ||
|                             if [ "$tag_date" != "Unknown" ]; then
 | ||
|                                 formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
 | ||
|                                 print_info "Release date: $formatted_date"
 | ||
|                             fi
 | ||
|                         fi
 | ||
|                         break
 | ||
|                     else
 | ||
|                         print_error "Invalid selection number. Please try again."
 | ||
|                     fi
 | ||
|                 else
 | ||
|                     print_error "Please enter a valid number."
 | ||
|                 fi
 | ||
|             done
 | ||
|         else
 | ||
|             print_warning "No release tags found, using default: main"
 | ||
|             DEPLOYMENT_BRANCH="main"
 | ||
|         fi
 | ||
|     else
 | ||
|         print_warning "Could not connect to GitHub repository"
 | ||
|         print_warning "This might be due to:"
 | ||
|         print_warning "  • Network connectivity issues"
 | ||
|         print_warning "  • Firewall blocking git access"
 | ||
|         print_warning "  • GitHub repository access restrictions"
 | ||
|         print_warning "Using default branch: main"
 | ||
|         DEPLOYMENT_BRANCH="main"
 | ||
|     fi
 | ||
|     
 | ||
|     # Clean up
 | ||
|     cd /
 | ||
|     rm -rf "$TEMP_DIR"
 | ||
| }
 | ||
| 
 | ||
| interactive_setup() {
 | ||
|     print_banner
 | ||
|     
 | ||
|     print_info "Welcome to PatchMon Self-Hosting Installation!"
 | ||
|     print_info "This script will guide you through the installation process."
 | ||
|     echo ""
 | ||
|     
 | ||
|     # Check prerequisites
 | ||
|     check_prerequisites
 | ||
|     echo ""
 | ||
|     
 | ||
|     # Check timezone
 | ||
|     check_timezone
 | ||
|     echo ""
 | ||
|     
 | ||
|     # Get basic information
 | ||
|     print_question "Let's gather some information about your installation:"
 | ||
|     echo ""
 | ||
|     
 | ||
|     read_input "Enter your domain name or IP address (e.g., patchmon.yourdomain.com or 192.168.1.100)" FQDN "patchmon.internal"
 | ||
|     
 | ||
|     echo ""
 | ||
|     print_info "🔒 SSL/HTTPS Configuration:"
 | ||
|     print_info "   • Public hosting (accessible from internet): Enable SSL for security"
 | ||
|     print_info "   • Local hosting (internal network only): SSL not required"
 | ||
|     echo ""
 | ||
|     read_yes_no "Are you hosting this publicly on the internet and want SSL/HTTPS with Let's Encrypt?" SSL_ENABLED "n"
 | ||
|     
 | ||
|     if [ "$SSL_ENABLED" = "y" ]; then
 | ||
|         read_input "Enter your email address for Let's Encrypt SSL certificate" EMAIL
 | ||
|     else
 | ||
|         EMAIL=""
 | ||
|     fi
 | ||
|     
 | ||
|     
 | ||
|     # Select branch
 | ||
|     echo ""
 | ||
|     select_branch
 | ||
|     echo ""
 | ||
|     
 | ||
|     # Confirm settings
 | ||
|     print_info "Please confirm your settings:"
 | ||
|     echo "  Domain/IP: $FQDN"
 | ||
|     echo "  SSL Enabled: $SSL_ENABLED"
 | ||
|     if [ "$SSL_ENABLED" = "y" ]; then
 | ||
|         echo "  Email: $EMAIL"
 | ||
|     fi
 | ||
|     echo "  Branch: $DEPLOYMENT_BRANCH"
 | ||
|     echo ""
 | ||
|     
 | ||
|     read_yes_no "Proceed with installation?" CONFIRM_INSTALL "y"
 | ||
|     
 | ||
|     if [ "$CONFIRM_INSTALL" = "n" ]; then
 | ||
|         print_info "Installation cancelled by user."
 | ||
|         exit 0
 | ||
|     fi
 | ||
|     
 | ||
|     print_success "Starting installation process..."
 | ||
|     echo ""
 | ||
| }
 | ||
| 
 | ||
| # Generate random password
 | ||
| generate_password() {
 | ||
|     openssl rand -base64 32 | tr -d "=+/" | cut -c1-25
 | ||
| }
 | ||
| 
 | ||
| # Generate JWT secret
 | ||
| generate_jwt_secret() {
 | ||
|     openssl rand -base64 64 | tr -d "=+/" | cut -c1-50
 | ||
| }
 | ||
| 
 | ||
| # Generate Redis password
 | ||
| generate_redis_password() {
 | ||
|     openssl rand -base64 32 | tr -d "=+/" | cut -c1-25
 | ||
| }
 | ||
| 
 | ||
| # Find next available Redis database
 | ||
| find_next_redis_db() {
 | ||
|     print_info "Finding next available Redis database..."
 | ||
|     
 | ||
|     # Start from database 0 and keep checking until we find an empty one
 | ||
|     local db_num=0
 | ||
|     local max_attempts=16  # Redis default is 16 databases
 | ||
|     
 | ||
|     # Check if Redis requires authentication
 | ||
|     local test_output
 | ||
|     test_output=$(redis-cli -h localhost -p 6379 ping 2>&1)
 | ||
|     
 | ||
|     # Determine auth requirements
 | ||
|     local auth_required=false
 | ||
|     local redis_auth_args=""
 | ||
|     
 | ||
|     if echo "$test_output" | grep -q "NOAUTH\|WRONGPASS"; then
 | ||
|         auth_required=true
 | ||
|         
 | ||
|         # Try to load admin credentials if ACL file exists
 | ||
|         if [ -f /etc/redis/users.acl ] && grep -q "^user admin" /etc/redis/users.acl; then
 | ||
|             # Redis is configured with ACL - try to extract admin password
 | ||
|             print_info "Redis requires authentication, attempting with admin credentials..."
 | ||
|             
 | ||
|             # For multi-instance setups, we can't know the admin password yet
 | ||
|             # So we'll just use database 0 as default
 | ||
|             print_info "Using database 0 (Redis ACL already configured)"
 | ||
|             echo "0"
 | ||
|             return 0
 | ||
|         fi
 | ||
|     fi
 | ||
|     
 | ||
|     while [ $db_num -lt $max_attempts ]; do
 | ||
|         # Test if database is empty
 | ||
|         local key_count
 | ||
|         local redis_output
 | ||
|         
 | ||
|         # Try to get database size (with or without auth)
 | ||
|         redis_output=$(redis-cli -h localhost -p 6379 -n "$db_num" DBSIZE 2>&1)
 | ||
|         
 | ||
|         # Check for authentication errors
 | ||
|         if echo "$redis_output" | grep -q "NOAUTH\|WRONGPASS"; then
 | ||
|             # If we hit auth errors and haven't configured yet, use database 0
 | ||
|             print_info "Redis requires authentication, defaulting to database 0"
 | ||
|             echo "0"
 | ||
|             return 0
 | ||
|         fi
 | ||
|         
 | ||
|         # Check for other errors
 | ||
|         if echo "$redis_output" | grep -q "ERR"; then
 | ||
|             if echo "$redis_output" | grep -q "invalid DB index"; then
 | ||
|                 print_warning "Reached maximum database limit at database $db_num"
 | ||
|                 break
 | ||
|             else
 | ||
|                 print_error "Error checking database $db_num: $redis_output"
 | ||
|                 return 1
 | ||
|             fi
 | ||
|         fi
 | ||
|         
 | ||
|         key_count="$redis_output"
 | ||
|         
 | ||
|         # If database is empty, use it
 | ||
|         if [ "$key_count" = "0" ] || [ "$key_count" = "(integer) 0" ]; then
 | ||
|             print_status "Found available Redis database: $db_num (empty)"
 | ||
|             echo "$db_num"
 | ||
|             return 0
 | ||
|         fi
 | ||
|         
 | ||
|         print_info "Database $db_num has $key_count keys, checking next..."
 | ||
|         db_num=$((db_num + 1))
 | ||
|     done
 | ||
|     
 | ||
|     print_warning "No available Redis databases found (checked 0-$max_attempts)"
 | ||
|     print_info "Using database 0 (may have existing data)"
 | ||
|     echo "0"
 | ||
|     return 0
 | ||
| }
 | ||
| 
 | ||
| # Initialize instance variables
 | ||
| init_instance_vars() {
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] init_instance_vars function started" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Creating safe database name from FQDN: $FQDN" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Create safe database name from FQDN
 | ||
|     DB_SAFE_NAME=$(echo "$FQDN" | sed 's/[^a-zA-Z0-9]/_/g' | sed 's/^_*//' | sed 's/_*$//')
 | ||
|     
 | ||
|     # Check if FQDN starts with a digit (likely an IP address)
 | ||
|     if [[ "$FQDN" =~ ^[0-9] ]]; then
 | ||
|         # Generate 2 random letters for IP address prefixing
 | ||
|         RANDOM_PREFIX=$(tr -dc 'a-z' < /dev/urandom | head -c 2)
 | ||
|         DB_SAFE_NAME="${RANDOM_PREFIX}${DB_SAFE_NAME}"
 | ||
|         echo "[$(date '+%Y-%m-%d %H:%M:%S')] IP address detected, prefixed with: $RANDOM_PREFIX" >> "$DEBUG_LOG"
 | ||
|         print_info "IP address detected ($FQDN), using prefix '$RANDOM_PREFIX' for database/service names"
 | ||
|     fi
 | ||
|     
 | ||
|     DB_NAME="${DB_SAFE_NAME}"
 | ||
|     DB_USER="${DB_SAFE_NAME}"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] DB_SAFE_NAME: $DB_SAFE_NAME" >> "$DEBUG_LOG"
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] DB_NAME: $DB_NAME" >> "$DEBUG_LOG"
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] DB_USER: $DB_USER" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Generating password..." >> "$DEBUG_LOG"
 | ||
|     DB_PASS=$(generate_password)
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Generating JWT secret..." >> "$DEBUG_LOG"
 | ||
|     JWT_SECRET=$(generate_jwt_secret)
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Generating Redis password..." >> "$DEBUG_LOG"
 | ||
|     REDIS_PASSWORD=$(generate_redis_password)
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Finding next available Redis database..." >> "$DEBUG_LOG"
 | ||
|     REDIS_DB=$(find_next_redis_db)
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Generating random backend port..." >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Generate random backend port (3001-3999)
 | ||
|     BACKEND_PORT=$((3001 + RANDOM % 999))
 | ||
|     
 | ||
|     # Set SERVER_PORT_SEL to 443 for HTTPS (external port) or backend port for HTTP
 | ||
|     if [ "$SERVER_PROTOCOL_SEL" = "https" ]; then
 | ||
|         SERVER_PORT_SEL=443
 | ||
|     else
 | ||
|         SERVER_PORT_SEL=$BACKEND_PORT
 | ||
|     fi
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] BACKEND_PORT: $BACKEND_PORT" >> "$DEBUG_LOG"
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] SERVER_PORT_SEL: $SERVER_PORT_SEL" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Setting application directory and service name..." >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Set application directory and service name
 | ||
|     APP_DIR="/opt/${FQDN}"
 | ||
|     SERVICE_NAME="${FQDN}"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] APP_DIR: $APP_DIR" >> "$DEBUG_LOG"
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] SERVICE_NAME: $SERVICE_NAME" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Creating dedicated user name..." >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Create dedicated user name (safe for system users)
 | ||
|     INSTANCE_USER=$(echo "$DB_SAFE_NAME" | cut -c1-32)
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] INSTANCE_USER: $INSTANCE_USER" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     print_info "Initialized variables for $FQDN"
 | ||
|     print_info "Database: $DB_NAME"
 | ||
|     print_info "Backend Port: $BACKEND_PORT"
 | ||
|     print_info "App Directory: $APP_DIR"
 | ||
|     print_info "Instance User: $INSTANCE_USER"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] init_instance_vars function completed successfully" >> "$DEBUG_LOG"
 | ||
| }
 | ||
| 
 | ||
| # Update system packages
 | ||
| update_system() {
 | ||
|     print_info "Updating system packages..."
 | ||
|     $PKG_UPDATE -y
 | ||
|     $PKG_UPGRADE
 | ||
| }
 | ||
| 
 | ||
| # Install essential tools
 | ||
| install_essential_tools() {
 | ||
|     print_info "Installing essential tools..."
 | ||
|     $PKG_INSTALL curl netcat-openbsd git jq
 | ||
| }
 | ||
| 
 | ||
| # Install Node.js (if not already installed)
 | ||
| install_nodejs() {
 | ||
|     # Force PATH refresh to ensure we get the latest Node.js
 | ||
|     export PATH="/usr/bin:/usr/local/bin:$PATH"
 | ||
|     hash -r  # Clear bash command cache
 | ||
|     
 | ||
|     NODE_VERSION=""
 | ||
|     if command -v node >/dev/null 2>&1; then
 | ||
|         NODE_VERSION=$(node --version 2>/dev/null | sed 's/v//')
 | ||
|         print_info "Node.js already installed: v$NODE_VERSION"
 | ||
|         
 | ||
|         # Check if version is 18 or higher
 | ||
|         if [ "$(echo "$NODE_VERSION" | cut -d. -f1)" -ge 18 ]; then
 | ||
|             print_status "Node.js version is sufficient (v$NODE_VERSION)"
 | ||
|             # Clean npm cache to avoid issues
 | ||
|             npm cache clean --force 2>/dev/null || true
 | ||
|             return 0
 | ||
|         else
 | ||
|             print_warning "Node.js version $NODE_VERSION is too old, updating..."
 | ||
|         fi
 | ||
|     fi
 | ||
|     
 | ||
|     print_info "Installing Node.js 20.x..."
 | ||
|     curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
 | ||
|     $PKG_INSTALL nodejs
 | ||
|     
 | ||
|     # Verify installation
 | ||
|     NODE_VERSION=$(node --version | sed 's/v//')
 | ||
|     NPM_VERSION=$(npm --version)
 | ||
|     print_status "Node.js installed: v$NODE_VERSION"
 | ||
|     print_status "npm installed: v$NPM_VERSION"
 | ||
|     
 | ||
|     # Clean npm cache to avoid issues
 | ||
|     npm cache clean --force 2>/dev/null || true
 | ||
| }
 | ||
| 
 | ||
| # Install PostgreSQL
 | ||
| install_postgresql() {
 | ||
|     print_info "Installing PostgreSQL..."
 | ||
|     
 | ||
|     if systemctl is-active --quiet postgresql; then
 | ||
|         print_status "PostgreSQL already running"
 | ||
|     else
 | ||
|         $PKG_INSTALL postgresql postgresql-contrib
 | ||
|         systemctl start postgresql
 | ||
|         systemctl enable postgresql
 | ||
|         print_status "PostgreSQL installed and started"
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Install Redis
 | ||
| install_redis() {
 | ||
|     print_info "Installing Redis..."
 | ||
|     
 | ||
|     if systemctl is-active --quiet redis-server; then
 | ||
|         print_status "Redis already running"
 | ||
|     else
 | ||
|         $PKG_INSTALL redis-server
 | ||
|         systemctl start redis-server
 | ||
|         systemctl enable redis-server
 | ||
|         print_status "Redis installed and started"
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Configure Redis with user authentication
 | ||
| configure_redis() {
 | ||
|     print_info "Configuring Redis with user authentication..."
 | ||
|     
 | ||
|     # Check if Redis is running
 | ||
|     if ! systemctl is-active --quiet redis-server; then
 | ||
|         print_error "Redis is not running. Please start Redis first."
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Generate Redis username based on instance (global variable for use in create_env_files)
 | ||
|     REDIS_USER="patchmon_${DB_SAFE_NAME}"
 | ||
|     
 | ||
|     # Generate separate user password (more secure than reusing admin password)
 | ||
|     # This will be stored in the .env file for the application to use
 | ||
|     REDIS_USER_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
 | ||
|     
 | ||
|     print_info "Creating Redis user: $REDIS_USER for database $REDIS_DB"
 | ||
|     
 | ||
|     # Create Redis configuration backup
 | ||
|     if [ -f /etc/redis/redis.conf ]; then
 | ||
|         cp /etc/redis/redis.conf /etc/redis/redis.conf.backup.$(date +%Y%m%d_%H%M%S)
 | ||
|         print_info "Created Redis configuration backup"
 | ||
|     fi
 | ||
|     
 | ||
|     # Configure Redis with ACL authentication
 | ||
|     print_info "Configuring Redis with ACL authentication"
 | ||
|     
 | ||
|     # Ensure ACL file exists and is configured
 | ||
|     if [ ! -f /etc/redis/users.acl ]; then
 | ||
|         touch /etc/redis/users.acl
 | ||
|         chown redis:redis /etc/redis/users.acl
 | ||
|         chmod 640 /etc/redis/users.acl
 | ||
|         print_status "Created Redis ACL file"
 | ||
|     fi
 | ||
|     
 | ||
|     # Configure ACL file in redis.conf
 | ||
|     if ! grep -q "^aclfile" /etc/redis/redis.conf; then
 | ||
|         echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf
 | ||
|         print_status "Added ACL file configuration to Redis"
 | ||
|     fi
 | ||
|     
 | ||
|     # Remove any requirepass configuration (incompatible with ACL)
 | ||
|     if grep -q "^requirepass" /etc/redis/redis.conf; then
 | ||
|         sed -i 's/^requirepass.*/# &/' /etc/redis/redis.conf
 | ||
|         print_status "Disabled requirepass (incompatible with ACL)"
 | ||
|     fi
 | ||
|     
 | ||
|     # Remove any user definitions from redis.conf (should be in ACL file)
 | ||
|     if grep -q "^user " /etc/redis/redis.conf; then
 | ||
|         sed -i '/^user /d' /etc/redis/redis.conf
 | ||
|         print_status "Removed user definitions from redis.conf"
 | ||
|     fi
 | ||
|     
 | ||
|     # Create admin user in ACL file if it doesn't exist
 | ||
|     if ! grep -q "^user admin" /etc/redis/users.acl; then
 | ||
|         echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
 | ||
|         print_status "Added admin user to ACL file"
 | ||
|     fi
 | ||
|     
 | ||
|     # Restart Redis to apply ACL configuration
 | ||
|     print_info "Restarting Redis to apply ACL configuration..."
 | ||
|     systemctl restart redis-server
 | ||
|     
 | ||
|     # Wait for Redis to start
 | ||
|     sleep 3
 | ||
|     
 | ||
|     # Test admin connection
 | ||
|     if ! redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ping > /dev/null 2>&1; then
 | ||
|         print_error "Failed to configure Redis ACL authentication"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     print_status "Redis ACL authentication configuration successful"
 | ||
|     
 | ||
|     # Create Redis user with ACL
 | ||
|     print_info "Creating Redis ACL user: $REDIS_USER"
 | ||
|     
 | ||
|     # Create user with password and permissions - capture output for error handling
 | ||
|     local acl_result
 | ||
|     acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
 | ||
|     
 | ||
|     if [ "$acl_result" = "OK" ]; then
 | ||
|         print_status "Redis user '$REDIS_USER' created successfully"
 | ||
|         
 | ||
|         # Save ACL users to file to persist across restarts
 | ||
|         local save_result
 | ||
|         save_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE 2>&1)
 | ||
|         
 | ||
|         if [ "$save_result" = "OK" ]; then
 | ||
|             print_status "Redis ACL users saved to file"
 | ||
|         else
 | ||
|             print_warning "Failed to save ACL users to file: $save_result"
 | ||
|         fi
 | ||
|         
 | ||
|         # Verify user was actually created
 | ||
|         local verify_result
 | ||
|         verify_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL GETUSER "$REDIS_USER" 2>&1)
 | ||
|         
 | ||
|         if [ "$verify_result" = "(nil)" ]; then
 | ||
|             print_error "User creation reported OK but user does not exist"
 | ||
|             return 1
 | ||
|         fi
 | ||
|     else
 | ||
|         print_error "Failed to create Redis user: $acl_result"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Test user connection
 | ||
|     print_info "Testing Redis user connection..."
 | ||
|     if redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping > /dev/null 2>&1; then
 | ||
|         print_status "Redis user connection test successful"
 | ||
|     else
 | ||
|         print_error "Redis user connection test failed"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Mark the selected database as in-use
 | ||
|     redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" SET "patchmon:initialized" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /dev/null
 | ||
|     print_status "Marked Redis database $REDIS_DB as in-use"
 | ||
|     
 | ||
|     # Note: Redis credentials will be written to .env by create_env_files() function
 | ||
|     print_status "Redis user '$REDIS_USER' configured successfully"
 | ||
|     print_info "Redis credentials will be saved to backend/.env"
 | ||
|     
 | ||
|     return 0
 | ||
| }
 | ||
| 
 | ||
| # Install nginx
 | ||
| install_nginx() {
 | ||
|     print_info "Installing nginx..."
 | ||
|     
 | ||
|     if systemctl is-active --quiet nginx; then
 | ||
|         print_status "nginx already running"
 | ||
|     else
 | ||
|         $PKG_INSTALL nginx
 | ||
|         systemctl start nginx
 | ||
|         systemctl enable nginx
 | ||
|         print_status "nginx installed and started"
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Install certbot for Let's Encrypt
 | ||
| install_certbot() {
 | ||
|     print_info "Installing certbot for Let's Encrypt..."
 | ||
|     
 | ||
|     if command -v certbot >/dev/null 2>&1; then
 | ||
|         print_status "certbot already installed"
 | ||
|     else
 | ||
|         $PKG_INSTALL certbot python3-certbot-nginx
 | ||
|         print_status "certbot installed"
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Create dedicated user for this instance
 | ||
| create_instance_user() {
 | ||
|     print_info "Creating dedicated user: $INSTANCE_USER"
 | ||
|     
 | ||
|     # Create application directory first (as root)
 | ||
|     mkdir -p "$APP_DIR"
 | ||
|     
 | ||
|     # Check if user already exists
 | ||
|     if id "$INSTANCE_USER" &>/dev/null; then
 | ||
|         print_warning "User $INSTANCE_USER already exists, skipping creation"
 | ||
|         # Ensure directory ownership is correct for existing user
 | ||
|         chown -R "$INSTANCE_USER:$INSTANCE_USER" "$APP_DIR"
 | ||
|         chmod 755 "$APP_DIR"
 | ||
|         return 0
 | ||
|     fi
 | ||
|     
 | ||
|     # Create user with no login shell and no home directory
 | ||
|     useradd --system --no-create-home --shell /bin/false "$INSTANCE_USER"
 | ||
|     
 | ||
|     # Set ownership and permissions
 | ||
|     chown -R "$INSTANCE_USER:$INSTANCE_USER" "$APP_DIR"
 | ||
|     chmod 755 "$APP_DIR"
 | ||
|     
 | ||
|     print_status "Dedicated user $INSTANCE_USER created successfully"
 | ||
| }
 | ||
| 
 | ||
| # Setup Node.js environment isolation for this instance
 | ||
| setup_nodejs_isolation() {
 | ||
|     print_info "Setting up Node.js environment isolation for $INSTANCE_USER..."
 | ||
|     
 | ||
|     # Create npm directories as root first
 | ||
|     mkdir -p "$APP_DIR/.npm" "$APP_DIR/.npm-global"
 | ||
|     
 | ||
|     # Create .npmrc file with proper configuration
 | ||
|     cat > "$APP_DIR/.npmrc" << EOF
 | ||
| cache=$APP_DIR/.npm
 | ||
| prefix=$APP_DIR/.npm-global
 | ||
| init-module=$APP_DIR/.npm-global/.npm-init.js
 | ||
| tmp=$APP_DIR/.npm/tmp
 | ||
| EOF
 | ||
|     
 | ||
|     # Set ownership to the dedicated user
 | ||
|     chown -R "$INSTANCE_USER:$INSTANCE_USER" "$APP_DIR/.npm" "$APP_DIR/.npm-global" "$APP_DIR/.npmrc"
 | ||
|     
 | ||
|     print_status "Node.js environment isolation configured for $INSTANCE_USER"
 | ||
| }
 | ||
| 
 | ||
| # Setup database for instance
 | ||
| setup_database() {
 | ||
|     print_info "Setting up database: $DB_NAME"
 | ||
|     
 | ||
|     # Check if sudo is available for user switching
 | ||
|     if command -v sudo >/dev/null 2>&1; then
 | ||
|         # Check if user exists
 | ||
|         user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" || echo "0")
 | ||
|         
 | ||
|         if [ "$user_exists" = "1" ]; then
 | ||
|             print_info "Database user $DB_USER already exists, skipping creation"
 | ||
|         else
 | ||
|             print_info "Creating database user $DB_USER"
 | ||
|             sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
 | ||
|         fi
 | ||
|         
 | ||
|         # Check if database exists
 | ||
|         db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" || echo "0")
 | ||
|         
 | ||
|         if [ "$db_exists" = "1" ]; then
 | ||
|             print_info "Database $DB_NAME already exists, skipping creation"
 | ||
|         else
 | ||
|             print_info "Creating database $DB_NAME"
 | ||
|             sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
 | ||
|         fi
 | ||
|         
 | ||
|         # Always grant privileges (in case they were revoked)
 | ||
|         sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
 | ||
|     else
 | ||
|         # Alternative method for systems without sudo (run as postgres user directly)
 | ||
|         print_warning "sudo not available, using alternative method for PostgreSQL setup"
 | ||
|         
 | ||
|         # Check if user exists
 | ||
|         user_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" || echo "0")
 | ||
|         
 | ||
|         if [ "$user_exists" = "1" ]; then
 | ||
|             print_info "Database user $DB_USER already exists, skipping creation"
 | ||
|         else
 | ||
|             print_info "Creating database user $DB_USER"
 | ||
|             su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\""
 | ||
|         fi
 | ||
|         
 | ||
|         # Check if database exists
 | ||
|         db_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" || echo "0")
 | ||
|         
 | ||
|         if [ "$db_exists" = "1" ]; then
 | ||
|             print_info "Database $DB_NAME already exists, skipping creation"
 | ||
|         else
 | ||
|             print_info "Creating database $DB_NAME"
 | ||
|             su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
 | ||
|         fi
 | ||
|         
 | ||
|         # Always grant privileges (in case they were revoked)
 | ||
|         su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;\""
 | ||
|     fi
 | ||
|     
 | ||
|     print_status "Database setup complete for $DB_NAME"
 | ||
| }
 | ||
| 
 | ||
| # Clone application repository
 | ||
| clone_application() {
 | ||
|     print_info "Cloning PatchMon application..."
 | ||
|     
 | ||
|     if [ -d "$APP_DIR" ]; then
 | ||
|         print_warning "Directory $APP_DIR already exists, removing..."
 | ||
|         rm -rf "$APP_DIR"
 | ||
|     fi
 | ||
|     
 | ||
|     git clone -b "$DEPLOYMENT_BRANCH" "$GITHUB_REPO" "$APP_DIR"
 | ||
|     
 | ||
|     # Set ownership to the dedicated user
 | ||
|     chown -R "$INSTANCE_USER:$INSTANCE_USER" "$APP_DIR"
 | ||
|     
 | ||
|     cd "$APP_DIR"
 | ||
|     
 | ||
|     print_status "Application cloned to $APP_DIR with ownership set to $INSTANCE_USER"
 | ||
| }
 | ||
| 
 | ||
| # Setup Node.js environment
 | ||
| setup_node_environment() {
 | ||
|     print_info "Setting up Node.js environment..."
 | ||
|     
 | ||
|     cd "$APP_DIR"
 | ||
|     
 | ||
|     # Set Node.js environment
 | ||
|     export NODE_ENV=production
 | ||
|     export PATH="/usr/bin:/usr/local/bin:$PATH"
 | ||
|     
 | ||
|     print_status "Node.js environment configured"
 | ||
| }
 | ||
| 
 | ||
| # Install dependencies
 | ||
| install_dependencies() {
 | ||
|     print_info "Installing dependencies as user $INSTANCE_USER..."
 | ||
|     
 | ||
|     cd "$APP_DIR"
 | ||
|     
 | ||
|     # Clean up any existing node_modules to avoid conflicts
 | ||
|     rm -rf node_modules
 | ||
|     
 | ||
|     # Create tmp directory for npm
 | ||
|     mkdir -p "$APP_DIR/.npm/tmp"
 | ||
|     
 | ||
|     # Fix npm cache ownership issues (common problem)
 | ||
|     chown -R "$INSTANCE_USER:$INSTANCE_USER" "$APP_DIR/.npm"
 | ||
|     
 | ||
|     # Clean npm cache to avoid permission issues
 | ||
|     run_as_user "$INSTANCE_USER" "cd $APP_DIR && npm cache clean --force" 2>/dev/null || true
 | ||
|     
 | ||
|     # Install root dependencies as the dedicated user
 | ||
|     print_info "Installing root dependencies..."
 | ||
|     if ! run_as_user "$INSTANCE_USER" "
 | ||
|         cd $APP_DIR
 | ||
|         export NPM_CONFIG_CACHE=$APP_DIR/.npm
 | ||
|         export NPM_CONFIG_PREFIX=$APP_DIR/.npm-global
 | ||
|         export NPM_CONFIG_TMP=$APP_DIR/.npm/tmp
 | ||
|         npm install --omit=dev --no-audit --no-fund --no-save --ignore-scripts
 | ||
|     "; then
 | ||
|         print_error "Failed to install root dependencies"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Install backend dependencies as the dedicated user
 | ||
|     print_info "Installing backend dependencies..."
 | ||
|     cd backend
 | ||
|     rm -rf node_modules
 | ||
|     if ! run_as_user "$INSTANCE_USER" "
 | ||
|         cd $APP_DIR/backend
 | ||
|         export NPM_CONFIG_CACHE=$APP_DIR/.npm
 | ||
|         export NPM_CONFIG_PREFIX=$APP_DIR/.npm-global
 | ||
|         export NPM_CONFIG_TMP=$APP_DIR/.npm/tmp
 | ||
|         npm install --omit=dev --no-audit --no-fund --no-save --ignore-scripts
 | ||
|     "; then
 | ||
|         print_error "Failed to install backend dependencies"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     cd ..
 | ||
|     
 | ||
|     # Install frontend dependencies as the dedicated user (including dev dependencies for build)
 | ||
|     print_info "Installing frontend dependencies..."
 | ||
|     cd frontend
 | ||
|     rm -rf node_modules
 | ||
|     if ! run_as_user "$INSTANCE_USER" "
 | ||
|         cd $APP_DIR/frontend
 | ||
|         export NPM_CONFIG_CACHE=$APP_DIR/.npm
 | ||
|         export NPM_CONFIG_PREFIX=$APP_DIR/.npm-global
 | ||
|         export NPM_CONFIG_TMP=$APP_DIR/.npm/tmp
 | ||
|         npm install --no-audit --no-fund --no-save --ignore-scripts
 | ||
|     "; then
 | ||
|         print_error "Failed to install frontend dependencies"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Build frontend
 | ||
|     print_info "Building frontend..."
 | ||
|     if ! run_as_user "$INSTANCE_USER" "
 | ||
|         cd $APP_DIR/frontend
 | ||
|         export NPM_CONFIG_CACHE=$APP_DIR/.npm
 | ||
|         export NPM_CONFIG_PREFIX=$APP_DIR/.npm-global
 | ||
|         export NPM_CONFIG_TMP=$APP_DIR/.npm/tmp
 | ||
|         npm run build
 | ||
|     "; then
 | ||
|         print_error "Failed to build frontend"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     cd ..
 | ||
|     
 | ||
|     # Ensure ownership is maintained
 | ||
|     chown -R "$INSTANCE_USER:$INSTANCE_USER" "$APP_DIR"
 | ||
|     
 | ||
|     print_status "Dependencies installed and frontend built as $INSTANCE_USER"
 | ||
| }
 | ||
| 
 | ||
| # Create environment files
 | ||
| create_env_files() {
 | ||
|     print_info "Creating environment files..."
 | ||
|     
 | ||
|     cd "$APP_DIR"
 | ||
|     
 | ||
|     # Backend .env
 | ||
|     cat > backend/.env << EOF
 | ||
| # Database Configuration
 | ||
| DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
 | ||
| PM_DB_CONN_MAX_ATTEMPTS=30
 | ||
| PM_DB_CONN_WAIT_INTERVAL=2
 | ||
| 
 | ||
| # JWT Configuration
 | ||
| JWT_SECRET="$JWT_SECRET"
 | ||
| JWT_EXPIRES_IN=1h
 | ||
| JWT_REFRESH_EXPIRES_IN=7d
 | ||
| 
 | ||
| # Server Configuration
 | ||
| PORT=$BACKEND_PORT
 | ||
| NODE_ENV=production
 | ||
| 
 | ||
| # API Configuration
 | ||
| API_VERSION=v1
 | ||
| 
 | ||
| # CORS Configuration
 | ||
| CORS_ORIGIN="$SERVER_PROTOCOL_SEL://$FQDN"
 | ||
| 
 | ||
| # Session Configuration
 | ||
| SESSION_INACTIVITY_TIMEOUT_MINUTES=30
 | ||
| 
 | ||
| # User Configuration
 | ||
| DEFAULT_USER_ROLE=user
 | ||
| 
 | ||
| # Rate Limiting (times in milliseconds)
 | ||
| RATE_LIMIT_WINDOW_MS=900000
 | ||
| RATE_LIMIT_MAX=5000
 | ||
| AUTH_RATE_LIMIT_WINDOW_MS=600000
 | ||
| AUTH_RATE_LIMIT_MAX=500
 | ||
| AGENT_RATE_LIMIT_WINDOW_MS=60000
 | ||
| AGENT_RATE_LIMIT_MAX=1000
 | ||
| 
 | ||
| # Redis Configuration
 | ||
| REDIS_HOST=localhost
 | ||
| REDIS_PORT=6379
 | ||
| REDIS_USER=$REDIS_USER
 | ||
| REDIS_PASSWORD=$REDIS_USER_PASSWORD
 | ||
| REDIS_DB=$REDIS_DB
 | ||
| 
 | ||
| # Logging
 | ||
| LOG_LEVEL=info
 | ||
| ENABLE_LOGGING=true
 | ||
| 
 | ||
| # TFA Configuration
 | ||
| TFA_REMEMBER_ME_EXPIRES_IN=30d
 | ||
| TFA_MAX_REMEMBER_SESSIONS=5
 | ||
| TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3
 | ||
| EOF
 | ||
| 
 | ||
|     # Frontend .env
 | ||
|     cat > frontend/.env << EOF
 | ||
| VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
 | ||
| VITE_APP_NAME=PatchMon
 | ||
| VITE_APP_VERSION=1.3.0
 | ||
| EOF
 | ||
| 
 | ||
|     print_status "Environment files created"
 | ||
| }
 | ||
| 
 | ||
| # Run database migrations
 | ||
| run_migrations() {
 | ||
|     print_info "Running database migrations as user $INSTANCE_USER..."
 | ||
|     
 | ||
|     cd "$APP_DIR/backend"
 | ||
|     # Suppress Prisma CLI output (still logged to install log via tee)
 | ||
|     run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma migrate deploy" >/dev/null 2>&1 || true
 | ||
|     run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma generate" >/dev/null 2>&1 || true
 | ||
|     
 | ||
|     print_status "Database migrations completed as $INSTANCE_USER"
 | ||
| }
 | ||
| 
 | ||
| # Admin account creation removed - handled by application's first-time setup
 | ||
| 
 | ||
| # Create systemd service
 | ||
| create_systemd_service() {
 | ||
|     print_info "Creating systemd service for user $INSTANCE_USER..."
 | ||
|     
 | ||
|     cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
 | ||
| [Unit]
 | ||
| Description=PatchMon Service for $FQDN
 | ||
| After=network.target postgresql.service
 | ||
| 
 | ||
| [Service]
 | ||
| Type=simple
 | ||
| User=$INSTANCE_USER
 | ||
| Group=$INSTANCE_USER
 | ||
| WorkingDirectory=$APP_DIR/backend
 | ||
| ExecStart=/usr/bin/node src/server.js
 | ||
| Restart=always
 | ||
| RestartSec=10
 | ||
| Environment=NODE_ENV=production
 | ||
| Environment=PATH=/usr/bin:/usr/local/bin
 | ||
| NoNewPrivileges=true
 | ||
| PrivateTmp=true
 | ||
| ProtectSystem=strict
 | ||
| ProtectHome=true
 | ||
| ReadWritePaths=$APP_DIR
 | ||
| 
 | ||
| [Install]
 | ||
| WantedBy=multi-user.target
 | ||
| EOF
 | ||
| 
 | ||
|     systemctl daemon-reload
 | ||
|     systemctl enable "$SERVICE_NAME"
 | ||
|     
 | ||
|     print_status "Systemd service created: $SERVICE_NAME (running as $INSTANCE_USER)"
 | ||
| }
 | ||
| 
 | ||
| # Unified nginx configuration generator
 | ||
| generate_nginx_config() {
 | ||
|     local fqdn="$1"
 | ||
|     local app_dir="$2"
 | ||
|     local backend_port="$3"
 | ||
|     local ssl_enabled="$4"  # "true" or "false"
 | ||
|     local config_file="/etc/nginx/sites-available/$fqdn"
 | ||
|     
 | ||
|     print_info "Generating nginx configuration for $fqdn (SSL: $ssl_enabled)"
 | ||
|     
 | ||
|     if [ "$ssl_enabled" = "true" ]; then
 | ||
|         # SSL Configuration
 | ||
|         cat > "$config_file" << EOF
 | ||
| # HTTP to HTTPS redirect
 | ||
| server {
 | ||
|     listen 80;
 | ||
|     server_name $fqdn;
 | ||
|     
 | ||
|     # Let's Encrypt challenge location
 | ||
|     location /.well-known/acme-challenge/ {
 | ||
|         root /var/www/html;
 | ||
|     }
 | ||
|     
 | ||
|     # Redirect all other traffic to HTTPS
 | ||
|     location / {
 | ||
|         return 301 https://\$server_name\$request_uri;
 | ||
|     }
 | ||
| }
 | ||
| 
 | ||
| # HTTPS server block
 | ||
| server {
 | ||
|     listen 443 ssl http2;
 | ||
|     server_name $fqdn;
 | ||
|     
 | ||
|     # SSL Configuration
 | ||
|     ssl_certificate /etc/letsencrypt/live/$fqdn/fullchain.pem;
 | ||
|     ssl_certificate_key /etc/letsencrypt/live/$fqdn/privkey.pem;
 | ||
|     include /etc/letsencrypt/options-ssl-nginx.conf;
 | ||
|     ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 | ||
|     
 | ||
|     # Security headers (applied to all responses)
 | ||
|     add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
 | ||
|     add_header X-Frame-Options DENY always;
 | ||
|     add_header X-Content-Type-Options nosniff always;
 | ||
|     add_header X-XSS-Protection "1; mode=block" always;
 | ||
|     add_header Referrer-Policy "strict-origin-when-cross-origin" always;
 | ||
|     
 | ||
|     # Frontend
 | ||
|     location / {
 | ||
|         root $app_dir/frontend/dist;
 | ||
|         try_files \$uri \$uri/ /index.html;
 | ||
|     }
 | ||
|     
 | ||
|     # Bull Board proxy
 | ||
|     location /bullboard {
 | ||
|         proxy_pass http://127.0.0.1:$backend_port;
 | ||
|         proxy_http_version 1.1;
 | ||
|         proxy_set_header Upgrade \$http_upgrade;
 | ||
|         proxy_set_header Connection 'upgrade';
 | ||
|         proxy_set_header Host \$host;
 | ||
|         proxy_set_header X-Real-IP \$remote_addr;
 | ||
|         proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
 | ||
|         proxy_set_header X-Forwarded-Proto \$scheme;
 | ||
|         proxy_set_header X-Forwarded-Host \$host;
 | ||
|         proxy_set_header Cookie \$http_cookie;
 | ||
|         proxy_cache_bypass \$http_upgrade;
 | ||
|         proxy_read_timeout 300s;
 | ||
|         proxy_connect_timeout 75s;
 | ||
|         
 | ||
|         # Enable cookie passthrough
 | ||
|         proxy_pass_header Set-Cookie;
 | ||
|         proxy_cookie_path / /;
 | ||
|         
 | ||
|         # Preserve original client IP
 | ||
|         proxy_set_header X-Original-Forwarded-For \$http_x_forwarded_for;
 | ||
|         
 | ||
|         if (\$request_method = 'OPTIONS') {
 | ||
|             return 204;
 | ||
|         }
 | ||
|     }
 | ||
|     
 | ||
|     # API proxy
 | ||
|     location /api/ {
 | ||
|         proxy_pass http://127.0.0.1:$backend_port;
 | ||
|         proxy_http_version 1.1;
 | ||
|         proxy_set_header Upgrade \$http_upgrade;
 | ||
|         proxy_set_header Connection 'upgrade';
 | ||
|         proxy_set_header Host \$host;
 | ||
|         proxy_set_header X-Real-IP \$remote_addr;
 | ||
|         proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
 | ||
|         proxy_set_header X-Forwarded-Proto \$scheme;
 | ||
|         proxy_cache_bypass \$http_upgrade;
 | ||
|         proxy_read_timeout 300s;
 | ||
|         proxy_connect_timeout 75s;
 | ||
|         
 | ||
|         # Preserve original client IP
 | ||
|         proxy_set_header X-Original-Forwarded-For \$http_x_forwarded_for;
 | ||
|         
 | ||
|         if (\$request_method = 'OPTIONS') {
 | ||
|             return 204;
 | ||
|         }
 | ||
|     }
 | ||
|     
 | ||
|     # Static assets caching (exclude Bull Board assets)
 | ||
|     location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
 | ||
|         root $app_dir/frontend/dist;
 | ||
|         expires 1y;
 | ||
|         add_header Cache-Control "public, immutable";
 | ||
|     }
 | ||
|     
 | ||
|     # Health check endpoint
 | ||
|     location /health {
 | ||
|         proxy_pass http://127.0.0.1:$backend_port/health;
 | ||
|         access_log off;
 | ||
|     }
 | ||
| }
 | ||
| EOF
 | ||
|     else
 | ||
|         # HTTP-only configuration
 | ||
|         cat > "$config_file" << EOF
 | ||
| server {
 | ||
|     listen 80;
 | ||
|     server_name $fqdn;
 | ||
|     
 | ||
|     # Security headers
 | ||
|     add_header X-Frame-Options DENY always;
 | ||
|     add_header X-Content-Type-Options nosniff always;
 | ||
|     add_header X-XSS-Protection "1; mode=block" always;
 | ||
|     add_header Referrer-Policy "strict-origin-when-cross-origin" always;
 | ||
|     
 | ||
|     # Frontend
 | ||
|     location / {
 | ||
|         root $app_dir/frontend/dist;
 | ||
|         try_files \$uri \$uri/ /index.html;
 | ||
|     }
 | ||
|     
 | ||
|     # Bull Board proxy
 | ||
|     location /bullboard {
 | ||
|         proxy_pass http://127.0.0.1:$backend_port;
 | ||
|         proxy_http_version 1.1;
 | ||
|         proxy_set_header Upgrade \$http_upgrade;
 | ||
|         proxy_set_header Connection 'upgrade';
 | ||
|         proxy_set_header Host \$host;
 | ||
|         proxy_set_header X-Real-IP \$remote_addr;
 | ||
|         proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
 | ||
|         proxy_set_header X-Forwarded-Proto \$scheme;
 | ||
|         proxy_set_header X-Forwarded-Host \$host;
 | ||
|         proxy_set_header Cookie \$http_cookie;
 | ||
|         proxy_cache_bypass \$http_upgrade;
 | ||
|         proxy_read_timeout 300s;
 | ||
|         proxy_connect_timeout 75s;
 | ||
|         
 | ||
|         # Enable cookie passthrough
 | ||
|         proxy_pass_header Set-Cookie;
 | ||
|         proxy_cookie_path / /;
 | ||
|         
 | ||
|         # Preserve original client IP
 | ||
|         proxy_set_header X-Original-Forwarded-For \$http_x_forwarded_for;
 | ||
|         
 | ||
|         if (\$request_method = 'OPTIONS') {
 | ||
|             return 204;
 | ||
|         }
 | ||
|     }
 | ||
|     
 | ||
|     # API proxy
 | ||
|     location /api/ {
 | ||
|         proxy_pass http://127.0.0.1:$backend_port;
 | ||
|         proxy_http_version 1.1;
 | ||
|         proxy_set_header Upgrade \$http_upgrade;
 | ||
|         proxy_set_header Connection 'upgrade';
 | ||
|         proxy_set_header Host \$host;
 | ||
|         proxy_set_header X-Real-IP \$remote_addr;
 | ||
|         proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
 | ||
|         proxy_set_header X-Forwarded-Proto \$scheme;
 | ||
|         proxy_cache_bypass \$http_upgrade;
 | ||
|         proxy_read_timeout 300s;
 | ||
|         proxy_connect_timeout 75s;
 | ||
|         
 | ||
|         # Preserve original client IP
 | ||
|         proxy_set_header X-Original-Forwarded-For \$http_x_forwarded_for;
 | ||
|         
 | ||
|         if (\$request_method = 'OPTIONS') {
 | ||
|             return 204;
 | ||
|         }
 | ||
|     }
 | ||
|     
 | ||
|     # Static assets caching (exclude Bull Board assets)
 | ||
|     location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
 | ||
|         root $app_dir/frontend/dist;
 | ||
|         expires 1y;
 | ||
|         add_header Cache-Control "public, immutable";
 | ||
|     }
 | ||
|     
 | ||
|     # Health check endpoint
 | ||
|     location /health {
 | ||
|         proxy_pass http://127.0.0.1:$backend_port/health;
 | ||
|         access_log off;
 | ||
|     }
 | ||
| }
 | ||
| EOF
 | ||
|     fi
 | ||
|     
 | ||
|     print_status "Nginx configuration generated for $fqdn"
 | ||
| }
 | ||
| 
 | ||
| # Setup nginx configuration
 | ||
| setup_nginx() {
 | ||
|     print_info "Setting up nginx configuration..."
 | ||
|     log_message "Setting up nginx configuration for $FQDN"
 | ||
|     
 | ||
|     # Generate HTTP-only config first (needed for Let's Encrypt challenge if SSL enabled)
 | ||
|     generate_nginx_config "$FQDN" "$APP_DIR" "$BACKEND_PORT" "false"
 | ||
| 
 | ||
|     # Enable site
 | ||
|     ln -sf "/etc/nginx/sites-available/$FQDN" "/etc/nginx/sites-enabled/"
 | ||
|     
 | ||
|     # Remove default site if it exists
 | ||
|     rm -f /etc/nginx/sites-enabled/default
 | ||
|     
 | ||
|     # Test nginx configuration
 | ||
|     nginx -t
 | ||
|     
 | ||
|     # Reload nginx
 | ||
|     systemctl reload nginx
 | ||
|     
 | ||
|     print_status "nginx configuration created for $FQDN"
 | ||
| }
 | ||
| 
 | ||
| # Setup Let's Encrypt SSL
 | ||
| setup_letsencrypt() {
 | ||
|     print_info "Setting up Let's Encrypt SSL certificate..."
 | ||
|     
 | ||
|     # Check if a valid certificate already exists
 | ||
|     if certbot certificates 2>/dev/null | grep -q "$FQDN" && certbot certificates 2>/dev/null | grep -A 10 "$FQDN" | grep -q "VALID"; then
 | ||
|         print_status "Valid SSL certificate already exists for $FQDN"
 | ||
|         
 | ||
|         # Generate SSL config with existing certificate
 | ||
|         generate_nginx_config "$FQDN" "$APP_DIR" "$BACKEND_PORT" "true"
 | ||
|         
 | ||
|         # Enable the site
 | ||
|         ln -sf "/etc/nginx/sites-available/$FQDN" "/etc/nginx/sites-enabled/"
 | ||
|         
 | ||
|         # Test nginx configuration
 | ||
|         if nginx -t; then
 | ||
|             print_status "Nginx configuration updated for existing SSL certificate"
 | ||
|             systemctl reload nginx
 | ||
|         else
 | ||
|             print_error "Nginx configuration test failed"
 | ||
|             return 1
 | ||
|         fi
 | ||
|         
 | ||
|         return 0
 | ||
|     fi
 | ||
|     
 | ||
|     print_info "No valid certificate found, generating new SSL certificate..."
 | ||
|     
 | ||
|     # Wait for nginx to be ready
 | ||
|     sleep 5
 | ||
|     
 | ||
|     # Obtain SSL certificate
 | ||
|     log_message "Obtaining SSL certificate for $FQDN using Let's Encrypt"
 | ||
|     certbot --nginx -d "$FQDN" --non-interactive --agree-tos --email "$EMAIL" --redirect
 | ||
|     log_message "SSL certificate obtained successfully"
 | ||
|     
 | ||
|     # Generate SSL nginx configuration
 | ||
|     generate_nginx_config "$FQDN" "$APP_DIR" "$BACKEND_PORT" "true"
 | ||
|     
 | ||
|     # Test and reload nginx
 | ||
|     if nginx -t; then
 | ||
|         systemctl reload nginx
 | ||
|         print_status "Nginx configuration updated successfully"
 | ||
|     else
 | ||
|         print_error "Nginx configuration test failed"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Setup auto-renewal
 | ||
|     echo "0 12 * * * /usr/bin/certbot renew --quiet" | crontab -
 | ||
|     
 | ||
|     print_status "SSL certificate obtained and auto-renewal configured"
 | ||
| }
 | ||
| 
 | ||
| # Start services
 | ||
| start_services() {
 | ||
|     print_info "Starting services..."
 | ||
|     
 | ||
|     # Start PatchMon service
 | ||
|     systemctl start "$SERVICE_NAME"
 | ||
|     
 | ||
|     # Wait for service to start
 | ||
|     sleep 10
 | ||
|     
 | ||
|     # Check if service is running
 | ||
|     if systemctl is-active --quiet "$SERVICE_NAME"; then
 | ||
|         print_status "PatchMon service started successfully"
 | ||
|     else
 | ||
|         print_error "Failed to start PatchMon service"
 | ||
|         systemctl status "$SERVICE_NAME"
 | ||
|         return 1
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Populate server settings in database
 | ||
| populate_server_settings() {
 | ||
|     print_info "Populating server settings in database..."
 | ||
|     
 | ||
|     cd "$APP_DIR/backend"
 | ||
|     
 | ||
|     # Create settings update script
 | ||
|     cat > update_settings.js << EOF
 | ||
| const { PrismaClient } = require('@prisma/client');
 | ||
| const prisma = new PrismaClient();
 | ||
| 
 | ||
| async function updateSettings() {
 | ||
|   try {
 | ||
|     // Check if settings record exists, create or update
 | ||
|     const existingSettings = await prisma.settings.findFirst();
 | ||
|     
 | ||
|     const settingsData = {
 | ||
|       server_url: '$SERVER_PROTOCOL_SEL://$FQDN',
 | ||
|       server_protocol: '$SERVER_PROTOCOL_SEL',
 | ||
|       server_host: '$FQDN',
 | ||
|       server_port: $SERVER_PORT_SEL,
 | ||
|       update_interval: 60,
 | ||
|       auto_update: true
 | ||
|     };
 | ||
|     
 | ||
|     if (existingSettings) {
 | ||
|       // Update existing settings
 | ||
|       await prisma.settings.update({
 | ||
|         where: { id: existingSettings.id },
 | ||
|         data: settingsData
 | ||
|       });
 | ||
|     } else {
 | ||
|       // Create new settings record
 | ||
|       await prisma.settings.create({
 | ||
|         data: settingsData
 | ||
|       });
 | ||
|     }
 | ||
|     
 | ||
|     console.log('✅ Database settings updated successfully');
 | ||
|   } catch (error) {
 | ||
|     console.error('❌ Error updating settings:', error.message);
 | ||
|     process.exit(1);
 | ||
|   } finally {
 | ||
|     await prisma.\$disconnect();
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| updateSettings();
 | ||
| EOF
 | ||
| 
 | ||
|     # Run the settings update script as the dedicated user
 | ||
|     run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && node update_settings.js"
 | ||
|     
 | ||
|     # Clean up temporary script
 | ||
|     rm -f update_settings.js
 | ||
|     
 | ||
|     print_status "Server settings populated successfully"
 | ||
| }
 | ||
| 
 | ||
| # Create agent version
 | ||
| create_agent_version() {
 | ||
|     echo -e "${BLUE}🤖 Creating agent version...${NC}"
 | ||
|     log_message "Creating agent version in database..."
 | ||
|     cd $APP_DIR/backend
 | ||
|     
 | ||
|     # Priority 1: Get version from agent script (most accurate for agent versions)
 | ||
|     local current_version="N/A"
 | ||
|     if [ -f "$APP_DIR/agents/patchmon-agent.sh" ]; then
 | ||
|         current_version=$(grep '^AGENT_VERSION=' "$APP_DIR/agents/patchmon-agent.sh" | cut -d'"' -f2 2>/dev/null || echo "N/A")
 | ||
|         if [ "$current_version" != "N/A" ] && [ -n "$current_version" ]; then
 | ||
|             print_info "Detected agent version from script: $current_version"
 | ||
|         fi
 | ||
|     fi
 | ||
|     
 | ||
|     # Priority 2: Use fallback version if not found
 | ||
|     if [ "$current_version" = "N/A" ] || [ -z "$current_version" ]; then
 | ||
|         current_version="1.3.0"
 | ||
|         print_warning "Could not determine version, using fallback: $current_version"
 | ||
|     fi
 | ||
|     
 | ||
|     print_info "Creating/updating agent version: $current_version"
 | ||
|     print_info "This will ensure the latest agent script is available in the database"
 | ||
|     
 | ||
|     # Test connection before creating agent version
 | ||
|     if ! PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then
 | ||
|         print_error "Cannot connect to database before creating agent version"
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Copy agent script to backend directory
 | ||
|     if [ -f "$APP_DIR/agents/patchmon-agent.sh" ]; then
 | ||
|         cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
 | ||
|         
 | ||
|         print_status "Agent version management removed - using file-based approach"
 | ||
| # Ensure we close the conditional and the function properly
 | ||
|     fi
 | ||
| 
 | ||
|     return 0
 | ||
| }
 | ||
| # Create deployment summary
 | ||
| create_deployment_summary() {
 | ||
|     print_info "Writing deployment summary into deployment-info.txt..."
 | ||
|     
 | ||
|     # Reuse the unified deployment info file
 | ||
|     SUMMARY_FILE="$APP_DIR/deployment-info.txt"
 | ||
|     
 | ||
|     cat >> "$SUMMARY_FILE" << EOF
 | ||
| 
 | ||
| ----------------------------------------------------
 | ||
|         Deployment Summary (Appended)
 | ||
| ----------------------------------------------------
 | ||
| 
 | ||
| Deployment Information:
 | ||
| - Email: $EMAIL
 | ||
| - Branch: $DEPLOYMENT_BRANCH
 | ||
| - Deployed: $(date)
 | ||
| - Deployment Duration: $(($(date +%s) - $DEPLOYMENT_START_TIME)) seconds
 | ||
| 
 | ||
| Service Status:
 | ||
| - PatchMon Service: $(systemctl is-active $SERVICE_NAME)
 | ||
| - Nginx Service: $(systemctl is-active nginx)
 | ||
| - PostgreSQL Service: $(systemctl is-active postgresql)
 | ||
| - SSL Certificate: $(if [ "$USE_LETSENCRYPT" = "true" ]; then echo "Enabled"; else echo "Disabled"; fi)
 | ||
| 
 | ||
| Diagnostic Commands:
 | ||
| - Service Status: systemctl status $SERVICE_NAME
 | ||
| - Service Logs: journalctl -u $SERVICE_NAME -f
 | ||
| - Nginx Status: systemctl status nginx
 | ||
| - Nginx Logs: journalctl -u nginx -f
 | ||
| - Database Status: systemctl status postgresql
 | ||
| - SSL Certificate: certbot certificates
 | ||
| - Disk Usage: df -h $APP_DIR
 | ||
| - Process Status: ps aux | grep $SERVICE_NAME
 | ||
| 
 | ||
| Troubleshooting:
 | ||
| - Check deployment log: cat $APP_DIR/patchmon-install.log
 | ||
| - Check service logs: journalctl -u $SERVICE_NAME --since "1 hour ago"
 | ||
| - Check nginx config: nginx -t
 | ||
| - Check database connection: sudo -u $DB_USER psql -d $DB_NAME -c "SELECT 1;"
 | ||
| - Check port binding: netstat -tlnp | grep $BACKEND_PORT
 | ||
| 
 | ||
| ====================================================
 | ||
| EOF
 | ||
| 
 | ||
|     # Ensure permissions
 | ||
|     chmod 644 "$SUMMARY_FILE"
 | ||
|     chown "$INSTANCE_USER:$INSTANCE_USER" "$SUMMARY_FILE"
 | ||
|     
 | ||
|     # Copy the entire installation log into the instance folder
 | ||
|     if [ -f "$INSTALL_LOG" ]; then
 | ||
|         cp "$INSTALL_LOG" "$APP_DIR/patchmon-install.log" || true
 | ||
|         chown "$INSTANCE_USER:$INSTANCE_USER" "$APP_DIR/patchmon-install.log" || true
 | ||
|         chmod 644 "$APP_DIR/patchmon-install.log" || true
 | ||
|     fi
 | ||
|     
 | ||
|     # Verify file was created
 | ||
|     if [ -f "$SUMMARY_FILE" ]; then
 | ||
|         print_status "Deployment summary appended to: $SUMMARY_FILE"
 | ||
|     else
 | ||
|         print_error "⚠️  Failed to append to deployment-info.txt file"
 | ||
|         return 1
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Email notification function removed for self-hosting deployment
 | ||
| 
 | ||
| # Save deployment information to file
 | ||
| save_deployment_info() {
 | ||
|     print_info "Saving deployment information to file..."
 | ||
|     
 | ||
|     # Create deployment info file
 | ||
|     INFO_FILE="$APP_DIR/deployment-info.txt"
 | ||
|     
 | ||
|     cat > "$INFO_FILE" << EOF
 | ||
| ====================================================
 | ||
|         PatchMon Deployment Information
 | ||
| ====================================================
 | ||
| 
 | ||
| Instance Details:
 | ||
| - FQDN: $FQDN
 | ||
| - URL: $SERVER_PROTOCOL_SEL://$FQDN
 | ||
| - Deployed: $(date)
 | ||
| - Deployment Type: $(if [ "$USE_LETSENCRYPT" = "true" ]; then echo "Public with SSL"; else echo "Local/Internal"; fi)
 | ||
| - SSL Enabled: $USE_LETSENCRYPT
 | ||
| - Service Name: $SERVICE_NAME
 | ||
| 
 | ||
| Directories:
 | ||
| - App Directory: $APP_DIR
 | ||
| - Backend: $APP_DIR/backend
 | ||
| - Frontend (built): $APP_DIR/frontend/dist
 | ||
| - Node.js isolation dir: $APP_DIR/.npm
 | ||
| 
 | ||
| Database Information:
 | ||
| - Name: $DB_NAME
 | ||
| - User: $DB_USER
 | ||
| - Password: $DB_PASS
 | ||
| - Host: localhost
 | ||
| - Port: 5432
 | ||
| 
 | ||
| Redis Information:
 | ||
| - Host: localhost
 | ||
| - Port: 6379
 | ||
| - User: $REDIS_USER
 | ||
| - Password: $REDIS_USER_PASSWORD
 | ||
| - Database: $REDIS_DB
 | ||
| 
 | ||
| Networking:
 | ||
| - Backend Port: $BACKEND_PORT
 | ||
| - Nginx Config: /etc/nginx/sites-available/$FQDN
 | ||
| 
 | ||
| Logs & Files:
 | ||
| - Deployment Log: $LOG_FILE
 | ||
| - Systemd Service: /etc/systemd/system/$SERVICE_NAME.service
 | ||
| 
 | ||
| Common Commands:
 | ||
| - Restart backend service: sudo systemctl restart $SERVICE_NAME
 | ||
| - Check backend status:   systemctl status $SERVICE_NAME
 | ||
| - Tail backend logs:      journalctl -u $SERVICE_NAME -f
 | ||
| - Test nginx config:      nginx -t && systemctl reload nginx
 | ||
| - Check DB connection:    sudo -u $DB_USER psql -d $DB_NAME -c "SELECT 1;"
 | ||
| 
 | ||
| First-Time Setup:
 | ||
| - Visit the web interface: $SERVER_PROTOCOL_SEL://$FQDN
 | ||
| - Create the admin account through the web UI (no pre-created credentials)
 | ||
| 
 | ||
| Notes:
 | ||
| - Default role permissions (admin/user) are created automatically on backend startup
 | ||
| - Keep this file for future reference of your environment
 | ||
| 
 | ||
| ====================================================
 | ||
| EOF
 | ||
| 
 | ||
|     # Set permissions (readable by root and instance user)
 | ||
|     chmod 644 "$INFO_FILE"
 | ||
|     chown "$INSTANCE_USER:$INSTANCE_USER" "$INFO_FILE"
 | ||
|     
 | ||
|     # Verify file was created
 | ||
|     if [ -f "$INFO_FILE" ]; then
 | ||
|         print_status "Deployment information saved to: $INFO_FILE"
 | ||
|         print_info "File details: $(ls -lh "$INFO_FILE" | awk '{print $5, $9}')"
 | ||
|     else
 | ||
|         print_error "⚠️  Failed to create deployment-info.txt file"
 | ||
|         return 1
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Restart PatchMon service
 | ||
| restart_patchmon() {
 | ||
|     print_info "Restarting PatchMon service..."
 | ||
|     
 | ||
|     # Restart PatchMon service
 | ||
|     systemctl restart "$SERVICE_NAME"
 | ||
|     
 | ||
|     # Wait for service to restart
 | ||
|     sleep 5
 | ||
|     
 | ||
|     # Check if service is running
 | ||
|     if systemctl is-active --quiet "$SERVICE_NAME"; then
 | ||
|         print_status "PatchMon service restarted successfully"
 | ||
|     else
 | ||
|         print_error "Failed to restart PatchMon service"
 | ||
|         systemctl status "$SERVICE_NAME"
 | ||
|         return 1
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Setup logging for deployment
 | ||
| setup_deployment_logging() {
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] setup_deployment_logging function started" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     print_info "Setting up deployment logging..."
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] APP_DIR variable: $APP_DIR" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Use the main installation log file
 | ||
|     LOG_FILE="$INSTALL_LOG"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Using main log file: $LOG_FILE" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     print_info "Deployment log: $LOG_FILE"
 | ||
|     
 | ||
|     # Function to log with timestamp
 | ||
|     log_output() {
 | ||
|         echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
 | ||
|     }
 | ||
|     
 | ||
|     # Redirect all output to both terminal and log file
 | ||
|     exec > >(tee -a "$LOG_FILE")
 | ||
|     exec 2>&1
 | ||
|     
 | ||
|     log_output "=== PatchMon Deployment Started ==="
 | ||
|     log_output "Script started at: $(date)"
 | ||
|     log_output "Script PID: $$"
 | ||
|     log_output "Running as user: $(whoami)"
 | ||
|     log_output "Current directory: $(pwd)"
 | ||
|     log_output "Script arguments: $@"
 | ||
|     log_output "FQDN: $FQDN"
 | ||
|     log_output "Email: $EMAIL"
 | ||
|     log_output "Branch: $DEPLOYMENT_BRANCH"
 | ||
|     log_output "SSL Enabled: $USE_LETSENCRYPT"
 | ||
|     log_output "====================================="
 | ||
| }
 | ||
| 
 | ||
| # Main deployment function
 | ||
| deploy_instance() {
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy_instance function started" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     log_message "=== SELF-HOSTING-INSTALL.SH DEPLOYMENT STARTED ==="
 | ||
|     log_message "Script version: $SCRIPT_VERSION"
 | ||
|     log_message "FQDN: $FQDN"
 | ||
|     log_message "Email: $EMAIL"
 | ||
|     log_message "SSL Enabled: $USE_LETSENCRYPT"
 | ||
|     
 | ||
|     print_banner
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Skipping early logging setup - will do after variables initialized" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Record deployment start time
 | ||
|     DEPLOYMENT_START_TIME=$(date +%s)
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] About to validate parameters" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Parameters are already validated in interactive_setup
 | ||
|     print_info "All parameters validated successfully"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Parameter validation passed" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Checking if instance already exists at /opt/$FQDN" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Check if instance already exists
 | ||
|     if [ -d "/opt/$FQDN" ]; then
 | ||
|         print_error "Instance for $FQDN already exists at /opt/$FQDN"
 | ||
|         echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Instance already exists" >> "$DEBUG_LOG"
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Instance check passed - no existing instance found" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     print_info "🚀 Deploying PatchMon instance for $FQDN"
 | ||
|     print_info "📧 Email: $EMAIL"
 | ||
|     print_info "🌿 Branch: $DEPLOYMENT_BRANCH"
 | ||
|     print_info "🔒 SSL: $USE_LETSENCRYPT"
 | ||
|     if [ "$USE_LETSENCRYPT" = "true" ]; then
 | ||
|         print_info "📧 SSL Email: $EMAIL"
 | ||
|     fi
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] About to call init_instance_vars function" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Initialize variables
 | ||
|     init_instance_vars
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] init_instance_vars function completed" >> "$DEBUG_LOG"
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Variables initialized, APP_DIR: $APP_DIR" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Setup logging (after variables are initialized)
 | ||
|     setup_deployment_logging
 | ||
|     
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deployment logging setup completed" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Display generated credentials
 | ||
|     echo -e "${BLUE}🔐 Auto-generated credentials:${NC}"
 | ||
|     echo -e "${YELLOW}Database Name: $DB_NAME${NC}"
 | ||
|     echo -e "${YELLOW}Database User: $DB_USER${NC}"
 | ||
|     echo -e "${YELLOW}Database Password: $DB_PASS${NC}"
 | ||
|     echo -e "${YELLOW}Redis User: $REDIS_USER${NC}"
 | ||
|     echo -e "${YELLOW}Redis User Password: $REDIS_USER_PASSWORD${NC}"
 | ||
|     echo -e "${YELLOW}Redis Database: $REDIS_DB${NC}"
 | ||
|     echo -e "${YELLOW}JWT Secret: $JWT_SECRET${NC}"
 | ||
|     echo -e "${YELLOW}Backend Port: $BACKEND_PORT${NC}"
 | ||
|     echo -e "${YELLOW}Instance User: $INSTANCE_USER${NC}"
 | ||
|     echo -e "${YELLOW}Node.js Isolation: $APP_DIR/.npm${NC}"
 | ||
|     echo ""
 | ||
|     
 | ||
|     # System setup (prerequisites already installed in interactive_setup)
 | ||
|     install_nodejs
 | ||
|     install_postgresql
 | ||
|     install_redis
 | ||
|     configure_redis
 | ||
|     install_nginx
 | ||
|     
 | ||
|     # Only install certbot if SSL is enabled
 | ||
|     if [ "$USE_LETSENCRYPT" = "true" ]; then
 | ||
|         install_certbot
 | ||
|     fi
 | ||
|     
 | ||
|     # Instance-specific setup
 | ||
|     create_instance_user
 | ||
|     setup_nodejs_isolation
 | ||
|     setup_database
 | ||
|     clone_application
 | ||
|     setup_node_environment
 | ||
|     install_dependencies
 | ||
|     create_env_files
 | ||
|     run_migrations
 | ||
|     # Admin account creation removed - handled by application's first-time setup
 | ||
|     
 | ||
|     # Service and web server setup
 | ||
|     create_systemd_service
 | ||
|     setup_nginx
 | ||
|     
 | ||
|     # SSL setup (if enabled)
 | ||
|     if [ "$USE_LETSENCRYPT" = "true" ]; then
 | ||
|         setup_letsencrypt
 | ||
|     else
 | ||
|         print_info "SSL disabled - skipping SSL certificate setup"
 | ||
|     fi
 | ||
|     
 | ||
|     # Start services
 | ||
|     start_services
 | ||
|     
 | ||
|     # Populate server settings in database
 | ||
|     populate_server_settings
 | ||
|     
 | ||
|     # Create agent version in database
 | ||
|     create_agent_version
 | ||
|     
 | ||
|     # Restart PatchMon service to ensure it's running properly
 | ||
|     restart_patchmon
 | ||
|     
 | ||
|     # Save deployment information to file
 | ||
|     save_deployment_info
 | ||
|     
 | ||
|     # Create deployment summary
 | ||
|     create_deployment_summary
 | ||
|     
 | ||
|     # Email notifications removed for self-hosting deployment
 | ||
|     
 | ||
|     # Final status
 | ||
|     log_message "=== DEPLOYMENT COMPLETED SUCCESSFULLY ==="
 | ||
|     log_message "Instance URL: $SERVER_PROTOCOL_SEL://$FQDN"
 | ||
|     log_message "Service name: $SERVICE_NAME"
 | ||
|     log_message "Backend port: $BACKEND_PORT"
 | ||
|     log_message "SSL enabled: $USE_LETSENCRYPT"
 | ||
|     
 | ||
|     print_status "🎉 PatchMon instance deployed successfully!"
 | ||
|     echo ""
 | ||
|     print_info "Next steps:"
 | ||
|     echo "  • Visit your URL: $SERVER_PROTOCOL_SEL://$FQDN (ensure DNS is configured)"
 | ||
|     echo "  • Deployment information file: $APP_DIR/deployment-info.txt"
 | ||
|     echo "  • View deployment info: cat $APP_DIR/deployment-info.txt"
 | ||
|     echo ""
 | ||
|     
 | ||
|     # Suppress JSON echo to terminal; details already logged and saved to summary/credentials files
 | ||
|     :
 | ||
| }
 | ||
| 
 | ||
| # Detect existing PatchMon installations
 | ||
| detect_installations() {
 | ||
|     local installations=()
 | ||
|     
 | ||
|     # Find all directories in /opt that contain PatchMon installations
 | ||
|     if [ -d "/opt" ]; then
 | ||
|         for dir in /opt/*/; do
 | ||
|             local dirname=$(basename "$dir")
 | ||
|             # Skip backup directories
 | ||
|             if [[ "$dirname" =~ \.backup\. ]]; then
 | ||
|                 continue
 | ||
|             fi
 | ||
|             # Check if it's a PatchMon installation
 | ||
|             if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
 | ||
|                 installations+=("$dirname")
 | ||
|             fi
 | ||
|         done
 | ||
|     fi
 | ||
|     
 | ||
|     echo "${installations[@]}"
 | ||
| }
 | ||
| 
 | ||
| # Select installation to update
 | ||
| select_installation_to_update() {
 | ||
|     local installations=($(detect_installations))
 | ||
|     
 | ||
|     if [ ${#installations[@]} -eq 0 ]; then
 | ||
|         print_error "No existing PatchMon installations found in /opt"
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     print_info "Found ${#installations[@]} existing installation(s):"
 | ||
|     echo ""
 | ||
|     
 | ||
|     local i=1
 | ||
|     declare -A install_map
 | ||
|     for install in "${installations[@]}"; do
 | ||
|         # Get current version if possible
 | ||
|         local version="unknown"
 | ||
|         if [ -f "/opt/$install/backend/package.json" ]; then
 | ||
|             version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
 | ||
|         fi
 | ||
|         
 | ||
|         # Get service status - try multiple naming conventions
 | ||
|         # Convention 1: Just the install name (e.g., patchmon.internal)
 | ||
|         local service_name="$install"
 | ||
|         # Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
 | ||
|         local alt_service_name1="patchmon.$install"
 | ||
|         # Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
 | ||
|         local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
 | ||
|         local status="unknown"
 | ||
|         
 | ||
|         # Try convention 1 first (most common)
 | ||
|         if systemctl is-active --quiet "$service_name" 2>/dev/null; then
 | ||
|             status="running"
 | ||
|         elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
 | ||
|             status="stopped"
 | ||
|         # Try convention 2
 | ||
|         elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
 | ||
|             status="running"
 | ||
|             service_name="$alt_service_name1"
 | ||
|         elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
 | ||
|             status="stopped"
 | ||
|             service_name="$alt_service_name1"
 | ||
|         # Try convention 3
 | ||
|         elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
 | ||
|             status="running"
 | ||
|             service_name="$alt_service_name2"
 | ||
|         elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
 | ||
|             status="stopped"
 | ||
|             service_name="$alt_service_name2"
 | ||
|         fi
 | ||
|         
 | ||
|         printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
 | ||
|         install_map[$i]="$install"
 | ||
|         # Store the service name for later use
 | ||
|         declare -g "service_map_$i=$service_name"
 | ||
|         i=$((i + 1))
 | ||
|     done
 | ||
|     
 | ||
|     echo ""
 | ||
|     
 | ||
|     while true; do
 | ||
|         read_input "Select installation number to update" SELECTION "1"
 | ||
|         
 | ||
|         if [[ "$SELECTION" =~ ^[0-9]+$ ]] && [ -n "${install_map[$SELECTION]}" ]; then
 | ||
|             SELECTED_INSTANCE="${install_map[$SELECTION]}"
 | ||
|             # Get the stored service name
 | ||
|             local varname="service_map_$SELECTION"
 | ||
|             SELECTED_SERVICE_NAME="${!varname}"
 | ||
|             print_status "Selected: $SELECTED_INSTANCE"
 | ||
|             print_info "Service: $SELECTED_SERVICE_NAME"
 | ||
|             return 0
 | ||
|         else
 | ||
|             print_error "Invalid selection. Please enter a number from 1 to ${#installations[@]}"
 | ||
|         fi
 | ||
|     done
 | ||
| }
 | ||
| 
 | ||
| # Check and update Redis configuration for existing installation
 | ||
| update_redis_configuration() {
 | ||
|     print_info "Checking Redis configuration..."
 | ||
|     
 | ||
|     # Check if Redis configuration exists in .env
 | ||
|     if [ -f "$instance_dir/backend/.env" ]; then
 | ||
|         if grep -q "^REDIS_HOST=" "$instance_dir/backend/.env" && \
 | ||
|            grep -q "^REDIS_PASSWORD=" "$instance_dir/backend/.env"; then
 | ||
|             print_status "Redis configuration already exists in .env"
 | ||
|             return 0
 | ||
|         fi
 | ||
|     fi
 | ||
|     
 | ||
|     print_warning "Redis configuration not found in .env - this is a legacy installation"
 | ||
|     print_info "Setting up Redis for this instance..."
 | ||
|     
 | ||
|     # Detect package manager if not already set
 | ||
|     if [ -z "$PKG_INSTALL" ]; then
 | ||
|         if command -v apt >/dev/null 2>&1; then
 | ||
|             PKG_INSTALL="apt install -y"
 | ||
|         elif command -v apt-get >/dev/null 2>&1; then
 | ||
|             PKG_INSTALL="apt-get install -y"
 | ||
|         else
 | ||
|             print_error "No supported package manager found"
 | ||
|             return 1
 | ||
|         fi
 | ||
|     fi
 | ||
|     
 | ||
|     # Ensure Redis is installed and running
 | ||
|     if ! systemctl is-active --quiet redis-server; then
 | ||
|         print_info "Installing Redis..."
 | ||
|         $PKG_INSTALL redis-server
 | ||
|         systemctl start redis-server
 | ||
|         systemctl enable redis-server
 | ||
|     fi
 | ||
|     
 | ||
|     # Generate Redis variables for this instance
 | ||
|     # Extract DB_SAFE_NAME from existing database name
 | ||
|     DB_SAFE_NAME=$(echo "$DB_NAME" | sed 's/[^a-zA-Z0-9]/_/g')
 | ||
|     REDIS_USER="patchmon_${DB_SAFE_NAME}"
 | ||
|     REDIS_USER_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
 | ||
|     
 | ||
|     # Find available Redis database
 | ||
|     print_info "Finding available Redis database..."
 | ||
|     local redis_db=0
 | ||
|     local max_attempts=16
 | ||
|     
 | ||
|     while [ $redis_db -lt $max_attempts ]; do
 | ||
|         local key_count
 | ||
|         key_count=$(redis-cli -h localhost -p 6379 -n "$redis_db" DBSIZE 2>&1 | grep -v "ERR" || echo "1")
 | ||
|         
 | ||
|         if [ "$key_count" = "0" ] || [ "$key_count" = "(integer) 0" ]; then
 | ||
|             print_status "Found available Redis database: $redis_db"
 | ||
|             REDIS_DB=$redis_db
 | ||
|             break
 | ||
|         fi
 | ||
|         redis_db=$((redis_db + 1))
 | ||
|     done
 | ||
|     
 | ||
|     if [ -z "$REDIS_DB" ]; then
 | ||
|         print_warning "No empty Redis database found, using database 0"
 | ||
|         REDIS_DB=0
 | ||
|     fi
 | ||
|     
 | ||
|     # Generate admin password if not exists
 | ||
|     REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
 | ||
|     
 | ||
|     # Configure Redis with ACL if needed
 | ||
|     print_info "Configuring Redis ACL..."
 | ||
|     
 | ||
|     # Create ACL file if it doesn't exist
 | ||
|     if [ ! -f /etc/redis/users.acl ]; then
 | ||
|         touch /etc/redis/users.acl
 | ||
|         chown redis:redis /etc/redis/users.acl
 | ||
|         chmod 640 /etc/redis/users.acl
 | ||
|     fi
 | ||
|     
 | ||
|     # Configure ACL file in redis.conf
 | ||
|     if ! grep -q "^aclfile" /etc/redis/redis.conf 2>/dev/null; then
 | ||
|         echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf
 | ||
|     fi
 | ||
|     
 | ||
|     # Remove requirepass (incompatible with ACL)
 | ||
|     if grep -q "^requirepass" /etc/redis/redis.conf 2>/dev/null; then
 | ||
|         sed -i 's/^requirepass.*/# &/' /etc/redis/redis.conf
 | ||
|     fi
 | ||
|     
 | ||
|     # Create admin user if it doesn't exist
 | ||
|     if ! grep -q "^user admin" /etc/redis/users.acl; then
 | ||
|         echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
 | ||
|         systemctl restart redis-server
 | ||
|         sleep 3
 | ||
|     fi
 | ||
|     
 | ||
|     # Create instance-specific Redis user
 | ||
|     print_info "Creating Redis user: $REDIS_USER"
 | ||
|     
 | ||
|     # Try to authenticate with admin (may already exist from another instance)
 | ||
|     local acl_result
 | ||
|     acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
 | ||
|     
 | ||
|     if [ "$acl_result" = "OK" ] || echo "$acl_result" | grep -q "OK"; then
 | ||
|         print_status "Redis user created successfully"
 | ||
|         redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE > /dev/null 2>&1
 | ||
|     else
 | ||
|         print_warning "Could not create Redis user with ACL, trying without authentication..."
 | ||
|         # Fallback for systems without ACL configured
 | ||
|         redis-cli -h 127.0.0.1 -p 6379 CONFIG SET requirepass "$REDIS_USER_PASSWORD" > /dev/null 2>&1 || true
 | ||
|     fi
 | ||
|     
 | ||
|     # Backup existing .env
 | ||
|     cp "$instance_dir/backend/.env" "$instance_dir/backend/.env.backup.$(date +%Y%m%d_%H%M%S)"
 | ||
|     print_info "Backed up existing .env file"
 | ||
|     
 | ||
|     # Add Redis configuration to .env
 | ||
|     print_info "Adding Redis configuration to .env..."
 | ||
|     cat >> "$instance_dir/backend/.env" << EOF
 | ||
| 
 | ||
| # Redis Configuration (added during update)
 | ||
| REDIS_HOST=localhost
 | ||
| REDIS_PORT=6379
 | ||
| REDIS_USER=$REDIS_USER
 | ||
| REDIS_PASSWORD=$REDIS_USER_PASSWORD
 | ||
| REDIS_DB=$REDIS_DB
 | ||
| EOF
 | ||
|     
 | ||
|     print_status "Redis configuration added to .env"
 | ||
|     print_info "Redis User: $REDIS_USER"
 | ||
|     print_info "Redis Database: $REDIS_DB"
 | ||
|     
 | ||
|     return 0
 | ||
| }
 | ||
| 
 | ||
| # Update .env file with missing variables while preserving existing values
 | ||
| update_env_file() {
 | ||
|     print_info "Checking .env file for missing variables..."
 | ||
|     
 | ||
|     local env_file="$instance_dir/backend/.env"
 | ||
|     
 | ||
|     if [ ! -f "$env_file" ]; then
 | ||
|         print_error ".env file not found at $env_file"
 | ||
|         return 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Backup existing .env
 | ||
|     cp "$env_file" "$env_file.backup.$(date +%Y%m%d_%H%M%S)"
 | ||
|     print_info "Backed up existing .env file"
 | ||
|     
 | ||
|     # Source existing .env to get current values
 | ||
|     set -a
 | ||
|     source "$env_file"
 | ||
|     set +a
 | ||
|     
 | ||
|     # Define all expected variables with their defaults
 | ||
|     # Only set if not already defined (preserves user values)
 | ||
|     
 | ||
|     # Database (already loaded from .env)
 | ||
|     : ${PM_DB_CONN_MAX_ATTEMPTS:=30}
 | ||
|     : ${PM_DB_CONN_WAIT_INTERVAL:=2}
 | ||
|     
 | ||
|     # JWT (JWT_SECRET should already exist)
 | ||
|     : ${JWT_EXPIRES_IN:=1h}
 | ||
|     : ${JWT_REFRESH_EXPIRES_IN:=7d}
 | ||
|     
 | ||
|     # Server
 | ||
|     : ${NODE_ENV:=production}
 | ||
|     
 | ||
|     # API
 | ||
|     : ${API_VERSION:=v1}
 | ||
|     
 | ||
|     # CORS (preserve existing or use current FQDN)
 | ||
|     if [ -z "$CORS_ORIGIN" ]; then
 | ||
|         # Determine protocol from existing URL or default to https
 | ||
|         if echo "$DATABASE_URL" | grep -q "localhost"; then
 | ||
|             CORS_ORIGIN="http://$SELECTED_INSTANCE"
 | ||
|         else
 | ||
|             CORS_ORIGIN="https://$SELECTED_INSTANCE"
 | ||
|         fi
 | ||
|     fi
 | ||
|     
 | ||
|     # Session
 | ||
|     : ${SESSION_INACTIVITY_TIMEOUT_MINUTES:=30}
 | ||
|     
 | ||
|     # User
 | ||
|     : ${DEFAULT_USER_ROLE:=user}
 | ||
|     
 | ||
|     # Rate Limiting
 | ||
|     : ${RATE_LIMIT_WINDOW_MS:=900000}
 | ||
|     : ${RATE_LIMIT_MAX:=5000}
 | ||
|     : ${AUTH_RATE_LIMIT_WINDOW_MS:=600000}
 | ||
|     : ${AUTH_RATE_LIMIT_MAX:=500}
 | ||
|     : ${AGENT_RATE_LIMIT_WINDOW_MS:=60000}
 | ||
|     : ${AGENT_RATE_LIMIT_MAX:=1000}
 | ||
|     
 | ||
|     # Redis (already handled by update_redis_configuration if missing)
 | ||
|     : ${REDIS_HOST:=localhost}
 | ||
|     : ${REDIS_PORT:=6379}
 | ||
|     : ${REDIS_DB:=0}
 | ||
|     
 | ||
|     # Logging
 | ||
|     : ${LOG_LEVEL:=info}
 | ||
|     : ${ENABLE_LOGGING:=true}
 | ||
|     
 | ||
|     # TFA
 | ||
|     : ${TFA_REMEMBER_ME_EXPIRES_IN:=30d}
 | ||
|     : ${TFA_MAX_REMEMBER_SESSIONS:=5}
 | ||
|     : ${TFA_SUSPICIOUS_ACTIVITY_THRESHOLD:=3}
 | ||
|     
 | ||
|     # Track which variables were added
 | ||
|     local added_vars=()
 | ||
|     
 | ||
|     # Check and add missing variables
 | ||
|     if ! grep -q "^PM_DB_CONN_MAX_ATTEMPTS=" "$env_file"; then
 | ||
|         added_vars+=("PM_DB_CONN_MAX_ATTEMPTS")
 | ||
|     fi
 | ||
|     if ! grep -q "^PM_DB_CONN_WAIT_INTERVAL=" "$env_file"; then
 | ||
|         added_vars+=("PM_DB_CONN_WAIT_INTERVAL")
 | ||
|     fi
 | ||
|     if ! grep -q "^JWT_EXPIRES_IN=" "$env_file"; then
 | ||
|         added_vars+=("JWT_EXPIRES_IN")
 | ||
|     fi
 | ||
|     if ! grep -q "^JWT_REFRESH_EXPIRES_IN=" "$env_file"; then
 | ||
|         added_vars+=("JWT_REFRESH_EXPIRES_IN")
 | ||
|     fi
 | ||
|     if ! grep -q "^API_VERSION=" "$env_file"; then
 | ||
|         added_vars+=("API_VERSION")
 | ||
|     fi
 | ||
|     if ! grep -q "^CORS_ORIGIN=" "$env_file"; then
 | ||
|         added_vars+=("CORS_ORIGIN")
 | ||
|     fi
 | ||
|     if ! grep -q "^SESSION_INACTIVITY_TIMEOUT_MINUTES=" "$env_file"; then
 | ||
|         added_vars+=("SESSION_INACTIVITY_TIMEOUT_MINUTES")
 | ||
|     fi
 | ||
|     if ! grep -q "^DEFAULT_USER_ROLE=" "$env_file"; then
 | ||
|         added_vars+=("DEFAULT_USER_ROLE")
 | ||
|     fi
 | ||
|     if ! grep -q "^RATE_LIMIT_WINDOW_MS=" "$env_file"; then
 | ||
|         added_vars+=("RATE_LIMIT_WINDOW_MS")
 | ||
|     fi
 | ||
|     if ! grep -q "^RATE_LIMIT_MAX=" "$env_file"; then
 | ||
|         added_vars+=("RATE_LIMIT_MAX")
 | ||
|     fi
 | ||
|     if ! grep -q "^AUTH_RATE_LIMIT_WINDOW_MS=" "$env_file"; then
 | ||
|         added_vars+=("AUTH_RATE_LIMIT_WINDOW_MS")
 | ||
|     fi
 | ||
|     if ! grep -q "^AUTH_RATE_LIMIT_MAX=" "$env_file"; then
 | ||
|         added_vars+=("AUTH_RATE_LIMIT_MAX")
 | ||
|     fi
 | ||
|     if ! grep -q "^AGENT_RATE_LIMIT_WINDOW_MS=" "$env_file"; then
 | ||
|         added_vars+=("AGENT_RATE_LIMIT_WINDOW_MS")
 | ||
|     fi
 | ||
|     if ! grep -q "^AGENT_RATE_LIMIT_MAX=" "$env_file"; then
 | ||
|         added_vars+=("AGENT_RATE_LIMIT_MAX")
 | ||
|     fi
 | ||
|     if ! grep -q "^LOG_LEVEL=" "$env_file"; then
 | ||
|         added_vars+=("LOG_LEVEL")
 | ||
|     fi
 | ||
|     if ! grep -q "^ENABLE_LOGGING=" "$env_file"; then
 | ||
|         added_vars+=("ENABLE_LOGGING")
 | ||
|     fi
 | ||
|     if ! grep -q "^TFA_REMEMBER_ME_EXPIRES_IN=" "$env_file"; then
 | ||
|         added_vars+=("TFA_REMEMBER_ME_EXPIRES_IN")
 | ||
|     fi
 | ||
|     if ! grep -q "^TFA_MAX_REMEMBER_SESSIONS=" "$env_file"; then
 | ||
|         added_vars+=("TFA_MAX_REMEMBER_SESSIONS")
 | ||
|     fi
 | ||
|     if ! grep -q "^TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=" "$env_file"; then
 | ||
|         added_vars+=("TFA_SUSPICIOUS_ACTIVITY_THRESHOLD")
 | ||
|     fi
 | ||
|     
 | ||
|     # If there are missing variables, add them
 | ||
|     if [ ${#added_vars[@]} -gt 0 ]; then
 | ||
|         print_info "Adding ${#added_vars[@]} missing environment variable(s)..."
 | ||
|         
 | ||
|         cat >> "$env_file" << EOF
 | ||
| 
 | ||
| # Environment variables added during update on $(date)
 | ||
| EOF
 | ||
|         
 | ||
|         # Add database config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "PM_DB_CONN_MAX_ATTEMPTS"; then
 | ||
|             echo "PM_DB_CONN_MAX_ATTEMPTS=$PM_DB_CONN_MAX_ATTEMPTS" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "PM_DB_CONN_WAIT_INTERVAL"; then
 | ||
|             echo "PM_DB_CONN_WAIT_INTERVAL=$PM_DB_CONN_WAIT_INTERVAL" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add JWT config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "JWT_EXPIRES_IN"; then
 | ||
|             echo "JWT_EXPIRES_IN=$JWT_EXPIRES_IN" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "JWT_REFRESH_EXPIRES_IN"; then
 | ||
|             echo "JWT_REFRESH_EXPIRES_IN=$JWT_REFRESH_EXPIRES_IN" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add API config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "API_VERSION"; then
 | ||
|             echo "API_VERSION=$API_VERSION" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add CORS config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "CORS_ORIGIN"; then
 | ||
|             echo "CORS_ORIGIN=$CORS_ORIGIN" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add session config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "SESSION_INACTIVITY_TIMEOUT_MINUTES"; then
 | ||
|             echo "SESSION_INACTIVITY_TIMEOUT_MINUTES=$SESSION_INACTIVITY_TIMEOUT_MINUTES" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add user config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "DEFAULT_USER_ROLE"; then
 | ||
|             echo "DEFAULT_USER_ROLE=$DEFAULT_USER_ROLE" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add rate limiting if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "RATE_LIMIT_WINDOW_MS"; then
 | ||
|             echo "RATE_LIMIT_WINDOW_MS=$RATE_LIMIT_WINDOW_MS" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "RATE_LIMIT_MAX"; then
 | ||
|             echo "RATE_LIMIT_MAX=$RATE_LIMIT_MAX" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "AUTH_RATE_LIMIT_WINDOW_MS"; then
 | ||
|             echo "AUTH_RATE_LIMIT_WINDOW_MS=$AUTH_RATE_LIMIT_WINDOW_MS" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "AUTH_RATE_LIMIT_MAX"; then
 | ||
|             echo "AUTH_RATE_LIMIT_MAX=$AUTH_RATE_LIMIT_MAX" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "AGENT_RATE_LIMIT_WINDOW_MS"; then
 | ||
|             echo "AGENT_RATE_LIMIT_WINDOW_MS=$AGENT_RATE_LIMIT_WINDOW_MS" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "AGENT_RATE_LIMIT_MAX"; then
 | ||
|             echo "AGENT_RATE_LIMIT_MAX=$AGENT_RATE_LIMIT_MAX" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add logging config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "LOG_LEVEL"; then
 | ||
|             echo "LOG_LEVEL=$LOG_LEVEL" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "ENABLE_LOGGING"; then
 | ||
|             echo "ENABLE_LOGGING=$ENABLE_LOGGING" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         # Add TFA config if missing
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "TFA_REMEMBER_ME_EXPIRES_IN"; then
 | ||
|             echo "TFA_REMEMBER_ME_EXPIRES_IN=$TFA_REMEMBER_ME_EXPIRES_IN" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "TFA_MAX_REMEMBER_SESSIONS"; then
 | ||
|             echo "TFA_MAX_REMEMBER_SESSIONS=$TFA_MAX_REMEMBER_SESSIONS" >> "$env_file"
 | ||
|         fi
 | ||
|         if printf '%s\n' "${added_vars[@]}" | grep -q "TFA_SUSPICIOUS_ACTIVITY_THRESHOLD"; then
 | ||
|             echo "TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=$TFA_SUSPICIOUS_ACTIVITY_THRESHOLD" >> "$env_file"
 | ||
|         fi
 | ||
|         
 | ||
|         print_status ".env file updated with ${#added_vars[@]} new variable(s)"
 | ||
|         print_info "Added variables: ${added_vars[*]}"
 | ||
|     else
 | ||
|         print_status ".env file is up to date - no missing variables"
 | ||
|     fi
 | ||
|     
 | ||
|     return 0
 | ||
| }
 | ||
| 
 | ||
| # Update nginx configuration for existing installation
 | ||
| update_nginx_configuration() {
 | ||
|     print_info "Updating nginx configuration..."
 | ||
|     
 | ||
|     # Detect SSL status
 | ||
|     local ssl_enabled="false"
 | ||
|     if [ -f "/etc/letsencrypt/live/$SELECTED_INSTANCE/fullchain.pem" ]; then
 | ||
|         ssl_enabled="true"
 | ||
|         print_info "SSL certificate detected, updating HTTPS configuration"
 | ||
|     else
 | ||
|         print_info "No SSL certificate found, updating HTTP configuration"
 | ||
|     fi
 | ||
|     
 | ||
|     # Backup existing config
 | ||
|     local backup_file="/etc/nginx/sites-available/$SELECTED_INSTANCE.backup.$(date +%Y%m%d_%H%M%S)"
 | ||
|     if [ -f "/etc/nginx/sites-available/$SELECTED_INSTANCE" ]; then
 | ||
|         cp "/etc/nginx/sites-available/$SELECTED_INSTANCE" "$backup_file"
 | ||
|         print_info "Backed up existing nginx config to: $backup_file"
 | ||
|     fi
 | ||
|     
 | ||
|     # Extract backend port
 | ||
|     local backend_port=$(grep -o 'proxy_pass http://127.0.0.1:[0-9]*' "/etc/nginx/sites-available/$SELECTED_INSTANCE" 2>/dev/null | grep -oP ':\K[0-9]+' | head -1)
 | ||
|     if [ -z "$backend_port" ] && [ -f "$instance_dir/backend/.env" ]; then
 | ||
|         backend_port=$(grep '^PORT=' "$instance_dir/backend/.env" | cut -d'=' -f2 | tr -d ' ')
 | ||
|     fi
 | ||
|     
 | ||
|     if [ -z "$backend_port" ]; then
 | ||
|         print_warning "Could not determine backend port, skipping nginx config update"
 | ||
|         return 0
 | ||
|     fi
 | ||
|     
 | ||
|     print_info "Detected backend port: $backend_port"
 | ||
|     
 | ||
|     # Generate new configuration using the unified function
 | ||
|     generate_nginx_config "$SELECTED_INSTANCE" "$instance_dir" "$backend_port" "$ssl_enabled"
 | ||
|     
 | ||
|     # Test and reload nginx
 | ||
|     if nginx -t; then
 | ||
|         systemctl reload nginx
 | ||
|         print_status "Nginx configuration updated successfully"
 | ||
|     else
 | ||
|         print_error "Nginx configuration test failed"
 | ||
|         # Restore backup
 | ||
|         if [ -f "$backup_file" ]; then
 | ||
|             mv "$backup_file" "/etc/nginx/sites-available/$SELECTED_INSTANCE"
 | ||
|             print_info "Restored backup nginx configuration"
 | ||
|         fi
 | ||
|         return 1
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Update existing installation
 | ||
| update_installation() {
 | ||
|     local instance_dir="/opt/$SELECTED_INSTANCE"
 | ||
|     local service_name="$SELECTED_SERVICE_NAME"
 | ||
|     
 | ||
|     print_info "Updating PatchMon installation: $SELECTED_INSTANCE"
 | ||
|     print_info "Installation directory: $instance_dir"
 | ||
|     print_info "Service name: $service_name"
 | ||
|     
 | ||
|     # Verify it's a git repository
 | ||
|     if [ ! -d "$instance_dir/.git" ]; then
 | ||
|         print_error "Installation directory is not a git repository"
 | ||
|         print_error "Cannot perform git-based update"
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Add git safe.directory to avoid ownership issues when running as root
 | ||
|     print_info "Configuring git safe.directory..."
 | ||
|     git config --global --add safe.directory "$instance_dir" 2>/dev/null || true
 | ||
|     
 | ||
|     # Load existing .env to get database credentials
 | ||
|     if [ -f "$instance_dir/backend/.env" ]; then
 | ||
|         source "$instance_dir/backend/.env"
 | ||
|         print_status "Loaded existing configuration"
 | ||
|         
 | ||
|         # Parse DATABASE_URL to extract credentials
 | ||
|         # Format: postgresql://user:password@host:port/database
 | ||
|         if [ -n "$DATABASE_URL" ]; then
 | ||
|             # Extract components using regex
 | ||
|             DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p')
 | ||
|             DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p')
 | ||
|             DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
 | ||
|             DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
 | ||
|             DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
 | ||
|             
 | ||
|             print_info "Database: $DB_NAME (user: $DB_USER)"
 | ||
|         else
 | ||
|             print_error "DATABASE_URL not found in .env file"
 | ||
|             exit 1
 | ||
|         fi
 | ||
|     else
 | ||
|         print_error "Cannot find .env file at $instance_dir/backend/.env"
 | ||
|         exit 1
 | ||
|     fi
 | ||
|     
 | ||
|     # Select branch/version to update to
 | ||
|     select_branch
 | ||
|     
 | ||
|     print_info "Updating to: $DEPLOYMENT_BRANCH"
 | ||
|     echo ""
 | ||
|     
 | ||
|     read_yes_no "Proceed with update? This will pull new code and restart services" CONFIRM_UPDATE "y"
 | ||
|     
 | ||
|     if [ "$CONFIRM_UPDATE" != "y" ]; then
 | ||
|         print_warning "Update cancelled by user"
 | ||
|         exit 0
 | ||
|     fi
 | ||
|     
 | ||
|     # Stop the service
 | ||
|     print_info "Stopping service: $service_name"
 | ||
|     systemctl stop "$service_name" || true
 | ||
|     
 | ||
|     # Create backup directory
 | ||
|     local timestamp=$(date +%Y%m%d_%H%M%S)
 | ||
|     local backup_dir="$instance_dir.backup.$timestamp"
 | ||
|     local db_backup_file="$backup_dir/database_backup_$timestamp.sql"
 | ||
|     
 | ||
|     print_info "Creating backup directory: $backup_dir"
 | ||
|     mkdir -p "$backup_dir"
 | ||
|     
 | ||
|     # Backup database
 | ||
|     print_info "Backing up database: $DB_NAME"
 | ||
|     if PGPASSWORD="$DB_PASS" pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -F c -f "$db_backup_file" 2>/dev/null; then
 | ||
|         print_status "Database backup created: $db_backup_file"
 | ||
|     else
 | ||
|         print_warning "Database backup failed, but continuing with code backup"
 | ||
|     fi
 | ||
|     
 | ||
|     # Backup code
 | ||
|     print_info "Backing up code files..."
 | ||
|     cp -r "$instance_dir" "$backup_dir/code"
 | ||
|     print_status "Code backup created"
 | ||
|     
 | ||
|     # Update code
 | ||
|     print_info "Pulling latest code from branch: $DEPLOYMENT_BRANCH"
 | ||
|     cd "$instance_dir"
 | ||
|     
 | ||
|     # Clean up any untracked files that might conflict with incoming changes
 | ||
|     print_info "Cleaning up untracked files to prevent merge conflicts..."
 | ||
|     git clean -fd
 | ||
|     
 | ||
|     # Reset any local changes to ensure clean state
 | ||
|     print_info "Resetting local changes to ensure clean state..."
 | ||
|     git reset --hard HEAD
 | ||
|     
 | ||
|     # Fetch latest changes
 | ||
|     git fetch origin
 | ||
|     
 | ||
|     # Checkout the selected branch/tag
 | ||
|     git checkout "$DEPLOYMENT_BRANCH"
 | ||
|     git pull origin "$DEPLOYMENT_BRANCH" || git pull # For tags, just pull
 | ||
|     
 | ||
|     print_status "Code updated successfully"
 | ||
|     
 | ||
|     # Update dependencies
 | ||
|     print_info "Updating backend dependencies..."
 | ||
|     cd "$instance_dir/backend"
 | ||
|     npm install --production --ignore-scripts
 | ||
|     
 | ||
|     print_info "Updating frontend dependencies..."
 | ||
|     cd "$instance_dir/frontend"
 | ||
|     npm install --ignore-scripts
 | ||
|     
 | ||
|     # Build frontend
 | ||
|     print_info "Building frontend..."
 | ||
|     npm run build
 | ||
|     
 | ||
|     # Run database migrations and generate Prisma client
 | ||
|     print_info "Running database migrations..."
 | ||
|     cd "$instance_dir/backend"
 | ||
|     npx prisma generate
 | ||
|     npx prisma migrate deploy
 | ||
|     
 | ||
|     # Check and update Redis configuration if needed (for legacy installations)
 | ||
|     update_redis_configuration
 | ||
|     
 | ||
|     # Update .env file with any missing variables (preserve existing values)
 | ||
|     update_env_file
 | ||
|     
 | ||
|     # Update nginx configuration with latest improvements
 | ||
|     update_nginx_configuration
 | ||
|     
 | ||
|     # Start the service
 | ||
|     print_info "Starting service: $service_name"
 | ||
|     systemctl start "$service_name"
 | ||
|     
 | ||
|     # Wait a moment and check status
 | ||
|     sleep 3
 | ||
|     
 | ||
|     if systemctl is-active --quiet "$service_name"; then
 | ||
|         print_success "✅ Update completed successfully!"
 | ||
|         print_status "Service $service_name is running"
 | ||
|         
 | ||
|         # Get new version
 | ||
|         local new_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
 | ||
|         print_info "Updated to version: $new_version"
 | ||
|         echo ""
 | ||
|         print_info "Backup Information:"
 | ||
|         print_info "  Code backup: $backup_dir/code"
 | ||
|         print_info "  Database backup: $db_backup_file"
 | ||
|         echo ""
 | ||
|         print_info "To restore database if needed:"
 | ||
|         print_info "  PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\""
 | ||
|         echo ""
 | ||
|     else
 | ||
|         print_error "Service failed to start after update"
 | ||
|         echo ""
 | ||
|         print_warning "ROLLBACK INSTRUCTIONS:"
 | ||
|         print_info "1. Restore code:"
 | ||
|         print_info "   sudo rm -rf $instance_dir"
 | ||
|         print_info "   sudo mv $backup_dir/code $instance_dir"
 | ||
|         echo ""
 | ||
|         print_info "2. Restore database:"
 | ||
|         print_info "   PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\""
 | ||
|         echo ""
 | ||
|         print_info "3. Restart service:"
 | ||
|         print_info "   sudo systemctl start $service_name"
 | ||
|         echo ""
 | ||
|         print_info "Check logs: journalctl -u $service_name -f"
 | ||
|         exit 1
 | ||
|     fi
 | ||
| }
 | ||
| 
 | ||
| # Main script execution
 | ||
| main() {
 | ||
|     # Parse command-line arguments
 | ||
|     if [ "$1" = "--update" ]; then
 | ||
|         UPDATE_MODE="true"
 | ||
|     fi
 | ||
|     
 | ||
|     # Log script entry
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script started - Update mode: $UPDATE_MODE" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Handle update mode
 | ||
|     if [ "$UPDATE_MODE" = "true" ]; then
 | ||
|         print_banner
 | ||
|         print_info "🔄 PatchMon Update Mode"
 | ||
|         echo ""
 | ||
|         
 | ||
|         # Select installation to update
 | ||
|         select_installation_to_update
 | ||
|         
 | ||
|         # Perform update
 | ||
|         update_installation
 | ||
|         
 | ||
|         exit 0
 | ||
|     fi
 | ||
|     
 | ||
|     # Normal installation mode
 | ||
|     # Check if existing installations are present
 | ||
|     local existing_installs=($(detect_installations))
 | ||
|     if [ ${#existing_installs[@]} -gt 0 ]; then
 | ||
|         print_warning "⚠️  Found ${#existing_installs[@]} existing PatchMon installation(s):"
 | ||
|         for install in "${existing_installs[@]}"; do
 | ||
|             print_info "   - $install"
 | ||
|         done
 | ||
|         echo ""
 | ||
|         print_warning "If you want to UPDATE an existing installation, run:"
 | ||
|         print_info "   sudo bash $0 --update"
 | ||
|         echo ""
 | ||
|         print_warning "If you want to create a NEW installation alongside the existing one(s), continue below."
 | ||
|         echo ""
 | ||
|         read_yes_no "Do you want to continue with NEW installation?" CONTINUE_NEW "n"
 | ||
|         
 | ||
|         if [ "$CONTINUE_NEW" != "y" ]; then
 | ||
|             print_info "Installation cancelled. Run with --update flag to update existing installations."
 | ||
|             exit 0
 | ||
|         fi
 | ||
|     fi
 | ||
|     
 | ||
|     # Run interactive setup
 | ||
|     interactive_setup
 | ||
|     
 | ||
|     # Set GitHub repo (always use public repo for self-hosted deployments)
 | ||
|     GITHUB_REPO="$DEFAULT_GITHUB_REPO"
 | ||
|     
 | ||
|     # Validate SSL setting
 | ||
|     if [ "$SSL_ENABLED" = "y" ] || [ "$SSL_ENABLED" = "yes" ]; then
 | ||
|         USE_LETSENCRYPT="true"
 | ||
|         SERVER_PROTOCOL_SEL="https"
 | ||
|         print_info "SSL enabled - will use Let's Encrypt for HTTPS"
 | ||
|         
 | ||
|         # Validate email for SSL
 | ||
|         if [ -z "$EMAIL" ]; then
 | ||
|             print_error "Email is required when SSL is enabled for Let's Encrypt"
 | ||
|             exit 1
 | ||
|         fi
 | ||
|     else
 | ||
|         USE_LETSENCRYPT="false"
 | ||
|         SERVER_PROTOCOL_SEL="http"
 | ||
|         print_info "SSL disabled - will use HTTP only"
 | ||
|     fi
 | ||
|     
 | ||
|     # Log before calling deploy_instance
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] About to call deploy_instance function" >> "$DEBUG_LOG"
 | ||
|     
 | ||
|     # Run deployment
 | ||
|     deploy_instance
 | ||
|     
 | ||
|     # Log after deploy_instance completes
 | ||
|     echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy_instance function completed" >> "$DEBUG_LOG"
 | ||
| }
 | ||
| 
 | ||
| # Show usage/help
 | ||
| show_usage() {
 | ||
|     echo "PatchMon Self-Hosting Installation & Update Script"
 | ||
|     echo "Version: $SCRIPT_VERSION"
 | ||
|     echo ""
 | ||
|     echo "Usage:"
 | ||
|     echo "  $0              # Interactive installation (default)"
 | ||
|     echo "  $0 --update     # Update existing installation"
 | ||
|     echo "  $0 --help       # Show this help message"
 | ||
|     echo ""
 | ||
|     echo "Examples:"
 | ||
|     echo "  # New installation:"
 | ||
|     echo "  sudo bash $0"
 | ||
|     echo ""
 | ||
|     echo "  # Update existing installation:"
 | ||
|     echo "  sudo bash $0 --update"
 | ||
|     echo ""
 | ||
| }
 | ||
| 
 | ||
| # Check for help flag
 | ||
| if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
 | ||
|     show_usage
 | ||
|     exit 0
 | ||
| fi
 | ||
| 
 | ||
| # Run main function
 | ||
| main "$@"
 |