#!/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.1 (Optimized and Error-Free) # Author: Anthony Garces (tony@mightybox.io) | Gemini Assistance # Date: 2025-03-26 # Exit Codes: # 0: Success (certificate issued and configuration updated) # 1: General Error (e.g., failed to issue certificate, XML validation failed) # 2: Backup/Restore Error (e.g., failed to create or restore backup) # 3: Restart Error (e.g., LiteSpeed service failed to restart) # ============================================================================== set -euo pipefail # === Configuration === 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="" # Placeholder for public IP address DOMAINS=() # Placeholder for domains (array) EMAIL="" # Placeholder for email address VERBOSE=0 # - Internal Variables - BACKUP_FILE="${LOG_DIR}/httpd_config_backup_$(date +%Y%m%d%H%M%S).xml" XML_ERROR_LOG="${LOG_DIR}/xml-edit-error.log" SCRIPT_LOG="${LOG_DIR}/ssl_manager.log" # === Cleanup Trap === trap 'log "Script interrupted. Cleaning up temporary files..."; sudo rm -f "$BACKUP_FILE"' EXIT # === Functions === log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | sudo tee -a "$SCRIPT_LOG" } log_verbose() { [[ "$VERBOSE" -eq 1 ]] && log "[VERBOSE] $1" } 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 "✔ Successfully installed '$apt_pkg'." else log "❌ ERROR: Failed to install '$apt_pkg'. Exiting." exit 1 fi fi } validate_domain() { local domain="$1" if [[ "$domain" =~ ^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$ ]]; then return 0 else return 1 fi } validate_ip() { local ip="$1" if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then return 0 else return 1 fi } validate_email() { local email="$1" if [[ "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then return 0 else return 1 fi } create_default_backup() { if [[ ! -f "$DEFAULT_CONF" ]]; then log "Creating initial backup of '$CONF_FILE' as '$DEFAULT_CONF'..." if sudo cp -a "$CONF_FILE" "$DEFAULT_CONF"; then log "✔ Initial backup created: '$DEFAULT_CONF'." else log "❌ FATAL: Failed to create initial backup '$DEFAULT_CONF'. Exiting." exit 2 fi else log "Info: Default backup '$DEFAULT_CONF' already exists. Skipping creation." fi } validate_xml() { local file_to_validate="$1" log_verbose "Validating XML structure of '$file_to_validate'..." if sudo xmllint --noout "$file_to_validate" 2>/dev/null; then log_verbose "✔ XML structure of '$file_to_validate' is valid." return 0 else log "❌ ERROR: XML structure of '$file_to_validate' is invalid." return 1 fi } validate_dns() { local domain="$1" local expected_ip="$2" log "Validating DNS resolution for '$domain'..." local resolved_ip resolved_ip=$(dig +short "$domain" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1) if [[ "$resolved_ip" == "$expected_ip" ]]; then log "✔ Domain '$domain' resolves to the expected IP '$expected_ip'." return 0 else log "❌ ERROR: Domain '$domain' does NOT resolve to the expected IP ('$expected_ip'). Resolved to '$resolved_ip'." return 1 fi } validate_http_access() { local domain="$1" log "Validating HTTP access for '$domain'..." local token token=$(openssl rand -hex 16) echo "$token" > "/var/www/webroot/ROOT/.well-known/acme-challenge/test-token" local response response=$(curl -s "http://$domain/.well-known/acme-challenge/test-token") if [[ "$response" == "$token" ]]; then log "✔ HTTP access verified for '$domain'." return 0 else log "❌ ERROR: HTTP access check failed for '$domain'. Response: '$response'." return 1 fi } issue_certificate() { local domain="$1" local email="$2" log "Issuing SSL certificate for domain '$domain' with email '$email'..." if sudo certbot certonly --standalone --preferred-challenges http -d "$domain" --non-interactive --agree-tos --email "$email"; then log "✔ SSL certificate issued successfully for '$domain'." else log "❌ ERROR: Failed to issue SSL certificate for '$domain'." exit 1 fi } update_httpd_config() { local domain="$1" local ip="$2" log "Updating httpd_config.xml for domain '$domain' with IP '$ip'..." # Create a backup of the current configuration log "Creating timestamped backup of '$CONF_FILE' as '$BACKUP_FILE'..." if sudo cp -a "$CONF_FILE" "$BACKUP_FILE"; then log "✔ Backup created: '$BACKUP_FILE'." else log "❌ ERROR: Failed to create backup of '$CONF_FILE'. Exiting." exit 2 fi # Update the listener configuration using xmlstarlet log "Adding listener for domain '$domain' with IP '$ip'..." sudo xmlstarlet ed -L \ -s "//listenerList" -t elem -n "listener" \ -s "//listener[last()]" -t elem -n "name" -v "$domain" \ -s "//listener[last()]" -t elem -n "address" -v "$ip:443" \ -s "//listener[last()]" -t elem -n "secure" -v "1" \ -s "//listener[last()]" -t elem -n "keyFile" -v "/etc/letsencrypt/live/$domain/privkey.pem" \ -s "//listener[last()]" -t elem -n "certFile" -v "/etc/letsencrypt/live/$domain/fullchain.pem" \ "$CONF_FILE" # Validate the updated configuration if validate_xml "$CONF_FILE"; then log "✔ Updated configuration is valid." else log "❌ ERROR: Updated configuration is invalid. Restoring backup..." sudo cp -a "$BACKUP_FILE" "$CONF_FILE" log "✔ Backup restored: '$CONF_FILE'." exit 1 fi } restart_litespeed() { log "Restarting LiteSpeed server..." if sudo systemctl restart lsws; then log "✔ LiteSpeed server restarted successfully." else log "❌ ERROR: Failed to restart LiteSpeed server. Restoring backup..." sudo cp -a "$BACKUP_FILE" "$CONF_FILE" log "✔ Backup restored: '$CONF_FILE'." exit 3 fi } # === Main Script Logic === log "Starting SSL Manager V2.0.1..." # Ensure log directory exists sudo mkdir -p "$LOG_DIR" || { log "❌ ERROR: Cannot create log directory '$LOG_DIR'. Check permissions."; exit 1; } sudo touch "$SCRIPT_LOG" "$XML_ERROR_LOG" sudo chown "$(whoami)":"$(id -gn)" "$SCRIPT_LOG" "$XML_ERROR_LOG" 2>/dev/null || log_verbose "Info: Could not change ownership of log files (may require sudo)." # Argument Parsing for arg in "$@"; do case $arg in --public-ip=*) PUBLIC_IP="${arg#*=}" ;; --domains=*) IFS=',' read -ra DOMAINS <<< "${arg#*=}" PRIMARY_DOMAIN="${DOMAINS[0]}" ;; --email=*) EMAIL="${arg#*=}" ;; --verbose) VERBOSE=1 log "Verbose mode enabled." ;; *) log "Invalid argument: $arg" exit 1 ;; esac done # Input Validation if [[ -z "$PRIMARY_DOMAIN" || -z "$PUBLIC_IP" || -z "$EMAIL" ]]; then log "❌ ERROR: Missing required parameters. Provide --domains, --public-ip, and --email." exit 1 fi if ! validate_domain "$PRIMARY_DOMAIN"; then log "❌ ERROR: Invalid domain '$PRIMARY_DOMAIN'." exit 1 fi if ! validate_ip "$PUBLIC_IP"; then log "❌ ERROR: Invalid IP address '$PUBLIC_IP'." exit 1 fi if ! validate_email "$EMAIL"; then log "❌ ERROR: Invalid email address '$EMAIL'." exit 1 fi # Validate DNS and HTTP Access if ! validate_dns "$PRIMARY_DOMAIN" "$PUBLIC_IP"; then log "❌ ERROR: DNS validation failed. Exiting." exit 1 fi if ! validate_http_access "$PRIMARY_DOMAIN"; then log "❌ ERROR: HTTP access validation failed. Exiting." exit 1 fi # Dependency Checks log_verbose "Checking dependencies..." check_command "xmllint" "libxml2-utils" check_command "xmlstarlet" "xmlstarlet" check_command "certbot" "certbot" # Create Default Backup create_default_backup # Issue SSL Certificate issue_certificate "$PRIMARY_DOMAIN" "$EMAIL" # Update httpd_config.xml update_httpd_config "$PRIMARY_DOMAIN" "$PUBLIC_IP" # Restart LiteSpeed restart_litespeed log "✅ SSL Manager completed successfully. Certificate issued and configuration updated." exit 0