#!/bin/bash # ============================================================================== # Script Name: ssl_manager.sh # Description: Automates SSL certificate issuance and updates LiteSpeed's httpd_config.xml. # Ensures robust backups, validation, and error-free updates. # Version: 2.0.4 (Idempotent, Clean Exit Messages) # Author: Anthony Garces (tony@mightybox.io) # Date: 2025-03-26 # ============================================================================== set -euo pipefail CONF_FILE="/var/www/conf/httpd_config.xml" DEFAULT_CONF="/var/www/conf/httpd_config.default.xml" SERVER_ROOT="/var/www" LOG_DIR="/var/log/mb-ssl" CERT_DIR="/etc/letsencrypt/live" PUBLIC_IP="" DOMAINS=() EMAIL="" VERBOSE=0 SCRIPT_LOG="${LOG_DIR}/ssl_manager.log" ERROR_LOG="${LOG_DIR}/ssl_manager-error.log" DEBUG_LOG="${LOG_DIR}/ssl_manager-debug.log" BACKUP_FILE="${LOG_DIR}/httpd_config_backup_$(date +%Y%m%d%H%M%S).xml" SCRIPT_EXIT_STATUS=0 setup_logging() { # Create log directory if it doesn't exist sudo mkdir -p "$LOG_DIR" || { echo "❌ ERROR: Cannot create log directory '$LOG_DIR'. Check permissions."; exit 1; } # Set proper permissions sudo chown -R "$(whoami)":"$(id -gn)" "$LOG_DIR" sudo chmod 755 "$LOG_DIR" # Create log files with proper permissions touch "$SCRIPT_LOG" "$ERROR_LOG" "$DEBUG_LOG" chmod 644 "$SCRIPT_LOG" "$ERROR_LOG" "$DEBUG_LOG" # Add log rotation if log files are too large (>10MB) for log_file in "$SCRIPT_LOG" "$ERROR_LOG" "$DEBUG_LOG"; do if [ -f "$log_file" ] && [ "$(stat -f%z "$log_file" 2>/dev/null || stat -c%s "$log_file")" -gt 10485760 ]; then mv "$log_file" "${log_file}.$(date +%Y%m%d)" touch "$log_file" chmod 644 "$log_file" gzip "${log_file}.$(date +%Y%m%d)" fi done } log() { local level="INFO" local message="$1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') # Log to main log file echo "[$timestamp] [$level] $message" | sudo tee -a "$SCRIPT_LOG" # Log errors to error log file if [[ "$message" == *"ERROR"* ]] || [[ "$message" == *"❌"* ]]; then echo "[$timestamp] [$level] $message" | sudo tee -a "$ERROR_LOG" fi } log_verbose() { if [[ "$VERBOSE" -eq 1 ]]; then local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [DEBUG] $1" | sudo tee -a "$DEBUG_LOG" fi } log_error() { local message="$1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [ERROR] $message" | sudo tee -a "$ERROR_LOG" "$SCRIPT_LOG" } log_success() { local message="$1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [SUCCESS] $message" | sudo tee -a "$SCRIPT_LOG" } on_exit() { if [[ $SCRIPT_EXIT_STATUS -ne 0 ]]; then log_error "Script ended with error. Exit Code: $SCRIPT_EXIT_STATUS" log_error "Backup file retained: $BACKUP_FILE" else sudo rm -f "$BACKUP_FILE" log_success "Script completed successfully. Backup cleaned." fi exit $SCRIPT_EXIT_STATUS } trap 'on_exit' EXIT check_command() { local cmd="$1" local apt_pkg="$2" if ! command -v "$cmd" &>/dev/null; then log "Required command '$cmd' not found. Attempting to install package '$apt_pkg'..." if sudo yum install -y "$apt_pkg"; then log_success "Successfully installed '$apt_pkg'" else log_error "Failed to install '$apt_pkg'" SCRIPT_EXIT_STATUS=1; return 1 fi fi } validate_domain() { [[ "$1" =~ ^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$ ]]; } validate_ip() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; } validate_email() { [[ "$1" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; } create_default_backup() { if [[ ! -f "$DEFAULT_CONF" ]]; then log "Creating initial backup of '$CONF_FILE' as '$DEFAULT_CONF'..." sudo cp -a "$CONF_FILE" "$DEFAULT_CONF" || { log_error "FATAL: Failed to create initial backup '$DEFAULT_CONF'" SCRIPT_EXIT_STATUS=2; return 1 } log_success "Created initial backup successfully" else log "Default backup '$DEFAULT_CONF' already exists. Skipping creation." fi } validate_xml() { log_verbose "Validating XML structure of '$1'..." sudo xmllint --noout "$1" 2>/dev/null || { log_error "Invalid XML structure in '$1'" SCRIPT_EXIT_STATUS=1 return 1 } log_verbose "XML validation passed for '$1'" } validate_dns() { local resolved_ip resolved_ip=$(dig +short "$1" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1) [[ "$resolved_ip" == "$2" ]] } validate_http_access() { local token token=$(openssl rand -hex 16) echo "$token" > "/var/www/webroot/ROOT/.well-known/acme-challenge/test-token" [[ "$(curl -s "http://$1/.well-known/acme-challenge/test-token")" == "$token" ]] } issue_certificate() { if [[ -f "$CERT_DIR/$1/fullchain.pem" ]]; then log_success "Certificate already exists for '$1'. Skipping issuance." return fi log "Issuing SSL certificate for domain '$1' with email '$2'..." sudo certbot certonly --standalone --preferred-challenges http -d "$1" --non-interactive --agree-tos --email "$2" || { log_error "Failed to issue certificate for '$1'" SCRIPT_EXIT_STATUS=1; return 1 } log_success "Certificate successfully issued for '$1'" } update_httpd_config() { local domain="$1" local ip="$2" local listener_name="HTTPS-$domain" local vhost_name="Jelastic" log "Checking if listener exists for '$listener_name'..." local existing existing=$(xmlstarlet sel -t -v "/httpServerConfig/listenerList/listener[name='$listener_name']/name" "$CONF_FILE" 2>/dev/null || true) if [[ -n "$existing" ]]; then log "Listener '$listener_name' already exists. Checking for vhost mapping..." local vhost_map_exists vhost_map_exists=$(xmlstarlet sel -t -v "/httpServerConfig/listenerList/listener[name='$listener_name']/vhostMapList/vhostMap/domain" "$CONF_FILE" 2>/dev/null || true) if [[ -z "$vhost_map_exists" ]]; then log "Adding vhostMapList to existing listener for '$listener_name'..." sudo cp -a "$CONF_FILE" "$BACKUP_FILE" # Create a temporary XML file with the new vhostMapList local temp_xml=$(mktemp) cat > "$temp_xml" << EOF $vhost_name $domain EOF # First validate the temporary XML if ! xmllint --noout "$temp_xml" 2>/dev/null; then log_error "Invalid temporary XML structure" rm -f "$temp_xml" SCRIPT_EXIT_STATUS=1 return 1 fi # Insert the vhostMapList into the existing listener sudo xmlstarlet ed -L \ -i "/httpServerConfig/listenerList/listener[name='$listener_name']" \ -t elem -n "vhostMapList" \ -v "$(xmlstarlet sel -t -c "//vhostMapList/*" "$temp_xml")" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to insert vhostMapList" SCRIPT_EXIT_STATUS=1 return 1 } rm -f "$temp_xml" # Validate the modified config if ! xmllint --noout "$CONF_FILE" 2>/dev/null; then log_error "Invalid XML structure after modification. Restoring backup..." sudo cp -a "$BACKUP_FILE" "$CONF_FILE" SCRIPT_EXIT_STATUS=1 return 1 fi log_success "Successfully added vhostMapList to existing listener" else log_success "vhostMap already present. No update needed." fi return fi log "Updating httpd_config.xml for domain '$domain' with IP '$ip'..." sudo cp -a "$CONF_FILE" "$BACKUP_FILE" # Create a temporary XML file with the new listener configuration local temp_xml=$(mktemp) cat > "$temp_xml" << EOF $listener_name
*:443
1 /etc/letsencrypt/live/$domain/privkey.pem /etc/letsencrypt/live/$domain/fullchain.pem $vhost_name $domain
EOF # First validate the temporary XML if ! xmllint --noout "$temp_xml" 2>/dev/null; then log_error "Invalid temporary XML structure" rm -f "$temp_xml" SCRIPT_EXIT_STATUS=1 return 1 fi # Insert the new listener into the configuration using a different approach sudo xmlstarlet ed -L \ -s "//listenerList" \ -t elem -n "listener" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to create new listener element" SCRIPT_EXIT_STATUS=1 return 1 } # Now add each element individually sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]" \ -t elem -n "name" \ -v "$listener_name" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add name element" SCRIPT_EXIT_STATUS=1 return 1 } sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]" \ -t elem -n "address" \ -v "*:443" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add address element" SCRIPT_EXIT_STATUS=1 return 1 } sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]" \ -t elem -n "secure" \ -v "1" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add secure element" SCRIPT_EXIT_STATUS=1 return 1 } sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]" \ -t elem -n "keyFile" \ -v "/etc/letsencrypt/live/$domain/privkey.pem" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add keyFile element" SCRIPT_EXIT_STATUS=1 return 1 } sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]" \ -t elem -n "certFile" \ -v "/etc/letsencrypt/live/$domain/fullchain.pem" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add certFile element" SCRIPT_EXIT_STATUS=1 return 1 } # Add vhostMapList and its children sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]" \ -t elem -n "vhostMapList" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add vhostMapList element" SCRIPT_EXIT_STATUS=1 return 1 } sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]/vhostMapList" \ -t elem -n "vhostMap" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add vhostMap element" SCRIPT_EXIT_STATUS=1 return 1 } sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]/vhostMapList/vhostMap" \ -t elem -n "vhost" \ -v "$vhost_name" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add vhost element" SCRIPT_EXIT_STATUS=1 return 1 } sudo xmlstarlet ed -L \ -s "//listenerList/listener[last()]/vhostMapList/vhostMap" \ -t elem -n "domain" \ -v "$domain" \ "$CONF_FILE" || { rm -f "$temp_xml" log_error "Failed to add domain element" SCRIPT_EXIT_STATUS=1 return 1 } rm -f "$temp_xml" # Validate the modified config if ! xmllint --noout "$CONF_FILE" 2>/dev/null; then log_error "Invalid XML structure after modification. Restoring backup..." sudo cp -a "$BACKUP_FILE" "$CONF_FILE" SCRIPT_EXIT_STATUS=1 return 1 fi log_success "Listener + vhostMap added for '$listener_name'" } cleanup_xml() { local domain="$1" log "Cleaning up XML structure..." sudo cp -a "$CONF_FILE" "$BACKUP_FILE" # Remove any incorrectly placed vhostMapList elements sudo xmlstarlet ed -L \ -d "//listenerList/vhostMapList[not(parent::listener)]" \ "$CONF_FILE" || { log_error "Failed to clean up XML structure" SCRIPT_EXIT_STATUS=1 return 1 } # Validate the cleaned config if ! xmllint --noout "$CONF_FILE" 2>/dev/null; then log_error "Invalid XML structure after cleanup. Restoring backup..." sudo cp -a "$BACKUP_FILE" "$CONF_FILE" SCRIPT_EXIT_STATUS=1 return 1 fi log_success "XML structure cleaned up successfully" } restart_litespeed() { log "Restarting LiteSpeed server..." sudo systemctl restart lsws || { log_error "Failed to restart LiteSpeed. Restoring backup..." sudo cp -a "$BACKUP_FILE" "$CONF_FILE" SCRIPT_EXIT_STATUS=3 return 1 } log_success "LiteSpeed server restarted successfully" } # === Main Script Logic === main() { # Setup logging first setup_logging log "Starting SSL Manager V2.0.4" # Parse parameters for arg in "$@"; do case $arg in --public-ip=*) PUBLIC_IP="${arg#*=}"; log_verbose "Set public IP: $PUBLIC_IP";; --domain=*) PRIMARY_DOMAIN="${arg#*=}"; DOMAINS=("$PRIMARY_DOMAIN"); log_verbose "Set primary domain: $PRIMARY_DOMAIN";; --domains=*) IFS=',' read -ra DOMAINS <<< "${arg#*=}"; PRIMARY_DOMAIN="${DOMAINS[0]}"; log_verbose "Set domains: ${DOMAINS[*]}";; --email=*) EMAIL="${arg#*=}"; log_verbose "Set email: $EMAIL";; --verbose) VERBOSE=1; log "Verbose mode enabled";; *) log_error "Invalid argument: $arg"; SCRIPT_EXIT_STATUS=1; exit 1;; esac done [[ -z "$PRIMARY_DOMAIN" || -z "$PUBLIC_IP" || -z "$EMAIL" ]] && { log_error "Missing required parameters. Provide --domains, --public-ip, and --email." SCRIPT_EXIT_STATUS=1; exit 1 } validate_domain "$PRIMARY_DOMAIN" || { log_error "Invalid domain '$PRIMARY_DOMAIN'"; SCRIPT_EXIT_STATUS=1; exit 1; } validate_ip "$PUBLIC_IP" || { log_error "Invalid IP '$PUBLIC_IP'"; SCRIPT_EXIT_STATUS=1; exit 1; } validate_email "$EMAIL" || { log_error "Invalid email '$EMAIL'"; SCRIPT_EXIT_STATUS=1; exit 1; } validate_dns "$PRIMARY_DOMAIN" "$PUBLIC_IP" || { log_error "DNS validation failed"; SCRIPT_EXIT_STATUS=1; exit 1; } validate_http_access "$PRIMARY_DOMAIN" || { log_error "HTTP access validation failed"; SCRIPT_EXIT_STATUS=1; exit 1; } log_verbose "Checking dependencies..." check_command xmllint libxml2-utils check_command xmlstarlet xmlstarlet check_command certbot certbot create_default_backup issue_certificate "$PRIMARY_DOMAIN" "$EMAIL" for domain in "${DOMAINS[@]}"; do update_httpd_config "$domain" "$PUBLIC_IP" cleanup_xml "$domain" done restart_litespeed log_success "SSL Manager completed successfully" SCRIPT_EXIT_STATUS=0 } # === Entry Point === main "$@"