Обновить версию SDK до 2.1.16.post.b; добавить классы для обработки вебхуков и их валидации

This commit is contained in:
Artem 2025-10-03 02:06:08 +02:00
parent 3f0b5af2cf
commit c2cb9b12ca
No known key found for this signature in database
GPG key ID: 833485276B7902CE
6 changed files with 313 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

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