#!/bin/sh # ============================================================================= # backup_restore.sh - restore a backup made by backup_create.sh. # ============================================================================= # What it does, fully automatically: # # 1. picks the newest backup (or the one you name) and verifies its checksums, # 2. stops the running Seafile stack, # 3. MOVES the current install directory and data directories aside # (.pre-restore-) - nothing is deleted, # 4. restores the compose files + .env, the Seafile data (and SeaDoc / Caddy # data if they were backed up), # 5. starts a fresh MariaDB, recreates the Seafile DB user and imports the # database dump, # 6. re-installs the boot service if it is missing, starts Seafile and waits # until the web UI is up. # # It also works on a NEW machine: first get Docker + the service in place with # sudo sh install-seafile.sh install --no-start # then run this script - the freshly generated config is moved aside and # replaced by the backed-up one (including all passwords/secrets). # # Usage: sudo sh backup_restore.sh [BACKUP_DIR] [options] # sudo sh backup_restore.sh --help # # POSIX sh only (works with dash, ash/busybox, bash) - no bashisms. # ============================================================================= set -eu umask 022 PROG=${0##*/} # ------------------------------- defaults ------------------------------------ SRC= BACKUP_ROOT=${SEAFILE_BACKUP_DIR:-/var/backups/seafile} INSTALL_DIR=${SEAFILE_DIR:-} DATA_DIR=${SEAFILE_DATA_DIR:-} WAIT_TIMEOUT=${SEAFILE_WAIT_TIMEOUT:-900} START_NOW=1 VERIFY=1 SKIP_CHECKS=0 ASSUME_YES=0 SERVICE=seafile UNIT_DIR=${SEAFILE_UNIT_DIR:-/etc/systemd/system} INITD_DIR=${SEAFILE_INITD_DIR:-/etc/init.d} INIT=none ENV_FILE= DB_CONTAINER= DB_CLIENT= DBS= ASIDE= TS= IS_GNU_TAR=0 TAR_OPTS= # ------------------------------- helpers ------------------------------------- if [ -t 1 ]; then C_RED=$(printf '\033[31m'); C_YEL=$(printf '\033[33m') C_GRN=$(printf '\033[32m'); C_OFF=$(printf '\033[0m') else C_RED=; C_YEL=; C_GRN=; C_OFF= fi log() { printf '%s==>%s %s\n' "$C_GRN" "$C_OFF" "$*"; } warn() { printf '%sWARNING:%s %s\n' "$C_YEL" "$C_OFF" "$*" >&2; } die() { printf '%sERROR:%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } try() { "$@" >/dev/null 2>&1; } usage() { cat <.pre-restore-, never deleted. EOF } set_opt() { # option value case $1 in --backup-dir) BACKUP_ROOT=$2 ;; --dir) INSTALL_DIR=$2 ;; --data-dir) DATA_DIR=$2 ;; esac } parse_args() { while [ $# -gt 0 ]; do case $1 in --backup-dir|--dir|--data-dir) [ $# -ge 2 ] || die "Option $1 needs a value." set_opt "$1" "$2" shift ;; --backup-dir=*|--dir=*|--data-dir=*) set_opt "${1%%=*}" "${1#*=}" ;; --no-start) START_NOW=0 ;; --no-verify) VERIFY=0 ;; --skip-checks) SKIP_CHECKS=1 ;; -y|--yes) ASSUME_YES=1 ;; -h|--help) usage; exit 0 ;; -*) die "Unknown argument: $1 (see --help)" ;; *) [ -z "$SRC" ] || die "Only one backup directory can be given." SRC=$1 ;; esac shift done } check_path() { # path case $1 in /*) ;; *) die "Paths must be absolute: '$1'" ;; esac case $1 in *[!A-Za-z0-9_./-]*) die "Unsupported characters in path '$1' (allowed: letters, digits, _ . / -)." ;; esac } require_root() { if [ "$(id -u)" -eq 0 ]; then return 0; fi if have sudo && [ -f "$0" ]; then log "Not running as root - re-executing with sudo." exec sudo -E sh "$0" "$@" fi die "Run this script as root (for example: sudo sh $PROG)." } # --- .env editing (same conventions as install-seafile.sh) --- env_quote() { case $1 in *[!A-Za-z0-9_.,:@/+=-]*) printf "'%s'" "$1" ;; *) printf '%s' "$1" ;; esac } set_env() { # key value : replace KEY=... in $ENV_FILE, or append it _v=$(env_quote "$2") _tmp=$(mktemp "$ENV_FILE.XXXXXX") SE_KEY=$1 SE_VAL=$_v awk ' BEGIN { k = ENVIRON["SE_KEY"]; v = ENVIRON["SE_VAL"]; done = 0 } { i = index($0, "=") if (!done && i > 1 && substr($0, 1, i - 1) == k) { print k "=" v; done = 1 } else print } END { if (!done) print k "=" v } ' "$ENV_FILE" > "$_tmp" cat "$_tmp" > "$ENV_FILE" rm -f "$_tmp" } get_env() { # key -> value (quotes stripped) from $ENV_FILE sed -n "s/^$1=//p" "$ENV_FILE" | head -n 1 | tr -d "'\"" } info_get() { # key -> value from the backup's backup.info sed -n "s/^$1=//p" "$SRC/backup.info" | head -n 1 } compose_in_dir() { (cd "$INSTALL_DIR" && docker compose "$@"); } detect_init() { case ${SEAFILE_INIT_SYSTEM:-} in systemd|openrc|none) INIT=$SEAFILE_INIT_SYSTEM; return 0 ;; esac if [ -d /run/systemd/system ] && have systemctl; then INIT=systemd elif have rc-service && have rc-update; then INIT=openrc else INIT=none fi } detect_tar() { if tar --version 2>/dev/null | grep -qi 'gnu tar'; then IS_GNU_TAR=1; fi _d=$(mktemp -d) if try tar --numeric-owner -cf /dev/null -C "$_d" .; then TAR_OPTS=--numeric-owner; fi rmdir "$_d" } untar() { # archive destination-dir # shellcheck disable=SC2086 tar $TAR_OPTS -C "$2" -xpf "$1" || die "Extracting $1 failed." } on_exit() { _rc=$? trap - EXIT if [ "$_rc" -ne 0 ] && [ -n "$ASIDE" ]; then warn "The restore did not complete. Your previous files were moved to:" for _a in $ASIDE; do warn " $_a"; done warn "Move them back (or re-run this script) to recover." fi exit "$_rc" } # ------------------------------- locating the backup ------------------------- find_backup() { if [ -z "$SRC" ]; then check_path "$BACKUP_ROOT" SRC=$(ls -1d "${BACKUP_ROOT%/}"/seafile-backup-* 2>/dev/null | sort | tail -n 1 || true) [ -n "$SRC" ] || die "No backups found in $BACKUP_ROOT (use --backup-dir or pass a backup directory)." log "Using the newest backup: $SRC" fi [ -d "$SRC" ] || die "Backup directory not found: $SRC" SRC=$(cd "$SRC" && pwd) check_path "$SRC" [ -f "$SRC/backup.info" ] || die "$SRC is not a Seafile backup (backup.info missing)." [ -f "$SRC/database.sql.gz" ] || die "database.sql.gz is missing in $SRC." [ -f "$SRC/seafile-data.tar" ] || die "seafile-data.tar is missing in $SRC." [ -f "$SRC/config.tar" ] || die "config.tar is missing in $SRC." } verify_backup() { [ "$VERIFY" -eq 1 ] || return 0 if [ ! -f "$SRC/SHA256SUMS" ]; then warn "This backup has no SHA256SUMS - skipping verification." return 0 fi if ! have sha256sum; then warn "sha256sum not found - skipping verification." return 0 fi log "Verifying backup checksums..." (cd "$SRC" && sha256sum -c SHA256SUMS >/dev/null 2>&1) \ || die "Checksum verification FAILED - the backup is damaged or incomplete (use --no-verify to override)." gzip -t "$SRC/database.sql.gz" || die "database.sql.gz is corrupt." } # ------------------------------- docker -------------------------------------- check_docker() { have docker || die "Docker is not installed. On a new machine run first: sudo sh install-seafile.sh install --no-start" docker info >/dev/null 2>&1 || die "The Docker daemon is not running." docker compose version >/dev/null 2>&1 || die "The Docker Compose v2 plugin is missing (sudo sh install-seafile.sh install --no-start installs it)." } check_space() { if [ "$SKIP_CHECKS" -eq 1 ]; then return 0; fi _need=0 for _f in "$SRC"/*.tar; do [ -f "$_f" ] || continue _k=$(du -sk "$_f" 2>/dev/null | awk '{ print $1 }') _need=$((_need + ${_k:-0})) done mkdir -p "$DATA_DIR" _avail=$(df -Pk "$DATA_DIR" 2>/dev/null | awk 'NR == 2 { print $4 }') if [ -n "$_avail" ] && [ "$_avail" -lt "$_need" ]; then die "Not enough free space in $DATA_DIR: need about $((_need / 1024)) MB, have $((_avail / 1024)) MB (use --skip-checks to ignore)." fi } confirm() { printf '%s [y/N]: ' "$1" >&2 _a= read -r _a || _a= case $_a in y|Y|yes|YES|Yes) return 0 ;; esac return 1 } aside() { # path : rename an existing path out of the way if [ -e "$1" ] || [ -L "$1" ]; then mv "$1" "$1.pre-restore-$TS" ASIDE="$ASIDE $1.pre-restore-$TS" log "Moved aside: $1 -> $1.pre-restore-$TS" fi } # ------------------------------- database ------------------------------------ db_service() { _s=$(compose_in_dir config --services 2>/dev/null | grep -E '^(db|mysql|mariadb)$' | head -n 1 || true) printf '%s' "${_s:-db}" } find_db_container() { _svc=$(db_service) _id=$(compose_in_dir ps -q "$_svc" 2>/dev/null | head -n 1 || true) if [ -z "$_id" ] && try docker inspect seafile-mysql; then _id=seafile-mysql; fi printf '%s' "$_id" } find_in_container() { # container cmd1 cmd2 ... -> first command that exists _c=$1; shift for _x in "$@"; do if docker exec "$_c" sh -c "command -v $_x" >/dev/null 2>&1; then printf '%s' "$_x" return 0 fi done return 1 } wait_db() { _t=0 while [ "$_t" -lt 300 ]; do DB_CONTAINER=$(find_db_container) if [ -n "$DB_CONTAINER" ] \ && [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER" 2>/dev/null || true)" = true ]; then if [ -z "$DB_CLIENT" ]; then DB_CLIENT=$(find_in_container "$DB_CONTAINER" mariadb mysql || true); fi # -h 127.0.0.1: MariaDB's temporary init server has no TCP, so this only # succeeds once the real server is up. if [ -n "$DB_CLIENT" ] \ && docker exec -e MYSQL_PWD "$DB_CONTAINER" "$DB_CLIENT" -h 127.0.0.1 -uroot -e 'SELECT 1' >/dev/null 2>&1; then return 0 fi fi sleep 3 _t=$((_t + 3)) done return 1 } restore_database() { _pw=$(get_env INIT_SEAFILE_MYSQL_ROOT_PASSWORD) [ -n "$_pw" ] || die "INIT_SEAFILE_MYSQL_ROOT_PASSWORD is missing in $ENV_FILE." MYSQL_PWD=$_pw export MYSQL_PWD _dbuser=$(get_env SEAFILE_MYSQL_DB_USER) _dbuser=${_dbuser:-seafile} _dbpw=$(get_env SEAFILE_MYSQL_DB_PASSWORD) [ -n "$_dbpw" ] || die "SEAFILE_MYSQL_DB_PASSWORD is missing in $ENV_FILE." case $_dbuser$_dbpw in *"'"*|*'\'*) die "The database user/password contain a quote or backslash - restore the database manually." ;; esac log "Starting a fresh database container..." compose_in_dir up -d "$(db_service)" || die "Could not start the database container." wait_db || die "The database did not become ready in time (docker logs seafile-mysql)." log "Creating the database user '$_dbuser'..." if ! { printf "CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s';\n" "$_dbuser" "$_dbpw" printf "ALTER USER '%s'@'%%' IDENTIFIED BY '%s';\n" "$_dbuser" "$_dbpw" for _d in $DBS; do printf 'GRANT ALL PRIVILEGES ON `%s`.* TO '"'%s'@'%%'"';\n' "$_d" "$_dbuser" done printf 'FLUSH PRIVILEGES;\n' } | docker exec -i -e MYSQL_PWD "$DB_CONTAINER" "$DB_CLIENT" -uroot; then die "Creating the database user failed." fi log "Importing the database dump..." if ! gzip -dc "$SRC/database.sql.gz" | docker exec -i -e MYSQL_PWD "$DB_CONTAINER" "$DB_CLIENT" -uroot; then die "Importing the database dump failed." fi } # ------------------------------- service ------------------------------------- restore_service() { case $INIT in systemd) if [ ! -f "$UNIT_DIR/$SERVICE.service" ] && [ -f "$SRC/service.systemd" ]; then mkdir -p "$UNIT_DIR" cp "$SRC/service.systemd" "$UNIT_DIR/$SERVICE.service" sed -i "s|^WorkingDirectory=.*|WorkingDirectory=$INSTALL_DIR|" "$UNIT_DIR/$SERVICE.service" systemctl daemon-reload log "Re-installed the systemd unit $UNIT_DIR/$SERVICE.service." fi if [ -f "$UNIT_DIR/$SERVICE.service" ]; then try systemctl enable "$SERVICE" || warn "Could not enable the $SERVICE service at boot." fi ;; openrc) if [ ! -f "$INITD_DIR/$SERVICE" ] && [ -f "$SRC/service.openrc" ]; then mkdir -p "$INITD_DIR" cp "$SRC/service.openrc" "$INITD_DIR/$SERVICE" chmod 755 "$INITD_DIR/$SERVICE" log "Re-installed the OpenRC script $INITD_DIR/$SERVICE." fi if [ -f "$INITD_DIR/$SERVICE" ]; then try rc-update add "$SERVICE" default || warn "Could not add $SERVICE to the default runlevel." fi ;; esac } start_stack() { log "Starting Seafile..." # 'restart' (not 'start'): the oneshot unit may still count as "active" from # before the restore, in which case 'start' would do nothing. if [ "$INIT" = systemd ] && [ -f "$UNIT_DIR/$SERVICE.service" ]; then systemctl restart "$SERVICE" || die "The $SERVICE service failed to start (journalctl -u $SERVICE)." elif [ "$INIT" = openrc ] && [ -f "$INITD_DIR/$SERVICE" ]; then rc-service "$SERVICE" restart || die "The $SERVICE service failed to start." else compose_in_dir up -d --remove-orphans || die "'docker compose up' failed." fi } wait_ready() { log "Waiting for Seafile to come up (can take a few minutes)..." _t=0 while [ "$_t" -lt "$WAIT_TIMEOUT" ]; do _h=$(docker inspect -f '{{.State.Health.Status}}' seafile 2>/dev/null || true) if [ "$_h" = healthy ]; then return 0; fi if docker logs --tail 300 seafile 2>&1 | grep -q 'Seahub is started'; then return 0; fi sleep 5 _t=$((_t + 5)) if [ $((_t % 30)) -eq 0 ]; then log " ...still starting (${_t}s)"; fi done return 1 } # -------------------------------- main --------------------------------------- main() { for _a in "$@"; do case $_a in -h|--help) usage; exit 0 ;; esac done require_root "$@" parse_args "$@" [ "$(uname -s)" = Linux ] || die "This script supports Linux only." have tar || die "tar is required." have gzip || die "gzip is required." find_backup verify_backup # Directories: command line > backup.info _orig_install=$(info_get install_dir) _orig_data=$(info_get data_dir) INSTALL_DIR=${INSTALL_DIR:-$_orig_install} DATA_DIR=${DATA_DIR:-$_orig_data} [ -n "$INSTALL_DIR" ] && [ -n "$DATA_DIR" ] || die "Cannot determine target directories - use --dir and --data-dir." INSTALL_DIR=${INSTALL_DIR%/} DATA_DIR=${DATA_DIR%/} check_path "$INSTALL_DIR" check_path "$DATA_DIR" case $DATA_DIR in "$INSTALL_DIR"|"$INSTALL_DIR"/*) die "The data directory must not be inside the install directory." ;; esac case $INSTALL_DIR in "$DATA_DIR"/seafile-data|"$DATA_DIR"/seafile-data/*|"$DATA_DIR"/seafile-mysql|"$DATA_DIR"/seafile-mysql/*) \ die "The install directory must not be inside the data directories." ;; esac ENV_FILE=$INSTALL_DIR/.env DBS=$(info_get databases) [ -n "$DBS" ] || die "backup.info lists no databases." case $DBS in *[!A-Za-z0-9_\ ]*) die "Unexpected characters in the database list: $DBS" ;; esac check_docker detect_init detect_tar check_space _has_seadoc=0; _has_caddy=0 [ -f "$SRC/seadoc-data.tar" ] && _has_seadoc=1 [ -f "$SRC/seafile-caddy.tar" ] && _has_caddy=1 printf '\nRestore Seafile from:\n' printf ' backup %s\n' "$SRC" printf ' created %s (%s backup, host %s)\n' "$(info_get created)" "$(info_get mode)" "$(info_get host)" printf ' image %s\n' "$(info_get image)" printf ' config %s\n' "$INSTALL_DIR" printf ' data %s\n' "$DATA_DIR" printf ' databases %s\n\n' "$DBS" printf 'Seafile will be stopped and the current directories above will be moved\n' printf 'aside (not deleted) and replaced by the backup.\n\n' if [ "$ASSUME_YES" -eq 0 ]; then [ -t 0 ] || die "Not running from a terminal - re-run with --yes to confirm the restore." confirm "Continue with the restore?" || die "Aborted by user." fi TS=$(date +%Y%m%d-%H%M%S) trap on_exit EXIT # 1. stop whatever is running if [ -f "$INSTALL_DIR/.env" ]; then log "Stopping the current Seafile stack..." compose_in_dir down -t 120 || warn "'docker compose down' reported a problem." fi for _n in seafile seafile-mysql seafile-redis seafile-caddy seadoc; do if try docker inspect "$_n"; then log "Removing leftover container '$_n'..." docker rm -f "$_n" >/dev/null 2>&1 || warn "Could not remove container $_n." fi done # 2. move current state aside aside "$INSTALL_DIR" aside "$DATA_DIR/seafile-data" aside "$DATA_DIR/seafile-mysql" if [ "$_has_seadoc" -eq 1 ]; then aside "$DATA_DIR/seadoc-data"; fi if [ "$_has_caddy" -eq 1 ]; then aside "$DATA_DIR/seafile-caddy"; fi # 3. configuration log "Restoring configuration to $INSTALL_DIR..." mkdir -p "$INSTALL_DIR" untar "$SRC/config.tar" "$INSTALL_DIR" [ -f "$ENV_FILE" ] || die "The backup's config.tar contains no .env." chmod 600 "$ENV_FILE" if [ "$(get_env BASIC_STORAGE_PATH)" != "$DATA_DIR" ]; then set_env BASIC_STORAGE_PATH "$DATA_DIR" log "Updated BASIC_STORAGE_PATH in $ENV_FILE to $DATA_DIR." fi # 4. data mkdir -p "$DATA_DIR" log "Restoring $DATA_DIR/seafile-data (this can take a long time for big libraries)..." untar "$SRC/seafile-data.tar" "$DATA_DIR" if [ "$_has_seadoc" -eq 1 ]; then log "Restoring $DATA_DIR/seadoc-data..." untar "$SRC/seadoc-data.tar" "$DATA_DIR" fi if [ "$_has_caddy" -eq 1 ]; then log "Restoring $DATA_DIR/seafile-caddy..." untar "$SRC/seafile-caddy.tar" "$DATA_DIR" fi mkdir -p "$DATA_DIR/seafile-mysql/db" "$DATA_DIR/seafile-caddy" if [ "$_has_seadoc" -eq 1 ] || [ -d "$DATA_DIR/seadoc-data" ]; then mkdir -p "$DATA_DIR/seadoc-data"; fi compose_in_dir config -q || die "The restored compose configuration is invalid ($ENV_FILE)." # 5. database restore_database # 6. service + start restore_service if [ "$START_NOW" -eq 0 ]; then log "Restore finished; Seafile was not started (--no-start). The database container is running." log "Start everything with: systemctl restart $SERVICE (or: cd $INSTALL_DIR && docker compose up -d)" else start_stack if wait_ready; then log "Seafile is up and running." else warn "Seafile did not report 'ready' within ${WAIT_TIMEOUT}s - check: docker logs -f seafile" ASIDE= exit 2 fi fi _proto=$(get_env SEAFILE_SERVER_PROTOCOL) _host=$(get_env SEAFILE_SERVER_HOSTNAME) printf '\n URL %s://%s\n' "$_proto" "$_host" printf ' Admin %s (password: as it was when the backup was made)\n' "$(get_env INIT_SEAFILE_ADMIN_EMAIL)" printf ' Settings %s\n' "$ENV_FILE" if [ -n "$ASIDE" ]; then printf '\nThe previous state was kept here - delete it once you have checked the restore:\n' for _a in $ASIDE; do printf ' rm -rf %s\n' "$_a"; done fi printf '\nIf this machine has a different address than the one in the backup (%s),\n' "$_host" printf 'update SEAFILE_SERVER_HOSTNAME in %s, restart the service, and adjust\n' "$ENV_FILE" printf 'SERVICE_URL / FILE_SERVER_ROOT in System Admin > Settings (or seahub_settings.py).\n' ASIDE= } main "$@"