From 1141e4ed93e8db7c43be99bd09d1601708f0db21 Mon Sep 17 00:00:00 2001 From: Artem Date: Sat, 4 Oct 2025 01:28:29 +0200 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=8B=20=D0=B8=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=B2=D0=B5=D0=B1=D1=85?= =?UTF-8?q?=D1=83=D0=BA=D0=BE=D0=B2;=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=87=D0=B8=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D1=85=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- remnawave/controllers/webhooks.py | 138 +++++++++++-- remnawave/enums/__init__.py | 18 +- remnawave/enums/webhook.py | 90 +++++---- remnawave/models/webhook.py | 313 +++++++++++++++++++----------- 4 files changed, 385 insertions(+), 174 deletions(-) diff --git a/remnawave/controllers/webhooks.py b/remnawave/controllers/webhooks.py index 5ceb5f5..6d501a8 100644 --- a/remnawave/controllers/webhooks.py +++ b/remnawave/controllers/webhooks.py @@ -1,9 +1,44 @@ import hmac import hashlib import json -from typing import Union, Optional, Dict -from remnawave.enums import UserEvent, NodeEvent, InfraBillingEvent, ServiceEvent -from remnawave.models import WebhookHeadersWebhookDto, WebhookPayloadWebhookDto +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: @@ -14,7 +49,12 @@ class WebhookUtility: webhook_secret: str ) -> bool: """ - Webhook authentication via HMAC SHA-256. + Validates the webhook's authenticity using HMAC SHA-256. + + :param body: The webhook request body (either a JSON string or a parsed dictionary). + :param signature: The signature received from the server. + :param webhook_secret: The secret key used to compute the HMAC. + :return: True if the signature matches, otherwise False. """ if isinstance(body, str): original_body = body @@ -22,8 +62,8 @@ class WebhookUtility: original_body = json.dumps(body, separators=(',', ':')) computed_signature = hmac.new( - webhook_secret.encode("utf-8"), - original_body.encode("utf-8"), + webhook_secret.encode('utf-8'), + original_body.encode('utf-8'), hashlib.sha256 ).hexdigest() @@ -32,26 +72,37 @@ class WebhookUtility: @staticmethod def validate_webhook_with_headers( body: Union[str, dict], - headers: Union[Dict[str, str], WebhookHeadersWebhookDto], + headers: Union[dict[str, str], WebhookHeadersDto], webhook_secret: str ) -> bool: """ - Checking webhook headers. + 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 = WebhookHeadersWebhookDto.from_headers(headers) - + 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], WebhookHeadersWebhookDto], + headers: Union[dict[str, str], WebhookHeadersDto], webhook_secret: str, validate: bool = True - ) -> Optional[WebhookPayloadWebhookDto]: + ) -> Optional[WebhookPayloadDto]: """ - Parsing and (optional) validating the webhook payload. + 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 @@ -59,20 +110,71 @@ class WebhookUtility: if isinstance(body, str): body = json.loads(body) - return WebhookPayloadWebhookDto.from_dict(body) + return WebhookPayloadDto.from_dict(body) @staticmethod def is_user_event(event: str) -> bool: - return event in {e.value for e in UserEvent} + """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: - return event in {e.value for e in NodeEvent} + """Check if event is a node event.""" + return event.startswith("node.") @staticmethod def is_infra_billing_event(event: str) -> bool: - return event in {e.value for e in InfraBillingEvent} + """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: - return event in {e.value for e in ServiceEvent} \ No newline at end of file + """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 54797ea..4599088 100644 --- a/remnawave/enums/__init__.py +++ b/remnawave/enums/__init__.py @@ -5,8 +5,9 @@ from .fingerprint import Fingerprint from .security_layer import SecurityLayer from .template_type import TemplateType from .users import TrafficLimitStrategy, UserStatus -from .webhook import UserEvent, NodeEvent, InfraBillingEvent, ServiceEvent - +from .webhook import ( + TCRMEvents, TErrorsEvents, TNodeEvents, TResetPeriods, TServiceEvents, TUserEvents, TUserHwidDevicesEvents, TUsersStatus +) __all__ = [ "TrafficLimitStrategy", "UserStatus", @@ -15,9 +16,14 @@ __all__ = [ "ALPN", "Fingerprint", "SecurityLayer", - "UserEvent", - "NodeEvent", - "InfraBillingEvent", - "ServiceEvent", "TemplateType", + # Webhook enums + "TNodeEvents", + "TUserEvents", + "TServiceEvents", + "TErrorsEvents", + "TCRMEvents", + "TUserHwidDevicesEvents", + "TResetPeriods", + "TUsersStatus", ] diff --git a/remnawave/enums/webhook.py b/remnawave/enums/webhook.py index 00db92c..68c3a5c 100644 --- a/remnawave/enums/webhook.py +++ b/remnawave/enums/webhook.py @@ -1,46 +1,60 @@ +from typing import Literal -from enum import StrEnum +# ---------------- ENUMS / CONSTANTS ---------------- # -class UserEvent(StrEnum): - CREATED = "user.created" - MODIFIED = "user.modified" - DELETED = "user.deleted" - REVOKED = "user.revoked" - DISABLED = "user.disabled" - ENABLED = "user.enabled" - LIMITED = "user.limited" - EXPIRED = "user.expired" - TRAFFIC_RESET = "user.traffic_reset" - EXPIRES_IN_72_HOURS = "user.expires_in_72_hours" - EXPIRES_IN_48_HOURS = "user.expires_in_48_hours" - EXPIRES_IN_24_HOURS = "user.expires_in_24_hours" - EXPIRED_24_HOURS_AGO = "user.expired_24_hours_ago" - FIRST_CONNECTED = "user.first_connected" - BANDWIDTH_USAGE_THRESHOLD_REACHED = "user.bandwidth_usage_threshold_reached" +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", +] -class NodeEvent(StrEnum): - CREATED = "node.created" - MODIFIED = "node.modified" - DISABLED = "node.disabled" - ENABLED = "node.enabled" - DELETED = "node.deleted" - CONNECTION_LOST = "node.connection_lost" - CONNECTION_RESTORED = "node.connection_restored" - TRAFFIC_NOTIFY = "node.traffic_notify" +TServiceEvents = Literal[ + "service.panel_started", + "service.login_attempt_failed", + "service.login_attempt_success", +] +TErrorsEvents = Literal[ + "errors.bandwidth_usage_threshold_reached_max_notifications", +] -class InfraBillingEvent(StrEnum): - PAYMENT_IN_7_DAYS = "crm.infra_billing_node_payment_in_7_days" - PAYMENT_IN_48HRS = "crm.infra_billing_node_payment_in_48hrs" - PAYMENT_IN_24HRS = "crm.infra_billing_node_payment_in_24hrs" - PAYMENT_DUE_TODAY = "crm.infra_billing_node_payment_due_today" - PAYMENT_OVERDUE_24HRS = "crm.infra_billing_node_payment_overdue_24hrs" - PAYMENT_OVERDUE_48HRS = "crm.infra_billing_node_payment_overdue_48hrs" - PAYMENT_OVERDUE_7_DAYS = "crm.infra_billing_node_payment_overdue_7_days" +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", +] -class ServiceEvent(StrEnum): - PANEL_STARTED = "service.panel_started" - LOGIN_ATTEMPT_FAILED = "service.login_attempt_failed" - LOGIN_ATTEMPT_SUCCESS = "service.login_attempt_success" +TResetPeriods = Literal["NO_RESET", "DAY", "WEEK", "MONTH"] +TUsersStatus = Literal["DISABLED", "LIMITED", "EXPIRED", "ACTIVE"] diff --git a/remnawave/models/webhook.py b/remnawave/models/webhook.py index e82fdca..2dd2a51 100644 --- a/remnawave/models/webhook.py +++ b/remnawave/models/webhook.py @@ -1,175 +1,268 @@ from datetime import datetime -from typing import List, Optional, Union, Literal +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 InboundWebhookDto(BaseModel): +class InternalSquadDto(BaseModel): uuid: UUID - tag: str - type: str - network: str | None = None - security: str | None = None + name: str + description: Optional[str] = None + + model_config = {"alias_generator": to_camel, "populate_by_name": True} -class WebhookUserWebhookDto(BaseModel): +class BaseUserDto(BaseModel): uuid: UUID - subscription_uuid: UUID short_uuid: str username: str - status: Literal['DISABLED', 'LIMITED', 'EXPIRED', 'ACTIVE'] + status: TUsersStatus + used_traffic_bytes: str lifetime_used_traffic_bytes: str + traffic_limit_bytes: str - traffic_limit_strategy: Literal['NO_RESET', 'DAY', 'WEEK', 'MONTH'] - sub_last_user_agent: str | None = None - sub_last_opened_at: datetime | None = None + traffic_limit_strategy: TResetPeriods + sub_last_user_agent: Optional[str] = None + sub_last_opened_at: Optional[datetime] = None + expire_at: datetime - online_at: datetime | None = None - sub_revoked_at: datetime | None = None - last_traffic_reset_at: datetime | None = None + sub_revoked_at: Optional[datetime] = None + last_traffic_reset_at: Optional[datetime] = None + trojan_password: str vless_uuid: UUID ss_password: str - description: str | None = None - telegram_id: str | None = None - email: str | None = None - hwid_device_limit: int | None = None + + 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 - first_connected_at: datetime | None = None - last_triggered_threshold: int - active_user_inbounds: List[InboundWebhookDto] - model_config = { - "alias_generator": to_camel, - "populate_by_name": True, - } + model_config = {"alias_generator": to_camel, "populate_by_name": True} -class WebhookNodeWebhookDto(BaseModel): +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: int | None = None + port: Optional[int] = None is_connected: bool is_connecting: bool is_disabled: bool is_node_online: bool is_xray_running: bool - last_status_change: datetime | None = None - last_status_message: str | None = None - xray_version: str | None = None + + 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: int | None = None + + users_online: Optional[int] = None + is_traffic_tracking_active: bool - traffic_reset_day: int | None = None - traffic_limit_bytes: str | None = None - traffic_used_bytes: str | None = None - notify_percent: int | None = None + 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: int | None = None - cpu_model: str | None = None - total_ram: str | None = None + + cpu_count: Optional[int] = None + cpu_model: Optional[str] = None + total_ram: Optional[str] = None + created_at: datetime updated_at: datetime - excluded_inbounds: List[InboundWebhookDto] - model_config = { - "alias_generator": to_camel, - "populate_by_name": True, - } + 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 InfraBillingSummaryWebhookDto(BaseModel): - node_name: str - provider_name: str - login_url: str - next_billing_at: datetime +class NodeEventDto(BaseModel): + node: NodesDto + event_name: TNodeEvents - model_config = { - "alias_generator": to_camel, - "populate_by_name": True, - } + model_config = {"alias_generator": to_camel, "populate_by_name": True} -class LoginAttemptWebhookDto(BaseModel): - username: str - ip: str - user_agent: str - description: str | None = None - password: str | None = None +# ---------------- ERROR EVENTS ---------------- # - model_config = { - "alias_generator": to_camel, - "populate_by_name": True, - } +class CustomErrorEventDto(BaseModel): + event_name: TErrorsEvents + data: dict + + model_config = {"alias_generator": to_camel, "populate_by_name": True} -class WebhookServiceWebhookDto(BaseModel): - login_attempt: LoginAttemptWebhookDto | None = None +# ---------------- CRM EVENTS ---------------- # - model_config = { - "alias_generator": to_camel, - "populate_by_name": True, - } +class CrmEventDto(BaseModel): + event_name: TCRMEvents + data: dict + skip_telegram_notification: bool = False + + model_config = {"alias_generator": to_camel, "populate_by_name": True} -class WebhookHeadersWebhookDto(BaseModel): - signature: str = Field(alias="x-remnawave-signature") - timestamp: str = Field(alias="x-remnawave-timestamp") +# ---------------- WEBHOOK PAYLOAD ---------------- # - model_config = { - "populate_by_name": True, - "extra": "allow", - } - - @classmethod - def from_headers(cls, headers: dict[str, str]) -> "WebhookHeadersWebhookDto": - """ - Creates a WebhookHeadersWebhookDto from a dictionary of HTTP headers. - Handles case-insensitive keys. - """ - normalized = {k.lower(): v for k, v in headers.items()} - return cls( - **{ - "x-remnawave-signature": normalized.get("x-remnawave-signature"), - "x-remnawave-timestamp": normalized.get("x-remnawave-timestamp"), - } - ) - - -class WebhookPayloadWebhookDto(BaseModel): +class WebhookPayloadDto(BaseModel): event: str data: Union[ - WebhookUserWebhookDto, - WebhookNodeWebhookDto, - InfraBillingSummaryWebhookDto, - WebhookServiceWebhookDto, + 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) -> "WebhookPayloadWebhookDto": - """ - Parses the webhook payload and automatically determines the WebhookDto based on the event type. - """ + def from_dict(cls, payload: dict) -> "WebhookPayloadDto": event = payload.get("event", "") data_raw = payload.get("data", {}) if event.startswith("user."): - data = WebhookUserWebhookDto(**data_raw) + data = UserDto(**data_raw) + elif event.startswith("user_hwid_devices."): + data = HwidUserDeviceDto(**data_raw) elif event.startswith("node."): - data = WebhookNodeWebhookDto(**data_raw) - elif event.startswith("crm.infra_billing"): - data = InfraBillingSummaryWebhookDto(**data_raw) + data = NodesDto(**data_raw) elif event.startswith("service."): - data = WebhookServiceWebhookDto(**data_raw) + # может быть 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 @@ -179,8 +272,4 @@ class WebhookPayloadWebhookDto(BaseModel): else: timestamp = timestamp_raw - return cls( - event=event, - data=data, - timestamp=timestamp - ) \ No newline at end of file + return cls(event=event, data=data, timestamp=timestamp) \ No newline at end of file