2
0

backup_and_restore.sh 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. #!/usr/bin/env bash
  2. DEBIAN_DOCKER_IMAGE="debian:buster-slim"
  3. if [[ ! -z ${MAILCOW_BACKUP_LOCATION} ]]; then
  4. BACKUP_LOCATION="${MAILCOW_BACKUP_LOCATION}"
  5. fi
  6. if [[ ! ${1} =~ (backup|restore) ]]; then
  7. echo "First parameter needs to be 'backup' or 'restore'"
  8. exit 1
  9. fi
  10. if [[ ${1} == "backup" && ! ${2} =~ (crypt|vmail|redis|rspamd|postfix|mysql|all|--delete-days) ]]; then
  11. echo "Second parameter needs to be 'vmail', 'crypt', 'redis', 'rspamd', 'postfix', 'mysql', 'all' or '--delete-days'"
  12. exit 1
  13. fi
  14. if [[ -z ${BACKUP_LOCATION} ]]; then
  15. while [[ -z ${BACKUP_LOCATION} ]]; do
  16. read -ep "Backup location (absolute path, starting with /): " BACKUP_LOCATION
  17. done
  18. fi
  19. if [[ ! ${BACKUP_LOCATION} =~ ^/ ]]; then
  20. echo "Backup directory needs to be given as absolute path (starting with /)."
  21. exit 1
  22. fi
  23. if [[ -f ${BACKUP_LOCATION} ]]; then
  24. echo "${BACKUP_LOCATION} is a file!"
  25. exit 1
  26. fi
  27. if [[ ! -d ${BACKUP_LOCATION} ]]; then
  28. echo "${BACKUP_LOCATION} is not a directory"
  29. read -p "Create it now? [y|N] " CREATE_BACKUP_LOCATION
  30. if [[ ! ${CREATE_BACKUP_LOCATION,,} =~ ^(yes|y)$ ]]; then
  31. exit 1
  32. else
  33. mkdir -p ${BACKUP_LOCATION}
  34. chmod 755 ${BACKUP_LOCATION}
  35. fi
  36. else
  37. if [[ ${1} == "backup" ]] && [[ -z $(echo $(stat -Lc %a ${BACKUP_LOCATION}) | grep -oE '[0-9][0-9][5-7]') ]]; then
  38. echo "${BACKUP_LOCATION} is not write-able for others, that's required for a backup."
  39. exit 1
  40. fi
  41. fi
  42. BACKUP_LOCATION=$(echo ${BACKUP_LOCATION} | sed 's#/$##')
  43. SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
  44. COMPOSE_FILE=${SCRIPT_DIR}/../docker-compose.yml
  45. if [ ! -f ${COMPOSE_FILE} ]; then
  46. echo "Compose file not found"
  47. exit 1
  48. fi
  49. echo "Using ${BACKUP_LOCATION} as backup/restore location."
  50. echo
  51. source ${SCRIPT_DIR}/../mailcow.conf
  52. if [[ -z ${COMPOSE_PROJECT_NAME} ]]; then
  53. echo "Could not determine compose project name"
  54. exit 1
  55. else
  56. echo "Found project name ${COMPOSE_PROJECT_NAME}"
  57. CMPS_PRJ=$(echo ${COMPOSE_PROJECT_NAME} | tr -cd "[0-9A-Za-z-_]")
  58. fi
  59. function backup() {
  60. DATE=$(date +"%Y-%m-%d-%H-%M-%S")
  61. mkdir -p "${BACKUP_LOCATION}/mailcow-${DATE}"
  62. chmod 755 "${BACKUP_LOCATION}/mailcow-${DATE}"
  63. cp "${SCRIPT_DIR}/../mailcow.conf" "${BACKUP_LOCATION}/mailcow-${DATE}"
  64. while (( "$#" )); do
  65. case "$1" in
  66. vmail|all)
  67. docker run --name mailcow-backup --rm \
  68. -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup \
  69. -v $(docker volume ls -qf name=${CMPS_PRJ}_vmail-vol-1):/vmail:ro \
  70. ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="gzip --rsyncable --best" -Pcvpf /backup/backup_vmail.tar.gz /vmail
  71. ;;&
  72. crypt|all)
  73. docker run --name mailcow-backup --rm \
  74. -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup \
  75. -v $(docker volume ls -qf name=${CMPS_PRJ}_crypt-vol-1):/crypt:ro \
  76. ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="gzip --rsyncable --best" -Pcvpf /backup/backup_crypt.tar.gz /crypt
  77. ;;&
  78. redis|all)
  79. docker exec $(docker ps -qf name=redis-mailcow) redis-cli save
  80. docker run --name mailcow-backup --rm \
  81. -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup \
  82. -v $(docker volume ls -qf name=${CMPS_PRJ}_redis-vol-1):/redis:ro \
  83. ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="gzip --rsyncable --best" -Pcvpf /backup/backup_redis.tar.gz /redis
  84. ;;&
  85. rspamd|all)
  86. docker run --name mailcow-backup --rm \
  87. -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup \
  88. -v $(docker volume ls -qf name=${CMPS_PRJ}_rspamd-vol-1):/rspamd:ro \
  89. ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="gzip --rsyncable --best" -Pcvpf /backup/backup_rspamd.tar.gz /rspamd
  90. ;;&
  91. postfix|all)
  92. docker run --name mailcow-backup --rm \
  93. -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup \
  94. -v $(docker volume ls -qf name=${CMPS_PRJ}_postfix-vol-1):/postfix:ro \
  95. ${DEBIAN_DOCKER_IMAGE} /bin/tar --warning='no-file-ignored' --use-compress-program="gzip --rsyncable --best" -Pcvpf /backup/backup_postfix.tar.gz /postfix
  96. ;;&
  97. mysql|all)
  98. SQLIMAGE=$(grep -iEo '(mysql|mariadb)\:.+' ${COMPOSE_FILE})
  99. if [[ -z "${SQLIMAGE}" ]]; then
  100. echo "Could not determine SQL image version, skipping backup..."
  101. shift
  102. continue
  103. else
  104. echo "Using SQL image ${SQLIMAGE}, starting..."
  105. docker run --name mailcow-backup --rm \
  106. --network $(docker network ls -qf name=${CMPS_PRJ}_) \
  107. -v $(docker volume ls -qf name=${CMPS_PRJ}_mysql-vol-1):/var/lib/mysql/:ro \
  108. --entrypoint= \
  109. -v ${BACKUP_LOCATION}/mailcow-${DATE}:/backup \
  110. ${SQLIMAGE} /bin/sh -c "mariabackup --host mysql --user root --password ${DBROOT} --backup --rsync --target-dir=/backup_mariadb ; \
  111. mariabackup --prepare --target-dir=/backup_mariadb ; \
  112. chown -R mysql:mysql /backup_mariadb ; \
  113. /bin/tar --warning='no-file-ignored' --use-compress-program='gzip --rsyncable --best' -Pcvpf /backup/backup_mariadb.tar.gz /backup_mariadb ;"
  114. fi
  115. ;;&
  116. --delete-days)
  117. shift
  118. if [[ "${1}" =~ ^[0-9]+$ ]]; then
  119. find ${BACKUP_LOCATION}/* -maxdepth 0 -mmin +$((${1}*60*24)) -exec rm -rvf {} \;
  120. else
  121. echo "Parameter of --delete-days is not a number."
  122. fi
  123. ;;
  124. esac
  125. shift
  126. done
  127. }
  128. function restore() {
  129. echo
  130. echo "Stopping watchdog-mailcow..."
  131. docker stop $(docker ps -qf name=watchdog-mailcow)
  132. echo
  133. RESTORE_LOCATION="${1}"
  134. shift
  135. while (( "$#" )); do
  136. case "$1" in
  137. vmail)
  138. docker stop $(docker ps -qf name=dovecot-mailcow)
  139. docker run -it --name mailcow-backup --rm \
  140. -v ${RESTORE_LOCATION}:/backup \
  141. -v $(docker volume ls -qf name=${CMPS_PRJ}_vmail-vol-1):/vmail \
  142. ${DEBIAN_DOCKER_IMAGE} /bin/tar -Pxvzf /backup/backup_vmail.tar.gz
  143. docker start $(docker ps -aqf name=dovecot-mailcow)
  144. echo
  145. 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:"
  146. echo
  147. echo "docker exec $(docker ps -qf name=dovecot-mailcow) doveadm force-resync -A '*'"
  148. echo
  149. read -p "Force a resync now? [y|N] " FORCE_RESYNC
  150. if [[ ${FORCE_RESYNC,,} =~ ^(yes|y)$ ]]; then
  151. docker exec $(docker ps -qf name=dovecot-mailcow) doveadm force-resync -A '*'
  152. else
  153. echo "OK, skipped."
  154. fi
  155. ;;
  156. redis)
  157. docker stop $(docker ps -qf name=redis-mailcow)
  158. docker run -it --name mailcow-backup --rm \
  159. -v ${RESTORE_LOCATION}:/backup \
  160. -v $(docker volume ls -qf name=${CMPS_PRJ}_redis-vol-1):/redis \
  161. ${DEBIAN_DOCKER_IMAGE} /bin/tar -Pxvzf /backup/backup_redis.tar.gz
  162. docker start $(docker ps -aqf name=redis-mailcow)
  163. ;;
  164. crypt)
  165. docker stop $(docker ps -qf name=dovecot-mailcow)
  166. docker run -it --name mailcow-backup --rm \
  167. -v ${RESTORE_LOCATION}:/backup \
  168. -v $(docker volume ls -qf name=${CMPS_PRJ}_crypt-vol-1):/crypt \
  169. ${DEBIAN_DOCKER_IMAGE} /bin/tar -Pxvzf /backup/backup_crypt.tar.gz
  170. docker start $(docker ps -aqf name=dovecot-mailcow)
  171. ;;
  172. rspamd)
  173. docker stop $(docker ps -qf name=rspamd-mailcow)
  174. docker run -it --name mailcow-backup --rm \
  175. -v ${RESTORE_LOCATION}:/backup \
  176. -v $(docker volume ls -qf name=${CMPS_PRJ}_rspamd-vol-1):/rspamd \
  177. ${DEBIAN_DOCKER_IMAGE} /bin/tar -Pxvzf /backup/backup_rspamd.tar.gz
  178. docker start $(docker ps -aqf name=rspamd-mailcow)
  179. ;;
  180. postfix)
  181. docker stop $(docker ps -qf name=postfix-mailcow)
  182. docker run -it --name mailcow-backup --rm \
  183. -v ${RESTORE_LOCATION}:/backup \
  184. -v $(docker volume ls -qf name=${CMPS_PRJ}_postfix-vol-1):/postfix \
  185. ${DEBIAN_DOCKER_IMAGE} /bin/tar -Pxvzf /backup/backup_postfix.tar.gz
  186. docker start $(docker ps -aqf name=postfix-mailcow)
  187. ;;
  188. mysql)
  189. SQLIMAGE=$(grep -iEo '(mysql|mariadb)\:.+' ${COMPOSE_FILE})
  190. if [[ -z "${SQLIMAGE}" ]]; then
  191. echo "Could not determine SQL image version, skipping restore..."
  192. shift
  193. continue
  194. elif [ ! -f "${RESTORE_LOCATION}/mailcow.conf" ]; then
  195. echo "Could not find the corresponding mailcow.conf in ${RESTORE_LOCATION}, skipping restore."
  196. echo "If you lost that file, copy the last working mailcow.conf file to ${RESTORE_LOCATION} and restart the restore process."
  197. shift
  198. continue
  199. else
  200. 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
  201. if [[ ${MYSQL_STOP_MAILCOW,,} =~ ^(no|n|N)$ ]]; then
  202. echo "OK, skipped."
  203. shift
  204. continue
  205. else
  206. echo "Stopping mailcow..."
  207. docker-compose down
  208. fi
  209. #docker stop $(docker ps -qf name=mysql-mailcow)
  210. if [[ -d "${RESTORE_LOCATION}/mysql" ]]; then
  211. docker run --name mailcow-backup --rm \
  212. -v $(docker volume ls -qf name=${CMPS_PRJ}_mysql-vol-1):/var/lib/mysql/:rw \
  213. --entrypoint= \
  214. -v ${RESTORE_LOCATION}/mysql:/backup \
  215. ${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/"
  216. elif [[ -f "${RESTORE_LOCATION}/backup_mysql.gz" ]]; then
  217. docker run \
  218. -it --name mailcow-backup --rm \
  219. -v $(docker volume ls -qf name=${CMPS_PRJ}_mysql-vol-1):/var/lib/mysql/ \
  220. --entrypoint= \
  221. -u mysql \
  222. -v ${RESTORE_LOCATION}:/backup \
  223. ${SQLIMAGE} /bin/sh -c "mysqld --skip-grant-tables & \
  224. until mysqladmin ping; do sleep 3; done && \
  225. echo Restoring... && \
  226. gunzip < backup/backup_mysql.gz | mysql -uroot && \
  227. mysql -uroot -e SHUTDOWN;"
  228. elif [[ -f "${RESTORE_LOCATION}/backup_mariadb.tar.gz" ]]; then
  229. docker run --name mailcow-backup --rm \
  230. -v $(docker volume ls -qf name=${CMPS_PRJ}_mysql-vol-1):/backup_mariadb/:rw \
  231. --entrypoint= \
  232. -v ${RESTORE_LOCATION}:/backup \
  233. ${SQLIMAGE} /bin/bash -c "shopt -s dotglob ; \
  234. /bin/rm -rf /backup_mariadb/* ; \
  235. /bin/tar -Pxvzf /backup/backup_mariadb.tar.gz"
  236. fi
  237. echo "Modifying mailcow.conf..."
  238. source ${RESTORE_LOCATION}/mailcow.conf
  239. sed -i "/DBNAME/c\DBNAME=${DBNAME}" ${SCRIPT_DIR}/../mailcow.conf
  240. sed -i "/DBUSER/c\DBUSER=${DBUSER}" ${SCRIPT_DIR}/../mailcow.conf
  241. sed -i "/DBPASS/c\DBPASS=${DBPASS}" ${SCRIPT_DIR}/../mailcow.conf
  242. sed -i "/DBROOT/c\DBROOT=${DBROOT}" ${SCRIPT_DIR}/../mailcow.conf
  243. source ${SCRIPT_DIR}/../mailcow.conf
  244. echo "Starting mailcow..."
  245. docker-compose up -d
  246. #docker start $(docker ps -aqf name=mysql-mailcow)
  247. fi
  248. ;;
  249. esac
  250. shift
  251. done
  252. echo
  253. echo "Starting watchdog-mailcow..."
  254. docker start $(docker ps -aqf name=watchdog-mailcow)
  255. }
  256. if [[ ${1} == "backup" ]]; then
  257. backup ${@,,}
  258. elif [[ ${1} == "restore" ]]; then
  259. i=1
  260. declare -A FOLDER_SELECTION
  261. if [[ $(find ${BACKUP_LOCATION}/mailcow-* -maxdepth 1 -type d 2> /dev/null| wc -l) -lt 1 ]]; then
  262. echo "Selected backup location has no subfolders"
  263. exit 1
  264. fi
  265. for folder in $(ls -d ${BACKUP_LOCATION}/mailcow-*/); do
  266. echo "[ ${i} ] - ${folder}"
  267. FOLDER_SELECTION[${i}]="${folder}"
  268. ((i++))
  269. done
  270. echo
  271. input_sel=0
  272. while [[ ${input_sel} -lt 1 || ${input_sel} -gt ${i} ]]; do
  273. read -p "Select a restore point: " input_sel
  274. done
  275. i=1
  276. echo
  277. declare -A FILE_SELECTION
  278. RESTORE_POINT="${FOLDER_SELECTION[${input_sel}]}"
  279. if [[ -z $(find "${FOLDER_SELECTION[${input_sel}]}" -maxdepth 1 \( -type d -o -type f \) -regex ".*\(redis\|rspamd\|mariadb\|mysql\|crypt\|vmail\|postfix\).*") ]]; then
  280. echo "No datasets found"
  281. exit 1
  282. fi
  283. echo "[ 0 ] - all"
  284. # find all files in folder with *.gz extension, print their base names, remove backup_, remove .tar (if present), remove .gz
  285. 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/\.[^.]*$//')
  286. for file in $(ls -f "${FOLDER_SELECTION[${input_sel}]}"); do
  287. if [[ ${file} =~ vmail ]]; then
  288. echo "[ ${i} ] - Mail directory (/var/vmail)"
  289. FILE_SELECTION[${i}]="vmail"
  290. ((i++))
  291. elif [[ ${file} =~ crypt ]]; then
  292. echo "[ ${i} ] - Crypt data"
  293. FILE_SELECTION[${i}]="crypt"
  294. ((i++))
  295. elif [[ ${file} =~ redis ]]; then
  296. echo "[ ${i} ] - Redis DB"
  297. FILE_SELECTION[${i}]="redis"
  298. ((i++))
  299. elif [[ ${file} =~ rspamd ]]; then
  300. echo "[ ${i} ] - Rspamd data"
  301. FILE_SELECTION[${i}]="rspamd"
  302. ((i++))
  303. elif [[ ${file} =~ postfix ]]; then
  304. echo "[ ${i} ] - Postfix data"
  305. FILE_SELECTION[${i}]="postfix"
  306. ((i++))
  307. elif [[ ${file} =~ mysql ]] || [[ ${file} =~ mariadb ]]; then
  308. echo "[ ${i} ] - SQL DB"
  309. FILE_SELECTION[${i}]="mysql"
  310. ((i++))
  311. fi
  312. done
  313. echo
  314. input_sel=-1
  315. while [[ ${input_sel} -lt 0 || ${input_sel} -gt ${i} ]]; do
  316. read -p "Select a dataset to restore: " input_sel
  317. done
  318. echo "Restoring ${FILE_SELECTION[${input_sel}]} from ${RESTORE_POINT}..."
  319. restore "${RESTORE_POINT}" ${FILE_SELECTION[${input_sel}]}
  320. fi