mirror of
https://github.com/remnawave/python-sdk.git
synced 2026-05-13 12:16:42 +00:00
Обновить версию SDK до 2.1.16.post.b; добавить классы для обработки вебхуков и их валидации
This commit is contained in:
parent
3f0b5af2cf
commit
c2cb9b12ca
6 changed files with 313 additions and 11 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
46
remnawave/enums/webhook.py
Normal file
46
remnawave/enums/webhook.py
Normal 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"
|
||||
|
|
@ -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
186
remnawave/models/webhook.py
Normal 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
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue