chore: tune backup function and related docs

This commit is contained in:
3252a8
2026-05-27 14:11:22 +03:00
parent 0250264fa0
commit 4bd547f06a
13 changed files with 56 additions and 143 deletions
-13
View File
@@ -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
-40
View File
@@ -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:
@@ -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(
-1
View File
@@ -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)
-3
View File
@@ -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",
-11
View File
@@ -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
-11
View File
@@ -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
-11
View File
@@ -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
-11
View File
@@ -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
-2
View File
@@ -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`. |
+24 -13
View File
@@ -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` для запрета записи. |
+32 -7
View File
@@ -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:
-2
View File
@@ -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(