#!/usr/bin/env bash set -Eeuo pipefail readonly DEFAULT_BACKUP_ROOT="/var/backups/ochenstarik-server" readonly CONFIG_DIR="/etc/ochenstarik-server" readonly CONFIG_FILE="${CONFIG_DIR}/backup.conf" readonly BACKUP_RUNNER="/usr/local/sbin/ochenstarik-server-backup" readonly SERVICE_FILE="/etc/systemd/system/ochenstarik-backup@.service" readonly TIMER_PREFIX="/etc/systemd/system/ochenstarik-backup" log() { printf '[+] %s\n' "$*"; } warn() { printf '[!] %s\n' "$*" >&2; } die() { printf '[x] %s\n' "$*" >&2; exit 1; } require_command() { command -v "$1" >/dev/null 2>&1 || die "Не найдена команда: $1" } choose_schedules() { local answer token declare -gA SELECTED_SCHEDULES=() while :; do printf '\nВыберите автоматические резервные копии (можно несколько):\n' printf ' 1) Ежедневные — хранить последние 7\n' printf ' 2) Еженедельные — хранить последние 4\n' printf ' 3) Ежемесячные — хранить последние 12\n' printf ' 4) Все расписания\n' printf ' 0) Только первичный снимок, без расписания\n' read -rp "Введите номера через пробел или запятую [4]: " answer \ || die "Ввод был прерван" answer="${answer:-4}" answer="${answer//,/ }" SELECTED_SCHEDULES=() if [[ "$answer" =~ ^[[:space:]]*4[[:space:]]*$ ]]; then SELECTED_SCHEDULES[daily]=1 SELECTED_SCHEDULES[weekly]=1 SELECTED_SCHEDULES[monthly]=1 return 0 fi if [[ "$answer" =~ ^[[:space:]]*0[[:space:]]*$ ]]; then return 0 fi for token in $answer; do case "$token" in 1) SELECTED_SCHEDULES[daily]=1 ;; 2) SELECTED_SCHEDULES[weekly]=1 ;; 3) SELECTED_SCHEDULES[monthly]=1 ;; *) warn "Неизвестный пункт: $token" SELECTED_SCHEDULES=() break ;; esac done ((${#SELECTED_SCHEDULES[@]} > 0)) && return 0 warn "Выберите 1, 2, 3, их комбинацию, 4 или 0" done } choose_backup_root() { local entered_path read -rp "Каталог резервных копий [${DEFAULT_BACKUP_ROOT}]: " entered_path \ || die "Ввод был прерван" entered_path="${entered_path:-$DEFAULT_BACKUP_ROOT}" [[ "$entered_path" == /* ]] || die "Укажите абсолютный путь, начинающийся с /" BACKUP_ROOT="$(readlink -m -- "$entered_path")" [[ "$BACKUP_ROOT" =~ ^/[A-Za-z0-9._/-]+$ ]] \ || die "Путь для бэкапов может содержать только латинские буквы, цифры, /, точку, дефис и подчёркивание" case "$BACKUP_ROOT" in /|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/tmp|/tmp/*) die "Нельзя хранить резервные копии в $BACKUP_ROOT" ;; esac } write_backup_runner() { cat > "$BACKUP_RUNNER" <<'BACKUP_RUNNER_EOF' #!/usr/bin/env bash set -Eeuo pipefail umask 077 readonly CONFIG_FILE="/etc/ochenstarik-server/backup.conf" readonly LOCK_FILE="/run/lock/ochenstarik-server-backup.lock" readonly MIN_FREE_RESERVE=$((512 * 1024 * 1024)) log() { printf '[+] %s\n' "$*"; } warn() { printf '[!] %s\n' "$*" >&2; } die() { printf '[x] %s\n' "$*" >&2; exit 1; } human_size() { numfmt --to=iec-i --suffix=B "$1" } protect_initial_files() { local file command -v chattr >/dev/null 2>&1 || return 0 for file in "$BACKUP_ROOT"/initial/initial-*.tar.zst \ "$BACKUP_ROOT"/initial/initial-*.tar.zst.sha256; do [[ -e "$file" ]] || continue chattr +i -- "$file" 2>/dev/null \ || warn "Файловая система не поддерживает immutable для $file" done } check_free_space() { local root_device backup_device root_used backup_used source_used local available reserve estimated_required backup_total root_device="$(df --output=source / | awk 'NR == 2 { print $1 }')" backup_device="$(df --output=source "$BACKUP_ROOT" | awk 'NR == 2 { print $1 }')" root_used="$(df --output=used -B1 / | awk 'NR == 2 { print $1 }')" backup_total="$(df --output=size -B1 "$BACKUP_ROOT" | awk 'NR == 2 { print $1 }')" available="$(df --output=avail -B1 "$BACKUP_ROOT" | awk 'NR == 2 { print $1 }')" source_used="$root_used" if [[ "$root_device" == "$backup_device" ]]; then backup_used="$(du -s -B1 -- "$BACKUP_ROOT" | awk '{ print $1 }')" ((backup_used < source_used)) && source_used=$((source_used - backup_used)) fi reserve=$((backup_total / 20)) ((reserve >= MIN_FREE_RESERVE)) || reserve="$MIN_FREE_RESERVE" estimated_required=$((source_used / 2 + reserve)) if ((available < estimated_required)); then die "Недостаточно свободного места: доступно $(human_size "$available"), приблизительно требуется $(human_size "$estimated_required"). Укажите другой диск в $CONFIG_FILE" fi } rotate_backups() { local backup_kind="$1" keep_count="$2" index filename local target_dir="${BACKUP_ROOT}/${backup_kind}" local -a archives mapfile -t archives < <( find "$target_dir" -maxdepth 1 -type f -name "${backup_kind}-*.tar.zst" \ -printf '%f\n' | sort -r ) for ((index = keep_count; index < ${#archives[@]}; index++)); do filename="${archives[index]}" rm -f -- "$target_dir/$filename" "$target_dir/${filename}.sha256" log "Удалён старый ${backup_kind}-архив: $filename" done } create_backup() { local backup_kind="$1" keep_count="$2" timestamp target_dir local archive temporary checksum checksum_temporary exclude_backup_root target_dir="${BACKUP_ROOT}/${backup_kind}" mkdir -p -m 700 -- "$target_dir" if [[ "$backup_kind" == initial ]] && find "$target_dir" -maxdepth 1 -type f -name 'initial-*.tar.zst' -print -quit | grep -q .; then log "Первичный снимок уже существует и не будет перезаписан" protect_initial_files return 0 fi check_free_space timestamp="$(date +%Y%m%d-%H%M%S)" archive="${target_dir}/${backup_kind}-${timestamp}.tar.zst" temporary="${archive}.partial" checksum="${archive}.sha256" checksum_temporary="${checksum}.partial" exclude_backup_root=".${BACKUP_ROOT}" trap 'rm -f -- "${temporary:-}" "${checksum_temporary:-}"' EXIT [[ ! -e "$archive" && ! -e "$temporary" ]] \ || die "Архив с таким именем уже существует: $archive" log "Создаю ${backup_kind}-архив: $archive" tar \ --create \ --file=- \ --directory=/ \ --one-file-system \ --acls \ --xattrs \ --xattrs-include='*' \ --numeric-owner \ --sparse \ --ignore-failed-read \ --warning=no-file-changed \ --exclude='./proc' \ --exclude='./proc/*' \ --exclude='./sys' \ --exclude='./sys/*' \ --exclude='./dev' \ --exclude='./dev/*' \ --exclude='./run' \ --exclude='./run/*' \ --exclude='./tmp' \ --exclude='./tmp/*' \ --exclude='./var/tmp' \ --exclude='./var/tmp/*' \ --exclude='./mnt' \ --exclude='./mnt/*' \ --exclude='./media' \ --exclude='./media/*' \ --exclude='./lost+found' \ --exclude='./swapfile' \ --exclude="$exclude_backup_root" \ --exclude="${exclude_backup_root}/*" \ . | zstd -T0 -3 --quiet -o "$temporary" [[ -s "$temporary" ]] || die "Получился пустой архив" chmod 600 "$temporary" mv -- "$temporary" "$archive" ( cd "$target_dir" sha256sum "$(basename "$archive")" > "$(basename "$checksum_temporary")" ) chmod 600 "$checksum_temporary" mv -- "$checksum_temporary" "$checksum" trap - EXIT if [[ "$backup_kind" == initial ]]; then protect_initial_files else rotate_backups "$backup_kind" "$keep_count" fi log "Готово: $archive ($(human_size "$(stat -c %s "$archive")"))" } [[ "$EUID" -eq 0 ]] || die "Запустите команду от имени root" [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \ || die "Не найден безопасный файл настроек: $CONFIG_FILE" [[ "$(stat -c %u "$CONFIG_FILE")" == 0 ]] \ || die "Файл настроек должен принадлежать root: $CONFIG_FILE" # shellcheck source=/dev/null source "$CONFIG_FILE" [[ "${BACKUP_ROOT:-}" == /* && "$BACKUP_ROOT" != / ]] \ || die "Некорректный BACKUP_ROOT в $CONFIG_FILE" mkdir -p -m 700 -- "$BACKUP_ROOT"/{initial,daily,weekly,monthly} exec 9>"$LOCK_FILE" flock -n 9 || die "Другое резервное копирование уже выполняется" case "${1:-}" in initial) create_backup initial 0 ;; daily) create_backup daily 7 ;; weekly) create_backup weekly 4 ;; monthly) create_backup monthly 12 ;; *) die "Использование: $0 {initial|daily|weekly|monthly}" ;; esac BACKUP_RUNNER_EOF chmod 700 "$BACKUP_RUNNER" } write_systemd_units() { cat > "$SERVICE_FILE" < "${TIMER_PREFIX}-daily.timer" <<'EOF' [Unit] Description=Daily Ochenstarik server backup [Timer] OnCalendar=*-*-* 03:15:00 Persistent=true RandomizedDelaySec=10m Unit=ochenstarik-backup@daily.service [Install] WantedBy=timers.target EOF cat > "${TIMER_PREFIX}-weekly.timer" <<'EOF' [Unit] Description=Weekly Ochenstarik server backup [Timer] OnCalendar=Sun *-*-* 04:15:00 Persistent=true RandomizedDelaySec=10m Unit=ochenstarik-backup@weekly.service [Install] WantedBy=timers.target EOF cat > "${TIMER_PREFIX}-monthly.timer" <<'EOF' [Unit] Description=Monthly Ochenstarik server backup [Timer] OnCalendar=*-*-01 05:15:00 Persistent=true RandomizedDelaySec=10m Unit=ochenstarik-backup@monthly.service [Install] WantedBy=timers.target EOF chmod 644 "$SERVICE_FILE" "${TIMER_PREFIX}-daily.timer" \ "${TIMER_PREFIX}-weekly.timer" "${TIMER_PREFIX}-monthly.timer" } apply_schedule_selection() { local schedule timer systemctl daemon-reload for schedule in daily weekly monthly; do timer="ochenstarik-backup-${schedule}.timer" if [[ -n "${SELECTED_SCHEDULES[$schedule]:-}" ]]; then systemctl enable --now "$timer" else systemctl disable --now "$timer" >/dev/null 2>&1 || true fi done } print_summary() { local schedule printf '\nНастройка резервного копирования завершена.\n' printf 'Каталог: %s\n' "$BACKUP_ROOT" printf 'Первичный снимок: создан один раз, автоматическая ротация отключена.\n' printf 'Активные расписания:' if ((${#SELECTED_SCHEDULES[@]} == 0)); then printf ' нет\n' else for schedule in daily weekly monthly; do [[ -n "${SELECTED_SCHEDULES[$schedule]:-}" ]] && printf ' %s' "$schedule" done printf '\n' fi printf '\nПроверка таймеров:\n systemctl list-timers "ochenstarik-backup-*"\n' printf 'Ручной запуск:\n sudo %s daily\n' "$BACKUP_RUNNER" printf 'Проверка архива:\n cd %s/initial && sha256sum -c ./*.sha256\n' "$BACKUP_ROOT" printf '\nХраните дополнительную копию на другом сервере или диске.\n' } [[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root" for command_name in apt-get cat chmod install readlink systemctl; do require_command "$command_name" done choose_schedules choose_backup_root export DEBIAN_FRONTEND=noninteractive log "Устанавливаю зависимости" apt-get update apt-get install -y coreutils e2fsprogs findutils tar util-linux zstd for command_name in awk basename chattr df du find flock grep numfmt sha256sum sort stat tar zstd; do require_command "$command_name" done install -d -o root -g root -m 700 "$CONFIG_DIR" "$BACKUP_ROOT" [[ ! -L "$CONFIG_FILE" ]] || die "Отказ от записи через символическую ссылку: $CONFIG_FILE" printf 'BACKUP_ROOT=%q\n' "$BACKUP_ROOT" > "$CONFIG_FILE" chown root:root "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" write_backup_runner write_systemd_units log "Создаю первичный снимок (при повторном запуске существующий не изменяется)" "$BACKUP_RUNNER" initial apply_schedule_selection print_summary