diff --git a/README.md b/README.md index a630ca5..da23460 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ pip install git+https://github.com/remnawave/python-sdk.git@development | Contract Version | Remnawave Panel Version | | ---------------- | ----------------------- | +| 2.1.17 | >=2.1.16 | | 2.1.16 | >=2.1.16 | | 2.1.13 | >=2.1.13, <=2.1.15 | | 2.1.9 | >=2.1.9, <=2.1.12 | diff --git a/pyproject.toml b/pyproject.toml index deafe97..08bf23c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "remnawave" -version = "2.1.16" -description = "A Python SDK for interacting with the Remnawave API v2.1.16." +version = "2.1.17" +description = "A Python SDK for interacting with the Remnawave API v2.1.17." authors = [ {name = "Artem",email = "dev@forestsnet.com"} ] diff --git a/remnawave/controllers/webhooks.py b/remnawave/controllers/webhooks.py index 493ee68..6d501a8 100644 --- a/remnawave/controllers/webhooks.py +++ b/remnawave/controllers/webhooks.py @@ -1,7 +1,45 @@ import hmac import hashlib import json -from typing import Union +from typing import Union, Optional + +from remnawave.models.webhook import ( + WebhookPayloadDto, + UserDto, + NodesDto, + HwidUserDeviceDto, + LoginAttemptDto, + UserHwidDeviceEventDto, +) + +class WebhookHeadersDto: + """Helper class for webhook headers""" + + def __init__(self, signature: str, timestamp: str): + self.signature = signature + self.timestamp = timestamp + + @classmethod + def from_headers(cls, headers: dict[str, str]) -> "WebhookHeadersDto": + """ + Create WebhookHeadersDto from headers dictionary. + Handles case-insensitive header names. + """ + signature = None + timestamp = None + + for key, value in headers.items(): + lower_key = key.lower() + if lower_key == "x-remnawave-signature": + signature = value + elif lower_key == "x-remnawave-timestamp": + timestamp = value + + if not signature or not timestamp: + raise ValueError("Missing required webhook headers") + + return cls(signature=signature, timestamp=timestamp) + class WebhookUtility: @staticmethod @@ -29,4 +67,114 @@ class WebhookUtility: hashlib.sha256 ).hexdigest() - return hmac.compare_digest(computed_signature, signature) \ No newline at end of file + return hmac.compare_digest(computed_signature, signature) + + @staticmethod + def validate_webhook_with_headers( + body: Union[str, dict], + headers: Union[dict[str, str], WebhookHeadersDto], + webhook_secret: str + ) -> bool: + """ + Validates the webhook using headers object. + + :param body: The webhook request body. + :param headers: Dictionary with headers or WebhookHeadersDto object. + :param webhook_secret: The secret key used to compute the HMAC. + :return: True if the signature matches, otherwise False. + """ + if isinstance(headers, dict): + headers = WebhookHeadersDto.from_headers(headers) + + return WebhookUtility.validate_webhook(body, headers.signature, webhook_secret) + + @staticmethod + def parse_webhook( + body: Union[str, dict], + headers: Union[dict[str, str], WebhookHeadersDto], + webhook_secret: str, + validate: bool = True + ) -> Optional[WebhookPayloadDto]: + """ + Parses and optionally validates the webhook payload. + + :param body: The webhook request body. + :param headers: Dictionary with headers or WebhookHeadersDto object. + :param webhook_secret: The secret key used to compute the HMAC. + :param validate: Whether to validate the webhook signature (default: True). + :return: Parsed WebhookPayloadDto or None if validation fails. + """ + if validate and not WebhookUtility.validate_webhook_with_headers(body, headers, webhook_secret): + return None + + if isinstance(body, str): + body = json.loads(body) + + return WebhookPayloadDto.from_dict(body) + + @staticmethod + def is_user_event(event: str) -> bool: + """Check if event is a user event.""" + return event.startswith("user.") + + @staticmethod + def is_user_hwid_devices_event(event: str) -> bool: + """Check if event is a user HWID devices event.""" + return event.startswith("user_hwid_devices.") + + @staticmethod + def is_node_event(event: str) -> bool: + """Check if event is a node event.""" + return event.startswith("node.") + + @staticmethod + def is_infra_billing_event(event: str) -> bool: + """Check if event is an infra billing event.""" + return event.startswith("crm.infra_billing") + + @staticmethod + def is_crm_event(event: str) -> bool: + """Check if event is a CRM event.""" + return event.startswith("crm.") + + @staticmethod + def is_service_event(event: str) -> bool: + """Check if event is a service event.""" + return event.startswith("service.") + + @staticmethod + def is_errors_event(event: str) -> bool: + """Check if event is an errors event.""" + return event.startswith("errors.") + + @staticmethod + def get_typed_data(payload: WebhookPayloadDto) -> Union[UserDto, NodesDto, HwidUserDeviceDto, LoginAttemptDto, UserHwidDeviceEventDto, dict]: + """ + Get typed data from webhook payload based on event type. + + :param payload: Parsed webhook payload. + :return: Typed data object. + """ + return payload.data + + @staticmethod + def extract_user_hwid_event_data(payload: WebhookPayloadDto) -> Optional[tuple[UserDto, HwidUserDeviceDto]]: + """ + Extract user and HWID device from user_hwid_devices event. + + :param payload: Parsed webhook payload. + :return: Tuple of (UserDto, HwidUserDeviceDto) or None if not a HWID event. + """ + if not WebhookUtility.is_user_hwid_devices_event(payload.event): + return None + + if isinstance(payload.data, dict): + user_data = payload.data.get("user", {}) + hwid_data = payload.data.get("hwidUserDevice", {}) + + return ( + UserDto(**user_data), + HwidUserDeviceDto(**hwid_data) + ) + + return None \ No newline at end of file diff --git a/remnawave/enums/__init__.py b/remnawave/enums/__init__.py index 075eb42..4599088 100644 --- a/remnawave/enums/__init__.py +++ b/remnawave/enums/__init__.py @@ -5,7 +5,9 @@ from .fingerprint import Fingerprint from .security_layer import SecurityLayer from .template_type import TemplateType from .users import TrafficLimitStrategy, UserStatus - +from .webhook import ( + TCRMEvents, TErrorsEvents, TNodeEvents, TResetPeriods, TServiceEvents, TUserEvents, TUserHwidDevicesEvents, TUsersStatus +) __all__ = [ "TrafficLimitStrategy", "UserStatus", @@ -15,4 +17,13 @@ __all__ = [ "Fingerprint", "SecurityLayer", "TemplateType", + # Webhook enums + "TNodeEvents", + "TUserEvents", + "TServiceEvents", + "TErrorsEvents", + "TCRMEvents", + "TUserHwidDevicesEvents", + "TResetPeriods", + "TUsersStatus", ] diff --git a/remnawave/enums/webhook.py b/remnawave/enums/webhook.py new file mode 100644 index 0000000..68c3a5c --- /dev/null +++ b/remnawave/enums/webhook.py @@ -0,0 +1,60 @@ +from typing import Literal + +# ---------------- ENUMS / CONSTANTS ---------------- # + +TNodeEvents = Literal[ + "node.created", + "node.modified", + "node.disabled", + "node.enabled", + "node.deleted", + "node.connection_lost", + "node.connection_restored", + "node.traffic_notify", +] + +TUserEvents = Literal[ + "user.created", + "user.modified", + "user.deleted", + "user.revoked", + "user.disabled", + "user.enabled", + "user.limited", + "user.expired", + "user.traffic_reset", + "user.expires_in_72_hours", + "user.expires_in_48_hours", + "user.expires_in_24_hours", + "user.expired_24_hours_ago", + "user.first_connected", + "user.bandwidth_usage_threshold_reached", +] + +TServiceEvents = Literal[ + "service.panel_started", + "service.login_attempt_failed", + "service.login_attempt_success", +] + +TErrorsEvents = Literal[ + "errors.bandwidth_usage_threshold_reached_max_notifications", +] + +TCRMEvents = Literal[ + "crm.infra_billing_node_payment_in_7_days", + "crm.infra_billing_node_payment_in_48hrs", + "crm.infra_billing_node_payment_in_24hrs", + "crm.infra_billing_node_payment_due_today", + "crm.infra_billing_node_payment_overdue_24hrs", + "crm.infra_billing_node_payment_overdue_48hrs", + "crm.infra_billing_node_payment_overdue_7_days", +] + +TUserHwidDevicesEvents = Literal[ + "user_hwid_devices.added", + "user_hwid_devices.deleted", +] + +TResetPeriods = Literal["NO_RESET", "DAY", "WEEK", "MONTH"] +TUsersStatus = Literal["DISABLED", "LIMITED", "EXPIRED", "ACTIVE"] diff --git a/remnawave/models/__init__.py b/remnawave/models/__init__.py index 749c9a2..e50e7e4 100644 --- a/remnawave/models/__init__.py +++ b/remnawave/models/__init__.py @@ -244,6 +244,24 @@ from .subscription_request_history import ( HourlyRequestStat, SubscriptionRequestHistoryStatsData ) +from .webhook import ( + UserEventDto, + UserHwidDeviceEventDto, + HwidUserDeviceDto, + LastConnectedNodeDto, + InternalSquadDto, + BaseUserDto, + UserDto, + NodesDto, + ConfigProfileInboundDto, + InfraProviderDto, + LoginAttemptDto, + ServiceEventDto, + NodeEventDto, + CustomErrorEventDto, + CrmEventDto, + WebhookPayloadDto, +) __all__ = [ # Auth models @@ -487,4 +505,34 @@ __all__ = [ "AppStatItem", "HourlyRequestStat", "SubscriptionRequestHistoryStatsData", + # Webhook models + # USER + "LastConnectedNodeDto", + "InternalSquadDto", + "BaseUserDto", + "UserDto", + "UserEventDto", + + # HWID DEVICES + "HwidUserDeviceDto", + "UserHwidDeviceEventDto", + + # SERVICE EVENTS + "LoginAttemptDto", + "ServiceEventDto", + + # NODE ENTITIES + "ConfigProfileInboundDto", + "InfraProviderDto", + "NodesDto", + "NodeEventDto", + + # ERROR EVENTS + "CustomErrorEventDto", + + # CRM EVENTS + "CrmEventDto", + + # WEBHOOK PAYLOAD + "WebhookPayloadDto", ] diff --git a/remnawave/models/webhook.py b/remnawave/models/webhook.py new file mode 100644 index 0000000..2dd2a51 --- /dev/null +++ b/remnawave/models/webhook.py @@ -0,0 +1,275 @@ +from datetime import datetime +from typing import List, Optional, Literal, Union +from uuid import UUID +from pydantic import BaseModel, Field +from pydantic.alias_generators import to_camel +from remnawave.enums import ( + TUsersStatus, TUserEvents, TUserHwidDevicesEvents, TServiceEvents, TNodeEvents, TErrorsEvents, TCRMEvents, TResetPeriods +) +# ---------------- USER ---------------- # + +class LastConnectedNodeDto(BaseModel): + node_name: str + country_code: str + connected_at: datetime + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class InternalSquadDto(BaseModel): + uuid: UUID + name: str + description: Optional[str] = None + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class BaseUserDto(BaseModel): + uuid: UUID + short_uuid: str + username: str + status: TUsersStatus + + used_traffic_bytes: str + lifetime_used_traffic_bytes: str + + traffic_limit_bytes: str + traffic_limit_strategy: TResetPeriods + sub_last_user_agent: Optional[str] = None + sub_last_opened_at: Optional[datetime] = None + + expire_at: datetime + sub_revoked_at: Optional[datetime] = None + last_traffic_reset_at: Optional[datetime] = None + + trojan_password: str + vless_uuid: UUID + ss_password: str + + description: Optional[str] = None + tag: Optional[str] = None + telegram_id: Optional[str] = None + email: Optional[str] = None + + hwid_device_limit: Optional[int] = None + + first_connected_at: Optional[datetime] = None + last_triggered_threshold: int + + online_at: Optional[datetime] = None + last_connected_node_uuid: Optional[str] = None + + created_at: datetime + updated_at: datetime + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class UserDto(BaseUserDto): + active_internal_squads: List[InternalSquadDto] = Field(default_factory=list) + last_connected_node: Optional[LastConnectedNodeDto] = None + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class UserEventDto(BaseModel): + user: UserDto + event_name: TUserEvents + skip_telegram_notification: bool = False + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +# ---------------- HWID DEVICES ---------------- # + +class HwidUserDeviceDto(BaseModel): + hwid: str + user_uuid: UUID + platform: Optional[str] = None + os_version: Optional[str] = None + device_model: Optional[str] = None + user_agent: Optional[str] = None + + created_at: datetime + updated_at: datetime + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class UserHwidDeviceEventDto(BaseModel): + data: dict + event_name: TUserHwidDevicesEvents + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + @classmethod + def build(cls, user: UserDto, hwid_device: HwidUserDeviceDto, event: TUserHwidDevicesEvents): + return cls(data={"user": user, "hwidUserDevice": hwid_device}, event_name=event) + + +# ---------------- SERVICE EVENTS ---------------- # + +class LoginAttemptDto(BaseModel): + username: str + ip: str + user_agent: str + description: Optional[str] = None + password: Optional[str] = None + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class ServiceEventDto(BaseModel): + event_name: TServiceEvents + data: dict + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +# ---------------- NODE ENTITIES ---------------- # + +class ConfigProfileInboundDto(BaseModel): + uuid: UUID + name: str + config: dict + + created_at: datetime + updated_at: datetime + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class InfraProviderDto(BaseModel): + name: str + uuid: UUID + favicon_link: Optional[str] = None + login_url: Optional[str] = None + + created_at: datetime + updated_at: datetime + + billing_history: Optional[dict] = None + billing_nodes: Optional[List[dict]] = None + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class NodesDto(BaseModel): + uuid: UUID + name: str + address: str + port: Optional[int] = None + is_connected: bool + is_connecting: bool + is_disabled: bool + is_node_online: bool + is_xray_running: bool + + last_status_change: Optional[datetime] = None + last_status_message: Optional[str] = None + + xray_version: Optional[str] = None + node_version: Optional[str] = None + xray_uptime: str + + users_online: Optional[int] = None + + is_traffic_tracking_active: bool + traffic_reset_day: Optional[int] = None + traffic_limit_bytes: Optional[str] = None + traffic_used_bytes: Optional[str] = None + notify_percent: Optional[int] = None + + view_position: int + country_code: str + consumption_multiplier: str + + cpu_count: Optional[int] = None + cpu_model: Optional[str] = None + total_ram: Optional[str] = None + + created_at: datetime + updated_at: datetime + + active_config_profile_uuid: Optional[UUID] = None + active_inbounds: List[ConfigProfileInboundDto] = Field(default_factory=list) + + provider_uuid: Optional[UUID] = None + provider: Optional[InfraProviderDto] = None + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +class NodeEventDto(BaseModel): + node: NodesDto + event_name: TNodeEvents + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +# ---------------- ERROR EVENTS ---------------- # + +class CustomErrorEventDto(BaseModel): + event_name: TErrorsEvents + data: dict + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +# ---------------- CRM EVENTS ---------------- # + +class CrmEventDto(BaseModel): + event_name: TCRMEvents + data: dict + skip_telegram_notification: bool = False + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + +# ---------------- WEBHOOK PAYLOAD ---------------- # + +class WebhookPayloadDto(BaseModel): + event: str + data: Union[ + UserDto, + NodesDto, + HwidUserDeviceDto, + LoginAttemptDto, + UserHwidDeviceEventDto, + dict + ] + timestamp: datetime + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + @classmethod + def from_dict(cls, payload: dict) -> "WebhookPayloadDto": + event = payload.get("event", "") + data_raw = payload.get("data", {}) + + if event.startswith("user."): + data = UserDto(**data_raw) + elif event.startswith("user_hwid_devices."): + data = HwidUserDeviceDto(**data_raw) + elif event.startswith("node."): + data = NodesDto(**data_raw) + elif event.startswith("service."): + # может быть loginAttempt или другое + if "username" in data_raw and "ip" in data_raw: + data = LoginAttemptDto(**data_raw) + else: + data = data_raw + elif event.startswith("errors."): + data = data_raw + elif event.startswith("crm."): + data = data_raw + else: + data = data_raw + + timestamp_raw = payload.get("timestamp") + if isinstance(timestamp_raw, (int, float)): + timestamp = datetime.fromtimestamp(timestamp_raw) + else: + timestamp = timestamp_raw + + return cls(event=event, data=data, timestamp=timestamp) \ No newline at end of file