import asyncio import json import ssl import base64 from datetime import datetime, timedelta from typing import Dict, List, Optional, Union, Any from urllib.parse import urlparse 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 RemnaWaveUser: uuid: str short_uuid: str username: str status: UserStatus used_traffic_bytes: int lifetime_used_traffic_bytes: int 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 sub_last_user_agent: Optional[str] = None sub_last_opened_at: Optional[datetime] = None online_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 first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None @dataclass class RemnaWaveInternalSquad: uuid: str name: str members_count: int inbounds_count: int inbounds: List[Dict[str, Any]] @dataclass class RemnaWaveNode: uuid: str name: str address: str country_code: str is_connected: bool is_disabled: bool is_node_online: bool is_xray_running: bool users_online: Optional[int] traffic_used_bytes: Optional[int] traffic_limit_bytes: Optional[int] @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 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): self.base_url = base_url.rstrip('/') self.api_key = api_key self.secret_key = secret_key self.username = username self.password = password 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' } if self.username and self.password: import base64 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 заголовке") else: headers['X-Api-Key'] = self.api_key logger.debug("Используем API ключ в X-Api-Key заголовке") headers['Authorization'] = f'Bearer {self.api_key}' return headers async def __aenter__(self): conn_type = self._detect_connection_type() logger.info(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 response = await self._make_request('POST', '/api/users', data) return self._parse_user(response['response']) async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]: try: response = await self._make_request('GET', f'/api/users/{uuid}') return self._parse_user(response['response']) 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 [] return [self._parse_user(user) for user in users_data] 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}') return self._parse_user(response['response']) 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) return self._parse_user(response['response']) 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') return self._parse_user(response['response']) async def disable_user(self, uuid: str) -> RemnaWaveUser: response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable') return self._parse_user(response['response']) async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser: response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic') return self._parse_user(response['response']) async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser: data = {} if new_short_uuid: data['shortUuid'] = new_short_uuid response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data) return self._parse_user(response['response']) async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]: params = {'start': start, 'size': size} response = await self._make_request('GET', '/api/users', params=params) return { 'users': [self._parse_user(user) for user in response['response']['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_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') return self._parse_subscription_info(response['response']) 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_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]]: response = await self._make_request('GET', '/api/nodes/usage/realtime') return response['response'] 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 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') return RemnaWaveUser( uuid=user_data['uuid'], short_uuid=user_data['shortUuid'], username=user_data['username'], status=UserStatus(user_data['status']), used_traffic_bytes=int(user_data['usedTrafficBytes']), lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']), traffic_limit_bytes=user_data['trafficLimitBytes'], traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']), 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['subscriptionUrl'], active_internal_squads=user_data['activeInternalSquads'], created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')), updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')), sub_last_user_agent=user_data.get('subLastUserAgent'), sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')), online_at=self._parse_optional_datetime(user_data.get('onlineAt')), 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'), first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), happ_link=happ_link, happ_crypto_link=happ_crypto_link ) 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_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad: return RemnaWaveInternalSquad( uuid=squad_data['uuid'], name=squad_data['name'], members_count=squad_data['info']['membersCount'], inbounds_count=squad_data['info']['inboundsCount'], inbounds=squad_data['inbounds'] ) 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['countryCode'], is_connected=node_data['isConnected'], is_disabled=node_data['isDisabled'], is_node_online=node_data['isNodeOnline'], is_xray_running=node_data['isXrayRunning'], users_online=node_data.get('usersOnline'), traffic_used_bytes=node_data.get('trafficUsedBytes'), traffic_limit_bytes=node_data.get('trafficLimitBytes') ) 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