558 lines
21 KiB
Bash
Executable file
558 lines
21 KiB
Bash
Executable file
#!/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" <<EOF
|
||
[Unit]
|
||
Description=Ochenstarik server backup (%i)
|
||
After=local-fs.target
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=${BACKUP_RUNNER} %i
|
||
Nice=10
|
||
IOSchedulingClass=idle
|
||
UMask=0077
|
||
TimeoutStartSec=infinity
|
||
EOF
|
||
|
||
cat > "${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
|