#!/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 "$@"