2026-09-19 18:21:02 +03:00
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# glchat: бэкап БД и файлов с age-шифрованием (AGENT.md 10.8).
|
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
|
|
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
|
|
|
|
|
|
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}"
|
|
|
|
|
|
ENV_FILE="${GLCHAT_ETC_DIR}/.env"
|
|
|
|
|
|
BACKUP_DIR="${GLCHAT_DATA_DIR}/backups"
|
|
|
|
|
|
KEEP_DAYS_DEFAULT=7
|
|
|
|
|
|
|
|
|
|
|
|
log() { printf '[backup] %s\n' "$*"; }
|
2026-09-22 18:44:50 +03:00
|
|
|
|
warn() { printf '[backup] внимание: %s\n' "$*" >&2; }
|
2026-09-19 18:21:02 +03:00
|
|
|
|
die() {
|
|
|
|
|
|
printf '[backup] ошибка: %s\n' "$*" >&2
|
|
|
|
|
|
exit 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[ -f "$ENV_FILE" ] || die "не найден ${ENV_FILE}"
|
|
|
|
|
|
# shellcheck disable=SC1090
|
|
|
|
|
|
set -a && . "$ENV_FILE" && set +a
|
|
|
|
|
|
|
|
|
|
|
|
KEEP_DAYS="${BACKUP_KEEP_DAYS:-$KEEP_DAYS_DEFAULT}"
|
|
|
|
|
|
AGE_RECIPIENT="${AGE_RECIPIENT:-}"
|
2026-09-22 18:44:50 +03:00
|
|
|
|
# Выгрузка копии наружу: rsync-цель (user@host:/path) или `rclone:remote:path`
|
|
|
|
|
|
# (AGENT.md 10.8, опциональный offsite). Ошибка выгрузки не отменяет локальный
|
|
|
|
|
|
# бэкап: о ней пишем предупреждение.
|
|
|
|
|
|
OFFSITE_TARGET="${BACKUP_OFFSITE_TARGET:-}"
|
|
|
|
|
|
# Ротация на принимающей стороне — только по явной настройке: пусто значит
|
|
|
|
|
|
# «копим копии, чистим вручную».
|
|
|
|
|
|
OFFSITE_KEEP_DAYS="${BACKUP_OFFSITE_KEEP_DAYS:-}"
|
|
|
|
|
|
case "$OFFSITE_KEEP_DAYS" in
|
|
|
|
|
|
"") ;;
|
|
|
|
|
|
*[!0-9]*)
|
|
|
|
|
|
warn "BACKUP_OFFSITE_KEEP_DAYS='${OFFSITE_KEEP_DAYS}' не число — offsite-ротация выключена"
|
|
|
|
|
|
OFFSITE_KEEP_DAYS=""
|
|
|
|
|
|
;;
|
|
|
|
|
|
esac
|
2026-09-19 18:21:02 +03:00
|
|
|
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
2026-09-22 18:44:50 +03:00
|
|
|
|
# Два запуска в одну секунду не должны затирать друг друга: имя с суффиксом.
|
|
|
|
|
|
SUFFIX=""
|
|
|
|
|
|
suffix_n=1
|
|
|
|
|
|
while [ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz" ] ||
|
|
|
|
|
|
[ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz.age" ]; do
|
|
|
|
|
|
SUFFIX="-${suffix_n}"
|
|
|
|
|
|
suffix_n=$((suffix_n + 1))
|
|
|
|
|
|
done
|
2026-09-19 18:21:02 +03:00
|
|
|
|
WORK_DIR="$(mktemp -d)"
|
2026-09-22 18:44:50 +03:00
|
|
|
|
ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
TARGET="${ARCHIVE}.age"
|
2026-09-22 18:44:50 +03:00
|
|
|
|
# ARCHIVE_OR_TARGET — готовый файл бэкапа (с шифрованием или без): его выгружает
|
|
|
|
|
|
# offsite-копирование.
|
|
|
|
|
|
ARCHIVE_OR_TARGET=""
|
2026-09-19 18:21:02 +03:00
|
|
|
|
|
|
|
|
|
|
cleanup() { rm -rf "$WORK_DIR"; }
|
|
|
|
|
|
trap cleanup EXIT
|
|
|
|
|
|
|
|
|
|
|
|
mkdir -p "$BACKUP_DIR"
|
|
|
|
|
|
chmod 700 "$BACKUP_DIR"
|
|
|
|
|
|
|
|
|
|
|
|
log "дамп SQLite (sqlite3 .backup)"
|
|
|
|
|
|
if command -v sqlite3 >/dev/null 2>&1; then
|
|
|
|
|
|
sqlite3 "${GLCHAT_DATA_DIR}/db/glchat.db" ".backup '${WORK_DIR}/glchat.db'" ||
|
|
|
|
|
|
die "не удалось сделать дамп базы"
|
|
|
|
|
|
else
|
|
|
|
|
|
docker exec glchat-app true 2>/dev/null || die "sqlite3 не установлен и контейнер недоступен"
|
|
|
|
|
|
docker cp glchat-app:/app/data/db/glchat.db "${WORK_DIR}/glchat.db"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
log "копируем файлы"
|
|
|
|
|
|
mkdir -p "${WORK_DIR}/files"
|
|
|
|
|
|
if [ -d "${GLCHAT_DATA_DIR}/files" ]; then
|
|
|
|
|
|
if command -v rsync >/dev/null 2>&1; then
|
|
|
|
|
|
rsync -a --delete "${GLCHAT_DATA_DIR}/files/" "${WORK_DIR}/files/"
|
|
|
|
|
|
else
|
|
|
|
|
|
cp -a "${GLCHAT_DATA_DIR}/files/." "${WORK_DIR}/files/"
|
|
|
|
|
|
fi
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
log "сохраняем конфиги (без секретов)"
|
|
|
|
|
|
mkdir -p "${WORK_DIR}/config"
|
|
|
|
|
|
cp -a "${GLCHAT_ETC_DIR}/compose.yaml" "${WORK_DIR}/config/" 2>/dev/null || true
|
|
|
|
|
|
cp -a "${GLCHAT_ETC_DIR}/livekit" "${WORK_DIR}/config/" 2>/dev/null || true
|
|
|
|
|
|
cp -a "${GLCHAT_ETC_DIR}/external-proxy.conf" "${WORK_DIR}/config/" 2>/dev/null || true
|
|
|
|
|
|
cat >"${WORK_DIR}/MANIFEST.txt" <<EOF
|
|
|
|
|
|
glchat backup
|
|
|
|
|
|
created_at=${STAMP}
|
|
|
|
|
|
domain=${DOMAIN:-unknown}
|
|
|
|
|
|
version=${APP_VERSION:-unknown}
|
|
|
|
|
|
profile=${PROFILE:-unknown}
|
|
|
|
|
|
EOF
|
|
|
|
|
|
|
|
|
|
|
|
log "упаковываем"
|
|
|
|
|
|
tar -czf "$ARCHIVE" -C "$WORK_DIR" .
|
|
|
|
|
|
|
|
|
|
|
|
if [ -n "$AGE_RECIPIENT" ]; then
|
|
|
|
|
|
command -v age >/dev/null 2>&1 || die "age не установлен, а AGE_RECIPIENT задан"
|
|
|
|
|
|
age -r "$AGE_RECIPIENT" -o "$TARGET" "$ARCHIVE" || die "age-шифрование не удалось"
|
|
|
|
|
|
rm -f "$ARCHIVE"
|
|
|
|
|
|
chmod 600 "$TARGET"
|
2026-09-22 18:44:50 +03:00
|
|
|
|
ARCHIVE_OR_TARGET="$TARGET"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
log "готово: ${TARGET} ($(du -h "$TARGET" | cut -f1))"
|
|
|
|
|
|
else
|
|
|
|
|
|
chmod 600 "$ARCHIVE"
|
2026-09-22 18:44:50 +03:00
|
|
|
|
ARCHIVE_OR_TARGET="$ARCHIVE"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
log "готово (БЕЗ шифрования, AGE_RECIPIENT не задан): ${ARCHIVE}"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
log "ротация: оставляем ${KEEP_DAYS} дн."
|
|
|
|
|
|
find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' -mtime "+${KEEP_DAYS}" -print -delete || true
|
|
|
|
|
|
|
2026-09-22 18:44:50 +03:00
|
|
|
|
# Offsite идёт после локальной ротации: наружу уезжает уже актуальный набор.
|
|
|
|
|
|
# Неудача выгрузки не отменяет локальный бэкап — только предупреждение.
|
|
|
|
|
|
if [ -n "$OFFSITE_TARGET" ]; then
|
|
|
|
|
|
log "выгружаем копию: ${OFFSITE_TARGET}"
|
|
|
|
|
|
offsite_ok=0
|
|
|
|
|
|
case "$OFFSITE_TARGET" in
|
|
|
|
|
|
rclone:*)
|
|
|
|
|
|
remote="${OFFSITE_TARGET#rclone:}"
|
|
|
|
|
|
if command -v rclone >/dev/null 2>&1; then
|
|
|
|
|
|
rclone copy "$ARCHIVE_OR_TARGET" "$remote" && offsite_ok=1
|
|
|
|
|
|
else
|
|
|
|
|
|
warn "rclone не установлен: offsite-копия не сделана"
|
|
|
|
|
|
fi
|
|
|
|
|
|
;;
|
|
|
|
|
|
*)
|
|
|
|
|
|
if command -v rsync >/dev/null 2>&1; then
|
|
|
|
|
|
# Цель — каталог на приёмнике; без --delete копии только копятся.
|
|
|
|
|
|
rsync -az --partial "$ARCHIVE_OR_TARGET" "${OFFSITE_TARGET%/}/" && offsite_ok=1
|
|
|
|
|
|
else
|
|
|
|
|
|
warn "rsync не установлен: offsite-копия не сделана"
|
|
|
|
|
|
fi
|
|
|
|
|
|
;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
if [ "$offsite_ok" = "1" ]; then
|
|
|
|
|
|
log "offsite-копия отправлена"
|
|
|
|
|
|
else
|
|
|
|
|
|
warn "offsite-копия не отправлена (локальный бэкап на месте)"
|
|
|
|
|
|
fi
|
|
|
|
|
|
if [ -n "$OFFSITE_KEEP_DAYS" ]; then
|
|
|
|
|
|
case "$OFFSITE_TARGET" in
|
|
|
|
|
|
rclone:*)
|
|
|
|
|
|
if [ "$offsite_ok" = "1" ]; then
|
|
|
|
|
|
rclone delete --min-age "${OFFSITE_KEEP_DAYS}d" "${OFFSITE_TARGET#rclone:}" &&
|
|
|
|
|
|
log "offsite-ротация: удалены копии старше ${OFFSITE_KEEP_DAYS} дн." ||
|
|
|
|
|
|
warn "offsite-ротация не удалась"
|
|
|
|
|
|
fi
|
|
|
|
|
|
;;
|
|
|
|
|
|
*)
|
|
|
|
|
|
warn "offsite-ротация по возрасту поддержана только для rclone — почистите приёмник вручную"
|
|
|
|
|
|
;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
fi
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
2026-09-19 18:21:02 +03:00
|
|
|
|
log "бэкапов в каталоге: $(find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' | wc -l)"
|