diff --git a/README.md b/README.md index 6e369b1..4994bfb 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ pip install git+https://github.com/remnawave/python-sdk.git@development | Contract Version | Remnawave Panel Version | | ---------------- | ----------------------- | +| 2.6.2 | >=2.6.2 | | 2.6.1 | >=2.6.0 | | 2.4.4 | >=2.4.0 | | 2.3.2 | >=2.3.0, <2.4.0 | diff --git a/pyproject.toml b/pyproject.toml index b95c373..f292715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "remnawave" -version = "2.6.1" -description = "A Python SDK for interacting with the Remnawave API v2.6.1." +version = "2.6.2" +description = "A Python SDK for interacting with the Remnawave API v2.6.2." authors = [ {name = "Artem",email = "dev@forestsnet.com"} ] diff --git a/remnawave/__init__.py b/remnawave/__init__.py index e72a8a9..aa0b703 100644 --- a/remnawave/__init__.py +++ b/remnawave/__init__.py @@ -32,6 +32,7 @@ from remnawave.controllers import ( SnippetsController, RemnawaveSettingsController, SubscriptionPageConfigController, + IpControlController, ) @@ -96,6 +97,7 @@ class RemnawaveSDK: self.snippets = SnippetsController(self._client) self.remnawave_settings = RemnawaveSettingsController(self._client) self.subscription_page_config = SubscriptionPageConfigController(self._client) + self.ip_control = IpControlController(self._client) def _validate_params(self) -> None: if self._client is None: diff --git a/remnawave/controllers/__init__.py b/remnawave/controllers/__init__.py index b861241..c5b327b 100644 --- a/remnawave/controllers/__init__.py +++ b/remnawave/controllers/__init__.py @@ -26,6 +26,7 @@ from .external_squads import ExternalSquadsController from .snippets import SnippetsController from .remnawave_settings import RemnawaveSettingsController from .subscription_page import SubscriptionPageConfigController +from .ip_control import IpControlController __all__ = [ "APITokensManagementController", @@ -55,5 +56,6 @@ __all__ = [ "ExternalSquadsController", "SnippetsController", "RemnawaveSettingsController", - "SubscriptionPageConfigController" + "SubscriptionPageConfigController", + "IpControlController", ] diff --git a/remnawave/controllers/ip_control.py b/remnawave/controllers/ip_control.py new file mode 100644 index 0000000..ee21b3f --- /dev/null +++ b/remnawave/controllers/ip_control.py @@ -0,0 +1,55 @@ +from typing import Annotated + +from rapid_api_client import Path +from rapid_api_client.annotations import PydanticBody + +from remnawave.models import ( + DropConnectionsRequestDto, + DropConnectionsResponseDto, + FetchIpsResponseDto, + FetchIpsResultResponseDto, +) +from remnawave.rapid import BaseController, get, post + + +class IpControlController(BaseController): + @post("/ip-control/fetch-ips/{uuid}", response_class=FetchIpsResponseDto) + async def fetch_user_ips( + self, + uuid: Annotated[str, Path(description="UUID of the user")], + ) -> FetchIpsResponseDto: + """Request IP List for User. + + Starts a background job that queries all connected nodes for the IPs + used by the given user. The returned ``job_id`` must be passed to + :meth:`get_fetch_ips_result` to retrieve the actual list once the job + is complete. + """ + ... + + @get("/ip-control/fetch-ips/result/{jobId}", response_class=FetchIpsResultResponseDto) + async def get_fetch_ips_result( + self, + jobId: Annotated[str, Path(description="Job ID returned by fetch_user_ips")], + ) -> FetchIpsResultResponseDto: + """Get IP List Result by Job ID. + + Poll this endpoint after calling :meth:`fetch_user_ips`. When + ``is_completed`` is ``True`` the ``result`` field contains per-node + IP lists. When ``is_failed`` is ``True`` the job encountered an + error. + """ + ... + + @post("/ip-control/drop-connections", response_class=DropConnectionsResponseDto) + async def drop_connections( + self, + body: Annotated[DropConnectionsRequestDto, PydanticBody()], + ) -> DropConnectionsResponseDto: + """Drop active connections. + + Sends a drop-connections event to the target nodes. You can specify + the connections to drop either by user UUIDs or by IP addresses, and + you can target all connected nodes or a specific subset. + """ + ... diff --git a/remnawave/models/__init__.py b/remnawave/models/__init__.py index d1f8328..1e18b1f 100644 --- a/remnawave/models/__init__.py +++ b/remnawave/models/__init__.py @@ -382,6 +382,7 @@ from .webhook import ( BaseUserDto, UserDto, NodeDto, + WebhookNodeConfigProfileDto, ConfigProfileInboundDto, InfraProviderDto, LoginAttemptDto, @@ -467,6 +468,25 @@ from .subscription_page import ( UpdateSubscriptionPageConfigRequestDto, UpdateSubscriptionPageConfigResponseDto, ) +from .ip_control import ( + # Request DTOs + DropConnectionsRequestDto, + DropByUserUuids, + DropByIpAddresses, + TargetAllNodes, + TargetSpecificNodes, + # Response DTOs + FetchIpsResponseDto, + FetchIpsResultResponseDto, + DropConnectionsResponseDto, + # Data models + FetchIpsJobData, + FetchIpsProgressData, + FetchIpsNodeResult, + FetchIpsResult, + FetchIpsResultData, + DropConnectionsResponseData, +) __all__ = [ # Auth models @@ -829,6 +849,7 @@ __all__ = [ "ConfigProfileInboundDto", "InfraProviderDto", "NodeDto", + "WebhookNodeConfigProfileDto", "NodeEventDto", # ERROR EVENTS @@ -916,4 +937,20 @@ __all__ = [ "SubscriptionPageConfigDto", "UpdateSubscriptionPageConfigRequestDto", "UpdateSubscriptionPageConfigResponseDto", + + # IP Control models + "DropConnectionsRequestDto", + "DropByUserUuids", + "DropByIpAddresses", + "TargetAllNodes", + "TargetSpecificNodes", + "FetchIpsResponseDto", + "FetchIpsResultResponseDto", + "DropConnectionsResponseDto", + "FetchIpsJobData", + "FetchIpsProgressData", + "FetchIpsNodeResult", + "FetchIpsResult", + "FetchIpsResultData", + "DropConnectionsResponseData", ] diff --git a/remnawave/models/ip_control.py b/remnawave/models/ip_control.py new file mode 100644 index 0000000..8470bd8 --- /dev/null +++ b/remnawave/models/ip_control.py @@ -0,0 +1,142 @@ +from typing import Annotated, List, Literal, Optional, Union +from uuid import UUID + +from pydantic import BaseModel, Field + + +# ───────────────────────────────────────────────────────────────────────────── +# Fetch IPs – step 1: start the job +# ───────────────────────────────────────────────────────────────────────────── + +class FetchIpsJobData(BaseModel): + """Returned job ID after requesting IP fetch""" + job_id: str = Field(alias="jobId") + + +class FetchIpsResponseDto(FetchIpsJobData): + """Response for POST /api/ip-control/fetch-ips/{uuid}""" + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# Fetch IPs – step 2: poll the job result +# ───────────────────────────────────────────────────────────────────────────── + +class FetchIpsProgressData(BaseModel): + """Progress information for an IP-fetch job""" + total: int + completed: int + percent: float + + +class FetchIpsNodeResult(BaseModel): + """Per-node IP list for a user""" + node_uuid: UUID = Field(alias="nodeUuid") + node_name: str = Field(alias="nodeName") + country_code: str = Field(alias="countryCode") + ips: List[str] + + +class FetchIpsResult(BaseModel): + """Full result payload when the job is completed""" + success: bool + user_uuid: UUID = Field(alias="userUuid") + user_id: str = Field(alias="userId") + nodes: List[FetchIpsNodeResult] + + +class FetchIpsResultData(BaseModel): + """Job state + optional result""" + is_completed: bool = Field(alias="isCompleted") + is_failed: bool = Field(alias="isFailed") + progress: FetchIpsProgressData + result: Optional[FetchIpsResult] = None + + +class FetchIpsResultResponseDto(FetchIpsResultData): + """Response for GET /api/ip-control/fetch-ips/result/{jobId}""" + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# Drop Connections request – discriminated unions for dropBy / targetNodes +# ───────────────────────────────────────────────────────────────────────────── + +class DropByUserUuids(BaseModel): + """Drop connections for specific user UUIDs""" + by: Literal["userUuids"] = "userUuids" + user_uuids: List[UUID] = Field( + ..., + serialization_alias="userUuids", + min_length=1, + description="List of user UUIDs whose connections should be dropped", + ) + + +class DropByIpAddresses(BaseModel): + """Drop connections from specific IP addresses""" + by: Literal["ipAddresses"] = "ipAddresses" + ip_addresses: List[str] = Field( + ..., + serialization_alias="ipAddresses", + min_length=1, + description="List of IP addresses to disconnect", + ) + + +# Discriminated union – use `by` field as the discriminator +DropBy = Annotated[ + Union[DropByUserUuids, DropByIpAddresses], + Field(discriminator="by"), +] + + +class TargetAllNodes(BaseModel): + """Send the drop-connections event to all connected nodes""" + target: Literal["allNodes"] = "allNodes" + + +class TargetSpecificNodes(BaseModel): + """Send the drop-connections event to specific nodes only""" + target: Literal["specificNodes"] = "specificNodes" + node_uuids: List[UUID] = Field( + ..., + serialization_alias="nodeUuids", + min_length=1, + description="List of node UUIDs to target", + ) + + +# Discriminated union – use `target` field as the discriminator +TargetNodes = Annotated[ + Union[TargetAllNodes, TargetSpecificNodes], + Field(discriminator="target"), +] + + +class DropConnectionsRequestDto(BaseModel): + """Request body for POST /api/ip-control/drop-connections""" + drop_by: DropBy = Field( + ..., + serialization_alias="dropBy", + description="Selector for whose connections to drop", + ) + target_nodes: TargetNodes = Field( + ..., + serialization_alias="targetNodes", + description="Selector for which nodes to send the drop event to", + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Drop Connections response +# ───────────────────────────────────────────────────────────────────────────── + +class DropConnectionsResponseData(BaseModel): + """Payload confirming the drop-connections event was sent""" + event_sent: bool = Field(alias="eventSent") + + +class DropConnectionsResponseDto(DropConnectionsResponseData): + """Response for POST /api/ip-control/drop-connections""" + pass diff --git a/remnawave/models/users.py b/remnawave/models/users.py index 80cc6b8..aec4b99 100644 --- a/remnawave/models/users.py +++ b/remnawave/models/users.py @@ -119,6 +119,7 @@ class UserTrafficDto(BaseModel): class UserResponseDto(BaseModel): """User response DTO - обновленная структура с userTraffic""" uuid: UUID + id: int short_uuid: str = Field(alias="shortUuid") username: str status: UserStatus = Field(default=UserStatus.ACTIVE) diff --git a/remnawave/models/webhook.py b/remnawave/models/webhook.py index b0602bf..c3b724f 100644 --- a/remnawave/models/webhook.py +++ b/remnawave/models/webhook.py @@ -37,6 +37,7 @@ class UserTrafficDto(BaseModel): class BaseUserDto(BaseModel): uuid: UUID + id: int short_uuid: str username: str status: TUsersStatus @@ -59,10 +60,13 @@ class BaseUserDto(BaseModel): tag: Optional[str] = None telegram_id: Optional[int] = None email: Optional[str] = None + external_squad_uuid: Optional[UUID] = None hwid_device_limit: Optional[int] = None last_triggered_threshold: int + subscription_url: str + created_at: datetime updated_at: datetime @@ -211,6 +215,14 @@ class InfraProviderDto(BaseModel): model_config = {"alias_generator": to_camel, "populate_by_name": True} +class WebhookNodeConfigProfileDto(BaseModel): + """Nested config profile for node webhook events""" + active_config_profile_uuid: Optional[UUID] = None + active_inbounds: List[ConfigProfileInboundDto] = Field(default_factory=list) + + model_config = {"alias_generator": to_camel, "populate_by_name": True} + + class NodeDto(BaseModel): uuid: UUID name: str @@ -227,16 +239,18 @@ class NodeDto(BaseModel): xray_uptime: str users_online: Optional[int] = None - + is_traffic_tracking_active: bool traffic_reset_day: Optional[int] = None - traffic_limit_bytes: Optional[int] = None - traffic_used_bytes: Optional[int] = None + traffic_limit_bytes: Optional[float] = None + traffic_used_bytes: Optional[float] = None notify_percent: Optional[int] = None view_position: int country_code: str - consumption_multiplier: int + consumption_multiplier: float + + tags: List[str] = Field(default_factory=list) cpu_count: Optional[int] = None cpu_model: Optional[str] = None @@ -245,14 +259,22 @@ class NodeDto(BaseModel): created_at: datetime updated_at: datetime - active_config_profile_uuid: Optional[UUID] = None - active_inbounds: List[ConfigProfileInboundDto] = Field(default_factory=list) + config_profile: WebhookNodeConfigProfileDto provider_uuid: Optional[UUID] = None provider: Optional[InfraProviderDto] = None model_config = {"alias_generator": to_camel, "populate_by_name": True} + # Backward-compat shims for code that used the flat fields directly + @property + def active_config_profile_uuid(self) -> Optional[UUID]: + return self.config_profile.active_config_profile_uuid + + @property + def active_inbounds(self) -> List[ConfigProfileInboundDto]: + return self.config_profile.active_inbounds + class NodeEventDto(BaseModel): event_name: TNodeEvents