Files
mailcow-dockerized/helper-scripts/restic.sh
T
FreddleSpl0it 6cf0390d58 [Web][Restic][Dovecot][Dockerapi] Add restic backups with restore in the UI
- Scheduled, encrypted backups to S3, SFTP, REST or a local folder (new restic-mailcow container)
- Restore of mailboxes and whole domains in the UI, also deleted ones, for admins and domain admins
- Scope set by RESTIC_BACKUP_MODE in mailcow.conf (all, data, config)
- Optional backup before updates (update.sh), helper-scripts/restic.sh for a full restore
2026-11-08 22:56:27 +01:00

718 lines
33 KiB
Bash
Executable File

#!/usr/bin/env bash
# Restore of mailcow from a restic snapshot of the backup feature (System > Configuration > Backup).
#
# helper-scripts/restic.sh snapshots list the snapshots of the repository
# helper-scripts/restic.sh restore [id] full restore (default: the latest mailcow snapshot): the mailcow
# directory with its version and configuration, all data volumes
# and the database, as they were when the snapshot was made
# helper-scripts/restic.sh restore --data-only [id] only the data volumes and the database (optionally data/conf and
# mailcow.conf) into this mailcow, e.g. to move them into a newer one
#
# Snapshots made with RESTIC_BACKUP_MODE=data (of the mailcow directory only mailcow.conf) or config (without mails) are
# restored with --data-only; a config snapshot leaves mails, mail index, Rspamd and SOGo data as they are.
#
# A full restore moves this directory aside (<dir>.before-restore-<date>) and restores the snapshot's directory in its
# place, then starts that version with its images. On a new server only Docker, a clone of mailcow and this script are
# needed (no generate_config.sh, no first start).
#
# The repository settings come from the mailcow database when mailcow runs, else from the environment:
# RESTIC_REPOSITORY e.g. s3:https://s3.example.com/bucket/path, sftp:user@host:/path, rest:https://host:8000/path,
# /repository/<path> for a local repository (folder RESTIC_LOCAL_PATH, else the volume restic-repo-vol-1)
# RESTIC_PASSWORD asked for when not set
# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION (S3), RESTIC_REST_USERNAME, RESTIC_REST_PASSWORD (REST)
# RESTIC_OPTIONS extra options, e.g. "-o s3.bucket-lookup=path"
# RESTIC_SSH_KEY private key file for SFTP (the host key is accepted on first use)
# RESTIC_LOCAL_PATH host folder of a local repository (as in mailcow.conf; empty: the volume restic-repo-vol-1)
# A full restore replaces the directory of this script: it runs from a copy
if [[ "${BASH_SOURCE[0]}" == "${0}" && -z ${RESTIC_SH_COPY} ]]; then
RESTIC_SH_COPY=$(mktemp -d)
cp "${BASH_SOURCE[0]}" "${RESTIC_SH_COPY}/restic.sh"
export RESTIC_SH_COPY RESTIC_SH_MAILCOW_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd -P )"
exec bash "${RESTIC_SH_COPY}/restic.sh" "$@"
fi
MAILCOW_DIR="${RESTIC_SH_MAILCOW_DIR:-$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd -P )}"
# the copy goes when the script ends (load_config adds its temp folder to this)
[[ "${BASH_SOURCE[0]}" == "${0}" && -n ${RESTIC_SH_COPY} ]] && trap 'rm -rf "${RESTIC_SH_COPY}"' EXIT
# volumes of a snapshot: path in the snapshot => compose volume
declare -A SNAPSHOT_VOLUMES=(
[/var/vmail]=vmail-vol-1
[/var/vmail_index]=vmail-index-vol-1
[/mail_crypt]=crypt-vol-1
[/redis]=redis-vol-1
[/rspamd]=rspamd-vol-1
[/sogo_backup]=sogo-userdata-backup-vol-1
)
declare -A MANIFEST
# docker compose in a mailcow directory with its .env (mailcow.conf): the files of COMPOSE_FILE, else
# docker-compose.yml and docker-compose.override.yml
function compose() {
local dir=${1}
shift
local files=()
if [[ -z ${COMPOSE_FILE} ]]; then
files=(-f "${dir}/docker-compose.yml")
[[ -f ${dir}/docker-compose.override.yml ]] && files+=(-f "${dir}/docker-compose.override.yml")
fi
( cd "${dir}" && ${COMPOSE_COMMAND} --project-directory "${dir}" "${files[@]}" --env-file "${dir}/.env" "$@" )
}
# Runs restic in its image with the repository of RESTIC_ENV_FILE; docker options before --, restic arguments after it
function restic_run() {
local docker_args=()
while [[ $# -gt 0 && ${1} != "--" ]]; do
docker_args+=("${1}")
shift
done
shift
# the network of mailcow if it runs (targets in it), the cache and the SSH key of the runner if they exist
local network= cache= repo=()
if [[ -n ${CMPS_PRJ} ]]; then
network=$(docker network ls -qf name=^${CMPS_PRJ}_mailcow-network$)
cache=$(docker volume ls -qf name=^${CMPS_PRJ}_restic-vol-1$)
fi
# a local repository: the folder RESTIC_LOCAL_PATH, else the volume of mailcow
if [[ -n ${RESTIC_LOCAL_DIR} ]]; then
mkdir -p "${RESTIC_LOCAL_DIR}"
repo=(-v "${RESTIC_LOCAL_DIR}:/repository")
elif [[ -n ${CMPS_PRJ} && -n $(docker volume ls -qf name=^${CMPS_PRJ}_restic-repo-vol-1$) ]]; then
repo=(-v "${CMPS_PRJ}_restic-repo-vol-1:/repository")
fi
docker run --rm --security-opt label=disable ${network:+--network ${network}} -e TZ=${TZ} --env-file "${RESTIC_ENV_FILE}" \
${cache:+-v ${cache}:/cache} \
"${repo[@]}" \
${RESTIC_SSH_KEY:+-v "${RESTIC_SSH_KEY}":/run/ssh_key:ro} \
"${docker_args[@]}" --entrypoint /usr/bin/python3 ${RESTIC_IMAGE} -c "
import json, os, sys
opts = json.loads(os.environ.pop('MAILCOW_RESTIC_OPTS', '[]'))
os.execvp('restic', ['restic'] + opts + sys.argv[1:])" "$@"
}
function project_name() {
echo "${1}" | tr -cd "[0-9A-Za-z-_]"
}
function load_config() {
if [[ ! -f ${MAILCOW_DIR}/docker-compose.yml ]]; then
echo -e "\e[31mdocker-compose.yml not found in ${MAILCOW_DIR}\e[0m"
exit 1
fi
# mailcow.conf is missing in a fresh clone (new server)
CMPS_PRJ=
if [[ -f ${MAILCOW_DIR}/mailcow.conf ]]; then
source "${MAILCOW_DIR}/mailcow.conf"
CMPS_PRJ=$(project_name "${COMPOSE_PROJECT_NAME}")
fi
if docker compose version > /dev/null 2>&1; then
COMPOSE_COMMAND="docker compose"
elif command -v docker-compose > /dev/null; then
COMPOSE_COMMAND="docker-compose"
else
echo -e "\e[31mNeither docker compose nor docker-compose found\e[0m"
exit 1
fi
RESTIC_IMAGE=$(grep -oE 'ghcr.io/mailcow/restic:[^[:space:]"]+' "${MAILCOW_DIR}/docker-compose.yml" | head -n 1)
if [[ -z ${RESTIC_IMAGE} ]]; then
echo -e "\e[31mCould not find the restic image in ${MAILCOW_DIR}/docker-compose.yml\e[0m"
exit 1
fi
# schema of this checkout, DDMMYYYY_HHMM
CHECKOUT_DB_VERSION=$(grep -oE 'DB_VERSION=[0-9]{8}_[0-9]{4}' "${MAILCOW_DIR}/docker-compose.yml" | head -n 1 | cut -d= -f2)
# a local repository folder: absolute, or ./relative to the mailcow directory; empty: the volume restic-repo-vol-1
RESTIC_LOCAL_DIR=${RESTIC_LOCAL_PATH}
if [[ -n ${RESTIC_LOCAL_DIR} ]]; then
if [[ ! ${RESTIC_LOCAL_DIR} =~ ^(/|\./) || /${RESTIC_LOCAL_DIR}/ == */../* ]]; then
echo -e "\e[31mRESTIC_LOCAL_PATH must be an absolute path (or ./relative to the mailcow directory) without ..\e[0m"
exit 1
fi
[[ ${RESTIC_LOCAL_DIR} =~ ^/ ]] || RESTIC_LOCAL_DIR="${MAILCOW_DIR}/${RESTIC_LOCAL_DIR#./}"
fi
# repository settings with secrets: only for this run, readable by root only
RESTIC_TMP=$(mktemp -d)
chmod 700 "${RESTIC_TMP}"
trap 'rm -rf "${RESTIC_TMP}" ${RESTIC_SH_COPY:+"${RESTIC_SH_COPY}"}' EXIT
RESTIC_ENV_FILE="${RESTIC_TMP}/restic.env"
touch "${RESTIC_ENV_FILE}"
chmod 600 "${RESTIC_ENV_FILE}"
}
function mysql_container() {
docker ps -qf name=mysql-mailcow -f label=com.docker.compose.project=${CMPS_PRJ} | head -n 1
}
# Repository settings: from the environment, else from the backup settings in the database
function repository_env() {
local cache=
[[ -n ${CMPS_PRJ} ]] && cache=$(docker volume ls -qf name=^${CMPS_PRJ}_restic-vol-1$)
if [[ -z ${RESTIC_REPOSITORY} ]]; then
if [[ -n ${CMPS_PRJ} && -n $(mysql_container) ]]; then
# the runner code builds repository, options and credentials from backup_setting (secrets never in argv)
DBPASS="${DBPASS}" docker run --rm --security-opt label=disable -e DBNAME=${DBNAME} -e DBUSER=${DBUSER} -e DBPASS -e MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} \
-v ${CMPS_PRJ}_mysql-socket-vol-1:/var/run/mysqld/ ${cache:+-v ${cache}:/cache} \
--entrypoint /usr/bin/python3 ${RESTIC_IMAGE} -c "
import json, sys
sys.path.insert(0, '/usr/local/bin')
import restic_runner as rr
try:
s = rr.settings()
repo = rr.Repository(s)
repo.prepare(s)
except Exception as e:
sys.exit('%s' % e)
for key, value in sorted(repo.env.items()):
if key.startswith(('RESTIC_', 'AWS_')) and '\n' not in value:
print('%s=%s' % (key, value))
print('MAILCOW_RESTIC_OPTS=' + json.dumps(repo.options))" > "${RESTIC_ENV_FILE}" 2> "${RESTIC_TMP}/error"
if [[ $? -eq 0 ]]; then
echo "Using the repository of the backup settings in the mailcow database."
return 0
fi
echo -e "\e[33mThe backup settings in the database cannot be used: $(cat "${RESTIC_TMP}/error")\e[0m"
fi
echo "Pass the repository in the environment (see the top of this script), e.g. RESTIC_REPOSITORY=sftp:user@host:/path"
read -ep "Repository: " RESTIC_REPOSITORY
[[ -z ${RESTIC_REPOSITORY} ]] && exit 1
fi
if [[ -z ${RESTIC_PASSWORD} ]]; then
IFS= read -rsp "Repository password: " RESTIC_PASSWORD
echo
fi
local opts=()
read -ra opts <<< "${RESTIC_OPTIONS}"
if [[ -n ${RESTIC_SSH_KEY} ]]; then
if [[ ! -f ${RESTIC_SSH_KEY} ]]; then
echo -e "\e[31m${RESTIC_SSH_KEY} not found\e[0m"
exit 1
fi
RESTIC_SSH_KEY=$(readlink -f "${RESTIC_SSH_KEY}")
opts+=(-o "sftp.args=-i /run/ssh_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/known_hosts")
fi
{
for var in RESTIC_REPOSITORY RESTIC_PASSWORD AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION RESTIC_REST_USERNAME RESTIC_REST_PASSWORD; do
[[ -n ${!var} ]] && echo "${var}=${!var}"
done
echo "RESTIC_CACHE_DIR=/cache/restic"
printf 'MAILCOW_RESTIC_OPTS=['
local sep=
for opt in "${opts[@]}"; do
printf '%s"%s"' "${sep}" "$(echo "${opt}" | sed 's/\\/\\\\/g; s/"/\\"/g')"
sep=,
done
echo ']'
} > "${RESTIC_ENV_FILE}"
}
function snapshots() {
restic_run -- snapshots
}
# The newest snapshot made by mailcow, or the full id of the given one
function snapshot_id() {
if [[ -z ${1} ]]; then
set -- --tag mailcow latest
fi
restic_run -- snapshots --json "$@" | grep -oE '"id":"[0-9a-f]{64}"' | head -n 1 | cut -d'"' -f4
}
# The manifest of a snapshot into MANIFEST[...] (hostname, db_version, arch, instance, domains, mode, commit, branch,
# version, changed_count) and MANIFEST_CHANGED (locally changed files of the mailcow directory)
function read_manifest() {
local key value
MANIFEST=()
MANIFEST_CHANGED=()
while IFS=$'\t' read -r key value; do
if [[ ${key} == "changed" ]]; then
MANIFEST_CHANGED+=("${value}")
elif [[ -n ${key} ]]; then
MANIFEST[${key}]=${value}
fi
done < <(restic_run -- dump ${1} /mailcow/manifest.json 2> /dev/null | docker run --rm -i --security-opt label=disable --entrypoint /usr/bin/python3 ${RESTIC_IMAGE} -c '
import json, sys
try:
m = json.load(sys.stdin)
except ValueError:
sys.exit(0)
i = m.get("install") or {}
clean = lambda v: " ".join(str(v).split())
for key, value in (("hostname", m.get("hostname")), ("db_version", m.get("db_version")), ("arch", m.get("arch")),
("instance", m.get("instance")), ("domains", len(m.get("domains") or [])), ("mode", m.get("mode") or "all"), ("commit", i.get("commit")),
("branch", i.get("branch")), ("version", i.get("version")), ("changed_count", i.get("changed_count")),
("install", int(bool(i)))):
if value not in (None, ""):
print("%s\t%s" % (key, clean(value)))
for line in i.get("changed") or []:
print("changed\t%s" % clean(line))')
}
function show_manifest() {
echo "Snapshot of ${MANIFEST[hostname]}: database schema ${MANIFEST[db_version]}, ${MANIFEST[domains]:-0} domains"
[[ ${MANIFEST[mode]} != "all" ]] && echo "Backup mode ${MANIFEST[mode]}: $(mode_text ${MANIFEST[mode]})"
if [[ -n ${MANIFEST[commit]} ]]; then
echo "mailcow ${MANIFEST[version]:-?}, branch ${MANIFEST[branch]:-?}, commit ${MANIFEST[commit]:0:12}"
if [[ ${MANIFEST[changed_count]:-0} -gt 0 ]]; then
echo "Locally changed or added files (${MANIFEST[changed_count]}):"
printf ' %s\n' "${MANIFEST_CHANGED[@]:0:30}"
[[ ${MANIFEST[changed_count]} -gt 30 ]] && echo " ..."
fi
fi
}
function mode_text() {
case ${1} in
data) echo "of the mailcow directory only mailcow.conf" ;;
config) echo "without mails, mail index, Rspamd and SOGo data" ;;
*) echo "complete" ;;
esac
}
# DDMMYYYY_HHMM as YYYYMMDDHHMM
function version_key() {
[[ ${1} =~ ^([0-9]{2})([0-9]{2})([0-9]{4})_([0-9]{4})$ ]] && echo "${BASH_REMATCH[3]}${BASH_REMATCH[2]}${BASH_REMATCH[1]}${BASH_REMATCH[4]}"
}
# The dump of the snapshot is complete (read through, no database needed)
function check_dump() {
echo "Checking the database dump of the snapshot..."
restic_run -- dump ${1} /mailcow/mysql/mailcow.sql | tail -c 300 | grep -q "Dump completed"
if [[ ${PIPESTATUS[0]} -ne 0 || ${PIPESTATUS[2]} -ne 0 ]]; then
echo -e "\e[31mThe database dump of the snapshot could not be read or is incomplete, nothing was changed\e[0m"
return 1
fi
}
# Instance id of the backup container of a project on this server (empty on a new server)
function local_instance() {
local volume=$(docker volume ls -qf name=^${1}_restic-vol-1$)
[[ -n ${volume} ]] && docker run --rm --security-opt label=disable -v ${volume}:/cache:ro --entrypoint cat ${RESTIC_IMAGE} /cache/instance_id 2> /dev/null
}
# Scheduled backups go on only on the server that made the snapshot: two servers must not back up into one repository
function schedule_decision() {
local here=$(local_instance ${1})
if [[ -n ${MANIFEST[instance]} && ${here} == "${MANIFEST[instance]}" ]]; then
KEEP_SCHEDULE=y
SCHEDULE_NOTE="The snapshot was made by this server, the backup schedule stays as it was in the snapshot."
else
KEEP_SCHEDULE=n
SCHEDULE_NOTE="The snapshot was made by another mailcow (or this one lost its backup volume): scheduled backups are switched off, so two servers do not back up into the same repository. Switch them on again in the backup settings when the old server no longer backs up."
fi
}
function restore_volumes() {
local snapshot=${1} manifest_arch=${2} includes=() mounts=() path volume
# the backed up paths of the snapshot, one per line
local paths=$(restic_run -- snapshots --json ${snapshot} | grep -oE '"paths":\[[^]]*\]' | grep -oE '"/[^"]*"' | tr -d '"')
for path in "${!SNAPSHOT_VOLUMES[@]}"; do
volume=$(docker volume ls -qf name=^${CMPS_PRJ}_${SNAPSHOT_VOLUMES[${path}]}$)
if [[ -z ${volume} ]]; then
echo -e "\e[31mVolume ${CMPS_PRJ}_${SNAPSHOT_VOLUMES[${path}]} not found\e[0m"
return 1
fi
if ! grep -qx "${path}" <<< "${paths}"; then
if [[ ${path} == "/var/vmail_index" ]] && grep -qx /var/vmail <<< "${paths}"; then
echo "The snapshot has no mail index, emptying the index volume (Dovecot rebuilds it)..."
docker run --rm --security-opt label=disable -v ${volume}:/clean --entrypoint /bin/sh ${RESTIC_IMAGE} -c 'find /clean -mindepth 1 -delete'
NO_INDEX=y
else
echo -e "\e[33m${path} is not in the snapshot, ${volume} is left as it is\e[0m"
fi
continue
fi
if [[ ${path} == "/rspamd" && -n ${manifest_arch} && ${manifest_arch} != $(uname -m) ]]; then
echo -e "\e[33mThe snapshot was made on ${manifest_arch}, this is $(uname -m): Rspamd data is skipped\e[0m"
continue
fi
includes+=(--include ${path})
mounts+=(-v ${volume}:${path})
done
echo "Restoring the volumes (${includes[*]//--include /})..."
# --delete: the volumes get exactly the content of the snapshot
restic_run "${mounts[@]}" -- restore ${snapshot} --target / --delete "${includes[@]}"
}
# Copies the dump of the snapshot into the database container (a running MariaDB is waited for)
function fetch_dump() {
local snapshot=${1} mysql=${MYSQL_CONTAINER:-$(mysql_container)}
local i=0
# over TCP: MariaDB initializing a new volume listens only on its socket
until MYSQL_PWD="${DBROOT}" docker exec -e MYSQL_PWD ${mysql} mariadb -uroot -h 127.0.0.1 --protocol=TCP -e 'SELECT 1' > /dev/null 2>&1; do
if [[ ${i} -ge 150 ]]; then
echo -e "\e[31mThe database does not answer\e[0m"
return 1
fi
sleep 2
i=$((i + 1))
done
echo "Reading the database dump from the snapshot..."
# the client misreads long lines from a pipe: the dump goes to a file in the container first
restic_run -- dump ${snapshot} /mailcow/mysql/mailcow.sql | docker exec -i ${mysql} sh -c 'umask 077; cat > /tmp/mailcow-restore.sql'
if [[ ${PIPESTATUS[0]} -ne 0 || ${PIPESTATUS[1]} -ne 0 ]] \
|| ! docker exec ${mysql} sh -c 'tail -c 300 /tmp/mailcow-restore.sql | grep -q "Dump completed"'; then
docker exec ${mysql} rm -f /tmp/mailcow-restore.sql
echo -e "\e[31mThe database dump of the snapshot could not be read or is incomplete\e[0m"
return 1
fi
}
function restore_database() {
local mysql=${MYSQL_CONTAINER:-$(mysql_container)}
echo "Importing the database dump into ${DBNAME}..."
MYSQL_PWD="${DBROOT}" docker exec -i -e MYSQL_PWD ${mysql} mariadb -uroot \
-e "DROP DATABASE IF EXISTS \`${DBNAME}\`; CREATE DATABASE \`${DBNAME}\`;" || return 1
MYSQL_PWD="${DBROOT}" docker exec -e MYSQL_PWD ${mysql} sh -c 'mariadb -uroot "${1}" < /tmp/mailcow-restore.sql; rc=$?; rm -f /tmp/mailcow-restore.sql; exit ${rc}' sh ${DBNAME}
if [[ $? -ne 0 ]]; then
echo -e "\e[31mThe database import failed\e[0m"
return 1
fi
# jobs that were queued or running when the snapshot was made must not run again
local sql="UPDATE backup_job SET state = 'failed', error = 'interrupted: restored from a snapshot', finished = NOW() WHERE state IN ('queued', 'running');"
# the schedule counts from now: no catch-up backup of the days since the snapshot
sql+=" INSERT INTO backup_setting (name, value) VALUES ('state_schedule_since', UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE value = VALUES(value);"
[[ ${KEEP_SCHEDULE} != "y" ]] && sql+=" UPDATE backup_setting SET value = '0' WHERE name = 'enabled';"
MYSQL_PWD="${DBROOT}" docker exec -i -e MYSQL_PWD ${mysql} mariadb -uroot ${DBNAME} -e "${sql}"
}
# data/conf (in place, or into a side folder when the snapshot is of another mailcow version) and data/assets/ssl
function restore_config() {
local snapshot=${1} conf_dir=${2}
if [[ ${conf_dir} == "${MAILCOW_DIR}/data/conf" ]]; then
echo "Restoring data/conf and data/assets/ssl (files of the snapshot overwrite the current ones, newer files stay)..."
else
echo "Restoring data/conf into ${conf_dir} and data/assets/ssl..."
mkdir -p "${conf_dir}"
fi
restic_run -v "${conf_dir}":/mailcow/install/data/conf -v "${MAILCOW_DIR}/data/assets/ssl":/mailcow/install/data/assets/ssl -- \
restore ${snapshot} --target / --include /mailcow/install/data/conf --include /mailcow/install/data/assets/ssl
}
# mailcow.conf of the snapshot; database credentials and project name stay those of this installation
function restore_mailcow_conf() {
local snapshot=${1} backup="${MAILCOW_DIR}/mailcow.conf.before-restore-$(date +%Y%m%d-%H%M%S)" var
restic_run -- dump ${snapshot} /mailcow/install/mailcow.conf > "${RESTIC_TMP}/mailcow.conf" || return 1
for var in DBNAME DBUSER DBPASS DBROOT COMPOSE_PROJECT_NAME; do
if grep -q "^${var}=" "${RESTIC_TMP}/mailcow.conf"; then
VALUE="${!var}" awk -v name="${var}" 'index($0, name "=") == 1 { print name "=" ENVIRON["VALUE"]; next } { print }' \
"${RESTIC_TMP}/mailcow.conf" > "${RESTIC_TMP}/mailcow.conf.new" && mv "${RESTIC_TMP}/mailcow.conf.new" "${RESTIC_TMP}/mailcow.conf"
fi
done
cp -p "${MAILCOW_DIR}/mailcow.conf" "${backup}"
cat "${RESTIC_TMP}/mailcow.conf" > "${MAILCOW_DIR}/mailcow.conf"
echo "mailcow.conf restored (DBNAME, DBUSER, DBPASS, DBROOT and COMPOSE_PROJECT_NAME kept), the old one is ${backup}"
}
# Project name in the mailcow.conf of a snapshot
function snapshot_project() {
project_name "$(restic_run -- dump ${1} /mailcow/install/mailcow.conf 2> /dev/null | grep -m 1 '^COMPOSE_PROJECT_NAME=' | cut -d= -f2-)"
}
# Stops the containers of a project (graceful, no volumes are removed)
function stop_project() {
local ids=$(docker ps -aq --filter label=com.docker.compose.project=${1})
[[ -z ${ids} ]] && return 0
echo "Stopping the containers of ${1}..."
docker stop -t 60 ${ids} > /dev/null
docker rm ${ids} > /dev/null
}
# After the directory was restored: its mailcow.conf (project, database credentials) and compose files
function load_restored_config() {
if [[ ! -f ${MAILCOW_DIR}/mailcow.conf ]]; then
echo -e "\e[31mThe restored directory has no mailcow.conf\e[0m"
return 1
fi
source "${MAILCOW_DIR}/mailcow.conf"
CMPS_PRJ=$(project_name "${COMPOSE_PROJECT_NAME}")
}
function failed_after_move() {
echo -e "\e[31mThe restore stopped. The previous mailcow directory is ${ASIDE_DIR}.\e[0m"
echo "To go back to it: stop the stack, remove ${MAILCOW_DIR}, move ${ASIDE_DIR} back to ${MAILCOW_DIR}"
echo "and start it with docker compose up -d (data volumes restored so far stay restored)."
if [[ -n ${OLD_DUMP} ]]; then
echo "The database as it was before the restore: ${ASIDE_DIR}/${OLD_DUMP} (import it with mariadb into ${DBNAME:-the database})."
fi
exit 1
}
function failed_data_only() {
echo -e "\e[31mThe restore stopped, mailcow is stopped.\e[0m"
echo "Fix the problem shown above and run the restore again, or start mailcow as it is with"
echo " ${COMPOSE_COMMAND} up -d"
echo "(volumes and database restored so far stay restored)."
exit 1
}
# The snapshots made by mailcow; asks which one to restore (Enter: the newest)
function choose_snapshot() {
local latest choice
restic_run -- snapshots --tag mailcow --compact >&2
latest=$(snapshot_id)
[[ -z ${latest} ]] && return 1
read -ep "Snapshot to restore (ID, Enter for the newest one, ${latest:0:8}): " choice >&2
if [[ -z ${choice} ]]; then
echo "${latest}"
elif [[ ${choice} =~ ^[0-9a-f]{8,64}$ ]]; then
snapshot_id "${choice}"
fi
}
# The database before a full restore, into the directory that is moved aside (needs the running database)
function dump_current_database() {
local mysql=$(docker ps -qf name=mysql-mailcow -f label=com.docker.compose.project=${1} | head -n 1)
[[ -z ${mysql} || -z ${DBROOT} || -z ${DBNAME} ]] && return 0
OLD_DUMP="mailcow-before-restore-$(date +%Y%m%d-%H%M%S).sql"
echo "Saving the current database to ${OLD_DUMP} (moves aside with the directory)..."
( umask 077; MYSQL_PWD="${DBROOT}" docker exec -e MYSQL_PWD ${mysql} mariadb-dump -uroot --single-transaction --routines \
--triggers --events --hex-blob "${DBNAME}" > "${MAILCOW_DIR}/${OLD_DUMP}" )
if [[ $? -ne 0 ]]; then
echo -e "\e[31mThe current database could not be saved, nothing was changed\e[0m"
rm -f "${MAILCOW_DIR}/${OLD_DUMP}"
exit 1
fi
}
function confirm() {
echo
echo -e "\e[31m${1}\e[0m"
echo
read -p "Type 'yes' to restore: " CONFIRM
[[ ${CONFIRM} == "yes" ]] || exit 1
}
# Full restore: the mailcow directory of the snapshot (version, configuration, local changes), its images, all data
# volumes and the database
function restore_full() {
local snapshot snap_prj repo_rel= other
if [[ -n ${1} ]]; then
snapshot=$(snapshot_id ${1})
else
snapshot=$(choose_snapshot)
fi
if [[ -z ${snapshot} ]]; then
echo -e "\e[31mSnapshot ${1:-latest} not found\e[0m"
exit 1
fi
restic_run -- snapshots ${snapshot}
read_manifest ${snapshot}
if [[ -z ${MANIFEST[db_version]} ]]; then
echo -e "\e[31mThe snapshot has no mailcow manifest, it was not made by the mailcow backup\e[0m"
exit 1
fi
if [[ ${MANIFEST[mode]} != "all" ]]; then
echo -e "\e[31mThe snapshot was made with RESTIC_BACKUP_MODE=${MANIFEST[mode]} ($(mode_text ${MANIFEST[mode]})), it has no complete"
echo -e "mailcow directory. Install mailcow (git clone, generate_config.sh) and use: restore --data-only ${snapshot:0:8}\e[0m"
exit 1
fi
snap_prj=$(snapshot_project ${snapshot})
if [[ ${MANIFEST[install]} != "1" || -z ${snap_prj} ]]; then
echo -e "\e[31mThe snapshot has no mailcow directory (made before full backups), use: restore --data-only ${snapshot:0:8}\e[0m"
exit 1
fi
show_manifest
# another installation of this project on this server (other directory) is not touched
other=$(docker ps -a --filter label=com.docker.compose.project=${snap_prj} --format '{{.Label "com.docker.compose.project.working_dir"}}' \
| sort -u | grep -vxF "${MAILCOW_DIR}" | head -n 1)
if [[ -n ${other} ]]; then
echo -e "\e[31mThe project ${snap_prj} of the snapshot runs from ${other}, not from ${MAILCOW_DIR}. Restore it there.\e[0m"
exit 1
fi
schedule_decision ${snap_prj}
ASIDE_DIR="${MAILCOW_DIR}.before-restore-$(date +%Y%m%d-%H%M)"
[[ -e ${ASIDE_DIR} ]] && ASIDE_DIR="${ASIDE_DIR}$(date +%S)"
confirm "WARNING: this restores mailcow completely as it was in snapshot ${snapshot:0:8}: the mailcow directory
(version and configuration), all mails, the mail index and crypt keys, Redis, Rspamd, the SOGo backup files and the
database. ${MAILCOW_DIR} is moved to ${ASIDE_DIR}, the database volume is recreated.
Everything newer than the snapshot is lost. mailcow is stopped during the restore."
check_dump ${snapshot} || exit 1
# nothing was changed until here
OLD_DUMP=
[[ -n ${CMPS_PRJ} ]] && dump_current_database ${CMPS_PRJ}
if [[ -n ${CMPS_PRJ} ]]; then
echo "Stopping mailcow..."
compose "${MAILCOW_DIR}" down
fi
stop_project ${snap_prj}
echo "Moving ${MAILCOW_DIR} to ${ASIDE_DIR}..."
mv "${MAILCOW_DIR}" "${ASIDE_DIR}" || exit 1
mkdir "${MAILCOW_DIR}"
chmod --reference="${ASIDE_DIR}" "${MAILCOW_DIR}"
chown --reference="${ASIDE_DIR}" "${MAILCOW_DIR}"
# a local repository inside the directory moved with it
if [[ ${RESTIC_LOCAL_DIR} == "${MAILCOW_DIR}"/* ]]; then
repo_rel=${RESTIC_LOCAL_DIR#${MAILCOW_DIR}/}
RESTIC_LOCAL_DIR="${ASIDE_DIR}/${repo_rel}"
fi
echo "Restoring the mailcow directory..."
restic_run -v "${MAILCOW_DIR}":/mailcow/install -- restore ${snapshot} --target / --include /mailcow/install || failed_after_move
if [[ -n ${repo_rel} ]]; then
mkdir -p "$(dirname "${MAILCOW_DIR}/${repo_rel}")"
rmdir "${MAILCOW_DIR}/${repo_rel}" 2> /dev/null
mv "${ASIDE_DIR}/${repo_rel}" "${MAILCOW_DIR}/${repo_rel}" && RESTIC_LOCAL_DIR="${MAILCOW_DIR}/${repo_rel}"
fi
load_restored_config || failed_after_move
echo "Fetching the images of the restored version..."
compose "${MAILCOW_DIR}" pull || failed_after_move
echo "Creating volumes and containers, the database volume is recreated..."
compose "${MAILCOW_DIR}" down
docker volume rm ${CMPS_PRJ}_mysql-vol-1 > /dev/null 2>&1
compose "${MAILCOW_DIR}" up --no-start || failed_after_move
restore_volumes ${snapshot} "${MANIFEST[arch]}" || failed_after_move
echo "Starting the database..."
compose "${MAILCOW_DIR}" up -d mysql-mailcow || failed_after_move
fetch_dump ${snapshot} || failed_after_move
restore_database || failed_after_move
echo "Starting mailcow..."
compose "${MAILCOW_DIR}" up -d || failed_after_move
echo
echo -e "\e[32mRestore of snapshot ${snapshot:0:8} finished.\e[0m"
echo "Next steps:"
echo "- ${SCHEDULE_NOTE}"
echo "- The previous mailcow directory is ${ASIDE_DIR}, remove it when everything works."
echo "- New server or new IP address: update the DNS records (A/AAAA of ${MAILCOW_HOSTNAME}, MX, SPF, PTR)."
echo "- SFTP target on a new server: the backup creates its own SSH key, add its public key (backup settings) to the"
echo " SFTP server, or copy the old key into the volume ${CMPS_PRJ}_restic-vol-1 (ssh/id_ed25519)."
if [[ ${NO_INDEX} == "y" ]]; then
echo "- The snapshot has no mail index: with full text search enabled, rebuild it:"
else
echo "- If the full text search index was excluded from the backups, rebuild it:"
fi
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm fts rescan -A"
echo "- If a mailbox shows wrong content, resync it; quotas:"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm force-resync -A '*'"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm quota recalc -A"
}
# Data only: volumes and database into this mailcow (same or newer version), optionally data/conf and mailcow.conf
function restore_data_only() {
local snapshot conf_dir
if [[ -z ${CMPS_PRJ} ]]; then
echo -e "\e[31mmailcow.conf not found: a data-only restore needs an installed mailcow (or use a full restore)\e[0m"
exit 1
fi
if [[ -n ${1} ]]; then
snapshot=$(snapshot_id ${1})
else
snapshot=$(choose_snapshot)
fi
if [[ -z ${snapshot} ]]; then
echo -e "\e[31mSnapshot ${1:-latest} not found\e[0m"
exit 1
fi
restic_run -- snapshots ${snapshot}
read_manifest ${snapshot}
if [[ -z ${MANIFEST[db_version]} ]]; then
echo -e "\e[31mThe snapshot has no mailcow manifest, it was not made by the mailcow backup\e[0m"
exit 1
fi
show_manifest
if [[ -z $(version_key "${MANIFEST[db_version]}") || -z $(version_key "${CHECKOUT_DB_VERSION}") ]]; then
echo -e "\e[31mThe database schema of the snapshot (${MANIFEST[db_version]}) or of this checkout (${CHECKOUT_DB_VERSION}) is unknown\e[0m"
exit 1
fi
if [[ $(version_key "${MANIFEST[db_version]}") > $(version_key "${CHECKOUT_DB_VERSION}") ]]; then
echo -e "\e[31mThe snapshot was made with a newer mailcow (database schema ${MANIFEST[db_version]}, this checkout has ${CHECKOUT_DB_VERSION})."
echo -e "Update this mailcow first (./update.sh) or use a full restore.\e[0m"
exit 1
fi
schedule_decision ${CMPS_PRJ}
if [[ ${MANIFEST[mode]} == "config" ]]; then
confirm "WARNING: this replaces the mail crypt keys, Redis and the whole database of this mailcow with the content of
snapshot ${snapshot:0:8}. The snapshot holds no mails: mails, mail index, Rspamd and SOGo data stay as they are.
Everything newer than the snapshot is lost. mailcow is stopped during the restore."
else
confirm "WARNING: this replaces all mails, the mail index, the mail crypt keys, Redis, Rspamd and SOGo backup data
and the whole database of this mailcow with the content of snapshot ${snapshot:0:8}.
Everything newer than the snapshot is lost. mailcow is stopped during the restore."
fi
RESTORE_CONF=
if [[ ${MANIFEST[install]} == "1" ]]; then
# data snapshots have only mailcow.conf of the mailcow directory
if [[ ${MANIFEST[mode]} != "data" ]]; then
conf_dir="${MAILCOW_DIR}/data/conf"
if [[ ${MANIFEST[db_version]} == "${CHECKOUT_DB_VERSION}" ]]; then
read -p "Also restore data/conf and data/assets/ssl from the snapshot? [y|N] " RESTORE_CONF
else
# the configuration of another mailcow version may not fit this checkout
conf_dir="${MAILCOW_DIR}/data/conf.from-snapshot-$(date +%Y%m%d-%H%M%S)"
echo "The snapshot was made with another mailcow version, its data/conf is restored into ${conf_dir} to compare."
read -p "Restore data/conf into that folder and data/assets/ssl? [y|N] " RESTORE_CONF
fi
fi
read -p "Also restore mailcow.conf from the snapshot (the current one is kept as a copy)? [y|N] " RESTORE_MAILCOW_CONF
fi
check_dump ${snapshot} || exit 1
echo "Stopping mailcow..."
compose "${MAILCOW_DIR}" down
echo "Starting the database..."
compose "${MAILCOW_DIR}" up -d mysql-mailcow
if ! fetch_dump ${snapshot}; then
echo "Nothing was changed, starting mailcow again..."
compose "${MAILCOW_DIR}" up -d
exit 1
fi
restore_volumes ${snapshot} "${MANIFEST[arch]}" || failed_data_only
restore_database || failed_data_only
[[ ${RESTORE_CONF,,} =~ ^(yes|y)$ ]] && { restore_config ${snapshot} "${conf_dir}" || failed_data_only; }
[[ ${RESTORE_MAILCOW_CONF,,} =~ ^(yes|y)$ ]] && { restore_mailcow_conf ${snapshot} || failed_data_only; }
echo "Starting mailcow..."
compose "${MAILCOW_DIR}" up -d
echo
echo -e "\e[32mRestore of snapshot ${snapshot:0:8} finished.\e[0m"
echo "Next steps:"
echo "- ${SCHEDULE_NOTE}"
echo "- When Dovecot runs, check the mailboxes. If a mailbox shows wrong content, resync it, and recalculate the quotas:"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm force-resync -A '*'"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm quota recalc -A"
if [[ ${NO_INDEX} == "y" ]]; then
echo "- The snapshot has no mail index: with full text search enabled, rebuild it:"
else
echo "- If the full text search index was excluded from the backups, rebuild it:"
fi
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm fts rescan -A"
echo "- Run an \"init\" job in the backup settings, it refreshes the list of snapshots."
if [[ ${conf_dir} != "${MAILCOW_DIR}/data/conf" && ${RESTORE_CONF,,} =~ ^(yes|y)$ ]]; then
echo "- Compare ${conf_dir} with data/conf and take over what you need."
fi
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
# restore [--data-only] [id], the option before or after the id
if [[ ${1} == "restore" ]]; then
SNAPSHOT_ARG=
for arg in "${@:2}"; do
if [[ ${arg} == "--data-only" ]]; then
MODE=data
elif [[ -z ${SNAPSHOT_ARG} && ${arg} =~ ^[0-9a-f]{8,64}$ ]]; then
SNAPSHOT_ARG=${arg}
else
echo "Unknown argument: ${arg}"
exit 1
fi
done
set -- restore "${SNAPSHOT_ARG}"
fi
if [[ ! ${1} =~ ^(snapshots|restore)$ ]]; then
echo "Usage: ${0##*/} snapshots | restore [snapshot id] | restore --data-only [snapshot id]"
exit 1
fi
load_config
repository_env
case ${1} in
snapshots) snapshots ;;
restore)
if [[ ${MODE} == "data" ]]; then
restore_data_only ${2}
else
restore_full ${2}
fi
;;
esac
fi