Добавить классы и типы для обработки вебхуков; обновить модели и перечисления для поддержки новых событий

This commit is contained in:
Artem 2025-10-04 01:28:29 +02:00
parent 87b8d8c3d8
commit 1141e4ed93
No known key found for this signature in database
GPG key ID: 833485276B7902CE
4 changed files with 385 additions and 174 deletions

View file

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

View file

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

View file

@ -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
)
return cls(event=event, data=data, timestamp=timestamp)