mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-26 06:12:00 +00:00
Merge pull request #530 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Add Remnawave component management API
This commit is contained in:
41
app/external/remnawave_api.py
vendored
41
app/external/remnawave_api.py
vendored
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
152
app/webapi/routes/remnawave.py
Normal file
152
app/webapi/routes/remnawave.py
Normal file
@@ -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,
|
||||
)
|
||||
46
app/webapi/schemas/remnawave.py
Normal file
46
app/webapi/schemas/remnawave.py
Normal file
@@ -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
|
||||
@@ -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` всегда массив: пустой список означает отсутствие предопределённых значений.
|
||||
|
||||
Reference in New Issue
Block a user