#!/bin/bash # ============================================================================== # Script Name: ssl_remover.sh # Description: Removes SSL certificates and cleans up LiteSpeed configurations. # Ensures safe removal of listeners, virtual hosts, and certificates. # Version: 2.0.0 (Professional-Grade Optimized) # Author: Anthony Garces (tony@mightybox.io) # Date: 2025-03-26 # Exit Codes: # 0: Success (SSL removed and configuration cleaned up) # 1: General Error (e.g., invalid parameters, XML validation failed) # 2: Backup/Restore Error (e.g., failed to create backup) # 3: Restart Error (e.g., LiteSpeed service failed to restart) # ============================================================================== set -euo pipefail # === Configuration === CONF_FILE="/var/www/conf/httpd_config.xml" BACKUP_DIR="/var/www/conf/backups" LOG_DIR="/var/log/mb-ssl" CERT_DIR="/etc/letsencrypt/live" SCRIPT_LOG="${LOG_DIR}/ssl-remover.log" ERROR_LOG="${LOG_DIR}/ssl-remover-error.log" DEBUG_LOG="${LOG_DIR}/ssl-remover-debug.log" VERBOSE=0 # Validate configuration file exists and is readable if [[ ! -f "$CONF_FILE" ]]; then echo "❌ ERROR: Configuration file '$CONF_FILE' does not exist" exit 1 fi if [[ ! -r "$CONF_FILE" ]]; then echo "❌ ERROR: Configuration file '$CONF_FILE' is not readable" exit 1 fi # Validate required tools are available for tool in xmlstarlet xmllint certbot; do if ! command -v "$tool" >/dev/null 2>&1; then echo "❌ ERROR: Required tool '$tool' is not installed" exit 1 fi done # === Functions === check_command() { local cmd="$1" local pkg="$2" if ! command -v "$cmd" &>/dev/null; then log "⚠ Required command '$cmd' not found. Attempting to install package '$pkg'..." # Try dnf first (AlmaLinux) if command -v dnf &>/dev/null; then if sudo dnf install -y "$pkg"; then log "✔ Successfully installed '$pkg' using dnf." return 0 fi fi # Fallback to yum (CentOS) if command -v yum &>/dev/null; then if sudo yum install -y "$pkg"; then log "✔ Successfully installed '$pkg' using yum." return 0 fi fi log "❌ ERROR: Failed to install '$pkg' using either dnf or yum. Exiting." exit 1 fi } 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" | tee -a "$SCRIPT_LOG" # Log errors to error log file if [[ "$message" == *"ERROR"* ]] || [[ "$message" == *"❌"* ]]; then echo "[$timestamp] [$level] $message" >> "$ERROR_LOG" fi } log_verbose() { if [[ "$VERBOSE" -eq 1 ]]; then local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [DEBUG] $1" | tee -a "$DEBUG_LOG" fi } log_error() { local message="$1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [ERROR] $message" | tee -a "$ERROR_LOG" "$SCRIPT_LOG" } log_success() { local message="$1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [SUCCESS] $message" | tee -a "$SCRIPT_LOG" } send_email() { local subject="$1" local body="$2" local recipient="${EMAIL:-}" if [[ -n "$recipient" ]]; then log "Sending email notification to $recipient..." curl -s "https://api.postmarkapp.com/email" \ -X POST \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "X-Postmark-Server-Token: d88b25c4-2fdb-43d3-9097-f6c655a9742b" \ -d "{ \"From\": \"admin@mightybox.io\", \"To\": \"$recipient\", \"Subject\": \"$subject\", \"HtmlBody\": \"$body\", \"MessageStream\": \"outbound\" }" > /dev/null && log "Email sent." || log "Email failed." fi } validate_domain() { local domain="$1" if [[ "$domain" =~ ^([a-zA-Z0-9](-*[a-zA-Z0-9])*\.)+[a-zA-Z]{2,}$ ]]; then return 0 else return 1 fi } backup_config() { local timestamp=$(date +%Y%m%d%H%M%S) local backup_file="${BACKUP_DIR}/httpd_config.pre-removal-${timestamp}.xml" # Create backup directory with proper permissions sudo mkdir -p "$BACKUP_DIR" || { log_error "Failed to create backup directory '$BACKUP_DIR'"; exit 2; } sudo chown -R "$(whoami)":"$(id -gn)" "$BACKUP_DIR" sudo chmod 755 "$BACKUP_DIR" if cp "$CONF_FILE" "$backup_file"; then log "Config backup saved to $backup_file" else log_error "Failed to create backup '$backup_file'. Exiting." exit 2 fi } remove_certificate() { local domain="$1" log "Checking for certificate for domain '$domain'..." # Validate domain format first if ! validate_domain "$domain"; then log_error "Invalid domain format: '$domain'" return 1 fi # Check if certbot is available and working if ! certbot --version >/dev/null 2>&1; then log_error "certbot is not available or not working properly" return 1 fi # Check for certificate existence with better error handling if ! certbot certificates | grep -q "Domains: $domain"; then log "No certificate found for '$domain'. Skipping removal." return 0 fi log "Found certificate for '$domain'. Proceeding with removal..." # Create backup of certificate files before removal local cert_backup_dir="${BACKUP_DIR}/certificates/${domain}_$(date +%Y%m%d%H%M%S)" sudo mkdir -p "$cert_backup_dir" if [[ -d "/etc/letsencrypt/live/$domain" ]]; then sudo cp -r "/etc/letsencrypt/live/$domain" "$cert_backup_dir/" fi if [[ -d "/etc/letsencrypt/archive/$domain" ]]; then sudo cp -r "/etc/letsencrypt/archive/$domain" "$cert_backup_dir/" fi # Attempt to remove certificate if ! certbot delete --cert-name "$domain" --non-interactive; then log_error "Failed to remove certificate for '$domain'" return 1 fi # Verify certificate removal if certbot certificates | grep -q "Domains: $domain"; then log_error "Certificate removal verification failed for '$domain'" return 1 fi # Remove certificate files with verification local files_removed=0 if [[ -d "/etc/letsencrypt/live/$domain" ]]; then sudo rm -rf "/etc/letsencrypt/live/$domain"* ((files_removed++)) fi if [[ -d "/etc/letsencrypt/archive/$domain" ]]; then sudo rm -rf "/etc/letsencrypt/archive/$domain"* ((files_removed++)) fi if [[ $files_removed -eq 0 ]]; then log "No certificate files found to remove for '$domain'" else log_success "Certificate successfully removed for '$domain'" log_verbose "Removed certificate files from /etc/letsencrypt/live/$domain and /etc/letsencrypt/archive/$domain" fi return 0 } cleanup_listeners() { local domain="$1" local listener_name="HTTPS-$domain" log "Starting cleanup for listener '$listener_name'..." # Validate domain format if ! validate_domain "$domain"; then log_error "Invalid domain format: '$domain'" return 1 fi # Create backup directory with proper permissions sudo mkdir -p "$BACKUP_DIR" || { log_error "Failed to create backup directory '$BACKUP_DIR'"; return 1; } sudo chown -R "$(whoami)":"$(id -gn)" "$BACKUP_DIR" sudo chmod 755 "$BACKUP_DIR" # First backup the config local backup_file="${BACKUP_DIR}/httpd_config.pre-removal-$(date +%Y%m%d%H%M%S).xml" cp "$CONF_FILE" "$backup_file" || { log_error "Failed to create backup before cleanup" return 1 } log_verbose "Created backup at: $backup_file" # Verify listener exists before attempting removal if ! awk ' // { in_listener=1; buffer="" } in_listener { buffer = buffer $0 ORS } /<\/listener>/ { if (in_listener && buffer ~ /'"$domain"'/) { print buffer } in_listener=0 }' "$CONF_FILE" >/dev/null 2>&1; then log "Listener for '$domain' not found in configuration. Skipping removal." rm -f "$backup_file" return 0 fi # Remove the listener block using xmlstarlet if ! sudo xmlstarlet ed --inplace -d "//listener[contains(., '$domain')]" "$CONF_FILE"; then log_error "Failed to remove listener for '$domain'" cp "$backup_file" "$CONF_FILE" return 1 fi log_verbose "Removed listener block for domain: $domain" # Verify the listener was actually removed if awk ' // { in_listener=1; buffer="" } in_listener { buffer = buffer $0 ORS } /<\/listener>/ { if (in_listener && buffer ~ /'"$domain"'/) { print buffer } in_listener=0 }' "$CONF_FILE" >/dev/null 2>&1; then log_error "Listener for '$domain' still exists after removal attempt" cp "$backup_file" "$CONF_FILE" return 1 fi # Validate XML after removal if ! xmllint --noout "$CONF_FILE" 2>/dev/null; then log_error "Invalid XML structure after listener removal. Restoring backup..." cp "$backup_file" "$CONF_FILE" return 1 fi log_verbose "XML validation passed after listener removal" # Set proper permissions on the config file sudo chown litespeed:litespeed "$CONF_FILE" sudo chmod 644 "$CONF_FILE" log_success "Successfully removed listener for '$domain'" rm -f "$backup_file" return 0 } verify_cleanup_state() { local domain="$1" local listener_name="HTTPS-$domain" # Check certificate state local cert_exists=0 if certbot certificates | grep -q "Domains: $domain"; then cert_exists=1 fi # Check listener state - improved check local listener_exists=0 if xmlstarlet sel -t -c "//listenerList/listener[name='$listener_name']" "$CONF_FILE" >/dev/null 2>&1; then listener_exists=1 fi # Log current state log_verbose "Current state for domain '$domain':" log_verbose "Certificate exists: $cert_exists" log_verbose "Listener exists: $listener_exists" # Return state code echo "$cert_exists$listener_exists" } validate_xml_structure() { local file="$1" # Check for basic XML structure if ! xmllint --noout "$file" 2>/dev/null; then log_error "Invalid XML structure in $file" return 1 fi # Check for required root elements if ! xmlstarlet sel -t -v "//httpServerConfig" "$file" >/dev/null 2>&1; then log_error "Missing httpServerConfig root element in $file" return 1 fi # Check for required sections local required_sections=("listenerList" "virtualHostList") for section in "${required_sections[@]}"; do if ! xmlstarlet sel -t -v "//$section" "$file" >/dev/null 2>&1; then log_error "Missing required section '$section' in $file" return 1 fi done # Check for orphaned elements if xmlstarlet sel -t -v "//vhostMapList[not(parent::listener)]" "$file" >/dev/null 2>&1; then log_error "Found orphaned vhostMapList elements in $file" return 1 fi return 0 } cleanup_xml() { local file="$1" local temp_file="${file}.tmp" # Create temporary copy cp "$file" "$temp_file" || { log_error "Failed to create temporary file for XML cleanup" return 1 } # Remove orphaned elements if ! xmlstarlet ed -L \ -d "//vhostMapList[not(parent::listener)]" \ -d "//listener[not(.//vhostMapList)]" \ "$temp_file"; then log_error "Failed to clean up orphaned elements" rm -f "$temp_file" return 1 fi # Validate cleaned XML if ! validate_xml_structure "$temp_file"; then log_error "Invalid XML structure after cleanup" rm -f "$temp_file" return 1 fi # Move temporary file to actual config file if ! mv "$temp_file" "$file"; then log_error "Failed to update configuration file" rm -f "$temp_file" return 1 fi # Set proper permissions sudo chown litespeed:litespeed "$file" sudo chmod 644 "$file" return 0 } validate_xml() { log "Validating XML configuration..." # First check with xmlstarlet if ! sudo xmlstarlet val --well-formed "$CONF_FILE" >/dev/null 2>&1; then log "❌ ERROR: XML configuration is not well-formed according to xmlstarlet. Check backups." return 1 fi # Then check with xmllint for additional validation if ! sudo xmllint --noout "$CONF_FILE" 2>/dev/null; then log "❌ ERROR: XML configuration is invalid according to xmllint. Check backups." return 1 fi log "✔ XML configuration is valid (verified by both xmlstarlet and xmllint)." return 0 } 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." return 3 fi } # === Main Script Logic === main() { declare -a DOMAINS EMAIL="" # Setup logging first setup_logging log "Starting SSL Removal Process" # Check and install required dependencies log_verbose "Checking dependencies..." check_command "xmlstarlet" "xmlstarlet" check_command "xmllint" "libxml2-utils" check_command "certbot" "certbot" # Parse parameters while [[ $# -gt 0 ]]; do case "$1" in --domains=*) IFS=',' read -ra DOMAINS <<< "${1#*=}" log_verbose "Parsed domains: ${DOMAINS[*]}" ;; --email=*) EMAIL="${1#*=}" log_verbose "Set email notification to: $EMAIL" ;; --verbose) VERBOSE=1 log "Verbose mode enabled" ;; *) log_error "Invalid parameter: $1" exit 1 ;; esac shift done # Validate input if [[ ${#DOMAINS[@]} -eq 0 ]]; then log_error "--domains parameter is required" exit 1 fi # Process each domain for domain in "${DOMAINS[@]}"; do log "Processing domain: $domain" # Validate domain format if ! validate_domain "$domain"; then log_error "Invalid domain '$domain'. Skipping." continue fi # Check current state local state=$(verify_cleanup_state "$domain") log_verbose "Current state code: $state" case "$state" in "00") # No certificate, no listener log "Domain '$domain' is already clean. No action needed." continue ;; "01") # No certificate, listener exists log "Found orphaned listener for '$domain'. Removing..." if ! cleanup_listeners "$domain"; then log_error "Failed to remove orphaned listener for '$domain'" continue fi ;; "10") # Certificate exists, no listener log "Found orphaned certificate for '$domain'. Removing..." if ! remove_certificate "$domain"; then log_error "Failed to remove orphaned certificate for '$domain'" continue fi ;; "11") # Both certificate and listener exist log "Found both certificate and listener for '$domain'. Removing both..." if ! remove_certificate "$domain"; then log "⚠ Warning: Failed to remove certificate for '$domain'. Continuing with listener cleanup..." fi if ! cleanup_listeners "$domain"; then log_error "Failed to clean up listeners for '$domain'" continue fi ;; esac done # Validate and clean up XML structure if ! validate_xml_structure "$CONF_FILE"; then log "Cleaning up XML structure..." if ! cleanup_xml "$CONF_FILE"; then log_error "Failed to clean up XML structure" exit 1 fi fi # Validate final XML configuration if validate_xml; then restart_litespeed log_success "SSL Removal completed successfully for domains: ${DOMAINS[*]}" send_email "SSL Removal Complete" "Successfully removed SSL for domains: ${DOMAINS[*]}" else log_error "SSL removed but configuration validation failed for domains: ${DOMAINS[*]}" send_email "SSL Removal Warning" "SSL removed but configuration validation failed for domains: ${DOMAINS[*]}" exit 1 fi } # === Entry Point === main "$@"