Skip to content

Commit 984aaf8

Browse files
authored
Merge pull request #12 from remnawave:development
Update SDK version and add webhook handling classes
2 parents fa20059 + e0abcdc commit 984aaf8

7 files changed

Lines changed: 548 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pip install git+https://github.com/remnawave/python-sdk.git@development
6363

6464
| Contract Version | Remnawave Panel Version |
6565
| ---------------- | ----------------------- |
66+
| 2.1.17 | >=2.1.16 |
6667
| 2.1.16 | >=2.1.16 |
6768
| 2.1.13 | >=2.1.13, <=2.1.15 |
6869
| 2.1.9 | >=2.1.9, <=2.1.12 |

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "remnawave"
3-
version = "2.1.16"
4-
description = "A Python SDK for interacting with the Remnawave API v2.1.16."
3+
version = "2.1.17"
4+
description = "A Python SDK for interacting with the Remnawave API v2.1.17."
55
authors = [
66
{name = "Artem",email = "dev@forestsnet.com"}
77
]

remnawave/controllers/webhooks.py

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,45 @@
11
import hmac
22
import hashlib
33
import json
4-
from typing import Union
4+
from typing import Union, Optional
5+
6+
from remnawave.models.webhook import (
7+
WebhookPayloadDto,
8+
UserDto,
9+
NodesDto,
10+
HwidUserDeviceDto,
11+
LoginAttemptDto,
12+
UserHwidDeviceEventDto,
13+
)
14+
15+
class WebhookHeadersDto:
16+
"""Helper class for webhook headers"""
17+
18+
def __init__(self, signature: str, timestamp: str):
19+
self.signature = signature
20+
self.timestamp = timestamp
21+
22+
@classmethod
23+
def from_headers(cls, headers: dict[str, str]) -> "WebhookHeadersDto":
24+
"""
25+
Create WebhookHeadersDto from headers dictionary.
26+
Handles case-insensitive header names.
27+
"""
28+
signature = None
29+
timestamp = None
30+
31+
for key, value in headers.items():
32+
lower_key = key.lower()
33+
if lower_key == "x-remnawave-signature":
34+
signature = value
35+
elif lower_key == "x-remnawave-timestamp":
36+
timestamp = value
37+
38+
if not signature or not timestamp:
39+
raise ValueError("Missing required webhook headers")
40+
41+
return cls(signature=signature, timestamp=timestamp)
42+
543

644
class WebhookUtility:
745
@staticmethod
@@ -29,4 +67,114 @@ def validate_webhook(
2967
hashlib.sha256
3068
).hexdigest()
3169

32-
return hmac.compare_digest(computed_signature, signature)
70+
return hmac.compare_digest(computed_signature, signature)
71+
72+
@staticmethod
73+
def validate_webhook_with_headers(
74+
body: Union[str, dict],
75+
headers: Union[dict[str, str], WebhookHeadersDto],
76+
webhook_secret: str
77+
) -> bool:
78+
"""
79+
Validates the webhook using headers object.
80+
81+
:param body: The webhook request body.
82+
:param headers: Dictionary with headers or WebhookHeadersDto object.
83+
:param webhook_secret: The secret key used to compute the HMAC.
84+
:return: True if the signature matches, otherwise False.
85+
"""
86+
if isinstance(headers, dict):
87+
headers = WebhookHeadersDto.from_headers(headers)
88+
89+
return WebhookUtility.validate_webhook(body, headers.signature, webhook_secret)
90+
91+
@staticmethod
92+
def parse_webhook(
93+
body: Union[str, dict],
94+
headers: Union[dict[str, str], WebhookHeadersDto],
95+
webhook_secret: str,
96+
validate: bool = True
97+
) -> Optional[WebhookPayloadDto]:
98+
"""
99+
Parses and optionally validates the webhook payload.
100+
101+
:param body: The webhook request body.
102+
:param headers: Dictionary with headers or WebhookHeadersDto object.
103+
:param webhook_secret: The secret key used to compute the HMAC.
104+
:param validate: Whether to validate the webhook signature (default: True).
105+
:return: Parsed WebhookPayloadDto or None if validation fails.
106+
"""
107+
if validate and not WebhookUtility.validate_webhook_with_headers(body, headers, webhook_secret):
108+
return None
109+
110+
if isinstance(body, str):
111+
body = json.loads(body)
112+
113+
return WebhookPayloadDto.from_dict(body)
114+
115+
@staticmethod
116+
def is_user_event(event: str) -> bool:
117+
"""Check if event is a user event."""
118+
return event.startswith("user.")
119+
120+
@staticmethod
121+
def is_user_hwid_devices_event(event: str) -> bool:
122+
"""Check if event is a user HWID devices event."""
123+
return event.startswith("user_hwid_devices.")
124+
125+
@staticmethod
126+
def is_node_event(event: str) -> bool:
127+
"""Check if event is a node event."""
128+
return event.startswith("node.")
129+
130+
@staticmethod
131+
def is_infra_billing_event(event: str) -> bool:
132+
"""Check if event is an infra billing event."""
133+
return event.startswith("crm.infra_billing")
134+
135+
@staticmethod
136+
def is_crm_event(event: str) -> bool:
137+
"""Check if event is a CRM event."""
138+
return event.startswith("crm.")
139+
140+
@staticmethod
141+
def is_service_event(event: str) -> bool:
142+
"""Check if event is a service event."""
143+
return event.startswith("service.")
144+
145+
@staticmethod
146+
def is_errors_event(event: str) -> bool:
147+
"""Check if event is an errors event."""
148+
return event.startswith("errors.")
149+
150+
@staticmethod
151+
def get_typed_data(payload: WebhookPayloadDto) -> Union[UserDto, NodesDto, HwidUserDeviceDto, LoginAttemptDto, UserHwidDeviceEventDto, dict]:
152+
"""
153+
Get typed data from webhook payload based on event type.
154+
155+
:param payload: Parsed webhook payload.
156+
:return: Typed data object.
157+
"""
158+
return payload.data
159+
160+
@staticmethod
161+
def extract_user_hwid_event_data(payload: WebhookPayloadDto) -> Optional[tuple[UserDto, HwidUserDeviceDto]]:
162+
"""
163+
Extract user and HWID device from user_hwid_devices event.
164+
165+
:param payload: Parsed webhook payload.
166+
:return: Tuple of (UserDto, HwidUserDeviceDto) or None if not a HWID event.
167+
"""
168+
if not WebhookUtility.is_user_hwid_devices_event(payload.event):
169+
return None
170+
171+
if isinstance(payload.data, dict):
172+
user_data = payload.data.get("user", {})
173+
hwid_data = payload.data.get("hwidUserDevice", {})
174+
175+
return (
176+
UserDto(**user_data),
177+
HwidUserDeviceDto(**hwid_data)
178+
)
179+
180+
return None

remnawave/enums/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from .security_layer import SecurityLayer
66
from .template_type import TemplateType
77
from .users import TrafficLimitStrategy, UserStatus
8-
8+
from .webhook import (
9+
TCRMEvents, TErrorsEvents, TNodeEvents, TResetPeriods, TServiceEvents, TUserEvents, TUserHwidDevicesEvents, TUsersStatus
10+
)
911
__all__ = [
1012
"TrafficLimitStrategy",
1113
"UserStatus",
@@ -15,4 +17,13 @@
1517
"Fingerprint",
1618
"SecurityLayer",
1719
"TemplateType",
20+
# Webhook enums
21+
"TNodeEvents",
22+
"TUserEvents",
23+
"TServiceEvents",
24+
"TErrorsEvents",
25+
"TCRMEvents",
26+
"TUserHwidDevicesEvents",
27+
"TResetPeriods",
28+
"TUsersStatus",
1829
]

remnawave/enums/webhook.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from typing import Literal
2+
3+
# ---------------- ENUMS / CONSTANTS ---------------- #
4+
5+
TNodeEvents = Literal[
6+
"node.created",
7+
"node.modified",
8+
"node.disabled",
9+
"node.enabled",
10+
"node.deleted",
11+
"node.connection_lost",
12+
"node.connection_restored",
13+
"node.traffic_notify",
14+
]
15+
16+
TUserEvents = Literal[
17+
"user.created",
18+
"user.modified",
19+
"user.deleted",
20+
"user.revoked",
21+
"user.disabled",
22+
"user.enabled",
23+
"user.limited",
24+
"user.expired",
25+
"user.traffic_reset",
26+
"user.expires_in_72_hours",
27+
"user.expires_in_48_hours",
28+
"user.expires_in_24_hours",
29+
"user.expired_24_hours_ago",
30+
"user.first_connected",
31+
"user.bandwidth_usage_threshold_reached",
32+
]
33+
34+
TServiceEvents = Literal[
35+
"service.panel_started",
36+
"service.login_attempt_failed",
37+
"service.login_attempt_success",
38+
]
39+
40+
TErrorsEvents = Literal[
41+
"errors.bandwidth_usage_threshold_reached_max_notifications",
42+
]
43+
44+
TCRMEvents = Literal[
45+
"crm.infra_billing_node_payment_in_7_days",
46+
"crm.infra_billing_node_payment_in_48hrs",
47+
"crm.infra_billing_node_payment_in_24hrs",
48+
"crm.infra_billing_node_payment_due_today",
49+
"crm.infra_billing_node_payment_overdue_24hrs",
50+
"crm.infra_billing_node_payment_overdue_48hrs",
51+
"crm.infra_billing_node_payment_overdue_7_days",
52+
]
53+
54+
TUserHwidDevicesEvents = Literal[
55+
"user_hwid_devices.added",
56+
"user_hwid_devices.deleted",
57+
]
58+
59+
TResetPeriods = Literal["NO_RESET", "DAY", "WEEK", "MONTH"]
60+
TUsersStatus = Literal["DISABLED", "LIMITED", "EXPIRED", "ACTIVE"]

remnawave/models/__init__.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,24 @@
244244
HourlyRequestStat,
245245
SubscriptionRequestHistoryStatsData
246246
)
247+
from .webhook import (
248+
UserEventDto,
249+
UserHwidDeviceEventDto,
250+
HwidUserDeviceDto,
251+
LastConnectedNodeDto,
252+
InternalSquadDto,
253+
BaseUserDto,
254+
UserDto,
255+
NodesDto,
256+
ConfigProfileInboundDto,
257+
InfraProviderDto,
258+
LoginAttemptDto,
259+
ServiceEventDto,
260+
NodeEventDto,
261+
CustomErrorEventDto,
262+
CrmEventDto,
263+
WebhookPayloadDto,
264+
)
247265

248266
__all__ = [
249267
# Auth models
@@ -487,4 +505,34 @@
487505
"AppStatItem",
488506
"HourlyRequestStat",
489507
"SubscriptionRequestHistoryStatsData",
508+
# Webhook models
509+
# USER
510+
"LastConnectedNodeDto",
511+
"InternalSquadDto",
512+
"BaseUserDto",
513+
"UserDto",
514+
"UserEventDto",
515+
516+
# HWID DEVICES
517+
"HwidUserDeviceDto",
518+
"UserHwidDeviceEventDto",
519+
520+
# SERVICE EVENTS
521+
"LoginAttemptDto",
522+
"ServiceEventDto",
523+
524+
# NODE ENTITIES
525+
"ConfigProfileInboundDto",
526+
"InfraProviderDto",
527+
"NodesDto",
528+
"NodeEventDto",
529+
530+
# ERROR EVENTS
531+
"CustomErrorEventDto",
532+
533+
# CRM EVENTS
534+
"CrmEventDto",
535+
536+
# WEBHOOK PAYLOAD
537+
"WebhookPayloadDto",
490538
]

0 commit comments

Comments
 (0)