diff --git a/.env.example b/.env.example
index f07df1f1..742841e8 100644
--- a/.env.example
+++ b/.env.example
@@ -485,6 +485,8 @@ BACKUP_SEND_ENABLED=true
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
+# Пароль для архива бекапа (опционально). Если задан - бекап отправляется в зашифрованном ZIP с AES
+BACKUP_ARCHIVE_PASSWORD=
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
VERSION_CHECK_ENABLED=true
diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml
index de86d419..8c7b2124 100644
--- a/.github/workflows/docker-hub.yml
+++ b/.github/workflows/docker-hub.yml
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
- VERSION="v2.9.2-$(git rev-parse --short HEAD)"
+ VERSION="v2.9.3-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
- VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
+ VERSION="v2.9.3-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
- VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
+ VERSION="v2.9.3-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml
index 7ae15e95..b45fe175 100644
--- a/.github/workflows/docker-registry.yml
+++ b/.github/workflows/docker-registry.yml
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
- VERSION="v2.9.2-$(git rev-parse --short HEAD)"
+ VERSION="v2.9.3-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
- VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
+ VERSION="v2.9.3-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
- VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
+ VERSION="v2.9.3-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
diff --git a/Dockerfile b/Dockerfile
index 47ec74e5..4ad136a3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
-ARG VERSION="v2.9.2"
+ARG VERSION="v2.9.3"
ARG BUILD_DATE
ARG VCS_REF
diff --git a/app/config.py b/app/config.py
index 3f15335b..32c1f597 100644
--- a/app/config.py
+++ b/app/config.py
@@ -439,6 +439,7 @@ class Settings(BaseSettings):
BACKUP_SEND_ENABLED: bool = False
BACKUP_SEND_CHAT_ID: Optional[str] = None
BACKUP_SEND_TOPIC_ID: Optional[int] = None
+ BACKUP_ARCHIVE_PASSWORD: Optional[str] = None
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
@@ -1449,6 +1450,10 @@ class Settings(BaseSettings):
return (self.BACKUP_SEND_ENABLED and
self.get_backup_send_chat_id() is not None)
+ def get_backup_archive_password(self) -> Optional[str]:
+ password = (self.BACKUP_ARCHIVE_PASSWORD or "").strip()
+ return password if password else None
+
def get_referral_settings(self) -> Dict:
return {
"program_enabled": self.is_referral_program_enabled(),
diff --git a/app/services/backup_service.py b/app/services/backup_service.py
index 02db9f4c..14ff3b2e 100644
--- a/app/services/backup_service.py
+++ b/app/services/backup_service.py
@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import aiofiles
+import pyzipper
from aiogram.types import FSInputFile
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -1534,13 +1535,24 @@ class BackupService:
if not chat_id:
return
+ password = settings.get_backup_archive_password()
+ file_to_send = file_path
+ temp_zip_path = None
+
+ if password:
+ temp_zip_path = await self._create_password_protected_archive(file_path, password)
+ if temp_zip_path:
+ file_to_send = temp_zip_path
+
+ caption = f"📦 Резервная копия\n\n"
+ if temp_zip_path:
+ caption += f"🔐 Архив защищён паролем\n\n"
+ caption += f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"
+
send_kwargs = {
'chat_id': chat_id,
- 'document': FSInputFile(file_path),
- 'caption': (
- f"📦 Резервная копия\n\n"
- f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"
- ),
+ 'document': FSInputFile(file_to_send),
+ 'caption': caption,
'parse_mode': 'HTML'
}
@@ -1549,8 +1561,43 @@ class BackupService:
await self.bot.send_document(**send_kwargs)
logger.info(f"Бекап отправлен в чат {chat_id}")
+
+ if temp_zip_path and Path(temp_zip_path).exists():
+ try:
+ Path(temp_zip_path).unlink()
+ except Exception as cleanup_error:
+ logger.warning(f"Не удалось удалить временный архив: {cleanup_error}")
+
except Exception as e:
logger.error(f"Ошибка отправки бекапа в чат: {e}")
+ async def _create_password_protected_archive(self, file_path: str, password: str) -> Optional[str]:
+ try:
+ source_path = Path(file_path)
+ if not source_path.exists():
+ logger.error(f"Исходный файл бекапа не найден: {file_path}")
+ return None
+
+ zip_filename = source_path.stem + ".zip"
+ zip_path = source_path.parent / zip_filename
+
+ def create_zip():
+ with pyzipper.AESZipFile(
+ zip_path,
+ 'w',
+ compression=pyzipper.ZIP_DEFLATED,
+ encryption=pyzipper.WZ_AES
+ ) as zf:
+ zf.setpassword(password.encode('utf-8'))
+ zf.write(source_path, arcname=source_path.name)
+
+ await asyncio.to_thread(create_zip)
+ logger.info(f"Создан защищённый паролем архив: {zip_path}")
+ return str(zip_path)
+
+ except Exception as e:
+ logger.error(f"Ошибка создания защищённого архива: {e}")
+ return None
+
backup_service = BackupService()
diff --git a/app/webapi/routes/backups.py b/app/webapi/routes/backups.py
index b9f3aa00..2d681f7c 100644
--- a/app/webapi/routes/backups.py
+++ b/app/webapi/routes/backups.py
@@ -1,9 +1,12 @@
from __future__ import annotations
+import os
from datetime import datetime
+from pathlib import Path
from typing import Any, Optional
-from fastapi import APIRouter, HTTPException, Query, Security, status
+from fastapi import APIRouter, HTTPException, Query, Security, UploadFile, File, status
+from fastapi.responses import FileResponse
from app.services.backup_service import backup_service
@@ -11,8 +14,11 @@ from ..background.backup_tasks import backup_task_manager
from ..dependencies import require_api_token
from ..schemas.backups import (
BackupCreateResponse,
+ BackupDeleteResponse,
BackupInfo,
BackupListResponse,
+ BackupRestoreRequest,
+ BackupRestoreResponse,
BackupStatusResponse,
BackupTaskInfo,
BackupTaskListResponse,
@@ -157,3 +163,149 @@ async def list_backup_tasks(
]
return BackupTaskListResponse(items=items, total=len(items))
+
+
+@router.get(
+ "/download/{filename:path}",
+ summary="Скачать файл резервной копии",
+ responses={
+ 200: {
+ "content": {"application/octet-stream": {}},
+ "description": "Файл резервной копии",
+ }
+ },
+)
+async def download_backup(
+ filename: str,
+ _: Any = Security(require_api_token),
+) -> FileResponse:
+ backup_path = backup_service.backup_dir / filename
+
+ if not backup_path.exists():
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found")
+
+ if not backup_path.is_file():
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid backup path")
+
+ resolved_path = backup_path.resolve()
+ backup_dir_resolved = backup_service.backup_dir.resolve()
+ if not str(resolved_path).startswith(str(backup_dir_resolved)):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied")
+
+ return FileResponse(
+ path=str(backup_path),
+ filename=filename,
+ media_type="application/octet-stream",
+ )
+
+
+@router.post(
+ "/restore/{filename:path}",
+ response_model=BackupRestoreResponse,
+ summary="Восстановить из резервной копии",
+)
+async def restore_backup(
+ filename: str,
+ payload: BackupRestoreRequest,
+ _: Any = Security(require_api_token),
+) -> BackupRestoreResponse:
+ backup_path = backup_service.backup_dir / filename
+
+ if not backup_path.exists():
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found")
+
+ resolved_path = backup_path.resolve()
+ backup_dir_resolved = backup_service.backup_dir.resolve()
+ if not str(resolved_path).startswith(str(backup_dir_resolved)):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied")
+
+ success, message = await backup_service.restore_backup(
+ str(backup_path),
+ clear_existing=payload.clear_existing
+ )
+
+ return BackupRestoreResponse(
+ success=success,
+ message=message,
+ )
+
+
+@router.post(
+ "/upload",
+ response_model=BackupRestoreResponse,
+ summary="Загрузить и восстановить из файла резервной копии",
+)
+async def upload_and_restore_backup(
+ file: UploadFile = File(..., description="Файл резервной копии (.tar.gz, .json, .json.gz)"),
+ clear_existing: bool = Query(False, description="Очистить существующие данные"),
+ _: Any = Security(require_api_token),
+) -> BackupRestoreResponse:
+ if not file.filename:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Filename is required")
+
+ safe_filename = Path(file.filename).name
+ if not safe_filename or safe_filename in ('.', '..'):
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid filename")
+
+ allowed_extensions = ('.tar.gz', '.json', '.json.gz', '.tar')
+ if not any(safe_filename.endswith(ext) for ext in allowed_extensions):
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
+ )
+
+ temp_path = backup_service.backup_dir / f"uploaded_{safe_filename}"
+
+ resolved_path = temp_path.resolve()
+ backup_dir_resolved = backup_service.backup_dir.resolve()
+ if not str(resolved_path).startswith(str(backup_dir_resolved)):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Invalid file path")
+
+ try:
+ content = await file.read()
+ with open(temp_path, 'wb') as f:
+ f.write(content)
+
+ success, message = await backup_service.restore_backup(
+ str(temp_path),
+ clear_existing=clear_existing
+ )
+
+ return BackupRestoreResponse(
+ success=success,
+ message=message,
+ )
+
+ finally:
+ if temp_path.exists():
+ try:
+ temp_path.unlink()
+ except Exception:
+ pass
+
+
+@router.delete(
+ "/{filename:path}",
+ response_model=BackupDeleteResponse,
+ summary="Удалить резервную копию",
+)
+async def delete_backup(
+ filename: str,
+ _: Any = Security(require_api_token),
+) -> BackupDeleteResponse:
+ backup_path = backup_service.backup_dir / filename
+
+ resolved_path = backup_path.resolve()
+ backup_dir_resolved = backup_service.backup_dir.resolve()
+ if not str(resolved_path).startswith(str(backup_dir_resolved)):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied")
+
+ success, message = await backup_service.delete_backup(filename)
+
+ if not success:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, message)
+
+ return BackupDeleteResponse(
+ success=success,
+ message=message,
+ )
diff --git a/app/webapi/schemas/backups.py b/app/webapi/schemas/backups.py
index 8c46809f..5a535c85 100644
--- a/app/webapi/schemas/backups.py
+++ b/app/webapi/schemas/backups.py
@@ -53,3 +53,22 @@ class BackupTaskInfo(BackupStatusResponse):
class BackupTaskListResponse(BaseModel):
items: list[BackupTaskInfo]
total: int
+
+
+class BackupRestoreRequest(BaseModel):
+ clear_existing: bool = Field(
+ default=False,
+ description="Очистить существующие данные перед восстановлением"
+ )
+
+
+class BackupRestoreResponse(BaseModel):
+ success: bool
+ message: str
+ tables_restored: Optional[int] = None
+ records_restored: Optional[int] = None
+
+
+class BackupDeleteResponse(BaseModel):
+ success: bool
+ message: str
diff --git a/requirements.txt b/requirements.txt
index af50155d..055ee40e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -41,3 +41,6 @@ aiofiles==23.2.1
# Вебхуки PayPalych (Flask)
Flask==3.1.0
+
+# Архивирование с паролем
+pyzipper==0.3.6