MailcowBackup/backup/restore.sh

661 lines
22 KiB
Bash
Executable File

#!/bin/sh
#######
### restore mailcow from borgbackup repository
### this assumes three things:
### 1. standard mailcow-dockerized setup as per the docs
### 2. backups made using the backup script from this git repo
### 3. backups already downloaded from your borg repo
#######
### text-formatting presets
if command -v tput >/dev/null; then
bold=$(tput bold)
cyan=$(tput bold)$(tput setaf 6)
err=$(tput bold)$(tput setaf 1)
info=$(tput sgr0)
magenta=$(tput sgr0)$(tput setaf 5)
norm=$(tput sgr0)
ok=$(tput setaf 2)
width=$(tput cols)
yellow=$(tput sgr0)$(tput setaf 3)
else
bold=''
cyan=''
err=''
info=''
magenta=''
norm=''
ok=''
width=80
yellow=''
fi
### trap
trap trapExit 1 2 3 6
### functions
consoleError() {
printf "\n%s%s\n" "$err" "$2"
printf "Exiting.\n\n%s" "$norm"
exit "$1"
}
exitError() {
printf "%s[%s] --- %s execution completed with error ---\n%s" "$err" "$(stamp)" "$scriptName" "$norm" >>"$logfile"
exit "$1"
}
doRestore() {
sourceFiles=$(find "${backupLocation}" -iname "${1}" -type d)
if [ -n "$sourceFiles" ]; then
if [ "$verbose" -eq 1 ]; then
if ( ! (cd "$sourceFiles/_data" && tar -cf - .) | (cd "${2}" && tar xvf -) >>"$logfile"); then
return 1
else
return 0
fi
else
if ( ! (cd "$sourceFiles/_data" && tar -cf - .) | (cd "${2}" && tar xvf -) >/dev/null 2>&1); then
return 1
else
return 0
fi
fi
else
return 2
fi
}
scriptHelp() {
textNewline
printf "%sUsage: %s [parameters]%s\n\n" "$bold" "$scriptName" "$norm"
textblock "The only required parameter is -b | --backup-location."
textblock "If a parameter is not supplied, its default value will be used."
textblock "Switch parameters will only be activated if specified."
textblockHeader "script parameters"
textblockParam "-b | --backup-location"
textblock "Directory containing extracted backup files from borg repo. REQUIRED."
textNewline
textblockParam "-l | --log" "scriptPath/scriptName.log"
textblock "Path to write log file. Best efforts will be made to create any specified paths."
textNewline
textblockParam "-v | --verbose" "false"
textblock "Enable verbose logging. This will list EVERY restored file possibly making your log file quite large! [SWITCH]"
textNewline
textblockParam "--skip-mail" "false"
textblock "Skip restoring mail and encryption key. [SWITCH]"
textNewline
textblockParam "--skip-sql" "false"
textblock "Skip restoring mailcow settings database. [SWITCH]"
textNewline
textblockParam "--skip-postfix" "false"
textblock "Skip restoring postfix settings. [SWITCH]"
textNewline
textblockParam "--skip-rspamd" "false"
textblock "Skip restoring Rspamd settings/configuration/history. [SWITCH]"
textNewline
textblockParam "--skip-redis" "false"
textblock "Skip restoring redis database. [SWITCH]"
textNewline
textblockParam "-? | -h | --help"
textblock "Display this help screen."
textblockHeader "mailcow parameters"
textblockParam "-d | --docker-compose" "/opt/mailcow-dockerized/docker-compose.yml"
textblock "FULL path to mailcow's 'docker-compose.yml' file."
textNewline
textblockParam "-m | --mailcow-config" "/opt/mailcow-dockerized/mailcow.conf"
textblock "FULL path to mailcow configuration file ('mailcow.conf'). The path of this file is also used to determine your mailcow directory."
textblockHeader "docker parameters"
textblockParam "-t1 | --timeout-start" "180"
textblock "Seconds to wait for docker containers to start."
textNewline
textblockParam "-t2 | --timeout-stop" "120"
textblock "Seconds to wait for docker containers to stop."
textNewline
textblock "More details and examples of script usage can be found in the repo wiki at ${yellow}https://git.asifbacchus.app/asif/MailcowBackup/wiki${norm}"
textNewline
exit 0
}
stamp() {
(date +%F' '%T)
}
textblock() {
printf "%s\n" "$1" | fold -w "$width" -s
}
textblockHeader() {
printf "\n%s%s*** %s ***%s\n\n" "$bold" "$magenta" "$1" "$norm"
}
textblockParam() {
if [ -z "$2" ]; then
# no default
printf "%s%s%s\n" "$cyan" "$1" "$norm"
else
# default parameter provided
printf "%s%s %s(%s)%s\n" "$cyan" "$1" "$yellow" "$2" "$norm"
fi
}
textNewline() {
printf "\n"
}
trapExit() {
printf "%s[%s] -- [ERROR] 99: Caught signal --%s\n" "$err" "$(stamp)" "$norm" >>"$logfile"
printf "%s[%s] --- %s execution terminated via signal ---\n%s" "$err" "$(stamp)" "$scriptName" "$norm" >>"$logfile"
exit 99
}
writeLog() {
if [ "$1" = "task" ]; then
printf "%s[%s] -- [INFO] %s... " "$info" "$(stamp)" "$2" >>"$logfile"
elif [ "$1" = "done" ]; then
if [ -z "$2" ]; then
printf "%sdone%s --\n%s" "$ok" "$info" "$norm" >>"$logfile"
elif [ "$2" = "error" ]; then
printf "%sERROR%s --\n%s" "$err" "$info" "$norm" >>"$logfile"
elif [ "$2" = "warn" ]; then
printf "%swarning%s --\n%s" "$yellow" "$info" "$norm" >>"$logfile"
fi
elif [ "$1" = "error" ]; then
printf "%s[%s] -- [ERROR] %s: %s --\n%s" "$err" "$(stamp)" "$2" "$3" "$norm" >>"$logfile"
elif [ "$1" = "warn" ]; then
printf "%s[%s] -- [WARNING] %s --\n%s" "$yellow" "$(stamp)" "$2" "$norm" >>"$logfile"
elif [ "$1" = "info" ]; then
printf "%s[%s] -- [INFO] %s --\n%s" "$info" "$(stamp)" "$2" "$norm" >>"$logfile"
elif [ "$1" = "success" ]; then
printf "%s[%s] -- [SUCCESS] %s --\n%s" "$ok" "$(stamp)" "$2" "$norm" >>"$logfile"
fi
}
### parameter defaults
# script related
scriptPath="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)"
scriptName="$(basename "$0")"
errorCount=0
warnCount=0
backupLocation=""
sqlBackup=""
restoreMail=1
restoreSQL=1
restorePostfix=1
restoreRedis=1
restoreRspamd=1
verbose=0
# logfile default: same location and name as script but with '.log' extension
logfile="$scriptPath/${scriptName%.*}.log"
# mailcow/docker related
mcConfig='/opt/mailcow-dockerized/mailcow.conf'
mcDockerCompose='/opt/mailcow-dockerized/docker-compose.yml'
sqlRunning=0
dockerStartTimeout=180
dockerStopTimeout=120
dockerCmd="docker compose"
### check if user is root
if [ "$(id -u)" -ne 0 ]; then
consoleError '2' "This script must be run as ROOT."
fi
### process startup parameters
while [ $# -gt 0 ]; do
case "$1" in
-h | -\? | --help)
# display help
scriptHelp
;;
-l | --log)
# set logfile location
if [ -z "$2" ]; then
consoleError '1' "Log file path cannot be null. Leave unspecified to save log in the same directory as this script."
fi
logfile="$2"
shift
;;
-v | --verbose)
verbose=1
;;
-d | --docker-compose)
# FULL path to docker-compose file
if [ -n "$2" ]; then
if [ -f "$2" ]; then
mcDockerCompose="$2"
shift
else
consoleError '1' "$1: cannot find docker-compose file as specified."
fi
else
consoleError '1' "$1: cannot be blank/empty."
fi
;;
-m | --mailcow-config)
# FULL path to mailcow configuration file file
if [ -n "$2" ]; then
if [ -f "$2" ]; then
mcConfig="$2"
shift
else
consoleError '1' "$1: cannot find mailcow configuration file as specified."
fi
else
consoleError '1' "$1: cannot be blank/empty."
fi
;;
-t1 | --timeout-start)
if [ -z "$2" ]; then
consoleError '1' "$1: cannot be blank/empty."
else
dockerStartTimeout="$2"
shift
fi
;;
-t2 | --timeout-stop)
if [ -z "$2" ]; then
consoleError '1' "$1: cannot be blank/empty."
else
dockerStopTimeout="$2"
shift
fi
;;
-b | --backup-location)
if [ -n "$2" ]; then
if [ -d "$2" ] && [ -n "$(ls -A "$2")" ]; then
backupLocation="${2%/}"
shift
else
consoleError '1' "$1: cannot find specified backup location directory or it is empty."
fi
else
consoleError '1' "$1: cannot be blank/empty."
fi
;;
--skip-mail)
restoreMail=0
;;
--skip-sql)
restoreSQL=0
;;
--skip-postfix)
restorePostfix=0
;;
--skip-redis)
restoreRedis=0
;;
--skip-rspamd)
restoreRspamd=0
;;
*)
printf "\n%sUnknown option: %s\n" "$err" "$1"
printf "Use '--help' for valid options.%s\n\n" "$norm"
exit 1
;;
esac
shift
done
### pre-flight checks
# ensure there's something to do
if [ "$restoreMail" -eq 0 ] && [ "$restoreSQL" -eq 0 ] && [ "$restorePostfix" -eq 0 ] && [ "$restoreRedis" -eq 0 ] && [ "$restoreRedis" -eq 0 ]; then
printf "\n%sAll restore operations skipped -- nothing to do!%s\n\n" "$yellow" "$norm"
exit 0
fi
# set path so checks are valid for this script environment
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# docker installed?
if ! command -v docker >/dev/null; then
consoleError '3' 'docker does not seem to be installed!'
fi
# verify compose installed and set proper commands based on version
if ! (docker compose version >/dev/null 2>&1); then
if ! (docker-compose --version >/dev/null 2>&1); then
printf "\n%sERROR: Docker Compose not installed or not functioning%s\n\n" "$err" "$norm"
exit 3
fi
dockerCmd="docker-compose"
fi
# mailcow.conf?
if [ ! -f "$mcConfig" ]; then
consoleError '1' "mailcow configuration file ($mcConfig) cannot be found."
fi
# docker-compose configuration?
if [ ! -f "$mcDockerCompose" ]; then
consoleError '1' "docker-compose configuration ($mcDockerCompose) cannot be found."
fi
# forgot to set backup location?
if [ -z "$backupLocation" ]; then
consoleError '1' "'--backup-location' cannot be unspecified or null/empty."
fi
# change to mailcow directory so commands execute properly
\cd "${mcConfig%/*}" || consoleError '4' 'Cannot change to mailcow directory as determined from mailcow.conf location.'
### read mailcow.conf and import vars
# shellcheck source=./mailcow.conf.shellcheck
. "$mcConfig"
export COMPOSE_HTTP_TIMEOUT="$dockerStartTimeout"
### start logging
# verify logfile specification is valid
if ! printf "%s" "$logfile" | grep -o / >/dev/null; then
# no slashes -> filename provided, save in scriptdir
logfile="$scriptPath/$logfile"
elif [ "$(printf "%s" "$logfile" | tail -c 1)" = '/' ]; then
# ends in '/' --> directory provided, does it exist?
if [ ! -d "$logfile" ]; then
if ! mkdir -p "$logfile" >/dev/null 2>&1; then
consoleError '1' "Unable to make specified log file directory."
fi
fi
logdir="$(cd "$logfile" 2>/dev/null && pwd -P)"
logfile="${logdir}/${scriptName%.*}.log"
else
# full path provided, does the parent directory exist?
if [ ! -d "${logfile%/*}" ]; then
# make parent path
if ! mkdir -p "${logfile%/*}" >/dev/null 2>&1; then
consoleError '1' "Unable to make specified log file path."
fi
fi
fi
# write initial log entries
if ! printf "%s[%s] --- Start %s execution ---\n%s" "$magenta" "$(stamp)" "$scriptName" "$norm" 2>/dev/null >>"$logfile"; then
consoleError '1' "Unable to write to log file ($logfile)"
fi
writeLog 'info' "Log located at $logfile"
### get location of docker volumes
dockerVolumeMail=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_vmail-vol-1)
writeLog 'info' "Using MAIL volume: ${dockerVolumeMail}"
dockerVolumeCrypt=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_crypt-vol-1)
writeLog 'info' "Using MAILCRYPT volume: ${dockerVolumeCrypt}"
dockerVolumePostfix=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_postfix-vol-1)
writeLog 'info' "Using POSTFIX volume: ${dockerVolumePostfix}"
dockerVolumeRedis=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_redis-vol-1)
writeLog 'info' "Using REDIS volume: ${dockerVolumeRedis}"
dockerVolumeRspamd=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_rspamd-vol-1)
writeLog 'info' "Using RSPAMD volume: ${dockerVolumeRspamd}"
# exit if mail or crypt containers cannot be found (mailcow not initialized beforehand)
if [ -z "$dockerVolumeMail" ] || [ -z "$dockerVolumeCrypt" ]; then
writeLog 'error' '5' "Cannot find mail volume. Mailcow probably not initialized before running restore."
exitError 5
fi
### restore SQL
if [ "$restoreSQL" -eq 1 ]; then
writeLog 'task' "Restoring mailcow database"
# sql restore pre-requisites
sqlBackup=$(find "${backupLocation}/tmp" -iname "*.sql")
if [ -n "$sqlBackup" ]; then
# start mysql container if not already running
if ! docker container inspect -f '{{ .State.Running }}' ${COMPOSE_PROJECT_NAME}_mysql-mailcow_1 >/dev/null 2>&1; then
"${dockerCmd}" up -d mysql-mailcow >/dev/null 2>&1
if docker container inspect -f '{{ .State.Running }}' ${COMPOSE_PROJECT_NAME}_mysql-mailcow_1 >/dev/null 2>&1; then
sqlRunning=1
else
writeLog 'done' 'error'
writeLog 'error' '12' "Cannot start mysql-mailcow container -- cannot restore mailcow database!"
errorCount=$((errorCount + 1))
fi
else
sqlRunning=1
fi
else
writeLog 'done' 'error'
writeLog 'error' '11' "Cannot locate SQL backup -- cannot restore mailcow database!"
errorCount=$((errorCount + 1))
fi
# restore sql
if [ "$sqlRunning" -eq 1 ]; then
if docker exec -i "$("${dockerCmd}" ps -q mysql-mailcow)" mysql -u${DBUSER} -p${DBPASS} ${DBNAME} <"${sqlBackup}" >/dev/null 2>&1; then
writeLog 'done'
else
writeLog 'done' 'error'
writeLog 'error' '13' "Something went wrong while trying to restore SQL database. Perhaps try again?"
errorCount=$((errorCount + 1))
fi
fi
fi
### stop containers (necessary for all restore operations except SQL)
writeLog 'task' "Stopping mailcow"
if ! "${dockerCmd}" down --timeout "${dockerStopTimeout}" >/dev/null 2>&1; then
writeLog 'done' 'error'
writeLog 'error' '20' "Unable to bring mailcow containers down -- cannot reliably restore. Aborting."
exitError 20
fi
if [ "$(docker ps --filter "name=${COMPOSE_PROJECT_NAME}" -q | wc -l)" -gt 0 ]; then
writeLog 'done' 'error'
writeLog 'error' '20' "Unable to bring mailcow containers down -- cannot reliably restore. Aborting."
exitError 20
fi
writeLog 'done'
### restore mail and encryption key
if [ "$restoreMail" -eq 1 ]; then
if [ "$verbose" -eq 1 ]; then
writeLog 'info' "Restoring email"
else
writeLog 'task' "Restoring email"
fi
# restore email messages
doRestore "${COMPOSE_PROJECT_NAME}_vmail-vol-1" "$dockerVolumeMail"
ec="$?"
case "$ec" in
0)
if [ "$verbose" -eq 1 ]; then
writeLog 'success' "Email messages restored"
else
writeLog 'done'
fi
;;
1)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '52' "There was an error restoring one or more email messages."
else
writeLog 'done' 'error'
writeLog 'error' '52' "There was an error restoring one or more email messages."
fi
;;
2)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '51' "Cannot locate email message backups!"
else
writeLog 'done' 'error'
writeLog 'error' '51' "Cannot locate email message backups!"
fi
;;
esac
# restore encryption key
doRestore "${COMPOSE_PROJECT_NAME}_crypt-vol-1" "$dockerVolumeCrypt"
ec="$?"
case "$ec" in
0)
if [ "$verbose" -eq 1 ]; then
writeLog 'success' "Encryption key restored"
else
writeLog 'done'
fi
;;
1)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '52' "There was an error restoring the encryption key! Any restored messages are likely *not* readable!"
else
writeLog 'done' 'error'
writeLog 'error' '52' "There was an error restoring the encryption key! Any restored messages are likely *not* readable!"
fi
;;
2)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '51' "Cannot locate encryption key backup!"
else
writeLog 'done' 'error'
writeLog 'error' '51' "Cannot locate encryption key backup!"
fi
;;
esac
fi
### restore postfix
if [ "$restorePostfix" -eq 1 ]; then
if [ "$verbose" -eq 1 ]; then
writeLog 'info' "Restoring postfix files"
else
writeLog 'task' "Restoring postfix files"
fi
doRestore "${COMPOSE_PROJECT_NAME}_postfix-vol-1" "$dockerVolumePostfix"
ec="$?"
case "$ec" in
0)
if [ "$verbose" -eq 1 ]; then
writeLog 'success' "Postfix files restored"
else
writeLog 'done'
fi
;;
1)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '52' "There was an error restoring one or more postfix files."
else
writeLog 'done' 'error'
writeLog 'error' '52' "There was an error restoring one or more postfix files."
fi
;;
2)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '51' "Cannot locate postfix backups!"
else
writeLog 'done' 'error'
writeLog 'error' '51' "Cannot locate postfix backups!"
fi
;;
esac
fi
### restore rspamd
if [ "$restoreRspamd" -eq 1 ]; then
if [ "$verbose" -eq 1 ]; then
writeLog 'info' "Restoring Rspamd files"
else
writeLog 'task' "Restoring Rspamd files"
fi
doRestore "${COMPOSE_PROJECT_NAME}_rspamd-vol-1" "$dockerVolumeRspamd"
ec="$?"
case "$ec" in
0)
if [ "$verbose" -eq 1 ]; then
writeLog 'success' "Rspamd files restored"
else
writeLog 'done'
fi
;;
1)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '52' "There was an error restoring one or more Rspamd files."
else
writeLog 'done' 'error'
writeLog 'error' '52' "There was an error restoring one or more Rspamd files."
fi
;;
2)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '51' "Cannot locate Rspamd backups!"
else
writeLog 'done' 'error'
writeLog 'error' '51' "Cannot locate Rspamd backups!"
fi
;;
esac
fi
### restore redis
if [ "$restoreRedis" -eq 1 ]; then
if [ "$verbose" -eq 1 ]; then
writeLog 'info' "Restoring redis database"
else
writeLog 'task' "Restoring redis database"
fi
doRestore "${COMPOSE_PROJECT_NAME}_redis-vol-1" "$dockerVolumeRedis"
ec="$?"
case "$ec" in
0)
if [ "$verbose" -eq 1 ]; then
writeLog 'success' "Redis database restored"
else
writeLog 'done'
fi
;;
1)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '52' "There was an error restoring the redis database. This is usually *not* a serious issue."
else
writeLog 'done' 'error'
writeLog 'error' '52' "There was an error restoring the redis database. This is usually *not* a serious issue."
fi
;;
2)
if [ "$verbose" -eq 1 ]; then
writeLog 'error' '51' "Cannot locate redis database backups!"
else
writeLog 'done' 'error'
writeLog 'error' '51' "Cannot locate redis database backups!"
fi
;;
esac
fi
### restart mailcow
writeLog 'task' "Starting mailcow"
if ! "${dockerCmd}" up -d >/dev/null 2>&1; then
writeLog 'done' 'warn'
writeLog 'warn' '21' "Unable to automatically start mailcow containers. Please attempt a manual start and note any errors."
warnCount=$((warnCount + 1))
fi
writeLog 'done'
### exit gracefully
if [ "$errorCount" -gt 0 ]; then
# note non-terminating errors
printf "%s[%s] --- %s execution completed with %s error(s) ---\n%s" "$err" "$(stamp)" "$scriptName" "$errorCount" "$norm" >>"$logfile"
exit 98
elif [ "$warnCount" -gt 0 ]; then
printf "%s[%s] --- %s execution completed with %s warning(s) ---\n%s" "$yellow" "$(stamp)" "$scriptName" "$warnCount" "$norm" >>"$logfile"
exit 97
else
writeLog 'success' "All processes completed"
printf "%s[%s] --- %s execution completed ---\n%s" "$magenta" "$(stamp)" "$scriptName" "$norm" >>"$logfile"
exit 0
fi
### error codes:
# 1: parameter error
# 2: not run as root
# 3: docker not installed
# 4: cannot change to mailcow directory
# 5: mailcow not initialized before running script
# 1x: SQL errors
# 11: cannot locate SQL dump in backup directory
# 12: cannot start mysql-mailcow container
# 13: restoring SQL dump was unsuccessful
# 2x: Docker/Docker-Compose errors
# 20: cannot bring docker container(s) down successfully
# 21: cannot bring docker container(s) up successfully
# 5x: File restore errors
# 51: cannot locate source files in backup directory
# 52: error restoring one or more files
# 97: script completed with 1 or more warnings
# 98: script completed with 1 or more non-terminating errors
# 99: TERM signal trapped
#EOF