Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}
]
Expand Down
152 changes: 150 additions & 2 deletions remnawave/controllers/webhooks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,4 +67,114 @@ def validate_webhook(
hashlib.sha256
).hexdigest()

return hmac.compare_digest(computed_signature, signature)
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
13 changes: 12 additions & 1 deletion remnawave/enums/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
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",
Expand All @@ -15,4 +17,13 @@
"Fingerprint",
"SecurityLayer",
"TemplateType",
# Webhook enums
"TNodeEvents",
"TUserEvents",
"TServiceEvents",
"TErrorsEvents",
"TCRMEvents",
"TUserHwidDevicesEvents",
"TResetPeriods",
"TUsersStatus",
]
60 changes: 60 additions & 0 deletions remnawave/enums/webhook.py
Original file line number Diff line number Diff line change
@@ -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"]
48 changes: 48 additions & 0 deletions remnawave/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,24 @@
HourlyRequestStat,
SubscriptionRequestHistoryStatsData
)
from .webhook import (
UserEventDto,
UserHwidDeviceEventDto,
HwidUserDeviceDto,
LastConnectedNodeDto,
InternalSquadDto,
BaseUserDto,
UserDto,
NodesDto,
ConfigProfileInboundDto,
InfraProviderDto,
LoginAttemptDto,
ServiceEventDto,
NodeEventDto,
CustomErrorEventDto,
CrmEventDto,
WebhookPayloadDto,
)

__all__ = [
# Auth models
Expand Down Expand Up @@ -487,4 +505,34 @@
"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",
]
Loading