From 9c1ca5b7480355d23bfc1b2bf181743cab901925 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 28 Sep 2025 06:16:13 +0300 Subject: [PATCH] Ensure default web API token stays in sync with settings --- app/database/universal_migration.py | 13 +++- app/services/system_settings_service.py | 22 +++++++ app/webapi/routes/remnawave.py | 86 +++++++++++++++++-------- docs/web-admin-integration.md | 21 ++++-- 4 files changed, 109 insertions(+), 33 deletions(-) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index a563432a..4e6284f3 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1,4 +1,6 @@ import logging +from datetime import datetime + from sqlalchemy import inspect, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -1986,9 +1988,18 @@ async def ensure_default_web_api_token() -> bool: existing = result.scalar_one_or_none() if existing: + updated = False + if not existing.is_active: existing.is_active = True - existing.updated_at = existing.updated_at or existing.created_at + updated = True + + if token_name and existing.name != token_name: + existing.name = token_name + updated = True + + if updated: + existing.updated_at = datetime.utcnow() await session.commit() return True diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index b77d0745..cde93e4c 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -4,6 +4,8 @@ import logging from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin +from app.database.universal_migration import ensure_default_web_api_token + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -541,6 +543,8 @@ class BotConfigurationService: cls._overrides_raw[key] = raw_value cls._apply_to_settings(key, parsed_value) + await cls._sync_default_web_api_token() + @classmethod async def reload(cls) -> None: cls._overrides_raw.clear() @@ -642,6 +646,9 @@ class BotConfigurationService: cls._overrides_raw[key] = raw_value cls._apply_to_settings(key, value) + if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: + await cls._sync_default_web_api_token() + @classmethod async def reset_value(cls, db: AsyncSession, key: str) -> None: await delete_system_setting(db, key) @@ -649,6 +656,9 @@ class BotConfigurationService: original = cls.get_original_value(key) cls._apply_to_settings(key, original) + if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: + await cls._sync_default_web_api_token() + @classmethod def _apply_to_settings(cls, key: str, value: Any) -> None: try: @@ -656,6 +666,18 @@ class BotConfigurationService: except Exception as error: logger.error("Не удалось применить значение %s=%s: %s", key, value, error) + @staticmethod + async def _sync_default_web_api_token() -> None: + default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip() + if not default_token: + return + + success = await ensure_default_web_api_token() + if not success: + logger.warning( + "Не удалось синхронизировать бутстрап токен веб-API после обновления настроек", + ) + @classmethod def get_setting_summary(cls, key: str) -> Dict[str, Any]: definition = cls.get_definition(key) diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index 624648cb..35bfc0c3 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -1,13 +1,11 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TYPE_CHECKING from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService - from ..dependencies import get_db_session, require_api_token from ..schemas.remnawave import ( RemnaWaveConnectionStatus, @@ -31,11 +29,41 @@ from ..schemas.remnawave import ( RemnaWaveUserTrafficResponse, ) +try: # pragma: no cover - импорт может не работать без optional-зависимостей + from app.services.remnawave_service import ( # type: ignore + RemnaWaveConfigurationError, + RemnaWaveService, + ) +except Exception: # pragma: no cover - при ошибке импорта скрываем функционал + RemnaWaveConfigurationError = None # type: ignore[assignment] + RemnaWaveService = None # type: ignore[assignment] + +if TYPE_CHECKING: # pragma: no cover - только для типов в IDE + from app.services.remnawave_service import RemnaWaveService as RemnaWaveServiceType +else: + RemnaWaveServiceType = Any + router = APIRouter() -def _ensure_service_configured(service: RemnaWaveService) -> None: +def _get_service() -> "RemnaWaveServiceType": + if RemnaWaveService is None: # pragma: no cover - зависимость не доступна + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="RemnaWave сервис недоступен", + ) + + return RemnaWaveService() + + +def _ensure_service_configured(service: "RemnaWaveServiceType") -> None: + if RemnaWaveService is None: # pragma: no cover - зависимость не доступна + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="RemnaWave сервис недоступен", + ) + if not service.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -74,7 +102,7 @@ def _parse_last_updated(value: Any) -> Optional[datetime]: async def get_remnawave_status( _: Any = Security(require_api_token), ) -> RemnaWaveStatusResponse: - service = RemnaWaveService() + service = _get_service() connection_info: Optional[RemnaWaveConnectionStatus] = None connection_result = await service.test_api_connection() @@ -93,7 +121,7 @@ async def get_remnawave_status( async def get_system_statistics( _: Any = Security(require_api_token), ) -> RemnaWaveSystemStatsResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.get_system_statistics() @@ -108,7 +136,7 @@ async def get_system_statistics( async def list_nodes( _: Any = Security(require_api_token), ) -> RemnaWaveNodeListResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) nodes = await service.get_all_nodes() @@ -120,7 +148,7 @@ async def list_nodes( async def get_nodes_realtime_usage( _: Any = Security(require_api_token), ) -> List[Dict[str, Any]]: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) return await service.get_nodes_realtime_usage() @@ -130,7 +158,7 @@ async def get_node_details( node_uuid: str, _: Any = Security(require_api_token), ) -> RemnaWaveNode: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) node = await service.get_node_details(node_uuid) @@ -144,7 +172,7 @@ async def get_node_statistics( node_uuid: str, _: Any = Security(require_api_token), ) -> RemnaWaveNodeStatisticsResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.get_node_statistics(node_uuid) @@ -171,7 +199,7 @@ async def get_node_usage_range( end: Optional[datetime] = Query(default=None), _: Any = Security(require_api_token), ) -> RemnaWaveNodeUsageResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) end_dt = end or datetime.utcnow() @@ -190,7 +218,7 @@ async def manage_node( payload: RemnaWaveNodeActionRequest, _: Any = Security(require_api_token), ) -> RemnaWaveNodeActionResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = await service.manage_node(node_uuid, payload.action) @@ -212,7 +240,7 @@ async def manage_node( async def restart_all_nodes( _: Any = Security(require_api_token), ) -> RemnaWaveNodeActionResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = await service.restart_all_nodes() @@ -224,7 +252,7 @@ async def restart_all_nodes( async def list_squads( _: Any = Security(require_api_token), ) -> RemnaWaveSquadListResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) squads = await service.get_all_squads() @@ -237,7 +265,7 @@ async def get_squad_details( squad_uuid: str, _: Any = Security(require_api_token), ) -> RemnaWaveSquad: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) squad = await service.get_squad_details(squad_uuid) @@ -251,7 +279,7 @@ async def create_squad( payload: RemnaWaveSquadCreateRequest, _: Any = Security(require_api_token), ) -> RemnaWaveOperationResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = await service.create_squad(payload.name, payload.inbound_uuids) @@ -265,7 +293,7 @@ async def update_squad( payload: RemnaWaveSquadUpdateRequest, _: Any = Security(require_api_token), ) -> RemnaWaveOperationResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = False @@ -288,7 +316,7 @@ async def squad_actions( payload: RemnaWaveSquadActionRequest, _: Any = Security(require_api_token), ) -> RemnaWaveOperationResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) action = payload.action @@ -322,7 +350,7 @@ async def squad_actions( async def list_inbounds( _: Any = Security(require_api_token), ) -> RemnaWaveInboundsResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) inbounds = await service.get_all_inbounds() @@ -334,7 +362,7 @@ async def get_user_traffic( telegram_id: int, _: Any = Security(require_api_token), ) -> RemnaWaveUserTrafficResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.get_user_traffic_stats(telegram_id) @@ -350,15 +378,17 @@ async def sync_from_panel( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) try: stats = await service.sync_users_from_panel(db, payload.mode) detail = "Синхронизация из панели выполнена" return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) - except RemnaWaveConfigurationError as exc: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc + except Exception as exc: # pragma: no cover - точный тип зависит от импорта + if RemnaWaveConfigurationError and isinstance(exc, RemnaWaveConfigurationError): + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc + raise @router.post("/sync/to-panel", response_model=RemnaWaveGenericSyncResponse) @@ -366,7 +396,7 @@ async def sync_to_panel( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.sync_users_to_panel(db) @@ -379,7 +409,7 @@ async def validate_and_fix_subscriptions( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.validate_and_fix_subscriptions(db) @@ -392,7 +422,7 @@ async def cleanup_orphaned_subscriptions( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.cleanup_orphaned_subscriptions(db) @@ -405,7 +435,7 @@ async def sync_subscription_statuses( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.sync_subscription_statuses(db) @@ -418,7 +448,7 @@ async def get_sync_recommendations( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) data = await service.get_sync_recommendations(db) diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 31539a9b..049916f0 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -69,7 +69,8 @@ python main.py ## 5. Аутентификация и токены -- Первый токен удобно задать через `WEB_API_DEFAULT_TOKEN`. Он появится в таблице при запуске миграции. +- Первый токен удобно задать через `WEB_API_DEFAULT_TOKEN`. Он появится в таблице при запуске миграции и будет автоматически + пересоздан/активирован после изменения значения через интерфейс настроек. - Для управления токенами используйте эндпоинты `/tokens`: - `GET /tokens` — список токенов. - `POST /tokens` — создать новый токен. Возвращает открытое значение один раз. @@ -137,13 +138,25 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `GET` | `/remnawave/status` | Проверка конфигурации и доступности RemnaWave API. | | `GET` | `/remnawave/system` | Агрегированная статистика по пользователям, нодам и трафику. | | `GET` | `/remnawave/nodes` | Список нод и их текущее состояние. | +| `GET` | `/remnawave/nodes/realtime` | Текущая загрузка нод (realtime-метрики RemnaWave). | +| `GET` | `/remnawave/nodes/{uuid}` | Детальная информация по конкретной ноде. | +| `GET` | `/remnawave/nodes/{uuid}/statistics` | Агрегированная статистика и история нагрузок по ноде. | +| `GET` | `/remnawave/nodes/{uuid}/usage` | История использования ноды пользователями за выбранный период. | | `POST` | `/remnawave/nodes/{uuid}/actions` | Включение, отключение или перезапуск ноды. | -| `GET` | `/remnawave/squads` | Управление сквадами и их инбаундами. | +| `POST` | `/remnawave/nodes/restart` | Массовый перезапуск всех нод в RemnaWave. | +| `GET` | `/remnawave/squads` | Список внутренних сквадов с составом и статистикой. | +| `GET` | `/remnawave/squads/{uuid}` | Детали выбранного сквада. | +| `POST` | `/remnawave/squads` | Создание нового сквада и привязка inbounds. | +| `PATCH` | `/remnawave/squads/{uuid}` | Обновление имени или состава inbounds сквада. | +| `POST` | `/remnawave/squads/{uuid}/actions` | Массовые операции: добавить/удалить всех, переименовать, обновить inbounds, удалить. | +| `GET` | `/remnawave/inbounds` | Список доступных inbounds в панели RemnaWave. | +| `GET` | `/remnawave/users/{telegram_id}/traffic` | Использование трафика конкретного пользователя RemnaWave. | | `POST` | `/remnawave/sync/from-panel` | Синхронизация пользователей и подписок из панели в бота. | | `POST` | `/remnawave/sync/to-panel` | Обратная синхронизация данных бота в панель. | | `POST` | `/remnawave/sync/subscriptions/validate` | Проверка и восстановление подписок в RemnaWave. | - -> Остальные операции (история трафика, рекомендации по синхронизации, очистка подписок и т. д.) также доступны в этом разделе Swagger UI. +| `POST` | `/remnawave/sync/subscriptions/cleanup` | Очистка «осиротевших» подписок и пользователей в RemnaWave. | +| `POST` | `/remnawave/sync/subscriptions/statuses` | Приведение статусов подписок в боте и панели к единому виду. | +| `GET` | `/remnawave/sync/recommendations` | Рекомендации по синхронизации: что добавить, обновить или удалить. | > Все списковые эндпоинты поддерживают пагинацию (`limit`, `offset`) и фильтры, описанные в OpenAPI спецификации. Если `WEB_API_DOCS_ENABLED=true`, документация доступна по `/docs`. В ответах `/settings` поле `choices` всегда массив: пустой список означает отсутствие предопределённых значений.