| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 | #!/usr/bin/env bashDEBIAN_DOCKER_IMAGE="mailcow/backup:latest"if [[ ! -z ${MAILCOW_BACKUP_LOCATION} ]]; then  BACKUP_LOCATION="${MAILCOW_BACKUP_LOCATION}"fiif [[ ! ${1} =~ (backup|restore) ]]; then  echo "First parameter needs to be 'backup' or 'restore'"  exit 1fiif [[ ${1} == "backup" && ! ${2} =~ (crypt|vmail|redis|rspamd|postfix|mysql|all|--delete-days) ]]; then  echo "Second parameter needs to be 'vmail', 'crypt', 'redis', 'rspamd', 'postfix', 'mysql', 'all' or '--delete-days'"  exit 1fiif [[ -z ${BACKUP_LOCATION} ]]; then  while [[ -z ${BACKUP_LOCATION} ]]; do    read -ep "Backup location (absolute path, starting with /): " BACKUP_LOCATION  donefiif [[ ! ${BACKUP_LOCATION} =~ ^/ ]]; then  echo "Backup directory needs to be given as absolute path (starting with /)."  exit 1fiif [[ -f ${BACKUP_LOCATION} ]]; then  echo "${BACKUP_LOCATION} is a file!"  exit 1fiif [[ ! -d ${BACKUP_LOCATION} ]]; then  echo "${BACKUP_LOCATION} is not a directory"  read -p "Create it now? [y|N] " CREATE_BACKUP_LOCATION  if [[ ! ${CREATE_BACKUP_LOCATION,,} =~ ^(yes|y)$ ]]; then    exit 1  else    mkdir -p ${BACKUP_LOCATION}    chmod 755 ${BACKUP_LOCATION}  fielse  if [[ ${1} == "backup" ]] && [[ -z $(echo $(stat -Lc %a ${BACKUP_LOCATION}) | grep -oE '[0-9][0-9][5-7]') ]]; then    echo "${BACKUP_LOCATION} is not write-able for others, that's required for a backup."    exit 1  fifiBACKUP_LOCATION=$(echo ${BACKUP_LOCATION} | sed 's#/$##')SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"COMPOSE_FILE=${SCRIPT_DIR}/../docker-compose.ymlENV_FILE=${SCRIPT_DIR}/../.envTHREADS=$(echo ${THREADS:-1})ARCH=$(uname -m)if ! [[ "${THREADS}" =~ ^[1-9][0-9]?$ ]] ; then  echo "Thread input is not a number!"  exit 1elif [[ "${THREADS}" =~ ^[1-9][0-9]?$ ]] ; then  echo "Using ${THREADS} Thread(s) for this run."  echo "Notice: You can set the Thread count with the THREADS Variable before you run this script."fiif [ ! -f ${COMPOSE_FILE} ]; then  echo "Compose file not found"  exit 1fiif [ ! -f ${ENV_FILE} ]; then  echo "Environment file not found"  exit 1fiecho "Using ${BACKUP_LOCATION} as backup/restore location."echosource ${SCRIPT_DIR}/../mailcow.confif [[ -z ${COMPOSE_PROJECT_NAME} ]]; then  echo "Could not determine compose project name"  exit 1else  echo "Found project name ${COMPOSE_PROJECT_NAME}"  CMPS_PRJ=$(echo ${COMPOSE_PROJECT_NAME} | tr -cd "[0-9A-Za-z-_]")fiif grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then  >&2 echo -e "\e[31mBusyBox grep detected on local system, please install GNU grep\e[0m"  exit 1fifunction backup() {  DATE=$(date +"%Y-%m-%d-%H-%M-%S")  mkdir -p "${BACKUP_LOCATION}/mailcow-${DATE}"  chmod 755 "${BACKUP_LOCATION}/mailcow-${DATE}"  cp "${SCRIPT_DIR}/../mailcow.conf" "${BACKUP_LOCATION}/mailcow-${DATE}"  touch "${BACKUP_LOCATION}/mailcow-${DATE}/.$ARCH"  for bin in docker; do  if [[ -z $(which ${bin}) ]]; then    >&2 echo -e "\e[31mCannot find ${bin} in local PATH, exiting...\e[0m"    exit 1  fi  done  while (( "$#" )); do    case "$1" in    vmail|all)      docker run --name mailcow-backup --rm \        -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_vmail-vol-1$):/vmail:ro,z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="pigz --rsyncable -p ${THREADS}" -Pcvpf /backup/backup_vmail.tar.gz /vmail      ;;&    crypt|all)      docker run --name mailcow-backup --rm \        -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_crypt-vol-1$):/crypt:ro,z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="pigz --rsyncable -p ${THREADS}" -Pcvpf /backup/backup_crypt.tar.gz /crypt      ;;&    redis|all)      docker exec $(docker ps -qf name=redis-mailcow) redis-cli -a ${REDISPASS} --no-auth-warning save      docker run --name mailcow-backup --rm \        -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_redis-vol-1$):/redis:ro,z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="pigz --rsyncable -p ${THREADS}" -Pcvpf /backup/backup_redis.tar.gz /redis      ;;&    rspamd|all)      docker run --name mailcow-backup --rm \        -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_rspamd-vol-1$):/rspamd:ro,z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="pigz --rsyncable -p ${THREADS}" -Pcvpf /backup/backup_rspamd.tar.gz /rspamd      ;;&    postfix|all)      docker run --name mailcow-backup --rm \        -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_postfix-vol-1$):/postfix:ro,z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="pigz --rsyncable -p ${THREADS}" -Pcvpf /backup/backup_postfix.tar.gz /postfix      ;;&    mysql|all)      SQLIMAGE=$(grep -iEo '(mysql|mariadb)\:.+' ${COMPOSE_FILE})      if [[ -z "${SQLIMAGE}" ]]; then        echo "Could not determine SQL image version, skipping backup..."        shift        continue      else        echo "Using SQL image ${SQLIMAGE}, starting..."        docker run --name mailcow-backup --rm \          --network $(docker network ls -qf name=^${CMPS_PRJ}_mailcow-network$) \          -v $(docker volume ls -qf name=^${CMPS_PRJ}_mysql-vol-1$):/var/lib/mysql/:ro,z \          -t --entrypoint= \          --sysctl net.ipv6.conf.all.disable_ipv6=1 \          -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup:z \          ${SQLIMAGE} /bin/sh -c "mariabackup --host mysql --user root --password ${DBROOT} --backup --rsync --target-dir=/backup_mariadb ; \          mariabackup --prepare --target-dir=/backup_mariadb ; \          chown -R 999:999 /backup_mariadb ; \          /bin/tar --warning='no-file-ignored' --use-compress-program='gzip --rsyncable' -Pcvpf /backup/backup_mariadb.tar.gz /backup_mariadb ;"      fi      ;;&    --delete-days)      shift      if [[ "${1}" =~ ^[0-9]+$ ]]; then        find ${BACKUP_LOCATION}/mailcow-* -maxdepth 0 -mmin +$((${1}*60*24)) -exec rm -rvf {} \;      else        echo "Parameter of --delete-days is not a number."      fi      ;;    esac    shift  done}function restore() {  for bin in docker; do  if [[ -z $(which ${bin}) ]]; then    >&2 echo -e "\e[31mCannot find ${bin} in local PATH, exiting...\e[0m"    exit 1  fi  done  if [ "${DOCKER_COMPOSE_VERSION}" == "native" ]; then  COMPOSE_COMMAND="docker compose"  elif [ "${DOCKER_COMPOSE_VERSION}" == "standalone" ]; then    COMPOSE_COMMAND="docker-compose"  else    echo -e "\e[31mCan not read DOCKER_COMPOSE_VERSION variable from mailcow.conf! Is your mailcow up to date? Exiting...\e[0m"    exit 1  fi  echo  echo "Stopping watchdog-mailcow..."  docker stop $(docker ps -qf name=watchdog-mailcow)  echo  RESTORE_LOCATION="${1}"  shift  while (( "$#" )); do    case "$1" in    vmail)      docker stop $(docker ps -qf name=dovecot-mailcow)      docker run -i --name mailcow-backup --rm \        -v ${RESTORE_LOCATION}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_vmail-vol-1$):/vmail:z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_vmail.tar.gz      docker start $(docker ps -aqf name=dovecot-mailcow)      echo      echo "In most cases it is not required to run a full resync, you can run the command printed below at any time after testing wether the restore process broke a mailbox:"      echo      echo "docker exec $(docker ps -qf name=dovecot-mailcow) doveadm force-resync -A '*'"      echo      read -p "Force a resync now? [y|N] " FORCE_RESYNC      if [[ ${FORCE_RESYNC,,} =~ ^(yes|y)$ ]]; then        docker exec $(docker ps -qf name=dovecot-mailcow) doveadm force-resync -A '*'      else        echo "OK, skipped."      fi      ;;    redis)      docker stop $(docker ps -qf name=redis-mailcow)      docker run -i --name mailcow-backup --rm \        -v ${RESTORE_LOCATION}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_redis-vol-1$):/redis:z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_redis.tar.gz      docker start $(docker ps -aqf name=redis-mailcow)      ;;    crypt)      docker stop $(docker ps -qf name=dovecot-mailcow)      docker run -i --name mailcow-backup --rm \        -v ${RESTORE_LOCATION}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_crypt-vol-1$):/crypt:z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_crypt.tar.gz      docker start $(docker ps -aqf name=dovecot-mailcow)      ;;    rspamd)      if [[ $(find "${RESTORE_LOCATION}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') == "" ]]; then        echo -e "\e[33mCould not find a architecture signature of the loaded backup... Maybe the backup was done before the multiarch update?"        sleep 2        echo -e "Continuing anyhow. If rspamd is crashing opon boot try remove the rspamd volume with docker volume rm ${CMPS_PRJ}_rspamd-vol-1 after you've stopped the stack.\e[0m"        sleep 2        docker stop $(docker ps -qf name=rspamd-mailcow)        docker run -i --name mailcow-backup --rm \          -v ${RESTORE_LOCATION}:/backup:z \          -v $(docker volume ls -qf name=^${CMPS_PRJ}_rspamd-vol-1$):/rspamd:z \          ${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_rspamd.tar.gz        docker start $(docker ps -aqf name=rspamd-mailcow)      elif [[ $ARCH != $(find "${RESTORE_LOCATION}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') ]]; then        echo -e "\e[31mThe Architecture of the backed up mailcow OS is different then your restoring mailcow OS..."        sleep 2        echo -e "Skipping rspamd due to compatibility issues!\e[0m"      else        docker stop $(docker ps -qf name=rspamd-mailcow)        docker run -i --name mailcow-backup --rm \          -v ${RESTORE_LOCATION}:/backup:z \          -v $(docker volume ls -qf name=^${CMPS_PRJ}_rspamd-vol-1$):/rspamd:z \          ${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_rspamd.tar.gz        docker start $(docker ps -aqf name=rspamd-mailcow)      fi      ;;    postfix)      docker stop $(docker ps -qf name=postfix-mailcow)      docker run -i --name mailcow-backup --rm \        -v ${RESTORE_LOCATION}:/backup:z \        -v $(docker volume ls -qf name=^${CMPS_PRJ}_postfix-vol-1$):/postfix:z \        ${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_postfix.tar.gz      docker start $(docker ps -aqf name=postfix-mailcow)      ;;    mysql|mariadb)      SQLIMAGE=$(grep -iEo '(mysql|mariadb)\:.+' ${COMPOSE_FILE})      if [[ -z "${SQLIMAGE}" ]]; then        echo "Could not determine SQL image version, skipping restore..."        shift        continue      elif [ ! -f "${RESTORE_LOCATION}/mailcow.conf" ]; then        echo "Could not find the corresponding mailcow.conf in ${RESTORE_LOCATION}, skipping restore."        echo "If you lost that file, copy the last working mailcow.conf file to ${RESTORE_LOCATION} and restart the restore process."        shift        continue      else        read -p "mailcow will be stopped and the currently active mailcow.conf will be modified to use the DB parameters found in ${RESTORE_LOCATION}/mailcow.conf - do you want to proceed? [Y|n] " MYSQL_STOP_MAILCOW        if [[ ${MYSQL_STOP_MAILCOW,,} =~ ^(no|n|N)$ ]]; then          echo "OK, skipped."          shift          continue        else          echo "Stopping mailcow..."          ${COMPOSE_COMMAND} -f ${COMPOSE_FILE} --env-file ${ENV_FILE} down        fi        #docker stop $(docker ps -qf name=mysql-mailcow)        if [[ -d "${RESTORE_LOCATION}/mysql" ]]; then        docker run --name mailcow-backup --rm \          -v $(docker volume ls -qf name=^${CMPS_PRJ}_mysql-vol-1$):/var/lib/mysql/:rw,z \          --entrypoint= \          -v ${RESTORE_LOCATION}/mysql:/backup:z \          ${SQLIMAGE} /bin/bash -c "shopt -s dotglob ; /bin/rm -rf /var/lib/mysql/* ; rsync -avh --usermap=root:mysql --groupmap=root:mysql /backup/ /var/lib/mysql/"        elif [[ -f "${RESTORE_LOCATION}/backup_mysql.gz" ]]; then        docker run \          -i --name mailcow-backup --rm \          -v $(docker volume ls -qf name=^${CMPS_PRJ}_mysql-vol-1$):/var/lib/mysql/:z \          --entrypoint= \          -u mysql \          -v ${RESTORE_LOCATION}:/backup:z \          ${SQLIMAGE} /bin/sh -c "mysqld --skip-grant-tables & \          until mysqladmin ping; do sleep 3; done && \          echo Restoring... && \          gunzip < backup/backup_mysql.gz | mysql -uroot && \          mysql -uroot -e SHUTDOWN;"        elif [[ -f "${RESTORE_LOCATION}/backup_mariadb.tar.gz" ]]; then        docker run --name mailcow-backup --rm \          -v $(docker volume ls -qf name=^${CMPS_PRJ}_mysql-vol-1$):/backup_mariadb/:rw,z \          --entrypoint= \          -v ${RESTORE_LOCATION}:/backup:z \          ${SQLIMAGE} /bin/bash -c "shopt -s dotglob ; \            /bin/rm -rf /backup_mariadb/* ; \            /bin/tar -Pxvzf /backup/backup_mariadb.tar.gz"        fi        echo "Modifying mailcow.conf..."        source ${RESTORE_LOCATION}/mailcow.conf        sed -i --follow-symlinks "/DBNAME/c\DBNAME=${DBNAME}" ${SCRIPT_DIR}/../mailcow.conf        sed -i --follow-symlinks "/DBUSER/c\DBUSER=${DBUSER}" ${SCRIPT_DIR}/../mailcow.conf        sed -i --follow-symlinks "/DBPASS/c\DBPASS=${DBPASS}" ${SCRIPT_DIR}/../mailcow.conf        sed -i --follow-symlinks "/DBROOT/c\DBROOT=${DBROOT}" ${SCRIPT_DIR}/../mailcow.conf        source ${SCRIPT_DIR}/../mailcow.conf        echo "Starting mailcow..."        ${COMPOSE_COMMAND} -f ${COMPOSE_FILE} --env-file ${ENV_FILE} up -d        #docker start $(docker ps -aqf name=mysql-mailcow)      fi      ;;    esac    shift  done  echo  echo "Starting watchdog-mailcow..."  docker start $(docker ps -aqf name=watchdog-mailcow)}if [[ ${1} == "backup" ]]; then  backup ${@,,}elif [[ ${1} == "restore" ]]; then  i=1  declare -A FOLDER_SELECTION  if [[ $(find ${BACKUP_LOCATION}/mailcow-* -maxdepth 1 -type d 2> /dev/null| wc -l) -lt 1 ]]; then    echo "Selected backup location has no subfolders"    exit 1  fi  for folder in $(ls -d ${BACKUP_LOCATION}/mailcow-*/); do    echo "[ ${i} ] - ${folder}"    FOLDER_SELECTION[${i}]="${folder}"    ((i++))  done  echo  input_sel=0  while [[ ${input_sel} -lt 1 ||  ${input_sel} -gt ${i} ]]; do    read -p "Select a restore point: " input_sel  done  i=1  echo  declare -A FILE_SELECTION  RESTORE_POINT="${FOLDER_SELECTION[${input_sel}]}"  if [[ -z $(find "${FOLDER_SELECTION[${input_sel}]}" -maxdepth 1 \( -type d -o -type f \) -regex ".*\(redis\|rspamd\|mariadb\|mysql\|crypt\|vmail\|postfix\).*") ]]; then    echo "No datasets found"    exit 1  fi  echo "[ 0 ] - all"  # find all files in folder with *.gz extension, print their base names, remove backup_, remove .tar (if present), remove .gz  FILE_SELECTION[0]=$(find "${FOLDER_SELECTION[${input_sel}]}" -maxdepth 1 \( -type d -o -type f \) \( -name '*.gz' -o -name 'mysql' \) -printf '%f\n' | sed 's/backup_*//' | sed 's/\.[^.]*$//' | sed 's/\.[^.]*$//')  for file in $(ls -f "${FOLDER_SELECTION[${input_sel}]}"); do    if [[ ${file} =~ vmail ]]; then      echo "[ ${i} ] - Mail directory (/var/vmail)"      FILE_SELECTION[${i}]="vmail"      ((i++))    elif [[ ${file} =~ crypt ]]; then      echo "[ ${i} ] - Crypt data"      FILE_SELECTION[${i}]="crypt"      ((i++))    elif [[ ${file} =~ redis ]]; then      echo "[ ${i} ] - Redis DB"      FILE_SELECTION[${i}]="redis"      ((i++))    elif [[ ${file} =~ rspamd ]]; then      if [[ $(find "${FOLDER_SELECTION[${input_sel}]}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') == "" ]]; then        echo "[ ${i} ] - Rspamd data (unkown Arch detected, restore with caution!)"        FILE_SELECTION[${i}]="rspamd"        ((i++))      elif [[ $ARCH != $(find "${FOLDER_SELECTION[${input_sel}]}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') ]]; then        echo -e "\e[31m[ NaN ] - Rspamd data (incompatible Arch, cannot restore it)\e[0m"      else        echo "[ ${i} ] - Rspamd data"        FILE_SELECTION[${i}]="rspamd"        ((i++))      fi    elif [[ ${file} =~ postfix ]]; then      echo "[ ${i} ] - Postfix data"      FILE_SELECTION[${i}]="postfix"      ((i++))    elif [[ ${file} =~ mysql ]] || [[ ${file} =~ mariadb ]]; then      echo "[ ${i} ] - SQL DB"      FILE_SELECTION[${i}]="mysql"      ((i++))    fi  done  echo  input_sel=-1  while [[ ${input_sel} -lt 0 ||  ${input_sel} -gt ${i} ]]; do    read -p "Select a dataset to restore: " input_sel  done  echo "Restoring ${FILE_SELECTION[${input_sel}]} from ${RESTORE_POINT}..."  restore "${RESTORE_POINT}" ${FILE_SELECTION[${input_sel}]}fi
 |