diff --git a/seafile/backup_create.sh b/seafile/backup_create.sh new file mode 100644 index 0000000..39c9f09 --- /dev/null +++ b/seafile/backup_create.sh @@ -0,0 +1,407 @@ +#!/bin/sh +# ============================================================================= +# backup_create.sh - create a complete backup of a Seafile server that was +# installed with install-seafile.sh (Docker Compose, Seafile 13.x CE). +# ============================================================================= +# What gets backed up (one directory per backup, written atomically): +# +# database.sql.gz logical dump of every Seafile database (ccnet_db, +# seafile_db, seahub_db, ...) taken from the MariaDB container +# seafile-data.tar libraries, config (seafile/conf), avatars, logs, ... +# seadoc-data.tar SeaDoc data (only if present) +# seafile-caddy.tar Caddy / Let's Encrypt certificates (only if present) +# config.tar the install directory: compose files and .env (SECRETS!) +# service.systemd / service.openrc the boot service file (if any) +# backup.info metadata used by backup_restore.sh +# SHA256SUMS checksums of all of the above +# +# Consistency: by default the backup is "hot" (the server keeps running). The +# database is dumped first and the files afterwards, which is the order +# recommended by the Seafile manual. Use --stop for a fully consistent +# "cold" backup (Seafile is unavailable for the duration of the file copy). +# +# Usage: sudo sh backup_create.sh [options] +# sudo sh backup_create.sh --help +# +# Suitable for cron, e.g.: 30 3 * * * /root/backup_create.sh --keep 14 +# +# POSIX sh only (works with dash, ash/busybox, bash) - no bashisms. +# ============================================================================= + +set -eu +umask 077 + +PROG=${0##*/} + +# ------------------------------- defaults ------------------------------------ +INSTALL_DIR=${SEAFILE_DIR:-/opt/seafile} +BACKUP_ROOT=${SEAFILE_BACKUP_DIR:-/var/backups/seafile} +KEEP=${SEAFILE_BACKUP_KEEP:-7} +STOP_STACK=0 +CHECKSUM=1 +SKIP_CHECKS=0 +SERVICE=seafile +UNIT_DIR=${SEAFILE_UNIT_DIR:-/etc/systemd/system} +INITD_DIR=${SEAFILE_INITD_DIR:-/etc/init.d} + +ENV_FILE= +DATA_DIR= +DB_CONTAINER= +DB_CLIENT= +DB_DUMP= +DBS= +PARTIAL= +LOCK= +STOPPED=0 +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 < value (quotes stripped) from $ENV_FILE + sed -n "s/^$1=//p" "$ENV_FILE" | head -n 1 | tr -d "'\"" +} + +compose_in_dir() { (cd "$INSTALL_DIR" && docker compose "$@"); } + +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" +} + +tar_dir() { # base-dir name output-file + _rc=0 + # shellcheck disable=SC2086 + tar $TAR_OPTS -C "$1" -cf "$3" "$2" || _rc=$? + if [ "$_rc" -eq 1 ] && [ "$IS_GNU_TAR" -eq 1 ]; then + warn "Some files changed while '$2' was being archived (normal for a running server)." + elif [ "$_rc" -ne 0 ]; then + die "Archiving '$2' failed (tar exit code $_rc)." + fi +} + +# ------------------------- locking & cleanup --------------------------------- +cleanup() { + _rc=$? + trap - EXIT INT TERM HUP + if [ "$STOPPED" -eq 1 ]; then + log "Restarting the Seafile containers..." + compose_in_dir up -d --remove-orphans || warn "Could not restart Seafile - run: cd $INSTALL_DIR && docker compose up -d" + fi + if [ -n "$PARTIAL" ] && [ -d "$PARTIAL" ]; then rm -rf "$PARTIAL"; fi + if [ -n "$LOCK" ]; then rmdir "$LOCK" 2>/dev/null || true; fi + exit "$_rc" +} + +acquire_lock() { + _base=/tmp + if [ -d /run ] && [ -w /run ]; then _base=/run; fi + _l=$_base/seafile-backup.lock + if mkdir "$_l" 2>/dev/null; then + LOCK=$_l + else + die "Another backup seems to be running (lock: $_l). If not, remove that directory and retry." + fi +} + +# ------------------------------ database ------------------------------------- +db_service() { + compose_in_dir config --services 2>/dev/null | grep -E '^(db|mysql|mariadb)$' | head -n 1 || true +} + +find_db_container() { + _svc=$(db_service) + _id= + if [ -n "$_svc" ]; then + _id=$(compose_in_dir ps -q "$_svc" 2>/dev/null | head -n 1 || true) + fi + 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 +} + +prepare_database() { + DB_CONTAINER=$(find_db_container) + [ -n "$DB_CONTAINER" ] || die "Could not find the MariaDB container. Is Seafile running? (cd $INSTALL_DIR && docker compose ps)" + [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER" 2>/dev/null || true)" = true ] \ + || die "The database container is not running - start Seafile first (systemctl start $SERVICE)." + + _pw=$(get_env INIT_SEAFILE_MYSQL_ROOT_PASSWORD) + if [ -z "$_pw" ]; then + _pw=$(docker exec "$DB_CONTAINER" printenv MYSQL_ROOT_PASSWORD 2>/dev/null || true) + fi + [ -n "$_pw" ] || die "Could not determine the MariaDB root password (INIT_SEAFILE_MYSQL_ROOT_PASSWORD in $ENV_FILE)." + MYSQL_PWD=$_pw + export MYSQL_PWD # handed to 'docker exec -e MYSQL_PWD' without appearing in 'ps' + + DB_CLIENT=$(find_in_container "$DB_CONTAINER" mariadb mysql) || die "No mariadb/mysql client found in the database container." + DB_DUMP=$(find_in_container "$DB_CONTAINER" mariadb-dump mysqldump) || die "No mariadb-dump/mysqldump found in the database container." + + DBS=$(docker exec -e MYSQL_PWD "$DB_CONTAINER" "$DB_CLIENT" -uroot -N -B -e 'SHOW DATABASES' 2>/dev/null \ + | grep -Ev '^(information_schema|performance_schema|mysql|sys)$' | tr '\n' ' ' | sed 's/ *$//' || true) + [ -n "$DBS" ] || die "No Seafile databases found (wrong root password or empty database server?)." + case $DBS in + *[!A-Za-z0-9_\ ]*) die "Unexpected characters in database names: $DBS" ;; + esac +} + +dump_database() { + log "Dumping databases: $DBS" + _sql=$PARTIAL/database.sql + # shellcheck disable=SC2086 + docker exec -e MYSQL_PWD "$DB_CONTAINER" "$DB_DUMP" -uroot \ + --single-transaction --quick --routines --triggers --events --hex-blob \ + --databases $DBS > "$_sql" \ + || die "The database dump failed." + tail -c 300 "$_sql" | grep -q 'Dump completed' || die "The database dump looks incomplete." + gzip -f "$_sql" +} + +# ------------------------------ preflight ------------------------------------ +check_space() { + if [ "$SKIP_CHECKS" -eq 1 ]; then return 0; fi + _need=0 + for _n in seafile-data seadoc-data seafile-caddy; do + [ -d "$DATA_DIR/$_n" ] || continue + _k=$(du -sk "$DATA_DIR/$_n" 2>/dev/null | awk '{ print $1 }') + _need=$((_need + ${_k:-0})) + done + _avail=$(df -Pk "$BACKUP_ROOT" 2>/dev/null | awk 'NR == 2 { print $4 }') + if [ -n "$_avail" ] && [ "$_avail" -lt "$_need" ]; then + die "Not enough free space in $BACKUP_ROOT: need about $((_need / 1024)) MB, have $((_avail / 1024)) MB (use --skip-checks to ignore)." + fi +} + +# ------------------------------ retention ------------------------------------ +prune_old() { + [ "$KEEP" -gt 0 ] || return 0 + _n=0 + for _d in $(ls -1d "$BACKUP_ROOT"/seafile-backup-* 2>/dev/null | sort -r); do + [ -f "$_d/backup.info" ] || continue + _n=$((_n + 1)) + if [ "$_n" -gt "$KEEP" ]; then + log "Removing old backup $_d" + rm -rf "$_d" + fi + done +} + +# -------------------------------- main --------------------------------------- +main() { + for _a in "$@"; do + case $_a in -h|--help) usage; exit 0 ;; esac + done + require_root "$@" + parse_args "$@" + validate_inputs + + [ "$(uname -s)" = Linux ] || die "This script supports Linux only." + [ -f "$ENV_FILE" ] || die "No Seafile installation found in $INSTALL_DIR (no .env). Use --dir to point to it." + have docker || die "Docker is not installed." + 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." + have gzip || die "gzip is required." + have tar || die "tar is required." + + DATA_DIR=$(get_env BASIC_STORAGE_PATH) + DATA_DIR=${DATA_DIR:-/opt} + DATA_DIR=${DATA_DIR%/} + [ -d "$DATA_DIR/seafile-data" ] || die "Data directory $DATA_DIR/seafile-data not found (BASIC_STORAGE_PATH in $ENV_FILE)." + + case $BACKUP_ROOT/ in + "$DATA_DIR"/seafile-data/*|"$DATA_DIR"/seadoc-data/*|"$DATA_DIR"/seafile-caddy/*|"$DATA_DIR"/seafile-mysql/*|"$INSTALL_DIR"/*) + die "The backup directory must not be inside the Seafile data or install directory." ;; + esac + + mkdir -p "$BACKUP_ROOT" + chmod 700 "$BACKUP_ROOT" + detect_tar + check_space + + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM HUP + acquire_lock + + _stamp=$(date +%Y%m%d-%H%M%S) + _final=$BACKUP_ROOT/seafile-backup-$_stamp + PARTIAL=$BACKUP_ROOT/.partial-$_stamp + [ ! -e "$_final" ] || die "$_final already exists." + mkdir "$PARTIAL" + + prepare_database + _mode=hot + + if [ "$STOP_STACK" -eq 1 ]; then + _mode=cold + _svcs=$(compose_in_dir config --services 2>/dev/null | grep -Ev '^(db|mysql|mariadb)$' | tr '\n' ' ' || true) + [ -n "$_svcs" ] || die "Could not determine which services to stop." + log "Stopping the Seafile containers (database stays up): $_svcs" + STOPPED=1 + # shellcheck disable=SC2086 + compose_in_dir stop -t 120 $_svcs + fi + + # Database first, files afterwards (order recommended by the Seafile manual). + dump_database + + log "Archiving $DATA_DIR/seafile-data (this can take a long time for big libraries)..." + tar_dir "$DATA_DIR" seafile-data "$PARTIAL/seafile-data.tar" + for _n in seadoc-data seafile-caddy; do + if [ -d "$DATA_DIR/$_n" ]; then + log "Archiving $DATA_DIR/$_n..." + tar_dir "$DATA_DIR" "$_n" "$PARTIAL/$_n.tar" + fi + done + + log "Archiving configuration ($INSTALL_DIR)..." + tar_dir "$INSTALL_DIR" . "$PARTIAL/config.tar" + + if [ -f "$UNIT_DIR/$SERVICE.service" ]; then + cp "$UNIT_DIR/$SERVICE.service" "$PARTIAL/service.systemd" + elif [ -f "$INITD_DIR/$SERVICE" ]; then + cp "$INITD_DIR/$SERVICE" "$PARTIAL/service.openrc" + fi + + if [ "$STOPPED" -eq 1 ]; then + log "Restarting the Seafile containers..." + compose_in_dir up -d --remove-orphans || warn "Restart failed - run: cd $INSTALL_DIR && docker compose up -d" + STOPPED=0 + fi + + _image=$(docker inspect -f '{{.Config.Image}}' seafile 2>/dev/null || true) + { + printf 'format=1\n' + printf 'created=%s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" + printf 'host=%s\n' "$(hostname 2>/dev/null || echo unknown)" + printf 'mode=%s\n' "$_mode" + printf 'install_dir=%s\n' "$INSTALL_DIR" + printf 'data_dir=%s\n' "$DATA_DIR" + printf 'databases=%s\n' "$DBS" + printf 'image=%s\n' "$_image" + printf 'server_hostname=%s\n' "$(get_env SEAFILE_SERVER_HOSTNAME)" + } > "$PARTIAL/backup.info" + + if [ "$CHECKSUM" -eq 1 ]; then + if have sha256sum; then + log "Computing checksums..." + _files=$(cd "$PARTIAL" && ls) + # shellcheck disable=SC2086 + (cd "$PARTIAL" && sha256sum $_files > SHA256SUMS) + else + warn "sha256sum not found - the backup will not carry checksums." + fi + fi + + mv "$PARTIAL" "$_final" + PARTIAL= + prune_old + + _size=$(du -sh "$_final" 2>/dev/null | awk '{ print $1 }') + printf '\n' + log "Backup finished: $_final (${_size:-?}, $_mode backup)" + printf ' Restore with: sudo sh backup_restore.sh %s\n' "$_final" + printf ' The backup contains your .env with all secrets - store it somewhere safe,\n' + printf ' and copy it off this machine (rsync, rclone, scp, ...).\n' +} + +main "$@" diff --git a/seafile/backup_restore.sh b/seafile/backup_restore.sh new file mode 100644 index 0000000..4cb9f45 --- /dev/null +++ b/seafile/backup_restore.sh @@ -0,0 +1,564 @@ +#!/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 "$@"