From c10e34ad43c4ab15794d9b9a8efb0e1eabe3b5c4 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 28 Sep 2025 04:53:01 +0300 Subject: [PATCH] Add Remnawave component management API --- app/external/remnawave_api.py | 41 ++++++ app/services/remnawave_service.py | 231 +++++++++++++++++++++++++++++- app/webapi/app.py | 2 + app/webapi/routes/remnawave.py | 152 ++++++++++++++++++++ app/webapi/schemas/remnawave.py | 46 ++++++ docs/web-admin-integration.md | 4 + 6 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 app/webapi/routes/remnawave.py create mode 100644 app/webapi/schemas/remnawave.py diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 8949dad9..0539432b 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -477,6 +477,47 @@ class RemnaWaveAPI: return response['response']['eventSent'] + async def list_components(self) -> List[Dict[str, Any]]: + response = await self._make_request('GET', '/api/components') + components = response.get('response') + + if isinstance(components, dict): + if 'components' in components: + components = components['components'] + elif 'items' in components: + components = components['items'] + + if not components: + return [] + + if isinstance(components, list): + return components + + return [components] + + async def get_component(self, component_id: str) -> Dict[str, Any]: + response = await self._make_request('GET', f'/api/components/{component_id}') + component = response.get('response') + + if isinstance(component, dict): + return component + + return {} + + async def perform_component_action( + self, + component_id: str, + action: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + endpoint = f'/api/components/{component_id}/actions/{action}' + response = await self._make_request('POST', endpoint, data=payload) + return response.get('response') or {} + + async def update_component(self, component_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + response = await self._make_request('PATCH', f'/api/components/{component_id}', payload) + return response.get('response') or {} + async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo: response = await self._make_request('GET', f'/api/sub/{short_uuid}/info') return self._parse_subscription_info(response['response']) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 1a02a08f..cb1aa63b 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -254,7 +254,7 @@ class RemnaWaveService: try: if not bandwidth_str or bandwidth_str == '0 B' or bandwidth_str == '0': return 0 - + bandwidth_str = bandwidth_str.replace(' ', '').upper() units = { @@ -291,9 +291,74 @@ class RemnaWaveService: except Exception as e: logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}") return 0 - + + def _normalize_component(self, component: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(component, dict): + return {} + + metadata = dict(component) + + component_uuid = ( + metadata.get("uuid") + or metadata.get("id") + or metadata.get("slug") + or metadata.get("name") + ) + + installed_version = ( + metadata.get("installedVersion") + or metadata.get("installed_version") + or metadata.get("version") + ) + latest_version = ( + metadata.get("latestVersion") + or metadata.get("latest_version") + or metadata.get("availableVersion") + ) + + status = metadata.get("status") or metadata.get("state") + + is_installed = metadata.get("isInstalled") + if is_installed is None: + for key in ("installed", "is_installed"): + if key in metadata: + is_installed = metadata[key] + break + + is_enabled = metadata.get("isEnabled") + if is_enabled is None: + for key in ("enabled", "is_enabled"): + if key in metadata: + is_enabled = metadata[key] + break + + tags = metadata.get("tags") or metadata.get("labels") or metadata.get("keywords") + if isinstance(tags, str): + tags = [tags] + elif isinstance(tags, list): + tags = [str(tag) for tag in tags] + else: + tags = [] + + return { + "uuid": component_uuid, + "name": metadata.get("name") or metadata.get("title") or component_uuid, + "slug": metadata.get("slug") or metadata.get("id"), + "status": status, + "installed_version": installed_version, + "latest_version": latest_version, + "category": metadata.get("category") or metadata.get("type"), + "description": metadata.get("description") or metadata.get("details"), + "is_installed": is_installed, + "is_enabled": is_enabled, + "installed_at": metadata.get("installedAt") or metadata.get("installed_at"), + "updated_at": metadata.get("updatedAt") or metadata.get("updated_at"), + "tags": tags, + "metadata": metadata, + } + async def get_all_nodes(self) -> List[Dict[str, Any]]: - + try: async with self.get_api_client() as api: nodes = await api.get_all_nodes() @@ -337,7 +402,7 @@ class RemnaWaveService: try: async with self.get_api_client() as api: node = await api.get_node_by_uuid(node_uuid) - + if not node: return None @@ -358,7 +423,163 @@ class RemnaWaveService: except Exception as e: logger.error(f"Ошибка получения информации о ноде {node_uuid}: {e}") return None - + + async def list_components(self) -> List[Dict[str, Any]]: + try: + async with self.get_api_client() as api: + raw_components = await api.list_components() + + normalized = [ + self._normalize_component(component) + for component in raw_components + if isinstance(component, dict) + ] + + logger.info(f"✅ Получено {len(normalized)} компонентов из Remnawave") + return normalized + + except RemnaWaveConfigurationError: + raise + except RemnaWaveAPIError: + raise + except Exception as error: + logger.error(f"Ошибка получения списка компонентов Remnawave: {error}") + raise RemnaWaveAPIError( + "Не удалось получить список компонентов", + response_data={"details": str(error)}, + ) + + async def get_component_details(self, component_id: str) -> Optional[Dict[str, Any]]: + try: + async with self.get_api_client() as api: + raw_component = await api.get_component(component_id) + + if not raw_component: + return None + + return self._normalize_component(raw_component) + + except RemnaWaveConfigurationError: + raise + except RemnaWaveAPIError as error: + if error.status_code == 404: + logger.info(f"Компонент Remnawave {component_id} не найден") + return None + raise + except Exception as err: + logger.error(f"Ошибка получения компонента Remnawave {component_id}: {err}") + raise RemnaWaveAPIError( + "Не удалось получить данные компонента", + response_data={"details": str(err)}, + ) + + async def perform_component_action( + self, + component_id: str, + action: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + if not action or not re.fullmatch(r"[A-Za-z0-9_-]+", action): + raise ValueError("Недопустимое действие для компонента") + + try: + async with self.get_api_client() as api: + raw_result = await api.perform_component_action(component_id, action, payload) + + except RemnaWaveConfigurationError: + raise + except RemnaWaveAPIError: + raise + except Exception as err: + logger.error( + f"Ошибка выполнения действия '{action}' для компонента {component_id}: {err}" + ) + raise RemnaWaveAPIError( + "Не удалось выполнить действие с компонентом", + response_data={"details": str(err)}, + ) + + details: Dict[str, Any] + if isinstance(raw_result, dict): + details = raw_result + elif raw_result is None: + details = {} + else: + details = {"response": raw_result} + + component_data = details.get("component") or details.get("data") or details.get("response") + normalized_component = ( + self._normalize_component(component_data) + if isinstance(component_data, dict) + else None + ) + + success = details.get("success") if isinstance(details.get("success"), bool) else None + if success is None: + success = True + + message = details.get("message") if isinstance(details.get("message"), str) else None + if not message: + message = f"Действие '{action}' выполнено" + + return { + "success": bool(success), + "message": message, + "details": details, + "component": normalized_component, + } + + async def update_component_settings( + self, component_id: str, payload: Dict[str, Any] + ) -> Dict[str, Any]: + if not payload: + raise ValueError("Данные для обновления компонента не могут быть пустыми") + + try: + async with self.get_api_client() as api: + raw_result = await api.update_component(component_id, payload) + + except RemnaWaveConfigurationError: + raise + except RemnaWaveAPIError: + raise + except Exception as err: + logger.error(f"Ошибка обновления компонента Remnawave {component_id}: {err}") + raise RemnaWaveAPIError( + "Не удалось обновить компонент", + response_data={"details": str(err)}, + ) + + details: Dict[str, Any] + if isinstance(raw_result, dict): + details = raw_result + elif raw_result is None: + details = {} + else: + details = {"response": raw_result} + + component_data = details.get("component") or details.get("response") + normalized_component = ( + self._normalize_component(component_data) + if isinstance(component_data, dict) + else None + ) + + success = details.get("success") if isinstance(details.get("success"), bool) else None + if success is None: + success = True + + message = details.get("message") if isinstance(details.get("message"), str) else None + if not message: + message = "Настройки компонента обновлены" + + return { + "success": bool(success), + "message": message, + "details": details, + "component": normalized_component, + } + async def manage_node(self, node_uuid: str, action: str) -> bool: try: async with self.get_api_client() as api: diff --git a/app/webapi/app.py b/app/webapi/app.py index dd3249f2..8b11b82e 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -10,6 +10,7 @@ from .routes import ( config, health, promo_groups, + remnawave, stats, subscriptions, tickets, @@ -51,6 +52,7 @@ def create_web_api_app() -> FastAPI: app.include_router(tickets.router, prefix="/tickets", tags=["support"]) app.include_router(transactions.router, prefix="/transactions", tags=["transactions"]) app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"]) + app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) return app diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py new file mode 100644 index 00000000..adb02798 --- /dev/null +++ b/app/webapi/routes/remnawave.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, HTTPException, Security, status + +from app.external.remnawave_api import RemnaWaveAPIError +from app.services.remnawave_service import ( + RemnaWaveConfigurationError, + RemnaWaveService, +) + +from ..dependencies import require_api_token +from ..schemas.remnawave import ( + ComponentActionRequest, + ComponentActionResponse, + ComponentInfo, + ComponentListResponse, + ComponentUpdateRequest, +) + +router = APIRouter() + + +def _map_api_error(error: RemnaWaveAPIError) -> HTTPException: + status_code = error.status_code or status.HTTP_502_BAD_GATEWAY + if status_code < 400 or status_code >= 600: + status_code = status.HTTP_502_BAD_GATEWAY + return HTTPException(status_code=status_code, detail=error.message) + + +@router.get("/components", response_model=ComponentListResponse) +async def list_components( + _: object = Security(require_api_token), +) -> ComponentListResponse: + service = RemnaWaveService() + + try: + components = await service.list_components() + except RemnaWaveConfigurationError as error: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error) + ) from error + except RemnaWaveAPIError as error: + raise _map_api_error(error) + + items = [ComponentInfo(**component) for component in components] + return ComponentListResponse(items=items, total=len(items)) + + +@router.get("/components/{component_id}", response_model=ComponentInfo) +async def get_component( + component_id: str, + _: object = Security(require_api_token), +) -> ComponentInfo: + service = RemnaWaveService() + + try: + component = await service.get_component_details(component_id) + except RemnaWaveConfigurationError as error: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error) + ) from error + except RemnaWaveAPIError as error: + raise _map_api_error(error) + + if not component: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Компонент не найден") + + return ComponentInfo(**component) + + +@router.post( + "/components/{component_id}/actions/{action}", + response_model=ComponentActionResponse, +) +async def perform_component_action( + component_id: str, + action: str, + request: Optional[ComponentActionRequest] = None, + _: object = Security(require_api_token), +) -> ComponentActionResponse: + service = RemnaWaveService() + payload = request.payload if request else None + + try: + result = await service.perform_component_action(component_id, action, payload) + except RemnaWaveConfigurationError as error: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error) + ) from error + except RemnaWaveAPIError as error: + raise _map_api_error(error) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + component_data = result.get("component") + component = ComponentInfo(**component_data) if isinstance(component_data, dict) else None + + details = result.get("details") + if details is not None and not isinstance(details, dict): + details = {"response": details} + + return ComponentActionResponse( + success=bool(result.get("success", True)), + message=result.get("message"), + component=component, + details=details, + ) + + +@router.patch( + "/components/{component_id}", + response_model=ComponentActionResponse, +) +async def update_component( + component_id: str, + request: ComponentUpdateRequest, + _: object = Security(require_api_token), +) -> ComponentActionResponse: + service = RemnaWaveService() + + if not request.payload: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Поле payload не может быть пустым", + ) + + try: + result = await service.update_component_settings(component_id, request.payload) + except RemnaWaveConfigurationError as error: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error) + ) from error + except RemnaWaveAPIError as error: + raise _map_api_error(error) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + component_data = result.get("component") + component = ComponentInfo(**component_data) if isinstance(component_data, dict) else None + + details = result.get("details") + if details is not None and not isinstance(details, dict): + details = {"response": details} + + return ComponentActionResponse( + success=bool(result.get("success", True)), + message=result.get("message"), + component=component, + details=details, + ) diff --git a/app/webapi/schemas/remnawave.py b/app/webapi/schemas/remnawave.py new file mode 100644 index 00000000..bc2404dd --- /dev/null +++ b/app/webapi/schemas/remnawave.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ComponentInfo(BaseModel): + """Normalized representation of a Remnawave component.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: Optional[str] = None + name: Optional[str] = None + slug: Optional[str] = None + status: Optional[str] = None + installed_version: Optional[str] = Field(default=None, alias="installedVersion") + latest_version: Optional[str] = Field(default=None, alias="latestVersion") + category: Optional[str] = None + description: Optional[str] = None + is_installed: Optional[bool] = Field(default=None, alias="isInstalled") + is_enabled: Optional[bool] = Field(default=None, alias="isEnabled") + installed_at: Optional[str] = Field(default=None, alias="installedAt") + updated_at: Optional[str] = Field(default=None, alias="updatedAt") + tags: Optional[List[str]] = None + metadata: Optional[Dict[str, Any]] = None + + +class ComponentListResponse(BaseModel): + items: List[ComponentInfo] + total: int + + +class ComponentActionRequest(BaseModel): + payload: Optional[Dict[str, Any]] = None + + +class ComponentUpdateRequest(BaseModel): + payload: Dict[str, Any] = Field(default_factory=dict) + + +class ComponentActionResponse(BaseModel): + success: bool + message: Optional[str] = None + component: Optional[ComponentInfo] = None + details: Optional[Dict[str, Any]] = None diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index d5d2693d..e465f231 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -126,6 +126,10 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `POST` | `/promo-groups` | Создать промо-группу. | `PATCH` | `/promo-groups/{id}` | Обновить промо-группу. | `DELETE` | `/promo-groups/{id}` | Удалить промо-группу. +| `GET` | `/remnawave/components` | Список компонентов Remnawave. +| `GET` | `/remnawave/components/{id}` | Детали конкретного компонента. +| `POST` | `/remnawave/components/{id}/actions/{action}` | Выполнить действие над компонентом (install, update, restart и т.д.). +| `PATCH` | `/remnawave/components/{id}` | Обновить настройки компонента. | `GET` | `/tokens` | Управление токенами доступа. > Все списковые эндпоинты поддерживают пагинацию (`limit`, `offset`) и фильтры, описанные в OpenAPI спецификации. Если `WEB_API_DOCS_ENABLED=true`, документация доступна по `/docs`. В ответах `/settings` поле `choices` всегда массив: пустой список означает отсутствие предопределённых значений.