2025-01-29 13:03:24 +00:00
#!/bin/bash
2025-03-25 18:45:01 +00:00
# ==============================================================================
# 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.
2025-03-27 16:26:05 +00:00
# Version: 2.0.4 (Idempotent, Clean Exit Messages)
2025-03-27 16:36:52 +00:00
# Author: Anthony Garces (tony@mightybox.io)
2025-03-25 18:45:01 +00:00
# Date: 2025-03-26
# ==============================================================================
2025-01-29 13:03:24 +00:00
set -euo pipefail
2025-03-25 18:45:01 +00:00
CONF_FILE = "/var/www/conf/httpd_config.xml"
DEFAULT_CONF = "/var/www/conf/httpd_config.default.xml"
SERVER_ROOT = "/var/www"
2025-01-29 13:03:24 +00:00
LOG_DIR = "/var/log/mb-ssl"
2025-03-25 18:45:01 +00:00
CERT_DIR = "/etc/letsencrypt/live"
2025-03-27 16:26:05 +00:00
PUBLIC_IP = ""
DOMAINS = ( )
EMAIL = ""
2025-03-25 18:45:01 +00:00
VERBOSE = 0
2025-08-19 16:04:45 +00:00
VHOST_NAME = "Jelastic" # Default vhost name, can be overridden with --vhost parameter
VHOSTS_DIR = " $SERVER_ROOT /conf/vhosts "
DEFAULT_DOCROOT = "/var/www/webroot/ROOT"
2025-01-29 13:03:24 +00:00
2025-03-25 18:45:01 +00:00
SCRIPT_LOG = " ${ LOG_DIR } /ssl_manager.log "
2025-03-27 16:26:05 +00:00
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
}
2025-01-29 13:03:24 +00:00
2025-03-25 18:45:01 +00:00
log( ) {
2025-03-27 16:26:05 +00:00
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
2025-01-29 13:03:24 +00:00
}
2025-03-25 18:45:01 +00:00
log_verbose( ) {
2025-03-27 16:26:05 +00:00
if [ [ " $VERBOSE " -eq 1 ] ] ; then
local timestamp = $( date '+%Y-%m-%d %H:%M:%S' )
echo " [ $timestamp ] [DEBUG] $1 " | sudo tee -a " $DEBUG_LOG "
fi
2025-01-29 13:03:24 +00:00
}
2025-03-27 16:26:05 +00:00
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 "
2025-01-29 13:03:24 +00:00
}
2025-03-27 16:26:05 +00:00
log_success( ) {
local message = " $1 "
local timestamp = $( date '+%Y-%m-%d %H:%M:%S' )
echo " [ $timestamp ] [SUCCESS] $message " | sudo tee -a " $SCRIPT_LOG "
2025-01-29 13:03:24 +00:00
}
2025-03-27 16:26:05 +00:00
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 "
2025-01-29 13:03:24 +00:00
else
2025-03-27 16:26:05 +00:00
sudo rm -f " $BACKUP_FILE "
log_success "Script completed successfully. Backup cleaned."
2025-03-20 18:07:43 +00:00
fi
2025-03-27 16:26:05 +00:00
exit $SCRIPT_EXIT_STATUS
2025-01-29 13:03:24 +00:00
}
2025-03-27 16:26:05 +00:00
trap 'on_exit' EXIT
2025-01-29 13:03:24 +00:00
2025-03-27 16:26:05 +00:00
check_command( ) {
local cmd = " $1 "
2025-08-19 16:04:45 +00:00
local pkg = " $2 "
2025-03-27 16:26:05 +00:00
if ! command -v " $cmd " & >/dev/null; then
2025-08-19 16:04:45 +00:00
log " Required command ' $cmd ' not found. Attempting to install package ' $pkg '... "
if command -v dnf & >/dev/null; then
if sudo dnf install -y " $pkg " ; then
log_success " Successfully installed ' $pkg ' via dnf "
return 0
fi
fi
if command -v yum & >/dev/null; then
if sudo yum install -y " $pkg " ; then
log_success " Successfully installed ' $pkg ' via yum "
return 0
fi
2025-03-27 16:26:05 +00:00
fi
2025-08-19 16:04:45 +00:00
log_error " Failed to install ' $pkg ' "
SCRIPT_EXIT_STATUS = 1; return 1
2025-03-20 18:07:43 +00:00
fi
}
2025-03-27 16:26:05 +00:00
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,} $ ] ] ; }
2025-03-25 18:45:01 +00:00
create_default_backup( ) {
if [ [ ! -f " $DEFAULT_CONF " ] ] ; then
log " Creating initial backup of ' $CONF_FILE ' as ' $DEFAULT_CONF '... "
2025-03-27 16:26:05 +00:00
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"
2025-03-20 18:07:43 +00:00
else
2025-03-27 16:26:05 +00:00
log " Default backup ' $DEFAULT_CONF ' already exists. Skipping creation. "
2025-03-20 18:07:43 +00:00
fi
}
2025-03-25 18:45:01 +00:00
validate_xml( ) {
2025-03-27 16:26:05 +00:00
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
2025-03-20 18:07:43 +00:00
return 1
2025-03-27 16:26:05 +00:00
}
log_verbose " XML validation passed for ' $1 ' "
2025-03-20 18:07:43 +00:00
}
2025-03-25 18:45:01 +00:00
validate_dns( ) {
local resolved_ip
2025-03-27 16:26:05 +00:00
resolved_ip = $( dig +short " $1 " | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)
[ [ " $resolved_ip " = = " $2 " ] ]
2025-03-20 18:07:43 +00:00
}
2025-03-25 18:45:01 +00:00
validate_http_access( ) {
local token
token = $( openssl rand -hex 16)
2025-08-19 16:04:45 +00:00
local acme_dir = "/var/www/webroot/ROOT/.well-known/acme-challenge"
sudo mkdir -p " $acme_dir "
echo " $token " | sudo tee " $acme_dir /test-token " >/dev/null
2025-03-27 16:26:05 +00:00
[ [ " $( curl -s " http:// $1 /.well-known/acme-challenge/test-token " ) " = = " $token " ] ]
2025-03-20 18:22:22 +00:00
}
2025-03-25 18:45:01 +00:00
issue_certificate( ) {
2025-08-19 16:04:45 +00:00
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 --webroot -w "/var/www/webroot/ROOT" -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 ' "
2025-03-20 18:22:22 +00:00
}
2025-08-19 16:04:45 +00:00
issue_certificate_san( ) {
local email = " $1 " ; shift
local primary = " $1 " ; shift
local others = ( " $@ " )
local args = ( certonly --webroot -w " $DEFAULT_DOCROOT " --non-interactive --agree-tos --email " $email " )
for d in " ${ others [@] } " ; do
args += ( -d " $d " )
done
log " Requesting SAN certificate for: $primary ${ others [*] } "
sudo certbot " ${ args [@] } " || { log_error "SAN issuance failed" ; return 1; }
return 0
}
2025-03-27 16:26:05 +00:00
2025-08-19 16:04:45 +00:00
update_httpd_config( ) {
local domain = " $1 "
local ip = " $2 "
local vhost_name = " $domain " # vhost named after the domain
local vhost_dir = " $VHOSTS_DIR / $domain "
local vhconf_file = " $vhost_dir /vhconf.xml "
log " Configuring SNI for domain ' $domain ' on existing HTTPS listener (443) "
sudo cp -a " $CONF_FILE " " $BACKUP_FILE "
# 1) Ensure virtualHostList entry exists for this domain
local vh_exists
vh_exists = $( xmlstarlet sel -t -v " /httpServerConfig/virtualHostList/virtualHost[name=' $vhost_name ']/name " " $CONF_FILE " 2>/dev/null || true )
if [ [ -z " $vh_exists " ] ] ; then
log " Adding virtualHost ' $vhost_name ' to httpd_config.xml "
# Create vhost directory
sudo mkdir -p " $vhost_dir "
# Write minimal vhconf.xml with SSL at vhost level (SNI)
cat <<EOF | sudo tee " $vhconf_file " >/dev/null
<?xml version = "1.0" encoding = "UTF-8" ?>
<virtualHostConfig>
<docRoot>$VH_ROOT /ROOT/</docRoot>
<enableGzip>1</enableGzip>
<vhssl>
<keyFile>/etc/letsencrypt/live/$domain /privkey.pem</keyFile>
<certFile>/etc/letsencrypt/live/$domain /fullchain.pem</certFile>
<certChain>1</certChain>
</vhssl>
</virtualHostConfig>
2025-03-27 16:26:05 +00:00
EOF
2025-08-19 16:04:45 +00:00
# Add virtualHost entry
sudo xmlstarlet ed -L \
-s "/httpServerConfig/virtualHostList" -t elem -n "virtualHost" \
" $CONF_FILE " || { log_error "Failed to create virtualHost node" ; return 1; }
sudo xmlstarlet ed -L \
-s "/httpServerConfig/virtualHostList/virtualHost[last()]" -t elem -n "name" -v " $vhost_name " \
" $CONF_FILE " || { log_error "Failed to set virtualHost name" ; return 1; }
sudo xmlstarlet ed -L \
-s "/httpServerConfig/virtualHostList/virtualHost[last()]" -t elem -n "vhRoot" -v " $SERVER_ROOT /webroot/ " \
" $CONF_FILE " || { log_error "Failed to set vhRoot" ; return 1; }
sudo xmlstarlet ed -L \
-s "/httpServerConfig/virtualHostList/virtualHost[last()]" -t elem -n "configFile" -v " $vhost_dir /vhconf.xml " \
" $CONF_FILE " || { log_error "Failed to set configFile" ; return 1; }
sudo xmlstarlet ed -L \
-s "/httpServerConfig/virtualHostList/virtualHost[last()]" -t elem -n "allowSymbolLink" -v "1" \
" $CONF_FILE " || { log_error "Failed to set allowSymbolLink" ; return 1; }
sudo xmlstarlet ed -L \
-s "/httpServerConfig/virtualHostList/virtualHost[last()]" -t elem -n "enableScript" -v "1" \
" $CONF_FILE " || { log_error "Failed to set enableScript" ; return 1; }
sudo xmlstarlet ed -L \
-s "/httpServerConfig/virtualHostList/virtualHost[last()]" -t elem -n "restrained" -v "1" \
" $CONF_FILE " || { log_error "Failed to set restrained" ; return 1; }
else
log " virtualHost ' $vhost_name ' already exists "
fi
# 2) Map domain to this vhost in existing HTTPS listener
local https_listener_exists
https_listener_exists = $( xmlstarlet sel -t -v "/httpServerConfig/listenerList/listener[name='HTTPS']/name" " $CONF_FILE " 2>/dev/null || true )
local https6_listener_exists
https6_listener_exists = $( xmlstarlet sel -t -v "/httpServerConfig/listenerList/listener[name='HTTPS-ipv6']/name" " $CONF_FILE " 2>/dev/null || true )
if [ [ -z " $https_listener_exists " ] ] ; then
log_error " HTTPS listener not found in $CONF_FILE . Cannot proceed with SNI mapping. "
SCRIPT_EXIT_STATUS = 1; return 1
fi
for ln in HTTPS ${ https6_listener_exists : +HTTPS-ipv6 } ; do
local mapping_exists
mapping_exists = $( xmlstarlet sel -t -v " /httpServerConfig/listenerList/listener[name=' $ln ']/vhostMapList/vhostMap[domain=' $domain ']/domain " " $CONF_FILE " 2>/dev/null || true )
if [ [ -z " $mapping_exists " ] ] ; then
log " Adding vhostMap for domain ' $domain ' to $ln listener "
# Ensure vhostMapList exists
local vhost_maplist_exists
vhost_maplist_exists = $( xmlstarlet sel -t -v " /httpServerConfig/listenerList/listener[name=' $ln ']/vhostMapList/vhostMap/domain " " $CONF_FILE " 2>/dev/null || true )
if [ [ -z " $vhost_maplist_exists " ] ] ; then
sudo xmlstarlet ed -L -s " /httpServerConfig/listenerList/listener[name=' $ln '] " -t elem -n "vhostMapList" " $CONF_FILE " || { log_error " Failed to create vhostMapList for $ln " ; return 1; }
fi
# Add vhostMap
sudo xmlstarlet ed -L -s " /httpServerConfig/listenerList/listener[name=' $ln ']/vhostMapList " -t elem -n "vhostMap" " $CONF_FILE " || { log_error " Failed to create vhostMap for $ln " ; return 1; }
sudo xmlstarlet ed -L -s " /httpServerConfig/listenerList/listener[name=' $ln ']/vhostMapList/vhostMap[last()] " -t elem -n "vhost" -v " $vhost_name " " $CONF_FILE " || { log_error " Failed to set vhost in map for $ln " ; return 1; }
sudo xmlstarlet ed -L -s " /httpServerConfig/listenerList/listener[name=' $ln ']/vhostMapList/vhostMap[last()] " -t elem -n "domain" -v " $domain " " $CONF_FILE " || { log_error " Failed to set domain in map for $ln " ; return 1; }
else
log " vhostMap for ' $domain ' already exists on $ln listener "
fi
done
# 3) Validate final config
if ! xmllint --noout " $CONF_FILE " 2>/dev/null; then
log_error "Invalid XML structure after SNI configuration. Restoring backup..."
sudo cp -a " $BACKUP_FILE " " $CONF_FILE "
SCRIPT_EXIT_STATUS = 1; return 1
fi
log_success " SNI configured for ' $domain ' on port 443 with vhost ' $vhost_name ' "
2025-03-27 16:26:05 +00:00
}
cleanup_xml( ) {
local domain = " $1 "
log "Cleaning up XML structure..."
sudo cp -a " $CONF_FILE " " $BACKUP_FILE "
# Remove any incorrectly placed vhostMapList elements
2025-03-25 18:45:01 +00:00
sudo xmlstarlet ed -L \
2025-03-27 16:26:05 +00:00
-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..."
2025-03-25 18:45:01 +00:00
sudo cp -a " $BACKUP_FILE " " $CONF_FILE "
2025-03-27 16:26:05 +00:00
SCRIPT_EXIT_STATUS = 1
return 1
2025-03-20 18:22:22 +00:00
fi
2025-03-27 16:26:05 +00:00
log_success "XML structure cleaned up successfully"
2025-03-20 18:22:22 +00:00
}
restart_litespeed( ) {
2025-03-25 18:45:01 +00:00
log "Restarting LiteSpeed server..."
2025-03-27 16:26:05 +00:00
sudo systemctl restart lsws || {
log_error "Failed to restart LiteSpeed. Restoring backup..."
2025-03-25 18:45:01 +00:00
sudo cp -a " $BACKUP_FILE " " $CONF_FILE "
2025-03-27 16:26:05 +00:00
SCRIPT_EXIT_STATUS = 3
return 1
}
log_success "LiteSpeed server restarted successfully"
2025-03-20 18:07:43 +00:00
}
2025-03-25 18:45:01 +00:00
# === Main Script Logic ===
2025-03-27 16:26:05 +00:00
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 " ; ;
2025-08-19 16:04:45 +00:00
--vhost= *) VHOST_NAME = " ${ arg #*= } " ; log_verbose " Set vhost name: $VHOST_NAME " ; ;
2025-03-27 16:26:05 +00:00
--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..."
2025-08-19 16:04:45 +00:00
check_command xmllint libxml2
2025-03-27 16:26:05 +00:00
check_command xmlstarlet xmlstarlet
check_command certbot certbot
2025-08-19 16:04:45 +00:00
check_command dig bind-utils
check_command curl curl
check_command openssl openssl
2025-03-27 16:26:05 +00:00
create_default_backup
for domain in " ${ DOMAINS [@] } " ; do
2025-08-19 16:04:45 +00:00
issue_certificate " $domain " " $EMAIL "
2025-03-27 16:26:05 +00:00
update_httpd_config " $domain " " $PUBLIC_IP "
cleanup_xml " $domain "
done
restart_litespeed
log_success "SSL Manager completed successfully"
SCRIPT_EXIT_STATUS = 0
}
# === Entry Point ===
2025-08-19 16:04:45 +00:00
main " $@ "