diff --git a/backup/restore.sh b/backup/restore.sh new file mode 100644 index 0000000..1bb4608 --- /dev/null +++ b/backup/restore.sh @@ -0,0 +1,373 @@ +#!/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 successfully written to 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 + +badDetails() { + if [ "$1" = 'empty' ]; then + writeLog 'done' 'error' + writeLog 'error' '10' "details:${2} cannot be blank/empty." + exitError 130 + elif [ "$1" = 'dne' ]; then + writeLog 'done' 'error' + writeLog 'error' '11' "details:${2} file or directory does not exist." + exitError 131 + fi +} + +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" +} + +scriptHelp() { + textNewline + printf "%sUsage: %s [parameters]%s\n\n" "$bold" "$scriptName" "$norm" + textNewline + textblock "If a parameter is not supplied, its default value will be used. Switch parameters will remain DEactivated if NOT specified." + textNewline + exit 0 +} + +stamp() { + (date +%F' '%T) +} + +textblock() { + printf "%s\n" "$1" | fold -w "$width" -s +} + +textblockHeader() { + printf "\n%s%s***%s***%s\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" + cleanup + 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" )" +configDetails="$scriptPath/${scriptName%.*}.details" +errorCount=0 +warnCount=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' +dockerStartTimeout=180 +dockerStopTimeout=120 + + +### 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 + ;; + *) + printf "\n%Unknown option: %s\n" "$err" "$1" + printf "Use '--help' for valid options.%s\n\n" "$norm" + exit 1 + ;; + esac + shift +done + + +### pre-flight checks +# docker installed? +if ! command -v docker > /dev/null; then + consoleError '3' 'docker does not seem to be installed!' +fi +# borg installed? +if ! command -v borg > /dev/null; then + consoleError '3' 'borgbackup does not seem to be installed!' +fi +# details file? +if [ ! -f "$configDetails" ]; then + consoleError '1' "configuration file ($configDetails) cannot be found." +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 + + +### read mailcow.conf and import vars +# shellcheck source=./mailcow.conf.shellcheck +. "$mcConfig" +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +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) +printf "%s[%s] -- [INFO] Using MAIL volume: %s --%s\n" \ + "$cyan" "$(stamp)" "$dockerVolumeMail" "$norm" >> "$logfile" +dockerVolumeRspamd=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_rspamd-vol-1) +printf "%s[%s] -- [INFO] Using RSPAMD volume: %s --%s\n" \ + "$cyan" "$(stamp)" "$dockerVolumeRspamd" "$norm" >> "$logfile" +dockerVolumePostfix=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_postfix-vol-1) +printf "%s[%s] -- [INFO] Using POSTFIX volume: %s --%s\n" \ + "$cyan" "$(stamp)" "$dockerVolumePostfix" "$norm" >> "$logfile" +dockerVolumeRedis=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_redis-vol-1) +printf "%s[%s] -- [INFO] Using REDIS volume: %s --%s\n" \ + "$cyan" "$(stamp)" "$dockerVolumeRedis" "$norm" >> "$logfile" +dockerVolumeCrypt=$(docker volume inspect -f '{{ .Mountpoint }}' ${COMPOSE_PROJECT_NAME}_crypt-vol-1) +printf "%s[%s] -- [INFO] Using MAILCRYPT volume: %s --%s\n" \ + "$cyan" "$(stamp)" "$dockerVolumeCrypt" "$norm" >> "$logfile" + + +### source configuration details file +case "${configDetails}" in + /*) + # absolute path, no need to rewrite variable + # shellcheck source=./backup.details + . "${configDetails}" + ;; + *) + # relative path, prepend './' to create absolute path + # shellcheck source=./backup.details + . "./${configDetails}" + ;; +esac +writeLog 'info' "Configuration file: ${yellow}${configDetails}${info} imported" + + +### verify borg variables +# verify borg base directory +writeLog 'task' 'Verify details:borgBaseDir' +if [ -z "${borgBaseDir}" ]; then + badDetails empty 'borgBaseDir' +elif [ ! -d "${borgBaseDir}" ]; then + badDetails dne 'borgBaseDir' +fi +export BORG_BASE_DIR="${borgBaseDir%/}" +writeLog 'done' +# check path to SSH keyfile +writeLog 'task' 'Verify details:borgSSHKey' +if [ -z "${borgSSHKey}" ]; then + badDetails empty 'borgSSHKey' +elif [ ! -f "${borgSSHKey}" ]; then + badDetails dne 'borgSSHKey' +fi +export BORG_RSH="ssh -i ${borgSSHKey}" +writeLog 'done' +# check borg repo connect string +writeLog 'task' 'Verify details:borgConnectRepo' +if [ -z "${borgConnectRepo}" ]; then + badDetails empty 'borgConnectRepo' +fi +export BORG_REPO="${borgConnectRepo}" +writeLog 'done' +# check borg repo password +writeLog 'task' 'Verify details:borgRepoPassphrase' +if [ -z "${borgRepoPassphrase}" ]; then + # an empty repo passphrase is considered a mistake so throw an error + # if the user meant to enter an empty passphrase they should use 'NONE' + badDetails empty 'borgRepoPassphrase' +elif [ "${borgRepoPassphrase}" = 'NONE' ]; then + # password intentionally blank, use but issue warning + export BORG_PASSPHRASE='' + writeLog 'done' 'warn' + writeLog 'warn' 'Using a borg repo with a blank password is an insecure configuration!' + warnCount=$((warnCount+1)) +else + export BORG_PASSPHRASE="${borgRepoPassphrase}" + writeLog 'done' +fi +# check borg repo keyfile location +writeLog 'task' 'Verify details:borgKeyfileLocation' +if [ -z "${borgKeyfileLocation}" ]; then + # will use default location + writeLog 'done' +else + # verify keyfile location exists + if [ ! -f "${borgKeyfileLocation}" ]; then + badDetails dne 'bogKeyfileLocation' + fi + export BORG_KEY_FILE="${borgKeyfileLocation}" + writeLog 'done' +fi +# export borg remote path, if specified +if [ -n "${borgRemote}" ]; then export BORG_REMOTE_PATH="${borgRemote}"; fi + + +### create borg temp dir +## python requires a writable temporary directory when unpacking borg and +## executing commands. This defaults to /tmp but many systems mount /tmp with +## the 'noexec' option for security. Thus, we will use/create a 'tmp' folder +## within the BORG_BASE_DIR and instruct python to use that instead of /tmp + +# check if BORG_BASE_DIR/tmp exists, if not, create it +if [ ! -d "${borgBaseDir}/tmp" ]; then + if ! mkdir "${borgBaseDir}/tmp" 2>/dev/null; then + writeLog 'error' '31' "Unable to create borg temp directory (${borgBaseDir}/tmp)" + exitError 31 + fi +fi +export TMPDIR="${borgBaseDir}/tmp" + + +### change to mailcow directory so docker commands run properly +cd "$(dirname ${mcConfig})" || writeLog 'error' '100' "Could not change to mailcow directory." && exitError 100 + +#TODO: stop containers +#TODO: pull backup via borg +#TODO: copy backups to correct docker volumes +#TODO: copy additional files to correct locations +#TODO: restart docker containers +#TODO: optionally reindex dovecot (parameter) +#TODO: delete downloaded backup (parameter) + + +### exit gracefully +writeLog 'success' "All processes completed" +printf "%s[%s] --- %s execution completed ---\n%s" "$magenta" "$(stamp)" "$scriptName" "$norm" >> "$logfile" +# note non-terminating errors +if [ "$errorCount" -gt 0 ]; then + printf "%s%s errors encountered!%s\n" "$err" "$errorCount" "$norm" >> "$logfile" +fi +# note warnings +if [ "$warnCount" -gt 0 ]; then + printf "%s%s warnings issued!%s\n" "$yellow" "$warnCount" "$norm" >> "$logfile" +fi +exit 0 + + +### error codes: +# 1: parameter error +# 2: not run as root +# 3: borg or docker not installed +# 10: null configuration variable in details file +# 11: invalid configuration variable in details file +# 99: TERM signal trapped +# 100: could not change to mailcow-dockerized directory +# 101: could not stop container(s) +# 102: could not start container(s) +# 110: borg exited with a critical error + +#EOF