diff --git a/.env.example b/.env.example index 2c4ccdf..1a0be7b 100644 --- a/.env.example +++ b/.env.example @@ -70,16 +70,3 @@ FRONTEND_PORT=8082 # Reverse proxy IPs/CIDRs trusted for X-Forwarded-For. # Keep loopback for local proxy; add your proxy network if needed. 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 diff --git a/backend/bot/services/backup_archive.py b/backend/bot/services/backup_archive.py index 9c0ecd9..723c026 100644 --- a/backend/bot/services/backup_archive.py +++ b/backend/bot/services/backup_archive.py @@ -1,53 +1,15 @@ import hashlib -import hmac import json import zipfile from pathlib import Path from typing import Any -from config.settings import Settings - BACKUP_APP_ID = "remnawave-minishop" BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-" BACKUP_FORMAT_VERSION = 1 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: digest = hashlib.sha256() with path.open("rb") as handle: @@ -79,14 +41,12 @@ def attach_archive_integrity( manifest: dict[str, Any], *, file_records: list[dict[str, Any]], - settings: Settings, ) -> None: manifest["app"] = BACKUP_APP_ID manifest["format_version"] = BACKUP_FORMAT_VERSION manifest["archive"] = { "files": file_records, } - manifest["archive"]["signature"] = sign_manifest(manifest, settings) def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None: diff --git a/backend/bot/services/backup_restore_service.py b/backend/bot/services/backup_restore_service.py index 6a745c5..3d73be7 100644 --- a/backend/bot/services/backup_restore_service.py +++ b/backend/bot/services/backup_restore_service.py @@ -21,7 +21,6 @@ from bot.services.backup_archive import ( BACKUP_MANIFEST_NAME, attach_archive_integrity, build_file_records, - verify_manifest_signature, write_manifest, write_zip_from_directory, ) @@ -153,16 +152,6 @@ class BackupRestoreService: with zipfile.ZipFile(archive_path) as archive: self._validate_zip_members(archive.infolist()) 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 compose_members = self._compose_file_members(archive) @@ -401,7 +390,6 @@ class BackupRestoreService: attach_archive_integrity( manifest, file_records=build_file_records(staging_dir), - settings=self.settings, ) write_manifest(staging_dir, manifest) 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") 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: if not zipfile.is_zipfile(archive_path): raise BackupArchiveError("Archive is not a valid ZIP file") with zipfile.ZipFile(archive_path) as archive: self._validate_zip_members(archive.infolist()) 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) def _validate_archive_integrity( diff --git a/backend/bot/services/backup_worker.py b/backend/bot/services/backup_worker.py index 84fe2e6..4d70c64 100644 --- a/backend/bot/services/backup_worker.py +++ b/backend/bot/services/backup_worker.py @@ -179,7 +179,6 @@ class BackupWorker: attach_archive_integrity( manifest, file_records=build_file_records(staging_dir), - settings=self.settings, ) write_manifest(staging_dir, manifest) diff --git a/backend/config/settings.py b/backend/config/settings.py index 87e33b9..d0ace68 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -135,8 +135,6 @@ class Settings(BaseSettings): BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60) BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore") 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_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source") BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None) @@ -1005,7 +1003,6 @@ class Settings(BaseSettings): "SMTP_FROM_EMAIL", "SMTP_FROM_NAME", "SMTP_FALLBACK_PORTS", - "BACKUP_ARCHIVE_SIGNATURE_SECRET", "BACKUP_COMPOSE_SOURCE_DIR", "BACKUP_COMPOSE_RESTORE_DIR", mode="before", diff --git a/deploy/examples/caddy/.env.example b/deploy/examples/caddy/.env.example index 4f8cbd7..5d7fd3d 100644 --- a/deploy/examples/caddy/.env.example +++ b/deploy/examples/caddy/.env.example @@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me # Caddy and Docker network ranges that may set X-Forwarded-For. 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 diff --git a/deploy/examples/newt/.env.example b/deploy/examples/newt/.env.example index 64eebd1..4672a0b 100644 --- a/deploy/examples/newt/.env.example +++ b/deploy/examples/newt/.env.example @@ -39,14 +39,3 @@ PANEL_WEBHOOK_SECRET=change_me # Pangolin/Newt and Docker network ranges that may set X-Forwarded-For. 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 diff --git a/deploy/examples/nginx/.env.example b/deploy/examples/nginx/.env.example index 549d3a3..c93a284 100644 --- a/deploy/examples/nginx/.env.example +++ b/deploy/examples/nginx/.env.example @@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me # Nginx and Docker network ranges that may set X-Forwarded-For. 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 diff --git a/deploy/examples/no-proxy/.env.example b/deploy/examples/no-proxy/.env.example index 81e14f7..763dee3 100644 --- a/deploy/examples/no-proxy/.env.example +++ b/deploy/examples/no-proxy/.env.example @@ -39,14 +39,3 @@ PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me 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 diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 8543702..19c624d 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -84,8 +84,6 @@ | `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. | | `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. | | `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_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. | | `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. | diff --git a/docs/features/backups.md b/docs/features/backups.md index 92c1bea..cccc342 100644 --- a/docs/features/backups.md +++ b/docs/features/backups.md @@ -24,23 +24,26 @@ remnawave-minishop-backup-20260527-120000+0300.zip Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**. -Минимальный `.env`: +Минимальный `.env`, если `LOG_CHAT_ID` уже задан и подходит для бэкапов: ```env 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`. -Каждый архив подписывается HMAC-подписью в `manifest.json` и содержит SHA-256 каждого файла. По умолчанию restore принимает только архивы с валидной подписью этого инстанса. Если нужен отдельный стабильный ключ подписи, задайте `BACKUP_ARCHIVE_SIGNATURE_SECRET`; если ключ пустой, используется `BOT_TOKEN`. +Каждый архив содержит `manifest.json` с SHA-256 и размером каждого файла. Это позволяет проверить, что архив не поврежден и его содержимое не отличается от manifest. + +Архив не привязан к текущему инстансу, `BOT_TOKEN` или серверу. Его можно загрузить и восстановить на другом сервере, если формат архива поддерживается и проверки целостности проходят. ## Mount compose-папки @@ -91,7 +94,6 @@ Backend валидирует архив до восстановления: - файл должен быть валидным ZIP; - `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата; -- HMAC-подпись manifest должна быть валидной, если `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True`; - SHA-256 и размер каждого файла должны совпадать с manifest; - выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется; - пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли; @@ -101,7 +103,18 @@ Backend валидирует архив до восстановления: - compose restore стартует только если целевая папка существует и доступна на запись; - 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_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. | | `BACKUP_DIR` | Каталог ZIP-архивов. | -| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись manifest при upload/restore. | -| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи архивов; если пустой, используется `BOT_TOKEN`. | | `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. | | `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. | | `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. | diff --git a/tests/test_backup_restore_service.py b/tests/test_backup_restore_service.py index e4efdb1..6ca822c 100644 --- a/tests/test_backup_restore_service.py +++ b/tests/test_backup_restore_service.py @@ -34,7 +34,6 @@ def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings: def _write_backup_archive( - settings: Settings, path: Path, *, include_db=True, @@ -65,7 +64,6 @@ def _write_backup_archive( attach_archive_integrity( manifest, file_records=build_file_records(staging_dir), - settings=settings, ) write_manifest(staging_dir, manifest) 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() settings = _settings(tmp_path, compose_dir) 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() @@ -106,7 +104,7 @@ def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path) settings = _settings(tmp_path, compose_dir) 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) 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() settings = _settings(tmp_path, compose_dir) 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): BackupRestoreService(settings).restore_archive_sync( @@ -148,7 +146,7 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path): compose_dir.mkdir() settings = _settings(tmp_path, compose_dir) 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) restored_payloads = [] @@ -169,6 +167,33 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path): 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): compose_dir = tmp_path / "compose" compose_dir.mkdir() @@ -185,7 +210,7 @@ def test_backup_restore_service_rejects_tampered_archive(tmp_path): compose_dir.mkdir() settings = _settings(tmp_path, compose_dir) 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") with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target: diff --git a/tests/test_settings.py b/tests/test_settings.py index f35fa8c..46be6e4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -209,8 +209,6 @@ class SettingsTests(unittest.TestCase): self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source") self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR) 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): settings = Settings(