chore: tune backup function and related docs
This commit is contained in:
@@ -70,16 +70,3 @@ FRONTEND_PORT=8082
|
|||||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
# Keep loopback for local proxy; add your proxy network if needed.
|
# Keep loopback for local proxy; add your proxy network if needed.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
TRUSTED_PROXIES=127.0.0.1,::1
|
||||||
|
|
||||||
# Periodic backups from the worker container. Disabled by default.
|
|
||||||
# The archive contains a pg_dump custom-format dump and a snapshot of the
|
|
||||||
# compose directory mounted read-only into /app/compose-source.
|
|
||||||
BACKUP_ENABLED=False
|
|
||||||
BACKUP_CHAT_ID=
|
|
||||||
BACKUP_INTERVAL_SECONDS=3600
|
|
||||||
BACKUP_LOCAL_RETENTION=100
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
|
||||||
BACKUP_COMPOSE_ENABLED=True
|
|
||||||
COMPOSE_BACKUP_SOURCE=.
|
|
||||||
COMPOSE_RESTORE_MODE=rw
|
|
||||||
|
|||||||
@@ -1,53 +1,15 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from config.settings import Settings
|
|
||||||
|
|
||||||
BACKUP_APP_ID = "remnawave-minishop"
|
BACKUP_APP_ID = "remnawave-minishop"
|
||||||
BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-"
|
BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-"
|
||||||
BACKUP_FORMAT_VERSION = 1
|
BACKUP_FORMAT_VERSION = 1
|
||||||
BACKUP_MANIFEST_NAME = "manifest.json"
|
BACKUP_MANIFEST_NAME = "manifest.json"
|
||||||
|
|
||||||
|
|
||||||
def backup_signature_secret(settings: Settings) -> str:
|
|
||||||
configured = str(getattr(settings, "BACKUP_ARCHIVE_SIGNATURE_SECRET", "") or "").strip()
|
|
||||||
return configured or settings.BOT_TOKEN
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_manifest_payload(manifest: dict[str, Any]) -> bytes:
|
|
||||||
payload = json.loads(json.dumps(manifest, ensure_ascii=False))
|
|
||||||
archive = payload.get("archive")
|
|
||||||
if isinstance(archive, dict):
|
|
||||||
archive.pop("signature", None)
|
|
||||||
return json.dumps(
|
|
||||||
payload,
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def sign_manifest(manifest: dict[str, Any], settings: Settings) -> str:
|
|
||||||
return hmac.new(
|
|
||||||
backup_signature_secret(settings).encode("utf-8"),
|
|
||||||
canonical_manifest_payload(manifest),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_manifest_signature(manifest: dict[str, Any], settings: Settings) -> bool:
|
|
||||||
archive = manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
|
|
||||||
signature = str(archive.get("signature") or "")
|
|
||||||
if not signature:
|
|
||||||
return False
|
|
||||||
expected = sign_manifest(manifest, settings)
|
|
||||||
return hmac.compare_digest(signature, expected)
|
|
||||||
|
|
||||||
|
|
||||||
def file_sha256(path: Path) -> str:
|
def file_sha256(path: Path) -> str:
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with path.open("rb") as handle:
|
with path.open("rb") as handle:
|
||||||
@@ -79,14 +41,12 @@ def attach_archive_integrity(
|
|||||||
manifest: dict[str, Any],
|
manifest: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
file_records: list[dict[str, Any]],
|
file_records: list[dict[str, Any]],
|
||||||
settings: Settings,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
manifest["app"] = BACKUP_APP_ID
|
manifest["app"] = BACKUP_APP_ID
|
||||||
manifest["format_version"] = BACKUP_FORMAT_VERSION
|
manifest["format_version"] = BACKUP_FORMAT_VERSION
|
||||||
manifest["archive"] = {
|
manifest["archive"] = {
|
||||||
"files": file_records,
|
"files": file_records,
|
||||||
}
|
}
|
||||||
manifest["archive"]["signature"] = sign_manifest(manifest, settings)
|
|
||||||
|
|
||||||
|
|
||||||
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
|
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from bot.services.backup_archive import (
|
|||||||
BACKUP_MANIFEST_NAME,
|
BACKUP_MANIFEST_NAME,
|
||||||
attach_archive_integrity,
|
attach_archive_integrity,
|
||||||
build_file_records,
|
build_file_records,
|
||||||
verify_manifest_signature,
|
|
||||||
write_manifest,
|
write_manifest,
|
||||||
write_zip_from_directory,
|
write_zip_from_directory,
|
||||||
)
|
)
|
||||||
@@ -153,16 +152,6 @@ class BackupRestoreService:
|
|||||||
with zipfile.ZipFile(archive_path) as archive:
|
with zipfile.ZipFile(archive_path) as archive:
|
||||||
self._validate_zip_members(archive.infolist())
|
self._validate_zip_members(archive.infolist())
|
||||||
manifest = self._read_manifest(archive)
|
manifest = self._read_manifest(archive)
|
||||||
signature_valid = self._archive_signature_valid(manifest)
|
|
||||||
signature_required = getattr(
|
|
||||||
self.settings,
|
|
||||||
"BACKUP_ARCHIVE_SIGNATURE_REQUIRED",
|
|
||||||
True,
|
|
||||||
)
|
|
||||||
if signature_required and not signature_valid:
|
|
||||||
raise BackupArchiveError("Archive manifest signature is not valid")
|
|
||||||
if not signature_valid:
|
|
||||||
warnings.append("manifest signature is not valid")
|
|
||||||
has_database = self._find_database_dump_member(archive) is not None
|
has_database = self._find_database_dump_member(archive) is not None
|
||||||
compose_members = self._compose_file_members(archive)
|
compose_members = self._compose_file_members(archive)
|
||||||
|
|
||||||
@@ -401,7 +390,6 @@ class BackupRestoreService:
|
|||||||
attach_archive_integrity(
|
attach_archive_integrity(
|
||||||
manifest,
|
manifest,
|
||||||
file_records=build_file_records(staging_dir),
|
file_records=build_file_records(staging_dir),
|
||||||
settings=self.settings,
|
|
||||||
)
|
)
|
||||||
write_manifest(staging_dir, manifest)
|
write_manifest(staging_dir, manifest)
|
||||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||||
@@ -551,18 +539,12 @@ class BackupRestoreService:
|
|||||||
raise BackupArchiveError("Archive manifest format is not supported")
|
raise BackupArchiveError("Archive manifest format is not supported")
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def _archive_signature_valid(self, manifest: dict[str, Any]) -> bool:
|
|
||||||
return verify_manifest_signature(manifest, self.settings)
|
|
||||||
|
|
||||||
def _validate_archive_for_restore(self, archive_path: Path) -> None:
|
def _validate_archive_for_restore(self, archive_path: Path) -> None:
|
||||||
if not zipfile.is_zipfile(archive_path):
|
if not zipfile.is_zipfile(archive_path):
|
||||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||||
with zipfile.ZipFile(archive_path) as archive:
|
with zipfile.ZipFile(archive_path) as archive:
|
||||||
self._validate_zip_members(archive.infolist())
|
self._validate_zip_members(archive.infolist())
|
||||||
manifest = self._read_manifest(archive)
|
manifest = self._read_manifest(archive)
|
||||||
if getattr(self.settings, "BACKUP_ARCHIVE_SIGNATURE_REQUIRED", True):
|
|
||||||
if not self._archive_signature_valid(manifest):
|
|
||||||
raise BackupArchiveError("Archive manifest signature is not valid")
|
|
||||||
self._validate_archive_integrity(archive, manifest)
|
self._validate_archive_integrity(archive, manifest)
|
||||||
|
|
||||||
def _validate_archive_integrity(
|
def _validate_archive_integrity(
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ class BackupWorker:
|
|||||||
attach_archive_integrity(
|
attach_archive_integrity(
|
||||||
manifest,
|
manifest,
|
||||||
file_records=build_file_records(staging_dir),
|
file_records=build_file_records(staging_dir),
|
||||||
settings=self.settings,
|
|
||||||
)
|
)
|
||||||
write_manifest(staging_dir, manifest)
|
write_manifest(staging_dir, manifest)
|
||||||
|
|
||||||
|
|||||||
@@ -135,8 +135,6 @@ class Settings(BaseSettings):
|
|||||||
BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||||
BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore")
|
BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore")
|
||||||
BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED: bool = Field(default=True)
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_SECRET: Optional[str] = Field(default=None)
|
|
||||||
BACKUP_COMPOSE_ENABLED: bool = Field(default=True)
|
BACKUP_COMPOSE_ENABLED: bool = Field(default=True)
|
||||||
BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source")
|
BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source")
|
||||||
BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None)
|
BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None)
|
||||||
@@ -1005,7 +1003,6 @@ class Settings(BaseSettings):
|
|||||||
"SMTP_FROM_EMAIL",
|
"SMTP_FROM_EMAIL",
|
||||||
"SMTP_FROM_NAME",
|
"SMTP_FROM_NAME",
|
||||||
"SMTP_FALLBACK_PORTS",
|
"SMTP_FALLBACK_PORTS",
|
||||||
"BACKUP_ARCHIVE_SIGNATURE_SECRET",
|
|
||||||
"BACKUP_COMPOSE_SOURCE_DIR",
|
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||||
"BACKUP_COMPOSE_RESTORE_DIR",
|
"BACKUP_COMPOSE_RESTORE_DIR",
|
||||||
mode="before",
|
mode="before",
|
||||||
|
|||||||
@@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
|
|
||||||
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||||
|
|
||||||
# Periodic backups from the worker container. Disabled by default.
|
|
||||||
BACKUP_ENABLED=False
|
|
||||||
BACKUP_CHAT_ID=
|
|
||||||
BACKUP_INTERVAL_SECONDS=3600
|
|
||||||
BACKUP_LOCAL_RETENTION=100
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
|
||||||
BACKUP_COMPOSE_ENABLED=True
|
|
||||||
COMPOSE_BACKUP_SOURCE=.
|
|
||||||
COMPOSE_RESTORE_MODE=rw
|
|
||||||
|
|||||||
@@ -39,14 +39,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
|
|
||||||
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||||
|
|
||||||
# Periodic backups from the worker container. Disabled by default.
|
|
||||||
BACKUP_ENABLED=False
|
|
||||||
BACKUP_CHAT_ID=
|
|
||||||
BACKUP_INTERVAL_SECONDS=3600
|
|
||||||
BACKUP_LOCAL_RETENTION=100
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
|
||||||
BACKUP_COMPOSE_ENABLED=True
|
|
||||||
COMPOSE_BACKUP_SOURCE=.
|
|
||||||
COMPOSE_RESTORE_MODE=rw
|
|
||||||
|
|||||||
@@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
|
|
||||||
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||||
|
|
||||||
# Periodic backups from the worker container. Disabled by default.
|
|
||||||
BACKUP_ENABLED=False
|
|
||||||
BACKUP_CHAT_ID=
|
|
||||||
BACKUP_INTERVAL_SECONDS=3600
|
|
||||||
BACKUP_LOCAL_RETENTION=100
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
|
||||||
BACKUP_COMPOSE_ENABLED=True
|
|
||||||
COMPOSE_BACKUP_SOURCE=.
|
|
||||||
COMPOSE_RESTORE_MODE=rw
|
|
||||||
|
|||||||
@@ -39,14 +39,3 @@ PANEL_API_KEY=change_me
|
|||||||
PANEL_WEBHOOK_SECRET=change_me
|
PANEL_WEBHOOK_SECRET=change_me
|
||||||
|
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
TRUSTED_PROXIES=127.0.0.1,::1
|
||||||
|
|
||||||
# Periodic backups from the worker container. Disabled by default.
|
|
||||||
BACKUP_ENABLED=False
|
|
||||||
BACKUP_CHAT_ID=
|
|
||||||
BACKUP_INTERVAL_SECONDS=3600
|
|
||||||
BACKUP_LOCAL_RETENTION=100
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
|
||||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
|
||||||
BACKUP_COMPOSE_ENABLED=True
|
|
||||||
COMPOSE_BACKUP_SOURCE=.
|
|
||||||
COMPOSE_RESTORE_MODE=rw
|
|
||||||
|
|||||||
@@ -84,8 +84,6 @@
|
|||||||
| `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. |
|
| `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. |
|
||||||
| `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. |
|
| `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. |
|
||||||
| `BACKUP_PG_RESTORE_TIMEOUT_SECONDS` | Таймаут выполнения `pg_restore`. |
|
| `BACKUP_PG_RESTORE_TIMEOUT_SECONDS` | Таймаут выполнения `pg_restore`. |
|
||||||
| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись `manifest.json` при upload/restore. По умолчанию `True`. |
|
|
||||||
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи backup-архивов. Если пусто, используется `BOT_TOKEN`. |
|
|
||||||
| `BACKUP_COMPOSE_ENABLED` | Добавлять snapshot compose-каталога в архив. Если mount отсутствует, бэкап БД не падает. |
|
| `BACKUP_COMPOSE_ENABLED` | Добавлять snapshot compose-каталога в архив. Если mount отсутствует, бэкап БД не падает. |
|
||||||
| `BACKUP_COMPOSE_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. |
|
| `BACKUP_COMPOSE_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. |
|
||||||
| `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. |
|
| `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. |
|
||||||
|
|||||||
+24
-13
@@ -24,23 +24,26 @@ remnawave-minishop-backup-20260527-120000+0300.zip
|
|||||||
|
|
||||||
Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**.
|
Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**.
|
||||||
|
|
||||||
Минимальный `.env`:
|
Минимальный `.env`, если `LOG_CHAT_ID` уже задан и подходит для бэкапов:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
BACKUP_ENABLED=True
|
BACKUP_ENABLED=True
|
||||||
BACKUP_CHAT_ID=-1001234567890
|
|
||||||
BACKUP_INTERVAL_SECONDS=3600
|
|
||||||
BACKUP_LOCAL_RETENTION=100
|
|
||||||
BACKUP_COMPOSE_ENABLED=True
|
|
||||||
COMPOSE_BACKUP_SOURCE=.
|
|
||||||
COMPOSE_RESTORE_MODE=rw
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа: 12:00, 13:00 и т.д. Значение по умолчанию для локального хранения - 100 последних ZIP-архивов.
|
Если бэкапы нужно отправлять в отдельный чат или topic/thread, добавьте только нужные переменные:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BACKUP_CHAT_ID=-1001234567890
|
||||||
|
BACKUP_THREAD_ID=123
|
||||||
|
```
|
||||||
|
|
||||||
|
Остальные backup-переменные обычно не нужны в `.env`: `BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа 12:00, 13:00 и т.д.; `BACKUP_LOCAL_RETENTION=100` хранит 100 последних ZIP-архивов; `BACKUP_COMPOSE_ENABLED=True`, `COMPOSE_BACKUP_SOURCE=.` и `COMPOSE_RESTORE_MODE=rw` уже совпадают со стандартным compose-сценарием.
|
||||||
|
|
||||||
`BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`.
|
`BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`.
|
||||||
|
|
||||||
Каждый архив подписывается HMAC-подписью в `manifest.json` и содержит SHA-256 каждого файла. По умолчанию restore принимает только архивы с валидной подписью этого инстанса. Если нужен отдельный стабильный ключ подписи, задайте `BACKUP_ARCHIVE_SIGNATURE_SECRET`; если ключ пустой, используется `BOT_TOKEN`.
|
Каждый архив содержит `manifest.json` с SHA-256 и размером каждого файла. Это позволяет проверить, что архив не поврежден и его содержимое не отличается от manifest.
|
||||||
|
|
||||||
|
Архив не привязан к текущему инстансу, `BOT_TOKEN` или серверу. Его можно загрузить и восстановить на другом сервере, если формат архива поддерживается и проверки целостности проходят.
|
||||||
|
|
||||||
## Mount compose-папки
|
## Mount compose-папки
|
||||||
|
|
||||||
@@ -91,7 +94,6 @@ Backend валидирует архив до восстановления:
|
|||||||
|
|
||||||
- файл должен быть валидным ZIP;
|
- файл должен быть валидным ZIP;
|
||||||
- `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата;
|
- `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата;
|
||||||
- HMAC-подпись manifest должна быть валидной, если `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True`;
|
|
||||||
- SHA-256 и размер каждого файла должны совпадать с manifest;
|
- SHA-256 и размер каждого файла должны совпадать с manifest;
|
||||||
- выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется;
|
- выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется;
|
||||||
- пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли;
|
- пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли;
|
||||||
@@ -101,7 +103,18 @@ Backend валидирует архив до восстановления:
|
|||||||
- compose restore стартует только если целевая папка существует и доступна на запись;
|
- compose restore стартует только если целевая папка существует и доступна на запись;
|
||||||
- backup/restore защищены одним Redis lock, чтобы две операции не выполнялись одновременно.
|
- backup/restore защищены одним Redis lock, чтобы две операции не выполнялись одновременно.
|
||||||
|
|
||||||
Это защищает от случайной загрузки мусорного файла, zip-slip-архивов, поврежденных ZIP и структурно похожих архивов, которые не были созданы этим инстансом. Если вы сознательно восстанавливаете старый неподписанный архив, временно выставьте `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=False`, восстановите архив и верните проверку обратно.
|
Это защищает от случайной загрузки мусорного файла, zip-slip-архивов и поврежденных ZIP. Проверка специально не привязана к секретам инстанса, чтобы архивы можно было использовать для переноса между серверами. Это не проверка доверенного источника: не восстанавливайте архивы, происхождение которых вы не контролируете.
|
||||||
|
|
||||||
|
## Перенос на другой сервер
|
||||||
|
|
||||||
|
Для переноса БД между инстансами:
|
||||||
|
|
||||||
|
1. Создайте backup на старом сервере или возьмите ZIP из Telegram.
|
||||||
|
2. На новом сервере загрузите архив в **Система -> Бэкапы**.
|
||||||
|
3. Выберите `БД`; `compose-папку` включайте только если хотите перенести `.env`, `docker-compose.yml` и proxy-конфиги.
|
||||||
|
4. Запустите restore и после восстановления выполните миграции/healthcheck.
|
||||||
|
|
||||||
|
Если переносите compose-папку, проверьте домены, токены, `WEBHOOK_BASE_URL`, `SUBSCRIPTION_MINI_APP_URL`, bind-порты и volume/mount пути: на новом сервере они могут отличаться.
|
||||||
|
|
||||||
## Ручное восстановление БД
|
## Ручное восстановление БД
|
||||||
|
|
||||||
@@ -134,8 +147,6 @@ docker compose logs -f backend worker
|
|||||||
| `BACKUP_INTERVAL_SECONDS` | Периодичность, по умолчанию `3600`. |
|
| `BACKUP_INTERVAL_SECONDS` | Периодичность, по умолчанию `3600`. |
|
||||||
| `BACKUP_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. |
|
| `BACKUP_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. |
|
||||||
| `BACKUP_DIR` | Каталог ZIP-архивов. |
|
| `BACKUP_DIR` | Каталог ZIP-архивов. |
|
||||||
| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись manifest при upload/restore. |
|
|
||||||
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи архивов; если пустой, используется `BOT_TOKEN`. |
|
|
||||||
| `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. |
|
| `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. |
|
||||||
| `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. |
|
| `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. |
|
||||||
| `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. |
|
| `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. |
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
|
|||||||
|
|
||||||
|
|
||||||
def _write_backup_archive(
|
def _write_backup_archive(
|
||||||
settings: Settings,
|
|
||||||
path: Path,
|
path: Path,
|
||||||
*,
|
*,
|
||||||
include_db=True,
|
include_db=True,
|
||||||
@@ -65,7 +64,6 @@ def _write_backup_archive(
|
|||||||
attach_archive_integrity(
|
attach_archive_integrity(
|
||||||
manifest,
|
manifest,
|
||||||
file_records=build_file_records(staging_dir),
|
file_records=build_file_records(staging_dir),
|
||||||
settings=settings,
|
|
||||||
)
|
)
|
||||||
write_manifest(staging_dir, manifest)
|
write_manifest(staging_dir, manifest)
|
||||||
write_zip_from_directory(staging_dir, path)
|
write_zip_from_directory(staging_dir, path)
|
||||||
@@ -80,7 +78,7 @@ def test_backup_restore_service_lists_archives_with_contents(tmp_path):
|
|||||||
compose_dir.mkdir()
|
compose_dir.mkdir()
|
||||||
settings = _settings(tmp_path, compose_dir)
|
settings = _settings(tmp_path, compose_dir)
|
||||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||||
_write_backup_archive(settings, archive_path)
|
_write_backup_archive(archive_path)
|
||||||
|
|
||||||
archives = BackupRestoreService(settings).list_archives()
|
archives = BackupRestoreService(settings).list_archives()
|
||||||
|
|
||||||
@@ -106,7 +104,7 @@ def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path)
|
|||||||
|
|
||||||
settings = _settings(tmp_path, compose_dir)
|
settings = _settings(tmp_path, compose_dir)
|
||||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||||
_write_backup_archive(settings, archive_path, include_db=False)
|
_write_backup_archive(archive_path, include_db=False)
|
||||||
|
|
||||||
service = BackupRestoreService(settings)
|
service = BackupRestoreService(settings)
|
||||||
result = service.restore_archive_sync(
|
result = service.restore_archive_sync(
|
||||||
@@ -131,7 +129,7 @@ def test_backup_restore_service_prevents_zip_slip_in_compose_restore(tmp_path):
|
|||||||
compose_dir.mkdir()
|
compose_dir.mkdir()
|
||||||
settings = _settings(tmp_path, compose_dir)
|
settings = _settings(tmp_path, compose_dir)
|
||||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||||
_write_backup_archive(settings, archive_path, include_db=False, unsafe=True)
|
_write_backup_archive(archive_path, include_db=False, unsafe=True)
|
||||||
|
|
||||||
with pytest.raises(BackupArchiveError):
|
with pytest.raises(BackupArchiveError):
|
||||||
BackupRestoreService(settings).restore_archive_sync(
|
BackupRestoreService(settings).restore_archive_sync(
|
||||||
@@ -148,7 +146,7 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
|
|||||||
compose_dir.mkdir()
|
compose_dir.mkdir()
|
||||||
settings = _settings(tmp_path, compose_dir)
|
settings = _settings(tmp_path, compose_dir)
|
||||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||||
_write_backup_archive(settings, archive_path, include_compose=False)
|
_write_backup_archive(archive_path, include_compose=False)
|
||||||
service = BackupRestoreService(settings)
|
service = BackupRestoreService(settings)
|
||||||
restored_payloads = []
|
restored_payloads = []
|
||||||
|
|
||||||
@@ -169,6 +167,33 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
|
|||||||
assert restored_payloads == [b"fake dump"]
|
assert restored_payloads == [b"fake dump"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_restore_service_accepts_archive_from_another_instance(tmp_path):
|
||||||
|
compose_dir = tmp_path / "compose"
|
||||||
|
compose_dir.mkdir()
|
||||||
|
target_settings = _settings(tmp_path, compose_dir, BOT_TOKEN="target-token")
|
||||||
|
archive_path = Path(target_settings.BACKUP_DIR) / (
|
||||||
|
f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||||
|
)
|
||||||
|
_write_backup_archive(archive_path, include_compose=False)
|
||||||
|
|
||||||
|
service = BackupRestoreService(target_settings)
|
||||||
|
restored_payloads = []
|
||||||
|
|
||||||
|
def fake_pg_restore(dump_path: Path) -> None:
|
||||||
|
restored_payloads.append(dump_path.read_bytes())
|
||||||
|
|
||||||
|
service._run_pg_restore = fake_pg_restore
|
||||||
|
|
||||||
|
result = service.restore_archive_sync(
|
||||||
|
archive_path.name,
|
||||||
|
restore_database=True,
|
||||||
|
restore_compose=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.database_restored is True
|
||||||
|
assert restored_payloads == [b"fake dump"]
|
||||||
|
|
||||||
|
|
||||||
def test_backup_restore_service_validates_uploaded_zip(tmp_path):
|
def test_backup_restore_service_validates_uploaded_zip(tmp_path):
|
||||||
compose_dir = tmp_path / "compose"
|
compose_dir = tmp_path / "compose"
|
||||||
compose_dir.mkdir()
|
compose_dir.mkdir()
|
||||||
@@ -185,7 +210,7 @@ def test_backup_restore_service_rejects_tampered_archive(tmp_path):
|
|||||||
compose_dir.mkdir()
|
compose_dir.mkdir()
|
||||||
settings = _settings(tmp_path, compose_dir)
|
settings = _settings(tmp_path, compose_dir)
|
||||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||||
_write_backup_archive(settings, archive_path, include_compose=False)
|
_write_backup_archive(archive_path, include_compose=False)
|
||||||
|
|
||||||
tampered_path = archive_path.with_name("tampered.zip")
|
tampered_path = archive_path.with_name("tampered.zip")
|
||||||
with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target:
|
with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target:
|
||||||
|
|||||||
@@ -209,8 +209,6 @@ class SettingsTests(unittest.TestCase):
|
|||||||
self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source")
|
self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source")
|
||||||
self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR)
|
self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR)
|
||||||
self.assertEqual(settings.BACKUP_PG_RESTORE_PATH, "pg_restore")
|
self.assertEqual(settings.BACKUP_PG_RESTORE_PATH, "pg_restore")
|
||||||
self.assertTrue(settings.BACKUP_ARCHIVE_SIGNATURE_REQUIRED)
|
|
||||||
self.assertIsNone(settings.BACKUP_ARCHIVE_SIGNATURE_SECRET)
|
|
||||||
|
|
||||||
def test_subscription_purchase_description_is_localized_and_toggleable(self):
|
def test_subscription_purchase_description_is_localized_and_toggleable(self):
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
|
|||||||
Reference in New Issue
Block a user