565 lines
18 KiB
Bash
565 lines
18 KiB
Bash
#!/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 '
|
|
/<listener>/ { 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 '
|
|
/<listener>/ { 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 "$@" |