From c2cb9b12ca398ed053aebd9d5a1b3ef919a949f0 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 3 Oct 2025 02:06:08 +0200 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E=20SDK=20=D0=B4?= =?UTF-8?q?=D0=BE=202.1.16.post.b;=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B5=D0=B1=D1=85=D1=83=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=85=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- remnawave/controllers/webhooks.py | 66 +++++++++-- remnawave/enums/__init__.py | 5 + remnawave/enums/webhook.py | 46 ++++++++ remnawave/models/__init__.py | 19 +++ remnawave/models/webhook.py | 186 ++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 remnawave/enums/webhook.py create mode 100644 remnawave/models/webhook.py diff --git a/pyproject.toml b/pyproject.toml index deafe97..07293dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remnawave" -version = "2.1.16" +version = "2.1.16.post.b" description = "A Python SDK for interacting with the Remnawave API v2.1.16." authors = [ {name = "Artem",email = "dev@forestsnet.com"} diff --git a/remnawave/controllers/webhooks.py b/remnawave/controllers/webhooks.py index 493ee68..5ceb5f5 100644 --- a/remnawave/controllers/webhooks.py +++ b/remnawave/controllers/webhooks.py @@ -1,7 +1,10 @@ import hmac import hashlib import json -from typing import Union +from typing import Union, Optional, Dict +from remnawave.enums import UserEvent, NodeEvent, InfraBillingEvent, ServiceEvent +from remnawave.models import WebhookHeadersWebhookDto, WebhookPayloadWebhookDto + class WebhookUtility: @staticmethod @@ -11,12 +14,7 @@ class WebhookUtility: webhook_secret: str ) -> bool: """ - 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. + Webhook authentication via HMAC SHA-256. """ if isinstance(body, str): original_body = body @@ -24,9 +22,57 @@ 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() - 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], WebhookHeadersWebhookDto], + webhook_secret: str + ) -> bool: + """ + Checking webhook headers. + """ + if isinstance(headers, dict): + headers = WebhookHeadersWebhookDto.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], + webhook_secret: str, + validate: bool = True + ) -> Optional[WebhookPayloadWebhookDto]: + """ + Parsing and (optional) validating the webhook payload. + """ + if validate and not WebhookUtility.validate_webhook_with_headers(body, headers, webhook_secret): + return None + + if isinstance(body, str): + body = json.loads(body) + + return WebhookPayloadWebhookDto.from_dict(body) + + @staticmethod + def is_user_event(event: str) -> bool: + return event in {e.value for e in UserEvent} + + @staticmethod + def is_node_event(event: str) -> bool: + return event in {e.value for e in NodeEvent} + + @staticmethod + def is_infra_billing_event(event: str) -> bool: + return event in {e.value for e in InfraBillingEvent} + + @staticmethod + def is_service_event(event: str) -> bool: + return event in {e.value for e in ServiceEvent} \ No newline at end of file diff --git a/remnawave/enums/__init__.py b/remnawave/enums/__init__.py index 075eb42..54797ea 100644 --- a/remnawave/enums/__init__.py +++ b/remnawave/enums/__init__.py @@ -5,6 +5,7 @@ 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 __all__ = [ "TrafficLimitStrategy", @@ -14,5 +15,9 @@ __all__ = [ "ALPN", "Fingerprint", "SecurityLayer", + "UserEvent", + "NodeEvent", + "InfraBillingEvent", + "ServiceEvent", "TemplateType", ] diff --git a/remnawave/enums/webhook.py b/remnawave/enums/webhook.py new file mode 100644 index 0000000..00db92c --- /dev/null +++ b/remnawave/enums/webhook.py @@ -0,0 +1,46 @@ + +from enum import StrEnum + +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" + + +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" + + +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" + + +class ServiceEvent(StrEnum): + PANEL_STARTED = "service.panel_started" + LOGIN_ATTEMPT_FAILED = "service.login_attempt_failed" + LOGIN_ATTEMPT_SUCCESS = "service.login_attempt_success" diff --git a/remnawave/models/__init__.py b/remnawave/models/__init__.py index 749c9a2..655249e 100644 --- a/remnawave/models/__init__.py +++ b/remnawave/models/__init__.py @@ -244,6 +244,16 @@ from .subscription_request_history import ( HourlyRequestStat, SubscriptionRequestHistoryStatsData ) +from .webhook import ( + InboundWebhookDto, + WebhookUserWebhookDto, + WebhookNodeWebhookDto, + WebhookHeadersWebhookDto, + WebhookPayloadWebhookDto, + InfraBillingSummaryWebhookDto, + LoginAttemptWebhookDto, + WebhookServiceWebhookDto, +) __all__ = [ # Auth models @@ -487,4 +497,13 @@ __all__ = [ "AppStatItem", "HourlyRequestStat", "SubscriptionRequestHistoryStatsData", + # Webhook models + "InboundWebhookDto", + "WebhookUserWebhookDto", + "WebhookNodeWebhookDto", + "WebhookHeadersWebhookDto", + "WebhookPayloadWebhookDto", + "InfraBillingSummaryWebhookDto", + "LoginAttemptWebhookDto", + "WebhookServiceWebhookDto", ] diff --git a/remnawave/models/webhook.py b/remnawave/models/webhook.py new file mode 100644 index 0000000..e82fdca --- /dev/null +++ b/remnawave/models/webhook.py @@ -0,0 +1,186 @@ +from datetime import datetime +from typing import List, Optional, Union, Literal +from uuid import UUID + +from pydantic import BaseModel, Field +from pydantic.alias_generators import to_camel + + +class InboundWebhookDto(BaseModel): + uuid: UUID + tag: str + type: str + network: str | None = None + security: str | None = None + + +class WebhookUserWebhookDto(BaseModel): + uuid: UUID + subscription_uuid: UUID + short_uuid: str + username: str + status: Literal['DISABLED', 'LIMITED', 'EXPIRED', 'ACTIVE'] + 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 + expire_at: datetime + online_at: datetime | None = None + sub_revoked_at: datetime | None = None + last_traffic_reset_at: datetime | None = 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 + 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, + } + + +class WebhookNodeWebhookDto(BaseModel): + uuid: UUID + name: str + address: str + port: int | None = 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 + xray_uptime: str + users_online: int | None = 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 + view_position: int + country_code: str + consumption_multiplier: str + cpu_count: int | None = None + cpu_model: str | None = None + total_ram: str | None = None + created_at: datetime + updated_at: datetime + excluded_inbounds: List[InboundWebhookDto] + + 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 + + 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 + + model_config = { + "alias_generator": to_camel, + "populate_by_name": True, + } + + +class WebhookServiceWebhookDto(BaseModel): + login_attempt: LoginAttemptWebhookDto | None = None + + 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") + + 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): + event: str + data: Union[ + WebhookUserWebhookDto, + WebhookNodeWebhookDto, + InfraBillingSummaryWebhookDto, + WebhookServiceWebhookDto, + dict + ] + timestamp: datetime + + @classmethod + def from_dict(cls, payload: dict) -> "WebhookPayloadWebhookDto": + """ + Parses the webhook payload and automatically determines the WebhookDto based on the event type. + """ + event = payload.get("event", "") + data_raw = payload.get("data", {}) + + if event.startswith("user."): + data = WebhookUserWebhookDto(**data_raw) + elif event.startswith("node."): + data = WebhookNodeWebhookDto(**data_raw) + elif event.startswith("crm.infra_billing"): + data = InfraBillingSummaryWebhookDto(**data_raw) + elif event.startswith("service."): + data = WebhookServiceWebhookDto(**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