Merge pull request #12 from remnawave:development

Update SDK version and add webhook handling classes
This commit is contained in:
Artem 2025-10-05 01:16:15 +02:00 committed by GitHub
commit 984aaf8e0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 548 additions and 5 deletions

View file

@ -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 |

View file

@ -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"}
]

View file

@ -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)
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

View file

@ -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",
]

View file

@ -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"]

View file

@ -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",
]

275
remnawave/models/webhook.py Normal file
View file

@ -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)