#!/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 } choose_protection() { local answer known_hosts read -rp 'Шифровать архивы через age? [y/N]: ' answer || die "Ввод был прерван" case "${answer,,}" in y|yes) BACKUP_ENCRYPTION=age read -rp 'Публичный age recipient (age1...): ' AGE_RECIPIENT \ || die "Ввод был прерван" [[ "$AGE_RECIPIENT" =~ ^age1[0-9a-z]{20,}$ ]] \ || die "Укажите корректный публичный age recipient" ;; ''|n|no) BACKUP_ENCRYPTION=none ;; *) die "Введите y или n" ;; esac read -rp 'Выгружать архивы на другой SSH-хост? [y/N]: ' answer \ || die "Ввод был прерван" case "${answer,,}" in y|yes) read -rp 'SSH-цель в формате user@host: ' OFFSITE_TARGET \ || die "Ввод был прерван" [[ "$OFFSITE_TARGET" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] \ || die "Некорректная SSH-цель" read -rp 'Каталог на целевом хосте (абсолютный путь): ' OFFSITE_PATH \ || die "Ввод был прерван" [[ "$OFFSITE_PATH" =~ ^/[A-Za-z0-9._/-]+$ && "$OFFSITE_PATH" != / \ && "$OFFSITE_PATH" != *..* ]] \ || die "Некорректный удалённый каталог" read -rp 'Локальный файл known_hosts с заранее проверенным ключом: ' known_hosts \ || die "Ввод был прерван" [[ -f "$known_hosts" && ! -L "$known_hosts" && -s "$known_hosts" ]] \ || die "Нужен существующий непустой обычный known_hosts файл" [[ "$(stat -c %u "$known_hosts")" == 0 ]] \ || die "known_hosts должен принадлежать root" [[ "$(stat -c %a "$known_hosts")" =~ ^[0-7]*[0-5][0-5]$ ]] \ || die "known_hosts не должен быть доступен для записи группе или всем" OFFSITE_KNOWN_HOSTS_SOURCE="$(readlink -f -- "$known_hosts")" OFFSITE_KNOWN_HOSTS="${CONFIG_DIR}/backup-known_hosts" ;; ''|n|no) OFFSITE_TARGET=""; OFFSITE_PATH=""; OFFSITE_KNOWN_HOSTS="" OFFSITE_KNOWN_HOSTS_SOURCE="" ;; *) die "Введите y или n" ;; 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 \ "$BACKUP_ROOT"/initial/initial-*.tar.zst.age \ "$BACKUP_ROOT"/initial/initial-*.tar.zst.age.sha256; do [[ -f "$file" && ! -L "$file" ]] || continue chattr +i -- "$file" 2>/dev/null \ || warn "Файловая система не поддерживает immutable для $file" done } upload_offsite() { local archive="$1" checksum="$2" [[ -z "$OFFSITE_TARGET" ]] && return 0 scp -B \ -o BatchMode=yes \ -o ConnectTimeout=15 \ -o StrictHostKeyChecking=yes \ -o "UserKnownHostsFile=$OFFSITE_KNOWN_HOSTS" \ -o ForwardAgent=no \ -o ClearAllForwardings=yes \ "$archive" "$checksum" "${OFFSITE_TARGET}:${OFFSITE_PATH}/" \ || die "Выгрузка на заданный SSH-хост не удалась; резервная копия не считается завершённой" log "Архив и checksum выгружены на настроенный SSH-хост" } 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" -o -name "${backup_kind}-*.tar.zst.age" \) \ -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 } read_backup_config() { local line key value BACKUP_ROOT="" BACKUP_ENCRYPTION=none AGE_RECIPIENT="" OFFSITE_TARGET="" OFFSITE_PATH="" OFFSITE_KNOWN_HOSTS="" OFFSITE_KNOWN_HOSTS_SOURCE="" [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \ || die "Не найден безопасный файл настроек: $CONFIG_FILE" [[ "$(stat -c %u "$CONFIG_FILE")" == 0 ]] \ || die "Файл настроек должен принадлежать root: $CONFIG_FILE" [[ "$(stat -c %a "$CONFIG_FILE")" =~ ^[0-7]*[0-5][0-5]$ ]] \ || die "Файл настроек не должен быть доступен для записи группе или всем: $CONFIG_FILE" while IFS= read -r line || [[ -n "$line" ]]; do line="${line//$'\r'/}" [[ -n "$line" && "$line" != \#* ]] || continue [[ "$line" == *=* ]] || die "Некорректная строка в $CONFIG_FILE: $line" key="${line%%=*}" value="${line#*=}" case "$key" in BACKUP_ROOT) [[ "$value" =~ ^/[A-Za-z0-9._/-]+$ ]] \ || die "Некорректный BACKUP_ROOT в $CONFIG_FILE" BACKUP_ROOT="$value" ;; BACKUP_ENCRYPTION) [[ "$value" == none || "$value" == age ]] \ || die "Некорректный BACKUP_ENCRYPTION в $CONFIG_FILE" BACKUP_ENCRYPTION="$value" ;; AGE_RECIPIENT) [[ "$value" =~ ^age1[0-9a-z]{20,}$ ]] \ || die "Некорректный AGE_RECIPIENT в $CONFIG_FILE" AGE_RECIPIENT="$value" ;; OFFSITE_TARGET) [[ "$value" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] \ || die "Некорректный OFFSITE_TARGET в $CONFIG_FILE" OFFSITE_TARGET="$value" ;; OFFSITE_PATH) [[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != / \ && "$value" != *..* ]] \ || die "Некорректный OFFSITE_PATH в $CONFIG_FILE" OFFSITE_PATH="$value" ;; OFFSITE_KNOWN_HOSTS) [[ "$value" =~ ^/[A-Za-z0-9._/-]+$ ]] \ || die "Некорректный OFFSITE_KNOWN_HOSTS в $CONFIG_FILE" OFFSITE_KNOWN_HOSTS="$value" ;; *) die "Неизвестный ключ в $CONFIG_FILE: $key" ;; esac done < "$CONFIG_FILE" if [[ "$BACKUP_ENCRYPTION" == age ]]; then [[ -n "$AGE_RECIPIENT" ]] || die "Для шифрования нужен AGE_RECIPIENT" command -v age >/dev/null 2>&1 || die "Для шифрования не найдена команда age" elif [[ -n "$AGE_RECIPIENT" ]]; then die "AGE_RECIPIENT задан, но шифрование отключено" fi if [[ -n "$OFFSITE_TARGET" || -n "$OFFSITE_PATH" || -n "$OFFSITE_KNOWN_HOSTS" ]]; then [[ -n "$OFFSITE_TARGET" && -n "$OFFSITE_PATH" && -n "$OFFSITE_KNOWN_HOSTS" ]] \ || die "Настройки выгрузки должны быть заполнены полностью" [[ -f "$OFFSITE_KNOWN_HOSTS" && ! -L "$OFFSITE_KNOWN_HOSTS" \ && -s "$OFFSITE_KNOWN_HOSTS" && "$(stat -c %u "$OFFSITE_KNOWN_HOSTS")" == 0 \ && "$(stat -c %a "$OFFSITE_KNOWN_HOSTS")" =~ ^[0-7]*[0-5][0-5]$ ]] \ || die "known_hosts отсутствует или имеет небезопасные права" command -v scp >/dev/null 2>&1 || die "Для выгрузки не найдена команда scp" fi } create_backup() { local backup_kind="$1" keep_count="$2" timestamp target_dir local archive temporary checksum checksum_temporary exclude_backup_root existing local -a initial_archives target_dir="${BACKUP_ROOT}/${backup_kind}" mkdir -p -m 700 -- "$target_dir" if [[ "$backup_kind" == initial ]]; then mapfile -t initial_archives < <( find "$target_dir" -maxdepth 1 -type f \ \( -name 'initial-*.tar.zst' -o -name 'initial-*.tar.zst.age' \) \ -printf '%f\n' | sort -r ) if ((${#initial_archives[@]} > 0)); then existing="${target_dir}/${initial_archives[0]}" checksum="${existing}.sha256" [[ -f "$existing" && ! -L "$existing" \ && -f "$checksum" && ! -L "$checksum" ]] \ || die "Первичный снимок или его контрольная сумма отсутствует либо небезопасна" ( cd -- "$target_dir" sha256sum --check --status "$(basename "$checksum")" ) || die "Контрольная сумма первичного снимка не совпадает" if [[ "$BACKUP_ENCRYPTION" == age && "$existing" != *.tar.zst.age ]]; then die "Первичный снимок уже создан без шифрования; он сохранён, но не соответствует выбранной политике age. Укажите прежнюю политику или архивируйте систему отдельно." fi protect_initial_files upload_offsite "$existing" "$checksum" log "Первичный снимок уже существует и не будет перезаписан; checksum проверен" return 0 fi fi check_free_space timestamp="$(date +%Y%m%d-%H%M%S)" archive="${target_dir}/${backup_kind}-${timestamp}.tar.zst" [[ "$BACKUP_ENCRYPTION" != age ]] || archive="${archive}.age" 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 | { if [[ "$BACKUP_ENCRYPTION" == age ]]; then age --encrypt --recipient "$AGE_RECIPIENT" --output "$temporary" else cat > "$temporary" fi } [[ -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 upload_offsite "$archive" "$checksum" log "Готово: $archive ($(human_size "$(stat -c %s "$archive")"))" } [[ "$EUID" -eq 0 ]] || die "Запустите команду от имени root" read_backup_config [[ "${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 choose_protection export DEBIAN_FRONTEND=noninteractive log "Устанавливаю зависимости" apt-get update apt-get install -y coreutils e2fsprogs findutils tar util-linux zstd [[ "$BACKUP_ENCRYPTION" != age ]] || apt-get install -y age [[ -z "$OFFSITE_TARGET" ]] || apt-get install -y openssh-client 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" if [[ -n "$OFFSITE_TARGET" ]]; then [[ ! -L "$OFFSITE_KNOWN_HOSTS" ]] \ || die "Отказ от записи через символическую ссылку: $OFFSITE_KNOWN_HOSTS" install -o root -g root -m 600 -- "$OFFSITE_KNOWN_HOSTS_SOURCE" "$OFFSITE_KNOWN_HOSTS" fi [[ ! -L "$CONFIG_FILE" ]] || die "Отказ от записи через символическую ссылку: $CONFIG_FILE" { printf 'BACKUP_ROOT=%s\n' "$BACKUP_ROOT" printf 'BACKUP_ENCRYPTION=%s\n' "$BACKUP_ENCRYPTION" [[ "$BACKUP_ENCRYPTION" != age ]] || printf 'AGE_RECIPIENT=%s\n' "$AGE_RECIPIENT" if [[ -n "$OFFSITE_TARGET" ]]; then printf 'OFFSITE_TARGET=%s\n' "$OFFSITE_TARGET" printf 'OFFSITE_PATH=%s\n' "$OFFSITE_PATH" printf 'OFFSITE_KNOWN_HOSTS=%s\n' "$OFFSITE_KNOWN_HOSTS" fi } > "$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