Files
remnawave-bedolaga-telegram…/app/external/remnawave_api.py
2026-01-07 17:25:06 +03:00

1268 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import json
import ssl
import base64
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
import aiohttp
import logging
from dataclasses import dataclass
from enum import Enum
from urllib.parse import urlparse, urljoin
logger = logging.getLogger(__name__)
class UserStatus(Enum):
ACTIVE = "ACTIVE"
DISABLED = "DISABLED"
LIMITED = "LIMITED"
EXPIRED = "EXPIRED"
class TrafficLimitStrategy(Enum):
NO_RESET = "NO_RESET"
DAY = "DAY"
WEEK = "WEEK"
MONTH = "MONTH"
@dataclass
class UserTraffic:
"""Данные о трафике пользователя (новая структура API)"""
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
online_at: Optional[datetime] = None
first_connected_at: Optional[datetime] = None
last_connected_node_uuid: Optional[str] = None
@dataclass
class RemnaWaveUser:
uuid: str
short_uuid: str
username: str
status: UserStatus
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
telegram_id: Optional[int]
email: Optional[str]
hwid_device_limit: Optional[int]
description: Optional[str]
tag: Optional[str]
subscription_url: str
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
user_traffic: Optional[UserTraffic] = None
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
last_triggered_threshold: int = 0
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
external_squad_uuid: Optional[str] = None
id: Optional[int] = None
@property
def used_traffic_bytes(self) -> int:
"""Обратная совместимость: получение used_traffic_bytes из user_traffic"""
if self.user_traffic:
return self.user_traffic.used_traffic_bytes
return 0
@property
def lifetime_used_traffic_bytes(self) -> int:
"""Обратная совместимость: получение lifetime_used_traffic_bytes из user_traffic"""
if self.user_traffic:
return self.user_traffic.lifetime_used_traffic_bytes
return 0
@property
def online_at(self) -> Optional[datetime]:
"""Обратная совместимость: получение online_at из user_traffic"""
if self.user_traffic:
return self.user_traffic.online_at
return None
@property
def first_connected_at(self) -> Optional[datetime]:
"""Обратная совместимость: получение first_connected_at из user_traffic"""
if self.user_traffic:
return self.user_traffic.first_connected_at
return None
@dataclass
class RemnaWaveInbound:
"""Структура inbound для Internal Squad"""
uuid: str
profile_uuid: str
tag: str
type: str
network: Optional[str] = None
security: Optional[str] = None
port: Optional[int] = None
raw_inbound: Optional[Any] = None
@dataclass
class RemnaWaveInternalSquad:
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: List[RemnaWaveInbound]
view_position: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@dataclass
class RemnaWaveAccessibleNode:
"""Доступная нода для Internal Squad"""
uuid: str
node_name: str
country_code: str
config_profile_uuid: str
config_profile_name: str
active_inbounds: List[str]
@dataclass
class RemnaWaveNode:
uuid: str
name: str
address: str
country_code: str
is_connected: bool
is_disabled: bool
users_online: Optional[int]
traffic_used_bytes: Optional[int]
traffic_limit_bytes: Optional[int]
port: Optional[int] = None
is_connecting: bool = False
xray_version: Optional[str] = None
node_version: Optional[str] = None
view_position: int = 0
tags: Optional[List[str]] = None
# Новые поля API
last_status_change: Optional[datetime] = None
last_status_message: Optional[str] = None
xray_uptime: Optional[str] = None
is_traffic_tracking_active: bool = False
traffic_reset_day: Optional[int] = None
notify_percent: Optional[int] = None
consumption_multiplier: float = 1.0
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
provider_uuid: Optional[str] = None
@property
def is_node_online(self) -> bool:
"""Обратная совместимость: is_node_online = is_connected"""
return self.is_connected
@property
def is_xray_running(self) -> bool:
"""Обратная совместимость: xray работает если нода подключена"""
return self.is_connected
@dataclass
class SubscriptionInfo:
is_found: bool
user: Optional[Dict[str, Any]]
links: List[str]
ss_conf_links: Dict[str, str]
subscription_url: str
happ: Optional[Dict[str, str]]
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
@dataclass
class SubscriptionPageConfig:
"""Конфигурация страницы подписки"""
uuid: str
name: str
view_position: int
config: Optional[Dict[str, Any]] = None
@dataclass
class RemnaWaveExternalSquad:
"""Структура External Squad"""
uuid: str
name: str
view_position: int
members_count: int
templates: List[Dict[str, str]]
subscription_settings: Optional[Dict[str, Any]] = None
host_overrides: Optional[Dict[str, Any]] = None
response_headers: Optional[Dict[str, str]] = None
hwid_settings: Optional[Dict[str, Any]] = None
custom_remarks: Optional[Dict[str, Any]] = None
subpage_config_uuid: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class RemnaWaveAPIError(Exception):
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class RemnaWaveAPI:
def __init__(
self,
base_url: str,
api_key: str,
secret_key: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
caddy_token: Optional[str] = None,
auth_type: str = "api_key",
):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.secret_key = secret_key
self.username = username
self.password = password
self.caddy_token = caddy_token
self.auth_type = auth_type.lower() if auth_type else "api_key"
self.session: Optional[aiohttp.ClientSession] = None
self.authenticated = False
def _detect_connection_type(self) -> str:
parsed = urlparse(self.base_url)
local_hosts = [
'localhost', '127.0.0.1', 'remnawave',
'remnawave-backend', 'app', 'api'
]
if parsed.hostname in local_hosts:
return "local"
if parsed.hostname:
if (parsed.hostname.startswith('192.168.') or
parsed.hostname.startswith('10.') or
parsed.hostname.startswith('172.') or
parsed.hostname.endswith('.local')):
return "local"
return "external"
def _prepare_auth_headers(self) -> Dict[str, str]:
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Forwarded-Proto': 'https',
'X-Forwarded-For': '127.0.0.1',
'X-Real-IP': '127.0.0.1'
}
# Caddy авторизация — добавляется поверх основной
if self.caddy_token:
# Caddy Security: готовый base64 токен используется как есть
headers['Authorization'] = f'Basic {self.caddy_token}'
logger.debug("Используем Caddy Basic Auth")
# Основная авторизация RemnaWave API
if self.auth_type == "basic" and self.username and self.password:
credentials = f"{self.username}:{self.password}"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
elif self.auth_type == "caddy":
# Для caddy auth_type основная авторизация уже в Authorization header
# Но API ключ всё равно нужен для RemnaWave
if self.api_key:
headers['X-Api-Key'] = self.api_key
logger.debug("Используем API ключ для RemnaWave + Caddy авторизацию")
else:
# api_key или bearer — стандартный режим
headers['X-Api-Key'] = self.api_key
if not self.caddy_token:
headers['Authorization'] = f'Bearer {self.api_key}'
logger.debug("Используем API ключ в X-Api-Key заголовке")
return headers
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = self._prepare_auth_headers()
cookies = None
if self.secret_key:
if ':' in self.secret_key:
key_name, key_value = self.secret_key.split(':', 1)
cookies = {key_name: key_value}
logger.debug(f"Используем куки: {key_name}=***")
else:
cookies = {self.secret_key: self.secret_key}
logger.debug(f"Используем куки: {self.secret_key}=***")
connector_kwargs = {}
if conn_type == "local":
logger.debug("Используют локальные заголовки proxy")
headers.update({
'X-Forwarded-Host': 'localhost',
'Host': 'localhost'
})
if self.base_url.startswith('https://'):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector_kwargs['ssl'] = ssl_context
logger.debug("SSL проверка отключена для локального HTTPS")
elif conn_type == "external":
logger.debug("Используют внешнее подключение с полной SSL проверкой")
pass
connector = aiohttp.TCPConnector(**connector_kwargs)
session_kwargs = {
'timeout': aiohttp.ClientTimeout(total=30),
'headers': headers,
'connector': connector
}
if cookies:
session_kwargs['cookies'] = cookies
self.session = aiohttp.ClientSession(**session_kwargs)
self.authenticated = True
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.session:
await self.session.close()
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None
) -> Dict:
if not self.session:
raise RemnaWaveAPIError("Session not initialized. Use async context manager.")
url = f"{self.base_url}{endpoint}"
try:
kwargs = {
'url': url,
'params': params
}
if data:
kwargs['json'] = data
async with self.session.request(method, **kwargs) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
if response.status >= 400:
error_message = response_data.get('message', f'HTTP {response.status}')
logger.error(f"API Error {response.status}: {error_message}")
logger.error(f"Response: {response_text[:500]}")
raise RemnaWaveAPIError(
error_message,
response.status,
response_data
)
return response_data
except aiohttp.ClientError as e:
logger.error(f"Request failed: {e}")
raise RemnaWaveAPIError(f"Request failed: {str(e)}")
async def create_user(
self,
username: str,
expire_at: datetime,
status: UserStatus = UserStatus.ACTIVE,
traffic_limit_bytes: int = 0,
traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
active_internal_squads: Optional[List[str]] = None
) -> RemnaWaveUser:
data = {
'username': username,
'status': status.value,
'expireAt': expire_at.isoformat(),
'trafficLimitBytes': traffic_limit_bytes,
'trafficLimitStrategy': traffic_limit_strategy.value
}
if telegram_id:
data['telegramId'] = telegram_id
if email:
data['email'] = email
if hwid_device_limit:
data['hwidDeviceLimit'] = hwid_device_limit
if description:
data['description'] = description
if tag:
data['tag'] = tag
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
logger.debug("Создание пользователя в панели: %s", data)
response = await self._make_request('POST', '/api/users', data)
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/{uuid}')
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
users_data = response.get('response', [])
if not users_data:
return []
users = [self._parse_user(user) for user in users_data]
return [await self.enrich_user_with_happ_link(u) for u in users]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
raise
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/by-username/{username}')
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def update_user(
self,
uuid: str,
status: Optional[UserStatus] = None,
traffic_limit_bytes: Optional[int] = None,
traffic_limit_strategy: Optional[TrafficLimitStrategy] = None,
expire_at: Optional[datetime] = None,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
active_internal_squads: Optional[List[str]] = None
) -> RemnaWaveUser:
data = {'uuid': uuid}
if status:
data['status'] = status.value
if traffic_limit_bytes is not None:
data['trafficLimitBytes'] = traffic_limit_bytes
if traffic_limit_strategy:
data['trafficLimitStrategy'] = traffic_limit_strategy.value
if expire_at:
data['expireAt'] = expire_at.isoformat()
if telegram_id is not None:
data['telegramId'] = telegram_id
if email is not None:
data['email'] = email
if hwid_device_limit is not None:
data['hwidDeviceLimit'] = hwid_device_limit
if description is not None:
data['description'] = description
if tag is not None:
data['tag'] = tag
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('PATCH', '/api/users', data)
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def delete_user(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/users/{uuid}')
return response['response']['isDeleted']
async def enable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def disable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def revoke_user_subscription(
self,
uuid: str,
new_short_uuid: Optional[str] = None,
revoke_only_passwords: bool = False
) -> RemnaWaveUser:
"""
Отзывает подписку пользователя (меняет ссылку/пароли).
Args:
uuid: UUID пользователя
new_short_uuid: Новый короткий UUID (опционально, рекомендуется генерировать автоматически)
revoke_only_passwords: Если True, меняются только пароли без изменения URL подписки
"""
data = {}
if new_short_uuid:
data['shortUuid'] = new_short_uuid
if revoke_only_passwords:
data['revokeOnlyPasswords'] = True
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
params = {'start': start, 'size': size}
response = await self._make_request('GET', '/api/users', params=params)
users = [self._parse_user(user) for user in response['response']['users']]
if enrich_happ_links:
users = [await self.enrich_user_with_happ_link(u) for u in users]
return {
'users': users,
'total': response['response']['total']
}
async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]:
response = await self._make_request('GET', '/api/internal-squads')
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]:
try:
response = await self._make_request('GET', f'/api/internal-squads/{uuid}')
return self._parse_internal_squad(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad:
data = {
'name': name,
'inbounds': inbounds
}
response = await self._make_request('POST', '/api/internal-squads', data)
return self._parse_internal_squad(response['response'])
async def update_internal_squad(
self,
uuid: str,
name: Optional[str] = None,
inbounds: Optional[List[str]] = None
) -> RemnaWaveInternalSquad:
data = {'uuid': uuid}
if name:
data['name'] = name
if inbounds is not None:
data['inbounds'] = inbounds
response = await self._make_request('PATCH', '/api/internal-squads', data)
return self._parse_internal_squad(response['response'])
async def delete_internal_squad(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
return response['response']['isDeleted']
async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]:
"""Получает список доступных нод для Internal Squad"""
try:
response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes')
return [self._parse_accessible_node(node) for node in response['response']['accessibleNodes']]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
raise
async def add_users_to_internal_squad(self, uuid: str) -> bool:
"""Добавляет всех пользователей в Internal Squad (bulk action)"""
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/add-users')
return response['response']['eventSent']
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]:
"""
Изменяет порядок Internal Squads
items: список словарей с uuid и viewPosition
Пример: [{'uuid': '...', 'viewPosition': 0}, {'uuid': '...', 'viewPosition': 1}]
"""
data = {'items': items}
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
# ============== External Squads API ==============
async def get_external_squads(self) -> List[RemnaWaveExternalSquad]:
"""Получает список всех External Squads"""
response = await self._make_request('GET', '/api/external-squads')
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]:
"""Получает External Squad по UUID"""
try:
response = await self._make_request('GET', f'/api/external-squads/{uuid}')
return self._parse_external_squad(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad:
data = {'name': name}
response = await self._make_request('POST', '/api/external-squads', data)
return self._parse_external_squad(response['response'])
async def update_external_squad(
self,
uuid: str,
name: Optional[str] = None,
templates: Optional[List[Dict[str, str]]] = None,
subscription_settings: Optional[Dict[str, Any]] = None,
host_overrides: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, str]] = None,
hwid_settings: Optional[Dict[str, Any]] = None,
custom_remarks: Optional[Dict[str, Any]] = None,
subpage_config_uuid: Optional[str] = None
) -> RemnaWaveExternalSquad:
data = {'uuid': uuid}
if name is not None:
data['name'] = name
if templates is not None:
data['templates'] = templates
if subscription_settings is not None:
data['subscriptionSettings'] = subscription_settings
if host_overrides is not None:
data['hostOverrides'] = host_overrides
if response_headers is not None:
data['responseHeaders'] = response_headers
if hwid_settings is not None:
data['hwidSettings'] = hwid_settings
if custom_remarks is not None:
data['customRemarks'] = custom_remarks
if subpage_config_uuid is not None:
data['subpageConfigUuid'] = subpage_config_uuid
response = await self._make_request('PATCH', '/api/external-squads', data)
return self._parse_external_squad(response['response'])
async def delete_external_squad(self, uuid: str) -> bool:
"""Удаляет External Squad"""
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}')
return response['response']['isDeleted']
async def add_users_to_external_squad(self, uuid: str) -> bool:
"""Добавляет всех пользователей в External Squad (bulk action)"""
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users')
return response['response']['eventSent']
async def remove_users_from_external_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из External Squad (bulk action)"""
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]:
data = {'items': items}
response = await self._make_request('POST', '/api/external-squads/actions/reorder', data)
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad:
"""Парсит данные External Squad"""
info = squad_data.get('info', {})
return RemnaWaveExternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
view_position=squad_data.get('viewPosition', 0),
members_count=info.get('membersCount', 0),
templates=squad_data.get('templates', []),
subscription_settings=squad_data.get('subscriptionSettings'),
host_overrides=squad_data.get('hostOverrides'),
response_headers=squad_data.get('responseHeaders'),
hwid_settings=squad_data.get('hwidSettings'),
custom_remarks=squad_data.get('customRemarks'),
subpage_config_uuid=squad_data.get('subpageConfigUuid'),
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
)
async def get_all_nodes(self) -> List[RemnaWaveNode]:
response = await self._make_request('GET', '/api/nodes')
return [self._parse_node(node) for node in response['response']]
async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]:
try:
response = await self._make_request('GET', f'/api/nodes/{uuid}')
return self._parse_node(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def enable_node(self, uuid: str) -> RemnaWaveNode:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable')
return self._parse_node(response['response'])
async def disable_node(self, uuid: str) -> RemnaWaveNode:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable')
return self._parse_node(response['response'])
async def restart_node(self, uuid: str) -> bool:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
return response['response']['eventSent']
async def restart_all_nodes(self) -> bool:
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
return response['response']['eventSent']
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
info = self._parse_subscription_info(response['response'])
# Обогащаем happ_crypto_link если его нет но есть subscription_url
if not info.happ_crypto_link and info.subscription_url:
encrypted = await self.encrypt_happ_crypto_link(info.subscription_url)
if encrypted:
info.happ_crypto_link = encrypted
return info
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
if response.status >= 400:
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
return await response.text()
async def get_subscription_by_client_type(self, short_uuid: str, client_type: str) -> str:
valid_types = ["stash", "singbox", "singbox-legacy", "mihomo", "json", "v2ray-json", "clash"]
if client_type not in valid_types:
raise ValueError(f"Invalid client type. Must be one of: {valid_types}")
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}/{client_type}") as response:
if response.status >= 400:
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
return await response.text()
async def get_subscription_links(self, short_uuid: str) -> Dict[str, str]:
base_url = f"{self.base_url}/api/sub/{short_uuid}"
links = {
"base": base_url,
"stash": f"{base_url}/stash",
"singbox": f"{base_url}/singbox",
"singbox_legacy": f"{base_url}/singbox-legacy",
"mihomo": f"{base_url}/mihomo",
"json": f"{base_url}/json",
"v2ray_json": f"{base_url}/v2ray-json",
"clash": f"{base_url}/clash"
}
return links
async def get_outline_subscription(self, short_uuid: str, encoded_tag: str) -> str:
async with self.session.get(f"{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}") as response:
if response.status >= 400:
raise RemnaWaveAPIError(f"Failed to get outline subscription: {response.status}")
return await response.text()
async def get_system_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats')
return response['response']
async def get_system_metadata(self) -> Dict[str, Any]:
"""
Получает метаданные системы Remnawave.
Returns:
Dict с полями:
- version: версия Remnawave
- build: {time, number} - информация о сборке
- git: {backend: {commitSha}, node: {commitSha}} - информация о коммитах
"""
response = await self._make_request('GET', '/api/system/metadata')
return response['response']
async def get_bandwidth_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats/bandwidth')
return response['response']
async def get_nodes_statistics(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats/nodes')
return response['response']
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
return await self.get_bandwidth_stats_nodes_realtime()
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
# ============== Bandwidth Stats API ==============
async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
return response['response']
async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
return response['response']
async def get_bandwidth_stats_node_users(
self,
node_uuid: str,
start_date: str,
end_date: str,
top_users_limit: int = 10
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date,
'topUsersLimit': top_users_limit
}
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params)
return response['response']
async def get_bandwidth_stats_node_users_legacy(
self,
node_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params)
return response['response']
async def get_bandwidth_stats_user(
self,
user_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params)
return response['response']
async def get_bandwidth_stats_user_legacy(
self,
user_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params)
return response
# ============== Subscription Page Configs API ==============
async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]:
response = await self._make_request('GET', '/api/subscription-page-configs')
configs_data = response['response'].get('configs', [])
return [self._parse_subscription_page_config(c) for c in configs_data]
async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]:
try:
response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}')
return self._parse_subscription_page_config(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig:
data = {'name': name}
response = await self._make_request('POST', '/api/subscription-page-configs', data)
return self._parse_subscription_page_config(response['response'])
async def update_subscription_page_config(
self,
uuid: str,
name: Optional[str] = None,
config: Optional[Dict[str, Any]] = None
) -> SubscriptionPageConfig:
data = {'uuid': uuid}
if name is not None:
data['name'] = name
if config is not None:
data['config'] = config
response = await self._make_request('PATCH', '/api/subscription-page-configs', data)
return self._parse_subscription_page_config(response['response'])
async def delete_subscription_page_config(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}')
return response['response']['isDeleted']
async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]:
data = {'items': items}
response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data)
configs_data = response['response'].get('configs', [])
return [self._parse_subscription_page_config(c) for c in configs_data]
async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig:
data = {'cloneFromUuid': clone_from_uuid}
response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data)
return self._parse_subscription_page_config(response['response'])
async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]:
try:
response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}')
return response.get('response')
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig:
"""Парсит данные конфигурации страницы подписки"""
return SubscriptionPageConfig(
uuid=data['uuid'],
name=data['name'],
view_position=data['viewPosition'],
config=data.get('config')
)
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
return response['response']
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
return True
failed_count = 0
for device in devices:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f"Ошибка при сбросе устройств: {e}")
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
try:
data = {"linkToEncrypt": link_to_encrypt}
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
return response.get('response', {}).get('encryptedLink')
except RemnaWaveAPIError as e:
logger.warning(f"Не удалось зашифровать happ ссылку: {e.message}")
return None
except Exception as e:
logger.warning(f"Ошибка при шифровании happ ссылки: {e}")
return None
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
if not user.happ_crypto_link and user.subscription_url:
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
if encrypted:
user.happ_crypto_link = encrypted
return user
def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]:
"""Парсит данные трафика из нового формата API"""
if not traffic_data:
return None
return UserTraffic(
used_traffic_bytes=int(traffic_data.get('usedTrafficBytes', 0)),
lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)),
online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')),
first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')),
last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid')
)
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
happ_data = user_data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
# Парсим userTraffic из нового формата API
user_traffic = self._parse_user_traffic(user_data.get('userTraffic'))
# Получаем status с fallback на ACTIVE
status_str = user_data.get('status') or 'ACTIVE'
try:
status = UserStatus(status_str)
except ValueError:
logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE")
status = UserStatus.ACTIVE
# Получаем trafficLimitStrategy с fallback
strategy_str = user_data.get('trafficLimitStrategy') or 'NO_RESET'
try:
traffic_strategy = TrafficLimitStrategy(strategy_str)
except ValueError:
logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET")
traffic_strategy = TrafficLimitStrategy.NO_RESET
return RemnaWaveUser(
uuid=user_data['uuid'],
short_uuid=user_data['shortUuid'],
username=user_data['username'],
status=status,
traffic_limit_bytes=user_data.get('trafficLimitBytes', 0),
traffic_limit_strategy=traffic_strategy,
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
telegram_id=user_data.get('telegramId'),
email=user_data.get('email'),
hwid_device_limit=user_data.get('hwidDeviceLimit'),
description=user_data.get('description'),
tag=user_data.get('tag'),
subscription_url=user_data.get('subscriptionUrl', ''),
active_internal_squads=user_data.get('activeInternalSquads', []),
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
user_traffic=user_traffic,
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link,
external_squad_uuid=user_data.get('externalSquadUuid'),
id=user_data.get('id')
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
if date_str:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound:
"""Парсит данные inbound"""
return RemnaWaveInbound(
uuid=inbound_data['uuid'],
profile_uuid=inbound_data['profileUuid'],
tag=inbound_data['tag'],
type=inbound_data['type'],
network=inbound_data.get('network'),
security=inbound_data.get('security'),
port=inbound_data.get('port'),
raw_inbound=inbound_data.get('rawInbound')
)
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
info = squad_data.get('info', {})
inbounds_raw = squad_data.get('inbounds', [])
inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else []
return RemnaWaveInternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
members_count=info.get('membersCount', 0),
inbounds_count=info.get('inboundsCount', 0),
inbounds=inbounds,
view_position=squad_data.get('viewPosition', 0),
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
)
def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode:
"""Парсит данные доступной ноды для Internal Squad"""
return RemnaWaveAccessibleNode(
uuid=node_data['uuid'],
node_name=node_data['nodeName'],
country_code=node_data['countryCode'],
config_profile_uuid=node_data['configProfileUuid'],
config_profile_name=node_data['configProfileName'],
active_inbounds=node_data.get('activeInbounds', [])
)
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
return RemnaWaveNode(
uuid=node_data['uuid'],
name=node_data['name'],
address=node_data['address'],
country_code=node_data.get('countryCode', ''),
is_connected=node_data.get('isConnected', False),
is_disabled=node_data.get('isDisabled', False),
users_online=node_data.get('usersOnline'),
traffic_used_bytes=node_data.get('trafficUsedBytes'),
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
port=node_data.get('port'),
is_connecting=node_data.get('isConnecting', False),
xray_version=node_data.get('xrayVersion'),
node_version=node_data.get('nodeVersion'),
view_position=node_data.get('viewPosition', 0),
tags=node_data.get('tags', []),
# Новые поля API
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
last_status_message=node_data.get('lastStatusMessage'),
xray_uptime=node_data.get('xrayUptime'),
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
traffic_reset_day=node_data.get('trafficResetDay'),
notify_percent=node_data.get('notifyPercent'),
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
cpu_count=node_data.get('cpuCount'),
cpu_model=node_data.get('cpuModel'),
total_ram=node_data.get('totalRam'),
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
provider_uuid=node_data.get('providerUuid')
)
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
happ_data = data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
return SubscriptionInfo(
is_found=data['isFound'],
user=data.get('user'),
links=data.get('links', []),
ss_conf_links=data.get('ssConfLinks', {}),
subscription_url=data.get('subscriptionUrl', ''),
happ=data.get('happ'),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
)
def format_bytes(bytes_value: int) -> str:
if bytes_value == 0:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = bytes_value
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
return f"{size:.1f} {units[unit_index]}"
def parse_bytes(size_str: str) -> int:
size_str = size_str.upper().strip()
units = {
'B': 1,
'KB': 1024,
'MB': 1024 ** 2,
'GB': 1024 ** 3,
'TB': 1024 ** 4
}
for unit, multiplier in units.items():
if size_str.endswith(unit):
try:
value = float(size_str[:-len(unit)].strip())
return int(value * multiplier)
except ValueError:
break
return 0
async def test_api_connection(api: RemnaWaveAPI) -> bool:
try:
await api.get_system_stats()
return True
except Exception as e:
logger.error(f"API connection test failed: {e}")
return False