Files
patchmon.net/setup.sh
2025-10-22 14:15:49 +01:00

2709 lines
91 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 "$@"